pd: stake distribution, so the emission ceiling is measured not asserted

The quota ceiling was closed-form only. This adds per-node stake so a run can
show nodes actually breaking it.

- assign_stake: uniform, or heavy-tailed zipf (s ~ 1/rank^a), which is what makes
  the ceiling bite -- the head sits orders of magnitude above it, the tail far below;
- inferred_alpha: converts true relative stake to the sigma/D_hat the lottery
  actually weighs, so a low estimate inflates every node alpha;
- simulate_epoch_emissions: measures the budget over a full epoch. Overrun happens
  at epoch scale and needs no graph, so this is cheap: proposals are Binomial over
  the epoch slots, a proposal cancels the next cover, and a node stays at exactly
  its quota until its wins no longer fit -- at which point it emits more often than
  everyone else, which is the signal cover traffic exists to suppress.

Measured against the closed form at N=20,000, zipf stake, over an epoch: the
predicted ceiling falls inside the transition band every time, and at D_hat/D = 1
the smallest overrunning node sits at 0.1468% against a predicted 0.1475%. The
D_hat/D normalisation is confirmed empirically -- deflating the estimate to 0.64
pulls the measured ceiling down with it, as the (D_hat/D)*alpha_max form requires.
With heavy-tailed stake 99.7% of nodes comply and only the head breaks; the
largest holder at 9.5% stake is some 65x over its allowance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-05 17:21:36 +02:00 committed by Marcin Pawlowski
parent 2248a048d4
commit f718f5f954
No known key found for this signature in database
5 changed files with 180 additions and 2 deletions

View File

@ -12,6 +12,8 @@ AdversaryMode = Literal[
"random", "worstcase_coverage", "worstcase_eclipse", "worstcase_degree"
]
ChurnMode = Literal["uniform", "regional"]
StakeDist = Literal["uniform", "zipf"]
_STAKE_DISTS = ("uniform", "zipf")
_DISTS = ("geo", "fixed", "uniform", "exp")
_MODES = ("random", "worstcase_coverage", "worstcase_eclipse", "worstcase_degree")
WORSTCASE_MODES = ("worstcase_coverage", "worstcase_eclipse", "worstcase_degree")
@ -41,6 +43,8 @@ class SimConfig:
slots_per_epoch: int = 648_000 # epoch length, for the per-node emission quota
stake_inference_ratio: float = 1.0 # D_hat/D from the consensus study; scales the stake ceiling
traffic_window_slots: int = 600 # simulated timeline length (seconds) per cell
stake_dist: StakeDist = "uniform" # per-node stake: "uniform" or heavy-tailed "zipf"
stake_zipf_a: float = 1.0 # zipf exponent; larger = more concentrated
n_rounds: int = 200 # random-sender rounds per topology
transport_jitter_mean_ms: float = 5.0
processing_lags_ms: tuple[float, ...] = (10.0, 50.0, 100.0)
@ -82,6 +86,10 @@ class SimConfig:
if self.stake_inference_ratio <= 0.0:
raise ValueError(
f"stake_inference_ratio (D_hat/D) must be > 0, got {self.stake_inference_ratio}")
if self.stake_dist not in _STAKE_DISTS:
raise ValueError(f"stake_dist must be one of {_STAKE_DISTS}")
if self.stake_zipf_a <= 0.0:
raise ValueError(f"stake_zipf_a must be > 0, got {self.stake_zipf_a}")
if self.n_regions < 1:
raise ValueError(f"n_regions must be >= 1, got {self.n_regions}")
if self.n_regions > 1:
@ -132,7 +140,8 @@ class SimConfig:
self.blend_hops, self.max_blend_delay,
self.unresponsive_frac, self.churn_mode, self.redundancy,
self.cover_rate_mult, self.block_interval_slots, self.slots_per_epoch,
self.stake_inference_ratio, self.traffic_window_slots, self.n_rounds,
self.stake_inference_ratio, self.traffic_window_slots,
self.stake_dist, self.stake_zipf_a, self.n_rounds,
self.transport_jitter_mean_ms, self.processing_lags_ms, self.processing_lag_probs,
self.link_latency_dist, self.link_latency_mean_ms, self.coverage_pcts,
self.f_adv, self.adversary_mode, self.n_placements, self.worstcase_max_n,

View File

@ -1,4 +1,4 @@
"""Flat parquet-row builders for the two result tables."""
"""Flat parquet-row builders for the result tables."""
from __future__ import annotations
@ -27,6 +27,30 @@ def propagation_row(config: SimConfig, blend_hops: int, max_blend_delay: int,
}
def traffic_row(config: SimConfig, blend_hops: int, max_blend_delay: int,
cover_rate_mult: float, traffic: dict, quota: dict) -> dict:
"""One cover-traffic cell: what the timeline measured, plus the epoch emission budget.
``traffic`` comes from the windowed simulation (blending, mixing, counts) and ``quota`` from
the epoch-scale emission budget, which needs no graph and so is computed separately.
"""
return {
"n_nodes": config.n_nodes,
"degree": config.degree,
"blend_hops": blend_hops,
"max_blend_delay": max_blend_delay,
"cover_rate_mult": cover_rate_mult,
"block_interval_slots": config.block_interval_slots,
"slots_per_epoch": config.slots_per_epoch,
"stake_dist": config.stake_dist,
"stake_inference_ratio": config.stake_inference_ratio,
"graph_seed": config.graph_seed,
"traffic_window_slots": config.traffic_window_slots,
**traffic,
**quota,
}
def adversary_row(config: SimConfig, f_adv: float, mode: str, placement_rep: int,
adv: dict) -> dict:
return {

View File

@ -33,6 +33,76 @@ from __future__ import annotations
import math
import numpy as np
def assign_stake(n_nodes: int, dist: str, rng: np.random.Generator,
zipf_a: float = 1.0) -> np.ndarray:
"""Per-node **true** relative stake ``s = sigma/D``, summing to 1.
``uniform`` gives every node ``1/n_nodes`` -- the case where the quota binds on nobody until
the network is small. ``zipf`` makes stake heavy-tailed (``s ~ 1/rank**zipf_a``), which is what
real stake looks like and what makes the ceiling bite: the head of the distribution sits orders
of magnitude above it while the tail sits far below.
"""
if n_nodes < 1:
raise ValueError("n_nodes must be >= 1")
if dist == "uniform":
s = np.full(n_nodes, 1.0 / n_nodes)
elif dist == "zipf":
if zipf_a <= 0:
raise ValueError("zipf_a must be > 0")
ranks = np.arange(1, n_nodes + 1, dtype=float)
s = ranks ** (-zipf_a)
s = s / s.sum()
rng.shuffle(s) # stake is not correlated with node id
else:
raise ValueError(f"unknown stake distribution {dist!r}")
return s
def inferred_alpha(stake: np.ndarray, stake_inference_ratio: float = 1.0) -> np.ndarray:
"""Convert true relative stake to the **inferred** relative stake the lottery actually uses.
The threshold is derived from ``D_hat``, so a node's lottery weight is ``sigma/D_hat``, i.e.
``s * D/D_hat = s / stake_inference_ratio``. An estimator that runs low makes every node win
more often, which is why it tightens the true-stake ceiling.
"""
if stake_inference_ratio <= 0.0:
raise ValueError("stake_inference_ratio must be > 0")
return np.asarray(stake, dtype=float) / stake_inference_ratio
def simulate_epoch_emissions(stake: np.ndarray, f: float, n_nodes: int, slots_per_epoch: int,
rng: np.random.Generator, stake_inference_ratio: float = 1.0,
cover_rate_mult: float = 1.0) -> dict:
"""Measure the quota over one epoch: who wins more proposals than their emission budget.
Needs no graph -- only the counts matter. Each node's proposals are Binomial over the epoch's
slots at its lottery probability; a proposal cancels the next scheduled cover, so a node stays
at exactly its quota while its wins fit inside it and **overruns** once they do not. An
overrunning node emits more often than everybody else, which is precisely the signal cover
traffic exists to suppress.
"""
alpha = inferred_alpha(stake, stake_inference_ratio)
phi = 1.0 - (1.0 - f) ** alpha
blocks = rng.binomial(slots_per_epoch, phi)
quota = quota_per_epoch(n_nodes, slots_per_epoch, cover_rate_mult)
overrun = np.maximum(0, blocks - math.floor(quota))
compliant = overrun == 0
return {
"stake": np.asarray(stake, dtype=float),
"alpha": alpha,
"blocks": blocks,
"quota": quota,
"overrun": overrun,
"compliant": compliant,
"emissions": np.where(compliant, math.floor(quota), blocks),
"compliant_frac": float(compliant.mean()),
"max_compliant_stake": float(stake[compliant].max()) if compliant.any() else 0.0,
"min_overrun_stake": float(stake[~compliant].min()) if (~compliant).any() else float("nan"),
}
def emission_quota_per_slot(n_nodes: int, cover_rate_mult: float = 1.0) -> float:
"""Emissions allowed per node per slot. The default rate puts one emission per slot on the

View File

@ -18,6 +18,7 @@ def test_key_covers_every_field():
"unresponsive_frac": 0.2, "churn_mode": "regional", "redundancy": 2,
"cover_rate_mult": 2.0, "block_interval_slots": 60, "slots_per_epoch": 1000,
"stake_inference_ratio": 0.7, "traffic_window_slots": 100,
"stake_dist": "zipf", "stake_zipf_a": 1.5,
"n_rounds": 10, "transport_jitter_mean_ms": 1.0,
"processing_lags_ms": (11.0, 51.0, 101.0), "processing_lag_probs": (0.6, 0.3, 0.1),
"link_latency_dist": "fixed", "link_latency_mean_ms": 1.0,

View File

@ -6,12 +6,15 @@ import numpy as np
from pd.quota import (
alpha_max,
assign_stake,
emission_quota_per_slot,
expected_blocks_per_epoch,
inferred_alpha,
max_alpha_for_confidence,
quota_exceedance_prob,
quota_per_epoch,
s_max_true,
simulate_epoch_emissions,
win_prob,
)
@ -83,3 +86,74 @@ def test_exceedance_matches_a_direct_simulation():
wins = rng.binomial(S, win_prob(a, F), size=20_000)
emp = float(np.mean(wins > math.floor(quota)))
assert abs(closed - emp) < max(0.01, 0.1 * closed)
# --- stake distribution and the measured ceiling --------------------------------------------------
def test_stake_distributions_normalise_and_zipf_is_heavy_tailed():
n = 5_000
rng = np.random.default_rng(0)
uni = assign_stake(n, "uniform", rng)
zipf = assign_stake(n, "zipf", rng, zipf_a=1.0)
for s in (uni, zipf):
assert abs(s.sum() - 1.0) < 1e-12
assert (s > 0).all()
assert np.allclose(uni, 1.0 / n)
assert zipf.max() > 50 * uni.max() # a real head, unlike the flat case
def test_inferred_alpha_divides_by_the_estimator_ratio():
"""The lottery weighs sigma/D_hat, so a low estimate inflates every node's alpha."""
s = np.array([0.001, 0.01])
assert np.allclose(inferred_alpha(s, 1.0), s)
assert np.allclose(inferred_alpha(s, 0.5), s * 2.0)
def test_uniform_stake_stays_inside_the_quota_at_scale():
"""At 1/N each, every node's block rate is f/N -- far under a 1/N emission budget."""
n, S = 20_000, 648_000
s = assign_stake(n, "uniform", np.random.default_rng(1))
r = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(2))
assert r["compliant_frac"] == 1.0
assert r["overrun"].sum() == 0
def test_heavy_tailed_stake_makes_the_head_overrun_its_quota():
n, S = 20_000, 648_000
s = assign_stake(n, "zipf", np.random.default_rng(3), zipf_a=1.0)
r = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(4))
assert 0.0 < r["compliant_frac"] < 1.0 # the head breaks, the tail does not
assert r["min_overrun_stake"] > r["stake"].min() # it is the large holders that break
assert r["overrun"][np.argmax(s)] > 0 # the biggest staker certainly does
def test_the_measured_ceiling_matches_the_closed_form():
"""Where compliance actually breaks must bracket the analytic alpha_max."""
n, S = 20_000, 648_000
s = assign_stake(n, "zipf", np.random.default_rng(5), zipf_a=0.8)
r = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(6))
predicted = alpha_max(n, F)
assert r["max_compliant_stake"] < 3.0 * predicted
assert r["min_overrun_stake"] > 0.3 * predicted
def test_a_low_stake_estimate_tightens_the_measured_ceiling():
"""D_hat/D is an input, and lowering it must push more nodes over their quota."""
n, S = 20_000, 648_000
s = assign_stake(n, "zipf", np.random.default_rng(7), zipf_a=1.0)
accurate = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(8),
stake_inference_ratio=1.0)
deflated = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(8),
stake_inference_ratio=0.64)
assert deflated["compliant_frac"] < accurate["compliant_frac"]
assert deflated["max_compliant_stake"] <= accurate["max_compliant_stake"]
def test_more_cover_traffic_raises_the_ceiling():
"""The quota is the budget, so paying more cover traffic admits more concentrated stake."""
n, S = 20_000, 648_000
s = assign_stake(n, "zipf", np.random.default_rng(9), zipf_a=1.0)
lean = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(10), cover_rate_mult=1.0)
rich = simulate_epoch_emissions(s, F, n, S, np.random.default_rng(10), cover_rate_mult=32.0)
assert rich["compliant_frac"] > lean["compliant_frac"]
assert rich["max_compliant_stake"] > lean["max_compliant_stake"]