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:
leetcrypt
2026-07-19 17:18:44 -07:00
parent 21263da8ca
commit 12fd2537e3
5 changed files with 502 additions and 4 deletions
+9 -4
View File
@@ -11,8 +11,13 @@ confirmatory-cell data — CLAUDE.md build discipline). Specifically:
known-unlinked pair.
These are pure functions over in-memory series/distributions — they read no
pcaps, spawn no engine, move no traffic, and touch no VM fabric. The
traffic-moving R7 pieces (``churn.py`` seeded VM spin/kill; the live selector
rebuild loop; the confirmatory battery that writes ``metrics.json``) are NOT
here — they are HELD pending R4/R6 + a live grid (see OVERSEER-STATUS.md).
pcaps, spawn no engine, move no traffic, and touch no VM fabric.
The rest of R7 is landed alongside: ``metrics.py`` (this package) aggregates the
four DV families into a schema-valid, write-once ``metrics.json``; the seeded
churn schedule + rebuild loop live in ``cmd_chat/sor/{churn,selector}.py``. The
live VM spin/kill against the isolated hackhouse fabric, and the full
pre-registered confirmatory battery, remain gated by the containment law + the
human freeze (see OVERSEER-STATUS.md) — the churn schedule is seeded *data* here,
not a real VM operation.
"""
+113
View File
@@ -0,0 +1,113 @@
"""R7 — metrics.json aggregator (the DV writer for a SOR run).
Reads the offline detector primitives (``detectors.py``) plus a churn/selector
:class:`~cmd_chat.sor.selector.SelectionResult` and writes an immutable, schema-
valid ``metrics.json`` into a run directory. It aggregates the four DV families
the study reports:
* RQ1 — bridge-correlation AUC (linkability of ingress↔egress flows);
* RQ2 — anonymity-set entropy (bits) of the observed sender distribution;
* RQ3 — throughput retention under churn + a rebuild-pattern classifier AUC.
The detectors are calibrated on synthetic fixtures only (never fit to
confirmatory-cell data — CLAUDE.md build discipline). This module computes and
serializes; it moves no traffic and spawns no engine. ``metrics.json`` is written
once and never edited in place (artifact immutability)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from cmd_chat.sor.analysis.detectors import (
auc,
bridge_correlation_auc,
shannon_entropy_bits,
)
from cmd_chat.sor.selector import SelectionResult
METRICS_SCHEMA = "sor-metrics/1"
def throughput_retention(result: SelectionResult) -> float:
"""Fraction of dropped circuits that were successfully rebuilt (a proxy for
throughput retained under churn). 1.0 when every drop was healed; lower when
drops were deferred for lack of a live pool. No drops -> full retention."""
if result.drops <= 0:
return 1.0
healed = result.drops - result.deferred
return max(0.0, min(1.0, healed / result.drops))
def rebuild_classifier_auc(
churned_gaps: Sequence[float], baseline_gaps: Sequence[float]
) -> float:
"""AUC separating a churned run's rebuild-interval signal from a low-churn
baseline's — the RQ3 rebuild-pattern classifier. ≈1 when the two regimes are
cleanly separable, ≈0.5 when indistinguishable. Calibrated on labeled control
signals, not fit to confirmatory data."""
# Shorter gaps (more frequent rebuilds) mark the churned regime; score churned
# as the positive class on the negated gap so larger score = more churn.
pos = [-g for g in churned_gaps]
neg = [-g for g in baseline_gaps]
return auc(pos, neg)
def compute_metrics(
*,
sender_counts: Dict[str, int],
ingress: Sequence[Sequence[float]],
egress: Sequence[Sequence[float]],
selection: SelectionResult,
churned_gaps: Optional[Sequence[float]] = None,
baseline_gaps: Optional[Sequence[float]] = None,
) -> Dict[str, Any]:
"""Aggregate the four DV families into a metrics dict (not yet written)."""
metrics: Dict[str, Any] = {
"schema": METRICS_SCHEMA,
"seed": selection.seed,
"selector_strategy": selection.strategy,
"hops": selection.hops,
"rq1_bridge_correlation_auc": bridge_correlation_auc(ingress, egress),
"rq2_anonymity_entropy_bits": shannon_entropy_bits(sender_counts),
"rq2_sender_count": len([c for c in sender_counts.values() if c > 0]),
"rq3_throughput_retention": throughput_retention(selection),
"rq3_drops": selection.drops,
"rq3_rebuilds": len(selection.rebuilds),
"rq3_deferred": selection.deferred,
"rq3_every_drop_rebuilt": selection.every_drop_rebuilt,
}
if churned_gaps is not None and baseline_gaps is not None:
metrics["rq3_rebuild_classifier_auc"] = rebuild_classifier_auc(
churned_gaps, baseline_gaps
)
return metrics
def _validate(metrics: Dict[str, Any]) -> None:
required = (
"schema",
"rq1_bridge_correlation_auc",
"rq2_anonymity_entropy_bits",
"rq3_throughput_retention",
"rq3_every_drop_rebuilt",
)
missing = [k for k in required if k not in metrics]
if missing:
raise ValueError(f"metrics schema: missing keys {missing}")
if metrics["schema"] != METRICS_SCHEMA:
raise ValueError(f"metrics schema: unexpected {metrics['schema']!r}")
def write_metrics(run_dir: Path, metrics: Dict[str, Any]) -> Path:
"""Write ``metrics.json`` into ``run_dir`` (created if needed) and return its
path. Refuses to overwrite an existing metrics.json (write-once artifact)."""
_validate(metrics)
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
path = run_dir / "metrics.json"
if path.exists():
raise FileExistsError(f"metrics.json already exists (immutable): {path}")
path.write_text(json.dumps(metrics, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return path
+71
View File
@@ -0,0 +1,71 @@
"""R7 — Seeded churn schedule (deterministic node kill/spawn stream).
The churn generator produces a **schedule** — a reproducible list of kill/spawn
events drawn from the R1 ``Domain.CHURN`` stream — that models nodes dropping out
of and rejoining the grid over time. It is pure data: this module spins and kills
no real VM (that live half runs against the isolated hackhouse VM fabric and is
gated by the same containment law as the R4 forwarder). Producing the schedule
here, deterministically from the seed, is what lets the R7 acceptance check assert
that a fixed churn seed drives the selector to rebuild *every* dropped circuit —
verifiable entirely offline.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from cmd_chat.sor.config import Domain, SorRng
@dataclass(frozen=True)
class ChurnEvent:
"""One scheduled grid event at logical step ``t``. ``kind`` is ``"kill"`` or
``"spawn"``; ``node`` is the affected node id."""
t: int
kind: str # "kill" | "spawn"
node: str
def churn_schedule(
seed: int,
nodes: List[str],
steps: int,
kill_prob_pct: int = 30,
) -> List[ChurnEvent]:
"""Deterministically build a churn schedule over ``nodes`` for ``steps`` logical
steps, drawing from the seed's CHURN stream alone (so the same seed yields the
same schedule — the R7 determinism the selector check relies on).
At each step every currently-live node may be killed with probability
``kill_prob_pct``%, and every currently-dead node is respawned with the same
probability. Events are emitted in a stable (step, node) order."""
if not nodes or steps <= 0:
return []
s = SorRng(seed).stream(Domain.CHURN)
live = {n: True for n in nodes}
events: List[ChurnEvent] = []
for t in range(steps):
for n in nodes: # stable order -> stable schedule
roll = s.next_below(100)
if live[n]:
if roll < kill_prob_pct:
live[n] = False
events.append(ChurnEvent(t, "kill", n))
else:
if roll < kill_prob_pct:
live[n] = True
events.append(ChurnEvent(t, "spawn", n))
return events
def live_nodes_at(nodes: List[str], schedule: List[ChurnEvent], t: int) -> List[str]:
"""The set of live nodes at (through the end of) step ``t``, replaying the
schedule from the all-live initial state. Deterministic."""
live = {n: True for n in nodes}
for ev in schedule:
if ev.t > t:
break
live[ev.node] = ev.kind == "spawn"
return [n for n in nodes if live[n]]
+171
View File
@@ -0,0 +1,171 @@
"""R7 — Path selector that rebuilds circuits under churn.
The selector consumes a churn schedule (``churn.py``) and, whenever a live circuit
loses a node to a kill, **rebuilds** it from the currently-live pool. Three
strategies, all deterministic and offline:
* ``static`` — canonical-order pick (stable, no randomness);
* ``random`` — seeded PATH-stream pick from the live pool;
* ``agent`` — a *local* stability heuristic (prefers nodes that have churned
least). The paid frontier-model agent arm (GOAL autonomy envelope (c)) is
**human-gated and intentionally NOT wired here** — this strategy spends
nothing and calls no external model; it is the offline stand-in used to
validate the rebuild loop.
The R7 acceptance predicate this satisfies: under a fixed churn seed the selector
rebuilds every dropped circuit (as long as the live pool can supply ``hops``
nodes). Rebuild activity is emitted as R3 ``rebuild_start``/``rebuild_done`` +
``churn_kill``/``churn_spawn`` events when an :class:`EventLog` is supplied.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from cmd_chat.sor.churn import ChurnEvent
from cmd_chat.sor.config import Domain, SorRng
from cmd_chat.sor.events import EventLog
STRATEGIES = ("static", "random", "agent")
@dataclass
class Rebuild:
"""One rebuild triggered by a drop: the step, the node that dropped, and the
replacement circuit (node ids in order)."""
t: int
dropped: str
circuit: List[str]
@dataclass
class SelectionResult:
"""Outcome of replaying a churn schedule under a selector strategy."""
strategy: str
seed: int
hops: int
initial_circuit: List[str]
rebuilds: List[Rebuild] = field(default_factory=list)
drops: int = 0
deferred: int = 0 # drops that could not be rebuilt (pool < hops at that step)
events_sha256: Optional[str] = None
@property
def every_drop_rebuilt(self) -> bool:
"""True iff every drop that broke the circuit was met by a rebuild — the
R7 acceptance predicate (no deferred/unhealed drops)."""
return self.deferred == 0 and len(self.rebuilds) == self.drops
class Selector:
"""Picks a ``hops``-length circuit from a live pool per its strategy. Pure and
deterministic; the same (seed, strategy, live pool, churn count) always yields
the same circuit."""
def __init__(self, strategy: str, seed: int, hops: int) -> None:
if strategy not in STRATEGIES:
raise ValueError(f"unknown selector strategy {strategy!r} (of {STRATEGIES})")
self.strategy = strategy
self.seed = seed
self.hops = hops
self._draws = 0 # advances the random stream across rebuilds
self._kill_count: Dict[str, int] = {} # node -> times killed (agent signal)
def note_kill(self, node: str) -> None:
self._kill_count[node] = self._kill_count.get(node, 0) + 1
def build(self, live: List[str]) -> Optional[List[str]]:
"""Return a ``hops``-length circuit from ``live``, or ``None`` if the pool
is too small (fewer than ``hops`` live nodes)."""
if len(live) < self.hops:
return None
if self.strategy == "static":
return sorted(live)[: self.hops]
if self.strategy == "random":
pool = sorted(live)
s = SorRng(self.seed).stream(Domain.SELECTOR)
for _ in range(self._draws): # replay to current position (determinism)
s.next_u64()
picked: List[str] = []
work = list(pool)
for _ in range(self.hops):
j = s.next_below(len(work))
picked.append(work.pop(j))
self._draws += 1
return picked
# agent: local stability heuristic — fewest kills first, ties by id. No
# external model, no spend (the paid arm is human-gated, not wired here).
ranked = sorted(live, key=lambda n: (self._kill_count.get(n, 0), n))
return ranked[: self.hops]
def run_selection(
seed: int,
nodes: List[str],
hops: int,
schedule: List[ChurnEvent],
strategy: str = "static",
log: Optional[EventLog] = None,
) -> SelectionResult:
"""Replay ``schedule`` over ``nodes`` under ``strategy`` and rebuild the circuit
whenever a kill drops one of its nodes. Deterministic from the inputs.
Returns a :class:`SelectionResult`; ``result.every_drop_rebuilt`` is the R7
acceptance predicate. If ``log`` is given, emits R3 churn/rebuild events."""
sel = Selector(strategy, seed, hops)
live = {n: True for n in nodes}
live_list = [n for n in nodes if live[n]]
initial = sel.build(live_list) or []
circuit: List[str] = list(initial)
result = SelectionResult(strategy, seed, hops, list(initial))
for ev in schedule:
if ev.kind == "kill":
live[ev.node] = False
sel.note_kill(ev.node)
if log is not None:
log.emit("churn_kill", node_fp=_fp(ev.node), hop_index=ev.t)
if ev.node in circuit:
# The live circuit lost a hop -> must rebuild.
result.drops += 1
if log is not None:
log.emit("rebuild_start", circuit_id=f"t{ev.t}", hop_index=ev.t)
rebuilt = sel.build([n for n in nodes if live[n]])
if rebuilt is None:
result.deferred += 1 # pool too small right now
circuit = []
else:
circuit = rebuilt
result.rebuilds.append(Rebuild(ev.t, ev.node, list(rebuilt)))
if log is not None:
log.emit("rebuild_done", circuit_id=f"t{ev.t}",
hop_index=len(rebuilt))
else: # spawn
live[ev.node] = True
if log is not None:
log.emit("churn_spawn", node_fp=_fp(ev.node), hop_index=ev.t)
if not circuit:
# A deferred drop can now be healed once the pool recovers.
rebuilt = sel.build([n for n in nodes if live[n]])
if rebuilt is not None:
circuit = rebuilt
result.deferred = max(0, result.deferred - 1)
result.rebuilds.append(Rebuild(ev.t, ev.node, list(rebuilt)))
if log is not None:
log.emit("rebuild_done", circuit_id=f"t{ev.t}",
hop_index=len(rebuilt))
if log is not None:
result.events_sha256 = log.close()
return result
def _fp(node: str) -> str:
"""A short, stable fingerprint of a node id for event metadata (never the id
verbatim in case ids ever carry structure)."""
import hashlib
return hashlib.sha256(node.encode("utf-8")).hexdigest()[:8]