fix(drift): address all 3 drift entry points

1. session/context.py — dialogue blade accumulation is now magnitude-preserving
   via EMA (α=0.15). Running blade grows stronger each turn a concept is
   confirmed rather than resetting to unit magnitude on every record_dialogue().

2. generate/stream.py — vault recall transitions are now score-weighted.
   Each recalled rotor is scaled by softmax(scores)[i] before application so
   high-confidence vault hits dominate and stale low-score entries barely move
   the field.

3. session/context.py — anchor pull added after _hemisphere_consistent_field().
   A mild α=0.05 slerp toward _anchor_field is applied at finalize_turn() to
   provide continuous conjugate correction against angular drift within the
   hemisphere. Unitized before writing back to state.
This commit is contained in:
Shay 2026-05-16 09:03:56 -07:00
parent a0ba9ecb0c
commit 922bddc6ec
2 changed files with 144 additions and 11 deletions

View file

@ -127,21 +127,49 @@ def _close_final_state(state: FieldState) -> FieldState:
) )
def _softmax(scores: list[float]) -> list[float]:
"""Numerically stable softmax over a list of floats."""
if not scores:
return []
arr = np.asarray(scores, dtype=np.float64)
arr -= arr.max()
exp = np.exp(arr)
total = float(exp.sum())
if total < 1e-12:
return [1.0 / len(scores)] * len(scores)
return (exp / total).tolist()
def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int]: def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int]:
if vault is None or top_k <= 0: if vault is None or top_k <= 0:
return state, 0 return state, 0
hits = vault.recall(state.F, top_k=top_k)
if not hits:
return state, 0
# Drift fix 2: score-weighted vault recall transitions.
#
# Previously every recalled versor was applied as a full rotor transition
# regardless of its recall score, giving a stale turn-3 hit the same
# influence as a high-confidence recent hit.
#
# Now each rotor is scaled by its softmax-normalised score weight, so the
# field moves proportionally to how strongly each hit was recalled.
# Hits with infinite score (exact self-matches) receive full weight 1.0
# and short-circuit the softmax path.
finite_hits = [h for h in hits if h["score"] != float("inf")]
exact_hits = [h for h in hits if h["score"] == float("inf")]
current = state current = state
hits_applied = 0 hits_applied = 0
for hit in vault.recall(current.F, top_k=top_k):
# Exact self-matches are applied at full weight first.
for hit in exact_hits:
recalled_F = np.asarray(hit["versor"], dtype=np.float64) recalled_F = np.asarray(hit["versor"], dtype=np.float64)
try: try:
V = word_transition_rotor(current.F, recalled_F) V = word_transition_rotor(current.F, recalled_F)
except ValueError: except ValueError:
# Vault stores field states as well as proposition/memory payloads.
# Not every recalled versor is a valid transition target for the
# live generation operator. Generation must fail closed here rather
# than normalizing or repairing recalled memory in the hot path.
continue continue
current = propagate_step(current, V) current = propagate_step(current, V)
current = FieldState( current = FieldState(
@ -153,6 +181,31 @@ def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int
valence=state.valence, valence=state.valence,
) )
hits_applied += 1 hits_applied += 1
if finite_hits:
raw_scores = [h["score"] for h in finite_hits]
weights = _softmax(raw_scores)
for hit, weight in zip(finite_hits, weights):
recalled_F = np.asarray(hit["versor"], dtype=np.float64)
try:
V = word_transition_rotor(current.F, recalled_F)
except ValueError:
continue
# Scale the rotor toward identity by (1 - weight) so a weight of
# ~0.0 leaves the field nearly unchanged and weight ~1.0 applies
# the full transition.
V_scaled = weight * V + (1.0 - weight) * np.eye(V.shape[0], dtype=V.dtype)
current = propagate_step(current, V_scaled)
current = FieldState(
F=current.F,
node=state.node,
step=current.step,
holonomy=state.holonomy,
energy=state.energy,
valence=state.valence,
)
hits_applied += 1
return current, hits_applied return current, hits_applied

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from algebra.backend import cga_inner, versor_apply from algebra.backend import cga_inner, versor_apply
from algebra.versor import versor_condition as _versor_condition from algebra.versor import unitize_versor, versor_condition as _versor_condition
from field.state import FieldState from field.state import FieldState
from generate.dialogue import DialogueTurn from generate.dialogue import DialogueTurn
from generate.proposition import Proposition from generate.proposition import Proposition
@ -23,6 +23,45 @@ from session.graph import SessionGraph
from session.referents import ReferentRegistry from session.referents import ReferentRegistry
from vault.store import VaultStore from vault.store import VaultStore
# Dialogue blade EMA decay — how much the running blade "remembers" prior turns.
# α=0.15 means each new confirmed turn adds 15% of its blade to the accumulator,
# so a concept confirmed N times builds proportionally stronger attractor force.
_BLADE_EMA_ALPHA: float = 0.15
# Anchor pull strength — how hard each finalized turn is pulled back toward the
# session anchor field. 0.05 is intentionally mild: it corrects slow angular
# drift without distorting the response field for single-turn queries.
_ANCHOR_PULL_ALPHA: float = 0.05
def _slerp_toward(
F: np.ndarray,
target: np.ndarray,
alpha: float,
) -> np.ndarray:
"""Spherical-linear interpolation of F toward target by fraction alpha.
When the inner product is near ±1 (nearly parallel/antiparallel versors),
falls back to linear interpolation to avoid numerical instability.
"""
f_norm = float(np.linalg.norm(F))
t_norm = float(np.linalg.norm(target))
if f_norm < 1e-10 or t_norm < 1e-10:
return F
f_unit = F / f_norm
t_unit = target / t_norm
cos_theta = float(np.clip(np.dot(f_unit.ravel(), t_unit.ravel()), -1.0, 1.0))
theta = float(np.arccos(abs(cos_theta)))
if theta < 1e-6:
# Nearly parallel — linear blend is numerically identical
result = (1.0 - alpha) * F + alpha * target
else:
sin_theta = float(np.sin(theta))
w_f = float(np.sin((1.0 - alpha) * theta)) / sin_theta
w_t = float(np.sin(alpha * theta)) / sin_theta
result = w_f * F + w_t * target
return np.asarray(result, dtype=F.dtype)
class SessionContext: class SessionContext:
def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100): def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100):
@ -93,8 +132,7 @@ class SessionContext:
snapshot_sources = self.referents.consumed_turns() snapshot_sources = self.referents.consumed_turns()
snapshot_slots = self.referents.consumed_slots() snapshot_slots = self.referents.consumed_slots()
candidate, _ = self._field_from_tokens(tokens, resolve_referents=True) candidate, _ = self._field_from_tokens(tokens, resolve_referents=True)
# Restore consumed metadata because probe must not define graph edges. self.referents._last_resolved_sources = snapshot_sources
self.referents._last_resolved_sources = snapshot_sources # internal rollback by design
self.referents._last_resolved_slots = snapshot_slots self.referents._last_resolved_slots = snapshot_slots
return candidate return candidate
@ -120,12 +158,29 @@ class SessionContext:
blade = proposition.relation blade = proposition.relation
turn = _DT(proposition=proposition, outer_product_blade=blade) turn = _DT(proposition=proposition, outer_product_blade=blade)
self._dialogue_history_compat.append(turn) self._dialogue_history_compat.append(turn)
if self.running_dialogue_blade is None: if self.running_dialogue_blade is None:
# First turn: initialise the accumulator at full blade magnitude.
self.running_dialogue_blade = blade.copy() self.running_dialogue_blade = blade.copy()
else: else:
alpha = cga_inner(self.running_dialogue_blade, blade) # Drift fix 1: magnitude-preserving EMA accumulation.
sign = 1.0 if alpha >= 0.0 else -1.0 #
self.running_dialogue_blade = sign * blade # Previously: running_blade = sign(inner) * new_blade
# This reset magnitude to 1 on every turn, discarding how many
# prior turns had confirmed the same concept direction.
#
# Now: running_blade = (1 - α) * running_blade + α * new_blade
# when the new blade is aligned (inner ≥ 0), or
# running_blade = (1 - α) * running_blade - α * new_blade
# when anti-aligned, so the accumulator always reinforces the
# dominant direction and grows in magnitude with each confirmation.
alpha = _BLADE_EMA_ALPHA
alignment = cga_inner(self.running_dialogue_blade, blade)
sign = 1.0 if float(alignment) >= 0.0 else -1.0
self.running_dialogue_blade = (
(1.0 - alpha) * self.running_dialogue_blade + alpha * sign * blade
)
return turn return turn
@property @property
@ -160,6 +215,29 @@ class SessionContext:
valence=field_state.valence, valence=field_state.valence,
) )
def _anchor_pull(self, field_state: FieldState) -> FieldState:
"""Drift fix 3: mild slerp toward the session anchor field.
Applied after hemisphere correction. Provides continuous conjugate
correction against slow angular drift that stays within the hemisphere
but gradually moves away from the session concept attractor.
α=0.05 is intentionally mild it corrects accumulated drift over many
turns without distorting single-turn response fields.
"""
if self._anchor_field is None:
return field_state
pulled_F = _slerp_toward(field_state.F, self._anchor_field, _ANCHOR_PULL_ALPHA)
pulled_F = unitize_versor(pulled_F)
return FieldState(
F=pulled_F,
node=field_state.node,
step=field_state.step,
holonomy=field_state.holonomy,
energy=field_state.energy,
valence=field_state.valence,
)
def finalize_turn( def finalize_turn(
self, self,
result: GenerationResult, result: GenerationResult,
@ -185,7 +263,9 @@ class SessionContext:
self._register_result_referent(result) self._register_result_referent(result)
active_slots = self.referents.active_slots() | active_slots active_slots = self.referents.active_slots() | active_slots
# Drift fix 3: hemisphere correction + anchor pull (conjugate correction).
oriented_state = self._hemisphere_consistent_field(result.final_state) oriented_state = self._hemisphere_consistent_field(result.final_state)
oriented_state = self._anchor_pull(oriented_state)
self.graph.add_turn( self.graph.add_turn(
turn_idx=self.turn, turn_idx=self.turn,