core/chat/cross_pack_grounding.py
Shay 6207b5fd0e feat(register): R1–R4 register pack subsystem — deterministic surface variation
Introduces the presentation axis as a fourth pack class (sibling to identity /
safety / ethics), orthogonal to the truth path. Same input + same packs +
same register ⇒ bit-for-bit reproducible surface; varying any of the three ⇒
genuinely different output. No stochastic sampling.

ADR-0068 (R1): RegisterPack frozen dataclass, loader, ratify script, seam test.
  - default_neutral_v1 ratified as null register.

ADR-0069 (R2): realizer register parameter threaded through 9 composer entry
  points; RuntimeConfig.register_pack_id; three byte-identity invariants
  (A: None ≡ pre-R2 unregistered; B: None ≡ default_neutral_v1; C: trace_hash
  invariant under register). Amended to default-with-lint after 167-call-site
  scout: composers default to UNREGISTERED, AST lint enforces explicit
  register= at runtime call sites.

ADR-0070 (R3): terse_v1 register, first non-neutral pack. realizer_overrides
  schema with known-keys allow-list (disclosure_domain_count ∈ {1,2,3}).
  build_pack_surface_candidate reads override with fail-soft clamp. New
  invariant register_invariant_grounding asserts grounding_source +
  trace_hash byte-identical across {None, neutral, terse}.

ADR-0071 (R4): seeded surface variation via convivial_v1.
  chat/register_variation.py applies SHA-256-seeded marker selection from
  bounded discourse-marker buckets. ChatResponse.pre_decoration_surface routes
  truth-path surface to core/cognition/pipeline.py so trace_hash stays
  invariant under register (the load-bearing architectural fix — initially
  invariant C failed under convivial because decoration was leaking into
  trace_hash via response.surface). Empty-string marker entries now
  legitimate ("no marker this turn" is a valid seed pick). realizer_overrides
  schema widened with per_intent nested block (validated against IntentTag
  whitelist; wired but not exercised by convivial). Two new invariants:
  seeded_variation_replay_equivalence (fresh runtimes → byte-identical) and
  seeded_variation_turn_distinct (same prompt across turns → ≥2 distinct
  surfaces).

ADR-0072 (R5, draft): telemetry + operator surface — TurnEvent gains
  register_id and register_variant_id, core chat --register flag, core demo
  register-tour. Status: Proposed; not yet implemented.

Three ratified register packs ship: default_neutral_v1 (null), terse_v1
(disclosure_domain_count=1), convivial_v1 (3 openings × 3 closings).

Verification:
  - 84 register tests pass + 1 documented skip
  - Curated lanes green: smoke 67, cognition 120+1s, teaching 17, packs 6,
    runtime 19, algebra 132
  - Cognition eval byte-identical to pre-register baseline:
    public 100/100/91.7/100, holdout 100/100/83.3/100
  - Full lane: 2608 passed, 4 skipped, 1 failed (pre-existing
    test_cli_demo.py "Combined Demo" → "Run Every Demo" rename, unrelated)

Truth-path isolation: chat/register_variation.py is realizer-side; the seam
test (tests/test_register_pack_seam.py) refuses imports of packs.register
from intent classification, propagation, vault recall, trace hashing, and
algebra.
2026-05-19 16:52:36 -07:00

287 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""chat/cross_pack_grounding.py — ADR-0067 cross-pack teaching surface.
Phases 13 closed the chain-gap and OOV flywheels and opened the
turn-level composition surfaces (NARRATIVE / EXAMPLE / anaphora).
But every reviewed chain still had to live entirely within one
ratified pack: ADR-0064 binds each :data:`TEACHING_CORPORA` entry
1:1 to a single ``pack_id``, and chains whose subject and object
resolve to different packs are dropped at load time.
That constraint is structural — it kept cross-domain leakage out of
v1 while the per-pack chain DAGs ratified. With three packs
(cognition + relations v1/v2) live and 36 reviewed in-pack chains,
the prerequisite is satisfied; this module lifts the constraint with
a deliberately narrow cross-pack chain shape.
Each chain in the cross-pack corpus carries TWO ``pack_id`` fields —
``subject_pack_id`` and ``object_pack_id`` — and the loader verifies
that the subject resolves in the named subject pack and the object
resolves in the named object pack. No cross-pack collision matters:
each chain names its own residency. The surface tag exposes both
pack ids so the trust boundary is explicit:
"{X} — cross-pack-grounded (cross_pack_chains_v1:
{subject_pack_id} × {object_pack_id}): {dX}. {X} {conn} {Y}
({dY}). No session evidence yet."
Design constraints (mirrors ADR-0052 / ADR-0064):
- Reconstruction-over-storage: corpus + both packs loaded lazily once.
- Strict per-chain pack-residency: a chain whose subject is not in
its declared subject pack (or whose object is not in its declared
object pack) is dropped silently — pack-corpus skew cannot leak a
non-pack atom into the surface.
- Connective MUST be in :func:`generate.semantic_templates.humanize_predicate` —
no free-form predicates, ever.
- No prose generation. Every visible non-template token is a lemma,
a pack ``semantic_domains`` string, or a whitelisted connective.
The corpus path is the sole write surface (proposal-only per
ADR-0027 / ADR-0057). This module is read-only.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from chat.pack_resolver import _pack_lexicon_for
from generate.intent import IntentTag
from generate.semantic_templates import humanize_predicate
from packs.register.loader import RegisterPack, UNREGISTERED
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,
*,
register: RegisterPack = UNREGISTERED,
) -> 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)