From de6e392953eca3d007349fa6bb1534fce6f9d6b1 Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 23 Jul 2026 07:27:00 -0700 Subject: [PATCH] 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). --- core/cli.py | 17 ++ core/cli_teaching.py | 40 ++++ ...0255-discovery-yield-baseline-telemetry.md | 47 ++++ engine_state/__init__.py | 12 + ...stamp_discovery_yield_baseline_20260723.py | 125 ++++++++++ teaching/discovery_yield.py | 82 +++++++ tests/test_discovery_yield.py | 217 ++++++++++++++++++ 7 files changed, 540 insertions(+) create mode 100644 docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md create mode 100644 scripts/ops/stamp_discovery_yield_baseline_20260723.py create mode 100644 teaching/discovery_yield.py create mode 100644 tests/test_discovery_yield.py diff --git a/core/cli.py b/core/cli.py index 140991a0..39be2a76 100644 --- a/core/cli.py +++ b/core/cli.py @@ -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", diff --git a/core/cli_teaching.py b/core/cli_teaching.py index a2e558a5..7dc47278 100644 --- a/core/cli_teaching.py +++ b/core/cli_teaching.py @@ -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. diff --git a/docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md b/docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md new file mode 100644 index 00000000..9679c552 --- /dev/null +++ b/docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md @@ -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]`). diff --git a/engine_state/__init__.py b/engine_state/__init__.py index 93ae5cd8..b4ab752a 100644 --- a/engine_state/__init__.py +++ b/engine_state/__init__.py @@ -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), diff --git a/scripts/ops/stamp_discovery_yield_baseline_20260723.py b/scripts/ops/stamp_discovery_yield_baseline_20260723.py new file mode 100644 index 00000000..d7d77135 --- /dev/null +++ b/scripts/ops/stamp_discovery_yield_baseline_20260723.py @@ -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()) diff --git a/teaching/discovery_yield.py b/teaching/discovery_yield.py new file mode 100644 index 00000000..5980cc5a --- /dev/null +++ b/teaching/discovery_yield.py @@ -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, + ) diff --git a/tests/test_discovery_yield.py b/tests/test_discovery_yield.py new file mode 100644 index 00000000..f0a54e72 --- /dev/null +++ b/tests/test_discovery_yield.py @@ -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, + }