2026-07-30 18:57:10 +02:00

96 lines
3.8 KiB
Python

"""Exact large-N topology probe: mean gossip path latency l_mean(N, degree) up to N = 10^6.
The only N-dependent term in the load law rho = f*D_vis (report §3.3/§4, eq 1) is the mean
shortest-path transport latency l_mean over the peering graph, which grows ~ log_(d-1) N.
Direct per-node simulation is memory-bound at N ~ 3*10^4 (the N x N matrix), but l_mean is
measurable EXACTLY at any N with sampled-source Dijkstra on the same graph generator the
simulator uses (circulant base + 10x Maslov-Sneppen swaps, geo per-link latencies).
Writes runs/topology_probe.parquet: one row per (n, degree, replicate) with l_mean, quantiles,
and the derived D_vis / rho for the study's mixing budgets. Used by report §3.8.
Run: python scripts/topology_probe.py (~2-3 h, parallel over cells)
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
from joblib import Parallel, delayed
from scipy.sparse import csr_matrix
from scipy.sparse.csgraph import dijkstra
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from tsi_sim.config import SimConfig # noqa: E402
from tsi_sim.topology import ( # noqa: E402
_circulant_edges,
_double_edge_swaps,
_sample_link_latencies,
)
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
F = 1.0 / 30.0
HOPS = 3
DELAYS = (4.0, 8.0) # blend mixing budgets studied in the N-scaling ladder
LINK_MEAN = 0.5 # per-link geo mean (slots), matching the ladder configs
N_GRID = (1_000, 4_000, 16_000, 64_000, 250_000, 1_000_000)
DEGREES = (4, 6, 8)
N_SOURCES = 64 # sampled Dijkstra sources per graph
REPS = {n: (3 if n <= 64_000 else 1) for n in N_GRID}
def probe_cell(n: int, degree: int, rep: int) -> dict:
rng = np.random.default_rng(np.random.SeedSequence([n, degree, rep, 20260721]))
cfg = SimConfig(n_nodes=min(n, 10_000), degree=degree, link_latency_mean=LINK_MEAN,
link_latency_dist="geo") # only used for latency sampling params
t0 = time.time()
edges = _circulant_edges(n, degree)
edges = _double_edge_swaps(edges, n_swaps=10 * len(edges), rng=rng)
e = np.array(sorted(edges), dtype=np.int64)
w = _sample_link_latencies(e.shape[0], cfg, rng)
adj = csr_matrix(
(np.concatenate([w, w]),
(np.concatenate([e[:, 0], e[:, 1]]), np.concatenate([e[:, 1], e[:, 0]]))),
shape=(n, n))
sources = rng.choice(n, size=min(N_SOURCES, n), replace=False)
dist = dijkstra(adj, directed=False, indices=sources)
mask = np.isfinite(dist) & (dist > 0)
d = dist[mask]
row = dict(
n=n, degree=degree, replicate=rep, n_sources=len(sources), n_edges=e.shape[0],
l_mean=float(d.mean()), l_p50=float(np.percentile(d, 50)),
l_p90=float(np.percentile(d, 90)), l_p99=float(np.percentile(d, 99)),
l_max=float(d.max()), build_s=float(time.time() - t0),
)
for delay in DELAYS:
dvis = HOPS * delay / 2.0 + (HOPS + 1) * row["l_mean"]
row[f"d_vis_{delay:g}"] = dvis
row[f"rho_{delay:g}"] = F * dvis
print(f"n={n:>9,} deg={degree} rep={rep}: l_mean={row['l_mean']:.2f} "
f"p99={row['l_p99']:.2f} rho(8)={row['rho_8']:.3f} [{row['build_s']:.0f}s]",
flush=True)
return row
def main() -> None:
cells = [(n, d, r) for n in N_GRID for d in DEGREES for r in range(REPS[n])]
# large-N cells first so the slowest work starts immediately
cells.sort(key=lambda c: -c[0])
rows = Parallel(n_jobs=4, prefer="processes")(
delayed(probe_cell)(n, d, r) for n, d, r in cells)
df = pd.DataFrame(rows)
out = RUNS / "topology_probe.parquet"
df.to_parquet(out)
print(f"wrote {len(df)} rows -> {out}")
if __name__ == "__main__":
main()