mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-11 13:43:22 +00:00
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>
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 seedseq_for
|
||
from tsi_sim.stake import stake_for
|
||
|
||
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 = stake_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()
|