feat(adr-0067): cross-pack teaching chains — Plan Phase 4 closed

ADR-0064 bound each teaching corpus 1:1 to a single ratified pack;
chains whose subject + object resolved to different packs were
dropped at load time. Phases 1–3 ratified the per-pack DAGs needed
to lift that constraint safely.

ADR-0067 introduces a deliberately narrow cross-pack chain shape.
Each entry carries explicit subject_pack_id and object_pack_id
fields, and the loader verifies per-chain residency. Same-pack
entries are rejected as corpus-misfilings (anti-leakage). The
cross-pack composer is the fall-through after the in-pack composer,
so the cognition lane stays byte-identical.

Files:
- chat/cross_pack_grounding.py — CrossPackChain + loader +
  single-chain composer + multi-chain enumerators
- teaching/cross_pack_chains/cross_pack_chains_v1.jsonl — 5 seed
  chains (family×identity, parent×understanding, family×memory,
  identity×family, understanding×parent)
- chat/runtime.py — fall-through wiring in CAUSE/VERIFICATION
- chat/narrative_surface.py, chat/example_surface.py — merge
  cross-pack chains, per-chain pack-residency helpers
- tests/test_cross_pack_chains.py — 31 tests covering loader,
  surface, multi-chain access, runtime integration, in-pack
  precedence
- tests/test_narrative_example_intents.py — corpus-tag assertions
  widened to allow cross-pack aggregation

Verification:
- 31 new tests pass
- Curated lanes: smoke 67 / cognition 121 / teaching 17 / packs 6 /
  runtime 19 — all green
- Cognition eval byte-identical (public 100/100/91.7/100, holdout
  100/100/83.3/100)
- Full lane: 2098 passed, 2 skipped, 0 failed in 2:30
This commit is contained in:
Shay 2026-05-18 17:22:43 -07:00
parent ce8226e9a2
commit d5a6e81b33
9 changed files with 947 additions and 17 deletions

View file

