Adds referent tracking, session graph traversal, unknown-domain gating, correction propagation, compositional surface assembly, and regression coverage. Follow-up fixes included before merge: - split probe/commit/finalize turn flow so unknown-domain checks run before current-query vault writes - record real input tokens and input versors for sync and async session paths - return true graph distances from backward walks and consume them in correction decay - synchronize corrected graph outputs into vault-backed recall and live referent state - regenerate correction responses from corrected context rather than correction text - keep coreference pronouns lowercase in question bodies - centralize elaboration-string construction to avoid plan/surface drift - add targeted dialogue fluency regression tests
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""
|
|
SessionContext — binds field, vault, vocab, persona, referents, and graph.
|
|
|
|
The ingest path is split into a non-mutating probe and a committing ingest so
|
|
runtime gates can inspect the candidate field before durable vault writes. All
|
|
response paths finalize through one graph/vault/session-state method.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from algebra.backend import cga_inner, versor_apply
|
|
from algebra.cga import outer_product
|
|
from field.state import FieldState
|
|
from generate.dialogue import DialogueTurn
|
|
from generate.proposition import Proposition
|
|
from generate.result import GenerationResult
|
|
from generate.stream import generate
|
|
from ingest.gate import inject
|
|
from persona.motor import PersonaMotor
|
|
from session.graph import SessionGraph
|
|
from session.referents import ReferentRegistry
|
|
from vault.store import VaultStore
|
|
|
|
|
|
class SessionContext:
|
|
def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100):
|
|
self.vocab = vocab
|
|
self.persona = persona or PersonaMotor.identity()
|
|
self.vault = vault or VaultStore(reproject_interval=vault_reproject_interval)
|
|
self.state: FieldState | None = None
|
|
self.turn: int = 0
|
|
self.graph: SessionGraph = SessionGraph()
|
|
self.referents: ReferentRegistry = ReferentRegistry()
|
|
self.running_dialogue_blade: np.ndarray | None = None
|
|
self._last_response_tokens: tuple[str, ...] | None = None
|
|
self._anchor_field: np.ndarray | None = None
|
|
self._dialogue_history_compat: list[DialogueTurn] = []
|
|
self._last_input_tokens: tuple[str, ...] = ()
|
|
self._last_resolved_input_tokens: tuple[str, ...] = ()
|
|
self._last_input_versor: np.ndarray | None = None
|
|
|
|
@property
|
|
def dialogue_history(self) -> list[DialogueTurn]:
|
|
return self._dialogue_history_compat
|
|
|
|
@property
|
|
def last_input_tokens(self) -> tuple[str, ...]:
|
|
return self._last_input_tokens
|
|
|
|
@property
|
|
def last_resolved_input_tokens(self) -> tuple[str, ...]:
|
|
return self._last_resolved_input_tokens
|
|
|
|
def _field_from_tokens(self, tokens: list[str], *, resolve_referents: bool) -> tuple[FieldState, list[str]]:
|
|
resolved_tokens = self.referents.resolve(tokens) if resolve_referents else list(tokens)
|
|
injected = inject(resolved_tokens, self.vocab)
|
|
anchor_token = resolved_tokens[0] if resolved_tokens else (tokens[0] if tokens else "")
|
|
try:
|
|
node_idx = self.vocab.index_of(anchor_token)
|
|
except (KeyError, IndexError):
|
|
node_idx = self.vocab.index_of(tokens[0]) if tokens else 0
|
|
|
|
if self.state is None:
|
|
candidate = FieldState(
|
|
F=injected.F,
|
|
node=node_idx,
|
|
step=injected.step,
|
|
holonomy=injected.holonomy,
|
|
energy=injected.energy,
|
|
valence=injected.valence,
|
|
)
|
|
else:
|
|
candidate = FieldState(
|
|
F=versor_apply(injected.F, self.state.F),
|
|
node=node_idx,
|
|
step=self.state.step + 1,
|
|
holonomy=injected.holonomy,
|
|
energy=injected.energy,
|
|
valence=injected.valence,
|
|
)
|
|
return candidate, resolved_tokens
|
|
|
|
def probe_ingest(self, tokens: list[str]) -> FieldState:
|
|
"""Build the candidate ingest field without mutating state or vault."""
|
|
snapshot_sources = self.referents.consumed_turns()
|
|
snapshot_slots = self.referents.consumed_slots()
|
|
candidate, _ = self._field_from_tokens(tokens, resolve_referents=True)
|
|
# Restore consumed metadata because probe must not define graph edges.
|
|
self.referents._last_resolved_sources = snapshot_sources # internal rollback by design
|
|
self.referents._last_resolved_slots = snapshot_slots
|
|
return candidate
|
|
|
|
def commit_ingest(self, tokens: list[str]) -> FieldState:
|
|
"""Resolve, inject, mutate live state, and store the user field."""
|
|
field_state, resolved_tokens = self._field_from_tokens(tokens, resolve_referents=True)
|
|
self.state = field_state
|
|
if self._anchor_field is None:
|
|
self._anchor_field = field_state.F.copy()
|
|
self._last_input_tokens = tuple(tokens)
|
|
self._last_resolved_input_tokens = tuple(resolved_tokens)
|
|
self._last_input_versor = field_state.F.copy()
|
|
self.vault.store(field_state.F, {"turn": self.turn, "role": "user"})
|
|
return field_state
|
|
|
|
def ingest(self, tokens: list[str]) -> FieldState:
|
|
"""Backward-compatible committing ingest."""
|
|
return self.commit_ingest(tokens)
|
|
|
|
def record_dialogue(self, proposition: Proposition) -> DialogueTurn:
|
|
from generate.dialogue import DialogueTurn as _DT
|
|
|
|
blade = proposition.relation
|
|
turn = _DT(proposition=proposition, outer_product_blade=blade)
|
|
self._dialogue_history_compat.append(turn)
|
|
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)
|
|
return turn
|
|
|
|
@property
|
|
def last_dialogue_blade(self) -> np.ndarray | None:
|
|
if not self._dialogue_history_compat:
|
|
return None
|
|
return self._dialogue_history_compat[-1].outer_product_blade.copy()
|
|
|
|
def _register_result_referent(self, result: GenerationResult) -> None:
|
|
if not result.tokens:
|
|
return
|
|
versors: dict[str, np.ndarray] = {}
|
|
for tok in result.tokens:
|
|
try:
|
|
versors[tok] = self.vocab.get_versor(tok)
|
|
except KeyError:
|
|
pass
|
|
self.referents.register_from_tokens(result.tokens, versors, turn=self.turn)
|
|
|
|
def finalize_turn(
|
|
self,
|
|
result: GenerationResult,
|
|
*,
|
|
tokens_in: tuple[str, ...] | None = None,
|
|
dialogue_role: str = "assert",
|
|
input_versor: np.ndarray | None = None,
|
|
metadata: dict | None = None,
|
|
) -> None:
|
|
"""Finalize assistant output into referents, graph, vault, and state."""
|
|
if self.state is None and input_versor is None:
|
|
raise AssertionError("Call ingest() before finalize_turn().")
|
|
|
|
input_F = (
|
|
np.asarray(input_versor, dtype=np.float32).copy()
|
|
if input_versor is not None
|
|
else (self._last_input_versor.copy() if self._last_input_versor is not None else self.state.F.copy())
|
|
)
|
|
turn_tokens = tuple(tokens_in if tokens_in is not None else self._last_input_tokens)
|
|
backward_edges = self.referents.consumed_turns()
|
|
active_slots = self.referents.active_slots()
|
|
|
|
self._register_result_referent(result)
|
|
# Include any newly registered output referent in the turn metadata.
|
|
active_slots = self.referents.active_slots() | active_slots
|
|
|
|
self.graph.add_turn(
|
|
turn_idx=self.turn,
|
|
input_versor=input_F,
|
|
output_versor=result.final_state.F,
|
|
tokens_in=turn_tokens,
|
|
tokens_out=tuple(result.tokens or []),
|
|
dialogue_role=dialogue_role,
|
|
referent_slots=active_slots,
|
|
backward_edges=backward_edges,
|
|
)
|
|
self.state = result.final_state
|
|
payload = {"turn": self.turn, "role": "assistant"}
|
|
if metadata:
|
|
payload.update(metadata)
|
|
self.vault.store(result.final_state.F, payload)
|
|
self.turn += 1
|
|
self._last_response_tokens = result.tokens
|
|
|
|
def apply_corrected_outputs(self, records) -> None:
|
|
"""Synchronize corrected graph records into live session recall surfaces."""
|
|
for record in records:
|
|
self.vault.store(
|
|
record.new_versor,
|
|
{"turn": record.turn_idx, "role": "assistant", "corrected": True},
|
|
)
|
|
self.referents.update_turn_versor(record.turn_idx, record.new_versor)
|
|
if records:
|
|
last = max(records, key=lambda r: r.turn_idx)
|
|
if self.state is not None:
|
|
self.state = FieldState(
|
|
F=last.new_versor,
|
|
node=self.state.node,
|
|
step=self.state.step,
|
|
holonomy=self.state.holonomy,
|
|
energy=self.state.energy,
|
|
valence=self.state.valence,
|
|
)
|
|
|
|
def respond(self, max_tokens: int = 128) -> GenerationResult:
|
|
assert self.state is not None, "Call ingest() before respond()."
|
|
input_versor = self._last_input_versor.copy() if self._last_input_versor is not None else self.state.F.copy()
|
|
result = generate(self.state, self.vocab, self.persona, max_tokens, vault=self.vault)
|
|
if self._last_response_tokens is not None and result.tokens == self._last_response_tokens and result.tokens:
|
|
try:
|
|
pivot_node = self.vocab.index_of(result.tokens[0])
|
|
except KeyError:
|
|
pivot_node = self.state.node
|
|
if pivot_node != self.state.node:
|
|
pivot = FieldState(
|
|
F=self.state.F,
|
|
node=pivot_node,
|
|
step=self.state.step,
|
|
holonomy=self.state.holonomy,
|
|
energy=self.state.energy,
|
|
valence=self.state.valence,
|
|
)
|
|
result = generate(pivot, self.vocab, self.persona, max_tokens, vault=self.vault)
|
|
result = self._orient_result_to_anchor(result)
|
|
self.finalize_turn(result, input_versor=input_versor, dialogue_role="assert")
|
|
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 self.state is not None else None)
|
|
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,
|
|
step=final_state.step,
|
|
holonomy=final_state.holonomy,
|
|
energy=final_state.energy,
|
|
valence=final_state.valence,
|
|
)
|
|
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,
|
|
)
|
|
return result
|
|
|
|
async def arespond(self, max_tokens: int = 128):
|
|
assert self.state is not None, "Call ingest() before arespond()."
|
|
input_versor = self._last_input_versor.copy() if self._last_input_versor is not None else self.state.F.copy()
|
|
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.finalize_turn(result, input_versor=input_versor, dialogue_role="assert")
|
|
|
|
def recall(self, query_tokens: list, top_k: int = 5) -> list:
|
|
query_state = inject(query_tokens, self.vocab)
|
|
return self.vault.recall(query_state.F, top_k=top_k)
|