core/generate/proof_chain/categorical.py
Shay fdfb71f59b feat(deduction-serve): Phase 4 — Band v1b categorical/syllogism serving (ADR-0256)
core chat now decides Aristotelian syllogisms end-to-end -- the marquee
'basic logical work' widening. Closes Phase 0's fork: the ONLY categorical
decider was evals/syllogism/oracle.py (the sealed independence oracle serving
must not import; INV-25). This ships a production decider.

generate/proof_chain/categorical.py lowers a categorical argument to the
propositional regime (one Boolean atom per term-membership profile) and rides
the already-verified ROBDD engine -- no new decision procedure, soundness
inherits from the flagship. Sound AND complete for the modern/Boolean reading
(Darapti + existential-import-only forms correctly INVALID), with no reliance
on a lucky domain size. Independent of the eval oracle by mechanism (ROBDD-
over-profiles vs brute-force finite-model enumeration); proven by case-for-case
agreement with it across the whole syllogism gold lane.

New: generate/proof_chain/categorical.py, render_syllogism (deterministic
valid/invalid/inconsistent templates), CATEGORICAL shape-band, arena
categorical band (4 valid forms + 2 invalid, by-construction gold cross-checked
vs the independent syllogism oracle), tests/test_categorical_decider.py (8
tests incl. the independence-by-agreement proof).

Changed: chat/deduction_surface.py (categorical branch, license-gated via a
shared _license_gate helper; propositional path byte-identical), arena
DeductionSolver dual-path in lock-step with the composer, re-sealed ledger (5
bands, categorical earns SERVE at 0.99087 wrong=0), Phase-2 lane decide()
mirrors the dual path with the 3 categorical cases reclassified to their true
valid/invalid verdicts + 1 invalid case (n 27->28), report+SHA regenerated.

Honest wrinkles: irregular plurals ('fish') decline with unknown_morphology
(a reader limit, correctly not a wrong); surfaces read in singularized terms
('all whale are animal') -- the exact ids the engine reasoned over, no cosmetic
re-pluralization that could drift from the decision.

[Verification]: smoke 180 passed; cognition 122 passed/1 skipped; core test
--suite deductive 45 passed; test_categorical_decider 8 passed; practice
runner 5 bands all SERVE wrong=0; deduction_serve lane SHA regenerates to the
committed pin.
2026-07-23 13:17:35 -07:00

