From 9f83b27a7cc2817cd7d8751308f9a1de22214bb4 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 18 May 2026 15:00:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(adr-0063):=20cross-pack=20surface=20resolv?= =?UTF-8?q?er=20=E2=80=94=20kinship=20lemmas=20ground=20on=20live=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0063 closes the ADR-0048/0050/0053/0061 hardcoded-cognition-pack asymmetry. New chat/pack_resolver.py provides resolve_lemma(lemma, pack_ids) → (resolving_pack_id, semantic_domains) across an ordered tuple of mounted lexicon packs (first-match-wins, lru_cache per-pack). Surface composers in chat/pack_grounding.py now consult the resolver instead of a hardcoded en_core_cognition_v1. en_core_relations_v1 joins RuntimeConfig.input_packs defaults; kinship lemmas now ground on the live path: > What is a parent? parent — pack-grounded (en_core_relations_v1): kinship.ascendant.direct; kinship.parent; biology.progenitor. No session evidence yet. Cross-pack comparison (knowledge × parent) renders composite tag (en_core_cognition_v1 × en_core_relations_v1). Cognition lane remains byte-identical: cognition is resolved first and the surface format for cognition lemmas is unchanged. Cognition eval (byte-identical to pre-ADR baseline): public → intent 100% / surface 100% / term 91.7% / closure 100% holdout → intent 100% / surface 100% / term 83.3% / closure 100% Curated lanes green: smoke 67 / cognition 121 / teaching 17 / packs 6 / runtime 19 / algebra 132. New tests: test_pack_resolver.py (28) + test_cross_pack_grounding.py (17). test_en_core_relations_v1_pack.py: default-input-packs guard inverted. test_pack_grounding.py: two stale ADR-0048 tests rewritten (premises invalidated by ADR-0052/0061; now use fully-out-of-pack prompts). chat/teaching_grounding.py UNCHANGED — cognition_chains_v1 corpus stays cognition-only. Cross-pack teaching corpora are the natural ADR-0064. --- chat/pack_grounding.py | 115 +++++--- chat/pack_resolver.py | 136 +++++++++ core/config.py | 6 + docs/curriculum/relations_pack_v1.md | 52 ++-- .../ADR-0063-cross-pack-surface-resolver.md | 265 ++++++++++++++++++ docs/decisions/README.md | 1 + tests/test_cross_pack_grounding.py | 148 ++++++++++ tests/test_en_core_relations_v1_pack.py | 30 +- tests/test_pack_grounding.py | 26 +- tests/test_pack_resolver.py | 167 +++++++++++ 10 files changed, 860 insertions(+), 86 deletions(-) create mode 100644 chat/pack_resolver.py create mode 100644 docs/decisions/ADR-0063-cross-pack-surface-resolver.md create mode 100644 tests/test_cross_pack_grounding.py create mode 100644 tests/test_pack_resolver.py diff --git a/chat/pack_grounding.py b/chat/pack_grounding.py index 941ab3fe..f7e1c096 100644 --- a/chat/pack_grounding.py +++ b/chat/pack_grounding.py @@ -38,6 +38,12 @@ import json from functools import lru_cache from pathlib import Path +from chat.pack_resolver import ( + DEFAULT_RESOLVABLE_PACK_IDS, + mounted_lemmas, + resolve_lemma, +) + PACK_ID: str = "en_core_cognition_v1" _PACK_LEXICON_PATH = ( @@ -77,44 +83,51 @@ def _pack_index() -> dict[str, tuple[str, ...]]: return out -def pack_grounded_surface(lemma: str) -> str | None: +def pack_grounded_surface( + lemma: str, + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> str | None: """Return a deterministic pack-grounded surface for *lemma*, or ``None``. The surface format is fixed: - "{lemma} — pack-grounded ({pack_id}): {d1}; {d2}; {d3}. No session evidence yet." + "{lemma} — pack-grounded ({resolved_pack_id}): {d1}; {d2}; {d3}. No session evidence yet." - Only the lemma and up to three semantic_domains from the pack are - emitted; both come directly from the ratified pack lexicon, with no - rewording. The trailing disclosure is the constant trust-boundary - label that distinguishes pack-grounded surfaces from vault-grounded - surfaces (which would carry session evidence) and from the universal - "insufficient grounding" disclosure (which carries neither). + ADR-0063 — *resolved_pack_id* is the pack in *pack_ids* whose lexicon + contained *lemma* (first-match-wins). Cognition lemmas keep emitting + ``pack-grounded (en_core_cognition_v1)`` byte-identically; kinship + lemmas emit ``pack-grounded (en_core_relations_v1)``. + + Only the lemma and up to three semantic_domains from the resolved + pack are emitted; both come directly from the ratified pack lexicon, + with no rewording. Returns ``None`` when: - the lemma is empty or not a string, - - the pack lexicon file is unavailable, - - the lemma is not present in the pack, - - the pack entry has no ``semantic_domains``. + - the lemma does not resolve in any of *pack_ids*. """ - if not lemma or not isinstance(lemma, str): + resolved = resolve_lemma(lemma, pack_ids) + if resolved is None: return None + resolved_pack_id, domains = resolved key = lemma.strip().lower() - if not key: - return None - index = _pack_index() - domains = index.get(key) - if not domains: - return None head = "; ".join(domains[:3]) return ( - f"{key} — pack-grounded ({PACK_ID}): {head}. " + f"{key} — pack-grounded ({resolved_pack_id}): {head}. " f"No session evidence yet." ) def is_pack_lemma(lemma: str) -> bool: - """Return True iff *lemma* has an entry with ``semantic_domains`` in the pack.""" + """Return True iff *lemma* has an entry with ``semantic_domains`` in the + ratified cognition pack (``en_core_cognition_v1``). + + Cognition-pack-specific helper retained for back-compat with the + cognition-corpus modules (discovery, contemplation, teaching + chains) whose semantics are scoped to the cognition pack. For + cross-pack residency checks, use + :func:`chat.pack_resolver.is_resolvable`. + """ if not lemma or not isinstance(lemma, str): return False return lemma.strip().lower() in _pack_index() @@ -133,24 +146,27 @@ _CORRECTION_TOPIC_STOPWORDS: frozenset[str] = frozenset({ def _extract_correction_topic_lemma(text: str) -> str | None: - """Return the first pack-resident, topical lemma in *text*, or None. + """Return the first mounted-pack-resident, topical lemma in *text*, or None. Deterministic: tokens are processed in left-to-right utterance - order; the first token that is pack-resident AND not in the - correction-stopword set wins. Stopwords filter out the meta- - cognition lemma itself (``correction``) and dialogue fillers + order; the first token that is resident in any mounted pack AND not + in the correction-stopword set wins. Stopwords filter out the + meta-cognition lemma itself (``correction``) and dialogue fillers (``be``, ``have``) that classify as pack lemmas but carry no topical signal. - Used by ``pack_grounded_correction_surface`` to weave the + ADR-0063 — residency is checked across all mounted lexicon packs + (see :data:`chat.pack_resolver.DEFAULT_RESOLVABLE_PACK_IDS`), so a + kinship correction (``"No, my parent disagrees"``) anchors the + acknowledgement on the kinship topic. + + Used by :func:`pack_grounded_correction_surface` to weave the corrected claim's subject into the acknowledgement template. """ if not text or not isinstance(text, str): return None - index = _pack_index() - # Tokenize: lowercase, strip surrounding punctuation, skip empties. + lemmas = mounted_lemmas() raw = text.lower() - # Replace common punctuation with whitespace; preserve word boundaries. for ch in ",.;:!?\"'()[]{}": raw = raw.replace(ch, " ") for token in raw.split(): @@ -158,7 +174,7 @@ def _extract_correction_topic_lemma(text: str) -> str | None: continue if token in _CORRECTION_TOPIC_STOPWORDS: continue - if token in index: + if token in lemmas: return token return None @@ -208,7 +224,12 @@ def pack_grounded_correction_surface(text: str | None = None) -> str | None: head = "; ".join(domains[:3]) topic_lemma = _extract_correction_topic_lemma(text) if text else None if topic_lemma is not None: - topic_domains = index.get(topic_lemma, ()) + # ADR-0063 — topic_lemma may resolve in a non-cognition pack + # (e.g. ``parent`` in en_core_relations_v1). Anchor pack stays + # cognition (``correction`` is a cognition lemma), topic domains + # come from whichever pack resolves the topic. + topic_resolved = resolve_lemma(topic_lemma) + topic_domains = topic_resolved[1] if topic_resolved is not None else () topic_head = "; ".join(topic_domains[:2]) if topic_domains else "" if topic_head: return ( @@ -257,7 +278,7 @@ def _extract_procedure_topic_lemma(subject_text: str) -> str | None: """ if not subject_text or not isinstance(subject_text, str): return None - index = _pack_index() + lemmas = mounted_lemmas() raw = subject_text.lower() for ch in ",.;:!?\"'()[]{}": raw = raw.replace(ch, " ") @@ -267,7 +288,7 @@ def _extract_procedure_topic_lemma(subject_text: str) -> str | None: continue if token in _PROCEDURE_TOPIC_STOPWORDS: continue - if token in index: + if token in lemmas: last_match = token return last_match @@ -304,13 +325,17 @@ def pack_grounded_procedure_surface(subject_text: str) -> str | None: lemma = _extract_procedure_topic_lemma(subject_text) if lemma is None: return None - index = _pack_index() - domains = index.get(lemma, ()) - if not domains: + # ADR-0063 — resolve topic across all mounted lexicon packs. The + # surface tag follows the resolving pack id so a kinship procedure + # (``"How do I trace my ancestor?"``) emits + # ``procedure-grounded (en_core_relations_v1)``. + resolved = resolve_lemma(lemma) + if resolved is None: return None + resolved_pack_id, domains = resolved head = "; ".join(domains[:2]) return ( - f"procedure-grounded ({PACK_ID}): {lemma} ({head}). " + f"procedure-grounded ({resolved_pack_id}): {lemma} ({head}). " f"Step-by-step guidance for {lemma} is not yet ratified in this session." ) @@ -350,14 +375,22 @@ def pack_grounded_comparison_surface( return None if key_a == key_b: return None - index = _pack_index() - domains_a = index.get(key_a) - domains_b = index.get(key_b) - if not domains_a or not domains_b: + resolved_a = resolve_lemma(key_a) + resolved_b = resolve_lemma(key_b) + if resolved_a is None or resolved_b is None: return None + pack_a, domains_a = resolved_a + pack_b, domains_b = resolved_b head_a = "; ".join(domains_a[:2]) head_b = "; ".join(domains_b[:2]) + # ADR-0063 — tag follows the resolving pack ids. Cognition-only + # comparisons stay byte-identical (both sides resolve to cognition); + # cross-pack comparisons render the composite tag explicitly. + if pack_a == pack_b: + tag = f"pack-grounded ({pack_a})" + else: + tag = f"pack-grounded ({pack_a} × {pack_b})" return ( f"{key_a} ({head_a}) contrasts with {key_b} ({head_b}) " - f"— pack-grounded ({PACK_ID}). No session evidence yet." + f"— {tag}. No session evidence yet." ) diff --git a/chat/pack_resolver.py b/chat/pack_resolver.py new file mode 100644 index 00000000..9bf59233 --- /dev/null +++ b/chat/pack_resolver.py @@ -0,0 +1,136 @@ +"""chat/pack_resolver.py — ADR-0063 cross-pack surface resolver. + +The cold-start grounding composers in :mod:`chat.pack_grounding` were +hardcoded to a single lexicon pack (``en_core_cognition_v1``). That +asymmetry blocked ``en_core_relations_v1`` from being mounted on the +default runtime: mounting it would silently widen vault recall and +intent classification without a corresponding ratified surface +composer for kinship lemmas. + +This module supplies the missing abstraction: a *deterministic*, +*first-match-wins* resolver that maps a lemma to ``(pack_id, semantic_domains)`` +across an ordered tuple of mounted lexicon packs. Surface composers +consult the resolver instead of a single pack; the surface trust-boundary +tag follows the *resolving* pack id. + +Design constraints (CLAUDE.md / Reconstruction-over-storage): + +- The resolver loads each pack lexicon at most once per process via + :func:`functools.lru_cache`. Ratified packs are immutable, so caching + is sound. +- A pack that fails to load (missing file, unreadable JSON) contributes + an empty index — callers cannot distinguish "pack absent" from "lemma + absent in mounted packs". Both produce ``None``. +- First-match-wins on collision. The orthogonality test in + ``tests/test_en_core_relations_v1_pack.py`` enforces zero overlap + today; this rule documents the deterministic resolution if a future + pack pair collides. +- No mutation of pack data is performed anywhere in this module. All + return types are tuples/frozensets/dicts of plain strings. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path + +# Default mounted lexicon-pack ids that ADR-0063 surface composers +# consult. Order matters: earlier packs win on lemma collision. This +# tuple is intentionally narrow — it lists only ratified ``en_core_*`` +# *content* packs. Identity/safety/ethics packs are propositional +# overlays and never carry vocabulary; they are deliberately excluded. +DEFAULT_RESOLVABLE_PACK_IDS: tuple[str, ...] = ( + "en_core_cognition_v1", + "en_core_relations_v1", +) + +_PACK_ROOT = Path(__file__).resolve().parent.parent / "language_packs" / "data" + + +@lru_cache(maxsize=16) +def _pack_lexicon_for(pack_id: str) -> dict[str, tuple[str, ...]]: + """Return ``{lemma_lower: semantic_domains}`` for *pack_id*, cached. + + Returns an empty dict if the pack's lexicon file is missing, + unreadable, or contains no entries with ``semantic_domains``. + Ratified packs are immutable so the cache survives the process + lifetime. + """ + lexicon_path = _PACK_ROOT / pack_id / "lexicon.jsonl" + if not lexicon_path.exists(): + return {} + out: dict[str, tuple[str, ...]] = {} + for line in lexicon_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 + lemma = entry.get("lemma") or entry.get("surface") + if not lemma or not isinstance(lemma, str): + continue + domains = entry.get("semantic_domains", ()) + if not isinstance(domains, (list, tuple)) or not domains: + continue + out[lemma.lower()] = tuple(str(d) for d in domains) + return out + + +def resolve_lemma( + lemma: str, + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> tuple[str, tuple[str, ...]] | None: + """Return ``(pack_id, semantic_domains)`` for the first pack in + *pack_ids* whose lexicon contains *lemma*, else ``None``. + + First-match-wins on collision. ``pack_ids`` defaults to + :data:`DEFAULT_RESOLVABLE_PACK_IDS`. + """ + if not lemma or not isinstance(lemma, str): + return None + key = lemma.strip().lower() + if not key: + return None + for pack_id in pack_ids: + index = _pack_lexicon_for(pack_id) + domains = index.get(key) + if domains: + return (pack_id, domains) + return None + + +def is_resolvable( + lemma: str, + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> bool: + """Return True iff *lemma* resolves in any of *pack_ids*.""" + return resolve_lemma(lemma, pack_ids) is not None + + +def mounted_lemmas( + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> frozenset[str]: + """Return the frozen union of lemmas across *pack_ids*. + + Used by topic extractors that iterate over user-text tokens and + need an O(1) "is this token any mounted lemma?" check. + """ + out: set[str] = set() + for pack_id in pack_ids: + out.update(_pack_lexicon_for(pack_id).keys()) + return frozenset(out) + + +def clear_resolver_cache() -> None: + """Drop the lru_cache for :func:`_pack_lexicon_for`. + + Test-only escape hatch: enables fixture-based pack mutation + scenarios. Production code never calls this — ratified packs are + immutable. + """ + _pack_lexicon_for.cache_clear() diff --git a/core/config.py b/core/config.py index 23bdc9c0..aa0b4368 100644 --- a/core/config.py +++ b/core/config.py @@ -5,9 +5,15 @@ from dataclasses import dataclass @dataclass(frozen=True, slots=True) class RuntimeConfig: + # ADR-0063 — ``en_core_relations_v1`` (kinship starter pack) joins the + # default mount once the cross-pack surface resolver lands. Pack + # composers in :mod:`chat.pack_grounding` now consult + # :mod:`chat.pack_resolver`, so kinship lemmas ground deterministically + # without a separate composer module. input_packs: tuple[str, ...] = ( "en_minimal_v1", "en_core_cognition_v1", + "en_core_relations_v1", "he_logos_micro_v1", "grc_logos_micro_v1", ) diff --git a/docs/curriculum/relations_pack_v1.md b/docs/curriculum/relations_pack_v1.md index 506b7c57..c111185d 100644 --- a/docs/curriculum/relations_pack_v1.md +++ b/docs/curriculum/relations_pack_v1.md @@ -4,7 +4,7 @@ **Author:** Shay **Pack ID:** `en_core_relations_v1` **Lemma count:** 8 (kinship-only; deliberately tight) -**Status:** Ratified (checksum verified). **Not yet mounted** on the default runtime. +**Status:** Ratified (checksum verified). **Mounted on the default runtime** as of ADR-0063 (`chat/pack_resolver.py`) — cross-pack surface composers ground kinship lemmas deterministically on the live path. --- @@ -86,30 +86,37 @@ follow-up ADR — there is no `relations_chains_v1.jsonl` yet). --- -## Engagement (opt-in only) +## Engagement (default mount as of ADR-0063) -The pack is **not** in the default `RuntimeConfig.input_packs`. -Mounting it changes the runtime's mounted manifold (89 → 93 -lemmas in the cognition+relations combination). Until cross-pack -teaching-grounded composition exists, mounting the relations -pack would expose lemmas to vault recall and intent classification -without a corresponding ratified surface composer for them. That -asymmetry would silently lower the deterministic-grounding -fraction of any kinship prompt. +The pack is in the default `RuntimeConfig.input_packs` as of +ADR-0063 (cross-pack surface resolver). Mounting changes the +runtime's mounted manifold (cognition+relations combination) but the +pack-grounded surface composers in `chat/pack_grounding.py` now +consult `chat/pack_resolver.py` for cross-pack lemma residency — so +kinship lemmas ground on the live path: -To opt in for development: +``` +> What is a parent? +parent — pack-grounded (en_core_relations_v1): kinship.ascendant.direct; +kinship.parent; biology.progenitor. No session evidence yet. +``` + +For development with the pack disabled, opt out: ```python from core.config import RuntimeConfig from chat.runtime import ChatRuntime -cfg = RuntimeConfig(input_packs=RuntimeConfig().input_packs + ("en_core_relations_v1",)) +# Opt-out of the default mount (development only): +defaults = RuntimeConfig().input_packs +cfg = RuntimeConfig( + input_packs=tuple(p for p in defaults if p != "en_core_relations_v1") +) rt = ChatRuntime(config=cfg) ``` -Programmatic mounting works today. CLI-level support -(`core chat --pack en_core_relations_v1`) is already in place via -the `--pack` flag (see `core chat --help`). +For full production use, no override is needed — the pack is mounted by +default and the cross-pack resolver finds kinship lemmas automatically. --- @@ -138,13 +145,14 @@ data + a contract test. No runtime code path changed. ## Path forward (future ADRs in priority order) -1. **Cross-pack teaching-grounded composition.** - `chat/pack_grounding.py` and `chat/teaching_grounding.py` are - currently hardcoded to `en_core_cognition_v1`. A - `pack_resolver` abstraction that picks the right pack(s) for a - given intent/subject is the natural unlock — composes the - relations pack into the live surface path without a separate - composer module. +1. **Cross-pack teaching-grounded composition.** ✅ **Landed (ADR-0063).** + `chat/pack_resolver.py` provides `resolve_lemma(lemma, pack_ids) → + (resolving_pack_id, semantic_domains)`. Surface composers in + `chat/pack_grounding.py` consult the resolver; kinship lemmas + ground on the live path without a separate composer module. + `chat/teaching_grounding.py` still references the cognition-chains + corpus only — extending it to a relations-chains corpus is the + natural next ADR. 2. **First kinship reviewed chains** — a `relations_chains_v1.jsonl` or extension of the cognition diff --git a/docs/decisions/ADR-0063-cross-pack-surface-resolver.md b/docs/decisions/ADR-0063-cross-pack-surface-resolver.md new file mode 100644 index 00000000..9c561b04 --- /dev/null +++ b/docs/decisions/ADR-0063-cross-pack-surface-resolver.md @@ -0,0 +1,265 @@ +# ADR-0063 — Cross-pack surface resolver + +**Status:** Accepted +**Date:** 2026-05-18 +**Author:** Shay +**Supersedes:** none (extends ADR-0048 / ADR-0050 / ADR-0052 / ADR-0053 / ADR-0060 / ADR-0061 / ADR-0062) +**Builds on:** the kinship starter pack `en_core_relations_v1` (commit `f0c57eb`). + +--- + +## Context + +ADR-0048 introduced the first pack-grounded surface composer — a +deterministic cold-start surface for `DEFINITION` / `RECALL` intents +whose subject lemma is present in the ratified cognition pack. ADR-0050 +extended that to `COMPARISON`. ADR-0053 + ADR-0060 added a `CORRECTION` +acknowledgement composer (cold-start branch). ADR-0061 added a +`PROCEDURE` composer. All four composers shared a hardcoded reference +to a single lexicon pack: + +```python +# chat/pack_grounding.py — pre-ADR-0063 +PACK_ID: str = "en_core_cognition_v1" +``` + +That asymmetry was the rate-limiter on domain expansion. Mounting the +`en_core_relations_v1` kinship starter pack (already ratified — +checksum `1a013b46…`) would silently widen the runtime's mounted +manifold (vault recall + intent classification) without a corresponding +surface composer for kinship lemmas. The cold-start +`What is a parent?` prompt would fall through to the universal +"insufficient grounding" disclosure even though `parent` is in a +ratified, immutable pack on disk. + +The previous ADR's memory recorded this explicitly: + +> Engagement status: OPT-IN ONLY. Pack is NOT in +> `RuntimeConfig.input_packs` defaults… until cross-pack +> teaching-grounded composition exists, mounting silently exposes +> lemmas to vault recall and intent classification without a +> corresponding ratified surface composer. + +ADR-0063 is the **one-PR architectural unlock** that closes that gap: +the surface composers consult an abstract *resolver* across all +mounted lexicon packs instead of one hardcoded pack, and the relations +pack joins the default mount. + +--- + +## Decision + +### 1. New abstraction: `chat/pack_resolver.py` + +A small (~140 lines) pure module that maps a lemma to a +`(resolving_pack_id, semantic_domains)` pair across an ordered tuple of +mounted lexicon packs: + +```python +DEFAULT_RESOLVABLE_PACK_IDS: tuple[str, ...] = ( + "en_core_cognition_v1", + "en_core_relations_v1", +) + +def resolve_lemma( + lemma: str, + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> tuple[str, tuple[str, ...]] | None: ... + +def is_resolvable(lemma, pack_ids=...) -> bool: ... +def mounted_lemmas(pack_ids=...) -> frozenset[str]: ... +``` + +Properties: + +- **First-match-wins.** Order matters: cognition is listed first so + that any future cross-pack collision resolves cognition-side, + preserving cognition-lane byte-identity. +- **lru_cache per-pack.** Each pack's `lexicon.jsonl` is loaded at most + once per process (ratified packs are immutable). +- **No mutation.** Returns tuples, frozensets, plain strings. +- **Pure.** No I/O outside the cached read; no network; no LLM; no + approximation. + +### 2. Surface composers consult the resolver + +`chat/pack_grounding.py`: + +- `pack_grounded_surface(lemma)` — surface tag follows the resolving + pack id. Cognition lemmas keep emitting + `pack-grounded (en_core_cognition_v1)` byte-identically; kinship + lemmas emit `pack-grounded (en_core_relations_v1)`. +- `pack_grounded_comparison_surface(a, b)` — each side resolved + independently. When both resolve to the same pack, the tag stays + single (`pack-grounded (en_core_cognition_v1)`). When the two + resolve to different packs, the tag becomes composite + (`pack-grounded (en_core_cognition_v1 × en_core_relations_v1)`). +- `pack_grounded_correction_surface(text)` — anchor pack stays + cognition (`correction` is a cognition lemma); topic domains come + from whichever pack resolves the topic lemma. Topic extraction + scans `mounted_lemmas()` instead of the cognition-only index. +- `pack_grounded_procedure_surface(subject_text)` — topic extraction + scans `mounted_lemmas()`; resolving pack id drives the surface tag. + +`chat/teaching_grounding.py` is **unchanged** in this ADR. The +reviewed teaching corpus (`cognition_chains_v1.jsonl`) still +references cognition-pack lemmas only. Cross-pack teaching chains +(e.g. a `relations_chains_v1.jsonl`) are the natural next ADR. + +### 3. Backward compatibility + +- `chat/pack_grounding.py:PACK_ID = "en_core_cognition_v1"` and + `_pack_index()` (cognition-only) are retained for back-compat with + consumers in `teaching/contemplation.py`, `teaching/discovery.py`, + `chat/teaching_grounding.py`, and various tests whose semantics are + scoped to the cognition pack. They are NOT removed — only the + composers' internal lookups switch to the resolver. +- `is_pack_lemma(lemma)` keeps its cognition-only semantics; cross-pack + residency is `chat.pack_resolver.is_resolvable`. + +### 4. Default mount adds the relations pack + +`core/config.py`: + +```python +input_packs: tuple[str, ...] = ( + "en_minimal_v1", + "en_core_cognition_v1", + "en_core_relations_v1", # added in ADR-0063 + "he_logos_micro_v1", + "grc_logos_micro_v1", +) +``` + +Mounting is now safe because the composers ground kinship lemmas +deterministically. + +### 5. The relations-pack default-input test inverts + +`tests/test_en_core_relations_v1_pack.py`: +- Before: `test_pack_is_not_in_default_input_packs` +- After: `test_pack_is_in_default_input_packs` + +The earlier test was an explicit *guard against premature mounting*. +It guarded the asymmetry that ADR-0063 closes; its inverse is now the +correct invariant. + +--- + +## Consequences + +### Capability unlocked + +| Surface composer | Cognition lemmas | Kinship lemmas | +|---|---|---| +| `pack_grounded_surface` (DEFINITION / RECALL) | byte-identical | **now grounds** | +| `pack_grounded_comparison_surface` (COMPARISON) | byte-identical when both cognition | **now grounds** kinship × kinship; composite tag for cross-pack | +| `pack_grounded_correction_surface` (CORRECTION) | byte-identical when topic ∈ cognition | **now anchors topic** on kinship lemmas | +| `pack_grounded_procedure_surface` (PROCEDURE) | byte-identical when topic ∈ cognition | **now grounds** kinship topics | + +### Cognition lane: byte-identical + +The cognition lane's prompts use only cognition lemmas. The resolver +picks cognition first, the surface tag stays `en_core_cognition_v1`, +the surface bytes are identical: + +``` +core eval cognition → 100/100/91.7/100 (public) +core eval cognition --split holdout → 100/100/83.3/100 (holdout) +``` + +Both byte-identical to the pre-ADR baseline. + +### Live-path grounding on kinship lemmas + +```text +$ core chat +> What is a parent? +parent — pack-grounded (en_core_relations_v1): kinship.ascendant.direct; +kinship.parent; biology.progenitor. No session evidence yet. +grounding_source = pack +``` + +### Future ADRs unlocked + +1. **ADR-0064 — Cross-pack teaching chains.** Add + `relations_chains_v1.jsonl` (reviewed kinship chains) and extend + `chat/teaching_grounding.py` to consult both corpora. The natural + next ADR. +2. **Pronoun + role-filler v2.** `mother`, `father`, `son`, etc. as + specializations of the v1 kinship primitives — extends + `en_core_relations_v1` itself. +3. **Cross-domain triples.** `family causes belonging`, + `parent grounds identity` — opens after cognition + relations both + have ratified internal DAGs of reviewed chains. + +--- + +## Trust boundaries + +- The resolver does NOT execute pack metadata, validators, or scripts. + It reads `lexicon.jsonl` only. +- A pack that fails to load produces an empty index — callers see + `None`. No exception leaks into the runtime. +- No mutation site introduced. The resolver is read-only over + immutable, ratified pack data. +- Tag tokens emitted in surfaces are always one of: + `en_core_cognition_v1`, `en_core_relations_v1`, or the composite + `{a} × {b}` form derived from mounted-pack ids — never from user + input. +- `pack_resolver.clear_resolver_cache()` is a test-only escape hatch; + production paths never call it. + +--- + +## Verification + +``` +tests/test_pack_resolver.py 28 passed +tests/test_cross_pack_grounding.py 17 passed +tests/test_pack_grounding.py 13 passed +tests/test_pack_grounded_comparison.py passed +tests/test_pack_grounded_correction.py passed +tests/test_correction_topic_lemma.py passed +tests/test_procedure_surface.py passed +tests/test_teaching_grounding.py passed +tests/test_composed_surface.py 11 passed +tests/test_en_core_relations_v1_pack.py 6 passed (inverted) + +Lanes: + core test --suite smoke 67 passed + core test --suite cognition 121 passed + core test --suite teaching 17 passed + core test --suite packs 6 passed + core test --suite runtime 19 passed + core test --suite algebra 132 passed + core test --suite full + +Cognition eval (byte-identical to pre-ADR baseline): + public split → intent 100% / surface 100% / term 91.7% / closure 100% + holdout split → intent 100% / surface 100% / term 83.3% / closure 100% +``` + +The non-negotiable field invariant `versor_condition(F) < 1e-6` is +unaffected — this ADR is a routing/dispatch change over immutable +pack data; no algebra, no field operators, no normalization sites +were touched. + +--- + +## Files changed + +``` +chat/pack_resolver.py NEW (~140 lines) +chat/pack_grounding.py composers use resolver +core/config.py relations pack joins defaults +tests/test_pack_resolver.py NEW (28 tests) +tests/test_cross_pack_grounding.py NEW (17 tests) +tests/test_pack_grounding.py 2 stale tests rewritten +tests/test_en_core_relations_v1_pack.py default-input test inverted +docs/decisions/ADR-0063-cross-pack-surface-resolver.md NEW (this file) +docs/decisions/README.md ADR-0063 index entry +``` + +Lines of net change: small. The architectural unlock is in +`chat/pack_resolver.py`; the rest of the diff is wiring + tests. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e83c8404..b8e21d5a 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-0063](ADR-0063-cross-pack-surface-resolver.md) | Cross-pack surface resolver: `chat/pack_resolver.py` introduces `resolve_lemma(lemma, pack_ids)` that maps a lemma to `(resolving_pack_id, semantic_domains)` across an ordered tuple of mounted lexicon packs (first-match-wins); pack-grounded DEFINITION / RECALL / COMPARISON / CORRECTION / PROCEDURE composers now consult the resolver instead of a hardcoded `en_core_cognition_v1`; surface trust-boundary tag follows the resolving pack id; `en_core_relations_v1` joins `RuntimeConfig.input_packs` defaults — kinship lemmas now ground on the live path without a separate composer module; cognition-lane surfaces remain byte-identical (cognition is resolved first) | **Accepted** (2026-05-18) | --- diff --git a/tests/test_cross_pack_grounding.py b/tests/test_cross_pack_grounding.py new file mode 100644 index 00000000..0c3d30e2 --- /dev/null +++ b/tests/test_cross_pack_grounding.py @@ -0,0 +1,148 @@ +"""ADR-0063 — cross-pack surface grounding integration tests. + +These tests pin the live-path behaviour the cross-pack surface resolver +unlocks: with ``en_core_relations_v1`` joining the default mount, the +pack-grounded DEFINITION / RECALL / COMPARISON / CORRECTION / PROCEDURE +composers all engage on kinship lemmas the same way they already engage +on cognition lemmas. + +The trust-boundary tag in the emitted surface follows the *resolving* +pack id — cognition lemmas still emit ``pack-grounded +(en_core_cognition_v1)`` byte-identically, kinship lemmas emit +``pack-grounded (en_core_relations_v1)``. + +Cognition lane invariants (covered elsewhere) must remain unchanged: +the only surfaces that move are the ones whose subject lemma is a +kinship token. +""" + +from __future__ import annotations + +import pytest + +from chat.pack_grounding import ( + pack_grounded_comparison_surface, + pack_grounded_correction_surface, + pack_grounded_procedure_surface, + pack_grounded_surface, +) +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig + + +# --------------------------------------------------------------------------- +# Pure-function surface composers — kinship lemmas now ground +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "lemma", + ["parent", "child", "sibling", "family", + "ancestor", "descendant", "spouse", "offspring"], +) +def test_pack_grounded_surface_resolves_kinship_lemmas(lemma: str) -> None: + """Every kinship lemma now produces a deterministic pack-grounded + surface tagged with the resolving pack id.""" + surface = pack_grounded_surface(lemma) + assert surface is not None, f"{lemma!r} did not surface" + assert lemma in surface + assert "pack-grounded (en_core_relations_v1)" in surface + assert "No session evidence yet." in surface + + +def test_cognition_surface_is_byte_identical_after_resolver() -> None: + """ADR-0063 must not change the surface for cognition-pack lemmas + — the resolver picks cognition first, the pack tag remains + ``en_core_cognition_v1``.""" + surface = pack_grounded_surface("light") + assert surface is not None + assert "pack-grounded (en_core_cognition_v1)" in surface + assert "en_core_relations_v1" not in surface + + +def test_comparison_cross_pack_renders_composite_tag() -> None: + """A kinship × cognition comparison emits the composite pack tag + ``(en_core_cognition_v1 × en_core_relations_v1)`` — first side's + resolution first.""" + surface = pack_grounded_comparison_surface("knowledge", "parent") + assert surface is not None + assert "knowledge" in surface + assert "parent" in surface + assert "contrasts with" in surface + assert "en_core_cognition_v1 × en_core_relations_v1" in surface + + +def test_comparison_cognition_only_is_byte_identical() -> None: + """A cognition × cognition comparison must keep the single-pack + tag — adding the resolver did not regress the existing surface.""" + surface = pack_grounded_comparison_surface("knowledge", "truth") + assert surface is not None + assert "pack-grounded (en_core_cognition_v1)" in surface + assert "×" not in surface + + +def test_procedure_surface_resolves_kinship_topic() -> None: + """A procedure verb-phrase whose topical lemma resolves only in + the relations pack must tag the surface with the relations pack id.""" + surface = pack_grounded_procedure_surface("trace my ancestor") + assert surface is not None + assert "ancestor" in surface + assert "procedure-grounded (en_core_relations_v1)" in surface + assert "not yet ratified in this session" in surface + + +def test_correction_surface_threads_kinship_topic() -> None: + """A CORRECTION whose first topic lemma is kinship-pack-resident + weaves it into the acknowledgement. Anchor pack stays cognition + (the ``correction`` lemma lives there).""" + surface = pack_grounded_correction_surface( + "No, my parent disagrees with that." + ) + assert surface is not None + assert "pack-grounded (en_core_cognition_v1)" in surface + assert "Noted topic: parent" in surface + # Topic domains come from the relations pack, but render verbatim + # as part of the topic clause — no separate tag emitted (the topic + # pack is implied by the lemma). + assert "kinship" in surface + + +# --------------------------------------------------------------------------- +# Live runtime — DEFINITION on a kinship lemma grounds +# --------------------------------------------------------------------------- + + +def test_runtime_definition_on_kinship_lemma_engages_pack_path() -> None: + """``What is a parent?`` on a cold-start runtime now routes through + the pack-grounded path — the relations pack is mounted, the + resolver finds the lemma, the surface composer emits a + deterministic surface tagged ``en_core_relations_v1``.""" + rt = ChatRuntime() + resp = rt.chat("What is a parent?") + assert resp.grounding_source == "pack" + assert "parent" in resp.surface + assert "en_core_relations_v1" in resp.surface + + +def test_runtime_recall_on_kinship_lemma_engages_pack_path() -> None: + """``Remember family`` — RECALL intent — also surfaces via the + cross-pack path.""" + rt = ChatRuntime() + resp = rt.chat("Remember family") + assert resp.grounding_source == "pack" + assert "family" in resp.surface + + +def test_relations_pack_is_in_default_input_packs() -> None: + """ADR-0063 — invariant: the relations pack joins the default + mount once the resolver lands. If this asserts false, the live + path lost cross-pack grounding silently.""" + assert "en_core_relations_v1" in RuntimeConfig().input_packs + + +def test_resolver_default_pack_order_favors_cognition() -> None: + """Cognition is resolved first. When future packs introduce a + name collision (today there is none), cognition wins — preserving + cognition-lane byte-identity.""" + from chat.pack_resolver import DEFAULT_RESOLVABLE_PACK_IDS + assert DEFAULT_RESOLVABLE_PACK_IDS[0] == "en_core_cognition_v1" diff --git a/tests/test_en_core_relations_v1_pack.py b/tests/test_en_core_relations_v1_pack.py index 922401fb..3a430f73 100644 --- a/tests/test_en_core_relations_v1_pack.py +++ b/tests/test_en_core_relations_v1_pack.py @@ -5,18 +5,18 @@ Following the teaching-order doctrine domain expansion starts with kinship — a tight, well-bounded domain whose triples exercise every formation gate end-to-end. -This pack is **not yet mounted** on the default runtime: it is -not in ``RuntimeConfig.input_packs`` defaults. Operator opt-in via -explicit ``RuntimeConfig(input_packs=(..., "en_core_relations_v1"))`` -is the engagement path until a follow-up ADR introduces cross-pack -composition for teaching-grounded surfaces. +As of ADR-0063 (cross-pack surface resolver), this pack IS in the +default ``RuntimeConfig.input_packs``. Pack-grounded surface +composers in :mod:`chat.pack_grounding` consult +:mod:`chat.pack_resolver` so kinship lemmas ground on the live path +without a separate composer module. These tests pin: - The pack loads via ``load_pack("en_core_relations_v1")`` without a checksum mismatch (manifest.checksum matches the bytes on disk per CLAUDE.md's pack-discipline). - - The 9 kinship lemmas are all present. + - The 8 kinship lemmas are all present. - Each lemma carries the expected canonical semantic_domains (deterministic taxonomy under ``kinship.*``, ``lineage.*``, ``biology.*``, ``social.*``). @@ -130,11 +130,15 @@ def test_no_lemma_collision_with_cognition_pack() -> None: ) -def test_pack_is_not_in_default_input_packs() -> None: - """Engagement is opt-in. Adding the pack to the default - ``input_packs`` is a follow-up ADR scope: cross-pack teaching- - grounded composition does not exist yet, and silently mounting - the pack would change the runtime's recall surface without a - corresponding ratification path.""" +def test_pack_is_in_default_input_packs() -> None: + """ADR-0063 — once the cross-pack surface resolver landed, the + relations pack joined the default mount. Pack composers in + :mod:`chat.pack_grounding` consult :mod:`chat.pack_resolver` for + cross-pack lemma residency, so mounting the pack no longer + silently widens recall without a corresponding surface composer + — the composer is now cross-pack by default. + + Inverted from the original ADR-0063-pre guard + ``test_pack_is_not_in_default_input_packs``.""" from core.config import RuntimeConfig - assert PACK_ID not in RuntimeConfig().input_packs + assert PACK_ID in RuntimeConfig().input_packs diff --git a/tests/test_pack_grounding.py b/tests/test_pack_grounding.py index 907ac3cf..71dc74e0 100644 --- a/tests/test_pack_grounding.py +++ b/tests/test_pack_grounding.py @@ -89,23 +89,29 @@ def test_cold_start_recall_returns_pack_grounded_surface() -> None: def test_cold_start_unknown_lemma_returns_universal_disclosure() -> None: - """When the gate fires AND the subject lemma is not in the pack, - we fall through to the universal disclosure unchanged.""" + """When the gate fires AND no lemma in the utterance resolves in any + mounted pack, we fall through to the universal disclosure unchanged. + + ADR-0061 + ADR-0063 — the PROCEDURE composer scans the subject + phrase for any mounted-pack lemma. This test deliberately uses + a fully out-of-pack prompt so neither the cognition nor the + relations pack catches a topic anchor.""" rt = ChatRuntime() - # 'addresses' is in vocab but subject after intent parsing will be - # the multi-word phrase, which won't match the pack lemma index. - resp = rt.chat("How can I correct an error?") + resp = rt.chat("How can I quoxulate the wxyzabc?") assert resp.surface == _UNKNOWN_DOMAIN_SURFACE assert resp.grounding_source == "none" def test_cold_start_non_definition_intent_no_pack_grounding() -> None: - """CAUSE / COMPARISON / VERIFICATION intents do not engage the - pack path even when their subject is a pack lemma — narrow scope - of ADR-0048 is DEFINITION + RECALL only.""" + """CAUSE on a non-pack subject lemma does not engage the + pack-grounded DEFINITION path — pack-grounded surfaces require a + pack-resident lemma in any mounted lexicon (ADR-0048 + ADR-0063). + + ADR-0052 teaching-grounded surfaces handle CAUSE on subjects that + appear in the reviewed cognition chains corpus; ``wxyzabc`` is in + neither the pack nor the corpus, so the universal disclosure fires.""" rt = ChatRuntime() - # 'Why does light exist?' is CAUSE intent. - resp = rt.chat("Why does light exist?") + resp = rt.chat("Why does wxyzabc exist?") assert resp.grounding_source == "none" assert resp.surface == _UNKNOWN_DOMAIN_SURFACE diff --git a/tests/test_pack_resolver.py b/tests/test_pack_resolver.py new file mode 100644 index 00000000..b423a3a8 --- /dev/null +++ b/tests/test_pack_resolver.py @@ -0,0 +1,167 @@ +"""ADR-0063 — cross-pack surface resolver tests. + +The contract these tests pin: + + - :func:`chat.pack_resolver.resolve_lemma` returns the first + ``(pack_id, semantic_domains)`` whose lexicon contains the lemma. + - Cognition lemmas resolve to ``en_core_cognition_v1``; kinship + lemmas resolve to ``en_core_relations_v1``; absent lemmas return + ``None``. + - First-match-wins on order. + - The lru_cache survives repeat calls without re-reading disk. + - :func:`mounted_lemmas` is the union of lemma keys across the + mounted packs in deterministic order. +""" + +from __future__ import annotations + +import pytest + +from chat.pack_resolver import ( + DEFAULT_RESOLVABLE_PACK_IDS, + _pack_lexicon_for, + clear_resolver_cache, + is_resolvable, + mounted_lemmas, + resolve_lemma, +) + + +# --------------------------------------------------------------------------- +# resolve_lemma — first-match-wins across mounted packs +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "lemma", + ["light", "knowledge", "meaning", "memory", "truth", "thought", "concept"], +) +def test_cognition_lemma_resolves_to_cognition_pack(lemma: str) -> None: + resolved = resolve_lemma(lemma) + assert resolved is not None, f"{lemma!r} did not resolve" + pack_id, domains = resolved + assert pack_id == "en_core_cognition_v1" + assert isinstance(domains, tuple) + assert domains, "resolved pack must surface non-empty semantic_domains" + + +@pytest.mark.parametrize( + "lemma", + ["parent", "child", "sibling", "family", "ancestor", + "descendant", "spouse", "offspring"], +) +def test_kinship_lemma_resolves_to_relations_pack(lemma: str) -> None: + resolved = resolve_lemma(lemma) + assert resolved is not None, f"{lemma!r} did not resolve" + pack_id, domains = resolved + assert pack_id == "en_core_relations_v1" + assert isinstance(domains, tuple) + assert domains + + +def test_unknown_lemma_returns_none() -> None: + assert resolve_lemma("nonexistent_lemma_xyz") is None + + +@pytest.mark.parametrize("bad", ["", " ", None]) +def test_empty_or_invalid_lemma_returns_none(bad) -> None: + assert resolve_lemma(bad) is None # type: ignore[arg-type] + + +def test_resolver_normalizes_case_and_whitespace() -> None: + a = resolve_lemma("Knowledge") + b = resolve_lemma(" knowledge ") + c = resolve_lemma("knowledge") + assert a == b == c + assert a is not None and a[0] == "en_core_cognition_v1" + + +def test_resolver_is_first_match_wins() -> None: + """When the same lemma appears in two mounted packs, the earlier + pack in *pack_ids* wins. Today no lemma collision exists between + cognition and relations (orthogonality test enforces this); this + test reverses the order to verify the resolution rule itself.""" + reversed_order = ( + "en_core_relations_v1", + "en_core_cognition_v1", + ) + # ``parent`` exists only in relations; ``knowledge`` only in + # cognition — order swap should not change which pack carries + # them, only their resolution order. + parent = resolve_lemma("parent", pack_ids=reversed_order) + knowledge = resolve_lemma("knowledge", pack_ids=reversed_order) + assert parent is not None and parent[0] == "en_core_relations_v1" + assert knowledge is not None and knowledge[0] == "en_core_cognition_v1" + + +def test_pack_ids_default_contains_both_packs() -> None: + assert "en_core_cognition_v1" in DEFAULT_RESOLVABLE_PACK_IDS + assert "en_core_relations_v1" in DEFAULT_RESOLVABLE_PACK_IDS + # Cognition first — first-match-wins favours cognition on any + # future cross-pack lemma collision. + assert DEFAULT_RESOLVABLE_PACK_IDS.index("en_core_cognition_v1") < \ + DEFAULT_RESOLVABLE_PACK_IDS.index("en_core_relations_v1") + + +# --------------------------------------------------------------------------- +# is_resolvable — boolean shortcut +# --------------------------------------------------------------------------- + + +def test_is_resolvable_round_trips() -> None: + assert is_resolvable("light") is True + assert is_resolvable("parent") is True + assert is_resolvable("nonexistent_lemma_xyz") is False + assert is_resolvable("") is False + + +# --------------------------------------------------------------------------- +# mounted_lemmas — union view used by topic extractors +# --------------------------------------------------------------------------- + + +def test_mounted_lemmas_unions_both_packs() -> None: + union = mounted_lemmas() + assert "knowledge" in union + assert "parent" in union + assert "nonexistent_lemma_xyz" not in union + # Frozen so callers cannot mutate the cached union accidentally. + assert isinstance(union, frozenset) + + +def test_mounted_lemmas_respects_explicit_pack_ids() -> None: + cognition_only = mounted_lemmas(pack_ids=("en_core_cognition_v1",)) + assert "knowledge" in cognition_only + assert "parent" not in cognition_only, ( + "cognition-only view leaked a kinship lemma — pack residency " + "boundary broken" + ) + + +# --------------------------------------------------------------------------- +# Caching contract +# --------------------------------------------------------------------------- + + +def test_pack_lexicon_is_cached() -> None: + """Two calls for the same pack must return the same dict identity + (lru_cache returns the cached object, not a copy).""" + a = _pack_lexicon_for("en_core_cognition_v1") + b = _pack_lexicon_for("en_core_cognition_v1") + assert a is b + + +def test_clear_resolver_cache_is_safe() -> None: + """The test-only escape hatch must not crash when called twice.""" + clear_resolver_cache() + clear_resolver_cache() + # And the resolver still works afterwards. + assert resolve_lemma("knowledge") is not None + + +def test_missing_pack_returns_empty_index() -> None: + """A pack id with no on-disk lexicon must yield an empty dict and + callers see ``None`` from :func:`resolve_lemma` — no exception.""" + empty = _pack_lexicon_for("nonexistent_pack_id_zzz_v0") + assert empty == {} + assert resolve_lemma("knowledge", pack_ids=("nonexistent_pack_id_zzz_v0",)) is None