core/tests/test_binding_graph_acyclicity.py
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

167 lines
6.2 KiB
Python

"""ADR-0203 — acyclicity guard for the binding-graph dependency structure.
Two layers, both exercised here:
1. The **pure checker** (`find_cycle`) in isolation against synthetic adjacency
graphs — no binding-graph construction. Cyclic graphs return the cycle;
acyclic graphs return None; fails-loud under mutation (the equivalent
cyclic/acyclic assertions are mutually constraining, so a neutered detector
fails the suite).
2. The **construction-boundary enforcement** in
`SemanticSymbolicBindingGraph.__post_init__` — a cyclic equation set raises
`BindingGraphError(circular_dependency …)`; an acyclic set (including the
math-adapter shape: fresh result symbol per op, deps point backward)
constructs fine — the math-lane regression proof.
"""
from __future__ import annotations
import pytest
from generate.binding_graph import (
CIRCULAR_DEPENDENCY,
BindingGraphError,
BoundEquation,
SemanticSymbolicBindingGraph,
SourceSpanLink,
SymbolBinding,
find_cycle,
)
# ---------------------------------------------------------------------------
# Layer 1 — pure checker, isolated
# ---------------------------------------------------------------------------
ACYCLIC_GRAPHS = [
{}, # empty
{"a": frozenset()}, # single, no edges
{"a": frozenset({"b"}), "b": frozenset({"c"})}, # linear chain
{"a": frozenset({"b", "c"}), "b": frozenset({"d"}),
"c": frozenset({"d"}), "d": frozenset()}, # diamond / shared dep
{"a": frozenset({"b", "c", "d"})}, # leaves not defined by any eq
]
@pytest.mark.parametrize("graph", ACYCLIC_GRAPHS)
def test_acyclic_graphs_return_none(graph) -> None:
assert find_cycle(graph) is None
CYCLIC_GRAPHS = [
{"a": frozenset({"a"})}, # self-loop
{"a": frozenset({"b"}), "b": frozenset({"a"})}, # 2-cycle
{"a": frozenset({"b"}), "b": frozenset({"c"}),
"c": frozenset({"a"})}, # 3-cycle
{"t": frozenset({"a"}), "a": frozenset({"b"}),
"b": frozenset({"c"}), "c": frozenset({"b"})}, # cycle with a tail (t→a→b→c→b)
]
@pytest.mark.parametrize("graph", CYCLIC_GRAPHS)
def test_cyclic_graphs_are_detected(graph) -> None:
cycle = find_cycle(graph)
assert cycle is not None
# A reported cycle closes on itself and every hop is a real edge.
assert cycle[0] == cycle[-1]
for src, dst in zip(cycle, cycle[1:]):
assert dst in graph[src], f"{src}->{dst} is not an edge"
def test_self_loop_reported_as_length_one_cycle() -> None:
assert find_cycle({"x": frozenset({"x"})}) == ("x", "x")
def test_reported_cycle_is_deterministic() -> None:
graph = {"a": frozenset({"b"}), "b": frozenset({"c"}), "c": frozenset({"a"})}
assert find_cycle(graph) == find_cycle(graph)
# ---------------------------------------------------------------------------
# Construction fixtures (mirror tests/test_binding_graph_model.py helpers)
# ---------------------------------------------------------------------------
def _span() -> SourceSpanLink:
return SourceSpanLink(source_id="src", start=0, end=3, text="xyz")
def _sym(symbol_id: str) -> SymbolBinding:
return SymbolBinding(
symbol_id=symbol_id,
name=symbol_id,
semantic_role="quantity",
source_span=_span(),
introduced_by="test",
)
def _eq(lhs: str, deps: set[str]) -> BoundEquation:
return BoundEquation(
lhs_symbol_id=lhs,
rhs_canonical=f"{lhs} := f({sorted(deps)})",
dependencies=frozenset(deps),
operation_kind="add",
unit_proof="pending",
admissibility_status="pending",
source_span=_span(),
)
# ---------------------------------------------------------------------------
# Layer 2 — enforcement at the shared construction boundary
# ---------------------------------------------------------------------------
def test_acyclic_equation_set_constructs() -> None:
# r1 := f(x); r2 := f(r1, y) — strict DAG, edges point backward.
syms = tuple(_sym(s) for s in ("x", "y", "r1", "r2"))
eqs = (_eq("r1", {"x"}), _eq("r2", {"r1", "y"}))
graph = SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert len(graph.equations) == 2
def test_adapter_shape_is_acyclic_by_construction() -> None:
# Mirrors the math adapter: each op result depends only on prior symbols.
syms = tuple(_sym(s) for s in ("q0", "q1", "op_0", "op_1"))
eqs = (
_eq("op_0", {"q0", "q1"}), # op_0 := q0 + q1
_eq("op_1", {"op_0", "q1"}), # op_1 := op_0 + q1 (chains forward)
)
graph = SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert len(graph.equations) == 2
def test_two_cycle_equation_set_refuses() -> None:
syms = (_sym("x"), _sym("y"))
eqs = (_eq("x", {"y"}), _eq("y", {"x"})) # x↔y circular dependency
with pytest.raises(BindingGraphError) as exc:
SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert CIRCULAR_DEPENDENCY in str(exc.value)
def test_self_dependent_equation_refuses() -> None:
syms = (_sym("x"),)
eqs = (_eq("x", {"x"}),) # x defined in terms of itself
with pytest.raises(BindingGraphError) as exc:
SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert CIRCULAR_DEPENDENCY in str(exc.value)
def test_longer_cycle_equation_set_refuses() -> None:
syms = tuple(_sym(s) for s in ("a", "b", "c"))
eqs = (_eq("a", {"b"}), _eq("b", {"c"}), _eq("c", {"a"}))
with pytest.raises(BindingGraphError) as exc:
SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert CIRCULAR_DEPENDENCY in str(exc.value)
def test_referential_integrity_still_enforced_before_cycle_check() -> None:
# An unknown dependency is still the referential-integrity refusal, not a
# cycle — the existing ADR-0132 invariant is unchanged.
syms = (_sym("x"),)
eqs = (_eq("x", {"ghost"}),)
with pytest.raises(BindingGraphError) as exc:
SemanticSymbolicBindingGraph(symbols=syms, equations=eqs)
assert "unknown dependency" in str(exc.value)
assert CIRCULAR_DEPENDENCY not in str(exc.value)