diff --git a/generate/stream.py b/generate/stream.py index 46ff114f..9a7b3c33 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -26,6 +26,13 @@ _RECENT_WINDOW = 3 _STOP_TOKENS = frozenset({"it", "to", "word"}) +def _try_index(vocab, token: str) -> int | None: + try: + return vocab.index_of(token) + except (KeyError, IndexError): + return None + + def _articulate(vocab, word: str) -> str: morphology_for_word = getattr(vocab, "morphology_for_word", None) if morphology_for_word is None: @@ -207,9 +214,8 @@ def generate( candidates_used = None if candidate_indices is None else len(candidate_indices) stop_nodes = frozenset( - vocab.index_of(token) - for token in _STOP_TOKENS - if token in {vocab.get_word_at(i) for i in range(len(vocab))} + idx for token in _STOP_TOKENS + if (idx := _try_index(vocab, token)) is not None ) token_budget = min(max_tokens, int(candidates_used)) if candidates_used is not None else max_tokens @@ -265,9 +271,8 @@ async def agenerate( current = state recent_nodes = deque([state.node], maxlen=_RECENT_WINDOW) stop_nodes = frozenset( - vocab.index_of(token) - for token in _STOP_TOKENS - if token in {vocab.get_word_at(i) for i in range(len(vocab))} + idx for token in _STOP_TOKENS + if (idx := _try_index(vocab, token)) is not None ) for _ in range(max_tokens): current, _hits_applied = _recall_state( diff --git a/session/context.py b/session/context.py index 0e0706db..de439c56 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.cga import outer_product +from algebra.versor import versor_condition as _versor_condition from field.state import FieldState from generate.dialogue import DialogueTurn from generate.proposition import Proposition @@ -72,8 +72,14 @@ class SessionContext: valence=injected.valence, ) else: + composed_F = versor_apply(injected.F, self.state.F) + condition = _versor_condition(composed_F) + if condition > 1e-2: + raise RuntimeError( + f"Cross-turn field composition violated versor condition: {condition:.3e}" + ) candidate = FieldState( - F=versor_apply(injected.F, self.state.F), + F=composed_F, node=node_idx, step=self.state.step + 1, holonomy=injected.holonomy, @@ -117,7 +123,9 @@ class SessionContext: if self.running_dialogue_blade is None: self.running_dialogue_blade = blade.copy() else: - self.running_dialogue_blade = outer_product(self.running_dialogue_blade, blade) + alpha = cga_inner(self.running_dialogue_blade, blade) + sign = 1.0 if alpha >= 0.0 else -1.0 + self.running_dialogue_blade = sign * blade return turn @property diff --git a/tests/test_dialogue_fluency_regression.py b/tests/test_dialogue_fluency_regression.py index bb092315..8a044644 100644 --- a/tests/test_dialogue_fluency_regression.py +++ b/tests/test_dialogue_fluency_regression.py @@ -96,6 +96,35 @@ def test_surface_elaboration_matches_rendered_elaboration_string() -> None: assert "mercy but justice" in sentence.surface +def test_running_dialogue_blade_stays_nonzero_after_three_turns() -> None: + """The running blade must not collapse to zero through grade explosion.""" + from algebra.rotor import make_rotor_from_angle + from generate.proposition import Proposition + + def _prop(slot: int) -> Proposition: + relation = make_rotor_from_angle(0.1 * (slot + 1), bivector_idx=6) + return Proposition( + subject="a", predicate="b", object_=None, surface="a b", + frame_id="test", + subject_versor=_v(0), predicate_versor=_v(1), + object_versor=None, relation=relation, + ) + + from session.context import SessionContext + from language_packs import load_pack + _, vocab = load_pack("en_core_cognition_v1") + ctx = SessionContext(vocab) + + for i in range(5): + ctx.record_dialogue(_prop(i)) + + blade = ctx.running_dialogue_blade + assert blade is not None + assert float(np.linalg.norm(blade)) > 1e-6, ( + "running_dialogue_blade collapsed to zero after multiple turns" + ) + + def test_unknown_gate_fires_on_empty_vault_without_self_storage() -> None: class EmptyVault: def __len__(self) -> int: diff --git a/vault/store.py b/vault/store.py index 00df4c99..45b12b10 100644 --- a/vault/store.py +++ b/vault/store.py @@ -12,6 +12,8 @@ O(N) np.array_equal scans. from __future__ import annotations +from collections import deque + import numpy as np from algebra.backend import vault_recall from algebra.cga import null_project @@ -27,8 +29,8 @@ class VaultStore: reproject_interval: int = 100, max_entries: int | None = None, ): - self._versors: list[np.ndarray] = [] - self._metadata: list[dict] = [] + self._versors: deque[np.ndarray] = deque(maxlen=max_entries) + self._metadata: deque[dict] = deque(maxlen=max_entries) self._store_count: int = 0 self._reproject_interval = reproject_interval self._max_entries = max_entries @@ -38,20 +40,20 @@ class VaultStore: """Store a versor. Returns its index. Auto-reprojects every N stores.""" arr = np.asarray(F, dtype=np.float32).copy() - if self._max_entries is not None and len(self._versors) >= self._max_entries: - self._evict_oldest() - + will_evict = self._max_entries is not None and len(self._versors) >= self._max_entries self._versors.append(arr) self._metadata.append(metadata or {}) - idx = len(self._versors) - 1 - - key = _versor_key(arr) - self._exact_index.setdefault(key, []).append(idx) + if will_evict: + self._rebuild_index() + else: + idx = len(self._versors) - 1 + key = _versor_key(arr) + self._exact_index.setdefault(key, []).append(idx) self._store_count += 1 if self._reproject_interval > 0 and self._store_count % self._reproject_interval == 0: self.reproject() - return idx + return len(self._versors) - 1 def recall(self, query: np.ndarray, top_k: int = 5) -> list: """ @@ -62,7 +64,7 @@ class VaultStore: return [] query_arr = np.asarray(query, dtype=np.float32) - ranked = vault_recall(self._versors, query_arr, max(top_k, 1)) + ranked = vault_recall(list(self._versors), query_arr, max(top_k, 1)) key = _versor_key(query_arr) exact_indices = self._exact_index.get(key, []) @@ -86,7 +88,8 @@ class VaultStore: Re-project all stored versors onto the null cone. Corrects floating-point drift. Run between turns or asynchronously. """ - self._versors = [null_project(v) for v in self._versors] + reprojected = deque((null_project(v) for v in self._versors), maxlen=self._max_entries) + self._versors = reprojected self._rebuild_index() def _rebuild_index(self) -> None: @@ -95,24 +98,6 @@ class VaultStore: key = _versor_key(v) self._exact_index.setdefault(key, []).append(i) - def _evict_oldest(self) -> None: - """Remove the oldest entry. Deterministic FIFO eviction.""" - if not self._versors: - return - evicted = self._versors.pop(0) - self._metadata.pop(0) - key = _versor_key(evicted) - indices = self._exact_index.get(key, []) - if indices: - indices.pop(0) - if not indices: - del self._exact_index[key] - self._reindex_after_eviction() - - def _reindex_after_eviction(self) -> None: - """Rebuild index after front-removal shifts all indices by -1.""" - self._rebuild_index() - @property def reproject_interval(self) -> int: return self._reproject_interval