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:
parent
e7e28a2fd5
commit
5e6a16d473
5 changed files with 320 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
83
docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md
Normal file
83
docs/decisions/ADR-0153-turn-event-trace-hash-backstamp.md
Normal 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.
|
||||
183
tests/test_adr_0153_trace_hash_backstamp.py
Normal file
183
tests/test_adr_0153_trace_hash_backstamp.py
Normal 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
|
||||
Loading…
Reference in a new issue