core/generate/proof_chain/rules.py
Shay 64f3da18a7 feat: modus_ponens + disagreement rule — proof_chain wrong=0 mechanism (ADR-0205)
Phase 2.3: the first inference rule + the wrong=0 mechanism for proofs.

- generate/proof_chain/rules.py: evaluate_modus_ponens / evaluate_proof_conclusion.
  Proof-layer dispatch (Option B) over proposition FORMULAS via the canonicalizer;
  never touches check_admissibility/_resolve_dep_units (proofs have no units).
  Disagreement rule = the select_self_verified twin: pool ALL admissible single-step
  MP derivations, require a unique canonical key == declared conclusion. Pooling (not
  filter-to-declared-first) is the soundness mechanism.
- generate/logic_canonical.py: parse_top_implication (+ _unparse) — recovers an
  implication's syntactic antecedent/consequent (the ROBDD form doesn't preserve it).
- Closed typed-reason set; the corpus's finer labels consolidate (6 disagreement
  refuse-labels -> conclusion_disagreement; 4 antecedent-flavor labels ->
  unestablished_antecedent — same redundancy, same mechanism-makes-one-distinction
  principle).
- Honesty boundary (exact scope): guarantees a unique conclusion among SINGLE-STEP MP
  over the premises, NOT "uniquely entailed" by all strategies.

Cross-check: all 24 GPT-5.5 adversarial corpus cases agree on OUTCOME against the real
rule (no rule bug / no corpus outcome-misread); reasons consolidate as above.
Mutation: filter-to-declared-first makes DISAGREE-007/010 wrongly admit -> pooling
tests fail (pooling load-bearing).

Drive-by fix (cleanup-as-you-find): merged ADR-0204 ProofNode.__post_init__ was
dedented to module level -> all ProofNode validation was silently DEAD (smoke skips
the dedicated test file; the smoke != full-suite hazard). Re-indented; validation
restored.

Additive (math lane untouched). Full binding-graph surface green; smoke 67.
2026-06-02 20:56:57 -07:00

127 lines
5.4 KiB
Python

"""ADR-0205 — modus_ponens + the disagreement/uniqueness rule (proof_chain 2.3).
The first inference rule, and the wrong=0 mechanism for proofs. Operates on
proposition FORMULAS via the canonicalizer (`generate.logic_canonical`) — the
proof-layer dispatch (Option B): it never touches the math `check_admissibility` /
`_resolve_dep_units` (proofs have no units; the named 2.2 constraint, satisfied by
construction).
The disagreement rule is the literal twin of
`generate.derivation.verify.select_self_verified`: **pool ALL admissible single-step
MP derivations the premise set supports**, collect their canonical conclusion keys,
and admit iff they collapse to exactly one key equal to the declared conclusion.
Pooling over the premise set — NOT filtering to the declared conclusion first — is
the soundness mechanism: filter-first would admit-by-assertion when the same
premises admit a different key (the ``20/5 == 4`` class one level up).
**Honesty-boundary scope (load-bearing):** this guarantees a unique conclusion among
**single-step modus ponens** derivations over the given premises — NOT "uniquely
entailed" by all proof strategies. Same discipline as propositional-not-FOL.
Closed typed-reason set (the mechanism makes exactly these distinctions; the corpus's
finer labels collapse onto them — ADR-0205 §reason-set):
* 6 disagreement refuse-labels → ``conclusion_disagreement``;
* 4 antecedent-flavor labels (missing_antecedent / antecedent_mismatch /
affirming_consequent / implication_direction_mismatch) → ``unestablished_antecedent``.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Final
from generate.logic_canonical import canonicalize, parse_top_implication
from generate.proof_chain.model import Proof, ProofError
class MPOutcome(str, Enum):
ADMIT = "admit"
REFUSE = "refuse"
# Closed reason vocabulary.
UNIQUE_CANONICAL_CONCLUSION: Final[str] = "unique_canonical_conclusion" # admit
MISSING_IMPLICATION: Final[str] = "missing_implication"
UNESTABLISHED_ANTECEDENT: Final[str] = "unestablished_antecedent"
CONCLUSION_MISMATCH: Final[str] = "conclusion_mismatch"
CONCLUSION_DISAGREEMENT: Final[str] = "conclusion_disagreement"
MP_REASONS: Final[frozenset[str]] = frozenset({
UNIQUE_CANONICAL_CONCLUSION,
MISSING_IMPLICATION,
UNESTABLISHED_ANTECEDENT,
CONCLUSION_MISMATCH,
CONCLUSION_DISAGREEMENT,
})
@dataclass(frozen=True, slots=True)
class MPVerdict:
outcome: MPOutcome
reason: str
conclusion_key: str | None # the unique admitted key (admit only)
derived_keys: tuple[str, ...] # distinct admissible-derivation keys (sorted)
def evaluate_modus_ponens(premises: tuple[str, ...], conclusion: str) -> MPVerdict:
"""Single-step modus ponens + the disagreement rule over ``premises``.
Admit iff the admissible single-step MP derivations the premises support
collapse to exactly one canonical key equal to ``conclusion``'s key. Refuses
(typed) otherwise. Propagates the canonicalizer's ``LogicError`` family on a
malformed / out-of-regime premise or conclusion."""
conclusion_key = canonicalize(conclusion).canonical_key
# Each premise establishes its own canonical key; implications also expose a
# syntactic (antecedent, consequent).
established: set[str] = set()
implications: list[tuple[str, str]] = []
for premise in premises:
established.add(canonicalize(premise).canonical_key)
parts = parse_top_implication(premise)
if parts is not None:
implications.append(parts)
if not implications:
return MPVerdict(MPOutcome.REFUSE, MISSING_IMPLICATION, None, ())
# Enumerate admissible derivations: an implication A->B fires iff key(A) is an
# established premise; it yields B. Pool over the WHOLE premise set.
derived: dict[str, None] = {} # insertion-ordered distinct yielded keys
for antecedent, consequent in implications:
if canonicalize(antecedent).canonical_key in established:
derived[canonicalize(consequent).canonical_key] = None
if not derived:
# Implication(s) present, but none has an established antecedent.
return MPVerdict(MPOutcome.REFUSE, UNESTABLISHED_ANTECEDENT, None, ())
distinct = tuple(sorted(derived))
if len(distinct) >= 2:
# The premises admit deriving distinct conclusions → disagreement.
return MPVerdict(MPOutcome.REFUSE, CONCLUSION_DISAGREEMENT, None, distinct)
only = distinct[0]
if only != conclusion_key:
# A single admissible derivation, but it concludes something else.
return MPVerdict(MPOutcome.REFUSE, CONCLUSION_MISMATCH, None, distinct)
return MPVerdict(MPOutcome.ADMIT, UNIQUE_CANONICAL_CONCLUSION, only, distinct)
def evaluate_proof_conclusion(proof: Proof) -> MPVerdict:
"""Evaluate ``proof``'s conclusion node as a modus_ponens step.
Wires the rule to the ADR-0204 ``Proof``: gathers the conclusion node's
dependency-node formulas as the premise set and evaluates. Requires the
conclusion node's ``rule == "modus_ponens"``."""
by_id = {n.node_id: n for n in proof.nodes}
concl = by_id[proof.conclusion_id]
if concl.rule != "modus_ponens":
raise ProofError(
f"evaluate_proof_conclusion expects a modus_ponens conclusion; "
f"got rule={concl.rule!r}"
)
premises = tuple(by_id[dep].formula for dep in concl.depends_on)
return evaluate_modus_ponens(premises, concl.formula)