core/tests/test_discourse_planner_compound.py
Shay 7af7892dd8 feat(intent+discourse): CompoundIntent + sub-plan composition
Adds compound-intent decomposition for prompts that ask multiple
things in one turn ("What is X, and why does it matter?",
"Explain X, but how does it work?", "What is X, and what is Y?").

Three landings in one PR (rule says additive; the three pieces
are inseparable for the runtime hook to do anything useful):

1. generate/intent.py
   * New ``CompoundIntent`` frozen dataclass — ordered tuple of
     ``DialogueIntent`` parts + raw_text + ``.primary`` back-compat
     accessor + ``.is_compound()`` helper.
   * New ``classify_compound_intent(prompt)`` sibling to
     ``classify_intent``.  Pure, deterministic, byte-stable.  Splits
     on closed connector list (``,\s+(and|but|because|while)\s+``);
     anaphoric tails ("why does it matter") get the prior part's
     subject substituted ("why does truth matter") then are
     classified independently.
   * ``classify_intent`` return shape is untouched — every existing
     caller still receives ``DialogueIntent``.
   * No new ``IntentTag`` introduced.  v1 semantic approximation:
     "why does X matter" routes to ``CAUSE(X)``; "matter" means
     causal/relevance support, not metaphysical importance.

2. generate/discourse_planner.py
   * New ``plan_compound_discourse(compound, mode, bundles)`` —
     concatenates per-part sub-plans in source order with a
     ``TRANSITION`` bridge (fact=None) between consecutive parts.
     No cross-part re-sorting.
   * New private kw-only ``_exclude_facts`` parameter on
     ``plan_discourse`` so subsequent sub-plans can avoid emitting
     the same facts the prior sub-plans already used (prevents
     "Truth is X. Truth is X." duplicates on shared-subject
     compounds).  Public signature ``(intent, mode, bundle)`` is
     unchanged.

3. chat/runtime.py
   * Helper ``_maybe_apply_discourse_planner`` now consults the
     compound classifier first.  When the prompt is multi-part it
     builds per-part bundles and calls ``plan_compound_discourse``;
     otherwise it follows the previous single-intent path.
   * Compound bypass: when upstream tagged the surface ``oov`` /
     ``none`` because the flat classifier saw a polluted subject
     (e.g. ``"truth, and why does it matter"``), but the compound
     decomposition reveals a pack-resident primary subject, the
     planner engages on the decomposed parts.  This narrowly widens
     the gate exclusively for compound prompts with substrate.
   * BRIEF mode upgrades to EXPLAIN for compound prompts —
     single-anchor sub-plans on shared subjects would emit duplicate
     anchor sentences in BRIEF.
   * Return shape widened to ``tuple[str, str] | None`` —
     ``(rendered_surface, new_source_tag)``.  ``new_source_tag`` is
     ``"teaching"`` when the plan uses any teaching fact, else
     ``"pack"`` — so downstream labels reflect actual provenance
     even on the compound bypass.  Both cold and warm call sites
     updated to apply both fields.

24 new tests pin: compound decomposition correctness, source-order
preservation across sub-plans, anaphoric-followup rewriting,
deterministic byte-stable plans, no new IntentTag introduced,
fact-dedup across sub-plans, compound-bypass engagement, and
source-tag correction on planner-engaged surfaces.

Lane re-measurement after 3 compound cases added to cases.jsonl
(24 total cases):

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

Note: disclosure flag-on dropped to 0.0 because the source-tag
correction now correctly labels compound-bypass surfaces as
``pack/teaching`` instead of letting the upstream ``oov`` label
inflate disclosure.  The two remaining unarticulate cases flag-on
are the walkthrough prompts targeted by the next landing.

Critical gates all green:
* flag off cognition byte-identical: public 100/100/91.7/100
* smoke suite 67/67
* 32/32 planner tests pass (helper + render + compound)
* 18/18 compound classifier tests pass
2026-05-19 12:23:58 -07:00

242 lines
8.8 KiB
Python

