From 9e6fa4be7554f3022d6964551f384fc91564b6d8 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 20 May 2026 14:11:40 -0700 Subject: [PATCH] feat(adr-0083): transitive (multi-hop) teaching-grounded surface (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- chat/runtime.py | 14 +- chat/teaching_grounding.py | 157 ++++++++-- core/config.py | 17 + .../ADR-0083-transitive-chain-surface.md | 295 ++++++++++++++++++ tests/test_transitive_surface.py | 269 ++++++++++++++++ 5 files changed, 727 insertions(+), 25 deletions(-) create mode 100644 docs/decisions/ADR-0083-transitive-chain-surface.md create mode 100644 tests/test_transitive_surface.py diff --git a/chat/runtime.py b/chat/runtime.py index 909ad1df..9363d13b 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -21,6 +21,7 @@ from chat.pack_grounding import ( from chat.teaching_grounding import ( teaching_grounded_surface, teaching_grounded_surface_composed, + teaching_grounded_surface_transitive, TEACHING_CORPUS_ID as _TEACHING_CORPUS_ID, ) from chat.refusal import ( @@ -782,7 +783,18 @@ class ChatRuntime: ) if surface is not None: return (surface, "pack", ()) - if self.config.composed_surface: + if self.config.transitive_surface: + # ADR-0083 — transitive supersedes composed. At + # max_depth=1 this degrades byte-identically to the + # single-chain surface; at max_depth=2 byte-identical + # to ADR-0062 when no second hop exists. + surface = teaching_grounded_surface_transitive( + lemma, + intent.tag, + register=self.register_pack, + max_depth=self.config.transitive_max_depth, + ) + elif self.config.composed_surface: surface = teaching_grounded_surface_composed( lemma, intent.tag, register=self.register_pack, ) diff --git a/chat/teaching_grounding.py b/chat/teaching_grounding.py index 8cabecbc..23770504 100644 --- a/chat/teaching_grounding.py +++ b/chat/teaching_grounding.py @@ -401,6 +401,44 @@ def teaching_grounded_surface( ) +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, @@ -456,20 +494,15 @@ def teaching_grounded_surface_composed( ) connective = humanize_predicate(chain.connective) - # Look for a follow-up chain whose subject equals the initial - # chain's object. Prefer cause; fall back to verification. - follow_up = None - for next_intent in ("cause", "verification"): - candidate = corpus.get((chain.object, next_intent)) - if candidate is None: - continue - # Cycle guard: don't follow if the next object is the initial - # subject (1-step cycle) or the same as the current object - # (degenerate same-cell mismatch). - if candidate.object in (chain.subject, chain.object): - continue - follow_up = candidate - break + # 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 @@ -480,18 +513,10 @@ def teaching_grounded_surface_composed( 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, ()) - if not follow_object_domains: - # Follow-up's object isn't pack-resident with semantic domains - # — degrade to single-chain surface rather than emit a - # partially-grounded composition. - 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." - ) - follow_head = "; ".join( follow_object_domains[: max(1, follow_up.domains_object_k)] ) @@ -504,6 +529,90 @@ def teaching_grounded_surface_composed( ) +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).""" diff --git a/core/config.py b/core/config.py index 4c8ffc9c..99f31bfc 100644 --- a/core/config.py +++ b/core/config.py @@ -73,6 +73,23 @@ class RuntimeConfig: # depth (max one follow-up chain in v1). composed_surface: bool = False + # ADR-0083 — transitive (multi-hop) teaching-grounded surface. + # Strict superset of ADR-0062's depth-1 composer: iterates the + # per-hop follow-up resolution under a visited-set guard, so the + # surface can extend beyond a single follow-up chain. + # ``transitive_max_depth`` is the maximum number of follow-up hops + # to append beyond the initial chain. At ``max_depth=0`` + # byte-identical to the single-chain surface; at ``max_depth=1`` + # byte-identical to ADR-0062's composed surface; at + # ``max_depth=2`` byte-identical to ADR-0062 when no second hop + # exists, strict superset when one does. When True, this + # supersedes ``composed_surface``. Cycle-safe across every depth + # (visited-set covers ADR-0062's 1-step cycle guard). Single- + # corpus traversal in v1; cross-corpus transitive is deferred to + # a follow-up ADR. + transitive_surface: bool = False + transitive_max_depth: int = 2 + # ADR-0066 / P3.2 — opt-in thread anaphora. When enabled, the # runtime prepends a deterministic backreference to a recent # grounded turn when the current turn's subject lemma matches diff --git a/docs/decisions/ADR-0083-transitive-chain-surface.md b/docs/decisions/ADR-0083-transitive-chain-surface.md new file mode 100644 index 00000000..6c1a1b48 --- /dev/null +++ b/docs/decisions/ADR-0083-transitive-chain-surface.md @@ -0,0 +1,295 @@ +# ADR-0083 — Transitive Chain Surface (Bounded Multi-Hop Composition) + +**Status:** Accepted +**Date:** 2026-05-20 +**Author:** Shay + +--- + +## Context + +ADR-0062 introduced depth-1 chain-of-chains composition +(`teaching_grounded_surface_composed`): given a ratified chain +`(A, intent_A, conn_A, B)` and a follow-up `(B, intent_B, conn_B, C)`, +emit a two-clause surface + +``` +A {conn_A} B (dB), which {conn_B} C (dC). +``` + +The composer is opt-in (`RuntimeConfig.composed_surface=False`), +cycle-guarded, pack-residency-guarded, and degrades byte-identically +to the single-chain surface when no follow-up exists. On the 21-chain +saturation-v2 cognition corpus, follow-up resolution succeeds for +~12 chains. + +### What's still missing + +ADR-0062 explicitly defers deeper composition: + +> v1 follows **exactly one hop**. Deeper compositions (A→B→C→D) are +> deferred to a future ADR. + +Inspection of the holdout misses (memory: +`dev-holdout-generalization-2026-05-18`, `adr-0053-cognition-lane-closure`) +and the cognition saturation corpus shows several latent depth-2 paths +that today's composer cannot reach: + +| Hop 1 | Hop 2 | Hop 3 | +|---|---|---| +| `inference requires evidence` | `evidence grounds knowledge` | `knowledge requires evidence` (cycle — stops at hop 2) | +| `thought reveals meaning` | `meaning grounds understanding` | `understanding requires knowledge` | +| `light reveals truth` | `truth grounds knowledge` | `knowledge requires evidence` | +| `definition grounds concept` | `concept requires definition` (cycle — stops at hop 1) | — | + +Three of these would emit a three-clause surface today if the +composer could follow a second hop with cycle tracking; the fourth +demonstrates the cycle guard already does its job at depth 1 and +must extend to depth N. + +### Why this is the next architectural step + +The repo's φ separation probe (memory: `phi separation falsified`) +made it explicit that semantic "stress" or "contradiction" cannot +currently be encoded geometrically — the working semantic engine is +**pack-grounded chain composition over ratified teaching corpora**. +That engine is the cognition surface. Its depth ceiling is therefore +the cognition surface's depth ceiling. + +ADR-0083 raises that ceiling by one constant per release: depth-2 +now, with the visited-set machinery to support depth-N later under +the same flag. + +--- + +## Decision + +Add `teaching_grounded_surface_transitive(subject, intent_tag, *, max_depth)` +to `chat/teaching_grounding.py` alongside the existing single-chain +and composed (depth-1) composers. Route it via a new opt-in +`RuntimeConfig.transitive_surface: bool = False` and a separate +`RuntimeConfig.transitive_max_depth: int = 2`. + +### Surface format + +``` +"{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}. + {A} {conn_A} {B} ({dB1}), which {conn_B} {C} ({dC1}), which {conn_C} {D} ({dD1}). + No session evidence yet." +``` + +Each additional hop adds one `", which {conn_k} {X_k} ({dX_k1})"` +clause before the trailing period. The `, which ` linker is reused +from ADR-0062; no new template constants. + +### Follow-up resolution rules + +Reuse ADR-0062's per-hop rules, applied iteratively: + +1. Resolve the initial chain `(subject, intent)`. If absent, return `None`. +2. For each hop `k ∈ {1, ..., max_depth - 1}`: + a. Look up `(X_k.object, "cause")`; fall back to + `(X_k.object, "verification")`. + b. **Visited-set guard.** Maintain `visited = {subject, X_1.object, ..., X_k.object}`. + Refuse a candidate whose `object` is in `visited` (closes the + ADR-0062 cycle guard at every depth). + c. **Pack-residency guard.** Refuse a candidate whose `object` is + not pack-resident with `semantic_domains` in the candidate's + resolving corpus's pack. + d. **Cross-corpus guard.** v1 requires all hops to resolve in the + **same** corpus (`spec.corpus_id == initial.corpus_id`). ADR-0064 + cross-corpus chains exist, but transitive composition across + corpora needs its own audit — deferred. + e. If no candidate survives, stop and emit whatever depth has + accumulated so far. +3. If no hop survived past the initial chain, degrade byte-identically + to the ADR-0062 composed surface (which itself degrades to the + single-chain surface). + +### Bounded depth + +`transitive_max_depth: int = 2` is the v1 default — equivalent to one +additional hop beyond ADR-0062's composed surface. The config field +is exposed so operators can characterise depth-3 / depth-4 on their +workloads before any default change is proposed. The runtime clamps +to `max(1, transitive_max_depth)` so a misconfigured 0 degrades +gracefully to single-chain. + +### Opt-in flag + +`RuntimeConfig.transitive_surface: bool = False`. Default preserves +all pre-ADR-0083 behaviour byte-identically. Mirrors the +ADR-0047/0058/0062 pattern: ship the capability behind a flag, +characterise empirically, decide on default behaviour in a follow-up. + +The flag composes with `composed_surface`: + +| `composed_surface` | `transitive_surface` | Behaviour | +|---|---|---| +| False | False | single-chain (pre-ADR-0062) | +| True | False | depth-1 composed (ADR-0062) | +| * | True | depth-N transitive (ADR-0083, overrides composed) | + +`transitive_max_depth` is the number of follow-up hops to append +beyond the initial chain. At `max_depth=0` transitive degrades +byte-identically to single-chain; at `max_depth=1` byte-identical +to ADR-0062's composed surface; at `max_depth=2` byte-identical to +ADR-0062 when no second hop survives the guards, strict superset +when one does. + +--- + +## Verification + +### Required tests (new file: `tests/test_transitive_surface.py`) + +- **Pure function**: + - returns `None` when no chain / initial-only / depth-1 / depth-2; + - emits an N-clause surface (N ≤ `max_depth`) when chains chain; + - includes every intermediate object as a clause subject; + - includes every intermediate `semantic_domains` head; + - deterministic across two calls; + - visited-set guard blocks a depth-N cycle at every depth from 2 to `max_depth`; + - pack-residency guard blocks at any depth (not just depth-1); + - cross-corpus guard refuses to traverse a cross-corpus follow-up. +- **Runtime**: + - default keeps single-chain; + - `composed_surface=True, transitive_surface=False` matches ADR-0062; + - `transitive_surface=True, max_depth=1` matches single-chain; + - `transitive_surface=True, max_depth=2` matches ADR-0062 when no + second hop exists, **strict superset** when one does; + - flags observable on frozen config. +- **Null-drop invariant**: + - cognition-lane public + holdout metrics byte-identical + `transitive_surface` OFF vs ON at `max_depth=2`. The composer is + a strict-superset emitter (every token in the depth-1 surface is + preserved; new tokens append) so `expected_term` and + `expected_surface_contains` assertions must hold flag-on. + +### Lanes (regression check) + +``` +core test --suite smoke +core test --suite cognition +core test --suite teaching +core eval cognition +``` + +### Live-prompt observable lift (expected) + +``` +composed_surface=True (ADR-0062): + "Why does light exist?" + → light — teaching-grounded (cognition_chains_v1): + cognition.illumination; logos.core. light reveals truth + (cognition.truth), which grounds knowledge (cognition.knowledge). + No session evidence yet. + +transitive_surface=True, max_depth=2 (ADR-0083): + "Why does light exist?" + → light — teaching-grounded (cognition_chains_v1): + cognition.illumination; logos.core. light reveals truth + (cognition.truth), which grounds knowledge (cognition.knowledge), + which requires evidence (cognition.evidence). No session evidence yet. +``` + +The third clause is **already in the ratified corpus** +(`knowledge requires evidence`). ADR-0083 doesn't introduce content; +it surfaces content the realizer was silently dropping. + +--- + +## Consequences + +### What changes + +- `core/config.py` — + `RuntimeConfig.transitive_surface: bool = False`, + `RuntimeConfig.transitive_max_depth: int = 2`. +- `chat/teaching_grounding.py` — + `teaching_grounded_surface_transitive(subject, intent_tag, *, register, max_depth)` + sibling to the depth-1 composer; shared follow-up-resolution helper + refactored out of ADR-0062's body so both composers consult one + function (no behavioural change to the depth-1 path). +- `chat/runtime.py` — dispatch branch in `_maybe_pack_grounded_surface` + for `IntentTag.CAUSE` / `IntentTag.VERIFICATION` picks + transitive > composed > single based on the two flags. + +### What does not change + +- The pack-grounded discipline: every visible token remains lemma, + pack-domain, connective, or template constant. No LLM. No synthesis. +- ADR-0053's cold-start contract: empty session + no chain → universal + disclosure. +- ADR-0062's null-drop invariant: still enforced; ADR-0083 adds its + own equivalent at depth-2. +- Default runtime behaviour: byte-identical to pre-ADR-0083 main. +- The non-negotiable field invariant + (`versor_condition(F) < 1e-6`) is unaffected — this ADR composes + surface text from ratified chains; no algebra is touched. +- Trust-boundary labels: every emitted surface continues to carry the + `teaching:{corpus_id}` tag. + +--- + +## Scope limits + +- **Depth-2 default.** v1 ships at `max_depth=2`. Depth-3 and beyond + are reachable by raising the config field, but the default holds + until a follow-up ADR characterises lift and readability. +- **Single-corpus traversal.** Cross-corpus transitive chains (e.g. + cognition → relations) are deferred. ADR-0064 already allows + cross-corpus single-chain emission; transitive cross-corpus adds an + audit surface (`teaching:{cognition_chains_v1, relations_chains_v1}`) + that needs its own ADR. +- **No multi-claim aggregation.** When a subject has multiple + initial chains, the composer still picks one — same selection as + ADR-0062. +- **English path only.** Linker `, which ` and `humanize_predicate` + connectives are English-specific. Anchor-lens lifts of transitive + surfaces will need their own per-pack linkers — out of scope. +- **Flag stays off by default.** Operators must opt in. A follow-up + ADR decides on default behaviour after measuring on holdout cases + and on the cognition eval. + +--- + +## Why now (and why not the alternatives) + +The three other candidate next-steps: + +1. **A learned φ embedding** (replace hash-derived φ with one trained + on chain co-occurrence). Real research, could be wrong, lives in + `evals/lab/`. +2. **Greek/Hebrew content phase II** (lift the cognition-tier grc/he + packs from 9+3 lemmas to ~50+). Content grind, not architecture. +3. **Teaching corpus epistemology v2 / kinship v2**. Also content grind. + +ADR-0083 is the only one that: + +- exercises only existing primitives (PropositionGraph, chains, + pack-grounded surfaces, the ADR-0062 composer pattern), +- ships behind the established opt-in-flag + null-drop-invariant + pattern, +- has measurable lift on the existing holdout corpus without any + new content, +- and lands inside one ADR. + +It is the smallest design step that moves the substantive ceiling. + +--- + +## Cross-References + +- [ADR-0062](./ADR-0062-composed-teaching-grounded-surface.md) — the + depth-1 composer this ADR extends. +- [ADR-0058](./ADR-0058-forward-graph-constraint-status.md) — the + opt-in-default-False + null-lift-invariant pattern reused here. +- [ADR-0053](./ADR-0053-cognition-lane-closure.md) — the cognition- + lane closure that frames "term_capture is the next ceiling." +- [ADR-0064](./ADR-0064-cross-pack-teaching-chains.md) — defines the + cross-corpus chain model that the cross-corpus-traversal scope + limit defers to a future ADR. +- Memory: `phi separation falsified` — establishes that semantic + capability lives in chain composition, not in φ geometry, framing + why deepening the composer is the load-bearing next step. diff --git a/tests/test_transitive_surface.py b/tests/test_transitive_surface.py new file mode 100644 index 00000000..e3889c2b --- /dev/null +++ b/tests/test_transitive_surface.py @@ -0,0 +1,269 @@ +"""ADR-0083 — transitive (multi-hop) teaching-grounded surface. + +Strict superset of ADR-0062's depth-1 composer. ``max_depth`` is the +number of follow-up hops appended beyond the initial chain. This +test file pins: + + - Pure function: ``None`` when no chain; depth-0 == single-chain; + depth-1 == ADR-0062 composed; depth-2 is a strict superset when + a second hop survives. + - Visited-set guard: blocks cycles at every depth. + - Single-corpus traversal: cross-corpus follow-ups are refused in v1. + - Determinism: same input → same surface bytes. + - Runtime: ``transitive_surface=True`` supersedes + ``composed_surface``; flags observable on frozen config. + - Cognition lane: metrics byte-identical flag OFF vs ON at + ``max_depth=2`` on both public and holdout splits (null-drop + invariant — transitive mode adds clauses, never drops tokens). +""" + +from __future__ import annotations + +from dataclasses import replace + +from core.config import RuntimeConfig +from chat.runtime import ChatRuntime +from chat.teaching_grounding import ( + teaching_grounded_surface, + teaching_grounded_surface_composed, + teaching_grounded_surface_transitive, +) +from generate.intent import IntentTag + + +# --------------------------------------------------------------------------- +# Pure-function contract +# --------------------------------------------------------------------------- + + +def test_transitive_returns_none_when_no_chain() -> None: + out = teaching_grounded_surface_transitive( + "zzznotalemma", IntentTag.CAUSE, max_depth=2, + ) + assert out is None + + +def test_transitive_depth_zero_matches_single_chain() -> None: + """``max_depth=0`` appends zero follow-ups → byte-identical to the + single-chain surface for any (subject, intent) the corpus knows.""" + for lemma, intent in ( + ("light", IntentTag.CAUSE), + ("memory", IntentTag.VERIFICATION), + ("knowledge", IntentTag.CAUSE), + ): + trans = teaching_grounded_surface_transitive(lemma, intent, max_depth=0) + single = teaching_grounded_surface(lemma, intent) + assert trans is not None + assert single is not None + assert trans == single, f"depth-0 != single for {lemma}/{intent}" + + +def test_transitive_depth_one_matches_composed() -> None: + """``max_depth=1`` appends one follow-up → byte-identical to + ADR-0062's depth-1 composer.""" + for lemma, intent in ( + ("light", IntentTag.CAUSE), + ("memory", IntentTag.VERIFICATION), + ("knowledge", IntentTag.CAUSE), + ): + trans = teaching_grounded_surface_transitive(lemma, intent, max_depth=1) + composed = teaching_grounded_surface_composed(lemma, intent) + assert trans is not None + assert composed is not None + assert trans == composed, ( + f"depth-1 != composed for {lemma}/{intent}" + ) + + +def test_transitive_depth_two_is_strict_superset_for_light_cause() -> None: + """``light cause``: chain ``light reveals truth``, follow-ups + ``truth grounds knowledge`` (hop 1) and ``knowledge requires + evidence`` (hop 2) — depth-2 emits both ``knowledge`` AND + ``evidence`` clauses, strict superset of ADR-0062.""" + depth2 = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + composed = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert depth2 is not None + assert composed is not None + assert depth2 != composed + assert composed in depth2[: len(composed) - 1] or "knowledge" in depth2 + assert "evidence" in depth2 + # Each appended hop adds a ", which " linker — depth-2 has two. + assert depth2.count(", which ") == 2 + + +def test_transitive_depth_two_includes_every_intermediate_object() -> None: + """Pack-grounded discipline: the initial subject and every hop + object appear as a clause subject in the depth-2 surface.""" + depth2 = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + assert depth2 is not None + for token in ("light", "truth", "knowledge", "evidence"): + assert token in depth2, f"{token} missing from depth-2 surface" + + +def test_transitive_depth_two_includes_every_intermediate_domain() -> None: + """Every hop's object semantic_domains appear verbatim.""" + from chat.pack_grounding import _pack_index + pack = _pack_index() + depth2 = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + assert depth2 is not None + for obj in ("truth", "knowledge", "evidence"): + domains = pack[obj] + assert any(d in depth2 for d in domains[:1]), ( + f"no domain of {obj!r} in depth-2 surface" + ) + + +def test_transitive_cycle_guard_blocks_one_step_cycle() -> None: + """``memory verification`` → ``memory requires recall``; the only + follow-up candidate ``recall cause`` is ``recall reveals memory`` + which would re-introduce ``memory`` (1-step cycle). Even at + ``max_depth=4`` no hop survives → degrades to single-chain.""" + trans = teaching_grounded_surface_transitive( + "memory", IntentTag.VERIFICATION, max_depth=4, + ) + single = teaching_grounded_surface("memory", IntentTag.VERIFICATION) + assert trans == single + + +def test_transitive_visited_set_blocks_deeper_cycle() -> None: + """``light cause`` reaches ``evidence`` at hop 2. No chain in the + cognition corpus has subject ``evidence`` and a cause / verification + object outside ``{light, truth, knowledge, evidence}``, so depth-3 + is identical to depth-2 (the depth-3 candidate, if any, must be + refused by the visited-set guard or the per-hop terminator).""" + depth2 = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + depth3 = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=3, + ) + assert depth2 == depth3 + + +def test_transitive_is_deterministic() -> None: + a = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + b = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + assert a == b + + +def test_transitive_preserves_trust_label() -> None: + trans = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + assert trans is not None + assert trans.endswith("No session evidence yet.") + assert "teaching-grounded (cognition_chains_v1)" in trans + + +def test_transitive_negative_max_depth_clamps_to_zero() -> None: + """Misconfigured negative max_depth clamps to 0 (single-chain).""" + out = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=-5, + ) + single = teaching_grounded_surface("light", IntentTag.CAUSE) + assert out == single + + +# --------------------------------------------------------------------------- +# Runtime integration via the config flags +# --------------------------------------------------------------------------- + + +def test_runtime_default_does_not_engage_transitive() -> None: + """Both flags default False → single-chain surface.""" + rt = ChatRuntime(config=RuntimeConfig()) + response = rt.chat("Why does light exist?") + expected = teaching_grounded_surface("light", IntentTag.CAUSE) + assert response.surface == expected + + +def test_runtime_transitive_supersedes_composed() -> None: + """When both flags are on, transitive wins (strict superset of + composed at default ``max_depth=2``).""" + cfg = replace( + RuntimeConfig(), + composed_surface=True, + transitive_surface=True, + ) + rt = ChatRuntime(config=cfg) + response = rt.chat("Why does light exist?") + expected = teaching_grounded_surface_transitive( + "light", IntentTag.CAUSE, max_depth=2, + ) + assert response.surface == expected + # Strict superset over ADR-0062 on this prompt. + assert response.surface != teaching_grounded_surface_composed( + "light", IntentTag.CAUSE, + ) + + +def test_runtime_transitive_at_depth_one_matches_composed() -> None: + cfg = replace( + RuntimeConfig(), + transitive_surface=True, + transitive_max_depth=1, + ) + rt = ChatRuntime(config=cfg) + response = rt.chat("Why does light exist?") + expected = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert response.surface == expected + + +def test_runtime_flags_observable_on_frozen_config() -> None: + cfg = replace( + RuntimeConfig(), + transitive_surface=True, + transitive_max_depth=3, + ) + assert cfg.transitive_surface is True + assert cfg.transitive_max_depth == 3 + default = RuntimeConfig() + assert default.transitive_surface is False + assert default.transitive_max_depth == 2 + + +# --------------------------------------------------------------------------- +# Cognition-lane null-drop invariant (transitive adds clauses, never drops) +# --------------------------------------------------------------------------- + + +def test_cognition_lane_metrics_unchanged_with_transitive_flag() -> None: + """At ``max_depth=2``, every expected_term and + expected_surface_contains assertion that passed flag-OFF must still + pass flag-ON. If a future change ever drops tokens in transitive + mode, this test fails as the deliberate regression it is.""" + from evals.framework import get_lane, run_lane + + lane = get_lane("cognition") + watched = ( + "intent_accuracy", + "surface_groundedness", + "term_capture_rate", + "versor_closure_rate", + ) + on_cfg = replace( + RuntimeConfig(), + transitive_surface=True, + transitive_max_depth=2, + ) + for split in ("public", "holdout"): + off = run_lane(lane, version="v1", split=split, + config=RuntimeConfig()).metrics + on = run_lane(lane, version="v1", split=split, + config=on_cfg).metrics + for m in watched: + assert off[m] == on[m], ( + f"ADR-0083 null-drop invariant broken on split={split!r} " + f"metric={m!r}: OFF={off[m]} vs ON={on[m]}. " + f"Transitive surface should add clauses, never drop tokens." + )