feat(generalization): curriculum-grounded serving — exams answered from ratified curriculum (ADR-0262)

Phase 2 of the generalization arc, implementing the plan's §4
curriculum-entailment gold contract. "Does force cause acceleration?" is
answered from the ratified physics chain corpus and nothing else; "Does
gravity cause acceleration?" is declined because gravity is in no pack
CORE has been taught; "Does force cause motion?" is unsettled even though
the curriculum contains both links, because nothing ratified says
causation composes.

Path (flag-gated, default off): closed question grammar -> subject routing
by ratified vocabulary -> family-scoped premise compilation from reviewed,
pack-resident chains -> the SAME argument bands (ADR-0260/0261) -> the
ROBDD engine. Zero subject-specific decision code: physics differs from
philosophy only in which rows load.

Epistemology enforced mechanically, not by convention:
- gold is a function of (curriculum, question); cases pin chain ids and
  the runner FAILS a case whose pinned chain is absent or unratified;
- untaught => UNKNOWN, never "no" (open-world; silence is not denial);
- an independent oracle (own loader, ratification predicate, family table,
  agreement rules, verdict rule) re-derives every gold — it disagreed once,
  on "entropy reveals energy" vs "entropy causes energy", and the ORACLE
  was the side that was wrong;
- anti-recall probes are a lane GUARD: a split without >=3 true-but-untaught
  probes refuses to run.

Findings recorded rather than worked around (ADR-0262 §5):
- every curriculum band is UNEARNED and every answer is hedged. A band needs
  n>=657 with a real outcome mix; physics teaches 7 causal + 9 modal
  relations, so at most 16 questions in the subject can ever be ENTAILED.
  A balanced band needs ~219 taught relations per subject×family — a ~25x
  gap that only ratified curriculum content closes. Phase 2's blocker is
  curriculum volume, not machinery.
- REFUTED is unreachable from present corpora (every chain is positive).
- there is NO biology domain-chain corpus; the biology OOD lane measures
  fluency, not truth. The four subjects with ratified chains and mounted
  packs are physics, mathematics_logic, systems_software,
  philosophy_theology — the composer serves all four.

[Verification]: curriculum lane 32/32 wrong=0 with 5 anti-recall probes and
all three contract guards passing; tests/test_curriculum_serve.py 20 passed;
core test --suite deductive 252 passed; lane pinned as curriculum_serve_v1.
This commit is contained in:
Shay 2026-07-24 14:38:08 -07:00
parent e84c0e8428
commit 44e78aa438
20 changed files with 1810 additions and 0 deletions

View file

@ -44,6 +44,7 @@ is a CI failure (`.github/workflows/lane-shas.yml`).
| ADR-0131 | `math_teaching_corpus_v1` | Math teaching corpus replays deterministically; all chains pass exit criterion (correct_rate=1.0, wrong=0) | `evals/math_teaching_corpus/v1/report.json` | `eaf160d145da29f9050ede8d58bf111b0f651dd40aeae9201857d0b97e014dd4` |
| ADR-0206 | `deductive_logic_v1` | Propositional entailment scored against an independent truth-table oracle; dev+holdout+external 716/716 correct, wrong=0, refused=0 | `evals/deductive_logic/report.json` | `97a230949016e38d5e3f37a69e4245b320575ee70e5af92ff7607f7b05f74b5f` |
| ADR-0256 | `deduction_serve_v1` | Flag-gated deduction serving decides real English/member/fused/verb/existential arguments end-to-end under earned SERVE licenses; wrong=0 across all splits | `evals/deduction_serve/report.json` | `0b461a5a49c8f8260ca87d0c9c9f9a17232bd1fdedd982e34649eedf9cca30b5` |
| ADR-0262 | `curriculum_serve_v1` | Flag-gated curriculum serving answers exam questions from a subject's RATIFIED chain corpus only; untaught facts return UNKNOWN (anti-recall probes enforced), wrong=0 | `evals/curriculum_serve/report.json` | `d9e7ba500f040b865870413a940ee9a49910ac22e1a89c9feec1a60bdd2513f1` |
## Verification

View file

