core/tests/test_thread_context.py
Shay fe4cc2cd1f feat(adr-0066): session-thread context + opt-in anaphora prefix (Phase 3.1 + 3.2)
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.
2026-05-18 17:01:34 -07:00

254 lines
9.4 KiB
Python

"""Phase 3.1 — session-thread context tests.
The contract these tests pin:
- ``TurnSummary`` is frozen + carries only structured fields.
- ``ThreadContext`` is bounded (FIFO eviction at max_turns).
- ``push`` appends; ``snapshot`` returns the deque in insertion
order (oldest first).
- ``recent_for_subject`` returns the most-recent matching summary,
skipping ungrounded tiers by default.
- ``recent_subjects`` returns unique subjects most-recent-first.
- ChatRuntime owns one ThreadContext; pushes a summary at
end-of-turn for BOTH the stub path and the walk path.
- Teaching-grounded turns carry chain_id + corpus_id in the
summary so anaphora composers can detect same-chain reference.
"""
from __future__ import annotations
import pytest
from chat.runtime import ChatRuntime
from chat.thread_context import (
MAX_THREAD_TURNS,
ThreadContext,
TurnSummary,
)
# ---------------------------------------------------------------------------
# TurnSummary dataclass
# ---------------------------------------------------------------------------
def test_turn_summary_is_frozen() -> None:
s = TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching",
chain_id="cause_light_reveals_truth",
corpus_id="cognition_chains_v1")
with pytest.raises((AttributeError, TypeError)):
s.subject = "other" # type: ignore[misc]
def test_turn_summary_as_dict_round_trips() -> None:
s = TurnSummary(turn_index=3, intent_tag_name="definition",
subject="parent", grounding_source="pack")
blob = s.as_dict()
assert blob["turn_index"] == 3
assert blob["subject"] == "parent"
assert blob["grounding_source"] == "pack"
assert blob["chain_id"] is None
assert blob["corpus_id"] is None
# ---------------------------------------------------------------------------
# ThreadContext bounded FIFO
# ---------------------------------------------------------------------------
def test_default_capacity() -> None:
tc = ThreadContext()
assert tc.max_turns == MAX_THREAD_TURNS
def test_invalid_capacity_raises() -> None:
with pytest.raises(ValueError):
ThreadContext(max_turns=0)
with pytest.raises(ValueError):
ThreadContext(max_turns=-1)
def test_push_and_snapshot_preserve_order() -> None:
tc = ThreadContext(max_turns=4)
for i in range(3):
tc.push(TurnSummary(turn_index=i, intent_tag_name="cause",
subject=f"s{i}", grounding_source="teaching"))
snap = tc.snapshot()
assert [s.turn_index for s in snap] == [0, 1, 2]
assert [s.subject for s in snap] == ["s0", "s1", "s2"]
def test_eviction_drops_oldest() -> None:
tc = ThreadContext(max_turns=3)
for i in range(5):
tc.push(TurnSummary(turn_index=i, intent_tag_name="cause",
subject=f"s{i}", grounding_source="teaching"))
snap = tc.snapshot()
assert len(snap) == 3
# 0, 1 evicted; 2, 3, 4 retained.
assert [s.turn_index for s in snap] == [2, 3, 4]
def test_clear_resets_state() -> None:
tc = ThreadContext(max_turns=4)
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="x", grounding_source="teaching"))
assert len(tc) == 1
tc.clear()
assert len(tc) == 0
# ---------------------------------------------------------------------------
# recent_for_subject
# ---------------------------------------------------------------------------
def test_recent_for_subject_returns_most_recent_match() -> None:
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=1, intent_tag_name="cause",
subject="memory", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=2, intent_tag_name="definition",
subject="light", grounding_source="pack"))
match = tc.recent_for_subject("light")
assert match is not None
assert match.turn_index == 2 # most recent, not the first
assert match.intent_tag_name == "definition"
def test_recent_for_subject_skips_ungrounded_by_default() -> None:
"""OOV / partial / none turns are excluded from recency lookup
by default — they're not strong-enough anchors for anaphora."""
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=1, intent_tag_name="definition",
subject="light", grounding_source="oov"))
match = tc.recent_for_subject("light")
assert match is not None
assert match.turn_index == 0 # teaching turn wins; oov skipped
def test_recent_for_subject_can_include_excluded_tiers() -> None:
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=1, intent_tag_name="definition",
subject="light", grounding_source="oov"))
match = tc.recent_for_subject("light", exclude_grounding=())
assert match is not None
assert match.turn_index == 1
def test_recent_for_subject_normalises_input() -> None:
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
for query in ("LIGHT", "Light", " light "):
match = tc.recent_for_subject(query)
assert match is not None
def test_recent_for_subject_returns_none_when_absent() -> None:
tc = ThreadContext()
assert tc.recent_for_subject("anything") is None
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="x", grounding_source="teaching"))
assert tc.recent_for_subject("y") is None
# ---------------------------------------------------------------------------
# recent_subjects
# ---------------------------------------------------------------------------
def test_recent_subjects_unique_most_recent_first() -> None:
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=1, intent_tag_name="cause",
subject="memory", grounding_source="teaching"))
tc.push(TurnSummary(turn_index=2, intent_tag_name="definition",
subject="light", grounding_source="pack"))
subjects = tc.recent_subjects()
assert subjects == ("light", "memory")
def test_recent_subjects_skips_empty_and_ungrounded() -> None:
tc = ThreadContext()
tc.push(TurnSummary(turn_index=0, intent_tag_name="",
subject="", grounding_source="vault"))
tc.push(TurnSummary(turn_index=1, intent_tag_name="definition",
subject="photosynthesis", grounding_source="oov"))
tc.push(TurnSummary(turn_index=2, intent_tag_name="cause",
subject="light", grounding_source="teaching"))
subjects = tc.recent_subjects()
assert subjects == ("light",)
# ---------------------------------------------------------------------------
# Runtime integration — ChatRuntime pushes after each turn
# ---------------------------------------------------------------------------
def test_runtime_pushes_summary_on_cold_start_pack_turn() -> None:
rt = ChatRuntime()
rt.chat("What is light?")
snap = rt.thread_context.snapshot()
assert len(snap) == 1
s = snap[0]
assert s.intent_tag_name == "definition"
assert s.subject == "light"
assert s.grounding_source == "pack"
def test_runtime_pushes_chain_id_for_teaching_grounded_turn() -> None:
rt = ChatRuntime()
rt.chat("Why does light exist?")
s = rt.thread_context.snapshot()[0]
assert s.grounding_source == "teaching"
assert s.chain_id == "cause_light_reveals_truth"
assert s.corpus_id == "cognition_chains_v1"
def test_runtime_pushes_summary_for_oov_turn() -> None:
rt = ChatRuntime()
rt.chat("What is photosynthesis?")
s = rt.thread_context.snapshot()[0]
assert s.intent_tag_name == "definition"
assert s.subject == "photosynthesis"
assert s.grounding_source == "oov"
assert s.chain_id is None
def test_runtime_thread_context_grows_across_turns() -> None:
rt = ChatRuntime()
rt.chat("What is light?")
rt.chat("What is parent?")
rt.chat("What is photosynthesis?")
snap = rt.thread_context.snapshot()
assert len(snap) == 3
assert snap[0].subject == "light"
assert snap[1].subject == "parent"
assert snap[2].subject == "photosynthesis"
def test_runtime_thread_context_indexes_match_turn_log() -> None:
rt = ChatRuntime()
rt.chat("What is light?")
rt.chat("What is parent?")
snap = rt.thread_context.snapshot()
assert [s.turn_index for s in snap] == list(range(len(rt.turn_log)))
def test_runtime_default_capacity_evicts_old_turns() -> None:
rt = ChatRuntime()
for _ in range(MAX_THREAD_TURNS + 3):
rt.chat("What is light?")
snap = rt.thread_context.snapshot()
assert len(snap) == MAX_THREAD_TURNS
# Oldest retained turn_index is (total - MAX_THREAD_TURNS).
total_turns = len(rt.turn_log)
assert snap[0].turn_index == total_turns - MAX_THREAD_TURNS