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:
parent
da3447e95c
commit
de6e392953
7 changed files with 540 additions and 0 deletions
17
core/cli.py
17
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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
47
docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md
Normal file
47
docs/adr/ADR-0255-discovery-yield-baseline-telemetry.md
Normal 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]`).
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
125
scripts/ops/stamp_discovery_yield_baseline_20260723.py
Normal file
125
scripts/ops/stamp_discovery_yield_baseline_20260723.py
Normal 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())
|
||||
82
teaching/discovery_yield.py
Normal file
82
teaching/discovery_yield.py
Normal 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,
|
||||
)
|
||||
217
tests/test_discovery_yield.py
Normal file
217
tests/test_discovery_yield.py
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue