Anchor the uncle reference window to the parent, not the uncle

The spec bounds an uncle's own slot (0 < sl_A - sl_U <= w_u) but leaves its
PARENT unconstrained beyond lying on the referencing chain. So a block minted
NOW, built on a chain block from arbitrarily far back, is a legal first-fork
uncle: recent by its own slot, ancient by its parent's. Verifying it means
deriving the epoch state and ledger root as of that ancient parent, per
reference, and those are precisely the inputs the counting rules require -- so
the work cannot be amortised. It costs the adversary nothing beyond lottery
wins it already has; it just builds them somewhere useless.

Measured with a deep_parent coalition. At the deployed operating point a 30%
adversary moves the MEDIAN counted reference's reach from 54 slots back to
20,144, and the worst case to 76,778 -- the epoch boundary, ~21 hours of
history, ~256x the nominal window. It is not a tail effect.

The fix is a SUBSTITUTION, not an additional rule. A block strictly postdates
its parent and a referenced uncle strictly precedes its referencer, so
sl_A - sl_U < sl_A - sl_parent(U) <= w_u: bounding the parent bounds the uncle
for free, and a both-windows variant would be identical to the parent one. Both
invariants are pinned in a new test_slot_ordering.py rather than argued -- the
user asked to confirm sl_A > sl_U explicitly, and it turns out to be
load-bearing for the whole implication, so it is tested at three geometries
plus a hand-built counting case.

Under the parent anchor the same coalition reaches 292/300/300 slots at
delta_max 4/8/16 -- capped by construction. Honest recovery is unaffected:
0.9993 -> 0.9999, 0.9969 -> 0.9986, 0.9791 -> 0.9858, no loss anywhere within
one to two SEM, because a latency orphan's parent is recent by construction.

One finding that sharpens the case: at delta_max = 16 the HONEST uncle-anchored
arm already reaches 315 slots, past its own w_u = 300. Under the current rule
w_u is not a bound on validation reach even with no adversary present. It only
becomes a state-retention bound once anchored to the parent.

Recorded as sec 6.12 with fig38, a new row in the sec 8.5 spec deltas, both new
knobs in sec 7, and the study in sec 9. uncle_window_anchor and the deep_parent
strategy are appended to the RNG key only when non-default, so no committed run
is reseeded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-07 12:22:50 +02:00
parent abf2c86285
commit f1433cbbb3
No known key found for this signature in database
11 changed files with 404 additions and 16 deletions

View File

