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.
This commit is contained in:
parent
7af7892dd8
commit
4e3ddee91f
4 changed files with 380 additions and 4 deletions
|
|
@ -279,9 +279,18 @@ _MODE_BUDGETS: dict[ResponseMode, tuple[int, int]] = {
|
||||||
ResponseMode.EXPLAIN: (1, 3),
|
ResponseMode.EXPLAIN: (1, 3),
|
||||||
ResponseMode.PARAGRAPH: (1, 5),
|
ResponseMode.PARAGRAPH: (1, 5),
|
||||||
ResponseMode.EXAMPLE: (1, 3),
|
ResponseMode.EXAMPLE: (1, 3),
|
||||||
ResponseMode.WALKTHROUGH: (1, 1),
|
# WALKTHROUGH v1: ≤ 4 hops along the teaching-chain graph. The
|
||||||
|
# planner walks ``(subject, *, object) → (object, *, *)``
|
||||||
|
# starting from the anchor and follows up to three additional
|
||||||
|
# hops (4 moves total including the anchor). When no chain is
|
||||||
|
# available the v1 implementation falls back to the expository
|
||||||
|
# plan shape (EXPLAIN budget) rather than fabricating steps —
|
||||||
|
# operator-chain WALKTHROUGH is deferred to a follow-up ADR.
|
||||||
|
ResponseMode.WALKTHROUGH: (1, 4),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_WALKTHROUGH_MAX_HOPS = 3 # 3 hops after the anchor = 4 moves total
|
||||||
|
|
||||||
|
|
||||||
def _select_anchor(
|
def _select_anchor(
|
||||||
intent: DialogueIntent,
|
intent: DialogueIntent,
|
||||||
|
|
@ -379,6 +388,111 @@ def _select_transition(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_walkthrough(
|
||||||
|
intent: DialogueIntent,
|
||||||
|
mode: ResponseMode,
|
||||||
|
bundle: GroundingBundle,
|
||||||
|
anchor_fact: GroundedFact,
|
||||||
|
moves: list[DiscourseMove],
|
||||||
|
used: set[tuple[int, str, str, str, str]],
|
||||||
|
) -> DiscoursePlan:
|
||||||
|
"""WALKTHROUGH v1 — sequential teaching-chain walk.
|
||||||
|
|
||||||
|
Starting from the anchor's subject, follow up to
|
||||||
|
``_WALKTHROUGH_MAX_HOPS`` hops along teaching-chain edges
|
||||||
|
``(subject, *, object) → (object, *, *)``. Each hop is one
|
||||||
|
``RELATION`` move; the final hop becomes a ``CLOSURE`` move.
|
||||||
|
|
||||||
|
Cycle-safe: never re-emits a fact already in *used*. Bounded
|
||||||
|
depth. When the substrate has no chain rooted on the anchor (or
|
||||||
|
the walk stalls before any hop), the v1 implementation falls
|
||||||
|
back to the expository (EXPLAIN) plan shape rather than
|
||||||
|
fabricating walk steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
given_lemmas: list[str] = [anchor_fact.subject]
|
||||||
|
current_subject = anchor_fact.subject
|
||||||
|
|
||||||
|
walked_facts: list[GroundedFact] = []
|
||||||
|
for _hop in range(_WALKTHROUGH_MAX_HOPS):
|
||||||
|
next_fact: GroundedFact | None = None
|
||||||
|
for fact in bundle.facts_by_source(FactSource.TEACHING):
|
||||||
|
if fact.sort_key() in used:
|
||||||
|
continue
|
||||||
|
if fact.subject == current_subject:
|
||||||
|
next_fact = fact
|
||||||
|
break
|
||||||
|
if next_fact is None:
|
||||||
|
break
|
||||||
|
walked_facts.append(next_fact)
|
||||||
|
used.add(next_fact.sort_key())
|
||||||
|
current_subject = next_fact.obj.strip().lower()
|
||||||
|
|
||||||
|
if not walked_facts:
|
||||||
|
# No teaching-chain substrate — fall back to expository plan
|
||||||
|
# rather than fabricating walk steps. Anchor + (SUPPORT) +
|
||||||
|
# (RELATION) shape preserves the "walkthrough" intent without
|
||||||
|
# claiming a process the substrate cannot support.
|
||||||
|
return _plan_walkthrough_fallback(
|
||||||
|
intent, bundle, anchor_fact, moves, used
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emit walked facts as RELATION moves with the final one becoming
|
||||||
|
# CLOSURE so the rendered surface terminates explicitly.
|
||||||
|
for idx, fact in enumerate(walked_facts):
|
||||||
|
kind = (
|
||||||
|
DiscourseMoveKind.CLOSURE
|
||||||
|
if idx == len(walked_facts) - 1
|
||||||
|
else DiscourseMoveKind.RELATION
|
||||||
|
)
|
||||||
|
moves.append(
|
||||||
|
DiscourseMove(
|
||||||
|
kind=kind,
|
||||||
|
topic=fact.subject,
|
||||||
|
given=tuple(given_lemmas),
|
||||||
|
new=(fact.obj,),
|
||||||
|
relation_to_previous=Relation.SEQUENCE,
|
||||||
|
fact=fact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
given_lemmas.append(fact.obj)
|
||||||
|
|
||||||
|
return DiscoursePlan(intent=intent, mode=mode, moves=tuple(moves))
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_walkthrough_fallback(
|
||||||
|
intent: DialogueIntent,
|
||||||
|
bundle: GroundingBundle,
|
||||||
|
anchor_fact: GroundedFact,
|
||||||
|
moves: list[DiscourseMove],
|
||||||
|
used: set[tuple[int, str, str, str, str]],
|
||||||
|
) -> DiscoursePlan:
|
||||||
|
"""Fallback shape when no teaching chain is available for
|
||||||
|
WALKTHROUGH. Emits an ANCHOR + (SUPPORT) plan — the
|
||||||
|
``ResponseMode`` stays WALKTHROUGH on the resulting plan so
|
||||||
|
callers can tell the planner attempted a walkthrough but
|
||||||
|
degraded honestly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
given_lemmas: list[str] = [anchor_fact.subject]
|
||||||
|
support_fact = _select_support(anchor_fact, bundle)
|
||||||
|
if support_fact is not None and support_fact.sort_key() not in used:
|
||||||
|
moves.append(
|
||||||
|
DiscourseMove(
|
||||||
|
kind=DiscourseMoveKind.SUPPORT,
|
||||||
|
topic=support_fact.subject,
|
||||||
|
given=tuple(given_lemmas),
|
||||||
|
new=(support_fact.obj,),
|
||||||
|
relation_to_previous=Relation.ELABORATION,
|
||||||
|
fact=support_fact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
used.add(support_fact.sort_key())
|
||||||
|
return DiscoursePlan(
|
||||||
|
intent=intent, mode=ResponseMode.WALKTHROUGH, moves=tuple(moves)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def plan_discourse(
|
def plan_discourse(
|
||||||
intent: DialogueIntent,
|
intent: DialogueIntent,
|
||||||
mode: ResponseMode,
|
mode: ResponseMode,
|
||||||
|
|
@ -441,7 +555,12 @@ def plan_discourse(
|
||||||
]
|
]
|
||||||
used: set[tuple[int, str, str, str, str]] = {anchor_fact.sort_key()}
|
used: set[tuple[int, str, str, str, str]] = {anchor_fact.sort_key()}
|
||||||
_, max_moves = _move_budget(mode)
|
_, max_moves = _move_budget(mode)
|
||||||
if max_moves <= 1 or mode is ResponseMode.WALKTHROUGH:
|
|
||||||
|
# WALKTHROUGH v1 — sequential teaching-chain walk.
|
||||||
|
if mode is ResponseMode.WALKTHROUGH:
|
||||||
|
return _plan_walkthrough(intent, mode, bundle, anchor_fact, moves, used)
|
||||||
|
|
||||||
|
if max_moves <= 1:
|
||||||
return DiscoursePlan(intent=intent, mode=mode, moves=tuple(moves))
|
return DiscoursePlan(intent=intent, mode=mode, moves=tuple(moves))
|
||||||
|
|
||||||
given_lemmas: list[str] = [anchor_fact.subject]
|
given_lemmas: list[str] = [anchor_fact.subject]
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,18 @@ _RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = (
|
||||||
re.compile(r"^paragraph\s+(?:about|on)\s+", re.IGNORECASE),
|
re.compile(r"^paragraph\s+(?:about|on)\s+", re.IGNORECASE),
|
||||||
IntentTag.DEFINITION,
|
IntentTag.DEFINITION,
|
||||||
),
|
),
|
||||||
|
# WALKTHROUGH-shape requests — semantic intent is "describe X step
|
||||||
|
# by step". Routes to DEFINITION so the grounded substrate fires
|
||||||
|
# on X; ``ResponseMode.WALKTHROUGH`` carries the walk depth and
|
||||||
|
# selects the sequential teaching-chain plan budget at planning
|
||||||
|
# time. Same orthogonality discipline as the EXPLAIN rule.
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"^walk\s+(?:me\s+)?through\s+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
IntentTag.DEFINITION,
|
||||||
|
),
|
||||||
(re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE),
|
(re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE),
|
||||||
# "What causes / triggers / enables / prevents / drives X?" — the
|
# "What causes / triggers / enables / prevents / drives X?" — the
|
||||||
# query is about what causes X, so the subject of the CAUSE intent
|
# query is about what causes X, so the subject of the CAUSE intent
|
||||||
|
|
|
||||||
|
|
@ -232,10 +232,16 @@ class TestExampleMode:
|
||||||
|
|
||||||
|
|
||||||
class TestWalkthroughMode:
|
class TestWalkthroughMode:
|
||||||
def test_walkthrough_falls_back_to_brief_shape(self) -> None:
|
def test_walkthrough_emits_chain_walk(self) -> None:
|
||||||
|
# WALKTHROUGH v1 — sequential teaching-chain walk. The
|
||||||
|
# _full_bundle has a 2-hop chain (truth→knowledge→evidence)
|
||||||
|
# plus pack anchor, so the walk emits ANCHOR + RELATION +
|
||||||
|
# CLOSURE. See test_discourse_planner_walkthrough.py for
|
||||||
|
# the dedicated suite.
|
||||||
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _full_bundle())
|
plan = plan_discourse(_intent(), ResponseMode.WALKTHROUGH, _full_bundle())
|
||||||
kinds = [m.kind for m in plan.moves]
|
kinds = [m.kind for m in plan.moves]
|
||||||
assert kinds == [DiscourseMoveKind.ANCHOR]
|
assert kinds[0] is DiscourseMoveKind.ANCHOR
|
||||||
|
assert DiscourseMoveKind.CLOSURE in kinds or DiscourseMoveKind.RELATION in kinds
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
239
tests/test_discourse_planner_walkthrough.py
Normal file
239
tests/test_discourse_planner_walkthrough.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""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
|
||||||
Loading…
Reference in a new issue