|
| 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). |
0 commit comments