mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-09 04:33:15 +00:00
192 lines
9.0 KiB
Python
192 lines
9.0 KiB
Python
|
|
"""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
|
|||
|
|
from tsi_sim.rng import rng_for, seedseq_for
|
|||
|
|
from tsi_sim.stake import make_stake
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
stake = make_stake(cfg, rng_for(cfg))
|
|||
|
|
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()
|