core/tests/test_discourse_planner_reflective.py
Shay 9dfb505f06 feat(discourse): Phase 2 — reflective rendering pronominalizes focus subject
The Phase 1 multi-clause renderer (commit 63ffd88) produces grounded
content but reads mechanically because the subject lemma repeats in
every clause:

  "Truth is what is true. Furthermore, truth belongs to cognition.truth.
   In turn, truth grounds knowledge. Truth belongs to epistemic.ground.
   Furthermore, truth belongs to logos.core. In turn, truth requires
   evidence."

This is the literal articulation gap that motivated Phase 2 —
"reasoning at meaningful checkpoints during sentence construction
in order to have a stronger idea of what has come prior and is
already done to help better inform the next move."  Between move
``i`` and move ``i+1`` the renderer now reflects on what subject
has just been established (the "focus") and renders the next clause
with a pronoun when the focus carries forward:

  "Truth is what is true. Furthermore, it belongs to cognition.truth.
   In turn, it grounds knowledge. It belongs to epistemic.ground.
   Furthermore, it belongs to logos.core. In turn, it requires
   evidence."

Rules
-----

* Track ``focus_subject`` across moves (the lemma most recently used
  as a fact subject).
* When the next move's ``fact.subject`` is byte-equal to the current
  focus → swap subject token to ``"it"``.
* When the next move's subject differs → preserve the explicit lemma
  AND update focus.  Topic shifts (TRANSITION moves; compound bridge
  TRANSITION) thus reset the pronominalization channel naturally.
* Sentence-initial position (no connective): capitalised ``"It"``.
* Mid-sentence (after connective + comma): lowercase ``"it"``.

Doctrine alignment
------------------

