core/chat/teaching_grounding.py
Shay 6207b5fd0e feat(register): R1–R4 register pack subsystem — deterministic surface variation
Introduces the presentation axis as a fourth pack class (sibling to identity /
safety / ethics), orthogonal to the truth path. Same input + same packs +
same register ⇒ bit-for-bit reproducible surface; varying any of the three ⇒
genuinely different output. No stochastic sampling.

ADR-0068 (R1): RegisterPack frozen dataclass, loader, ratify script, seam test.
  - default_neutral_v1 ratified as null register.

ADR-0069 (R2): realizer register parameter threaded through 9 composer entry
  points; RuntimeConfig.register_pack_id; three byte-identity invariants
  (A: None ≡ pre-R2 unregistered; B: None ≡ default_neutral_v1; C: trace_hash
  invariant under register). Amended to default-with-lint after 167-call-site
  scout: composers default to UNREGISTERED, AST lint enforces explicit
  register= at runtime call sites.

ADR-0070 (R3): terse_v1 register, first non-neutral pack. realizer_overrides
  schema with known-keys allow-list (disclosure_domain_count ∈ {1,2,3}).
  build_pack_surface_candidate reads override with fail-soft clamp. New
  invariant register_invariant_grounding asserts grounding_source +
  trace_hash byte-identical across {None, neutral, terse}.

ADR-0071 (R4): seeded surface variation via convivial_v1.
  chat/register_variation.py applies SHA-256-seeded marker selection from
  bounded discourse-marker buckets. ChatResponse.pre_decoration_surface routes
  truth-path surface to core/cognition/pipeline.py so trace_hash stays
  invariant under register (the load-bearing architectural fix — initially
  invariant C failed under convivial because decoration was leaking into
  trace_hash via response.surface). Empty-string marker entries now
  legitimate ("no marker this turn" is a valid seed pick). realizer_overrides
  schema widened with per_intent nested block (validated against IntentTag
  whitelist; wired but not exercised by convivial). Two new invariants:
  seeded_variation_replay_equivalence (fresh runtimes → byte-identical) and
  seeded_variation_turn_distinct (same prompt across turns → ≥2 distinct
  surfaces).

ADR-0072 (R5, draft): telemetry + operator surface — TurnEvent gains
  register_id and register_variant_id, core chat --register flag, core demo
  register-tour. Status: Proposed; not yet implemented.

Three ratified register packs ship: default_neutral_v1 (null), terse_v1
(disclosure_domain_count=1), convivial_v1 (3 openings × 3 closings).

Verification:
  - 84 register tests pass + 1 documented skip
  - Curated lanes green: smoke 67, cognition 120+1s, teaching 17, packs 6,
    runtime 19, algebra 132
  - Cognition eval byte-identical to pre-register baseline:
    public 100/100/91.7/100, holdout 100/100/83.3/100
  - Full lane: 2608 passed, 4 skipped, 1 failed (pre-existing
    test_cli_demo.py "Combined Demo" → "Run Every Demo" rename, unrelated)

Truth-path isolation: chat/register_variation.py is realizer-side; the seam
test (tests/test_register_pack_seam.py) refuses imports of packs.register
from intent classification, propagation, vault recall, trace hashing, and
algebra.
2026-05-19 16:52:36 -07:00

529 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""chat/teaching_grounding.py — teaching-grounded surface for cold-start
CAUSE and VERIFICATION intents (ADR-0052).
ADR-0048 added pack-grounded surfaces for cold-start DEFINITION / RECALL,
and ADR-0050 extended that to COMPARISON. Both consult the ratified
``en_core_cognition_v1`` pack as a second source of grounding alongside
the session vault.
CAUSE and VERIFICATION cannot be answered from pack ``semantic_domains``
alone — those describe a single subject, not a relation between two
subjects. But the system already has reviewed, auditable memory for a
small, well-known set of cognition-core chains (e.g. ``knowledge requires
evidence``, ``memory requires recall``, ``light reveals truth``). Per
the Teaching Safety discipline in CLAUDE.md, reviewed memory may
contribute grounding evidence; this module supplies that contribution as
a third grounding source.
The corpus lives at ``teaching/cognition_chains/cognition_chains_v1.jsonl``
and is treated as reviewed, immutable memory at runtime: each entry
names a subject lemma, an intent (``cause`` or ``verification``), a
fixed connective predicate already present in
``generate/semantic_templates.py:_PREDICATE_HUMANIZE``, and an object
lemma. Both lemmas must be present in the ratified cognition pack —
every visible non-template token in the emitted surface is therefore
either one of the two lemmas, a verbatim pack ``semantic_domains``
string, or a fixed-template connective. No LLM, no synthesis, no
inference.
Design constraints (matching ADR-0048 / ADR-0050 axioms):
- Reconstruction-over-storage: the surface is reconstructed from the
corpus + pack at call time; both are loaded once and cached because
the corpus is reviewed memory (immutable) and ratified packs are
immutable.
- Dual-correction: any subject not in the corpus, any intent outside
``{CAUSE, VERIFICATION}``, or any chain referencing lemmas missing
from the pack returns ``None`` and callers fall through to
``_UNKNOWN_DOMAIN_SURFACE`` unchanged.
- Trust boundary: every surface produced here is explicitly tagged
``teaching:cognition_chains_v1`` so the audit contract distinguishes
teaching-grounded surfaces from pack-grounded surfaces from
vault-grounded surfaces.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from chat.pack_grounding import PACK_ID as COGNITION_PACK_ID, _pack_index
from chat.pack_resolver import _pack_lexicon_for
from generate.intent import IntentTag
from generate.semantic_templates import humanize_predicate
from packs.register.loader import RegisterPack, UNREGISTERED
TEACHING_CORPUS_ID: str = "cognition_chains_v1"
_VALID_INTENTS: frozenset[str] = frozenset({"cause", "verification"})
_INTENT_TAG_BY_NAME: dict[str, IntentTag] = {
"cause": IntentTag.CAUSE,
"verification": IntentTag.VERIFICATION,
}
_TEACHING_ROOT = Path(__file__).resolve().parent.parent / "teaching"
_CORPUS_PATH = (
_TEACHING_ROOT
/ "cognition_chains"
/ f"{TEACHING_CORPUS_ID}.jsonl"
)
@dataclass(frozen=True, slots=True)
class TeachingCorpusSpec:
"""ADR-0064 — descriptor for one reviewed teaching corpus.
A corpus is a JSONL file of reviewed chains plus the single lexicon
pack whose vocabulary every chain in that corpus must reside in. The
1-to-1 corpus↔pack binding is the structural invariant that prevents
cross-domain leakage during cold-start surface composition: a
relations-domain chain cannot accidentally surface a cognition-pack
atom (or vice versa) because the pack-consistency check at load time
is scoped to the corpus's declared pack.
Each registered corpus is treated as immutable, reviewed memory.
Cross-domain triples (cognition × relations) are deliberately out of
scope for v1 — they require a follow-up ADR that introduces a
cross-pack chain shape, per ``docs/teaching_order.md`` §5.
"""
corpus_id: str
path: Path
pack_id: str
# ADR-0064 — registered teaching corpora. Order matters: chains in
# earlier corpora win on (subject, intent) collision. Cognition is
# listed first so the cognition-lane byte-identity invariant is
# preserved when a relations chain ever shares a key (today the
# orthogonal-pack invariant prevents any such collision, but the
# resolution rule is documented).
TEACHING_CORPORA: tuple[TeachingCorpusSpec, ...] = (
TeachingCorpusSpec(
corpus_id="cognition_chains_v1",
path=_TEACHING_ROOT / "cognition_chains" / "cognition_chains_v1.jsonl",
pack_id="en_core_cognition_v1",
),
TeachingCorpusSpec(
corpus_id="relations_chains_v1",
path=_TEACHING_ROOT / "relations_chains" / "relations_chains_v1.jsonl",
pack_id="en_core_relations_v1",
),
TeachingCorpusSpec(
corpus_id="relations_chains_v2",
path=_TEACHING_ROOT / "relations_chains_v2" / "relations_chains_v2.jsonl",
pack_id="en_core_relations_v2",
),
)
@dataclass(frozen=True, slots=True)
class TeachingChain:
"""One reviewed teaching chain.
Fields are copied verbatim from the JSONL line; the runtime never
mutates them. ``provenance`` is preserved for audit but not emitted
in the user-facing surface.
ADR-0064 — ``corpus_id`` records which registered teaching corpus
the chain belongs to so the surface tag and audit trail are
unambiguous when multiple corpora are active.
"""
chain_id: str
subject: str
intent: str
connective: str
object: str
domains_subject_k: int
domains_object_k: int
provenance: str
corpus_id: str = "cognition_chains_v1"
def _load_corpus(spec: TeachingCorpusSpec) -> dict[tuple[str, str], TeachingChain]:
"""ADR-0064 — load one registered teaching corpus.
Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
within this corpus only. Pack-consistency is scoped to
``spec.pack_id``: every chain's subject AND object must reside in
that specific pack's lexicon. Cross-pack chain shapes (e.g. a
relations subject with a cognition object) are out of scope for
v1 per ``docs/teaching_order.md`` §5 and produce a drop with no
surface impact.
ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
entry's ``superseded_by`` is dropped from the active view.
Append-only history on disk is preserved; the loader derives the
active set.
"""
if not spec.path.exists():
return {}
pack = _pack_lexicon_for(spec.pack_id)
if not pack:
return {}
superseded_ids: set[str] = set()
parsed_lines: list[dict] = []
for line in spec.path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
parsed_lines.append(entry)
sup = entry.get("superseded_by")
if isinstance(sup, str) and sup.strip():
superseded_ids.add(sup.strip())
out: dict[tuple[str, str], TeachingChain] = {}
for entry in parsed_lines:
subject = (entry.get("subject") or "").strip().lower()
intent = (entry.get("intent") or "").strip().lower()
obj = (entry.get("object") or "").strip().lower()
connective = (entry.get("connective") or "").strip()
if not subject or not intent or not obj or not connective:
continue
if intent not in _VALID_INTENTS:
continue
if subject not in pack or obj not in pack:
continue
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
if chain_id in superseded_ids:
continue
try:
chain = TeachingChain(
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
domains_subject_k=int(entry.get("domains_subject_k", 2)),
domains_object_k=int(entry.get("domains_object_k", 1)),
provenance=str(entry.get("provenance", "")),
corpus_id=spec.corpus_id,
)
except (TypeError, ValueError):
continue
out[(subject, intent)] = chain
return out
@lru_cache(maxsize=1)
def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
"""Load the cognition-chains corpus once (back-compat surface).
Retained for discovery / replay / audit consumers whose semantics
are scoped to the cognition corpus specifically. Cross-corpus
composition uses :func:`_all_chains_index` instead.
Returns ``{(subject_lower, intent_lower): TeachingChain}``. Entries
with invalid schema, unsupported intents, or with subject/object
missing from the ratified cognition pack are dropped — the corpus
is reviewed memory but the runtime still verifies pack consistency
on load so a pack-corpus skew cannot leak a non-pack atom into a
surface.
ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
entry's ``superseded_by`` is also dropped from the active view.
Append-only history on disk is preserved; the loader derives the
active set.
"""
if not _CORPUS_PATH.exists():
return {}
pack = _pack_index()
# First sweep: collect supersession claims. Only well-formed
# entries (parseable JSON object) can retire other entries — a
# malformed line cannot supersede a good one.
superseded_ids: set[str] = set()
parsed_lines: list[dict] = []
for line in _CORPUS_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
parsed_lines.append(entry)
sup = entry.get("superseded_by")
if isinstance(sup, str) and sup.strip():
superseded_ids.add(sup.strip())
out: dict[tuple[str, str], TeachingChain] = {}
for entry in parsed_lines:
subject = (entry.get("subject") or "").strip().lower()
intent = (entry.get("intent") or "").strip().lower()
obj = (entry.get("object") or "").strip().lower()
connective = (entry.get("connective") or "").strip()
if not subject or not intent or not obj or not connective:
continue
if intent not in _VALID_INTENTS:
continue
# Both lemmas MUST be in the ratified pack — guarantees every
# surface atom is pack-sourced.
if subject not in pack or obj not in pack:
continue
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
if chain_id in superseded_ids:
continue
try:
chain = TeachingChain(
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
domains_subject_k=int(entry.get("domains_subject_k", 2)),
domains_object_k=int(entry.get("domains_object_k", 1)),
provenance=str(entry.get("provenance", "")),
corpus_id=TEACHING_CORPUS_ID,
)
except (TypeError, ValueError):
continue
out[(subject, intent)] = chain
return out
@lru_cache(maxsize=1)
def _all_chains_index() -> dict[tuple[str, str], TeachingChain]:
"""ADR-0064 — aggregated view across every registered teaching corpus.
Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
across all corpora in :data:`TEACHING_CORPORA`. Registration order
is the resolution order: earlier corpora win on collision. The
cognition corpus is registered first so the cognition-lane
byte-identity invariant is preserved.
The :func:`_corpus_index` back-compat loader is **not** an input to
this aggregator — both consult the same underlying file but
:func:`_corpus_index` is reserved for cognition-corpus-only
consumers (audit, replay, discovery's gate). Cross-corpus surface
composition consults :func:`_all_chains_index`.
"""
aggregated: dict[tuple[str, str], TeachingChain] = {}
for spec in TEACHING_CORPORA:
corpus = _load_corpus(spec)
for key, chain in corpus.items():
if key not in aggregated:
aggregated[key] = chain
return aggregated
@lru_cache(maxsize=8)
def _pack_for_corpus(corpus_id: str) -> dict[str, tuple[str, ...]]:
"""Return the lexicon for the pack bound to *corpus_id*, cached.
ADR-0064 — each registered teaching corpus is bound to exactly
one lexicon pack via :data:`TEACHING_CORPORA`. Returns an empty
dict if *corpus_id* is unknown — callers see this as "chain
cannot be surfaced" and fall through to the universal disclosure.
"""
for spec in TEACHING_CORPORA:
if spec.corpus_id == corpus_id:
return _pack_lexicon_for(spec.pack_id)
return {}
def _intent_name(intent_tag: IntentTag) -> str | None:
"""Return the lower-case intent key for the corpus, or ``None``."""
if intent_tag is IntentTag.CAUSE:
return "cause"
if intent_tag is IntentTag.VERIFICATION:
return "verification"
return None
def teaching_grounded_surface(
subject_lemma: str,
intent_tag: IntentTag,
*,
register: RegisterPack = UNREGISTERED,
) -> str | None:
"""Return a deterministic teaching-grounded surface, or ``None``.
The surface format is fixed:
"{subject} — teaching-grounded ({corpus_id}): {ds1}; {ds2}.
{subject} {connective} {object} ({do1}). No session evidence yet."
Every visible non-template token is either one of the two lemmas, a
verbatim ``semantic_domains`` string from the ratified cognition
pack, or the connective predicate already humanised by
``generate.semantic_templates.humanize_predicate``. The trailing
disclosure (``No session evidence yet.``) is the constant
trust-boundary label that distinguishes teaching-grounded surfaces
from vault-grounded surfaces.
Returns ``None`` when:
- the lemma is empty or not a string,
- the intent tag is not ``CAUSE`` or ``VERIFICATION``,
- the (subject, intent) pair is not in the teaching corpus.
"""
if not subject_lemma or not isinstance(subject_lemma, str):
return None
key = subject_lemma.strip().lower()
if not key:
return None
intent_name = _intent_name(intent_tag)
if intent_name is None:
return None
chain = _all_chains_index().get((key, intent_name))
if chain is None:
return None
# ADR-0064 — pack-residency is scoped to the chain's resolving
# corpus. Each registered corpus is bound to exactly one pack.
pack = _pack_for_corpus(chain.corpus_id)
subject_domains = pack.get(chain.subject, ())
object_domains = pack.get(chain.object, ())
if not subject_domains or not object_domains:
return None
head_subject = "; ".join(
subject_domains[: max(1, chain.domains_subject_k)]
)
head_object = "; ".join(
object_domains[: max(1, chain.domains_object_k)]
)
connective = humanize_predicate(chain.connective)
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object}). No session evidence yet."
)
def teaching_grounded_surface_composed(
subject_lemma: str,
intent_tag: IntentTag,
*,
register: RegisterPack = UNREGISTERED,
) -> str | None:
"""ADR-0062 — chain-of-chains teaching-grounded surface.
When a chain ``(A, intent_A, conn_A, B)`` exists AND a follow-up
chain ``(B, ?, conn_B, C)`` exists for either intent, compose a
two-clause surface:
"{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}.
{A} {conn_A} {B} ({dB1}), which {conn_B} {C} ({dC1}).
No session evidence yet."
Cycle-safe: if ``C == A`` or ``C == B``, the composer falls back
to the single-chain surface (no follow-up clause). Bounded depth:
v1 follows exactly one hop; deeper chains require a future ADR.
Follow-up intent preference: prefer ``cause`` when both exist
(causal continuation reads more naturally than a verification
detour). This preference is deterministic and pack-agnostic.
Returns ``None`` under the same conditions as
``teaching_grounded_surface``. When the initial chain exists
but no follow-up does, the composer degrades to the single-chain
surface byte-identically — drop-in replacement.
"""
if not subject_lemma or not isinstance(subject_lemma, str):
return None
key = subject_lemma.strip().lower()
if not key:
return None
intent_name = _intent_name(intent_tag)
if intent_name is None:
return None
corpus = _all_chains_index()
chain = corpus.get((key, intent_name))
if chain is None:
return None
# ADR-0064 — pack lookups follow each chain's resolving corpus.
pack = _pack_for_corpus(chain.corpus_id)
subject_domains = pack.get(chain.subject, ())
object_domains = pack.get(chain.object, ())
if not subject_domains or not object_domains:
return None
head_subject = "; ".join(
subject_domains[: max(1, chain.domains_subject_k)]
)
head_object_short = "; ".join(
object_domains[: max(1, chain.domains_object_k)]
)
connective = humanize_predicate(chain.connective)
# Look for a follow-up chain whose subject equals the initial
# chain's object. Prefer cause; fall back to verification.
follow_up = None
for next_intent in ("cause", "verification"):
candidate = corpus.get((chain.object, next_intent))
if candidate is None:
continue
# Cycle guard: don't follow if the next object is the initial
# subject (1-step cycle) or the same as the current object
# (degenerate same-cell mismatch).
if candidate.object in (chain.subject, chain.object):
continue
follow_up = candidate
break
if follow_up is None:
# No follow-up available — degrade to single-chain surface
# byte-identically with ``teaching_grounded_surface``.
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short}). No session evidence yet."
)
follow_pack = _pack_for_corpus(follow_up.corpus_id)
follow_object_domains = follow_pack.get(follow_up.object, ())
if not follow_object_domains:
# Follow-up's object isn't pack-resident with semantic domains
# — degrade to single-chain surface rather than emit a
# partially-grounded composition.
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short}). No session evidence yet."
)
follow_head = "; ".join(
follow_object_domains[: max(1, follow_up.domains_object_k)]
)
follow_connective = humanize_predicate(follow_up.connective)
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short}), which {follow_connective} {follow_up.object} "
f"({follow_head}). No session evidence yet."
)
def has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool:
"""Return True iff a reviewed chain exists for (subject, intent)
in any registered teaching corpus (ADR-0064 cross-corpus view)."""
if not subject_lemma or not isinstance(subject_lemma, str):
return False
intent_name = _intent_name(intent_tag)
if intent_name is None:
return False
return (subject_lemma.strip().lower(), intent_name) in _all_chains_index()
def clear_teaching_caches() -> None:
"""Drop every teaching-grounding lru_cache.
ADR-0064 — the replay-equivalence gate swaps ``_CORPUS_PATH`` to
a transient corpus and clears ``_corpus_index``; when multiple
corpora are registered the aggregated index must also reset so
the swap takes effect. Test-only and replay-only escape hatch;
production code never calls this on the hot path.
"""
_corpus_index.cache_clear()
_all_chains_index.cache_clear()
_pack_for_corpus.cache_clear()