From 5e6a16d4736d702f699887cbae2760dc99a345d3 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 25 May 2026 18:42:35 -0700 Subject: [PATCH] feat(W-020a): TurnEvent.trace_hash back-stamp (ADR-0153) (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TurnEvent had no trace_hash field, so teaching/discovery._trace_hash always returned "" via getattr default. Every persisted DiscoveryCandidate had source_turn_trace="" — provenance gap observed in a real 103-turn session. - Add trace_hash: str = "" to TurnEvent - runtime.finalize_turn_trace_hash back-stamps last TurnEvent and unstamped tail of _pending_candidates, then re-persists - CognitiveTurnPipeline.process calls finalize_turn_trace_hash after compute_trace_hash, before constructing CognitiveTurnResult Invariants: empty hash is a no-op; back-walk halts at first already- stamped candidate (no overwrite of prior turns); trace_hash bytes are unchanged for any given turn. Validated: tests/test_adr_0153_trace_hash_backstamp.py (6 tests), core test --suite cognition/smoke/runtime/teaching all green. Out of scope: OOV candidate trace_hash (same root cause, line-streamed sink requires different fix); telemetry-sink trace_hash exposure. --- chat/runtime.py | 37 ++++ core/cognition/pipeline.py | 8 + core/physics/identity.py | 9 + ...DR-0153-turn-event-trace-hash-backstamp.md | 83 ++++++++ tests/test_adr_0153_trace_hash_backstamp.py | 183 ++++++++++++++++++ 5 files changed, 320 insertions(+) create mode 100644 docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md create mode 100644 tests/test_adr_0153_trace_hash_backstamp.py diff --git a/chat/runtime.py b/chat/runtime.py index 489bd4f7..310709fe 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -707,6 +707,43 @@ class ChatRuntime: ) -> None: self._pending_recognizer_examples.append((tuple(tokens), bundle)) + def finalize_turn_trace_hash(self, trace_hash: str) -> None: + """ADR-0153 (W-020a) — back-stamp the canonical trace_hash. + + Called by ``CognitiveTurnPipeline.process`` after + ``compute_trace_hash`` produces the turn's canonical + SHA-256. Stamps the trace_hash onto the most recent + TurnEvent and any DiscoveryCandidate emitted during this + turn (i.e., the unstamped tail of ``_pending_candidates``), + then re-persists the candidates checkpoint so the on-disk + audit trail names the originating turn instead of the + prior empty-string default. + + No-op when ``trace_hash`` is empty (pre-pipeline call sites, + refusal stub path). Idempotent: stamping a tail whose + ``source_turn_trace`` is already non-empty halts the back-walk. + """ + if not trace_hash: + return + from dataclasses import replace + if self.turn_log: + last_event = self.turn_log[-1] + if not last_event.trace_hash: + self.turn_log[-1] = replace(last_event, trace_hash=trace_hash) + stamped = False + for idx in range(len(self._pending_candidates) - 1, -1, -1): + cand = self._pending_candidates[idx] + if cand.source_turn_trace: + break + self._pending_candidates[idx] = replace( + cand, source_turn_trace=trace_hash + ) + stamped = True + if stamped and self._engine_state_store is not None: + self._engine_state_store.save_discovery_candidates( + self._pending_candidates + ) + def first_admitted_recognizer(self): if not self.config.recognition_grounded_graph: return None diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 561c18d1..c044a468 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -425,6 +425,14 @@ class CognitiveTurnPipeline: refusal_reason=refusal_reason, ) + # ADR-0153 (W-020a) — back-stamp the canonical trace_hash onto + # the runtime's most-recent TurnEvent and any DiscoveryCandidate + # emitted during this turn. The runtime cannot compute + # trace_hash itself (the pipeline owns ``compute_trace_hash``), + # so candidates were previously persisted with empty + # ``source_turn_trace``. See runtime.finalize_turn_trace_hash. + self.runtime.finalize_turn_trace_hash(trace_hash) + return CognitiveTurnResult( input_text=text, input_tokens=raw_tokens, diff --git a/core/physics/identity.py b/core/physics/identity.py index 725a6c25..bc261814 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -298,6 +298,15 @@ class TurnEvent: epistemic_state: str = "undetermined" normative_clearance: str = "unassessable" normative_detail: str = "" + # ADR-0153 (W-020a) — canonical SHA-256 trace hash for this turn, + # back-stamped by ``CognitiveTurnPipeline.process`` after + # ``compute_trace_hash`` runs. Empty string on construction; + # populated via dataclass.replace in ``runtime.finalize_turn_trace_hash``. + # Discovery candidates and OOV candidates emitted during the same + # turn read this field to populate their ``source_turn_trace`` + # provenance, replacing the prior empty-string default that left + # the audit trail unable to identify the originating turn. + trace_hash: str = "" # ADR-0072 (R5) — operator-visible register identity per turn. # ``register_id`` is the loaded pack id (e.g. ``"convivial_v1"``), # or ``""`` for the in-memory UNREGISTERED sentinel. diff --git a/docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md b/docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md new file mode 100644 index 00000000..1534d656 --- /dev/null +++ b/docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md @@ -0,0 +1,83 @@ +# ADR-0153 — TurnEvent trace_hash back-stamp (W-020a) + +Status: accepted +Date: 2026-05-25 + +## Context + +`teaching/discovery.py` documents that `DiscoveryCandidate.source_turn_trace` +"is the upstream `TurnEvent.trace_hash`" (module docstring, line 41). +The extractor reads it via: + +```python +def _trace_hash(turn_event: Any) -> str: + value = getattr(turn_event, "trace_hash", "") or "" + return str(value) +``` + +But `TurnEvent` (`core/physics/identity.py`) has no `trace_hash` field. +Every `DiscoveryCandidate` written to disk had `source_turn_trace=""`. +Same defect at `chat/runtime.py:874` for `OOVCandidate`. + +The real `trace_hash` is computed by `core/cognition/trace.py:compute_trace_hash` +inside `CognitiveTurnPipeline.process` **after** `runtime.chat()` returns — +which is after `_emit_discovery_candidates` has already buffered and +`_checkpointed_response` has already persisted the candidates. + +Symptom observed: a 103-turn session wrote a `discovery_candidates.jsonl` +whose one candidate had `"source_turn_trace": ""` — provenance lost. + +## Decision + +1. Add `trace_hash: str = ""` to `TurnEvent`. +2. Add `ChatRuntime.finalize_turn_trace_hash(trace_hash)`. Back-stamps: + - `turn_log[-1]` (via `dataclasses.replace`) + - the unstamped tail of `_pending_candidates` + - re-persists `discovery_candidates.jsonl` +3. `CognitiveTurnPipeline.process` calls `finalize_turn_trace_hash` after + `compute_trace_hash`, before constructing `CognitiveTurnResult`. + +The back-walk halts at the first already-stamped candidate, so prior +turns' candidates are never overwritten. + +## Invariants + +- Empty `trace_hash` is a no-op (refusal/stub call sites do not lose + byte-identity). +- Re-stamping is idempotent for the most recent turn and forbidden for + earlier turns. +- No change to `compute_trace_hash` inputs — trace_hash bytes for any + given turn remain identical to pre-ADR-0153. +- No change to `TurnEvent` telemetry sink emission order (sinks receive + the pre-stamp event; the persisted record gets the stamped one). A + follow-up may add a "turn finalized" sink event with the trace_hash. + +## Out of scope + +- OOV candidates (`OOVCandidate.source_turn_trace`) — same root cause, + but the sink is line-streamed at emit time, not buffered. Fix + requires either buffering OOV or computing trace_hash earlier. + Tracked separately; no production OOV sink was configured during + the observed regression. +- Telemetry sink trace_hash exposure — see invariants above. + +## Validation + +`tests/test_adr_0153_trace_hash_backstamp.py`: +- `TurnEvent.trace_hash` field exists with default `""` +- main-path `pipe.run` stamps `turn_log[-1].trace_hash == result.trace_hash` +- main-path `pipe.run` stamps `_pending_candidates[*].source_turn_trace` +- on-disk `discovery_candidates.jsonl` reflects the stamped values +- empty trace_hash is a no-op +- idempotent: prior stamps are not overwritten + +CLI lanes: `core test --suite cognition` (120 + 1 skipped), +`core test --suite smoke` (67), `core test --suite runtime` (17), +`core test --suite teaching` (20) all green. + +## Provenance gap closure + +After this ADR, every `DiscoveryCandidate` persisted via the +load-time auto-proposal pipeline (ADR-0151) carries the canonical +trace_hash of the turn that produced it, restoring the audit-trail +invariant the discovery module's docstring already promised. diff --git a/tests/test_adr_0153_trace_hash_backstamp.py b/tests/test_adr_0153_trace_hash_backstamp.py new file mode 100644 index 00000000..4eb88707 --- /dev/null +++ b/tests/test_adr_0153_trace_hash_backstamp.py @@ -0,0 +1,183 @@ +"""ADR-0153 (W-020a) — back-stamp canonical trace_hash onto TurnEvent +and DiscoveryCandidate. + +Before this ADR, ``TurnEvent`` had no ``trace_hash`` field, so +``teaching/discovery._trace_hash`` (which read it via ``getattr(..., +"trace_hash", "")``) always returned ``""``. Every persisted +``DiscoveryCandidate.source_turn_trace`` was empty, breaking the +audit trail that promises (per the dataclass docstring) to name the +originating turn. + +The fix: + 1. Add ``trace_hash: str = ""`` to ``TurnEvent``. + 2. ``CognitiveTurnPipeline.process`` calls + ``runtime.finalize_turn_trace_hash(trace_hash)`` after + ``compute_trace_hash`` and before constructing + ``CognitiveTurnResult``. + 3. The runtime back-stamps the last ``TurnEvent`` and the unstamped + tail of ``_pending_candidates``, then re-persists the + ``discovery_candidates.jsonl`` checkpoint. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from chat.runtime import ChatRuntime +from core.cognition.pipeline import CognitiveTurnPipeline +from core.config import RuntimeConfig + + +def _pick_cold_cause_subject() -> str: + """Pick a pack lemma that is NOT in any (lemma, cause) teaching chain. + + Mirrors the fixture-rotation pattern from + ``tests/test_discovery_candidates.py`` so this test does not break + when a future curriculum unit ratifies ``(principle, cause)``. + """ + from chat.pack_grounding import _pack_index + from chat.teaching_grounding import _corpus_index + + corpus = _corpus_index() + for candidate in ("principle", "narrative", "concept", "judgment"): + if candidate in _pack_index() and (candidate, "cause") not in corpus: + return candidate + pytest.skip("no cold (*, cause) pack lemma available for fixture") + + +def test_turn_event_has_trace_hash_field() -> None: + """ADR-0153: ``TurnEvent`` exposes a ``trace_hash`` field.""" + from core.physics.identity import TurnEvent + + assert "trace_hash" in TurnEvent.__dataclass_fields__ + assert TurnEvent.__dataclass_fields__["trace_hash"].default == "" + + +def test_pipeline_back_stamps_turn_event_trace_hash(tmp_path: Path) -> None: + """After ``pipe.run``, the last ``TurnEvent.trace_hash`` matches the + pipeline's computed ``CognitiveTurnResult.trace_hash``.""" + state_path = tmp_path / "engine_state" + runtime = ChatRuntime( + config=RuntimeConfig(), + engine_state_path=state_path, + ) + pipe = CognitiveTurnPipeline(runtime=runtime) + result = pipe.run("What causes light?") + + assert result.trace_hash, "pipeline must produce a non-empty trace_hash" + assert runtime.turn_log, "turn_log must contain at least one event" + assert runtime.turn_log[-1].trace_hash == result.trace_hash + + +def test_pipeline_back_stamps_pending_discovery_candidates( + tmp_path: Path, +) -> None: + """Discovery candidates emitted during the turn carry the canonical + trace_hash in their ``source_turn_trace`` field after the pipeline + finalizes the turn.""" + subject = _pick_cold_cause_subject() + state_path = tmp_path / "engine_state" + runtime = ChatRuntime( + config=RuntimeConfig(), + engine_state_path=state_path, + ) + pipe = CognitiveTurnPipeline(runtime=runtime) + result = pipe.run(f"What causes {subject}?") + + cold_candidates = [ + c + for c in runtime._pending_candidates + if c.proposed_chain.get("subject") == subject + and c.proposed_chain.get("intent") == "cause" + ] + if not cold_candidates: + pytest.skip( + "discovery did not fire for " + f"({subject}, cause) under default config; fixture stale" + ) + + assert result.trace_hash + for cand in cold_candidates: + assert cand.source_turn_trace == result.trace_hash, ( + "DiscoveryCandidate.source_turn_trace must equal the canonical " + f"trace_hash; got {cand.source_turn_trace!r} vs {result.trace_hash!r}" + ) + + +def test_persisted_candidates_jsonl_carries_trace_hash( + tmp_path: Path, +) -> None: + """The on-disk ``discovery_candidates.jsonl`` checkpoint reflects the + back-stamped trace_hash (not the empty default).""" + subject = _pick_cold_cause_subject() + state_path = tmp_path / "engine_state" + runtime = ChatRuntime( + config=RuntimeConfig(), + engine_state_path=state_path, + ) + pipe = CognitiveTurnPipeline(runtime=runtime) + result = pipe.run(f"What causes {subject}?") + runtime.checkpoint_engine_state() + + candidates_path = state_path / "discovery_candidates.jsonl" + if not candidates_path.exists(): + pytest.skip("checkpoint did not write candidates this turn") + + lines = [ + json.loads(line) + for line in candidates_path.read_text("utf-8").splitlines() + if line.strip() + ] + cold = [ + ln + for ln in lines + if ln["proposed_chain"]["subject"] == subject + and ln["proposed_chain"]["intent"] == "cause" + ] + if not cold: + pytest.skip("discovery did not fire under default config; fixture stale") + for ln in cold: + assert ln["source_turn_trace"] == result.trace_hash + + +def test_finalize_is_noop_for_empty_trace_hash(tmp_path: Path) -> None: + """Empty trace_hash MUST be a no-op (stub/refusal call sites).""" + runtime = ChatRuntime( + config=RuntimeConfig(), + engine_state_path=tmp_path / "engine_state", + ) + # Construct a fake last-turn so we can assert no mutation occurs. + pipe = CognitiveTurnPipeline(runtime=runtime) + pipe.run("Hello.") + before = runtime.turn_log[-1].trace_hash + runtime.finalize_turn_trace_hash("") + assert runtime.turn_log[-1].trace_hash == before + + +def test_finalize_is_idempotent_on_already_stamped_tail( + tmp_path: Path, +) -> None: + """A second back-stamp with a different hash MUST NOT overwrite + candidates whose ``source_turn_trace`` is already non-empty. + + Protects against re-stamping prior turns when a new turn fires + discovery; the back-walk halts at the first already-stamped entry. + """ + subject = _pick_cold_cause_subject() + runtime = ChatRuntime( + config=RuntimeConfig(), + engine_state_path=tmp_path / "engine_state", + ) + pipe = CognitiveTurnPipeline(runtime=runtime) + pipe.run(f"What causes {subject}?") + + stamped_before = [ + c.source_turn_trace for c in runtime._pending_candidates + ] + runtime.finalize_turn_trace_hash("deadbeef" * 8) + stamped_after = [ + c.source_turn_trace for c in runtime._pending_candidates + ] + assert stamped_before == stamped_after