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:
parent
ef914460df
commit
30948a1605
4 changed files with 338 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
205
tests/test_discourse_planner_render.py
Normal file
205
tests/test_discourse_planner_render.py
Normal 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
|
||||
Loading…
Reference in a new issue