diff --git a/chat/runtime.py b/chat/runtime.py index 45fc3e8a..e6e807f8 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -40,6 +40,8 @@ _SEED_ALIASES = { "arche": "ἀρχή", "aletheia": "ἀλήθεια", } +_QUESTION_WORDS = frozenset({"what", "who", "how", "why", "when", "where", "which"}) +_TERMINALS = frozenset({".", "?", ";", "!"}) def _energy_scalar(energy_obj) -> float: @@ -54,6 +56,33 @@ def _energy_scalar(energy_obj) -> float: return 1.0 +def _is_question_input(raw_text: str, tokens: Sequence[str]) -> bool: + if raw_text.strip().endswith("?"): + return True + return bool(tokens and tokens[0].casefold() in _QUESTION_WORDS) + + +def _stable_dialogue_role(role: DialogueRole, *, raw_text: str, tokens: Sequence[str]) -> DialogueRole: + if role == "question" and not _is_question_input(raw_text, tokens): + return "elaborate" + return role + + +def _terminal_for_role(role: DialogueRole, output_language: str) -> str: + if role == "question": + return ";" if output_language == "grc" else "?" + return "." + + +def _terminate_surface(surface: str, *, role: DialogueRole, output_language: str) -> str: + stripped = surface.strip() + if not stripped: + return stripped + if stripped[-1] in _TERMINALS: + return stripped + return f"{stripped}{_terminal_for_role(role, output_language)}" + + @dataclass class _StubBindingFrame: frame_id: str @@ -272,9 +301,10 @@ class ChatRuntime: self._frame_registry, output_lang=self.config.output_language, ) - dialogue_role = classify_dialogue_blade( - base_proposition.relation, - reference_blade, + dialogue_role = _stable_dialogue_role( + classify_dialogue_blade(base_proposition.relation, reference_blade), + raw_text=text, + tokens=tokens, ) proposition = propose_dialogue( field_state, @@ -346,7 +376,12 @@ class ChatRuntime: ) walk_surface = sentence_plan.surface - surface = articulation.surface + surface = _terminate_surface( + articulation.surface, + role=dialogue_role, + output_language=self.config.output_language, + ) + articulation_surface = surface vault_hits = int(result.vault_hits) turn_event = TurnEvent( @@ -354,7 +389,7 @@ class ChatRuntime: input_tokens=tuple(filtered), surface=surface, walk_surface=walk_surface, - articulation_surface=articulation.surface, + articulation_surface=articulation_surface, dialogue_role=str(dialogue_role), identity_score=identity_score, cycle_cost_total=cycle_cost.total, @@ -369,7 +404,7 @@ class ChatRuntime: surface=surface, proposition=proposition, articulation=articulation, - articulation_surface=articulation.surface, + articulation_surface=articulation_surface, dialogue_role=dialogue_role, versor_condition=versor_condition(result.final_state.F), output_language=self.config.output_language, diff --git a/generate/proposition.py b/generate/proposition.py index 57e86d66..f75161a9 100644 --- a/generate/proposition.py +++ b/generate/proposition.py @@ -153,9 +153,6 @@ def propose( preferred_pos=frozenset({"noun", "pronoun"}), candidate_indices=candidate_indices, ) - # Predicate selection must remain anchored to the prompt field, not a - # recall-contaminated or drive-biased current field, so slot evidence stays - # closer to prompt than unrelated vault points. predicate_word, predicate_idx = _nearest_content_word( vocab, prompt, @@ -275,7 +272,7 @@ def _first_existing(vocab, candidates: tuple[str, ...]) -> str | None: def _prompt_versor(field_state: FieldState) -> np.ndarray: - return field_state.holonomy if field_state.holonomy is not None else field_state.F + return field_state.F def _nearest_content_word( diff --git a/generate/stream.py b/generate/stream.py index 97b528d6..46ff114f 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -3,6 +3,11 @@ 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 @@ -13,7 +18,6 @@ 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 normalize_to_versor, unitize_versor from generate.attention import AttentionOperator from generate.result import GenerationResult from generate.salience import SalienceOperator @@ -22,29 +26,6 @@ _RECENT_WINDOW = 3 _STOP_TOKENS = frozenset({"it", "to", "word"}) -def _closed_F(F: np.ndarray) -> np.ndarray: - arr = np.asarray(F, dtype=np.float64) - try: - return unitize_versor(arr) - except ValueError: - return normalize_to_versor(arr) - - -def _renorm(state: FieldState) -> FieldState: - """Return state with F reclosed onto the versor manifold.""" - closed = _closed_F(state.F) - if np.allclose(closed, state.F, atol=1e-12, rtol=1e-12): - return state - return FieldState( - F=closed, - node=state.node, - step=state.step, - holonomy=state.holonomy, - energy=state.energy, - valence=state.valence, - ) - - def _articulate(vocab, word: str) -> str: morphology_for_word = getattr(vocab, "morphology_for_word", None) if morphology_for_word is None: @@ -117,26 +98,33 @@ def _nearest_with_optional_candidates( def _voiced_state(state: FieldState, persona) -> FieldState: - return _renorm(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 = _renorm(state) + current = state hits_applied = 0 for hit in vault.recall(current.F, top_k=top_k): - recalled_F = _closed_F(np.asarray(hit["versor"], dtype=np.float64)) - V = word_transition_rotor(current.F, recalled_F) - current = _renorm(propagate_step(current, V)) + 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, @@ -203,7 +191,7 @@ def generate( tokens = [] trajectory = [] if record_trajectory else None vault_hits = 0 - current = _renorm(state) + 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( @@ -245,7 +233,7 @@ def generate( B = vocab.get_versor_at(word_idx) V = word_transition_rotor(A, B) - current = _renorm(propagate_step(current, V)) + current = propagate_step(current, V) current = FieldState( F=current.F, node=word_idx, @@ -258,7 +246,7 @@ def generate( return GenerationResult( tokens=tokens, - final_state=_renorm(current), + final_state=current, trajectory=trajectory, salience_top_k=salience_budget, candidates_used=candidates_used, @@ -274,7 +262,7 @@ async def agenerate( vault=None, recall_top_k: int = 3, ): - current = _renorm(state) + current = state recent_nodes = deque([state.node], maxlen=_RECENT_WINDOW) stop_nodes = frozenset( vocab.index_of(token) @@ -300,7 +288,7 @@ async def agenerate( B = vocab.get_versor_at(word_idx) V = word_transition_rotor(A, B) - current = _renorm(propagate_step(current, V)) + current = propagate_step(current, V) current = FieldState( F=current.F, node=word_idx, diff --git a/session/context.py b/session/context.py index 25b289f6..68283f41 100644 --- a/session/context.py +++ b/session/context.py @@ -110,9 +110,21 @@ class SessionContext: valence=self.state.valence, ) result = generate(pivot, self.vocab, self.persona, max_tokens, vault=self.vault) + result = self._orient_result_to_anchor(result) + self.state = result.final_state + self.vault.store(result.final_state.F, {"turn": self.turn, "role": "assistant"}) + self.turn += 1 + self._last_response_tokens = result.tokens + return result + + def _orient_result_to_anchor(self, result: GenerationResult) -> GenerationResult: final_state = result.final_state coherence_anchor = self._anchor_field if self._anchor_field is not None else self.state.F - if cga_inner(final_state.F, coherence_anchor) < 0.0: + if coherence_anchor is None: + return result + cga_score = cga_inner(final_state.F, coherence_anchor) + euclidean_score = float(np.dot(final_state.F, coherence_anchor)) + if cga_score < 0.0 or euclidean_score < 0.0: final_state = FieldState( F=-final_state.F, node=final_state.node, @@ -121,17 +133,15 @@ class SessionContext: energy=final_state.energy, valence=final_state.valence, ) - result = GenerationResult( + return GenerationResult( tokens=result.tokens, final_state=final_state, trajectory=result.trajectory, salience_top_k=result.salience_top_k, candidates_used=result.candidates_used, + vault_hits=result.vault_hits, + identity_score=result.identity_score, ) - self.state = result.final_state - self.vault.store(result.final_state.F, {"turn": self.turn, "role": "assistant"}) - self.turn += 1 - self._last_response_tokens = result.tokens return result async def arespond(self, max_tokens: int = 128): @@ -143,7 +153,9 @@ class SessionContext: yielding the surface tokens. """ assert self.state is not None, "Call ingest() before arespond()." - result = generate(self.state, self.vocab, self.persona, max_tokens, vault=self.vault) + result = self._orient_result_to_anchor( + generate(self.state, self.vocab, self.persona, max_tokens, vault=self.vault) + ) for token in result.tokens: yield token self.state = result.final_state