core/docs/decisions/ADR-0203-binding-graph-acyclicity-invariant.md
Shay 451c4b1e17 feat: binding-graph acyclicity invariant — circular_dependency guard (ADR-0203)
proof_chain phase 2.1: the acyclicity guard at the shared binding-graph
construction boundary, before phase 2.2 wiring can build a cyclic-capable structure.

- generate/binding_graph/acyclicity.py: pure find_cycle(adjacency) detector
  (deterministic three-colour DFS; isolated, no model import).
- model.py __post_init__: builds {lhs: deps} adjacency over equations and raises
  BindingGraphError(circular_dependency ...) on a cycle. Runs on every binding
  graph (math + future proof) — illegal states unrepresentable for all consumers.
- tests/test_binding_graph_acyclicity.py: 17 tests (pure checker + construction
  enforcement); mutation-verified non-vacuous.
- ADR-0203: new ADDITIVE invariant referencing ADR-0132 (not an amendment —
  preserves the why-added-later history).

Math-lane regression proof: the only producer (math adapter) is acyclic by
construction (fresh result symbol per op, deps point backward); full
binding-graph + admissibility surface 392 green; guard refuses no existing graph.

Honesty boundary (load-bearing): through phase 2.3, proof_chain is SOUND OVER
DECLARED ATOMS, not grounded in recognized input (grounding is phase 2.4).

full binding-graph/admissibility surface: 392 passed. smoke: 67 passed.
2026-06-02 18:42:40 -07:00

107 lines
5.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0203 — Binding-Graph Acyclicity Invariant (`circular_dependency` refusal)
**Status:** Accepted (proof_chain phase 2.1 — the isolated guard; ADR-0201 §Deferred)
**Date:** 2026-06-02
**Relates to:** **ADR-0132** (binding-graph data model — this *adds* an invariant to
its construction contract; see §amend-vs-additive), ADR-0201 (canonicalizer +
phase plan), ADR-0202 (proposition representation contract).
---
## Context
ADR-0132's `SemanticSymbolicBindingGraph.__post_init__` enforces **referential
integrity** (every `BoundEquation` dependency names a known symbol) but **not
acyclicity**. A cycle in the equation dependency structure — `x` defined from `y`,
`y` defined from `x`, or a self-dependency `x ← x` — is **circular reasoning**:
structurally well-formed, semantically invalid. It is the proof-domain analog of
the `20/5 == 4` class the arithmetic gate refuses.
`proof_chain` (ADR-0201) is the first consumer that can *build* such a structure:
its phase 2.2 wiring constructs binding graphs from proofs, where a malformed proof
could close a dependency loop. Per CORE's "build the defensive refusal NOW"
discipline, the guard must exist **before** the structure where a cycle can exist —
so this phase (2.1) lands it *before* any 2.2 wiring.
## Decision
Add an **acyclicity invariant** to binding-graph construction:
- **`generate/binding_graph/acyclicity.py`** — a pure `find_cycle(adjacency)`
cycle detector (deterministic three-colour DFS; sorted traversal → byte-stable
reported cycle; self-edge → length-1 cycle). No model import — testable in
isolation against synthetic adjacency graphs.
- **Enforcement at the shared construction boundary.** `__post_init__` builds a
`{lhs_symbol_id: dependencies}` adjacency over its equations and calls
`find_cycle`; a non-`None` result raises
`BindingGraphError("circular_dependency: equation dependency cycle …")` naming
the cycle. This runs on **every** binding graph — math and (future) proof alike —
making a cyclic graph unrepresentable for all consumers.
### Why the shared `__post_init__` (not a proof-only check)
Putting it at the substrate boundary closes the gap universally and makes illegal
states unrepresentable (the CLAUDE.md design principle), rather than leaving the
math binding graph cycle-unchecked. The guard exists the instant the structure
becomes constructible — strictly *before* the proof wiring that makes a cycle
reachable.
### Math-lane regression proof (the shared-constructor risk)
Because the guard runs on the existing math/algebra path too, it must refuse no
existing graph. Verified:
- The **only** production producer is `generate/binding_graph/adapter.py`
(`bind_math_problem_graph`). It mints a **fresh** result symbol per operation
(`_op_result_symbol_id(idx)`) and depends only on symbols that already exist —
edges point strictly backward in construction order. It is therefore **acyclic
by construction** and cannot produce a graph this guard would refuse.
- No existing test constructs a dependency cycle (the `sym_ghost` fixture is the
referential-integrity refusal, not a cycle).
- The **full binding-graph + admissibility test surface — 392 tests — stays
green** with the guard in `__post_init__`. (Smoke: 67 passed.)
A future/in-flight consumer that *did* build a cycle would now be refused at
construction — which is the point.
## amend-vs-additive (ADR-0203 vs amending ADR-0132)
**Decision: a new additive ADR (this one) that references ADR-0132 — not an
amendment of the closed record.**
ADR-0132 is `Accepted`. The acyclicity invariant was not part of its original
decision; it became necessary later, when `proof_chain` made cycles *reachable*.
Recording it as a new invariant preserves that history — *why* the guard was added
and *when it became load-bearing* — which an in-place edit of the closed record
would erase. This mirrors the history-vs-current-state discipline used elsewhere
(append the new fact; don't rewrite the settled one). The code lives beside
ADR-0132's referential-integrity check in `__post_init__`; the *decision record* is
additive.
## Honesty boundary (load-bearing — carried by every phase-2 ADR, 02030205)
Through phase 2.3, `proof_chain` is **sound over its declared atoms** — it does
**not** reason over recognized input. Atoms are opaque/declared symbol ids
(ADR-0202); grounding them to ADR-0144 `EpistemicNode`/`FeatureBundle` carriers is
**phase 2.4** (fork B). This must **never** be softened to "reasons over input"
before 2.4 lands. This ADR is structure-only and makes no grounding claim; it is
named here so the boundary travels with the work, not just with the rule ADRs.
## Evidence
- `tests/test_binding_graph_acyclicity.py` — 17 tests: pure checker (acyclic→None;
self-loop/2-/3-cycle/cycle-with-tail detected; deterministic), construction
enforcement (cyclic + self-dependent refuse; acyclic + adapter-shape construct),
and referential-integrity-still-fires-first.
- **Mutation-verified non-vacuous:** neutering `find_cycle` (→ always `None`) makes
a 2-cycle construct without refusal — `test_two_cycle_equation_set_refuses` would
fail. The guard is load-bearing, not decoration.
- Full binding-graph/admissibility surface: **392 passed**. Smoke: **67 passed**.
## Deferred (not in this phase)
- **2.2** — proof-graph builder (proof → `BoundEquation`s; `canonical_key`
`rhs_canonical`); the first construction that exercises this guard through the
real proof path (ADR-0204).
- **2.3** — `modus_ponens` + the disagreement rule (ADR-0205).
- **2.4** — atom→`EpistemicNode` carrier grounding (ADR-0206).