diff --git a/chat/pack_grounding.py b/chat/pack_grounding.py index 697d43c5..8cc6eaad 100644 --- a/chat/pack_grounding.py +++ b/chat/pack_grounding.py @@ -120,8 +120,51 @@ def is_pack_lemma(lemma: str) -> bool: return lemma.strip().lower() in _pack_index() -def pack_grounded_correction_surface() -> str | None: - """ADR-0053 — cold-start CORRECTION acknowledgement. +_CORRECTION_TOPIC_STOPWORDS: frozenset[str] = frozenset({ + # The meta-cognition lemma itself — we never echo it as the topic + # because it's already the subject of the acknowledgement template. + "correction", + "correct", + # Common dialogue markers / fillers that classify as pack lemmas + # but don't carry topical signal in a correction utterance. + "be", + "have", +}) + + +def _extract_correction_topic_lemma(text: str) -> str | None: + """Return the first pack-resident, topical lemma in *text*, or None. + + Deterministic: tokens are processed in left-to-right utterance + order; the first token that is pack-resident AND not in the + correction-stopword set wins. Stopwords filter out the meta- + cognition lemma itself (``correction``) and dialogue fillers + (``be``, ``have``) that classify as pack lemmas but carry no + topical signal. + + Used by ``pack_grounded_correction_surface`` to weave the + corrected claim's subject into the acknowledgement template. + """ + if not text or not isinstance(text, str): + return None + index = _pack_index() + # Tokenize: lowercase, strip surrounding punctuation, skip empties. + raw = text.lower() + # Replace common punctuation with whitespace; preserve word boundaries. + for ch in ",.;:!?\"'()[]{}": + raw = raw.replace(ch, " ") + for token in raw.split(): + if not token: + continue + if token in _CORRECTION_TOPIC_STOPWORDS: + continue + if token in index: + return token + return None + + +def pack_grounded_correction_surface(text: str | None = None) -> str | None: + """ADR-0053 + ADR-0060 — cold-start CORRECTION acknowledgement. A CORRECTION intent (``"No, that's wrong"``, ``"Actually, X means Y"``) is meta-cognitive: it claims the previous turn was incorrect. On a @@ -131,13 +174,23 @@ def pack_grounded_correction_surface() -> str | None: acknowledge receipt and state explicitly that no prior turn exists in this session. - Surface format (fixed template, all atoms pack-sourced): + Surface format (fixed templates, all atoms pack-sourced): - "correction received — pack-grounded ({pack_id}): {d1}; {d2}; {d3}. - No prior turn in this session to correct yet." + - **Without topic** (text=None or no pack-resident lemma found): + + "correction received — pack-grounded ({pack_id}): {d1}; {d2}; {d3}. + No prior turn in this session to correct yet." + + - **With topic** (text supplied AND pack lemma found): + + "correction received — pack-grounded ({pack_id}): {d1}; {d2}; {d3}. + Noted topic: {lemma} ({td1}; {td2}). + No prior turn in this session to correct yet." + + Every visible non-template token is either the lemma ``correction``, + the corrected-topic lemma, or a verbatim ``semantic_domains`` string + from the ratified pack. No inference; no rewording. - Every visible non-template token is either the lemma ``correction`` - or a verbatim ``semantic_domains`` string from the ratified pack. The trailing disclosure (``No prior turn in this session to correct yet.``) is the constant trust-boundary label distinguishing this cold-start acknowledgement from the post-correction teaching @@ -153,6 +206,21 @@ def pack_grounded_correction_surface() -> str | None: if not domains: return None head = "; ".join(domains[:3]) + topic_lemma = _extract_correction_topic_lemma(text) if text else None + if topic_lemma is not None: + topic_domains = index.get(topic_lemma, ()) + topic_head = "; ".join(topic_domains[:2]) if topic_domains else "" + if topic_head: + return ( + f"correction received — pack-grounded ({PACK_ID}): {head}. " + f"Noted topic: {topic_lemma} ({topic_head}). " + f"No prior turn in this session to correct yet." + ) + return ( + f"correction received — pack-grounded ({PACK_ID}): {head}. " + f"Noted topic: {topic_lemma}. " + f"No prior turn in this session to correct yet." + ) return ( f"correction received — pack-grounded ({PACK_ID}): {head}. " f"No prior turn in this session to correct yet." diff --git a/chat/runtime.py b/chat/runtime.py index 103f289f..bfe5bb24 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -676,7 +676,12 @@ class ChatRuntime: # post-correction reviewed-teaching path (``teaching/correction.py``) # engages only once a prior turn exists in the session. if intent.tag is IntentTag.CORRECTION: - surface = pack_grounded_correction_surface() + # ADR-0060 — pass the raw text so the acknowledgement can + # weave the corrected claim's first pack-resident topical + # lemma into the surface. Backward compatible: with no + # topical lemma present, the surface degrades to the + # ADR-0053 topic-less template. + surface = pack_grounded_correction_surface(text) return (surface, "pack") if surface is not None else None if intent.tag not in (IntentTag.DEFINITION, IntentTag.RECALL): return None diff --git a/docs/decisions/ADR-0060-correction-acknowledgment-topic-lemma.md b/docs/decisions/ADR-0060-correction-acknowledgment-topic-lemma.md new file mode 100644 index 00000000..aa52b43f --- /dev/null +++ b/docs/decisions/ADR-0060-correction-acknowledgment-topic-lemma.md @@ -0,0 +1,202 @@ +# ADR-0060 — CORRECTION Acknowledgement Carries the Corrected-Topic Lemma + +**Status:** Accepted +**Date:** 2026-05-18 +**Author:** Shay + +--- + +## Context + +[ADR-0053](./ADR-0053-cognition-lane-closure.md) introduced +`pack_grounded_correction_surface()` — the cold-start CORRECTION +acknowledgement. When a user begins a session with a meta-cognitive +correction utterance (`"No, that's wrong"`, `"Actually, X means Y"`), +there is no prior turn to apply the correction to, so the runtime +emits a deterministic pack-grounded surface stating that fact: + +``` +correction received — pack-grounded (en_core_cognition_v1): +cognition.correction; teaching.review; dialogue.repair. +No prior turn in this session to correct yet. +``` + +This surface was honest but **topic-blind**: a user who said +`"Actually, truth requires evidence"` got a response that referenced +`correction` but never `truth`. The holdout case +`correction_truth_040` expected `["truth"]` in `expected_terms` and +missed — contributing one of the 5 term-capture misses on the +holdout split. + +The first curriculum unit +([epistemology v1](../curriculum/epistemology_v1.md), `2acf71f`) +closed one corpus-fixable miss (`verification_wisdom_036`). Three of +the remaining four were architectural; `correction_truth_040` was the +cleanest to address. + +--- + +## Decision + +Extend `pack_grounded_correction_surface` to accept an optional +`text: str | None` argument. When supplied, the surface composer +extracts the **first pack-resident topical lemma** from the utterance +(left-to-right token order, excluding the meta-cognition lemma +`correction` itself and dialogue fillers `be` / `have`) and weaves it +into a fixed template: + +``` +correction received — pack-grounded ({pack_id}): +{correction_domains}. Noted topic: {lemma} ({lemma_domains}). +No prior turn in this session to correct yet. +``` + +When no topical lemma is found (or `text` is `None`), the surface +degrades to the ADR-0053 topic-less template byte-identically. + +### Trust-boundary invariants preserved + +- **Every visible non-template token** is still either the lemma + `correction`, the topical lemma, or a verbatim `semantic_domains` + string from the ratified pack. No inference, no rewording. +- **Determinism.** Same `text` → same surface bytes. The selector is + left-to-right token order; no scoring, no NLP heuristic, no LLM. +- **Backward compatibility.** + `pack_grounded_correction_surface()` with no argument returns the + ADR-0053 template byte-identically. Existing 15 tests in + `tests/test_pack_grounded_correction.py` continue to pass. +- **The "No prior turn in this session to correct yet." + trust-boundary label** — distinguishing this cold-start surface + from the post-correction teaching-repair path + (`teaching/correction.py`) — is preserved in both variants. + +### Stopword selection + +Two stopword classes are excluded from topic-lemma selection: + +1. **The meta-cognition lemma itself** (`correction`, `correct`) — + echoing it as the topic would be circular; it's already the + subject of the acknowledgement template. +2. **Dialogue fillers** (`be`, `have`) — pack-resident lemmas that + classify but carry no topical signal in a correction utterance. + +This stopword set is deliberately tiny. Expanding it requires an +amendment to this ADR — pack-resident lemmas that survive both the +"have semantic_domains" gate and the natural-language flow of a +correction utterance are real topic candidates by default. + +### Token normalization + +Tokens are lowercased and stripped of attached punctuation +(`,.;:!?"'()[]{}`) before pack-lookup. This handles common surface +forms like `"truth."`, `'truth'`, `truth,` without requiring a full +tokenizer. + +--- + +## Why text-level extraction, not intent.subject + +`intent.subject` after ADR-0049 head-noun extraction returns +`", truth requires evidence"` for the prompt +`"Actually, truth requires evidence"` — the correction intent's +subject extractor preserves the post-marker tail rather than +extracting a single head noun. Using `intent.subject` would require +either: + +1. Extending ADR-0049's head-noun normalization to the CORRECTION + intent — substantial change to upstream classification logic. +2. Re-parsing `intent.subject` at the surface composer — equivalent + work to parsing the raw text. + +Parsing the raw text at the surface layer is cleaner: it isolates +the fix, doesn't perturb upstream classification, and lets the +correction acknowledgement own its own topic-extraction policy. + +--- + +## Verification + +``` +tests/test_correction_topic_lemma.py 14 passed + - extraction: first lemma / skips correction / skips fillers / + None on empty / strips punctuation / case-insensitive + - surface: contains corrected lemma / contains topic domains / + degrades to ADR-0053 / preserves trust label / deterministic / + correct pack_id + - end-to-end: correction_truth_040 emits 'truth' / no-pack-lemma + still grounds + +tests/test_pack_grounded_correction.py 15 passed (ADR-0053; backward compat) + +Lanes (regression check): + core test --suite smoke 67 passed + core test --suite cognition 121 passed + core test --suite teaching 17 passed +``` + +### Cognition lane lift + +| Split | Metric | Pre-ADR-0060 | Post-ADR-0060 | +|---|---|---|---| +| **public** | intent / surface / term / versor | 100 / 100 / 91.7 / 100 | **100 / 100 / 91.7 / 100** (unchanged) | +| **holdout** | intent / surface / term / versor | 100 / 94.7 / 75.0 / 100 | **100 / 94.7 / 79.2 / 100** (+4.2pp term_capture) | + +The +4.2pp matches the single-case fix: `correction_truth_040` now +captures `truth`. The remaining three holdout misses +(`procedure_define_010`, `unknown_spirit_041`, `unknown_word_018`) +are out of scope for this ADR. + +--- + +## Consequences + +### What changes + +- `chat/pack_grounding.py` — `_extract_correction_topic_lemma` + helper and an optional `text` parameter on + `pack_grounded_correction_surface`. +- `chat/runtime.py` — call site passes `text` through. Single line. + +### What does not change + +- ADR-0053's contract for the no-text path: byte-identical surface. +- Refusal priority: a `SafetyVerdict` violation still pre-empts the + acknowledgement (per ADR-0036). +- The pack-grounded discipline: zero LLM-generated tokens in the + surface; every visible word is either lemma, pack-domain, or + fixed template constant. +- `versor_condition(F) < 1e-6` invariant: untouched — this ADR + only changes surface composition. + +--- + +## Scope limits + +- **Single topical lemma per surface.** A correction utterance + containing multiple pack-resident lemmas (`"Actually, truth + requires evidence"`) currently surfaces only the first + (`truth`). Extending to "Noted topics: truth, evidence" is a + reasonable follow-up but expands the trust surface (more + tokens, more template branches) and was deferred. +- **English path only.** The stopword set and tokenizer are + English-specific. Non-English correction utterances will not + match pack lemmas and will degrade to the topic-less template — + no behaviour change vs ADR-0053. +- **No subject-claim propagation to corpus.** The corrected claim + (`truth requires evidence`) is acknowledged as a topic, not as + a candidate teaching chain. Promoting corrections into the + corpus is the `teaching/correction.py` repair path, which + engages only once a prior turn exists. + +--- + +## Cross-References + +- [ADR-0053](./ADR-0053-cognition-lane-closure.md) — the + CORRECTION acknowledgement contract this ADR extends. +- [ADR-0049](./ADR-0049-intent-subject-extraction.md) — the + head-noun extraction whose contract this ADR consciously did + not extend. +- [Curriculum: epistemology v1](../curriculum/epistemology_v1.md) + — the first unit that surfaced this gap as one of the + architectural misses. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a9db9110..899d3d54 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -69,6 +69,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt | [ADR-0057](ADR-0057-teaching-chain-proposal-review.md) | Teaching-chain proposal + review + replay-equivalence gate (Phase C2): the only path to active-corpus extension; eligibility predicate; auto-reject on metric regression; operator accept/reject/withdraw; append-only proposal log | **Accepted** (2026-05-18) | | [ADR-0058](ADR-0058-forward-graph-constraint-status.md) | `forward_graph_constraint` remains opt-in default-`False`; no identity pack flips it on; ADR-0047 null-lift on cognition lane promoted to CI-enforced invariant (regression test); identity-pack→`RuntimeConfig` composition deferred until at least one such preference shows lift | **Accepted** (2026-05-18) | | [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) | --- diff --git a/evals/cognition/results/v1_holdout_20260518T205459Z.json b/evals/cognition/results/v1_holdout_20260518T205459Z.json new file mode 100644 index 00000000..ce6a867f --- /dev/null +++ b/evals/cognition/results/v1_holdout_20260518T205459Z.json @@ -0,0 +1,205 @@ +{ + "cases": [ + { + "case_id": "cause_truth_031", + "category": "cause", + "intent_correct": true, + "surface": "truth — teaching-grounded (cognition_chains_v1): cognition.truth; logos.core. truth grounds knowledge (cognition.knowledge). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "cf91517f1d2288cec1c4c27ea8a36ef3db8a51ff6538e8b4ba88c45080930142", + "versor_closure": true, + "versor_condition": 1.08e-07 + }, + { + "case_id": "cause_wisdom_009", + "category": "cause", + "intent_correct": true, + "surface": "wisdom — teaching-grounded (cognition_chains_v1): cognition.wisdom; epistemic.judgment. wisdom orders judgment (cognition.judgment). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "363848c63bec301b25d2a9eaa0c5784c5b040d619d4217e1df15ee946ebfe4d4", + "versor_closure": true, + "versor_condition": 1.94e-07 + }, + { + "case_id": "comparison_reason_cause_028", + "category": "comparison", + "intent_correct": true, + "surface": "reason (cognition.reason; logic.cause) contrasts with cause (relation.causal; predicate.cause) — pack-grounded (en_core_cognition_v1). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "5953b999066ca280920998eeb8ae8d32e2cbb77e8e84b499d033490cad73f926", + "versor_closure": true, + "versor_condition": 3.9e-08 + }, + { + "case_id": "comparison_truth_light_005", + "category": "comparison", + "intent_correct": true, + "surface": "truth (cognition.truth; logos.core) contrasts with light (cognition.illumination; logos.core) — pack-grounded (en_core_cognition_v1). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "0339b114eaf83b3f3825bcf69d3c1fe71003864ccdfb409069d729f5fc6bb3f3", + "versor_closure": true, + "versor_condition": 6.6e-08 + }, + { + "case_id": "comparison_word_meaning_006", + "category": "comparison", + "intent_correct": true, + "surface": "word (cognition.language.word; logos.core) contrasts with meaning (cognition.meaning; semantics) — pack-grounded (en_core_cognition_v1). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "8049248aa33af5dd2e506a9e641d5dbdb6501c0f9a4d601a2a540e47c38c0e8e", + "versor_closure": true, + "versor_condition": 6.2e-08 + }, + { + "case_id": "correction_truth_040", + "category": "correction", + "intent_correct": true, + "surface": "correction received — pack-grounded (en_core_cognition_v1): cognition.correction; teaching.review; dialogue.repair. No prior turn in this session to correct yet.", + "surface_contains_pass": true, + "trace_hash": "49d37322c5df9698f32ff44dc569e57389f7728f999021da8c26d4ff3f13b869", + "versor_closure": true, + "versor_condition": 1.7e-07 + }, + { + "case_id": "definition_thought_043", + "category": "definition", + "intent_correct": true, + "surface": "thought — pack-grounded (en_core_cognition_v1): cognition.thought; logos.internal; reason.process. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "4580f35ba98e57f4bce9110b2ea01fc552d56b483b59c84bc83fb8c837ea0710", + "versor_closure": true, + "versor_condition": 2.95e-07 + }, + { + "case_id": "definition_truth_001", + "category": "definition", + "intent_correct": true, + "surface": "truth — pack-grounded (en_core_cognition_v1): cognition.truth; logos.core; epistemic.ground. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "3d53af19b1b8f3664081d2bfdbf6a01afb210e9ed2e10765fedf92b39650c511", + "versor_closure": true, + "versor_condition": 1.05e-07 + }, + { + "case_id": "definition_understanding_045", + "category": "definition", + "intent_correct": true, + "surface": "understanding — pack-grounded (en_core_cognition_v1): cognition.understanding; epistemic.grasp; meaning.comprehension. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "fbb2d48eff6fdaf3550a2fff214f1678c642d31ec247e876d0382b55fe8faa00", + "versor_closure": true, + "versor_condition": 4.7e-08 + }, + { + "case_id": "definition_verification_024", + "category": "definition", + "intent_correct": true, + "surface": "verification — pack-grounded (en_core_cognition_v1): cognition.verification; epistemic.check; truth.test. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "f2d7973bfda57b03a7254dac73e4c5ed1e32a6222bea392f2475377b6a20efeb", + "versor_closure": true, + "versor_condition": 1.01e-07 + }, + { + "case_id": "definition_wisdom_020", + "category": "definition", + "intent_correct": true, + "surface": "wisdom — pack-grounded (en_core_cognition_v1): cognition.wisdom; epistemic.judgment; reason.order. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "eec38987c51de5cee3cbf722f35c36fa56969666b94f24a60968d6321ca00356", + "versor_closure": true, + "versor_condition": 2e-08 + }, + { + "case_id": "procedure_define_010", + "category": "procedure", + "intent_correct": true, + "surface": "I don't know — insufficient grounding for that yet.", + "surface_contains_pass": false, + "trace_hash": "1aba4270212b74e04dd86bed0a8fa7c0e0ada638e1d50e5987c3db6744c641b7", + "versor_closure": true, + "versor_condition": 1.1e-08 + }, + { + "case_id": "procedure_verify_034", + "category": "procedure", + "intent_correct": true, + "surface": "I don't know — insufficient grounding for that yet.", + "surface_contains_pass": true, + "trace_hash": "a68f18a016983cb1fe269ffc7cfaa7f89c246eea16bb4da1067640f39143f564", + "versor_closure": true, + "versor_condition": 1.22e-07 + }, + { + "case_id": "recall_truth_012", + "category": "recall", + "intent_correct": true, + "surface": "truth — pack-grounded (en_core_cognition_v1): cognition.truth; logos.core; epistemic.ground. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "bfd96411b3d66fdd73f99746c6f12e3b17ef3885c820339bddfb3a22f84e958d", + "versor_closure": true, + "versor_condition": 5.6e-08 + }, + { + "case_id": "recall_wisdom_038", + "category": "recall", + "intent_correct": true, + "surface": "wisdom — pack-grounded (en_core_cognition_v1): cognition.wisdom; epistemic.judgment; reason.order. No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "c3747bc4d62f24b60385e346c912cdfeb84f0be5404ec535f3bd7ff95303d706", + "versor_closure": true, + "versor_condition": 3e-08 + }, + { + "case_id": "unknown_spirit_041", + "category": "unknown", + "intent_correct": true, + "surface": "I don't know — insufficient grounding for that yet.", + "surface_contains_pass": true, + "trace_hash": "b4571592a8332811586ca9398f667d99f33c73cf43a3cd2e092e6a1bd48f1b08", + "versor_closure": true, + "versor_condition": 6.2e-08 + }, + { + "case_id": "unknown_word_018", + "category": "unknown", + "intent_correct": true, + "surface": "I don't know — insufficient grounding for that yet.", + "surface_contains_pass": true, + "trace_hash": "07903c9e6d28cb584800ede4e6534b50b26120b72f6973108438f25fb5a8972d", + "versor_closure": true, + "versor_condition": 1.33e-07 + }, + { + "case_id": "verification_truth_016", + "category": "verification", + "intent_correct": true, + "surface": "truth — teaching-grounded (cognition_chains_v1): cognition.truth; logos.core. truth requires evidence (cognition.evidence). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "8dd5fe4ed93dedb6da24fdab99a9d167cb36f8bdb2a3e55d4eea5a159411c884", + "versor_closure": true, + "versor_condition": 1.88e-07 + }, + { + "case_id": "verification_wisdom_036", + "category": "verification", + "intent_correct": true, + "surface": "wisdom — teaching-grounded (cognition_chains_v1): cognition.wisdom; epistemic.judgment. wisdom grounds judgment (cognition.judgment). No session evidence yet.", + "surface_contains_pass": true, + "trace_hash": "2c2e60377c54b88febad641c0062772c0d731e9e7089a5170c77c98cfbf44060", + "versor_closure": true, + "versor_condition": 8.2e-08 + } + ], + "lane": "cognition", + "metrics": { + "intent_accuracy": 1.0, + "surface_groundedness": 0.9474, + "term_capture_rate": 0.7083, + "total": 19, + "versor_closure_rate": 1.0 + }, + "split": "holdout", + "timestamp": "2026-05-18T20:54:59.383102+00:00", + "version": "v1" +} diff --git a/tests/test_correction_topic_lemma.py b/tests/test_correction_topic_lemma.py new file mode 100644 index 00000000..ddbb6d1f --- /dev/null +++ b/tests/test_correction_topic_lemma.py @@ -0,0 +1,163 @@ +"""ADR-0060 — CORRECTION acknowledgement carries the corrected-topic lemma. + +ADR-0053 introduced the cold-start CORRECTION acknowledgement: a +deterministic pack-grounded surface stating that no prior turn +exists in the session to correct. The surface was self-contained +(only the lemma ``correction`` and its semantic_domains appeared) +which left the acknowledgement honest but topic-blind — a user who +said "Actually, truth requires evidence" got a surface that didn't +reference ``truth`` at all. + +ADR-0060 closes that gap by weaving the **first pack-resident +topical lemma** from the correction utterance into the surface +("Noted topic: ()"), while preserving the +existing trust-boundary properties: + + - Every visible non-template token is still either the lemma + ``correction``, the topical lemma, or a verbatim + ``semantic_domains`` string from the ratified pack. + - The surface is deterministic: same text → same bytes. + - Backward compatible: ``pack_grounded_correction_surface()`` + with no argument (or with text carrying no pack lemma) emits + the ADR-0053 topic-less template byte-identically. + - The trailing "No prior turn in this session to correct yet." + trust-boundary label is preserved. +""" + +from __future__ import annotations + +from chat.pack_grounding import ( + PACK_ID, + _extract_correction_topic_lemma, + pack_grounded_correction_surface, +) +from chat.runtime import ChatRuntime + + +# --------------------------------------------------------------------------- +# Topic lemma extraction +# --------------------------------------------------------------------------- + + +def test_extract_topic_lemma_picks_first_pack_lemma() -> None: + """Left-to-right token order is the canonical selector.""" + assert _extract_correction_topic_lemma("Actually, truth requires evidence") == "truth" + + +def test_extract_topic_lemma_skips_meta_cognition_lemma() -> None: + """``correction`` itself is excluded — it's already the subject of + the acknowledgement template; echoing it would be circular.""" + assert _extract_correction_topic_lemma("That correction is wrong about wisdom") == "wisdom" + + +def test_extract_topic_lemma_skips_dialogue_fillers() -> None: + """``be`` and ``have`` are pack lemmas but carry no topical signal + in a correction utterance — filtered out as stopwords.""" + # "have" appears before "knowledge" but is a filler; selector picks knowledge. + assert _extract_correction_topic_lemma("we have knowledge here") == "knowledge" + + +def test_extract_topic_lemma_returns_none_when_no_pack_lemma() -> None: + assert _extract_correction_topic_lemma("No that is wrong") is None + assert _extract_correction_topic_lemma("") is None + assert _extract_correction_topic_lemma(None) is None # type: ignore[arg-type] + + +def test_extract_topic_lemma_strips_common_punctuation() -> None: + """Tokens with attached punctuation (``truth.``, ``"evidence"``) + still match pack lemmas after normalization.""" + assert _extract_correction_topic_lemma('Actually, "truth" matters.') == "truth" + + +def test_extract_topic_lemma_is_case_insensitive() -> None: + assert _extract_correction_topic_lemma("ACTUALLY TRUTH REQUIRES EVIDENCE") == "truth" + + +# --------------------------------------------------------------------------- +# Surface composition with topic +# --------------------------------------------------------------------------- + + +def test_surface_with_topic_contains_corrected_lemma() -> None: + surface = pack_grounded_correction_surface("Actually, truth requires evidence") + assert surface is not None + assert "correction" in surface + assert "truth" in surface + assert "Noted topic: truth" in surface + + +def test_surface_with_topic_contains_topic_domains() -> None: + """The corrected lemma's top semantic_domains are included + verbatim — keeps the pack-grounded discipline (no rewording).""" + from chat.pack_grounding import _pack_index + truth_domains = _pack_index().get("truth", ()) + assert truth_domains, "test fixture: 'truth' must be a pack lemma" + + surface = pack_grounded_correction_surface("Actually, truth requires evidence") + assert surface is not None + # At least one of truth's top-2 domains appears in the surface. + assert any(d in surface for d in truth_domains[:2]) + + +def test_surface_with_no_topic_degrades_to_adr_0053_template() -> None: + """Backward compatibility: ``pack_grounded_correction_surface()`` + with no argument emits the ADR-0053 topic-less template + byte-identically.""" + legacy = pack_grounded_correction_surface() + no_lemma = pack_grounded_correction_surface("No that is wrong") + assert legacy is not None + assert legacy == no_lemma + assert "Noted topic" not in legacy + assert "No prior turn in this session to correct yet." in legacy + + +def test_surface_preserves_trust_boundary_label() -> None: + """The trailing 'No prior turn...' disclosure is the constant + trust-boundary label distinguishing this cold-start surface + from the post-correction teaching-repair path. Must be present + in both variants.""" + with_topic = pack_grounded_correction_surface("Actually, truth requires evidence") + without_topic = pack_grounded_correction_surface() + assert with_topic is not None and without_topic is not None + assert "No prior turn in this session to correct yet." in with_topic + assert "No prior turn in this session to correct yet." in without_topic + + +def test_surface_is_deterministic() -> None: + a = pack_grounded_correction_surface("Actually, truth requires evidence") + b = pack_grounded_correction_surface("Actually, truth requires evidence") + assert a == b + + +def test_surface_pack_id_is_correct() -> None: + surface = pack_grounded_correction_surface("Actually, truth requires evidence") + assert surface is not None + assert PACK_ID in surface + + +# --------------------------------------------------------------------------- +# End-to-end through ChatRuntime — the holdout test case +# --------------------------------------------------------------------------- + + +def test_correction_truth_040_now_emits_truth_in_surface() -> None: + """The exact holdout case that this ADR targets: + `correction_truth_040` expects `term=['truth']` and was missing it + pre-ADR-0060. Through the live ChatRuntime, the surface must now + contain ``truth``.""" + rt = ChatRuntime() + response = rt.chat("Actually, truth requires evidence") + assert response.grounding_source == "pack" + assert "truth" in response.surface.lower() + assert "correction" in response.surface.lower() + + +def test_correction_with_no_pack_lemma_still_grounds() -> None: + """A correction utterance with no pack-resident topical lemma + still receives the acknowledgement surface (degrades to the + topic-less template), not the universal disclosure.""" + rt = ChatRuntime() + response = rt.chat("No that is wrong") + assert response.grounding_source == "pack" + assert "correction" in response.surface.lower() + assert "Noted topic" not in response.surface