From d997b88d328d851e173ef78e540eb3151b7fc164 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 13 May 2026 14:35:31 -0700 Subject: [PATCH] Tighten session node tracking and generation selection --- chat/runtime.py | 8 ---- generate/stream.py | 68 +++++++++++++++++++++++++++++---- session/context.py | 9 ++++- tests/test_engine_loop_proof.py | 1 + tests/test_generate_stream.py | 54 ++++++++++++++++++++++++++ vocab/manifold.py | 17 +++++++-- 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 tests/test_generate_stream.py diff --git a/chat/runtime.py b/chat/runtime.py index cf286670..4792b068 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -4,7 +4,6 @@ import re from language_packs import OOVPolicy, load_pack, load_pack_entries from persona.motor import PersonaMotor -from field.state import FieldState from session.context import SessionContext _TOKEN_RE = re.compile(r"\w+", re.UNICODE) @@ -58,13 +57,6 @@ class ChatRuntime: if not filtered: return "" self._context.ingest(filtered) - node_idx = self._context.vocab.index_of(filtered[0]) - self._context.state = FieldState( - F=self._context.state.F, - node=node_idx, - step=self._context.state.step, - holonomy=self._context.state.holonomy, - ) result = self._context.respond(max_tokens=max_tokens) guarded = self._syntactic_guard(result.tokens) return " ".join(guarded) diff --git a/generate/stream.py b/generate/stream.py index 98decffe..be376b11 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -16,22 +16,50 @@ F is always on the manifold. nearest() is exact. """ from __future__ import annotations +from collections import deque + from field.state import FieldState from field.propagate import propagate_step from algebra.rotor import word_transition_rotor from generate.result import GenerationResult +_RECENT_WINDOW = 3 +_STOP_TOKENS = frozenset({"it", "to", "word"}) -def _nearest_next(vocab, F_voiced, current_node: int) -> tuple[str, int]: + +def _nearest_next( + vocab, + F_voiced, + current_node: int, + recent_nodes: tuple[int, ...] = (), + stop_nodes: frozenset[int] = frozenset(), +) -> tuple[str, int]: """ - Select the nearest non-current vocabulary point when possible. + Select the nearest vocabulary point while avoiding short loops. Allowing the current node to win makes V = transition(A, A), which is an identity-like transition and can stall generation forever on one token. - VocabManifold already exposes exclude_idx for this exact seam. + Recent-node exclusion reduces two- and three-token attractor cycles. + Stop-node exclusion keeps function-word wells from dominating when more + informative neighbors are available. """ - exclude_idx = current_node if len(vocab) > 1 else -1 - return vocab.nearest(F_voiced, exclude_idx=exclude_idx) + if len(vocab) <= 1: + return vocab.nearest(F_voiced) + + recent = set(recent_nodes) + stop = set(stop_nodes) + fallback_orders = ( + recent | stop, + stop, + recent, + set(), + ) + for extra in fallback_orders: + try: + return vocab.nearest(F_voiced, exclude_idx=current_node, exclude_indices=extra) + except ValueError: + continue + return vocab.nearest(F_voiced, exclude_idx=current_node) def generate( @@ -59,10 +87,22 @@ def generate( tokens = [] trajectory = [] if record_trajectory else None 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))} + ) for _ in range(max_tokens): F_voiced = persona.apply(current.F) - word, word_idx = _nearest_next(vocab, F_voiced, current.node) + word, word_idx = _nearest_next( + vocab, + F_voiced, + current.node, + recent_nodes=tuple(recent_nodes), + stop_nodes=stop_nodes, + ) tokens.append(word) if record_trajectory: @@ -74,6 +114,7 @@ def generate( current = propagate_step(current, V) current = FieldState(F=current.F, node=word_idx, step=current.step, holonomy=current.holonomy) + recent_nodes.append(word_idx) return GenerationResult( tokens=tokens, @@ -99,9 +140,21 @@ async def agenerate( Yields: str (one token per iteration) """ 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))} + ) for _ in range(max_tokens): F_voiced = persona.apply(current.F) - word, word_idx = _nearest_next(vocab, F_voiced, current.node) + word, word_idx = _nearest_next( + vocab, + F_voiced, + current.node, + recent_nodes=tuple(recent_nodes), + stop_nodes=stop_nodes, + ) yield word A = vocab.get_versor_at(current.node) @@ -110,3 +163,4 @@ async def agenerate( current = propagate_step(current, V) current = FieldState(F=current.F, node=word_idx, step=current.step, holonomy=current.holonomy) + recent_nodes.append(word_idx) diff --git a/session/context.py b/session/context.py index 0f488b86..b963d878 100644 --- a/session/context.py +++ b/session/context.py @@ -30,7 +30,14 @@ class SessionContext: def ingest(self, tokens: list) -> FieldState: """Inject a prompt. Sets self.state. Stores the user field in vault.""" - self.state = inject(tokens, self.vocab) + state = inject(tokens, self.vocab) + node_idx = self.vocab.index_of(tokens[0]) + self.state = FieldState( + F=state.F, + node=node_idx, + step=state.step, + holonomy=state.holonomy, + ) self.vault.store(self.state.F, {"turn": self.turn, "role": "user"}) return self.state diff --git a/tests/test_engine_loop_proof.py b/tests/test_engine_loop_proof.py index e9311277..41475a06 100644 --- a/tests/test_engine_loop_proof.py +++ b/tests/test_engine_loop_proof.py @@ -95,6 +95,7 @@ def test_minimum_engine_loop_is_deterministic_and_stores_generated_state() -> No def test_session_context_respond_preserves_and_vaults_final_state() -> None: session = SessionContext(vocab=_minimal_vocab()) initial = session.ingest(["logos", "arche"]) + assert initial.node == session.vocab.index_of("logos") result = session.respond(max_tokens=3) diff --git a/tests/test_generate_stream.py b/tests/test_generate_stream.py new file mode 100644 index 00000000..f99598e5 --- /dev/null +++ b/tests/test_generate_stream.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from generate.stream import _nearest_next + + +class _StubVocab: + def __init__(self, words: list[str]): + self._words = words + self.calls: list[tuple[int, frozenset[int]]] = [] + + def __len__(self) -> int: + return len(self._words) + + def nearest(self, F, exclude_idx: int = -1, exclude_indices=None): + blocked = frozenset(exclude_indices or ()) + self.calls.append((exclude_idx, blocked)) + for idx, word in enumerate(self._words): + if idx == exclude_idx or idx in blocked: + continue + return word, idx + raise ValueError("No candidate word available after exclusions.") + + +def test_nearest_next_excludes_recent_and_stop_nodes_when_possible(): + vocab = _StubVocab(["seed", "to", "it", "meaning", "truth"]) + + word, idx = _nearest_next( + vocab, + F_voiced=None, + current_node=0, + recent_nodes=(3,), + stop_nodes=frozenset({1, 2}), + ) + + assert (word, idx) == ("truth", 4) + assert vocab.calls[0] == (0, frozenset({1, 2, 3})) + + +def test_nearest_next_relaxes_recent_window_before_stop_tokens(): + vocab = _StubVocab(["seed", "to", "truth"]) + + word, idx = _nearest_next( + vocab, + F_voiced=None, + current_node=0, + recent_nodes=(2,), + stop_nodes=frozenset({1}), + ) + + assert (word, idx) == ("truth", 2) + assert vocab.calls == [ + (0, frozenset({1, 2})), + (0, frozenset({1})), + ] diff --git a/vocab/manifold.py b/vocab/manifold.py index f6aa7133..e00b8a85 100644 --- a/vocab/manifold.py +++ b/vocab/manifold.py @@ -92,7 +92,12 @@ class VocabManifold: except ValueError: raise KeyError(f"Word '{word}' not in vocabulary.") - def nearest(self, F: np.ndarray, exclude_idx: int = -1) -> tuple[str, int]: + def nearest( + self, + F: np.ndarray, + exclude_idx: int = -1, + exclude_indices: set[int] | frozenset[int] | None = None, + ) -> tuple[str, int]: """ Find the word whose versor is closest to F by CGA inner product. Returns (word, index). O(|vocab|), exact, no approximation. @@ -100,15 +105,21 @@ class VocabManifold: Hot path: cga_inner routes through algebra.backend. """ + blocked = set(exclude_indices or ()) + if exclude_idx >= 0: + blocked.add(exclude_idx) + best_score = -np.inf - best_idx = 0 + best_idx = -1 for i, v in enumerate(self._versors): - if i == exclude_idx: + if i in blocked: continue score = cga_inner(F, v) if score > best_score: best_score = score best_idx = i + if best_idx < 0: + raise ValueError("No candidate word available after exclusions.") return self._words[best_idx], best_idx def __len__(self) -> int: