# 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/adr/ADR-0064-cross-pack-teaching-chains.md           NEW (this file)
docs/adr/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.
