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.
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""Phase 5 — articulation-quality miner unit tests.
|
|
|
|
Tests ``core.contemplation.miners.articulation_quality.
|
|
mine_articulation_observations`` against synthetic observations:
|
|
|
|
* Empty stream → no findings
|
|
* Single observation → no findings (threshold not met)
|
|
* recurring_predicate_monotony — fires at >= _MIN_RECURRENCE
|
|
* recurring_planner_gap — fires at >= _MIN_RECURRENCE
|
|
* low_average_predicate_diversity — fires when mean < threshold
|
|
* Determinism: byte-equal finding IDs across two runs
|
|
* All emitted findings stay SPECULATIVE
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from chat.articulation_telemetry import (
|
|
ArticulationObservation,
|
|
format_articulation_observation_jsonl,
|
|
load_articulation_observations,
|
|
)
|
|
from core.contemplation.miners.articulation_quality import (
|
|
mine_articulation_observations,
|
|
)
|
|
from core.contemplation.schema import FindingKind
|
|
from teaching.epistemic import EpistemicStatus
|
|
|
|
|
|
def _obs(
|
|
*,
|
|
turn_id: int = 0,
|
|
anchor: str = "truth",
|
|
prompt_h: str = "p0000000000000000",
|
|
plan_h: str = "s0000000000000000",
|
|
metrics: dict | None = None,
|
|
findings: tuple[dict, ...] = (),
|
|
) -> ArticulationObservation:
|
|
return ArticulationObservation(
|
|
turn_id=turn_id,
|
|
anchor_subject=anchor,
|
|
prompt_hash=prompt_h,
|
|
plan_substrate_hash=plan_h,
|
|
metrics=metrics or {
|
|
"move_count": 4,
|
|
"fact_bearing_count": 4,
|
|
"anchor_count": 1,
|
|
"support_count": 1,
|
|
"relation_count": 1,
|
|
"transition_count": 1,
|
|
"closure_count": 0,
|
|
"unique_predicates": 4,
|
|
"unique_subjects": 1,
|
|
"unique_sources": 2,
|
|
"topic_shift_count": 0,
|
|
"pronominalization_opportunities": 3,
|
|
"predicate_diversity_ratio": 1.0,
|
|
"subject_focus_ratio": 1.0,
|
|
},
|
|
findings=findings,
|
|
)
|
|
|
|
|
|
def _weak_surface_finding(
|
|
subject: str, predicate: str,
|
|
) -> dict[str, str | None]:
|
|
return {
|
|
"kind": FindingKind.WEAK_SURFACE.value,
|
|
"subject": subject,
|
|
"predicate": "predicate_repeats_in_plan",
|
|
"object": predicate,
|
|
}
|
|
|
|
|
|
def _planner_gap_finding(
|
|
subject: str, mode: str = "explain",
|
|
) -> dict[str, str | None]:
|
|
return {
|
|
"kind": FindingKind.PLANNER_GAP.value,
|
|
"subject": subject,
|
|
"predicate": "anchor_only_depth",
|
|
"object": mode,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trivial cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_stream_yields_no_findings() -> None:
|
|
assert mine_articulation_observations(observations=()) == ()
|
|
|
|
|
|
def test_below_threshold_recurrence_yields_no_findings() -> None:
|
|
"""Two ``WEAK_SURFACE`` observations is below the default
|
|
``_MIN_RECURRENCE = 3`` — nothing should fire."""
|
|
observations = (
|
|
_obs(turn_id=0, findings=(_weak_surface_finding("truth", "belongs_to"),)),
|
|
_obs(turn_id=1, findings=(_weak_surface_finding("truth", "belongs_to"),)),
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
assert findings == ()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rule: recurring_predicate_monotony
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recurring_predicate_monotony_fires_at_threshold() -> None:
|
|
observations = tuple(
|
|
_obs(turn_id=i, findings=(_weak_surface_finding("truth", "belongs_to"),))
|
|
for i in range(3)
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
matching = [
|
|
f for f in findings
|
|
if f.predicate == "recurring_predicate_monotony"
|
|
]
|
|
assert len(matching) == 1
|
|
f = matching[0]
|
|
assert f.kind is FindingKind.PACK_MUTATION_CANDIDATE
|
|
assert f.subject == "truth"
|
|
assert f.object == "belongs_to"
|
|
assert "diversify substrate" in f.proposed_action
|
|
assert f.epistemic_status is EpistemicStatus.SPECULATIVE
|
|
|
|
|
|
def test_recurring_predicate_monotony_separates_by_subject() -> None:
|
|
"""Two different subjects each above threshold → two separate
|
|
findings, not one merged finding."""
|
|
observations = (
|
|
*(
|
|
_obs(turn_id=i, anchor="truth",
|
|
findings=(_weak_surface_finding("truth", "belongs_to"),))
|
|
for i in range(3)
|
|
),
|
|
*(
|
|
_obs(turn_id=i + 100, anchor="memory",
|
|
findings=(_weak_surface_finding("memory", "requires"),))
|
|
for i in range(3)
|
|
),
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
matching = [
|
|
f for f in findings
|
|
if f.predicate == "recurring_predicate_monotony"
|
|
]
|
|
assert len(matching) == 2
|
|
by_subject = {f.subject: f for f in matching}
|
|
assert "truth" in by_subject and by_subject["truth"].object == "belongs_to"
|
|
assert "memory" in by_subject and by_subject["memory"].object == "requires"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rule: recurring_planner_gap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recurring_planner_gap_fires_at_threshold() -> None:
|
|
observations = tuple(
|
|
_obs(turn_id=i, anchor="rare_lemma",
|
|
findings=(_planner_gap_finding("rare_lemma", "explain"),))
|
|
for i in range(3)
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
matching = [
|
|
f for f in findings
|
|
if f.predicate == "recurring_planner_gap"
|
|
]
|
|
assert len(matching) == 1
|
|
assert matching[0].subject == "rare_lemma"
|
|
assert "widen substrate" in matching[0].proposed_action
|
|
|
|
|
|
def test_recurring_planner_gap_collects_distinct_modes() -> None:
|
|
"""When the same subject hits PLANNER_GAP across different modes,
|
|
the finding's ``object`` lists all of them, sorted."""
|
|
observations = (
|
|
_obs(turn_id=0, anchor="rare",
|
|
findings=(_planner_gap_finding("rare", "explain"),)),
|
|
_obs(turn_id=1, anchor="rare",
|
|
findings=(_planner_gap_finding("rare", "paragraph"),)),
|
|
_obs(turn_id=2, anchor="rare",
|
|
findings=(_planner_gap_finding("rare", "example"),)),
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
matching = [
|
|
f for f in findings if f.predicate == "recurring_planner_gap"
|
|
]
|
|
assert len(matching) == 1
|
|
assert matching[0].object == "example,explain,paragraph"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rule: low_average_predicate_diversity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_low_average_predicate_diversity_fires_below_threshold() -> None:
|
|
low_metrics = dict(
|
|
move_count=6, fact_bearing_count=6,
|
|
anchor_count=1, support_count=2, relation_count=2,
|
|
transition_count=1, closure_count=0,
|
|
unique_predicates=2, unique_subjects=1, unique_sources=1,
|
|
topic_shift_count=0, pronominalization_opportunities=5,
|
|
predicate_diversity_ratio=2.0 / 6.0, # 0.333 — well below 0.5
|
|
subject_focus_ratio=1.0,
|
|
)
|
|
observations = tuple(
|
|
_obs(turn_id=i, anchor="truth", metrics=low_metrics)
|
|
for i in range(3)
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
matching = [
|
|
f for f in findings
|
|
if f.predicate == "low_average_predicate_diversity"
|
|
]
|
|
assert len(matching) == 1
|
|
f = matching[0]
|
|
assert f.kind is FindingKind.PACK_MUTATION_CANDIDATE
|
|
assert f.subject == "truth"
|
|
# object is the average ratio as a string, formatted to 3 decimals
|
|
assert f.object is not None
|
|
assert float(f.object) == pytest.approx(2.0 / 6.0, abs=1e-3)
|
|
|
|
|
|
def test_low_average_predicate_diversity_skips_when_above_threshold() -> None:
|
|
high_metrics = dict(
|
|
move_count=4, fact_bearing_count=4,
|
|
anchor_count=1, support_count=1, relation_count=2,
|
|
transition_count=0, closure_count=0,
|
|
unique_predicates=4, unique_subjects=1, unique_sources=2,
|
|
topic_shift_count=0, pronominalization_opportunities=3,
|
|
predicate_diversity_ratio=1.0,
|
|
subject_focus_ratio=1.0,
|
|
)
|
|
observations = tuple(
|
|
_obs(turn_id=i, anchor="truth", metrics=high_metrics)
|
|
for i in range(5)
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
assert not [
|
|
f for f in findings
|
|
if f.predicate == "low_average_predicate_diversity"
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Determinism
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_miner_is_deterministic_across_runs() -> None:
|
|
observations = tuple(
|
|
_obs(turn_id=i, findings=(_weak_surface_finding("truth", "belongs_to"),))
|
|
for i in range(3)
|
|
)
|
|
a = mine_articulation_observations(observations=observations)
|
|
b = mine_articulation_observations(observations=observations)
|
|
assert tuple(f.finding_id for f in a) == tuple(f.finding_id for f in b)
|
|
assert tuple(f.substrate_hash for f in a) == tuple(f.substrate_hash for f in b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SPECULATIVE doctrine pin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_all_findings_remain_speculative() -> None:
|
|
observations = (
|
|
*(
|
|
_obs(turn_id=i,
|
|
findings=(_weak_surface_finding("truth", "belongs_to"),))
|
|
for i in range(3)
|
|
),
|
|
*(
|
|
_obs(turn_id=i + 100, anchor="rare",
|
|
findings=(_planner_gap_finding("rare", "explain"),))
|
|
for i in range(3)
|
|
),
|
|
)
|
|
findings = mine_articulation_observations(observations=observations)
|
|
assert findings # at least the two recurring rules fired
|
|
for f in findings:
|
|
assert f.epistemic_status is EpistemicStatus.SPECULATIVE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round-trip via JSONL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_jsonl_round_trip_preserves_observation_identity() -> None:
|
|
original = _obs(
|
|
turn_id=42,
|
|
anchor="truth",
|
|
findings=(_weak_surface_finding("truth", "belongs_to"),),
|
|
)
|
|
line = format_articulation_observation_jsonl(original)
|
|
[recovered] = load_articulation_observations([line])
|
|
assert recovered.turn_id == original.turn_id
|
|
assert recovered.anchor_subject == original.anchor_subject
|
|
assert recovered.metrics == original.metrics
|
|
assert recovered.findings == original.findings
|