Pure evidence substrate for proof-carrying coherence promotion. No promotion, no vault import, no status transition, no teaching-path change; ADR-0218 stays Proposed. - generate/proof_chain/certificate.py: frozen PromotionCertificate (version/claim_form/premise ids+forms+statuses/entailment_trace/ engine_pin/decision/reason, canonical_json with sort_keys+compact separators); build_certificate runs evaluate_entailment_with_trace over entry_id-sorted premises; verify_certificate rebuilds from the embedded forms and accepts iff byte-identical — tampered forms/claim/ trace/decision/reason/ordering/version all fail replay, structural breakage fails closed as malformed_certificate. - promotion_positive is necessary-not-sufficient: entailed over a non-empty all-coherent recorded premise set; REFUTED/UNKNOWN/REFUSED verify as themselves but are never positive; zero-premise tautology certificates fail-closed non-positive in v1. - engine_pin is recorded provenance; pin tampering is caught only via caller-supplied expected_engine_pin (P3 must pass the lane SHA) — limit pinned by dedicated tests. - entail.py: docstring-only fix removing the incorrect ADR-0206 attribution (numbering collision); now cites ADR-0218. Zero behavior change. generate_claims.py:74 still carries the label (serving-frozen surface, flagged for follow-up). - No xfail markers retire: every PR-A obligation binds to the P3 promoter, which must not exist before ratification. The certificate- shaped halves of O1/O7 are proven for real in tests/test_proof_chain_certificate.py (27 tests). ADR phasing bullet and obligations docstring record the reconciliation. Validation: certificate 27 passed; obligations 3 passed + 6 xfailed; architectural invariants 61 passed (INV-21/INV-29 green, allowlists unchanged); entail/builder/rules 61 passed; smoke files 95 passed.
200 lines
8 KiB
Python
200 lines
8 KiB
Python
"""Propositional entailment operator (proof_chain phase 2.4) — the
|
||
proof-certificate substrate of ADR-0218 (Proof-Carrying Coherence Promotion).
|
||
|
||
Numbering note: this module originally carried an "ADR-0206" attribution —
|
||
a three-way numbering collision. Committed ADR-0206 is the
|
||
response-governance bridge; the old phase-2 plan's 2.4 referred to carrier
|
||
grounding (still unbuilt). This operator's committed home is ADR-0218
|
||
(§Context documents the collision; §D3–D4 specify how its
|
||
``EntailmentTrace`` evidence is consumed by ``certificate.py``).
|
||
|
||
The multi-hop inference operator ``gaps.md`` asked for and ADR-0205 deferred. Where
|
||
:func:`generate.proof_chain.rules.evaluate_modus_ponens` is **single-step** ("unique
|
||
conclusion among single-step modus ponens"), this is the full, sound **and complete**
|
||
propositional entailment decision built directly on the ADR-0201 ROBDD keystone:
|
||
|
||
premises ⊨ query iff (⋀ premises) → query is a tautology.
|
||
|
||
Because the ROBDD is a *canonical, complete* decision procedure for propositional
|
||
logic, this answers arbitrary multi-hop deductive queries (chains of implications,
|
||
conjunctive rules, contrapositive, disjunctive syllogism — anything propositional),
|
||
not just one modus-ponens step. ``wrong == 0`` is structural: a tautology check is
|
||
exact, never approximate, and the canonicalizer **refuses** (``LogicError``) rather
|
||
than guess on malformed / out-of-decidable-regime (quantified/predicate) input.
|
||
|
||
Four outcomes, all sound:
|
||
|
||
* ``ENTAILED`` — ``(⋀P) → Q`` is a tautology (Q holds in every model of P).
|
||
* ``REFUTED`` — ``(⋀P) → ¬Q`` is a tautology (Q fails in every model of P).
|
||
* ``UNKNOWN`` — neither; Q is true in some models of P and false in others.
|
||
* ``REFUSED`` — premises are inconsistent (no model — everything would follow
|
||
vacuously, so we decline an answer) **or** input is malformed / out-of-regime.
|
||
|
||
Honesty boundary (load-bearing): **propositional only**. Atoms are opaque Boolean
|
||
variables; predicate/quantified structure is out of regime and refuses (ADR-0201.1).
|
||
A grounded finite-entity problem (each predicate-entity pair → one atom) IS
|
||
propositional and in scope; an ungrounded ``forall x. P(x)`` is not.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from typing import Final
|
||
|
||
from generate.logic_canonical import LogicError, canonicalize
|
||
|
||
|
||
class Entailment(str, Enum):
|
||
ENTAILED = "entailed"
|
||
REFUTED = "refuted"
|
||
UNKNOWN = "unknown"
|
||
REFUSED = "refused"
|
||
|
||
|
||
# Closed reason vocabulary (the mechanism makes exactly these distinctions).
|
||
TAUTOLOGICAL_IMPLICATION: Final[str] = "tautological_implication" # entailed
|
||
TAUTOLOGICAL_REFUTATION: Final[str] = "tautological_refutation" # refuted
|
||
UNDETERMINED: Final[str] = "undetermined" # unknown
|
||
INCONSISTENT_PREMISES: Final[str] = "inconsistent_premises" # refused
|
||
OUT_OF_REGIME_OR_MALFORMED: Final[str] = "out_of_regime_or_malformed" # refused
|
||
|
||
ENTAILMENT_REASONS: Final[frozenset[str]] = frozenset({
|
||
TAUTOLOGICAL_IMPLICATION,
|
||
TAUTOLOGICAL_REFUTATION,
|
||
UNDETERMINED,
|
||
INCONSISTENT_PREMISES,
|
||
OUT_OF_REGIME_OR_MALFORMED,
|
||
})
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class EntailmentVerdict:
|
||
outcome: Entailment
|
||
reason: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class EntailmentTrace:
|
||
"""Deterministic proof evidence for a propositional entailment decision."""
|
||
|
||
outcome: Entailment
|
||
reason: str
|
||
premise_keys: tuple[str, ...]
|
||
conjunction_key: str | None
|
||
query_key: str | None
|
||
entailment_check_key: str | None
|
||
refutation_check_key: str | None
|
||
|
||
def as_dict(self) -> dict[str, object]:
|
||
return {
|
||
"outcome": self.outcome.value,
|
||
"reason": self.reason,
|
||
"premise_keys": self.premise_keys,
|
||
"conjunction_key": self.conjunction_key,
|
||
"query_key": self.query_key,
|
||
"entailment_check_key": self.entailment_check_key,
|
||
"refutation_check_key": self.refutation_check_key,
|
||
}
|
||
|
||
def canonical_json(self) -> str:
|
||
return json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
|
||
|
||
|
||
def _conjoin(premises: tuple[str, ...]) -> str:
|
||
"""Fully-parenthesized conjunction of the premise formulas (``true`` if empty).
|
||
|
||
Each premise is wrapped so its internal precedence cannot bleed across the
|
||
``&`` joins; an empty premise set is the always-true antecedent."""
|
||
if not premises:
|
||
return "true"
|
||
return " & ".join(f"({p})" for p in premises)
|
||
|
||
|
||
def evaluate_entailment(premises: tuple[str, ...], query: str) -> EntailmentVerdict:
|
||
"""Decide whether ``premises`` propositionally entail / refute ``query``.
|
||
|
||
Sound and complete over the propositional regime; refusal-first on anything
|
||
outside it. Never raises on a logic-domain error — every ``LogicError`` (and
|
||
its regime/budget subclasses) maps to a typed ``REFUSED`` verdict."""
|
||
trace = evaluate_entailment_with_trace(premises, query)
|
||
return EntailmentVerdict(trace.outcome, trace.reason)
|
||
|
||
|
||
def evaluate_entailment_with_trace(
|
||
premises: tuple[str, ...],
|
||
query: str,
|
||
) -> EntailmentTrace:
|
||
"""Decide entailment and return deterministic proof evidence.
|
||
|
||
This is the evidence-bearing API; :func:`evaluate_entailment` intentionally
|
||
remains the stable verdict-only wrapper for existing callers."""
|
||
premise_keys_list: list[str] = []
|
||
conjunction_key: str | None = None
|
||
query_key: str | None = None
|
||
entailment_check_key: str | None = None
|
||
refutation_check_key: str | None = None
|
||
|
||
try:
|
||
for premise in premises:
|
||
premise_keys_list.append(canonicalize(premise).canonical_key)
|
||
conj = _conjoin(premises)
|
||
conj_canon = canonicalize(conj)
|
||
conjunction_key = conj_canon.canonical_key
|
||
if conj_canon.is_contradiction:
|
||
# No model satisfies the premises: from a contradiction everything
|
||
# follows. We decline rather than assert a vacuous entailment.
|
||
return EntailmentTrace(
|
||
outcome=Entailment.REFUSED,
|
||
reason=INCONSISTENT_PREMISES,
|
||
premise_keys=tuple(premise_keys_list),
|
||
conjunction_key=conjunction_key,
|
||
query_key=None,
|
||
entailment_check_key=None,
|
||
refutation_check_key=None,
|
||
)
|
||
# Force the query through the canonicalizer too, so a malformed / out-of-
|
||
# regime query refuses even when the implication check would shortcut.
|
||
query_canon = canonicalize(query)
|
||
query_key = query_canon.canonical_key
|
||
entail_canon = canonicalize(f"({conj}) -> ({query})")
|
||
entailment_check_key = entail_canon.canonical_key
|
||
refute_canon = canonicalize(f"({conj}) -> (~({query}))")
|
||
refutation_check_key = refute_canon.canonical_key
|
||
entailed = entail_canon.is_tautology
|
||
refuted = refute_canon.is_tautology
|
||
except LogicError:
|
||
return EntailmentTrace(
|
||
outcome=Entailment.REFUSED,
|
||
reason=OUT_OF_REGIME_OR_MALFORMED,
|
||
premise_keys=tuple(premise_keys_list),
|
||
conjunction_key=conjunction_key,
|
||
query_key=query_key,
|
||
entailment_check_key=entailment_check_key,
|
||
refutation_check_key=refutation_check_key,
|
||
)
|
||
|
||
if entailed and refuted:
|
||
# Only possible if the premises are inconsistent, already handled above;
|
||
# defensive — never assert a contradiction-derived answer.
|
||
outcome = Entailment.REFUSED
|
||
reason = INCONSISTENT_PREMISES
|
||
elif entailed:
|
||
outcome = Entailment.ENTAILED
|
||
reason = TAUTOLOGICAL_IMPLICATION
|
||
elif refuted:
|
||
outcome = Entailment.REFUTED
|
||
reason = TAUTOLOGICAL_REFUTATION
|
||
else:
|
||
outcome = Entailment.UNKNOWN
|
||
reason = UNDETERMINED
|
||
return EntailmentTrace(
|
||
outcome=outcome,
|
||
reason=reason,
|
||
premise_keys=tuple(premise_keys_list),
|
||
conjunction_key=conjunction_key,
|
||
query_key=query_key,
|
||
entailment_check_key=entailment_check_key,
|
||
refutation_check_key=refutation_check_key,
|
||
)
|