@ -0,0 +1,95 @@
"""Serving-side SERVE license for curriculum-grounded answers (ADR-0262).
Reads the **ratified, committed** curriculum-serve ledger
(``chat/data/curriculum_serve_ledger.json``) and exposes, per *(subject ×
relation family)* band, whether the FULL serving pipeline (curriculum
compiler argument reader ROBDD engine) has earned ``Action.SERVE`` under
the safe default ceilings (θ_SERVE=0.99). The engine READS this artifact; it
never writes it the sealed-practice output of
``evals.curriculum_serve.practice.runner.seal_ledger`` is the only writer, and
its ``content_sha256`` is verified on load so a hand-edited ledger is rejected
rather than silently trusted.
A deliberate near-copy of ``chat.deduction_serve_license``: the second
instance of the seal ratify SHA-verify serve-gate pattern in this arc,
kept explicit here so the shared bridge (generalization plan Phase 3.3) is
extracted from two working instances rather than designed from one.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from core.reliability_gate import Action, Ceilings, ClassTally, LicenseDecision, license_for
from formation.hashing import sha256_of
_LEDGER_PATH = Path(__file__).resolve().parent / "data" / "curriculum_serve_ledger.json"
class RatifiedCurriculumLedgerError(ValueError):
"""The committed curriculum-serve ledger is missing, malformed, or tampered with."""
@lru_cache(maxsize=1)
def load_ratified_ledger() -> dict[str, ClassTally]:
"""Load + verify the ratified curriculum-serve ledger → per-band tallies.
An ABSENT ledger is not an error: it means no curriculum band has earned
anything yet, and every answer is served DISCLOSED. That is the honest
default for a capability whose practice volume is still being built, and
it keeps the composer usable before any license exists.
"""
if not _LEDGER_PATH.exists():
return {}
try:
artifact = json.loads(_LEDGER_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: # pragma: no cover - defensive
raise RatifiedCurriculumLedgerError(f"cannot read ratified ledger: {exc}") from exc
classes = artifact.get("classes")
if not isinstance(classes, dict):
raise RatifiedCurriculumLedgerError("ratified ledger has no 'classes' table")
if sha256_of(classes) != artifact.get("content_sha256"):
raise RatifiedCurriculumLedgerError(
"ratified ledger content_sha256 mismatch — not the sealed-practice output"
)
ledger: dict[str, ClassTally] = {}
for cls, counts in classes.items():
ledger[cls] = ClassTally(
class_name=cls,
correct=int(counts.get("correct", 0)),
wrong=int(counts.get("wrong", 0)),
refused=int(counts.get("refused", 0)),
t2_verified=int(counts.get("t2_verified", 0)),
t2_agrees_gold=int(counts.get("t2_agrees_gold", 0)),
)
return ledger
def curriculum_serve_license(
band: str,
*,
ledger: dict[str, ClassTally] | None = None,
ceilings: Ceilings | None = None,
) -> LicenseDecision | None:
"""The ``Action.SERVE`` license for a curriculum band, or ``None``.
``None`` means the band has no committed evidence never licensed; the
caller serves a disclosed (hedged) surface, the safe default.
"""
ledger = ledger if ledger is not None else load_ratified_ledger()
tally = ledger.get(band)
if tally is None:
return None
ceilings = ceilings if ceilings is not None else Ceilings.default()
return license_for(tally, Action.SERVE, ceilings)
__all__ = [
"RatifiedCurriculumLedgerError",
"curriculum_serve_license",
"load_ratified_ledger",
]

361
chat/curriculum_surface.py Normal file
View file

@ -0,0 +1,361 @@
"""chat/curriculum_surface.py — CURRICULUM composer (generalization arc Phase 2).
The second question shape of the curriculum-entailment gold contract
(docs/plans/generalization-arc-2026-07-24.md §4.2b): an exam question that
supplies only the QUERY "Does force cause acceleration?" answered from the
ratified curriculum of the subject it is about, and from nothing else.
The decision is made by the SAME argument bands the deduction composer uses
(ADR-02560261). This module compiles ratified chains into premise sentences,
appends the question as a "Therefore …" conclusion, and hands the assembled
argument to `deduction_grounded_surface`'s decider. There is no subject-
specific reasoning code anywhere in this path physics differs from
philosophy only in which curriculum rows load, which is what makes "port the
lifecycle to a new subject" a data operation rather than an engineering one.
Why an UNTAUGHT fact is UNKNOWN and never "no": the curriculum is read
OPEN-world. "The curriculum does not teach it" is not "it is false", and a
system that decoded the former into the latter would be inventing negative
knowledge it was never given. The only verdicts reachable from a purely
positive curriculum are therefore ENTAILED and UNKNOWN a real limit of the
present corpora, recorded in ADR-0262 §5 rather than papered over.
Fail-closed (INV-34): once `looks_like_curriculum_question` fires, every path
below returns a committed, honest surface typed refusal or decided verdict
never a silent fall-through to a different composer.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable
from chat.curriculum_serve_license import curriculum_serve_license
from core.reliability_gate import LicenseDecision
from generate.proof_chain.entail import (
INCONSISTENT_PREMISES,
Entailment,
evaluate_entailment_with_trace,
)
from generate.proof_chain.exist import ExistArgument, read_exist_argument
from generate.proof_chain.verb import VerbArgument, read_verb_argument, verb_forms_link
from teaching.curriculum_premises import (
CONNECTIVE_FAMILY,
compile_premises,
load_curriculum,
)
#: Subjects this composer serves, in a stable resolution order. A question is
#: routed to the subject whose ratified vocabulary contains BOTH of its terms;
#: more than one match is an `ambiguous_reading` refusal, never a guess.
SERVED_DOMAINS: tuple[str, ...] = (
"physics",
"mathematics_logic",
"systems_software",
"philosophy_theology",
)
#: The closed exam-question grammar: ``Does <subject> <verb> <object>?``.
#: Deliberately one shape. A question the grammar cannot read refuses typed —
#: the reader is never asked to guess which token is the relation.
_QUESTION_RE = re.compile(r"^\s*does\s+(.+?)\s*\?\s*$", re.IGNORECASE)
@dataclass(frozen=True, slots=True)
class CurriculumQuery:
"""A read exam question: the edge the curriculum is being asked about."""
subject: str
verb: str
obj: str
@dataclass(frozen=True, slots=True)
class CurriculumRefusal:
"""A typed refusal — the closed reason vocabulary of ADR-0262 §4.6."""
reason: str
detail: str = ""
def looks_like_curriculum_question(text: str) -> bool:
"""True iff *text* is a ``Does …?`` polar question.
A cheap, deterministic COMMIT gate not a decision. The reader below
remains the sole authority on whether the question is actually readable.
"""
return bool(_QUESTION_RE.match(text or ""))
def read_curriculum_question(text: str) -> CurriculumQuery | CurriculumRefusal:
"""Read *text* as ``Does <subject> <verb> <object>?``, or refuse (typed)."""
match = _QUESTION_RE.match(text or "")
if match is None:
return CurriculumRefusal("question_shape_out_of_band", text or "")
tokens = match.group(1).replace(",", " ").lower().split()
if len(tokens) != 3:
# Two-token ("does force act") and four-token ("does a force cause
# acceleration") shapes are genuinely different readings; the closed
# grammar refuses rather than dropping or inventing a slot.
return CurriculumRefusal("question_shape_out_of_band", " ".join(tokens))
return CurriculumQuery(tokens[0], tokens[1], tokens[2])
def resolve_domain(query: CurriculumQuery) -> str | CurriculumRefusal:
"""The subject whose ratified vocabulary contains BOTH of the query's
terms or a typed refusal (§4.6).
``untaught_vocabulary`` is the anti-recall boundary made mechanical: a
term no mounted pack teaches cannot be reasoned about here at all, however
familiar it is. ``ambiguous_reading`` fires when two subjects would both
claim the question; the composer declines rather than picking one.
"""
matches = [
domain
for domain in SERVED_DOMAINS
if {query.subject, query.obj} <= load_curriculum(domain).vocabulary
]
if not matches:
return CurriculumRefusal(
"untaught_vocabulary", f"{query.subject} / {query.obj}"
)
if len(matches) > 1:
return CurriculumRefusal("ambiguous_reading", ", ".join(matches))
return matches[0]
def resolve_family(query: CurriculumQuery) -> str | CurriculumRefusal:
"""The relation family of the query's verb, under the SAME closed
agreement relation the verb-predicate band uses (ADR-0260) so a question
may spell the relation in its base form ("cause") while the curriculum
states it in the third person ("causes")."""
for connective, family in CONNECTIVE_FAMILY.items():
if verb_forms_link(query.verb, connective):
return family
return CurriculumRefusal("out_of_curriculum", query.verb)
def _query_sentence(query: CurriculumQuery, family: str) -> str:
"""The conclusion sentence, spelled with the CURRICULUM's own connective
form so the reader's agreement linking has nothing to do — the question's
base form is normalized here, once, visibly."""
for connective in CONNECTIVE_FAMILY:
if CONNECTIVE_FAMILY[connective] == family and verb_forms_link(
query.verb, connective
):
return f"{query.subject} {connective} {query.obj}"
return f"{query.subject} {query.verb} {query.obj}" # pragma: no cover - guarded above
def band_for(domain: str, family: str) -> str:
"""The capability band an answer is licensed under: *(subject × relation
family)* ADR-0262 §4."""
return f"curriculum_{domain}_{family}"
@dataclass(frozen=True, slots=True)
class CurriculumDecision:
"""What the composer decided, before rendering — the lane scores this."""
verdict: str # entailed | refuted | unknown | declined
band: str # "" when the question never reached a band
reason: str # typed refusal reason, or "" when decided
domain: str = ""
family: str = ""
premise_count: int = 0
def decide_curriculum_question(text: str) -> CurriculumDecision:
"""Read, route, compile, and decide — the production decision path.
Kept separate from the rendering below so the lane can pin the DECISION
without pinning prose (the same split ``evals/deduction_serve/runner.py``
uses).
"""
query = read_curriculum_question(text)
if isinstance(query, CurriculumRefusal):
return CurriculumDecision("declined", "", query.reason)
domain = resolve_domain(query)
if isinstance(domain, CurriculumRefusal):
return CurriculumDecision("declined", "", domain.reason)
family = resolve_family(query)
if isinstance(family, CurriculumRefusal):
return CurriculumDecision("declined", "", family.reason, domain=domain)
curriculum = load_curriculum(domain)
premises, _chain_ids = compile_premises(curriculum, family)
if not premises:
return CurriculumDecision(
"declined", "", "empty_curriculum", domain=domain, family=family
)
conclusion = _query_sentence(query, family)
argument = ". ".join(premises) + f". Therefore {conclusion}."
# The SAME argument bands decide it. The verb band reads these sentences
# (each is `<term> <connective> <term>`); the existential band is tried
# after it exactly as in serving, so the cascade order here matches the
# deduction composer's tail and no subject-specific decider exists.
arg = read_verb_argument(argument)
if not isinstance(arg, VerbArgument):
arg = read_exist_argument(argument)
if not isinstance(arg, ExistArgument):
return CurriculumDecision(
"declined", "", "compiled_premises_unreadable",
domain=domain, family=family, premise_count=len(premises),
)
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
verdict = {
Entailment.ENTAILED: "entailed",
Entailment.REFUTED: "refuted",
Entailment.UNKNOWN: "unknown",
Entailment.REFUSED: "declined",
}[trace.outcome]
reason = trace.reason if trace.outcome is Entailment.REFUSED else ""
return CurriculumDecision(
verdict,
band_for(domain, family),
reason,
domain=domain,
family=family,
premise_count=len(premises),
)
_LicenseLookup = Callable[[str], LicenseDecision | None]
_REFUSAL_SURFACE = {
"question_shape_out_of_band": (
"That reads as a question about a subject I study, but not in a form I "
"can decide yet (I read \"Does <term> <relation> <term>?\")."
),
"untaught_vocabulary": (
"I haven't been taught {detail} — so I can't decide anything about it "
"from what I know. That's a gap in my curriculum, not a claim about "
"the world."
),
"out_of_curriculum": (
"I haven't been taught the relation \"{detail}\", so I can't decide "
"that question from my curriculum."
),
"ambiguous_reading": (
"Those terms are taught in more than one subject I study ({detail}), "
"so I can't tell which curriculum you're asking about."
),
"empty_curriculum": (
"I have no ratified material for that kind of relation yet, so there "
"is nothing for me to decide it from."
),
"compiled_premises_unreadable": (
"I have the curriculum for that, but I can't read it back precisely "
"enough to decide the question yet."
),
INCONSISTENT_PREMISES: (
"The curriculum I have for that is inconsistent — I won't assert "
"anything from it until it is corrected."
),
}
_DEFAULT_REFUSAL_SURFACE = (
"I can't decide that from my curriculum as stated."
)
#: Disclosed hedge for a band that has not earned SERVE — same posture as
#: the deduction composer (ADR-0256): the answer is sound, the track record
#: is not yet demonstrated, so it is served DISCLOSED rather than withheld.
_UNVERIFIED_SHAPE_DISCLOSURE = (
"(answered from my curriculum, but I haven't yet earned a verified track "
"record on questions of this shape) "
)
def curriculum_grounded_surface(
text: str, *, license_lookup: _LicenseLookup = curriculum_serve_license,
) -> str | None:
"""Return a deterministic CURRICULUM-tier surface, or ``None``.
``None`` only when *text* is not a ``Does ?`` question at all the
caller then falls through to its pre-existing dispatch, byte-identical to
before this composer existed.
"""
if not looks_like_curriculum_question(text):
return None
decision = decide_curriculum_question(text)
query = read_curriculum_question(text)
# Restate the question in the CURRICULUM's own spelling of the relation —
# the normalization the reader performed is shown, not hidden.
shown = (
_query_sentence(query, decision.family)
if isinstance(query, CurriculumQuery) and decision.family
else text.strip()
)
if decision.verdict == "declined":
template = _REFUSAL_SURFACE.get(decision.reason, _DEFAULT_REFUSAL_SURFACE)
return template.format(detail=_detail_of(text, decision))
if decision.verdict == "entailed":
surface = (
f"Yes — my {decision.domain} curriculum teaches that {shown}, "
f"directly, among the {decision.premise_count} {decision.family} "
f"relations it states."
)
elif decision.verdict == "refuted":
surface = (
f"No — my {decision.domain} curriculum rules out that {shown}."
)
else:
surface = (
f"My {decision.domain} curriculum doesn't settle whether {shown}. "
f"It states {decision.premise_count} {decision.family} relations, "
f"and none of them decides this one — which is not the same as "
f"its being false."
)
return _license_gate(surface, decision.band, license_lookup)
def _detail_of(text: str, decision: CurriculumDecision) -> str:
"""The user-visible fragment a typed refusal names (empty when the reason
carries no detail slot)."""
query = read_curriculum_question(text)
if not isinstance(query, CurriculumQuery):
return ""
if decision.reason == "untaught_vocabulary":
untaught = [
term
for term in (query.subject, query.obj)
if not any(
term in load_curriculum(d).vocabulary for d in SERVED_DOMAINS
)
]
return " or ".join(untaught) if untaught else f"{query.subject} / {query.obj}"
if decision.reason == "out_of_curriculum":
return query.verb
if decision.reason == "ambiguous_reading":
return ", ".join(
d
for d in SERVED_DOMAINS
if {query.subject, query.obj} <= load_curriculum(d).vocabulary
)
return ""
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)."""
decision = license_lookup(band)
if decision is not None and decision.licensed:
return surface
return _UNVERIFIED_SHAPE_DISCLOSURE + surface
__all__ = [
"CurriculumDecision",
"CurriculumQuery",
"CurriculumRefusal",
"SERVED_DOMAINS",
"band_for",
"curriculum_grounded_surface",
"decide_curriculum_question",
"looks_like_curriculum_question",
"read_curriculum_question",
"resolve_domain",
"resolve_family",
]

View file

@ -1757,6 +1757,22 @@ class ChatRuntime:
return (deduction_surface, "deduction", ())
if attempts is not None:
attempts.append(DispatchAttempt(source="deduction", outcome="skipped", reason="not_argument_shaped"))
# Generalization arc, Phase 2 (ADR-0262) — curriculum-grounded exam
# questions, checked beside deduction serving for the same reason: a
# pure function of the input text and the ratified curriculum, with no
# vault/field dependency, so warm/cold state cannot change the answer.
if self.config.curriculum_serving_enabled:
from chat.curriculum_surface import curriculum_grounded_surface
from generate.intent import DialogueIntent, IntentTag as _IntentTag
curriculum_surface = curriculum_grounded_surface(text)
if curriculum_surface is not None:
self._last_intent = DialogueIntent(tag=_IntentTag.DEDUCTION, subject=text)
if attempts is not None:
attempts.append(DispatchAttempt(source="curriculum", outcome="admitted", reason="curriculum_composer_committed"))
return (curriculum_surface, "curriculum", ())
if attempts is not None:
attempts.append(DispatchAttempt(source="curriculum", outcome="skipped", reason="not_curriculum_question"))
if not allow_warm and gate_source != "empty_vault":
if attempts is not None:
for src in ("pack", "teaching", "partial", "oov"):

View file

@ -183,6 +183,7 @@ TEST_SUITES: dict[str, tuple[str, ...]] = {
"tests/test_verb_argument_reader.py",
"tests/test_exist_argument_reader.py",
"tests/test_deduction_serve_e2e.py",
"tests/test_curriculum_serve.py",
),
"full": ("tests/",),
}

View file

@ -386,6 +386,21 @@ class RuntimeConfig:
# (hedged). OFF by default: flag-off is byte-identical to pre-arc dispatch.
deduction_serving_enabled: bool = False
# Generalization arc, Phase 2 (ADR-0262) — when on, ``chat/curriculum_surface.py``
# answers exam-shaped polar questions ("Does force cause acceleration?") from the
# RATIFIED domain-chain curriculum of the subject the question's vocabulary
# belongs to, decided by the same argument bands the deduction composer uses.
# The curriculum is read OPEN-world: a relation it does not teach is UNKNOWN,
# never "no", so an untaught fact can never be decoded into a negative claim.
# Like ``deduction_serving_enabled`` this is an enable for an EARNED path, not a
# direct-serve switch — an answer is served AUTHORITATIVELY only when its
# (subject x relation family) band holds a SERVE license on the committed,
# SHA-sealed curriculum ledger; an unearned band is served DISCLOSED (hedged),
# which is the state of every band today (ADR-0262 SS5: the binding constraint is
# ratified curriculum volume, not machinery). OFF by default: flag-off is
# byte-identical to pre-arc dispatch.
curriculum_serving_enabled: bool = False
# ASK serving gate enable flag. When True, ASK serving is allowed.
# Default False (dark).
ask_serving_enabled: bool = False

View file

@ -0,0 +1,164 @@
# ADR-0262 — Curriculum-grounded serving: exams answered from what was taught
- **Status:** Proposed
- **Date:** 2026-07-24
- **Arc:** generalization Phase 2 (docs/plans/generalization-arc-2026-07-24.md §4)
- **Governs:** `teaching/curriculum_premises.py`, `chat/curriculum_surface.py`,
`chat/curriculum_serve_license.py`, the `curriculum_<subject>_<family>` bands,
the `evals/curriculum_serve` lane and its independent oracle, and the
`curriculum_serving_enabled` flag.
## 1. Context
The band cascade (ADR-0256 → 0261) decides arguments whose premises are IN the
text. Exams are not shaped like that: the question supplies only the query, and
the premises are supposed to be what the student was taught. Until now CORE had
no path from "a ratified curriculum exists" to "a question is answered from
it" — the 16 domain seed packs and 5 domain-chain corpora were inert.
The generalization plan §4 fixes the epistemology for that path in advance, so
that implementing it cannot quietly become "answer from what the model knows".
This ADR implements §4.
## 2. Decision
Add a second serving composer, `chat/curriculum_surface.py`, behind the
default-off `curriculum_serving_enabled` flag:
1. **Closed question grammar**`Does <term> <relation> <term>?`. One shape.
Anything else refuses `question_shape_out_of_band` rather than guessing
which token is the relation.
2. **Subject routing by vocabulary** — the question goes to the subject whose
ratified pack vocabulary contains BOTH terms. No match is
`untaught_vocabulary`; more than one is `ambiguous_reading`. The subject is
never guessed.
3. **Premise compilation from the ratified curriculum only**
(`teaching/curriculum_premises.py`). A chain is admitted iff
`review_status == "reviewed"` AND both terms are resident in the packs the
chain declares. Compilation is scoped to the question's relation family.
4. **The same decider** — compiled premises plus the question as a "Therefore"
conclusion are handed to the verb-predicate band (ADR-0260), falling through
to the existential band (ADR-0261), then the ROBDD engine. **No
subject-specific decision code exists anywhere in this path.**
5. **Bands = (subject × relation family)**, e.g. `curriculum_physics_causal`,
gated by the same earned-license machinery as deduction serving through a
second ledger reader (`chat/curriculum_serve_license.py`).
The relation family comes from the CONNECTIVE, not from a chain row's declared
`operator_family`. A question carries a relation word and nothing else; a
family derived from anything the question cannot carry would let premise
compilation and question routing disagree, and a taught edge could then be
missing from the premises compiled to decide it — a wrong answer built out of a
correct curriculum. Two corpus rows currently declare a family their connective
does not imply; under this rule they are read as their connective says.
## 3. Why this is sound
1. **Gold is a function of (curriculum, question).** No case-local hidden text,
no world knowledge. A lane case pins the chain ids it draws on and the
runner FAILS the case if a pinned chain is absent or unratified — so a
curriculum that changes under a case breaks loudly instead of quietly
answering from what remains.
2. **Open-world reading.** A relation the curriculum does not state is
UNKNOWN, never "no". Deciding otherwise would manufacture negative
knowledge from silence, which is the same failure as manufacturing positive
knowledge from silence.
3. **No composition.** The curriculum teaches `force causes acceleration` and
`acceleration causes motion`; it does not teach that causation composes, so
`force causes motion` is UNKNOWN. Transitivity of a causal or modal relation
is a substantive claim about the world, not a logical truth, and nothing
ratified asserts it. The independent oracle reports the shortest path length
with its verdict precisely so the lane can assert that a reachable pair is
still answered UNKNOWN — composition is provably not happening rather than
merely absent.
4. **Face-value relations.** `entropy reveals energy` does not license
`entropy causes energy`. Same family, different relation: the reader mints
distinct atoms per verb group (ADR-0260), so a family-level near-miss cannot
become an entailment.
5. **Independent gold.** `evals/curriculum_serve/oracle.py` shares no code with
the serving path — its own JSONL loader, ratification predicate, family
table, agreement normalization, and verdict rule. It disagreed with the
serving path once during authoring, on exactly the point in (4), and the
ORACLE was the side that was wrong; that disagreement is why the check
exists.
6. **Anti-recall is a lane guard, not a hope.** A split must carry ≥3 probes
whose answer is true in the world and absent from the curriculum, and no
probe may carry a committed gold, or the lane refuses to run.
## 4. License posture
Every `curriculum_*` band is UNEARNED today, so every answer is served
DISCLOSED through the same hedge deduction serving uses. This is not a
temporary shortcut around the license — it is the license working. The reason
is §5.1.
## 5. Findings
### 5.1 The binding constraint on Phase 2 is ratified curriculum VOLUME
A band earns SERVE at θ_SERVE=0.99 only with n ≥ 657 committed cases and a
genuine outcome mix. The question space of a subject is
`|vocabulary|² |vocabulary|`, so volume is reachable (physics: 240,
philosophy_theology: 22,650). **Entailed** cases are not: physics teaches 7
causal and 9 modal relations, so at most 16 distinct questions in the whole
subject can come back ENTAILED. Sampling cannot fix this — repeating the same
16 questions inflates n without adding information, and a band whose entailed
class is 1% of its volume is passed by a pipeline that never entails anything.
Concretely: a balanced band (≈⅓ entailed) needs **≈219 distinct taught
relations per subject × family**. Physics has 7 and 9. That is a ~25× gap, and
it is a curriculum-authoring and ratification task — Shay's call, not an
engineering one.
The honest consequence, recorded rather than worked around: the machinery,
the lane, the oracle, and the license gate all ship and pass; the licenses do
not exist yet; every answer is hedged until they do. The plan's O→S checkpoint
("≥1 subject lane serving wrong=0 with earned licenses") is met on every clause
except the last, and the last one cannot be met from present data.
### 5.2 REFUTED is unreachable from present corpora
Every ratified chain is a positive assertion. Refuting a question needs the
curriculum to teach a negative ("no X causes Y") or an exclusion. Until a
corpus does, `refuted` is a verdict this path can render but never reach. The
serving code handles it; the lane documents it.
### 5.3 There is no biology domain-chain corpus
The plan names "physics and biology" as Phase 2's two subjects because "OOD
lanes, seed packs, and domain chains already exist". For biology only the first
two are true: `evals/foundational_biology_ood` is a **fluency** lane (its own
contract says it measures grammatical English, not truth) and there is no
`biology_chains_*.jsonl`. The four subjects with ratified chains AND mounted
packs are physics, mathematics_logic, systems_software, and
philosophy_theology; the composer serves all four. The second subject should be
chosen from those, or biology's chain corpus authored first.
## 6. Scope-outs
- **Question shapes** beyond `Does <term> <relation> <term>?` — multi-word
terms, "why"/"how" questions, negated questions, and quantified questions all
refuse typed. Each is a separate readable unit with its own lane.
- **Composition**, per §3.3 — a future ADR may license it for a family the
curriculum itself declares transitive, but that requires the declaration to
be an executable claim rather than a classification label, which today it is
not.
- **Premise-set scaling** — a family with more than `MAX_PREMISE_SENTENCES`
(16) ratified chains compiles to an argument the reader refuses, surfacing as
`compiled_premises_unreadable`. No current family exceeds 9. The fix when one
does is to compile the query's k-hop neighborhood rather than the whole
family (sound: fewer premises can lose an entailment, never create one), and
to disclose the narrowing.
## 7. Verification
- `tests/test_curriculum_serve.py` — 20 tests: premise provenance and the
loud-failure guard, family scoping never losing a taught edge, taught-edge
entailment, untaught composition staying UNKNOWN, untaught fact never
REFUTED, relation read at face value, base-form question normalization,
anti-recall declines, 3 typed refusals, non-question pass-through, routing,
the disclosed-until-earned license posture and the authoritative path when a
band IS licensed, and oracle independence including the depth-2 reachability
assertion.
- `evals/curriculum_serve` lane: 32/32, wrong=0, 5 anti-recall probes, both
physics bands exercised. Pinned as `curriculum_serve_v1`.

View file

@ -0,0 +1,109 @@
# Curriculum-grounded serving — the first exam answered from what was taught
**Date:** 2026-07-24 · **ADR:** ADR-0262 · **Arc:** generalization Phase 2 (Tier O)
## What shipped
"Does force cause acceleration?" is now answered — from the ratified physics
chain corpus and nothing else. "Does gravity cause acceleration?" is declined,
because *gravity* is not in any pack CORE has been taught. "Does force cause
motion?" comes back unsettled, even though the curriculum contains both links
in that chain, because nothing ratified says causation composes.
That third answer is the one worth staring at. It is the difference between a
system that decodes a curriculum and one that pattern-matches over it.
The lane: 32 hand-authored physics cases, wrong=0, five anti-recall probes, an
independent oracle re-deriving every gold, and three contract guards that fail
the LANE (not the case) if provenance, soundness, or anti-recall coverage
lapses. 20 unit tests. Zero subject-specific decision code: the composer routes
by vocabulary, compiles premises from ratified chains, and hands the result to
the same argument bands that decide "All philosophers teach. Socrates is a
philosopher."
## The design decision that carried the weight
The plan fixed the epistemology in §4 before any code existed, and that turned
out to matter more than any implementation choice. Three of its clauses did
real work:
- **Gold is a function of (curriculum, question).** No hidden premises in the
case file. A case pins chain ids; the runner reconstructs premises from the
corpus and fails loudly if a pinned chain is absent or unratified. A
curriculum that moves under a case breaks the case instead of quietly
changing its answer.
- **Untaught ⇒ UNKNOWN, never "no".** Open-world reading. Manufacturing a
negative from silence is the same error as manufacturing a positive from it,
and it is the more tempting one because it feels like rigor.
- **Anti-recall probes are mandatory.** The runner refuses to score a split
that does not contain questions whose answers are true in the world and
absent from the curriculum. Without them the lane cannot distinguish
decoding from recall, so it must not ship.
## Where the independent oracle earned its keep
The oracle disagreed with the serving path exactly once during authoring:
"Does entropy cause energy?" The curriculum teaches *entropy reveals energy*.
The serving path said UNKNOWN — different relation, different atom. The oracle
said ENTAILED, because I had written its edge lookup to match on relation
*family* rather than the relation itself.
The oracle was wrong, and the serving path was right. That is the useful shape
of the result: an independent check is worth building even when — especially
when — you expect it to agree, because the thing it catches is the assumption
you did not notice you had made. Family scoping decides which premises are in
play; the connective decides what was actually said.
A second authoring error surfaced the same way: two lane cases claimed both
directions of `field requires charge` were taught. Only one is. The provenance
and soundness guards caught it before the case file was committed.
## The finding: Phase 2's blocker is curriculum volume, not machinery
Every `curriculum_*` band is unearned, so every answer is served hedged. That
is not a shortcut around the license — it is the license working, and the
reason is arithmetic.
A band earns SERVE at θ_SERVE=0.99 with n ≥ 657 committed cases and a genuine
outcome mix. The question space is large enough (physics: 240 distinct
questions per family; philosophy_theology: 22,650). The *entailed* space is
not: physics teaches 7 causal and 9 modal relations, so at most 16 questions in
the entire subject can come back ENTAILED. Repeating them to reach 657 inflates
n without adding information, and a band whose entailed class is 1% of its
volume is passed by a pipeline that never entails anything at all.
A balanced band needs roughly **219 distinct taught relations per subject ×
family**. Physics has 7 and 9 — a ~25× gap. No sampling scheme closes it. What
closes it is ratified curriculum content, which is an authoring and
ratification decision, not an engineering one.
So the honest state of Phase 2: the lifecycle is ported and provably correct on
real curriculum data; the earned-license half is blocked on how much has been
taught. That is a much better problem to have than the reverse, and it is the
one the plan's own doctrine (capability → practice → calibration → serve)
predicts you would hit here.
## Two corrections to the plan's ground truth
- **There is no biology domain-chain corpus.** The plan names physics and
biology as Phase 2's subjects on the basis that "OOD lanes, seed packs, and
domain chains already exist". For biology the first two are true; the third
is not, and `evals/foundational_biology_ood`'s own contract says it measures
fluency, not truth. The subjects with ratified chains AND mounted packs are
physics, mathematics_logic, systems_software, and philosophy_theology. The
composer serves all four; the second subject should come from that list, or
biology's chains should be authored first.
- **`operator_family` and `connective` disagree in the corpora.** Two rows
declare a family their connective does not imply. The serving path reads the
connective, because that is the only thing an exam question carries — and if
routing and compilation disagreed about a row's family, a taught edge could
go missing from the premises compiled to decide it. That would be a wrong
answer assembled from a correct curriculum, which is the exact failure mode
ADR-0261 §5.1 found in the categorical band a day earlier.
## What is deliberately not built
Composition, negation, question shapes beyond `Does <term> <relation> <term>?`,
and premise-set scaling past the reader's 16-sentence honesty cap. Each is
scoped in ADR-0262 §6 with the condition that would unblock it. None of them
is a prerequisite for the next subject: adding one is a data operation now.

View file

View file

@ -0,0 +1,86 @@
# Curriculum-serve lane contract
## What this lane scores
The **production curriculum-grounded answering path** — the exact pipeline
`chat/curriculum_surface.py::decide_curriculum_question` runs on a `core chat`
turn when `curriculum_serving_enabled` is on:
```
Does <term> <relation> <term>? (closed exam-question grammar)
→ subject routing (the domain whose ratified vocabulary holds BOTH terms)
→ relation family (the connective, normalized by ADR-0260 agreement)
→ premise compilation (that family's RATIFIED chains, and nothing else)
→ the argument bands (ADR-0260 verb reading → ADR-0261 existential)
→ the ROBDD engine (ADR-0201/0218)
```
There is no subject-specific decision code anywhere in the path. Physics
differs from philosophy only in which curriculum rows load — which is the
property that makes "add a subject" a data operation.
## Gold vocabulary
Three classes are reachable: `entailed`, `unknown`, `declined`.
`refuted` is **unreachable from a purely positive curriculum** and that is not
an omission. The curriculum is read OPEN-world: a relation it does not state
is UNKNOWN, never "no". Refuting would require the curriculum to teach a
negative ("no X causes Y"), which no ratified corpus does yet. See ADR-0262 §5.
## The three lane guards (all run before any case is scored)
Failing a guard is a **lane** failure, not a case miss — the lane is unsound,
not merely under-covered.
1. **Provenance** (plan §4.3) — every chain id a case pins must resolve in the
subject's ratified curriculum. A case whose curriculum moved under it
breaks loudly rather than quietly answering from what remains.
2. **Corpus soundness** (§4.4) — the INDEPENDENT oracle
(`evals/curriculum_serve/oracle.py`, sharing no code with the serving path:
own loader, own ratification predicate, own family table, own agreement
normalization, own verdict rule) must re-derive every committed gold.
3. **Anti-recall coverage** (§4.7) — the split must carry ≥3 probes whose
answer is true in the world and absent from the curriculum, and no probe
may carry a committed gold. Without them the lane cannot show the system
decodes rather than recalls, and it must not ship.
## Splits
- `physics/` — 32 hand-authored cases over `physics_chains_v1`: 13 taught
edges (both connectives of each family, incl. the row whose declared
`operator_family` disagrees with its connective), 5 untaught compositions at
depth 23, 4 reverse-direction and cross-relation near-misses, 5 anti-recall
probes (3 with untaught vocabulary — *gravity*, *current*, *pressure* — and
2 with taught vocabulary but untaught relations), and 5 typed refusals.
## wrong=0 discipline
Identical to the deduction-serve lane: `wrong` (a committed verdict that
disagrees with gold) MUST stay 0; a decline where gold expected a verdict is a
coverage miss, tracked in `counts.declined`, never conflated with a
confabulation. The runner requires `correct == n`.
## What the lane deliberately does NOT do
- **It does not compose chains.** `force causes acceleration` and
`acceleration causes motion` are both taught; `force causes motion` is
UNKNOWN. Causal transitivity is a substantive claim about the world, and no
ratified corpus teaches it. The oracle reports the shortest path length
alongside its verdict precisely so the lane can assert that a reachable pair
is still answered UNKNOWN — composition is provably not happening.
- **It does not read the corpus's `operator_family` field.** The family comes
from the CONNECTIVE, because a question carries a relation word and nothing
else; deriving the family from a field the question cannot carry would let
premise compilation and question routing disagree, and a taught edge could
go missing from the premises compiled to decide it.
## Reproduce
```bash
uv run python -m evals.curriculum_serve.runner # human-facing
uv run python -m evals.curriculum_serve.runner --report evals/curriculum_serve/report.json
```
Pinned in `scripts/verify_lane_shas.py` as lane id `curriculum_serve_v1`.
`core test --suite deductive` runs `tests/test_curriculum_serve.py`.

View file

@ -0,0 +1,189 @@
"""Independent curriculum oracle — the gold for the curriculum-serve lane.
This is **deliberately a second, independent decision procedure** (plan §4.4).
It shares no code with the serving path: its own JSONL loader, its own
ratification predicate, its own connectivefamily table, its own agreement
normalization, and its own verdict rule. Two independently-written procedures
agreeing on every case is real evidence the serving path reads the curriculum
correctly; a shared-code "oracle" would only prove the compiler agrees with
itself.
It is intentionally simple: a closed-world reachability oracle over the chain
graph. "Closed-world" describes only what the ORACLE can see the ratified
corpus and nothing else. It does NOT mean untaught facts are false: an edge
absent from the corpus is UNKNOWN, never refuted, because a curriculum that
does not mention something has said nothing about it. The oracle also reports
the shortest path length between the two terms, which is what lets the lane
assert the property that matters most here that a reachable-but-untaught
pair is answered UNKNOWN rather than composed into a claim.
"""
from __future__ import annotations
import json
from collections import deque
from dataclasses import dataclass
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CHAIN_DIR = _REPO_ROOT / "teaching" / "domain_chains"
_PACK_DIR = _REPO_ROOT / "packs" / "data"
#: Independently restated (the serving path has its own copy — that is the point).
_FAMILY_OF_CONNECTIVE = {
"causes": "causal",
"reveals": "causal",
"grounds": "causal",
"requires": "modal",
"enables": "modal",
"precedes": "sequence",
"opposes": "contrast",
"supports": "evidential",
}
#: Which corpora and packs each subject is taught from — restated, not imported.
_DOMAIN_SOURCES: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
"physics": (("physics_chains_v1",), ("en_physics_v1",)),
"mathematics_logic": (("mathematics_logic_chains_v1",), ("en_mathematics_logic_v1",)),
"systems_software": (("systems_software_chains_v1",), ("en_systems_software_v1",)),
"philosophy_theology": (
("philosophy_theology_chains_v1",),
("en_core_cognition_v1", "en_core_meta_v1"),
),
}
@dataclass(frozen=True, slots=True)
class OracleVerdict:
"""The gold verdict, plus the evidence the lane asserts against."""
verdict: str # entailed | unknown | declined
reason: str # typed reason when declined, else ""
family: str # "" when the relation is not taught anywhere
depth: int # 1 = taught edge, >1 = reachable in n hops, 0 = no path
def _lemmas(pack_id: str) -> set[str]:
path = _PACK_DIR / pack_id / "lexicon.jsonl"
out: set[str] = set()
if not path.exists():
return out
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
row = json.loads(line)
lemma = row.get("lemma")
if isinstance(lemma, str):
out.add(lemma)
return out
def _edges(domain: str) -> list[tuple[str, str, str, str]]:
"""``(subject, connective, object, chain_id)`` for every ratified row."""
corpora, packs = _DOMAIN_SOURCES[domain]
vocabulary = set()
for pack in packs:
vocabulary |= _lemmas(pack)
out: list[tuple[str, str, str, str]] = []
for corpus in corpora:
path = _CHAIN_DIR / f"{corpus}.jsonl"
if not path.exists():
continue
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
row = json.loads(line)
if row.get("review_status") != "reviewed":
continue
if row.get("domain") != domain:
continue
subject, obj = row.get("subject"), row.get("object")
connective = row.get("connective")
if subject not in vocabulary or obj not in vocabulary:
continue
if connective not in _FAMILY_OF_CONNECTIVE:
continue
out.append((subject, connective, obj, row.get("chain_id", "")))
return out
def vocabulary(domain: str) -> set[str]:
"""Every lemma the subject's packs teach."""
out: set[str] = set()
for pack in _DOMAIN_SOURCES[domain][1]:
out |= _lemmas(pack)
return out
def _base_forms(word: str) -> set[str]:
"""The spellings a question's relation word may take — independently
written, covering the same +s/+es/yies agreement the serving path
normalizes with its own table."""
forms = {word}
forms.add(word + "s")
forms.add(word + "es")
if word.endswith("y"):
forms.add(word[:-1] + "ies")
if word.endswith("s"):
forms.add(word[:-1])
if word.endswith("es"):
forms.add(word[:-2])
if word.endswith("ies"):
forms.add(word[:-3] + "y")
return forms
def _connective_for(relation: str) -> str | None:
candidates = _base_forms(relation)
for connective in _FAMILY_OF_CONNECTIVE:
if connective in candidates or relation == connective:
return connective
return None
def oracle_answer(domain: str, subject: str, relation: str, obj: str) -> OracleVerdict:
"""The gold verdict for one exam question against *domain*'s curriculum."""
vocab = vocabulary(domain)
if subject not in vocab or obj not in vocab:
return OracleVerdict("declined", "untaught_vocabulary", "", 0)
connective = _connective_for(relation)
if connective is None:
return OracleVerdict("declined", "out_of_curriculum", "", 0)
family = _FAMILY_OF_CONNECTIVE[connective]
edges = [e for e in _edges(domain) if _FAMILY_OF_CONNECTIVE[e[1]] == family]
if not edges:
return OracleVerdict("declined", "empty_curriculum", family, 0)
# Entailment needs the SAME relation, not merely the same family: the
# curriculum teaching "entropy reveals energy" has not thereby taught
# "entropy causes energy". Family scoping decides which premises are in
# play; the connective decides what was actually said.
if any(s == subject and c == connective and o == obj for s, c, o, _id in edges):
return OracleVerdict("entailed", "", family, 1)
# No taught edge. Report the shortest path so the lane can prove the
# serving path does NOT compose a chain into a claim.
adjacency: dict[str, list[str]] = {}
for s, _c, o, _id in edges:
adjacency.setdefault(s, []).append(o)
seen = {subject}
queue: deque[tuple[str, int]] = deque([(subject, 0)])
while queue:
node, dist = queue.popleft()
for nxt in adjacency.get(node, ()):
if nxt == obj:
# ``dist`` counts edges already traversed to reach ``node``;
# this one closes the path, so the shortest path is dist + 1.
return OracleVerdict("unknown", "", family, dist + 1)
if nxt not in seen:
seen.add(nxt)
queue.append((nxt, dist + 1))
return OracleVerdict("unknown", "", family, 0)
def taught_edges(domain: str) -> list[tuple[str, str, str, str]]:
"""Public view for the lane's provenance assertions."""
return _edges(domain)
__all__ = ["OracleVerdict", "oracle_answer", "taught_edges", "vocabulary"]

