2026-08-07 11:52:58 +02:00
|
|
|
"""does per-recipient delay variance reproduce the standalone result? (the diagnostic).
|
2026-08-07 11:15:55 +02:00
|
|
|
|
2026-08-07 11:52:58 +02:00
|
|
|
The one fork-loss experiment that could invalidate the REPORT rather than the spec section it
|
|
|
|
|
is checking. The hypothesis for the original discrepancy is a modelling difference, not a
|
2026-08-07 11:15:55 +02:00
|
|
|
measurement one: the standalone simulation drew an independent propagation delay per
|
|
|
|
|
(block, recipient), whereas this simulator's Blend cascade floods network-wide from the last
|
|
|
|
|
relay, so nodes receive a block at nearly the same time and their views stay synchronised.
|
|
|
|
|
Independent per-recipient draws maximise view divergence, which is what manufactures the
|
|
|
|
|
depth->=2 forks the first-fork rule cannot recover.
|
|
|
|
|
|
|
|
|
|
`jitter_mean` adds per-(block, node) arrival noise on top of the cascade, so sweeping it
|
|
|
|
|
interpolates between the two models. The deciding observable is `deep_orphan_share`: the fraction
|
|
|
|
|
of in-window orphans sitting deeper than the first block of their fork — precisely the structural
|
2026-08-07 11:52:58 +02:00
|
|
|
quantity behind the spec section's deep-fork claim, and the thing `p_ref` conflates with
|
|
|
|
|
"never picked up".
|
2026-08-07 11:15:55 +02:00
|
|
|
|
2026-08-07 11:52:58 +02:00
|
|
|
Pass / fail:
|
2026-08-07 11:15:55 +02:00
|
|
|
* D-hat/D holds at ~1.000 and deep orphans stay negligible as jitter rises -> the standalone
|
2026-08-07 11:52:58 +02:00
|
|
|
model was simply wrong; its numbers are artefacts and the report is robust to this failure mode.
|
2026-08-07 11:15:55 +02:00
|
|
|
* accuracy degrades toward 0.986 and deep orphans reach ~1 % of blocks at some jitter level
|
|
|
|
|
-> record that level and compare it to what Blend plausibly delivers; per-recipient variance
|
|
|
|
|
then becomes a parameter the report must carry, and the spec section's number is defensible
|
|
|
|
|
under a stated assumption.
|
|
|
|
|
|
|
|
|
|
Exact oracle throughout: the windowed fork choice and the arrival prune are bit-exact only at
|
|
|
|
|
jitter_mean == 0, so both are disabled and the full arrival matrix is used.
|
|
|
|
|
|
|
|
|
|
Run: python scripts/spec_jitter.py (writes runs/spec_jitter.parquet)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
from joblib import Parallel, delayed
|
|
|
|
|
|
|
|
|
|
from tsi_sim.config import SimConfig
|
|
|
|
|
from tsi_sim.engine import run_trajectory
|
|
|
|
|
|
|
|
|
|
HERE = Path(__file__).resolve().parent.parent
|
|
|
|
|
RUNS = HERE / "runs"
|
|
|
|
|
RUNS.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
REPS = 12
|
|
|
|
|
N_JOBS = 12
|
|
|
|
|
JITTERS = [0.0, 1.0, 2.0, 4.0, 8.0]
|
|
|
|
|
CAPS = [0, 1, 2, 4]
|
|
|
|
|
|
|
|
|
|
SPEC_POINT = dict(n_nodes=1000, stake_dist="pareto", topology="blend", degree=6,
|
|
|
|
|
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
|
|
|
|
|
blend_delay_max=4.0, uncle_strategy="oldest", window_absorption=10.0,
|
|
|
|
|
k=2160, epochs=20, genesis_d_factor=0.5, early_stop=True,
|
Keep the deployed rule as the default; make the proposal an explicit arm
I had planned to flip uncle_window_anchor's default to "parent" so new configs
would measure the proposed design by default. Tried it, and it breaks a
documented guarantee: key() appends the anchor only when it is not "uncle", so
with "parent" as the default an --old run's key is no longer byte-identical to
the pre-redesign key and --old stops bit-reproducing historical runs (sec 9).
o.key() == o._base_key() fails outright.
Reverted, and on reflection the default was wrong for a second reason anyway.
The report's job is to describe the protocol as deployed and to RECOMMEND
changes; the default should therefore be the deployed rule, with the proposal
as an explicit arm. That is exactly the convention fixed_point already follows
(default exact f = the analysis convention, explicit True = spec-faithful).
Both reasons are recorded on the field.
To make the distinction visible rather than implicit, the spec-as-is studies
now pin uncle_window_anchor: uncle explicitly -- spec_point.py, spec_jitter.py
and the three spec-point-*.yaml configs answer "what does the DEPLOYED chain
do", so they must not drift onto a proposal if a default ever moves.
Adds uncle_window_anchor as a sweep axis (SweepConfig field plus _SWEEP_AXES),
and configs/absorption-window-anchor.yaml: the sec 3.4 absorption sweep re-run
under both anchors at three delays. That study is the one that can move a
recommendation -- the W >= 7/f floor was measured against the uncle gap, and the
parent gap runs about one block-interval longer, so the floor should sit higher
and the margin behind W = 10/f shrink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:53:47 +02:00
|
|
|
windowed_fork_choice=False, prune_arrival=False,
|
|
|
|
|
uncle_window_anchor="uncle") # SPEC AS DEPLOYED, not the §6.12 proposal
|
2026-08-07 11:15:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cell(model: str, jitter: float, u: int, rep: int) -> dict:
|
|
|
|
|
cfg = SimConfig(**SPEC_POINT, uncle_model=model, jitter_mean=jitter,
|
|
|
|
|
max_uncles=u, replicate=rep)
|
|
|
|
|
t = pd.DataFrame(run_trajectory(cfg))
|
|
|
|
|
t = t[t.epoch >= t.epoch.max() // 2]
|
|
|
|
|
return dict(model=model, jitter_mean=jitter, max_uncles=u, rep=rep,
|
|
|
|
|
mean_ratio=float(t.mean_ratio.mean()),
|
|
|
|
|
fork_rate=float(t.fork_rate.mean()),
|
|
|
|
|
deep_orphan_share=float(t.deep_orphan_share.mean()),
|
|
|
|
|
p_ref=float(t.p_ref.mean()),
|
|
|
|
|
max_reorg_depth=int(t.max_reorg_depth.max()),
|
|
|
|
|
range_ratio=float(t.range_ratio.max()),
|
|
|
|
|
agreement_window=float(t.agreement_window.min()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sweep() -> pd.DataFrame:
|
|
|
|
|
jobs = [(m, j, u, r) for m in ("countable", "old") for j in JITTERS
|
|
|
|
|
for u in CAPS for r in range(REPS)]
|
|
|
|
|
df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)(
|
|
|
|
|
delayed(_cell)(m, j, u, r) for m, j, u, r in jobs))
|
|
|
|
|
df.to_parquet(RUNS / "spec_jitter.parquet", index=False)
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def report(df: pd.DataFrame) -> None:
|
|
|
|
|
print("\n=== accuracy vs per-(block,node) jitter at the spec point (delta_max = 4) ===")
|
|
|
|
|
print(f"{'jitter':>7} | " + " ".join(f"U={u}" for u in CAPS)
|
|
|
|
|
+ f" | {'ceiling U=1':>11} {'gap':>8} {'deep orph':>10} {'fork':>6} {'consensus':>10}")
|
|
|
|
|
for j in JITTERS:
|
|
|
|
|
c = df[(df.model == "countable") & (df.jitter_mean == j)]
|
|
|
|
|
o = df[(df.model == "old") & (df.jitter_mean == j)]
|
|
|
|
|
cells = [f"{c[c.max_uncles == u].mean_ratio.mean():.4f}" for u in CAPS]
|
|
|
|
|
c1 = c[c.max_uncles == 1].mean_ratio.mean()
|
|
|
|
|
o1 = o[o.max_uncles == 1].mean_ratio.mean()
|
|
|
|
|
deep = c[c.max_uncles == 1].deep_orphan_share.mean()
|
|
|
|
|
fork = c[c.max_uncles == 1].fork_rate.mean()
|
|
|
|
|
ok = "exact" if c.range_ratio.max() == 0 else "SPREAD"
|
|
|
|
|
print(f"{j:7.1f} | " + " ".join(cells)
|
|
|
|
|
+ f" | {o1:11.4f} {o1 - c1:+8.4f} {deep:10.4f} {fork:6.3f} {ok:>10}")
|
|
|
|
|
print("\ndeep orph = share of in-window orphans below their fork's first block "
|
|
|
|
|
"(uncountable by construction); gap = ceiling - countable at U=1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
2026-08-07 11:52:58 +02:00
|
|
|
print(f"=== jitter sweep, exact oracle, {len(JITTERS)*len(CAPS)*REPS*2} runs ===")
|
2026-08-07 11:15:55 +02:00
|
|
|
report(sweep())
|
|
|
|
|
print(f"\nwrote {RUNS}/spec_jitter.parquet")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|