From 30948a1605388603ab2ceb086dc602c5754fc0e8 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 19 May 2026 11:29:25 -0700 Subject: [PATCH] feat(runtime): wire discourse planner behind RuntimeConfig flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the discourse-planner sequencing. Closes the chain: classify_intent + classify_response_mode -> grounding_bundle_for(subject) -> plan_discourse(intent, mode, bundle) -> render_plan(plan) -> response_surface Adds RuntimeConfig.discourse_planner (default False). When True, the runtime — after the warm pack/teaching-grounded surface is set — classifies the response mode, assembles a GroundingBundle from the ADR-style accessors, builds a DiscoursePlan, and replaces the warm surface with the deterministic multi-clause rendering whenever the plan has more than one move. Gating discipline: * Engages only on warm_grounding_source in {"pack", "teaching"} so vault/none turns and the discovery-signal CAUSE/VERIFICATION disclosure are preserved exactly. * BRIEF mode always collapses to a single ANCHOR move, so flag-on with BRIEF intent is byte-identical to flag-off. * Empty bundles produce empty plans; the runtime falls through to the existing warm surface untouched. Adds render_plan(plan) to generate/discourse_planner.py — a pure, deterministic multi-clause renderer with fixed canonical connectives: ANCHOR : capitalized opening sentence SUPPORT : "Furthermore, ..." RELATION : "In turn, ..." TRANSITION: "Consequently, ..." CLOSURE : skipped when fact is None Every visible token is a verbatim pack lexicon entry, gloss, or reviewed teaching chain string — no synthesis. 13 new tests pin: * render_plan empty/brief/paragraph shape * canonical connectives present in paragraph rendering * deterministic + verbatim-fact invariants * RuntimeConfig.discourse_planner defaults False * Flag-off surface has no planner connectives * Flag-on lifts produce structurally well-formed multi-sentence output on grounded substrate Lift measurement (multi_sentence_response public/v1, 15 cases): * flag off: multi=0.40, connective=0.50, grounded=0.40 * flag on : multi=0.40, connective=0.60, grounded=0.40 -> connective_present_rate +10pp; multi-sentence count flat because the existing narrative composer's literal "." chars in tags like "cognition.truth" already trigger sentence splits in the lane regex. Real lift is form quality: e.g. "Tell me about truth" now renders as "Truth is a claim or state grounded by evidence and coherent judgment. Furthermore, truth belongs to cognition.truth. In turn, truth grounds knowledge." instead of the prior provenance-laden narrative surface. Critical gates (all green): * flag off: cognition eval byte-identical - public 100/100/91.7/100, holdout 100/100/83.3/100 * smoke suite 67/67 * conversational_thread_coherence: 3 unwanted placeholders flag off and flag on (no regression) * planner JSON byte-stable across calls (contract tests) * grounding source order preserved (sidecar tests) --- chat/runtime.py | 36 +++++ core/config.py | 10 ++ generate/discourse_planner.py | 87 +++++++++++ tests/test_discourse_planner_render.py | 205 +++++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 tests/test_discourse_planner_render.py diff --git a/chat/runtime.py b/chat/runtime.py index 94fb0f1a..b33bd37a 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -1086,6 +1086,42 @@ class ChatRuntime: warm_pack_surface = prefix + warm_pack_surface response_surface = warm_pack_surface articulation = replace(articulation, surface=warm_pack_surface) + # Step 5 — discourse planner. Opt-in; engages only on + # pack/teaching-grounded turns where the response mode + # asks for more than a single-sentence brief. When the + # planner returns a multi-move plan, replace the warm + # surface with the deterministic multi-clause rendering. + # BRIEF mode always collapses to a single ANCHOR move so + # the flag-off path stays byte-identical to the existing + # composer. + if ( + self.config.discourse_planner + and warm_grounding_source in {"pack", "teaching"} + ): + from generate.discourse_planner import ( + plan_discourse, + render_plan, + ) + from generate.grounding_accessors import ( + grounding_bundle_for, + ) + from generate.intent import classify_response_mode + from generate.intent_bridge import ( + classify_intent_from_input, + ) + + _dintent = classify_intent_from_input(text) + _dmode = classify_response_mode(text) + if _dintent.subject: + _dbundle = grounding_bundle_for(_dintent.subject) + _dplan = plan_discourse(_dintent, _dmode, _dbundle) + if len(_dplan.moves) > 1: + _drendered = render_plan(_dplan) + if _drendered: + response_surface = _drendered + articulation = replace( + articulation, surface=_drendered + ) if should_inject_hedge(ethics_verdict, self.ethics_pack): hedge_prefix = build_hedge_prefix(self.identity_manifold) before = response_surface diff --git a/core/config.py b/core/config.py index 6f4b7dce..14a61cb5 100644 --- a/core/config.py +++ b/core/config.py @@ -82,6 +82,16 @@ class RuntimeConfig: # surface byte-identically. thread_anaphora: bool = False + # Discourse planner (step 5 of the discourse-planner sequencing). + # When True, the runtime builds a deterministic DiscoursePlan via + # ``generate.discourse_planner.plan_discourse`` from a + # ``GroundingBundle`` assembled by ``generate.grounding_accessors`` + # and renders it as multi-clause output. Mode selection comes from + # ``generate.intent.classify_response_mode``; BRIEF mode is + # byte-identical to today's single-sentence pack-grounded surface + # so the default-False path is fully preserved. + discourse_planner: bool = False + DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1" diff --git a/generate/discourse_planner.py b/generate/discourse_planner.py index eaf63b0e..bff586ec 100644 --- a/generate/discourse_planner.py +++ b/generate/discourse_planner.py @@ -518,6 +518,92 @@ def plan_discourse( return DiscoursePlan(intent=intent, mode=mode, moves=tuple(moves)) +# --------------------------------------------------------------------------- +# Plan rendering — deterministic multi-clause surface +# --------------------------------------------------------------------------- +# +# A first renderer that joins each move's grounded fact into a clause +# using fixed connectives. Step 5 of the discourse-planner sequencing +# uses this for the initial runtime wiring; a follow-up ADR will route +# plans through the existing PropositionGraph → realize_target spine. +# +# Every visible token in the rendered surface is either: +# * the subject/object of a GroundedFact (verbatim from pack lexicon +# or reviewed teaching corpus), +# * the gloss or semantic_domains string of a pack fact (verbatim), +# * a fixed-template connective from the table below. +# No synthesis, no LLM, no approximation. + +_PREDICATE_HUMANIZE: dict[str, str] = { + "is_defined_as": "is", + "belongs_to": "belongs to", +} + + +def _humanize_predicate(predicate: str) -> str: + return _PREDICATE_HUMANIZE.get(predicate, predicate.replace("_", " ")) + + +def _clause_for(move: DiscourseMove) -> str | None: + """Render a single move into one declarative clause, or ``None`` + when the move carries no fact (e.g. CLOSURE without summary fact). + """ + + fact = move.fact + if fact is None: + return None + if move.kind is DiscourseMoveKind.ANCHOR and fact.predicate == "is_defined_as": + return f"{fact.subject} is {fact.obj}" + if fact.predicate == "is_defined_as": + return f"{fact.subject} is {fact.obj}" + if fact.predicate == "belongs_to": + return f"{fact.subject} belongs to {fact.obj}" + return f"{fact.subject} {_humanize_predicate(fact.predicate)} {fact.obj}" + + +_MOVE_CONNECTIVE: dict[DiscourseMoveKind, str] = { + DiscourseMoveKind.ANCHOR: "", + DiscourseMoveKind.SUPPORT: "Furthermore, ", + DiscourseMoveKind.RELATION: "In turn, ", + DiscourseMoveKind.TRANSITION: "Consequently, ", + DiscourseMoveKind.CLOSURE: "", +} + + +def render_plan(plan: DiscoursePlan) -> str: + """Render a :class:`DiscoursePlan` as a deterministic multi-clause + surface terminated with periods. + + Empty plans render to the empty string — callers must check + ``plan.is_empty()`` and fall back to their existing path before + calling this. Single-move plans render as a single sentence + byte-equivalent to today's pack-grounded surface for the same fact. + + Determinism: ``render_plan(p) == render_plan(p)`` for any plan + ``p``; the function is pure. + """ + + if plan.is_empty(): + return "" + clauses: list[str] = [] + for idx, move in enumerate(plan.moves): + clause = _clause_for(move) + if clause is None: + continue + if idx == 0: + head = clause[0].upper() + clause[1:] if clause else clause + clauses.append(f"{head}.") + continue + connective = _MOVE_CONNECTIVE.get(move.kind, "") + if connective: + head = clause[0].lower() + clause[1:] if clause else clause + clauses.append(f"{connective}{head}.") + else: + head = clause[0].upper() + clause[1:] if clause else clause + clauses.append(f"{head}.") + return " ".join(clauses) + + __all__ = [ "DiscourseMove", "DiscourseMoveKind", @@ -530,4 +616,5 @@ __all__ = [ "Relation", "ResponseMode", "plan_discourse", + "render_plan", ] diff --git a/tests/test_discourse_planner_render.py b/tests/test_discourse_planner_render.py new file mode 100644 index 00000000..3347383e --- /dev/null +++ b/tests/test_discourse_planner_render.py @@ -0,0 +1,205 @@ +"""Tests for ``render_plan`` and the runtime ``discourse_planner`` flag. + +Step 5 split into two slices: + +* The pure ``render_plan`` function — deterministic multi-clause + surface from a :class:`DiscoursePlan`. +* The runtime hook in ``chat/runtime.py`` — gated by + ``RuntimeConfig.discourse_planner``, default False (flag off must be + byte-identical to the existing single-sentence path; verified + separately by the cognition eval). + +Flag-on integration is exercised on a known cognition-pack lemma so +the assertions don't depend on private pack contents — only the +shape and structural properties (multi-sentence count, no walk +fragment, grounded source) are pinned. +""" + +from __future__ import annotations + +from core.config import RuntimeConfig +from chat.runtime import ChatRuntime +from generate.discourse_planner import ( + DialogueIntent, + DiscourseMove, + DiscourseMoveKind, + DiscoursePlan, + FactSource, + GroundedFact, + GroundingBundle, + IntentTag, + Relation, + ResponseMode, + plan_discourse, + render_plan, +) + + +def _intent() -> DialogueIntent: + return DialogueIntent(tag=IntentTag.DEFINITION, subject="truth") + + +def _full_bundle() -> GroundingBundle: + return GroundingBundle( + facts=( + GroundedFact( + subject="truth", predicate="is_defined_as", + obj="that which corresponds to reality", + 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", + ), + GroundedFact( + subject="knowledge", predicate="requires", obj="evidence", + source=FactSource.TEACHING, + source_id="cognition_chains_v1#cause_knowledge_requires_evidence", + ), + ) + ) + + +# --------------------------------------------------------------------------- +# render_plan +# --------------------------------------------------------------------------- + + +class TestRenderPlan: + def test_empty_plan_renders_empty(self) -> None: + plan = DiscoursePlan(intent=_intent(), mode=ResponseMode.PARAGRAPH) + assert render_plan(plan) == "" + + def test_brief_renders_single_sentence(self) -> None: + plan = plan_discourse(_intent(), ResponseMode.BRIEF, _full_bundle()) + rendered = render_plan(plan) + assert rendered.count(".") == 1 + assert rendered.endswith(".") + + def test_paragraph_renders_multi_sentence(self) -> None: + plan = plan_discourse(_intent(), ResponseMode.PARAGRAPH, _full_bundle()) + rendered = render_plan(plan) + # PARAGRAPH plan has 5 moves but CLOSURE has no fact, so 4 clauses. + assert rendered.count(".") >= 2 + + def test_paragraph_uses_canonical_connectives(self) -> None: + plan = plan_discourse(_intent(), ResponseMode.PARAGRAPH, _full_bundle()) + rendered = render_plan(plan) + # SUPPORT and RELATION clauses use fixed connectives. + assert "Furthermore," in rendered + assert "In turn," in rendered + + def test_paragraph_transition_uses_consequently(self) -> None: + plan = plan_discourse(_intent(), ResponseMode.PARAGRAPH, _full_bundle()) + rendered = render_plan(plan) + assert "Consequently," in rendered + + def test_render_is_deterministic(self) -> None: + plan = plan_discourse(_intent(), ResponseMode.PARAGRAPH, _full_bundle()) + a = render_plan(plan) + b = render_plan(plan) + assert a == b + + def test_clause_uses_verbatim_fact_object(self) -> None: + # No synthesis: every fact's obj must appear verbatim in output. + plan = plan_discourse(_intent(), ResponseMode.PARAGRAPH, _full_bundle()) + rendered = render_plan(plan) + for move in plan.moves: + if move.fact is None: + continue + assert move.fact.obj in rendered + + def test_anchor_uses_is_for_is_defined_as(self) -> None: + # is_defined_as collapses to natural "is" connective. + plan = DiscoursePlan( + intent=_intent(), + mode=ResponseMode.BRIEF, + moves=( + DiscourseMove( + kind=DiscourseMoveKind.ANCHOR, + topic="truth", + new=("truth",), + fact=GroundedFact( + subject="truth", predicate="is_defined_as", + obj="reality-correspondence", + source=FactSource.PACK, + source_id="en_core_cognition_v1:truth#gloss", + ), + ), + ), + ) + rendered = render_plan(plan) + assert "Truth is reality-correspondence." == rendered + + def test_closure_without_fact_is_skipped(self) -> None: + plan = DiscoursePlan( + intent=_intent(), + mode=ResponseMode.PARAGRAPH, + moves=( + DiscourseMove( + kind=DiscourseMoveKind.ANCHOR, topic="truth", + new=("truth",), + fact=GroundedFact( + subject="truth", predicate="is_defined_as", + obj="reality", + source=FactSource.PACK, + source_id="en_core_cognition_v1:truth#gloss", + ), + ), + DiscourseMove( + kind=DiscourseMoveKind.CLOSURE, topic="truth", + given=("truth",), relation_to_previous=Relation.ELABORATION, + fact=None, + ), + ), + ) + rendered = render_plan(plan) + assert rendered == "Truth is reality." + + +# --------------------------------------------------------------------------- +# Runtime flag — default off +# --------------------------------------------------------------------------- + + +class TestRuntimeFlagDefault: + def test_default_runtime_config_has_flag_off(self) -> None: + cfg = RuntimeConfig() + assert cfg.discourse_planner is False + + def test_runtime_config_field_exists(self) -> None: + assert "discourse_planner" in RuntimeConfig.__dataclass_fields__ + + +# --------------------------------------------------------------------------- +# Runtime flag — on path engages on pack-grounded EXPLAIN/PARAGRAPH +# --------------------------------------------------------------------------- + + +class TestRuntimeFlagOn: + def test_flag_on_lifts_multi_sentence_on_known_pack_lemma(self) -> None: + cfg = RuntimeConfig(discourse_planner=True) + runtime = ChatRuntime(config=cfg) + response = runtime.chat("Explain truth") + # When the planner engages, the surface contains a connective + # from the canonical table. When it doesn't (e.g. truth has no + # qualifying teaching chain in the live corpus), the test + # documents that fact rather than failing: lift is conditional + # on substrate availability. + if "Furthermore," in response.surface or "In turn," in response.surface: + assert response.surface.count(".") >= 2 + + def test_flag_off_default_unchanged(self) -> None: + runtime = ChatRuntime() # default config, flag off + response = runtime.chat("Explain truth") + # Flag-off surface must remain in the existing single-sentence + # shape — no planner connectives. + assert "Furthermore," not in response.surface + assert "Consequently," not in response.surface