View file

@ -0,0 +1,32 @@
{"id": "cs-phy-0001", "text": "Does force cause acceleration?", "gold": "entailed", "chains": ["physics-causal-001"], "class": "taught_edge_causal"}
{"id": "cs-phy-0002", "text": "Does acceleration cause motion?", "gold": "entailed", "chains": ["physics-causal-002"], "class": "taught_edge_causal"}
{"id": "cs-phy-0003", "text": "Does work cause energy?", "gold": "entailed", "chains": ["physics-causal-003"], "class": "taught_edge_causal"}
{"id": "cs-phy-0004", "text": "Does charge cause field?", "gold": "entailed", "chains": ["physics-causal-004"], "class": "taught_edge_causal"}
{"id": "cs-phy-0005", "text": "Does field cause force?", "gold": "entailed", "chains": ["physics-causal-005"], "class": "taught_edge_causal"}
{"id": "cs-phy-0006", "text": "Does temperature reveal motion?", "gold": "entailed", "chains": ["physics-causal-006"], "class": "taught_edge_reveals"}
{"id": "cs-phy-0007", "text": "Does entropy reveal energy?", "gold": "entailed", "chains": ["physics-causal-007"], "class": "taught_edge_reveals"}
{"id": "cs-phy-0008", "text": "Does conservation require symmetry?", "gold": "entailed", "chains": ["physics-modal-001"], "class": "taught_edge_modal"}
{"id": "cs-phy-0009", "text": "Does mass require force?", "gold": "entailed", "chains": ["physics-modal-002"], "class": "taught_edge_modal"}
{"id": "cs-phy-0010", "text": "Does momentum require mass?", "gold": "entailed", "chains": ["physics-modal-003"], "class": "taught_edge_modal"}
{"id": "cs-phy-0011", "text": "Does energy require conservation?", "gold": "entailed", "chains": ["physics-modal-004"], "class": "taught_edge_modal"}
{"id": "cs-phy-0012", "text": "Does frequency require wave?", "gold": "entailed", "chains": ["physics-modal-005"], "class": "taught_edge_modal"}
{"id": "cs-phy-0013", "text": "Does wave require field?", "gold": "entailed", "chains": ["physics-causal-008"], "class": "taught_edge_family_by_connective"}
{"id": "cs-phy-0014", "text": "Does force cause motion?", "gold": "unknown", "chains": ["physics-causal-001", "physics-causal-002"], "class": "two_hop_not_composed"}
{"id": "cs-phy-0015", "text": "Does charge cause acceleration?", "gold": "unknown", "chains": ["physics-causal-004", "physics-causal-005", "physics-causal-001"], "class": "three_hop_not_composed"}
{"id": "cs-phy-0016", "text": "Does energy require symmetry?", "gold": "unknown", "chains": ["physics-modal-004", "physics-modal-001"], "class": "two_hop_modal_not_composed"}
{"id": "cs-phy-0017", "text": "Does momentum require force?", "gold": "unknown", "chains": ["physics-modal-003", "physics-modal-002"], "class": "two_hop_modal_not_composed"}
{"id": "cs-phy-0018", "text": "Does acceleration cause force?", "gold": "unknown", "chains": ["physics-causal-001"], "class": "reverse_direction"}
{"id": "cs-phy-0019", "text": "Does motion cause acceleration?", "gold": "unknown", "chains": ["physics-causal-002"], "class": "reverse_direction"}
{"id": "cs-phy-0020", "text": "Does entropy cause energy?", "gold": "unknown", "chains": ["physics-causal-007"], "class": "relation_not_interchangeable"}
{"id": "cs-phy-0021", "text": "Does work cause motion?", "gold": "unknown", "chains": [], "class": "unlinked_pair"}
{"id": "cs-phy-0022", "text": "Does temperature require symmetry?", "gold": "unknown", "chains": [], "class": "unlinked_pair"}
{"id": "cs-phy-0023", "text": "Does mass require energy?", "gold": "unknown", "chains": [], "class": "anti_recall_true_but_untaught"}
{"id": "cs-phy-0024", "text": "Does force cause work?", "gold": "unknown", "chains": [], "class": "anti_recall_true_but_untaught"}
{"id": "cs-phy-0025", "text": "Does charge require field?", "gold": "unknown", "chains": ["physics-modal-007"], "class": "reverse_direction_modal"}
{"id": "cs-phy-0026", "text": "Does field require charge?", "gold": "entailed", "chains": ["physics-modal-007"], "class": "taught_edge_modal"}
{"id": "cs-phy-0027", "text": "Does gravity cause acceleration?", "gold": "declined", "chains": [], "class": "anti_recall_untaught_vocabulary"}
{"id": "cs-phy-0028", "text": "Does current cause field?", "gold": "declined", "chains": [], "class": "anti_recall_untaught_vocabulary"}
{"id": "cs-phy-0029", "text": "Does pressure require volume?", "gold": "declined", "chains": [], "class": "anti_recall_untaught_vocabulary"}
{"id": "cs-phy-0030", "text": "Does force explain acceleration?", "gold": "declined", "chains": [], "class": "out_of_curriculum_relation"}
{"id": "cs-phy-0031", "text": "Does force accelerate mass quickly?", "gold": "declined", "chains": [], "class": "question_shape_out_of_band"}
{"id": "cs-phy-0032", "text": "Does the force cause acceleration?", "gold": "declined", "chains": [], "class": "question_shape_out_of_band"}

View file

@ -0,0 +1,40 @@
{
"aggregate": {
"correct": 32,
"declined": 0,
"n": 32,
"wrong": 0
},
"all_correct": true,
"arc": "generalization",
"lane": "curriculum_serve",
"schema_version": 1,
"splits": {
"physics": {
"all_cases_correct": true,
"anti_recall_probes": 5,
"by_band": {
"curriculum_physics_causal": 14,
"curriculum_physics_modal": 12
},
"by_gold": {
"declined": 6,
"entailed": 14,
"unknown": 12
},
"correct_by_gold": {
"declined": 6,
"entailed": 14,
"unknown": 12
},
"counts": {
"correct": 32,
"declined": 0,
"wrong": 0
},
"domain": "physics",
"n": 32
}
},
"wrong_is_zero": true
}

View file

@ -0,0 +1,216 @@
"""Curriculum-serve lane — scores curriculum-grounded exam answering.
The Phase-2 lane of the generalization arc. For each committed case, the raw
question text runs through the exact production path
``chat/curriculum_surface.py::decide_curriculum_question`` question reader
subject routing ratified-curriculum premise compilation the argument bands
the ROBDD engine and the resulting verdict is compared to gold authored
INDEPENDENTLY by ``evals.curriculum_serve.oracle`` (plan §4.4).
Three guards run before any case is scored, and any of them failing is a lane
failure, not a case miss:
1. **Provenance** (§4.3) every chain id a case pins must resolve in the
subject's ratified curriculum. A case whose curriculum moved under it
breaks loudly instead of quietly answering from what is left.
2. **Corpus soundness** (§4.4) the independent oracle must agree with every
committed gold. A gold nobody can re-derive from the curriculum is not gold.
3. **Anti-recall coverage** (§4.7) the split MUST contain cases whose answer
is true in the world but absent from the curriculum. Without them the lane
cannot show the system decodes rather than recalls, and it must not ship.
Counts mirror the deduction-serve lane exactly: ``wrong`` (a committed verdict
that disagrees with gold) MUST stay 0; a decline where gold expected a verdict
is a coverage miss, tracked but never conflated with a confabulation.
"""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
from chat.curriculum_surface import decide_curriculum_question, read_curriculum_question
from evals.curriculum_serve.oracle import oracle_answer
from teaching.curriculum_premises import load_curriculum, resolve_pinned
_ROOT = Path(__file__).resolve().parent
_SPLITS: tuple[tuple[str, str, Path], ...] = (
# (split name, subject domain, cases)
("physics", "physics", _ROOT / "physics" / "cases.jsonl"),
)
#: A split must carry at least this many anti-recall probes (§4.7). The class
#: prefix is the contract: a probe is a case whose answer is true in the world
#: and absent from the curriculum, so the honest verdict is a non-commitment.
_ANTI_RECALL_PREFIX = "anti_recall"
_MIN_ANTI_RECALL = 3
class LaneContractError(AssertionError):
"""A guard failed — the lane itself is unsound, not merely under-covered."""
def _load(path: Path) -> list[dict]:
with path.open(encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
def assert_provenance(domain: str, cases: list[dict]) -> None:
"""Every pinned chain id must resolve in the ratified curriculum (§4.3)."""
curriculum = load_curriculum(domain)
for case in cases:
pinned = tuple(case.get("chains", ()))
if not pinned:
continue
resolve_pinned(curriculum, pinned) # raises UnratifiedChain
def assert_corpus_sound(domain: str, cases: list[dict]) -> None:
"""The independent oracle must re-derive every committed gold (§4.4)."""
for case in cases:
query = read_curriculum_question(case["text"])
gold = case["gold"]
if not hasattr(query, "subject"):
# Unreadable question shapes are gold-``declined`` by construction;
# the oracle has no opinion on a text it cannot parse either.
if gold != "declined":
raise LaneContractError(
f"{case['id']}: unreadable question with gold={gold}"
)
continue
verdict = oracle_answer(domain, query.subject, query.verb, query.obj)
if verdict.verdict != gold:
raise LaneContractError(
f"{case['id']}: oracle={verdict.verdict} gold={gold} "
f"(family={verdict.family} depth={verdict.depth}) text={case['text']!r}"
)
def assert_anti_recall_coverage(cases: list[dict]) -> None:
"""The split must probe recall, or it must not ship (§4.7)."""
probes = [c for c in cases if str(c.get("class", "")).startswith(_ANTI_RECALL_PREFIX)]
if len(probes) < _MIN_ANTI_RECALL:
raise LaneContractError(
f"split has {len(probes)} anti-recall probes, needs >= {_MIN_ANTI_RECALL}"
)
for probe in probes:
if probe["gold"] in ("entailed", "refuted"):
raise LaneContractError(
f"{probe['id']}: an anti-recall probe cannot have a committed gold"
)
def build_report(domain: str, cases: list[dict]) -> dict:
assert_provenance(domain, cases)
assert_corpus_sound(domain, cases)
assert_anti_recall_coverage(cases)
counts = Counter({"correct": 0, "wrong": 0, "declined": 0})
by_gold: Counter[str] = Counter()
correct_by_gold: Counter[str] = Counter()
by_band: Counter[str] = Counter()
mismatches: list[dict] = []
for case in cases:
gold = case["gold"]
by_gold[gold] += 1
decision = decide_curriculum_question(case["text"])
if decision.band:
by_band[decision.band] += 1
if decision.verdict == gold:
counts["correct"] += 1
correct_by_gold[gold] += 1
elif decision.verdict == "declined":
counts["declined"] += 1
mismatches.append(
{"id": case["id"], "gold": gold, "got": decision.verdict,
"reason": decision.reason, "text": case["text"]}
)
else:
counts["wrong"] += 1
mismatches.append(
{"id": case["id"], "gold": gold, "got": decision.verdict,
"reason": decision.reason, "text": case["text"]}
)
return {
"n": len(cases),
"counts": dict(counts),
"by_gold": dict(by_gold),
"correct_by_gold": dict(correct_by_gold),
"by_band": dict(by_band),
"anti_recall_probes": sum(
1 for c in cases if str(c.get("class", "")).startswith(_ANTI_RECALL_PREFIX)
),
"all_cases_correct": counts["correct"] == len(cases),
"mismatch_examples": mismatches[:10],
}
def build_combined_report() -> dict:
"""Deterministic per-split + aggregate report — safe to SHA-pin."""
splits: dict[str, dict] = {}
aggregate = {"n": 0, "correct": 0, "wrong": 0, "declined": 0}
for name, domain, path in _SPLITS:
report = build_report(domain, _load(path))
splits[name] = {
"n": report["n"],
"domain": domain,
"counts": report["counts"],
"by_gold": report["by_gold"],
"correct_by_gold": report["correct_by_gold"],
"by_band": report["by_band"],
"anti_recall_probes": report["anti_recall_probes"],
"all_cases_correct": report["all_cases_correct"],
}
aggregate["n"] += report["n"]
for key in ("correct", "wrong", "declined"):
aggregate[key] += report["counts"][key]
return {
"schema_version": 1,
"lane": "curriculum_serve",
"arc": "generalization",
"splits": splits,
"aggregate": aggregate,
"wrong_is_zero": aggregate["wrong"] == 0,
"all_correct": all(s["all_cases_correct"] for s in splits.values()),
}
def write_combined_report(path: Path) -> dict:
report = build_combined_report()
path.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--report", type=Path, default=None)
args = parser.parse_args(argv)
if args.report is not None:
report = write_combined_report(args.report)
return 0 if (report["wrong_is_zero"] and report["all_correct"]) else 1
all_ok = True
for name, domain, path in _SPLITS:
report = build_report(domain, _load(path))
c = report["counts"]
print(
f"[{name}] n={report['n']} correct={c['correct']} wrong={c['wrong']} "
f"declined_mismatch={c['declined']} anti_recall={report['anti_recall_probes']}"
)
for m in report["mismatch_examples"]:
print(f" {m['id']}: gold={m['gold']} got={m['got']} "
f"reason={m['reason']} text={m['text']!r}")
all_ok = all_ok and report["all_cases_correct"]
return 0 if all_ok else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -509,10 +509,22 @@ def read_verb_argument(text: str) -> VerbArgument | VerbRefusal:
)
def verb_forms_link(a: str, b: str) -> bool:
"""Public view of this band's closed verb-agreement relation.
Exported so paths OUTSIDE the proof_chain package (the curriculum
composer, ADR-0262) can normalize a base form against a stated
third-person form using the SAME closed table and suffix rules the reader
uses one agreement authority, not two.
"""
return _verb_tokens_link(a, b)
__all__ = [
"MAX_ATOMS",
"MAX_PREMISE_SENTENCES",
"VerbArgument",
"VerbRefusal",
"read_verb_argument",
"verb_forms_link",
]

