core/generate/proof_chain/model.py
Shay 4cae904563 feat: proof-graph builder — proof_chain's first binding-graph consumer (ADR-0204)
Phase 2.2, structure only (no inference rule — modus_ponens is 2.3). Translates a
Proof into a SemanticSymbolicBindingGraph; the ADR-0203 acyclicity guard + ADR-0132
referential integrity fire at construction.

- generate/proof_chain/model.py: the one canonical proof input shape (ProofNode/Proof
  + proof_from_premises desugaring of the corpus premises/conclusion shape).
- generate/proof_chain/builder.py: build_proof_graph — node→symbol+equation;
  canonical_key→rhs_canonical, depends_on→dependencies, rule→operation_kind;
  premises = empty-deps/op="premise"; unit_proof=PROOF_NO_UNIT, admissibility="pending";
  conclusion tracked as conclusion_symbol_id.
- tests: 9 — valid DAG (PC-MP-001 desugared) + multistep construct; PC-CYCLE-001
  refuses THROUGH the real builder (circular_dependency); canonical_key round-trips
  byte-identical + equivalent formulas share rhs_canonical; admissibility-dispatch
  confirmation; self/dangling refusals; out-of-regime node refuses. Mutation-verified
  (drop dep-wiring -> cycle admitted -> test fails).

Admissibility dispatch confirmed graceful on proof operation_kinds: unknown kinds
-> AdmissibilityError(unknown_operation), unitless deps -> unit_unbound; NEVER
misroutes into _check_additive. Named 2.3 constraint: check_admissibility runs
_resolve_dep_units before dispatch, so modus_ponens must bypass unit-resolution.

Open items named for 2.3 (ADR-0204): conclusion typing (BoundUnknown revisit),
semantic_role="unknown" (closed vocab has no "proposition"), unit_proof sentinel.

Additive (first consumer; math lane untouched). Full binding-graph surface green;
smoke 67. Honesty boundary: through 2.3, sound over declared atoms, not grounded in input.
2026-06-02 19:53:41 -07:00

102 lines
4.2 KiB
Python

"""ADR-0204 — Proof input model (the one canonical proof shape).
A ``Proof`` is the single committed input representation for proof_chain — the
corpus surface shapes (``proof_nodes``/``depends_on``/``conclusion_node`` and
``premises``/``conclusion``/``rule``) desugar onto it, so proof input never
becomes a second dialect (same discipline ADR-0202 applies to formulas).
Pure data — no canonicalizer, no binding graph. :func:`generate.proof_chain.builder`
translates a ``Proof`` into a ``SemanticSymbolicBindingGraph``.
Honesty boundary (load-bearing through phase 2.3): proof_chain is **sound over its
declared atoms**, not grounded in recognized input. This module declares structure
only.
"""
from __future__ import annotations
from dataclasses import dataclass
class ProofError(ValueError):
"""Raised on malformed proof input. Refusal-first; never coerces."""
@dataclass(frozen=True, slots=True)
class ProofNode:
"""One node of a proof DAG.
``node_id`` becomes the binding-graph ``symbol_id`` / ``lhs_symbol_id`` and so
must be a Python identifier. ``formula`` is an ADR-0202 propositional string.
``depends_on`` names the nodes this one is derived from (→ the equation's
``dependencies``). ``rule`` is the inference label (→ ``operation_kind``);
``"premise"`` for an assumption (no ``depends_on``)."""
node_id: str
formula: str
depends_on: tuple[str, ...]
rule: str
def __post_init__(self) -> None:
object.__setattr__(self, "depends_on", tuple(self.depends_on))
if not self.node_id or not self.node_id.isidentifier():
raise ProofError(f"ProofNode.node_id must be a Python identifier; got {self.node_id!r}")
if not self.formula.strip():
raise ProofError("ProofNode.formula must be non-empty")
if not self.rule:
raise ProofError("ProofNode.rule must be non-empty")
if self.node_id in self.depends_on:
# A self-dependency is a length-1 cycle; the binding-graph acyclicity
# guard (ADR-0203) would also catch it, but refuse early and clearly.
raise ProofError(f"ProofNode {self.node_id!r} depends on itself")
@dataclass(frozen=True, slots=True)
class Proof:
"""A proof DAG: nodes plus the designated conclusion node.
Construction validates only proof-shape integrity (unique ids, conclusion
exists, dependencies name declared nodes). Acyclicity and referential
integrity at the symbol level are enforced by the binding graph at build
time (ADR-0203 / ADR-0132) — deliberately, so the guard fires through the
real builder path rather than a duplicate pre-check."""
nodes: tuple[ProofNode, ...]
conclusion_id: str
def __post_init__(self) -> None:
object.__setattr__(self, "nodes", tuple(self.nodes))
if not self.nodes:
raise ProofError("Proof must have at least one node")
ids = [n.node_id for n in self.nodes]
if len(ids) != len(set(ids)):
raise ProofError(f"duplicate ProofNode.node_id: {ids}")
known = set(ids)
for node in self.nodes:
for dep in node.depends_on:
if dep not in known:
raise ProofError(
f"ProofNode {node.node_id!r} depends on undeclared node {dep!r}"
)
if self.conclusion_id not in known:
raise ProofError(f"conclusion_id {self.conclusion_id!r} is not a declared node")
def proof_from_premises(
premises: tuple[str, ...], conclusion: str, *, rule: str
) -> Proof:
"""Desugar the ``premises``/``conclusion``/``rule`` corpus shape onto ``Proof``.
Each premise becomes a ``rule="premise"`` node with no dependencies; the
conclusion becomes one node with ``rule=rule`` depending on every premise."""
nodes: list[ProofNode] = []
premise_ids: list[str] = []
for i, formula in enumerate(premises):
nid = f"premise_{i}"
premise_ids.append(nid)
nodes.append(ProofNode(node_id=nid, formula=formula, depends_on=(), rule="premise"))
nodes.append(
ProofNode(node_id="conclusion", formula=conclusion,
depends_on=tuple(premise_ids), rule=rule)
)
return Proof(nodes=tuple(nodes), conclusion_id="conclusion")