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.
127 lines
5.4 KiB
Python
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)
|