Marcin Pawlowski c202ad9c17
Add deep_orphan_share: the structural observable behind the first-fork cost
E5 needs to watch whether per-recipient delay variance manufactures the
depth->=2 forks the countable rule cannot reach, and no recorded metric measured
that. p_ref conflates "unreachable by construction" with "eligible but never
picked up" -- the distinction that turned out to be the whole answer to item 5 --
and deep_ref_share is 0 by construction under the countable model, since the
proposer's candidate filter drops deep-fork blocks before any reference to one
is proposed. deep_orphan_share is the fraction of in-window orphans sitting
below the first block of their fork, computed from the depth array fork_stats
already builds.

Also fixes a splat-unpack in test_selfish_engine that silently re-bound to the
wrong quantities when fork_stats grew this field (it read deep_orphan_share as
p_ref_honest). fork_stats has now gained a field twice; both call sites unpack
by position explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:59:37 +02:00

107 lines
4.9 KiB
Python

"""E5 — does per-recipient delay variance reproduce the standalone result? (the diagnostic).
The only experiment in the fork-loss handoff that could invalidate the REPORT rather than the
spec section. 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 claim C2, and the thing `p_ref` conflates with "never picked up".
Pass / fail, as the handoff sets it:
* D-hat/D holds at ~1.000 and deep orphans stay negligible as jitter rises -> the standalone
model was simply wrong; C1/C2 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)
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"=== E5: jitter sweep, exact oracle, {len(JITTERS)*len(CAPS)*REPS*2} runs ===")
report(sweep())
print(f"\nwrote {RUNS}/spec_jitter.parquet")
if __name__ == "__main__":
main()