""" Generation loop — token streaming from the versor manifold. Every token: nearest non-current word to current F via CGA inner product. Every step: F <- versor_apply(V, F) where V = word_transition_rotor(A, B). Generation is not a normalization boundary. Raw prompt normalization belongs at ingest/gate.py; construction normalization belongs in algebra/vocab/persona. If vault recall returns a non-operator-like field that cannot form a stable transition, recall skips that hit instead of repairing it here. """ from __future__ import annotations from collections import deque import numpy as np from field.state import FieldState from field.propagate import propagate_step from algebra.rotor import word_transition_rotor from generate.attention import AttentionOperator from generate.result import GenerationResult from generate.salience import SalienceOperator _RECENT_WINDOW = 3 _STOP_TOKENS = frozenset({"it", "to", "word"}) def _articulate(vocab, word: str) -> str: morphology_for_word = getattr(vocab, "morphology_for_word", None) if morphology_for_word is None: return word morphology = morphology_for_word(word) return morphology.surface if morphology is not None else word def _nearest_next( vocab, F_voiced, current_node: int, recent_nodes: tuple[int, ...] = (), stop_nodes: frozenset[int] = frozenset(), candidate_indices: np.ndarray | None = None, ) -> tuple[str, int]: if len(vocab) <= 1: return vocab.nearest(F_voiced, candidate_indices=candidate_indices) recent = set(recent_nodes) stop = set(stop_nodes) fallback_orders = ( recent | stop, stop, recent, set(), ) for extra in fallback_orders: try: return _nearest_with_optional_candidates( vocab, F_voiced, current_node, extra, candidate_indices, ) except ValueError: continue return _nearest_with_optional_candidates( vocab, F_voiced, -1, set(), candidate_indices, ) def _nearest_with_optional_candidates( vocab, F_voiced, current_node: int, exclude_indices: set[int], candidate_indices: np.ndarray | None, ) -> tuple[str, int]: try: return vocab.nearest( F_voiced, exclude_idx=current_node, exclude_indices=exclude_indices, candidate_indices=candidate_indices, ) except TypeError: if candidate_indices is not None: raise return vocab.nearest( F_voiced, exclude_idx=current_node, exclude_indices=exclude_indices, ) def _voiced_state(state: FieldState, persona) -> FieldState: return FieldState( F=persona.apply(state.F), node=state.node, step=state.step, holonomy=state.holonomy, energy=state.energy, valence=state.valence, ) def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int]: if vault is None or top_k <= 0: return state, 0 current = state hits_applied = 0 for hit in vault.recall(current.F, top_k=top_k): 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( 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 def _candidate_indices_for_language(vocab, output_lang: str | None) -> np.ndarray | None: if output_lang is None: return None indices_for_language = getattr(vocab, "indices_for_language", None) if indices_for_language is None: return None indices = indices_for_language(output_lang) if len(indices) == 0: raise ValueError(f"No generation candidates for output language {output_lang!r}.") return indices def _intersect_candidates(a: np.ndarray | None, b: np.ndarray | None) -> np.ndarray | None: if a is None: return b if b is None: return a if len(a) == 0 or len(b) == 0: return np.asarray([], dtype=np.int64) b_set = {int(idx) for idx in b} return np.asarray([int(idx) for idx in a if int(idx) in b_set], dtype=np.int64) def _attention_candidates( state: FieldState, vocab, use_salience: bool, salience_top_k: int, inhibition_threshold: float, ) -> tuple[np.ndarray | None, int | None, int | None]: if not use_salience: return None, None, None salience = SalienceOperator().compute(state, vocab, top_k=salience_top_k) attention = AttentionOperator(inhibition_threshold).plan(salience, vocab) return attention.allowed_indices, salience.budget, len(attention.allowed_indices) def generate( state: FieldState, vocab, persona, max_tokens: int = 128, record_trajectory: bool = False, vault=None, recall_top_k: int = 3, output_lang: str | None = None, allow_cross_language_generation: bool = True, use_salience: bool = False, salience_top_k: int = 16, inhibition_threshold: float = 0.3, ) -> GenerationResult: tokens = [] trajectory = [] if record_trajectory else None vault_hits = 0 current = state recent_nodes = deque([state.node], maxlen=_RECENT_WINDOW) language_candidates = None if allow_cross_language_generation else _candidate_indices_for_language(vocab, output_lang) salience_candidates, salience_budget, candidates_used = _attention_candidates( state, vocab, use_salience=use_salience, salience_top_k=salience_top_k, inhibition_threshold=inhibition_threshold, ) candidate_indices = _intersect_candidates(language_candidates, salience_candidates) if candidate_indices is not None and len(candidate_indices) == 0: candidate_indices = salience_candidates if salience_candidates is not None else language_candidates 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))} ) token_budget = min(max_tokens, int(candidates_used)) if candidates_used is not None else max_tokens for _ in range(token_budget): current, hits_applied = _recall_state(_voiced_state(current, persona), vault, recall_top_k) vault_hits += hits_applied word, word_idx = _nearest_next( vocab, current.F, current.node, recent_nodes=tuple(recent_nodes), stop_nodes=stop_nodes, candidate_indices=candidate_indices, ) tokens.append(_articulate(vocab, word)) if record_trajectory: trajectory.append(current) A = vocab.get_versor_at(current.node) B = vocab.get_versor_at(word_idx) V = word_transition_rotor(A, B) current = propagate_step(current, V) current = FieldState( F=current.F, node=word_idx, step=current.step, holonomy=current.holonomy, energy=current.energy, valence=current.valence, ) recent_nodes.append(word_idx) return GenerationResult( tokens=tokens, final_state=current, trajectory=trajectory, salience_top_k=salience_budget, candidates_used=candidates_used, vault_hits=vault_hits, ) async def agenerate( state: FieldState, vocab, persona, max_tokens: int = 128, vault=None, recall_top_k: int = 3, ): 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): current, _hits_applied = _recall_state( _voiced_state(current, persona), vault, recall_top_k, ) word, word_idx = _nearest_next( vocab, current.F, current.node, recent_nodes=tuple(recent_nodes), stop_nodes=stop_nodes, ) yield _articulate(vocab, word) A = vocab.get_versor_at(current.node) B = vocab.get_versor_at(word_idx) V = word_transition_rotor(A, B) current = propagate_step(current, V) current = FieldState( F=current.F, node=word_idx, step=current.step, holonomy=current.holonomy, energy=current.energy, valence=current.valence, ) recent_nodes.append(word_idx)