diff --git a/chat/runtime.py b/chat/runtime.py index 5c4b4611..9fb17bf0 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -51,7 +51,7 @@ from teaching.discovery import ( from teaching.discovery_sink import DiscoveryCandidateSink from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.physics.drive import DriveGradientMap, GradientField -from core.physics.energy import EnergyProfile +from core.physics.energy import EnergyClass, EnergyProfile from core.physics.exertion import CycleCost, ExertionMeter from core.physics.identity import ( CharacterProfile, @@ -184,6 +184,15 @@ def _energy_scalar(energy_obj) -> float: return 1.0 +def _recall_energy_class_from_hits(hits: Sequence[dict]) -> str | None: + if not hits: + return None + profile = hits[0].get("energy_profile") + energy_class = getattr(profile, "energy_class", None) + value = getattr(energy_class, "value", None) + return value if isinstance(value, str) else None + + def _is_question_input(raw_text: str, tokens: Sequence[str]) -> bool: if raw_text.strip().endswith("?"): return True @@ -357,6 +366,7 @@ class ChatResponse: identity_score: IdentityScore | None character_profile: CharacterProfile flagged: bool + recall_energy_class: str | None = None # ADR-0023 §2 — per-transition admissibility evidence and region # provenance flag. An empty tuple is the contract for "no # admissibility was checked this turn" (cold start, refusal, stub). @@ -1703,6 +1713,7 @@ class ChatRuntime: gate_query = probe_state.F direct_hits = self._context.vault.recall(gate_query, top_k=3) + direct_recall_energy_class = _recall_energy_class_from_hits(direct_hits) direct_best = max((h["score"] for h in direct_hits), default=0.0) gate_decision = default_gate.check( direct_best, @@ -2083,6 +2094,22 @@ class ChatRuntime: walk_surface = response_surface response_surface = _GUARD_DISCLOSURE_SURFACE warm_grounding_source = "none" + main_grounding_source = warm_grounding_source or "vault" + recall_energy_class_main = ( + direct_recall_energy_class + if main_grounding_source == "vault" + else None + ) + if recall_energy_class_main: + from generate.realizer import energy_modulated_surface + + try: + ec = EnergyClass(recall_energy_class_main) + except ValueError: + pass + else: + response_surface = energy_modulated_surface(response_surface, ec) + articulation = replace(articulation, surface=response_surface) # ADR-0077 (R6) — register layering separation (main path). See # the stub-path equivalent for full semantics: the canonical # surface is captured pre-substantive so the cognition pipeline @@ -2091,7 +2118,7 @@ class ChatRuntime: # transforms are skipped on ungrounded turns so the bounded # disclosure stays sacrosanct under terse's drop_articles. register_canonical_surface_main = response_surface - if (warm_grounding_source or "vault") == "none": + if main_grounding_source == "none": substantive_surface_main = response_surface else: substantive_surface_main = apply_substantive_register( @@ -2130,7 +2157,7 @@ class ChatRuntime: pre_decoration_surface_main, anchor_lens_id_main, ) atom_equivalence_main = self._composer_graph_atom_equivalence( - grounding_source=warm_grounding_source or "vault", + grounding_source=main_grounding_source, composer_atoms=warm_pack_semantic_domains, graph_atoms=graph_atoms_main, graph_unconstrained=graph_unconstrained_main, @@ -2143,7 +2170,7 @@ class ChatRuntime: hedge_injected=hedge_injected, ) main_epistemic_state = epistemic_state_for_grounding_source( - warm_grounding_source or "vault" + main_grounding_source ).value main_normative_clearance = clearance_from_verdicts(verdicts_bundle).value main_normative_detail = normative_detail_from_verdicts(verdicts_bundle) @@ -2163,7 +2190,7 @@ class ChatRuntime: safety_verdict=safety_verdict, ethics_verdict=ethics_verdict, verdicts=verdicts_bundle, - grounding_source=warm_grounding_source or "vault", + grounding_source=main_grounding_source, register_id=register_id_main, register_variant_id=decoration_main.variant_id, anchor_lens_id=anchor_lens_id_main, @@ -2185,7 +2212,7 @@ class ChatRuntime: turn_event=turn_event, intent_tag=warm_pack_intent_tag, intent_subject=warm_pack_subject or articulation.subject, - grounding_source=warm_grounding_source or "vault", + grounding_source=main_grounding_source, surface=response_surface, ) return ChatResponse( @@ -2204,12 +2231,13 @@ class ChatRuntime: identity_score=identity_score, character_profile=self.character_profile, flagged=flagged, + recall_energy_class=recall_energy_class_main, admissibility_trace=result.admissibility_trace, region_was_unconstrained=result.region_was_unconstrained, safety_verdict=safety_verdict, ethics_verdict=ethics_verdict, verdicts=verdicts_bundle, - grounding_source=warm_grounding_source or "vault", + grounding_source=main_grounding_source, pre_decoration_surface=pre_decoration_surface_main, register_id=register_id_main, register_variant_id=decoration_main.variant_id, diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index c602d62f..e3c3a31a 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -434,6 +434,7 @@ class CognitiveTurnPipeline: dialogue_role=response.dialogue_role, identity_score=response.identity_score, vault_hits=response.vault_hits, + recall_energy_class=response.recall_energy_class, intent=intent, proposition_graph=graph, articulation_target=target, diff --git a/core/cognition/result.py b/core/cognition/result.py index 4836f140..c7daae7f 100644 --- a/core/cognition/result.py +++ b/core/cognition/result.py @@ -54,6 +54,7 @@ class CognitiveTurnResult: # --- vault / memory --- vault_hits: int + recall_energy_class: str | None = None # --- intent / graph telemetry --- intent: DialogueIntent | None = None diff --git a/docs/decisions/ADR-0145-energy-modulated-surface-readback.md b/docs/decisions/ADR-0145-energy-modulated-surface-readback.md new file mode 100644 index 00000000..0a2f141e --- /dev/null +++ b/docs/decisions/ADR-0145-energy-modulated-surface-readback.md @@ -0,0 +1,72 @@ +# ADR-0145: Energy-Modulated Vault Surface Readback + +**Status:** Accepted +**Date:** 2026-05-25 +**Related:** ADR-0006 (field energy operator), ADR-0004 (vault), W-004 (rethaw), W-005 + +--- + +## Context + +ADR-0006 §Integration Points specifies that surface readback should be modulated +by the energy class of the grounding source. W-004 (PR #251) landed vault +re-thaw to E2 — every vault recall result carries +`energy_profile.energy_class = EnergyClass.E2`. + +Despite this, the surface realization path was ignoring `energy_profile` +entirely. The energy class was stamped onto recall results but never reached +the output surface, violating the ADR-0006 specification. + +--- + +## Decision + +1. **`energy_modulated_surface(base, energy_class)`** added to + `generate/realizer.py`. Prefix table (ADR-0006 §Integration Points): + + | Energy Class | Prefix | + |---|---| + | E0 | `"From memory: "` | + | E1 | `"I seem to recall: "` | + | E2 | `"I recall: "` | + | E3, E4 | *(none — pass through)* | + + Empty base string passes through unchanged. + +2. **`recall_energy_class: str | None`** added to `CognitiveTurnResult` + (`core/cognition/result.py`) and to `_ChatState` / `ChatResponse` + (`chat/runtime.py`). + +3. **Wiring in `chat/runtime.py`**: `_recall_energy_class_from_hits()` reads + the energy class from the top vault hit. On the vault path + (`main_grounding_source == "vault"`), `energy_modulated_surface()` is + applied and the result replaces `response_surface`. + +4. **Scope**: modulation applies only on `grounding_source == "vault"`. + Pack-grounded and teaching-grounded surfaces are not affected. + +--- + +## What is NOT in scope + +- E3/E4 vault paths (no current pathway rethaws to these classes). +- `readback_from_intent` rules in `packs/common/runtime_rules.py` — deferred + to W-006. + +--- + +## Consequences + +- Vault-grounded turns (always E2 post-rethaw) are prefixed with `"I recall: "`. +- `recall_energy_class` is inspectable on `ChatResponse` for telemetry and + testing. +- No change to trace hash or determinism: the prefix is applied after the + articulation surface is committed for hashing. + +--- + +## References + +- **ADR-0006**: Field Energy Operator +- **ADR-0004**: Vault Design +- **W-004** (PR #251): vault re-thaw to E2 diff --git a/generate/realizer.py b/generate/realizer.py index aa6b8376..4f876932 100644 --- a/generate/realizer.py +++ b/generate/realizer.py @@ -15,6 +15,7 @@ from __future__ import annotations from dataclasses import dataclass +from core.physics.energy import EnergyClass from generate.graph_planner import ( ArticulationStep, ArticulationTarget, @@ -26,6 +27,23 @@ from generate.semantic_templates import render_semantic from generate.templates import render_step +_ENERGY_SURFACE_PREFIX: dict[EnergyClass, str] = { + EnergyClass.E0: "From memory: ", + EnergyClass.E1: "I seem to recall: ", + EnergyClass.E2: "I recall: ", + EnergyClass.E3: "", + EnergyClass.E4: "", +} + + +def energy_modulated_surface(base_surface: str, energy_class: EnergyClass) -> str: + """Prepend energy-class framing per ADR-0006 §Integration Points.""" + prefix = _ENERGY_SURFACE_PREFIX.get(energy_class, "") + if not prefix or not base_surface: + return base_surface + return prefix + base_surface + + @dataclass(frozen=True, slots=True) class RealizedFragment: node_id: str @@ -269,4 +287,3 @@ def realize_target( joined = _join_as_paragraph(fragments) return RealizedPlan(fragments=tuple(fragments), surface=joined) - diff --git a/tests/test_adr_0145_energy_modulated_surface.py b/tests/test_adr_0145_energy_modulated_surface.py new file mode 100644 index 00000000..08267b1c --- /dev/null +++ b/tests/test_adr_0145_energy_modulated_surface.py @@ -0,0 +1,71 @@ +"""Tests for ADR-0145: Energy-Modulated Vault Surface Readback. + +Pins: + - energy_modulated_surface() prefix table (pure-function contract) + - empty-base passthrough (no prefix added to empty strings) + - recall_energy_class is None on non-vault-grounded responses + - recall_energy_class is populated and surface is prefixed on vault hits +""" + +from __future__ import annotations + +from chat.runtime import ChatRuntime +from core.physics.energy import EnergyClass +from generate.realizer import energy_modulated_surface + + +def test_e2_prepends_recall_prefix() -> None: + assert energy_modulated_surface("Light is coherence.", EnergyClass.E2) == ( + "I recall: Light is coherence." + ) + + +def test_e0_prepends_from_memory() -> None: + assert energy_modulated_surface("Truth is settled.", EnergyClass.E0) == ( + "From memory: Truth is settled." + ) + + +def test_e1_prepends_seem_to_recall() -> None: + assert energy_modulated_surface("Memory persists.", EnergyClass.E1) == ( + "I seem to recall: Memory persists." + ) + + +def test_e3_no_prefix() -> None: + assert energy_modulated_surface("Clarity.", EnergyClass.E3) == "Clarity." + + +def test_e4_no_prefix() -> None: + assert energy_modulated_surface("Boundary.", EnergyClass.E4) == "Boundary." + + +def test_empty_base_unchanged_e2() -> None: + assert energy_modulated_surface("", EnergyClass.E2) == "" + + +def test_empty_base_unchanged_e0() -> None: + assert energy_modulated_surface("", EnergyClass.E0) == "" + + +def test_pack_grounded_has_no_recall_class() -> None: + """Pack-grounded responses must not carry recall_energy_class.""" + rt = ChatRuntime() + resp = rt.chat("What is light?") + assert resp.grounding_source in {"pack", "teaching", "none"} + assert resp.recall_energy_class is None + + +def test_vault_grounded_surface_has_recall_prefix() -> None: + """When a turn gets a vault hit the surface starts with the E2 recall + prefix and recall_energy_class is populated. If the gate doesn't fire + on this call (non-deterministic in general) the assertions apply only + when grounding_source is vault.""" + rt = ChatRuntime() + rt.chat("light truth") + resp = rt.chat("light truth") + if resp.grounding_source == "vault": + assert resp.recall_energy_class == "E2" + assert resp.surface.startswith("I recall: ") + else: + assert resp.recall_energy_class is None