diff --git a/chat/teaching_grounding.py b/chat/teaching_grounding.py index 0b691715..cffb4021 100644 --- a/chat/teaching_grounding.py +++ b/chat/teaching_grounding.py @@ -50,6 +50,7 @@ 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 @@ -62,21 +63,69 @@ _INTENT_TAG_BY_NAME: dict[str, IntentTag] = { "verification": IntentTag.VERIFICATION, } +_TEACHING_ROOT = Path(__file__).resolve().parent.parent / "teaching" + _CORPUS_PATH = ( - Path(__file__).resolve().parent.parent - / "teaching" + _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", + ), +) + + @dataclass(frozen=True, slots=True) class TeachingChain: - """One reviewed cognition chain. + """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 @@ -87,11 +136,87 @@ class TeachingChain: 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. + """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 @@ -155,6 +280,7 @@ def _corpus_index() -> dict[tuple[str, str], TeachingChain]: 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 @@ -162,6 +288,46 @@ def _corpus_index() -> dict[tuple[str, str], TeachingChain]: 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: @@ -202,10 +368,12 @@ def teaching_grounded_surface( intent_name = _intent_name(intent_tag) if intent_name is None: return None - chain = _corpus_index().get((key, intent_name)) + chain = _all_chains_index().get((key, intent_name)) if chain is None: return None - pack = _pack_index() + # 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: @@ -218,7 +386,7 @@ def teaching_grounded_surface( ) connective = humanize_predicate(chain.connective) return ( - f"{chain.subject} — teaching-grounded ({TEACHING_CORPUS_ID}): " + f"{chain.subject} — teaching-grounded ({chain.corpus_id}): " f"{head_subject}. {chain.subject} {connective} {chain.object} " f"({head_object}). No session evidence yet." ) @@ -258,11 +426,12 @@ def teaching_grounded_surface_composed( intent_name = _intent_name(intent_tag) if intent_name is None: return None - corpus = _corpus_index() + corpus = _all_chains_index() chain = corpus.get((key, intent_name)) if chain is None: return None - pack = _pack_index() + # 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: @@ -294,18 +463,19 @@ def teaching_grounded_surface_composed( # No follow-up available — degrade to single-chain surface # byte-identically with ``teaching_grounded_surface``. return ( - f"{chain.subject} — teaching-grounded ({TEACHING_CORPUS_ID}): " + 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_object_domains = pack.get(follow_up.object, ()) + 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 ({TEACHING_CORPUS_ID}): " + f"{chain.subject} — teaching-grounded ({chain.corpus_id}): " f"{head_subject}. {chain.subject} {connective} {chain.object} " f"({head_object_short}). No session evidence yet." ) @@ -315,7 +485,7 @@ def teaching_grounded_surface_composed( ) follow_connective = humanize_predicate(follow_up.connective) return ( - f"{chain.subject} — teaching-grounded ({TEACHING_CORPUS_ID}): " + 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." @@ -323,10 +493,25 @@ def teaching_grounded_surface_composed( def has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool: - """Return True iff a reviewed chain exists for (subject, intent).""" + """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 _corpus_index() + 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() diff --git a/docs/curriculum/relations_chains_v1.md b/docs/curriculum/relations_chains_v1.md new file mode 100644 index 00000000..8edea095 --- /dev/null +++ b/docs/curriculum/relations_chains_v1.md @@ -0,0 +1,153 @@ +# Curriculum Unit — `relations_chains_v1` (Kinship Seed) + +**Date:** 2026-05-18 +**Author:** Shay +**Corpus ID:** `relations_chains_v1` +**Pack binding:** `en_core_relations_v1` (1:1, pack-internal only) +**Chain count:** 7 +**Status:** Ratified — initial reviewed seed for the kinship domain. + +--- + +## Why this unit + +The `en_core_relations_v1` kinship pack was mounted by default in +ADR-0063, but the live teaching-grounded path had no reviewed chains +for any kinship lemma. Every cold-start CAUSE/VERIFICATION on a +kinship prompt fell through to the universal disclosure even though +the lemmas were known. + +ADR-0064 closed that gap architecturally (cross-pack teaching corpora +registered, surface composers consult the aggregated index). This +unit closes it operationally — seven hand-authored chains that +exercise every formation gate end-to-end against a fresh corpus. + +Per [`teaching_order.md`](../teaching_order.md) §5 — "Pick *one* +commercial domain and run the full 1→4 progression *inside* that +domain before opening a second domain. Cross-domain triples come +last and only after both domains have ratified their own internal +DAG." Every chain in v1 is therefore **strictly pack-internal** to +`en_core_relations_v1`. Cross-domain triples (e.g. `family grounds +identity` with `identity` from cognition) are deliberately deferred. + +--- + +## The seven chains + +| Chain ID | Subject | Intent | Connective | Object | +|---|---|---|---|---| +| `cause_parent_precedes_child` | parent | cause | precedes | child | +| `cause_child_follows_parent` | child | cause | follows | parent | +| `cause_ancestor_precedes_descendant` | ancestor | cause | precedes | descendant | +| `cause_descendant_follows_ancestor` | descendant | cause | follows | ancestor | +| `cause_family_grounds_parent` | family | cause | grounds | parent | +| `verification_child_requires_parent` | child | verification | requires | parent | +| `verification_descendant_requires_ancestor` | descendant | verification | requires | ancestor | + +Pack-residency: every subject ∈ `en_core_relations_v1`, every +object ∈ `en_core_relations_v1`. Zero cognition-pack atoms. + +Predicate residency: every connective (`precedes`, `follows`, +`grounds`, `requires`) already exists in +`generate.semantic_templates._PREDICATE_HUMANIZE`. No new +predicates introduced in this seed. + +--- + +## Coverage + +Five of the eight ratified relations lemmas (`parent`, `child`, +`ancestor`, `descendant`, `family`) receive at least one chain. +Three lemmas (`sibling`, `spouse`, `offspring`) are intentionally +deferred to a future curriculum unit — they need lateral / affinal / +descendant-direct chains that compose more naturally once the v1 +ancestor-descendant axis is in place. + +--- + +## Live verification + +``` +$ core chat +> Why does parent exist? +parent — teaching-grounded (relations_chains_v1): +kinship.ascendant.direct; kinship.parent. parent precedes child +(kinship.descendant.direct). No session evidence yet. +grounding_source = teaching + +> Does child require parent? +child — teaching-grounded (relations_chains_v1): +kinship.descendant.direct; kinship.child. child requires parent +(kinship.ascendant.direct). No session evidence yet. +grounding_source = teaching +``` + +Every kinship CAUSE/VERIFICATION prompt covered by the seed now +emits a deterministic teaching-grounded surface tagged with the +resolving corpus id (`relations_chains_v1`), not the cognition tag. + +--- + +## Provenance + +Every line carries: + +``` +"provenance": "adr-0064:reviewed:2026-05-18:relations_seed_v1" +``` + +This is the **direct-append seed pattern** — the same shape used +when the cognition corpus was originally seeded pre-ADR-0055 +(provenance `adr-0052:reviewed:...`, `adr-0053:reviewed:...`). +The propose/replay/accept pipeline is for *additions* once a +corpus has chains to baseline against; for an empty-corpus seed, +the replay gate has no baseline and direct append is the +correct surface. + +Future chains added to `relations_chains_v1` must go through the +propose/replay/accept pipeline. The seed is the only direct-write. + +--- + +## Eval impact + +The cognition lane is **byte-identical** — cognition lemmas resolve +to the cognition corpus first and the orthogonal-pack invariant +prevents any (subject, intent) collision. Public/holdout splits +remain at: + +``` +public: intent 100% / surface 100% / term 91.7% / closure 100% +holdout: intent 100% / surface 100% / term 83.3% / closure 100% +``` + +Relations-domain coverage opens on the live path but is not yet +measured by a dedicated eval lane. A `relations` lane is the +natural follow-up. + +--- + +## Path forward + +1. **`relations` eval lane.** Mirror the cognition lane harness with + relations-domain prompts. Use the seven chains as ground truth. +2. **`sibling`/`spouse`/`offspring` chains** — extend the seed to + cover the remaining ratified lemmas. +3. **Pronoun + role-filler v2** — `mother`/`father`/`son`/`daughter` + chains as specializations of v1's primitives. +4. **Cross-domain triples** — only after the relations corpus is + internally saturated. Then `family grounds identity`, + `parent informs experience`, etc. + +--- + +## Cross-References + +- [ADR-0064](../decisions/ADR-0064-cross-pack-teaching-chains.md) — + the architectural unlock that made this seed possible. +- [Pack: `en_core_relations_v1`](relations_pack_v1.md) — the lexicon + this corpus is bound to. +- [`teaching_order.md`](../teaching_order.md) §5 — the + prerequisite-topological doctrine. +- [Cognition saturation v2](cognition_saturation_v2.md) — the + sibling cognition curriculum unit. diff --git a/docs/decisions/ADR-0064-cross-pack-teaching-chains.md b/docs/decisions/ADR-0064-cross-pack-teaching-chains.md new file mode 100644 index 00000000..5d606739 --- /dev/null +++ b/docs/decisions/ADR-0064-cross-pack-teaching-chains.md @@ -0,0 +1,233 @@ +# ADR-0064 — Cross-pack teaching chains + +**Status:** Accepted +**Date:** 2026-05-18 +**Author:** Shay +**Supersedes:** none (extends ADR-0052 / ADR-0062 / ADR-0063) +**Phase:** Plan Phase 1 (corpus flywheel) + +--- + +## Context + +ADR-0052 introduced reviewed teaching chains as a third grounding +source alongside vault and pack-grounded surfaces. The corpus +(`teaching/cognition_chains/cognition_chains_v1.jsonl`) is reviewed, +immutable, append-only memory. ADR-0063 brought +`en_core_relations_v1` (kinship pack) onto the live runtime, but the +teaching-grounded surface composer was still hardcoded to the +cognition corpus: + +```python +# chat/teaching_grounding.py — pre-ADR-0064 +TEACHING_CORPUS_ID: str = "cognition_chains_v1" +_CORPUS_PATH = .../cognition_chains/cognition_chains_v1.jsonl +``` + +Every cold-start CAUSE/VERIFICATION prompt on a kinship lemma fell +through to the universal disclosure even though the relations pack +was mounted, because no kinship chain corpus existed and no path +existed to register one. + +ADR-0064 closes that gap architecturally. ADR-0063 was the resolver +unlock at the *pack* layer; ADR-0064 is the same unlock at the +*teaching corpus* layer. + +--- + +## Decision + +### 1. Teaching corpus registry + +A new dataclass + constant in `chat/teaching_grounding.py`: + +```python +@dataclass(frozen=True, slots=True) +class TeachingCorpusSpec: + corpus_id: str + path: Path + pack_id: str + +TEACHING_CORPORA: tuple[TeachingCorpusSpec, ...] = ( + TeachingCorpusSpec("cognition_chains_v1", .../cognition_chains_v1.jsonl, "en_core_cognition_v1"), + TeachingCorpusSpec("relations_chains_v1", .../relations_chains_v1.jsonl, "en_core_relations_v1"), +) +``` + +Each corpus is **1:1-bound to exactly one lexicon pack**. The 1:1 +binding is the structural invariant that prevents cross-domain +leakage during cold-start surface composition: a relations chain +cannot accidentally surface a cognition-pack atom (or vice versa) +because pack-residency at load time is scoped to the corpus's +declared pack. Cross-domain chain shapes are out of scope for v1 +per `docs/teaching_order.md` §5. + +### 2. Aggregated chain index + +`_all_chains_index()` — `lru_cache`d aggregator that loads every +registered corpus via `_load_corpus(spec)` and unions them into a +single `{(subject, intent): TeachingChain}` view. Registration order +is the resolution order; cognition is registered first so cognition- +lane byte-identity is preserved on any future cross-corpus +collision. + +`TeachingChain` gains a `corpus_id` field so the surface tag and +audit trail are unambiguous when multiple corpora are active. + +### 3. Surface composers consult the aggregated view + +`teaching_grounded_surface` and `teaching_grounded_surface_composed` +now call `_all_chains_index()`. The surface tag follows the chain's +resolving `corpus_id`: + +``` +parent → teaching-grounded (relations_chains_v1) +light → teaching-grounded (cognition_chains_v1) +``` + +Cognition-lane surfaces remain byte-identical; relations-pack lemmas +now ground on the live path. + +### 4. Discovery gate updated for cross-corpus residency + +`teaching/discovery.py` previously gated on +`(lemma in cognition_pack) AND ((lemma, intent) not in cognition_corpus)`. +Updated to: + +```python +from chat.pack_resolver import is_resolvable +from chat.teaching_grounding import _all_chains_index + +if not is_resolvable(lemma): # any mounted pack + return () +if (lemma, intent_name) in _all_chains_index(): # any registered corpus + return () +``` + +A kinship CAUSE prompt that lacks a relations chain is now +correctly flagged as a discovery candidate, instead of being +suppressed because the cognition pack doesn't carry the lemma. + +### 5. Replay-equivalence gate registers the swap + +`teaching/replay.py`'s `_swap_corpus_path` was extended to also +rewrite the registry entry's `path` for the swapped corpus AND +invalidate `_all_chains_index` cache, so surface composers re-read +the swapped corpus during the gate's transient phase. The active +corpus on disk remains byte-identical to its pre-swap state — the +replay invariant is preserved. + +### 6. Back-compat + +`_corpus_index()`, `_CORPUS_PATH`, `TEACHING_CORPUS_ID` retain +cognition-corpus-specific semantics for consumers whose scope is +explicitly the cognition corpus (audit, replay's cognition-public +runner, replay state-tracking). They are not removed. The aggregated +`_all_chains_index` is the new abstraction for surface composers +and the discovery gate. + +A new helper `clear_teaching_caches()` drops every teaching-related +`lru_cache` atomically — replaces ad-hoc `_corpus_index.cache_clear()` +calls in replay code paths. + +--- + +## Consequences + +### Capability unlocked + +| Path | Cognition lemmas | Kinship lemmas | +|---|---|---| +| `teaching_grounded_surface(CAUSE/VERIFICATION)` | byte-identical | **now grounds** for cells in `relations_chains_v1` | +| `teaching_grounded_surface_composed` | byte-identical | composes within relations corpus when chain-of-chains exists | +| Discovery gate | byte-identical | now emits candidates for kinship cells absent from the relations corpus | + +### Cognition lane: byte-identical + +Cognition lemmas resolve to the cognition corpus first; the +orthogonal-pack invariant prevents any (subject, intent) collision +between corpora. Public/holdout eval baselines unchanged: + +``` +public: intent 100% / surface 100% / term 91.7% / closure 100% +holdout: intent 100% / surface 100% / term 83.3% / closure 100% +``` + +### Live verification + +``` +$ core chat +> Why does parent exist? +parent — teaching-grounded (relations_chains_v1): kinship.ascendant.direct; +kinship.parent. parent precedes child (kinship.descendant.direct). +No session evidence yet. +grounding_source = teaching +``` + +### Future ADRs unlocked + +1. **Cross-domain triples.** Once relations corpus saturates + internally, a follow-up ADR can extend the chain shape to allow + subject and object in different packs (e.g. `family grounds + identity` — family ∈ relations, identity ∈ cognition). +2. **Relations eval lane.** Mirror the cognition lane harness with + relations-domain holdout cases. The seed corpus is the ground + truth. +3. **Audit + supersede for the relations corpus.** `teaching audit` + and `teaching supersede` are cognition-corpus-only today; the + registry layer makes generalizing them mechanical. + +--- + +## Trust boundaries + +- Each corpus is 1:1-bound to one lexicon pack. The binding is + declared statically in `TEACHING_CORPORA` — runtime cannot + introduce a new corpus or rebind a pack. +- Chain loading is read-only over immutable, reviewed, append-only + files. Same trust boundary as ADR-0052. +- Surface tag tokens emitted are corpus_id strings declared in + `TEACHING_CORPORA` — never derived from user input. +- The replay-gate swap rewrites the registry tuple in-place for the + duration of the gate's transient phase and restores it on exit. + Side-effect-free from outside the contextmanager. + +--- + +## Files changed + +``` +chat/teaching_grounding.py registry layer +teaching/discovery.py cross-corpus gate +teaching/replay.py swap-the-registry +teaching/relations_chains/relations_chains_v1.jsonl NEW (seed corpus, 7 chains) +tests/test_relations_chains_v1.py NEW (17 tests) +docs/decisions/ADR-0064-cross-pack-teaching-chains.md NEW (this file) +docs/decisions/README.md index entry +docs/curriculum/relations_chains_v1.md NEW (curriculum unit doc) +``` + +--- + +## Verification + +``` +tests/test_relations_chains_v1.py 17 passed +tests/test_teaching_audit.py 23 passed +tests/test_composed_surface.py 11 passed +tests/test_teaching_grounding.py passed +tests/test_discovery_candidates.py 24 passed + +Lanes: + core test --suite smoke 67 passed + core test --suite cognition 121 passed + core test --suite teaching 17 passed + core test --suite packs 6 passed + core test --suite runtime 19 passed + core test --suite algebra 132 passed +``` + +The non-negotiable field invariant `versor_condition(F) < 1e-6` is +unaffected — this ADR is a routing/dispatch change over immutable +corpus data; no algebra, no field operators, no normalization sites +were touched. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b8e21d5a..7363a0c7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,6 +72,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt | [ADR-0060](ADR-0060-correction-acknowledgment-topic-lemma.md) | CORRECTION acknowledgement surface weaves the first pack-resident topical lemma from the utterance (left-to-right, excluding `correction` itself and `be`/`have` fillers) into a fixed template; backward-compatible with ADR-0053 (no-arg path byte-identical); closes `correction_truth_040` holdout miss; holdout `term_capture_rate` 75.0% → 79.2% | **Accepted** (2026-05-18) | | [ADR-0061](ADR-0061-procedure-intent-pack-grounded-surface.md) | PROCEDURE intent (`"How do I X?"`) routes to new `pack_grounded_procedure_surface`; selector picks **last** pack-resident lemma from verb-phrase subject (object > verb), falls back to verb when object is OOV, returns `None` (→ universal disclosure) for no-pack-lemma utterances; closes `procedure_define_010` (term `concept`) + `procedure_verify_034` (surface); holdout `surface_groundedness` 94.7% → 100.0%; `term_capture_rate` 79.2% → 83.3% | **Accepted** (2026-05-18) | | [ADR-0062](ADR-0062-composed-teaching-grounded-surface.md) | Composed teaching-grounded surface: when a chain `(A, intent_A, conn_A, B)` has a follow-up chain `(B, ?, conn_B, C)`, emit `"{A} {conn_A} {B}, which {conn_B} {C}"` instead of just `"{A} {conn_A} {B}"`; depth-1 (one hop) + cycle guard + pack-residency guard; degrades to single-chain byte-identically when no follow-up survives the guards; opt-in via `RuntimeConfig.composed_surface=False` default; cognition lane null-drop invariant (metrics byte-identical flag OFF/ON) CI-pinned | **Accepted** (2026-05-18) | +| [ADR-0064](ADR-0064-cross-pack-teaching-chains.md) | Cross-pack teaching chains: `chat/teaching_grounding.py` registers a tuple of `TeachingCorpusSpec(corpus_id, path, pack_id)`; each corpus is 1:1-bound to one lexicon pack (cross-domain triples deferred per teaching_order.md §5); new `_all_chains_index()` aggregates across registered corpora (first-match-wins); surface composers + discovery gate consult the aggregated view; `TeachingChain` gains `corpus_id` field; surface tag follows the resolving corpus id; replay-equivalence gate rewrites registry path during transient phase; `relations_chains_v1` seeded with 7 reviewed kinship chains; cognition lane byte-identical | **Accepted** (2026-05-18) | | [ADR-0063](ADR-0063-cross-pack-surface-resolver.md) | Cross-pack surface resolver: `chat/pack_resolver.py` introduces `resolve_lemma(lemma, pack_ids)` that maps a lemma to `(resolving_pack_id, semantic_domains)` across an ordered tuple of mounted lexicon packs (first-match-wins); pack-grounded DEFINITION / RECALL / COMPARISON / CORRECTION / PROCEDURE composers now consult the resolver instead of a hardcoded `en_core_cognition_v1`; surface trust-boundary tag follows the resolving pack id; `en_core_relations_v1` joins `RuntimeConfig.input_packs` defaults — kinship lemmas now ground on the live path without a separate composer module; cognition-lane surfaces remain byte-identical (cognition is resolved first) | **Accepted** (2026-05-18) | --- diff --git a/evals/learning_loop/run_demo.py b/evals/learning_loop/run_demo.py index ae4fa6af..d3f4e37e 100644 --- a/evals/learning_loop/run_demo.py +++ b/evals/learning_loop/run_demo.py @@ -361,16 +361,32 @@ def _scene5_replay_now_grounded(transient: Path) -> SceneResult: "narrative reveals meaning. Identical bytes for any replay of " "the same prompt against this corpus state.", ) + # ADR-0064 — the cognition corpus is one of several registered + # teaching corpora; surface composers now consult + # ``_all_chains_index`` instead of ``_corpus_index`` alone. We + # rewrite the registry entry's path for the duration of the swap + # and clear every teaching cache so the aggregator re-reads the + # transient corpus. real_path = _tg._CORPUS_PATH + original_specs = _tg.TEACHING_CORPORA + swapped_specs = tuple( + _tg.TeachingCorpusSpec( + corpus_id=s.corpus_id, + path=transient if s.corpus_id == _tg.TEACHING_CORPUS_ID else s.path, + pack_id=s.pack_id, + ) + for s in original_specs + ) try: _tg._CORPUS_PATH = transient # type: ignore[assignment] - _tg._corpus_index.cache_clear() - # Fresh runtime to avoid any per-instance state. + _tg.TEACHING_CORPORA = swapped_specs # type: ignore[misc] + _tg.clear_teaching_caches() rt2 = ChatRuntime() response = rt2.chat(_DEMO_PROMPT) finally: _tg._CORPUS_PATH = real_path # type: ignore[assignment] - _tg._corpus_index.cache_clear() + _tg.TEACHING_CORPORA = original_specs # type: ignore[misc] + _tg.clear_teaching_caches() surface = response.surface grounding = response.grounding_source diff --git a/teaching/discovery.py b/teaching/discovery.py index 33868674..6b668ee1 100644 --- a/teaching/discovery.py +++ b/teaching/discovery.py @@ -261,15 +261,19 @@ def extract_discovery_candidates( if not lemma: return () - from chat.pack_grounding import _pack_index - from chat.teaching_grounding import _corpus_index + from chat.pack_resolver import is_resolvable + from chat.teaching_grounding import _all_chains_index - pack = _pack_index() - if lemma not in pack: + # ADR-0064 — discovery gate uses cross-pack residency (any mounted + # lexicon pack) AND cross-corpus chain lookup (any registered + # teaching corpus). A kinship CAUSE prompt whose subject is in + # the relations pack but has no relations-chain in the active + # corpus is now also a discovery signal. + if not is_resolvable(lemma): return () intent_name = _TEACHING_INTENT_NAME[intent_tag] - if (lemma, intent_name) in _corpus_index(): + if (lemma, intent_name) in _all_chains_index(): return () # The candidate's proposed_chain is intentionally partial: Phase B diff --git a/teaching/relations_chains/relations_chains_v1.jsonl b/teaching/relations_chains/relations_chains_v1.jsonl new file mode 100644 index 00000000..ab2babfd --- /dev/null +++ b/teaching/relations_chains/relations_chains_v1.jsonl @@ -0,0 +1,7 @@ +{"chain_id":"cause_parent_precedes_child","subject":"parent","intent":"cause","connective":"precedes","object":"child","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"cause_child_follows_parent","subject":"child","intent":"cause","connective":"follows","object":"parent","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"cause_ancestor_precedes_descendant","subject":"ancestor","intent":"cause","connective":"precedes","object":"descendant","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"cause_descendant_follows_ancestor","subject":"descendant","intent":"cause","connective":"follows","object":"ancestor","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"cause_family_grounds_parent","subject":"family","intent":"cause","connective":"grounds","object":"parent","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"verification_child_requires_parent","subject":"child","intent":"verification","connective":"requires","object":"parent","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} +{"chain_id":"verification_descendant_requires_ancestor","subject":"descendant","intent":"verification","connective":"requires","object":"ancestor","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0064:reviewed:2026-05-18:relations_seed_v1"} diff --git a/teaching/replay.py b/teaching/replay.py index 98b011da..497cbc6b 100644 --- a/teaching/replay.py +++ b/teaching/replay.py @@ -48,13 +48,28 @@ def _swap_corpus_path(temp_path: Path) -> Iterator[None]: corpus on disk is not touched. """ real_path = _tg._CORPUS_PATH + # ADR-0064 — the cognition corpus is one of several registered + # teaching corpora. When we swap it for replay, we must also + # rewrite the registry entry's path AND invalidate the aggregated + # index so surface composers re-read the swapped corpus. + original_specs = _tg.TEACHING_CORPORA + swapped_specs = tuple( + _tg.TeachingCorpusSpec( + corpus_id=s.corpus_id, + path=temp_path if s.corpus_id == _tg.TEACHING_CORPUS_ID else s.path, + pack_id=s.pack_id, + ) + for s in original_specs + ) try: _tg._CORPUS_PATH = temp_path # type: ignore[assignment] - _tg._corpus_index.cache_clear() + _tg.TEACHING_CORPORA = swapped_specs # type: ignore[misc] + _tg.clear_teaching_caches() yield finally: _tg._CORPUS_PATH = real_path # type: ignore[assignment] - _tg._corpus_index.cache_clear() + _tg.TEACHING_CORPORA = original_specs # type: ignore[misc] + _tg.clear_teaching_caches() def _run_cognition_public() -> dict[str, float]: @@ -113,9 +128,10 @@ def run_replay_equivalence(chain: dict[str, Any]) -> ReplayEvidence: active_path = _tg._CORPUS_PATH active_bytes_before = active_path.read_bytes() if active_path.exists() else b"" - # Baseline: just run against the active corpus. Cache is cleared - # to make sure we read the current state of disk. - _tg._corpus_index.cache_clear() + # Baseline: just run against the active corpus. Caches are + # cleared to make sure we read the current state of disk for + # every registered teaching corpus (ADR-0064). + _tg.clear_teaching_caches() baseline = _run_cognition_public() # Candidate: build a transient corpus with the chain appended diff --git a/tests/test_relations_chains_v1.py b/tests/test_relations_chains_v1.py new file mode 100644 index 00000000..460af33f --- /dev/null +++ b/tests/test_relations_chains_v1.py @@ -0,0 +1,207 @@ +"""ADR-0064 — ``relations_chains_v1`` reviewed teaching corpus tests. + +The contract these tests pin: + + - The relations corpus loads cleanly via the cross-corpus + aggregator (``_all_chains_index``); none of its chains drop on + pack-residency or schema gates. + - Every chain's subject AND object resides in + ``en_core_relations_v1`` (strict pack-internal, per + ``docs/teaching_order.md`` §5 — no cross-domain triples in v1). + - Every connective is already humanised by + :data:`generate.semantic_templates._PREDICATE_HUMANIZE` (no new + predicates introduced in this seed). + - Each chain emits a deterministic teaching-grounded surface tagged + ``teaching-grounded (relations_chains_v1)``. + - The cognition lane invariant is preserved: cognition chains + still tag ``cognition_chains_v1`` byte-identically. +""" + +from __future__ import annotations + +import pytest + +from chat.teaching_grounding import ( + TEACHING_CORPORA, + _all_chains_index, + _load_corpus, + clear_teaching_caches, + has_teaching_chain, + teaching_grounded_surface, +) +from chat.pack_resolver import _pack_lexicon_for +from generate.intent import IntentTag +from generate.semantic_templates import _PREDICATE_HUMANIZE + + +RELATIONS_CORPUS_ID = "relations_chains_v1" +RELATIONS_PACK_ID = "en_core_relations_v1" + + +EXPECTED_CHAIN_IDS: frozenset[str] = frozenset({ + "cause_parent_precedes_child", + "cause_child_follows_parent", + "cause_ancestor_precedes_descendant", + "cause_descendant_follows_ancestor", + "cause_family_grounds_parent", + "verification_child_requires_parent", + "verification_descendant_requires_ancestor", +}) + + +@pytest.fixture(autouse=True) +def _isolate_caches(): + clear_teaching_caches() + yield + clear_teaching_caches() + + +# --------------------------------------------------------------------------- +# Registry — the relations corpus is registered +# --------------------------------------------------------------------------- + + +def test_relations_corpus_is_registered() -> None: + corpus_ids = {spec.corpus_id for spec in TEACHING_CORPORA} + assert RELATIONS_CORPUS_ID in corpus_ids + + +def test_relations_corpus_is_bound_to_relations_pack() -> None: + spec = next(s for s in TEACHING_CORPORA if s.corpus_id == RELATIONS_CORPUS_ID) + assert spec.pack_id == RELATIONS_PACK_ID + + +# --------------------------------------------------------------------------- +# Corpus content — every chain loads, lives in the right pack +# --------------------------------------------------------------------------- + + +def test_all_seed_chains_load_cleanly() -> None: + spec = next(s for s in TEACHING_CORPORA if s.corpus_id == RELATIONS_CORPUS_ID) + loaded = _load_corpus(spec) + chain_ids = {c.chain_id for c in loaded.values()} + assert chain_ids == EXPECTED_CHAIN_IDS + + +def test_every_chain_is_pack_internal_to_relations() -> None: + """Strict pack-internal invariant: subject AND object must both + reside in ``en_core_relations_v1``. Cross-domain triples are + deferred to a future ADR per teaching_order.md §5.""" + spec = next(s for s in TEACHING_CORPORA if s.corpus_id == RELATIONS_CORPUS_ID) + pack = _pack_lexicon_for(RELATIONS_PACK_ID) + loaded = _load_corpus(spec) + for chain in loaded.values(): + assert chain.subject in pack, ( + f"{chain.chain_id}: subject {chain.subject!r} not in relations pack" + ) + assert chain.object in pack, ( + f"{chain.chain_id}: object {chain.object!r} not in relations pack" + ) + + +def test_every_connective_is_humanised() -> None: + """No new predicates introduced in the v1 seed — every connective + must already appear in ``_PREDICATE_HUMANIZE``.""" + spec = next(s for s in TEACHING_CORPORA if s.corpus_id == RELATIONS_CORPUS_ID) + loaded = _load_corpus(spec) + for chain in loaded.values(): + assert chain.connective in _PREDICATE_HUMANIZE, ( + f"{chain.chain_id}: connective {chain.connective!r} not humanised — " + f"add to generate/semantic_templates.py or pick an existing one" + ) + + +def test_corpus_id_recorded_on_loaded_chains() -> None: + spec = next(s for s in TEACHING_CORPORA if s.corpus_id == RELATIONS_CORPUS_ID) + loaded = _load_corpus(spec) + for chain in loaded.values(): + assert chain.corpus_id == RELATIONS_CORPUS_ID + + +# --------------------------------------------------------------------------- +# Aggregated index — chains visible cross-corpus +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "subject,intent", + [ + ("parent", IntentTag.CAUSE), + ("child", IntentTag.CAUSE), + ("ancestor", IntentTag.CAUSE), + ("descendant", IntentTag.CAUSE), + ("family", IntentTag.CAUSE), + ("child", IntentTag.VERIFICATION), + ("descendant", IntentTag.VERIFICATION), + ], +) +def test_has_teaching_chain_finds_relations_chains(subject: str, intent: IntentTag) -> None: + assert has_teaching_chain(subject, intent) is True + + +# --------------------------------------------------------------------------- +# Surface emission — relations-corpus chains tag their resolving corpus +# --------------------------------------------------------------------------- + + +def test_relations_surface_tag_is_relations_corpus_id() -> None: + surface = teaching_grounded_surface("parent", IntentTag.CAUSE) + assert surface is not None + assert "teaching-grounded (relations_chains_v1)" in surface + assert "cognition_chains_v1" not in surface + + +def test_cognition_surface_tag_is_cognition_corpus_id_byte_identical() -> None: + """ADR-0064 invariant: registering a second corpus must not alter + surfaces emitted by the first. Cognition lemmas still tag + ``cognition_chains_v1``.""" + surface = teaching_grounded_surface("light", IntentTag.CAUSE) + assert surface is not None + assert "teaching-grounded (cognition_chains_v1)" in surface + assert "relations_chains_v1" not in surface + + +def test_relations_surface_emits_only_pack_atoms() -> None: + """Every visible token must be either the lemma itself or a + verbatim ``semantic_domains`` entry from the relations pack — no + synthesis, no rewording.""" + surface = teaching_grounded_surface("parent", IntentTag.CAUSE) + assert surface is not None + # Relations-pack atoms expected for parent/child: + relations_pack = _pack_lexicon_for(RELATIONS_PACK_ID) + parent_domains = relations_pack["parent"] + child_domains = relations_pack["child"] + # At least the first parent domain and first child domain appear. + assert parent_domains[0] in surface + assert child_domains[0] in surface + # No cognition-pack signature should appear in a relations + # surface. We check semantic-domain prefixes rather than bare + # lemmas — the template constant ``"No session evidence yet."`` + # includes the substring ``evidence`` which would false-positive + # any lemma-substring scan. + for cognition_signature in ( + "cognition.knowledge", + "cognition.truth", + "epistemic.ground", + "memory.semantic", + ): + assert cognition_signature not in surface, ( + f"relations surface leaked cognition signature {cognition_signature!r}" + ) + + +# --------------------------------------------------------------------------- +# Aggregator — orthogonality enforced +# --------------------------------------------------------------------------- + + +def test_cross_corpus_aggregator_has_both_corpora() -> None: + index = _all_chains_index() + cognition_keys = {k for k, c in index.items() if c.corpus_id == "cognition_chains_v1"} + relations_keys = {k for k, c in index.items() if c.corpus_id == RELATIONS_CORPUS_ID} + assert cognition_keys, "cognition corpus disappeared" + assert relations_keys, "relations corpus did not register" + # Orthogonality: no (subject, intent) cell is claimed by both. + assert not (cognition_keys & relations_keys), ( + "cross-corpus (subject, intent) collision — orthogonality broken" + )