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:
parent
ce8226e9a2
commit
d5a6e81b33
9 changed files with 947 additions and 17 deletions
283
chat/cross_pack_grounding.py
Normal file
283
chat/cross_pack_grounding.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
237
docs/decisions/ADR-0067-cross-pack-teaching-chains.md
Normal file
237
docs/decisions/ADR-0067-cross-pack-teaching-chains.md
Normal 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 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.
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
5
teaching/cross_pack_chains/cross_pack_chains_v1.jsonl
Normal file
5
teaching/cross_pack_chains/cross_pack_chains_v1.jsonl
Normal 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"}
|
||||
356
tests/test_cross_pack_chains.py
Normal file
356
tests/test_cross_pack_chains.py
Normal 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]
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue