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>
This commit is contained in:
Marcin Pawlowski 2026-08-04 20:48:38 +02:00
parent bd2ac7b7be
commit ac6a309e58
No known key found for this signature in database
20 changed files with 585 additions and 123 deletions

View File

@ -4,6 +4,8 @@
This report selects and justifies the TSI parameters for Cryptarchia from a per-node network simulation. It is split into four cohesive parts; section numbers (§1§9, AC) are stable identifiers preserved across the set.
> **Uncle references.** The model analysed throughout is the **countable** one: counting-only references, deduplicated by slot, drawn from first-fork blocks only, within a window derived as `w_u = W_abs/f`. An **unrestricted** baseline — any orphan in the window at any fork depth — is measured alongside it for comparison. The two are indistinguishable in the design regime `ρ < 1` and diverge only under overload. See the model note at the top of [Part 1](tsi-report-1-overview-and-recommendations.md), the mechanism in [§2.1](tsi-report-2-accuracy-and-design.md#s2-1), the comparison in [§3.2](tsi-report-2-accuracy-and-design.md#s3-2)[§3.2a](tsi-report-2-accuracy-and-design.md#s3-2a), and the reproduction notes in [§9](tsi-report-4-reproducibility-and-appendices.md#s9).
## Parts
1. **[Overview and recommendations](tsi-report-1-overview-and-recommendations.md)** — the executive summary, the per-knob parameter reference (§7), and the safest selection with residual risks and the recommendation-vs-spec deltas (§8).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@ -8,6 +8,8 @@
---
> **The uncle-reference model analysed here.** Uncle references are *counting-only* — they never affect a block's validity ([§6.7](tsi-report-3-robustness-and-incentives.md#s6-7)[§6.8](tsi-report-3-robustness-and-incentives.md#s6-8)) — and are deduplicated by **slot**, not by block ([§8.5](#s8-5)). The reference window is derived from a **window absorption parameter**, `w_u = W_abs/f` with `W_abs = 10` expected block-intervals and bound `W_abs ≤ 0.6·k`, which is the `W = 300 = 10/f` sizing rule of [§3.4](tsi-report-2-accuracy-and-design.md#s3-4). One structural constraint shapes the results: only the **first block of a fork** — one whose parent lies on the referencing chain — is countable, which is the price of verifying every reference from chain data alone. This report calls that the **countable** model, and measures it against an **unrestricted** baseline in which any orphan in the window is referenceable at any fork depth ([§2.1](tsi-report-2-accuracy-and-design.md#s2-1), [§3.2](tsi-report-2-accuracy-and-design.md#s3-2), [§9](tsi-report-4-reproducibility-and-appendices.md#s9)). The result: **in the design regime `ρ < 1` the two are practically identical.** A dedicated 40-replicate sweep of the operating band (`δ_max` 15, `ρ ≈ 0.250.45`) puts every `U ≥ 1` cell of both models in 0.9981.001, resolves no difference in any individual cell (widest 95 % CI ±0.15 pp), and bounds the pooled first-fork cost below **0.15 %** — rising monotonically with delay and separating from zero only at the top of the band ([§3.2a](tsi-report-2-accuracy-and-design.md#s3-2a)). Differences appear only at `ρ ≥ 1`: the restriction trims ~1.4 % at `ρ ≈ 1`, and under overload (`ρ ≈ 1.8`) recovery saturates near 0.95 where the unrestricted model reaches ~1.00. Deep forks are unrecoverable by construction, so past `ρ ≈ 1` the ceiling is set by the fork *depth* profile and **no uncle cap buys it back** — a second, independent reason for the [§8](#s8) "keep `ρ < 1`" rule. The closed-form accuracy with the measured effective utilization, `log(1f)/log(1f/q_u)`, predicts every countable cell to within 0.2 %.
<a id="s1"></a>
## 1. Executive summary

View File

@ -48,9 +48,10 @@ D̂_next = max(1, D̂ · (1 β·(f_p m/T)/f_p)) β = learning rate
Occupied slots more frequent than the target push the estimate up (the lottery then gets harder); less frequent pulls it down; `m/T = f_p` is the resting point. The two versions differ **only in which slots count**:
- **Before uncle references (spec v1.0):** `m` counts only slots occupied by the *canonical* chain. Under Blend delay roughly a third of honest blocks are orphaned, so their slots vanish from `m`, the resting point sits far below truth (`D̂/D ≈ 0.640.74`, deepening with network size), and the deflated estimate makes the network *chronically over-produce* blocks at up to ~2× the target rate ([§3.2](#s3-2), fig1). Pre-uncle TSI is not viable under Blend.
- **After uncle references (spec v1.1):** `m` also counts the *referenced uncles* — orphans pointed at from the canonical chain — which puts the delay-orphaned slots back into the count and lifts the resting point up toward `D`. The spec as written deduplicates uncles by *block identity* (the same orphan referenced by two blocks counts once), recovering the bulk of the orphan loss. **This report recommends one refinement ([§8.5](tsi-report-1-overview-and-recommendations.md#s8-5)):** deduplicate by *slot*, not by block — a referenced uncle that shares a slot with a canonical block, or with another counted uncle, must add nothing. With slot-deduplication the orphaned slots return one-per-slot and the resting point lands at **exactly `D`**.
- **With uncle references, counting whole blocks (the *unrestricted* model):** `m` also counts the *referenced uncles* — orphans pointed at from the canonical chain — which puts the delay-orphaned slots back into the count and lifts the resting point up toward `D`. Any orphan in the reference window is eligible regardless of how deep in a fork it sits, and references are deduplicated by *block identity*. This report analyses it as the comparison baseline throughout, and calls it the **unrestricted** model.
- **With uncle references, counting slots under the first-fork restriction (the *countable* model — the rules analysed here):** two changes. First, deduplication is by **slot**, not by block: a referenced uncle sharing a slot with a canonical block, or with another counted uncle, adds nothing. Second, only the **first block of a fork** — one whose parent lies on the referencing chain — is countable, which is what makes every reference verifiable from chain data alone (deeper fork blocks would need the fork branch's ledger state). References never affect a block's validity; they are *counting-only*, the soft-rule property the incentive analysis requires ([§6.7](tsi-report-3-robustness-and-incentives.md#s6-7)[§6.8](tsi-report-3-robustness-and-incentives.md#s6-8)). The reference window is *derived* from a **window absorption parameter**, `w_u = W_abs/f` with `W_abs = 10` expected block-intervals and bound `W_abs ≤ 0.6·k` — the `W = 10/f` sizing rule of [§3.4](#s3-4). The price of chain-only verifiability is that deep-fork orphans are unrecoverable by construction; [§3.2](#s3-2) measures what that costs, and the answer is nothing in the design regime and a bounded ceiling under overload.
So the slot-deduplicated uncle rule recovers the orphan-loss the delay caused while preserving the "one count per slot" invariant, and the recovered equilibrium is exactly 1.0 — not a ceiling above it. Counting uncle *blocks* instead — as spec v1.1 is currently written, and as an earlier version of this simulator did — double-counts same-slot co-winners (one canonical, one referenced orphan) and inflates the equilibrium by the fixed multi-winner factor `c(f) ≈ 1.017`. Every result in this report uses the corrected slot count, and [§8.5](tsi-report-1-overview-and-recommendations.md#s8-5) carries the corresponding spec recommendation; the difference is a genuine ~1.7 % accuracy bias in the deployed rule, not merely a fixed simulation bug.
Slot deduplication is what makes the recovered equilibrium exactly 1.0 rather than a ceiling above it. Counting uncle *blocks* instead double-counts same-slot co-winners (one canonical, one referenced orphan) and inflates the equilibrium by the fixed multi-winner factor `c(f) ≈ 1.017` — a genuine accuracy bias, not a simulation artefact. Every result in this report uses the slot count; where a figure or table compares the two referencing models, the unrestricted baseline is labelled as such ([§9](tsi-report-4-reproducibility-and-appendices.md#s9)).
<a id="s2-2"></a>
### 2.2 The equilibrium is bounded by 1
@ -111,10 +112,62 @@ The U = 0 under-count **deepens with N** (more nodes → more concurrent proposa
![Fig 2 — per-node D̂/D vs uncle cap U (curves: max blending delay per hop = 1/2/3 slots, averaged over N): without uncles the estimate is biased low — deeper as blending delay grows — and one uncle restores it to exactly 1.](report-figures/fig2_uncle_recovery.png)
**What the first-fork restriction costs.** Running the delay × U grid under both referencing models on the same grid (`configs/countable-vs-old.yaml`, N = 1 000, blend, 3 hops, 5 replicates; [§9](tsi-report-4-reproducibility-and-appendices.md#s9)) separates the regimes cleanly (`fig30`). Cells are replicate means ± SEM; `t` is the two-sample statistic for the countableunrestricted gap, and cells with `t < 2` do **not** resolve a difference at this replicate count:
| `δ_max` (s) | ρ | U | countable | unrestricted | gap (t) |
|---|---|---|---|---|---|
| 4 | ≈ 0.4 | 1 / 2 / 4 | 0.9979 ± 0.0015 / 0.9982 ± 0.0014 / 0.9995 ± 0.0012 | 0.9981 ± 0.0006 / 0.9985 ± 0.0012 / 0.9992 ± 0.0013 | *none resolved* (0.10.2) |
| 8 | ≈ 0.6 | 1 / 2 / 4 | 0.9965 ± 0.0015 / 0.9955 ± 0.0017 / 0.9963 ± 0.0015 | 0.9986 ± 0.0017 / 0.9993 ± 0.0016 / 0.9989 ± 0.0017 | *none resolved* (1.01.6) |
| 16 | ≈ 1.0 | 1 | 0.9647 ± 0.0065 | 0.9610 ± 0.0043 | *not resolved* (0.5) |
| 16 | ≈ 1.0 | 2 / 4 | 0.9862 ± 0.0010 / 0.9911 ± 0.0032 | 0.9999 ± 0.0005 / 1.0013 ± 0.0011 | 0.014 (12.1) / 0.010 (3.0) |
| 32 | ≈ 1.8 | 1 | 0.608 ± 0.069 | 0.570 ± 0.042 | *not resolved* (0.5) |
| 32 | ≈ 1.8 | 2 / 4 | 0.9343 ± 0.0015 / 0.9517 ± 0.0007 | 0.9774 ± 0.0064 / 1.0020 ± 0.0010 | 0.043 (6.5) / 0.050 (41.0) |
**The `U = 0` rows are a negative control.** With no uncles the two models are identical by construction — no reference is ever taken, so any measured gap is pure between-run noise (the two models draw independent RNG streams by design, so the comparison is unpaired). That control reads **+0.010 (t = 0.8) at `δ_max = 4`** but **0.230 (t = 2.1) at `δ_max = 32`**, where single replicates range from 0.05 to 0.60. Read the table against that floor: at `δ_max = 32` a gap must clear ~0.2 to mean anything on a single-replicate basis, which is exactly why the `U = 1` cells there are reported as unresolved while the tightly-clustered `U = 2` and `U = 4` cells are not.
So: **at the operating loads (`ρ < 1`) no difference between the models is detectable at all** — the first-fork restriction costs nothing measurable where forks are shallow, and both models sit at ≥ 0.995 for every `U ≥ 1`. ([§3.2a](#s3-2a) puts a much tighter bound on this region.) Differences appear only at `ρ ≥ 1` and only at `U ≥ 2`: at `ρ ≈ 1` the restriction trims ~1.4 %, and in overload the countable recovery **saturates** — raising `U` 2 → 4 buys only 0.934 → 0.952 while the unrestricted model reaches ~1.00. The ceiling is the measured recovery rate `r` (0.92 at `δ_max = 32`, `U = 4`), set by the fork-*depth* profile rather than by capacity, so no uncle cap buys it back. The closed form `D̂/D = log(1f)/log(1f/q_u)` with the measured `q_u = q + (1q)·r` predicts every countable cell to within 0.2 % (`fig31`).
`fig32` shows `r` and the residual `1r` per delay, rising from 0.3 % to 7.8 % of the waste as `δ_max` goes 4 → 32 at `U = 4`. That residual is the *combined* first-fork and capacity loss — the simulator cannot separate the two, because the restriction acts at selection (a deep orphan is simply never referenced) rather than at counting. At `U = 4`, where capacity is not binding for `ρ ≤ 1.8`, most of it is attributable to fork depth; the split is not measured directly.
![Fig 30 — equilibrium D̂/D vs Blend mixing delay, countable (solid) vs unrestricted (dashed) per uncle cap, error bars = replicate SEM. The U=0 pair is the negative control: the models are identical with no uncles, so the visible gap there — small at low delay, large at 32 — is the pure between-run noise floor. Countable saturates below 1 in overload, where deep forks are unrecoverable by construction.](report-figures/fig30_countable_vs_old.png)
![Fig 31 — the q→q_u reduction check: predicted log(1f)/log(1f/q̄_u) from the measured q̄_u vs the simulated equilibrium, all countable U≥1 cells on the diagonal within 0.2 %.](report-figures/fig31_countable_prediction.png)
![Fig 32 — measured recovery rate r (left) and the non-recovered waste share 1r on a log axis (right) vs mixing delay: near-total recovery at design loads; the residual — first-fork and capacity losses combined, which this measurement cannot separate — grows with delay.](report-figures/fig32_countable_recovery.png)
**Bootstrap is self-limiting — but only with uncles** (`fig1`; full scale, k = 2160, Blend, N = 1 000 and 5 000, genesis guesses 0.01×2× the true stake; 0.1×2× at N = 5 000). With uncles (U = 2, solid lines) the cold start is a non-event: whatever the guess, block production snaps back to the target `f` within ~22.5 epochs and the estimate lands on 1.0 by epoch 2 — identically at both network sizes. Without uncles (U = 0, dashed) the system also converges from any guess, **but to the wrong place**: the estimate settles below truth (mean ≈ 0.58× across guesses, ranging ≈0.480.85; ≈0.51× at N = 5 000) and the network then *chronically over-produces* blocks at ~1.85× the target rate (~2.0× at N = 5 000) — the [§6.2](tsi-report-3-robustness-and-incentives.md#s6-2) load feedback in the flesh (a low estimate makes the lottery easier, extra blocks orphan, the count stays low). So uncle references are load-bearing from the very first epochs: they are what makes the bootstrap end *at the truth* rather than at a permanently overheated equilibrium.
![Fig 1 — bootstrap at full scale (k=2160, Blend, N=1000): from any genesis guess (0.01×2×), with uncles (solid, U=2) block production (top) and the estimate (bottom) converge to the target f and D̂/D = 1.0 within ~23 epochs (≈2 from a near-correct guess, ~3 from the extreme 0.01× start); without uncles (dashed, U=0) they settle more slowly to a chronically overheated, guess-dependent equilibrium — D̂/D ≈ 0.480.85 (mean ≈ 0.58; ≈ 0.51 at N=5000), block rate ≈ 1.22.1×f.](report-figures/fig1_bootstrap.png)
<a id="s3-2a"></a>
### 3.2a The design band at high precision — `δ_max` 15
**The question.** [§3.2](#s3-2) samples the mixing delay at 4/8/16/32. That resolves the overload regime, but it leaves the band the parameters are actually chosen in — the low-delay end, where every operating point sits — measured at four-fold spacing and five replicates, which is enough to say "no difference detected" and nothing more. How accurate is TSI across `δ_max` = 15, and how large a first-fork cost can be *excluded* there?
**The finding.** With one uncle slot the estimate is exact across the whole band: every `U ≥ 1` cell under both referencing models lands in **0.9981.001**, flat in delay. Without uncles the same band decays steeply, 0.810 → 0.640. Through `δ_max ≤ 4` the two referencing models are **indistinguishable**, and the first-fork restriction begins to cost something measurable only at the top of the band.
`configs/fine-delay.yaml` spends replicates instead of range — `δ_max` ∈ {1,2,3,4,5}, `U` ∈ {0,1,2,4}, **40 replicates** per cell (8× [§3.2](#s3-2)), N = 1 000, blend, 3 hops — run under both models ([§9](tsi-report-4-reproducibility-and-appendices.md#s9)). The band spans `ρ ≈ 0.25` to `0.45`, entirely inside the design regime.
| `δ_max` (s) | ρ | `U=0` countable / unrestricted | `U≥1` countable | `U≥1` unrestricted | pooled gap (t) |
|---|---|---|---|---|---|
| 1 | ≈ 0.25 | 0.810 / 0.806 | 0.9994 0.9999 | 0.9990 1.0010 | 0.0004 ± 0.0007 (1.1) |
| 2 | ≈ 0.30 | 0.762 / 0.763 | 0.9996 1.0002 | 0.9996 1.0003 | +0.0000 ± 0.0008 (0.0) |
| 3 | ≈ 0.35 | 0.716 / 0.723 | 0.9995 1.0002 | 0.9992 1.0004 | 0.0001 ± 0.0008 (0.2) |
| 4 | ≈ 0.40 | 0.664 / 0.668 | 0.9988 0.9996 | 0.9995 0.9999 | 0.0005 ± 0.0008 (1.3) |
| 5 | ≈ 0.45 | 0.640 / 0.623 | 0.9982 0.9988 | 0.9995 1.0003 | **0.0014 ± 0.0007 (3.7)** |
**No individual cell resolves a model difference.** Across the 15 `U ≥ 1` cells the widest 95 % CI half-width is ±0.0015, one cell clears `t = 2` (0.75 are expected to by chance), and its `t = 2.59` does not survive the Bonferroni threshold of 2.94 for 15 tests. Read cell by cell, the honest statement is that any difference is **smaller than ±0.15 pp**.
**Pooled, a small delay-dependent cost appears.** The three uncle caps are independent measurements of the same underlying difference, so inverse-variance pooling across them buys ~√3 in precision. Pooled per delay, `δ_max` 14 stay unresolved (|t| ≤ 1.3) while **`δ_max = 5` resolves at 0.0014 ± 0.0007 (t = 3.7)** — surviving correction for the five delays tested. Over the whole band the pooled gap is 0.00048 ± 0.00033 (t = 2.8), and 11 of 15 cells are negative. So the first-fork restriction does cost something, in the direction theory predicts, and the cost is **monotone in delay and below 0.15 % everywhere in this band** — negligible against the ±0.9 % per-epoch sampling noise of [Appendix B](tsi-report-4-reproducibility-and-appendices.md#sB). (The pooling was chosen after inspecting the per-cell results; the per-delay trend, not the whole-band figure, is the defensible claim.)
**The negative control passes.** The `U = 0` arms — identical models by construction — show |gap| ≤ 0.016 with max `t` = 1.26, i.e. no spurious signal, but a 95 % CI of ±0.025: **17× wider than the entire `U ≥ 1` range.** The unrecovered regime is intrinsically noisy, which is precisely why the model comparison has to be made where uncles are active.
![Fig 34 — design-regime accuracy, δ_max 15, countable (solid) vs unrestricted (dashed) per uncle cap, error bars = replicate SEM over 40 replicates: every U ≥ 1 curve is pinned at 1.000 across the band under both models, while U = 0 (the negative control) decays 0.81 → 0.64.](report-figures/fig34_fine_delay_accuracy.png)
![Fig 35 — the countable unrestricted gap with 95% CIs, zoomed to the U ≥ 1 scale, with the inverse-variance pooled estimate in black: no single cell resolves, the pooled trend is monotone in delay, and only δ_max = 5 separates from zero (0.0014 ± 0.0007).](report-figures/fig35_fine_delay_gap.png)
**What this settles.** One uncle slot is sufficient — not approximately, exactly — everywhere in the operating band, under either referencing model; the [§3.3](#s3-3) `U ≥ ⌈ρ⌉` rule has margin to spare at `ρ ≤ 0.45`. And the first-fork restriction, which [§3.2](#s3-2) shows costing 1.4 % at `ρ ≈ 1` and 5 % under overload, costs **less than 0.15 %** anywhere a deployment should be operating.
<a id="s3-3"></a>
### 3.3 One uncle is not always enough — the load `ρ`
@ -183,6 +236,12 @@ So the two levers separate cleanly: **`U` must cover the average load; `W` must
![Fig 25 — window sufficiency at scale (N = 1 000 blue vs N = 10 000 orange; U = 1 solid circles / U = 2 dashed squares): the floor position is N-invariant (left), W = 600 buys back the ρ ≈ 1 boundary (middle), and no window fixes ρ > U (right).](report-figures/fig25_window_scale.png)
**The derived window.** Sweeping the *derived* window `w_u = W_abs/f` directly (`configs/absorption-window.yaml`, U = 1, 5 replicates; `fig33`) confirms the `10/f` margin, with one correction to the shape. At the 8-s budget recovery runs 0.709 → 0.940 → **0.996** as `W_abs` goes 1 → 3 → 10; at the 16-s budget (`ρ ≈ 1`) `W_abs = 10` reaches 0.964; at the 32-s budget no window recovers what `U = 1` cannot drain. The window-miss probability `(1f)^(W_abs/f)` falls below 10⁻⁴ by `W_abs = 10`, so past that point the window is no longer a loss channel — which is what makes `W_abs = 10` and the bound `W_abs ≤ 0.6·k` land on this section's `W = 10/f` rule.
Two cautions on reading `fig33`. **Accuracy has not saturated at `W_abs ≈ 7`** — it is still climbing at every delay (8 s: 0.989 → 0.996; 16 s: 0.942 → 0.964 from 7 to 10), so the ≈ 7-block-interval queueing floor of the raw-slot sweep above is a floor on *usefulness*, not a point where widening stops paying. And the **32-s curve is noise, not a trend**: replicate SD reaches 0.22 there and the sampled points are non-monotonic (0.623 at `W_abs = 5`, 0.526 at 7, 0.617 at 10). Only its ceiling is meaningful, and that ceiling is set by `U = 1`, not by the window.
![Fig 33 — accuracy vs the window absorption parameter W_abs (derived w_u = W_abs/f) at U=1 for three mixing delays, error bars = replicate SEM: the spec default W_abs=10 recovers essentially fully at design loads and is still climbing at 7; the overloaded 32-s curve is U-limited and dominated by run-to-run spread, so its shape carries no signal.](report-figures/fig33_absorption_window.png)
<a id="s3-5"></a>
### 3.5 The joint (W, U) region — the levers are hierarchical

View File

@ -45,10 +45,17 @@ Sweep studies are committed configs, run with `make <name>` (writes a dated `run
| soft uncle inclusion (reward share vs emergent `p_ref`) | `scripts/reward_mandate.py` | [§6.8](tsi-report-3-robustness-and-incentives.md#s6-8), [§6.9](tsi-report-3-robustness-and-incentives.md#s6-9) |
| U = 0 fluctuation series (zero delay, k ∈ {256, 1024, 2160}) | `scripts/appendix_fluct.py --run` (`runs/fluctuation_u0.parquet`) | [Appendix B](#sB) |
| CI smoke grid + analytic sanity checks | `configs/smoke.yaml`; `scripts/verify.py` (`make verify`) — validation only, no figures | — |
| **countable vs unrestricted referencing** (accuracy over delay × U; measured `q_u`/recovery `r`) | `configs/countable-vs-old.yaml` run twice — default and with `--old`; figures + significance table by `scripts/plot_countable_vs_old.py` | [§2.1](tsi-report-2-accuracy-and-design.md#s2-1), [§3.2](tsi-report-2-accuracy-and-design.md#s3-2) |
| **fine delay band** (δ_max 15 at 40 replicates; tight CI on the model gap in the design regime) | `configs/fine-delay.yaml` run twice — default and with `--old`; figure + table by `scripts/plot_fine_delay.py` | [§3.2a](tsi-report-2-accuracy-and-design.md#s3-2a) |
| **window absorption sweep** (`W` in expected block-intervals, `w_u = W/f` derived) | `configs/absorption-window.yaml`; figure by `scripts/plot_countable_vs_old.py` | [§3.4](tsi-report-2-accuracy-and-design.md#s3-4) |
**Uncle-model convention.** The simulator's default is the **countable** model — first-fork candidates only, derived window `w_u = W/f`, occupied-slot exclusion, per-reference counting rules ([§2.1](tsi-report-2-accuracy-and-design.md#s2-1)). The **unrestricted** baseline is preserved in the code and selected with `--old` on `tsi-sweep`/`tsi-verify`. Its RNG key is byte-identical to the pre-restriction key, so `--old` **bit-reproduces the earlier runs**: a `rho-boundary` cell (δ_max = 8, U = 2, k = 256, N = 1 000) re-run under `--old` matches the committed `2026-07-27_195627_rho-boundary` parquet with `max |Δ| = 0` on every epoch and every metric. Studies in the table above that predate the countable default were produced under the unrestricted model and reproduce exactly under `--old`; the comparison rows quantify where the two models differ, and in the design regime (`ρ < 1`) no difference is resolvable, so those findings carry over unchanged.
Because the two models draw independent RNG streams, every countable-vs-unrestricted comparison is **unpaired**, and its resolution is set by the replicate spread rather than by the effect size. Each comparison sweep therefore includes a `U = 0` arm as a **negative control**: with no uncles the models are identical by construction, so the measured `U = 0` gap is a direct reading of the noise floor at that delay and replicate count. At `δ_max = 32` with 5 replicates that floor is ≈ 0.23 in `D̂/D` — larger than several real effects elsewhere in the grid — which is why [§3.2](tsi-report-2-accuracy-and-design.md#s3-2) reports a `t` statistic per cell and why the design regime is measured separately at 40 replicates ([§3.2a](tsi-report-2-accuracy-and-design.md#s3-2a)).
All studies were **re-run on 2026-07-23/24 with the corrected slot-counting mechanism** ([§2.1](tsi-report-2-accuracy-and-design.md#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`.
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. 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 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. 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/`).
---

View File

@ -156,7 +156,9 @@ src/tsi_sim/ constants config rng stake lottery topology blocktree(+build_tree
theory verify plotting/{style, figures_pernode, make_figures}
configs/ smoke.yaml default.yaml fullscale.yaml
countable-vs-old.yaml absorption-window.yaml (countable-model studies)
fine-delay.yaml (delay 1-5 at 40 replicates: the design band, high precision)
tests/ test_{pernode,config,rng,lottery,blocktree,uncles,tsi_counting,stake,
theory,latency,theory_convergence,countable_counting,...}.py
scripts/ plot_countable_vs_old.py (old-vs-countable comparison figures)
scripts/ plot_countable_vs_old.py (countable-vs-unrestricted comparison figures)
plot_fine_delay.py (design-band accuracy + model gap with 95% CIs)
```

View File

@ -1,8 +1,12 @@
# Headline comparison for the countable uncle model (cryptarchia-v1-protocol.md counting
# rules) vs the old pre-redesign model: accuracy vs Blend mixing delay at U in {0,1,2,4}.
# Run TWICE — default (countable) and with --old — same grid; the countable run also
# yields deep_ref_share (the first-fork restriction's rejection rate) and q/q_eff for the
# q_u = q + (1-q) r theory overlay. Latency is in SLOTS (1 slot = 1 s).
# Run TWICE — default (countable) and with --old — same grid; the countable run yields
# q/q_eff for the q_u = q + (1-q) r theory overlay. (deep_ref_share is a drift alarm that
# reads 0 on every real run, NOT a measured rejection rate — see measure.py.) The U=0 rows
# are the NEGATIVE CONTROL: with no uncles the two models are identical by construction, so
# any U=0 gap is pure between-run RNG noise (the models draw different streams by design)
# and bounds what a real countable-vs-old effect must exceed.
# Latency is in SLOTS (1 slot = 1 s).
n_nodes: [1000] # network size
stake_dist: [pareto] # heavy-tailed (realistic) stake distribution
topology: [blend] # Blend mixnet (the multi-slot fork regime)

View File

@ -0,0 +1,35 @@
# High-precision sweep of the LOW mixing-delay region — the intended operating band.
#
# countable-vs-old.yaml samples delay at 4/8/16/32 with 5 replicates, which resolves the
# overload regime but leaves the design regime under-measured: at delay <= 8 every
# countable-vs-old gap there sits inside the replicate noise, so the report can only say
# "no difference detected", not how large a difference is excluded. This sweep spends the
# replicates instead of the range — delay 1..5 at 40 replicates — to put a tight CI on the
# model difference exactly where the parameters are actually chosen.
#
# rho = f * D_vis with D_vis = hops*delta_max/2 + (hops+1)*ell_mean spans ~0.25 (delay 1)
# to ~0.45 (delay 5), so the whole sweep sits well inside rho < 1 and U >= 1 should hold
# the equilibrium at 1.0 under BOTH models. The question is the size of the residual gap.
#
# Run TWICE — default (countable) and with --old — same grid. U=0 is the negative control:
# with no uncles the models are identical by construction, so the U=0 gap measures the
# between-run RNG noise floor at this replicate count and bounds what a real effect must
# exceed. Latency is in SLOTS (1 slot = 1 s).
n_nodes: [1000] # network size
stake_dist: [pareto] # heavy-tailed (realistic) stake distribution
topology: [blend] # Blend mixnet (the multi-slot fork regime)
degree: [6] # peering degree of the d-regular graph
link_latency_mean: [0.5] # natural geographic transport (sub-slot)
link_latency_dist: [geo] # real-world geographic band mixture
blend_hops: [3] # fixed hop count; delay is the swept knob
blend_delay_max: [1.0, 2.0, 3.0, 4.0, 5.0] # THE fine region (integer slots)
max_uncles: [0, 1, 2, 4] # U: 0 = negative control, then the levers
uncle_strategy: [oldest] # spec selection: oldest-first fill
init_dest: [common] # per-node initial D_est from agreement
replicates: 40 # 8x countable-vs-old.yaml: this is the point
base: # per-run settings shared by every cell
k: 2160 # true security parameter
epochs: 20 # equilibrium within ~2 epochs; burn 50%
f: 0.03333333333333333 # slot activation coefficient (1/30)
genesis_d_factor: 0.5 # start near true stake (cheap epoch 0)
early_stop: true

View File

@ -54,7 +54,7 @@ def fig1(df: pd.DataFrame) -> None:
# Both panels are indexed by the estimate that DROVE each epoch's production: the
# start-of-epoch estimate `mean_ratio_in` (block rate depends on it, and at epoch 0 it
# IS the genesis guess, matching the legend). Plotting end-of-epoch `mean_ratio` here
# would show the already-updated value at epoch 0 and offset the two panels by one epoch.
# would show the already-updated value at epoch 0 and offset the panels by one epoch.
s = (d[(d.gdf == gdf) & (d.u == u)]
.groupby("epoch").agg(rate=("n_blocks", "mean"), ratio=("mean_ratio_in", "mean")))
rate = s.rate / (10 * int(2160 / F)) # blocks per slot

View File

@ -12,8 +12,11 @@ and renders (into --out):
dashed = old, one Okabe-Ito hue per U (color follows U).
cvo_prediction_vs_sim predicted log(1-f)/log(1-f/q_u) from the MEASURED q_u vs the
simulated equilibrium the q -> q_u reduction check.
cvo_recovery_vs_delay measured recovery r = (q_eff - q)/(1 - q) and the first-fork
rejection share (deep_ref_share) vs delay.
cvo_recovery_vs_delay measured recovery r = (q_eff - q)/(1 - q) and the non-recovered
waste share 1-r vs delay. (NOT deep_ref_share: under joint
countable selection+counting that is identically 0 the
first-fork restriction acts at selection, so it shows up
inside 1-r alongside capacity losses.)
absorption_window equilibrium vs the window absorption parameter W per delay.
Usage:
@ -31,8 +34,8 @@ import numpy as np
import pandas as pd
from tsi_sim.plotting import style
from tsi_sim.plotting.figures_pernode import equilibrium
from tsi_sim.theory import expected_ratio
from tsi_sim.plotting.figures_pernode import equilibrium, recovery_rate, sem
from tsi_sim.theory import expected_ratio, q_effective
DELAY = "blend_delay_max"
@ -42,37 +45,56 @@ def _load(run_dir: str | Path) -> pd.DataFrame:
def _eq(df: pd.DataFrame, extra_cols: tuple[str, ...] = ()) -> pd.DataFrame:
"""Equilibrium (post-burn) means per (delay, U) cell, averaged over replicates."""
"""Equilibrium (post-burn) per (delay, U) cell: replicate mean, SEM and count.
``equilibrium`` returns one row per (config, replicate); the spread across those
replicates is the only uncertainty estimate available, and at high delay it is large
enough to swamp the model difference so every consumer here carries it.
"""
eq = equilibrium(df)
keys = [DELAY, "max_uncles", *extra_cols]
agg = {"mean_ratio": "mean", "mean_q": "mean", "mean_q_eff": "mean"}
if "deep_ref_share" in eq.columns:
agg["deep_ref_share"] = "mean"
return eq.groupby(keys, as_index=False).agg(agg)
agg = {"mean_ratio": ("mean_ratio", "mean"),
"sem_ratio": ("mean_ratio", sem),
"n_rep": ("mean_ratio", "size"),
"mean_q": ("mean_q", "mean"),
"mean_q_eff": ("mean_q_eff", "mean")}
return eq.groupby(keys, as_index=False).agg(**agg)
def fig_accuracy_vs_delay(cnt: pd.DataFrame, old: pd.DataFrame) -> plt.Figure:
fig, ax = plt.subplots()
"""Accuracy vs delay per U, with replicate SEM bars.
U=0 is the NEGATIVE CONTROL: with no uncles the two models are identical by
construction, so the visible countable-vs-old gap on that pair of curves is pure
between-run RNG noise and calibrates what a real difference has to beat.
"""
fig, ax = plt.subplots(figsize=(6.4, 4.2))
us = sorted(cnt["max_uncles"].unique())
for i, u in enumerate(us):
c = style.color_for(i)
a = cnt[cnt.max_uncles == u].sort_values(DELAY)
b = old[old.max_uncles == u].sort_values(DELAY)
ax.plot(a[DELAY], a.mean_ratio, "-o", color=c, label=f"U={u} countable", ms=4)
ax.plot(b[DELAY], b.mean_ratio, "--s", color=c, label=f"U={u} old", ms=4,
alpha=0.75)
ctl = " (control)" if u == 0 else ""
ax.errorbar(a[DELAY], a.mean_ratio, yerr=a.sem_ratio, fmt="-o", color=c,
label=f"U={u} countable{ctl}", ms=4, capsize=2, lw=1.2)
ax.errorbar(b[DELAY], b.mean_ratio, yerr=b.sem_ratio, fmt="--s", color=c,
label=f"U={u} unrestricted{ctl}", ms=4, capsize=2, alpha=0.75, lw=1.2)
ax.axhline(1.0, color="0.4", lw=0.8, ls=":")
ax.set_xlabel("max per-relay mixing delay (slots)")
ax.set_ylabel(r"equilibrium $\hat{D}/D_{true}$")
ax.set_title("Accuracy vs delay: countable (solid) vs old (dashed) uncle model")
ax.legend(ncol=2)
ax.set_title("Accuracy vs delay: countable (solid) vs unrestricted (dashed) referencing")
ax.legend(ncol=2, fontsize="x-small")
return fig
def fig_prediction_vs_sim(cnt: pd.DataFrame, f: float) -> plt.Figure:
fig, ax = plt.subplots(figsize=(4.6, 4.4))
sub = cnt[cnt.max_uncles > 0]
pred = expected_ratio(f, sub.mean_q_eff.to_numpy())
# Reconstruct q_u through the theory identity the report quotes, q_u = q + (1-q) r,
# rather than reading mean_q_eff straight off the parquet: the figure then exercises
# the same closed form the text claims.
r = recovery_rate(sub.mean_q.to_numpy(), sub.mean_q_eff.to_numpy())
pred = expected_ratio(f, q_effective(sub.mean_q.to_numpy(), r))
us = sorted(sub["max_uncles"].unique())
for i, u in enumerate(us):
m = (sub.max_uncles == u).to_numpy()
@ -96,8 +118,7 @@ def fig_recovery_vs_delay(cnt: pd.DataFrame) -> plt.Figure:
us = [u for u in sorted(cnt["max_uncles"].unique()) if u > 0]
for i, u in enumerate(us):
a = cnt[cnt.max_uncles == u].sort_values(DELAY)
denom = np.maximum(1.0 - a.mean_q.to_numpy(), 1e-12)
r = (a.mean_q_eff.to_numpy() - a.mean_q.to_numpy()) / denom
r = recovery_rate(a.mean_q.to_numpy(), a.mean_q_eff.to_numpy())
ax1.plot(a[DELAY], r, "-o", color=style.color_for(i), label=f"U={u}", ms=4)
ax2.semilogy(a[DELAY], np.maximum(1.0 - r, 1e-4), "-o",
color=style.color_for(i), label=f"U={u}", ms=4)
@ -114,13 +135,20 @@ def fig_recovery_vs_delay(cnt: pd.DataFrame) -> plt.Figure:
def fig_absorption_window(absw: pd.DataFrame) -> plt.Figure:
fig, ax = plt.subplots()
"""Accuracy vs the derived window, with replicate SEM bars.
The bars matter here: at the 32-slot delay the run-to-run spread is the dominant
feature (replicate sd up to ~0.22), so that curve's shape is not interpretable as a
trend only its ceiling is, and the ceiling is set by U=1, not by the window.
"""
fig, ax = plt.subplots(figsize=(6.0, 4.0))
eq = equilibrium(absw)
agg = eq.groupby([DELAY, "window_absorption"], as_index=False).mean_ratio.mean()
agg = eq.groupby([DELAY, "window_absorption"], as_index=False).agg(
mean_ratio=("mean_ratio", "mean"), sem_ratio=("mean_ratio", sem))
for i, d in enumerate(sorted(agg[DELAY].unique())):
a = agg[agg[DELAY] == d].sort_values("window_absorption")
ax.plot(a.window_absorption, a.mean_ratio, "-o", color=style.color_for(i),
label=f"delay={d:g}", ms=4)
ax.errorbar(a.window_absorption, a.mean_ratio, yerr=a.sem_ratio, fmt="-o",
color=style.color_for(i), label=f"delay={d:g}", ms=4, capsize=2, lw=1.2)
ax.axhline(1.0, color="0.4", lw=0.8, ls=":")
ax.set_xlabel("window absorption parameter W (expected block-intervals)")
ax.set_ylabel(r"equilibrium $\hat{D}/D_{true}$")
@ -151,17 +179,32 @@ def main() -> None:
written += style.save(fig_recovery_vs_delay(cnt), out / "cvo_recovery_vs_delay", prov)
written += style.save(fig_absorption_window(_load(args.absorption)),
out / "absorption_window", prov)
# headline numbers for the report / analysis doc
# Headline numbers for the report. Every countable-vs-old gap is printed with the
# two-sample t = |diff| / SE(diff) over replicates; |t| < 2 means the cell does NOT
# resolve a model difference at this replicate count and must not be read as one.
# The U=0 rows are the negative control (models identical by construction, so their
# t is a pure noise reading).
print(f"{'cell':<14} {'countable':>17} {'old':>17} {'diff':>9} {'t':>6} verdict")
for u in sorted(cnt["max_uncles"].unique()):
for _, row in cnt[cnt.max_uncles == u].sort_values(DELAY).iterrows():
q, qu = row.mean_q, row.mean_q_eff
r = (qu - q) / max(1.0 - q, 1e-12)
r = float(recovery_rate(q, qu))
o = old[(old.max_uncles == u) & (old[DELAY] == row[DELAY])]
old_ratio = float(o.mean_ratio.iloc[0]) if len(o) else float("nan")
print(f"U={u} delay={row[DELAY]:>5g} countable={row.mean_ratio:.4f} "
f"old={old_ratio:.4f} q={q:.4f} q_u={qu:.4f} r={r:.4f} "
f"pred={float(expected_ratio(f, qu)):.4f} "
f"deep={row.get('deep_ref_share', float('nan')):.4f}")
if not len(o):
continue
orow = o.iloc[0]
diff = row.mean_ratio - orow.mean_ratio
se = float(np.hypot(row.sem_ratio, orow.sem_ratio))
t = abs(diff) / se if se > 0 else float("inf")
verdict = ("CONTROL (must be 0)" if u == 0 else
"resolved" if t >= 2 else "NOT RESOLVED (noise)")
print(f"U={u} delay={row[DELAY]:>5g} "
f"{row.mean_ratio:.4f}+-{row.sem_ratio:.4f} "
f"{orow.mean_ratio:.4f}+-{orow.sem_ratio:.4f} "
f"{diff:+.4f} {t:6.2f} {verdict}")
print(f"{'':>14} q={q:.4f} q_u={qu:.4f} r={r:.4f} "
f"pred={float(expected_ratio(f, q_effective(q, r))):.4f} "
f"n_rep={int(row.n_rep)}")
print(f"wrote {len(written)} files -> {out}")

View File

@ -0,0 +1,200 @@
"""High-precision figures for the LOW mixing-delay band (the design regime).
countable-vs-old.yaml samples delay at 4/8/16/32 with 5 replicates. That resolves the
overload regime but leaves the design regime under-measured: every countable-vs-unrestricted
gap at delay <= 8 sits inside the replicate noise there, so the only honest statement is
"no difference detected" with no bound on how large an undetected difference could be.
fine-delay.yaml spends replicates instead of range (delay 1..5, 40 replicates) to turn that
into a real bound. Consumes:
tsi-sweep --config configs/fine-delay.yaml --label fine-countable
tsi-sweep --config configs/fine-delay.yaml --old --label fine-old
and renders (into --out):
fine_accuracy_vs_delay equilibrium D/D_true vs delay 1..5, countable (solid) vs
unrestricted (dashed) per U, replicate-SEM bars.
fine_gap_vs_delay THE precision figure: the countable - unrestricted gap with 95%
CIs, against the U=0 negative-control band. A CI straddling zero
means no difference at this power; the band shows the floor.
Usage:
python scripts/plot_fine_delay.py --countable RUNDIR --old RUNDIR \
--out figures/fine-delay
"""
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from tsi_sim.plotting import style
from tsi_sim.plotting.figures_pernode import equilibrium, sem
DELAY = "blend_delay_max"
# Normal approximation: with 40 replicates per arm the t-quantile is within ~2% of 1.96,
# and the replicate spread itself is the dominant uncertainty, so 1.96 is precise enough.
Z95 = 1.96
def _load(run_dir: str | Path) -> pd.DataFrame:
return pd.read_parquet(Path(run_dir) / "results.parquet")
def _cells(df: pd.DataFrame) -> pd.DataFrame:
"""Per (delay, U): replicate mean, SEM and count of the equilibrium accuracy."""
return equilibrium(df).groupby([DELAY, "max_uncles"], as_index=False).agg(
mean_ratio=("mean_ratio", "mean"),
sem_ratio=("mean_ratio", sem),
n_rep=("mean_ratio", "size"),
mean_q=("mean_q", "mean"),
mean_q_eff=("mean_q_eff", "mean"))
def gaps(cnt: pd.DataFrame, old: pd.DataFrame) -> pd.DataFrame:
"""countable - unrestricted per cell, with the unpaired SE and 95% CI half-width."""
m = cnt.merge(old, on=[DELAY, "max_uncles"], suffixes=("_c", "_o"))
m["gap"] = m.mean_ratio_c - m.mean_ratio_o
m["se"] = np.hypot(m.sem_ratio_c, m.sem_ratio_o)
m["ci95"] = Z95 * m.se
m["t"] = np.where(m.se > 0, np.abs(m.gap) / m.se.replace(0, np.nan), np.inf)
return m.sort_values(["max_uncles", DELAY])
def fig_accuracy(cnt: pd.DataFrame, old: pd.DataFrame) -> plt.Figure:
fig, ax = plt.subplots(figsize=(6.4, 4.2))
for i, u in enumerate(sorted(cnt["max_uncles"].unique())):
c = style.color_for(i)
a = cnt[cnt.max_uncles == u].sort_values(DELAY)
b = old[old.max_uncles == u].sort_values(DELAY)
ctl = " (control)" if u == 0 else ""
ax.errorbar(a[DELAY], a.mean_ratio, yerr=a.sem_ratio, fmt="-o", color=c,
label=f"U={u} countable{ctl}", ms=4, capsize=2, lw=1.2)
ax.errorbar(b[DELAY], b.mean_ratio, yerr=b.sem_ratio, fmt="--s", color=c,
label=f"U={u} unrestricted{ctl}", ms=4, capsize=2, alpha=0.75, lw=1.2)
ax.axhline(1.0, color="0.4", lw=0.8, ls=":")
ax.set_xlabel("max per-relay mixing delay (slots)")
ax.set_ylabel(r"equilibrium $\hat{D}/D_{true}$")
ax.set_title("Design-regime accuracy: countable vs unrestricted referencing")
ax.legend(ncol=2, fontsize="x-small")
return fig
def pooled_by_delay(g: pd.DataFrame) -> pd.DataFrame:
"""Inverse-variance pooled gap across the U >= 1 arms, per delay.
Individual cells are underpowered against a sub-0.1 % effect even at 40 replicates, but
the three uncle caps are independent measurements of the same underlying difference, so
pooling them buys back a factor of ~sqrt(3) and is what actually resolves the trend.
"""
u = g[g.max_uncles > 0]
rows = []
for d, s in u.groupby(DELAY):
w = 1.0 / s.se.to_numpy() ** 2
p = float((s.gap.to_numpy() * w).sum() / w.sum())
e = float(np.sqrt(1.0 / w.sum()))
rows.append({DELAY: d, "gap": p, "se": e, "ci95": Z95 * e,
"t": abs(p) / e if e > 0 else np.inf})
return pd.DataFrame(rows).sort_values(DELAY)
def fig_gap(g: pd.DataFrame) -> plt.Figure:
"""The countable unrestricted gap with 95% CIs, zoomed to the U >= 1 scale.
The U=0 negative control is NOT plotted as a band here: with no uncles the models are
identical by construction, but the unrecovered regime is so noisy that its CI (±0.025)
is ~17x the entire range of the U >= 1 gaps and would fill the axes. Its magnitude is
annotated instead the point being that the control's noise floor lives far outside
anything the uncle arms show, so those arms are measuring signal, not spread.
"""
fig, ax = plt.subplots(figsize=(6.6, 4.2))
pooled = pooled_by_delay(g)
for i, u in enumerate(sorted(g["max_uncles"].unique())):
if u == 0:
continue
a = g[g.max_uncles == u].sort_values(DELAY)
ax.errorbar(a[DELAY], a.gap, yerr=a.ci95, fmt="-o", color=style.color_for(i),
label=f"U={u}", ms=4, capsize=3, lw=1.0, alpha=0.75)
ax.errorbar(pooled[DELAY], pooled.gap, yerr=pooled.ci95, fmt="-D", color="0.15",
label="pooled over U≥1", ms=5, capsize=4, lw=1.8, zorder=5)
ax.axhline(0.0, color="0.3", lw=0.9, ls=":")
ax.set_xlabel("max per-relay mixing delay (slots)")
ax.set_ylabel(r"$\hat{D}/D$ gap: countable $-$ unrestricted")
ax.set_title("The first-fork cost across the design band (95% CI)")
ctl = g[g.max_uncles == 0]
if len(ctl):
band = float(ctl.ci95.max())
span = float(np.abs(np.r_[g[g.max_uncles > 0].gap + g[g.max_uncles > 0].ci95,
g[g.max_uncles > 0].gap - g[g.max_uncles > 0].ci95]).max())
ax.set_ylim(-1.35 * span, 1.35 * span)
ax.text(0.015, 0.03,
f"U=0 negative control (true gap = 0): 95% CI ±{band:.4f}, "
f"{band / span:.0f}× outside this range",
transform=ax.transAxes, fontsize=6.5, alpha=0.75)
ax.legend(fontsize="x-small", ncol=2)
return fig
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--countable", required=True, help="run dir of fine-countable")
ap.add_argument("--old", required=True, help="run dir of fine-old")
ap.add_argument("--out", default="figures/fine-delay")
args = ap.parse_args()
style.apply_style()
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
cnt, old = _cells(_load(args.countable)), _cells(_load(args.old))
g = gaps(cnt, old)
prov = "tsi-sim-pernode fine-delay.yaml (+--old)"
written = []
written += style.save(fig_accuracy(cnt, old), out / "fine_accuracy_vs_delay", prov)
written += style.save(fig_gap(g), out / "fine_gap_vs_delay", prov)
print(f"{'cell':<16} {'countable':>17} {'unrestricted':>17} "
f"{'gap':>9} {'95% CI':>9} {'t':>6} verdict")
for _, r in g.iterrows():
verdict = ("CONTROL (true gap = 0)" if r.max_uncles == 0 else
"resolved" if r.t >= 2 else "no difference resolved")
print(f"U={int(r.max_uncles)} delay={r[DELAY]:>5g} "
f"{r.mean_ratio_c:.4f}+-{r.sem_ratio_c:.4f} "
f"{r.mean_ratio_o:.4f}+-{r.sem_ratio_o:.4f} "
f"{r.gap:+.4f} +-{r.ci95:.4f} {r.t:6.2f} {verdict}")
worst = g[g.max_uncles > 0]
print(f"\nn_rep = {int(g.n_rep_c.min())}/{int(g.n_rep_o.min())} per arm")
print(f"widest 95% CI half-width at U>=1: +-{worst.ci95.max():.4f} "
f"({100 * worst.ci95.max():.2f} pp)")
# Per-cell significance must be read against the number of cells tested: with 15 cells,
# ~0.75 are expected to clear t=2 by chance alone, so quote the Bonferroni threshold.
bonf = 2.935 if len(worst) == 15 else float("nan")
print(f"per-cell: {int((worst.t >= 2).sum())}/{len(worst)} cells with t>=2 "
f"(expected by chance {0.05 * len(worst):.2f}); max t = {worst.t.max():.2f} "
f"vs Bonferroni threshold {bonf:.3f}")
print("\npooled over U>=1 (the three caps measure the same difference):")
pooled = pooled_by_delay(g)
for _, r in pooled.iterrows():
mark = " <-- resolved" if r.t >= 2 else ""
print(f" delay={r[DELAY]:>4g}: {r.gap:+.5f} +-{r.ci95:.5f} t={r.t:5.2f}{mark}")
w = 1.0 / worst.se.to_numpy() ** 2
allp = float((worst.gap.to_numpy() * w).sum() / w.sum())
alle = float(np.sqrt(1.0 / w.sum()))
print(f" whole band: {allp:+.5f} +-{Z95 * alle:.5f} t={abs(allp) / alle:.2f}")
ctl = g[g.max_uncles == 0]
if len(ctl):
print(f"\nU=0 negative control (true gap = 0): |gap| up to {ctl.gap.abs().max():.4f}, "
f"max t = {ctl.t.max():.2f}, 95% CI +-{ctl.ci95.max():.4f} "
f"-> control {'PASSES' if ctl.t.max() < 2 else 'FAILS'}")
print(f"wrote {len(written)} files -> {out}")
if __name__ == "__main__":
main()

View File

@ -12,7 +12,13 @@ Counting models (``countable`` flag; CLI ``--old`` clears it):
(cryptarchia-v1-protocol.md): the reference must be within the window
(``0 < slot_B - slot_U <= w``), the uncle must not lie on the counting chain, and its
**parent must lie on the counting chain** (only the first block of a fork counts).
References failing the parent rule are tallied as ``deep`` diagnostics.
References failing the parent rule are tallied as ``deep``. On a real run that tally is
always ZERO and is a defensive invariant, not a measured rate: countable *selection*
already refuses non-first-fork candidates, and for a chain block ``B`` the producer's
chain below ``B`` is the same ancestor path as the counting chain below ``B``, so the
two rules cannot disagree. A non-zero ``deep`` means selection and counting have drifted
(asserted end-to-end by test_countable_counting.py). Only hand-baked references the
ones the unit tests construct can make it fire.
* **old** every baked reference in the measurement window counts (fork depth ignored),
reproducing the pre-redesign behaviour.
@ -51,9 +57,11 @@ class Measurement:
q: np.ndarray # (N,) honest active-slot fraction
q_eff: np.ndarray # (N,) uncle-recovered fraction
orphan_rate: np.ndarray # (N,)
ref_total: np.ndarray # (N,) distinct referenced uncles examined in the window
ref_deep: np.ndarray # (N,) of those, rejected by the parent-on-chain (first-fork) rule;
# always 0 under the old model (no rule to reject on)
ref_total: np.ndarray # (N,) distinct referenced uncles examined (slot in [0,T); the
# per-reference window check is applied after this tally)
ref_deep: np.ndarray # (N,) of those, rejected by the parent-on-chain (first-fork) rule.
# ALWAYS 0 on a real run under either model — a selection/
# counting drift alarm, not a rate (see the module docstring)
agreement_window: float
agreement_tip: float

View File

@ -27,6 +27,27 @@ CONFIG_COLS = ["n_nodes", "stake_dist", "pareto_shape", "topology", "degree",
GRAPH_TOPOLOGIES = ("regular", "blend")
def sem(x) -> float:
"""Standard error of the mean over replicates (0 for a single replicate).
Replicate spread is the only uncertainty estimate these sweeps carry, and at high
mixing delay it is large enough to swamp the effects being compared so any figure or
table quoting a cell mean should quote this alongside it.
"""
x = np.asarray(x, dtype=float)
return float(x.std(ddof=1) / np.sqrt(len(x))) if len(x) > 1 else 0.0
def recovery_rate(q, q_u):
"""Invert ``theory.q_effective``: ``r = (q_u - q) / (1 - q)``.
The share of wasted active slots that a countable uncle reference puts back into the
count. Clamped denominator so a saturated ``q -> 1`` cell stays finite.
"""
q, q_u = np.asarray(q, dtype=float), np.asarray(q_u, dtype=float)
return (q_u - q) / np.maximum(1.0 - q, 1e-12)
def _lat_axis(topo: str) -> tuple[str, str]:
"""(dataframe column, axis label) for the dominant latency knob of a graph topology."""
if topo == "blend":

View File

@ -16,79 +16,21 @@ block id only (no slot exclusion).
Selected refs are baked at production and immutable once adopted, so density counting
stays view-independent under both models.
``select_uncles_at_production`` is the ONLY selection entry point blocktree.py calls it
once per produced block. Tests drive it through the same signature (see
tests/test_uncles.py ``annotate_via_production``) rather than through a parallel offline
implementation, so there is no second copy of the rules to drift out of sync.
"""
from __future__ import annotations
import numpy as np
from .blocktree import GENESIS, BlockTree
from .blocktree import GENESIS
from .config import SimConfig
def _orphans_sorted(tree: BlockTree, canonical_ids: list[int]) -> tuple[np.ndarray, np.ndarray]:
"""Return orphan block ids sorted by (slot, id) and their slots."""
canonical = np.zeros(tree.n_blocks, dtype=bool)
canonical[canonical_ids] = True
all_real = np.arange(1, tree.n_blocks)
orphan_ids = all_real[~canonical[1:]]
orphan_slots = tree.slot[orphan_ids]
order = np.lexsort((orphan_ids, orphan_slots)) # by slot, then id
return orphan_ids[order], orphan_slots[order]
def annotate_uncles(
tree: BlockTree, canonical_ids: list[int], config: SimConfig, rng: np.random.Generator
) -> None:
"""Fill ``tree.uncles[B]`` for every canonical block ``B`` per the selection rule (offline).
Countable model: candidates are restricted to orphans whose parent is canonical (first
fork blocks), slots already occupied on the chain are excluded, and at most one uncle
per slot is picked. Old model: any orphan in the window, dedup by id only.
"""
u_max = config.max_uncles
if u_max <= 0:
return
w = config.effective_uncle_window
orphan_ids, orphan_slots = _orphans_sorted(tree, canonical_ids)
if orphan_ids.size == 0:
return
countable = config.uncle_model != "old"
occupied: set[int] = set()
if countable:
canonical = set(canonical_ids)
keep = [i for i in range(orphan_ids.size)
if int(tree.parent[orphan_ids[i]]) == GENESIS
or int(tree.parent[orphan_ids[i]]) in canonical]
orphan_ids, orphan_slots = orphan_ids[keep], orphan_slots[keep]
if orphan_ids.size == 0:
return
occupied = {int(tree.slot[b]) for b in canonical_ids}
referenced: set[int] = set()
# oldest canonical block first
for b in reversed(canonical_ids):
sb = int(tree.slot[b])
lo = int(np.searchsorted(orphan_slots, sb - w, side="left")) # slot_U >= sb - w
hi = int(np.searchsorted(orphan_slots, sb, side="left")) # slot_U < sb
if hi <= lo:
continue
window_ids = orphan_ids[lo:hi] # already oldest-first
if countable:
window_ids = np.array(
[x for x in window_ids.tolist() if int(tree.slot[x]) not in occupied],
dtype=np.int64,
)
selected = _select(window_ids, referenced, config, rng,
slot=tree.slot, one_per_slot=countable)
if selected:
tree.uncles[b] = tuple(selected)
referenced.update(selected)
if countable:
occupied.update(int(tree.slot[u]) for u in selected)
def _select(
window_ids: np.ndarray,
referenced: set[int],

View File

@ -82,3 +82,28 @@ def test_window_recheck_rejects_stale_reference():
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
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()}")

View File

@ -38,3 +38,46 @@ def test_fixed_point_bias_about_one_percent():
b = theory.fixed_point_bias(F)
assert abs(b - (F / (33 / 1000))) < 1e-12
assert 1.005 < b < 1.02
def test_q_effective_interpolates_between_q_and_one():
# r = 0 -> no recovery (chain-only q); r = 1 -> every wasted slot recovered (q_u = 1).
for q in (0.3, 0.65, 0.9):
assert abs(float(theory.q_effective(q, 0.0)) - q) < 1e-15
assert abs(float(theory.q_effective(q, 1.0)) - 1.0) < 1e-15
# monotone and strictly between for partial recovery
half = float(theory.q_effective(q, 0.5))
assert q < half < 1.0
def test_q_effective_round_trips_the_measured_recovery_rate():
# The identity the report quotes: given measured q and q_u, r = (q_u - q)/(1 - q)
# reconstructs q_u exactly. This is how plot_countable_vs_old.py derives its overlay.
for q, q_u in ((0.3129, 0.6147), (0.5380, 0.9874), (0.7485, 0.9992)):
r = (q_u - q) / (1.0 - q)
assert abs(float(theory.q_effective(q, r)) - q_u) < 1e-12
def test_q_effective_recovers_unbiased_equilibrium():
# Full recovery must lift the equilibrium to exactly 1 for any starting q.
for q in (0.3, 0.65, 0.9):
assert abs(float(theory.expected_ratio(F, theory.q_effective(q, 1.0))) - 1.0) < 1e-12
def test_window_miss_prob_decays_as_exp_minus_w():
# P(no canonical block in w_u = W/f slots) = (1-f)^(W/f) = exp(W * ln(1-f)/f).
# The docstring's "~ e^-W" is the f -> 0 limit: ln(1-f)/f = -(1 + f/2 + ...) = -1.0170
# at f = 1/30, so the true decay is slightly FASTER than e^-W, by a factor that grows
# with W (16% low by W = 10). Assert the exact form, and bracket the heuristic.
rate = np.log(1.0 - F) / F
assert -1.02 < rate < -1.0
for w_abs in (1.0, 3.0, 10.0):
p = float(theory.window_miss_prob(F, w_abs))
assert abs(p - (1.0 - F) ** (w_abs / F)) < 1e-15
assert abs(p - np.exp(rate * w_abs)) < 1e-15 # exact closed form
assert np.exp(-1.02 * w_abs) < p < np.exp(-w_abs) # brackets the e^-W heuristic
# the spec default W = 10 makes the window a negligible loss channel
assert float(theory.window_miss_prob(F, 10.0)) < 1e-4
# strictly decreasing in W
ps = [float(theory.window_miss_prob(F, w)) for w in (1, 2, 3, 5, 7, 10)]
assert all(a > b for a, b in zip(ps[:-1], ps[1:], strict=True))

View File

@ -2,7 +2,7 @@ import numpy as np
from tsi_sim.blocktree import BlockTree
from tsi_sim.config import SimConfig
from tsi_sim.uncles import annotate_uncles
from tsi_sim.uncles import select_uncles_at_production
def make_tree(slots, parents, heights, leaders):
@ -16,6 +16,23 @@ def make_tree(slots, parents, heights, leaders):
)
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(
@ -31,7 +48,7 @@ def _canonical_and_orphan_tree():
def test_oldest_selection_and_window():
tree, canonical = _canonical_and_orphan_tree()
cfg = SimConfig(max_uncles=1, uncle_window=300, uncle_strategy="oldest")
annotate_uncles(tree, canonical, cfg, np.random.default_rng(0))
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}
@ -40,7 +57,7 @@ def test_oldest_selection_and_window():
def test_no_uncles_when_u_zero():
tree, canonical = _canonical_and_orphan_tree()
cfg = SimConfig(max_uncles=0)
annotate_uncles(tree, canonical, cfg, np.random.default_rng(0))
annotate_via_production(tree, canonical, cfg, np.random.default_rng(0))
assert all(tree.uncles[b] == () for b in canonical)
@ -49,7 +66,7 @@ def test_window_excludes_out_of_range_orphan():
# 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_uncles(tree, canonical, cfg, np.random.default_rng(0))
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()
@ -61,14 +78,14 @@ def test_countable_window_is_derived_from_absorption():
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_uncles(tree, canonical, cfg, np.random.default_rng(0))
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_uncles(tree2, canonical2, cfg2, np.random.default_rng(0))
annotate_via_production(tree2, canonical2, cfg2, np.random.default_rng(0))
assert {u for b in canonical2 for u in tree2.uncles[b]} == set()
@ -88,8 +105,8 @@ def test_random_strategy_deterministic_and_capped():
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_uncles(tree_a, canon, cfg, np.random.default_rng(7))
annotate_uncles(tree_b, canon, cfg, np.random.default_rng(7))
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
@ -99,18 +116,20 @@ def test_random_p_one_matches_oldest():
import numpy as np
tree_r, canon = _wide_orphan_tree()
tree_o, _ = _wide_orphan_tree()
annotate_uncles(tree_r, canon, SimConfig(max_uncles=2, uncle_strategy="random",
uncle_random_p=1.0), np.random.default_rng(1))
annotate_uncles(tree_o, canon, SimConfig(max_uncles=2, uncle_strategy="oldest"),
np.random.default_rng(1))
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_uncles(tree, canon, SimConfig(max_uncles=4, uncle_strategy="random",
uncle_random_p=0.0), np.random.default_rng(1))
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)
@ -124,7 +143,7 @@ def test_dedup_across_ancestors():
)
canonical = [4, 3, 1]
cfg = SimConfig(max_uncles=4, uncle_window=300, uncle_strategy="oldest")
annotate_uncles(tree, canonical, cfg, np.random.default_rng(0))
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
@ -147,7 +166,7 @@ def _deep_fork_tree():
def test_countable_excludes_deep_fork_blocks():
tree, canonical = _deep_fork_tree()
cfg = SimConfig(max_uncles=4)
annotate_uncles(tree, canonical, cfg, np.random.default_rng(0))
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
@ -155,7 +174,7 @@ def test_countable_excludes_deep_fork_blocks():
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_uncles(tree, canonical, cfg, np.random.default_rng(0))
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
@ -170,7 +189,7 @@ def test_countable_excludes_occupied_slots_and_dedups_per_slot():
)
canonical = [5, 1]
cfg = SimConfig(max_uncles=4)
annotate_uncles(tree, canonical, cfg, np.random.default_rng(0))
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}
@ -196,3 +215,53 @@ def test_production_selection_countable_rules():
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