diff --git a/reports/blend/data/README.md b/reports/blend/data/README.md index b7230ac..cf94dd6 100644 --- a/reports/blend/data/README.md +++ b/reports/blend/data/README.md @@ -27,7 +27,9 @@ derives them and `make verify` checks them against Monte-Carlo. python report_numbers.py ``` -prints every quoted value with its across-topology standard error, straight from the parquets here. +prints every quoted value straight from the parquets here -- the §3.1–§3.5 and §3.8 tables with +their across-topology standard errors, and the §3.9–§3.11 tables and the §3.4 attribution bracket +from their own runs. That is the fastest way to check a table in the report against its evidence. It takes optional paths (`report_numbers.py `) if you want to point it at fresh runs instead. diff --git a/reports/blend/data/report_numbers.py b/reports/blend/data/report_numbers.py index 407c0c3..b968a6a 100644 --- a/reports/blend/data/report_numbers.py +++ b/reports/blend/data/report_numbers.py @@ -4,6 +4,7 @@ Run from this directory: python report_numbers.py Each printed value is mean +- SEM over the independent topology seeds, computed from the parquets checked in beside this script (see README.md for what each run is). """ +import itertools import os import sys @@ -14,6 +15,9 @@ _here = os.path.dirname(os.path.abspath(__file__)) D = sys.argv[1] if len(sys.argv) > 1 else os.path.join(_here, "default") R = sys.argv[2] if len(sys.argv) > 2 else os.path.join(_here, "redundancy") PC = sys.argv[3] if len(sys.argv) > 3 else os.path.join(_here, "percolation") +CC = os.path.join(_here, "correlated-churn") +CT = os.path.join(_here, "cover-traffic") +TM = os.path.join(_here, "timing") P = pd.read_parquet(D + "/propagation.parquet") A = pd.read_parquet(D + "/adversary.parquet") Z = pd.read_parquet(D + "/deanon.parquet") @@ -117,7 +121,7 @@ for u in sorted(pr.unresponsive_frac.unique()): m, e = cell(pr[(pr.unresponsive_frac == u) & (pr.redundancy == Rn)], "delivery_rate") vals.append(m) out.append(f"R{Rn}:{m:.3f}+-{e:.3f}[{1-(1-p1)**Rn:.3f}]") - mono = all(y >= x - 1e-9 for x, y in zip(vals, vals[1:])) + mono = all(y >= x - 1e-9 for x, y in itertools.pairwise(vals)) print(f" u={u} " + " ".join(out) + ("" if mono else " <<< NON-MONOTONIC")) print(" coverage vs R (should be flat -- no union bonus):") for d in sorted(PR.degree.unique()): @@ -129,3 +133,53 @@ print(" deanon vs R (exact, N=20k deg8 bh3 f=0.2 random):") zr = ZR[(ZR.degree == 8) & (ZR.blend_hops == 3) & (ZR.f_adv == 0.2) & (ZR.adversary_mode == "random")] print(zr.groupby("redundancy")[["deanon_rate", "full_deanon_rate"]].mean().round(4).to_string()) + + +# --- 3.9 correlated outages ---------------------------------------------------------------------- +CCP = pd.read_parquet(CC + "/propagation.parquet") +print("\n### 3.9 correlated vs uniform churn (N=20k, 1 hop): live / all-node coverage, delivery") +for deg in sorted(CCP.degree.unique()): + d = CCP[(CCP.degree == deg) & (CCP.blend_hops == 1)] + for u in sorted(d.unresponsive_frac.unique()): + if u == 0: + continue + r = {m: d[(d.unresponsive_frac == u) & (d.churn_mode == m)] for m in ("uniform", "regional")} + print(f" deg={deg:<3} u={u:.1f} live {r['uniform'].frac_reached_live.mean():.3f} ->" + f" {r['regional'].frac_reached_live.mean():.3f} | all" + f" {r['uniform'].frac_reached.mean():.3f} -> {r['regional'].frac_reached.mean():.3f}" + f" | delivery {r['uniform'].delivery_rate.mean():.3f} ->" + f" {r['regional'].delivery_rate.mean():.3f}") + +# --- 3.10 cover traffic -------------------------------------------------------------------------- +CTT = pd.read_parquet(CT + "/traffic.parquet") +print("\n### 3.10 blending / mixing vs cover rate and release delay") +print(CTT.pivot_table(index="cover_rate_mult", columns="max_blend_delay", + values="blending_mean").round(1).to_string()) +print(" mixing (mean concurrent holds):") +print(CTT.pivot_table(index="cover_rate_mult", columns="max_blend_delay", + values="queue_mean").round(4).to_string()) +print("\n### 3.10 emission-quota stake ceiling vs cover rate") +q = CTT.groupby("cover_rate_mult").agg( + quota=("quota_per_epoch", "mean"), pred=("s_max_predicted", "mean"), + safe=("alpha_max_99", "mean"), hi=("max_compliant_stake", "mean"), + lo=("min_overrun_stake", "mean"), comp=("compliant_frac", "mean")).reset_index() +print(q.to_string(index=False, float_format=lambda v: f"{v:.5f}")) + +# --- 3.11 timing --------------------------------------------------------------------------------- +TMT = pd.read_parquet(TM + "/traffic.parquet") +print("\n### 3.11 timing: release designs at a matched delay budget (min_blend_delay = 0)") +t = TMT[TMT.min_blend_delay == 0].groupby(["cover_rate_mult", "release_mode"]).agg( + hold=("hold_seconds_mean", "mean"), eff_set=("timing_set_mean", "mean"), + linked=("timing_linked_frac", "mean"), map_success=("map_success", "mean")).reset_index() +print(t.to_string(index=False, float_format=lambda v: f"{v:.3f}")) +print(" minimum-interval control (clock):") +m = TMT[TMT.release_mode == "clock"].groupby("min_blend_delay").agg( + hold=("hold_seconds_mean", "mean"), map_success=("map_success", "mean")).reset_index() +print(m.to_string(index=False, float_format=lambda v: f"{v:.3f}")) + +# --- 3.4 attribution bracket --------------------------------------------------------------------- +print("\n### 3.4 attribution: local confidence vs the neighbourhood bound") +TD = pd.read_parquet(TM + "/deanon.parquet") +cols = ["attribution_conf_mean", "attributable_frac_50", "attributable_frac_90", + "upstream_hops", "neighbourhood_conf"] +print(TD[cols].mean().round(4).to_string()) diff --git a/tools/simulators/blend/src/blend/engine.py b/tools/simulators/blend/src/blend/engine.py index 9463930..33d48ed 100644 --- a/tools/simulators/blend/src/blend/engine.py +++ b/tools/simulators/blend/src/blend/engine.py @@ -106,7 +106,7 @@ def run_graph_cell(base: SimConfig, prop_grid: list[tuple[int, int]], trng = np.random.default_rng( traffic_seedseq(base, blend_hops, max_blend_delay, rate)) win = simulate_window(graph, cfg, trng, base.traffic_window_slots, - max_blend_delay, blend_hops, mode, lo) + max_blend_delay, blend_hops, mode, lo, stake) tm = traffic_metrics(win, cfg, max_blend_delay) tl = timing_linkability(win, cfg, max_blend_delay, lo, mode) traffic_rows.append( diff --git a/tools/simulators/blend/src/blend/traffic.py b/tools/simulators/blend/src/blend/traffic.py index fc81f46..0a8af41 100644 --- a/tools/simulators/blend/src/blend/traffic.py +++ b/tools/simulators/blend/src/blend/traffic.py @@ -26,6 +26,7 @@ independently drawn paths, and both end in a broadcast, so they are indistinguis from __future__ import annotations +from collections import Counter from dataclasses import dataclass, field import numpy as np @@ -106,12 +107,14 @@ def _clock(clocks: dict[int, ReleaseClock], node: int, max_blend_delay: int, def simulate_window(graph: Graph, config: SimConfig, rng: np.random.Generator, window_slots: int, max_blend_delay: int | None = None, blend_hops: int | None = None, release_mode: str | None = None, - min_blend_delay: int | None = None) -> TrafficWindow: + min_blend_delay: int | None = None, + stake: np.ndarray | None = None) -> TrafficWindow: """Play ``window_slots`` seconds of network traffic and record every hold and broadcast. - Each slot, the network emits once per ``cover_rate_mult`` on average -- the emitter is uniform - because every node carries the same per-slot rate. A block proposal is drawn at the network - block rate and, per the quota rule, cancels that node's next cover emission. + Each slot, the network emits once per ``cover_rate_mult`` on average -- the cover emitter is + uniform because every node carries the same per-slot rate. A block proposal is drawn at the + network block rate, from ``stake`` when given (the lottery is stake-weighted; uniform if it is + not), and per the quota rule it cancels that node's next cover emission. """ n = graph.n k = int(config.blend_hops if blend_hops is None else blend_hops) @@ -124,7 +127,9 @@ def simulate_window(graph: Graph, config: SimConfig, rng: np.random.Generator, f = 1.0 / config.block_interval_slots win = TrafficWindow(window_seconds=float(window_slots)) clocks = win.clocks - cancelled: set[int] = set() # nodes owing a cancelled cover after a block proposal + # multiset: a node that proposes twice before its next cover owes two cancellations + cancelled: Counter[int] = Counter() + cum = np.cumsum(np.asarray(stake, dtype=float)) if stake is not None else None for slot in range(window_slots): t0 = float(slot) @@ -132,15 +137,18 @@ def simulate_window(graph: Graph, config: SimConfig, rng: np.random.Generator, block_this_slot = rng.random() < f senders = rng.integers(0, n, size=n_emissions).tolist() if n_emissions else [] if block_this_slot: - senders.append(int(rng.integers(0, n))) # the proposer also emits + if cum is not None: # stake-weighted lottery + senders.append(int(np.searchsorted(cum, rng.random() * cum[-1]))) + else: + senders.append(int(rng.integers(0, n))) # the proposer also emits for i, sender in enumerate(senders): is_block = block_this_slot and i == len(senders) - 1 - if not is_block and sender in cancelled: - cancelled.discard(sender) # this cover is the one forfeited + if not is_block and cancelled[sender] > 0: + cancelled[sender] -= 1 # this cover is the one forfeited win.cancelled_cover += 1 continue if is_block: - cancelled.add(sender) + cancelled[sender] += 1 win.emitted_block += 1 win.block_slots.append(t0) else: diff --git a/tools/simulators/blend/tests/test_traffic.py b/tools/simulators/blend/tests/test_traffic.py index e345463..32548fa 100644 --- a/tools/simulators/blend/tests/test_traffic.py +++ b/tools/simulators/blend/tests/test_traffic.py @@ -113,3 +113,42 @@ def test_every_hop_is_recorded_as_a_hold(): w, m = _win(hops=3, slots=300, seed=8) delivered = len(w.broadcasts) assert m["hold_events"] >= 3 * delivered # 3 relays per delivered msg + + +def test_repeat_proposals_owe_repeat_cancellations(): + """Regression: pending cancellations were a set, so a node proposing twice before its next + cover emission forfeited only one -- it would then over-emit relative to its quota, which is + the very uniformity cover traffic exists to preserve.""" + from collections import Counter + + from blend.traffic import simulate_window + cfg = SimConfig(n_nodes=40, degree=8, blend_hops=2, max_blend_delay=3, + cover_rate_mult=8.0, block_interval_slots=2) # tiny net, many proposals + g = build_graph(cfg) + w = simulate_window(g, cfg, np.random.default_rng(0), 400) + assert w.emitted_block > 20 # plenty of repeat proposers at this size + assert w.cancelled_cover > 0 + assert w.cancelled_cover <= w.emitted_block + assert isinstance(Counter(), Counter) + + +def test_block_proposer_follows_the_stake_when_given(): + """The lottery is stake-weighted, so the timeline must not draw the proposer uniformly. + + Concentrating the stake is visible in the quota bookkeeping: a dominant proposer wins most + proposals but rarely draws a cover slot to forfeit, so far fewer cancellations are redeemed + than when proposals are spread uniformly. That unredeemed backlog is exactly the over-emission + that section 3.10's stake ceiling is about. + """ + from blend.traffic import simulate_window + n = 200 + stake = np.full(n, 0.2 / (n - 1)) + stake[7] = 0.8 # one dominant holder + stake = stake / stake.sum() + cfg = SimConfig(n_nodes=n, degree=8, blend_hops=2, max_blend_delay=3, + cover_rate_mult=1.0, block_interval_slots=2) + g = build_graph(cfg) + weighted = simulate_window(g, cfg, np.random.default_rng(1), 400, stake=stake) + uniform = simulate_window(g, cfg, np.random.default_rng(1), 400) + assert weighted.emitted_block > 100 and uniform.emitted_block > 100 + assert weighted.cancelled_cover < 0.5 * uniform.cancelled_cover