@ -0,0 +1,283 @@
"""chat/cross_pack_grounding.py — ADR-0067 cross-pack teaching surface.
Phases 13 closed the chain-gap and OOV flywheels and opened the
turn-level composition surfaces (NARRATIVE / EXAMPLE / anaphora).
But every reviewed chain still had to live entirely within one
ratified pack: ADR-0064 binds each :data:`TEACHING_CORPORA` entry
1:1 to a single ``pack_id``, and chains whose subject and object
resolve to different packs are dropped at load time.
That constraint is structural it kept cross-domain leakage out of
v1 while the per-pack chain DAGs ratified. With three packs
(cognition + relations v1/v2) live and 36 reviewed in-pack chains,
the prerequisite is satisfied; this module lifts the constraint with
a deliberately narrow cross-pack chain shape.
Each chain in the cross-pack corpus carries TWO ``pack_id`` fields
``subject_pack_id`` and ``object_pack_id`` and the loader verifies
that the subject resolves in the named subject pack and the object
resolves in the named object pack. No cross-pack collision matters:
each chain names its own residency. The surface tag exposes both
pack ids so the trust boundary is explicit:
"{X} — cross-pack-grounded (cross_pack_chains_v1:
{subject_pack_id} × {object_pack_id}): {dX}. {X} {conn} {Y}
({dY}). No session evidence yet."
Design constraints (mirrors ADR-0052 / ADR-0064):
- Reconstruction-over-storage: corpus + both packs loaded lazily once.
- Strict per-chain pack-residency: a chain whose subject is not in
its declared subject pack (or whose object is not in its declared
object pack) is dropped silently pack-corpus skew cannot leak a
non-pack atom into the surface.
- Connective MUST be in :func:`generate.semantic_templates.humanize_predicate`
no free-form predicates, ever.
- No prose generation. Every visible non-template token is a lemma,
a pack ``semantic_domains`` string, or a whitelisted connective.
The corpus path is the sole write surface (proposal-only per
ADR-0027 / ADR-0057). This module is read-only.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from chat.pack_resolver import _pack_lexicon_for
from generate.intent import IntentTag
from generate.semantic_templates import humanize_predicate
CROSS_PACK_CORPUS_ID: str = "cross_pack_chains_v1"
_VALID_INTENTS: frozenset[str] = frozenset({"cause", "verification"})
_INTENT_NAME_BY_TAG: dict[IntentTag, str] = {
IntentTag.CAUSE: "cause",
IntentTag.VERIFICATION: "verification",
}
_TEACHING_ROOT = Path(__file__).resolve().parent.parent / "teaching"
_CORPUS_PATH = (
_TEACHING_ROOT / "cross_pack_chains" / f"{CROSS_PACK_CORPUS_ID}.jsonl"
)
@dataclass(frozen=True, slots=True)
class CrossPackChain:
"""One reviewed cross-pack chain.
Both ``subject_pack_id`` and ``object_pack_id`` are explicit per
entry the runtime never infers residency. ``provenance`` is
preserved for audit and never emitted in the surface.
"""
chain_id: str
subject: str
intent: str
connective: str
object: str
subject_pack_id: str
object_pack_id: str
domains_subject_k: int
domains_object_k: int
provenance: str
corpus_id: str = CROSS_PACK_CORPUS_ID
@lru_cache(maxsize=1)
def _all_cross_pack_chains() -> tuple[CrossPackChain, ...]:
"""Load every reviewed cross-pack chain once.
Returns a flat tuple of validated chains (insertion order).
Entries with invalid schema, unsupported intents, missing pack
ids, or whose subject/object are absent from their declared
packs are dropped silently.
NARRATIVE and EXAMPLE composers iterate this list directly so
multiple chains rooted on the same ``(subject, intent)`` are
surfaced as distinct clauses. Single-chain lookup goes through
:func:`_cross_pack_index` which keeps first-occurrence-wins.
ADR-0055 Phase A supersession: 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.
"""
if not _CORPUS_PATH.exists():
return ()
superseded_ids: set[str] = set()
parsed_lines: list[dict] = []
for line in _CORPUS_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
parsed_lines.append(entry)
sup = entry.get("superseded_by")
if isinstance(sup, str) and sup.strip():
superseded_ids.add(sup.strip())
out: list[CrossPackChain] = []
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()
subject_pack_id = (entry.get("subject_pack_id") or "").strip()
object_pack_id = (entry.get("object_pack_id") or "").strip()
if not all((subject, intent, obj, connective,
subject_pack_id, object_pack_id)):
continue
if intent not in _VALID_INTENTS:
continue
# Phase 4 anti-leakage invariant: a "cross-pack" chain must
# actually cross packs. Same-pack entries are corpus-mis-
# filings and should live in the in-pack corpus instead.
if subject_pack_id == object_pack_id:
continue
subject_pack = _pack_lexicon_for(subject_pack_id)
object_pack = _pack_lexicon_for(object_pack_id)
if subject not in subject_pack or obj not in object_pack:
continue
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}_{obj}")
if chain_id in superseded_ids:
continue
try:
chain = CrossPackChain(
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
subject_pack_id=subject_pack_id,
object_pack_id=object_pack_id,
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", "")),
)
except (TypeError, ValueError):
continue
out.append(chain)
return tuple(out)
@lru_cache(maxsize=1)
def _cross_pack_index() -> dict[tuple[str, str], CrossPackChain]:
"""``(subject, intent) → first cross-pack chain``.
First-occurrence-wins on collision same rule as
:func:`chat.teaching_grounding._all_chains_index`. Single-chain
surface composition (``cross_pack_grounded_surface``) goes through
this lookup; multi-chain composition (NARRATIVE / EXAMPLE) walks
:func:`_all_cross_pack_chains` directly.
"""
out: dict[tuple[str, str], CrossPackChain] = {}
for chain in _all_cross_pack_chains():
key = (chain.subject, chain.intent)
if key not in out:
out[key] = chain
return out
def clear_cross_pack_cache() -> None:
"""Test-only escape hatch: drop the lru_cache on the corpus index."""
_all_cross_pack_chains.cache_clear()
_cross_pack_index.cache_clear()
def cross_pack_grounded_surface(
subject_lemma: str, intent_tag: IntentTag
) -> str | None:
"""Return a deterministic cross-pack teaching surface, or ``None``.
The surface format is fixed:
"{X} — cross-pack-grounded ({corpus_id}: {pack_X} × {pack_Y}):
{dX1}; {dX2}. {X} {conn} {Y} ({dY1}). No session evidence
yet."
Returns ``None`` when:
- the lemma is empty or not a string,
- the intent tag is not ``CAUSE`` or ``VERIFICATION``,
- the (subject, intent) pair has no cross-pack chain,
- the chain's declared packs no longer resolve the lemmas
(corpus drift fail closed).
"""
if not subject_lemma or not isinstance(subject_lemma, str):
return None
key = subject_lemma.strip().lower()
if not key:
return None
intent_name = _INTENT_NAME_BY_TAG.get(intent_tag)
if intent_name is None:
return None
chain = _cross_pack_index().get((key, intent_name))
if chain is None:
return None
subject_pack = _pack_lexicon_for(chain.subject_pack_id)
object_pack = _pack_lexicon_for(chain.object_pack_id)
subject_domains = subject_pack.get(chain.subject, ())
object_domains = object_pack.get(chain.object, ())
if not subject_domains or not object_domains:
return None
head_subject = "; ".join(
subject_domains[: max(1, chain.domains_subject_k)]
)
head_object = "; ".join(
object_domains[: max(1, chain.domains_object_k)]
)
connective = humanize_predicate(chain.connective)
return (
f"{chain.subject} — cross-pack-grounded "
f"({chain.corpus_id}: {chain.subject_pack_id} × "
f"{chain.object_pack_id}): {head_subject}. "
f"{chain.subject} {connective} {chain.object} "
f"({head_object}). No session evidence yet."
)
def cross_pack_chains_for_subject(
subject_lemma: str,
) -> tuple[CrossPackChain, ...]:
"""Return every cross-pack chain rooted on *subject_lemma*.
Used by NARRATIVE composition (Phase 4.2) to weave cross-pack
clauses into the multi-clause narrative surface. Deterministic
ordering: by ``(intent, connective, object)``.
"""
if not subject_lemma or not isinstance(subject_lemma, str):
return ()
key = subject_lemma.strip().lower()
if not key:
return ()
matches = [c for c in _all_cross_pack_chains() if c.subject == key]
matches.sort(key=lambda c: (c.intent, c.connective, c.object))
return tuple(matches)
def cross_pack_chains_for_object(
object_lemma: str,
) -> tuple[CrossPackChain, ...]:
"""Return every cross-pack chain whose OBJECT is *object_lemma*.
Used by EXAMPLE composition (Phase 4.2) to weave reverse-chain
cross-pack clauses into the example surface.
"""
if not object_lemma or not isinstance(object_lemma, str):
return ()
key = object_lemma.strip().lower()
if not key:
return ()
matches = [c for c in _all_cross_pack_chains() if c.object == key]
matches.sort(key=lambda c: (c.intent, c.subject, c.connective))
return tuple(matches)

View file

@ -35,7 +35,8 @@ or to OOV invitation (if X is unknown).
from __future__ import annotations
from chat.pack_resolver import resolve_lemma
from chat.cross_pack_grounding import cross_pack_chains_for_object
from chat.pack_resolver import _pack_lexicon_for, resolve_lemma
from chat.teaching_grounding import (
_all_chains_index,
_pack_for_corpus,
@ -43,6 +44,14 @@ from chat.teaching_grounding import (
from generate.semantic_templates import humanize_predicate
def _object_domains_for_chain(chain) -> tuple[str, ...]:
"""Resolve object domains for both in-pack and cross-pack chains."""
object_pack_id = getattr(chain, "object_pack_id", None)
if object_pack_id:
return _pack_lexicon_for(object_pack_id).get(chain.object, ())
return _pack_for_corpus(chain.corpus_id).get(chain.object, ())
def example_grounded_surface(
object_lemma: str,
*,
@ -69,7 +78,9 @@ def example_grounded_surface(
return None
index = _all_chains_index()
matching = [chain for chain in index.values() if chain.object == key]
matching: list = [chain for chain in index.values() if chain.object == key]
# ADR-0067 — merge cross-pack chains whose object equals the lemma.
matching.extend(cross_pack_chains_for_object(key))
if not matching:
return None
@ -89,12 +100,7 @@ def example_grounded_surface(
break
first = deduped[0]
# Object domains come from the first chain's bound pack; falls
# back to the cross-pack resolver if the chain's corpus is bound
# to a pack that does not carry the object (defensive — strict
# pack-residency in ADR-0064 prevents this).
object_pack = _pack_for_corpus(first.corpus_id)
object_domains = object_pack.get(first.object, ())
object_domains = _object_domains_for_chain(first)
if not object_domains:
resolved = resolve_lemma(first.object)
if resolved is None:

View file

@ -51,7 +51,8 @@ narrative) or to the OOV invitation if X is also not pack-resident.
from __future__ import annotations
from chat.pack_resolver import resolve_lemma
from chat.cross_pack_grounding import cross_pack_chains_for_subject
from chat.pack_resolver import _pack_lexicon_for, resolve_lemma
from chat.teaching_grounding import (
_all_chains_index,
_pack_for_corpus,
@ -59,6 +60,27 @@ from chat.teaching_grounding import (
from generate.semantic_templates import humanize_predicate
def _object_domains_for_chain(chain) -> tuple[str, ...]:
"""Return the object lemma's semantic domains for *chain*.
Handles both in-pack ``TeachingChain`` (residency via its bound
corpus pack) and cross-pack ``CrossPackChain`` (residency in
its declared ``object_pack_id``).
"""
object_pack_id = getattr(chain, "object_pack_id", None)
if object_pack_id:
return _pack_lexicon_for(object_pack_id).get(chain.object, ())
return _pack_for_corpus(chain.corpus_id).get(chain.object, ())
def _subject_domains_for_chain(chain) -> tuple[str, ...]:
"""Same as :func:`_object_domains_for_chain` but for the subject."""
subject_pack_id = getattr(chain, "subject_pack_id", None)
if subject_pack_id:
return _pack_lexicon_for(subject_pack_id).get(chain.subject, ())
return _pack_for_corpus(chain.corpus_id).get(chain.subject, ())
def narrative_grounded_surface(
subject_lemma: str,
*,
@ -86,9 +108,13 @@ def narrative_grounded_surface(
return None
index = _all_chains_index()
matching = [
matching: list = [
chain for (s, _), chain in index.items() if s == key
]
# ADR-0067 — merge cross-pack chains rooted on the same subject.
# In-pack chains take precedence on (intent, connective, object)
# collision (first-occurrence-wins in dedup loop below).
matching.extend(cross_pack_chains_for_subject(key))
if not matching:
return None
@ -113,8 +139,7 @@ def narrative_grounded_surface(
# narrative header is sourced from the lemma's own pack — even
# when the matching chains span multiple corpora.
first = deduped[0]
subject_pack = _pack_for_corpus(first.corpus_id)
subject_domains = subject_pack.get(first.subject, ())
subject_domains = _subject_domains_for_chain(first)
if not subject_domains:
# Fall back to cross-pack resolver — subject may live in a
# different pack than its chains' corpus binding (defensive).
@ -133,8 +158,7 @@ def narrative_grounded_surface(
# Emit one clause per deduped chain.
clauses: list[str] = []
for chain in deduped:
obj_pack = _pack_for_corpus(chain.corpus_id)
obj_domains = obj_pack.get(chain.object, ())
obj_domains = _object_domains_for_chain(chain)
if not obj_domains:
continue
obj_head = "; ".join(

View file

@ -860,6 +860,17 @@ class ChatRuntime:
surface = teaching_grounded_surface(lemma, intent.tag)
if surface is not None:
return (surface, "teaching")
# ADR-0067 — fall through to cross-pack chains when no
# in-pack chain resolves the (subject, intent) pair.
# Cross-pack chains carry an explicit residency pair
# (subject_pack_id × object_pack_id) and surface tag
# exposes both packs. Deliberately listed AFTER the
# single-pack composer so the in-pack lane is byte-
# identical when a same-pack chain exists.
from chat.cross_pack_grounding import cross_pack_grounded_surface
surface = cross_pack_grounded_surface(lemma, intent.tag)
if surface is not None:
return (surface, "teaching")
# ADR-0053 — CORRECTION acknowledgement. Cold-start CORRECTION
# has no prior session turn to apply to; emit a pack-grounded
# surface that acknowledges the correction was received and

View file

@ -0,0 +1,237 @@
# ADR-0067 — Cross-pack teaching chains (Plan Phase 4)
**Status:** Accepted
**Date:** 2026-05-18
**Author:** Shay
**Phase:** Plan Phase 4 (multi-domain composition)
**Builds on:** ADR-0052 / ADR-0063 / ADR-0064 / ADR-0066
---
## Context
ADR-0064 introduced the cross-pack teaching corpora registry but
deliberately kept each corpus 1:1 bound to a single ratified pack.
Chains whose subject + object resolved to *different* packs were
dropped at load time. The structural rationale was sound for v1:
keep the per-pack DAGs ratified before adding cross-domain edges.
Phases 13 satisfied that prerequisite:
- 3 ratified content packs live on the default runtime
(cognition v1 + relations v1/v2).
- 36 reviewed in-pack chains across three corpora.
- NARRATIVE + EXAMPLE composers in place — ready to consume
multi-corpus chain sets.
The articulation wall now lives in the *gap between* domains. The
system has nothing to say when an operator asks:
```
> Why does family exist?
> Does identity require family?
> Does understanding require parent?
```
These prompts reference a relations-pack lemma and a cognition-pack
lemma in the same chain. No single-pack corpus can ground them; an
edge that crosses packs is required.
---
## Decision
Introduce a deliberately narrow cross-pack chain shape. Each chain
explicitly carries **two** pack-residency fields:
```json
{
"chain_id": "cause_family_grounds_identity",
"subject": "family",
"intent": "cause",
"connective": "grounds",
"object": "identity",
"subject_pack_id": "en_core_relations_v1",
"object_pack_id": "en_core_cognition_v1",
...
}
```
The loader verifies per-chain residency: the subject must resolve in
its declared subject pack, and the object must resolve in its
declared object pack. Same-pack entries are rejected (they belong in
the in-pack corpus).
### Files
```
chat/cross_pack_grounding.py NEW
teaching/cross_pack_chains/cross_pack_chains_v1.jsonl NEW (5 chains)
chat/runtime.py wired cross-pack fall-through
chat/narrative_surface.py aggregates cross-pack chains
chat/example_surface.py aggregates cross-pack reverse chains
tests/test_cross_pack_chains.py NEW (31 tests)
docs/decisions/ADR-0067-cross-pack-teaching-chains.md NEW (this file)
```
### Surface format
```
"{X} — cross-pack-grounded ({corpus_id}: {subject_pack_id} × {object_pack_id}):
{dX1}; {dX2}. {X} {conn} {Y} ({dY1}). No session evidence yet."
```
Both pack ids are exposed in the tag so the audit trail and operator
debugger can see *which* domains the chain crosses without parsing
the chain body.
### Resolution order
In `chat/runtime.py` for CAUSE/VERIFICATION:
1. `teaching_grounded_surface_composed` (when `composed_surface=True`)
OR `teaching_grounded_surface` — in-pack chain index
(`_all_chains_index` across all single-pack corpora).
2. **`cross_pack_grounded_surface` — fall-through when no in-pack
chain resolves the `(subject, intent)`.** [ADR-0067]
3. Fall through to OOV invitation if the subject is unknown to any
mounted pack.
The cross-pack composer is the fall-through only: when an in-pack
chain exists on the same `(subject, intent)`, the in-pack composer
wins. This preserves the cognition-lane byte-identity invariant.
### NARRATIVE and EXAMPLE aggregation
Both multi-clause composers (ADR-0066) now walk cross-pack chains
in addition to in-pack ones:
- `narrative_surface.py` calls `cross_pack_chains_for_subject(X)`
and appends those chains into the dedup-and-sort pipeline.
- `example_surface.py` calls `cross_pack_chains_for_object(X)`
similarly.
The corpus tag widens from `(cognition_chains_v1)` to
`(cognition_chains_v1 + cross_pack_chains_v1)` whenever any
cross-pack clause contributes. Stable lexicographic ordering — no
behaviour change on subjects with no cross-pack chains.
### Seed corpus
`teaching/cross_pack_chains/cross_pack_chains_v1.jsonl` ships with
5 hand-authored, reviewed chains:
| chain_id | direction |
|---|---|
| `cause_family_grounds_identity` | relations → cognition |
| `cause_parent_grounds_understanding` | relations → cognition |
| `cause_family_supports_memory` | relations → cognition |
| `verification_identity_requires_family` | cognition → relations |
| `verification_understanding_requires_parent` | cognition → relations |
All connectives are whitelisted in
`generate.semantic_templates._PREDICATE_HUMANIZE`.
---
## Consequences
### Capability unlocked
| Prompt | Pre-ADR-0067 | Post-ADR-0067 |
|---|---|---|
| `"Does identity require family?"` | OOV invitation | `identity requires family` (cross-pack) |
| `"Does understanding require parent?"` | OOV invitation | `understanding requires parent` (cross-pack) |
| `"Tell me about family"` | 1 clause (in-pack) | 3 clauses (in-pack + 2 cross-pack) |
| `"Give me an example of memory"` | 1 example | 2 examples (in-pack `recall` + cross-pack `family supports`) |
### Live verification
```
> Does identity require family?
[teaching] identity — cross-pack-grounded
(cross_pack_chains_v1: en_core_cognition_v1 × en_core_relations_v1):
cognition.identity; identity.stable. identity requires family
(kinship.unit). No session evidence yet.
> Tell me about family.
[teaching] family — narrative-grounded
(cross_pack_chains_v1 + relations_chains_v1): kinship.unit;
social.group.kin. family grounds identity (cognition.identity);
family grounds parent (kinship.ascendant.direct); family supports
memory (cognition.memory). No session evidence yet.
> Give me an example of memory.
[teaching] memory — example-grounded
(cognition_chains_v1 + cross_pack_chains_v1): cognition.memory.
Example: family supports memory; recall reveals memory. No session
evidence yet.
```
### Cognition lane — byte-identical
```
public: intent 100% / surface 100% / term 91.7% / closure 100%
holdout: intent 100% / surface 100% / term 83.3% / closure 100%
```
The cross-pack composer fires only as a fall-through. All cognition-
lane prompts (which exercise in-pack chains) follow the unchanged
path.
---
## Trust boundaries
- **Strict per-chain pack residency.** A chain declares
`subject_pack_id` and `object_pack_id` explicitly; the loader
verifies each lemma against its named pack. Skewed entries drop
silently with no surface impact.
- **Anti-leakage: cross-pack chains must actually cross packs.**
Entries where `subject_pack_id == object_pack_id` are rejected as
corpus-misfilings.
- **No prose generation.** Every visible non-template token in the
surface is a lemma, a pack `semantic_domains` atom, or a
whitelisted connective from `humanize_predicate`.
- **No new mutation surface.** ADR-0027 / ADR-0057 doctrine
preserved: corpus appends go through `accept_proposal` (or
`supersede_chain`) and nowhere else. This module is read-only.
- **In-pack precedence.** Cross-pack chains never override an
in-pack chain on the same `(subject, intent)` — the cognition-
lane byte-identity invariant depends on this.
- **Supersession honoured.** Cross-pack chains support
`superseded_by` (ADR-0055 Phase A) — retired entries drop from the
active view, history preserved on disk.
---
## Verification
```
tests/test_cross_pack_chains.py 31 passed
Curated lanes (all green):
smoke 67 / cognition 121 / teaching 17 / packs 6 / runtime 19
Cognition eval byte-identical (public + holdout).
Full lane: 2096 passed, 2 skipped, 0 failed.
```
---
## Future ADRs unlocked
- **Cross-pack supersede CLI.** Today `core teaching supersede` is
in-pack only. A cross-pack supersede needs to validate the new
chain's residency against the *same* `(subject_pack_id,
object_pack_id)` pair as the retired chain.
- **Cross-pack proposals via `core teaching propose`.** Today
proposals target the in-pack corpus only. Extending to cross-pack
needs the proposal schema to carry both pack ids and the replay-
equivalence gate to handle multi-corpus surface diffs.
- **Cross-pack composed surface (ADR-0062 generalisation).** Chain-
of-chains across packs (e.g. `family grounds identity, which
grounds knowledge`) would compose a relations seed → cognition
intermediate → cognition tail. Needs the composer to dispatch on
per-chain `object_pack_id` for follow-up lookup.
- **Three-pack chains.** Today cross-pack is binary
(`subject_pack × object_pack`). N-ary chains crossing 3+ packs
would need a different schema; out of scope until a third
content-bearing pack ratifies.

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-0067](ADR-0067-cross-pack-teaching-chains.md) | Cross-pack teaching chains (Plan Phase 4): `chat/cross_pack_grounding.py` + `teaching/cross_pack_chains/cross_pack_chains_v1.jsonl` seeded with 5 reviewed chains; each chain carries explicit `subject_pack_id` + `object_pack_id`, loader verifies per-chain residency, same-pack entries rejected (anti-leakage); fall-through after in-pack composer in CAUSE/VERIFICATION (cognition-lane byte-identity preserved); NARRATIVE + EXAMPLE composers merge cross-pack chains into multi-clause aggregation with widened corpus tag (e.g. `cognition_chains_v1 + cross_pack_chains_v1`); first cross-domain edges (`family grounds identity`, `identity requires family`, etc.) — relations × cognition; no prose generation, no new mutation surface, supersession honoured | **Accepted** (2026-05-18) |
| [ADR-0066](ADR-0066-turn-level-composition.md) | Turn-level composition (Plan Phase 3): bounded session-thread context (P3.1) + opt-in deterministic anaphora prefix `(Recalling turn N: chain X.)` (P3.2, default off) + `IntentTag.NARRATIVE` multi-clause composer for "Tell me about X" walking every chain rooted on X across registered corpora (P3.3) + `IntentTag.EXAMPLE` reverse-chain composer for "Give me an example of X" surfacing chains where X is the object (P3.4); no prose generation, no new corpus mutation, all composers consult ADR-0064's cross-corpus aggregator; cognition lane byte-identical | **Accepted** (2026-05-18) |
| [ADR-0065](ADR-0065-oov-gradient-and-relations-v2.md) | OOV gradient + relations v2 (Plan Phase 2): five-tier honesty gradient replaces the OOV cliff — pack / teaching / partial (one OOV + one known) / oov (learning invitation surface naming the unknown token + mounted-pack list) / universal disclosure; sink-emit OOVCandidates → `core teaching oov-gaps` aggregator → `core teaching oov-queue` auto-promotion mirrors P1.1+P1.2 architecture for vocab gaps; `en_core_relations_v2` adds 8 pronoun + role-filler lemmas (mother/father/son/daughter/brother/sister/grandparent/grandchild) with 7 reviewed v2-internal chains; no content synthesis, no domain inference, no auto-pack-mutation | **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) |

View file

@ -0,0 +1,5 @@
{"chain_id":"cause_family_grounds_identity","subject":"family","intent":"cause","connective":"grounds","object":"identity","subject_pack_id":"en_core_relations_v1","object_pack_id":"en_core_cognition_v1","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0067:reviewed:2026-05-18:cross_pack_seed_v1"}
{"chain_id":"cause_parent_grounds_understanding","subject":"parent","intent":"cause","connective":"grounds","object":"understanding","subject_pack_id":"en_core_relations_v1","object_pack_id":"en_core_cognition_v1","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0067:reviewed:2026-05-18:cross_pack_seed_v1"}
{"chain_id":"cause_family_supports_memory","subject":"family","intent":"cause","connective":"supports","object":"memory","subject_pack_id":"en_core_relations_v1","object_pack_id":"en_core_cognition_v1","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0067:reviewed:2026-05-18:cross_pack_seed_v1"}
{"chain_id":"verification_identity_requires_family","subject":"identity","intent":"verification","connective":"requires","object":"family","subject_pack_id":"en_core_cognition_v1","object_pack_id":"en_core_relations_v1","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0067:reviewed:2026-05-18:cross_pack_seed_v1"}
{"chain_id":"verification_understanding_requires_parent","subject":"understanding","intent":"verification","connective":"requires","object":"parent","subject_pack_id":"en_core_cognition_v1","object_pack_id":"en_core_relations_v1","domains_subject_k":2,"domains_object_k":1,"provenance":"adr-0067:reviewed:2026-05-18:cross_pack_seed_v1"}

View file

@ -0,0 +1,356 @@
"""Phase 4 / ADR-0067 — cross-pack teaching chain tests.
Contracts pinned here:
Loader
- Cross-pack corpus loads chains whose subject + object reside in
DIFFERENT ratified packs (verified per-chain via the declared
``subject_pack_id`` / ``object_pack_id`` fields).
- Same-pack entries are rejected as corpus-misfilings.
- Chains whose declared packs do not actually contain the lemmas
are dropped silently (pack-corpus skew defence).
- Invalid intents / missing connective / missing pack ids are
dropped.
- Superseded chains are dropped from the active view.
Single-chain surface
- ``cross_pack_grounded_surface(subject, intent)`` returns a
deterministic surface tagged with both pack ids.
- Returns ``None`` for unknown subject / unsupported intent.
Multi-chain access
- ``cross_pack_chains_for_subject`` and
``cross_pack_chains_for_object`` enumerate every chain (including
duplicates on ``(subject, intent)``) for NARRATIVE / EXAMPLE.
Runtime integration
- CAUSE/VERIFICATION on a cross-pack subject routes to the cross-
pack composer when no in-pack chain resolves.
- In-pack chains still take precedence (cognition lane byte-
identity preserved).
- NARRATIVE aggregates cross-pack + in-pack chains on the same
subject.
- EXAMPLE aggregates cross-pack + in-pack chains on the same object.
"""
from __future__ import annotations
import pytest
from chat.cross_pack_grounding import (
CROSS_PACK_CORPUS_ID,
CrossPackChain,
_all_cross_pack_chains,
_cross_pack_index,
cross_pack_chains_for_object,
cross_pack_chains_for_subject,
cross_pack_grounded_surface,
)
from chat.runtime import ChatRuntime
from generate.intent import IntentTag
# ---------------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------------
def test_corpus_loads_five_seed_chains() -> None:
chains = _all_cross_pack_chains()
assert len(chains) == 5
chain_ids = {c.chain_id for c in chains}
assert chain_ids == {
"cause_family_grounds_identity",
"cause_parent_grounds_understanding",
"cause_family_supports_memory",
"verification_identity_requires_family",
"verification_understanding_requires_parent",
}
def test_every_chain_actually_crosses_packs() -> None:
for chain in _all_cross_pack_chains():
assert chain.subject_pack_id != chain.object_pack_id
assert chain.subject_pack_id.startswith("en_core_")
assert chain.object_pack_id.startswith("en_core_")
def test_index_first_occurrence_wins_on_collision() -> None:
"""Both ``cause_family_grounds_identity`` and
``cause_family_supports_memory`` share ``(family, cause)``;
the index keeps the first."""
chain = _cross_pack_index()[("family", "cause")]
assert chain.chain_id == "cause_family_grounds_identity"
def test_loader_drops_unparseable_lines(tmp_path, monkeypatch) -> None:
bad = tmp_path / "bad.jsonl"
bad.write_text(
'{"chain_id":"good","subject":"family","intent":"cause",'
'"connective":"grounds","object":"identity",'
'"subject_pack_id":"en_core_relations_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n'
"{ not json }\n"
'"a top-level string"\n',
encoding="utf-8",
)
import chat.cross_pack_grounding as mod
monkeypatch.setattr(mod, "_CORPUS_PATH", bad)
mod.clear_cross_pack_cache()
try:
chains = _all_cross_pack_chains()
assert len(chains) == 1
assert chains[0].chain_id == "good"
finally:
mod.clear_cross_pack_cache()
def test_loader_drops_same_pack_entries(tmp_path, monkeypatch) -> None:
same = tmp_path / "same.jsonl"
same.write_text(
'{"chain_id":"bad","subject":"knowledge","intent":"cause",'
'"connective":"requires","object":"evidence",'
'"subject_pack_id":"en_core_cognition_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n'
'{"chain_id":"good","subject":"family","intent":"cause",'
'"connective":"grounds","object":"identity",'
'"subject_pack_id":"en_core_relations_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n',
encoding="utf-8",
)
import chat.cross_pack_grounding as mod
monkeypatch.setattr(mod, "_CORPUS_PATH", same)
mod.clear_cross_pack_cache()
try:
ids = {c.chain_id for c in _all_cross_pack_chains()}
assert ids == {"good"}
finally:
mod.clear_cross_pack_cache()
def test_loader_drops_lemma_outside_declared_pack(tmp_path, monkeypatch) -> None:
skewed = tmp_path / "skewed.jsonl"
skewed.write_text(
'{"chain_id":"skewed","subject":"family","intent":"cause",'
'"connective":"grounds","object":"identity",'
'"subject_pack_id":"en_core_cognition_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n',
encoding="utf-8",
)
import chat.cross_pack_grounding as mod
monkeypatch.setattr(mod, "_CORPUS_PATH", skewed)
mod.clear_cross_pack_cache()
try:
assert _all_cross_pack_chains() == ()
finally:
mod.clear_cross_pack_cache()
def test_loader_drops_invalid_intent(tmp_path, monkeypatch) -> None:
bad = tmp_path / "bad.jsonl"
bad.write_text(
'{"chain_id":"x","subject":"family","intent":"definition",'
'"connective":"grounds","object":"identity",'
'"subject_pack_id":"en_core_relations_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n',
encoding="utf-8",
)
import chat.cross_pack_grounding as mod
monkeypatch.setattr(mod, "_CORPUS_PATH", bad)
mod.clear_cross_pack_cache()
try:
assert _all_cross_pack_chains() == ()
finally:
mod.clear_cross_pack_cache()
def test_supersession_drops_retired_chain(tmp_path, monkeypatch) -> None:
corpus = tmp_path / "c.jsonl"
corpus.write_text(
'{"chain_id":"old","subject":"family","intent":"cause",'
'"connective":"grounds","object":"identity",'
'"subject_pack_id":"en_core_relations_v1",'
'"object_pack_id":"en_core_cognition_v1"}\n'
'{"chain_id":"new","subject":"family","intent":"cause",'
'"connective":"supports","object":"memory",'
'"subject_pack_id":"en_core_relations_v1",'
'"object_pack_id":"en_core_cognition_v1",'
'"superseded_by":"old"}\n',
encoding="utf-8",
)
import chat.cross_pack_grounding as mod
monkeypatch.setattr(mod, "_CORPUS_PATH", corpus)
mod.clear_cross_pack_cache()
try:
ids = {c.chain_id for c in _all_cross_pack_chains()}
assert ids == {"new"}
finally:
mod.clear_cross_pack_cache()
# ---------------------------------------------------------------------------
# Single-chain surface
# ---------------------------------------------------------------------------
def test_surface_tag_exposes_both_pack_ids() -> None:
s = cross_pack_grounded_surface("family", IntentTag.CAUSE)
assert s is not None
assert "cross-pack-grounded" in s
assert "cross_pack_chains_v1" in s
assert "en_core_relations_v1" in s
assert "en_core_cognition_v1" in s
def test_surface_relations_to_cognition_direction() -> None:
s = cross_pack_grounded_surface("family", IntentTag.CAUSE)
assert s is not None
assert "family grounds identity" in s
assert "kinship.unit" in s
assert "cognition.identity" in s
def test_surface_cognition_to_relations_direction() -> None:
s = cross_pack_grounded_surface("identity", IntentTag.VERIFICATION)
assert s is not None
assert "identity requires family" in s
assert "cognition.identity" in s
assert "kinship.unit" in s
def test_surface_returns_none_for_unknown_subject() -> None:
assert cross_pack_grounded_surface("photosynthesis", IntentTag.CAUSE) is None
assert cross_pack_grounded_surface("xyz", IntentTag.VERIFICATION) is None
@pytest.mark.parametrize("tag", [
IntentTag.DEFINITION,
IntentTag.RECALL,
IntentTag.COMPARISON,
IntentTag.CORRECTION,
IntentTag.PROCEDURE,
])
def test_surface_returns_none_for_unsupported_intent(tag: IntentTag) -> None:
assert cross_pack_grounded_surface("family", tag) is None
def test_surface_is_deterministic() -> None:
a = cross_pack_grounded_surface("family", IntentTag.CAUSE)
b = cross_pack_grounded_surface("family", IntentTag.CAUSE)
assert a == b
def test_surface_empty_input_returns_none() -> None:
assert cross_pack_grounded_surface("", IntentTag.CAUSE) is None
assert cross_pack_grounded_surface(" ", IntentTag.CAUSE) is None
# ---------------------------------------------------------------------------
# Multi-chain access
# ---------------------------------------------------------------------------
def test_chains_for_subject_returns_all_rooted_on_lemma() -> None:
chains = cross_pack_chains_for_subject("family")
ids = [c.chain_id for c in chains]
assert "cause_family_grounds_identity" in ids
assert "cause_family_supports_memory" in ids
def test_chains_for_object_returns_reverse_chains() -> None:
chains = cross_pack_chains_for_object("family")
ids = [c.chain_id for c in chains]
assert "verification_identity_requires_family" in ids
def test_chains_for_subject_unknown_returns_empty() -> None:
assert cross_pack_chains_for_subject("nonexistent") == ()
assert cross_pack_chains_for_subject("") == ()
def test_chains_for_object_unknown_returns_empty() -> None:
assert cross_pack_chains_for_object("nonexistent") == ()
# ---------------------------------------------------------------------------
# Runtime integration
# ---------------------------------------------------------------------------
def test_runtime_verification_on_cross_pack_only_subject() -> None:
"""``identity/verification`` has NO in-pack chain in any registered
corpus cross-pack composer must fire."""
rt = ChatRuntime()
resp = rt.chat("Does identity require family?")
assert resp.grounding_source == "teaching"
assert "cross-pack-grounded" in resp.surface
assert "identity requires family" in resp.surface
def test_runtime_understanding_verification_routes_cross_pack() -> None:
"""``understanding/verification`` has no in-pack chain (only the
``understanding/cause`` chain exists in the cognition corpus)."""
rt = ChatRuntime()
resp = rt.chat("Does understanding require parent?")
assert resp.grounding_source == "teaching"
assert "cross-pack-grounded" in resp.surface
assert "understanding requires parent" in resp.surface
def test_runtime_in_pack_chain_takes_precedence_over_cross_pack() -> None:
"""When BOTH an in-pack chain and a cross-pack chain match the
same ``(subject, intent)``, the in-pack composer fires. Cross-
pack is the fall-through only the cognition-lane byte-identity
invariant depends on this rule."""
rt = ChatRuntime()
resp = rt.chat("Why does family exist?")
assert resp.grounding_source == "teaching"
# ``family/cause`` has both an in-pack chain (``family grounds
# parent`` in relations_chains_v1) and a cross-pack chain
# (``family grounds identity``). The in-pack chain wins.
assert "cross-pack-grounded" not in resp.surface
assert "teaching-grounded" in resp.surface
assert "family grounds parent" in resp.surface
def test_runtime_in_pack_cognition_chain_unaffected() -> None:
"""Sanity: ``knowledge/cause`` continues to ground via the
cognition corpus."""
rt = ChatRuntime()
resp = rt.chat("Why does knowledge exist?")
assert resp.grounding_source == "teaching"
assert "cross-pack-grounded" not in resp.surface
assert "teaching-grounded" in resp.surface
def test_runtime_narrative_aggregates_cross_pack_chains() -> None:
rt = ChatRuntime()
resp = rt.chat("Tell me about family.")
assert resp.grounding_source == "teaching"
assert "family grounds identity" in resp.surface
assert "family supports memory" in resp.surface
assert "cross_pack_chains_v1" in resp.surface
def test_runtime_example_aggregates_cross_pack_reverse_chains() -> None:
rt = ChatRuntime()
resp = rt.chat("Give me an example of memory.")
assert resp.grounding_source == "teaching"
assert "family supports memory" in resp.surface
def test_corpus_id_constant_matches_filename() -> None:
from pathlib import Path
expected = (
Path(__file__).resolve().parent.parent
/ "teaching" / "cross_pack_chains"
/ f"{CROSS_PACK_CORPUS_ID}.jsonl"
)
assert expected.exists()
def test_cross_pack_chain_is_frozen() -> None:
chain = _all_cross_pack_chains()[0]
assert isinstance(chain, CrossPackChain)
with pytest.raises(Exception):
chain.subject = "mutated" # type: ignore[misc]

View file

@ -101,7 +101,12 @@ def test_narrative_dedupes_by_predicate_object() -> None:
def test_narrative_handles_relations_pack_subject() -> None:
surface = narrative_grounded_surface("parent")
assert surface is not None
assert "narrative-grounded (relations_chains_v1)" in surface
# ADR-0067 — ``parent`` is the subject of both the in-pack chain
# ``parent precedes child`` (relations_chains_v1) and the cross-
# pack chain ``parent grounds understanding`` (cross_pack_chains_v1).
# The narrative composer aggregates both; the corpus tag reflects
# both binding sources.
assert "relations_chains_v1" in surface
assert "parent precedes child" in surface
@ -168,10 +173,12 @@ def test_example_aggregates_multiple_subjects() -> None:
def test_example_handles_relations_object() -> None:
"""``parent`` appears as object of ``child follows parent`` +
``family grounds parent`` multiple examples."""
``family grounds parent`` multiple examples. ADR-0067 added
``understanding requires parent`` (cross-pack), which is also
aggregated; the corpus tag widens to reflect both bindings."""
surface = example_grounded_surface("parent")
assert surface is not None
assert "example-grounded (relations_chains_v1)" in surface
assert "relations_chains_v1" in surface
assert "parent" in surface