core/generate/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

91 lines
4 KiB
Python
Raw 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 — Acyclicity invariant for the binding-graph dependency structure.
Pure cycle detection over a ``{node: successors}`` adjacency, isolated from the
binding-graph model so it is testable against synthetic graphs with no
binding-graph construction. The model's ``__post_init__`` adapts its equations
into an adjacency and calls :func:`find_cycle`; a non-``None`` result is refused
with the typed reason :data:`CIRCULAR_DEPENDENCY`.
Why this exists (additive to ADR-0132, which it references): the ADR-0132 data
model enforces *referential integrity* (every dependency names a known symbol) but
not *acyclicity*. A cycle in the equation dependency structure is **circular
reasoning** — concluding ``P`` because ``Q`` because ``P`` — the proof-domain
analog of the ``20/5 == 4`` class: structurally well-formed, semantically invalid.
``proof_chain`` is the first consumer that can build such a structure, so the guard
lands at the shared construction boundary *before* that wiring exists (ADR-0201
phase 2.1).
On main today the only producer of binding graphs is the math adapter
(`generate/binding_graph/adapter.py`), which mints a fresh result symbol per
operation and depends only on symbols that already exist — edges point strictly
backward in construction order, so it is **acyclic by construction**. This guard
therefore refuses no existing graph; it protects the structure the moment a future
consumer could build a cycle.
Honesty boundary (carried by every phase-2 ADR, 02030205): through phase 2.3,
``proof_chain`` is **sound over its declared atoms**, not grounded in recognized
input. Atom→carrier grounding is phase 2.4. This module is structure-only and makes
no grounding claim.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Final
CIRCULAR_DEPENDENCY: Final[str] = "circular_dependency"
def find_cycle(adjacency: Mapping[str, frozenset[str]]) -> tuple[str, ...] | None:
"""Return a directed cycle as an ordered tuple ``(n0, …, nk, n0)``, or
``None`` if the graph is acyclic.
``adjacency`` maps a node to the set of nodes it points to (an equation's
``lhs_symbol_id`` → the symbols it reads). Nodes that appear only as
successors (leaf dependencies defined by no equation) have no out-edges and
cannot start a cycle.
Deterministic: roots and successors are visited in sorted order, so the
reported cycle is byte-stable across runs (the replay discipline). A node
listing itself as a successor is reported as a length-1 self-cycle
``(n, n)``.
"""
WHITE, GREY, BLACK = 0, 1, 2
color: dict[str, int] = {node: WHITE for node in adjacency}
for succs in adjacency.values():
for succ in succs:
color.setdefault(succ, WHITE)
def successors(node: str) -> list[str]:
return sorted(adjacency.get(node, frozenset()))
# Iterative three-colour DFS (iterative to avoid recursion limits on long
# dependency chains). GREY = on the current DFS path; a GREY successor is a
# back-edge, i.e. a cycle.
for root in sorted(color):
if color[root] != WHITE:
continue
path: list[str] = [root]
stack: list[tuple[str, list[str]]] = [(root, successors(root))]
color[root] = GREY
while stack:
node, succs = stack[-1]
descended = False
while succs:
nxt = succs.pop(0)
state = color[nxt]
if state == GREY:
start = path.index(nxt)
return tuple(path[start:] + [nxt])
if state == WHITE:
color[nxt] = GREY
path.append(nxt)
stack.append((nxt, successors(nxt)))
descended = True
break
# BLACK: fully explored, no cycle through it — skip.
if not descended:
color[node] = BLACK
path.pop()
stack.pop()
return None