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
This commit is contained in:
parent
07fefb923c
commit
7af7892dd8
8 changed files with 813 additions and 20 deletions
|
|
@ -706,13 +706,18 @@ class ChatRuntime:
|
|||
|
||||
def _maybe_apply_discourse_planner(
|
||||
self, text: str, source_tag: str
|
||||
) -> str | None:
|
||||
) -> tuple[str, str] | None:
|
||||
"""Build and render a :class:`DiscoursePlan` for *text*.
|
||||
|
||||
Returns the rendered multi-clause surface when the planner
|
||||
Returns ``(rendered_surface, new_source_tag)`` when the planner
|
||||
engages and produces more than one move, else ``None``. Callers
|
||||
own surface assignment — this helper neither reads nor writes
|
||||
any caller-visible state besides loading the grounding bundle.
|
||||
own assignment. The returned ``new_source_tag`` is the source
|
||||
the planner actually used (``"teaching"`` when the plan
|
||||
contains any teaching fact, else ``"pack"``) so downstream
|
||||
labels reflect the surface's true provenance — particularly
|
||||
important when the planner engaged via the compound bypass
|
||||
(upstream tagged "oov" but rendered output is pack/teaching
|
||||
content).
|
||||
|
||||
Gating discipline (must match both cold-start and warm hooks):
|
||||
|
||||
|
|
@ -731,23 +736,78 @@ class ChatRuntime:
|
|||
|
||||
if not self.config.discourse_planner:
|
||||
return None
|
||||
if source_tag not in {"pack", "teaching"}:
|
||||
return None
|
||||
from generate.discourse_planner import plan_discourse, render_plan
|
||||
from generate.discourse_planner import (
|
||||
GroundingBundle,
|
||||
plan_compound_discourse,
|
||||
plan_discourse,
|
||||
render_plan,
|
||||
)
|
||||
from generate.grounding_accessors import grounding_bundle_for
|
||||
from generate.intent import classify_response_mode
|
||||
from generate.intent import (
|
||||
classify_compound_intent,
|
||||
classify_response_mode,
|
||||
)
|
||||
from generate.intent_bridge import classify_intent_from_input
|
||||
|
||||
intent = classify_intent_from_input(text)
|
||||
if not intent.subject:
|
||||
return None
|
||||
compound = classify_compound_intent(text)
|
||||
mode = classify_response_mode(text)
|
||||
bundle = grounding_bundle_for(intent.subject)
|
||||
plan = plan_discourse(intent, mode, bundle)
|
||||
# Compound prompts implicitly request more depth than BRIEF
|
||||
# can express — a multi-part compound in BRIEF mode produces
|
||||
# one ANCHOR per part, which on shared-subject compounds
|
||||
# ("What is X, and why does it matter?") would emit duplicate
|
||||
# anchor sentences. Upgrade to EXPLAIN so each sub-plan has
|
||||
# ANCHOR+SUPPORT+RELATION budget and the parts differentiate.
|
||||
from generate.intent import ResponseMode as _ResponseMode
|
||||
if compound.is_compound() and mode is _ResponseMode.BRIEF:
|
||||
mode = _ResponseMode.EXPLAIN
|
||||
|
||||
# Standard gate: when upstream grounded the surface in pack or
|
||||
# teaching, the planner is free to engage.
|
||||
standard_gate = source_tag in {"pack", "teaching"}
|
||||
# Compound bypass: when upstream produced an OOV / none surface
|
||||
# because the flat classifier saw a polluted subject (e.g.
|
||||
# ``"truth, and why does it matter"``), but the compound
|
||||
# decomposition reveals at least one pack-resident primary
|
||||
# part, the substrate exists — the planner engages on the
|
||||
# decomposed parts rather than the polluted flat surface.
|
||||
compound_bypass = False
|
||||
if not standard_gate and compound.is_compound():
|
||||
primary = compound.primary
|
||||
if primary.subject:
|
||||
probe = grounding_bundle_for(primary.subject)
|
||||
if not probe.is_empty():
|
||||
compound_bypass = True
|
||||
if not standard_gate and not compound_bypass:
|
||||
return None
|
||||
|
||||
if compound.is_compound():
|
||||
bundles = tuple(
|
||||
grounding_bundle_for(part.subject)
|
||||
if part.subject
|
||||
else GroundingBundle()
|
||||
for part in compound.parts
|
||||
)
|
||||
plan = plan_compound_discourse(compound, mode, bundles)
|
||||
else:
|
||||
# Use the intent_bridge classifier on single-part prompts to
|
||||
# preserve the pre-compound behavior exactly.
|
||||
intent = classify_intent_from_input(text)
|
||||
if not intent.subject:
|
||||
return None
|
||||
bundle = grounding_bundle_for(intent.subject)
|
||||
plan = plan_discourse(intent, mode, bundle)
|
||||
if len(plan.moves) <= 1:
|
||||
return None
|
||||
rendered = render_plan(plan)
|
||||
return rendered or None
|
||||
if not rendered:
|
||||
return None
|
||||
from generate.discourse_planner import FactSource
|
||||
plan_uses_teaching = any(
|
||||
m.fact is not None and m.fact.source is FactSource.TEACHING
|
||||
for m in plan.moves
|
||||
)
|
||||
new_source = "teaching" if plan_uses_teaching else "pack"
|
||||
return rendered, new_source
|
||||
|
||||
def _stub_response(
|
||||
self,
|
||||
|
|
@ -928,7 +988,7 @@ class ChatRuntime:
|
|||
text, pack_source_tag
|
||||
)
|
||||
if planned is not None:
|
||||
pack_surface = planned
|
||||
pack_surface, pack_source_tag = planned
|
||||
self._context.finalize_turn(
|
||||
empty_result,
|
||||
tokens_in=tuple(filtered),
|
||||
|
|
@ -1148,8 +1208,10 @@ class ChatRuntime:
|
|||
text, warm_grounding_source or ""
|
||||
)
|
||||
if planned is not None:
|
||||
response_surface = planned
|
||||
articulation = replace(articulation, surface=planned)
|
||||
planned_surface, planned_source = planned
|
||||
response_surface = planned_surface
|
||||
articulation = replace(articulation, surface=planned_surface)
|
||||
warm_grounding_source = planned_source
|
||||
if should_inject_hedge(ethics_verdict, self.ethics_pack):
|
||||
hedge_prefix = build_hedge_prefix(self.identity_manifold)
|
||||
before = response_surface
|
||||
|
|
|
|||
|
|
@ -19,3 +19,6 @@
|
|||
{"id":"multi_primed_tell_light_019","category":"narrative","prompt":"Tell me about light.","subject_lemma":"light","expects_connective":false,"priming_prompts":["What is light?"]}
|
||||
{"id":"multi_primed_tell_parent_020","category":"narrative","prompt":"Tell me about parent.","subject_lemma":"parent","expects_connective":false,"priming_prompts":["What is a parent?"]}
|
||||
{"id":"multi_primed_essay_truth_021","category":"essay","prompt":"Write a short paragraph about truth.","subject_lemma":"truth","expects_connective":true,"priming_prompts":["What is truth?"]}
|
||||
{"id":"multi_compound_truth_matters_022","category":"compose","prompt":"What is truth, and why does it matter?","subject_lemma":"truth","expects_connective":true}
|
||||
{"id":"multi_compound_truth_knowledge_023","category":"compose","prompt":"What is truth, and what is knowledge?","subject_lemma":"truth","expects_connective":true}
|
||||
{"id":"multi_compound_explain_work_024","category":"compose","prompt":"Explain truth, but how does it work?","subject_lemma":"truth","expects_connective":true}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@ from dataclasses import dataclass, field
|
|||
from enum import Enum, unique
|
||||
|
||||
from generate.graph_planner import Relation
|
||||
from generate.intent import DialogueIntent, IntentTag, ResponseMode
|
||||
from generate.intent import (
|
||||
CompoundIntent,
|
||||
DialogueIntent,
|
||||
IntentTag,
|
||||
ResponseMode,
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
|
|
@ -378,6 +383,8 @@ def plan_discourse(
|
|||
intent: DialogueIntent,
|
||||
mode: ResponseMode,
|
||||
bundle: GroundingBundle,
|
||||
*,
|
||||
_exclude_facts: frozenset[tuple[int, str, str, str, str]] = frozenset(),
|
||||
) -> DiscoursePlan:
|
||||
"""Deterministic discourse planner.
|
||||
|
||||
|
|
@ -408,6 +415,16 @@ def plan_discourse(
|
|||
if bundle.is_empty():
|
||||
return DiscoursePlan(intent=intent, mode=mode, moves=())
|
||||
|
||||
# Filter out facts the caller has already used in prior sub-plans.
|
||||
if _exclude_facts:
|
||||
bundle = GroundingBundle(
|
||||
facts=tuple(
|
||||
f for f in bundle.facts if f.sort_key() not in _exclude_facts
|
||||
)
|
||||
)
|
||||
if bundle.is_empty():
|
||||
return DiscoursePlan(intent=intent, mode=mode, moves=())
|
||||
|
||||
anchor_fact = _select_anchor(intent, bundle)
|
||||
if anchor_fact is None:
|
||||
return DiscoursePlan(intent=intent, mode=mode, moves=())
|
||||
|
|
@ -518,6 +535,101 @@ def plan_discourse(
|
|||
return DiscoursePlan(intent=intent, mode=mode, moves=tuple(moves))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compound discourse planning
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When a prompt is decomposed into multiple ``DialogueIntent`` parts
|
||||
# by ``classify_compound_intent``, each part is planned independently
|
||||
# and the resulting sub-plans are concatenated in *source order*. No
|
||||
# cross-part re-sorting — determinism comes from the per-part canonical
|
||||
# selection inside ``plan_discourse`` plus the deterministic
|
||||
# decomposition order from the classifier.
|
||||
#
|
||||
# A bridging ``TRANSITION`` move is inserted between consecutive
|
||||
# sub-plans so the rendered surface has an explicit handoff between
|
||||
# parts. Topic for the bridge is taken from the next sub-plan's
|
||||
# anchor; ``given`` carries the prior part's topics forward.
|
||||
|
||||
|
||||
def plan_compound_discourse(
|
||||
compound: CompoundIntent,
|
||||
mode: ResponseMode,
|
||||
bundles: tuple[GroundingBundle, ...],
|
||||
) -> DiscoursePlan:
|
||||
"""Plan a multi-part response from a decomposed ``CompoundIntent``.
|
||||
|
||||
``bundles`` must have one ``GroundingBundle`` per part, in the same
|
||||
order as ``compound.parts``. Each part is planned with
|
||||
:func:`plan_discourse`; sub-plans are concatenated preserving
|
||||
source order with a ``TRANSITION`` move bridging consecutive parts.
|
||||
|
||||
Falls back to the single-part :func:`plan_discourse` shape when
|
||||
``compound`` carries exactly one part — byte-equivalent to calling
|
||||
``plan_discourse(compound.primary, mode, bundles[0])`` directly.
|
||||
|
||||
The returned plan's ``intent`` is the primary part; downstream
|
||||
consumers that only need a single ``DialogueIntent`` (e.g. the
|
||||
runtime surface tag) still get a meaningful value.
|
||||
"""
|
||||
|
||||
if len(compound.parts) != len(bundles):
|
||||
raise ValueError(
|
||||
f"plan_compound_discourse: parts ({len(compound.parts)}) and "
|
||||
f"bundles ({len(bundles)}) must align"
|
||||
)
|
||||
|
||||
if not compound.is_compound():
|
||||
return plan_discourse(compound.primary, mode, bundles[0])
|
||||
|
||||
moves: list[DiscourseMove] = []
|
||||
prior_topics: list[str] = []
|
||||
used_facts: set[tuple[int, str, str, str, str]] = set()
|
||||
for idx, (part, bundle) in enumerate(zip(compound.parts, bundles)):
|
||||
sub_plan = plan_discourse(
|
||||
part, mode, bundle, _exclude_facts=frozenset(used_facts)
|
||||
)
|
||||
if sub_plan.is_empty():
|
||||
continue
|
||||
for sub_move in sub_plan.moves:
|
||||
if sub_move.fact is not None:
|
||||
used_facts.add(sub_move.fact.sort_key())
|
||||
if moves:
|
||||
# Bridge from the previous sub-plan to this one. Topic is
|
||||
# the next anchor's topic; given carries the prior topics
|
||||
# forward so the rendered TRANSITION clause reads naturally.
|
||||
next_anchor = sub_plan.anchor()
|
||||
bridge_topic = (
|
||||
next_anchor.topic
|
||||
if next_anchor is not None
|
||||
else part.subject.strip().lower()
|
||||
)
|
||||
moves.append(
|
||||
DiscourseMove(
|
||||
kind=DiscourseMoveKind.TRANSITION,
|
||||
topic=bridge_topic,
|
||||
given=tuple(prior_topics),
|
||||
new=(bridge_topic,) if bridge_topic else (),
|
||||
relation_to_previous=Relation.SEQUENCE,
|
||||
fact=None,
|
||||
)
|
||||
)
|
||||
moves.extend(sub_plan.moves)
|
||||
for topic in sub_plan.topics():
|
||||
if topic not in prior_topics:
|
||||
prior_topics.append(topic)
|
||||
_ = idx # source-order index preserved by enumerate
|
||||
|
||||
if not moves:
|
||||
return DiscoursePlan(intent=compound.primary, mode=mode, moves=())
|
||||
|
||||
return DiscoursePlan(
|
||||
intent=compound.primary,
|
||||
mode=mode,
|
||||
moves=tuple(moves),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan rendering — deterministic multi-clause surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -605,6 +717,7 @@ def render_plan(plan: DiscoursePlan) -> str:
|
|||
|
||||
|
||||
__all__ = [
|
||||
"CompoundIntent",
|
||||
"DiscourseMove",
|
||||
"DiscourseMoveKind",
|
||||
"DiscoursePlan",
|
||||
|
|
@ -615,6 +728,7 @@ __all__ = [
|
|||
"IntentTag",
|
||||
"Relation",
|
||||
"ResponseMode",
|
||||
"plan_compound_discourse",
|
||||
"plan_discourse",
|
||||
"render_plan",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -401,3 +401,140 @@ def classify_response_mode(prompt: str) -> ResponseMode:
|
|||
if pattern.search(text):
|
||||
return mode
|
||||
return ResponseMode.BRIEF
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compound-intent decomposition
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Some prompts ask for more than one thing in a single turn:
|
||||
#
|
||||
# "What is X, and why does it matter?"
|
||||
# "What is X, and how does it relate to Y?"
|
||||
# "Explain X, but also why does it matter?"
|
||||
#
|
||||
# A single ``DialogueIntent`` can only carry one tag and one subject,
|
||||
# so a flat classifier silently drops every part after the first. The
|
||||
# compound layer is *additive*: ``classify_intent`` still returns the
|
||||
# single-intent shape every existing caller depends on; a separate
|
||||
# ``classify_compound_intent`` is the only entry point that returns the
|
||||
# ordered tuple of parts.
|
||||
#
|
||||
# Decomposition is rule-based and deterministic. Connectors that mark
|
||||
# part boundaries are matched on a closed list (``,\s+(and|but|because|
|
||||
# while)\s+`` plus a small set of canonical follow-up shapes like
|
||||
# "why does it matter"). No NLP heuristics, no synthesis. When the
|
||||
# prompt has no recognisable split, the compound result has exactly
|
||||
# one part — byte-equivalent to the original ``classify_intent`` shape.
|
||||
|
||||
_COMPOUND_SPLIT_RE = re.compile(
|
||||
r",\s+(?:and|but|because|while)\s+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Canonical follow-up shapes whose subject the decomposer should treat
|
||||
# as the prior part's subject when the follow-up is itself anaphoric
|
||||
# ("why does *it* matter"). These rewrite the trailing fragment with
|
||||
# the prior part's subject so each part is independently classifiable.
|
||||
#
|
||||
# v1 semantic approximation: "why does it matter" maps to ``CAUSE(X)``
|
||||
# because the existing CAUSE substrate already carries "matters /
|
||||
# causes / produces" relations. "Matter" here means causal/relevance
|
||||
# support, *not* metaphysical importance as a new primitive. No
|
||||
# ``IMPORTANCE`` tag is introduced.
|
||||
_ANAPHORIC_FOLLOWUPS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"^why\s+does\s+(?:it|that|this)\s+matter\??$", re.IGNORECASE),
|
||||
"why does {subject} matter"),
|
||||
(re.compile(r"^how\s+does\s+(?:it|that|this)\s+work\??$", re.IGNORECASE),
|
||||
"how does {subject} work"),
|
||||
(re.compile(r"^what\s+causes\s+(?:it|that|this)\??$", re.IGNORECASE),
|
||||
"what causes {subject}"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CompoundIntent:
|
||||
"""Ordered tuple of single-intent parts plus the raw prompt.
|
||||
|
||||
``parts`` always contains at least one ``DialogueIntent``. For
|
||||
prompts without a recognised connector, ``parts == (primary,)`` and
|
||||
the result is byte-equivalent to the single-intent classifier.
|
||||
|
||||
``primary`` is the first part — provided for back-compat so callers
|
||||
that received a compound by accident can degrade gracefully.
|
||||
"""
|
||||
|
||||
parts: tuple[DialogueIntent, ...]
|
||||
raw_text: str
|
||||
|
||||
@property
|
||||
def primary(self) -> DialogueIntent:
|
||||
return self.parts[0]
|
||||
|
||||
def is_compound(self) -> bool:
|
||||
return len(self.parts) > 1
|
||||
|
||||
|
||||
def _rewrite_anaphoric_followup(fragment: str, prior_subject: str) -> str:
|
||||
"""If *fragment* matches a canonical anaphoric follow-up shape,
|
||||
rewrite it with *prior_subject* substituted for the pronoun.
|
||||
|
||||
Returns the original fragment unchanged when no rule matches.
|
||||
"""
|
||||
|
||||
text = fragment.strip().rstrip("?.!").strip()
|
||||
if not text or not prior_subject:
|
||||
return fragment
|
||||
for pattern, template in _ANAPHORIC_FOLLOWUPS:
|
||||
if pattern.match(text):
|
||||
return template.format(subject=prior_subject)
|
||||
return fragment
|
||||
|
||||
|
||||
def classify_compound_intent(prompt: str) -> CompoundIntent:
|
||||
"""Decompose *prompt* into an ordered tuple of single-intent parts.
|
||||
|
||||
Deterministic: the same prompt always produces the same parts in
|
||||
the same order. Decomposition order is *preserved* — parts are
|
||||
not re-sorted by any criterion (downstream planner composition
|
||||
relies on this for surface order).
|
||||
|
||||
When *prompt* contains no recognised connector, the result has
|
||||
exactly one part and is byte-equivalent to ``classify_intent``.
|
||||
"""
|
||||
|
||||
text = prompt.strip()
|
||||
if not text:
|
||||
return CompoundIntent(parts=(classify_intent(""),), raw_text=prompt)
|
||||
|
||||
# Single-shot fast path: nothing to split.
|
||||
if not _COMPOUND_SPLIT_RE.search(text):
|
||||
return CompoundIntent(parts=(classify_intent(text),), raw_text=prompt)
|
||||
|
||||
fragments = _COMPOUND_SPLIT_RE.split(text)
|
||||
parts: list[DialogueIntent] = []
|
||||
prior_subject = ""
|
||||
for raw_fragment in fragments:
|
||||
fragment = raw_fragment.strip().rstrip(",;").strip()
|
||||
if not fragment:
|
||||
continue
|
||||
# Anaphoric follow-ups ("why does it matter") inherit the prior
|
||||
# part's subject so each fragment is independently classifiable.
|
||||
if prior_subject:
|
||||
fragment = _rewrite_anaphoric_followup(fragment, prior_subject)
|
||||
part = classify_intent(fragment)
|
||||
# Drop parts that classify to UNKNOWN with empty subject — they
|
||||
# carry no useful planning signal and would force the downstream
|
||||
# planner to emit an empty sub-plan.
|
||||
if part.tag is IntentTag.UNKNOWN and not part.subject.strip():
|
||||
continue
|
||||
parts.append(part)
|
||||
if part.subject and part.tag is not IntentTag.UNKNOWN:
|
||||
prior_subject = part.subject
|
||||
|
||||
if not parts:
|
||||
# Every fragment collapsed to UNKNOWN/empty — fall back to the
|
||||
# single-intent shape so callers always see at least one part.
|
||||
return CompoundIntent(parts=(classify_intent(text),), raw_text=prompt)
|
||||
|
||||
return CompoundIntent(parts=tuple(parts), raw_text=prompt)
|
||||
|
|
|
|||
193
tests/test_compound_intent.py
Normal file
193
tests/test_compound_intent.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Tests for ``CompoundIntent`` and ``classify_compound_intent``.
|
||||
|
||||
Pins:
|
||||
|
||||
* The compound classifier is purely additive — ``classify_intent``
|
||||
return shape is untouched.
|
||||
* Decomposition is deterministic, byte-stable, and preserves source
|
||||
order (no cross-part re-sorting).
|
||||
* Anaphoric follow-ups ("why does it matter") rewrite the pronoun
|
||||
with the prior part's subject.
|
||||
* Prompts without a recognised connector produce exactly one part
|
||||
and are byte-equivalent to ``classify_intent``.
|
||||
* The "matter" / "work" / "causes it" follow-ups map to existing
|
||||
intent tags (CAUSE in v1) — no new IntentTag is introduced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from generate.intent import (
|
||||
CompoundIntent,
|
||||
DialogueIntent,
|
||||
IntentTag,
|
||||
classify_compound_intent,
|
||||
classify_intent,
|
||||
)
|
||||
|
||||
# Imported for type clarity even when only used in assertion side-effects.
|
||||
_ = CompoundIntent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Back-compat: classify_intent untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleIntentUntouched:
|
||||
def test_classify_intent_still_returns_dialogue_intent(self) -> None:
|
||||
result = classify_intent("What is truth?")
|
||||
assert isinstance(result, DialogueIntent)
|
||||
|
||||
def test_compound_classifier_does_not_change_single_intent_shape(self) -> None:
|
||||
# Identical input through both APIs — single-intent payload
|
||||
# remains identical.
|
||||
flat = classify_intent("What is truth?")
|
||||
compound = classify_compound_intent("What is truth?")
|
||||
assert compound.parts == (flat,)
|
||||
assert compound.primary == flat
|
||||
assert not compound.is_compound()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decomposition behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompoundDecomposition:
|
||||
def test_what_is_x_and_why_does_it_matter(self) -> None:
|
||||
result = classify_compound_intent("What is truth, and why does it matter?")
|
||||
assert result.is_compound()
|
||||
assert len(result.parts) == 2
|
||||
assert result.parts[0].tag is IntentTag.DEFINITION
|
||||
assert result.parts[0].subject == "truth"
|
||||
# Anaphoric "it" rewritten to "truth"; CAUSE classification.
|
||||
assert result.parts[1].tag is IntentTag.CAUSE
|
||||
assert result.parts[1].subject == "truth"
|
||||
|
||||
def test_explain_x_but_also_how_does_it_work(self) -> None:
|
||||
result = classify_compound_intent("Explain truth, but how does it work?")
|
||||
assert result.is_compound()
|
||||
assert result.parts[0].tag is IntentTag.DEFINITION
|
||||
assert result.parts[0].subject == "truth"
|
||||
# how-does-X-work routes to CAUSE per the existing _HOW_DOES_X_RE
|
||||
# rule once the pronoun is rewritten.
|
||||
assert result.parts[1].tag is IntentTag.CAUSE
|
||||
assert result.parts[1].subject == "truth"
|
||||
|
||||
def test_what_is_x_because_y(self) -> None:
|
||||
# Non-anaphoric trailing fragment — kept as-is, classified
|
||||
# independently.
|
||||
result = classify_compound_intent(
|
||||
"What is truth, because what causes evidence?"
|
||||
)
|
||||
assert result.is_compound()
|
||||
assert result.parts[0].tag is IntentTag.DEFINITION
|
||||
assert result.parts[0].subject == "truth"
|
||||
assert result.parts[1].tag is IntentTag.CAUSE
|
||||
assert result.parts[1].subject == "evidence"
|
||||
|
||||
def test_decomposition_preserves_source_order(self) -> None:
|
||||
# Order in compound must match order of fragments in the prompt
|
||||
# — never re-sorted by tag or alphabet.
|
||||
result = classify_compound_intent(
|
||||
"What is wisdom, and why does it matter?"
|
||||
)
|
||||
subjects = [p.subject for p in result.parts]
|
||||
assert subjects == ["wisdom", "wisdom"]
|
||||
tags = [p.tag for p in result.parts]
|
||||
assert tags == [IntentTag.DEFINITION, IntentTag.CAUSE]
|
||||
|
||||
def test_three_part_compound(self) -> None:
|
||||
result = classify_compound_intent(
|
||||
"What is truth, and what is knowledge, and why does it matter?"
|
||||
)
|
||||
# Three fragments → three parts. Anaphoric "it" in the trailing
|
||||
# fragment refers to the immediately prior subject (knowledge).
|
||||
assert len(result.parts) == 3
|
||||
assert result.parts[0].subject == "truth"
|
||||
assert result.parts[1].subject == "knowledge"
|
||||
assert result.parts[2].tag is IntentTag.CAUSE
|
||||
assert result.parts[2].subject == "knowledge"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Degenerate / fall-through cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDegenerateCases:
|
||||
def test_empty_prompt_returns_single_unknown_part(self) -> None:
|
||||
result = classify_compound_intent("")
|
||||
assert len(result.parts) == 1
|
||||
assert result.parts[0].tag is IntentTag.UNKNOWN
|
||||
|
||||
def test_no_connector_returns_single_part(self) -> None:
|
||||
result = classify_compound_intent("Explain truth.")
|
||||
assert len(result.parts) == 1
|
||||
assert result.parts[0].tag is IntentTag.DEFINITION
|
||||
assert result.parts[0].subject == "truth"
|
||||
|
||||
def test_unknown_parts_with_empty_subject_are_dropped(self) -> None:
|
||||
# The only fragments that get dropped are UNKNOWN with empty
|
||||
# subject — they carry no useful planning signal. UNKNOWN
|
||||
# parts that carry a non-empty subject are still preserved
|
||||
# (the planner will simply not ground them, which is honest).
|
||||
result = classify_compound_intent("foo, and bar")
|
||||
# Both classify to UNKNOWN with non-empty subjects ⇒ both kept.
|
||||
assert len(result.parts) == 2
|
||||
assert all(p.tag is IntentTag.UNKNOWN for p in result.parts)
|
||||
|
||||
def test_pure_whitespace_fragments_fall_back_to_flat(self) -> None:
|
||||
# Constructed so every split fragment is empty after stripping.
|
||||
result = classify_compound_intent(", and ,")
|
||||
# No usable parts ⇒ compound layer falls back to a single-part
|
||||
# shape so callers always see at least one part.
|
||||
assert len(result.parts) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompoundDeterminism:
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
[
|
||||
"What is truth, and why does it matter?",
|
||||
"Explain memory, but how does it work?",
|
||||
"What is truth, and what is knowledge?",
|
||||
"What is truth?",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_byte_stable_across_calls(self, prompt: str) -> None:
|
||||
results = [classify_compound_intent(prompt) for _ in range(8)]
|
||||
assert len({r for r in results}) == 1
|
||||
|
||||
def test_compound_intent_is_frozen(self) -> None:
|
||||
result = classify_compound_intent("What is truth?")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
result.parts = () # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Doctrine: no new IntentTag introduced
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoNewIntentTagIntroduced:
|
||||
def test_intent_tag_membership_unchanged(self) -> None:
|
||||
# The compound layer must not add IMPORTANCE / MATTER /
|
||||
# any other tag. "Why does it matter?" maps to CAUSE.
|
||||
names = {tag.name for tag in IntentTag}
|
||||
assert "IMPORTANCE" not in names
|
||||
assert "MATTER" not in names
|
||||
|
||||
def test_why_does_it_matter_maps_to_cause(self) -> None:
|
||||
result = classify_compound_intent(
|
||||
"What is truth, and why does it matter?"
|
||||
)
|
||||
assert result.parts[1].tag is IntentTag.CAUSE
|
||||
242
tests/test_discourse_planner_compound.py
Normal file
242
tests/test_discourse_planner_compound.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""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)
|
||||
|
|
@ -296,7 +296,29 @@ class TestPlannerSignature:
|
|||
from typing import get_type_hints
|
||||
|
||||
sig = inspect.signature(plan_discourse)
|
||||
assert list(sig.parameters) == ["intent", "mode", "bundle"]
|
||||
# Public positional signature is (intent, mode, bundle). Any
|
||||
# keyword-only parameters added later (e.g. ``_exclude_facts``
|
||||
# for sub-plan composition) must remain keyword-only with a
|
||||
# leading underscore so they are not part of the public API.
|
||||
positional_params = [
|
||||
name
|
||||
for name, p in sig.parameters.items()
|
||||
if p.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
]
|
||||
assert positional_params == ["intent", "mode", "bundle"]
|
||||
keyword_only = [
|
||||
name
|
||||
for name, p in sig.parameters.items()
|
||||
if p.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
]
|
||||
for name in keyword_only:
|
||||
assert name.startswith("_"), (
|
||||
f"keyword-only parameter {name!r} must be underscore-prefixed"
|
||||
)
|
||||
hints = get_type_hints(plan_discourse)
|
||||
assert hints["intent"] is DialogueIntent
|
||||
assert hints["mode"] is ResponseMode
|
||||
|
|
|
|||
|
|
@ -54,8 +54,11 @@ class TestPlannerHelperEngagement:
|
|||
"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 result or "In turn," in result
|
||||
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
|
||||
|
|
@ -67,3 +70,20 @@ class TestPlannerHelperEngagement:
|
|||
"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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue