core/session/referents.py
Shay 5feedcebd9 feat(persistence): Shape B+ Phase C — SessionContext.snapshot/restore (full lived state)
Composes the FieldState (A) and VaultStore (B) codecs with new codecs for
SessionGraph/TurnNode, ReferentRegistry/ReferentEntry, Proposition, and
DialogueTurn into SessionContext.snapshot()/restore() — the complete lived
session state that must survive reboot for resume-as-same-life.

- session/graph.py: TurnNode + SessionGraph to_dict/from_dict (versors bit-exact).
- session/referents.py: ReferentEntry + ReferentRegistry, preserving the
  _slots<->_history object aliasing via slot->history-index (update_turn_versor
  relies on `is` identity).
- generate/proposition.py + generate/dialogue.py: Proposition + DialogueTurn
  codecs (relation_norm is derived in __post_init__, not persisted).
- vault/store.py: complete the metadata codec — vault metadata can hold a
  Proposition ({"kind":"proposition",...} from generate/proposition.py), tagged
  on encode and reconstructed on decode (lazy import, cycle-free). This closes a
  gap Phase B assumed away ("metadata is primitives only"); surfaced by the
  Phase C JSON-safe integration test.
- session/context.py: snapshot()/restore(). vocab/persona are NOT serialized
  (shared, supplied at restore); restore() mutates self by design (a load).

Exit gate: a real 4-turn session, snapshotted and restored into a fresh context,
is field-equal — field bit-exact, vault recall identical, graph/referents/
dialogue preserved (incl. the referent aliasing). 9 new tests; INV-02 +
session-coherence regression green (68 passed).

Part of the A->E Shape B+ scope (Phase C).
2026-06-05 12:13:46 -07:00

205 lines
6.8 KiB
Python

"""
session/referents.py — ReferentRegistry
Tracks active discourse referents across turns so incoming pronoun tokens can
be resolved before field propagation. Resolution also records which referent
turns were consumed by the most recent ingest, giving SessionGraph truthful
backward edges instead of broad historical guesses.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Sequence
import numpy as np
from core.array_codec import decode_array, encode_array
_PRONOUN_SLOTS: dict[str, str] = {
"it": "neut_sg",
"this": "neut_sg",
"that": "neut_sg",
"its": "neut_sg",
"they": "plural",
"them": "plural",
"their": "plural",
"these": "plural",
"those": "plural",
"he": "masc_sg",
"him": "masc_sg",
"his": "masc_sg",
"she": "fem_sg",
"her": "fem_sg",
"hers": "fem_sg",
}
_SLOT_NAMES: frozenset[str] = frozenset(_PRONOUN_SLOTS.values())
@dataclass(slots=True)
class ReferentEntry:
surface: str
versor: np.ndarray
turn: int
slot: str
def to_dict(self) -> dict[str, Any]:
return {
"surface": self.surface,
"versor": encode_array(self.versor),
"turn": int(self.turn),
"slot": self.slot,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "ReferentEntry":
return cls(
surface=payload["surface"],
versor=decode_array(payload["versor"]),
turn=int(payload["turn"]),
slot=payload["slot"],
)
class ReferentRegistry:
"""Per-session registry of active discourse referents."""
def __init__(self) -> None:
self._slots: dict[str, ReferentEntry] = {}
self._history: list[ReferentEntry] = []
self._last_resolved_sources: list[int] = []
self._last_resolved_slots: dict[str, int] = {}
def register(
self,
surface: str,
versor: np.ndarray,
turn: int,
slot: str = "neut_sg",
) -> None:
"""Register a noun as the active referent for *slot*."""
if slot not in _SLOT_NAMES:
raise ValueError(f"Unknown referent slot: {slot!r}. Valid: {_SLOT_NAMES}")
entry = ReferentEntry(
surface=surface,
versor=np.asarray(versor, dtype=np.float32).copy(),
turn=turn,
slot=slot,
)
self._slots[slot] = entry
self._history.append(entry)
def register_from_tokens(
self,
tokens: Sequence[str],
versors: dict[str, np.ndarray],
turn: int,
slot: str = "neut_sg",
) -> None:
"""Register the last token that has a supplied versor."""
for tok in reversed(tokens):
if tok in versors:
self.register(tok, versors[tok], turn=turn, slot=slot)
return
def update_turn_versor(self, turn: int, versor: np.ndarray) -> None:
"""Synchronize active/history entries for a corrected source turn."""
arr = np.asarray(versor, dtype=np.float32).copy()
for entry in self._history:
if entry.turn == turn:
entry.versor = arr.copy()
if self._slots.get(entry.slot) is entry:
self._slots[entry.slot] = entry
def resolve(self, tokens: Sequence[str]) -> list[str]:
"""
Replace anaphoric pronouns with the surface form of the active
referent in the matching slot. Unresolved pronouns are kept as-is.
"""
out: list[str] = []
sources: list[int] = []
slots: dict[str, int] = {}
for tok in tokens:
slot = _PRONOUN_SLOTS.get(tok.casefold())
if slot is None:
out.append(tok)
continue
entry = self._slots.get(slot)
if entry is None:
out.append(tok)
continue
out.append(entry.surface)
sources.append(entry.turn)
slots[slot] = entry.turn
self._last_resolved_sources = list(dict.fromkeys(sources))
self._last_resolved_slots = dict(slots)
return out
def resolve_versor(self, token: str) -> np.ndarray | None:
slot = _PRONOUN_SLOTS.get(token.casefold())
if slot is None:
return None
entry = self._slots.get(slot)
return entry.versor.copy() if entry is not None else None
def consumed_turns(self) -> list[int]:
"""Turn indices consumed by the most recent resolve() call."""
return list(self._last_resolved_sources)
def consumed_slots(self) -> dict[str, int]:
"""slot → source turn consumed by the most recent resolve() call."""
return dict(self._last_resolved_slots)
def active_referent(self, slot: str = "neut_sg") -> ReferentEntry | None:
return self._slots.get(slot)
def active_slots(self) -> dict[str, int]:
return {slot: entry.turn for slot, entry in self._slots.items()}
def history(self) -> list[ReferentEntry]:
return list(self._history)
def clear(self) -> None:
self._slots.clear()
self._history.clear()
self._last_resolved_sources.clear()
self._last_resolved_slots.clear()
def __repr__(self) -> str:
active = {k: v.surface for k, v in self._slots.items()}
return f"ReferentRegistry(active={active})"
def to_dict(self) -> dict[str, Any]:
"""Serialize, preserving the _slots <-> _history object aliasing.
``register`` puts the SAME ReferentEntry object in both ``_slots[slot]``
and ``_history``; ``update_turn_versor`` relies on that identity
(``_slots.get(slot) is entry``). We persist ``_history`` as the source of
truth and ``_slots`` as slot -> history-index, so restore rebinds the
exact same objects rather than independent copies.
"""
slot_to_index: dict[str, int] = {}
for slot, entry in self._slots.items():
for i, hist_entry in enumerate(self._history):
if hist_entry is entry:
slot_to_index[slot] = i
break
return {
"history": [e.to_dict() for e in self._history],
"slot_to_index": slot_to_index,
"last_resolved_sources": list(self._last_resolved_sources),
"last_resolved_slots": dict(self._last_resolved_slots),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "ReferentRegistry":
registry = cls()
registry._history = [ReferentEntry.from_dict(e) for e in payload["history"]]
registry._slots = {
slot: registry._history[i]
for slot, i in payload["slot_to_index"].items()
}
registry._last_resolved_sources = list(payload["last_resolved_sources"])
registry._last_resolved_slots = dict(payload["last_resolved_slots"])
return registry