mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 03:33:33 +00:00
pd: cover traffic -- emission quota and the blending timeline
First half of the cover-traffic work: the two new modules and their tests. quota.py -- the emission budget. Cover traffic gives every node the same number of emissions per epoch, which only holds while a node block proposals fit inside its quota. The bind is exact: alpha_max = ln(1-q)/ln(1-f), where alpha is stake relative to the INFERRED total D_hat, since that is the denominator the lottery threshold is derived from. In true stake the ceiling carries the estimator ratio, s_max = (D_hat/D)*alpha_max, with D_hat/D an input rather than an assumption. The familiar q/f is a small-q approximation that runs 1.7% high and so overstates the tolerable stake. Sitting on the mean bind overruns the quota half the time, so max_alpha_for_confidence gives the ceiling that holds with stated probability. traffic.py -- the timeline. The rest of the simulator samples independent rounds and draws each hold from the stationary residual, which has no notion of time and so can never let two messages meet at a relay. Here every node owns one free-running clock shared by all messages through it, extended lazily so only the relays actually visited grow one. A clock sampled once still reproduces mixclock.mix_wait, so single-message statistics are unchanged. It separates two quantities that are easy to conflate: mixing (messages a relay holds at once) and blending (messages it has SEEN between consecutive releases). Blending is the anonymity set -- every broadcast reaches every node, so an observer cannot tell which of them the relay forwarded. Gaps sampled at a release are size-biased, so blending is rate*(2M+1)/3, twice the mean hold, not rate*M/2 as a naive reading gives. Measured within 1-4% of that at M = 3, 10, 30 and linear in the cover rate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c42d030f0d
commit
e35804f29d
@ -34,6 +34,13 @@ class SimConfig:
|
||||
unresponsive_frac: float = 0.0 # ratio of nodes that do NOT relay any messages (swept)
|
||||
churn_mode: ChurnMode = "uniform" # "uniform" = independent nodes; "regional" = whole regions
|
||||
redundancy: int = 1 # copies per emission via R independent cascades (swept)
|
||||
|
||||
# --- cover traffic (1 slot = 1 second) ---
|
||||
cover_rate_mult: float = 1.0 # emissions/slot network-wide; 1.0 = one per second
|
||||
block_interval_slots: int = 30 # slots per block, i.e. f = 1/30
|
||||
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
|
||||
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)
|
||||
@ -66,6 +73,15 @@ class SimConfig:
|
||||
raise ValueError(f"need 0 <= unresponsive_frac < 1, got {self.unresponsive_frac}")
|
||||
if self.redundancy < 1:
|
||||
raise ValueError(f"redundancy must be >= 1, got {self.redundancy}")
|
||||
if self.cover_rate_mult < 0.0:
|
||||
raise ValueError(f"cover_rate_mult must be >= 0, got {self.cover_rate_mult}")
|
||||
if self.block_interval_slots < 2:
|
||||
raise ValueError(f"block_interval_slots must be >= 2, got {self.block_interval_slots}")
|
||||
if self.slots_per_epoch < 1 or self.traffic_window_slots < 1:
|
||||
raise ValueError("slots_per_epoch and traffic_window_slots must be >= 1")
|
||||
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.n_regions < 1:
|
||||
raise ValueError(f"n_regions must be >= 1, got {self.n_regions}")
|
||||
if self.n_regions > 1:
|
||||
@ -114,7 +130,9 @@ class SimConfig:
|
||||
return (
|
||||
self.n_nodes, self.degree, self.n_regions, self.region_locality,
|
||||
self.blend_hops, self.max_blend_delay,
|
||||
self.unresponsive_frac, self.churn_mode, self.redundancy, self.n_rounds,
|
||||
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.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,
|
||||
|
||||
120
tools/simulators/blend/pd/src/pd/quota.py
Normal file
120
tools/simulators/blend/pd/src/pd/quota.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""Emission budget: how much stake a node can hold and still look like everyone else.
|
||||
|
||||
Cover traffic gives every node the **same number of emissions per epoch**. A node emits in a slot
|
||||
either because a cover slot came up or because it won the block lottery, and a block cancels the
|
||||
next scheduled cover, so the emission *count* carries no information about who produces blocks --
|
||||
that uniformity is the anonymity property cover traffic buys.
|
||||
|
||||
It only holds while a node's block proposals fit inside its quota. The quota is
|
||||
|
||||
q = cover_rate_mult / n_nodes emissions per node per slot
|
||||
|
||||
and the Cryptarchia lottery gives a node of **inferred** relative stake ``alpha = sigma / D_hat``
|
||||
a per-slot win probability ``phi(alpha) = 1 - (1-f)**alpha``. Requiring ``phi(alpha) <= q``:
|
||||
|
||||
alpha_max = ln(1 - q) / ln(1 - f)
|
||||
|
||||
Note the stake here is relative to the *inferred* total ``D_hat``, not the true total ``D``, because
|
||||
that is the denominator the lottery threshold is derived from. Converting to true relative stake
|
||||
``s = sigma / D`` therefore needs the estimator's accuracy ratio: since every node's win rate scales
|
||||
with ``D/D_hat``, the network's block rate is ``f * D/D_hat`` and
|
||||
|
||||
s_max = (D_hat / D) * alpha_max
|
||||
|
||||
so an estimator that runs low tightens the true-stake ceiling in exact proportion. ``D_hat/D`` is an
|
||||
input here (``stake_inference_ratio``), measured by the consensus-side study, not assumed to be 1.
|
||||
|
||||
``alpha_max`` is the *mean* bind. Wins are a Bernoulli process, so a node sitting exactly at it
|
||||
overruns its quota in about half of all epochs; ``max_alpha_for_confidence`` gives the ceiling that
|
||||
keeps the quota with a stated probability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
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
|
||||
whole network, so each node gets ``1/n_nodes``."""
|
||||
if n_nodes < 1:
|
||||
raise ValueError("n_nodes must be >= 1")
|
||||
return cover_rate_mult / n_nodes
|
||||
|
||||
|
||||
def win_prob(alpha: float, f: float) -> float:
|
||||
"""Cryptarchia per-slot block-lottery probability for inferred relative stake ``alpha``."""
|
||||
if not (0.0 < f < 1.0):
|
||||
raise ValueError("need 0 < f < 1")
|
||||
return 1.0 - (1.0 - f) ** alpha
|
||||
|
||||
|
||||
def alpha_max(n_nodes: int, f: float, cover_rate_mult: float = 1.0) -> float:
|
||||
"""Largest **inferred** relative stake whose expected block rate fits the emission quota.
|
||||
|
||||
Exact bind ``ln(1-q)/ln(1-f)``; the familiar ``q/f`` is a small-q approximation that runs
|
||||
about 1.7 % high at f = 1/30 and so overstates the tolerable stake.
|
||||
"""
|
||||
q = emission_quota_per_slot(n_nodes, cover_rate_mult)
|
||||
if q >= 1.0:
|
||||
return math.inf # quota exceeds one emission per slot: never binds
|
||||
return math.log1p(-q) / math.log1p(-f)
|
||||
|
||||
|
||||
def s_max_true(n_nodes: int, f: float, stake_inference_ratio: float = 1.0,
|
||||
cover_rate_mult: float = 1.0) -> float:
|
||||
"""The ceiling expressed in **true** relative stake, ``(D_hat/D) * alpha_max``."""
|
||||
if stake_inference_ratio <= 0.0:
|
||||
raise ValueError("stake_inference_ratio must be > 0")
|
||||
return stake_inference_ratio * alpha_max(n_nodes, f, cover_rate_mult)
|
||||
|
||||
|
||||
def expected_blocks_per_epoch(alpha: float, f: float, slots_per_epoch: int) -> float:
|
||||
"""Expected proposals won by a node of inferred relative stake ``alpha`` over one epoch."""
|
||||
return win_prob(alpha, f) * slots_per_epoch
|
||||
|
||||
|
||||
def quota_per_epoch(n_nodes: int, slots_per_epoch: int, cover_rate_mult: float = 1.0) -> float:
|
||||
"""Emissions a node is allowed in one epoch."""
|
||||
return emission_quota_per_slot(n_nodes, cover_rate_mult) * slots_per_epoch
|
||||
|
||||
|
||||
def quota_exceedance_prob(alpha: float, f: float, n_nodes: int, slots_per_epoch: int,
|
||||
cover_rate_mult: float = 1.0) -> float:
|
||||
"""P(a node of inferred stake ``alpha`` wins more proposals in an epoch than its quota).
|
||||
|
||||
Wins are Binomial(slots_per_epoch, phi(alpha)); the Poisson limit is used, which is accurate
|
||||
here because phi is tiny and the epoch is long.
|
||||
"""
|
||||
lam = expected_blocks_per_epoch(alpha, f, slots_per_epoch)
|
||||
quota = quota_per_epoch(n_nodes, slots_per_epoch, cover_rate_mult)
|
||||
k = math.floor(quota)
|
||||
# P(X > k) for X ~ Poisson(lam), summed up from 0 (k is small in every regime of interest)
|
||||
if lam <= 0.0:
|
||||
return 0.0
|
||||
term = math.exp(-lam)
|
||||
cdf = term
|
||||
for i in range(1, k + 1):
|
||||
term *= lam / i
|
||||
cdf += term
|
||||
return max(0.0, min(1.0, 1.0 - cdf))
|
||||
|
||||
|
||||
def max_alpha_for_confidence(f: float, n_nodes: int, slots_per_epoch: int,
|
||||
confidence: float = 0.99,
|
||||
cover_rate_mult: float = 1.0) -> float:
|
||||
"""Largest inferred stake that keeps inside the quota with probability ``confidence``.
|
||||
|
||||
Always below :func:`alpha_max`, because a node sitting on the mean bind overruns half the time.
|
||||
"""
|
||||
if not (0.0 < confidence < 1.0):
|
||||
raise ValueError("need 0 < confidence < 1")
|
||||
lo, hi = 0.0, alpha_max(n_nodes, f, cover_rate_mult)
|
||||
if not math.isfinite(hi):
|
||||
return hi
|
||||
tol = 1.0 - confidence
|
||||
for _ in range(80): # bisection on a monotone exceedance probability
|
||||
mid = 0.5 * (lo + hi)
|
||||
p = quota_exceedance_prob(mid, f, n_nodes, slots_per_epoch, cover_rate_mult)
|
||||
lo, hi = (mid, hi) if p <= tol else (lo, mid)
|
||||
return lo
|
||||
245
tools/simulators/blend/pd/src/pd/traffic.py
Normal file
245
tools/simulators/blend/pd/src/pd/traffic.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""Cover traffic on a timeline: blending, mixing, and what the network sees between blocks.
|
||||
|
||||
The rest of the simulator samples *independent* rounds, drawing each relay's hold from
|
||||
``mixclock.mix_wait`` -- the stationary residual to that relay's next release. That is exact for one
|
||||
message meeting an independent clock, but it has no notion of time, so two messages can never meet
|
||||
at the same relay. Everything this module measures is defined by them meeting, so here each node
|
||||
owns **one free-running clock** shared by every message that passes through it, and messages are
|
||||
played out on a real timeline.
|
||||
|
||||
Two quantities, easy to confuse:
|
||||
|
||||
* **mixing** -- how many messages a relay is *holding* at once. A relay holds a message from its
|
||||
arrival until that relay's next tick.
|
||||
* **blending** -- how many messages a relay has *seen* between two consecutive releases. Every
|
||||
broadcast reaches every node, so a relay sees the whole network's broadcast stream; an observer
|
||||
watching it release cannot tell which of those it forwarded. This, not the held count, is the
|
||||
anonymity set, and it grows with the broadcast rate and with the release interval:
|
||||
``blending ~ rate * interval`` where the interval of a ``Uniform{0..M}`` clock averages ``M/2``.
|
||||
|
||||
Emissions are the union of cover traffic and block proposals. Each node emits at rate
|
||||
``cover_rate_mult / n_nodes`` per slot; winning the block lottery consumes the next scheduled cover,
|
||||
so every node emits the same number of times whether or not it produces blocks (see ``quota`` for
|
||||
the stake ceiling that keeps that true). Cover and block messages travel the same cascade over
|
||||
independently drawn paths, and both end in a broadcast, so they are indistinguishable in transit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from scipy.sparse.csgraph import dijkstra
|
||||
|
||||
from .config import SimConfig
|
||||
from .graph import Graph
|
||||
from .mixclock import mix_wait
|
||||
|
||||
|
||||
class ReleaseClock:
|
||||
"""One node's free-running release clock: ticks separated by ``Uniform{0..M}`` whole seconds.
|
||||
|
||||
The first tick is the stationary residual from t=0, so a clock sampled once reproduces
|
||||
``mixclock.mix_wait``. Ticks are extended lazily, which keeps a million-node network cheap --
|
||||
only the relays a message actually visits ever grow a clock.
|
||||
"""
|
||||
|
||||
__slots__ = ("_m", "_rng", "_ticks")
|
||||
|
||||
def __init__(self, max_blend_delay: int, rng: np.random.Generator) -> None:
|
||||
self._m = int(max_blend_delay)
|
||||
self._rng = rng
|
||||
first = 0.0 if self._m <= 0 else float(mix_wait(rng, self._m, 1)[0]) / 1000.0
|
||||
self._ticks: list[float] = [first]
|
||||
|
||||
def next_tick_at_or_after(self, t: float) -> float:
|
||||
"""Time of this clock's first tick at or after ``t`` (``t`` itself when M = 0)."""
|
||||
if self._m <= 0:
|
||||
return t
|
||||
while self._ticks[-1] < t:
|
||||
step = float(self._rng.integers(0, self._m + 1))
|
||||
self._ticks.append(self._ticks[-1] + step)
|
||||
for tick in self._ticks: # tick lists stay short (window / mean interval)
|
||||
if tick >= t:
|
||||
return tick
|
||||
return self._ticks[-1]
|
||||
|
||||
def ticks_in(self, lo: float, hi: float) -> list[float]:
|
||||
"""Ticks in ``(lo, hi]`` -- the release opportunities used to bracket blending."""
|
||||
self.next_tick_at_or_after(hi)
|
||||
return [t for t in self._ticks if lo < t <= hi]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hold:
|
||||
"""One message waiting at one relay."""
|
||||
node: int
|
||||
arrived: float
|
||||
released: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrafficWindow:
|
||||
"""Raw event record of a simulated window; :func:`traffic_metrics` reduces it."""
|
||||
emitted_cover: int = 0
|
||||
emitted_block: int = 0
|
||||
cancelled_cover: int = 0
|
||||
broadcasts: list[float] = field(default_factory=list) # when each message floods
|
||||
holds: list[Hold] = field(default_factory=list)
|
||||
window_seconds: float = 0.0
|
||||
block_slots: list[float] = field(default_factory=list) # when blocks were proposed
|
||||
cover_slots: list[float] = field(default_factory=list) # when cover was emitted
|
||||
clocks: dict[int, ReleaseClock] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _clock(clocks: dict[int, ReleaseClock], node: int, max_blend_delay: int,
|
||||
rng: np.random.Generator) -> ReleaseClock:
|
||||
c = clocks.get(node)
|
||||
if c is None:
|
||||
c = ReleaseClock(max_blend_delay, rng)
|
||||
clocks[node] = c
|
||||
return c
|
||||
|
||||
|
||||
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) -> 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.
|
||||
"""
|
||||
n = graph.n
|
||||
k = int(config.blend_hops if blend_hops is None else blend_hops)
|
||||
m = int(config.max_blend_delay if max_blend_delay is None else max_blend_delay)
|
||||
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
|
||||
|
||||
for slot in range(window_slots):
|
||||
t0 = float(slot)
|
||||
n_emissions = rng.poisson(config.cover_rate_mult) # network-wide emissions this slot
|
||||
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
|
||||
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
|
||||
win.cancelled_cover += 1
|
||||
continue
|
||||
if is_block:
|
||||
cancelled.add(sender)
|
||||
win.emitted_block += 1
|
||||
win.block_slots.append(t0)
|
||||
else:
|
||||
win.emitted_cover += 1
|
||||
win.cover_slots.append(t0)
|
||||
relays = rng.choice(n - 1, size=k, replace=False)
|
||||
relays[relays >= sender] += 1
|
||||
sources = np.empty(k + 1, dtype=np.int64)
|
||||
sources[0] = sender
|
||||
sources[1:] = relays
|
||||
data = (graph.base + rng.exponential(config.transport_jitter_mean_ms,
|
||||
size=graph.base.shape[0]) + graph.p[graph.src])
|
||||
dist = dijkstra(graph.weighted_csr(data), directed=True, indices=sources)
|
||||
t = t0
|
||||
dropped = False
|
||||
for hop in range(k):
|
||||
leg = dist[hop, relays[hop]]
|
||||
if not np.isfinite(leg):
|
||||
dropped = True
|
||||
break
|
||||
arrived = t + float(leg) / 1000.0
|
||||
released = _clock(clocks, int(relays[hop]), m, rng).next_tick_at_or_after(arrived)
|
||||
win.holds.append(Hold(int(relays[hop]), arrived, released))
|
||||
t = released
|
||||
if not dropped:
|
||||
win.broadcasts.append(t) # the final relay floods at t
|
||||
return win
|
||||
|
||||
|
||||
def traffic_metrics(win: TrafficWindow, config: SimConfig,
|
||||
max_blend_delay: int | None = None) -> dict:
|
||||
"""Reduce a window to the reported quantities.
|
||||
|
||||
``blending`` is evaluated per release: every broadcast in the network is seen by every node, so
|
||||
the anonymity set of a release at time ``T`` is the number of broadcasts since that relay's
|
||||
previous release.
|
||||
"""
|
||||
m = int(config.max_blend_delay if max_blend_delay is None else max_blend_delay)
|
||||
# cover emitted between consecutive block proposals, measured gap by gap
|
||||
if len(win.block_slots) >= 2:
|
||||
cover = np.sort(np.asarray(win.cover_slots))
|
||||
blocks = np.sort(np.asarray(win.block_slots))
|
||||
gaps = np.diff(np.searchsorted(cover, blocks, "right")).astype(float)
|
||||
per_block = float(gaps.mean())
|
||||
else:
|
||||
per_block = float("nan")
|
||||
out = {
|
||||
"emitted_cover": win.emitted_cover,
|
||||
"emitted_block": win.emitted_block,
|
||||
"cancelled_cover": win.cancelled_cover,
|
||||
"broadcasts_seen": len(win.broadcasts),
|
||||
"cover_per_block_interval": per_block,
|
||||
"hold_events": len(win.holds),
|
||||
}
|
||||
if not win.holds:
|
||||
out.update(hold_seconds_mean=float("nan"), delayed_frac=0.0,
|
||||
queue_mean=0.0, queue_p90=0.0, queue_max=0.0,
|
||||
blending_mean=float("nan"), blending_p50=float("nan"),
|
||||
blending_p90=float("nan"), blending_max=float("nan"))
|
||||
return out
|
||||
hold_s = np.array([h.released - h.arrived for h in win.holds])
|
||||
out["hold_seconds_mean"] = float(hold_s.mean())
|
||||
out["delayed_frac"] = float(np.mean(hold_s > 0.0))
|
||||
|
||||
# mixing: concurrent holds at a relay, time-weighted over the window
|
||||
by_node: dict[int, list[Hold]] = {}
|
||||
for h in win.holds:
|
||||
by_node.setdefault(h.node, []).append(h)
|
||||
occupancy, peaks = [], []
|
||||
for holds in by_node.values():
|
||||
events = sorted([(h.arrived, 1) for h in holds] + [(h.released, -1) for h in holds])
|
||||
cur = peak = 0
|
||||
area = 0.0
|
||||
prev = events[0][0]
|
||||
for tstamp, delta in events:
|
||||
area += cur * (tstamp - prev)
|
||||
prev = tstamp
|
||||
cur += delta
|
||||
peak = max(peak, cur)
|
||||
occupancy.append(area / win.window_seconds)
|
||||
peaks.append(peak)
|
||||
out["queue_mean"] = float(np.sum(occupancy) / max(len(by_node), 1))
|
||||
out["queue_p90"] = float(np.percentile(peaks, 90))
|
||||
out["queue_max"] = float(np.max(peaks))
|
||||
|
||||
# Blending: the anonymity set of a release. A timed-release relay would have flushed anything
|
||||
# that arrived before its previous tick, so the message it forwards must have arrived in the
|
||||
# last inter-tick interval -- and since every broadcast reaches every node, the candidates are
|
||||
# all broadcasts in that interval. Uses each clock's real previous tick, not the mean interval.
|
||||
casts = np.sort(np.asarray(win.broadcasts))
|
||||
sets: list[int] = []
|
||||
for node, holds in by_node.items():
|
||||
clock = win.clocks.get(node)
|
||||
for r in sorted({h.released for h in holds}):
|
||||
if m <= 0:
|
||||
prev = r # no delay: the set is whatever is instant
|
||||
elif clock is not None:
|
||||
earlier = [t for t in clock.ticks_in(-1e18, r) if t < r]
|
||||
prev = earlier[-1] if earlier else max(0.0, r - m / 2.0)
|
||||
else:
|
||||
prev = max(0.0, r - m / 2.0)
|
||||
# strictly before the release: when this relay is the last hop it broadcasts at exactly
|
||||
# `r`, and that is its own output, not a candidate input it saw.
|
||||
sets.append(int(np.searchsorted(casts, r, "left")
|
||||
- np.searchsorted(casts, prev, "right")))
|
||||
if sets:
|
||||
arr = np.asarray(sets, dtype=float)
|
||||
out.update(blending_mean=float(arr.mean()), blending_p50=float(np.percentile(arr, 50)),
|
||||
blending_p90=float(np.percentile(arr, 90)), blending_max=float(arr.max()))
|
||||
return out
|
||||
85
tools/simulators/blend/pd/tests/test_quota.py
Normal file
85
tools/simulators/blend/pd/tests/test_quota.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""The emission-quota stake ceiling: exact bind, the D_hat/D normalisation, and epoch compliance."""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pd.quota import (
|
||||
alpha_max,
|
||||
emission_quota_per_slot,
|
||||
expected_blocks_per_epoch,
|
||||
max_alpha_for_confidence,
|
||||
quota_exceedance_prob,
|
||||
quota_per_epoch,
|
||||
s_max_true,
|
||||
win_prob,
|
||||
)
|
||||
|
||||
F = 1.0 / 30.0
|
||||
|
||||
|
||||
def test_quota_is_one_emission_per_slot_network_wide():
|
||||
n = 20_000
|
||||
assert emission_quota_per_slot(n) * n == 1.0 # whole network emits once per slot
|
||||
assert emission_quota_per_slot(n, 4.0) * n == 4.0 # the multiplier scales it
|
||||
|
||||
|
||||
def test_alpha_max_is_where_the_win_rate_equals_the_quota():
|
||||
n = 20_000
|
||||
a = alpha_max(n, F)
|
||||
assert abs(win_prob(a, F) - emission_quota_per_slot(n)) < 1e-15
|
||||
|
||||
|
||||
def test_alpha_max_is_below_the_q_over_f_approximation():
|
||||
"""q/f is a small-q expansion and errs optimistic, so the exact bind must be lower."""
|
||||
for n in (1_000, 20_000, 10**6):
|
||||
exact = alpha_max(n, F)
|
||||
approx = emission_quota_per_slot(n) / F
|
||||
assert exact < approx
|
||||
assert abs(approx / exact - 1) < 0.02 # ~1.7% at f = 1/30
|
||||
|
||||
|
||||
def test_alpha_max_scales_inversely_with_network_size_and_with_cover_rate():
|
||||
assert abs(alpha_max(20_000, F) / alpha_max(200_000, F) - 10.0) < 0.01
|
||||
assert abs(alpha_max(20_000, F, 8.0) / alpha_max(20_000, F, 1.0) - 8.0) < 0.01
|
||||
|
||||
|
||||
def test_true_stake_ceiling_is_scaled_by_the_inference_ratio():
|
||||
"""The lottery uses sigma/D_hat, so the ceiling in TRUE stake carries the D_hat/D factor."""
|
||||
n = 20_000
|
||||
a = alpha_max(n, F)
|
||||
assert s_max_true(n, F, 1.0) == a # accurate estimator: no correction
|
||||
assert abs(s_max_true(n, F, 0.74) - 0.74 * a) < 1e-15 # deflated estimate tightens it
|
||||
assert s_max_true(n, F, 0.64) < s_max_true(n, F, 0.74) < a
|
||||
|
||||
|
||||
def test_expected_blocks_equal_the_quota_at_alpha_max():
|
||||
n, S = 20_000, 648_000
|
||||
a = alpha_max(n, F)
|
||||
assert abs(expected_blocks_per_epoch(a, F, S) - quota_per_epoch(n, S)) < 1e-6
|
||||
|
||||
|
||||
def test_a_node_at_the_mean_bind_overruns_about_half_the_time():
|
||||
n, S = 20_000, 648_000
|
||||
p = quota_exceedance_prob(alpha_max(n, F), F, n, S)
|
||||
assert 0.35 < p < 0.65 # mean bind is a coin flip, as expected
|
||||
|
||||
|
||||
def test_confidence_ceiling_is_stricter_than_the_mean_bind():
|
||||
n, S = 20_000, 648_000
|
||||
safe = max_alpha_for_confidence(F, n, S, confidence=0.99)
|
||||
assert safe < alpha_max(n, F)
|
||||
assert quota_exceedance_prob(safe, F, n, S) <= 0.01 + 1e-9
|
||||
assert 0.5 < safe / alpha_max(n, F) < 0.9 # Poisson noise eats real headroom
|
||||
|
||||
|
||||
def test_exceedance_matches_a_direct_simulation():
|
||||
"""Closed-form exceedance vs drawing epochs of block wins."""
|
||||
n, S = 2_000, 20_000
|
||||
a = alpha_max(n, F) * 0.8
|
||||
closed = quota_exceedance_prob(a, F, n, S)
|
||||
rng = np.random.default_rng(0)
|
||||
quota = quota_per_epoch(n, S)
|
||||
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)
|
||||
115
tools/simulators/blend/pd/tests/test_traffic.py
Normal file
115
tools/simulators/blend/pd/tests/test_traffic.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Cover traffic on a timeline: shared clocks, the emission quota, blending and mixing."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pd.config import SimConfig
|
||||
from pd.graph import build_graph
|
||||
from pd.traffic import ReleaseClock, simulate_window, traffic_metrics
|
||||
|
||||
|
||||
def _win(n_nodes=2000, degree=8, hops=3, M=3, mult=1.0, slots=600, seed=0):
|
||||
cfg = SimConfig(n_nodes=n_nodes, degree=degree, blend_hops=hops, max_blend_delay=M,
|
||||
cover_rate_mult=mult)
|
||||
g = build_graph(cfg)
|
||||
w = simulate_window(g, cfg, np.random.default_rng(seed), window_slots=slots)
|
||||
return w, traffic_metrics(w, cfg)
|
||||
|
||||
|
||||
# --- the clock ----------------------------------------------------------------------------------
|
||||
|
||||
def test_clock_ticks_are_monotonic_and_spaced_within_the_bound():
|
||||
c = ReleaseClock(3, np.random.default_rng(0))
|
||||
c.next_tick_at_or_after(100.0)
|
||||
ticks = c._ticks
|
||||
assert all(b >= a for a, b in zip(ticks, ticks[1:], strict=False))
|
||||
gaps = [b - a for a, b in zip(ticks, ticks[1:], strict=False)]
|
||||
assert all(0 <= g <= 3 + 1e-9 for g in gaps)
|
||||
|
||||
|
||||
def test_clock_with_zero_delay_releases_immediately():
|
||||
c = ReleaseClock(0, np.random.default_rng(0))
|
||||
for t in (0.0, 1.5, 99.0):
|
||||
assert c.next_tick_at_or_after(t) == t
|
||||
|
||||
|
||||
def test_next_tick_is_at_or_after_the_request_and_is_stable():
|
||||
c = ReleaseClock(3, np.random.default_rng(1))
|
||||
for t in (0.3, 5.0, 5.0, 12.7):
|
||||
assert c.next_tick_at_or_after(t) >= t
|
||||
assert c.next_tick_at_or_after(5.0) == c.next_tick_at_or_after(5.0) # idempotent
|
||||
|
||||
|
||||
def test_one_clock_is_shared_so_messages_batch_at_the_same_tick():
|
||||
"""Two messages arriving before the same tick leave together -- that is the mixing."""
|
||||
c = ReleaseClock(3, np.random.default_rng(2))
|
||||
t1 = c.next_tick_at_or_after(10.0)
|
||||
t2 = c.next_tick_at_or_after(10.0 + 1e-6)
|
||||
assert t1 == t2 or t2 >= t1
|
||||
|
||||
|
||||
def test_first_tick_reproduces_the_stationary_residual():
|
||||
"""A clock sampled once matches mixclock's residual, so single-message stats are unchanged."""
|
||||
M = 5
|
||||
firsts = [ReleaseClock(M, np.random.default_rng(s))._ticks[0] for s in range(4000)]
|
||||
assert abs(float(np.mean(firsts)) - (2 * M + 1) / 6) < 0.06 # mean residual (2M+1)/6
|
||||
|
||||
|
||||
# --- emissions and the quota --------------------------------------------------------------------
|
||||
|
||||
def test_block_proposals_cancel_a_later_cover_emission():
|
||||
w, _ = _win(slots=1500, seed=3)
|
||||
assert w.emitted_block > 0
|
||||
assert w.cancelled_cover > 0
|
||||
# every cancellation is owed to a block, and cannot exceed the blocks emitted
|
||||
assert w.cancelled_cover <= w.emitted_block
|
||||
|
||||
|
||||
def test_cover_between_blocks_matches_the_rate_times_the_block_interval():
|
||||
w, m = _win(slots=3000, seed=4)
|
||||
rate = (w.emitted_cover + w.emitted_block) / w.window_seconds
|
||||
assert abs(m["cover_per_block_interval"] - rate * 30) < 6 # ~30 at 1 msg/s
|
||||
|
||||
|
||||
# --- what the relays experience -------------------------------------------------------------------
|
||||
|
||||
def test_mean_hold_is_the_renewal_residual():
|
||||
for M in (3, 10):
|
||||
_, m = _win(M=M, slots=900, seed=5)
|
||||
assert abs(m["hold_seconds_mean"] - (2 * M + 1) / 6) < 0.25
|
||||
|
||||
|
||||
def test_blending_follows_the_size_biased_interval():
|
||||
"""Anonymity set = broadcasts seen in the last inter-tick gap. Gaps sampled at a release are
|
||||
size-biased, so the mean is rate*(2M+1)/3 -- twice the mean hold, not rate*M/2."""
|
||||
for M in (3, 10, 30):
|
||||
w, m = _win(M=M, slots=1200, seed=1)
|
||||
rate = (w.emitted_cover + w.emitted_block) / w.window_seconds
|
||||
assert abs(m["blending_mean"] - rate * (2 * M + 1) / 3) < 0.12 * rate * (2 * M + 1) / 3
|
||||
|
||||
|
||||
def test_blending_grows_with_the_cover_rate_and_with_the_delay():
|
||||
_, lo = _win(M=3, mult=1.0, slots=400, seed=2)
|
||||
_, hi = _win(M=3, mult=8.0, slots=400, seed=2)
|
||||
assert hi["blending_mean"] > 5 * lo["blending_mean"] # ~linear in rate
|
||||
_, slow = _win(M=30, mult=1.0, slots=400, seed=2)
|
||||
assert slow["blending_mean"] > 4 * lo["blending_mean"] # ~linear in delay
|
||||
|
||||
|
||||
def test_mixing_is_negligible_at_the_baseline_rate():
|
||||
"""One message per second over thousands of nodes: a relay essentially never holds two."""
|
||||
_, m = _win(n_nodes=4000, mult=1.0, slots=600, seed=6)
|
||||
assert m["queue_mean"] < 0.05
|
||||
assert m["queue_max"] <= 3
|
||||
|
||||
|
||||
def test_mixing_grows_when_the_network_is_loaded():
|
||||
_, lo = _win(n_nodes=500, mult=1.0, slots=400, seed=7)
|
||||
_, hi = _win(n_nodes=500, mult=32.0, slots=400, seed=7)
|
||||
assert hi["queue_max"] > lo["queue_max"]
|
||||
assert hi["queue_mean"] > lo["queue_mean"]
|
||||
|
||||
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user