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.
5.6 KiB
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 purefind_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 callsfind_cycle; a non-Noneresult raisesBindingGraphError("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_ghostfixture 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, 0203–0205)
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(→ alwaysNone) makes a 2-cycle construct without refusal —test_two_cycle_equation_set_refuseswould 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 →
BoundEquations;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→
EpistemicNodecarrier grounding (ADR-0206).