@ -490,6 +490,7 @@ Read this before using the numbers: the regime they hold in, and the margins tha
- **[§6.8](#s6-8)** a *soft* (reward-weighted) inclusion rule — the fork-safe choice (a validity rule cannot prove which forks a producer saw). The emergent reference rate stays high (honest blocks reference the published orphans), so the backfire mostly vanishes; the residual is set by the uncle window `W` (how far back a block may reach to reference an orphan; recommended 300 slots = 10/f, [§3.4](#s3-4)) and visibility.
- **[§6.9](#s6-9)** multiple coalitions and bribery — the abstention commons is bounded and structure-independent; multi-coalition *selfish* mining is flagged, not solved.
- **[§6.10](#s6-10)** fork rate and reorg depth — uncles and `ρ < 1` keep reorganisations shallow and keep even a 30 % coalition below the effective-majority cliff.
- **[§6.12](#s6-12)** the reference window bounds the uncle but not the historical state a validator must reach — a 30 % coalition drives it to the epoch boundary, and anchoring the window to the uncle's *parent* caps it at no honest cost.
- **[§6.11](#s6-11)** organic stake churn against the ~7.5-day epoch cadence — tracked with a one-epoch lag; a bounded rate wobble, never a consensus or accuracy failure.
The recurring theme: the same uncle references that fix the honest under-count ([§3.2](#s3-2)) are also the adversarial safeguard — and *rewarding* them (softly) makes the safeguard incentive-compatible without a fork risk.
@ -774,6 +775,38 @@ The 30 %-stake depth is the sensitive one: the recommended **`degree ≥ 6`** ho
---
<a id="s6-12"></a>
### 6.12 The reference window bounds the uncle, but not the state a validator must reach (`fig38`)
Every rule so far has been about *which* blocks count. This one is about what checking them costs, and it is the only place in this report where a parameter the spec already has fails to bound the thing it appears to bound.
**The gap.** The window is measured to the uncle's own slot — `0 < sl_A sl_U ≤ w_u` ([§2.1](#s2-1)). The uncle's **parent** is unconstrained apart from lying on the referencing chain. So a block minted *now*, built on a chain block from arbitrarily far back, is a perfectly legal first-fork uncle: recent by its own slot, ancient by its parent's. Verifying it means deriving the epoch state and ledger root as of that ancient parent — and those are exactly the inputs the counting rules require ([§2.1](#s2-1)), so the work is per reference and cannot be amortised.
Nothing about this is expensive for the attacker. It spends lottery wins it already has; it simply builds them somewhere useless instead of on the tip. The blocks never become canonical — they are deep forks by construction — but they do not need to. They only need to be *referenced*, and under the current rule they are eligible the moment they are produced.
**Measured** (`scripts/uncle_parent_window.py`, N = 600, k = 256, 10 replicates, `deep_parent` coalition). The observable is the age of the oldest chain state a *counted* reference reaches:
| `δ_max` | adversary | oldest state reached: median / p99 / max (slots) |
|---|---|---|
| 4 | none | 54 / 131 / 233 |
| 4 | **30 %** | **20 144 / 75 525 / 76 778** |
| 8 | 30 % | 6 390 / 75 272 / 76 789 |
| 16 | 30 % | 872 / 75 109 / 76 798 |
A 30 % coalition moves the *median* referenced parent from 54 slots back to 20 144, and the worst case to the epoch boundary — about 21 hours of history, ~256× the nominal window, on a reference the estimator counts. And the amplification is not a tail: at the deployment's own operating point the median reference reaches ten thousand slots back.
**The fix is a substitution, not an addition.** Anchor the window to the parent: `sl_A sl_parent(U) ≤ w_u`. Because a block strictly postdates its parent and a referenced uncle strictly precedes its referencer — both invariants pinned in `tests/test_slot_ordering.py` — the parent gap is never smaller than the uncle gap:
> `sl_A sl_U < sl_A sl_parent(U) ≤ w_u`
so bounding the parent bounds the uncle *for free*. A rule imposing both windows would be identical to the parent rule alone, which is why only two arms are simulated. Under it the same 30 % coalition reaches 292 / 300 / 300 slots at the three delays — capped at `w_u` by construction, a bound that now actually holds.
**It costs no honest recovery.** A latency orphan's parent is recent by construction: it forked off the chain a block or two ago. Measured honestly, `D̂/D` is **0.9993 → 0.9999** at `δ_max` = 4, **0.9969 → 0.9986** at 8 and **0.9791 → 0.9858** at 16 (uncle- → parent-anchored) — no loss anywhere, within one to two standard errors at every delay. The honest parent gap does run about one block-interval longer than the uncle gap (median 54 vs ~30), so the same numeric `w_u` is a slightly tighter window; at `W = 10` block-intervals that costs nothing measurable, but a deployment choosing `W` close to the [§3.4](#s3-4) floor of 7 should size against the parent gap rather than the uncle gap.
**One further observation, and it sharpens the case.** At `δ_max` = 16 the *honest* uncle-anchored arm already reaches **315 slots** — past its own `w_u` = 300. Under the current rule `w_u` is not a bound on how far back validation reaches even with no adversary present; under the parent rule the same cell is 296. The parameter only becomes a state-retention bound once it is anchored to the parent.
![Fig 38 — anchoring the reference window to the uncle's parent rather than the uncle: honest recovery is unchanged across the delay range (left), while a 30 % adversary's reach into historical chain state falls from the epoch boundary to the window itself (right, log scale).](report-figures/fig38_uncle_parent_window.png)
<a id="s7"></a>
## 7. Parameter reference — what each knob does
@ -814,6 +847,8 @@ Every parameter a protocol designer or operator can set, in three groups: **prot
| knob | probes | result | where |
|---|---|---|---|
| `adversary_strategy: selfish` | private-chain (SM1) override in the per-node engine | the profitable lever, now measurable end-to-end: revenue tracks EyalSirer at sub-slot latency (0.0356 vs 0.0356 at α = 0.1) and exceeds it as natural forking lifts `α_eff`; deflates `D̂` to 0.76 at α = 0.3 (δ = 8) with **no** cap able to recover it ([§6.8](#s6-8)) | [§6.6](#s6-6), [§6.8](#s6-8) |
| `uncle_window_anchor` | whether the reference window is measured to the uncle's own slot (`uncle`, the spec) or to its parent's (`parent`, proposed) | `parent` is strictly tighter and implies the uncle bound; it caps an adversary's reach into historical state at `w_u` (against ~76 800 slots under `uncle`) at no measurable honest cost | [§6.12](#s6-12), fig38 |
| `adversary_strategy: deep_parent` | mints legal first-fork uncles hanging off ancient chain blocks | the attack §6.12 measures: costs the adversary only lottery wins it already has, and inflates the median counted reference's reach from 54 slots to 20 144 | [§6.12](#s6-12) |
| `deep_orphan_share` (engine output) | share of in-window orphans sitting *below* the first block of their fork | the structural observable behind the first-fork cost, and the one `p_ref` conflates with "eligible but never picked up": these blocks are unreferenceable by construction ([§2.1](#s2-1)). 0.25 % honestly at the deployment's point, 3.3 % at 8 slots of per-recipient jitter ([§6.1](#s6-1)), 1941 % under a private-chain attack ([§6.8](#s6-8)) | [§6.1](#s6-1), [§6.8](#s6-8) |
| `p_ref_honest` (engine output) | reference rate over orphans produced *outside* the coalition | equals `p_ref` under suppression; diverges sharply under a private chain (0.450.69 vs ≈ 0.99), which is the quantity the estimator repair actually depends on | [§6.8](#s6-8) |
| `adversary_frac` (β_adv), `adversary_strategy` | uncle suppression / abstention withholding | suppression weak at ρ < 1, a cheap lever only at ρ > 1 (1.43× at ρ = 1.36, β_adv = 0.5); withholding → `D̂ ≈ (1β_adv)` — the *correct* active-stake answer, topology-independent | [§6.3](#s6-3)/6.4, figs 8, 9 |
@ -914,6 +949,7 @@ The deltas this report recommends, in one place. This is a parameter-selection r
| on-chain `f` precision | 10⁻³ (`f_p = 0.033`) | **10⁻⁶** (`0.033333`) | removes the residual ≈ 1 % `f/f_p` offset ([§2.2](#s2-2)) |
| uncle window `W` | `W` = 10 block-intervals, so `w_u = W/f` = 300 slots, bounded `1 ≤ W ≤ ⌊0.6k⌋` | **10/f = 300 slots** (confirms the default), widen to 450600 near `ρ ≈ 1` | the floor is ≥ 7/f, set by block *spacing* rather than network delay — which supplies what the spec's rationale leaves out. The spec justifies the *upper* bound (a candidate within `w_u` is still in the proposer's block tree, and referencing blocks stay in the same epoch as the window they count) and asserts of the value itself only that this window "comfortably captures the forks worth referencing"; it states no lower bound, and 300 slots is only a ~1.5× margin over the measured one. The `⌊0.6k⌋` ceiling is 1296 slots at k = 2160, so the 450600 widening recommended near `ρ ≈ 1` — where W = 600 recovers ≈ 0.99 against ≈ 0.96 at W = 300 (N = 1 000) — is well inside what the spec already permits, and needs no spec change ([§3.4](#s3-4)) |
| uncle cap `U` | `MAX_UNCLES` = 4 | **`⌈ρ⌉ + 1`** = 2 at the Blend target — well inside the spec's cap of 4 | the load-plus-margin rule ([§3.3](#s3-3)); 4 leaves headroom for heavier loads |
| uncle reference window anchor | the **uncle's** slot: `0 < sl_A sl_U ≤ w_u` | **the uncle's PARENT's slot**: `sl_A sl_parent(U) ≤ w_u` | the current rule bounds which block may be referenced but not how far back verifying it reaches, so a 30 % coalition forces counted references against state ~76 800 slots old (~21 h, 256× the window). The parent anchor is strictly tighter — it implies the uncle bound rather than adding to it — caps the reach at `w_u`, and costs no honest recovery at any tested delay ([§6.12](#s6-12)) |
| peering degree | (operator choice) | **≥ 6, scaled with N** | keeps `ρ` and reorg depth bounded at scale ([§3.7](#s3-7), [§6.10](#s6-10)) |
| uncle rewards | none (spec: uncle grants no block reward) | **soft inclusion, `w_u + w_n < 1`** | fork-safe fairness + anti-hiding ([§6.7](#s6-7)[§6.8](#s6-8)) |
| operating point | set by the Blend privacy budget | **`ρ = f·D_vis < 1`** | accuracy, weak grinding, shallow reorgs — one rule ([§6.3](#s6-3), [§6.10](#s6-10)) |
@ -971,6 +1007,7 @@ Sweep studies are committed configs, run with `make <name>` (writes a dated `run
| **residual §6.5-scope variants** (whale coalitions, `jitter > 0`, very slow `β`, and the static-withholding load sweep that finds the collapsed-branch event) | `scripts/adversary_variants.py` (`adversary_selection`); `runs/adversary_variants_{whale,jitter,beta,withhold_load}.parquet` | [§6.2](#s6-2), [§6.5](#s6-5) |
| **uncle cap under a private chain** (`U` × `W` × `α` against the SM1 engine adversary, plus the structural/queue decomposition of the honest orphans) | `scripts/selfish_uncle_margin.py` (`adversary_strategy: selfish`); `runs/selfish_uncle_margin{,_decomp}.parquet` | [§6.8](#s6-8) |
| **profit/deflation frontier** (how far a *paid* adversary can deflate `D̂`; the deflation-optimal MDP) | `scripts/deflation_frontier.py` (`selfish_mdp.deflation_frontier`, `.deflation_optimal_stats`); fig37 | [§6.6](#s6-6) |
| **uncle- vs parent-anchored reference window** (honest recovery cost, and an adversary's reach into historical state) | `scripts/uncle_parent_window.py` (`uncle_window_anchor`, `adversary_strategy: deep_parent`); `runs/uncle_parent_window{,_effort}.parquet`; fig38 | [§6.12](#s6-12) |
| **the deployed operating point** (`δ_max` = 4 from the spec's Blend profile; the three `f`-precision arms) | `scripts/spec_point.py` (`f_precision`); `runs/spec_point.parquet` | [§2.2](#s2-2), [§8.5](#s8-5), [Appendix A](#sA) |
| **per-recipient delay variance** (jitter 08 slots × `U`, both referencing models, exact oracle) | `scripts/spec_jitter.py`, `configs/spec-point-jitter.yaml`; `runs/spec_jitter.parquet` | [§6.1](#s6-1), [§8.3](#s8-3) item 15 |
| **the spec point at scale and by window** (`N` = 5000; `W_abs` sweep at `δ_max` = 4) | `configs/spec-point-n5000.yaml` (run twice, `--old`), `configs/spec-point-window.yaml` | [§3.4](#s3-4), [§8.5](#s8-5) |
@ -981,7 +1018,7 @@ Because the two models draw independent RNG streams, every countable-vs-unrestri
All studies were **re-run on 2026-07-23/24 with the corrected slot-counting mechanism** ([§2.1](#s2-1)) and the early-stop optimisation; the resilient batch is `scripts/run_all_reruns.sh` (per-step log in `runs/rerun_status.log`). Canonical run directories (latest): fullscale N=5000/10000 = `2026-07-24_094519_fullscale`; fullscale N=1000/2000 = `2026-07-23_171803_fullscale-small`; uncle-window = `2026-07-24_001456`; window-uncles = `2026-07-24_014240`; block-rate = `2026-07-24_043943`; blend-hops-delay = `2026-07-24_064052`; window-scale = `2026-07-24_085234`; latency-shape = `2026-07-24_090014_expdist`; stake-tail = `2026-07-24_090044_pareto133`; heterogeneous-start = `2026-07-24_090114_default`; N-scaling = the `nscaling-{a,b}` + `nscaling32-{a,b}` runs; adversary grids = `runs/adversary_grid/`; jitter = `runs/jitter_grid/`; bootstrap = `runs/bootstrap_fullscale/`; fluctuation = `runs/fluctuation_u0.parquet`; fork-rate = `runs/fork_rate_vs_delay.parquet`; ρ-boundary = `2026-07-27_195627_rho-boundary`. Referencing-model studies: countable-vs-unrestricted (paired, primary) = `2026-08-05_120804_cvo-paired-countable` / `2026-08-05_123403_cvo-paired-old`; the superseded unpaired pair = `2026-08-04_103536_cvo-countable` / `2026-08-04_104010_cvo-old`; window absorption = `2026-08-04_104633_absorption-window`; fine delay band (unpaired) = `2026-08-04_191441_fine-countable` / `2026-08-04_195353_fine-old`; fine delay band (paired, primary) = `2026-08-05_111109_fine-paired-countable` / `2026-08-05_113218_fine-paired-old`. Adversarial-countability studies: uncle-selection deviation = `2026-08-05_152617_uncle-selection`; countable-selfish MDP = `runs/countable_selfish.parquet` + `runs/countable_selfish_reorg.parquet`; §6.5 scope variants and the withhold-load sweep = `runs/adversary_variants_{whale,jitter,beta,withhold_load}.parquet`.
Figures are embedded from [`report-figures/`](report-figures) and versioned here alongside the report; the simulator folder does **not** commit its own generated figures, so the copies checked in here are the figures of record. Figures are in `report-figures/` (`fig1``fig29`, plus [Appendix B](#sB)'s `figB1``figB2`; numbering is generation order, not order of appearance). Committed generators: `fig1` (bootstrap, k=2160) by `scripts/bootstrap_dynamics.py`; `fig2`,`fig4`,`fig5`,`fig17``fig22` by `scripts/regenerate_extra_figs.py` from the latest sweeps (`fig3` hops×delay×U grid by `scripts/hops_delay_grid.py`, `fig6` (block-rate `U_min` grid + ρ-collapse) rendered ad hoc from `runs/2026-07-24_043943_block-rate` with no committed generator; `fig26` deficit-vs-ρ by `scripts/rho_boundary_analysis.py`) (fullscale-derived `fig17``fig20` pool both sizes in that run, N = 5 000 and N = 10 000 — the generators filter on stake_dist/topology/degree/init_dest only, never on `n_nodes`); `fig8`,`fig9` by `scripts/adversary_figs.py` from `runs/adversary_grid/`; `fig10``fig12` by `scripts/dynamic_withhold.py`; `fig13``fig15` by `scripts/selfish_mining.py`/`selfish_rewards.py`/`reward_mandate.py`; `fig16` by `scripts/stake_vs_delay.py`; `fig23``fig24` by `scripts/nscaling_analysis.py`; `fig25` by `scripts/window_scale_analysis.py`; `fig27``fig28` by `scripts/reorg_depth.py` (fork rates via `--measure`; private-chain model `src/tsi_sim/reorg.py`); `fig29` by `scripts/churn.py`; `figB1``figB2` by `scripts/appendix_fluct.py`. `fig7` (feedback fixed-point) is an analytic overlay; `fig30``fig33` (countable-vs-unrestricted accuracy, `q_u`-prediction check, recovery rate, absorption-window sweep) by `scripts/plot_countable_vs_old.py` from the `cvo-countable`/`cvo-old`/`absorption-window` runs; `fig34``fig35` (design-regime accuracy and the model gap with 95 % CIs) by `scripts/plot_fine_delay.py` from the `fine-countable`/`fine-old` runs; `fig36` (the countable recovery ceiling under a selfish adversary) by `scripts/countable_selfish.py`, which solves the MDP directly and writes `runs/countable_selfish.parquet` + `runs/countable_selfish_reorg.parquet` (no sweep input); `fig37` (the profit/deflation frontier) by `scripts/deflation_frontier.py` from `runs/deflation_frontier.parquet`, likewise solver-only. Every figure type the per-node simulator generates appears in this report, and the fork-rate/reorg-depth study closes the previous reproducibility gap for the adversarial figures (`fig8`,`fig9` now have committed generators from `runs/adversary_grid/`).
Figures are embedded from [`report-figures/`](report-figures) and versioned here alongside the report; the simulator folder does **not** commit its own generated figures, so the copies checked in here are the figures of record. Figures are in `report-figures/` (`fig1``fig29`, plus [Appendix B](#sB)'s `figB1``figB2`; numbering is generation order, not order of appearance). Committed generators: `fig1` (bootstrap, k=2160) by `scripts/bootstrap_dynamics.py`; `fig2`,`fig4`,`fig5`,`fig17``fig22` by `scripts/regenerate_extra_figs.py` from the latest sweeps (`fig3` hops×delay×U grid by `scripts/hops_delay_grid.py`, `fig6` (block-rate `U_min` grid + ρ-collapse) rendered ad hoc from `runs/2026-07-24_043943_block-rate` with no committed generator; `fig26` deficit-vs-ρ by `scripts/rho_boundary_analysis.py`) (fullscale-derived `fig17``fig20` pool both sizes in that run, N = 5 000 and N = 10 000 — the generators filter on stake_dist/topology/degree/init_dest only, never on `n_nodes`); `fig8`,`fig9` by `scripts/adversary_figs.py` from `runs/adversary_grid/`; `fig10``fig12` by `scripts/dynamic_withhold.py`; `fig13``fig15` by `scripts/selfish_mining.py`/`selfish_rewards.py`/`reward_mandate.py`; `fig16` by `scripts/stake_vs_delay.py`; `fig23``fig24` by `scripts/nscaling_analysis.py`; `fig25` by `scripts/window_scale_analysis.py`; `fig27``fig28` by `scripts/reorg_depth.py` (fork rates via `--measure`; private-chain model `src/tsi_sim/reorg.py`); `fig29` by `scripts/churn.py`; `figB1``figB2` by `scripts/appendix_fluct.py`. `fig7` (feedback fixed-point) is an analytic overlay; `fig30``fig33` (countable-vs-unrestricted accuracy, `q_u`-prediction check, recovery rate, absorption-window sweep) by `scripts/plot_countable_vs_old.py` from the `cvo-countable`/`cvo-old`/`absorption-window` runs; `fig34``fig35` (design-regime accuracy and the model gap with 95 % CIs) by `scripts/plot_fine_delay.py` from the `fine-countable`/`fine-old` runs; `fig36` (the countable recovery ceiling under a selfish adversary) by `scripts/countable_selfish.py`, which solves the MDP directly and writes `runs/countable_selfish.parquet` + `runs/countable_selfish_reorg.parquet` (no sweep input); `fig37` (the profit/deflation frontier) by `scripts/deflation_frontier.py` from `runs/deflation_frontier.parquet`, likewise solver-only; `fig38` (the reference-window anchor) by `scripts/uncle_parent_window.py`. Every figure type the per-node simulator generates appears in this report, and the fork-rate/reorg-depth study closes the previous reproducibility gap for the adversarial figures (`fig8`,`fig9` now have committed generators from `runs/adversary_grid/`).
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

@ -0,0 +1,191 @@
"""Should the uncle reference window be measured to the uncle, or to its PARENT? (fig38)
The spec bounds the uncle's own slot: `0 < sl_A - sl_U <= w_u`. Its **parent** is unbounded —
the only requirement is that it lie on the referencing chain. So a block minted now, hanging off
a chain block from arbitrarily far back, is a legal first-fork uncle: recent by its own slot,
ancient by its parent's. Verifying it means deriving the epoch state and ledger root as of that
ancient parent, per reference, and an adversary mints them at no cost beyond lottery wins it
already has.
The proposal: measure the window to the parent instead, `sl_A - sl_parent(U) <= w_u`.
That is strictly tighter rather than an additional rule. A block strictly postdates its parent
and a referenced uncle strictly precedes its referencer (both pinned in
`tests/test_slot_ordering.py`), so
sl_A - sl_U < sl_A - sl_parent(U) <= w_u
and bounding the parent bounds the uncle for free. A "both windows" variant would be identical
to the parent one, so only two arms are simulated.
Two questions, and they trade off:
* **What does it buy?** The effort an adversary can force, measured as the age of the oldest
chain state a validator must reach for a *counted* reference.
* **What does it cost?** Honest recovery. A latency orphan's parent is recent by construction,
so the prediction is ~nothing but the parent gap runs about one block-interval longer than
the uncle gap, so the same numeric `w_u` is effectively a tighter window and the margin
shrinks as delay grows. This sweeps delay to find where it starts to bind.
Run: python scripts/uncle_parent_window.py (writes runs/uncle_parent_window.parquet + fig38)
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
from joblib import Parallel, delayed
from tsi_sim import lottery, topology
from tsi_sim.blocktree import build_tree_pernode
from tsi_sim.config import SimConfig
from tsi_sim.engine import _adversary_mask, run_trajectory
from tsi_sim.plotting import style
from tsi_sim.rng import rng_for, seedseq_for
from tsi_sim.stake import make_stake
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
RUNS.mkdir(exist_ok=True)
FIGS.mkdir(exist_ok=True)
REPS = 10
N_JOBS = 10
ANCHORS = ["uncle", "parent"]
DELAYS = [4.0, 8.0, 16.0] # the deployed point, the report's design point, the boundary
ADVS = [0.0, 0.3]
BASE = dict(n_nodes=600, stake_dist="pareto", topology="blend", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
max_uncles=4, uncle_strategy="oldest", window_absorption=10.0,
k=256, epochs=10, genesis_d_factor=0.5, early_stop=False,
prune_arrival=False, windowed_fork_choice=False)
def _recovery(anchor: str, delay: float, adv: float, rep: int) -> dict:
"""Accuracy arm: what the estimator lands on under each rule."""
cfg = SimConfig(**BASE, uncle_window_anchor=anchor, blend_delay_max=delay,
adversary_frac=adv, adversary_strategy="deep_parent", replicate=rep)
t = pd.DataFrame(run_trajectory(cfg))
t = t[t.epoch >= t.epoch.max() // 2]
return dict(anchor=anchor, blend_delay_max=delay, adversary_frac=adv, rep=rep,
mean_ratio=float(t.mean_ratio.mean()), p_ref=float(t.p_ref.mean()),
fork_rate=float(t.fork_rate.mean()),
range_ratio=float(t.range_ratio.max()))
def _effort(anchor: str, delay: float, adv: float, rep: int) -> dict:
"""Effort arm: how far back a validator must reach for the references that COUNT.
Rebuilds one epoch's tree and reads the parent gap of every reference the counting rule
would accept the direct proxy for historical state a validator must materialise.
"""
cfg = SimConfig(**{**BASE, "epochs": 2}, uncle_window_anchor=anchor,
blend_delay_max=delay, adversary_frac=adv,
adversary_strategy="deep_parent", replicate=rep)
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
stake = make_stake(cfg, rng_for(cfg))
mask = _adversary_mask(cfg, stake)
pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1]))
d = np.full(cfg.n_nodes, cfg.genesis_d_factor * float(stake.sum()))
ws, wn = lottery.sample_wins(lottery.win_probs(stake, d, cfg.f), cfg.epoch_len,
np.random.default_rng(kids[3]))
slots, groups = lottery.group_by_slot(ws, wn)
tree, _A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]),
adversary_mask=mask)
s, par = tree.slot, tree.parent
gaps = np.array([int(s[b] - s[par[u]])
for b in range(1, tree.n_blocks) for u in tree.uncles[b]], dtype=np.int64)
if gaps.size == 0:
gaps = np.zeros(1, dtype=np.int64)
return dict(anchor=anchor, blend_delay_max=delay, adversary_frac=adv, rep=rep,
n_refs=int(gaps.size), gap_median=float(np.median(gaps)),
gap_p99=float(np.percentile(gaps, 99)), gap_max=int(gaps.max()),
distinct_parent_slots=int(np.unique(gaps).size))
def sweep() -> tuple[pd.DataFrame, pd.DataFrame]:
jobs = [(a, d, v, r) for a in ANCHORS for d in DELAYS for v in ADVS for r in range(REPS)]
par = Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)
rec = pd.DataFrame(par(delayed(_recovery)(*j) for j in jobs))
eff = pd.DataFrame(par(delayed(_effort)(*j) for j in jobs))
rec.to_parquet(RUNS / "uncle_parent_window.parquet", index=False)
eff.to_parquet(RUNS / "uncle_parent_window_effort.parquet", index=False)
return rec, eff
def report(rec: pd.DataFrame, eff: pd.DataFrame, w: int) -> None:
print(f"\n=== what it COSTS: honest recovery (adversary_frac = 0), w_u = {w} slots ===")
print(f"{'δ_max':>6} {'ρ':>6} | {'uncle-anchored':>18} {'parent-anchored':>18} {'Δ':>9}")
for d in DELAYS:
row = []
for a in ANCHORS:
g = rec[(rec.anchor == a) & (rec.blend_delay_max == d) & (rec.adversary_frac == 0)]
row.append((g.mean_ratio.mean(), g.mean_ratio.sem()))
rho = SimConfig(**BASE, blend_delay_max=d).f * (3 * d / 2 + 4 * 0.5)
print(f"{d:6.0f} {rho:6.2f} | {row[0][0]:10.4f}±{row[0][1]:.4f} "
f"{row[1][0]:10.4f}±{row[1][1]:.4f} {row[1][0] - row[0][0]:+9.4f}")
print("\n=== what it BUYS: age of chain state a counted reference reaches (slots) ===")
print(f"{'δ_max':>6} {'adv':>5} | {'anchor':>7} {'refs':>6} {'median':>9} "
f"{'p99':>9} {'max':>9}")
for d in DELAYS:
for v in ADVS:
for a in ANCHORS:
g = eff[(eff.anchor == a) & (eff.blend_delay_max == d) & (eff.adversary_frac == v)]
flag = "" if g.gap_max.max() <= w else " <-- EXCEEDS w_u"
print(f"{d:6.0f} {v:5.1f} | {a:>7} {g.n_refs.mean():6.0f} "
f"{g.gap_median.mean():9.0f} {g.gap_p99.mean():9.0f} "
f"{g.gap_max.max():9.0f}{flag}")
def fig38(rec: pd.DataFrame, eff: pd.DataFrame, w: int) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8))
ax = axes[0]
for i, a in enumerate(ANCHORS):
g = (rec[(rec.anchor == a) & (rec.adversary_frac == 0)]
.groupby("blend_delay_max").mean_ratio.agg(["mean", "sem"]).reset_index())
ax.errorbar(g.blend_delay_max, g["mean"], yerr=g["sem"], marker="o", ms=4, capsize=2,
color=style.OKABE_ITO[i + 1], label=f"{a}-anchored window")
ax.axhline(1.0, color="0.5", lw=0.9, ls="--")
ax.set_xlabel(r"Blend per-hop delay $\delta_{max}$ (s)")
ax.set_ylabel(r"$\hat D / D^*$ (honest)")
ax.set_title("Cost: honest recovery is unchanged")
ax.legend(fontsize=7, loc="lower left")
ax = axes[1]
x = np.arange(len(DELAYS))
for i, a in enumerate(ANCHORS):
vals = [eff[(eff.anchor == a) & (eff.blend_delay_max == d)
& (eff.adversary_frac == 0.3)].gap_max.max() for d in DELAYS]
ax.bar(x + (i - 0.5) * 0.36, vals, 0.36, color=style.OKABE_ITO[i + 1],
label=f"{a}-anchored")
ax.axhline(w, color=style.OKABE_ITO[0], lw=1.2, ls="--", label=rf"$w_u$ = {w} slots")
ax.set_yscale("log")
ax.set_xticks(x, [f"{d:.0f}" for d in DELAYS])
ax.set_xlabel(r"Blend per-hop delay $\delta_{max}$ (s)")
ax.set_ylabel("oldest chain state a counted\nreference reaches (slots, log)")
ax.set_title("Benefit: a 30 % adversary's reach, bounded")
ax.legend(fontsize=7, loc="upper left")
style.save(fig, FIGS / "fig38_uncle_parent_window",
provenance="scripts/uncle_parent_window.py")
plt.close(fig)
def main() -> None:
w = SimConfig(**BASE, blend_delay_max=4.0).effective_uncle_window
print(f"=== uncle- vs parent-anchored reference window (w_u = {w} slots) ===")
rec, eff = sweep()
report(rec, eff, w)
fig38(rec, eff, w)
print(f"\nwrote {RUNS}/uncle_parent_window{{,_effort}}.parquet + fig38")
if __name__ == "__main__":
main()

View File

@ -309,6 +309,11 @@ def build_tree_pernode(
selfish = (adversary_mask is not None and config.adversary_frac > 0.0
and config.adversary_strategy == "selfish")
# A deep-parent block hangs off an ancient chain block, whose arrival column the sliding
# prune has long since dropped, so this adversary needs the full matrix too.
deep_parent = (adversary_mask is not None and config.adversary_frac > 0.0
and config.adversary_strategy == "deep_parent")
deep_parent_max = float(E) # reach as far back as the epoch allows
# A private chain breaks the windowed horizon's premise: an unreleased block is old enough to
# be "fully propagated" while no honest node has it, and it becomes visible LATER (on release),
# which the one-way frontier pointer can never revisit. So selfish runs the exact full scan.
@ -336,8 +341,8 @@ def build_tree_pernode(
# choice AND jitter_mean == 0. With jitter the full matrix's safety clamp is required. A
# withholding adversary produces blocks that NEVER arrive (arrival > E), violating the prune's
# "finalized => arrived-everywhere" assumption, so it too forces the full matrix.
withholding = (adversary_mask is not None and config.adversary_frac > 0.0
and config.adversary_strategy == "withhold")
withholding = ((adversary_mask is not None and config.adversary_frac > 0.0
and config.adversary_strategy == "withhold") or deep_parent)
if config.prune_arrival and windowed and config.jitter_mean == 0.0 and not withholding:
# (selfish already cleared `windowed`, so it never reaches the pruned path either)
return _build_pruned(active_slots, winners_per_slot, path_latency, config, rng,
@ -400,6 +405,20 @@ def build_tree_pernode(
for wi in range(winners.shape[0]):
v = int(winners[wi])
p_id = int(parents[wi])
if deep_parent and adversary_mask[v]:
# Walk back along the chain this node would have extended, as far as the epoch
# allows. The result is a genuine lottery win whose parent lies on the canonical
# chain — a legal FIRST-FORK uncle — but one whose Proof of Leadership can only
# be verified against epoch/ledger state from far back. Nothing in the spec's
# uncle-anchored window forbids it: only the uncle's OWN slot is bounded, and
# that is `t`, the current slot.
anc = p_id
while anc > 0:
nxt = int(parent[anc])
if nxt <= 0 or t - int(slot[nxt]) > deep_parent_max:
break
anc = nxt
p_id = anc
h = int(height[p_id]) + 1
b = nb
slot[b], parent[b], height[b], leader[b] = t, p_id, h, v

View File

@ -35,13 +35,29 @@ InitDest = Literal["common", "heterogeneous"]
# forfeit by displacing honest work, and is the one profitable lever (report §6.6). Its
# estimator damage is what the countable uncle rule can only partly repair, because an
# override discards a CHAIN of honest blocks and only the first is referenceable (§2.1).
AdversaryStrategy = Literal["suppress", "withhold", "selfish"]
# "deep_parent" — mints blocks that hang off an ANCIENT chain block instead of the current
# tip. Each is a genuine lottery win and a legal first-fork uncle, and under the spec's
# uncle-anchored window it is eligible the moment it is produced (its OWN slot is recent),
# so honest proposers reference it — forcing every validator to derive the epoch and ledger
# state at that ancient parent to check its Proof of Leadership. The attack this branch
# evaluates: it costs the adversary nothing it would not already spend, and the effort it
# imposes is bounded only by how far back the block tree reaches.
AdversaryStrategy = Literal["suppress", "withhold", "selfish", "deep_parent"]
# WHICH nodes make up that coalition, at the same total stake:
# "random" — a uniformly random set grown until its stake reaches adversary_frac (the default; the
# block share is then smooth in adversary_frac, which is all the density levers depend on);
# "whale" — the LARGEST holders first. Same stake, far fewer nodes, so the coalition's block
# production is lumpier — the untested concentration case flagged in report §6.5's scope.
AdversarySelection = Literal["random", "whale"]
# WHICH slot the uncle reference window is measured against:
# "uncle" (spec) — the uncle's own slot: 0 < sl_A - sl_U <= w_u. Bounds how old a referenced
# block may be, but leaves its PARENT unbounded, so a valid uncle may hang off an
# arbitrarily old chain block and force a validator to derive historical epoch/ledger
# state at that ancient parent to check its Proof of Leadership.
# "parent" (proposed) — the uncle's parent's slot: sl_A - sl_parent(U) <= w_u. Since a block
# strictly postdates its parent, the parent gap is never smaller than the uncle gap, so
# this is STRICTLY TIGHTER: it bounds both, and bounds how far back state must be reached.
UncleWindowAnchor = Literal["uncle", "parent"]
@dataclass(frozen=True)
@ -128,6 +144,7 @@ class SimConfig:
# take the oldest candidates first, deterministically, because an uncle expires w_u slots
# after its own slot so the oldest are the closest to expiring. Every headline result uses it.
uncle_strategy: UncleStrategy = "oldest"
uncle_window_anchor: UncleWindowAnchor = "uncle" # "parent" = the proposed rule
# "random" is NOT a spec variant — it is the deviation probe: walk the same oldest-first
# candidate order but include each candidate with probability uncle_random_p, so a lone
# candidate is dropped half the time. Uncle selection is proposer-local and unvalidated, so a
@ -278,11 +295,16 @@ class SimConfig:
raise ValueError(f"clock_skew_max must be >= 0, got {self.clock_skew_max}")
if self.f_precision < 1 or self.f_precision != int(self.f_precision):
raise ValueError(f"f_precision must be a positive integer, got {self.f_precision!r}")
if self.uncle_window_anchor not in ("uncle", "parent"):
raise ValueError(f"uncle_window_anchor must be uncle|parent, got "
f"{self.uncle_window_anchor!r}")
if self.adversary_selection not in ("random", "whale"):
raise ValueError(f"adversary_selection must be random|whale, got "
f"{self.adversary_selection!r}")
if self.adversary_strategy not in ("suppress", "withhold", "selfish"):
raise ValueError(f"adversary_strategy must be suppress|withhold|selfish, got "
if self.adversary_strategy not in ("suppress", "withhold", "selfish",
"deep_parent"):
raise ValueError(f"adversary_strategy must be "
f"suppress|withhold|selfish|deep_parent, got "
f"{self.adversary_strategy!r}")
checks = {
"n_nodes": self.n_nodes >= 1,
@ -396,7 +418,9 @@ class SimConfig:
base = base + (self.adversary_selection,)
# Same append-only-when-non-default discipline: a run at the default precision keeps a
# byte-identical key, so no committed result is reseeded by adding the knob.
return base if self.f_precision == 1_000_000 else base + (self.f_precision,)
if self.f_precision != 1_000_000:
base = base + (self.f_precision,)
return base if self.uncle_window_anchor == "uncle" else base + (self.uncle_window_anchor,)
def seed_key(self) -> tuple:
"""The identity the RNG root is actually derived from (see ``rng.seedseq_for``).

View File

@ -108,7 +108,8 @@ def simulate_epoch(
ms = measure(tree, A, active_slots, T, cutoff=E,
legacy_block_count=config.legacy_block_count,
countable=config.uncle_model != "old",
w=config.effective_uncle_window)
w=config.effective_uncle_window,
parent_anchor=config.uncle_window_anchor == "parent")
n_active_window = int((active_slots < T).sum())
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point,

View File

@ -80,7 +80,7 @@ def _uncles_csr(tree: BlockTree) -> tuple[np.ndarray, np.ndarray]:
def _measure_tips_py(distinct_tips, parent, slot, uncle_flat, uncle_ptr, T, w, countable,
uncle_stamp, honest_stamp, chain_stamp):
uncle_stamp, honest_stamp, chain_stamp, parent_anchor=False):
"""Pure-Python per-distinct-tip walk (fallback / reference for the kernel)."""
K = distinct_tips.shape[0]
m = np.empty(K, np.int64)
@ -121,7 +121,12 @@ def _measure_tips_py(distinct_tips, parent, slot, uncle_flat, uncle_ptr, T, w, c
if countable:
# spec counting rules, re-checked per reference:
d = int(slot[b]) - su
if d <= 0 or d > w:
if d <= 0:
continue # uncle must strictly precede
# window anchor: the uncle's own slot (spec), or its PARENT's. The
# parent gap is never smaller, so the parent anchor is strictly tighter.
gap = int(slot[b]) - int(slot[int(parent[u])]) if parent_anchor else d
if gap > w:
continue # outside the reference window
if chain_stamp[u] == ki:
continue # uncle lies on the counting chain
@ -160,7 +165,7 @@ if _HAVE_NUMBA:
@njit(cache=True)
def _measure_tips_nb(distinct_tips, parent, slot, uncle_flat, uncle_ptr, T, w, countable,
uncle_stamp, honest_stamp, chain_stamp):
uncle_stamp, honest_stamp, chain_stamp, parent_anchor):
K = distinct_tips.shape[0]
m = np.empty(K, np.int64)
n_honest = np.empty(K, np.int64)
@ -198,8 +203,9 @@ if _HAVE_NUMBA:
ok = True
if countable:
d = slot[b] - su
if d <= 0 or d > w:
ok = False # outside the reference window
gap = slot[b] - slot[parent[u]] if parent_anchor else d
if d <= 0 or gap > w:
ok = False # non-preceding, or outside the window
elif chain_stamp[u] == ki:
ok = False # uncle lies on the counting chain
else:
@ -225,7 +231,7 @@ if _HAVE_NUMBA:
def measure(tree: BlockTree, A, active_slots: np.ndarray, T: int, cutoff: int,
use_numba: bool = True, legacy_block_count: bool = False,
countable: bool = False, w: int = 0) -> Measurement:
countable: bool = False, w: int = 0, parent_anchor: bool = False) -> Measurement:
"""Per-node m/q/q_eff + agreement, deduped by tip and (optionally) numba-accelerated.
``countable`` applies the spec's per-reference counting rules (window ``w``,
@ -250,7 +256,7 @@ def measure(tree: BlockTree, A, active_slots: np.ndarray, T: int, cutoff: int,
m_d, nh_d, nrec_d, nref_d, ndeep_d, clen_d, fp_d = kernel(
distinct_tips.astype(np.int64), tree.parent, tree.slot,
uncle_flat, uncle_ptr, np.int64(T), np.int64(w), bool(countable),
uncle_stamp, honest_stamp, chain_stamp)
uncle_stamp, honest_stamp, chain_stamp, bool(parent_anchor))
# correct slot counting: canonical slots + recovered (non-canonical, deduped) uncle slots.
# legacy_block_count reproduces the earlier per-block-id count (kernel's m = honest + ucnt).

View File

@ -14,7 +14,7 @@ _CONFIG_FIELDS = (
"n_nodes", "stake_dist", "pareto_shape", "latency", "topology", "degree",
"link_latency_mean", "link_latency_dist", "blend_hops", "blend_delay_max",
"init_dest", "init_spread", "uncle_model", "window_absorption",
"uncle_window", "max_uncles", "uncle_strategy",
"uncle_window", "max_uncles", "uncle_strategy", "uncle_window_anchor",
# Recorded so downstream analysis can TELL whether a countable/--old pair actually shared
# its RNG streams. The paired test is only valid on paired runs, and without this column
# the analysis silently falls back to the much weaker unpaired test.

View File

@ -167,6 +167,16 @@ def select_uncles_at_production(
[b for b in pre if int(parent[b]) == GENESIS or int(parent[b]) in chain_ids],
dtype=np.int64,
)
if config.uncle_window_anchor == "parent" and cands.size:
# Proposed rule: the window is measured to the uncle's PARENT, not the uncle. A block
# strictly postdates its parent, so this gap is never smaller than the uncle's own —
# the [t-w, t) scan above is therefore a superset and this only narrows it. Filtering
# here as well as at counting keeps the proposer from spending an entry on a reference
# that will not count.
cands = cands[np.array([int(slot[int(parent[b])]) >= t - w for b in cands.tolist()],
dtype=bool)]
if cands.size == 0:
return ()
if cands.size == 0:
return ()
# cands already oldest-first (slot, id); one uncle per slot per the spec's selection.

View File

@ -211,6 +211,7 @@ def _perturb(v):
# adversary_selection. Keyed by VALUE, so this only fires on fields that are currently
# "random" — uncle_strategy defaults to "oldest" and keeps its own flip above.
"random": "whale",
"uncle": "parent", # uncle_window_anchor
}
if isinstance(v, str) and v in flips:
return flips[v]

View File

@ -0,0 +1,99 @@
"""Slot-ordering invariants the uncle window rests on.
These look too obvious to test, which is exactly why they are worth pinning: the design
argument for windowing an uncle's PARENT rather than the uncle itself is a two-line
implication that holds only if both of them do.
sl_parent(U) < sl_U a block's slot strictly exceeds its parent's
sl_U < sl_A a referenced uncle strictly precedes its referencer
=> sl_A - sl_U < sl_A - sl_parent(U)
so a window on the parent is strictly tighter than the same window on the uncle, and
bounding the parent bounds the uncle for free. If either invariant were violated by a
same-slot parent, say the implication would fail and the two rules would have to be
imposed separately.
"""
import numpy as np
import pytest
from tsi_sim import lottery, topology
from tsi_sim.blocktree import build_tree_pernode
from tsi_sim.config import SimConfig
from tsi_sim.rng import rng_for, seedseq_for
from tsi_sim.stake import make_stake
GEOMETRIES = [
dict(topology="blend", blend_delay_max=4.0, blend_hops=3), # the deployed operating point
dict(topology="blend", blend_delay_max=16.0, blend_hops=3), # near the load boundary
dict(topology="regular", link_latency_mean=0.2), # sub-slot direct gossip
]
def _tree(**over):
cfg = SimConfig(**{**dict(n_nodes=300, stake_dist="pareto", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", max_uncles=4,
k=96, epochs=3, genesis_d_factor=0.5,
prune_arrival=False, windowed_fork_choice=False), **over})
kids = seedseq_for(cfg).spawn(cfg.epochs + 3)
stake = make_stake(cfg, rng_for(cfg))
pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1]))
d = np.full(cfg.n_nodes, cfg.genesis_d_factor * float(stake.sum()))
ws, wn = lottery.sample_wins(lottery.win_probs(stake, d, cfg.f), cfg.epoch_len,
np.random.default_rng(kids[3]))
slots, groups = lottery.group_by_slot(ws, wn)
tree, _A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]))
return cfg, tree
@pytest.mark.parametrize("geom", GEOMETRIES)
def test_a_blocks_slot_strictly_exceeds_its_parents(geom):
_cfg, tree = _tree(**geom)
s, par = tree.slot, tree.parent
bad = [b for b in range(1, tree.n_blocks) if par[b] > 0 and s[par[b]] >= s[b]]
assert not bad, f"{len(bad)} blocks do not strictly postdate their parent, e.g. {bad[:3]}"
@pytest.mark.parametrize("geom", GEOMETRIES)
def test_a_referenced_uncle_strictly_precedes_its_referencer(geom):
_cfg, tree = _tree(**geom)
s = tree.slot
bad = [(b, u) for b in range(1, tree.n_blocks) for u in tree.uncles[b] if s[u] >= s[b]]
assert not bad, f"{len(bad)} references do not precede their referencer, e.g. {bad[:3]}"
@pytest.mark.parametrize("geom", GEOMETRIES)
def test_parent_window_would_subsume_the_uncle_window(geom):
# The implication itself, checked on real trees rather than argued: for every reference the
# parent gap is at least the uncle gap, so any bound on the former bounds the latter.
_cfg, tree = _tree(**geom)
s, par = tree.slot, tree.parent
pairs = [(int(s[b] - s[u]), int(s[b] - s[par[u]]))
for b in range(1, tree.n_blocks) for u in tree.uncles[b]]
if not pairs:
pytest.skip("no uncles referenced in this geometry")
assert all(gp >= gu for gu, gp in pairs)
assert all(gu > 0 for gu, _ in pairs)
def test_the_counting_rule_rejects_a_non_preceding_uncle():
# sl_A > sl_U is enforced at COUNTING, not merely at selection -- so a hand-built block
# carrying a same-slot or future reference gains nothing from it. Both kernels implement
# the `d <= 0` guard; this pins the behaviour rather than the implementation.
from tsi_sim.measure import _measure_tips_py
# chain: 0 <- 1 <- 2 ; block 2 references block 3, which shares block 2's slot
parent = np.array([-1, 0, 1, 1], dtype=np.int64)
slot = np.array([-1, 0, 10, 10], dtype=np.int64) # block 3 does NOT precede block 2
uncle_flat = np.array([3], dtype=np.int64)
uncle_ptr = np.array([0, 0, 0, 1, 1], dtype=np.int64) # only block 2 carries a reference
stamps = [np.full(4, -1, dtype=np.int64) for _ in range(2)]
m, honest, rec, refs, deep, _clen, _fp = _measure_tips_py(
np.array([2], dtype=np.int64), parent, slot, uncle_flat, uncle_ptr,
T=100, w=300, countable=True,
uncle_stamp=stamps[0], honest_stamp=np.full(101, -1, dtype=np.int64),
chain_stamp=stamps[1])
assert refs[0] == 1 # the reference was examined
assert rec[0] == 0 # ...and recovered nothing
assert m[0] == honest[0] # the count is the chain alone