Marcin Pawlowski bd2ac7b7be
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.

Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
  referenceable and countable, which makes every reference verifiable
  from chain data alone.
- The reference window is derived from a window-absorption parameter,
  w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
  bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
  takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
  rejections as deep_ref_share.

The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.

Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.

Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
  models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
  reports/tsi/report-figures/.

Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.

Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.

The reports/tsi/ prose describing this model is held back for a
separate editorial pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00

99 lines
4.0 KiB
Python

"""Corrected slot-based density counting: one count per slot, never more."""
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.theory import block_count_ceiling
from tsi_sim.tsi import density_m
def make_tree(slots, parents, heights, uncles):
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=uncles,
)
def test_same_slot_co_winner_uncle_not_counted():
"""An uncle sharing a canonical block's slot must not add a count (slot already won)."""
# canonical 1(slot0), 3(slot2); orphan 2 ALSO at slot0 (co-winner), referenced by 3.
tree = make_tree(
slots=[-1, 0, 0, 2],
parents=[-1, 0, 0, 1],
heights=[0, 1, 1, 2],
uncles=[(), (), (), (2,)],
)
canonical = [3, 1]
assert density_m(tree, canonical, T=10) == 2 # slots {0, 2} — uncle adds nothing
assert density_m(tree, canonical, T=10, legacy_block_count=True) == 3 # the old bug
def test_multiple_uncles_same_slot_count_once():
"""Two referenced orphans in the same (non-canonical) slot count as one recovered slot."""
# canonical 1(slot0), 4(slot3); orphans 2 and 3 BOTH at slot1, both referenced.
tree = make_tree(
slots=[-1, 0, 1, 1, 3],
parents=[-1, 0, 0, 0, 1],
heights=[0, 1, 1, 1, 2],
uncles=[(), (), (), (), (2, 3)],
)
canonical = [4, 1]
assert density_m(tree, canonical, T=10) == 3 # slots {0, 1, 3}
assert density_m(tree, canonical, T=10, legacy_block_count=True) == 4 # the old bug
def test_distinct_slot_uncles_still_counted():
"""The fix must not lose genuinely distinct recovered slots."""
tree = make_tree(
slots=[-1, 0, 1, 2, 3],
parents=[-1, 0, 0, 0, 1],
heights=[0, 1, 1, 1, 2],
uncles=[(), (), (), (), (2, 3)],
)
canonical = [4, 1]
assert density_m(tree, canonical, T=10) == 4 # slots {0, 1, 2, 3}
def test_zero_delay_equilibrium_is_one_not_ceiling():
"""The c(f) ceiling was the bug: corrected counting equilibrates at 1.0 with uncles.
Holds under the (default) countable model too: at zero delay the only orphans are
same-slot co-winners, which countable selection never references (occupied slot) and
which add nothing to the slot count anyway. 5 replicates / 0.02 tolerance because the
countable model's key() draws a different RNG stream than the historical runs the old
3-rep/0.015 margin was tuned on.
"""
base = dict(n_nodes=300, stake_dist="uniform", topology="full_mesh", latency=0,
max_uncles=2, uncle_window=300, k=64, epochs=24, genesis_d_factor=1.0)
tails = []
for rep in range(5):
df = pd.DataFrame(run_trajectory(SimConfig(**base, replicate=rep)))
tails.append(df[df.epoch >= 8].mean_ratio.mean())
assert abs(np.mean(tails) - 1.0) < 0.02
def test_legacy_flag_reproduces_the_ceiling():
# OLD model on purpose: the c(f) ceiling arises from referencing same-slot co-winners
# and counting them per block id. The countable model never references a same-slot
# co-winner (its slot is already occupied on the chain), so under it the legacy flag
# has nothing to double-count and this historical bug cannot be reproduced.
base = dict(n_nodes=300, stake_dist="uniform", topology="full_mesh", latency=0,
max_uncles=2, uncle_window=300, k=64, epochs=24, genesis_d_factor=1.0,
uncle_model="old")
tails = []
for rep in range(3):
df = pd.DataFrame(run_trajectory(
SimConfig(**base, legacy_block_count=True, replicate=rep)))
tails.append(df[df.epoch >= 8].mean_ratio.mean())
c = block_count_ceiling(SimConfig().f)
assert abs(np.mean(tails) - c) < 0.015