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.
This commit is contained in:
Shay 2026-06-02 20:56:57 -07:00
parent 0850e6fde7
commit 64f3da18a7
6 changed files with 453 additions and 26 deletions

View file

@ -0,0 +1,116 @@
# ADR-0205 — modus_ponens + the Disagreement Rule (proof_chain's first inference rule)
**Status:** Accepted (proof_chain phase 2.3 — the first inference rule + the wrong=0 mechanism)
**Date:** 2026-06-02
**Relates to:** ADR-0204 (proof-graph builder — the proof-step shape this rule consumes),
ADR-0201/0201.1/0202 (canonicalizer + contract), ADR-0203 (acyclicity guard),
`generate.derivation.verify.select_self_verified` (the arithmetic twin of the disagreement rule).
**Deferred to:** ADR-0206 (atom→carrier grounding).
---
## Context
proof_chain has a DAG substrate (ADR-0204) and a canonical form (ADR-0201). Phase
2.3 adds the first **inference rule** (`modus_ponens`) and the **wrong=0 mechanism
for proofs** (the disagreement/uniqueness rule). GPT-5.5 authored an independent
adversarial corpus (`modus_ponens_cases.json`, 24 cases: 6 valid / 8 invalid / 10
disagreement) against the ADR-0202 grammar, with the proof-step shape left
`[PENDING ADR-0204 SHAPE]` and the disagreement cases `[CONFIRM WITH OPUS]` — the
contract-first handshake.
## Decision
### 1. The MP-rule contract (committed before the rule)
- **Proof-step shape** = the already-landed ADR-0204 `Proof`/`ProofNode`
(`proof_from_premises` desugars premises→premise-nodes, conclusion→mp-node). The
`[PENDING ADR-0204 SHAPE]` placeholders resolve to this; the rule also accepts a
bare `(premises, conclusion)` for corpus cross-check.
- **Closed typed-reason set** (`generate/proof_chain/rules.py::MP_REASONS`):
`unique_canonical_conclusion` (admit); `missing_implication`,
`unestablished_antecedent`, `conclusion_mismatch`, `conclusion_disagreement` (refuse).
### 2. `evaluate_modus_ponens` — ROBDD-exact, bypassing unit-resolution
In `generate/proof_chain/rules.py` (the proof-layer dispatch, ADR-0205 Option B):
operates on proposition **formulas** via the canonicalizer; **never** calls
`check_admissibility` / `_resolve_dep_units` (proofs have no units — the named 2.2
constraint, satisfied by construction). An implication `A→B` fires iff `key(A)` is
an established premise key; the consequent `B` is recovered syntactically
(`logic_canonical.parse_top_implication`) because the ROBDD form does not preserve
which side is antecedent (`P→Q` and `¬PQ` share one diagram).
### 3. The disagreement / uniqueness rule (the wrong=0 mechanism)
The literal twin of `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;
≥2 distinct keys → refuse (`conclusion_disagreement`); one key ≠ declared →
`conclusion_mismatch`; no admissible derivation → `missing_implication` /
`unestablished_antecedent`.
**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): e.g. a premise
set that MP-derives both an unrelated tautology and the declared atom, or both the
declared atom and a strictly stronger one. Collapse-vs-conflict is judged by **exact
canonical key**, never surface — equivalent paths (`P∧Q`/`Q∧P`, `Q→R`/`¬QR`,
`Q`/`Q∧(R¬R)`) collapse and admit; subtly-different ones (`P∧Q`/`PQ`, `P→Q`/`Q→P`,
`Q`/`Q∧R`) refuse.
### 4. Reason-set consolidation (the reconciliation finding)
The mechanism makes one distinction per axis; the corpus's finer labels collapse onto
the closed set — *the same redundancy confirmed for the disagreement labels applies to
the invalid labels*:
- 6 disagreement refuse-labels (conflicting / contradictory / tautology_vs_substantive
/ distinct_atom / near_miss / stronger) → **`conclusion_disagreement`** (the rule
knows only: keys agree or they don't).
- 4 antecedent-flavor labels (missing_antecedent / antecedent_mismatch /
affirming_consequent / implication_direction_mismatch) → **`unestablished_antecedent`**
(the rule knows only: the available implication's antecedent is or isn't an
established premise; `affirming_consequent` and `implication_direction_mismatch` are
the identical pattern `A→B, conclusion≡A, premise≡B` — no mechanical distinction).
This consolidation is conveyed back to GPT-5.5 for the corpus's committed reasons.
## Honesty boundary (load-bearing — exact scope stated)
Through phase 2.3, proof_chain is **sound over its declared atoms**, not grounded in
recognized input (grounding is 2.4). And the disagreement rule's guarantee has a
**precise scope**: it guarantees a unique conclusion among **single-step modus ponens
derivations over the given premises** — **NOT** "uniquely entailed" by all proof
strategies (a stronger, currently-false claim). This must never be read as
"uniquely entailed." Same discipline as propositional-not-FOL.
## Evidence (cross-checked, not asserted)
- **All 24 corpus cases agree on OUTCOME against the real rule** (6 admit / 8 refuse
/ 4 admit / 6 refuse) — no rule bug, no corpus outcome-misread. Reasons: 3 exact,
21 consolidate onto the closed set as above.
- `tests/test_proof_chain_rules.py` — the 24 cases (transcribed, reproducible) + the
pooling guards. **Mutation:** a filter-to-declared-conclusion-first variant makes
MP-DISAGREE-007/010 wrongly admit → `test_pooling_*` fail. Pooling is load-bearing.
- **Drive-by fix (cleanup-as-you-find):** the merged ADR-0204 `ProofNode.__post_init__`
was dedented to module level, so **all `ProofNode` validation was silently dead**
(smoke skips the dedicated test file, so the merge missed it — the "smoke ≠ full
suite" hazard). Re-indented; validation restored;
`test_self_dependency_refused_at_proof_model` passes.
- Full binding-graph + admissibility surface green; smoke 67. Additive (no math path
touched).
## Three deferrals — settled
1. **modus_ponens bypasses unit-resolution** — by construction (proof-layer dispatch).
2. **Conclusion typing** — the rule needs the declared conclusion's key + premise
formulas, both reachable; `conclusion_symbol_id` suffices, **no `BoundUnknown`**.
3. **`semantic_role`** — proofs still not wired into serving; **stays `"unknown"`.**
## Deferred
- **2.4** — atom→ADR-0144 `EpistemicNode` grounding (ADR-0206). Until then, "sound
over declared atoms," never "reasons over input."
- Multi-step proof composition and a proof *search* (the disagreement rule here is
over a given premise set, single-step) — later, separately scoped.

View file

@ -266,6 +266,41 @@ def _collect_atoms(ast: _Ast) -> set[str]:
return _collect_atoms(ast[1]) | _collect_atoms(ast[2])
_BINARY_OPS: Final[dict[str, str]] = {"and": "&", "or": "|", "implies": "->", "iff": "<->"}
def _unparse(ast: _Ast) -> str:
"""Render an AST back to a fully-parenthesized formula string. Used to hand a
sub-formula (e.g. an implication's antecedent/consequent) back to
:func:`canonicalize`. Parenthesized everywhere so re-parsing is unambiguous."""
kind = ast[0]
if kind == "atom":
return ast[1]
if kind == "const":
return "true" if ast[1] else "false"
if kind == "not":
return f"(~{_unparse(ast[1])})"
return f"({_unparse(ast[1])} {_BINARY_OPS[kind]} {_unparse(ast[2])})"
def parse_top_implication(formula: str) -> tuple[str, str] | None:
"""If ``formula``'s top-level connective is ``->``, return
``(antecedent, consequent)`` as formula strings; else ``None``.
Modus ponens needs the syntactic antecedent/consequent of an implication
premise the ROBDD form does not preserve which side is which (``P->Q`` and
``~P|Q`` share one diagram), so this works at the parse layer. Raises
:class:`LogicError` / :class:`LogicRegimeError` on malformed / out-of-regime
input, consistent with :func:`canonicalize`."""
_reject_out_of_regime_text(formula)
tokens = _tokenize(formula)
_reject_out_of_regime_tokens(tokens)
ast = _Parser(tokens).parse()
if ast[0] == "implies":
return _unparse(ast[1]), _unparse(ast[2])
return None
# ---------------------------------------------------------------------------
# ROBDD manager (hand-rolled, minimal: mk + apply + negate + unique table)
# ---------------------------------------------------------------------------

View file

@ -1,12 +1,14 @@
"""ADR-0204 — proof_chain: propositional proof graphs over the binding-graph DAG.
"""ADR-0204/0205 — proof_chain: propositional proof graphs over the binding-graph DAG.
Phase 2.2 (this module set): the proof-graph *builder* proof binding graph,
structure only. The canonicalizer (`generate.logic_canonical`) and equivalence
check (`generate.logic_equivalence`) it rides on are top-level modules; the
inference rule (`modus_ponens` + the disagreement rule) is phase 2.3 / ADR-0205.
- Phase 2.2 (ADR-0204): the proof-graph *builder* proof binding graph, structure
only (`build_proof_graph`).
- Phase 2.3 (ADR-0205): the first inference rule + the wrong=0 mechanism
`evaluate_modus_ponens` / `evaluate_proof_conclusion` (modus_ponens + the
disagreement/uniqueness rule), in `rules.py`.
Honesty boundary (load-bearing through 2.3): sound over declared atoms, not
grounded in recognized input.
Honesty boundary (load-bearing through 2.3): sound over declared atoms, not grounded
in recognized input; the disagreement rule guarantees a unique conclusion among
SINGLE-STEP modus ponens over the premises, not "uniquely entailed" by all strategies.
"""
from __future__ import annotations
@ -19,8 +21,26 @@ from .builder import (
build_proof_graph,
)
from .model import Proof, ProofError, ProofNode, proof_from_premises
from .rules import (
CONCLUSION_DISAGREEMENT,
CONCLUSION_MISMATCH,
MISSING_IMPLICATION,
MP_REASONS,
UNESTABLISHED_ANTECEDENT,
UNIQUE_CANONICAL_CONCLUSION,
MPOutcome,
MPVerdict,
evaluate_modus_ponens,
evaluate_proof_conclusion,
)
__all__ = (
"CONCLUSION_DISAGREEMENT",
"CONCLUSION_MISMATCH",
"MISSING_IMPLICATION",
"MP_REASONS",
"MPOutcome",
"MPVerdict",
"PROOF_INTRODUCED_BY",
"PROOF_NO_UNIT",
"PROOF_SOURCE_ID",
@ -28,6 +48,10 @@ __all__ = (
"ProofError",
"ProofGraph",
"ProofNode",
"UNESTABLISHED_ANTECEDENT",
"UNIQUE_CANONICAL_CONCLUSION",
"build_proof_graph",
"evaluate_modus_ponens",
"evaluate_proof_conclusion",
"proof_from_premises",
)

View file

@ -37,26 +37,26 @@ class ProofNode:
depends_on: tuple[str, ...]
rule: str
def __post_init__(self) -> None:
object.__setattr__(self, "depends_on", tuple(self.depends_on))
def __post_init__(self) -> None:
object.__setattr__(self, "depends_on", tuple(self.depends_on))
if not isinstance(self.node_id, str) or not self.node_id.isidentifier():
raise ProofError(
"ProofNode.node_id must be a Python identifier str; "
f"got {self.node_id!r}"
)
if not isinstance(self.formula, str) or not self.formula.strip():
raise ProofError("ProofNode.formula must be a non-empty str")
if not isinstance(self.rule, str) or not self.rule.strip():
raise ProofError("ProofNode.rule must be a non-empty str")
if self.rule != self.rule.strip():
raise ProofError("ProofNode.rule must not have leading/trailing whitespace")
if self.rule == "premise" and self.depends_on:
raise ProofError('ProofNode.rule == "premise" requires empty depends_on')
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")
if not isinstance(self.node_id, str) or not self.node_id.isidentifier():
raise ProofError(
"ProofNode.node_id must be a Python identifier str; "
f"got {self.node_id!r}"
)
if not isinstance(self.formula, str) or not self.formula.strip():
raise ProofError("ProofNode.formula must be a non-empty str")
if not isinstance(self.rule, str) or not self.rule.strip():
raise ProofError("ProofNode.rule must be a non-empty str")
if self.rule != self.rule.strip():
raise ProofError("ProofNode.rule must not have leading/trailing whitespace")
if self.rule == "premise" and self.depends_on:
raise ProofError('ProofNode.rule == "premise" requires empty depends_on')
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)

View file

@ -0,0 +1,127 @@
"""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)

View file

@ -0,0 +1,125 @@
"""ADR-0205 — modus_ponens + disagreement rule (phase 2.3).
The 24 adversarial corpus cases (GPT-5.5's independent oracle) transcribed for a
committed, reproducible cross-check against the real rule: 6 valid / 8 invalid /
10 disagreement. The disagreement cases are the wrong=0 guard 007/010 in
particular pin the pooling semantics: pool ALL admissible MP derivations and
require a unique key. A filter-to-declared-conclusion-first rule would admit them
(admit-by-assertion); pool-first refuses. That test failing under filter-first is
the proof the soundness mechanism is load-bearing.
"""
from __future__ import annotations
import pytest
from generate.proof_chain import (
CONCLUSION_DISAGREEMENT,
CONCLUSION_MISMATCH,
MISSING_IMPLICATION,
MP_REASONS,
Proof,
ProofNode,
UNESTABLISHED_ANTECEDENT,
UNIQUE_CANONICAL_CONCLUSION,
MPOutcome,
evaluate_modus_ponens,
evaluate_proof_conclusion,
)
# (id, premises, conclusion, expected_outcome, expected_reason) — reasons are the
# CLOSED set; the corpus's finer labels collapse onto them (ADR-0205 §reason-set).
CASES = [
# --- valid (admit) ---
("VALID-001", ("P", "P -> Q"), "Q", "admit", UNIQUE_CANONICAL_CONCLUSION),
("VALID-002", ("P_rains", "P_rains -> Q_ground_wet"), "Q_ground_wet", "admit", UNIQUE_CANONICAL_CONCLUSION),
("VALID-003", ("P and R", "(P and R) -> Q"), "Q", "admit", UNIQUE_CANONICAL_CONCLUSION),
("VALID-004", ("P_switch_on", "P_switch_on -> (Q_lamp_lit or R_alarm_on)"), "Q_lamp_lit or R_alarm_on", "admit", UNIQUE_CANONICAL_CONCLUSION),
("VALID-005", ("P", "P -> (Q -> R)"), "Q -> R", "admit", UNIQUE_CANONICAL_CONCLUSION),
("VALID-006", ("(P or Q) and not R", "((P or Q) and not R) -> S"), "S", "admit", UNIQUE_CANONICAL_CONCLUSION),
# --- invalid (refuse, typed) ---
("INVALID-001", ("P", "P -> Q"), "R", "refuse", CONCLUSION_MISMATCH),
("INVALID-002", ("P",), "Q", "refuse", MISSING_IMPLICATION),
("INVALID-003", ("Q", "P -> Q"), "P", "refuse", UNESTABLISHED_ANTECEDENT), # corpus: affirming_consequent
("INVALID-004", ("R", "P -> Q"), "Q", "refuse", UNESTABLISHED_ANTECEDENT), # corpus: antecedent_mismatch
("INVALID-005", ("P", "Q -> P"), "Q", "refuse", UNESTABLISHED_ANTECEDENT), # corpus: implication_direction_mismatch
("INVALID-006", ("P", "(P or R) -> Q"), "Q", "refuse", UNESTABLISHED_ANTECEDENT), # corpus: antecedent_mismatch
("INVALID-007", ("P", "P -> (Q or R)"), "Q", "refuse", CONCLUSION_MISMATCH),
("INVALID-008", ("P -> Q",), "Q", "refuse", UNESTABLISHED_ANTECEDENT), # corpus: missing_antecedent
# --- disagreement (admit: collapse to one key; refuse: distinct keys) ---
("DISAGREE-001", ("A", "A -> C", "B", "B -> C"), "C", "admit", UNIQUE_CANONICAL_CONCLUSION),
("DISAGREE-002", ("A", "A -> (P and Q)", "B", "B -> (Q and P)"), "P and Q", "admit", UNIQUE_CANONICAL_CONCLUSION),
("DISAGREE-003", ("A", "A -> (Q -> R)", "B", "B -> (not Q or R)"), "Q -> R", "admit", UNIQUE_CANONICAL_CONCLUSION),
("DISAGREE-004", ("A", "A -> Q", "B", "B -> (Q and (R or not R))"), "Q", "admit", UNIQUE_CANONICAL_CONCLUSION),
("DISAGREE-005", ("A", "A -> (P and Q)", "B", "B -> (P or Q)"), "P and Q", "refuse", CONCLUSION_DISAGREEMENT),
("DISAGREE-006", ("A", "A -> Q_ground_wet", "B", "B -> Q_ground_damp"), "Q_ground_wet", "refuse", CONCLUSION_DISAGREEMENT),
("DISAGREE-007", ("A", "A -> (R or not R)", "B", "B -> Q"), "Q", "refuse", CONCLUSION_DISAGREEMENT),
("DISAGREE-008", ("A", "A -> Q", "B", "B -> not Q"), "Q", "refuse", CONCLUSION_DISAGREEMENT),
("DISAGREE-009", ("A", "A -> (P -> Q)", "B", "B -> (Q -> P)"), "P -> Q", "refuse", CONCLUSION_DISAGREEMENT),
("DISAGREE-010", ("A", "A -> Q", "B", "B -> (Q and R)"), "Q", "refuse", CONCLUSION_DISAGREEMENT),
]
@pytest.mark.parametrize("cid,premises,conclusion,outcome,reason", CASES, ids=[c[0] for c in CASES])
def test_corpus_case(cid, premises, conclusion, outcome, reason) -> None:
v = evaluate_modus_ponens(premises, conclusion)
assert v.outcome.value == outcome, cid
assert v.reason == reason, cid
assert v.reason in MP_REASONS
def test_admit_yields_the_canonical_conclusion_key() -> None:
from generate.logic_canonical import canonicalize
v = evaluate_modus_ponens(("P", "P -> Q"), "Q")
assert v.outcome is MPOutcome.ADMIT
assert v.conclusion_key == canonicalize("Q").canonical_key
# ---------------------------------------------------------------------------
# The pooling semantics — the wrong=0 mechanism. 007/010 MUST refuse: the premises
# admit deriving a second distinct key, so pool-first refuses. A filter-first rule
# (keep only derivations whose key == declared conclusion, then check uniqueness)
# would admit both — admit-by-assertion. These assertions fail under that mutation.
# ---------------------------------------------------------------------------
def test_pooling_refuses_unrelated_tautology_path() -> None:
# 007: one path yields T (R or not R), the substantive path yields Q.
v = evaluate_modus_ponens(("A", "A -> (R or not R)", "B", "B -> Q"), "Q")
assert v.outcome is MPOutcome.REFUSE
assert v.reason == CONCLUSION_DISAGREEMENT
assert len(v.derived_keys) == 2 # T and key(Q) — distinct, pooled
def test_pooling_refuses_stronger_conclusion_path() -> None:
# 010: paths yield Q and (Q and R) — distinct keys.
v = evaluate_modus_ponens(("A", "A -> Q", "B", "B -> (Q and R)"), "Q")
assert v.outcome is MPOutcome.REFUSE
assert v.reason == CONCLUSION_DISAGREEMENT
assert len(v.derived_keys) == 2
def test_equivalent_paths_collapse_not_disagree() -> None:
# 003: Q->R and (not Q or R) are equivalent → one key → admit.
v = evaluate_modus_ponens(("A", "A -> (Q -> R)", "B", "B -> (not Q or R)"), "Q -> R")
assert v.outcome is MPOutcome.ADMIT
assert len(v.derived_keys) == 1
# ---------------------------------------------------------------------------
# Wiring to the ADR-0204 Proof.
# ---------------------------------------------------------------------------
def test_evaluate_proof_conclusion_via_builder_shape() -> None:
proof = Proof(
nodes=(
ProofNode("premise_0", "P", (), "premise"),
ProofNode("premise_1", "P -> Q", (), "premise"),
ProofNode("conclusion", "Q", ("premise_0", "premise_1"), "modus_ponens"),
),
conclusion_id="conclusion",
)
v = evaluate_proof_conclusion(proof)
assert v.outcome is MPOutcome.ADMIT
assert v.reason == UNIQUE_CANONICAL_CONCLUSION