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
242 lines
8.8 KiB
Python
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)
|