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]:
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue