diff --git a/chat/runtime.py b/chat/runtime.py index 07525040..30580960 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -19,6 +19,7 @@ from chat.pack_grounding import ( ) from chat.teaching_grounding import ( teaching_grounded_surface, + teaching_grounded_surface_composed, TEACHING_CORPUS_ID as _TEACHING_CORPUS_ID, ) from chat.refusal import ( @@ -668,7 +669,17 @@ class ChatRuntime: lemma = (intent.subject or "").strip() if not lemma: return None - surface = teaching_grounded_surface(lemma, intent.tag) + # ADR-0062 — when ``composed_surface`` is enabled, the + # teaching-grounded composer extends the single-chain + # surface with a follow-up chain whose subject equals the + # initial chain's object. Backward-compatible: with the + # flag off, the single-chain composer is used; with the + # flag on and no follow-up chain available, the composer + # degrades to the single-chain surface byte-identically. + if self.config.composed_surface: + surface = teaching_grounded_surface_composed(lemma, intent.tag) + else: + surface = teaching_grounded_surface(lemma, intent.tag) return (surface, "teaching") if surface is not None else None # ADR-0053 — CORRECTION acknowledgement. Cold-start CORRECTION # has no prior session turn to apply to; emit a pack-grounded diff --git a/chat/teaching_grounding.py b/chat/teaching_grounding.py index 63a54137..0b691715 100644 --- a/chat/teaching_grounding.py +++ b/chat/teaching_grounding.py @@ -224,6 +224,104 @@ def teaching_grounded_surface( ) +def teaching_grounded_surface_composed( + subject_lemma: str, intent_tag: IntentTag, +) -> 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 = _corpus_index() + chain = corpus.get((key, intent_name)) + if chain is None: + return None + pack = _pack_index() + 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) + + # 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 + + 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 ({TEACHING_CORPUS_ID}): " + f"{head_subject}. {chain.subject} {connective} {chain.object} " + f"({head_object_short}). No session evidence yet." + ) + + follow_object_domains = 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 ({TEACHING_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)] + ) + follow_connective = humanize_predicate(follow_up.connective) + return ( + f"{chain.subject} — teaching-grounded ({TEACHING_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 has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool: """Return True iff a reviewed chain exists for (subject, intent).""" if not subject_lemma or not isinstance(subject_lemma, str): diff --git a/core/config.py b/core/config.py index 771fd406..23bdc9c0 100644 --- a/core/config.py +++ b/core/config.py @@ -48,6 +48,16 @@ class RuntimeConfig: # disable to retain the pre-ADR-0046 unconstrained walk. forward_graph_constraint: bool = False + # ADR-0062 — composed teaching-grounded surface. When enabled, + # the teaching-grounded composer extends a single-chain surface + # with a follow-up chain whose subject equals the initial chain's + # object — producing surfaces like "light reveals truth, which + # grounds knowledge" instead of just "light reveals truth". + # Default False preserves all pre-ADR-0062 behaviour. Cycle-safe + # (won't follow if the next subject has been visited), bounded + # depth (max one follow-up chain in v1). + composed_surface: bool = False + DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1" diff --git a/docs/decisions/ADR-0062-composed-teaching-grounded-surface.md b/docs/decisions/ADR-0062-composed-teaching-grounded-surface.md new file mode 100644 index 00000000..a3dd5ee7 --- /dev/null +++ b/docs/decisions/ADR-0062-composed-teaching-grounded-surface.md @@ -0,0 +1,218 @@ +# ADR-0062 — Composed Teaching-Grounded Surface (Chain-of-Chains) + +**Status:** Accepted +**Date:** 2026-05-18 +**Author:** Shay + +--- + +## Context + +Pre-ADR-0062, `teaching_grounded_surface` emitted exactly **one +reviewed chain** per surface: + +``` +light — teaching-grounded (cognition_chains_v1): cognition.illumination; +logos.core. light reveals truth (cognition.truth). +No session evidence yet. +``` + +Every grounded prompt produced a single-clause surface, regardless +of how many follow-up chains the corpus already contained. With +21 active chains in `cognition_chains_v1` (after curriculum +saturation v2), many grounded prompts have an immediate corpus- +ratified follow-up that the surface composer was silently dropping: + +| Initial chain | Follow-up chain | What single-chain emits | What composed could emit | +|---|---|---|---| +| `light reveals truth` | `truth grounds knowledge` | `light reveals truth` | `light reveals truth, which grounds knowledge` | +| `thought reveals meaning` | `meaning grounds understanding` | `thought reveals meaning` | `thought reveals meaning, which grounds understanding` | +| `inference requires evidence` | `evidence grounds knowledge` | `inference requires evidence` | `inference requires evidence, which grounds knowledge` | + +This is the *fluency-from-existing-corpus* gap I called out +in the "more packs?" question: the rate-limiter on articulation +isn't pack vocabulary, it's surface composition over chains that +already exist. + +--- + +## Decision + +Add `teaching_grounded_surface_composed(subject, intent_tag)` to +`chat/teaching_grounding.py` alongside the existing single-chain +composer, and route it via a new opt-in +`RuntimeConfig.composed_surface: bool = False`. + +### Surface format + +``` +"{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}. + {A} {conn_A} {B} ({dB}), which {conn_B} {C} ({dC}). + No session evidence yet." +``` + +Every visible non-template token remains a lemma, a verbatim pack +`semantic_domains` string, or a `humanize_predicate`-emitted +connective. The new `, which ` linker is the only added template +constant. + +### Follow-up resolution rules + +1. Look up an initial chain `(subject, intent)`. +2. Look up a follow-up chain whose `subject` equals the initial + chain's `object`. Prefer `cause`; fall back to `verification`. + (Causal continuation reads more naturally than a verification + detour; the preference is deterministic.) +3. **Cycle guard.** If the follow-up's `object` equals the initial + `subject` OR the initial `object`, do not follow (1-step cycle + or degenerate same-cell mismatch). +4. **Pack-residency guard.** If the follow-up's `object` is not + pack-resident with `semantic_domains`, do not follow (would + emit a partially-grounded composition). +5. If no follow-up survives the guards, degrade to the + single-chain surface **byte-identically**. Drop-in replacement. + +### Bounded depth + +v1 follows **exactly one hop**. Deeper compositions (A→B→C→D) are +deferred to a future ADR. The cycle/pack-residency guards alone +don't suffice for unbounded depth — a depth-2 chain can re-enter +through a different intent. Bounded depth + visited-set check is +the natural next step but adds template-shape complexity not +needed today. + +### Opt-in flag + +`RuntimeConfig.composed_surface: bool = False`. Default preserves +all pre-ADR-0062 behaviour byte-identically. Mirrors the +ADR-0047/0058 `forward_graph_constraint` pattern: ship the +capability behind a flag, characterise empirically, decide on +default behaviour in a follow-up once downstream consumers have +observed it on their workloads. + +--- + +## Verification + +``` +tests/test_composed_surface.py 11 passed + - Pure function: None when no chain / degrades when no follow-up / + produces two-clause when follow-up exists / includes both + intermediate and final domains / deterministic / cycle guard + blocks 1-step cycle / preserves trust-boundary label. + - Runtime: default keeps single-chain / flag-on uses composed / + flag is observable on frozen config. + - Null-drop invariant: cognition-lane metrics byte-identical + flag OFF vs ON on both public and holdout splits. + +Lanes (regression check): + core test --suite smoke 67 passed + core test --suite cognition 121 passed + core test --suite teaching 17 passed +``` + +### Cognition-lane null-drop invariant + +Composed mode emits a **strictly longer** surface — every token +in the single-chain surface still appears, plus one follow-up +clause. So every `expected_term` and `expected_surface_contains` +that passed flag-OFF must still pass flag-ON. The contract test +`test_cognition_lane_metrics_unchanged_with_composed_flag` runs +both public and holdout splits twice (flag OFF vs ON) and asserts +all four watched metrics are pair-wise identical: + +| Split | Flag OFF | Flag ON | +|---|---|---| +| public | 100 / 100 / 91.7 / 100 | **100 / 100 / 91.7 / 100** | +| holdout | 100 / 100 / 83.3 / 100 | **100 / 100 / 83.3 / 100** | + +If a future change ever drops tokens in composed mode (e.g. +shortens the surface to omit the intermediate object), this test +fails as the deliberate regression it is. + +### Live-prompt observable lift + +Composed mode visibly enriches the surface on prompts where +follow-ups exist: + +``` +flag OFF: "Why does light exist?" + → light — teaching-grounded (cognition_chains_v1): + cognition.illumination; logos.core. light reveals truth + (cognition.truth). No session evidence yet. + +flag ON: "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. +``` + +Of the 21 active chains, the follow-up resolution succeeds for +~12 of them (the rest hit cycle guards or pack-residency +guards). Saturation v2's three coherent clusters were authored +partly with this composition in mind — `thought reveals meaning` ++ `meaning grounds understanding`, `definition grounds concept` + +`concept requires definition` (cycle-blocked, degrades cleanly), +etc. + +--- + +## Consequences + +### What changes + +- `core/config.py` — `RuntimeConfig.composed_surface: bool = False`. +- `chat/teaching_grounding.py` — + `teaching_grounded_surface_composed(subject, intent_tag)` sibling + to `teaching_grounded_surface`. +- `chat/runtime.py` — dispatch branch in `_maybe_pack_grounded_surface` + for `IntentTag.CAUSE` / `IntentTag.VERIFICATION` selects composed + vs single-chain based on the config flag. Single-line change. + +### What does not change + +- The pack-grounded discipline: zero LLM-generated tokens; every + visible word is lemma, pack-domain, connective, or template + constant. +- ADR-0053's cold-start contract: empty session + no chain still + emits the universal disclosure. +- Default runtime behaviour: byte-identical to pre-ADR-0062 main. +- The non-negotiable field invariant + (`versor_condition(F) < 1e-6`) is unaffected — this ADR only + changes surface composition, not rotor construction or sandwich + application. + +--- + +## Scope limits + +- **Depth-1 only.** v1 follows one hop. `light reveals truth, + which grounds knowledge, which requires evidence` would require + a depth-2 composer with visited-set tracking — out of scope + here. +- **No multi-claim aggregation.** When the same subject has + multiple ratified chains (e.g. `knowledge requires evidence` + AND `knowledge` is the object of three other chains), the + composer still picks one initial chain. Aggregation across + multiple grounded views is a separate ADR. +- **English path only.** The `, which ` linker and the + `humanize_predicate` connectives are English-specific. +- **Flag stays off by default.** Operators must opt in. A follow-up + ADR will decide on default behaviour after characterising the + flag on more workloads (mirrors ADR-0047/0058). + +--- + +## Cross-References + +- [ADR-0052](./ADR-0052-teaching-grounded-surface.md) — the + single-chain teaching-grounded composer this ADR extends. +- [ADR-0053](./ADR-0053-cognition-lane-closure.md) — the cognition- + lane closure that exposed the saturation headroom. +- [ADR-0058](./ADR-0058-forward-graph-constraint-status.md) — + the opt-in-default-False + null-lift-invariant pattern this ADR + reuses. +- [Curriculum: cognition saturation v2](../curriculum/cognition_saturation_v2.md) + — the unit that produced the 21 chains this composer composes + over. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index af00544f..e83c8404 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -71,6 +71,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt | [ADR-0059](ADR-0059-correction-pass-telemetry.md) | `ChatRuntime.correct()` emits a discriminated `"type": "correction"` JSONL event to the existing telemetry sink with `target_turn`, `records_count`, `turn_idxs_affected`, `max_delta_norm`, `mean_delta_norm`, SHA-256 correction-versor digest, pack ids — no raw versor coordinates; deterministic; no-op without sink | **Accepted** (2026-05-18) | | [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) | --- diff --git a/tests/test_composed_surface.py b/tests/test_composed_surface.py new file mode 100644 index 00000000..7c1381bc --- /dev/null +++ b/tests/test_composed_surface.py @@ -0,0 +1,171 @@ +"""ADR-0062 — composed teaching-grounded surface (chain-of-chains). + +When a chain ``(A, intent_A, conn_A, B)`` is grounded and a follow-up +chain ``(B, ?, conn_B, C)`` exists in the corpus, the composed +composer extends the single-chain surface with a second clause: + + "{A} — teaching-grounded ({corpus_id}): {dA}. {A} {conn_A} {B} + ({dB}), which {conn_B} {C} ({dC}). No session evidence yet." + +This test file pins: + + - Default config keeps the flag off → byte-identical single-chain + surface. + - Flag-on with a follow-up available → composed two-clause surface. + - Flag-on with no follow-up available → composer degrades to the + single-chain surface (drop-in replacement; never errors). + - Cycle guard: 1-step cycles (A→B, B→A) are not followed. + - Determinism: same input → same surface bytes. + - Cognition lane: metrics byte-identical flag OFF vs ON on both + public and holdout splits (the null-lift invariant for composed + surface — composition adds tokens but doesn't drop any). +""" + +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, +) +from generate.intent import IntentTag + + +# --------------------------------------------------------------------------- +# Pure-function contract +# --------------------------------------------------------------------------- + + +def test_composed_returns_none_when_no_chain() -> None: + """No chain for (subject, intent) → None, matching the + single-chain composer's behaviour.""" + out = teaching_grounded_surface_composed("zzznotalemma", IntentTag.CAUSE) + assert out is None + + +def test_composed_degrades_to_single_chain_when_no_follow_up() -> None: + """``memory verification`` has no follow-up chain whose subject + is its object (``recall``) that doesn't cycle back to ``memory`` + — composer degrades to the single-chain surface byte-identically.""" + composed = teaching_grounded_surface_composed("memory", IntentTag.VERIFICATION) + single = teaching_grounded_surface("memory", IntentTag.VERIFICATION) + assert composed is not None + assert single is not None + assert composed == single + + +def test_composed_produces_two_clause_when_follow_up_exists() -> None: + """``light cause`` has ``light reveals truth`` AND there exists a + follow-up ``truth cause`` (``truth grounds knowledge``). The + composed surface must contain both ``light`` and ``knowledge``.""" + composed = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + single = teaching_grounded_surface("light", IntentTag.CAUSE) + assert composed is not None + assert single is not None + assert composed != single + # Surface must contain initial subject, intermediate object, and final object. + assert "light" in composed + assert "truth" in composed + assert "knowledge" in composed + # And the ", which " connective clause. + assert ", which " in composed + + +def test_composed_includes_intermediate_and_final_domains() -> None: + """Pack-grounded discipline: both the intermediate object's + semantic_domains AND the final object's semantic_domains appear + verbatim in the composed surface.""" + from chat.pack_grounding import _pack_index + pack = _pack_index() + truth_d = pack["truth"] + knowledge_d = pack["knowledge"] + + composed = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert composed is not None + assert any(d in composed for d in truth_d[:1]) + assert any(d in composed for d in knowledge_d[:1]) + + +def test_composed_is_deterministic() -> None: + a = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + b = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert a == b + + +def test_composed_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). Composer + must not follow. Surface == single-chain surface.""" + composed = teaching_grounded_surface_composed("memory", IntentTag.VERIFICATION) + single = teaching_grounded_surface("memory", IntentTag.VERIFICATION) + assert composed == single + + +def test_composed_preserves_trust_label() -> None: + """The trailing ``No session evidence yet.`` trust-boundary label + must be preserved in both single-chain and composed variants.""" + composed = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert composed is not None + assert "No session evidence yet." in composed + + +# --------------------------------------------------------------------------- +# Runtime integration via the config flag +# --------------------------------------------------------------------------- + + +def test_runtime_default_uses_single_chain() -> None: + """Default ``RuntimeConfig`` keeps ``composed_surface=False`` → + runtime emits the single-chain surface for ``Why does light exist?``.""" + 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_with_flag_on_uses_composed() -> None: + rt = ChatRuntime(config=replace(RuntimeConfig(), composed_surface=True)) + response = rt.chat("Why does light exist?") + expected = teaching_grounded_surface_composed("light", IntentTag.CAUSE) + assert response.surface == expected + # And the composed surface is observably different from single. + assert response.surface != teaching_grounded_surface("light", IntentTag.CAUSE) + + +def test_runtime_flag_is_observable_on_frozen_config() -> None: + cfg = replace(RuntimeConfig(), composed_surface=True) + assert cfg.composed_surface is True + assert RuntimeConfig().composed_surface is False + + +# --------------------------------------------------------------------------- +# Cognition-lane null-lift invariant (composed mode adds tokens, never drops) +# --------------------------------------------------------------------------- + + +def test_cognition_lane_metrics_unchanged_with_composed_flag() -> None: + """Composed mode emits a strictly longer surface with one + additional follow-up clause; every expected_term that passed + flag-OFF must still pass flag-ON. Public + holdout splits. + If a future change drops tokens in composed mode (e.g. omitting + the intermediate object), this test fails as a regression.""" + from evals.framework import get_lane, run_lane + + lane = get_lane("cognition") + watched = ("intent_accuracy", "surface_groundedness", + "term_capture_rate", "versor_closure_rate") + 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=replace(RuntimeConfig(), composed_surface=True)).metrics + for m in watched: + assert off[m] == on[m], ( + f"ADR-0062 null-drop invariant broken on split={split!r} " + f"metric={m!r}: OFF={off[m]} vs ON={on[m]}. " + f"Composed surface should add tokens, never drop them." + )