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.
167 lines
7.1 KiB
Python
167 lines
7.1 KiB
Python
"""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",
|
||
]
|