core/tests/test_discourse_planner_walkthrough.py
Shay 4e3ddee91f feat(discourse): WALKTHROUGH v1 — sequential teaching-chain walk
Closes the last unarticulate cases on the multi_sentence_response
lane.  Two complementary changes:

1. ``generate/discourse_planner.py``
   * ``ResponseMode.WALKTHROUGH`` budget lifted from (1, 1) to
     (1, 4): 1 anchor + up to 3 hops along the teaching-chain graph,
     final hop becomes CLOSURE.
   * New ``_plan_walkthrough`` selector walks (subject, *, object) →
     (object, *, *) starting from the anchor; cycle-safe via the
     existing used-fact set; bounded by ``_WALKTHROUGH_MAX_HOPS=3``.
   * New ``_plan_walkthrough_fallback`` — when no teaching chain is
     rooted on the anchor, emit ANCHOR + (SUPPORT) rather than
     fabricating walk steps.  Plan retains ``mode=WALKTHROUGH`` so
     callers detect "attempted walkthrough, degraded honestly".

2. ``generate/intent.py``
   * New classifier rule: ``^walk\s+(?:me\s+)?through\s+`` →
     ``IntentTag.DEFINITION``.  Same orthogonality discipline as the
     ``Explain X`` rule: ``ResponseMode.WALKTHROUGH`` carries the
     walk depth on its own axis.

13 new tests pin: walk shape (ANCHOR + RELATION* + CLOSURE), the
walk invariant (each teaching hop's subject = prior hop's object),
the 4-move cap, the fallback shape on absent chains, fallback mode
retention, cycle-safety against (A→B→A) cycles, and determinism.

Lane re-measurement (24 cases, multi_sentence_response public/v1):

  flag off: articulate=0.0833, disclosure=0.1667, unarticulate=0.7500
  flag on : articulate=1.0000, disclosure=0.0000, unarticulate=0.0000

