core/evals/l10_continuity/runner.py
Shay 23bc28caf9 feat(evals): L10 continuity spike — falsifiable long-horizon soak lane
Build evals/l10_continuity/, the empirical gate between the two L10 targets
(T-resume: provable same-life resume; T-experience: continuous experiencing
field-life). Drives the REAL turn loop (ChatRuntime + CognitiveTurnPipeline)
over a deterministic in-vocab corpus, with reboot and orphan-crash legs, and
evaluates falsifiable predicates over recorded evidence. Additive only; no
existing file touched; read-only over the runtime; no serving-path import.

Predicates (each with a *_holds real-soak test AND a *_bites mutation test, per
the CLAUDE.md schema-as-proof discipline):
- P1 closure: versor_condition < 1e-6 every turn (green guard).
- P2a determinism: two independent runtimes -> byte-identical trace_hash.
- P2b reboot transparency (the diagnostic): a reboot never alters pre-reboot
  turns (hard guard); post-reboot transparency is MEASURED and today FALSE --
  the mechanical proof that Shape B (ADR-0146) discards the lived field/vault,
  i.e. "many lives sharing a checkpoint". A pinned test flips if persistence is
  ever added, forcing a doc update so the gap can't close silently.
- P3 bounded resources: vault grows linear-bounded/monotonic (RSS recorded).
- P4 crash recovery: two recoveries from one checkpoint converge (determinism)
  + commit-point/ARIES force boundary (recovered turn_count == committed) +
  atomic-write survives mid-os.replace kill (ADR-0156).
- P5b anchor stability (T-experience crux): field anchors without COLLAPSE
  (dist_to_anchor not -> 0) or FREEZE (turn_movement not -> 0); the long-horizon
  test of the sanctioned _session_anchor_pull (alpha=0.05). Thresholds measured.
- P5c coherence: surfaces stay non-empty and not collapsed to one output, over
  more than one corpus cycle.
- P5a recall precision@k: recorded as not_covered (needs a held-out probe set).

report.py assembles the panel into a structured report with a hardware-stable
deterministic_digest (trace_hash sequence + verdicts; excludes RSS/wall-clock)
as the freeze handle. Run: python -m evals.l10_continuity [n_turns] [reboot_turn].

24 tests pass; adversarially reviewed across 4 lenses (bite-discipline,
invariant/trust-boundary, honesty/determinism, correctness) before landing.
2026-06-05 11:14:17 -07:00

202 lines
7.6 KiB
Python

