From 922bddc6eccfcab2d131b6b71408b780f56013fa Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 16 May 2026 09:03:56 -0700 Subject: [PATCH] fix(drift): address all 3 drift entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- generate/stream.py | 63 ++++++++++++++++++++++++++++--- session/context.py | 92 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/generate/stream.py b/generate/stream.py index b88fad0b..52f42ce0 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -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 diff --git a/session/context.py b/session/context.py index 121ff344..bea90ca8 100644 --- a/session/context.py +++ b/session/context.py @@ -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,