View file

@ -74,6 +74,10 @@ _LANE_ADR: dict[str, tuple[str, str]] = {
"ADR-0206",
"Propositional entailment scored against an independent truth-table oracle; dev+holdout+external 716/716 correct, wrong=0, refused=0",
),
"curriculum_serve_v1": (
"ADR-0262",
"Flag-gated curriculum serving answers exam questions from a subject's RATIFIED chain corpus only; untaught facts return UNKNOWN (anti-recall probes enforced), wrong=0",
),
"deduction_serve_v1": (
"ADR-0256",
"Flag-gated deduction serving decides real English/member/fused/verb/existential arguments end-to-end under earned SERVE licenses; wrong=0 across all splits",

View file

@ -52,6 +52,7 @@ PINNED_SHAS: dict[str, str] = {
"math_teaching_corpus_v1": "eaf160d145da29f9050ede8d58bf111b0f651dd40aeae9201857d0b97e014dd4",
"deductive_logic_v1": "97a230949016e38d5e3f37a69e4245b320575ee70e5af92ff7607f7b05f74b5f",
"deduction_serve_v1": "0b461a5a49c8f8260ca87d0c9c9f9a17232bd1fdedd982e34649eedf9cca30b5",
"curriculum_serve_v1": "d9e7ba500f040b865870413a940ee9a49910ac22e1a89c9feec1a60bdd2513f1",
}
@ -137,6 +138,12 @@ LANE_SPECS: tuple[LaneSpec, ...] = (
report_relative="evals/deduction_serve/report.json",
run_as_module=True,
),
LaneSpec(
lane_id="curriculum_serve_v1",
runner_module="evals/curriculum_serve/runner.py",
report_relative="evals/curriculum_serve/report.json",
run_as_module=True,
),
)

