"""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)