Marcin Pawlowski bd2ac7b7be
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.

Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
  referenceable and countable, which makes every reference verifiable
  from chain data alone.
- The reference window is derived from a window-absorption parameter,
  w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
  bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
  takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
  rejections as deep_ref_share.

The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.

Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.

Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
  models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
  reports/tsi/report-figures/.

Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.

Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.

The reports/tsi/ prose describing this model is held back for a
separate editorial pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00

349 lines
17 KiB
Python

"""Parameter-sweep expansion, parallel execution, and result persistence.
Across-config parallelism is the main multicore lever: ``run_trajectory`` is a pure
function of an immutable, hash-seeded ``SimConfig``, so results are bitwise
order-independent and the grid is embarrassingly parallel. We use joblib's process-based
**loky** backend (this workload is CPU-bound Python that holds the GIL, so threads would
serialise), and pin each worker to a single BLAS thread to avoid oversubscription.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import multiprocessing as mp
import os
import queue
import sys
import time
from dataclasses import dataclass, replace
from pathlib import Path
import numpy as np
import pandas as pd
import yaml
from joblib import Parallel, delayed
from tqdm import tqdm
from .config import SimConfig, SweepConfig
from .engine import run_trajectory
from .memguard import DEFAULT_BUDGET_FRAC, ArrivalMatrixTooLarge
from .memguard import total_ram_bytes as _total_ram_bytes
# ``calibrate="auto"`` runs a real memory probe when the analytic estimate is either extrapolated
# past the validated network size (N > CALIBRATION_N_THRESHOLD) OR simply large in absolute terms
# (per-worker estimate > CALIBRATION_BYTES_THRESHOLD) — the latter catches a block-count explosion
# from a low genesis_d_factor even at small N, which is exactly what OOM-froze the box at N=1000.
CALIBRATION_N_THRESHOLD = 2000
CALIBRATION_BYTES_THRESHOLD = 1.5 * 1024**3
# Keep numpy/BLAS single-threaded inside each worker process (belt-and-braces alongside
# joblib's inner_max_num_threads); prevents N_workers x N_blas_threads oversubscription.
for _var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
os.environ.setdefault(_var, "1")
def expected_peak_blocks(config: SimConfig) -> int:
"""Expected block count of the *heaviest* epoch (genesis), which sizes the arrival matrix.
``n_blocks`` is NOT ``~10*k``: it is the number of lottery wins over the epoch, which scales
with ``sum_i phi(w_i / D_est)``. At genesis ``D_est = genesis_d_factor * D_true`` is at its
smallest, so ``sum(stake)/D_est = 1/genesis_d_factor`` is largest and block production peaks
there (a low ``genesis_d_factor`` can inflate it 100x — the collapsed-estimate regime). We
realise the seeded stake and compute the expected genesis-epoch win count exactly; this is
what previously blew the estimate up by ~90x and OOM-ed the box. Capped at ``N*E`` (every
node can win every slot at most once).
"""
from .lottery import win_probs
from .rng import seedseq_for
from .stake import make_stake
child0 = seedseq_for(config).spawn(config.epochs + 3)[0]
stake = make_stake(config, np.random.default_rng(child0))
d_est_genesis = config.genesis_d_factor * float(stake.sum())
p = win_probs(stake, d_est_genesis, config.f)
expected = float(config.epoch_len) * float(p.sum())
return int(min(expected, float(config.n_nodes) * config.epoch_len)) + 1
def _arrival_columns(config: SimConfig, peak_blocks: int) -> int:
"""Estimated stored arrival columns: full ``n_blocks``, or the pruned keep-span window.
With ``prune_arrival`` the arrival buffer keeps only blocks inside ``max(horizon, W)``
slots, so its width is ``~keepspan * blocks_per_slot`` regardless of how far the block count
exploded. We don't have the graph here, so ``horizon`` is approximated generously (dominated by
``uncle_window``, plus the blend mix cascade); the exact buffer size is guarded in-worker.
"""
if not (config.prune_arrival and config.windowed_fork_choice):
return peak_blocks
per_slot = peak_blocks / config.epoch_len if config.epoch_len else peak_blocks
keepspan = float(config.effective_uncle_window)
if config.topology == "blend":
lat = max(config.link_latency_mean, 0.1)
keepspan = max(keepspan, (config.blend_hops + 1) * lat * 4
+ config.blend_hops * config.blend_delay_max)
buf_width = 2.0 * (keepspan * per_slot * 1.5 + 64) # ~ _build_pruned's 2*(cap + slack)
return int(min(peak_blocks, buf_width)) + 1
def estimate_worker_bytes(config: SimConfig) -> int:
"""Rough peak RSS of one worker running ``config``.
The arrival buffer and the dense ``path_latency`` (``N x N`` float64, plus Dijkstra scratch)
dominate; everything else (block-tree 1-D arrays, measurement temp) is smaller. The arrival
term is ``N * columns * 8`` where ``columns`` is the full peak-epoch block count
(``expected_peak_blocks``, capturing a low-``genesis_d_factor`` explosion) or, with
``prune_arrival``, just the bounded keep-span window (``_arrival_columns``). Factors are
generous so the cap errs toward fewer, safe workers.
"""
n = config.n_nodes
cols = _arrival_columns(config, expected_peak_blocks(config))
arrival = 1.35 * n * cols * 8 # buffer + measurement/fork-choice temps
path_latency = 2.2 * n * n * 8 # dense (N,N) + Dijkstra predecessor/scratch
fixed = 250 * 1024**2 # python + numpy + numba loaded per process
return int(arrival + path_latency + fixed)
def _ru_maxrss_bytes(ru_maxrss: int) -> int:
"""Normalise ``getrusage`` peak RSS to bytes (macOS reports bytes, Linux kibibytes)."""
return int(ru_maxrss) if sys.platform == "darwin" else int(ru_maxrss) * 1024
def _calibration_target(config: SimConfig, q: mp.Queue) -> None: # pragma: no cover - subprocess
"""Child entry point: run ONE epoch of ``config`` and report this process's peak RSS.
One epoch reaches the same peak as a full trajectory — the arrival matrix ``A`` and the
``path_latency`` matrix are (re)built every epoch and the measurement temporary peaks within
an epoch — so a single epoch is a faithful, cheap probe of a worker's high-water mark.
The probe bounds ITSELF to a fraction of physical RAM (overriding any inherited budget): it
must be free to allocate the real peak in order to measure it, but must still fail loud rather
than freeze if the heaviest config exceeds the box — in which case the parent falls back to the
(large) analytic estimate.
"""
import resource
from .engine import run_trajectory
from .memguard import total_ram_bytes
os.environ["TSI_ARRIVAL_BYTES_BUDGET"] = str(int(DEFAULT_BUDGET_FRAC * total_ram_bytes()))
run_trajectory(replace(config, epochs=1))
q.put(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
def measure_worker_bytes(config: SimConfig, timeout: float = 900.0) -> int | None:
"""Peak RSS (bytes) of a real worker running ``config``, measured in a fresh spawned
process — the same isolation loky gives each worker, so numba/numpy/scratch are all counted.
Returns ``None`` if the probe cannot start, crashes, or exceeds ``timeout`` (the caller then
falls back to the analytic estimate). ``spawn`` matches loky and keeps the probe independent
of the parent's already-imported modules; it needs an importable ``__main__`` (a real script
or ``-m`` entry point), so from a bare REPL/stdin the child fails to bootstrap and we return
``None`` promptly by polling liveness rather than blocking the full timeout.
"""
ctx = mp.get_context("spawn")
q: mp.Queue = ctx.Queue()
proc = ctx.Process(target=_calibration_target, args=(config, q), daemon=True)
proc.start()
deadline = time.monotonic() + timeout
try:
while True:
try:
ru_maxrss = q.get(timeout=1.0)
return _ru_maxrss_bytes(ru_maxrss)
except queue.Empty:
if not proc.is_alive(): # child died (bootstrap failure / crash / OOM-kill)
return None
if time.monotonic() >= deadline:
return None
finally:
if proc.is_alive():
proc.terminate()
proc.join(timeout=5.0)
@dataclass
class WorkerPlan:
"""The chosen worker count plus the numbers behind it (for logging)."""
n_jobs: int
per_worker_bytes: int
calibrated: bool
ram_bytes: int
mem_frac: float
def plan_workers(requested: int, configs: list[SimConfig], mem_frac: float,
calibrate: str = "auto") -> WorkerPlan:
"""Size the worker pool so ``n_jobs * per-worker peak`` fits ``mem_frac`` of physical RAM.
Per-worker peak is the analytic ``estimate_worker_bytes`` by default, but is replaced by a
**measured** peak RSS (one epoch of the heaviest config, in a spawned process) when the
calibration probe fires:
* ``calibrate="auto"`` (default) — probe when the estimate is extrapolated past the validated
network size (N > ``CALIBRATION_N_THRESHOLD``) OR is large in absolute terms
(> ``CALIBRATION_BYTES_THRESHOLD``); the latter catches a low-``genesis_d_factor`` block
explosion even at small N;
* ``calibrate="always"`` — always probe;
* ``calibrate="never"`` — never probe (analytic estimate only).
``requested`` follows joblib's convention (``-1`` = all logical cores); ``mem_frac <= 0``
disables the cap entirely.
"""
cores = os.cpu_count() or 1
want = cores if requested < 0 else max(1, requested)
ram = _total_ram_bytes()
if mem_frac <= 0.0 or not configs:
return WorkerPlan(want, 0, False, ram, mem_frac)
heaviest = max(configs, key=estimate_worker_bytes)
per_worker = estimate_worker_bytes(heaviest)
calibrated = False
do_probe = calibrate == "always" or (calibrate == "auto" and (
heaviest.n_nodes > CALIBRATION_N_THRESHOLD
or per_worker > CALIBRATION_BYTES_THRESHOLD))
if do_probe:
measured = measure_worker_bytes(heaviest)
if measured is not None:
per_worker = int(measured * 1.1) # 10% headroom over the measured high-water mark
calibrated = True
budget = int(mem_frac * ram)
fit = max(1, budget // per_worker)
return WorkerPlan(min(want, fit), per_worker, calibrated, ram, mem_frac)
def run_sweep(
sweep: SweepConfig, n_jobs: int = -1, progress: bool = True, batch_size: str | int = "auto",
mem_frac: float = 0.7, calibrate: str = "auto",
) -> pd.DataFrame:
"""Expand the grid, run every config across cores (loky), return one big frame.
``n_jobs=-1`` uses all logical cores, but the count is then **capped** so that
``workers * per-worker peak RSS`` stays under ``mem_frac`` of physical RAM (``mem_frac=0``
uncaps concurrency but still keeps the per-process fail-loud guard). This prevents the
``(N x n_blocks)`` arrival matrix from OOM-ing the box when many heavy configs run at once —
including the collapsed-``D_est`` regime where a low ``genesis_d_factor`` explodes ``n_blocks``
(see ``expected_peak_blocks``). Per-worker peak is measured by a **calibration probe** when
heavy (see ``plan_workers`` / ``calibrate``), and each worker additionally enforces an
in-process budget (``TSI_ARRIVAL_BYTES_BUDGET`` on both ``A`` and ``path_latency``) so a
mis-estimated config fails loud instead of freezing the box. ``batch_size`` defaults to joblib
"auto"; pass ``1`` for a small grid of heavy full-scale configs.
"""
configs = sweep.expand()
plan = plan_workers(n_jobs, configs, mem_frac, calibrate)
# Per-worker byte budget for the in-worker guard: each worker's share of the RAM budget.
# Enforced in build_tree_pernode (A) and build_path_latency (N^2) -> ArrivalMatrixTooLarge.
# "0" is NOT "unlimited": memguard resolves it to DEFAULT_BUDGET_FRAC of RAM, so even a
# `mem_frac=0` run keeps an absolute per-process ceiling (no single alloc can freeze the box).
worker_budget = (int(mem_frac * plan.ram_bytes // plan.n_jobs)
if (mem_frac > 0 and plan.n_jobs) else 0)
os.environ["TSI_ARRIVAL_BYTES_BUDGET"] = str(worker_budget)
if progress:
src = "measured" if plan.calibrated else "estimated"
gb = plan.per_worker_bytes / 1024**3
print(f"[sweep] {len(configs)} configs; ~{gb:.2f} GB/worker ({src}); "
f"using {plan.n_jobs} worker(s) of {os.cpu_count()} cores "
f"(RAM {plan.ram_bytes / 1024**3:.0f} GB x cap {mem_frac:g})")
if worker_budget and plan.per_worker_bytes > worker_budget:
print(f"[sweep] WARNING: a single config's estimated peak "
f"({gb:.1f} GB) exceeds its per-worker RAM share "
f"({worker_budget / 1024**3:.1f} GB); it may swap or hit the arrival-matrix "
f"guard. Raise genesis_d_factor, lower n_nodes/k/epochs, or raise --mem-frac.")
runner = Parallel(
n_jobs=plan.n_jobs,
backend="loky",
inner_max_num_threads=1,
batch_size=batch_size,
return_as="generator",
)(delayed(run_trajectory)(c) for c in configs)
if progress:
runner = tqdm(runner, total=len(configs), desc="configs")
rows: list[dict] = []
try:
for traj in runner:
rows.extend(traj)
except ArrivalMatrixTooLarge as exc:
raise RuntimeError(
f"aborting sweep: {exc} (worker memory guard tripped — the per-worker estimate was "
f"too low, likely an under-modelled block explosion)."
) from exc
return pd.DataFrame(rows)
def persist(df: pd.DataFrame, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix == ".parquet":
df.to_parquet(path, index=False)
else:
df.to_csv(path, index=False)
def load_sweep_yaml(path: str | Path) -> SweepConfig:
with open(path) as fh:
data = yaml.safe_load(fh)
return SweepConfig.from_dict(data)
def new_run_dir(outdir: str | Path, label: str) -> Path:
"""Create a fresh timestamped run directory so runs never overwrite each other."""
ts = _dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
base = Path(outdir) / f"{ts}_{label}"
run_dir, i = base, 2
while run_dir.exists(): # avoid same-second collisions
run_dir = base.with_name(f"{base.name}_{i}")
i += 1
run_dir.mkdir(parents=True)
return run_dir
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Run a TSI parameter sweep + figures")
parser.add_argument("--config", required=True, help="sweep YAML")
parser.add_argument("--outdir", default="runs",
help="parent dir; a dated sub-folder is created per run")
parser.add_argument("--label", default=None, help="run label (default: config name)")
parser.add_argument("--n-jobs", type=int, default=-1, help="-1 = all logical cores")
parser.add_argument("--batch-size", default="auto",
help="'auto' (default) or an int; use 1 for a small heavy grid")
parser.add_argument("--mem-frac", type=float, default=0.7,
help="cap concurrent workers to this fraction of physical RAM "
"(0 disables the cap)")
parser.add_argument("--calibrate", choices=["auto", "always", "never"], default="auto",
help="measure a real worker's peak RSS to size the pool: 'auto' "
"(default) probes when N>2000, 'always', or 'never' (estimate only)")
parser.add_argument("--no-figures", action="store_true",
help="skip auto figure generation")
parser.add_argument("--old", action="store_true",
help="run the old (pre countable redesign) uncle model: window = "
"uncle_window slots, any-depth orphans referenceable, every "
"baked reference counts; bit-reproduces historical runs")
args = parser.parse_args(argv)
batch_size = int(args.batch_size) if args.batch_size != "auto" else "auto"
label = args.label or (Path(args.config).stem + ("-old" if args.old else ""))
run_dir = new_run_dir(args.outdir, label)
sweep = load_sweep_yaml(args.config)
if args.old:
sweep.base["uncle_model"] = "old"
df = run_sweep(sweep, n_jobs=args.n_jobs, batch_size=batch_size, mem_frac=args.mem_frac,
calibrate=args.calibrate)
results_path = run_dir / "results.parquet"
persist(df, results_path)
key_cols = ["n_nodes", "stake_dist", "topology", "degree", "link_latency_mean",
"latency", "uncle_model", "max_uncles", "uncle_strategy", "init_dest",
"replicate"]
n_cfg = len(df[key_cols].drop_duplicates())
print(f"wrote {len(df)} rows ({n_cfg} configs) -> {results_path}")
if not args.no_figures:
from .plotting.make_figures import render
n_fig = render(df, run_dir / "figures")
print(f"wrote {n_fig} figures -> {run_dir / 'figures'}")
print(f"run dir: {run_dir}")
if __name__ == "__main__": # pragma: no cover
main()