View file

@ -0,0 +1,251 @@
"""Curriculum → premise compiler (generalization arc Phase 2, ADR-0262).
The load-bearing half of the curriculum-entailment gold contract
(docs/plans/generalization-arc-2026-07-24.md §4): an exam question supplies
only the QUERY; its premises are compiled here, from the RATIFIED domain-chain
corpus of the subject being examined, and from nowhere else. Gold is therefore
a function of *(curriculum, question)* never of case-local hidden text, and
never of what a model happens to know about the world. That is the
decoding-not-generating line in mechanical form: a fact absent from the
curriculum is UNKNOWN, however true it is.
**Ratification** (§4.3) is the same predicate the capability reporter applies
to these corpora, restated here rather than imported: a chain counts iff its
``review_status`` is ``reviewed`` AND both its subject and object lemmas are
resident in the packs the chain itself declares. The duplication is
deliberate and narrow a serving path must not take a dependency on the
reporting subsystem, and this module must be able to state its own admission
rule in the terms the serve-time refusals name.
**Compilation is family-scoped.** A question about a `causes` relation is
answered from the `causes` chains. Restricting cannot lose an entailment (a
chain's family is fixed by its connective, so an edge that would answer the
question is always in the question's own family), and it keeps the compiled
premise set inside the reader's honesty caps as corpora grow.
The compiler emits plain English sentences ``"force causes acceleration"``
because the argument bands are the decider (ADR-02560261). There is no
subject-specific decision code anywhere in this path: physics differs from
philosophy only in which rows load.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from core.capability.domains import (
DOMAIN_CAPABILITY_CORPORA,
DOMAIN_CORPORA,
DOMAIN_PACKS,
)
_REPO_ROOT = Path(__file__).resolve().parents[1]
#: Connective → relation family. The closed set this path serves; a chain
#: whose connective is absent is not compiled (and cannot be questioned about
#: either, so the two ends stay consistent).
#:
#: The CONNECTIVE is the sole family authority here, deliberately — not the
#: row's own ``operator_family`` field. An exam question carries a relation
#: word and nothing else, so a family derived from anything the question does
#: not carry would let premise compilation and question routing disagree, and
#: a taught edge could then be missing from the premises compiled to decide
#: it — a wrong answer built from a correct curriculum. Two corpus rows
#: currently declare a family their connective does not imply
#: (``physics-causal-008`` and one systems_software row, both
#: ``requires``/``causal``); under this rule they are read as modal, which is
#: what their connective says.
CONNECTIVE_FAMILY: dict[str, str] = {
"causes": "causal",
"reveals": "causal",
"grounds": "causal",
"requires": "modal",
"enables": "modal",
"precedes": "sequence",
"opposes": "contrast",
"supports": "evidential",
}
#: Relation families, in a stable order (the band-key axis — ADR-0262 §4).
FAMILIES: tuple[str, ...] = ("causal", "modal", "sequence", "contrast", "evidential")
@dataclass(frozen=True, slots=True)
class CurriculumChain:
"""One ratified curriculum edge, as the exam path reads it."""
chain_id: str
subject: str
connective: str
obj: str
family: str
@property
def sentence(self) -> str:
"""The English premise this chain compiles to. The connective is
already a third-person-singular verb form, so the sentence lands in
the verb-predicate grammar (ADR-0260) exactly as written."""
return f"{self.subject} {self.connective} {self.obj}"
@dataclass(frozen=True, slots=True)
class Curriculum:
"""The ratified curriculum of one subject — the ONLY premise source."""
domain: str
chains: tuple[CurriculumChain, ...]
vocabulary: frozenset[str]
def family(self, family: str) -> tuple[CurriculumChain, ...]:
return tuple(c for c in self.chains if c.family == family)
def chain_by_id(self, chain_id: str) -> CurriculumChain | None:
return next((c for c in self.chains if c.chain_id == chain_id), None)
@lru_cache(maxsize=None)
def _pack_lemmas(pack_id: str) -> frozenset[str]:
path = _REPO_ROOT / "packs" / "data" / pack_id / "lexicon.jsonl"
if not path.exists():
return frozenset()
lemmas: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
lemma = row.get("lemma") if isinstance(row, dict) else None
if isinstance(lemma, str):
lemmas.add(lemma)
return frozenset(lemmas)
def _domain_vocabulary(domain: str) -> frozenset[str]:
"""Every lemma the subject's mounted packs teach — the vocabulary boundary
an exam question may not cross (§4.6 ``untaught_vocabulary``)."""
lemmas: set[str] = set()
for pack_id in DOMAIN_PACKS.get(domain, ()):
lemmas |= _pack_lemmas(pack_id)
return frozenset(lemmas)
def _ratified_rows(corpus_id: str, domain: str) -> list[dict]:
rel = DOMAIN_CAPABILITY_CORPORA.get(corpus_id)
if rel is None:
return []
path = _REPO_ROOT / rel
if not path.exists():
return []
rows: list[dict] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(row, dict):
continue
if row.get("review_status") != "reviewed":
continue
if str(row.get("domain") or "").strip() != domain:
continue
subject = str(row.get("subject") or "").strip()
obj = str(row.get("object") or "").strip()
subject_pack = str(row.get("subject_pack_id") or "").strip()
object_pack = str(row.get("object_pack_id") or "").strip()
if not subject or not obj:
continue
if subject_pack and subject not in _pack_lemmas(subject_pack):
continue
if object_pack and obj not in _pack_lemmas(object_pack):
continue
rows.append(row)
return rows
@lru_cache(maxsize=None)
def load_curriculum(domain: str) -> Curriculum:
"""The ratified curriculum for *domain* — deterministic, corpus order.
Unratified rows, rows whose terms have left their packs, and rows whose
connective is outside :data:`CONNECTIVE_FAMILY` are DROPPED here, at the
admission boundary, so that everything downstream can treat every chain it
sees as licensed premise material. A dropped chain is not a silent
weakening of an argument the way a dropped *premise* would be (ADR-0261
§5.1): the exam path pins chain ids and fails loudly when a pinned chain
did not survive admission see ``resolve_pinned``.
"""
chains: list[CurriculumChain] = []
for corpus_id in DOMAIN_CORPORA.get(domain, ()):
for row in _ratified_rows(corpus_id, domain):
connective = str(row.get("connective") or "").strip()
family = CONNECTIVE_FAMILY.get(connective)
if family is None:
continue
chains.append(
CurriculumChain(
chain_id=str(row.get("chain_id") or ""),
subject=str(row.get("subject") or "").strip(),
connective=connective,
obj=str(row.get("object") or "").strip(),
family=family,
)
)
return Curriculum(
domain=domain,
chains=tuple(chains),
vocabulary=_domain_vocabulary(domain),
)
def compile_premises(
curriculum: Curriculum, family: str
) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""``(premise_sentences, chain_ids)`` for *family*, in corpus order.
Deterministic: the same curriculum and family always compile to the same
sentences in the same order, so a lane report over them is SHA-pinnable.
"""
chains = curriculum.family(family)
return tuple(c.sentence for c in chains), tuple(c.chain_id for c in chains)
class UnratifiedChain(LookupError):
"""A lane case pinned a chain that is absent or did not survive admission."""
def resolve_pinned(curriculum: Curriculum, chain_ids: tuple[str, ...]) -> tuple[CurriculumChain, ...]:
"""The pinned chains, or raise (§4.3).
A case that pins a chain the curriculum no longer ratifies MUST fail not
quietly answer from whatever else is present. This is the provenance guard
that keeps "gold is a function of the curriculum" true over time: if the
curriculum changes under a case, the case breaks loudly.
"""
resolved: list[CurriculumChain] = []
for chain_id in chain_ids:
chain = curriculum.chain_by_id(chain_id)
if chain is None:
raise UnratifiedChain(
f"{curriculum.domain}: pinned chain {chain_id!r} is absent or unratified"
)
resolved.append(chain)
return tuple(resolved)
__all__ = [
"CONNECTIVE_FAMILY",
"FAMILIES",
"Curriculum",
"CurriculumChain",
"UnratifiedChain",
"compile_premises",
"load_curriculum",
"resolve_pinned",
]

