feat(W-003): wire VaultPromotionPolicy into turn boundary (ADR-0148) (#272)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shay 2026-05-25 11:57:00 -07:00 committed by GitHub
parent 9bbdcc96aa
commit 0b940674c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 393 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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']}"
)

View file

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