core/chat/teaching_grounding.py
Shay 9e6fa4be75
feat(adr-0083): transitive (multi-hop) teaching-grounded surface (#63)
Strict superset of ADR-0062's depth-1 composer.  `max_depth` is the
number of follow-up hops appended beyond the initial chain:

  max_depth=0  → byte-identical to single-chain surface
  max_depth=1  → byte-identical to ADR-0062 composed
  max_depth=2  → byte-identical to ADR-0062 when no second hop
                 survives, strict superset when one does

The composer surfaces content the realizer was silently dropping
from chains already ratified in `cognition_chains_v1`.  Example
live lift on `"Why does light exist?"`:

  composed: "light reveals truth, which grounds knowledge."
  transitive(2): "...which grounds knowledge, which requires evidence."

Cycle-safe at every depth via a single visited-set; single-corpus
traversal in v1 (cross-corpus transitive deferred to a follow-up
ADR alongside ADR-0064's cross-pack model).

Both flags default False — every existing surface is preserved
byte-identically.  When both `composed_surface` and
`transitive_surface` are True, transitive wins.

Implementation:
- `core/config.py`: `transitive_surface: bool = False`,
  `transitive_max_depth: int = 2`.
- `chat/teaching_grounding.py`: `_resolve_followup` shared helper
  refactored out of the depth-1 composer (no behavioural change),
  plus new `teaching_grounded_surface_transitive(subject,
  intent_tag, *, max_depth)`.
- `chat/runtime.py`: dispatch order — transitive > composed > single.

Verification:
- tests/test_transitive_surface.py: 16 new tests covering pure-fn
  contract, visited-set cycle guard at every depth, runtime
  integration, and the cognition-lane null-drop invariant at
  `max_depth=2` (public + holdout splits).
- tests/test_composed_surface.py: 11/11 pass after the helper
  refactor (ADR-0062 behaviour preserved).
- `core test --suite smoke`: 67 pass.
- `core test --suite cognition`: 120 pass, 1 skipped.
- `core test --suite teaching`: 17 pass.
- `core eval cognition`: 100 / 91.7 / 100 / 100 (byte-identical).
2026-05-20 14:11:40 -07:00

638 lines
24 KiB
Python
Raw Permalink 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/teaching_grounding.py — teaching-grounded surface for cold-start
CAUSE and VERIFICATION intents (ADR-0052).
ADR-0048 added pack-grounded surfaces for cold-start DEFINITION / RECALL,
and ADR-0050 extended that to COMPARISON. Both consult the ratified
``en_core_cognition_v1`` pack as a second source of grounding alongside
the session vault.
CAUSE and VERIFICATION cannot be answered from pack ``semantic_domains``
alone — those describe a single subject, not a relation between two
subjects. But the system already has reviewed, auditable memory for a
small, well-known set of cognition-core chains (e.g. ``knowledge requires
evidence``, ``memory requires recall``, ``light reveals truth``). Per
the Teaching Safety discipline in CLAUDE.md, reviewed memory may
contribute grounding evidence; this module supplies that contribution as
a third grounding source.
The corpus lives at ``teaching/cognition_chains/cognition_chains_v1.jsonl``
and is treated as reviewed, immutable memory at runtime: each entry
names a subject lemma, an intent (``cause`` or ``verification``), a
fixed connective predicate already present in
``generate/semantic_templates.py:_PREDICATE_HUMANIZE``, and an object
lemma. Both lemmas must be present in the ratified cognition pack —
every visible non-template token in the emitted surface is therefore
either one of the two lemmas, a verbatim pack ``semantic_domains``
string, or a fixed-template connective. No LLM, no synthesis, no
inference.
Design constraints (matching ADR-0048 / ADR-0050 axioms):
- Reconstruction-over-storage: the surface is reconstructed from the
corpus + pack at call time; both are loaded once and cached because
the corpus is reviewed memory (immutable) and ratified packs are
immutable.
- Dual-correction: any subject not in the corpus, any intent outside
``{CAUSE, VERIFICATION}``, or any chain referencing lemmas missing
from the pack returns ``None`` and callers fall through to
``_UNKNOWN_DOMAIN_SURFACE`` unchanged.
- Trust boundary: every surface produced here is explicitly tagged
``teaching:cognition_chains_v1`` so the audit contract distinguishes
teaching-grounded surfaces from pack-grounded surfaces from
vault-grounded surfaces.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from chat.pack_grounding import PACK_ID as COGNITION_PACK_ID, _pack_index
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
TEACHING_CORPUS_ID: str = "cognition_chains_v1"
_VALID_INTENTS: frozenset[str] = frozenset({"cause", "verification"})
_INTENT_TAG_BY_NAME: dict[str, IntentTag] = {
"cause": IntentTag.CAUSE,
"verification": IntentTag.VERIFICATION,
}
_TEACHING_ROOT = Path(__file__).resolve().parent.parent / "teaching"
_CORPUS_PATH = (
_TEACHING_ROOT
/ "cognition_chains"
/ f"{TEACHING_CORPUS_ID}.jsonl"
)
@dataclass(frozen=True, slots=True)
class TeachingCorpusSpec:
"""ADR-0064 — descriptor for one reviewed teaching corpus.
A corpus is a JSONL file of reviewed chains plus the single lexicon
pack whose vocabulary every chain in that corpus must reside in. The
1-to-1 corpus↔pack binding is the structural invariant that prevents
cross-domain leakage during cold-start surface composition: a
relations-domain chain cannot accidentally surface a cognition-pack
atom (or vice versa) because the pack-consistency check at load time
is scoped to the corpus's declared pack.
Each registered corpus is treated as immutable, reviewed memory.
Cross-domain triples (cognition × relations) are deliberately out of
scope for v1 — they require a follow-up ADR that introduces a
cross-pack chain shape, per ``docs/teaching_order.md`` §5.
"""
corpus_id: str
path: Path
pack_id: str
# ADR-0064 — registered teaching corpora. Order matters: chains in
# earlier corpora win on (subject, intent) collision. Cognition is
# listed first so the cognition-lane byte-identity invariant is
# preserved when a relations chain ever shares a key (today the
# orthogonal-pack invariant prevents any such collision, but the
# resolution rule is documented).
TEACHING_CORPORA: tuple[TeachingCorpusSpec, ...] = (
TeachingCorpusSpec(
corpus_id="cognition_chains_v1",
path=_TEACHING_ROOT / "cognition_chains" / "cognition_chains_v1.jsonl",
pack_id="en_core_cognition_v1",
),
TeachingCorpusSpec(
corpus_id="relations_chains_v1",
path=_TEACHING_ROOT / "relations_chains" / "relations_chains_v1.jsonl",
pack_id="en_core_relations_v1",
),
TeachingCorpusSpec(
corpus_id="relations_chains_v2",
path=_TEACHING_ROOT / "relations_chains_v2" / "relations_chains_v2.jsonl",
pack_id="en_core_relations_v2",
),
)
@dataclass(frozen=True, slots=True)
class TeachingChain:
"""One reviewed teaching chain.
Fields are copied verbatim from the JSONL line; the runtime never
mutates them. ``provenance`` is preserved for audit but not emitted
in the user-facing surface.
ADR-0064 — ``corpus_id`` records which registered teaching corpus
the chain belongs to so the surface tag and audit trail are
unambiguous when multiple corpora are active.
"""
chain_id: str
subject: str
intent: str
connective: str
object: str
domains_subject_k: int
domains_object_k: int
provenance: str
corpus_id: str = "cognition_chains_v1"
def _load_corpus(spec: TeachingCorpusSpec) -> dict[tuple[str, str], TeachingChain]:
"""ADR-0064 — load one registered teaching corpus.
Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
within this corpus only. Pack-consistency is scoped to
``spec.pack_id``: every chain's subject AND object must reside in
that specific pack's lexicon. Cross-pack chain shapes (e.g. a
relations subject with a cognition object) are out of scope for
v1 per ``docs/teaching_order.md`` §5 and produce a drop with no
surface impact.
ADR-0055 Phase A: 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; the loader derives the
active set.
"""
if not spec.path.exists():
return {}
pack = _pack_lexicon_for(spec.pack_id)
if not pack:
return {}
superseded_ids: set[str] = set()
parsed_lines: list[dict] = []
for line in spec.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: dict[tuple[str, str], TeachingChain] = {}
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()
if not subject or not intent or not obj or not connective:
continue
if intent not in _VALID_INTENTS:
continue
if subject not in pack or obj not in pack:
continue
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
if chain_id in superseded_ids:
continue
try:
chain = TeachingChain(
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
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", "")),
corpus_id=spec.corpus_id,
)
except (TypeError, ValueError):
continue
out[(subject, intent)] = chain
return out
@lru_cache(maxsize=1)
def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
"""Load the cognition-chains corpus once (back-compat surface).
Retained for discovery / replay / audit consumers whose semantics
are scoped to the cognition corpus specifically. Cross-corpus
composition uses :func:`_all_chains_index` instead.
Returns ``{(subject_lower, intent_lower): TeachingChain}``. Entries
with invalid schema, unsupported intents, or with subject/object
missing from the ratified cognition pack are dropped — the corpus
is reviewed memory but the runtime still verifies pack consistency
on load so a pack-corpus skew cannot leak a non-pack atom into a
surface.
ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
entry's ``superseded_by`` is also dropped from the active view.
Append-only history on disk is preserved; the loader derives the
active set.
"""
if not _CORPUS_PATH.exists():
return {}
pack = _pack_index()
# First sweep: collect supersession claims. Only well-formed
# entries (parseable JSON object) can retire other entries — a
# malformed line cannot supersede a good one.
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: dict[tuple[str, str], TeachingChain] = {}
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()
if not subject or not intent or not obj or not connective:
continue
if intent not in _VALID_INTENTS:
continue
# Both lemmas MUST be in the ratified pack — guarantees every
# surface atom is pack-sourced.
if subject not in pack or obj not in pack:
continue
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
if chain_id in superseded_ids:
continue
try:
chain = TeachingChain(
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
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", "")),
corpus_id=TEACHING_CORPUS_ID,
)
except (TypeError, ValueError):
continue
out[(subject, intent)] = chain
return out
@lru_cache(maxsize=1)
def _all_chains_index() -> dict[tuple[str, str], TeachingChain]:
"""ADR-0064 — aggregated view across every registered teaching corpus.
Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
across all corpora in :data:`TEACHING_CORPORA`. Registration order
is the resolution order: earlier corpora win on collision. The
cognition corpus is registered first so the cognition-lane
byte-identity invariant is preserved.
The :func:`_corpus_index` back-compat loader is **not** an input to
this aggregator — both consult the same underlying file but
:func:`_corpus_index` is reserved for cognition-corpus-only
consumers (audit, replay, discovery's gate). Cross-corpus surface
composition consults :func:`_all_chains_index`.
"""
aggregated: dict[tuple[str, str], TeachingChain] = {}
for spec in TEACHING_CORPORA:
corpus = _load_corpus(spec)
for key, chain in corpus.items():
if key not in aggregated:
aggregated[key] = chain
return aggregated
@lru_cache(maxsize=8)
def _pack_for_corpus(corpus_id: str) -> dict[str, tuple[str, ...]]:
"""Return the lexicon for the pack bound to *corpus_id*, cached.
ADR-0064 — each registered teaching corpus is bound to exactly
one lexicon pack via :data:`TEACHING_CORPORA`. Returns an empty
dict if *corpus_id* is unknown — callers see this as "chain
cannot be surfaced" and fall through to the universal disclosure.
"""
for spec in TEACHING_CORPORA:
if spec.corpus_id == corpus_id:
return _pack_lexicon_for(spec.pack_id)
return {}
def _intent_name(intent_tag: IntentTag) -> str | None:
"""Return the lower-case intent key for the corpus, or ``None``."""
if intent_tag is IntentTag.CAUSE:
return "cause"
if intent_tag is IntentTag.VERIFICATION:
return "verification"
return None
def teaching_grounded_surface(
subject_lemma: str,
intent_tag: IntentTag,
*,
register: RegisterPack = UNREGISTERED,
) -> str | None:
"""Return a deterministic teaching-grounded surface, or ``None``.
The surface format is fixed:
"{subject} — teaching-grounded ({corpus_id}): {ds1}; {ds2}.
{subject} {connective} {object} ({do1}). No session evidence yet."
Every visible non-template token is either one of the two lemmas, a
verbatim ``semantic_domains`` string from the ratified cognition
pack, or the connective predicate already humanised by
``generate.semantic_templates.humanize_predicate``. The trailing
disclosure (``No session evidence yet.``) is the constant
trust-boundary label that distinguishes teaching-grounded surfaces
from vault-grounded surfaces.
Returns ``None`` when:
- the lemma is empty or not a string,
- the intent tag is not ``CAUSE`` or ``VERIFICATION``,
- the (subject, intent) pair is not in the teaching corpus.
"""
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(intent_tag)
if intent_name is None:
return None
chain = _all_chains_index().get((key, intent_name))
if chain is None:
return None
# ADR-0064 — pack-residency is scoped to the chain's resolving
# corpus. Each registered corpus is bound to exactly one pack.
pack = _pack_for_corpus(chain.corpus_id)
subject_domains = pack.get(chain.subject, ())
object_domains = 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} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object}). No session evidence yet."
)
def _resolve_followup(
*,
current: TeachingChain,
corpus: dict[tuple[str, str], TeachingChain],
visited: frozenset[str],
same_corpus_id: str,
) -> TeachingChain | None:
"""ADR-0083 shared resolver — return the next chain that survives
the cycle / pack-residency / single-corpus guards, else ``None``.
Used by both the ADR-0062 depth-1 composer and the ADR-0083
transitive composer. Per-hop rules:
1. Prefer ``(current.object, "cause")``; fall back to
``(current.object, "verification")``.
2. Refuse candidates whose ``object`` is in *visited* (covers
ADR-0062's 1-step cycle guard as the depth-2 case).
3. Refuse candidates whose ``object`` is not pack-resident with
``semantic_domains`` in the candidate's resolving corpus's pack.
4. Refuse candidates from a different corpus than *same_corpus_id*
(single-corpus traversal in v1; cross-corpus transitive is
deferred to a follow-up ADR).
"""
for next_intent in ("cause", "verification"):
candidate = corpus.get((current.object, next_intent))
if candidate is None:
continue
if candidate.object in visited:
continue
if candidate.corpus_id != same_corpus_id:
continue
pack = _pack_for_corpus(candidate.corpus_id)
if not pack.get(candidate.object, ()):
continue
return candidate
return None
def teaching_grounded_surface_composed(
subject_lemma: str,
intent_tag: IntentTag,
*,
register: RegisterPack = UNREGISTERED,
) -> str | None:
"""ADR-0062 — chain-of-chains teaching-grounded surface.
When a chain ``(A, intent_A, conn_A, B)`` exists AND a follow-up
chain ``(B, ?, conn_B, C)`` exists for either intent, compose a
two-clause surface:
"{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}.
{A} {conn_A} {B} ({dB1}), which {conn_B} {C} ({dC1}).
No session evidence yet."
Cycle-safe: if ``C == A`` or ``C == B``, the composer falls back
to the single-chain surface (no follow-up clause). Bounded depth:
v1 follows exactly one hop; deeper chains require a future ADR.
Follow-up intent preference: prefer ``cause`` when both exist
(causal continuation reads more naturally than a verification
detour). This preference is deterministic and pack-agnostic.
Returns ``None`` under the same conditions as
``teaching_grounded_surface``. When the initial chain exists
but no follow-up does, the composer degrades to the single-chain
surface byte-identically — drop-in replacement.
"""
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(intent_tag)
if intent_name is None:
return None
corpus = _all_chains_index()
chain = corpus.get((key, intent_name))
if chain is None:
return None
# ADR-0064 — pack lookups follow each chain's resolving corpus.
pack = _pack_for_corpus(chain.corpus_id)
subject_domains = pack.get(chain.subject, ())
object_domains = 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_short = "; ".join(
object_domains[: max(1, chain.domains_object_k)]
)
connective = humanize_predicate(chain.connective)
# ADR-0083 — shared resolver enforces ADR-0062's cycle guard
# (visited = {subject, object}) plus pack-residency + single-
# corpus guards. Behaviour at depth-1 is unchanged.
follow_up = _resolve_followup(
current=chain,
corpus=corpus,
visited=frozenset({chain.subject, chain.object}),
same_corpus_id=chain.corpus_id,
)
if follow_up is None:
# No follow-up available — degrade to single-chain surface
# byte-identically with ``teaching_grounded_surface``.
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short}). No session evidence yet."
)
# ADR-0083 — _resolve_followup already enforced pack-residency
# on follow_up.object, so a non-None result is safe to render.
follow_pack = _pack_for_corpus(follow_up.corpus_id)
follow_object_domains = follow_pack.get(follow_up.object, ())
follow_head = "; ".join(
follow_object_domains[: max(1, follow_up.domains_object_k)]
)
follow_connective = humanize_predicate(follow_up.connective)
return (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short}), which {follow_connective} {follow_up.object} "
f"({follow_head}). No session evidence yet."
)
def teaching_grounded_surface_transitive(
subject_lemma: str,
intent_tag: IntentTag,
*,
register: RegisterPack = UNREGISTERED,
max_depth: int = 2,
) -> str | None:
"""ADR-0083 — bounded multi-hop teaching-grounded surface.
Strict superset of :func:`teaching_grounded_surface_composed`.
Iterates the shared :func:`_resolve_followup` helper under a
visited-set guard, appending one ``", which {conn} {obj} ({dom})"``
clause per surviving hop, up to *max_depth - 1* follow-ups beyond
the initial chain.
``max_depth`` is the maximum number of follow-up hops to append
beyond the initial chain. At ``max_depth=0`` byte-identical to
:func:`teaching_grounded_surface` (no hops). At ``max_depth=1``
byte-identical to :func:`teaching_grounded_surface_composed`
(one follow-up). At ``max_depth=2`` byte-identical to ADR-0062
when no second hop survives, strict superset when one does.
Single-corpus traversal in v1 — every hop must resolve in the
initial chain's corpus. Cross-corpus transitive is deferred to a
follow-up ADR. Returns ``None`` under the same conditions as
:func:`teaching_grounded_surface`.
"""
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(intent_tag)
if intent_name is None:
return None
corpus = _all_chains_index()
chain = corpus.get((key, intent_name))
if chain is None:
return None
pack = _pack_for_corpus(chain.corpus_id)
subject_domains = pack.get(chain.subject, ())
object_domains = 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_short = "; ".join(
object_domains[: max(1, chain.domains_object_k)]
)
connective = humanize_predicate(chain.connective)
depth_cap = max(0, int(max_depth))
hops: list[TeachingChain] = []
visited = {chain.subject, chain.object}
current = chain
while len(hops) < depth_cap:
nxt = _resolve_followup(
current=current,
corpus=corpus,
visited=frozenset(visited),
same_corpus_id=chain.corpus_id,
)
if nxt is None:
break
hops.append(nxt)
visited.add(nxt.object)
current = nxt
body = (
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
f"{head_subject}. {chain.subject} {connective} {chain.object} "
f"({head_object_short})"
)
for hop in hops:
hop_pack = _pack_for_corpus(hop.corpus_id)
hop_domains = hop_pack.get(hop.object, ())
hop_head = "; ".join(hop_domains[: max(1, hop.domains_object_k)])
hop_connective = humanize_predicate(hop.connective)
body += f", which {hop_connective} {hop.object} ({hop_head})"
body += ". No session evidence yet."
return body
def has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool:
"""Return True iff a reviewed chain exists for (subject, intent)
in any registered teaching corpus (ADR-0064 cross-corpus view)."""
if not subject_lemma or not isinstance(subject_lemma, str):
return False
intent_name = _intent_name(intent_tag)
if intent_name is None:
return False
return (subject_lemma.strip().lower(), intent_name) in _all_chains_index()
def clear_teaching_caches() -> None:
"""Drop every teaching-grounding lru_cache.
ADR-0064 — the replay-equivalence gate swaps ``_CORPUS_PATH`` to
a transient corpus and clears ``_corpus_index``; when multiple
corpora are registered the aggregated index must also reset so
the swap takes effect. Test-only and replay-only escape hatch;
production code never calls this on the hot path.
"""
_corpus_index.cache_clear()
_all_chains_index.cache_clear()
_pack_for_corpus.cache_clear()