"""The L10 continuity soak runner — drives the REAL turn loop over N turns.
It runs the deterministic corpus through ``CognitiveTurnPipeline`` over a fresh
``ChatRuntime`` whose engine-state checkpoint lives in a caller-supplied
directory. Optionally it injects *reboot legs*: at a chosen turn boundary it
drops the live runtime and reconstructs a new one from the on-disk checkpoint —
exactly the lifecycle the L10 telos asks about ("resume as the same life") — and
optionally simulates a kill mid-checkpoint-write by leaving an orphan temp file
the reconstruct must ignore (ADR-0156 atomicity).
The runner is pure instrumentation: it records per-turn evidence
(``versor_condition``, canonical ``trace_hash``, vault size, peak RSS, anchor
distance, turn-to-turn field movement, and which boot segment produced the turn)
and returns it. It makes NO pass/fail judgement — that is ``predicates.py`` — and
it never repairs, normalizes, or mutates field state (it only reads what the real
pipeline produced).
What a reboot restores (today, Shape B / ADR-0146): recognizers, discovery
candidates, and ``turn_count`` — and NOTHING else. The lived field, vault,
session graph, referents, and session anchor are process-local and discarded on
exit. The ``booted_segment`` tag on each record exists precisely so the
reboot-transparency predicate (P2b) can locate where a rebooted run diverges
from an uninterrupted one.
"""
from __future__ import annotations
import resource
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from core.config import RuntimeConfig
from evals.l10_continuity.corpus import prompt_at
@dataclass(frozen=True, slots=True)
class TurnRecord:
"""Per-turn evidence captured from the real pipeline (no judgement)."""
turn_index: int
input_text: str
trace_hash: str
versor_condition: float
surface: str
vault_size: int
peak_rss_raw: int
booted_segment: int
# P5 signals (NaN when undefined — e.g. movement on a segment's first turn,
# or distance before an anchor exists).
dist_to_anchor: float
turn_movement: float
@dataclass(frozen=True, slots=True)
class SoakResult:
"""The full ordered evidence of one soak run."""
n_turns: int
reboot_at: tuple[int, ...]
records: tuple[TurnRecord, ...]
def trace_hashes(self) -> tuple[str, ...]:
return tuple(r.trace_hash for r in self.records)
def versor_conditions(self) -> tuple[float, ...]:
return tuple(r.versor_condition for r in self.records)
def post_reboot_records(self) -> tuple[TurnRecord, ...]:
"""Records produced at/after the first reboot (the recovered tail)."""
if not self.reboot_at:
return ()
first = self.reboot_at[0]
return tuple(r for r in self.records if r.turn_index >= first)
def _peak_rss_raw() -> int:
"""Process peak RSS as the OS reports it (bytes on macOS, KiB on Linux).
The unit differs by platform, so callers must use this only for
*ratio*/monotonic checks (P3), never as an absolute byte ceiling.
"""
return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
def _new_runtime(config: RuntimeConfig, engine_state_dir: Path) -> ChatRuntime:
"""Construct a ChatRuntime bound to the checkpoint dir.
Reconstruction is the reboot: ``ChatRuntime.__init__`` loads the on-disk
engine-state checkpoint when one exists (recognizers / candidates /
turn_count), so a second instance over the same directory resumes from the
last durable checkpoint.
"""
return ChatRuntime(config=config, engine_state_path=engine_state_dir)
def _inject_orphan_tmp(engine_state_dir: Path) -> None:
"""Simulate a kill mid-checkpoint-write: leave an orphan temp file.
ADR-0156 writes ``content`` to a ``.<name>.<rand>.tmp`` file, fsyncs, then
``os.replace``s it into place. A SIGKILL between fsync and replace leaves
exactly such an orphan with the *real* target fully intact. The loader reads
only the canonical filenames, so the orphan must be ignored. We write a
deliberately-corrupt orphan to prove the loader never reads it.
"""
engine_state_dir.mkdir(parents=True, exist_ok=True)
orphan = engine_state_dir / ".manifest.json.deadbeef.tmp"
orphan.write_text("{ this is a torn, half-written checkpoint <<<", encoding="utf-8")
def read_recovered_turn_count(engine_state_dir: Path) -> int | None:
"""Read ``turn_count`` from the on-disk manifest, or None if absent."""
from engine_state import EngineStateStore
manifest = EngineStateStore(engine_state_dir).load_manifest()
return None if manifest is None else int(manifest.get("turn_count", 0))
def _anchor_distance(runtime: ChatRuntime) -> float:
ctx = runtime._context
if ctx.state is None or ctx._anchor_field is None:
return float("nan")
f = np.asarray(ctx.state.F, dtype=np.float64)
anchor = np.asarray(ctx._anchor_field, dtype=np.float64)
return float(np.linalg.norm(f - anchor))
def _current_field(runtime: ChatRuntime) -> np.ndarray | None:
ctx = runtime._context
return None if ctx.state is None else np.asarray(ctx.state.F, dtype=np.float64)
def run_soak(
n_turns: int,
*,
engine_state_dir: Path,
reboot_at: tuple[int, ...] = (),
config: RuntimeConfig | None = None,
inject_orphan_tmp_at_reboot: bool = False,
) -> SoakResult:
"""Run ``n_turns`` of the deterministic corpus, optionally rebooting.
``reboot_at`` is a set of turn indices at which, *before* running that turn,
the live runtime is dropped and reconstructed from the checkpoint. A reboot
at turn 0 is meaningless (nothing checkpointed yet) and is ignored. When
``inject_orphan_tmp_at_reboot`` is set, a torn-write orphan temp file is left
in the checkpoint dir immediately before each reconstruct, so the reboot
exercises ADR-0156 crash recovery rather than a clean restart.
"""
if n_turns < 0:
raise ValueError(f"n_turns must be non-negative, got {n_turns}")
config = config or RuntimeConfig()
reboot_set = {i for i in reboot_at if i > 0}
runtime = _new_runtime(config, engine_state_dir)
pipe = CognitiveTurnPipeline(runtime=runtime)
segment = 0
prev_field: np.ndarray | None = None
records: list[TurnRecord] = []
for i in range(n_turns):
if i in reboot_set:
if inject_orphan_tmp_at_reboot:
_inject_orphan_tmp(engine_state_dir)
runtime = _new_runtime(config, engine_state_dir)
pipe = CognitiveTurnPipeline(runtime=runtime)
segment += 1
prev_field = None # movement is undefined across a reboot boundary
text = prompt_at(i)
result = pipe.run(text)
field = _current_field(runtime)
movement = (
float(np.linalg.norm(field - prev_field))
if field is not None and prev_field is not None
else float("nan")
)
records.append(
TurnRecord(
turn_index=i,
input_text=text,
trace_hash=result.trace_hash,
versor_condition=float(result.versor_condition),
surface=result.surface,
vault_size=len(runtime._context.vault),
peak_rss_raw=_peak_rss_raw(),
booted_segment=segment,
dist_to_anchor=_anchor_distance(runtime),
turn_movement=movement,
)
)
prev_field = field
return SoakResult(
n_turns=n_turns,
reboot_at=tuple(sorted(reboot_set)),
records=tuple(records),
)