Anchor the uncle reference window to the parent, not the uncle
The spec bounds an uncle's own slot (0 < sl_A - sl_U <= w_u) but leaves its
PARENT unconstrained beyond lying on the referencing chain. So a block minted
NOW, built on a chain block from arbitrarily far back, is a legal first-fork
uncle: recent by its own slot, ancient by its parent's. Verifying it means
deriving the epoch state and ledger root as of that ancient parent, per
reference, and those are precisely the inputs the counting rules require -- so
the work cannot be amortised. It costs the adversary nothing beyond lottery
wins it already has; it just builds them somewhere useless.
Measured with a deep_parent coalition. At the deployed operating point a 30%
adversary moves the MEDIAN counted reference's reach from 54 slots back to
20,144, and the worst case to 76,778 -- the epoch boundary, ~21 hours of
history, ~256x the nominal window. It is not a tail effect.
The fix is a SUBSTITUTION, not an additional rule. A block strictly postdates
its parent and a referenced uncle strictly precedes its referencer, so
sl_A - sl_U < sl_A - sl_parent(U) <= w_u: bounding the parent bounds the uncle
for free, and a both-windows variant would be identical to the parent one. Both
invariants are pinned in a new test_slot_ordering.py rather than argued -- the
user asked to confirm sl_A > sl_U explicitly, and it turns out to be
load-bearing for the whole implication, so it is tested at three geometries
plus a hand-built counting case.
Under the parent anchor the same coalition reaches 292/300/300 slots at
delta_max 4/8/16 -- capped by construction. Honest recovery is unaffected:
0.9993 -> 0.9999, 0.9969 -> 0.9986, 0.9791 -> 0.9858, no loss anywhere within
one to two SEM, because a latency orphan's parent is recent by construction.
One finding that sharpens the case: at delta_max = 16 the HONEST uncle-anchored
arm already reaches 315 slots, past its own w_u = 300. Under the current rule
w_u is not a bound on validation reach even with no adversary present. It only
becomes a state-retention bound once anchored to the parent.
Recorded as sec 6.12 with fig38, a new row in the sec 8.5 spec deltas, both new
knobs in sec 7, and the study in sec 9. uncle_window_anchor and the deep_parent
strategy are appended to the RNG key only when non-default, so no committed run
is reseeded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:22:50 +02:00
|
|
|
|
"""Should the uncle reference window be measured to the uncle, or to its PARENT? (fig38)
|
|
|
|
|
|
|
|
|
|
|
|
The spec bounds the uncle's own slot: `0 < sl_A - sl_U <= w_u`. Its **parent** is unbounded —
|
|
|
|
|
|
the only requirement is that it lie on the referencing chain. So a block minted now, hanging off
|
|
|
|
|
|
a chain block from arbitrarily far back, is a legal first-fork uncle: recent by its own slot,
|
|
|
|
|
|
ancient by its parent's. Verifying it means deriving the epoch state and ledger root as of that
|
|
|
|
|
|
ancient parent, per reference, and an adversary mints them at no cost beyond lottery wins it
|
|
|
|
|
|
already has.
|
|
|
|
|
|
|
|
|
|
|
|
The proposal: measure the window to the parent instead, `sl_A - sl_parent(U) <= w_u`.
|
|
|
|
|
|
|
|
|
|
|
|
That is strictly tighter rather than an additional rule. A block strictly postdates its parent
|
|
|
|
|
|
and a referenced uncle strictly precedes its referencer (both pinned in
|
|
|
|
|
|
`tests/test_slot_ordering.py`), so
|
|
|
|
|
|
|
|
|
|
|
|
sl_A - sl_U < sl_A - sl_parent(U) <= w_u
|
|
|
|
|
|
|
|
|
|
|
|
and bounding the parent bounds the uncle for free. A "both windows" variant would be identical
|
|
|
|
|
|
to the parent one, so only two arms are simulated.
|
|
|
|
|
|
|
|
|
|
|
|
Two questions, and they trade off:
|
|
|
|
|
|
* **What does it buy?** The effort an adversary can force, measured as the age of the oldest
|
|
|
|
|
|
chain state a validator must reach for a *counted* reference.
|
|
|
|
|
|
* **What does it cost?** Honest recovery. A latency orphan's parent is recent by construction,
|
|
|
|
|
|
so the prediction is ~nothing — but the parent gap runs about one block-interval longer than
|
|
|
|
|
|
the uncle gap, so the same numeric `w_u` is effectively a tighter window and the margin
|
|
|
|
|
|
shrinks as delay grows. This sweeps delay to find where it starts to bind.
|
|
|
|
|
|
|
|
|
|
|
|
Run: python scripts/uncle_parent_window.py (writes runs/uncle_parent_window.parquet + fig38)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
from joblib import Parallel, delayed
|
|
|
|
|
|
|
|
|
|
|
|
from tsi_sim import lottery, topology
|
|
|
|
|
|
from tsi_sim.blocktree import build_tree_pernode
|
|
|
|
|
|
from tsi_sim.config import SimConfig
|
|
|
|
|
|
from tsi_sim.engine import _adversary_mask, run_trajectory
|
|
|
|
|
|
from tsi_sim.plotting import style
|
Re-measure the W pairing paired, and correct my own severity numbers
The W = 12 pairing is now measured the way a ~0.001 claim has to be: every
integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs
on common random numbers (_base_key excludes both uncle_window_anchor and
window_absorption, so a replicate draws one stake vector, one graph and one
lottery for every cell). The earlier unpaired sweep reported +0.0008 against a
standard error of 0.0009 — it could not resolve its own headline.
Paired against today's recipe (uncle-anchored, W = 10):
parent W=10 -0.0056 +- 0.0005 t = -10.4
parent W=11 -0.0024 +- 0.0005 t = -4.5
parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity
parent W=14 +0.0016 +- 0.0004 t = 3.5
W = 12 is the smallest window reaching parity, and the parity is exact rather
than marginal: W = 11, one interval short, is still resolvably worse. p_ref
agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the
unpaired edition had it. Also states what the sweep makes visible: widening
today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap
cost-neutral against the CURRENT recipe rather than optimal in absolute terms.
CORRECTIONS to the previous commit, which measured contamination on the wrong
RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I
used rng_for(config), the root. Both are valid stake draws, neither is the same
vector. Redone properly:
- The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its
label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61%
majority" was wrong and is withdrawn from §8.4 and §9.
- The finding that survives is sharper: on that same mild overshoot the spec's
rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning
harder on the reference window is far more sensitive to an oversized
suppressing coalition.
- Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst
0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities).
§6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the
one sweep that still needs re-running.
- The general severity is worse than first stated, not better: at the report's
geometry a nominal 0.3 realised a majority in 12% of replicates.
Two more defects found on the way:
- stake_for(config) added, because scripts used rng_for and the engine uses
the spawned child — so every script that rebuilt a tree was analysing a
different network than the trajectory it was compared against. All scripts
and tests now use it.
- A coalition member could receive a private block BEFORE its parent: the
arrival was clamped against the PRODUCER's view of the parent and applied to
the whole coalition, so a member still awaiting a public parent got the child
first. Now clamped per member. Caught by the existing arrival-order test once
the stake derivation was corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
|
|
|
|
from tsi_sim.rng import seedseq_for
|
|
|
|
|
|
from tsi_sim.stake import stake_for
|
Anchor the uncle reference window to the parent, not the uncle
The spec bounds an uncle's own slot (0 < sl_A - sl_U <= w_u) but leaves its
PARENT unconstrained beyond lying on the referencing chain. So a block minted
NOW, built on a chain block from arbitrarily far back, is a legal first-fork
uncle: recent by its own slot, ancient by its parent's. Verifying it means
deriving the epoch state and ledger root as of that ancient parent, per
reference, and those are precisely the inputs the counting rules require -- so
the work cannot be amortised. It costs the adversary nothing beyond lottery
wins it already has; it just builds them somewhere useless.
Measured with a deep_parent coalition. At the deployed operating point a 30%
adversary moves the MEDIAN counted reference's reach from 54 slots back to
20,144, and the worst case to 76,778 -- the epoch boundary, ~21 hours of
history, ~256x the nominal window. It is not a tail effect.
The fix is a SUBSTITUTION, not an additional rule. A block strictly postdates
its parent and a referenced uncle strictly precedes its referencer, so
sl_A - sl_U < sl_A - sl_parent(U) <= w_u: bounding the parent bounds the uncle
for free, and a both-windows variant would be identical to the parent one. Both
invariants are pinned in a new test_slot_ordering.py rather than argued -- the
user asked to confirm sl_A > sl_U explicitly, and it turns out to be
load-bearing for the whole implication, so it is tested at three geometries
plus a hand-built counting case.
Under the parent anchor the same coalition reaches 292/300/300 slots at
delta_max 4/8/16 -- capped by construction. Honest recovery is unaffected:
0.9993 -> 0.9999, 0.9969 -> 0.9986, 0.9791 -> 0.9858, no loss anywhere within
one to two SEM, because a latency orphan's parent is recent by construction.
One finding that sharpens the case: at delta_max = 16 the HONEST uncle-anchored
arm already reaches 315 slots, past its own w_u = 300. Under the current rule
w_u is not a bound on validation reach even with no adversary present. It only
becomes a state-retention bound once anchored to the parent.
Recorded as sec 6.12 with fig38, a new row in the sec 8.5 spec deltas, both new
knobs in sec 7, and the study in sec 9. uncle_window_anchor and the deep_parent
strategy are appended to the RNG key only when non-default, so no committed run
is reseeded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:22:50 +02:00
|
|
|
|
|
|
|
|
|
|
HERE = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
RUNS = HERE / "runs"
|
|
|
|
|
|
FIGS = HERE / "report-figures"
|
|
|
|
|
|
RUNS.mkdir(exist_ok=True)
|
|
|
|
|
|
FIGS.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
REPS = 10
|
|
|
|
|
|
N_JOBS = 10
|
|
|
|
|
|
ANCHORS = ["uncle", "parent"]
|
|
|
|
|
|
DELAYS = [4.0, 8.0, 16.0] # the deployed point, the report's design point, the boundary
|
|
|
|
|
|
ADVS = [0.0, 0.3]
|
|
|
|
|
|
|
|
|
|
|
|
BASE = dict(n_nodes=600, stake_dist="pareto", topology="blend", degree=6,
|
|
|
|
|
|
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
|
|
|
|
|
|
max_uncles=4, uncle_strategy="oldest", window_absorption=10.0,
|
|
|
|
|
|
k=256, epochs=10, genesis_d_factor=0.5, early_stop=False,
|
|
|
|
|
|
prune_arrival=False, windowed_fork_choice=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recovery(anchor: str, delay: float, adv: float, rep: int) -> dict:
|
|
|
|
|
|
"""Accuracy arm: what the estimator lands on under each rule."""
|
|
|
|
|
|
cfg = SimConfig(**BASE, uncle_window_anchor=anchor, blend_delay_max=delay,
|
|
|
|
|
|
adversary_frac=adv, adversary_strategy="deep_parent", replicate=rep)
|
|
|
|
|
|
t = pd.DataFrame(run_trajectory(cfg))
|
|
|
|
|
|
t = t[t.epoch >= t.epoch.max() // 2]
|
|
|
|
|
|
return dict(anchor=anchor, blend_delay_max=delay, adversary_frac=adv, rep=rep,
|
|
|
|
|
|
mean_ratio=float(t.mean_ratio.mean()), p_ref=float(t.p_ref.mean()),
|
|
|
|
|
|
fork_rate=float(t.fork_rate.mean()),
|
|
|
|
|
|
range_ratio=float(t.range_ratio.max()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _effort(anchor: str, delay: float, adv: float, rep: int) -> dict:
|
|
|
|
|
|
"""Effort arm: how far back a validator must reach for the references that COUNT.
|
|
|
|
|
|
|
|
|
|
|
|
Rebuilds one epoch's tree and reads the parent gap of every reference the counting rule
|
|
|
|
|
|
would accept — the direct proxy for historical state a validator must materialise.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cfg = SimConfig(**{**BASE, "epochs": 2}, uncle_window_anchor=anchor,
|
|
|
|
|
|
blend_delay_max=delay, adversary_frac=adv,
|
|
|
|
|
|
adversary_strategy="deep_parent", replicate=rep)
|
|
|
|
|
|
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
|
Re-measure the W pairing paired, and correct my own severity numbers
The W = 12 pairing is now measured the way a ~0.001 claim has to be: every
integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs
on common random numbers (_base_key excludes both uncle_window_anchor and
window_absorption, so a replicate draws one stake vector, one graph and one
lottery for every cell). The earlier unpaired sweep reported +0.0008 against a
standard error of 0.0009 — it could not resolve its own headline.
Paired against today's recipe (uncle-anchored, W = 10):
parent W=10 -0.0056 +- 0.0005 t = -10.4
parent W=11 -0.0024 +- 0.0005 t = -4.5
parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity
parent W=14 +0.0016 +- 0.0004 t = 3.5
W = 12 is the smallest window reaching parity, and the parity is exact rather
than marginal: W = 11, one interval short, is still resolvably worse. p_ref
agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the
unpaired edition had it. Also states what the sweep makes visible: widening
today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap
cost-neutral against the CURRENT recipe rather than optimal in absolute terms.
CORRECTIONS to the previous commit, which measured contamination on the wrong
RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I
used rng_for(config), the root. Both are valid stake draws, neither is the same
vector. Redone properly:
- The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its
label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61%
majority" was wrong and is withdrawn from §8.4 and §9.
- The finding that survives is sharper: on that same mild overshoot the spec's
rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning
harder on the reference window is far more sensitive to an oversized
suppressing coalition.
- Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst
0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities).
§6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the
one sweep that still needs re-running.
- The general severity is worse than first stated, not better: at the report's
geometry a nominal 0.3 realised a majority in 12% of replicates.
Two more defects found on the way:
- stake_for(config) added, because scripts used rng_for and the engine uses
the spawned child — so every script that rebuilt a tree was analysing a
different network than the trajectory it was compared against. All scripts
and tests now use it.
- A coalition member could receive a private block BEFORE its parent: the
arrival was clamped against the PRODUCER's view of the parent and applied to
the whole coalition, so a member still awaiting a public parent got the child
first. Now clamped per member. Caught by the existing arrival-order test once
the stake derivation was corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
|
|
|
|
stake = stake_for(cfg)
|
Anchor the uncle reference window to the parent, not the uncle
The spec bounds an uncle's own slot (0 < sl_A - sl_U <= w_u) but leaves its
PARENT unconstrained beyond lying on the referencing chain. So a block minted
NOW, built on a chain block from arbitrarily far back, is a legal first-fork
uncle: recent by its own slot, ancient by its parent's. Verifying it means
deriving the epoch state and ledger root as of that ancient parent, per
reference, and those are precisely the inputs the counting rules require -- so
the work cannot be amortised. It costs the adversary nothing beyond lottery
wins it already has; it just builds them somewhere useless.
Measured with a deep_parent coalition. At the deployed operating point a 30%
adversary moves the MEDIAN counted reference's reach from 54 slots back to
20,144, and the worst case to 76,778 -- the epoch boundary, ~21 hours of
history, ~256x the nominal window. It is not a tail effect.
The fix is a SUBSTITUTION, not an additional rule. A block strictly postdates
its parent and a referenced uncle strictly precedes its referencer, so
sl_A - sl_U < sl_A - sl_parent(U) <= w_u: bounding the parent bounds the uncle
for free, and a both-windows variant would be identical to the parent one. Both
invariants are pinned in a new test_slot_ordering.py rather than argued -- the
user asked to confirm sl_A > sl_U explicitly, and it turns out to be
load-bearing for the whole implication, so it is tested at three geometries
plus a hand-built counting case.
Under the parent anchor the same coalition reaches 292/300/300 slots at
delta_max 4/8/16 -- capped by construction. Honest recovery is unaffected:
0.9993 -> 0.9999, 0.9969 -> 0.9986, 0.9791 -> 0.9858, no loss anywhere within
one to two SEM, because a latency orphan's parent is recent by construction.
One finding that sharpens the case: at delta_max = 16 the HONEST uncle-anchored
arm already reaches 315 slots, past its own w_u = 300. Under the current rule
w_u is not a bound on validation reach even with no adversary present. It only
becomes a state-retention bound once anchored to the parent.
Recorded as sec 6.12 with fig38, a new row in the sec 8.5 spec deltas, both new
knobs in sec 7, and the study in sec 9. uncle_window_anchor and the deep_parent
strategy are appended to the RNG key only when non-default, so no committed run
is reseeded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:22:50 +02:00
|
|
|
|
mask = _adversary_mask(cfg, stake)
|
|
|
|
|
|
pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1]))
|
|
|
|
|
|
d = np.full(cfg.n_nodes, cfg.genesis_d_factor * float(stake.sum()))
|
|
|
|
|
|
ws, wn = lottery.sample_wins(lottery.win_probs(stake, d, cfg.f), cfg.epoch_len,
|
|
|
|
|
|
np.random.default_rng(kids[3]))
|
|
|
|
|
|
slots, groups = lottery.group_by_slot(ws, wn)
|
|
|
|
|
|
tree, _A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]),
|
|
|
|
|
|
adversary_mask=mask)
|
|
|
|
|
|
s, par = tree.slot, tree.parent
|
|
|
|
|
|
gaps = np.array([int(s[b] - s[par[u]])
|
|
|
|
|
|
for b in range(1, tree.n_blocks) for u in tree.uncles[b]], dtype=np.int64)
|
|
|
|
|
|
if gaps.size == 0:
|
|
|
|
|
|
gaps = np.zeros(1, dtype=np.int64)
|
|
|
|
|
|
return dict(anchor=anchor, blend_delay_max=delay, adversary_frac=adv, rep=rep,
|
|
|
|
|
|
n_refs=int(gaps.size), gap_median=float(np.median(gaps)),
|
|
|
|
|
|
gap_p99=float(np.percentile(gaps, 99)), gap_max=int(gaps.max()),
|
|
|
|
|
|
distinct_parent_slots=int(np.unique(gaps).size))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sweep() -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
|
|
|
|
jobs = [(a, d, v, r) for a in ANCHORS for d in DELAYS for v in ADVS for r in range(REPS)]
|
|
|
|
|
|
par = Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)
|
|
|
|
|
|
rec = pd.DataFrame(par(delayed(_recovery)(*j) for j in jobs))
|
|
|
|
|
|
eff = pd.DataFrame(par(delayed(_effort)(*j) for j in jobs))
|
|
|
|
|
|
rec.to_parquet(RUNS / "uncle_parent_window.parquet", index=False)
|
|
|
|
|
|
eff.to_parquet(RUNS / "uncle_parent_window_effort.parquet", index=False)
|
|
|
|
|
|
return rec, eff
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def report(rec: pd.DataFrame, eff: pd.DataFrame, w: int) -> None:
|
|
|
|
|
|
print(f"\n=== what it COSTS: honest recovery (adversary_frac = 0), w_u = {w} slots ===")
|
|
|
|
|
|
print(f"{'δ_max':>6} {'ρ':>6} | {'uncle-anchored':>18} {'parent-anchored':>18} {'Δ':>9}")
|
|
|
|
|
|
for d in DELAYS:
|
|
|
|
|
|
row = []
|
|
|
|
|
|
for a in ANCHORS:
|
|
|
|
|
|
g = rec[(rec.anchor == a) & (rec.blend_delay_max == d) & (rec.adversary_frac == 0)]
|
|
|
|
|
|
row.append((g.mean_ratio.mean(), g.mean_ratio.sem()))
|
|
|
|
|
|
rho = SimConfig(**BASE, blend_delay_max=d).f * (3 * d / 2 + 4 * 0.5)
|
|
|
|
|
|
print(f"{d:6.0f} {rho:6.2f} | {row[0][0]:10.4f}±{row[0][1]:.4f} "
|
|
|
|
|
|
f"{row[1][0]:10.4f}±{row[1][1]:.4f} {row[1][0] - row[0][0]:+9.4f}")
|
|
|
|
|
|
|
|
|
|
|
|
print("\n=== what it BUYS: age of chain state a counted reference reaches (slots) ===")
|
|
|
|
|
|
print(f"{'δ_max':>6} {'adv':>5} | {'anchor':>7} {'refs':>6} {'median':>9} "
|
|
|
|
|
|
f"{'p99':>9} {'max':>9}")
|
|
|
|
|
|
for d in DELAYS:
|
|
|
|
|
|
for v in ADVS:
|
|
|
|
|
|
for a in ANCHORS:
|
|
|
|
|
|
g = eff[(eff.anchor == a) & (eff.blend_delay_max == d) & (eff.adversary_frac == v)]
|
|
|
|
|
|
flag = "" if g.gap_max.max() <= w else " <-- EXCEEDS w_u"
|
|
|
|
|
|
print(f"{d:6.0f} {v:5.1f} | {a:>7} {g.n_refs.mean():6.0f} "
|
|
|
|
|
|
f"{g.gap_median.mean():9.0f} {g.gap_p99.mean():9.0f} "
|
|
|
|
|
|
f"{g.gap_max.max():9.0f}{flag}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fig38(rec: pd.DataFrame, eff: pd.DataFrame, w: int) -> None:
|
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
style.apply_style()
|
|
|
|
|
|
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8))
|
|
|
|
|
|
|
|
|
|
|
|
ax = axes[0]
|
|
|
|
|
|
for i, a in enumerate(ANCHORS):
|
|
|
|
|
|
g = (rec[(rec.anchor == a) & (rec.adversary_frac == 0)]
|
|
|
|
|
|
.groupby("blend_delay_max").mean_ratio.agg(["mean", "sem"]).reset_index())
|
|
|
|
|
|
ax.errorbar(g.blend_delay_max, g["mean"], yerr=g["sem"], marker="o", ms=4, capsize=2,
|
|
|
|
|
|
color=style.OKABE_ITO[i + 1], label=f"{a}-anchored window")
|
|
|
|
|
|
ax.axhline(1.0, color="0.5", lw=0.9, ls="--")
|
|
|
|
|
|
ax.set_xlabel(r"Blend per-hop delay $\delta_{max}$ (s)")
|
|
|
|
|
|
ax.set_ylabel(r"$\hat D / D^*$ (honest)")
|
|
|
|
|
|
ax.set_title("Cost: honest recovery is unchanged")
|
|
|
|
|
|
ax.legend(fontsize=7, loc="lower left")
|
|
|
|
|
|
|
|
|
|
|
|
ax = axes[1]
|
|
|
|
|
|
x = np.arange(len(DELAYS))
|
|
|
|
|
|
for i, a in enumerate(ANCHORS):
|
|
|
|
|
|
vals = [eff[(eff.anchor == a) & (eff.blend_delay_max == d)
|
|
|
|
|
|
& (eff.adversary_frac == 0.3)].gap_max.max() for d in DELAYS]
|
|
|
|
|
|
ax.bar(x + (i - 0.5) * 0.36, vals, 0.36, color=style.OKABE_ITO[i + 1],
|
|
|
|
|
|
label=f"{a}-anchored")
|
|
|
|
|
|
ax.axhline(w, color=style.OKABE_ITO[0], lw=1.2, ls="--", label=rf"$w_u$ = {w} slots")
|
|
|
|
|
|
ax.set_yscale("log")
|
|
|
|
|
|
ax.set_xticks(x, [f"{d:.0f}" for d in DELAYS])
|
|
|
|
|
|
ax.set_xlabel(r"Blend per-hop delay $\delta_{max}$ (s)")
|
|
|
|
|
|
ax.set_ylabel("oldest chain state a counted\nreference reaches (slots, log)")
|
|
|
|
|
|
ax.set_title("Benefit: a 30 % adversary's reach, bounded")
|
|
|
|
|
|
ax.legend(fontsize=7, loc="upper left")
|
|
|
|
|
|
|
|
|
|
|
|
style.save(fig, FIGS / "fig38_uncle_parent_window",
|
|
|
|
|
|
provenance="scripts/uncle_parent_window.py")
|
|
|
|
|
|
plt.close(fig)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
w = SimConfig(**BASE, blend_delay_max=4.0).effective_uncle_window
|
|
|
|
|
|
print(f"=== uncle- vs parent-anchored reference window (w_u = {w} slots) ===")
|
|
|
|
|
|
rec, eff = sweep()
|
|
|
|
|
|
report(rec, eff, w)
|
|
|
|
|
|
fig38(rec, eff, w)
|
|
|
|
|
|
print(f"\nwrote {RUNS}/uncle_parent_window{{,_effort}}.parquet + fig38")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|