core/tests/test_anaphora.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

175 lines
6.8 KiB
Python

"""Phase 3.2 — thread anaphora prefix tests.
The contract these tests pin:
- ``thread_anaphora_prefix`` is deterministic and pure.
- Fires only when BOTH the prior turn (recovered from
``ThreadContext``) AND the current turn are pack/teaching tier.
- Same-intent revisits do not fire (redundant prefix).
- Format is structural-fields-only: turn index + chain_id or
grounding tier; never re-derives prose.
- Live runtime: ``RuntimeConfig.thread_anaphora=False`` keeps
every existing surface byte-identical (default off).
- With the flag on AND seeded thread state, the runtime prepends
the deterministic backreference to the pack-grounded surface.
"""
from __future__ import annotations
import pytest
from chat.anaphora import thread_anaphora_prefix
from chat.runtime import ChatRuntime
from chat.thread_context import ThreadContext, TurnSummary
from core.config import RuntimeConfig
def _seed(tc: ThreadContext, **kw) -> None:
"""Helper to push a TurnSummary into a context."""
tc.push(TurnSummary(
turn_index=kw.get("turn_index", 0),
intent_tag_name=kw.get("intent", "cause"),
subject=kw.get("subject", "light"),
grounding_source=kw.get("source", "teaching"),
chain_id=kw.get("chain_id"),
corpus_id=kw.get("corpus_id"),
))
# ---------------------------------------------------------------------------
# Pure-function contract
# ---------------------------------------------------------------------------
def test_prior_teaching_current_pack_fires_with_chain_id() -> None:
tc = ThreadContext()
_seed(tc, turn_index=0, intent="cause", subject="light",
source="teaching", chain_id="cause_light_reveals_truth",
corpus_id="cognition_chains_v1")
prefix = thread_anaphora_prefix(tc, "light", "definition", "pack")
assert prefix is not None
assert "Recalling turn 0" in prefix
assert "cause_light_reveals_truth" in prefix
def test_prior_pack_current_teaching_fires_with_grounding_tier() -> None:
tc = ThreadContext()
_seed(tc, turn_index=2, intent="definition", subject="light", source="pack")
prefix = thread_anaphora_prefix(tc, "light", "cause", "teaching")
assert prefix is not None
assert "Recalling turn 2" in prefix
assert "pack" in prefix
assert "light" in prefix
def test_same_intent_revisit_does_not_fire() -> None:
"""Asking the same question twice on the same subject — the
prior turn IS the same surface modulo vault drift; prefixing
would be redundant."""
tc = ThreadContext()
_seed(tc, turn_index=0, intent="definition", subject="light", source="pack")
assert thread_anaphora_prefix(tc, "light", "definition", "pack") is None
def test_prior_weak_tier_does_not_anchor() -> None:
"""Prior turn whose grounding was OOV / partial / vault / none
is not a strong-enough anchor."""
tc = ThreadContext()
_seed(tc, source="oov")
# recent_for_subject excludes OOV by default → prior lookup returns None.
assert thread_anaphora_prefix(tc, "light", "definition", "pack") is None
tc2 = ThreadContext()
_seed(tc2, source="partial")
assert thread_anaphora_prefix(tc2, "light", "definition", "pack") is None
def test_current_weak_tier_does_not_fire() -> None:
"""Anaphora is a *forward-reference* prefix on a strongly-grounded
surface; weak-tier current turns are not hosts."""
tc = ThreadContext()
_seed(tc, source="teaching", chain_id="x")
assert thread_anaphora_prefix(tc, "light", "definition", "oov") is None
assert thread_anaphora_prefix(tc, "light", "definition", "partial") is None
assert thread_anaphora_prefix(tc, "light", "definition", "none") is None
def test_empty_subject_returns_none() -> None:
tc = ThreadContext()
_seed(tc, source="teaching", chain_id="x")
assert thread_anaphora_prefix(tc, "", "definition", "pack") is None
assert thread_anaphora_prefix(tc, " ", "definition", "pack") is None
def test_no_recent_match_returns_none() -> None:
tc = ThreadContext()
_seed(tc, subject="light", source="teaching", chain_id="x")
assert thread_anaphora_prefix(tc, "memory", "definition", "pack") is None
def test_most_recent_match_wins() -> None:
"""If the same subject appears twice, the most recent matching
grounded turn is the anchor — not the earliest."""
tc = ThreadContext()
_seed(tc, turn_index=0, intent="cause", subject="light",
source="teaching", chain_id="cause_old")
_seed(tc, turn_index=2, intent="cause", subject="light",
source="teaching", chain_id="cause_new")
prefix = thread_anaphora_prefix(tc, "light", "definition", "pack")
assert prefix is not None
assert "cause_new" in prefix
assert "turn 2" in prefix
def test_is_deterministic() -> None:
tc = ThreadContext()
_seed(tc, source="teaching", chain_id="x")
a = thread_anaphora_prefix(tc, "light", "definition", "pack")
b = thread_anaphora_prefix(tc, "light", "definition", "pack")
assert a == b
# ---------------------------------------------------------------------------
# Live runtime integration
# ---------------------------------------------------------------------------
def test_runtime_default_off_preserves_surface_bytewise() -> None:
"""With ``thread_anaphora=False`` (default), even a seeded thread
context does not alter the emitted surface."""
rt = ChatRuntime() # default config
rt.thread_context.push(TurnSummary(
turn_index=0, intent_tag_name="cause", subject="light",
grounding_source="teaching", chain_id="cause_light_reveals_truth",
corpus_id="cognition_chains_v1",
))
resp = rt.chat("What is light?")
assert resp.grounding_source == "pack"
assert "Recalling" not in resp.surface
assert "light — pack-grounded" in resp.surface
def test_runtime_anaphora_on_emits_prefix() -> None:
cfg = RuntimeConfig(thread_anaphora=True)
rt = ChatRuntime(config=cfg)
rt.thread_context.push(TurnSummary(
turn_index=0, intent_tag_name="cause", subject="light",
grounding_source="teaching", chain_id="cause_light_reveals_truth",
corpus_id="cognition_chains_v1",
))
resp = rt.chat("What is light?")
assert resp.grounding_source == "pack"
assert "Recalling turn 0" in resp.surface
assert "cause_light_reveals_truth" in resp.surface
# Prefix precedes the pack-grounded surface, never replaces it.
assert "light — pack-grounded" in resp.surface
def test_runtime_anaphora_on_does_not_fire_without_anchor() -> None:
"""With the flag on but NO prior turn on the subject, the prefix
composer returns None and the surface is unchanged."""
cfg = RuntimeConfig(thread_anaphora=True)
rt = ChatRuntime(config=cfg)
resp = rt.chat("What is light?")
assert resp.grounding_source == "pack"
assert "Recalling" not in resp.surface