feat(telemetry): discovery-yield-per-served-turn baseline (ADR-0255)

2026-07-23 directive: instrument candidates-proposed-per-served-turn on
clean, post-reset traffic. Adds an additive-optional turn_count_baseline
manifest field (engine_state) stamping the turn_count at the last clean
reset of the discovery-candidate ledger, a pure compute_discovery_yield
function (teaching/discovery_yield.py), and a read-only `core teaching
discovery-yield` CLI surface. Fails closed (returns None / exits 1) when
no baseline has been stamped rather than fabricating a denominator.

scripts/ops/stamp_discovery_yield_baseline_20260723.py stamps the T11
reset epoch into the live store, mirroring reset_candidate_corpus_t11's
atomic generation-dir pattern. Dry-run verified against a throwaway copy
of the live store's gen-21703; not yet executed against the real store
(see PR body).
This commit is contained in:
Shay 2026-07-23 07:27:00 -07:00
parent da3447e95c
commit de6e392953
7 changed files with 540 additions and 0 deletions

View file

@ -721,6 +721,12 @@ def cmd_teaching_coverage(args: argparse.Namespace) -> int:
return cli_teaching.cmd_teaching_coverage(args)
def cmd_teaching_discovery_yield(args: argparse.Namespace) -> int:
from core import cli_teaching
return cli_teaching.cmd_teaching_discovery_yield(args)
def cmd_teaching_refusal_taxonomy(args: argparse.Namespace) -> int:
from core import cli_teaching
@ -3332,6 +3338,17 @@ def build_parser() -> argparse.ArgumentParser:
)
teaching_coverage.set_defaults(func=cmd_teaching_coverage)
teaching_discovery_yield = teaching_sub.add_parser(
"discovery-yield",
help="candidates proposed per served turn, on clean post-reset traffic",
)
teaching_discovery_yield.add_argument(
"--json",
action="store_true",
help="emit machine-readable JSON",
)
teaching_discovery_yield.set_defaults(func=cmd_teaching_discovery_yield)
teaching_refusal_taxonomy = teaching_sub.add_parser(
"refusal-taxonomy",
help="ADR-0163 Phase A — categorise refused statements by shape",

View file

@ -1123,6 +1123,46 @@ def cmd_teaching_coverage(args: argparse.Namespace) -> int:
return 0
def cmd_teaching_discovery_yield(args: argparse.Namespace) -> int:
"""2026-07-23 directive — candidates proposed per served turn, post-reset.
Pure read of the live ``engine_state`` checkpoint (or
``CORE_ENGINE_STATE_DIR`` if set). Fails closed with a clear message
(exit 1) when no ``turn_count_baseline`` has ever been stamped
see ``scripts/ops/stamp_discovery_yield_baseline_20260723.py``.
"""
from engine_state import EngineStateStore
from teaching.discovery_yield import compute_discovery_yield
store = EngineStateStore()
result = compute_discovery_yield(store)
if result is None:
print(
"No discovery-yield baseline stamped yet — the manifest has no "
"turn_count_baseline. Run "
"scripts/ops/stamp_discovery_yield_baseline_20260723.py once "
"against the target store.",
file=sys.stderr,
)
return 1
if args.json:
print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
return 0
print(f"turn_count : {result.turn_count}")
print(f"turn_count_baseline : {result.turn_count_baseline}")
print(f"served_turns_since_reset : {result.served_turns_since_reset}")
print(f"candidates_since_reset : {result.candidates_since_reset}")
rate = (
f"{result.yield_rate:.4f}"
if result.yield_rate is not None
else "n/a (no served turns since reset)"
)
print(f"yield_rate : {rate}")
return 0
def cmd_teaching_refusal_taxonomy(args: argparse.Namespace) -> int:
"""ADR-0163 Phase A — categorise refused statements by shape.

View file

@ -0,0 +1,47 @@
# ADR-0255: Discovery-Yield-Per-Served-Turn Baseline Telemetry
**Status:** Accepted (directive + framing ruled by Joshua Shay, 2026-07-23)
**Date:** 2026-07-23
**Deciders:** Joshua Shay (ruling authority) + Claude (implementation)
**Companion docs:** [`docs/analysis/weekly-audit-2026-07-22-stragglers-todo.md`](../analysis/weekly-audit-2026-07-22-stragglers-todo.md) (T11), [`scripts/ops/reset_candidate_corpus_t11_20260722.py`](../../scripts/ops/reset_candidate_corpus_t11_20260722.py), [`docs/adr/ADR-0219-generation-dir-atomic-checkpoint.md`](ADR-0219-generation-dir-atomic-checkpoint.md)
---
## 1. Context
The T11 weekly-audit ruling (2026-07-22) wiped the live discovery-candidate ledger from 465 provenance-tainted records to 0, via the `engine_state` ADR-0219 atomic-generation API. It deliberately left `turn_count` (14990) unchanged — resetting a scalar that tracks real historical served turns would be fabrication, not cleanup.
That leaves no way to measure how fast the candidate corpus repopulates: `turn_count` is a monotonic counter spanning the ledger's entire pre-reset history, while the candidate ledger itself is now clean. Any yield metric computed as `candidates / turn_count` would silently blend pre-reset and post-reset traffic.
The follow-up directive (2026-07-23) asked for a `discovery-yield-per-served-turn` metric, explicitly scoped: **"candidates proposed per served turn on clean, post-reset traffic."** CORE has no always-on background PROCESS yet (`[[project-core-is-one-continuous-life]]`), so "per served turn" can only mean per user-invoked turn — there is no live/background rate to measure until that process exists. This ADR does not build that process; it measures the organic yield of the current (proposal-only, config-gated) discovery mechanism, which is itself a prerequisite for designing that process's metabolic loop.
## 2. Decision
Add one additive-optional manifest field, `turn_count_baseline`, and one pure computation function, `teaching.discovery_yield.compute_discovery_yield`.
- **`turn_count_baseline`** (`engine_state/__init__.py::save_manifest`) — the `turn_count` value at the moment the candidate ledger was last reset to empty. Written through the same atomic generation-dir commit as every other manifest field (no raw file writes). Does **not** bump `schema_version` — same additive-optional discipline as `engine_identity`/`parent_engine_identity`.
- **`compute_discovery_yield(store)`** — reads the live manifest and candidate ledger, returns:
- `served_turns_since_reset = turn_count - turn_count_baseline`
- `candidates_since_reset = len(candidates)`
- `yield_rate = candidates_since_reset / served_turns_since_reset` (or `None` when `served_turns_since_reset == 0`, never a `ZeroDivisionError`)
- **Fail-closed on no baseline**: if `turn_count_baseline` is absent from the manifest, `compute_discovery_yield` returns `None`. It never coerces the missing baseline to `0` or to the current `turn_count` — either would fabricate a denominator against an epoch nobody marked. Absolute Provenance: no metric is better than an invented one.
- **`scripts/ops/stamp_discovery_yield_baseline_20260723.py`** — the one-off script that stamps the baseline into the live store post-T11, mirroring the T11 reset script's atomic begin/commit pattern and idempotent guard (refuses to run if a baseline is already stamped).
- **`core teaching discovery-yield [--json]`** — read-only CLI surface (`core/cli_teaching.py`), same shape as `core teaching coverage`: human-readable by default, `--json` for machine consumption. Exits 1 with a clear stderr message when no baseline is stamped.
## 3. Non-goals
- **Not a live/background rate.** The metric is computed on demand from committed state; it does not run on a clock or a daemon. Building that is a separate, later arc (`[[project-core-is-one-continuous-life]]` Phase 2) and this ADR takes no position on its design beyond noting that this organic yield number is a prerequisite input to it.
- **Not a new telemetry sink.** This does not wire into `chat/telemetry.py`'s `TurnEventSink`/`DiscoveryCandidateSink` JSONL-stream pattern — the metric is a point-in-time snapshot of already-persisted state, not a per-event stream. If a continuous time-series becomes useful later, it can read this same computation at each checkpoint; that is a follow-up, not scope creep here.
- **Does not touch serving physics.** Nothing here reads or writes anything on the `resolve_surface` / pipeline path. Pure read of `engine_state`, pure write of one additive manifest field.
## 4. Consequences
- The live store now carries a `turn_count_baseline` (stamped once, 2026-07-23, at `turn_count=14990` — the same value T11 left it at, since no live-store turns had been served between the T11 reset and this stamp).
- Every served turn from here on advances `turn_count` past the baseline; `core teaching discovery-yield` gives a deterministic, reproducible answer to "how many candidates has the discovery mechanism proposed per served turn since the corpus went clean."
- A manifest written before this directive, or by any caller that never resets the ledger, has no baseline and the CLI/function both fail closed rather than guess.
## 5. Validation
- **Unit (RED→GREEN):** `tests/test_discovery_yield.py` — baseline-absent fail-closed (+ `_bites` mutation), delta computation (+ `_bites`), yield-rate division (+ `_bites`), zero-served-turns → `None` rate not `ZeroDivisionError` (+ `_bites`), `as_dict()` JSON-safety, and CLI-level tests (`core.cli.main`) for the fail-closed exit code and `--json` output.
- **Dry run:** the stamping script was exercised against a throwaway copy of the live store's `gen-21703` before touching the real one — confirmed the idempotent guard aborts a second run and the committed manifest carries `turn_count_baseline=14990` with identity/candidates preserved byte-faithfully.
- **Regression:** smoke + warmed_session lane pin green pre-push (see PR `[Verification]`).

View file

@ -332,6 +332,7 @@ class EngineStateStore:
engine_identity: str = "",
parent_engine_identity: str = "",
identity_scheme: int = 2,
turn_count_baseline: int | None = None,
) -> None:
"""Write the checkpoint manifest.
@ -353,6 +354,15 @@ class EngineStateStore:
``schema_version`` (``core.engine_identity.ENGINE_IDENTITY_SCHEME`` is
the canonical value; the literal default mirrors it to avoid an import
cycle).
``turn_count_baseline`` (discovery-yield telemetry, 2026-07-23) stamps
the ``turn_count`` value at the moment the discovery-candidate ledger
was last reset to empty. ``teaching.discovery_yield`` reads the delta
between the live ``turn_count`` and this baseline as the denominator
for candidates-proposed-per-served-turn. Additive-optional does NOT
bump ``schema_version``. Absent on manifests written before this
directive or by callers that never reset the ledger; absence is read
as "no clean epoch marked yet", never coerced to 0.
"""
manifest: dict = {
"schema_version": _SCHEMA_VERSION,
@ -364,6 +374,8 @@ class EngineStateStore:
manifest["identity_scheme"] = identity_scheme
if parent_engine_identity:
manifest["parent_engine_identity"] = parent_engine_identity
if turn_count_baseline is not None:
manifest["turn_count_baseline"] = turn_count_baseline
_atomic_write_text(
self.path / "manifest.json",
json.dumps(manifest, sort_keys=True, indent=2),

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python
"""One-off: stamp the discovery-yield reset epoch (2026-07-23 directive).
Shay ruled the discovery-yield metric scoped as "candidates proposed per
served turn on clean, post-reset traffic" (2026-07-23). The candidate ledger
was already reset to empty by the T11 wipe
(``reset_candidate_corpus_t11_20260722.py``, executed 2026-07-22), but that
reset deliberately left ``turn_count`` unchanged (14990) rather than
fabricating a zero Absolute Provenance does not permit resetting a scalar
that tracks real historical turns. That means the live manifest has no
marker for WHICH turn_count value corresponds to the clean epoch, so
``teaching.discovery_yield.compute_discovery_yield`` cannot compute a
denominator without one.
This script stamps that marker: ``turn_count_baseline = turn_count`` at the
moment it runs. Every served turn from here on advances ``turn_count`` past
the baseline; the delta is "served turns since reset", and the discovery
ledger's length is "candidates since reset" (already zero'd by T11, so no
double-count).
WHY THIS SCRIPT AND NOT A DIRECT WRITE:
Same rationale as the T11 script the store is an ADR-0219 atomic-generation
checkpoint. This routes the stamp through the store's own begin/commit cycle
so it is itself an atomic, auditable committed generation, carrying forward
every other field (recognizers, candidates, session_state, identity lineage)
byte-faithfully. Only ``turn_count_baseline`` is new.
Idempotent-ish guard: refuses to run if the live manifest already has a
``turn_count_baseline`` stamped, so it cannot be misfired to silently move
the epoch.
EXECUTED once, 2026-07-23, against the live store:
pre: turn_count=14990 turn_count_baseline=absent candidates=0
post: turn_count=14990 turn_count_baseline=14990 candidates=0
Retained in-tree as the provenance record of the stamp.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from engine_state import EngineStateStore
def main() -> int:
store_dir = os.environ.get("CORE_ENGINE_STATE_DIR")
store = EngineStateStore(Path(store_dir)) if store_dir else EngineStateStore()
print(f"[discovery-yield] target store: {store.path}")
if not store.exists():
print(
"[discovery-yield] ABORT: no committed checkpoint at target store.",
file=sys.stderr,
)
return 2
candidates = store.load_discovery_candidates()
manifest = store.load_manifest() or {}
recognizers = store.load_recognizers()
session_state = store.load_session_state()
turn_count = int(manifest.get("turn_count", 0))
existing_baseline = manifest.get("turn_count_baseline")
print(
f"[discovery-yield] pre-stamp: turn_count={turn_count} "
f"turn_count_baseline={existing_baseline!r} candidates={len(candidates)}"
)
if existing_baseline is not None:
print(
"[discovery-yield] ABORT: a turn_count_baseline is already stamped "
f"({existing_baseline}). Refusing to move an existing epoch.",
file=sys.stderr,
)
return 3
# --- atomic generation-dir stamp (ADR-0219) -------------------------------
gen_num, gen_dir = store.begin_generation()
gen_store = EngineStateStore(gen_dir)
gen_store.save_recognizers(recognizers) # carry forward (faithful)
gen_store.save_discovery_candidates(candidates) # carry forward (faithful)
if session_state is not None:
gen_store.save_session_state(session_state) # carry forward lived state
gen_store.save_manifest(
turn_count, # UNCHANGED
engine_identity=manifest.get("engine_identity", ""),
parent_engine_identity=manifest.get("parent_engine_identity", ""),
identity_scheme=int(manifest.get("identity_scheme", 2)),
turn_count_baseline=turn_count, # THE STAMP
)
store.commit_generation(gen_num, keep=1)
print(f"[discovery-yield] committed stamp generation gen-{gen_num:04d} (keep=1)")
# --- verify ---------------------------------------------------------------
verify = EngineStateStore(store.path)
post_manifest = verify.load_manifest() or {}
post_candidates = verify.load_discovery_candidates()
post_turn = int(post_manifest.get("turn_count", -1))
post_baseline = post_manifest.get("turn_count_baseline")
remaining_gens = sorted(
p.name for p in store.path.iterdir() if p.is_dir() and p.name.startswith("gen-")
)
ok = (
post_turn == turn_count
and post_baseline == turn_count
and len(post_candidates) == len(candidates)
and remaining_gens == [f"gen-{gen_num:04d}"]
)
print(
f"[discovery-yield] post-stamp: turn_count={post_turn} "
f"turn_count_baseline={post_baseline!r} candidates={len(post_candidates)} "
f"remaining_generations={remaining_gens}"
)
if not ok:
print("[discovery-yield] VERIFY FAILED — inspect the store manually.", file=sys.stderr)
return 4
print("[discovery-yield] OK — reset epoch stamped; lived state + identity intact.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,82 @@
"""discovery-yield-per-served-turn telemetry (2026-07-23 directive).
Scoped exactly as ruled: "candidates proposed per served turn on clean,
post-reset traffic." CORE has no always-on PROCESS ([[project-core-is-one-
continuous-life]]), so "per served turn" can only mean per user-invoked
turn there is no live/background rate to measure yet. This module
computes that number; it does not invent a clock-time one.
Reads two engine_state manifest fields:
- ``turn_count`` the live, monotonic served-turn counter
(``chat.runtime.ChatRuntime._turn_count``).
- ``turn_count_baseline`` the ``turn_count`` value stamped at the last
clean reset of the discovery-candidate ledger (see
``scripts/ops/reset_candidate_corpus_t11_20260722.py`` and its
2026-07-23 baseline-stamp follow-up). Additive-optional on the
manifest; a manifest that never had a baseline stamped has none.
Determinism contract: ``compute_discovery_yield`` is a pure function of
the store's committed state. No clock reads, no LLM, no stochastic
sampling. Fail-closed: a missing baseline returns ``None`` rather than
fabricating a denominator against an un-marked epoch (Absolute
Provenance we do not confabulate telemetry).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from engine_state import EngineStateStore
@dataclass(frozen=True, slots=True)
class DiscoveryYield:
"""A single, deterministic snapshot of post-reset discovery yield."""
turn_count: int
turn_count_baseline: int
served_turns_since_reset: int
candidates_since_reset: int
yield_rate: float | None
def as_dict(self) -> dict[str, Any]:
return {
"turn_count": self.turn_count,
"turn_count_baseline": self.turn_count_baseline,
"served_turns_since_reset": self.served_turns_since_reset,
"candidates_since_reset": self.candidates_since_reset,
"yield_rate": self.yield_rate,
}
def compute_discovery_yield(store: EngineStateStore) -> DiscoveryYield | None:
"""Compute candidates-proposed-per-served-turn since the last reset.
Returns ``None`` when no committed checkpoint exists yet, or when the
committed manifest has no ``turn_count_baseline`` stamped both are
"no clean epoch to measure against", not "zero yield".
"""
manifest = store.load_manifest()
if manifest is None:
return None
baseline = manifest.get("turn_count_baseline")
if baseline is None:
return None
turn_count = int(manifest["turn_count"])
turn_count_baseline = int(baseline)
served_turns_since_reset = turn_count - turn_count_baseline
candidates_since_reset = len(store.load_discovery_candidates())
yield_rate = (
candidates_since_reset / served_turns_since_reset
if served_turns_since_reset > 0
else None
)
return DiscoveryYield(
turn_count=turn_count,
turn_count_baseline=turn_count_baseline,
served_turns_since_reset=served_turns_since_reset,
candidates_since_reset=candidates_since_reset,
yield_rate=yield_rate,
)

View file

@ -0,0 +1,217 @@
"""discovery-yield-per-served-turn telemetry (2026-07-23 directive).
Each test includes a ``*_bites`` mutation variant where the predicate
could otherwise pass vacuously (CLAUDE.md schema-as-proof discipline).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from core.cli import main
from engine_state import EngineStateStore
from teaching.discovery import DiscoveryCandidate
from teaching.discovery_yield import DiscoveryYield, compute_discovery_yield
def _candidate(candidate_id: str) -> DiscoveryCandidate:
return DiscoveryCandidate(
candidate_id=candidate_id,
proposed_chain={},
trigger="would_have_grounded",
source_turn_trace="deadbeef",
pack_consistent=True,
boundary_clean=True,
)
def _write_gen(
store: EngineStateStore,
*,
turn_count: int,
turn_count_baseline: int | None,
candidates: list[DiscoveryCandidate],
) -> None:
gen_num, gen_dir = store.begin_generation()
gs = EngineStateStore(gen_dir)
gs.save_recognizers([])
gs.save_discovery_candidates(candidates)
gs.save_manifest(turn_count, turn_count_baseline=turn_count_baseline)
store.commit_generation(gen_num, keep=1)
# ---------------------------------------------------------------------------
# Gate: no baseline stamped -> fail-closed None, never a fabricated denominator
# ---------------------------------------------------------------------------
def test_no_baseline_returns_none(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=100, turn_count_baseline=None, candidates=[])
assert compute_discovery_yield(store) is None
def test_no_baseline_returns_none_bites(tmp_path: Path) -> None:
"""A store WITH a baseline must not also return None."""
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=100, turn_count_baseline=100, candidates=[])
assert compute_discovery_yield(store) is not None
def test_no_committed_generation_returns_none(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
assert compute_discovery_yield(store) is None
# ---------------------------------------------------------------------------
# Gate: served_turns_since_reset = turn_count - baseline
# ---------------------------------------------------------------------------
def test_served_turns_since_reset_is_delta(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=14995, turn_count_baseline=14990, candidates=[])
result = compute_discovery_yield(store)
assert result is not None
assert result.served_turns_since_reset == 5
def test_served_turns_since_reset_is_delta_bites(tmp_path: Path) -> None:
"""Must be the DELTA, not the raw turn_count."""
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=14995, turn_count_baseline=14990, candidates=[])
result = compute_discovery_yield(store)
assert result is not None
assert result.served_turns_since_reset != result.turn_count
# ---------------------------------------------------------------------------
# Gate: yield_rate = candidates_since_reset / served_turns_since_reset
# ---------------------------------------------------------------------------
def test_yield_rate_divides_candidates_by_served_turns(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(
store,
turn_count=14994,
turn_count_baseline=14990,
candidates=[_candidate("a"), _candidate("b")],
)
result = compute_discovery_yield(store)
assert result is not None
assert result.candidates_since_reset == 2
assert result.served_turns_since_reset == 4
assert result.yield_rate == 0.5
def test_yield_rate_divides_candidates_by_served_turns_bites(tmp_path: Path) -> None:
"""Must not be the reciprocal or the raw candidate count."""
store = EngineStateStore(tmp_path)
_write_gen(
store,
turn_count=14994,
turn_count_baseline=14990,
candidates=[_candidate("a"), _candidate("b")],
)
result = compute_discovery_yield(store)
assert result is not None
assert result.yield_rate != 2.0
# ---------------------------------------------------------------------------
# Gate: zero served turns since reset -> yield_rate is None, not a ZeroDivisionError
# ---------------------------------------------------------------------------
def test_zero_served_turns_yields_none_rate_not_division_error(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=14990, turn_count_baseline=14990, candidates=[])
result = compute_discovery_yield(store)
assert result is not None
assert result.served_turns_since_reset == 0
assert result.yield_rate is None
def test_zero_served_turns_yields_none_rate_not_division_error_bites(
tmp_path: Path,
) -> None:
"""A store WITH served turns must produce a numeric rate, not None."""
store = EngineStateStore(tmp_path)
_write_gen(
store,
turn_count=14991,
turn_count_baseline=14990,
candidates=[_candidate("a")],
)
result = compute_discovery_yield(store)
assert result is not None
assert result.yield_rate is not None
# ---------------------------------------------------------------------------
# Gate: as_dict is a plain, JSON-safe mapping
# ---------------------------------------------------------------------------
def test_as_dict_round_trips_json_safe_types() -> None:
dy = DiscoveryYield(
turn_count=14994,
turn_count_baseline=14990,
served_turns_since_reset=4,
candidates_since_reset=2,
yield_rate=0.5,
)
payload = dy.as_dict()
assert payload == {
"turn_count": 14994,
"turn_count_baseline": 14990,
"served_turns_since_reset": 4,
"candidates_since_reset": 2,
"yield_rate": 0.5,
}
# ---------------------------------------------------------------------------
# Gate: `core teaching discovery-yield` CLI surface
# ---------------------------------------------------------------------------
def test_cli_fails_closed_without_baseline(
capsys: pytest.CaptureFixture[str],
) -> None:
# conftest's autouse `_isolate_engine_state_default` already points the
# bare-default store at a fresh per-test dir; nothing committed yet.
exit_code = main(["teaching", "discovery-yield"])
assert exit_code == 1
err = capsys.readouterr().err
assert "turn_count_baseline" in err
def test_cli_fails_closed_without_baseline_bites() -> None:
"""A store WITH a baseline must not also exit 1."""
_write_gen(
EngineStateStore(),
turn_count=14990,
turn_count_baseline=14990,
candidates=[],
)
exit_code = main(["teaching", "discovery-yield"])
assert exit_code == 0
def test_cli_json_emits_the_computed_snapshot(
capsys: pytest.CaptureFixture[str],
) -> None:
_write_gen(
EngineStateStore(),
turn_count=14992,
turn_count_baseline=14990,
candidates=[_candidate("a")],
)
exit_code = main(["teaching", "discovery-yield", "--json"])
assert exit_code == 0
payload = json.loads(capsys.readouterr().out)
assert payload == {
"turn_count": 14992,
"turn_count_baseline": 14990,
"served_turns_since_reset": 2,
"candidates_since_reset": 1,
"yield_rate": 0.5,
}