""" session/graph.py — SessionGraph Append-only DAG of dialogue turns. Backward edges point from a turn to prior turns whose output was consumed as a referent during ingest. Correction passes walk those edges with true BFS distance, not traversal ordinal. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Sequence import numpy as np @dataclass(slots=True) class TurnNode: turn_idx: int input_versor: np.ndarray output_versor: np.ndarray tokens_in: tuple[str, ...] tokens_out: tuple[str, ...] dialogue_role: str referent_slots: dict[str, int] backward_edges: list[int] = field(default_factory=list) def copy_with_output( self, new_output_versor: np.ndarray, new_tokens_out: tuple[str, ...] | None = None, ) -> "TurnNode": return TurnNode( turn_idx=self.turn_idx, input_versor=self.input_versor.copy(), output_versor=np.asarray(new_output_versor, dtype=np.float32).copy(), tokens_in=self.tokens_in, tokens_out=new_tokens_out if new_tokens_out is not None else self.tokens_out, dialogue_role=self.dialogue_role, referent_slots=dict(self.referent_slots), backward_edges=list(self.backward_edges), ) class SessionGraph: """Append-only directed graph of TurnNodes indexed by turn_idx.""" def __init__(self) -> None: self._nodes: list[TurnNode] = [] def add_turn( self, turn_idx: int, input_versor: np.ndarray, output_versor: np.ndarray, tokens_in: Sequence[str], tokens_out: Sequence[str], dialogue_role: str, referent_slots: dict[str, int] | None = None, backward_edges: list[int] | None = None, ) -> TurnNode: clean_edges = [ int(edge) for edge in dict.fromkeys(backward_edges or []) if 0 <= int(edge) < turn_idx ] node = TurnNode( turn_idx=turn_idx, input_versor=np.asarray(input_versor, dtype=np.float32).copy(), output_versor=np.asarray(output_versor, dtype=np.float32).copy(), tokens_in=tuple(tokens_in), tokens_out=tuple(tokens_out), dialogue_role=dialogue_role, referent_slots=dict(referent_slots or {}), backward_edges=clean_edges, ) if turn_idx != len(self._nodes): raise ValueError( f"turn_idx must append monotonically: got {turn_idx}, expected {len(self._nodes)}" ) self._nodes.append(node) return node def update_output( self, turn_idx: int, new_output_versor: np.ndarray, new_tokens_out: tuple[str, ...] | None = None, ) -> TurnNode: node = self._nodes[turn_idx] updated = node.copy_with_output(new_output_versor, new_tokens_out) self._nodes[turn_idx] = updated return updated def node_at(self, turn_idx: int) -> TurnNode: return self._nodes[turn_idx] def all_nodes(self) -> list[TurnNode]: return list(self._nodes) def predecessors_of(self, turn_idx: int) -> list[TurnNode]: node = self._nodes[turn_idx] return [self._nodes[i] for i in node.backward_edges if i < len(self._nodes)] def successors_of(self, turn_idx: int) -> list[TurnNode]: return [node for node in self._nodes if turn_idx in node.backward_edges] def backward_walk( self, from_turn: int, max_depth: int = 16, ) -> list[tuple[int, TurnNode]]: """ BFS backward walk following backward_edges. Returns ``(distance, node)`` tuples in BFS order, excluding from_turn. Multiple nodes at the same graph depth preserve the same distance. """ if from_turn < 0 or from_turn >= len(self._nodes): raise IndexError(f"from_turn out of range: {from_turn}") visited: set[int] = {from_turn} queue: list[tuple[int, int]] = [(1, idx) for idx in self._nodes[from_turn].backward_edges] result: list[tuple[int, TurnNode]] = [] while queue: distance, idx = queue.pop(0) if distance > max_depth: continue if idx in visited or idx >= len(self._nodes) or idx < 0: continue visited.add(idx) node = self._nodes[idx] result.append((distance, node)) queue.extend((distance + 1, parent) for parent in node.backward_edges) return result def __len__(self) -> int: return len(self._nodes) def __repr__(self) -> str: return f"SessionGraph(turns={len(self._nodes)})"