View file

@ -0,0 +1,211 @@
"""Curriculum-grounded serving (ADR-0262) — the Phase-2 contract.
These tests pin the epistemology, not just the plumbing: that answers come
from the ratified curriculum and nowhere else, that an untaught fact is
UNKNOWN rather than "no", that a chain the curriculum does not teach is never
composed into a claim, and that a question whose vocabulary the subject has
never been taught is declined rather than answered from world knowledge.
"""
from __future__ import annotations
import pytest
from chat.curriculum_serve_license import curriculum_serve_license
from chat.curriculum_surface import (
CurriculumQuery,
CurriculumRefusal,
band_for,
curriculum_grounded_surface,
decide_curriculum_question,
looks_like_curriculum_question,
read_curriculum_question,
resolve_domain,
resolve_family,
)
from evals.curriculum_serve.oracle import oracle_answer
from teaching.curriculum_premises import (
UnratifiedChain,
compile_premises,
load_curriculum,
resolve_pinned,
)
# --- the curriculum is the only premise source --------------------------------
def test_premises_come_only_from_ratified_chains() -> None:
curriculum = load_curriculum("physics")
premises, chain_ids = compile_premises(curriculum, "causal")
assert premises == (
"force causes acceleration",
"acceleration causes motion",
"work causes energy",
"charge causes field",
"field causes force",
"temperature reveals motion",
"entropy reveals energy",
)
assert all(cid.startswith("physics-") for cid in chain_ids)
def test_pinned_chain_that_is_not_ratified_fails_loudly() -> None:
"""§4.3 provenance: a case whose curriculum moved under it must break, not
quietly answer from what remains."""
curriculum = load_curriculum("physics")
with pytest.raises(UnratifiedChain):
resolve_pinned(curriculum, ("physics-causal-999",))
def test_family_scoping_never_loses_a_taught_edge() -> None:
"""Compilation is family-scoped; every taught edge is reachable from the
family its own connective implies, so scoping cannot hide an answer."""
curriculum = load_curriculum("physics")
for chain in curriculum.chains:
premises, _ = compile_premises(curriculum, chain.family)
assert chain.sentence in premises
# --- the verdicts that matter -------------------------------------------------
def test_taught_edge_is_entailed() -> None:
decision = decide_curriculum_question("Does force cause acceleration?")
assert decision.verdict == "entailed"
assert decision.band == band_for("physics", "causal")
def test_untaught_composition_is_unknown_not_entailed() -> None:
"""The curriculum teaches force→acceleration and acceleration→motion. It
does NOT teach that causation composes, so "force causes motion" is
unsettled. Composing it would be inventing a rule nobody taught."""
assert decide_curriculum_question("Does force cause motion?").verdict == "unknown"
def test_untaught_fact_is_unknown_never_refuted() -> None:
"""Open-world: silence is not denial. No question answerable from a purely
positive curriculum may come back "no"."""
for text in (
"Does mass require energy?", # true in the world, untaught
"Does motion cause force?", # the taught edge runs the other way
"Does work cause motion?", # both terms taught, no relation
):
assert decide_curriculum_question(text).verdict == "unknown", text
def test_relation_is_read_at_face_value() -> None:
""""entropy reveals energy" is taught; "entropy causes energy" is not. Same
family, different relation the curriculum said one and not the other."""
assert decide_curriculum_question("Does entropy reveal energy?").verdict == "entailed"
assert decide_curriculum_question("Does entropy cause energy?").verdict == "unknown"
def test_question_may_spell_the_relation_in_its_base_form() -> None:
"""The question says "cause", the curriculum says "causes" — normalized by
the SAME closed agreement relation the verb band uses (ADR-0260)."""
assert decide_curriculum_question("Does charge cause field?").verdict == "entailed"
# --- anti-recall ---------------------------------------------------------------
@pytest.mark.parametrize(
"text", ["Does gravity cause acceleration?", "Does current cause field?"]
)
def test_untaught_vocabulary_declines_rather_than_recalling(text: str) -> None:
"""Both are true physics and both are outside every mounted pack. The
system has no curriculum to decide them from, and says so."""
decision = decide_curriculum_question(text)
assert decision.verdict == "declined"
assert decision.reason == "untaught_vocabulary"
surface = curriculum_grounded_surface(text)
assert surface is not None
assert "haven't been taught" in surface
assert "not a claim about the world" in surface
# --- typed refusals ------------------------------------------------------------
@pytest.mark.parametrize(
"text, reason",
[
("Does force explain acceleration?", "out_of_curriculum"),
("Does the force cause acceleration?", "question_shape_out_of_band"),
("Does force accelerate?", "question_shape_out_of_band"),
],
)
def test_typed_refusals(text: str, reason: str) -> None:
assert decide_curriculum_question(text).reason == reason
def test_non_question_text_is_not_claimed_at_all() -> None:
"""The composer returns None for anything that is not a ``Does …?``
question, so pre-existing dispatch is byte-identical."""
assert curriculum_grounded_surface("Force causes acceleration.") is None
assert not looks_like_curriculum_question("Is force a cause of acceleration?")
def test_question_reader_shape() -> None:
query = read_curriculum_question("Does force cause acceleration?")
assert isinstance(query, CurriculumQuery)
assert (query.subject, query.verb, query.obj) == ("force", "cause", "acceleration")
assert isinstance(read_curriculum_question("Why does it move?"), CurriculumRefusal)
def test_domain_and_family_routing() -> None:
query = read_curriculum_question("Does force cause acceleration?")
assert isinstance(query, CurriculumQuery)
assert resolve_domain(query) == "physics"
assert resolve_family(query) == "causal"
# --- the license posture -------------------------------------------------------
def test_answers_are_disclosed_until_a_band_earns_serve() -> None:
"""No curriculum band holds a SERVE license yet (ADR-0262 §5: ratified
curriculum volume, not machinery, is the binding constraint), so every
answer is served DISCLOSED sound, and honest about its track record."""
assert curriculum_serve_license(band_for("physics", "causal")) is None
surface = curriculum_grounded_surface("Does force cause acceleration?")
assert surface is not None
assert surface.startswith("(answered from my curriculum")
def test_an_earned_band_is_served_authoritatively() -> None:
"""The gate is wired, not stubbed: hand it a licensed band and the hedge
disappears."""
class _Licensed:
licensed = True
surface = curriculum_grounded_surface(
"Does force cause acceleration?", license_lookup=lambda band: _Licensed()
)
assert surface is not None
assert not surface.startswith("(answered from my curriculum")
assert "physics curriculum teaches that force causes acceleration" in surface
# --- the independent oracle ----------------------------------------------------
def test_oracle_is_independent_and_agrees() -> None:
"""The oracle shares no code with the serving path; agreement across the
verdict classes is the evidence the compiler reads the curriculum right."""
for subject, relation, obj, expected in (
("force", "cause", "acceleration", "entailed"),
("force", "cause", "motion", "unknown"),
("gravity", "cause", "acceleration", "declined"),
):
assert oracle_answer("physics", subject, relation, obj).verdict == expected
def test_oracle_reports_reachability_without_claiming_it() -> None:
"""force→acceleration→motion is reachable at depth 2 and still UNKNOWN —
the property that proves composition is not being silently performed."""
verdict = oracle_answer("physics", "force", "cause", "motion")
assert verdict.verdict == "unknown"
assert verdict.depth == 2