feat(W-020a): TurnEvent.trace_hash back-stamp (ADR-0153) (#277)

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.
This commit is contained in:
Shay 2026-05-25 18:42:35 -07:00 committed by GitHub
parent e7e28a2fd5
commit 5e6a16d473
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 320 additions and 0 deletions

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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