From 0b940674c0448e36d59287297e773ab1abd4ef42 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 25 May 2026 11:57:00 -0700 Subject: [PATCH] feat(W-003): wire VaultPromotionPolicy into turn boundary (ADR-0148) (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(W-003): wire VaultPromotionPolicy into turn boundary (ADR-0148) VaultPromotionPolicy had zero callers; vault entries never crystallized from SPECULATIVE to COHERENT. This PR wires the policy at the turn boundary so settled entries can promote automatically. Changes: - core/config.py: add vault_promotion_enabled flag (default False, null-drop) - vault/store.py: add promote_eligible_entries(policy) — metadata-only scan, versors unchanged, _matrix_cache not invalidated - session/context.py: persist energy_raw/energy_class/coherence_residual in vault payload inside finalize_turn so the policy has data to decide on - chat/runtime.py: call promote_eligible_entries after each finalize_turn, gated on vault_promotion_enabled; import VaultPromotionPolicy - docs/decisions/ADR-0148-vault-promotion-policy-wiring.md: decision record - tests/test_adr_0148_vault_promotion.py: 6 tests, all green Unlocks W-007 (DerivedRecognizer derivation from COHERENT vault entries). Co-Authored-By: Claude Sonnet 4.6 * fix(W-003): resolve Pyright errors on vault promotion wiring - vault/store.py: add TYPE_CHECKING guard to import VaultPromotionPolicy only at type-check time, avoiding circular import at runtime while making the name resolvable to Pyright. - session/context.py:262: suppress union-attr false positive — self.state is guarded non-None by the raise at line 256 when input_versor is also None, but Pyright cannot narrow through the nested ternary structure. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- chat/runtime.py | 7 + core/config.py | 10 + .../ADR-0148-vault-promotion-policy-wiring.md | 110 ++++++++++ session/context.py | 8 +- tests/test_adr_0148_vault_promotion.py | 205 ++++++++++++++++++ vault/store.py | 54 +++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/ADR-0148-vault-promotion-policy-wiring.md create mode 100644 tests/test_adr_0148_vault_promotion.py diff --git a/chat/runtime.py b/chat/runtime.py index d18acac2..ffc3e981 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -56,6 +56,7 @@ from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.physics.drive import DriveGradientMap, GradientField from core.physics.energy import EnergyClass, EnergyProfile from core.physics.exertion import CycleCost, ExertionMeter +from core.physics.learning import VaultPromotionPolicy from core.physics.identity import ( CharacterProfile, IdentityCheck, @@ -1806,6 +1807,9 @@ class ChatRuntime: "grounding_source": pack_source_tag if pack_surface else "none", }, ) + # ADR-0148 — post-finalize promotion scan (flag-gated, null-drop when False). + if self.config.vault_promotion_enabled: + self._context.vault.promote_eligible_entries(VaultPromotionPolicy()) discovery_intent_tag = None discovery_intent_subject: str | None = None stub_graph_atoms: tuple[str, ...] = () @@ -1988,6 +1992,9 @@ class ChatRuntime: tokens_in=tuple(filtered), dialogue_role=str(dialogue_role), ) + # ADR-0148 — post-finalize promotion scan (flag-gated, null-drop when False). + if self.config.vault_promotion_enabled: + self._context.vault.promote_eligible_entries(VaultPromotionPolicy()) current_valence = _energy_scalar(getattr(result.final_state, "valence", None)) surface_ctx = self._build_surface_context(identity_score, current_valence) self._last_valence = current_valence diff --git a/core/config.py b/core/config.py index 2e8f4566..4084e10f 100644 --- a/core/config.py +++ b/core/config.py @@ -242,6 +242,7 @@ class RuntimeConfig: # live workload. unified_ingest: bool = False + # ADR-0144 — recognition-grounded articulation graph. When True and a # DerivedRecognizer is attached to CognitiveTurnPipeline, the articulation # graph is derived from the admitted EpistemicNode via the connector rather @@ -258,6 +259,15 @@ class RuntimeConfig: # ADR-0021 §3. Default False preserves all pre-W-016 discovery # output byte-identically (null-drop invariant on discovery lanes). vault_probe_discoveries: bool = False + # ADR-0148 — wire VaultPromotionPolicy into turn boundary. + # When True, ChatRuntime calls vault.promote_eligible_entries() after each + # finalize_turn(), scanning SPECULATIVE entries for crystallization to + # COHERENT based on their energy profile (EnergyClass E0/E1, coherence_residual + # ≤ 0.05). Fresh entries written in the current turn are E2+ and will not + # promote yet — the policy fires on entries that have cooled across turns. + # Default False: zero behavior change when disabled (null-drop invariant). + # Unlocks W-007 (DerivedRecognizer derivation from promoted COHERENT entries). + vault_promotion_enabled: bool = False DEFAULT_IDENTITY_PACK: str = "default_general_v1" diff --git a/docs/decisions/ADR-0148-vault-promotion-policy-wiring.md b/docs/decisions/ADR-0148-vault-promotion-policy-wiring.md new file mode 100644 index 00000000..f6f01bc0 --- /dev/null +++ b/docs/decisions/ADR-0148-vault-promotion-policy-wiring.md @@ -0,0 +1,110 @@ +# ADR-0148 — Wire VaultPromotionPolicy into turn boundary + +**Status:** Accepted +**Date:** 2026-05-25 +**Work item:** W-003 + +--- + +## Context + +`VaultPromotionPolicy` (introduced in ADR-0014, implemented at +`core/physics/learning.py`) decides whether a stored vault entry should be +promoted from `SPECULATIVE` to `COHERENT` based on its energy profile. + +Prior to this ADR, the policy had **zero callers**. Every vault entry written +by `session/context.py` remained `SPECULATIVE` indefinitely, regardless of how +settled or coherent the underlying field region was. + +This blocked W-007 (DerivedRecognizer derivation), which requires `COHERENT` +vault entries to serve as valid recognition anchors. + +--- + +## Decision + +### 1. Flag in `RuntimeConfig` (`core/config.py`) + +```python +vault_promotion_enabled: bool = False +``` + +Default `False` enforces the **null-drop invariant**: zero behavior change when +disabled. Operators opt in explicitly. + +### 2. Energy metadata persisted at store time (`session/context.py`) + +In `finalize_turn()`, after `_anchor_pull()` resolves `oriented_state`, the +energy fields are written into the vault payload before `vault.store()`: + +```python +if oriented_state.energy is not None: + payload["energy_raw"] = float(oriented_state.energy.raw) + payload["energy_class"] = oriented_state.energy.energy_class.value + payload["coherence_residual"] = float(oriented_state.energy.coherence_residual) +``` + +Storing raw scalars (not the `EnergyProfile` object) keeps the payload +JSON-serializable and avoids coupling the vault to the energy dataclass. + +### 3. `VaultStore.promote_eligible_entries(policy)` (`vault/store.py`) + +New method scans all SPECULATIVE entries. For each entry: + +1. Parses the stored `epistemic_status` string. +2. If SPECULATIVE, reconstructs a minimal `EnergyProfile` from the stored + `energy_raw`, `energy_class`, `coherence_residual` fields. +3. Calls `policy.decide(energy)`. +4. If `decision.promote`, updates `epistemic_status` to `COHERENT` in-place. + +**Versors are not touched.** `_matrix_cache` is not invalidated because no +versor changes — only metadata mutates. Deterministic recall is unaffected. + +### 4. Promotion fires post-finalize in `chat/runtime.py` + +After each `finalize_turn()` call in `chat()`: + +```python +if self.config.vault_promotion_enabled: + self._context.vault.promote_eligible_entries(VaultPromotionPolicy()) +``` + +**Why post-finalize, not at store time?** + +A freshly stored entry is always `E2+` (new activation, high recency). The +`VaultPromotionPolicy` promotes only `E0`/`E1` entries +(`vault_candidate=True`). A just-written entry will not promote on the same +turn it was written — it needs to cool across subsequent turns. This is the +correct multi-turn crystallization behavior described in ADR-0014. + +--- + +## Consequences + +### Positive + +- Vault entries can now crystallize: SPECULATIVE regions that settle over + multiple turns become COHERENT, making them admissible as evidence under + `min_status=EpistemicStatus.COHERENT` recall. +- W-007 (DerivedRecognizer derivation from promoted entries) is now unblocked. +- Zero coupling change when `vault_promotion_enabled=False` (default). + +### Constraints preserved + +- **versor_condition invariant**: no versor is modified during promotion. + `promote_eligible_entries` mutates only `_metadata` dicts. +- **No normalization**: `vault/store.py` is a forbidden normalization site + per `CLAUDE.md`. Promotion is a metadata-only operation — it does not + repair, reproject, or normalize any field. +- **No approximate recall**: CGA inner-product scoring is unchanged. +- **Reviewed learning path**: promotion upgrades `epistemic_status` on + already-stored entries; it does not inject new content or bypass the + teaching review gate. + +--- + +## Unlocks + +- **W-007** — DerivedRecognizer can now query the vault at + `min_status=EpistemicStatus.COHERENT` and receive crystallized entries as + recognition anchors. diff --git a/session/context.py b/session/context.py index f1873984..ca2fd56b 100644 --- a/session/context.py +++ b/session/context.py @@ -241,7 +241,7 @@ class SessionContext: input_F = ( np.asarray(input_versor, dtype=np.float32).copy() if input_versor is not None - else (self._last_input_versor.copy() if self._last_input_versor is not None else self.state.F.copy()) + else (self._last_input_versor.copy() if self._last_input_versor is not None else self.state.F.copy()) # type: ignore[union-attr] ) turn_tokens = tuple(tokens_in if tokens_in is not None else self._last_input_tokens) backward_edges = self.referents.consumed_turns() @@ -268,6 +268,12 @@ class SessionContext: payload = {"turn": self.turn, "role": "assistant"} if metadata: payload.update(metadata) + # ADR-0148 — persist energy profile so VaultPromotionPolicy can decide + # promotion eligibility on future turns (after the entry has cooled). + if oriented_state.energy is not None: + payload["energy_raw"] = float(oriented_state.energy.raw) + payload["energy_class"] = oriented_state.energy.energy_class.value + payload["coherence_residual"] = float(oriented_state.energy.coherence_residual) self.vault.store( oriented_state.F, payload, diff --git a/tests/test_adr_0148_vault_promotion.py b/tests/test_adr_0148_vault_promotion.py new file mode 100644 index 00000000..db1a3d77 --- /dev/null +++ b/tests/test_adr_0148_vault_promotion.py @@ -0,0 +1,205 @@ +"""Tests for ADR-0148 — VaultPromotionPolicy wired into the turn boundary. + +Six tests cover: + 1. Flag-off: no promotion fires (null-drop invariant). + 2. Direct promote_eligible_entries: E0/low-residual entry becomes COHERENT. + 3. Active-energy entry (E3) is not promoted (vault_candidate=False). + 4. E0 entry with high coherence_residual is not promoted. + 5. promote_eligible_entries returns correct count. + 6. finalize_turn persists energy_raw / energy_class / coherence_residual in vault metadata. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.cga import embed_point +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from core.physics.energy import EnergyClass, EnergyProfile +from core.physics.learning import VaultPromotionPolicy +from teaching.epistemic import EpistemicStatus +from vault.store import VaultStore + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _random_versor(seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + return embed_point(rng.standard_normal(3).astype(np.float32)) + + +def _store_entry( + vault: VaultStore, + seed: int, + *, + energy_class: EnergyClass, + coherence_residual: float, + raw: float = 0.05, +) -> None: + """Store a SPECULATIVE entry with explicit energy metadata.""" + vault.store( + _random_versor(seed), + { + "turn": seed, + "role": "assistant", + "energy_raw": float(raw), + "energy_class": energy_class.value, + "coherence_residual": float(coherence_residual), + }, + epistemic_status=EpistemicStatus.SPECULATIVE, + ) + + +# --------------------------------------------------------------------------- +# Test 1 — flag-off: no promotion when vault_promotion_enabled=False +# --------------------------------------------------------------------------- + +def test_speculative_entry_not_promoted_when_flag_off() -> None: + """Vault entries remain SPECULATIVE when vault_promotion_enabled=False (default).""" + runtime = ChatRuntime( + config=RuntimeConfig( + vault_promotion_enabled=False, + output_language="en", + frame_pack="en", + ) + ) + + for text in ("word truth", "light word", "begin truth"): + runtime.chat(text) + + vault = runtime.session.vault + assert len(vault) > 0, "Vault must have entries after chat turns" + + statuses = [m.get("epistemic_status") for m in vault._metadata] + assert all(s == EpistemicStatus.SPECULATIVE.value for s in statuses), ( + "All entries should stay SPECULATIVE when vault_promotion_enabled=False" + ) + + +# --------------------------------------------------------------------------- +# Test 2 — promote_eligible_entries promotes E0/low-residual entry to COHERENT +# --------------------------------------------------------------------------- + +def test_promote_eligible_entries_promotes_coherent_entry() -> None: + """An E0 entry with coherence_residual=0.02 should be promoted to COHERENT.""" + vault = VaultStore() + policy = VaultPromotionPolicy(residual_threshold=0.05) + + _store_entry(vault, seed=1, energy_class=EnergyClass.E0, coherence_residual=0.02, raw=0.05) + + count = vault.promote_eligible_entries(policy) + + assert count == 1, "Expected exactly 1 promotion" + assert vault._metadata[0]["epistemic_status"] == EpistemicStatus.COHERENT.value, ( + "Entry should be promoted to COHERENT" + ) + + +# --------------------------------------------------------------------------- +# Test 3 — active-energy entry (E3) is NOT promoted +# --------------------------------------------------------------------------- + +def test_promote_eligible_entries_skips_active_energy() -> None: + """An E3 entry (vault_candidate=False) must not be promoted.""" + vault = VaultStore() + policy = VaultPromotionPolicy(residual_threshold=0.05) + + _store_entry(vault, seed=2, energy_class=EnergyClass.E3, coherence_residual=0.01, raw=0.70) + + count = vault.promote_eligible_entries(policy) + + assert count == 0, "E3 entry must not be promoted (region still active)" + assert vault._metadata[0]["epistemic_status"] == EpistemicStatus.SPECULATIVE.value + + +# --------------------------------------------------------------------------- +# Test 4 — E0 entry with high coherence_residual is NOT promoted +# --------------------------------------------------------------------------- + +def test_promote_eligible_entries_skips_high_residual() -> None: + """An E0 entry with coherence_residual=0.10 must not be promoted (above threshold=0.05).""" + vault = VaultStore() + policy = VaultPromotionPolicy(residual_threshold=0.05) + + _store_entry(vault, seed=3, energy_class=EnergyClass.E0, coherence_residual=0.10, raw=0.05) + + count = vault.promote_eligible_entries(policy) + + assert count == 0, "High-residual E0 entry must not be promoted" + assert vault._metadata[0]["epistemic_status"] == EpistemicStatus.SPECULATIVE.value + + +# --------------------------------------------------------------------------- +# Test 5 — promote_eligible_entries returns correct count +# --------------------------------------------------------------------------- + +def test_promotion_count_returned() -> None: + """promote_eligible_entries returns the number of entries actually promoted.""" + vault = VaultStore() + policy = VaultPromotionPolicy(residual_threshold=0.05) + + # 2 promotable (E0, low residual) + _store_entry(vault, seed=10, energy_class=EnergyClass.E0, coherence_residual=0.01, raw=0.05) + _store_entry(vault, seed=11, energy_class=EnergyClass.E1, coherence_residual=0.03, raw=0.18) + # 1 NOT promotable (E3) + _store_entry(vault, seed=12, energy_class=EnergyClass.E3, coherence_residual=0.00, raw=0.70) + # 1 NOT promotable (E0 but high residual) + _store_entry(vault, seed=13, energy_class=EnergyClass.E0, coherence_residual=0.08, raw=0.05) + + count = vault.promote_eligible_entries(policy) + + assert count == 2, f"Expected 2 promotions, got {count}" + + +# --------------------------------------------------------------------------- +# Test 6 — energy stored in vault metadata after finalize_turn +# --------------------------------------------------------------------------- + +def test_energy_stored_in_vault_metadata() -> None: + """After a finalize_turn call, vault metadata contains energy_raw, energy_class, + and coherence_residual for the assistant turn entry.""" + runtime = ChatRuntime( + config=RuntimeConfig( + vault_promotion_enabled=False, + output_language="en", + frame_pack="en", + ) + ) + + runtime.chat("light truth word") + + vault = runtime.session.vault + assert len(vault) >= 1, "Vault must have at least one entry after a chat turn" + + # Find assistant turn entries (role=="assistant"); they carry energy metadata + # when oriented_state.energy is not None. + assistant_entries = [ + m for m in vault._metadata if m.get("role") == "assistant" + ] + assert assistant_entries, "Expected at least one assistant entry in vault" + + # At least one assistant entry should carry energy metadata. + # (If oriented_state.energy is None for all entries, the test would fail, + # which would correctly surface a gap in energy propagation.) + entries_with_energy = [ + m for m in assistant_entries + if "energy_raw" in m and "energy_class" in m and "coherence_residual" in m + ] + assert entries_with_energy, ( + "Expected at least one assistant vault entry to carry energy metadata " + "(energy_raw, energy_class, coherence_residual)" + ) + + for m in entries_with_energy: + assert isinstance(m["energy_raw"], float), "energy_raw must be a float" + assert isinstance(m["energy_class"], str), "energy_class must be a string" + # Verify the energy_class value is a valid EnergyClass member + EnergyClass(m["energy_class"]) # raises ValueError if invalid + assert isinstance(m["coherence_residual"], float), "coherence_residual must be a float" + assert 0.0 <= m["coherence_residual"] <= 1.0, ( + f"coherence_residual out of range: {m['coherence_residual']}" + ) diff --git a/vault/store.py b/vault/store.py index bae1deba..54ad614d 100644 --- a/vault/store.py +++ b/vault/store.py @@ -13,6 +13,7 @@ O(N) np.array_equal scans. from __future__ import annotations from collections import deque +from typing import TYPE_CHECKING import numpy as np from algebra.backend import vault_recall, vault_recall_batch @@ -21,6 +22,9 @@ from core.epistemic_state import EpistemicState from core.physics.energy import EnergyClass, EnergyProfile from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus +if TYPE_CHECKING: + from core.physics.learning import VaultPromotionPolicy + # ADR-0006 §"Integration Points": # "Vault recall re-activates the region to E2 transiently, then lets it @@ -290,6 +294,56 @@ class VaultStore: ]) return results + def promote_eligible_entries(self, policy: "VaultPromotionPolicy") -> int: + """Scan SPECULATIVE entries; promote to COHERENT where policy decides. + + For each SPECULATIVE entry that carries stored energy metadata, reconstructs + an EnergyProfile and calls policy.decide(). Entries that pass are updated + to COHERENT in-place (metadata only — versors are unchanged, so + _matrix_cache is not invalidated). + + Returns the count of promotions made in this call. + ADR-0148. + """ + from core.physics.energy import EnergyClass as _EnergyClass, EnergyProfile as _EnergyProfile + + promoted = 0 + for meta in self._metadata: + raw_status = meta.get("epistemic_status", "speculative") + try: + entry_status = ( + raw_status + if isinstance(raw_status, EpistemicStatus) + else EpistemicStatus(raw_status) + ) + except ValueError: + entry_status = EpistemicStatus.SPECULATIVE + if entry_status is not EpistemicStatus.SPECULATIVE: + continue + # Reconstruct EnergyProfile from stored metadata fields. + # If energy metadata is absent, pass None so the policy returns + # "missing_energy_profile" rather than guessing. + energy: _EnergyProfile | None = None + if ( + "energy_raw" in meta + and "energy_class" in meta + and "coherence_residual" in meta + ): + try: + ec = _EnergyClass(meta["energy_class"]) + energy = _EnergyProfile( + raw=float(meta["energy_raw"]), + energy_class=ec, + coherence_residual=float(meta["coherence_residual"]), + ) + except (ValueError, TypeError): + energy = None + decision = policy.decide(energy) + if decision.promote: + meta["epistemic_status"] = EpistemicStatus.COHERENT.value + promoted += 1 + return promoted + def reproject(self) -> None: """ Re-project all stored versors onto the null cone.