57 lines
2.2 KiB
Python
Raw Normal View History

2026-07-30 18:57:10 +02:00
"""Fork rate and reorg depth from the global tree."""
from __future__ import annotations
import numpy as np
import pandas as pd
from tsi_sim.blocktree import BlockTree
from tsi_sim.config import SimConfig
from tsi_sim.engine import run_trajectory
from tsi_sim.fork import fork_stats
def make_tree(slots, parents, heights):
n = len(slots)
return BlockTree(
slot=np.array(slots, np.int64), parent=np.array(parents, np.int64),
height=np.array(heights, np.int64), leader=np.zeros(n, np.int64),
uncles=[() for _ in range(n)],
)
def test_no_forks():
# a straight chain 1->2->3, no orphans
tree = make_tree([-1, 0, 1, 2], [-1, 0, 1, 2], [0, 1, 2, 3])
Private-chain (SM1) adversary in the per-node engine Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is why every selfish result came from the global race model with uncle recovery as a free knob eta -- and why open item 5 (does the uncle cap need margin under attack-inflated orphaning?) could not be sized: a knob has no queue to overflow. adversary_strategy="selfish" adds it. The coalition mines one shared private chain and releases under the classic SM1 rules in (a, h) form: adopt when the public chain wins, match at equal length, override at a one-block lead, else wait. Only VISIBILITY is modelled -- the coalition's mining needs no special case, because a member's fork choice already builds on the private tip whenever it leads (that tip has the greatest height among blocks the member can see) and falls back to the public chain exactly when the public chain overtakes, which is the adopt branch. So the private chain forms, extends and is abandoned emergently, and the code that had to be written is the arrival matrix. Design notes worth keeping: - Private blocks reuse the sentinel `withhold` already had (never-arrives), so the existing exclusions from canonical-tip selection apply unchanged; release flips it back and gossips DIRECTLY from the producer, bypassing Blend, since an adversary has no privacy budget to respect and wants the race won. - A private chain breaks the windowed horizon's premise (a hidden block is old enough to look fully-propagated while no honest node has it, and it becomes visible LATER, which the one-way frontier pointer cannot revisit), so selfish forces the exact full scan and full matrix. - Blocks still hidden at epoch end are abandoned and hidden from the coalition too, or the canonical-tip search would crown a chain no honest node saw. Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484 at alpha = 0.4, with fork rate 0.38). Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the coalition. Under a private-chain attack this diverges sharply from p_ref, and only the honest one measures the repair the report credits to uncle counting -- an attacker's own discarded blocks are its loss to bear. test_fork unpacks fork_stats positionally, so its three call sites take the new fifth value. 247 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
2026-07-30 18:57:10 +02:00
assert fr == 0.0 and mx == 0 and mn == 0.0
def test_single_orphan_depth_one():
# canonical 1(s0),2(s1),4(s3); orphan 3(s2) hangs off block1 -> branch depth 1
tree = make_tree([-1, 0, 1, 2, 3], [-1, 0, 1, 1, 2], [0, 1, 2, 2, 3])
Private-chain (SM1) adversary in the per-node engine Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is why every selfish result came from the global race model with uncle recovery as a free knob eta -- and why open item 5 (does the uncle cap need margin under attack-inflated orphaning?) could not be sized: a knob has no queue to overflow. adversary_strategy="selfish" adds it. The coalition mines one shared private chain and releases under the classic SM1 rules in (a, h) form: adopt when the public chain wins, match at equal length, override at a one-block lead, else wait. Only VISIBILITY is modelled -- the coalition's mining needs no special case, because a member's fork choice already builds on the private tip whenever it leads (that tip has the greatest height among blocks the member can see) and falls back to the public chain exactly when the public chain overtakes, which is the adopt branch. So the private chain forms, extends and is abandoned emergently, and the code that had to be written is the arrival matrix. Design notes worth keeping: - Private blocks reuse the sentinel `withhold` already had (never-arrives), so the existing exclusions from canonical-tip selection apply unchanged; release flips it back and gossips DIRECTLY from the producer, bypassing Blend, since an adversary has no privacy budget to respect and wants the race won. - A private chain breaks the windowed horizon's premise (a hidden block is old enough to look fully-propagated while no honest node has it, and it becomes visible LATER, which the one-way frontier pointer cannot revisit), so selfish forces the exact full scan and full matrix. - Blocks still hidden at epoch end are abandoned and hidden from the coalition too, or the canonical-tip search would crown a chain no honest node saw. Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484 at alpha = 0.4, with fork rate 0.38). Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the coalition. Under a private-chain attack this diverges sharply from p_ref, and only the honest one measures the repair the report credits to uncle counting -- an attacker's own discarded blocks are its loss to bear. test_fork unpacks fork_stats positionally, so its three call sites take the new fifth value. 247 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
2026-07-30 18:57:10 +02:00
assert mx == 1
assert abs(fr - 1 / 4) < 1e-9 # 1 orphan of 4 in-window blocks
def test_deep_orphan_branch():
# canonical spine 1..3 (heights 1,2,3); a 2-deep orphan branch 4->5 off block1
# blocks: 0 gen; 1(s0,h1),2(s1,h2),3(s2,h3) canonical; 4(s1,h2)->1, 5(s2,h3)->4 orphan
tree = make_tree([-1, 0, 1, 2, 1, 2], [-1, 0, 1, 2, 1, 4], [0, 1, 2, 3, 2, 3])
Private-chain (SM1) adversary in the per-node engine Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is why every selfish result came from the global race model with uncle recovery as a free knob eta -- and why open item 5 (does the uncle cap need margin under attack-inflated orphaning?) could not be sized: a knob has no queue to overflow. adversary_strategy="selfish" adds it. The coalition mines one shared private chain and releases under the classic SM1 rules in (a, h) form: adopt when the public chain wins, match at equal length, override at a one-block lead, else wait. Only VISIBILITY is modelled -- the coalition's mining needs no special case, because a member's fork choice already builds on the private tip whenever it leads (that tip has the greatest height among blocks the member can see) and falls back to the public chain exactly when the public chain overtakes, which is the adopt branch. So the private chain forms, extends and is abandoned emergently, and the code that had to be written is the arrival matrix. Design notes worth keeping: - Private blocks reuse the sentinel `withhold` already had (never-arrives), so the existing exclusions from canonical-tip selection apply unchanged; release flips it back and gossips DIRECTLY from the producer, bypassing Blend, since an adversary has no privacy budget to respect and wants the race won. - A private chain breaks the windowed horizon's premise (a hidden block is old enough to look fully-propagated while no honest node has it, and it becomes visible LATER, which the one-way frontier pointer cannot revisit), so selfish forces the exact full scan and full matrix. - Blocks still hidden at epoch end are abandoned and hidden from the coalition too, or the canonical-tip search would crown a chain no honest node saw. Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484 at alpha = 0.4, with fork rate 0.38). Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the coalition. Under a private-chain attack this diverges sharply from p_ref, and only the honest one measures the repair the report credits to uncle counting -- an attacker's own discarded blocks are its loss to bear. test_fork unpacks fork_stats positionally, so its three call sites take the new fifth value. 247 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
2026-07-30 18:57:10 +02:00
assert mx == 2 # branch 4->5 is 2 deep
assert abs(fr - 2 / 5) < 1e-9 # 2 orphans of 5
def test_engine_reports_fork_columns():
cfg = SimConfig(n_nodes=300, stake_dist="pareto", topology="blend", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
blend_delay_max=16.0, max_uncles=1, uncle_window=300, k=256, epochs=8)
df = pd.DataFrame(run_trajectory(cfg))
for col in ("fork_rate", "max_reorg_depth", "mean_reorg_depth"):
assert col in df.columns
# heavy delay -> real forks
assert df[df.epoch >= 4].fork_rate.mean() > 0.0
assert df.max_reorg_depth.max() >= 1