Pure deterministic transformation of the existing plan; no new
content introduced, no LLM, no stochastic sampling.  Same plan in →
same surface out, always.  trace_hash invariance holds because:

  * BRIEF-mode prompts short-circuit the planner before render
    (commit 63ffd88's fast path) and are unaffected.
  * Multi-move plans render to a deterministically-different string
    that compute_trace_hash already folds in via ``surface``.

Wiring
------

* New ``reflective: bool = False`` parameter on ``render_plan``
  (back-compat default — every existing call site and test pinning
  Phase 1 output continues to work).
* ``_clause_for`` gains optional ``prior_focus_subject`` arg used by
  the reflective path; unchanged default behaviour.
* Runtime hook ``chat.runtime._maybe_apply_discourse_planner``
  passes ``reflective=True`` so the default chat path benefits.

Tests
-----

New ``tests/test_discourse_planner_reflective.py``:

* ``test_reflective_replaces_repeated_subject_with_it``
* ``test_reflective_handles_three_consecutive_same_subject_moves``
* ``test_reflective_capitalises_sentence_initial_pronoun``
* ``test_reflective_resets_focus_on_topic_shift``
* ``test_reflective_off_preserves_phase1_output``
* ``test_reflective_default_is_off_for_back_compat``
* ``test_reflective_is_deterministic``
* ``test_reflective_single_move_byte_identical_to_non_reflective``
  (load-bearing — pins that the cognition eval stays byte-equal
  across the Phase 2 flip because every cognition case is single-
  move).

Verification
------------

  pytest tests/test_discourse_planner_*.py        99/99 pass
                                                  (91 existing + 8 new)
  pytest tests/test_articulation_demo.py          all claims supported
  pytest tests/test_narrative_example_intents.py  pass
  pytest tests/test_runtime_config.py             pass
  cognition eval OFF vs ON                        45/45 surface byte-equal
                                                  45/45 trace_hash byte-equal
                                                  4/4 aggregate metrics
                                                      identical
  core test --suite smoke                         67/67 pass
  core test --suite runtime                       19/19 pass

Live demo (default config):

  "What is knowledge?"  → unchanged (BRIEF, fast-path)
  "Tell me about
    memory."            → "Memory is what a person recalls.
                          Furthermore, it belongs to cognition.memory.
                          In turn, it requires recall."
  "What is truth, and
    why does it matter?"→ "Truth is what is true. Furthermore, it
                          belongs to cognition.truth. In turn, it
                          grounds knowledge. It belongs to
                          epistemic.ground. Furthermore, it belongs
                          to logos.core. In turn, it requires
                          evidence."
  "Explain truth."      → "Truth is what is true. Furthermore, it
                          belongs to cognition.truth. In turn, it
                          grounds knowledge."

Out of scope for this commit (future Phase 2 follow-ons):

* Connective rotation ("Furthermore" → "Also" → "In addition"
  to break the repetitive cascade).
* Cross-clause de-duplication (skip moves whose ``new`` lemmas
  were already introduced by an earlier move).
* Generalised pronoun selection beyond ``it`` (requires gender /
  number / animacy signals the pack lexicon doesn't carry today).
2026-05-21 10:16:12 -07:00

293 lines
9.6 KiB
Python

"""Phase 2 — reflective rendering via subject pronominalization.
The Phase 1 (commit ``63ffd88``) planner produces multi-clause plans
but the renderer walks moves in order without awareness of what was
just surfaced. On a typical EXPLAIN/PARAGRAPH/compound output that
yields a mechanical-feeling cascade in which the subject lemma
repeats in every clause:
Truth is what is true.
Furthermore, truth belongs to cognition.truth.
In turn, truth grounds knowledge.
Truth belongs to epistemic.ground.
Furthermore, truth belongs to logos.core.
In turn, truth requires evidence.
Phase 2 wires the renderer with the **lightest possible interleaved
reflection**: track the most-recently-introduced subject (the
"focus") and, when the next move's subject is byte-equal to it,
emit a pronoun ("it") instead of repeating the lemma. The
substitution rules:
* Same subject AND no topic shift (i.e. ``move.topic`` agrees with
the prior focus) → swap to ``"it"``.
* Topic shift (TRANSITION move, or any move whose topic differs
from prior focus) → reset focus, keep the explicit subject.
* Sentence-initial position (no connective): capitalise ``"It"``.
* Mid-sentence position (after connective + comma): lowercase ``"it"``.
This is deterministic, replayable, and adds nothing the bundle
didn't already contain — it is a pure rendering improvement.
"""
from __future__ import annotations
from generate.discourse_planner import (
DiscourseMove,
DiscourseMoveKind,
DiscoursePlan,
FactSource,
GroundedFact,
render_plan,
)
from generate.intent import DialogueIntent, IntentTag, ResponseMode
def _fact(subject: str, predicate: str, obj: str) -> GroundedFact:
return GroundedFact(
subject=subject,
predicate=predicate,
obj=obj,
source=FactSource.PACK,
source_id="test_pack_v1",
)
def _intent(subject: str = "truth") -> DialogueIntent:
return DialogueIntent(tag=IntentTag.DEFINITION, subject=subject)
def _move(
kind: DiscourseMoveKind,
fact: GroundedFact,
*,
given: tuple[str, ...] = (),
new: tuple[str, ...] = (),
) -> DiscourseMove:
return DiscourseMove(
kind=kind,
topic=fact.subject,
given=given,
new=new,
relation_to_previous=None,
fact=fact,
)
# ---------------------------------------------------------------------------
# Pronominalization fires across consecutive same-subject moves
# ---------------------------------------------------------------------------
def test_reflective_replaces_repeated_subject_with_it() -> None:
"""Two moves with byte-equal subject → second clause uses ``it``."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.EXPLAIN,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
new=("truth",),
),
_move(
DiscourseMoveKind.SUPPORT,
_fact("truth", "belongs_to", "cognition.truth"),
given=("truth",),
new=("cognition.truth",),
),
),
)
rendered = render_plan(plan, reflective=True)
assert (
rendered
== "Truth is what is true. Furthermore, it belongs to cognition.truth."
)
def test_reflective_handles_three_consecutive_same_subject_moves() -> None:
"""Three same-subject moves → first is canonical, next two are ``it``."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.PARAGRAPH,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
new=("truth",),
),
_move(
DiscourseMoveKind.SUPPORT,
_fact("truth", "belongs_to", "cognition.truth"),
),
_move(
DiscourseMoveKind.RELATION,
_fact("truth", "grounds", "knowledge"),
),
),
)
rendered = render_plan(plan, reflective=True)
assert rendered == (
"Truth is what is true. "
"Furthermore, it belongs to cognition.truth. "
"In turn, it grounds knowledge."
)
def test_reflective_capitalises_sentence_initial_pronoun() -> None:
"""When the next clause has no connective (e.g. CLOSURE), the
pronoun must be sentence-initial and therefore capitalised."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.PARAGRAPH,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
_move(
DiscourseMoveKind.CLOSURE,
_fact("truth", "belongs_to", "epistemic.ground"),
),
),
)
rendered = render_plan(plan, reflective=True)
assert rendered == (
"Truth is what is true. It belongs to epistemic.ground."
)
def test_reflective_resets_focus_on_topic_shift() -> None:
"""When the next move's subject differs (topic shift), keep the
explicit lemma — pronouns would be ambiguous."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.PARAGRAPH,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
_move(
DiscourseMoveKind.TRANSITION,
_fact("knowledge", "belongs_to", "cognition.knowledge"),
),
_move(
DiscourseMoveKind.RELATION,
_fact("knowledge", "grounds", "judgment"),
),
),
)
rendered = render_plan(plan, reflective=True)
# After the TRANSITION shifts focus to ``knowledge``, the next
# ``knowledge`` reference becomes ``it`` again.
assert rendered == (
"Truth is what is true. "
"Consequently, knowledge belongs to cognition.knowledge. "
"In turn, it grounds judgment."
)
# ---------------------------------------------------------------------------
# Backward compatibility: reflective=False preserves Phase 1 output
# ---------------------------------------------------------------------------
def test_reflective_off_preserves_phase1_output() -> None:
"""``reflective=False`` reproduces the pre-Phase-2 byte-equivalent
output — the default kept for any caller pinning the raw shape."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.EXPLAIN,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
_move(
DiscourseMoveKind.SUPPORT,
_fact("truth", "belongs_to", "cognition.truth"),
),
),
)
rendered = render_plan(plan, reflective=False)
assert rendered == (
"Truth is what is true. "
"Furthermore, truth belongs to cognition.truth."
)
def test_reflective_default_is_off_for_back_compat() -> None:
"""``render_plan(plan)`` without an explicit kwarg keeps the
Phase-1 default so existing call sites (and existing tests) are
not silently rewritten. The runtime wiring explicitly opts in."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.EXPLAIN,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
_move(
DiscourseMoveKind.SUPPORT,
_fact("truth", "belongs_to", "cognition.truth"),
),
),
)
assert render_plan(plan) == render_plan(plan, reflective=False)
# ---------------------------------------------------------------------------
# Determinism: same input → same output, twice
# ---------------------------------------------------------------------------
def test_reflective_is_deterministic() -> None:
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.PARAGRAPH,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
_move(
DiscourseMoveKind.SUPPORT,
_fact("truth", "belongs_to", "cognition.truth"),
),
_move(
DiscourseMoveKind.RELATION,
_fact("truth", "grounds", "knowledge"),
),
),
)
a = render_plan(plan, reflective=True)
b = render_plan(plan, reflective=True)
assert a == b
# ---------------------------------------------------------------------------
# Single-move plans are byte-identical regardless of reflective mode
# (no second move ⇒ no pronoun substitution possible)
# ---------------------------------------------------------------------------
def test_reflective_single_move_byte_identical_to_non_reflective() -> None:
"""A BRIEF/single-move plan can't trigger pronominalization — the
output must be byte-identical to ``reflective=False``. This is
the load-bearing claim that lets the cognition eval (mostly
single-fact prompts) stay byte-equal across the Phase 2 flip."""
plan = DiscoursePlan(
intent=_intent(),
mode=ResponseMode.BRIEF,
moves=(
_move(
DiscourseMoveKind.ANCHOR,
_fact("truth", "is_defined_as", "what is true"),
),
),
)
assert render_plan(plan, reflective=True) == render_plan(
plan, reflective=False
)