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.
160 lines
6.4 KiB
Python
160 lines
6.4 KiB
Python
"""ADR-0204 — proof-graph builder (phase 2.2, structure only).
|
|
|
|
Proves the builder in isolation before any inference rule sits on it:
|
|
1. valid proof DAGs construct cleanly;
|
|
2. the corpus cycle case (PC-CYCLE-001) refuses through the REAL builder path —
|
|
the ADR-0203 guard firing on a real proof construction for the first time;
|
|
3. canonical_key round-trips byte-identically through rhs_canonical;
|
|
plus the admissibility-dispatch confirmation (proof operation_kinds refuse
|
|
gracefully, never misroute into the math checkers) and referential/out-of-regime
|
|
refusals inherited from the real substrate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from generate.binding_graph import (
|
|
AdmissibilityError,
|
|
BindingGraphError,
|
|
check_admissibility,
|
|
)
|
|
from generate.logic_canonical import LogicRegimeError, canonicalize
|
|
from generate.proof_chain import (
|
|
PROOF_NO_UNIT,
|
|
Proof,
|
|
ProofError,
|
|
ProofNode,
|
|
build_proof_graph,
|
|
proof_from_premises,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Valid proof DAGs construct cleanly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_valid_modus_ponens_shape_constructs() -> None:
|
|
# PC-MP-001 desugared. The STRUCTURE builds; the modus_ponens CHECK is 2.3.
|
|
proof = proof_from_premises(
|
|
("P_rains -> Q_ground_wet", "P_rains"), "Q_ground_wet", rule="modus_ponens"
|
|
)
|
|
pg = build_proof_graph(proof)
|
|
assert pg.conclusion_symbol_id == "conclusion"
|
|
assert len(pg.graph.equations) == 3
|
|
concl = next(e for e in pg.graph.equations if e.lhs_symbol_id == "conclusion")
|
|
assert concl.operation_kind == "modus_ponens"
|
|
assert concl.dependencies == frozenset({"premise_0", "premise_1"})
|
|
# Premises are equations with empty deps and operation_kind="premise".
|
|
prem = next(e for e in pg.graph.equations if e.lhs_symbol_id == "premise_0")
|
|
assert prem.operation_kind == "premise"
|
|
assert prem.dependencies == frozenset()
|
|
assert prem.unit_proof == PROOF_NO_UNIT
|
|
|
|
|
|
def test_multistep_dag_constructs() -> None:
|
|
# n1 (premise), n2 (premise), n3 := f(n1,n2), n4 := f(n3) — strict DAG.
|
|
proof = Proof(
|
|
nodes=(
|
|
ProofNode("n1", "P", (), "premise"),
|
|
ProofNode("n2", "P -> Q", (), "premise"),
|
|
ProofNode("n3", "Q", ("n1", "n2"), "modus_ponens"),
|
|
ProofNode("n4", "Q | R", ("n3",), "or_intro"),
|
|
),
|
|
conclusion_id="n4",
|
|
)
|
|
pg = build_proof_graph(proof)
|
|
assert len(pg.graph.equations) == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. PC-CYCLE-001 refuses through the REAL builder path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_corpus_cycle_refuses_through_builder() -> None:
|
|
# PC-CYCLE-001: n1 depends_on n2, n2 depends_on n1. The 2.1 acyclicity guard
|
|
# must fire through real proof construction — not just standalone find_cycle.
|
|
proof = Proof(
|
|
nodes=(
|
|
ProofNode("n1", "P_rains", ("n2",), "modus_ponens"),
|
|
ProofNode("n2", "Q_ground_wet", ("n1",), "modus_ponens"),
|
|
),
|
|
conclusion_id="n1",
|
|
)
|
|
with pytest.raises(BindingGraphError) as exc:
|
|
build_proof_graph(proof)
|
|
assert "circular_dependency" in str(exc.value)
|
|
|
|
|
|
def test_self_dependency_refused_at_proof_model() -> None:
|
|
# A length-1 cycle is refused early and clearly by the Proof model.
|
|
with pytest.raises(ProofError):
|
|
ProofNode("n1", "P", ("n1",), "modus_ponens")
|
|
|
|
|
|
def test_dangling_dependency_refuses() -> None:
|
|
with pytest.raises(ProofError):
|
|
Proof(nodes=(ProofNode("n1", "P", ("ghost",), "modus_ponens"),), conclusion_id="n1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. canonical_key round-trips byte-identically through rhs_canonical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_canonical_key_round_trips_byte_identical() -> None:
|
|
formula = "(P -> Q) & (R | ~S)"
|
|
proof = Proof(nodes=(ProofNode("n1", formula, (), "premise"),), conclusion_id="n1")
|
|
eq = build_proof_graph(proof).graph.equations[0]
|
|
assert eq.rhs_canonical == canonicalize(formula).canonical_key # byte-identical
|
|
|
|
|
|
def test_equivalent_node_formulas_share_rhs_canonical() -> None:
|
|
# Two nodes whose formulas are logically equivalent store identical
|
|
# rhs_canonical — the graph can decide equivalence by string comparison
|
|
# (the propositional twin of how the math graph uses rhs_canonical).
|
|
proof = Proof(
|
|
nodes=(
|
|
ProofNode("a", "P & Q", (), "premise"),
|
|
ProofNode("b", "Q & P", (), "premise"),
|
|
),
|
|
conclusion_id="a",
|
|
)
|
|
eqs = {e.lhs_symbol_id: e.rhs_canonical for e in build_proof_graph(proof).graph.equations}
|
|
assert eqs["a"] == eqs["b"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admissibility-dispatch confirmation: proof operation_kinds refuse gracefully,
|
|
# never misroute into the math checkers (2.3's modus_ponens check hangs off this).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_proof_operation_kinds_refuse_in_admissibility_never_misroute() -> None:
|
|
proof = proof_from_premises(("P -> Q", "P"), "Q", rule="modus_ponens")
|
|
graph = build_proof_graph(proof).graph
|
|
symbols = {s.symbol_id: s for s in graph.symbols}
|
|
for eq in graph.equations:
|
|
with pytest.raises(AdmissibilityError) as exc:
|
|
check_admissibility(eq, symbols=symbols)
|
|
# premise (no deps) reaches the kind dispatch -> unknown_operation;
|
|
# modus_ponens (unitless deps) refuses at unit-resolution -> unit_unbound.
|
|
# Either way it REFUSES and never returns a math UnitProof / misroutes.
|
|
assert exc.value.reason in {"unknown_operation", "unit_unbound"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Out-of-regime formula in a node: the builder inherits the canonicalizer's
|
|
# typed refusal (honesty boundary — no silent admission of predicate logic).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_out_of_regime_node_formula_refuses() -> None:
|
|
proof = Proof(
|
|
nodes=(ProofNode("n1", "forall x. rains(x)", (), "premise"),),
|
|
conclusion_id="n1",
|
|
)
|
|
with pytest.raises(LogicRegimeError):
|
|
build_proof_graph(proof)
|