ADR-0066 P3.1 + P3.2. Conversation now reads as a thread: turns
carry structured summaries of their predecessors and (optionally)
prefix new pack/teaching surfaces with deterministic backreferences.
P3.1 — chat/thread_context.py.
TurnSummary(turn_index, intent_tag_name, subject, grounding_source,
chain_id, corpus_id) — frozen, structured-fields-only.
ThreadContext — bounded FIFO (default MAX_THREAD_TURNS=8) with
snapshot(), recent_for_subject(), recent_subjects(), clear().
recent_for_subject() excludes ungrounded tiers (oov/partial/none)
by default — those are not strong-enough anchors.
ChatRuntime.thread_context is owned at construction.
_push_thread_summary runs at end-of-turn on BOTH stub and walk
paths. Teaching-grounded turns carry chain_id + corpus_id so
downstream composers (P3.2) can detect same-chain reference.
Cold-start intent classification now runs unconditionally (was:
gated on sink attachment) so thread context captures subject
regardless of sink state.
P3.2 — chat/anaphora.py.
thread_anaphora_prefix(ctx, subject, intent_name, source) returns
a deterministic prefix when:
- current turn is pack/teaching tier
- a prior pack/teaching turn on the same subject exists
- the prior intent differs from the current intent
Format (structural-fields-only — no prose):
"(Recalling turn N: chain <chain_id>.) " # prior was teaching
"(Recalling turn N: <subject> grounded pack.) " # prior was pack
Opt-in via RuntimeConfig.thread_anaphora=False. Default off keeps
every existing surface byte-identical.
Live verification (with thread_anaphora=True + seeded context):
> What is light? # following a "Why does light exist?" teaching turn
[pack] (Recalling turn 0: chain cause_light_reveals_truth.)
light — pack-grounded (en_core_cognition_v1): cognition.illumination;
logos.core; perception.clarity. No session evidence yet.
32 new tests passed. Curated lanes green. Cognition eval
byte-identical to pre-ADR baseline.
181 lines
7 KiB
Python
181 lines
7 KiB
Python
"""chat/thread_context.py — Phase 3.1: session-thread state.
|
|
|
|
The runtime today treats each turn as an independent grounded surface.
|
|
There is no thread-level memory: a turn cannot reference what was
|
|
established three turns ago, even when the same subject reappears.
|
|
That is the *articulation gap* in plain terms — conversation reads
|
|
mechanical because each turn is freshly minted, never referenced
|
|
backward.
|
|
|
|
This module is the data primitive that closes that gap. Phase 3.1
|
|
stores; Phase 3.2 (anaphora composer) reads. Surface emission is
|
|
unchanged at P3.1 — turning the data layer on cannot regress any
|
|
existing test.
|
|
|
|
Design constraints (matching CLAUDE.md doctrine):
|
|
|
|
- **Bounded.** Capacity ``MAX_THREAD_TURNS`` (default 8). Older
|
|
summaries evict in FIFO order; thread context is *not* a long-term
|
|
store, it is a small recency window the anaphora composer can
|
|
reference. Long-term memory is the vault.
|
|
- **Immutable summaries.** ``TurnSummary`` is frozen. Pushing
|
|
produces a new entry; never mutates an existing one.
|
|
- **No reconstruction from surface.** The summary carries only
|
|
structured fields (intent_tag, subject, grounding_source,
|
|
chain_id). Full surface text stays in the audit trail
|
|
(``rt.turn_log``); thread context references only the shape.
|
|
- **No clock-time reads.** Determinism — replays of the same
|
|
sequence of turns produce identical thread state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from typing import Iterable
|
|
|
|
|
|
# Recency window size. 8 is large enough for typical multi-turn
|
|
# anaphora ("As we established two turns ago..." through to "earlier
|
|
# in this conversation...") without giving the anaphora composer a
|
|
# context bigger than the surface itself. Operators can override
|
|
# at construction time via :class:`ThreadContext(max_turns=N)`.
|
|
MAX_THREAD_TURNS: int = 8
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TurnSummary:
|
|
"""One structured record summarising a turn for thread-anaphora
|
|
lookup. Frozen — the runtime never mutates a pushed summary.
|
|
|
|
Fields:
|
|
- ``turn_index``: monotonic integer (0-based). Same numbering
|
|
as ``rt.turn_log``.
|
|
- ``intent_tag_name``: lowercase intent name (``"cause"``,
|
|
``"definition"``, etc.). Lowercased for case-insensitive
|
|
match against new turns.
|
|
- ``subject``: lowercased subject lemma (normalised to match
|
|
the pack-resolver layer). Empty string when the turn had no
|
|
clean subject.
|
|
- ``grounding_source``: ``"vault" | "teaching" | "pack" |
|
|
"partial" | "oov" | "none"`` — the tier the turn surfaced
|
|
through. Anaphora is most useful when both turns surfaced
|
|
through ``"pack"`` or ``"teaching"`` (deterministic
|
|
backreference); the composer can choose to skip vault /
|
|
partial / oov / none on its own policy.
|
|
- ``chain_id``: present when ``grounding_source == "teaching"``,
|
|
else ``None``. Lets the anaphora composer detect "same
|
|
chain referenced again" vs "different chain on same subject".
|
|
- ``corpus_id``: present when ``grounding_source == "teaching"``,
|
|
else ``None``. Cross-corpus thread anaphora reads the
|
|
corpus tag back to the user.
|
|
"""
|
|
|
|
turn_index: int
|
|
intent_tag_name: str
|
|
subject: str
|
|
grounding_source: str
|
|
chain_id: str | None = None
|
|
corpus_id: str | None = None
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
return {
|
|
"turn_index": self.turn_index,
|
|
"intent_tag_name": self.intent_tag_name,
|
|
"subject": self.subject,
|
|
"grounding_source": self.grounding_source,
|
|
"chain_id": self.chain_id,
|
|
"corpus_id": self.corpus_id,
|
|
}
|
|
|
|
|
|
class ThreadContext:
|
|
"""Bounded FIFO of :class:`TurnSummary` records.
|
|
|
|
Owned by :class:`chat.runtime.ChatRuntime`; updated after each
|
|
turn. Read-only from outside the runtime — tests can inspect
|
|
via ``rt.thread_context.snapshot()``.
|
|
"""
|
|
|
|
__slots__ = ("_deque", "_max_turns")
|
|
|
|
def __init__(self, *, max_turns: int = MAX_THREAD_TURNS) -> None:
|
|
if max_turns < 1:
|
|
raise ValueError(f"max_turns must be >= 1 (got {max_turns!r})")
|
|
self._max_turns = max_turns
|
|
self._deque: deque[TurnSummary] = deque(maxlen=max_turns)
|
|
|
|
@property
|
|
def max_turns(self) -> int:
|
|
return self._max_turns
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._deque)
|
|
|
|
def push(self, summary: TurnSummary) -> None:
|
|
"""Append a new turn summary; evict the oldest if at capacity."""
|
|
if not isinstance(summary, TurnSummary): # pragma: no cover — defensive
|
|
raise TypeError(f"expected TurnSummary, got {type(summary).__name__}")
|
|
self._deque.append(summary)
|
|
|
|
def snapshot(self) -> tuple[TurnSummary, ...]:
|
|
"""Return an immutable tuple of every retained summary, in
|
|
insertion order (oldest first)."""
|
|
return tuple(self._deque)
|
|
|
|
def recent_for_subject(
|
|
self,
|
|
subject: str,
|
|
*,
|
|
exclude_grounding: Iterable[str] = ("none", "oov", "partial"),
|
|
) -> TurnSummary | None:
|
|
"""Return the most-recent summary whose ``subject`` matches
|
|
*subject* (case-insensitive, whitespace-trimmed), or ``None``.
|
|
|
|
Summaries with ``grounding_source`` in *exclude_grounding* are
|
|
skipped by default — they carry less anchor evidence than
|
|
pack/teaching turns and the anaphora composer's "as we just
|
|
established" reads false if the prior turn was actually
|
|
ungrounded. Operators can pass ``exclude_grounding=()`` to
|
|
include every prior turn.
|
|
"""
|
|
if not subject or not isinstance(subject, str):
|
|
return None
|
|
key = subject.strip().lower()
|
|
if not key:
|
|
return None
|
|
excluded = frozenset(exclude_grounding)
|
|
for summary in reversed(self._deque):
|
|
if summary.grounding_source in excluded:
|
|
continue
|
|
if summary.subject == key:
|
|
return summary
|
|
return None
|
|
|
|
def recent_subjects(
|
|
self,
|
|
*,
|
|
exclude_grounding: Iterable[str] = ("none", "oov", "partial"),
|
|
) -> tuple[str, ...]:
|
|
"""Return the set of unique subjects in the window, ordered by
|
|
most-recent-first. Skips empty subjects and any whose
|
|
grounding tier is in *exclude_grounding*."""
|
|
excluded = frozenset(exclude_grounding)
|
|
seen: set[str] = set()
|
|
ordered: list[str] = []
|
|
for summary in reversed(self._deque):
|
|
if summary.grounding_source in excluded:
|
|
continue
|
|
if not summary.subject or summary.subject in seen:
|
|
continue
|
|
seen.add(summary.subject)
|
|
ordered.append(summary.subject)
|
|
return tuple(ordered)
|
|
|
|
def clear(self) -> None:
|
|
"""Drop every retained summary. Used by tests + by callers
|
|
that explicitly reset session memory."""
|
|
self._deque.clear()
|
|
|
|
|
|
__all__ = ["TurnSummary", "ThreadContext", "MAX_THREAD_TURNS"]
|