feat(runtime): wire discourse planner behind RuntimeConfig flag

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)
This commit is contained in:
Shay 2026-05-19 11:29:25 -07:00
parent ef914460df
commit 30948a1605
4 changed files with 338 additions and 0 deletions

View file

@ -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

View file

@ -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"

View file

@ -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",
]

View file

@ -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