mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-06 19:23:26 +00:00
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:
parent
36c16a0a98
commit
cad2bf51b2
@ -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.
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user