diff --git a/chat/runtime.py b/chat/runtime.py index 21abe818..0e38ee40 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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 diff --git a/evals/multi_sentence_response/public/v1/cases.jsonl b/evals/multi_sentence_response/public/v1/cases.jsonl index 233b9f86..5f921a4c 100644 --- a/evals/multi_sentence_response/public/v1/cases.jsonl +++ b/evals/multi_sentence_response/public/v1/cases.jsonl @@ -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} diff --git a/generate/discourse_planner.py b/generate/discourse_planner.py index bff586ec..ea8eccd0 100644 --- a/generate/discourse_planner.py +++ b/generate/discourse_planner.py @@ -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", ] diff --git a/generate/intent.py b/generate/intent.py index 7ce8adf2..d389e7a3 100644 --- a/generate/intent.py +++ b/generate/intent.py @@ -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) diff --git a/tests/test_compound_intent.py b/tests/test_compound_intent.py new file mode 100644 index 00000000..4d31f0ab --- /dev/null +++ b/tests/test_compound_intent.py @@ -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 diff --git a/tests/test_discourse_planner_compound.py b/tests/test_discourse_planner_compound.py new file mode 100644 index 00000000..d65bde10 --- /dev/null +++ b/tests/test_discourse_planner_compound.py @@ -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) diff --git a/tests/test_discourse_planner_contract.py b/tests/test_discourse_planner_contract.py index 9dab13c3..8d3b49f6 100644 --- a/tests/test_discourse_planner_contract.py +++ b/tests/test_discourse_planner_contract.py @@ -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 diff --git a/tests/test_discourse_planner_helper.py b/tests/test_discourse_planner_helper.py index b3692b91..93097df2 100644 --- a/tests/test_discourse_planner_helper.py +++ b/tests/test_discourse_planner_helper.py @@ -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()