core/chat/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

94 lines
3.7 KiB
Python

"""chat/anaphora.py — Phase 3.2: deterministic thread anaphora prefix.
When the current turn's subject lemma appeared in a recent *grounded*
turn (pack or teaching tier), the anaphora composer prepends a
deterministic backreference to the current surface. Conversation
reads as a thread instead of a sequence of independent surfaces:
Turn 0 > Why does light exist?
[teaching] light — teaching-grounded (cognition_chains_v1):
cognition.illumination; logos.core. light reveals truth ...
Turn 1 > What is light?
[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.
The prefix is **strictly deterministic**:
- Same thread state + same current turn → byte-identical prefix.
- No prose generation; the prefix references the prior turn by
*turn index* and *chain id* (or grounding tier), nothing else.
- The composer only fires when *both* the prior turn AND the
current turn are pack/teaching tier; weaker tiers (vault /
partial / oov / none) do not anchor.
- Same-intent revisits (asking the same question twice) do not
fire — the prior turn IS the current turn modulo session vault
drift, prefixing it is redundant.
- Opt-in via :attr:`core.config.RuntimeConfig.thread_anaphora`.
Default ``False`` preserves every pre-P3.2 surface byte-identically.
"""
from __future__ import annotations
from chat.thread_context import ThreadContext
# Grounding tiers strong enough to anchor an anaphora reference.
_ANCHOR_TIERS: frozenset[str] = frozenset({"teaching", "pack"})
def thread_anaphora_prefix(
thread_context: ThreadContext,
current_subject: str,
current_intent_tag_name: str,
current_grounding_source: str,
) -> str | None:
"""Return a deterministic anaphora prefix, or ``None``.
Engagement conditions (ALL must hold):
1. ``current_subject`` is non-empty.
2. ``current_grounding_source`` is in ``{"pack", "teaching"}``
— weaker tiers cannot host a meaningful backreference.
3. A prior turn on the same subject exists in
``thread_context`` AND was itself pack/teaching grounded.
4. The prior turn's intent differs from the current turn's
intent (a same-intent revisit is the same surface modulo
vault drift; prefixing would be redundant).
The prefix format references the prior turn by structured fields
only — never by surface text, never by re-derived prose. Two
shapes exist:
- Prior was teaching-grounded → ``"(Recalling turn N: chain
<chain_id>.) "``
- Prior was pack-grounded → ``"(Recalling turn N: <subject>
grounded pack.) "``
Returns ``None`` when any engagement condition fails. Callers
then emit the unprefixed surface byte-identically.
"""
if not current_subject or not isinstance(current_subject, str):
return None
key = current_subject.strip().lower()
if not key:
return None
if current_grounding_source not in _ANCHOR_TIERS:
return None
prior = thread_context.recent_for_subject(key)
if prior is None:
return None
if prior.grounding_source not in _ANCHOR_TIERS:
return None
if prior.intent_tag_name and prior.intent_tag_name == current_intent_tag_name:
return None
if prior.grounding_source == "teaching" and prior.chain_id:
return f"(Recalling turn {prior.turn_index}: chain {prior.chain_id}.) "
return (
f"(Recalling turn {prior.turn_index}: {key} grounded "
f"{prior.grounding_source}.) "
)
__all__ = ["thread_anaphora_prefix"]