core/core/contemplation/plan_preflight.py
Shay 664e08150c feat(contemplation): Phase 3 — live plan contemplation pre-flight
Wires deterministic, read-only contemplation OVER a completed
``DiscoursePlan`` BEFORE the renderer fires.  This is the
"reasoning at meaningful checkpoints" capability — the system
now inspects the global shape of its own articulation plan and
emits SPECULATIVE findings about quality issues the move-by-move
planner couldn't see locally.

Doctrine alignment (ADR-0080)
-----------------------------

* **Read-only** — never mutates the plan, packs, vault, teaching
  corpus, or runtime state.  Returns findings as a tuple; the
  runtime stores them on a read-only property.
* **SPECULATIVE-only** — every finding is stamped
  ``EpistemicStatus.SPECULATIVE`` by the schema's ``__post_init__``;
  the doctrine pin ``test_findings_always_speculative`` keeps that
  invariant visible.
* **Deterministic replay** — same plan → byte-identical findings
  (same ``substrate_hash``, same ``finding_id``).
* **No parallel learning path** — findings flow to a read-only
  observation surface (``runtime.last_plan_findings``).  Promotion
  to memory still goes through the existing proposal → review →
  ratify chain.  The offline contemplation miner (Phase 5 target)
  is what eventually consumes the findings and emits reviewable
  pack-mutation candidates.

v1 rules (``core/contemplation/plan_preflight.py``)
----------------------------------------------------

* ``PLANNER_GAP`` — non-BRIEF mode produced anchor-only depth.
  Signals the teaching/cross-pack substrate for that lemma is too
  thin for the planner to expand.

* ``WEAK_SURFACE`` — three or more moves share a predicate.
  Signals the rendered surface will read mechanical (e.g. three
  ``belongs_to`` clauses in a row).  Fires on today's compound
  prompt ``"What is truth, and why does it matter?"`` — the
  6-sentence plan uses ``belongs_to`` 3 times.

* ``COVERAGE_GAP`` — every move in a multi-move plan draws from
  a single ``FactSource``.  Signals one-sided substrate (e.g.
  pack-only with no teaching enrichment).

Runtime wiring
--------------

* New ``RuntimeConfig.discourse_contemplation: bool = False`` —
  opt-in for now.  Default off keeps the cognition eval byte-
  identical to Phase 2 (verified 45/45 surface + 45/45 trace_hash).
* New ``ChatRuntime.last_plan_findings`` property — read-only tuple
  of ``ContemplationFinding`` records from the most recent turn.
  Reset to ``()`` at the start of every plan-engagement call so
  findings never leak across turns.
* Contemplation runs AFTER the planner produces a multi-move plan
  and BEFORE the renderer fires; the plan itself is not modified.

Demo (config: discourse_contemplation=True)
-------------------------------------------

  "What is knowledge?"          → planner fast-path; no findings
  "Tell me about memory."       → 3 moves, distinct predicates;
                                  no findings (good!)
  "What is truth, and why does
   it matter?"                  → 6 moves, ``belongs_to`` x 3:
                                  [WEAK_SURFACE] subject='truth'
                                    predicate='predicate_repeats_in_plan'
                                    object='belongs_to'
                                  proposed action: diversify the
                                  relation inventory for 'truth'
                                  (grounds / requires / reveals /
                                  contrasts) so the planner has
                                  more variety to draw from.
  "Explain truth."              → 3 moves, distinct predicates;
                                  no findings

Tests
-----

* ``tests/test_plan_contemplation.py`` — 11 unit tests pinning
  each rule, empty/trivial plans, determinism, and the
  SPECULATIVE-only doctrine.

* ``tests/test_plan_contemplation_runtime.py`` — 6 end-to-end
  tests proving the runtime wiring: disabled by default,
  populated when enabled, reset across turns, deterministic
  across runs, all findings SPECULATIVE.

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

  pytest tests/test_plan_contemplation*.py        17/17 pass
  pytest tests/test_discourse_planner_*.py        99/99 pass
  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

Phases roadmap (logged in commit, not built today)
--------------------------------------------------

* Phase 4 — articulation telemetry enrichment.  Emit per-turn
  metrics (grounding_ratio, anaphora_engagement, plan_completeness,
  novelty, focus_consistency) to the existing telemetry sink so
  the offline miner has structured signal.