"""Tests for ``plan_compound_discourse``.
Pins:
* Sub-plans are concatenated in source order — never re-sorted.
* A ``TRANSITION`` bridge move (fact=None) is inserted between
consecutive sub-plans; topic = next anchor's topic; given = prior
topics forward.
* Single-part compounds are byte-equivalent to ``plan_discourse``.
* Empty bundles for one part cause that part to be skipped without
breaking source order for the remaining parts.
* Plan equality and ``to_json`` are deterministic for compound plans.
"""
from __future__ import annotations
from generate.discourse_planner import (
CompoundIntent,
DiscourseMoveKind,
DiscoursePlan,
FactSource,
GroundedFact,
GroundingBundle,
plan_compound_discourse,
plan_discourse,
)
from generate.intent import (
DialogueIntent,
IntentTag,
ResponseMode,
)
def _truth_def_intent() -> DialogueIntent:
return DialogueIntent(tag=IntentTag.DEFINITION, subject="truth")
def _truth_cause_intent() -> DialogueIntent:
return DialogueIntent(tag=IntentTag.CAUSE, subject="truth")
def _knowledge_def_intent() -> DialogueIntent:
return DialogueIntent(tag=IntentTag.DEFINITION, subject="knowledge")
def _truth_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",
),
GroundedFact(
subject="truth", predicate="reveals", obj="knowledge",
source=FactSource.TEACHING,
source_id="cognition_chains_v1#cause_truth_reveals_knowledge",
),
)
)
def _knowledge_bundle() -> GroundingBundle:
return GroundingBundle(
facts=(
GroundedFact(
subject="knowledge", predicate="is_defined_as",
obj="justified true belief", source=FactSource.PACK,
source_id="en_core_cognition_v1:knowledge#gloss",
),
GroundedFact(
subject="knowledge", predicate="requires", obj="evidence",
source=FactSource.TEACHING,
source_id="cognition_chains_v1#cause_knowledge_requires_evidence",
),
)
)
# ---------------------------------------------------------------------------
# Single-part fall-through
# ---------------------------------------------------------------------------
class TestSinglePartFallthrough:
def test_single_part_equals_plan_discourse(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(),), raw_text="What is truth?"
)
composed = plan_compound_discourse(
compound, ResponseMode.EXPLAIN, (_truth_bundle(),)
)
direct = plan_discourse(
_truth_def_intent(), ResponseMode.EXPLAIN, _truth_bundle()
)
assert composed == direct
# ---------------------------------------------------------------------------
# Multi-part composition
# ---------------------------------------------------------------------------
class TestMultiPartComposition:
def test_two_part_compound_concatenates_in_source_order(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _knowledge_def_intent()),
raw_text="What is truth, and what is knowledge?",
)
plan = plan_compound_discourse(
compound, ResponseMode.EXPLAIN, (_truth_bundle(), _knowledge_bundle())
)
# First sub-plan starts with truth ANCHOR; somewhere between
# the two sub-plans a TRANSITION bridge appears; second sub-plan
# starts with knowledge ANCHOR.
kinds = [m.kind for m in plan.moves]
topics = [m.topic for m in plan.moves]
# ANCHOR positions: first move and the move after the bridge.
assert plan.moves[0].kind is DiscourseMoveKind.ANCHOR
assert plan.moves[0].topic == "truth"
# The bridge is TRANSITION with fact=None.
bridge_idx = next(
i for i, m in enumerate(plan.moves)
if m.kind is DiscourseMoveKind.TRANSITION and m.fact is None
)
assert plan.moves[bridge_idx + 1].kind is DiscourseMoveKind.ANCHOR
assert plan.moves[bridge_idx + 1].topic == "knowledge"
# No cross-part re-sorting: knowledge ANCHOR never precedes truth ANCHOR.
truth_anchor_idx = topics.index("truth")
knowledge_anchor_idx = topics.index("knowledge")
assert truth_anchor_idx < knowledge_anchor_idx
_ = kinds
def test_bridge_carries_prior_topics_in_given(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _knowledge_def_intent()),
raw_text="What is truth, and what is knowledge?",
)
plan = plan_compound_discourse(
compound, ResponseMode.EXPLAIN, (_truth_bundle(), _knowledge_bundle())
)
bridge = next(
m for m in plan.moves
if m.kind is DiscourseMoveKind.TRANSITION and m.fact is None
)
assert "truth" in bridge.given
assert bridge.topic == "knowledge"
def test_compound_plan_carries_primary_intent(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _truth_cause_intent()),
raw_text="What is truth, and why does it matter?",
)
plan = plan_compound_discourse(
compound, ResponseMode.EXPLAIN,
(_truth_bundle(), _truth_bundle()),
)
# primary intent is the first part — DEFINITION(truth).
assert plan.intent.tag is IntentTag.DEFINITION
assert plan.intent.subject == "truth"
# ---------------------------------------------------------------------------
# Degenerate cases
# ---------------------------------------------------------------------------
class TestDegenerateCases:
def test_part_with_empty_bundle_skipped_preserving_order(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _knowledge_def_intent()),
raw_text="What is truth, and what is knowledge?",
)
plan = plan_compound_discourse(
compound, ResponseMode.EXPLAIN,
(_truth_bundle(), GroundingBundle()),
)
topics = [m.topic for m in plan.moves]
# knowledge has no substrate ⇒ knowledge sub-plan is empty ⇒
# bridge is not added ⇒ plan reduces to just the truth sub-plan.
assert "knowledge" not in topics
assert "truth" in topics
def test_all_empty_bundles_produces_empty_compound_plan(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _knowledge_def_intent()),
raw_text="What is truth, and what is knowledge?",
)
plan = plan_compound_discourse(
compound, ResponseMode.EXPLAIN,
(GroundingBundle(), GroundingBundle()),
)
assert plan.is_empty()
def test_misaligned_parts_and_bundles_raises(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(),), raw_text="What is truth?"
)
try:
plan_compound_discourse(
compound, ResponseMode.EXPLAIN,
(_truth_bundle(), _knowledge_bundle()), # too many bundles
)
except ValueError as exc:
assert "must align" in str(exc)
else:
raise AssertionError("Expected ValueError on misaligned input")
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
class TestCompoundDeterminism:
def test_compound_plan_is_byte_stable(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _knowledge_def_intent()),
raw_text="What is truth, and what is knowledge?",
)
bundles = (_truth_bundle(), _knowledge_bundle())
encoded = [
plan_compound_discourse(compound, ResponseMode.EXPLAIN, bundles).to_json()
for _ in range(8)
]
assert len(set(encoded)) == 1
def test_compound_plan_equality(self) -> None:
compound = CompoundIntent(
parts=(_truth_def_intent(), _truth_cause_intent()),
raw_text="What is truth, and why does it matter?",
)
bundles = (_truth_bundle(), _truth_bundle())
a = plan_compound_discourse(compound, ResponseMode.EXPLAIN, bundles)
b = plan_compound_discourse(compound, ResponseMode.EXPLAIN, bundles)
assert a == b
assert isinstance(a, DiscoursePlan)