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.
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""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-0256…0261). 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",
|
||
]
|