core/evals/curriculum_serve/oracle.py
Shay 44e78aa438 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.
2026-07-24 14:38:08 -07:00

189 lines
7.3 KiB
Python

"""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 connective→family 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/y↔ies 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"]