research/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_counting.py

110 lines
4.6 KiB
Python
Raw Normal View History

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
"""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
Review fixes + high-precision design-band delay study 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>
2026-08-04 20:48:38 +02:00
def test_deep_ref_share_is_zero_end_to_end():
"""The counting-side parent-on-chain re-check must never fire on a real countable run.
Countable SELECTION already refuses to reference a non-first-fork block, and for a
block ``b`` on the counting chain the producer's chain below ``b`` IS the counting
chain below ``b`` (they are the same ancestor path). So ``ref_deep`` is a defensive
invariant, not a measured rate: any non-zero value means selection and counting have
drifted apart. The hand-built trees above are the only way to make it fire they bake
references selection would never emit.
"""
import pandas as pd
from tsi_sim.config import SimConfig
from tsi_sim.engine import run_trajectory
# Small but fork-rich: Blend delay spreads proposals over many slots.
cfg = SimConfig(n_nodes=60, topology="blend", blend_hops=2, blend_delay_max=8.0,
degree=4, max_uncles=2, k=32, epochs=3, f=0.1,
stake_dist="pareto", init_dest="common")
df = pd.DataFrame(run_trajectory(cfg))
assert df.n_blocks.sum() > 0 # the run actually produced blocks
assert (df.deep_ref_share == 0.0).all(), (
f"counting rejected references selection emitted: {df.deep_ref_share.tolist()}")