Adds empirical coverage of the three ADR-0219 checkpoint sub-steps where a process kill can occur: PARTIAL_GEN (gen dir exists but partially written), FULL_GEN_BEFORE_SWAP (all four files written but current pointer not yet swapped), and AFTER_SWAP (clean-commit control case, already covered by rec_a/rec_b). For each of the two non-trivial cut-points the harness: 1. runs a probe soak to commit reboot_turn turns, 2. injects the corresponding orphan shape, 3. reads the on-disk manifest to confirm the loader follows `current` and reads the committed generation (not the orphan), 4. runs two independent recovery soaks (each injecting the same orphan at the reboot boundary) and verifies their post-reboot trace_hash tails converge, 5. checks versor_condition < 1e-6 throughout every recovery soak. New predicate evaluate_p4_arbitrary_interruption (predicates.py) expresses this as a PredicateOutcome with a CutPointEvidence carrier. It has: - holds gate: recovered_turn_count == expected (orphan was ignored) - holds gate: tail_hashes_a == tail_hashes_b (two recoveries converge) - holds gate: all versor_conditions < ceiling (closure throughout) - bites gate: tested by four mutation tests (wrong count, diverging tails, vc violation, empty tails) contract.md: all spec predicates P1-P5c are now covered (NOT_COVERED is empty). Per CLAUDE.md schema-as-proof: every predicate gate is verified by a *_bites mutation test — 9 tests total (3 injection unit, 2 real-soak holds, 4 bites) all pass in 109s.
209 lines
8.5 KiB
Python
209 lines
8.5 KiB
Python
"""W2-R — Arbitrary-interruption recovery harness.
|
|
|
|
Tests the ``evaluate_p4_arbitrary_interruption`` predicate empirically against
|
|
the ADR-0219 generation-dir checkpoint model (W1-A). Each cut-point has a
|
|
``*_holds`` test (real soak) and a ``*_bites`` mutation test (synthetic evidence
|
|
that would make the predicate fail).
|
|
|
|
Cut-points tested:
|
|
- ``PARTIAL_GEN``: gen-9997 exists with one file; ``current`` unchanged.
|
|
Loader follows ``current``, ignores the partial dir, loads prior committed gen.
|
|
- ``FULL_GEN_BEFORE_SWAP``: gen-9997 exists with all four files; ``current``
|
|
unchanged. Loader still ignores the unreferenced complete gen dir.
|
|
- ``AFTER_SWAP``: no injection (the normal clean-commit control case); verifies
|
|
that the control case passes under the generation-dir model.
|
|
|
|
Per CLAUDE.md schema-as-proof discipline: a predicate that cannot fail under
|
|
the violation it nominally catches is decoration, not proof.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from evals.l10_continuity.predicates import (
|
|
CutPointEvidence,
|
|
evaluate_p4_arbitrary_interruption,
|
|
)
|
|
from evals.l10_continuity.runner import (
|
|
InterruptionCutPoint,
|
|
_inject_at_cutpoint,
|
|
_inject_full_gen_dir_before_swap,
|
|
_inject_partial_gen_dir,
|
|
read_recovered_turn_count,
|
|
run_soak,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Injection function unit tests (no soak needed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_inject_partial_gen_dir_creates_orphan(tmp_path: Path):
|
|
_inject_partial_gen_dir(tmp_path)
|
|
orphan = tmp_path / "gen-9997"
|
|
assert orphan.is_dir()
|
|
assert (orphan / "manifest.json").exists()
|
|
# Must NOT contain all four files (it's PARTIAL).
|
|
assert not (orphan / "session_state.json").exists()
|
|
|
|
|
|
def test_inject_full_gen_dir_before_swap_creates_all_four_files(tmp_path: Path):
|
|
_inject_full_gen_dir_before_swap(tmp_path)
|
|
orphan = tmp_path / "gen-9997"
|
|
assert orphan.is_dir()
|
|
for fname in ("recognizers.jsonl", "discovery_candidates.jsonl", "session_state.json", "manifest.json"):
|
|
assert (orphan / fname).exists(), f"expected {fname} in full-gen orphan"
|
|
|
|
|
|
def test_inject_at_cutpoint_after_swap_is_noop(tmp_path: Path):
|
|
_inject_at_cutpoint(tmp_path, InterruptionCutPoint.AFTER_SWAP)
|
|
# AFTER_SWAP injects nothing — directory should be empty (no orphan gen).
|
|
gen_dirs = [d for d in tmp_path.iterdir() if d.is_dir() and d.name.startswith("gen-")]
|
|
assert len(gen_dirs) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Holds tests — real soaks exercise each cut-point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_p4_arbitrary_interruption_partial_gen_holds(tmp_path: Path):
|
|
"""PARTIAL_GEN: orphan partial gen dir is ignored, prior gen loaded, recoveries converge."""
|
|
cp = InterruptionCutPoint.PARTIAL_GEN
|
|
reboot_turn = 3
|
|
n_turns = 6
|
|
|
|
# Commit-probe: verify loader reads prior committed gen (not the orphan).
|
|
probe_dir = tmp_path / "cp_probe"
|
|
run_soak(reboot_turn, engine_state_dir=probe_dir)
|
|
_inject_at_cutpoint(probe_dir, cp)
|
|
recovered_tc = read_recovered_turn_count(probe_dir)
|
|
assert recovered_tc == reboot_turn, (
|
|
f"PARTIAL_GEN: expected committed turn_count={reboot_turn}, got {recovered_tc}"
|
|
)
|
|
|
|
# Recovery soaks: two independent soaks with PARTIAL_GEN injection must converge.
|
|
rec_a = run_soak(n_turns, engine_state_dir=tmp_path / "a", reboot_at=(reboot_turn,), cutpoint_at_reboot=cp)
|
|
rec_b = run_soak(n_turns, engine_state_dir=tmp_path / "b", reboot_at=(reboot_turn,), cutpoint_at_reboot=cp)
|
|
tail_a = tuple(r.trace_hash for r in rec_a.post_reboot_records())
|
|
tail_b = tuple(r.trace_hash for r in rec_b.post_reboot_records())
|
|
assert tail_a == tail_b, "PARTIAL_GEN: two recovery soaks diverged"
|
|
|
|
# Full predicate.
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=cp.value,
|
|
recovered_turn_count=recovered_tc,
|
|
expected_turn_count=reboot_turn,
|
|
tail_hashes_a=tail_a,
|
|
tail_hashes_b=tail_b,
|
|
all_versor_conditions=rec_a.versor_conditions() + rec_b.versor_conditions(),
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert outcome.passed, outcome.detail
|
|
|
|
|
|
def test_p4_arbitrary_interruption_full_gen_before_swap_holds(tmp_path: Path):
|
|
"""FULL_GEN_BEFORE_SWAP: complete but unreferenced gen dir is ignored."""
|
|
cp = InterruptionCutPoint.FULL_GEN_BEFORE_SWAP
|
|
reboot_turn = 3
|
|
n_turns = 6
|
|
|
|
probe_dir = tmp_path / "cp_probe"
|
|
run_soak(reboot_turn, engine_state_dir=probe_dir)
|
|
_inject_at_cutpoint(probe_dir, cp)
|
|
recovered_tc = read_recovered_turn_count(probe_dir)
|
|
assert recovered_tc == reboot_turn, (
|
|
f"FULL_BEFORE_SWAP: expected committed turn_count={reboot_turn}, got {recovered_tc}"
|
|
)
|
|
|
|
rec_a = run_soak(n_turns, engine_state_dir=tmp_path / "a", reboot_at=(reboot_turn,), cutpoint_at_reboot=cp)
|
|
rec_b = run_soak(n_turns, engine_state_dir=tmp_path / "b", reboot_at=(reboot_turn,), cutpoint_at_reboot=cp)
|
|
tail_a = tuple(r.trace_hash for r in rec_a.post_reboot_records())
|
|
tail_b = tuple(r.trace_hash for r in rec_b.post_reboot_records())
|
|
assert tail_a == tail_b, "FULL_BEFORE_SWAP: two recovery soaks diverged"
|
|
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=cp.value,
|
|
recovered_turn_count=recovered_tc,
|
|
expected_turn_count=reboot_turn,
|
|
tail_hashes_a=tail_a,
|
|
tail_hashes_b=tail_b,
|
|
all_versor_conditions=rec_a.versor_conditions() + rec_b.versor_conditions(),
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert outcome.passed, outcome.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bites tests — synthetic evidence that trips each gate bullet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_p4_arbitrary_interruption_bites_on_wrong_recovered_turn_count():
|
|
"""Wrong turn_count: orphan gen loaded instead of committed gen → predicate fails."""
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=InterruptionCutPoint.PARTIAL_GEN.value,
|
|
recovered_turn_count=0, # wrong: orphan's manifest was read (0 turns)
|
|
expected_turn_count=3,
|
|
tail_hashes_a=("abc",),
|
|
tail_hashes_b=("abc",),
|
|
all_versor_conditions=(1e-13,),
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert not outcome.passed, "predicate must fail when recovered_turn_count != expected"
|
|
|
|
|
|
def test_p4_arbitrary_interruption_bites_on_diverging_recovery_tails():
|
|
"""Diverging tails: two independent recoveries produce different trace_hashes → fails."""
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=InterruptionCutPoint.FULL_GEN_BEFORE_SWAP.value,
|
|
recovered_turn_count=3,
|
|
expected_turn_count=3,
|
|
tail_hashes_a=("hash_a1", "hash_a2"),
|
|
tail_hashes_b=("hash_b1", "hash_b2"), # different → divergence
|
|
all_versor_conditions=(1e-13, 1e-13),
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert not outcome.passed, "predicate must fail when recovery tails diverge"
|
|
|
|
|
|
def test_p4_arbitrary_interruption_bites_on_versor_condition_violation():
|
|
"""Closure violation: versor_condition >= 1e-6 in a recovery soak → fails."""
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=InterruptionCutPoint.PARTIAL_GEN.value,
|
|
recovered_turn_count=3,
|
|
expected_turn_count=3,
|
|
tail_hashes_a=("abc",),
|
|
tail_hashes_b=("abc",),
|
|
all_versor_conditions=(1e-6,), # at the ceiling → violation
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert not outcome.passed, "predicate must fail when versor_condition >= 1e-6"
|
|
|
|
|
|
def test_p4_arbitrary_interruption_bites_on_empty_tails():
|
|
"""Empty recovery tails: no post-reboot evidence → fails."""
|
|
evidence = (
|
|
CutPointEvidence(
|
|
cut_point=InterruptionCutPoint.PARTIAL_GEN.value,
|
|
recovered_turn_count=3,
|
|
expected_turn_count=3,
|
|
tail_hashes_a=(), # empty: no post-reboot records
|
|
tail_hashes_b=(),
|
|
all_versor_conditions=(),
|
|
),
|
|
)
|
|
outcome = evaluate_p4_arbitrary_interruption(evidence)
|
|
assert not outcome.passed, "predicate must fail with empty recovery tails"
|