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
89 lines
3.3 KiB
Python
89 lines
3.3 KiB
Python
"""Tests for ``ChatRuntime._maybe_apply_discourse_planner``.
|
|
|
|
Pins the single-helper contract that the cold and warm runtime hooks
|
|
both call. These tests are unit-level — they exercise the helper
|
|
directly with a real ``ChatRuntime``, without driving the full
|
|
``chat`` pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from chat.runtime import ChatRuntime
|
|
from core.config import RuntimeConfig
|
|
|
|
|
|
@pytest.fixture()
|
|
def runtime_flag_off() -> ChatRuntime:
|
|
return ChatRuntime(config=RuntimeConfig(discourse_planner=False))
|
|
|
|
|
|
@pytest.fixture()
|
|
def runtime_flag_on() -> ChatRuntime:
|
|
return ChatRuntime(config=RuntimeConfig(discourse_planner=True))
|
|
|
|
|
|
class TestPlannerHelperGating:
|
|
def test_flag_off_returns_none(self, runtime_flag_off: ChatRuntime) -> None:
|
|
result = runtime_flag_off._maybe_apply_discourse_planner(
|
|
"Tell me about truth.", "teaching"
|
|
)
|
|
assert result is None
|
|
|
|
@pytest.mark.parametrize("tag", ["vault", "none", "oov", "", "unknown"])
|
|
def test_non_grounded_source_returns_none(
|
|
self, runtime_flag_on: ChatRuntime, tag: str
|
|
) -> None:
|
|
result = runtime_flag_on._maybe_apply_discourse_planner(
|
|
"Tell me about truth.", tag
|
|
)
|
|
assert result is None
|
|
|
|
def test_empty_subject_returns_none(self, runtime_flag_on: ChatRuntime) -> None:
|
|
# An unclassified prompt has no head-noun subject.
|
|
result = runtime_flag_on._maybe_apply_discourse_planner("", "pack")
|
|
assert result is None
|
|
|
|
|
|
class TestPlannerHelperEngagement:
|
|
def test_returns_multi_clause_surface_on_grounded_subject(
|
|
self, runtime_flag_on: ChatRuntime
|
|
) -> None:
|
|
result = runtime_flag_on._maybe_apply_discourse_planner(
|
|
"Tell me about truth.", "teaching"
|
|
)
|
|
assert result is not None
|
|
surface, source = result
|
|
# Multi-clause: at least one connective from the canonical table.
|
|
assert "Furthermore," in surface or "In turn," in surface
|
|
# Source is one of the two grounded labels — never "oov" or "none".
|
|
assert source in {"pack", "teaching"}
|
|
|
|
def test_returns_none_for_single_move_plan(
|
|
self, runtime_flag_on: ChatRuntime
|
|
) -> None:
|
|
# BRIEF mode (default for "What is X?") collapses to ANCHOR-only;
|
|
# helper must return None so callers don't replace the byte-
|
|
# identical single-sentence pack-grounded surface.
|
|
result = runtime_flag_on._maybe_apply_discourse_planner(
|
|
"What is truth?", "pack"
|
|
)
|
|
assert result is None
|
|
|
|
def test_compound_prompt_engages_via_oov_bypass(
|
|
self, runtime_flag_on: ChatRuntime
|
|
) -> None:
|
|
# Compound bypass: upstream tagged the surface "oov" because
|
|
# the flat classifier saw a polluted subject, but the compound
|
|
# decomposition reveals a pack-resident primary subject. The
|
|
# helper should engage and return a grounded source tag.
|
|
result = runtime_flag_on._maybe_apply_discourse_planner(
|
|
"What is truth, and what is knowledge?", "oov"
|
|
)
|
|
assert result is not None
|
|
surface, source = result
|
|
assert source in {"pack", "teaching"}
|
|
# Both subjects should appear in the rendered surface.
|
|
assert "truth" in surface.lower()
|
|
assert "knowledge" in surface.lower()
|