From d5a6e81b330009113beb1b1f70bf440e06a18969 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 18 May 2026 17:22:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(adr-0067):=20cross-pack=20teaching=20chain?= =?UTF-8?q?s=20=E2=80=94=20Plan=20Phase=204=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- chat/cross_pack_grounding.py | 283 ++++++++++++++ chat/example_surface.py | 22 +- chat/narrative_surface.py | 36 +- chat/runtime.py | 11 + .../ADR-0067-cross-pack-teaching-chains.md | 237 ++++++++++++ docs/decisions/README.md | 1 + .../cross_pack_chains_v1.jsonl | 5 + tests/test_cross_pack_chains.py | 356 ++++++++++++++++++ tests/test_narrative_example_intents.py | 13 +- 9 files changed, 947 insertions(+), 17 deletions(-) create mode 100644 chat/cross_pack_grounding.py create mode 100644 docs/decisions/ADR-0067-cross-pack-teaching-chains.md create mode 100644 teaching/cross_pack_chains/cross_pack_chains_v1.jsonl create mode 100644 tests/test_cross_pack_chains.py diff --git a/chat/cross_pack_grounding.py b/chat/cross_pack_grounding.py new file mode 100644 index 00000000..df6d4b8f --- /dev/null +++ b/chat/cross_pack_grounding.py @@ -0,0 +1,283 @@ +"""chat/cross_pack_grounding.py — ADR-0067 cross-pack teaching surface. + +Phases 1–3 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) diff --git a/chat/example_surface.py b/chat/example_surface.py index 71f4a73b..898a7602 100644 --- a/chat/example_surface.py +++ b/chat/example_surface.py @@ -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: diff --git a/chat/narrative_surface.py b/chat/narrative_surface.py index 920aaabb..97cf97dd 100644 --- a/chat/narrative_surface.py +++ b/chat/narrative_surface.py @@ -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( diff --git a/chat/runtime.py b/chat/runtime.py index 31014e61..5af90636 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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 diff --git a/docs/decisions/ADR-0067-cross-pack-teaching-chains.md b/docs/decisions/ADR-0067-cross-pack-teaching-chains.md new file mode 100644 index 00000000..9e343fac --- /dev/null +++ b/docs/decisions/ADR-0067-cross-pack-teaching-chains.md @@ -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 1–3 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c0d09f42..19d442a9 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,6 +72,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt | [ADR-0060](ADR-0060-correction-acknowledgment-topic-lemma.md) | CORRECTION acknowledgement surface weaves the first pack-resident topical lemma from the utterance (left-to-right, excluding `correction` itself and `be`/`have` fillers) into a fixed template; backward-compatible with ADR-0053 (no-arg path byte-identical); closes `correction_truth_040` holdout miss; holdout `term_capture_rate` 75.0% → 79.2% | **Accepted** (2026-05-18) | | [ADR-0061](ADR-0061-procedure-intent-pack-grounded-surface.md) | PROCEDURE intent (`"How do I X?"`) routes to new `pack_grounded_procedure_surface`; selector picks **last** pack-resident lemma from verb-phrase subject (object > verb), falls back to verb when object is OOV, returns `None` (→ universal disclosure) for no-pack-lemma utterances; closes `procedure_define_010` (term `concept`) + `procedure_verify_034` (surface); holdout `surface_groundedness` 94.7% → 100.0%; `term_capture_rate` 79.2% → 83.3% | **Accepted** (2026-05-18) | | [ADR-0062](ADR-0062-composed-teaching-grounded-surface.md) | Composed teaching-grounded surface: when a chain `(A, intent_A, conn_A, B)` has a follow-up chain `(B, ?, conn_B, C)`, emit `"{A} {conn_A} {B}, which {conn_B} {C}"` instead of just `"{A} {conn_A} {B}"`; depth-1 (one hop) + cycle guard + pack-residency guard; degrades to single-chain byte-identically when no follow-up survives the guards; opt-in via `RuntimeConfig.composed_surface=False` default; cognition lane null-drop invariant (metrics byte-identical flag OFF/ON) CI-pinned | **Accepted** (2026-05-18) | +| [ADR-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) | diff --git a/teaching/cross_pack_chains/cross_pack_chains_v1.jsonl b/teaching/cross_pack_chains/cross_pack_chains_v1.jsonl new file mode 100644 index 00000000..b10a961c --- /dev/null +++ b/teaching/cross_pack_chains/cross_pack_chains_v1.jsonl @@ -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"} diff --git a/tests/test_cross_pack_chains.py b/tests/test_cross_pack_chains.py new file mode 100644 index 00000000..c3209238 --- /dev/null +++ b/tests/test_cross_pack_chains.py @@ -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] diff --git a/tests/test_narrative_example_intents.py b/tests/test_narrative_example_intents.py index 241bd0ed..d0cf8c86 100644 --- a/tests/test_narrative_example_intents.py +++ b/tests/test_narrative_example_intents.py @@ -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