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.
198 lines
7 KiB
Python
198 lines
7 KiB
Python
"""Phase 5 — per-turn articulation observation schema + sink.
|
|
|
|
The runtime emits one structured observation per engaged turn
|
|
(``discourse_contemplation=True`` AND planner produced a multi-move
|
|
plan). Each observation bundles Phase 4 metrics and Phase 3 findings
|
|
plus identity context (turn_id, anchor subject, plan substrate hash)
|
|
so the offline contemplation miner (Phase 5) can aggregate across
|
|
many turns and emit reviewable pack-mutation candidates.
|
|
|
|
Doctrine alignment (ADR-0080 + ADR-0040):
|
|
|
|
* Read-only — observations are PROJECTIONS of plan state; nothing
|
|
is mutated when they emit.
|
|
* Append-only — sinks ONLY accept JSONL lines; observations never
|
|
rewrite or overwrite prior records.
|
|
* Deterministic — same plan + same metrics + same findings →
|
|
byte-identical JSONL line. Pinned by the
|
|
``test_observation_is_deterministic`` test.
|
|
* SPECULATIVE-only by transitivity — the findings carried inside
|
|
each observation are themselves SPECULATIVE (enforced by the
|
|
ContemplationFinding schema's __post_init__).
|
|
|
|
The sink protocol mirrors ``chat.telemetry.TurnEventSink``: any
|
|
object with ``def emit(line: str) -> None`` satisfies the contract.
|
|
Articulation observations flow through a SEPARATE sink (not the
|
|
turn-event sink) so consumers can subscribe to one stream without
|
|
the other, and the two streams' wire formats stay independent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Protocol
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ArticulationObservation:
|
|
"""One per-turn articulation observation.
|
|
|
|
Carries the Phase 4 metrics dict + the Phase 3 findings (compacted
|
|
to (kind, subject, predicate, object) tuples) plus identity fields.
|
|
Both inner payloads are pre-serialised via ``as_dict()`` so the
|
|
observation itself owns no live references — safe to log, archive,
|
|
or stream without keeping the runtime alive.
|
|
"""
|
|
|
|
turn_id: int
|
|
"""Sequential turn index within the emitting session (0-based)."""
|
|
|
|
anchor_subject: str
|
|
"""The plan's anchor subject lemma — the most stable aggregation
|
|
key. For a typical EXPLAIN/PARAGRAPH plan this is the prompt's
|
|
head noun; for compound prompts it is the primary part's
|
|
subject."""
|
|
|
|
prompt_hash: str
|
|
"""SHA-256-16 of the (lowercased, stripped) raw prompt text.
|
|
Lets the miner detect repeated prompts without storing raw
|
|
user input."""
|
|
|
|
plan_substrate_hash: str
|
|
"""SHA-256-16 of the plan's canonical JSON. Joins this
|
|
observation to the Phase 3 contemplation findings that share
|
|
the same substrate hash."""
|
|
|
|
metrics: dict[str, Any]
|
|
"""Phase 4 ``PlanMetrics.as_dict()`` — see
|
|
``core.contemplation.plan_metrics`` for field list and
|
|
semantics."""
|
|
|
|
findings: tuple[dict[str, str | None], ...]
|
|
"""Phase 3 finding summaries — each is
|
|
``{"kind": <FindingKind.value>, "subject": <str>,
|
|
"predicate": <str>, "object": <str|None>}``. Compacted form;
|
|
the full finding objects (with substrate_hash, finding_id,
|
|
proposed_action, evidence_refs) are recoverable from a separate
|
|
findings stream that emits via the existing
|
|
``DiscoveryCandidateSink``."""
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"turn_id": self.turn_id,
|
|
"anchor_subject": self.anchor_subject,
|
|
"prompt_hash": self.prompt_hash,
|
|
"plan_substrate_hash": self.plan_substrate_hash,
|
|
"metrics": dict(self.metrics),
|
|
"findings": [dict(f) for f in self.findings],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialisation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def serialize_articulation_observation(
|
|
observation: ArticulationObservation,
|
|
) -> dict[str, Any]:
|
|
"""Return a JSON-safe deterministic dict. Field order alphabetised
|
|
by the sort_keys=True at the JSONL boundary."""
|
|
return observation.as_dict()
|
|
|
|
|
|
def format_articulation_observation_jsonl(
|
|
observation: ArticulationObservation,
|
|
) -> str:
|
|
"""One deterministic JSONL line (sort_keys; no trailing newline)."""
|
|
return json.dumps(
|
|
observation.as_dict(),
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
default=str,
|
|
)
|
|
|
|
|
|
def prompt_hash(prompt: str) -> str:
|
|
"""Stable 16-char prompt fingerprint.
|
|
|
|
Lowercased + whitespace-collapsed so two presentations of the
|
|
same logical prompt collapse to one hash. Hashing means the
|
|
raw prompt never has to be persisted — privacy-respecting and
|
|
storage-cheap.
|
|
"""
|
|
canonical = " ".join((prompt or "").strip().lower().split())
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sink protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ArticulationObservationSink(Protocol):
|
|
"""Append-only JSONL sink. Structurally identical to
|
|
``chat.telemetry.TurnEventSink`` but kept as a distinct named
|
|
type so consumers can subscribe to articulation observations
|
|
without seeing the broader turn-event stream."""
|
|
|
|
def emit(self, line: str) -> None: ...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loader (for the offline miner)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def load_articulation_observations(
|
|
lines: Iterable[str],
|
|
) -> tuple[ArticulationObservation, ...]:
|
|
"""Parse a JSONL stream back into ``ArticulationObservation``s.
|
|
|
|
Lines that fail to parse are SKIPPED — a malformed line in the
|
|
middle of a long stream must not bring down the miner. Caller
|
|
can re-parse manually if strict parsing is needed.
|
|
"""
|
|
out: list[ArticulationObservation] = []
|
|
for raw in lines:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
try:
|
|
out.append(
|
|
ArticulationObservation(
|
|
turn_id=int(payload["turn_id"]),
|
|
anchor_subject=str(payload["anchor_subject"]),
|
|
prompt_hash=str(payload["prompt_hash"]),
|
|
plan_substrate_hash=str(payload["plan_substrate_hash"]),
|
|
metrics=dict(payload["metrics"]),
|
|
findings=tuple(
|
|
dict(f) for f in payload["findings"]
|
|
),
|
|
)
|
|
)
|
|
except (KeyError, TypeError, ValueError):
|
|
# Schema drift / partial record — skip rather than abort.
|
|
continue
|
|
return tuple(out)
|
|
|
|
|
|
__all__ = [
|
|
"ArticulationObservation",
|
|
"ArticulationObservationSink",
|
|
"format_articulation_observation_jsonl",
|
|
"load_articulation_observations",
|
|
"prompt_hash",
|
|
"serialize_articulation_observation",
|
|
]
|