391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""
|
|
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).
|
|
|
|
Architectural boundaries enforced here:
|
|
- VocabManifold owns manifold points only (get_versor_at, nearest).
|
|
- algebra.rotor.word_transition_rotor constructs the transition operator.
|
|
- Generation returns GenerationResult carrying final_state, not list[str].
|
|
- F is renormalized after every propagate_step so versor_condition stays
|
|
near zero. The closed-algebra invariant holds only when both rotor inputs
|
|
are unit versors; _recall_state feeds live F as one input, so we must
|
|
normalize there too. See ADR note below.
|
|
|
|
ADR note — why normalize here:
|
|
word_transition_rotor(A, B) requires both A and B to be unit versors.
|
|
Inside the main loop A is always vocab.get_versor_at(node) (safe).
|
|
Inside _recall_state A is current.F which drifts under repeated
|
|
sandwiching. Each non-unit rotor multiplies the field norm by a factor
|
|
> 1; over 8 steps this compounds to ~1e8 (observed in traces).
|
|
Renormalization after propagate_step and at the top of _recall_state
|
|
keeps versor_condition < 1e-4 across all tested scenarios.
|
|
|
|
No confidence gates. No IDK fallback. No attractor clamping.
|
|
"""
|
|
|
|
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 algebra.versor import unitize_versor
|
|
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 _renorm(state: FieldState) -> FieldState:
|
|
"""
|
|
Return state with F renormalized to unit versor norm.
|
|
|
|
This is called after every propagate_step to keep F on the manifold.
|
|
If F is already unit (norm within 1e-9 of 1.0) the copy is skipped and
|
|
the original state is returned unchanged.
|
|
"""
|
|
norm = float(np.linalg.norm(state.F))
|
|
if norm < 1e-12:
|
|
return state
|
|
if abs(norm - 1.0) < 1e-9:
|
|
return state
|
|
return FieldState(
|
|
F=state.F / norm,
|
|
node=state.node,
|
|
step=state.step,
|
|
holonomy=state.holonomy,
|
|
energy=state.energy,
|
|
valence=state.valence,
|
|
)
|
|
|
|
|
|
def _articulate(vocab, word: str) -> str:
|
|
"""
|
|
Recover the emitted surface through MorphologyEntry when available.
|
|
|
|
The manifold walk selects a vocabulary point. Articulation then returns
|
|
the structured surface carried by that point, preserving script and
|
|
inflection without introducing a corrective pass.
|
|
"""
|
|
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]:
|
|
"""
|
|
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.
|
|
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.
|
|
|
|
If attention/language filtering leaves only the current node available,
|
|
the final fallback deliberately permits that singleton candidate instead
|
|
of crashing. That keeps inhibition fail-closed to the attended region.
|
|
"""
|
|
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:
|
|
"""Compose the session persona motor into the live field path."""
|
|
return _renorm(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) -> FieldState:
|
|
"""
|
|
Feed exact vault recall back into the field as sequential operators.
|
|
|
|
Recall returns stored versors ranked by the vault's exact metric. Each hit
|
|
is treated as an additional operator in the propagation path.
|
|
|
|
IMPORTANT: current.F must be unit before passing to word_transition_rotor
|
|
as input A. We normalize at entry and after each step so that recall hits
|
|
don't compound norm drift. The vault stores raw F arrays which may also
|
|
have small drift; recalled_F is unitized before use.
|
|
"""
|
|
if vault is None or top_k <= 0:
|
|
return state
|
|
|
|
current = _renorm(state)
|
|
for hit in vault.recall(current.F, top_k=top_k):
|
|
recalled_F = np.asarray(hit["versor"], dtype=np.float64)
|
|
r_norm = float(np.linalg.norm(recalled_F))
|
|
if r_norm > 1e-12:
|
|
recalled_F = recalled_F / r_norm
|
|
V = word_transition_rotor(current.F, recalled_F)
|
|
current = _renorm(propagate_step(current, V))
|
|
current = FieldState(
|
|
F=current.F,
|
|
node=state.node,
|
|
step=current.step,
|
|
holonomy=state.holonomy,
|
|
energy=state.energy,
|
|
valence=state.valence,
|
|
)
|
|
return current
|
|
|
|
|
|
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:
|
|
"""
|
|
Generate a token sequence from an initial FieldState.
|
|
|
|
Loop:
|
|
1. Compose the persistent persona motor into the current field
|
|
2. Propagate exact vault recall hits into the current field
|
|
3. Find nearest non-current vocab node via CGA inner product
|
|
4. Emit token
|
|
5. Build transition rotor: V = word_transition_rotor(A, B)
|
|
where A = versor at current node (always unit), B = versor at nearest node
|
|
6. Propagate: F <- versor_apply(V, F)
|
|
7. Renormalize F to keep it on the manifold (versor_condition < 1e-4)
|
|
8. Advance node pointer
|
|
|
|
Returns:
|
|
GenerationResult with tokens, final_state, optional trajectory,
|
|
and salience telemetry when attention is enabled.
|
|
"""
|
|
tokens = []
|
|
trajectory = [] if record_trajectory else None
|
|
current = _renorm(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 = _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,
|
|
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 = _renorm(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,
|
|
)
|
|
|
|
|
|
async def agenerate(
|
|
state: FieldState,
|
|
vocab,
|
|
persona,
|
|
max_tokens: int = 128,
|
|
vault=None,
|
|
recall_top_k: int = 3,
|
|
):
|
|
"""
|
|
Async streaming version — yields one token at a time.
|
|
|
|
Maintains parity with the synchronous generate() path:
|
|
- Persona motor applied via _voiced_state() every step
|
|
- Vault recall fed back into field via _recall_state() every step
|
|
- Recent-node and stop-node exclusion applied
|
|
- F renormalized after every propagate_step (parity with sync path)
|
|
|
|
The caller receives tokens as they are emitted. For the full
|
|
GenerationResult (final_state, trajectory), use the synchronous
|
|
generate() path or wrap this generator in an async collector.
|
|
|
|
Yields: str (one token per iteration)
|
|
"""
|
|
current = _renorm(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 = _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 = _renorm(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)
|