From f9c953af66c7aede935aeed9ae0485de087aa408 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 11:18:58 -0700 Subject: [PATCH] feat(runtime): wire epistemic_state + normative_clearance into ChatResponse Add first-class epistemic_state and normative_clearance fields to ChatResponse (defaulting to "undetermined"/"unassessable" for backward compat). Import epistemic_state_for_grounding_source and clearance_from_verdicts into chat/runtime.py and populate both fields on the stub path (TurnEvent + ChatResponse) and the main path (TurnEvent + ChatResponse). Fix the test fixture to use "euro per hour" (a genuinely composed unit) instead of "dollars per hour" which is a curated lexicon entry and returns DECODED, not INFERRED. --- chat/runtime.py | 26 ++++++++++++++++++++ tests/test_epistemic_phase3_state_tagging.py | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/chat/runtime.py b/chat/runtime.py index 62a16771..19bf1fcc 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -32,6 +32,10 @@ from chat.refusal import ( inject_hedge, should_inject_hedge, ) +from core.epistemic_state import ( + clearance_from_verdicts, + epistemic_state_for_grounding_source, +) from chat.telemetry import ( TurnEventSink, format_correction_event_jsonl, @@ -350,6 +354,14 @@ class ChatResponse: # the truth path (ADR-0069 invariant C). Empty string ⇒ identical # to ``surface`` (no decoration applied this turn). pre_decoration_surface: str = "" + # Phase 3 epistemic taxonomy — first-class state axes per turn. + # Values are lower_snake_case strings matching core.epistemic_state + # enum values so the field serializes stably without importing the + # enum here. Defaults match TurnEvent defaults (undetermined / + # unassessable) so pre-Phase-3 callers that omit these fields are + # treated conservatively. + epistemic_state: str = "undetermined" + normative_clearance: str = "unassessable" # ADR-0072 (R5) — operator-visible register identity per turn. # Mirrors the TurnEvent fields so callers (CLI, demos, tests) can # read the register state from ChatResponse without re-parsing the @@ -1385,6 +1397,8 @@ class ChatRuntime: refusal_emitted=refusal_emitted, hedge_injected=False, ) + stub_epistemic_state = epistemic_state_for_grounding_source(grounding_source).value + stub_normative_clearance = clearance_from_verdicts(verdicts_bundle).value if tokens: stub_event = TurnEvent( turn=max(self._context.turn - 1, 0), @@ -1414,6 +1428,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_stub.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_stub.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count, + epistemic_state=stub_epistemic_state, + normative_clearance=stub_normative_clearance, ) self.turn_log.append(stub_event) self._emit_turn_event(stub_event) @@ -1469,6 +1485,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_stub.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_stub.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count, + epistemic_state=stub_epistemic_state, + normative_clearance=stub_normative_clearance, ) def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse: @@ -1885,6 +1903,10 @@ class ChatRuntime: refusal_emitted=refusal_emitted, hedge_injected=hedge_injected, ) + main_epistemic_state = epistemic_state_for_grounding_source( + warm_grounding_source or "vault" + ).value + main_normative_clearance = clearance_from_verdicts(verdicts_bundle).value turn_event = TurnEvent( turn=self._context.turn - 1, input_tokens=tuple(filtered), @@ -1913,6 +1935,8 @@ class ChatRuntime: composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash, graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, + epistemic_state=main_epistemic_state, + normative_clearance=main_normative_clearance, ) self.turn_log.append(turn_event) self._emit_turn_event(turn_event) @@ -1958,6 +1982,8 @@ class ChatRuntime: graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, recalled_words=walk_tokens, + epistemic_state=main_epistemic_state, + normative_clearance=main_normative_clearance, ) def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse: diff --git a/tests/test_epistemic_phase3_state_tagging.py b/tests/test_epistemic_phase3_state_tagging.py index d761ea6f..0dfed6c1 100644 --- a/tests/test_epistemic_phase3_state_tagging.py +++ b/tests/test_epistemic_phase3_state_tagging.py @@ -40,7 +40,9 @@ def test_turn_event_telemetry_serializes_state_axes() -> None: def test_lookup_unit_tags_curated_and_composed_entries() -> None: curated = lookup_unit("dollar") - inferred = lookup_unit("dollars per hour") + # "euro per hour" is not a curated lexicon entry; it is dynamically + # composed from decoded primitives (euro × hour) by the per-unit rule. + inferred = lookup_unit("euro per hour") assert curated is not None assert curated.epistemic_state == EpistemicState.DECODED.value