Marcin Pawlowski 525d081a3c
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

109 lines
5.0 KiB
Python

"""does per-recipient delay variance reproduce the standalone result? (the diagnostic).
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
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
quantity behind the spec section's deep-fork claim, and the thing `p_ref` conflates with
"never picked up".
Pass / fail:
* D-hat/D holds at ~1.000 and deep orphans stay negligible as jitter rises -> the standalone
model was simply wrong; its numbers are artefacts and the report is robust to this failure mode.
* 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,
windowed_fork_choice=False, prune_arrival=False,
uncle_window_anchor="uncle") # SPEC AS DEPLOYED, not the §6.12 proposal
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:
print(f"=== jitter sweep, exact oracle, {len(JITTERS)*len(CAPS)*REPS*2} runs ===")
report(sweep())
print(f"\nwrote {RUNS}/spec_jitter.parquet")
if __name__ == "__main__":
main()