Skip to content

Commit 96dc2e0

Browse files
zaebeeclaude
andauthored
feat(drift): tangle_ratio hygiene gate — the antisymmetry half of health (#186) (#241)
Adds tangle_ratio = Σ Mᵢ·tᵢ / 3 (normalized transpose-fixed mutual-motif mass) as a hygiene gate, the symmetric partner of cycle_ratio — closing the antisymmetry half of health = acyclicity + antisymmetry. Pure gate (weight 0 in drift_score), default max=0.25 (empirically bracketed: cgis 0.036 / owner-api 0.078 / mesh ~1.0), ratchet-able via hygiene_baseline. Exposed in the cgis_drift MCP payload; self-parsing guard hardened to fail on any hygiene gate_failed. Follow-up #244: discount the CALLS layer by (1 - unresolved_ratio). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7ec4a28 commit 96dc2e0

14 files changed

Lines changed: 405 additions & 7 deletions

docs/architecture/PATTERNS_AND_TRIADS.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ A macro-graph whose census mass sits in this tail (e.g. a frontend where
6767
`store ↔ components ↔ hooks`) is genuinely **meshed**, not layered — a signal no
6868
fan-out/fan-in/chain template can represent (see Part 3 and issue #186).
6969

70+
### `tangle_ratio` — the antisymmetry gate
71+
72+
The total mass of this tail is captured by one hygiene number,
73+
**`tangle_ratio` = `Σ Mᵢ · tᵢ / 3`**, where `Mᵢ` is the MAN mutual-dyad count of
74+
class `i` (the first digit: `111*`/`120*` → 1, `201`/`210` → 2, `300` → 3) and
75+
`tᵢ` is its normalized census fraction. A mutual dyad `A ↔ B` is its own
76+
*transpose*; `300` is the maximal fixed point. So `tangle_ratio` is the
77+
**transpose-fixed mass**: an *antisymmetric* graph — no mutual dyads, i.e. only
78+
`021*`/`030T`/`030C` (cycles included — acyclicity is `cycle_ratio`'s job,
79+
orthogonal to tangle) — scores 0, a pure mesh (`300`) scores 1. It is measured as `max` over the IMPORTS and CALLS layers
80+
(the worst layer breaches).
81+
82+
It is the symmetric partner of `cycle_ratio`. Together they complete the health
83+
predicate **`health = acyclicity + antisymmetry`**: `cycle_ratio` rejects the
84+
cyclic back-edge (`030C`), `tangle_ratio` rejects mutual coupling (`M ≥ 1`). It is
85+
a hard hygiene gate (`tangle_ratio: {max: …}` under `hygiene:`), ratchet-able per
86+
domain via `hygiene_baseline: {tangle_ratio: …}` (#151).
87+
7088
### How it's computed
7189

7290
`triad_census(node_ids, edges, edge_type)` walks every connected triple, encodes
@@ -126,8 +144,8 @@ A domain's **drift score** combines, per the active profile's weights
126144
pattern's `imports` ideal.
127145
2. **`calls` layer** — same over CALLS, discounted by `(1 − unresolved_ratio)`
128146
(a domain whose calls are mostly unresolved gets its calls-evidence faded).
129-
3. **`gates` layer** — hygiene invariants (`cycle_ratio`, `unresolved_ratio`)
130-
and template constraints (`dag_depth`, …).
147+
3. **`gates` layer** — hygiene invariants (`cycle_ratio`, `unresolved_ratio`,
148+
`tangle_ratio`) and template constraints (`dag_depth`, …).
131149

132150
**Total-variation (TV) distance** between two 13-vectors `p`, `q` is
133151
`½ · Σ wᵢ · |pᵢ − qᵢ|` — where `wᵢ` are per-triad weights from the profile's

docs/ontology/patterns.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ profiles:
5252
hygiene:
5353
cycle_ratio: {max: 0.0}
5454
unresolved_ratio: {max: 0.2}
55+
tangle_ratio: {max: 0.25}
5556

5657
# ── The closed alphabet: five templates (spec §2.1, §3.3) ────────────────────
5758
# ideal: hand-authored points in 13-triad space, one per layer, each summing
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# `tangle_ratio` hygiene gate — design (#186)
2+
3+
**Status:** approved (2026-06-13)
4+
**Issue:** #186 (recursive-quotient / pattern-algebra macro track), deliverable 1 of 3
5+
**Lane:** A (drift / ontology / query)
6+
7+
## Goal
8+
9+
Add a `tangle_ratio` hygiene invariant — the normalized mass of transpose-fixed
10+
(mutual) triad motifs — as the symmetric partner of the existing `cycle_ratio`
11+
gate, closing the **antisymmetry** half of the health predicate
12+
`health = acyclicity + antisymmetry`.
13+
14+
## Motivation
15+
16+
The drift subsystem enforces **acyclicity** via the `cycle_ratio` gate (catches
17+
`030C` back-edge loops). It does **not** enforce **antisymmetry**: a domain made
18+
entirely of mutual coupling (`M ≥ 1` triads — `111*`, `120*`, `201`, `210`, `300`)
19+
passes every gate today. The #186 9-repo census found `rider-web` is ~100% tangle
20+
and sails through clean. That is an unnamed product gap.
21+
22+
Pattern algebra (validated on #186): the 13-triad space partitions under the
23+
transpose operator ``. **Patterns** are the antisymmetric part (`` flips
24+
`021D↔021U`, fixes acyclic `021C`/`030T`). **Anti-patterns** are the
25+
transpose-*fixed* mutual mass — a dyad `A↔B` is its own transpose; `300` is the
26+
maximal fixed point. Scoring that fixed mass `Σ M·triad` is the direct
27+
operationalization of the antisymmetry half of the predicate.
28+
29+
## The metric
30+
31+
Per layer, from the normalized 13-triad census vector `t` (already carried on
32+
`PatternFingerprint` as `t_imports` / `t_calls`):
33+
34+
```
35+
tangle_mass(t) = ( Σ_i M_i · t_i ) / 3
36+
```
37+
38+
- `M_i` = the MAN first digit (mutual-dyad count) of triad `i`, aligned to
39+
`TRIAD_ORDER`:
40+
41+
| M | triads |
42+
|---|--------|
43+
| 0 | `021D`, `021U`, `021C`, `030T`, `030C` |
44+
| 1 | `111D`, `111U`, `120D`, `120U`, `120C` |
45+
| 2 | `201`, `210` |
46+
| 3 | `300` |
47+
48+
- `÷3` normalizes to `[0, 1]` so thresholds read on the same scale as
49+
`cycle_ratio`.
50+
51+
Reference points: pure DAG → `0`; a single mutual dyad (`201`) → `≈0.67`;
52+
pure mesh (`300`) → `1.0`. Healthy `030T` (transitive triangle, `M = 0`) does
53+
**not** count — only mutual coupling does.
54+
55+
**Both layers** combine via the worst layer (a hard gate takes the worst case,
56+
not a weighted distance):
57+
58+
```
59+
tangle_ratio = max( tangle_mass(t_imports), tangle_mass(t_calls) )
60+
```
61+
62+
## Where it lives (all append-only)
63+
64+
| File | Change |
65+
|------|--------|
66+
| `src/cgis/query/triads.py` | new pure `tangle_mass(census: tuple[float, ...]) -> float` + module-level `_TANGLE_WEIGHTS` aligned to `TRIAD_ORDER` |
67+
| `src/cgis/query/fingerprint.py` | computed `@property tangle_ratio` on `PatternFingerprint` = `max(tangle_mass(t_imports), tangle_mass(t_calls))` — derived from the census vectors, so it needs no constructor change and cannot drift from them |
68+
| `src/cgis/query/drift.py` | add `"tangle_ratio"` to `_COMPONENT_NAMES` (the constraint-recognition whitelist `_parse_constraints` iterates); `_hygiene_check` reads `actual.tangle_ratio` automatically — no gate-logic change |
69+
| `docs/ontology/patterns.yaml` | add `tangle_ratio: {max: <default>}` to the `hygiene:` block |
70+
| `src/cgis/query/ontology_init.py` | mirror the same line in `_DEFAULT_ONTOLOGY_HEADER` (staleness pin-test keeps the two parse-identical) |
71+
| `src/cgis/api/mcp_server.py` | append-only `tangle_ratio` in the `cgis_drift` payload (enriched explicitly — `dataclasses.asdict` skips the property) |
72+
73+
**CLI is intentionally not touched.** An early draft listed an append-only
74+
`tangle` column in the `cgis drift` table, but a breach already surfaces through
75+
the existing `gate_failed` status plus the hygiene-violation note, and a 7th
76+
column reintroduces the #177 note-wrapping width pressure. The numeric value is
77+
exposed via the MCP payload for programmatic consumers; the human table stays as
78+
is. (YAGNI.)
79+
80+
The `hygiene_baseline` ratchet (#151) works for free: `tangle_ratio` is a valid
81+
hygiene key like any other, so acknowledged debt is declared per-domain via
82+
`hygiene_baseline: {tangle_ratio: <relaxed>}`.
83+
84+
## Default threshold — empirical, not invented
85+
86+
The default `max` is chosen at plan time by **measuring** `tangle_ratio` on three
87+
anchors, not picked a priori:
88+
89+
- **rider-web** (~1.0 mesh) — the gate MUST fire here.
90+
- **cgis-self** (clean → micro) — the self-drift gate MUST stay green (the
91+
drift-rebaseline lesson: a refactor/feature that reddens the self-gate means
92+
re-negotiate the metric, not ship a red default).
93+
- **owner-api** (~0.21 macro mass, moderately coupled) — the borderline that
94+
calibrates where "coupled-but-shipping" sits relative to the threshold.
95+
96+
Expected landing zone `0.15–0.25`, but the committed number is whatever the
97+
measurement supports while keeping cgis-self green. If no single default
98+
separates rider-web from cgis-self, fall back to a conservative default that
99+
only fires on near-mesh and document the gap.
100+
101+
## Error / edge handling
102+
103+
- **Empty census** (`ZERO_TRIADS`, no data): `tangle_mass` returns `0.0` by
104+
construction (`Σ 0·M = 0`). A no-data domain is not tangled.
105+
- **Hygiene-only domains** (no `expected_pattern`): already run the gate path;
106+
`tangle_ratio` applies to them too — desirable (mesh with no declared template
107+
should still fail).
108+
- **Quotient level**: scored by the same `PatternFingerprint` + gate, so the
109+
project-level binding gets `tangle_ratio` automatically (cross-domain mutual
110+
coupling is exactly the macro-tangle the research flagged on owner-api).
111+
112+
## Testing
113+
114+
1. **`tangle_mass` unit table** (`tests/unit/test_triads.py` or new
115+
`test_tangle.py`): DAG-only census → `0.0`; pure `300``1.0`; pure `201`
116+
`≈0.667`; pure `030T``0.0`; empty → `0.0`. Weight-alignment assertion:
117+
`_TANGLE_WEIGHTS` length == 13 and matches `TRIAD_ORDER` first-digit mapping.
118+
2. **`PatternFingerprint.tangle_ratio`**: a synthetic mesh domain (mutual edges)
119+
yields high `tangle_ratio`; a synthetic DAG domain yields `0.0`; `max` picks
120+
the worse of imports/calls.
121+
3. **Gate integration** (`tests/unit/test_drift*.py`): a mesh domain with
122+
`tangle_ratio` over the default → status `gate_failed`; a clean domain →
123+
`clean`; `hygiene_baseline: {tangle_ratio: ...}` relaxes a known breach back
124+
to `clean` (ratchet path).
125+
4. **Staleness pin**: the existing patterns.yaml ↔ ontology_init header
126+
parse-identity test must still pass after adding the line to both.
127+
5. **Self-drift safety**: cgis-self drift run stays green with the chosen
128+
default. The self-parsing guard (`tests/self_parsing/test_drift.py::
129+
_assert_within_tolerance`) now asserts `status != "gate_failed"` per domain,
130+
so a future hygiene regression — including a `tangle_ratio` rise — fails CI
131+
rather than passing silently (tangle has weight 0 in `drift_score`, so the
132+
tolerance check alone would miss it).
133+
134+
## Known follow-ups (separate issues)
135+
136+
- **#244 — discount calls-layer tangle by `(1 − unresolved_ratio)`.** The
137+
drift-distance path fades the CALLS layer by the resolution confidence; the
138+
gate does not, and `max(imports, calls)` picks the worse layer. A domain with
139+
mostly-unresolved calls plus one resolved mutual triple can read a high
140+
calls-tangle on thin evidence → a false `gate_failed`. The imports layer
141+
(always resolved) carries the reliable signal; deferred so this PR ships the
142+
metric and the empirical threshold without re-opening the measurement.
143+
144+
## Out of scope (other #186 deliverables, separate PRs)
145+
146+
- `closure-gap` coupling metric (reachability saturation on the
147+
`[micro ↔ closure(micro)]` spectrum).
148+
- `cgis_fractal` tool (entropy + collapse-ratio per structural tier, 2D verdict).

src/cgis/api/mcp_server.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,17 @@ def cgis_drift(
257257
payload = {
258258
"any_critical": analysis.any_critical,
259259
"max_drift": max_drift,
260-
"domains": [dataclasses.asdict(r) for r in analysis.reports],
260+
"domains": [
261+
{**dataclasses.asdict(r), "tangle_ratio": round(r.actual.tangle_ratio, 4)}
262+
for r in analysis.reports
263+
],
261264
"quotient": [
262-
{**dataclasses.asdict(r), "enforce": b.enforce} for b, r in analysis.quotient
265+
{
266+
**dataclasses.asdict(r),
267+
"enforce": b.enforce,
268+
"tangle_ratio": round(r.actual.tangle_ratio, 4),
269+
}
270+
for b, r in analysis.quotient
263271
],
264272
"coverage": analysis.coverage,
265273
}

src/cgis/query/drift.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"router_count",
2020
"cycle_ratio",
2121
"unresolved_ratio",
22+
"tangle_ratio",
2223
)
2324

2425
_CALLS_LAYER = frozenset({"hub_count", "star_count", "chain_len", "router_count"})

src/cgis/query/fingerprint.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cgis.core.models import Edge, EdgeType, Node, NodeType
77
from cgis.query._scc import build_adjacency, tarjan_scc
88
from cgis.query.health import HealthScorer
9-
from cgis.query.triads import ZERO_TRIADS, normalized_census, triad_census
9+
from cgis.query.triads import ZERO_TRIADS, normalized_census, tangle_mass, triad_census
1010
from cgis.storage.sqlite_store import SQLiteStore
1111

1212
RAW_CALL_PREFIX = "raw_call:"
@@ -44,6 +44,21 @@ class PatternFingerprint:
4444
node_count: int = 1
4545
edge_count: int = 1
4646

47+
@property
48+
def tangle_ratio(self) -> float:
49+
"""Worst-layer normalized mutual-motif mass, in [0, 1] (spec #186).
50+
51+
max over the IMPORTS and CALLS census of tangle_mass — a hard hygiene
52+
signal: a breach in either layer is a breach. 0 for empty or
53+
antisymmetric (no-mutual-dyad) layers.
54+
55+
NOTE(#244): the CALLS layer is not yet faded by (1 - unresolved_ratio)
56+
as the drift-distance path is, so a sparse/mostly-unresolved call graph
57+
can over-report calls-tangle. The IMPORTS layer (always resolved) is the
58+
reliable signal until that discount lands.
59+
"""
60+
return max(tangle_mass(self.t_imports), tangle_mass(self.t_calls))
61+
4762

4863
def _in_domain(fqn: str, prefix: str) -> bool:
4964
"""Return True iff fqn is the prefix itself or a child of it (segment-boundary aware)."""

src/cgis/query/ontology_init.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
hygiene:
7373
cycle_ratio: {max: 0.0}
7474
unresolved_ratio: {max: 0.2}
75+
tangle_ratio: {max: 0.25}
7576
7677
# ── The closed alphabet: five templates (spec §2.1, §3.3) ────────────────────
7778
# ideal: hand-authored points in 13-triad space, one per layer, each summing

src/cgis/query/triads.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,25 @@ def tv_distance(
210210
for name, a, b, w in zip(TRIAD_ORDER, t, ideal, weights, strict=True)
211211
]
212212
return sum((c for _, c in contribs), 0.0), contribs
213+
214+
215+
#: MAN mutual-dyad count (M, the first digit of the MAN code) for each class,
216+
#: aligned index-for-index with TRIAD_ORDER. Transpose-fixed mutual mass is the
217+
#: anti-pattern signal (spec: health = acyclicity + antisymmetry).
218+
_TANGLE_WEIGHTS: tuple[int, ...] = (0, 0, 0, 1, 1, 0, 0, 2, 1, 1, 1, 2, 3)
219+
220+
#: Max single-class weight (pure 300), used to normalize tangle into [0, 1].
221+
_TANGLE_MAX_M = 3.0
222+
223+
224+
def tangle_mass(census: tuple[float, ...]) -> float:
225+
"""Normalized transpose-fixed (mutual) motif mass of a census, in [0, 1].
226+
227+
Weights each normalized triad fraction by its MAN mutual-dyad count M and
228+
divides by the maximal M (3, pure 300). An antisymmetric graph — no mutual
229+
dyads, i.e. only 021*/030T/030C (cycles included; acyclicity is cycle_ratio's
230+
job, orthogonal to tangle) — scores 0; a pure mutual mesh (300) scores 1.
231+
The empty census scores 0.
232+
"""
233+
weighted = sum(w * t for w, t in zip(_TANGLE_WEIGHTS, census, strict=True))
234+
return weighted / _TANGLE_MAX_M

tests/self_parsing/test_drift.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ def _assert_within_tolerance(store: SQLiteStore, patterns_path: str, graph: str)
3737
for domain in _selected_domains(scorer, graph):
3838
fp = extractor.extract(domain.fqn_prefix)
3939
report = scorer.score(fp, domain)
40+
# Hygiene breaches (cycle_ratio, unresolved_ratio, tangle_ratio) force
41+
# gate_failed independently of drift_score, so the tolerance check below
42+
# would miss them. Assert the gate explicitly so a future hygiene
43+
# regression on the self-graph — e.g. a tangle_ratio rise (#186) — fails
44+
# CI rather than passing silently with weight 0 in the score.
45+
if report.status == "gate_failed":
46+
failures.append(
47+
f"[gate] {domain.name} ({domain.fqn_prefix}): hygiene gate_failed\n"
48+
+ ("\n".join(f" - {v}" for v in report.violations) or " (none)")
49+
)
4050
if report.drift_score > domain.drift_tolerance:
4151
failures.append(
4252
_EXCEEDED.format(

tests/unit/test_drift.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
hygiene:
2424
cycle_ratio: {max: 0.0}
2525
unresolved_ratio: {max: 0.2}
26+
tangle_ratio: {max: 0.25}
2627
patterns:
2728
pure_utility:
2829
description: "Hub pattern"
@@ -1598,3 +1599,64 @@ def test_fit_templates_sorted_and_deterministic(repo_scorer: DriftScorer) -> Non
15981599
assert [r for _, r in fits] == sorted(r for _, r in fits)
15991600
assert fits[0][0] == "pipeline_stage"
16001601
assert repo_scorer.fit_templates(fp, "python") == fits
1602+
1603+
1604+
# ── tangle_ratio hygiene gate (#186) ──────────────────────────────────────────
1605+
1606+
1607+
def _hygiene_only_config(name: str) -> DomainConfig:
1608+
return DomainConfig(name=name, fqn_prefix=name, expected_pattern=None)
1609+
1610+
1611+
def _tangle_fp(
1612+
domain: str,
1613+
t_imports: tuple[float, ...] | None = None,
1614+
t_calls: tuple[float, ...] | None = None,
1615+
) -> PatternFingerprint:
1616+
zeros = tuple(0.0 for _ in TRIAD_ORDER)
1617+
return PatternFingerprint(
1618+
domain=domain,
1619+
hub_count=0,
1620+
star_count=0,
1621+
chain_len=0.0,
1622+
dag_depth=0,
1623+
router_count=0,
1624+
cycle_ratio=0.0,
1625+
unresolved_ratio=0.0,
1626+
t_imports=t_imports if t_imports is not None else zeros,
1627+
t_calls=t_calls if t_calls is not None else zeros,
1628+
node_count=10,
1629+
edge_count=10,
1630+
)
1631+
1632+
1633+
def _onehot(name: str) -> tuple[float, ...]:
1634+
return tuple(1.0 if t == name else 0.0 for t in TRIAD_ORDER)
1635+
1636+
1637+
def test_tangle_gate_fails_mesh_domain(scorer: DriftScorer) -> None:
1638+
"""A pure-mesh (300) CALLS layer breaches the tangle_ratio hygiene gate."""
1639+
fp = _tangle_fp("dom", t_calls=_onehot("300"))
1640+
report = scorer.score(fp, _hygiene_only_config("dom"))
1641+
assert report.status == "gate_failed"
1642+
assert any("tangle_ratio" in v for v in report.violations)
1643+
1644+
1645+
def test_tangle_gate_passes_clean_domain(scorer: DriftScorer) -> None:
1646+
"""A pure-DAG (021C) domain stays under the tangle bound."""
1647+
clean = _onehot("021C")
1648+
fp = _tangle_fp("dom", t_imports=clean, t_calls=clean)
1649+
report = scorer.score(fp, _hygiene_only_config("dom"))
1650+
assert report.status != "gate_failed"
1651+
1652+
1653+
def test_tangle_baseline_ratchet_acknowledges_breach(scorer: DriftScorer) -> None:
1654+
"""hygiene_baseline relaxes a known tangle breach back to non-failing."""
1655+
cfg = DomainConfig(
1656+
name="dom",
1657+
fqn_prefix="dom",
1658+
expected_pattern=None,
1659+
hygiene_baseline={"tangle_ratio": 1.0},
1660+
)
1661+
report = scorer.score(_tangle_fp("dom", t_calls=_onehot("300")), cfg)
1662+
assert report.status != "gate_failed"

0 commit comments

Comments
 (0)