core/tests/test_discourse_planner_helper.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

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()