167 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Categorical-syllogism decider by propositional lowering (deduction-serve
arc, Phase 4 / Band v1b, ADR-0256).
The Phase-0 fork found that the ONLY categorical decider in the tree was
``evals/syllogism/oracle.py`` — the sealed independence oracle the comprehension
lane scores against, which serving must never import (INV-25). This is the
production decider: it lowers a categorical argument into the propositional
regime and decides it with the already-verified ROBDD engine
(``generate.proof_chain.entail``, 716/716 wrong=0). No new decision procedure —
soundness rides on the flagship engine; only the lowering is new.
The lowering is EXACT for the modern/Boolean reading of the categorical forms
(universals without existential import — the same reading the eval oracle uses).
A model of a k-term categorical argument is fully determined by which of the
``2^k`` term-membership *profiles* are occupied by at least one element. Introduce
one Boolean atom ``occ_j`` per profile ("profile j has ≥1 element") and every
A/E/I/O proposition becomes a propositional formula over those atoms:
A all S are P ⋀_{j: S∈j, P∉j} ¬occ_j (no S-but-not-P element exists)
E no S are P ⋀_{j: S∈j, P∈j} ¬occ_j
I some S are P _{j: S∈j, P∈j} occ_j
O some S are not P _{j: S∈j, P∉j} occ_j
Validity ("conclusion holds in every model of the premises") is then exactly
propositional entailment, which the ROBDD engine decides. Because the profile
atoms range over ALL occupancy patterns, this is sound AND complete (no reliance
on a lucky domain size). Independent of the eval oracle by mechanism: ROBDD over
a profile encoding vs. brute-force subset enumeration — no shared code.
"""
from __future__ import annotations
from itertools import combinations
from generate.proof_chain.entail import (
Entailment,
EntailmentTrace,
evaluate_entailment_with_trace,
)
_FORMS = frozenset({"A", "E", "I", "O"})
class CategoricalError(ValueError):
"""Malformed / out-of-grammar categorical input; refuse, never guess."""
def _profiles(n_terms: int) -> tuple[tuple[int, ...], ...]:
"""All ``2^n`` membership profiles over ``n`` term-indices, as index tuples.
A profile is the set of term-indices an element belongs to; ``occ_<bits>``
names whether at least one element has that profile.
"""
idxs = range(n_terms)
out: list[tuple[int, ...]] = []
for r in range(n_terms + 1):
out.extend(combinations(idxs, r))
return tuple(out)
def _atom(profile: tuple[int, ...]) -> str:
"""Deterministic occupancy-atom name for a profile (its member indices)."""
return "occ_" + ("e" if not profile else "_".join(str(i) for i in profile))
def _lower_proposition(
form: str, s_idx: int, p_idx: int, profiles: tuple[tuple[int, ...], ...]
) -> str:
"""One A/E/I/O proposition → a propositional formula over occupancy atoms."""
if form == "A": # ⋀ ¬occ_j over profiles with S and not P
terms = [f"not {_atom(j)}" for j in profiles if s_idx in j and p_idx not in j]
return " and ".join(terms) if terms else "true"
if form == "E": # ⋀ ¬occ_j over profiles with S and P
terms = [f"not {_atom(j)}" for j in profiles if s_idx in j and p_idx in j]
return " and ".join(terms) if terms else "true"
if form == "I": # occ_j over profiles with S and P
terms = [_atom(j) for j in profiles if s_idx in j and p_idx in j]
return " or ".join(terms) if terms else "false"
if form == "O": # occ_j over profiles with S and not P
terms = [_atom(j) for j in profiles if s_idx in j and p_idx not in j]
return " or ".join(terms) if terms else "false"
raise CategoricalError(f"unsupported categorical form: {form!r}") # pragma: no cover
def _term_index(terms: list[str], name: str) -> int:
try:
return terms.index(name)
except ValueError as exc:
raise CategoricalError(f"proposition names unknown term: {name!r}") from exc
def _check_proposition(raw: object) -> tuple[str, str, str]:
if not isinstance(raw, dict):
raise CategoricalError(f"proposition must be an object: {raw!r}")
form = raw.get("form")
subject = raw.get("subject")
predicate = raw.get("predicate")
if form not in _FORMS:
raise CategoricalError(f"unsupported categorical form: {form!r}")
if not isinstance(subject, str) or not isinstance(predicate, str):
raise CategoricalError(f"proposition subject/predicate must be strings: {raw!r}")
if subject == predicate:
raise CategoricalError("subject and predicate must be distinct")
return form, subject, predicate
def lower_syllogism(
structure: dict, query: dict
) -> tuple[tuple[str, ...], str]:
"""Lower a categorical ``(structure, query)`` into ``(premises, query)``
propositional formula strings over occupancy atoms. Raises
:class:`CategoricalError` on malformed input."""
raw_terms = structure.get("terms")
if not isinstance(raw_terms, list) or len(raw_terms) < 2:
raise CategoricalError("terms must be a list of at least two term names")
terms = [t for t in raw_terms]
if any(not isinstance(t, str) or not t for t in terms):
raise CategoricalError("every term must be a non-empty string")
if len(set(terms)) != len(terms):
raise CategoricalError("terms contains duplicates")
raw_premises = structure.get("premises")
if not isinstance(raw_premises, list) or not raw_premises:
raise CategoricalError("premises must be a non-empty list")
if query.get("kind") != "validity":
raise CategoricalError(f"unsupported query kind: {query.get('kind')!r}")
profiles = _profiles(len(terms))
premise_formulas: list[str] = []
for raw in raw_premises:
form, subject, predicate = _check_proposition(raw)
premise_formulas.append(
_lower_proposition(
form, _term_index(terms, subject), _term_index(terms, predicate), profiles
)
)
c_form, c_subject, c_predicate = _check_proposition(query.get("conclusion"))
conclusion_formula = _lower_proposition(
c_form, _term_index(terms, c_subject), _term_index(terms, c_predicate), profiles
)
return tuple(premise_formulas), conclusion_formula
def decide_syllogism(structure: dict, query: dict) -> EntailmentTrace:
"""Decide a categorical validity query via the verified ROBDD engine.
Returns the underlying :class:`EntailmentTrace`. Map to a categorical
reading: ``ENTAILED`` ⇒ the syllogism is VALID (the conclusion holds in
every model of the premises); ``UNKNOWN``/``REFUTED`` ⇒ INVALID (the
conclusion is not forced); ``REFUSED`` ⇒ the premises are inconsistent
(no model) or the input was malformed.
"""
premises, conclusion = lower_syllogism(structure, query)
return evaluate_entailment_with_trace(premises, conclusion)
def syllogism_is_valid(structure: dict, query: dict) -> bool:
"""True iff the categorical argument is VALID (conclusion entailed)."""
return decide_syllogism(structure, query).outcome is Entailment.ENTAILED
__all__ = [
"CategoricalError",
"decide_syllogism",
"lower_syllogism",
"syllogism_is_valid",
]