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.
167 lines
6.2 KiB
Python
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)
|