mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 19:53:10 +00:00
Acts on a correctness/completeness review of the countable uncle model and its report material. Correctness fixes in the report: - s3.4 quoted 0.998 for W_abs=10 at the 8s budget; the run says 0.9963. - s1 claimed both models >= 0.996 at U >= 1; countable U=2 delta=8 is 0.9955. Corrected to >= 0.995. - The s3.2 table presented two cells (U=1 at delta 16 and 32) as model differences. They are not resolvable: t = 0.46 and 0.47 over 5 replicates. The table now carries +-SEM and a t per cell. - s3.4 claimed the ~7-block-interval floor "carries over unchanged". Accuracy is still climbing past W=7 at every delay (8s: 0.989 -> 0.996), so the claim is dropped. The 32s curve is non-monotonic with replicate SD up to 0.22 and is now flagged as noise, not a trend. - 1-r was attributed to the first-fork restriction alone; it is the combined first-fork and capacity loss, which this measurement cannot separate. Hedged to match fig32's own axis label. Completeness: the U=0 negative control was swept but never reported. With no uncles the two models are identical by construction, yet they differ by -0.23 at delta_max=32 (t=2.1) because they draw independent RNG streams. That is the noise floor the rest of the grid must clear, and it is now in s3.2, s9, fig30 and the config header. New study (configs/fine-delay.yaml, scripts/plot_fine_delay.py, s3.2a, fig34/fig35): the design band delta_max 1-5 at 40 replicates, both models. Findings: every U >= 1 cell of both models lands in 0.998-1.001, flat in delay, while U=0 decays 0.810 -> 0.640. No individual cell resolves a model difference (widest 95% CI +-0.15pp; max t=2.59 vs Bonferroni 2.94 over 15 cells). Pooled across uncle caps the first-fork cost is monotone in delay and separates from zero only at delta_max=5 (-0.0014 +- 0.0007, t=3.7) -- below 0.15% everywhere in the band, against +-0.9% per-epoch sampling noise. Code: - deep_ref_share is identically 0 on every real countable run: for a chain block B the producer's chain below B is the counting chain below B, so the counting-side parent-on-chain re-check cannot reject what selection emitted. It is a drift alarm, not a rate. Documented as such in measure.py, the plot docstring and the config header, and pinned by a new end-to-end test. - Removed annotate_uncles: a second countable implementation that production never called, while carrying most of the selection test coverage. Tests now drive select_uncles_at_production through an annotate_via_production replay helper -- same assertions, live path. - Added tests for the two previously uncovered branches of the live selection: the pmin/below chain walk that resolves parent-on-chain for candidates whose parent sits below the window, and the occupied-slot exclusion built from the chain walk. - theory.q_effective and theory.window_miss_prob were unused and untested. Now used (the prediction figure reconstructs q_u through the identity the report quotes) and tested. The window_miss_prob test records that its "~ e^-W" docstring is the f->0 limit: the true decay is e^-1.017W at f=1/30, 16% off by W=10. - Shared sem()/recovery_rate() moved into figures_pernode.py; fig30 and fig33 regenerated with SEM error bars and the U=0 control curve. - Fixed the pre-existing E501 in bootstrap_dynamics.py; ruff clean. Report prose reworked to read standalone: the countable model is described as the rules under analysis and the former model as a labelled "unrestricted" comparison baseline, with no dated banners and no round-to-round narration. Tests: 209 passed (was 202). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
268 lines
12 KiB
Python
268 lines
12 KiB
Python
import numpy as np
|
|
|
|
from tsi_sim.blocktree import BlockTree
|
|
from tsi_sim.config import SimConfig
|
|
from tsi_sim.uncles import select_uncles_at_production
|
|
|
|
|
|
def make_tree(slots, parents, heights, leaders):
|
|
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.array(leaders, np.int64),
|
|
uncles=[() for _ in range(n)],
|
|
)
|
|
|
|
|
|
def annotate_via_production(tree, canonical_ids, config, rng):
|
|
"""Fill ``tree.uncles`` by replaying the PRODUCTION selection for each canonical block.
|
|
|
|
The simulator has exactly one selection implementation
|
|
(``select_uncles_at_production``, called from blocktree.py per produced block); this
|
|
replays it offline — oldest canonical block first, each block selecting as its producer
|
|
would have at ``t = slot[b]`` extending ``parent[b]`` — so the tests below exercise the
|
|
live code path rather than a parallel offline copy. Requires ``tree.slot`` ascending by
|
|
block id (true for every tree these tests build, and for real runs).
|
|
"""
|
|
arrival = np.zeros(tree.n_blocks) # every block already in the producer's view
|
|
for b in reversed(canonical_ids): # oldest first, so earlier refs are visible
|
|
tree.uncles[b] = select_uncles_at_production(
|
|
tree.slot, tree.parent, tree.uncles, arrival, nb=tree.n_blocks,
|
|
parent_id=int(tree.parent[b]), t=int(tree.slot[b]), config=config, rng=rng)
|
|
|
|
|
|
def _canonical_and_orphan_tree():
|
|
# genesis(0); canonical chain 1(slot0)->3(slot3)->4(slot5); orphan 2(slot1)
|
|
tree = make_tree(
|
|
slots=[-1, 0, 1, 3, 5],
|
|
parents=[-1, 0, 0, 1, 3],
|
|
heights=[0, 1, 1, 2, 3],
|
|
leaders=[-1, 0, 1, 2, 3],
|
|
)
|
|
canonical = [4, 3, 1] # tip-first
|
|
return tree, canonical
|
|
|
|
|
|
def test_oldest_selection_and_window():
|
|
tree, canonical = _canonical_and_orphan_tree()
|
|
cfg = SimConfig(max_uncles=1, uncle_window=300, uncle_strategy="oldest")
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
# orphan 2 (slot1) is within window of block 3 (slot3) -> referenced there
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
assert referenced == {2}
|
|
|
|
|
|
def test_no_uncles_when_u_zero():
|
|
tree, canonical = _canonical_and_orphan_tree()
|
|
cfg = SimConfig(max_uncles=0)
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
assert all(tree.uncles[b] == () for b in canonical)
|
|
|
|
|
|
def test_window_excludes_out_of_range_orphan():
|
|
# OLD model: uncle_window is read directly. (The countable model ignores uncle_window
|
|
# and derives the window from window_absorption — see the countable twin below.)
|
|
tree, canonical = _canonical_and_orphan_tree()
|
|
cfg = SimConfig(max_uncles=1, uncle_window=1, uncle_strategy="oldest", uncle_model="old")
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
# orphan 2 at slot1; nearest canonical after it is block3 at slot3 -> gap 2 > W=1
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
assert referenced == set()
|
|
|
|
|
|
def test_countable_window_is_derived_from_absorption():
|
|
# countable: w_u = round(W / f). With f=0.5 and W=1, w_u = 2 slots: the orphan at slot1
|
|
# is out of range of the canonical block at slot5 (gap 4) and of slot3 (gap 2 <= 2 OK).
|
|
tree, canonical = _canonical_and_orphan_tree()
|
|
cfg = SimConfig(max_uncles=1, f=0.5, window_absorption=1.0)
|
|
assert cfg.effective_uncle_window == 2
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
assert referenced == {2} # block3 (slot3) still reaches it
|
|
# shrink f so the derived window rounds to 1 slot: gap 2 > 1 -> excluded
|
|
tree2, canonical2 = _canonical_and_orphan_tree()
|
|
cfg2 = SimConfig(max_uncles=1, f=0.9, window_absorption=1.0)
|
|
assert cfg2.effective_uncle_window == 1
|
|
annotate_via_production(tree2, canonical2, cfg2, np.random.default_rng(0))
|
|
assert {u for b in canonical2 for u in tree2.uncles[b]} == set()
|
|
|
|
|
|
def _wide_orphan_tree():
|
|
# canonical 1(0)->6(6); orphans 2,3,4,5 at slots 1,2,3,4 (all within window of block6)
|
|
tree = make_tree(
|
|
slots=[-1, 0, 1, 2, 3, 4, 6],
|
|
parents=[-1, 0, 0, 0, 0, 0, 1],
|
|
heights=[0, 1, 1, 1, 1, 1, 2],
|
|
leaders=[-1, 0, 1, 2, 3, 4, 0],
|
|
)
|
|
return tree, [6, 1] # tip-first
|
|
|
|
|
|
def test_random_strategy_deterministic_and_capped():
|
|
import numpy as np
|
|
tree_a, canon = _wide_orphan_tree()
|
|
tree_b, _ = _wide_orphan_tree()
|
|
cfg = SimConfig(max_uncles=2, uncle_window=300, uncle_strategy="random", uncle_random_p=0.5)
|
|
annotate_via_production(tree_a, canon, cfg, np.random.default_rng(7))
|
|
annotate_via_production(tree_b, canon, cfg, np.random.default_rng(7))
|
|
assert tree_a.uncles == tree_b.uncles # same seed -> identical
|
|
total = sum(len(tree_a.uncles[b]) for b in canon)
|
|
assert total <= cfg.max_uncles # capped
|
|
|
|
|
|
def test_random_p_one_matches_oldest():
|
|
import numpy as np
|
|
tree_r, canon = _wide_orphan_tree()
|
|
tree_o, _ = _wide_orphan_tree()
|
|
annotate_via_production(tree_r, canon, SimConfig(max_uncles=2, uncle_strategy="random",
|
|
uncle_random_p=1.0),
|
|
np.random.default_rng(1))
|
|
annotate_via_production(tree_o, canon, SimConfig(max_uncles=2, uncle_strategy="oldest"),
|
|
np.random.default_rng(1))
|
|
assert tree_r.uncles == tree_o.uncles # p=1 deterministically takes oldest-first
|
|
|
|
|
|
def test_random_p_zero_selects_nothing():
|
|
import numpy as np
|
|
tree, canon = _wide_orphan_tree()
|
|
annotate_via_production(tree, canon, SimConfig(max_uncles=4, uncle_strategy="random",
|
|
uncle_random_p=0.0),
|
|
np.random.default_rng(1))
|
|
assert all(tree.uncles[b] == () for b in canon)
|
|
|
|
|
|
def test_dedup_across_ancestors():
|
|
# Two canonical blocks both within window of the single orphan: only one references it.
|
|
tree = make_tree(
|
|
slots=[-1, 0, 1, 2, 3],
|
|
parents=[-1, 0, 0, 1, 3],
|
|
heights=[0, 1, 1, 2, 3],
|
|
leaders=[-1, 0, 9, 2, 3],
|
|
)
|
|
canonical = [4, 3, 1]
|
|
cfg = SimConfig(max_uncles=4, uncle_window=300, uncle_strategy="oldest")
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
counts = sum(len(tree.uncles[b]) for b in canonical)
|
|
assert counts == 1 # orphan 2 referenced exactly once despite two eligible blocks
|
|
|
|
|
|
# --- countable model (spec counting rules) ---------------------------------------------
|
|
|
|
|
|
def _deep_fork_tree():
|
|
# canonical 1(slot0)->5(slot5); orphan branch 2(slot1,parent=1)->3(slot2,parent=2);
|
|
# orphan 4(slot3, parent=1). Blocks 2,4 are FIRST fork blocks; 3 is deep.
|
|
tree = make_tree(
|
|
slots=[-1, 0, 1, 2, 3, 5],
|
|
parents=[-1, 0, 1, 2, 1, 1],
|
|
heights=[0, 1, 2, 3, 2, 2],
|
|
leaders=[-1, 0, 1, 2, 3, 4],
|
|
)
|
|
return tree, [5, 1] # tip-first
|
|
|
|
|
|
def test_countable_excludes_deep_fork_blocks():
|
|
tree, canonical = _deep_fork_tree()
|
|
cfg = SimConfig(max_uncles=4)
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
assert referenced == {2, 4} # deep block 3 (parent is an orphan) excluded
|
|
|
|
|
|
def test_old_model_still_references_deep_fork_blocks():
|
|
tree, canonical = _deep_fork_tree()
|
|
cfg = SimConfig(max_uncles=4, uncle_model="old", uncle_window=300)
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
assert referenced == {2, 3, 4} # --old: fork depth ignored
|
|
|
|
|
|
def test_countable_excludes_occupied_slots_and_dedups_per_slot():
|
|
# canonical 1(slot0)->5(slot4); orphans: 2 at slot0 (canonical-occupied), 3/4 at slot2.
|
|
tree = make_tree(
|
|
slots=[-1, 0, 0, 2, 2, 4],
|
|
parents=[-1, 0, 0, 1, 1, 1],
|
|
heights=[0, 1, 1, 2, 2, 2],
|
|
leaders=[-1, 0, 1, 2, 3, 4],
|
|
)
|
|
canonical = [5, 1]
|
|
cfg = SimConfig(max_uncles=4)
|
|
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
|
|
referenced = {u for b in canonical for u in tree.uncles[b]}
|
|
# slot0 is canonical-occupied -> orphan 2 excluded; slot2 pair -> exactly one picked
|
|
assert referenced == {3}
|
|
|
|
|
|
def test_production_selection_countable_rules():
|
|
from tsi_sim.uncles import select_uncles_at_production
|
|
|
|
# 0 genesis; 1 canonical slot0; 2 first-fork slot1 (parent 1); 3 deep slot2 (parent 2);
|
|
# 4/5 same-slot first-forks at slot3 (parent 1).
|
|
slot = np.array([-1, 0, 1, 2, 3, 3], np.int64)
|
|
parent = np.array([-1, 0, 1, 2, 1, 1], np.int64)
|
|
uncles: list = [() for _ in range(6)]
|
|
arrival = np.zeros(6) # everything arrived immediately
|
|
cfg = SimConfig(max_uncles=4)
|
|
sel = select_uncles_at_production(
|
|
slot, parent, uncles, arrival, nb=6, parent_id=1, t=5, config=cfg,
|
|
rng=np.random.default_rng(0))
|
|
assert sel == (2, 4) # deep 3 excluded; one per slot at slot3
|
|
|
|
old = SimConfig(max_uncles=4, uncle_model="old", uncle_window=300)
|
|
sel_old = select_uncles_at_production(
|
|
slot, parent, uncles, arrival, nb=6, parent_id=1, t=5, config=old,
|
|
rng=np.random.default_rng(0))
|
|
assert sel_old == (2, 3, 4, 5) # --old: depth and slot-dedup ignored
|
|
|
|
|
|
def test_production_parent_below_window_resolves_chain_membership():
|
|
"""Candidate parents can sit BELOW the reference window (uncles.py `pmin`/`below` walk).
|
|
|
|
The window walk only establishes chain membership for ancestors with slot >= t-w, so a
|
|
candidate whose parent is older than that needs the extra walk down to `pmin`. With a
|
|
narrow window this is the branch that decides first-fork eligibility.
|
|
|
|
chain 1(s0) -> 2(s1) -> 6(s8); off-chain 7(s2, parent 3) where 3(s2) is NOT on the chain.
|
|
Candidates at t=9 with w=4 are the blocks in slots [5,9): 4(s5, parent 2 -> ON chain,
|
|
countable) and 5(s6, parent 3 -> OFF chain, deep). Both parents sit below the window,
|
|
so only the `below` walk can tell them apart.
|
|
"""
|
|
from tsi_sim.uncles import select_uncles_at_production
|
|
|
|
# id: 0 1 2 3 4 5 6
|
|
slot = np.array([-1, 0, 1, 2, 5, 6, 8], np.int64)
|
|
parent = np.array([-1, 0, 1, 1, 2, 3, 2], np.int64)
|
|
uncles: list = [() for _ in range(7)]
|
|
arrival = np.zeros(7)
|
|
# w = round(1/0.25) = 4 slots; window at t=9 is [5, 9)
|
|
cfg = SimConfig(max_uncles=4, f=0.25, window_absorption=1.0)
|
|
assert cfg.effective_uncle_window == 4
|
|
sel = select_uncles_at_production(
|
|
slot, parent, uncles, arrival, nb=7, parent_id=6, t=9, config=cfg,
|
|
rng=np.random.default_rng(0))
|
|
assert sel == (4,) # 5 rejected: parent 3 is off-chain (deep) and below the window
|
|
|
|
|
|
def test_production_excludes_slots_occupied_by_chain_and_prior_uncles():
|
|
"""The occupied-slot exclusion is built from the producer's chain walk (uncles.py).
|
|
|
|
chain 1(s0) -> 2(s1) -> 5(s4); block 2 already references uncle 3(s2). Candidates at
|
|
t=5 are 3 (already referenced), 4(s2, same slot as the referenced 3 -> slot occupied)
|
|
and 6(s3, free slot). Only 6 survives.
|
|
"""
|
|
from tsi_sim.uncles import select_uncles_at_production
|
|
|
|
# id: 0 1 2 3 4 5 6
|
|
slot = np.array([-1, 0, 1, 2, 2, 4, 3], np.int64)
|
|
parent = np.array([-1, 0, 1, 1, 1, 2, 1], np.int64)
|
|
uncles: list = [() for _ in range(7)]
|
|
uncles[2] = (3,) # block 2 already referenced uncle 3
|
|
arrival = np.zeros(7)
|
|
cfg = SimConfig(max_uncles=4) # w = 300, whole tree in window
|
|
sel = select_uncles_at_production(
|
|
slot, parent, uncles, arrival, nb=7, parent_id=5, t=5, config=cfg,
|
|
rng=np.random.default_rng(0))
|
|
assert sel == (6,) # 3 already referenced; 4 shares slot 2 with it; 6 is free
|