The two previously-unarticulate WALKTHROUGH cases ("Walk me through
inference.", "Walk me through recall.") now engage the planner and
render as deterministic teaching-chain walks:

  "Inference is a conclusion drawn from premises by reasoning.
   Inference requires evidence."

  "Recall is to retrieve a stored state from memory.
   Recall reveals memory."

Each surface is grounded entirely in pack glosses and reviewed
teaching chains — no fabricated walk steps.

Critical gates all green:
* flag off cognition byte-identical:
  public 100/100/91.7/100, holdout 100/100/83.3/100
* smoke suite 67/67
* 91/91 planner tests pass (contract / behavior / compound / helper
  / render / walkthrough)

The 0.875 connective_present_rate remaining flag-on (3 cases without
expected connectives) is the only gap left, and it's now a render-
template question rather than a planner gap.
2026-05-19 12:29:20 -07:00

239 lines
9.4 KiB
Python

"""Tests for ``WALKTHROUGH`` v1 — sequential teaching-chain walk.
Pins:
* ≤ 4 moves total (1 anchor + ≤3 hops) — the hop cap is structural.
* Each hop follows ``(subject, *, object) → (object, *, *)`` along
the teaching-chain graph; the final hop is a ``CLOSURE`` move.
* When no teaching chain is rooted on the anchor, the planner falls
back to the expository (ANCHOR + SUPPORT) shape rather than
fabricating walk steps. The fallback plan retains
``mode=WALKTHROUGH`` so callers can tell the planner attempted a
walkthrough but degraded honestly.
* Cycle-safe: a teaching cycle ``A→B→A`` walks A→B→A only if the
facts are distinct; identical facts are never re-emitted.
"""
from __future__ import annotations
from generate.discourse_planner import (
DiscourseMoveKind,
FactSource,
GroundedFact,
GroundingBundle,
plan_discourse,
)
from generate.intent import DialogueIntent, IntentTag, ResponseMode
def _intent(subject: str = "truth") -> DialogueIntent:
return DialogueIntent(tag=IntentTag.DEFINITION, subject=subject)
def _chain_bundle() -> GroundingBundle:
"""4-link teaching chain: truth → knowledge → evidence → recall.
Plus a pack anchor so ``_select_anchor`` has a definitional fact.
"""
return GroundingBundle(
facts=(
GroundedFact(
subject="truth", predicate="is_defined_as",
obj="reality-correspondence", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#gloss",
),
GroundedFact(
subject="truth", predicate="reveals", obj="knowledge",
source=FactSource.TEACHING,
source_id="cognition_chains_v1#cause_truth_reveals_knowledge",
),
GroundedFact(
subject="knowledge", predicate="requires", obj="evidence",
source=FactSource.TEACHING,
source_id="cognition_chains_v1#cause_knowledge_requires_evidence",
),
GroundedFact(
subject="evidence", predicate="supports", obj="recall",
source=FactSource.TEACHING,
source_id="cognition_chains_v1#cause_evidence_supports_recall",
),
)
)
def _pack_only_bundle() -> GroundingBundle:
return GroundingBundle(
facts=(
GroundedFact(
subject="truth", predicate="is_defined_as",
obj="reality-correspondence", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#gloss",
),
GroundedFact(
subject="truth", predicate="belongs_to",
obj="epistemic_domain", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#domain:0",
),
)
)
# ---------------------------------------------------------------------------
# Walk shape
# ---------------------------------------------------------------------------
class TestWalkthroughShape:
def test_full_chain_emits_anchor_relation_relation_closure(self) -> None:
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle())
kinds = [m.kind for m in plan.moves]
# 1 anchor + 3 hops; last hop is CLOSURE.
assert kinds == [
DiscourseMoveKind.ANCHOR,
DiscourseMoveKind.RELATION,
DiscourseMoveKind.RELATION,
DiscourseMoveKind.CLOSURE,
]
def test_walk_follows_subject_to_object_to_subject(self) -> None:
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle())
# Walk invariant applies *across hops* — consecutive teaching
# facts on the chain. The anchor is a pack ``is_defined_as``
# whose obj is a gloss string, not a graph node, so it's
# excluded. First hop starts on the anchor's *subject*.
teaching_moves = [m for m in plan.moves if m.fact is not None and m.fact.source is FactSource.TEACHING]
for prev, curr in zip(teaching_moves, teaching_moves[1:]):
assert curr.fact is not None and prev.fact is not None
assert curr.fact.subject == prev.fact.obj
# First teaching hop must start on the anchor's subject.
anchor = plan.anchor()
assert anchor is not None and anchor.fact is not None
assert teaching_moves[0].fact is not None
assert teaching_moves[0].fact.subject == anchor.fact.subject
def test_hop_cap_at_four_moves(self) -> None:
# Build a chain longer than the cap.
long_facts = [
GroundedFact(
subject="truth", predicate="is_defined_as",
obj="reality-correspondence", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#gloss",
),
]
# 6-link teaching chain.
chain_subjects = ["truth", "a", "b", "c", "d", "e"]
chain_objects = ["a", "b", "c", "d", "e", "f"]
for s, o in zip(chain_subjects, chain_objects):
long_facts.append(
GroundedFact(
subject=s, predicate="leads_to", obj=o,
source=FactSource.TEACHING,
source_id=f"chain#{s}_to_{o}",
)
)
bundle = GroundingBundle(facts=tuple(long_facts))
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, bundle)
assert len(plan.moves) <= 4
def test_topics_walk_through_chain(self) -> None:
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle())
topics = plan.topics()
# Anchor topic + 3 hop topics.
assert topics == ("truth", "knowledge", "evidence")
# ---------------------------------------------------------------------------
# Fallback when no chain is rooted on the anchor
# ---------------------------------------------------------------------------
class TestWalkthroughFallback:
def test_no_chain_falls_back_to_expository(self) -> None:
plan = plan_discourse(
_intent(), ResponseMode.WALKTHROUGH, _pack_only_bundle()
)
kinds = [m.kind for m in plan.moves]
# ANCHOR + SUPPORT, no fabricated walk steps.
assert kinds == [
DiscourseMoveKind.ANCHOR,
DiscourseMoveKind.SUPPORT,
]
def test_fallback_plan_retains_walkthrough_mode(self) -> None:
plan = plan_discourse(
_intent(), ResponseMode.WALKTHROUGH, _pack_only_bundle()
)
# Even though the planner degraded, the mode tag remains
# WALKTHROUGH so callers can detect "attempted walkthrough,
# degraded honestly".
assert plan.mode is ResponseMode.WALKTHROUGH
def test_pack_only_no_support_returns_anchor_only(self) -> None:
# Anchor fact only, no support, no teaching chain ⇒ ANCHOR-only
# plan; mode still WALKTHROUGH.
bundle = GroundingBundle(
facts=(
GroundedFact(
subject="truth", predicate="is_defined_as",
obj="reality-correspondence", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#gloss",
),
)
)
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, bundle)
kinds = [m.kind for m in plan.moves]
assert kinds == [DiscourseMoveKind.ANCHOR]
assert plan.mode is ResponseMode.WALKTHROUGH
# ---------------------------------------------------------------------------
# Cycle safety
# ---------------------------------------------------------------------------
class TestWalkthroughCycleSafety:
def test_cyclic_chain_does_not_re_emit_same_fact(self) -> None:
# truth → A → truth (cycle). Distinct facts, but if a third
# hop tried to re-walk truth→A, it would re-emit the first
# fact. The planner must not.
bundle = GroundingBundle(
facts=(
GroundedFact(
subject="truth", predicate="is_defined_as",
obj="reality-correspondence", source=FactSource.PACK,
source_id="en_core_cognition_v1:truth#gloss",
),
GroundedFact(
subject="truth", predicate="produces", obj="echo",
source=FactSource.TEACHING,
source_id="chain#truth_produces_echo",
),
GroundedFact(
subject="echo", predicate="returns_to", obj="truth",
source=FactSource.TEACHING,
source_id="chain#echo_returns_to_truth",
),
)
)
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, bundle)
fact_keys = [m.fact.sort_key() for m in plan.moves if m.fact is not None]
assert len(fact_keys) == len(set(fact_keys))
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
class TestWalkthroughDeterminism:
def test_walk_is_byte_stable(self) -> None:
encoded = [
plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle()).to_json()
for _ in range(8)
]
assert len(set(encoded)) == 1
def test_walk_equality(self) -> None:
a = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle())
b = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _chain_bundle())
assert a == b