research/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_counting.py
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

85 lines
3.4 KiB
Python

"""Countable-model counting: measurement kernels vs the tsi.py reference oracle.
Hand-built tree exercising every counting rule on baked references:
canonical 1(s0) -> 2(s2) -> 3(s5) -> 4(s10, tip); orphans 5(s1, parent 1, first fork),
6(s6, parent 5, DEEP), 7(s7, parent 1, first fork), 8(s7, parent 2, first fork, same slot
as 7). References: block2 -> (5,), block3 -> (1,) [a canonical block], block4 -> (6, 7, 8).
With w = 5 and T = 20 the countable verdicts are: 5 counted (d=1); 1 skipped (on chain);
6 deep-rejected (parent is an orphan); 7 counted (d=3); 8 counted by id but its slot is
already recovered by 7 (slot dedup). Old model counts every reference.
"""
from __future__ import annotations
import numpy as np
import pytest
from tsi_sim.blocktree import BlockTree
from tsi_sim.measure import measure
from tsi_sim.tsi import countable_refs, density_m
W = 5
T = 20
def _tree() -> BlockTree:
tree = BlockTree(
slot=np.array([-1, 0, 2, 5, 10, 1, 6, 7, 7], np.int64),
parent=np.array([-1, 0, 1, 2, 3, 1, 5, 1, 2], np.int64),
height=np.array([0, 1, 2, 3, 4, 2, 3, 2, 3], np.int64),
leader=np.array([-1, 0, 1, 2, 3, 4, 5, 6, 7], np.int64),
uncles=[() for _ in range(9)],
)
tree.uncles[2] = (5,)
tree.uncles[3] = (1,)
tree.uncles[4] = (6, 7, 8)
return tree
CANONICAL = [4, 3, 2, 1]
ACTIVE = np.array([0, 1, 2, 5, 6, 7, 10], np.int64) # 7 distinct active slots in T
def test_countable_refs_oracle():
assert countable_refs(_tree(), CANONICAL, W) == {5, 7, 8}
def test_density_m_countable_and_old():
tree = _tree()
# countable: honest slots {0,2,5,10} + recovered slots {1, 7} -> 6
assert density_m(tree, CANONICAL, T, countable=True, w=W) == 6
# old model: every reference counts -> recovered slots {1, 6, 7} -> 7
assert density_m(tree, CANONICAL, T) == 7
@pytest.mark.parametrize("use_numba", [False, True])
def test_measure_countable_matches_oracle(use_numba):
tree = _tree()
A = np.zeros((2, tree.n_blocks)) # both nodes received everything
ms = measure(tree, A, ACTIVE, T, cutoff=15, use_numba=use_numba,
countable=True, w=W)
np.testing.assert_array_equal(ms.m, [6, 6]) # = density_m countable
np.testing.assert_allclose(ms.q, 4 / 7) # canonical slots / active
np.testing.assert_allclose(ms.q_eff, 6 / 7) # + recovered slots
np.testing.assert_array_equal(ms.ref_total, [5, 5]) # ids 5,1,6,7,8 examined
np.testing.assert_array_equal(ms.ref_deep, [1, 1]) # id 6 rejected as deep
@pytest.mark.parametrize("use_numba", [False, True])
def test_measure_old_counts_all_refs(use_numba):
tree = _tree()
A = np.zeros((2, tree.n_blocks))
ms = measure(tree, A, ACTIVE, T, cutoff=15, use_numba=use_numba)
np.testing.assert_array_equal(ms.m, [7, 7]) # = density_m old
np.testing.assert_allclose(ms.q_eff, 7 / 7)
np.testing.assert_array_equal(ms.ref_deep, [0, 0]) # no rule to reject on
def test_window_recheck_rejects_stale_reference():
# A baked reference outside the counting window is uncounted under countable
# (the old model still counts it): shrink w below block4 -> uncle 7 distance (d=3).
tree = _tree()
assert countable_refs(tree, CANONICAL, 2) == {5} # 7, 8 now out of window (d=3)
assert density_m(tree, CANONICAL, T, countable=True, w=2) == 5