* Phase 5 — offline contemplation miner.  Extend
  ``core/contemplation`` with a miner that consumes
  ``last_plan_findings`` streams and emits reviewable
  pack-mutation / teaching-corpus expansion proposals.  Still
  SPECULATIVE; review-gated.
2026-05-21 10:30:22 -07:00

246 lines
9.1 KiB
Python

"""Phase 3 — live contemplation pre-flight over a completed DiscoursePlan.
The Phase 1 planner (commit ``63ffd88``) builds a plan one move at a
time using local selectors (anchor → support → relation → transition
→ closure). No selector sees the full plan; pattern-level issues
that emerge only from the *global* shape (predicate monotony, source
homogeneity, anchor-only depth on a non-BRIEF mode) slip past.
Phase 3 closes that gap with a deterministic read-only contemplation
pass that runs AFTER the planner finishes and BEFORE the renderer
fires. Per ADR-0080 contemplation doctrine:
* Read-only — never mutates the plan, packs, vault, teaching
corpus, or runtime state. Returns findings as a tuple; callers
decide what to do with them.
* SPECULATIVE-only — every emitted finding is stamped
``EpistemicStatus.SPECULATIVE`` by the schema's ``__post_init__``.
* Deterministic replay — same plan → same findings, byte-for-byte.
The findings flow into the telemetry sink (see
``chat.telemetry``) so the offline contemplation miner (Phase 5
target) can aggregate them into reviewable evidence for pack /
teaching-corpus expansion proposals. At no point does this module
auto-promote anything to memory — that path remains the existing
proposal-review-ratify chain.
Rules implemented in v1
-----------------------
* ``PLANNER_GAP`` — non-BRIEF mode produced a single-move plan.
Anchor without support/relation/transition signals the substrate
for that lemma is too thin: there are no qualifying teaching-chain
or cross-pack facts the planner could surface. Proposed action
(operator-facing): widen the teaching corpus for that subject.
* ``WEAK_SURFACE`` — three or more moves in the plan share the
same predicate. Indicates rendered surface will repeat the same
relational pattern (e.g. three ``belongs_to`` clauses in a row),
which reads mechanical. Proposed action: diversify the relation
inventory for that subject.
* ``COVERAGE_GAP`` — every move in a multi-move plan draws from a
single ``FactSource``. Indicates the substrate is one-sided
(e.g. pack-only with no teaching enrichment, or teaching-only
with no pack anchor). Proposed action: confirm whether the
missing source actually has nothing on this subject, or whether
the planner's selector ordering is leaving gold on the table.
"""
from __future__ import annotations
import hashlib
from collections import Counter
from core.contemplation.schema import (
ContemplationEvidenceRef,
ContemplationFinding,
FindingKind,
)
from generate.discourse_planner import (
DiscourseMoveKind,
DiscoursePlan,
ResponseMode,
)
_PREDICATE_MONOTONY_THRESHOLD = 3
"""Trigger ``WEAK_SURFACE`` when this many moves share a predicate.
Two moves with the same predicate read naturally (e.g. ``belongs_to``
twice for two domain memberships). Three or more turns mechanical.
"""
def _plan_substrate_hash(plan: DiscoursePlan) -> str:
"""SHA-256-16 of the plan's canonical JSON.
Used as the ``substrate_hash`` on every emitted finding so two
contemplation passes over byte-equal plans produce byte-equal
finding IDs.
"""
return hashlib.sha256(plan.to_json().encode("utf-8")).hexdigest()[:16]
def _evidence_ref_for_plan(plan: DiscoursePlan) -> ContemplationEvidenceRef:
"""Single evidence ref pointing at the in-memory plan substrate."""
return ContemplationEvidenceRef(
source_type="discourse_plan",
source_id="in_memory_plan",
pointer=_plan_substrate_hash(plan),
summary=(
f"mode={plan.mode.value} "
f"intent={plan.intent.tag.value} "
f"subject={plan.intent.subject!r} "
f"moves={len(plan.moves)}"
),
)
def _rule_planner_gap(
plan: DiscoursePlan, substrate_hash: str,
) -> tuple[ContemplationFinding, ...]:
"""Detect anchor-only depth on a non-BRIEF mode plan.
BRIEF mode is anchor-only by design (budget ``(1, 1)``) — no gap.
EXPLAIN / PARAGRAPH / EXAMPLE / WALKTHROUGH plans that emit only
an anchor signal the planner ran out of substrate for that lemma.
"""
if plan.mode is ResponseMode.BRIEF:
return ()
if len(plan.moves) != 1:
return ()
anchor = plan.moves[0]
if anchor.kind is not DiscourseMoveKind.ANCHOR:
return ()
if anchor.fact is None:
return ()
return (
ContemplationFinding(
kind=FindingKind.PLANNER_GAP,
subject=anchor.fact.subject,
predicate="anchor_only_depth",
object=plan.mode.value,
evidence_refs=(_evidence_ref_for_plan(plan),),
proposed_action=(
f"widen substrate for {anchor.fact.subject!r}: planner "
f"under {plan.mode.value} mode could only surface an "
f"anchor — no qualifying support/relation/transition "
f"facts available. Candidates: add teaching chains "
f"rooted on this lemma, or add pack ``belongs_to`` "
f"facts that the SUPPORT selector can pick up."
),
substrate_hash=substrate_hash,
),
)
def _rule_predicate_monotony(
plan: DiscoursePlan, substrate_hash: str,
) -> tuple[ContemplationFinding, ...]:
"""Detect ``>= _PREDICATE_MONOTONY_THRESHOLD`` moves sharing a predicate."""
predicates = Counter(
m.fact.predicate for m in plan.moves if m.fact is not None
)
findings: list[ContemplationFinding] = []
for predicate, count in sorted(predicates.items()):
if count < _PREDICATE_MONOTONY_THRESHOLD:
continue
# Subject for the finding is the anchor subject (or fall back
# to the first move with a fact). Predicate of the finding
# itself names the issue ("predicate_repeats_in_plan"); the
# object is the dominating predicate.
anchor = plan.anchor()
subject = (
anchor.fact.subject
if anchor is not None and anchor.fact is not None
else next(
(m.fact.subject for m in plan.moves if m.fact is not None),
plan.intent.subject or "<unknown>",
)
)
findings.append(
ContemplationFinding(
kind=FindingKind.WEAK_SURFACE,
subject=subject,
predicate="predicate_repeats_in_plan",
object=predicate,
evidence_refs=(_evidence_ref_for_plan(plan),),
proposed_action=(
f"diversify relation inventory for {subject!r}: "
f"plan uses predicate {predicate!r} {count} times. "
f"Reader may perceive mechanical cadence. "
f"Candidates: add chains with different relations "
f"(grounds / requires / reveals / contrasts) so "
f"the planner's RELATION selector has more variety."
),
substrate_hash=substrate_hash,
)
)
return tuple(findings)
def _rule_source_homogeneity(
plan: DiscoursePlan, substrate_hash: str,
) -> tuple[ContemplationFinding, ...]:
"""Detect multi-move plans where every fact-bearing move draws from
a single ``FactSource``.
BRIEF / single-move plans are exempt (one source by definition).
"""
if len(plan.moves) < 2:
return ()
sources = Counter(
m.fact.source for m in plan.moves if m.fact is not None
)
if not sources or len(sources) > 1:
return ()
(source, count), = sources.items() # exactly one entry
if count < 2:
return ()
anchor = plan.anchor()
subject = (
anchor.fact.subject
if anchor is not None and anchor.fact is not None
else plan.intent.subject or "<unknown>"
)
return (
ContemplationFinding(
kind=FindingKind.COVERAGE_GAP,
subject=subject,
predicate="single_source_plan",
object=source.value,
evidence_refs=(_evidence_ref_for_plan(plan),),
proposed_action=(
f"confirm coverage for {subject!r}: every move in this "
f"plan draws from {source.value!r}. "
f"Verify whether the unused sources truly carry nothing "
f"on this subject, or whether selector ordering / "
f"corpus structure is leaving qualifying facts unsurfaced."
),
substrate_hash=substrate_hash,
),
)
def contemplate_plan(plan: DiscoursePlan) -> tuple[ContemplationFinding, ...]:
"""Run every plan-level rule over *plan* and collect findings.
Pure deterministic function: ``contemplate_plan(p) ==
contemplate_plan(p)`` byte-identical for any plan ``p``.
Empty plans yield no findings (nothing to reason about).
"""
if plan.is_empty():
return ()
substrate_hash = _plan_substrate_hash(plan)
findings: list[ContemplationFinding] = []
findings.extend(_rule_planner_gap(plan, substrate_hash))
findings.extend(_rule_predicate_monotony(plan, substrate_hash))
findings.extend(_rule_source_homogeneity(plan, substrate_hash))
return tuple(findings)
__all__ = [
"contemplate_plan",
]