R7 (live halves): seeded churn + rebuild selector + metrics.json
churn.py emits a seed-deterministic kill/spawn schedule (pure data — no real VM spin/kill; the live fabric half stays gated by the containment law). selector.py consumes the schedule and rebuilds a circuit whenever a kill drops one of its hops, across static | random | agent strategies; the paid frontier-model agent arm (GOAL envelope (c)) is human-gated and NOT wired — the agent strategy here is a local stability heuristic that spends nothing. analysis/metrics.py aggregates the four DV families (RQ1 correlation AUC, RQ2 entropy bits, RQ3 throughput retention + rebuild-classifier AUC) into a schema-valid, write-once metrics.json. Acceptance check green: under a fixed churn seed the selector rebuilds every dropped circuit (every_drop_rebuilt, all strategies) and metrics.json is produced. Python R7 selector suite 11 passed; full SOR suite 92 passed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""R7 (live halves) — churn schedule + selector rebuild + metrics.json.
|
||||
|
||||
Acceptance predicate (roadmap R7, the traffic-side half): under a fixed churn
|
||||
seed the selector rebuilds every dropped circuit, and metrics.json is produced.
|
||||
(The offline detector calibration halves — correlator AUC≈1/0.5 and entropy
|
||||
log2(N) — are covered in test_sor_analysis.py.)
|
||||
|
||||
All deterministic and offline: the churn schedule is seeded data (no real VM
|
||||
spin/kill), the selector replays it, and the metrics writer aggregates the DVs.
|
||||
The paid frontier-model ``agent`` arm is human-gated and NOT exercised here — the
|
||||
``agent`` strategy under test is the local stability heuristic (no spend).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cmd_chat.sor.analysis.detectors import synthetic_bridge_fixture
|
||||
from cmd_chat.sor.analysis.metrics import compute_metrics, write_metrics
|
||||
from cmd_chat.sor.churn import ChurnEvent, churn_schedule, live_nodes_at
|
||||
from cmd_chat.sor.events import EventLog
|
||||
from cmd_chat.sor.selector import STRATEGIES, run_selection
|
||||
|
||||
|
||||
NODES = [f"n{i}" for i in range(8)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Churn schedule — seeded + deterministic.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_churn_schedule_is_seed_deterministic():
|
||||
a = churn_schedule(2024, NODES, steps=20)
|
||||
b = churn_schedule(2024, NODES, steps=20)
|
||||
assert a == b
|
||||
assert churn_schedule(2025, NODES, steps=20) != a
|
||||
|
||||
|
||||
def test_churn_schedule_has_kills_and_spawns():
|
||||
sched = churn_schedule(7, NODES, steps=40, kill_prob_pct=40)
|
||||
kinds = {ev.kind for ev in sched}
|
||||
assert "kill" in kinds and "spawn" in kinds
|
||||
# A killed node is not live until it is spawned again.
|
||||
first_kill = next(ev for ev in sched if ev.kind == "kill")
|
||||
assert first_kill.node not in live_nodes_at(NODES, sched, first_kill.t)
|
||||
|
||||
|
||||
def test_empty_inputs_yield_empty_schedule():
|
||||
assert churn_schedule(1, [], steps=10) == []
|
||||
assert churn_schedule(1, NODES, steps=0) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Selector — rebuilds every dropped circuit under a fixed churn seed.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("strategy", STRATEGIES)
|
||||
def test_selector_rebuilds_every_dropped_circuit(strategy):
|
||||
# Pool (8) comfortably exceeds hops (3), so every drop can be healed.
|
||||
seed = 0xBADC0DE
|
||||
sched = churn_schedule(seed, NODES, steps=60, kill_prob_pct=35)
|
||||
result = run_selection(seed, NODES, hops=3, schedule=sched, strategy=strategy)
|
||||
assert len(result.initial_circuit) == 3
|
||||
assert result.drops > 0 # the churn actually perturbed the circuit
|
||||
assert result.every_drop_rebuilt # THE R7 acceptance predicate
|
||||
assert result.deferred == 0
|
||||
assert len(result.rebuilds) == result.drops
|
||||
# Every rebuilt circuit is a full, distinct-node, currently-plausible path.
|
||||
for rb in result.rebuilds:
|
||||
assert len(rb.circuit) == 3
|
||||
assert len(set(rb.circuit)) == 3
|
||||
|
||||
|
||||
def test_selector_is_deterministic():
|
||||
seed = 42
|
||||
sched = churn_schedule(seed, NODES, steps=30)
|
||||
r1 = run_selection(seed, NODES, hops=3, schedule=sched, strategy="random")
|
||||
r2 = run_selection(seed, NODES, hops=3, schedule=sched, strategy="random")
|
||||
assert [rb.circuit for rb in r1.rebuilds] == [rb.circuit for rb in r2.rebuilds]
|
||||
|
||||
|
||||
def test_unknown_strategy_refused():
|
||||
with pytest.raises(ValueError):
|
||||
run_selection(1, NODES, hops=3, schedule=[], strategy="frontier-gpt")
|
||||
|
||||
|
||||
def test_selection_emits_rebuild_events(tmp_path):
|
||||
seed = 0xBADC0DE
|
||||
sched = churn_schedule(seed, NODES, steps=40, kill_prob_pct=35)
|
||||
log = EventLog(tmp_path, "sel-run", seed=seed)
|
||||
result = run_selection(seed, NODES, hops=3, schedule=sched,
|
||||
strategy="static", log=log)
|
||||
assert result.events_sha256 and len(result.events_sha256) == 64
|
||||
lines = (tmp_path / "events.jsonl").read_text().strip().splitlines()
|
||||
events = [json.loads(ln)["event"] for ln in lines]
|
||||
assert "churn_kill" in events
|
||||
assert events.count("rebuild_done") == len(result.rebuilds)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# metrics.json — produced + schema-valid.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_metrics_json_is_produced(tmp_path):
|
||||
seed = 0xBADC0DE
|
||||
sched = churn_schedule(seed, NODES, steps=60, kill_prob_pct=35)
|
||||
result = run_selection(seed, NODES, hops=3, schedule=sched, strategy="static")
|
||||
|
||||
ingress, egress = synthetic_bridge_fixture(0xC0FFEE, linked=True) # AUC ~1
|
||||
metrics = compute_metrics(
|
||||
sender_counts={f"s{i}": 1 for i in range(8)}, # 8 equiprobable -> 3 bits
|
||||
ingress=ingress,
|
||||
egress=egress,
|
||||
selection=result,
|
||||
churned_gaps=[1.0, 1.5, 1.0, 2.0],
|
||||
baseline_gaps=[10.0, 12.0, 11.0, 13.0],
|
||||
)
|
||||
path = write_metrics(tmp_path / "run", metrics)
|
||||
assert path.exists()
|
||||
|
||||
loaded = json.loads(path.read_text())
|
||||
assert loaded["schema"] == "sor-metrics/1"
|
||||
assert loaded["rq2_anonymity_entropy_bits"] == pytest.approx(3.0)
|
||||
assert loaded["rq1_bridge_correlation_auc"] == pytest.approx(1.0)
|
||||
assert loaded["rq3_every_drop_rebuilt"] is True
|
||||
assert 0.0 <= loaded["rq3_throughput_retention"] <= 1.0
|
||||
assert loaded["rq3_rebuild_classifier_auc"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_metrics_json_is_write_once(tmp_path):
|
||||
result = run_selection(1, NODES, hops=3, schedule=[], strategy="static")
|
||||
metrics = compute_metrics(
|
||||
sender_counts={"a": 1, "b": 1},
|
||||
ingress=[[1, 2]],
|
||||
egress=[[1, 2]],
|
||||
selection=result,
|
||||
)
|
||||
run = tmp_path / "run"
|
||||
write_metrics(run, metrics)
|
||||
with pytest.raises(FileExistsError): # immutable artifact
|
||||
write_metrics(run, metrics)
|
||||
Reference in New Issue
Block a user