core/evals/edge_budget/runner.py
Shay 66b8c7c431 feat(edge): edge-deployment budget gate — deterministic per-turn persistence cost
A2 of the refined sequencing. Proves (deterministically, not by assertion) what a
long-running CORE life costs to persist per turn on a constrained offline device.
Measures the Shape B+ checkpoint BYTES per turn (session_state.json) over the real
turn loop — bytes, not wall-clock latency (machine-dependent → flaky). Reuses the L10
continuity corpus.

Measured cliff: save_session_state re-serializes the FULL snapshot every turn, so
per-turn bytes are O(n) in the accumulated life — 3,811 → 88,189 bytes (23x) over 24
turns, ~1.3KB/vault-entry re-written every turn. That blocks continuous-life at the edge.

The gate encodes the edge REQUIREMENT (≤16 KiB/turn regardless of session length) as
xfail(strict): it fails today (documenting the cliff), runs green in CI, and flips to
a hard failure the moment incremental/append-only persistence (O(Δ)/turn) lands —
forcing us to retire it. Plus a regression ceiling (passes today) and a determinism
check (the byte metric is reproducible → a valid gate).

The fix is algorithmic (incremental persistence, Python/Ring-2), NOT a language
rewrite. Tagged core/array_codec.py as the locked reference contract for a future
gated Ring-1 Zig byte-exact codec (ADR-0196 G0-G8) — step 3, only after the O(Δ) fix
and only if this gate proves the codec is still the bottleneck. See contract.md.
2026-06-06 10:27:10 -07:00

93 lines
4 KiB
Python

"""Edge-deployment budget lane — deterministic per-turn persistence cost.
Runs the REAL turn loop with ``persist_session_state=True`` and measures the BYTES the
Shape B+ checkpoint writes each turn (``session_state.json``). The metric is
DETERMINISTIC (snapshot bytes, not wall-clock latency, which is machine-dependent and
would make an edge gate flaky in CI) — so it is a falsifiable handle, not a vibe.
Today persistence is O(n) per turn: ``save_session_state`` re-serializes the FULL
snapshot every turn, so per-turn bytes grow linearly with the accumulated life (the
vault). This lane makes that cliff visible and gated; it is the falsification lane for
the incremental/append-only persistence fix (O(Δ)/turn → bounded per-turn bytes).
Reuses the L10 continuity corpus (``prompt_at``) — the same deterministic, always-in-
vocabulary turn ring the lived-spine soak uses — so the cost series is reproducible.
"""
from __future__ import annotations
import tempfile
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from core.config import RuntimeConfig
from evals.l10_continuity.corpus import prompt_at
#: Default soak length — enough turns that an O(n)-per-turn implementation visibly
#: breaches the bounded edge budget, kept small enough to stay fast in CI.
DEFAULT_TURNS = 20
#: The edge budget: the most a constrained device (clinic/disaster-center box) can
#: afford to write to durable storage PER TURN, for a life that runs indefinitely.
#: A bounded (O(Δ)) implementation writes only the turn's delta (~a few KB); 16 KiB is
#: generous for that. Today's O(n) snapshot blows through it within a handful of turns.
EDGE_PER_TURN_CEILING_BYTES = 16 * 1024
#: Regression guard (passes today): current max per-turn (~86 KiB at 20 turns) + head-
#: room. Catches a change that makes the cliff materially WORSE before the fix lands.
REGRESSION_PER_TURN_CEILING_BYTES = 160 * 1024
REGRESSION_TOTAL_CEILING_BYTES = 4 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class TurnCost:
turn_index: int
vault_size: int
checkpoint_bytes: int
def measure(n_turns: int = DEFAULT_TURNS, engine_state_dir: Path | None = None) -> list[TurnCost]:
"""Run the real turn loop and capture the per-turn checkpoint byte cost.
If ``engine_state_dir`` is None a TemporaryDirectory is used (and cleaned up).
"""
if engine_state_dir is not None:
return _measure_into(n_turns, engine_state_dir)
with tempfile.TemporaryDirectory() as tmp:
return _measure_into(n_turns, Path(tmp))
def _measure_into(n_turns: int, engine_state_dir: Path) -> list[TurnCost]:
config = replace(RuntimeConfig(), persist_session_state=True)
runtime = ChatRuntime(config=config, engine_state_path=engine_state_dir)
pipe = CognitiveTurnPipeline(runtime=runtime)
session_file = engine_state_dir / "session_state.json"
costs: list[TurnCost] = []
for i in range(n_turns):
pipe.run(prompt_at(i))
size = session_file.stat().st_size if session_file.exists() else 0
costs.append(TurnCost(i, len(runtime._context.vault), size))
return costs
def run(n_turns: int = DEFAULT_TURNS) -> dict[str, Any]:
"""Measure and summarize the per-turn persistence cost (JSON-safe report)."""
costs = measure(n_turns)
per_turn = [c.checkpoint_bytes for c in costs]
first = per_turn[0] if per_turn else 0
return {
"n_turns": n_turns,
"per_turn_bytes": per_turn,
"vault_sizes": [c.vault_size for c in costs],
"first_per_turn_bytes": first,
"final_per_turn_bytes": per_turn[-1] if per_turn else 0,
"max_per_turn_bytes": max(per_turn) if per_turn else 0,
"total_bytes_written": sum(per_turn),
"growth_ratio": round(per_turn[-1] / first, 3) if first else 0.0,
"edge_per_turn_ceiling_bytes": EDGE_PER_TURN_CEILING_BYTES,
"edge_budget_met": (max(per_turn) if per_turn else 0) <= EDGE_PER_TURN_CEILING_BYTES,
}