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:
parent
a0ba9ecb0c
commit
922bddc6ec
2 changed files with 144 additions and 11 deletions
|
|
@ -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]:
|
||||
if vault is None or top_k <= 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
|
||||
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)
|
||||
try:
|
||||
V = word_transition_rotor(current.F, recalled_F)
|
||||
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
|
||||
current = propagate_step(current, V)
|
||||
current = FieldState(
|
||||
|
|
@ -153,6 +181,31 @@ def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int
|
|||
valence=state.valence,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
import numpy as np
|
||||
|
||||
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 generate.dialogue import DialogueTurn
|
||||
from generate.proposition import Proposition
|
||||
|
|
@ -23,6 +23,45 @@ from session.graph import SessionGraph
|
|||
from session.referents import ReferentRegistry
|
||||
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:
|
||||
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_slots = self.referents.consumed_slots()
|
||||
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 # internal rollback by design
|
||||
self.referents._last_resolved_sources = snapshot_sources
|
||||
self.referents._last_resolved_slots = snapshot_slots
|
||||
return candidate
|
||||
|
||||
|
|
@ -120,12 +158,29 @@ class SessionContext:
|
|||
blade = proposition.relation
|
||||
turn = _DT(proposition=proposition, outer_product_blade=blade)
|
||||
self._dialogue_history_compat.append(turn)
|
||||
|
||||
if self.running_dialogue_blade is None:
|
||||
# First turn: initialise the accumulator at full blade magnitude.
|
||||
self.running_dialogue_blade = blade.copy()
|
||||
else:
|
||||
alpha = cga_inner(self.running_dialogue_blade, blade)
|
||||
sign = 1.0 if alpha >= 0.0 else -1.0
|
||||
self.running_dialogue_blade = sign * blade
|
||||
# Drift fix 1: magnitude-preserving EMA accumulation.
|
||||
#
|
||||
# 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
|
||||
|
||||
@property
|
||||
|
|
@ -160,6 +215,29 @@ class SessionContext:
|
|||
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(
|
||||
self,
|
||||
result: GenerationResult,
|
||||
|
|
@ -185,7 +263,9 @@ class SessionContext:
|
|||
self._register_result_referent(result)
|
||||
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._anchor_pull(oriented_state)
|
||||
|
||||
self.graph.add_turn(
|
||||
turn_idx=self.turn,
|
||||
|
|
|
|||
Loading…
Reference in a new issue