Final phase of the articulation arc. Consumes the per-turn
``PlanMetrics`` + ``ContemplationFinding`` streams produced by
Phases 3 + 4 and aggregates across many turns to emit
SPECULATIVE ``PACK_MUTATION_CANDIDATE`` findings that the operator
reviews via the existing proposal-review-ratify chain.
This is the doctrine-aligned answer to the user's question:
"Should we... realize a way to score whether it should use what
it produced towards memory confidence for future use?"
Yes — and it stays inside ADR-0080: read-only, SPECULATIVE-only,
deterministic, no parallel learning path, no autonomous memory
mutation.
What it adds
------------
* New module ``chat/articulation_telemetry.py``:
- ``ArticulationObservation`` frozen dataclass — per-turn
bundle of (turn_id, anchor_subject, prompt_hash,
plan_substrate_hash, metrics, findings).
- ``format_articulation_observation_jsonl(...)`` — deterministic
sort-keys JSONL line.
- ``load_articulation_observations(lines)`` — schema-tolerant
loader; malformed lines drop without aborting.
- ``ArticulationObservationSink`` protocol — structurally
identical to ``TurnEventSink`` but distinct named type so
consumers can subscribe to one stream without the other.
* New module ``core/contemplation/miners/articulation_quality.py``:
- ``mine_articulation_observations(observations, paths)`` —
pure deterministic aggregator with three v1 rules.
- **recurring_predicate_monotony** — when the same
(subject, predicate) pair is flagged WEAK_SURFACE in
>= _MIN_RECURRENCE (default 3) observations, propose
substrate diversification with non-dominant predicates.
- **recurring_planner_gap** — when the same subject is
flagged PLANNER_GAP >= _MIN_RECURRENCE times across modes,
propose substrate expansion.
- **low_average_predicate_diversity** — when mean
``predicate_diversity_ratio`` < 0.5 across >= _MIN_RECURRENCE
observations on the same anchor subject, propose
diversification.
* Runtime wiring (``chat/runtime.py``):
- New ``ChatRuntime.attach_articulation_sink(sink)`` method.
Mirrors ``attach_telemetry_sink`` pattern.
- Emission point at the end of
``_maybe_apply_discourse_planner``: when contemplation
enabled + sink attached + plan engaged, builds an
``ArticulationObservation`` and emits one JSONL line.
Sink errors propagate (fail-fast, no swallowing).
- Per-runtime ``_articulation_turn_counter`` increments on
every emission; gives downstream consumers a stable
sequence index.
Tests
-----
* ``tests/test_articulation_quality_miner.py`` (11 tests):
- Empty / sub-threshold cases yield no findings.
- Each of the three rules fires at threshold.
- Recurring_predicate_monotony separates by subject (no
cross-subject merging).
- Recurring_planner_gap collects distinct modes into a
sorted comma-joined string.
- Determinism — byte-equal finding IDs across two runs.
- SPECULATIVE doctrine pin.
- JSONL round-trip preserves observation identity.
* ``tests/test_articulation_quality_e2e.py`` (7 tests):
- Sink-detached + contemplation-on → no emission.
- Sink-attached + contemplation-off → no emission.
- Engaged turn emits exactly one observation line.
- BRIEF prompt emits nothing (fast-path).
- **Full loop** — run compound prompt 3x → 3 observations →
miner emits PACK_MUTATION_CANDIDATE with subject='truth',
predicate='recurring_predicate_monotony', object='belongs_to'.
- Full loop is deterministic (byte-equal finding IDs across
two complete runs).
- Every full-loop finding is SPECULATIVE.
Doctrine pins
-------------
| Claim | Pinned by |
|--------------------------------------|----------------------------------------------------------|
| SPECULATIVE-only | test_all_findings_remain_speculative |
| Deterministic across runs | test_miner_is_deterministic_across_runs |
| Full-loop determinism (e2e) | test_full_loop_is_deterministic_byte_equal_finding_ids |
| No autonomous mutation | Sink is append-only; miner outputs ContemplationFinding |
| | objects only; nothing writes to packs/vault/teaching. |
| Append-only stream | Sink protocol has emit(line: str) and nothing else. |
Live demo (3 identical compound-prompt turns)
---------------------------------------------
Runtime emits 3 observations. Offline miner aggregates and emits:
[pack_mutation_candidate] subject='truth'
predicate='recurring_predicate_monotony' object='belongs_to'
evidence_refs: 3 observations
proposed_action: "diversify substrate for 'truth': across 3
observations the plan repeatedly over-concentrated on
predicate 'belongs_to'. Candidates: add teaching chains
rooted on 'truth' with relations OTHER than 'belongs_to'
(grounds / requires / reveals / contrasts / precedes /
follows) so the planner's RELATION selector has more
variety to draw from."
epistemic_status: speculative
The system observed its own articulation patterns across many
turns, identified the corpus expansion priority, and emitted a
specific reviewable proposal — without mutating anything. The
operator decides whether to act on it via the existing review
chain.
Verification
------------
pytest test_articulation_quality_miner.py 11/11 pass
pytest test_articulation_quality_e2e.py 7/7 pass
pytest test_plan_metrics*.py 18/18 pass (Phase 4)
pytest test_plan_contemplation*.py 17/17 pass (Phase 3)
pytest test_discourse_planner_*.py 99/99 pass
pytest test_articulation_demo.py all claims supported
pytest test_narrative_example_intents.py pass
core test --suite smoke 67/67 pass
core test --suite runtime 19/19 pass
The articulation arc is complete. Future work documented in
``docs/sessions/SESSION-2026-05-21-articulation-arc.md`` §8:
connective rotation, generalised pronoun selection, doctrine-gated
plan revision, Phase 2.5 mid-sentence reflection. None blocking.
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
"""Phase 5 — end-to-end test of the full articulation-quality loop.
|
|
|
|
Demonstrates the doctrine-aligned feedback loop the user asked for:
|
|
|
|
live runtime (Phase 1-4)
|
|
→ per-turn ArticulationObservation
|
|
→ JSONL sink
|
|
→ offline mine_articulation_observations
|
|
→ SPECULATIVE PACK_MUTATION_CANDIDATE findings
|
|
|
|
No mutation of packs, vault, teaching corpus, or runtime state at any
|
|
step. Operator reviews the emitted findings via the existing
|
|
proposal-review-ratify chain.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List
|
|
|
|
from chat.articulation_telemetry import load_articulation_observations
|
|
from chat.runtime import ChatRuntime
|
|
from core.config import RuntimeConfig
|
|
from core.contemplation.miners.articulation_quality import (
|
|
mine_articulation_observations,
|
|
)
|
|
from core.contemplation.schema import FindingKind
|
|
from teaching.epistemic import EpistemicStatus
|
|
|
|
|
|
@dataclass
|
|
class _BufferSink:
|
|
"""Minimal in-memory ``ArticulationObservationSink``."""
|
|
lines: List[str] = field(default_factory=list)
|
|
|
|
def emit(self, line: str) -> None:
|
|
self.lines.append(line)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No sink attached → runtime emits nothing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_sink_means_no_emission() -> None:
|
|
"""Engaged plan + contemplation on + sink ABSENT → no JSONL line."""
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=True))
|
|
rt.chat("What is truth, and why does it matter?")
|
|
# No buffer to inspect — verify the planner engaged so the
|
|
# condition for emission was met EXCEPT for the missing sink.
|
|
assert rt.last_plan_metrics is not None
|
|
assert rt.last_plan_findings # multi-move compound prompt fires WEAK_SURFACE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sink attached + contemplation off → no emission
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sink_attached_but_contemplation_off_yields_nothing() -> None:
|
|
sink = _BufferSink()
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=False))
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is truth, and why does it matter?")
|
|
# Contemplation off → metrics None → emission gate fails closed.
|
|
assert sink.lines == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sink attached + contemplation on + planner engaged → one line emitted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_engaged_turn_emits_one_observation_line() -> None:
|
|
sink = _BufferSink()
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=True))
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is truth, and why does it matter?")
|
|
assert len(sink.lines) == 1
|
|
[observation] = load_articulation_observations(sink.lines)
|
|
assert observation.anchor_subject == "truth"
|
|
assert observation.metrics["move_count"] >= 4
|
|
assert any(
|
|
f["kind"] == FindingKind.WEAK_SURFACE.value
|
|
for f in observation.findings
|
|
)
|
|
|
|
|
|
def test_brief_turn_does_not_emit() -> None:
|
|
"""BRIEF mode prompts short-circuit the planner before any plan
|
|
is built — no observation should land in the sink."""
|
|
sink = _BufferSink()
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=True))
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is knowledge?")
|
|
assert sink.lines == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multiple turns + offline miner closes the loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_full_loop_emits_pack_mutation_candidate_after_repeated_pattern() -> None:
|
|
"""The headline Phase 5 demo:
|
|
|
|
1. Operator runs the compound prompt three times.
|
|
2. Each turn emits one observation; all three observations
|
|
carry a ``WEAK_SURFACE`` finding for
|
|
``(truth, belongs_to)`` because the plan structure is
|
|
deterministic.
|
|
3. Offline miner aggregates the three observations and emits
|
|
one ``PACK_MUTATION_CANDIDATE`` finding stamped
|
|
SPECULATIVE.
|
|
"""
|
|
sink = _BufferSink()
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=True))
|
|
rt.attach_articulation_sink(sink)
|
|
|
|
for _ in range(3):
|
|
# Fresh runtime per turn to keep determinism clean; otherwise
|
|
# vault state would diverge between turns. (The sink survives
|
|
# across the three runtimes — we re-attach.)
|
|
rt = ChatRuntime(config=RuntimeConfig(discourse_contemplation=True))
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is truth, and why does it matter?")
|
|
|
|
assert len(sink.lines) == 3
|
|
|
|
observations = load_articulation_observations(sink.lines)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
|
|
# At minimum the recurring_predicate_monotony rule must fire — three
|
|
# identical WEAK_SURFACE findings on (truth, belongs_to).
|
|
pmc = [
|
|
f for f in findings
|
|
if f.kind is FindingKind.PACK_MUTATION_CANDIDATE
|
|
]
|
|
assert pmc, "expected at least one PACK_MUTATION_CANDIDATE finding"
|
|
|
|
monotony = [
|
|
f for f in pmc if f.predicate == "recurring_predicate_monotony"
|
|
]
|
|
assert len(monotony) == 1
|
|
assert monotony[0].subject == "truth"
|
|
assert monotony[0].object == "belongs_to"
|
|
assert monotony[0].epistemic_status is EpistemicStatus.SPECULATIVE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Determinism across the full loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_full_loop_is_deterministic_byte_equal_finding_ids() -> None:
|
|
"""Two end-to-end runs over the same input produce byte-identical
|
|
finding IDs — the load-bearing claim for the offline miner."""
|
|
|
|
def _run_loop() -> tuple[str, ...]:
|
|
sink = _BufferSink()
|
|
for _ in range(3):
|
|
rt = ChatRuntime(
|
|
config=RuntimeConfig(discourse_contemplation=True),
|
|
)
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is truth, and why does it matter?")
|
|
observations = load_articulation_observations(sink.lines)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
return tuple(f.finding_id for f in findings)
|
|
|
|
ids_a = _run_loop()
|
|
ids_b = _run_loop()
|
|
assert ids_a == ids_b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Doctrine pin — every emitted finding is SPECULATIVE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_full_loop_emits_only_speculative_findings() -> None:
|
|
sink = _BufferSink()
|
|
for _ in range(3):
|
|
rt = ChatRuntime(
|
|
config=RuntimeConfig(discourse_contemplation=True),
|
|
)
|
|
rt.attach_articulation_sink(sink)
|
|
rt.chat("What is truth, and why does it matter?")
|
|
observations = load_articulation_observations(sink.lines)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
for f in findings:
|
|
assert f.epistemic_status is EpistemicStatus.SPECULATIVE
|