Skip to content

feat(drift): tangle_ratio hygiene gate — the antisymmetry half of health (#186)#241

Merged
zaebee merged 8 commits into
mainfrom
feat/issue-186-tangle-gate
Jun 13, 2026
Merged

feat(drift): tangle_ratio hygiene gate — the antisymmetry half of health (#186)#241
zaebee merged 8 commits into
mainfrom
feat/issue-186-tangle-gate

Conversation

@zaebee

@zaebee zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

First of three #186 macro-track deliverables: a tangle_ratio hygiene
invariant — the normalized mass of transpose-fixed (mutual) triad motifs — as the
symmetric partner of cycle_ratio. It closes the antisymmetry half of the
health predicate health = acyclicity + antisymmetry: a domain made of mutual
coupling (M ≥ 1 motifs) now fails the gate instead of passing silently.

The pattern-algebra basis (validated on #186): a mutual dyad A↔B is its own
transpose; 300 is the maximal fixed point. tangle_ratio = Σ Mᵢ·tᵢ / 3 measures
exactly that transpose-fixed mass. cycle_ratio rejects the cyclic back-edge
(030C); tangle_ratio rejects mutual coupling — together they complete the
health predicate.

What changed (all append-only, Lane A)

  • triads.py — pure tangle_mass(census) -> float, M-weights aligned to TRIAD_ORDER.
  • fingerprint.pytangle_ratio computed property = max over IMPORTS/CALLS layers (worst-layer breaches).
  • drift.py — registered tangle_ratio in the hygiene-constraint whitelist; the existing _hygiene_check enforces it. No gate-logic change.
  • patterns.yaml + bundled ontology headertangle_ratio: {max: 0.25} (kept parse-identical by the staleness pin). Ratchet-able per domain via hygiene_baseline (ci: enforce the drift-tolerance ratchet (values may only go down) #151).
  • mcp_server.pycgis_drift payload exposes tangle_ratio per domain/quotient. (No CLI column — the breach already surfaces via gate_failed + the hygiene note.)
  • PATTERNS_AND_TRIADS.md — documents the gate within the pattern-algebra grand structure.

Threshold = 0.25, empirically grounded

Measured tangle_ratio (intra-domain + quotient) on real repos:

repo tangle_ratio role
cgis-self 0.036 green floor — must stay green
owner-api 0.078 coupled-but-shipping
rider-web (mesh) ~1.0 canonical tangle (math-bounded; TS not ingestible)

0.25 clears coupled production code ~3× and cgis-self ~7×, while firing near
mesh — biased to the safe end for a new gate (avoid false-positive fatigue).

Test Plan

  • tangle_mass unit table: DAG→0, 300→1, 201→2/3, empty→0, weight-alignment guard.
  • tangle_ratio property: worst-layer max, empty/DAG→0.
  • Gate integration: mesh domain → gate_failed with tangle_ratio violation; clean → not failed; hygiene_baseline ratchet acknowledges a breach.
  • Staleness pin (yaml ↔ header) + hygiene-key validation still pass.
  • MCP payload carries tangle_ratio ∈ [0,1].
  • Full gate green: format / lint / mypy strict / 1016 pytest / interrogate 99.6%.
  • Self-drift safety: cgis-self → 0 gate_failed, 0 tangle breaches under the new gate.

Spec: docs/specs/2026-06-13-tangle-anti-pattern-design.md.
Remaining #186 deliverables (separate PRs): closure-gap coupling metric, cgis_fractal tool.

🤖 Generated with Claude Code

zaebee and others added 6 commits June 13, 2026 10:13
First of three #186 macro-track deliverables: a tangle_ratio hygiene
invariant (normalized transpose-fixed mutual-motif mass, Σ M·triad / 3)
as the symmetric partner of cycle_ratio, closing the antisymmetry half
of health = acyclicity + antisymmetry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the antisymmetry half of health = acyclicity + antisymmetry: a
domain of mutual coupling now fails the gate like a cyclic one. Registered
in _COMPONENT_NAMES (the hygiene-constraint whitelist), declared in both
patterns.yaml and the bundled ontology header (kept parse-identical by the
staleness pin), enforced by the existing _hygiene_check, ratchet-able via
hygiene_baseline (#151).

Threshold audit (measured intra-domain + quotient tangle_ratio):
  cgis-self  0.036  (green floor)
  owner-api  0.078  (coupled-but-shipping)
  mesh       ~1.0   (rider-web, math-bounded; TS not ingestible)
default 0.25 clears coupled code ~3x, cgis-self ~7x, fires near mesh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
asdict skips the tangle_ratio property, so enrich each domain/quotient
entry explicitly from report.actual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the tangle_ratio hygiene gate to measure and enforce antisymmetry (mutual coupling) across import and call layers. It adds the mathematical formulation for tangle_mass, integrates the new metric into the drift detection pipeline, updates the MCP server payload, and includes comprehensive unit tests. The review feedback correctly points out a technical inaccuracy in both the documentation and docstrings, where 030C (a cyclic triad) is incorrectly grouped under a 'pure DAG' description, and suggests revising the terminology to 'antisymmetric graph' or 'graph without mutual dyads'.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread docs/architecture/PATTERNS_AND_TRIADS.md Outdated
Comment thread src/cgis/query/triads.py Outdated
Sonar: float-equality checks (==) in tangle_mass / tangle_ratio tests now
go through pytest.approx.

gemini: 030C is a cyclic triad, so labelling the M=0 set {021*,030T,030C}
a "pure DAG" was wrong. It is an *antisymmetric* graph (no mutual dyads) —
cycles included; acyclicity is cycle_ratio's orthogonal job. Reworded the
tangle_mass docstring, the tangle_ratio property docstring, and
PATTERNS_AND_TRIADS.md accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaebee

zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the tangle_ratio hygiene gate to measure and enforce graph antisymmetry alongside acyclicity. It adds the design specification, implements the tangle_mass metric calculation based on mutual-dyad counts, exposes the new ratio on PatternFingerprint and API payloads, and includes comprehensive unit tests. The review feedback suggests returning None instead of 0.0 when there is no shape signal (such as an empty census) in tangle_mass to avoid false-positive fit quality bands, and adding corresponding test assertions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/cgis/query/triads.py
Comment thread tests/unit/test_triads.py
@zaebee

zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Code review — tangle_ratio hygiene gate (#186)

Clean, well-grounded addition. The math-heavy part is the risk, so I verified it directly rather than trusting the description — and it holds up.

✅ Verified — the metric is correct

The whole thing hinges on _TANGLE_WEIGHTS being the MAN mutual-dyad count M aligned index-for-index with TRIAD_ORDER. Checked every entry:

TRIAD_ORDER : 021D 021U 021C 111D 111U 030T 030C 201 120D 120U 120C 210 300
M (correct) :  0    0    0    1    1    0    0    2   1    1    1    2   3
_TANGLE_WTS :  0    0    0    1    1    0    0    2   1    1    1    2   3   ✅ exact

Ran tangle_mass on canonical censuses to confirm behaviour:

empty→0   pure-300(mesh)→1.0   pure-201→0.667   pure-120C→0.333
pure-030T(DAG)→0   pure-021C(chain)→0   pure-030C(3-cycle)→0   half-300/half-021D→0.5

The 030C → 0 case is the nice one: a directed 3-cycle (030C, not 021C which is an acyclic chain) scores zero tangle — confirming the orthogonality claim (acyclicity is cycle_ratio's job; tangle_ratio measures only mutual coupling). strict=True in the zip is the right guard against a future TRIAD_ORDER edit silently de-aligning the weights.

Normalization is sound too: normalized_census sums to ≤1, max weight is 3, so tangle_ratio ∈ [0, 1] by construction — no overflow path. The max over IMPORTS/CALLS (worst-layer) and the property living on PatternFingerprint (so MCP adds it explicitly since dataclasses.asdict skips properties) are both handled correctly.

🟡 1. Confirm weightless v1 domains' drift_score is unmoved

Registering tangle_ratio in _COMPONENT_NAMES means it joins the merged constraints dict, so it rides the score path too, not only _hygiene_check. For domains with explicit drift_weights this is inert (weights.get("tangle_ratio", 0) == 0eff_weight 0 → other weights' normalization unchanged). But on the raw_total == 0 fallback path weight = 1.0 / len(constraints), adding a constraint dilutes every other constraint's weight — so a weightless v1 domain that has some other breach could see its drift_score shift slightly even while tangle_ratio itself is under 0.25.

This follows the exact precedent of cycle_ratio/unresolved_ratio (also dual hygiene+score), so it's consistent, not a regression — but the PR claims "no gate-logic change" and self-drift safety on the gate, which doesn't cover the score. Worth a one-line confirmation that no domain without drift_weights changed drift_score/status (a quick before/after on the self-graph reports, like the byte-identical check #221 ran).

Notes (non-blocking)

  • Threshold 0.25 is empirically bracketed (cgis 0.036 / owner-api 0.078 / mesh ~1.0) and biased safe — reasonable for a new gate; the hygiene_baseline ratchet (ci: enforce the drift-tolerance ratchet (values may only go down) #151) gives per-domain escape.
  • normalized_census divides by the total including disconnected triads (003/012/102), so tangle is naturally diluted by sparse structure — consistent with how tv_distance normalizes, so intentional.

Math checks out to the digit and the wiring mirrors the existing hygiene metrics. LGTM once #1 is confirmed (likely already green). 🚀

🤖 via /code-review + tangle_mass dogfood

Edited: corrected a triad-label slip — 021C is an acyclic chain A→B→C; the actual directed 3-cycle is 030C. Both score 0 tangle (M=0); 030C→0 is the real orthogonality demonstrator. Code/docs were already correct.

@zaebee zaebee self-assigned this Jun 13, 2026
@zaebee

zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

✅ Re: 🟡1 — tangle_ratio score-path invariance confirmed

Verified the concern both mechanistically and empirically — no drift_score/status change for any realistic config.

Mechanistic. In the v2 gate path (_gate_drift_weighted_constraint_drift), the 1/len(constraints) dilution only fires on the raw_total == 0 branch — i.e. an entirely unweighted gate set. But every profile's drift_weights weights cycle_ratio (0.25/0.30), unresolved_ratio (0.15) and dag_depth (0.10/0.15), so for any profiled domain raw_total > 0. On that branch tangle_ratio gets weight = eff_weights["tangle_ratio"] / eff_total = 0 / eff_total = 0 — provably inert. The 1/len path would require a profile whose every gate constraint is weightless, which no config produces (and which would score 0 anyway).

Empirical (#221-style before/after on the self-graph): ran cgis drift on src/cgis with and without the tangle_ratio: {max: 0.25} hygiene line, comparing (drift_score, status) per domain.

domains: with=6  without=6
✅ IDENTICAL — every domain's (drift_score, status) unchanged by tangle_ratio registration

So "no gate-logic change" extends to the score path too: tangle_ratio rides the merged constraints dict exactly like cycle_ratio/unresolved_ratio already do, but contributes weight 0 wherever weights exist. Closing #1. 🚀

@zaebee zaebee left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colleague review — tangle_ratio hygiene gate

Verdict: 🟢 Approve, three non-blocking notes. Checked the math and wiring by hand, not on faith.

Verified ✅

  • Weight map correct. _TANGLE_WEIGHTS = (0,0,0,1,1,0,0,2,1,1,1,2,3) matches tuple(int(name[0]) for name in TRIAD_ORDER) exactly, and the MAN first digit = mutual-dyad count. test_tangle_weights_align_with_triad_order pins this.
  • [0,1] guarantee holds. t_imports/t_calls are always normalized_census (Σ=1) or ZERO_TRIADS; max M = 3, ÷3[0,1].
  • Score is byte-identical. tangle_ratio is absent from drift_weights → weight 0 in the score, so it's a pure gate. Adding it to _COMPONENT_NAMES is required (otherwise _parse_constraints silently drops the hygiene line) — correct.
  • Self-drift stays green with margin. Ran ingest on src/: max tangle_ratio across cgis.* domains = 0.0228 (cgis.extractors), rest 0.0. Default 0.25 is safe.
  • Quotient works — goes through the same extract(), census populated.
  • 213 relevant tests green.

Notes

1. (medium) CALLS-layer tangle is not discounted by unresolved_ratio — risk of false gate_failed on sparse call graphs.
tangle_ratio = max(tangle_mass(t_imports), tangle_mass(t_calls)), but t_calls is normalized over resolved intra-domain CALLS only. In a domain whose calls are mostly raw/external, a single connected mutual triple (e.g. one 201) makes t_calls≈one-hot → tangle_mass=0.67 → hard gate_failed on thin evidence. The drift-distance path deliberately fades the calls layer via (1 − unresolved_ratio); the gate does not, and max() picks the worse (calls) layer. Worth discussing: apply the discount to calls-tangle, or add a minimum-triad-count floor.

2. (medium) The spec's CLI tangle column is missing. Spec §"Where it lives" lists src/cgis/cli.py — an append-only column in the drift table. The diff doesn't touch cli.py: the table shows Drift / TV imp / TV calls / Fit / Status but no tangle. The MCP payload exposes the value, but the primary human surface (cgis drift) does not — a breach shows as ⛔ in Status with no number. Either finish the column or strike it from the spec.

3. (low-medium) Self-drift guardrail does not protect against tangle regressions. tests/self_parsing/test_drift.py::_assert_within_tolerance gates on drift_score > tolerance, and tangle (weight 0 in the score) never enters it. The spec claims "re-checked in CI's self-parsing path", but a future tangle rise on cgis-self would not fail CI. Harmless now (0.0228), but adding a status != "gate_failed" assertion to the self-parsing path would make the safety claim real.

All three are improvements, not blockers. Mergeable per our merge rule; I'd at least file note #1 as a follow-up (it's a real noise source).

@zaebee

zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Filed note #1 (CALLS-layer tangle undiscounted → false gate_failed on sparse calls) as follow-up #244 — not a blocker for this PR.

Colleague-review follow-ups (PR #241, notes 2 & 3):

- note 3: _assert_within_tolerance now also fails on status == gate_failed.
  Hygiene breaches (cycle/unresolved/tangle) force gate_failed independently
  of drift_score, which the tolerance check alone misses — so a future
  tangle_ratio rise on the self-graph now fails CI instead of passing with
  weight 0 in the score. Makes the spec's self-parsing safety claim real.

- note 2: spec "Where it lives" no longer lists a CLI tangle column (dropped
  by design — breach shows via gate_failed + hygiene note; value in MCP).
  Also corrected the fingerprint row (property, not stored field) and the
  drift row (_COMPONENT_NAMES whitelist).

- note 1 (calls-layer discount) deferred to #244; recorded as a known
  follow-up in the spec and a NOTE in the tangle_ratio docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaebee

zaebee commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Re: colleague review — three notes addressed (3da7d19)

Thanks, all three verified against the code and were real. Resolution:

Note 1 (calls-tangle not discounted) → deferred to #244. Confirmed: tangle_ratio = max(imports, calls) skips the (1 − unresolved_ratio) fade the drift-distance path applies to CALLS, so a sparse/mostly-unresolved call graph with one resolved mutual triple can over-report on thin evidence. It's a genuine noise source but the fix touches the metric and could nudge the measurement, so it's filed as a follow-up rather than reopening this PR's threshold work. Recorded as a known follow-up in the spec and a NOTE(#244) on the tangle_ratio property; the IMPORTS layer (always resolved) carries the reliable signal meanwhile.

Note 3 (self-parsing guard didn't cover the gate) → fixed. _assert_within_tolerance now also fails on status == "gate_failed". Good catch that this is broader than tangle — a cycle_ratio/unresolved_ratio breach with a sub-tolerance drift_score would have slipped the old check too. Now any hygiene regression on the self-graph (tangle included) fails CI. Self-parsing suite green (cgis-self has 0 gate_failed).

Note 2 (CLI column vs spec) → struck from spec. The column was dropped by design (breach surfaces via gate_failed status + the hygiene note; a 7th column reintroduces the #177 note-wrapping width pressure; the value is in the MCP payload). Updated the spec's "Where it lives" to say so explicitly, and corrected two stale rows there (property not stored field; _COMPONENT_NAMES not "line 20").

No code-behavior change to the gate itself this round — these are a CI-safety hardening + doc sync. 1016 tests green.

@sonarqubecloud

Copy link
Copy Markdown

@zaebee zaebee merged commit 96dc2e0 into main Jun 13, 2026
3 checks passed
@zaebee zaebee deleted the feat/issue-186-tangle-gate branch June 13, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant