blend: emission-model fidelity, and evidence tooling that covers every section

Second review pass, three findings.

Pending cancellations were a set, so a node that proposed twice before its next
cover emission forfeited only one and then over-emitted relative to its quota --
the precise uniformity cover traffic exists to preserve. Now a multiset.

The timeline drew the block proposer uniformly while quota.py used a stake-
weighted lottery, so the two halves of the cover-traffic model disagreed. The
timeline now takes the stake array. Concentration is visible in the bookkeeping: a
dominant proposer wins most proposals but rarely draws a cover slot to forfeit, so
cancellations redeemed fall from 107 to 28 -- the unredeemed backlog being exactly
the over-emission the stake ceiling describes.

data/report_numbers.py claimed to print every quoted value but covered only
sections 3.1-3.5 and 3.8. Extended to 3.9 correlated churn, 3.10 blending, mixing
and the quota ceiling, 3.11 the release designs, and the 3.4 attribution bracket;
the claim in data/README is corrected to say what it actually does.

Neither model fix moves a published number: the proposer identity does not enter
blending, mixing or timing, and repeat proposals are rare at the reported rates.
Two regression tests pin both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-06 14:31:33 +02:00 committed by Marcin Pawlowski
parent 36c16a0a98
commit cad2bf51b2
No known key found for this signature in database
5 changed files with 115 additions and 12 deletions

View File

@ -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 <default> <redundancy> <percolation>`) if you want to point it at fresh
runs instead.

View File

@ -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())

View File

@ -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(

View File

@ -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:

View File

@ -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