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.
This commit is contained in:
Shay 2026-07-23 13:17:35 -07:00
parent c98f1e07b2
commit fdfb71f59b
16 changed files with 576 additions and 81 deletions

View file

@ -7,6 +7,13 @@
"t2_verified": 0,
"wrong": 0
},
"categorical": {
"correct": 720,
"refused": 0,
"t2_agrees_gold": 0,
"t2_verified": 0,
"wrong": 0
},
"conditional_chain": {
"correct": 720,
"refused": 0,
@ -29,7 +36,7 @@
"wrong": 0
}
},
"content_sha256": "baf8d985deedc583ca52aa84296c270894598a31481dbff37f15287de339f9e6",
"content_sha256": "08c31fbaec5dcf4981a5d23dd5df65da9625dcb23a4017450405e5ba517a1530",
"note": "Sealed-practice committed ledger for deduction serving (ADR-0256). Engine reads, never writes. Ceilings stay at safe defaults (theta_SERVE=0.99). A band earns SERVE by demonstrated pipeline reliability (reader+projector+engine) at volume >= 657 committed.",
"provenance": "evals.deduction_serve.practice.runner.seal_ledger",
"schema": "deduction_serve_ledger_v1"

View file

@ -1,4 +1,4 @@
"""chat/deduction_surface.py — Phase 1 DEDUCTION composer (deduction-serve arc).
"""chat/deduction_surface.py — DEDUCTION composer (deduction-serve arc).
Wires the verified propositional-entailment engine (``generate.proof_chain``,
716/716 wrong=0 on ``evals/deductive_logic``) into serving. When a prompt
@ -8,18 +8,19 @@ strings, and decide with the sound+complete ROBDD entailment engine.
Deterministic templates only (``generate.proof_chain.render``) no LLM, no
synthesis, matching every other composer in this package.
Band v1 scope (docs/research/deduction-serve-arc-phase0-baseline-2026-07-23.md):
propositional arguments with single-token atoms only. A categorical/syllogism
"Therefore" argument is recognized as argument-shaped but declined as
out-of-band a production categorical decider is Band v1b, deferred.
``evals.syllogism.oracle`` must never be imported here: it is the sealed
independence oracle the comprehension lane scores against, not a serving
decider importing it would collapse INV-25 (independent gold).
Bands (docs/research/deduction-serve-arc-phase0-baseline-2026-07-23.md):
- Band v1 propositional arguments with single-token atoms.
- Band v1b (Phase 4) categorical/syllogism arguments ("All M are P. All S are
M. Therefore all S are P."), projected by ``to_syllogism`` and decided by
``generate.proof_chain.categorical`` (a propositional-lowering decider that
rides the same verified ROBDD engine). ``evals.syllogism.oracle`` must never
be imported here: it is the sealed independence oracle the comprehension lane
scores against, not a serving decider importing it would collapse INV-25
(independent gold).
Fail-closed (INV-34): once ``looks_like_deductive_argument`` fires, every
path below returns a committed, honest surface reader refusal, out-of-band
shape, and all four ``EntailmentTrace`` outcomes (ENTAILED/REFUTED/UNKNOWN/
REFUSED) never a silent fall-through to a different composer.
Fail-closed (INV-34): once ``looks_like_deductive_argument`` fires, every path
below returns a committed, honest surface reader refusal, out-of-band shape,
and every decision outcome never a silent fall-through to a different composer.
"""
from __future__ import annotations
@ -30,11 +31,12 @@ from typing import Callable
from chat.deduction_serve_license import deduction_serve_license
from core.reliability_gate import LicenseDecision
from generate.meaning_graph.projectors import to_deductive_logic
from generate.meaning_graph.projectors import to_deductive_logic, to_syllogism
from generate.meaning_graph.reader import Comprehension, comprehend
from generate.proof_chain.categorical import CategoricalError, decide_syllogism
from generate.proof_chain.entail import evaluate_entailment_with_trace
from generate.proof_chain.render import render_entailment
from generate.proof_chain.shape import classify_deduction_shape
from generate.proof_chain.render import render_entailment, render_syllogism
from generate.proof_chain.shape import CATEGORICAL, classify_deduction_shape
#: An argument's conclusion clause starts a sentence with "therefore" — the
#: same shape ``generate.meaning_graph.reader`` recognizes per-clause
@ -60,8 +62,8 @@ _READER_REFUSAL_SURFACE = (
"decide it yet ({reason})."
)
_OUT_OF_BAND_SURFACE = (
"That reads as an argument, but right now I can only decide plain "
"propositional arguments (not categorical 'all/no/some' ones yet)."
"That reads as an argument, but its form is outside what I can decide yet "
"(I handle plain propositional and categorical 'all/no/some' arguments)."
)
#: Disclosed hedge prepended when a decided argument's shape-band has NOT earned
@ -104,13 +106,33 @@ def deduction_grounded_surface(
comp = comprehend(text)
if not isinstance(comp, Comprehension):
return _READER_REFUSAL_SURFACE.format(reason=comp.reason)
# Band v1 — propositional argument.
projected = to_deductive_logic(comp)
if projected is None:
return _OUT_OF_BAND_SURFACE
premises, query = projected
trace = evaluate_entailment_with_trace(premises, query)
surface = render_entailment(trace, premises, query)
band = classify_deduction_shape(premises, query)
if projected is not None:
premises, query = projected
trace = evaluate_entailment_with_trace(premises, query)
surface = render_entailment(trace, premises, query)
return _license_gate(surface, classify_deduction_shape(premises, query), license_lookup)
# Band v1b — categorical / syllogism argument. Decided by the propositional-
# lowering categorical decider (rides the same verified ROBDD engine); a
# malformed categorical shape declines rather than guessing.
syllogism = to_syllogism(comp)
if syllogism is not None:
structure, s_query = syllogism
try:
trace = decide_syllogism(structure, s_query)
except CategoricalError:
return _OUT_OF_BAND_SURFACE
surface = render_syllogism(trace, structure, s_query)
return _license_gate(surface, CATEGORICAL, license_lookup)
return _OUT_OF_BAND_SURFACE
def _license_gate(surface: str, band: str, license_lookup: _LicenseLookup) -> str:
"""Serve *surface* authoritatively iff *band* holds a SERVE license; else
serve the same sound answer DISCLOSED (hedged). The one place the earned-
license posture is applied, shared by the propositional and categorical
bands (ADR-0256)."""
decision = license_lookup(band)
if decision is not None and decision.licensed:
return surface # earned SERVE — authoritative

View file

@ -176,6 +176,7 @@ TEST_SUITES: dict[str, tuple[str, ...]] = {
"tests/test_deductive_logic_entail.py",
"tests/test_deduction_serve_lane.py",
"tests/test_deduction_serve_license.py",
"tests/test_categorical_decider.py",
),
"full": ("tests/",),
}

View file

@ -23,7 +23,8 @@ The ROBDD engine is sound **and** complete over the propositional regime — it
Make deduction serving an earned capability gated by a committed, SHA-sealed reliability ledger.
- **Shape-band capability axis** (`generate/proof_chain/shape.py::classify_deduction_shape`) — a deterministic, structural classification of a projected `(premises, query)` argument into one of four exhaustive bands: `disjunctive`, `conditional_chain`, `conditional_single`, `atomic`. Computed identically by the practice arena and the serving composer (no drift). Coarse on purpose: each band mixes entailed/refuted/unknown gold, so a band's reliability measures decision correctness across outcomes, not just how many entailments pass through.
- **Shape-band capability axis** (`generate/proof_chain/shape.py`) — a deterministic, structural classification of an argument into one of five exhaustive bands: the four propositional bands `disjunctive` / `conditional_chain` / `conditional_single` / `atomic` (assigned by `classify_deduction_shape` over the projected `(premises, query)`), plus `categorical` (Band v1b, Phase 4 — assigned directly to any syllogism projected by `to_syllogism`). Computed identically by the practice arena and the serving composer (no drift). Coarse on purpose: each band mixes entailed/refuted/unknown (or valid/invalid) gold, so a band's reliability measures decision correctness across outcomes, not just how many entailments pass through.
- **Band v1b — production categorical decider** (`generate/proof_chain/categorical.py`) — the piece Phase 0's fork identified as missing. Serving cannot import `evals/syllogism/oracle.py` (the sealed independence oracle; INV-25), so this is a fresh production decider that lowers a categorical argument to the propositional regime (one Boolean atom per term-membership *profile*) and rides the already-verified ROBDD engine. Sound AND complete for the modern/Boolean reading; independent of the eval oracle by mechanism (ROBDD over a profile encoding vs. brute-force subset enumeration), verified by case-for-case agreement with it across the syllogism gold lane. Serving projects a syllogism via `to_syllogism` and decides it here (ENTAILED ⇒ valid; UNKNOWN/REFUTED ⇒ invalid; REFUSED ⇒ inconsistent).
- **Deduction practice arena** (`evals/deduction_serve/practice/`) — the **second** concrete instance of the ADR-0199 arena (GSM8K math is the first). A deterministic, synthetic, indexed corpus (`gold.py`, no clock/RNG) of propositional arguments per shape-band, each with **by-construction gold** (the generating template's logical form, independent of the reader — ADR-0199 L-2). `DeductionSolver` runs the exact serving pipeline; `ConstructionGoldTether` scores the committed outcome against the by-construction gold; a misread is caught as `wrong`. Sized to the SERVE volume floor (≥657 committed for a perfect record to clear θ_SERVE=0.99; `CASES_PER_BAND=720`).
- **Construction-time soundness check** (`gold.py::assert_corpus_sound`) — every generated case's by-construction gold is cross-checked against the **independent truth-table oracle** (`evals.deductive_logic.oracle`, INV-25 — shares no code with the ROBDD engine) before the ledger can seal. A mis-stated template gold can never silently inflate the ledger.
- **Sealed, SHA-verified committed ledger** (`chat/data/deduction_serve_ledger.json`, produced by `evals/deduction_serve/practice/runner.py::seal_ledger`) — the per-band `ClassTally`, self-sealed by `content_sha256`. The engine READS it; only the sealed-practice runner WRITES it. Mirrors the estimation ledger topology exactly (`generate/determine/data/estimation_ledger.json`).

View file

@ -0,0 +1,63 @@
# Deduction-serve arc — Phase 4 (Band v1b: categorical/syllogism serving), 2026-07-23
**Base:** `main` @ `6a54d27a`. **Branch:** `feat/deduction-serve-phase2` (stacked on Phase 13).
**ADR:** `docs/adr/ADR-0256` (updated — Band v1b now built).
**Depends on:** Phase 1 (composer), Phase 2 (lane), Phase 3 (arena + license).
## What shipped
`core chat` now decides **categorical syllogisms** end-to-end, closing the exact gap Phase 0's fork identified:
```
> All mammals are animals. All whales are mammals. Therefore all whales are animals.
Given: all mammal are animal; all whale are mammal. That's valid — all whale are animal follows.
> All cats are animals. All dogs are animals. Therefore all dogs are cats.
Given: all cat are animal; all dog are animal. That doesn't follow — all dog are cat isn't guaranteed by those premises.
```
The propositional bands (Phase 13) are unchanged. This is the marquee "basic logical work" widening — the Aristotelian syllogism.
## The Phase-0 fork, resolved
Phase 0 found 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). Building a **production** decider was the fork's deferred half. Resolution: **`generate/proof_chain/categorical.py`** — a fresh decider that lowers a categorical argument to the propositional regime and rides the already-verified ROBDD engine. No new decision procedure; soundness inherits from the flagship engine.
### Why the lowering is sound AND complete (not "big enough domain")
A k-term categorical argument's model is fully determined by which of the `2^k` term-membership *profiles* are occupied. One Boolean atom `occ_j` per profile, and A/E/I/O become propositional formulas over those atoms (all S are P ≡ ⋀_{profiles with S but not P} ¬occ_j, etc.). Validity is then exactly propositional entailment — decided by the ROBDD engine. Because the profile atoms range over *all* occupancy patterns, this is sound and complete for the modern/Boolean reading (universals without existential import), with no reliance on a lucky domain size. Darapti and the other existential-import-only forms are correctly ruled INVALID.
### Independence (INV-25) preserved and proven
The decider shares no code with `evals/syllogism/oracle.py`. Their agreement — **case-for-case across the entire syllogism gold lane, and with the committed gold** — is the soundness evidence: two disjoint mechanisms (ROBDD-over-profiles vs. brute-force finite-model enumeration) converging (`tests/test_categorical_decider.py`).
## New / changed files
- **`generate/proof_chain/categorical.py`** (new) — the profile-lowering categorical decider (`decide_syllogism`, `lower_syllogism`, `syllogism_is_valid`); malformed input refuses (`CategoricalError`).
- **`generate/proof_chain/shape.py`** — `CATEGORICAL` band added (5 bands total).
- **`generate/proof_chain/render.py`** — `render_syllogism` (deterministic valid/invalid/inconsistent templates over the argument's own clauses).
- **`chat/deduction_surface.py`** — categorical branch (Band v1b): propositional path first, then `to_syllogism → decide_syllogism → render_syllogism`, license-gated through the shared `_license_gate` helper. Propositional behavior byte-identical.
- **`evals/deduction_serve/practice/gold.py`** — categorical band added to the arena: syllogism templates (4 valid Aristotelian forms + 2 invalid) with by-construction gold cross-checked against the independent syllogism oracle; `DeductionSolver` gains the dual (propositional + categorical) path, in lock-step with the composer.
- **`chat/data/deduction_serve_ledger.json`** — re-sealed: 5 bands, `categorical` earns SERVE at reliability 0.99087, wrong=0.
- **`evals/deduction_serve/runner.py`** + **`v1/cases.jsonl`** — the Phase-2 lane's `decide()` mirrors the composer's dual path; the 3 categorical cases (previously gold `declined`) reclassified to their true `valid`/`invalid` verdicts + 1 invalid case added (n: 27 → 28). Report + SHA pin regenerated (`b23234b4…`).
- **`tests/test_categorical_decider.py`** (new, 8 tests) + updates to `test_deduction_surface.py` / `test_deduction_serve_lane.py`.
## Honest wrinkles
- **Irregular plurals decline.** "All birds are animals. All **fish** are animals…" declines with `unknown_morphology` — the reader can't singularize "fish". Correctly a decline, not a wrong; a reader-morphology limit, documented, not worked around (the invalid-case corpus uses regular plurals like cats/dogs).
- **Surfaces read in singularized terms** ("all whale are animal") — grammatically rough but honest: those are the exact class ids the engine reasoned over. The deterministic-template honesty bar (no LLM) means no cosmetic re-pluralization that could drift from what was decided.
## Verification
```
uv run python -m pytest tests/test_categorical_decider.py -q # 8 passed
uv run python -m evals.deduction_serve.practice.runner # 5 bands all SERVE, wrong=0
uv run python -m evals.deduction_serve.runner # 28/28, wrong=0
uv run core test --suite deductive -q # 45 passed
uv run core test --suite smoke -q # 180 passed
uv run core test --suite cognition -q # 122 passed, 1 skipped
# deduction_serve lane SHA regenerates to the committed pin b23234b4…
```
## Verdict
Phase 4 complete. Categorical syllogism serving is live, sound+complete, independent-of-the-eval-oracle, earned through the same license machinery as the propositional bands. The serving band now spans propositional arguments and Aristotelian syllogisms — the core of "basic logical work". Remaining Phase-4 ideas (multi-word English proposition reader relaxation; multi-step proof recap) are documented as scoped-out in Phase 5, deferred deliberately rather than rushed against wrong=0.

View file

@ -30,11 +30,13 @@ from dataclasses import dataclass, field
from typing import Any
from core.learning_arena.protocols import Problem
from generate.meaning_graph.projectors import to_deductive_logic
from generate.meaning_graph.projectors import to_deductive_logic, to_syllogism
from generate.meaning_graph.reader import Comprehension, comprehend
from generate.proof_chain.categorical import CategoricalError, decide_syllogism
from generate.proof_chain.entail import Entailment, evaluate_entailment_with_trace
from generate.proof_chain.shape import (
ATOMIC,
CATEGORICAL,
CONDITIONAL_CHAIN,
CONDITIONAL_SINGLE,
DISJUNCTIVE,
@ -88,6 +90,25 @@ _TEMPLATES: dict[str, tuple[tuple[str, Any], ...]] = {
# (a bare atom either restates or contradicts); the band is 2/3 the size
# of the others, still well above the volume floor.
),
# Categorical (syllogism). Terms are synthetic PLURAL class nouns (``x3s`` →
# the reader singularizes to ``x3``); ``a, b, c`` are the three distinct
# middle/subject/predicate classes. Gold is the unconditional validity (modern
# reading) of the standard form — cross-checked against the INDEPENDENT
# syllogism oracle in ``assert_corpus_sound``.
CATEGORICAL: (
# Barbara (AAA) — valid.
("valid", lambda a, b, c: f"All {a}s are {b}s. All {c}s are {a}s. Therefore all {c}s are {b}s."),
# Celarent (EAE) — valid.
("valid", lambda a, b, c: f"No {a}s are {b}s. All {c}s are {a}s. Therefore no {c}s are {b}s."),
# Darii (AII) — valid.
("valid", lambda a, b, c: f"All {a}s are {b}s. Some {c}s are {a}s. Therefore some {c}s are {b}s."),
# Ferio (EIO) — valid.
("valid", lambda a, b, c: f"No {a}s are {b}s. Some {c}s are {a}s. Therefore some {c}s are not {b}s."),
# Undistributed middle (AAA-2) — INVALID.
("invalid", lambda a, b, c: f"All {b}s are {a}s. All {c}s are {a}s. Therefore all {c}s are {b}s."),
# Existential-import overreach — INVALID in the modern reading.
("invalid", lambda a, b, c: f"All {a}s are {b}s. All {a}s are {c}s. Therefore some {c}s are {b}s."),
),
}
@ -117,7 +138,7 @@ def generate_problems(band: str, n: int) -> list[Problem]:
def all_gold_problems() -> list[Problem]:
"""The full deterministic corpus over every shape-band, in band order."""
problems: list[Problem] = []
for band in (CONDITIONAL_SINGLE, CONDITIONAL_CHAIN, DISJUNCTIVE, ATOMIC):
for band in (CONDITIONAL_SINGLE, CONDITIONAL_CHAIN, DISJUNCTIVE, ATOMIC, CATEGORICAL):
problems.extend(generate_problems(band, CASES_PER_BAND))
return problems
@ -143,15 +164,26 @@ _OUTCOME_TO_CLASS = {
Entailment.REFUSED: "declined",
}
#: Categorical outcome mapping: a syllogism is VALID iff the conclusion is
#: entailed; UNKNOWN/REFUTED ⇒ invalid; REFUSED ⇒ declined (inconsistent).
_CATEGORICAL_TO_CLASS = {
Entailment.ENTAILED: "valid",
Entailment.REFUTED: "invalid",
Entailment.UNKNOWN: "invalid",
Entailment.REFUSED: "declined",
}
@dataclass(frozen=True, slots=True)
class DeductionSolver:
"""The production serving pipeline as a ``DomainSolver``.
Runs exactly what ``chat/deduction_surface.py`` runs ``comprehend``
``to_deductive_logic`` ``evaluate_entailment_with_trace`` and commits
the decided outcome class. A reader refusal / non-projectable comprehension
is an uncommitted attempt (``refused``), never a guessed answer.
Runs exactly what ``chat/deduction_surface.py`` runs the propositional
path (``to_deductive_logic`` ``evaluate_entailment_with_trace``) then the
categorical path (``to_syllogism`` ``decide_syllogism``) and commits the
decided outcome class. A reader refusal / non-projectable comprehension /
engine REFUSED is an uncommitted attempt, never a guessed answer. Kept in
lock-step with the composer's dual path; the lane + license tests cover both.
"""
domain_id: str = _DOMAIN_ID
@ -164,21 +196,35 @@ class DeductionSolver:
committed=False, answer=None, reason=f"reader:{getattr(comp, 'reason', '')}",
case_id=problem.problem_id, shape=problem.class_name,
)
# Band v1 — propositional.
projected = to_deductive_logic(comp)
if projected is None:
if projected is not None:
premises, query = projected
outcome = evaluate_entailment_with_trace(premises, query).outcome
return _DeductionAttempt(
committed=False, answer=None, reason="unprojectable",
case_id=problem.problem_id, shape=problem.class_name,
committed=outcome is not Entailment.REFUSED,
answer=_OUTCOME_TO_CLASS[outcome], reason="",
case_id=problem.problem_id, shape=classify_deduction_shape(premises, query),
)
# Band v1b — categorical / syllogism.
syllogism = to_syllogism(comp)
if syllogism is not None:
structure, s_query = syllogism
try:
outcome = decide_syllogism(structure, s_query).outcome
except CategoricalError:
return _DeductionAttempt(
committed=False, answer=None, reason="categorical_malformed",
case_id=problem.problem_id, shape=CATEGORICAL,
)
return _DeductionAttempt(
committed=outcome is not Entailment.REFUSED,
answer=_CATEGORICAL_TO_CLASS[outcome], reason="",
case_id=problem.problem_id, shape=CATEGORICAL,
)
premises, query = projected
outcome = evaluate_entailment_with_trace(premises, query).outcome
answer = _OUTCOME_TO_CLASS[outcome]
# An engine REFUSED (inconsistent premises / out-of-regime) is an honest
# non-commitment, not a served verdict.
committed = outcome is not Entailment.REFUSED
return _DeductionAttempt(
committed=committed, answer=answer, reason="",
case_id=problem.problem_id, shape=classify_deduction_shape(premises, query),
committed=False, answer=None, reason="unprojectable",
case_id=problem.problem_id, shape=problem.class_name,
)
@ -202,24 +248,37 @@ class ConstructionGoldTether:
def assert_corpus_sound() -> None:
"""Belt-and-suspenders: every generated case's by-construction gold must
agree with the INDEPENDENT truth-table oracle over its projected form.
agree with an INDEPENDENT oracle over its projected form.
Guards against a template authoring bug (a mis-stated gold) silently
inflating the ledger. Raises ``AssertionError`` on any disagreement. Uses
the oracle (``evals.deductive_logic.oracle``), which shares no code with the
ROBDD serving engine (INV-25), as the construction-time check.
oracles that share no code with the ROBDD serving engine (INV-25): the
truth-table ``evals.deductive_logic.oracle`` for propositional bands, and
the finite-model ``evals.syllogism.oracle`` for the categorical band.
"""
from evals.deductive_logic.oracle import oracle_entailment
from evals.syllogism.oracle import oracle_answer
for problem in all_gold_problems():
comp = comprehend(problem.payload["text"])
assert isinstance(comp, Comprehension), problem.payload["text"]
gold = problem.payload["gold"]
projected = to_deductive_logic(comp)
assert projected is not None, problem.payload["text"]
premises, query = projected
oracle = oracle_entailment(premises, query)
assert oracle == problem.payload["gold"], (
f"{problem.problem_id}: oracle={oracle} gold={problem.payload['gold']} "
if projected is not None:
premises, query = projected
oracle = oracle_entailment(premises, query)
assert oracle == gold, (
f"{problem.problem_id}: oracle={oracle} gold={gold} "
f"text={problem.payload['text']!r}"
)
continue
syllogism = to_syllogism(comp)
assert syllogism is not None, problem.payload["text"]
structure, s_query = syllogism
oracle_valid = oracle_answer(structure, s_query)["valid"]
oracle_class = "valid" if oracle_valid else "invalid"
assert oracle_class == gold, (
f"{problem.problem_id}: syllogism_oracle={oracle_class} gold={gold} "
f"text={problem.payload['text']!r}"
)

View file

@ -1,8 +1,8 @@
{
"aggregate": {
"correct": 27,
"correct": 28,
"declined": 0,
"n": 27,
"n": 28,
"wrong": 0
},
"all_correct": true,
@ -13,23 +13,27 @@
"v1": {
"all_cases_correct": true,
"by_gold": {
"declined": 9,
"declined": 6,
"entailed": 8,
"invalid": 1,
"refuted": 5,
"unknown": 5
"unknown": 5,
"valid": 3
},
"correct_by_gold": {
"declined": 9,
"declined": 6,
"entailed": 8,
"invalid": 1,
"refuted": 5,
"unknown": 5
"unknown": 5,
"valid": 3
},
"counts": {
"correct": 27,
"correct": 28,
"declined": 0,
"wrong": 0
},
"n": 27
"n": 28
}
},
"wrong_is_zero": true

View file

@ -42,8 +42,9 @@ from collections import Counter
from pathlib import Path
from chat.deduction_surface import looks_like_deductive_argument
from generate.meaning_graph.projectors import to_deductive_logic
from generate.meaning_graph.projectors import to_deductive_logic, to_syllogism
from generate.meaning_graph.reader import Comprehension, comprehend
from generate.proof_chain.categorical import CategoricalError, decide_syllogism
from generate.proof_chain.entail import Entailment, evaluate_entailment_with_trace
_ROOT = Path(__file__).resolve().parent
@ -59,6 +60,14 @@ _OUTCOME_TO_CLASS = {
Entailment.REFUSED: "declined",
}
#: Categorical outcome → verdict class (matches the composer / arena mapping).
_CATEGORICAL_TO_CLASS = {
Entailment.ENTAILED: "valid",
Entailment.REFUTED: "invalid",
Entailment.UNKNOWN: "invalid",
Entailment.REFUSED: "declined",
}
def _load(path: Path) -> list[dict]:
with path.open(encoding="utf-8") as fh:
@ -66,13 +75,14 @@ def _load(path: Path) -> list[dict]:
def decide(text: str) -> str:
"""Run the exact pipeline ``chat/deduction_surface.py`` runs in
serving and return the outcome class: entailed/refuted/unknown/declined.
"""Run the exact decision pipeline ``chat/deduction_surface.py`` runs in
serving and return the outcome class: entailed/refuted/unknown (propositional),
valid/invalid (categorical), or declined.
Mirrors ``deduction_grounded_surface`` call-for-call up to (but not
including) the prose render the same production decision, typed
instead of rendered, so this lane's assertions are robust to wording
changes.
including) the prose render and the license gate the same production
decision, typed instead of rendered, so this lane's assertions are robust
to wording changes.
"""
if not looks_like_deductive_argument(text):
return "declined"
@ -80,11 +90,17 @@ def decide(text: str) -> str:
if not isinstance(comp, Comprehension):
return "declined"
projected = to_deductive_logic(comp)
if projected is None:
return "declined"
premises, query = projected
trace = evaluate_entailment_with_trace(premises, query)
return _OUTCOME_TO_CLASS[trace.outcome]
if projected is not None:
premises, query = projected
return _OUTCOME_TO_CLASS[evaluate_entailment_with_trace(premises, query).outcome]
syllogism = to_syllogism(comp)
if syllogism is not None:
structure, s_query = syllogism
try:
return _CATEGORICAL_TO_CLASS[decide_syllogism(structure, s_query).outcome]
except CategoricalError:
return "declined"
return "declined"
def build_report(cases: list[dict]) -> dict:

View file

@ -20,8 +20,9 @@
{"id": "ds-v1-0020", "text": "If p then q. p. Not q. Therefore r.", "gold": "declined", "class": "inconsistent_premises"}
{"id": "ds-v1-0021", "text": "p. Not p. Therefore p.", "gold": "declined", "class": "inconsistent_premises"}
{"id": "ds-v1-0022", "text": "p or q. Not p. Not q. Therefore p.", "gold": "declined", "class": "inconsistent_premises"}
{"id": "ds-v1-0023", "text": "All mammals are animals. All whales are mammals. Therefore all whales are animals.", "gold": "declined", "class": "out_of_band_categorical"}
{"id": "ds-v1-0024", "text": "No reptiles are mammals. All snakes are reptiles. Therefore no snakes are mammals.", "gold": "declined", "class": "out_of_band_categorical"}
{"id": "ds-v1-0023", "text": "All mammals are animals. All whales are mammals. Therefore all whales are animals.", "gold": "valid", "class": "categorical_barbara"}
{"id": "ds-v1-0024", "text": "No reptiles are mammals. All snakes are reptiles. Therefore no snakes are mammals.", "gold": "valid", "class": "categorical_celarent"}
{"id": "ds-v1-0025", "text": "If it rains then the ground is wet. It rains. Therefore the ground is wet.", "gold": "declined", "class": "out_of_band_multiword_conditional"}
{"id": "ds-v1-0026", "text": "Some students are poets. All poets are artists. Therefore some students are artists.", "gold": "declined", "class": "out_of_band_categorical"}
{"id": "ds-v1-0026", "text": "Some students are poets. All poets are artists. Therefore some students are artists.", "gold": "valid", "class": "categorical_datisi"}
{"id": "ds-v1-0028", "text": "All cats are animals. All dogs are animals. Therefore all dogs are cats.", "gold": "invalid", "class": "categorical_undistributed_middle"}
{"id": "ds-v1-0027", "text": "If p then q. If q then r. If r then s. p. Therefore s.", "gold": "entailed", "class": "three_hop_chain"}

View file

@ -0,0 +1,167 @@
"""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: Sj, Pj} ¬occ_j (no S-but-not-P element exists)
E no S are P _{j: Sj, Pj} ¬occ_j
I some S are P _{j: Sj, Pj} occ_j
O some S are not P _{j: Sj, Pj} 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",
]

View file

@ -17,6 +17,14 @@ from generate.proof_chain.entail import (
EntailmentTrace,
)
#: A/E/I/O categorical form → an English sentence template over (subject, predicate).
_CATEGORICAL_PHRASE = {
"A": "all {s} are {p}",
"E": "no {s} are {p}",
"I": "some {s} are {p}",
"O": "some {s} are not {p}",
}
def render_entailment(
trace: EntailmentTrace, premises: tuple[str, ...], query: str
@ -51,4 +59,37 @@ def render_entailment(
return f"Given: {given}. I can't evaluate {query} from that as stated."
__all__ = ["render_entailment"]
def _categorical_clause(prop: dict) -> str:
"""One categorical proposition dict → its English clause."""
template = _CATEGORICAL_PHRASE.get(prop.get("form", ""), "{s} ~ {p}")
return template.format(s=prop.get("subject", "?"), p=prop.get("predicate", "?"))
def render_syllogism(trace: EntailmentTrace, structure: dict, query: dict) -> str:
"""The user-facing surface for a categorical (syllogism) verdict.
Deterministic templates over the argument's own clauses — no synthesis. The
``EntailmentTrace`` is the propositional-lowering decision (``ENTAILED``
valid; ``UNKNOWN``/``REFUTED`` invalid; ``REFUSED`` inconsistent).
"""
premises = "; ".join(
_categorical_clause(p) for p in structure.get("premises", [])
)
conclusion = _categorical_clause(query.get("conclusion", {}))
if trace.outcome is Entailment.ENTAILED:
return f"Given: {premises}. That's valid — {conclusion} follows."
if trace.outcome is Entailment.REFUSED and trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {premises}. Those premises are inconsistent — they can't all "
f"be true, so I won't assert anything from them."
)
if trace.outcome is Entailment.REFUSED:
return f"Given: {premises}. I can't evaluate {conclusion} from that as stated."
# UNKNOWN or REFUTED — the conclusion is not forced by the premises.
return (
f"Given: {premises}. That doesn't follow — {conclusion} isn't guaranteed "
f"by those premises."
)
__all__ = ["render_entailment", "render_syllogism"]

View file

@ -27,12 +27,20 @@ DISJUNCTIVE = "disjunctive"
CONDITIONAL_CHAIN = "conditional_chain"
CONDITIONAL_SINGLE = "conditional_single"
ATOMIC = "atomic"
#: Band v1b (Phase 4) — categorical/syllogism arguments. Distinct from the four
#: propositional bands: a categorical argument is projected by ``to_syllogism``
#: (not ``to_deductive_logic``) and decided by ``generate.proof_chain.categorical``
#: (the propositional-lowering decider), so the serving composer assigns this band
#: directly rather than by ``classify_deduction_shape`` (which reads propositional
#: formula strings).
CATEGORICAL = "categorical"
SHAPE_BANDS: tuple[str, ...] = (
DISJUNCTIVE,
CONDITIONAL_CHAIN,
CONDITIONAL_SINGLE,
ATOMIC,
CATEGORICAL,
)
@ -62,6 +70,7 @@ def classify_deduction_shape(premises: tuple[str, ...], query: str) -> str:
__all__ = [
"ATOMIC",
"CATEGORICAL",
"CONDITIONAL_CHAIN",
"CONDITIONAL_SINGLE",
"DISJUNCTIVE",

View file

@ -51,7 +51,7 @@ PINNED_SHAS: dict[str, str] = {
"public_demo": "7d8ba0dbae9287cfe0bf15d231fa78a75abc627121c14900439293e01e1cc1d3",
"math_teaching_corpus_v1": "eaf160d145da29f9050ede8d58bf111b0f651dd40aeae9201857d0b97e014dd4",
"deductive_logic_v1": "97a230949016e38d5e3f37a69e4245b320575ee70e5af92ff7607f7b05f74b5f",
"deduction_serve_v1": "8fd86265b075be060b72f0d7b3677737de38268ee57b9bcfac30a7f6296d55eb",
"deduction_serve_v1": "b23234b4b57d92e3df9daa3b5d4bbf7fdc505355e32c1440af321c2e1f86bdd2",
}

View file

@ -0,0 +1,90 @@
"""Deduction-serve arc, Phase 4 / Band v1b (ADR-0256) — categorical decider.
The production categorical decider (`generate/proof_chain/categorical.py`) lowers
a syllogism to the propositional regime and rides the verified ROBDD engine.
These tests pin:
- it agrees, case-for-case, with the INDEPENDENT finite-model syllogism oracle
(`evals/syllogism/oracle.py`) across the committed gold lane two disjoint
mechanisms converging is the soundness evidence (the decider never imports
the oracle; INV-25 independence);
- the modern/Boolean reading (universals lack existential import): valid forms
are valid, existential-import-only forms are INVALID;
- inconsistent premises and malformed input are refused, never guessed.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from evals.syllogism.oracle import oracle_answer
from generate.proof_chain.categorical import (
CategoricalError,
decide_syllogism,
syllogism_is_valid,
)
from generate.proof_chain.entail import Entailment
_GOLD = Path(__file__).resolve().parents[1] / "evals" / "syllogism" / "v1" / "cases.jsonl"
def _gold_cases() -> list[dict]:
return [json.loads(l) for l in _GOLD.read_text(encoding="utf-8").splitlines() if l.strip()]
def test_agrees_with_independent_oracle_on_gold_lane() -> None:
"""Case-for-case agreement with the finite-model oracle AND the committed
gold the decider shares no code with either (independent convergence)."""
cases = _gold_cases()
assert cases, "syllogism gold lane must not be empty"
for c in cases:
mine = syllogism_is_valid(c["structure"], c["query"])
oracle = oracle_answer(c["structure"], c["query"])["valid"]
gold = c["gold"]["valid"]
assert mine == oracle == gold, (
f"{c['id']}: decider={mine} oracle={oracle} gold={gold} text={c['text']!r}"
)
def test_barbara_valid() -> None:
s = {"terms": ["s", "m", "p"], "premises": [
{"form": "A", "subject": "m", "predicate": "p"},
{"form": "A", "subject": "s", "predicate": "m"}]}
q = {"kind": "validity", "conclusion": {"form": "A", "subject": "s", "predicate": "p"}}
assert syllogism_is_valid(s, q) is True
def test_darapti_invalid_modern_reading() -> None:
"""Darapti (all M are P, all M are S, therefore some S are P) is valid only
with existential import; in the modern reading it is INVALID (M may be empty)."""
s = {"terms": ["s", "m", "p"], "premises": [
{"form": "A", "subject": "m", "predicate": "p"},
{"form": "A", "subject": "m", "predicate": "s"}]}
q = {"kind": "validity", "conclusion": {"form": "I", "subject": "s", "predicate": "p"}}
assert syllogism_is_valid(s, q) is False
assert decide_syllogism(s, q).outcome is Entailment.UNKNOWN
def test_inconsistent_premises_refused() -> None:
s = {"terms": ["s", "p"], "premises": [
{"form": "A", "subject": "s", "predicate": "p"},
{"form": "E", "subject": "s", "predicate": "p"},
{"form": "I", "subject": "s", "predicate": "p"}]}
q = {"kind": "validity", "conclusion": {"form": "A", "subject": "p", "predicate": "s"}}
assert decide_syllogism(s, q).outcome is Entailment.REFUSED
@pytest.mark.parametrize("bad_structure,bad_query", [
({"terms": ["a"], "premises": [{"form": "A", "subject": "a", "predicate": "b"}]},
{"kind": "validity", "conclusion": {"form": "A", "subject": "a", "predicate": "b"}}),
({"terms": ["a", "b"], "premises": [{"form": "Z", "subject": "a", "predicate": "b"}]},
{"kind": "validity", "conclusion": {"form": "A", "subject": "a", "predicate": "b"}}),
({"terms": ["a", "b"], "premises": [{"form": "A", "subject": "a", "predicate": "b"}]},
{"kind": "sort", "conclusion": {"form": "A", "subject": "a", "predicate": "b"}}),
])
def test_malformed_input_refused(bad_structure, bad_query) -> None:
with pytest.raises(CategoricalError):
decide_syllogism(bad_structure, bad_query)

View file

@ -16,15 +16,18 @@ _V1 = _ROOT / "v1" / "cases.jsonl"
def test_v1_lane_wrong_is_zero() -> None:
report = build_report(_load(_V1))
assert report["n"] == 27
assert report["n"] == 28
assert report["counts"]["wrong"] == 0
assert report["all_cases_correct"] is True
# the sizeable, honest signal: non-trivial committed classes covered
# the sizeable, honest signal: non-trivial committed classes covered,
# including the categorical band (valid + invalid) added in Phase 4.
cbg = report["correct_by_gold"]
assert cbg.get("entailed", 0) >= 5
assert cbg.get("refuted", 0) >= 5
assert cbg.get("unknown", 0) >= 5
assert cbg.get("declined", 0) >= 5
assert cbg.get("valid", 0) >= 3
assert cbg.get("invalid", 0) >= 1
def test_runner_treats_wrong_verdict_as_the_only_real_failure() -> None:

View file

@ -85,16 +85,27 @@ def test_inconsistent_premises_renders_typed_decline() -> None:
assert "inconsistent" in surface
def test_categorical_argument_declines_out_of_band() -> None:
"""A syllogism-shaped "therefore" argument commits (argument-shaped)
but is honestly declined Band v1 is propositional-only; the
production categorical decider is deferred (Band v1b)."""
def test_categorical_argument_is_decided_band_v1b() -> None:
"""A valid categorical syllogism is now DECIDED (Band v1b, Phase 4) — the
production categorical decider serves a validity verdict, no longer a
decline."""
surface = deduction_grounded_surface(
"All mammals are animals. All whales are mammals. "
"Therefore all whales are animals."
)
assert surface is not None
assert "categorical" in surface
assert "valid" in surface
assert "follows" in surface
def test_invalid_categorical_argument_is_rejected() -> None:
"""An invalid syllogism (undistributed middle) is decided INVALID, not
declined and not falsely served as valid (wrong=0 on the categorical band)."""
surface = deduction_grounded_surface(
"All cats are animals. All dogs are animals. Therefore all dogs are cats."
)
assert surface is not None
assert "doesn't follow" in surface
def test_multiword_conditional_declines_reader_refusal() -> None: