feat(adr-0064): cross-pack teaching chains + relations_chains_v1 seed (Phase 1.3+1.4)

ADR-0064 is the corpus-layer sibling of ADR-0063.  The teaching-grounded
surface composer was hardcoded to cognition_chains_v1, so kinship CAUSE/
VERIFICATION prompts fell through to the universal disclosure even though
en_core_relations_v1 was mounted on the live runtime (ADR-0063).

Architectural change in chat/teaching_grounding.py:

  - New TeachingCorpusSpec dataclass (corpus_id, path, pack_id).
  - TEACHING_CORPORA tuple registers every active corpus.  Each
    corpus is 1:1-bound to one lexicon pack — cross-domain triples
    deferred per docs/teaching_order.md §5.
  - _load_corpus(spec) loads one corpus with pack-residency scoped
    to its declared pack.
  - _all_chains_index() aggregates across all registered corpora
    (first-match-wins; cognition first preserves byte-identity).
  - _pack_for_corpus(corpus_id) → bound pack lexicon.
  - clear_teaching_caches() atomic cache invalidation.
  - TeachingChain gains corpus_id field → surface tag follows resolving corpus.

Wiring updates:

  - teaching_grounded_surface + teaching_grounded_surface_composed
    consult _all_chains_index; surface tag follows chain.corpus_id.
  - teaching/discovery.py gate uses chat.pack_resolver.is_resolvable
    (any mounted pack) + _all_chains_index (any registered corpus).
  - teaching/replay.py _swap_corpus_path rewrites the registry path
    + clears all teaching caches during the gate's transient phase.
    Active corpus bytes unchanged (replay invariant preserved).
  - evals/learning_loop/run_demo.py scene-5 swap mirrors the new
    pattern so the demo still grounds against transient corpora.

Back-compat preserved: _corpus_index, _CORPUS_PATH, TEACHING_CORPUS_ID
remain cognition-corpus-specific for audit/replay consumers.

Phase 1.4 — relations_chains_v1 seeded with 7 reviewed kinship chains:
  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

5 of 8 relations lemmas covered.  All connectives already humanised.
Strict pack-internal to en_core_relations_v1 (no cross-domain in v1).
Seed pattern matches cognition_chains_v1's original pre-ADR-0055 seed.

Live verification:
  > Why does parent exist?
  parent — teaching-grounded (relations_chains_v1):
  kinship.ascendant.direct; kinship.parent.
  parent precedes child (kinship.descendant.direct).
  grounding_source = teaching

Cognition eval byte-identical to pre-ADR baseline:
  public:  intent 100% / surface 100% / term 91.7% / closure 100%
  holdout: intent 100% / surface 100% / term 83.3% / closure 100%

Lanes green: smoke 67 / cognition 121 / teaching 17 / packs 6 /
runtime 19 / algebra 132 / full 1933 passed.
This commit is contained in:
Shay 2026-05-18 16:04:20 -07:00
parent 7c80b791ec
commit b5ba9b6d6f
9 changed files with 850 additions and 28 deletions

View file

@ -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 corpuspack 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()

View file

@ -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.

View file

@ -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.

View file

@ -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) |
---

View file

@ -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

View file

@ -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

View file

@ -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"}

View file

@ -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

View file

@ -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"
)