From 74ee06047cbdc6848f6252f0a84c8770dd2b8681 Mon Sep 17 00:00:00 2001 From: Marcin Pawlowski Date: Mon, 10 Aug 2026 11:47:51 +0200 Subject: [PATCH] Make the model carry-over metric-specific instead of wholesale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9 claimed that studies predating the countable model "carry over unchanged" because no difference is resolvable at ρ < 1. That is true of accuracy and false of the reference rate, and asserting it wholesale is what let the §8.4 capstone sit at p_ref = 1.000 for weeks after the redesign made it 0.944. Measured per metric off the committed paired band (δ_max 1–5, U ≥ 1, common random numbers, so the uncle rule is the only difference): D̂/D 0.9994 vs 1.0000 +0.00055 t = 4.9 carries over fork rate 0.2256 vs 0.2255 -0.00010 t = -2.0 carries over orphan rate 0.2256 vs 0.2255 -0.00009 t = -2.2 carries over tip agreement 0.9943 vs 0.9950 +0.00070 t = 0.9 carries over p_ref 0.9260 vs 0.9998 +0.07381 t = 155.0 RE-MEASURE p_ref fails in kind, not in degree: the unrestricted rule makes every in-window orphan referenceable, so its ≈1.0 restates the model rather than measuring the design. Any pre-countable p_ref is wrong by ~7 points. Also corrects §6.7(1), which still asserted p_ref ≈ 1 for genuine latency orphans — §6.8 had been corrected to the measured 0.944 but the earlier claim was left standing — and drops "no difference is resolvable" from §9, which contradicted §3.2a's pooled t = 5.7. Adds scripts/model_carryover.py so the verdict is reproducible rather than asserted, and a §9 study-table row. deep_ref_share is unaffected (0 by construction under the countable rule); no other reference-structure metric is quoted from a pre-countable run. Co-Authored-By: Claude Opus 5 --- reports/tsi/README.md | 17 ++++- .../scripts/model_carryover.py | 66 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tools/simulators/tsi/tsi-sim-pernode/scripts/model_carryover.py diff --git a/reports/tsi/README.md b/reports/tsi/README.md index 0a4800e..694faf3 100644 --- a/reports/tsi/README.md +++ b/reports/tsi/README.md @@ -660,7 +660,7 @@ At `α` = 0.4 an adversary earning exactly what honest mining would pay it drive Everything above measures *blocks*. Whether an attack **pays** depends on the reward schedule, which is a design choice. We add one as a configurable option (`selfish.RewardParams`): a block reward (`= 1`), an **uncle reward** `w_u` (a reward weight — distinct from the uncle *window*, which this report calls `W`) paid to the *producer* of an orphaned block that is referenced as an uncle, and a **nephew reward** `w_n` paid to the block that *references* it. The model has five knobs in total: `w_u`, `w_n`, the honest-orphan reference rate `p_ref` (defined below), the attacker's self-uncling rate `p_ref_adv` (= 1 for a rational attacker), and `adv_nephew` — the fraction of referenced honest orphans whose *nephew* reward the attacker's own canonical blocks capture (0 without a forced-inclusion mandate, since honest blocks do the referencing). Three effects, all adversarially analysed. -**(1) It compensates orphaned honest producers (fairness).** A block-only schedule pays an orphaned honest block **nothing** — the same "off-chain, uncounted" fate that drives both the [§3.2](#s3-2) latency under-count and the losses a selfish attacker inflicts. Define the **reference rate** `p_ref` = the probability that an orphaned honest block is referenced as an uncle by some canonical block within `W`. An uncle reward `w_u` refunds the loss in proportion to `p_ref`: honest recovery of *mined* value rises with `w_u` toward `1.0` (e.g. in the α = 0.4 selfish race, `0.61 → 0.80 → 1.00` for `w_u = 0 → 0.5 → 1` at `p_ref = 1`; `fig15`, right, plots recovery vs `p_ref`). The same references that restore the TSI *density* ([§6.6](#s6-6), `D̂`) restore the *reward*. **This benefit is robust for the case it is designed for** — a genuine latency orphan is *published*, straddled by *honest* canonical blocks that reference it, and (crucially) the **first block of its fork**, so `p_ref ≈ 1` and the refund is available in full. +**(1) It compensates orphaned honest producers (fairness).** A block-only schedule pays an orphaned honest block **nothing** — the same "off-chain, uncounted" fate that drives both the [§3.2](#s3-2) latency under-count and the losses a selfish attacker inflicts. Define the **reference rate** `p_ref` = the probability that an orphaned honest block is referenced as an uncle by some canonical block within `W`. An uncle reward `w_u` refunds the loss in proportion to `p_ref`: honest recovery of *mined* value rises with `w_u` toward `1.0` (e.g. in the α = 0.4 selfish race, `0.61 → 0.80 → 1.00` for `w_u = 0 → 0.5 → 1` at `p_ref = 1`; `fig15`, right, plots recovery vs `p_ref`). The same references that restore the TSI *density* ([§6.6](#s6-6), `D̂`) restore the *reward*. **This benefit is robust for the case it is designed for** — a genuine latency orphan is *published*, straddled by *honest* canonical blocks that reference it, and *usually* the **first block of its fork**, so the refund is available for most of them. "Usually" is the operative word and it is measured, not assumed: the emergent `p_ref` is **0.93–0.94**, not ≈ 1, because the first-fork restriction leaves the rest structurally unreferenceable ([§6.8](#s6-8), [§8.4](#s8-4)). That is far above what the schedule needs (`≳ 0.3`), but it is a ceiling on fairness, not a full refund. **But it cannot reach the orphans a private chain makes, at any `w_u`.** The recovery figures above are computed on the SM1 race, where the attacker acts the moment the honest branch reaches length 1 and so every orphan it makes is a first-fork block. A private-chain override discards a *chain*, and only its first block is referenceable ([§2.1](#s2-1)) — measured in the per-node engine, 20–40 % of the honest blocks such an attack destroys can never be referenced by anyone ([§6.8](#s6-8)). An uncle reward refunds `w_u` per *referenced* orphan, so those producers are uncompensatable by construction: not underpaid because `p_ref` is low and could be raised, but unreachable because no valid block may name them. The fairness ceiling is therefore the same first-fork ceiling that caps the density repair, and it binds hardest on exactly the honest miners an attacker targets. This is a limit of the *counting rule*, not of the reward schedule, so no choice of `w_u`/`w_n` addresses it; what a schedule can still do is avoid *compounding* it, which is what the [§6.7](#s6-7)(a) `w_u + w_n < 1` invariant does. @@ -1040,8 +1040,21 @@ Sweep studies are committed configs, run with `make ` (writes a dated `run | **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 0–8 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) | +| **which metrics survive the model change** (the per-metric carry-over verdict, off the paired band) | `scripts/model_carryover.py`, from the paired `fine-paired-*` runs | [§9](#s9) below, [§6.8](#s6-8), [§8.4](#s8-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](#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. +**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](#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`. + +**What carries over, and what does not.** The carry-over is **metric-specific**, and asserting it wholesale is what let the [§8.4](#s8-4) capstone go stale. Reading every metric off the same 40-replicate paired sweep of the design band (`δ_max` 1–5, `U ≥ 1`, both arms sharing stake, graph and lottery, so the uncle rule is the only difference — `scripts/model_carryover.py`): + +| metric | countable | unrestricted | paired Δ | t | +|---|---|---|---|---| +| `D̂/D` | 0.9994 | 1.0000 | +0.00055 ± 0.00011 | 4.9 | +| fork rate | 0.2256 | 0.2255 | −0.00010 ± 0.00005 | −2.0 | +| orphan rate | 0.2256 | 0.2255 | −0.00009 ± 0.00004 | −2.2 | +| tip agreement | 0.9943 | 0.9950 | +0.00070 ± 0.00075 | 0.9 | +| **`p_ref`** | **0.9260** | **0.9998** | **+0.0738 ± 0.0005** | **155** | + +**Accuracy, fork structure and consensus carry over; the reference rate does not.** The first four differ by at most 0.06 % — resolvable with 40 paired replicates ([§3.2a](#s3-2a)), negligible against the ±0.9 % per-epoch noise floor of [Appendix B](#sB), and in the same direction and magnitude the countable cost is reported at throughout. `p_ref` is different in kind: under the unrestricted rule *every* in-window orphan is referenceable, so `p_ref ≈ 1` is an artefact of the model rather than a finding, and the countable rule's 0.926 is 7.4 points lower at t = 155. **Any pre-countable `p_ref` is therefore wrong by about 7 points and has to be re-measured, not carried over** — which is exactly the [§8.4](#s8-4) correction (1.000 → 0.944), and the reason [§6.8](#s6-8)'s reference rate is quoted from the countable capstone and not from the structural "published orphans are straddled by honest blocks" argument. `deep_ref_share` is not affected because it is 0 by construction under the countable rule ([§6.8](#s6-8)); no other reference-structure metric is quoted from a pre-countable run. 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](#s3-2) reports a `t` statistic per cell and why the design regime is measured separately at 40 replicates ([§3.2a](#s3-2a)). diff --git a/tools/simulators/tsi/tsi-sim-pernode/scripts/model_carryover.py b/tools/simulators/tsi/tsi-sim-pernode/scripts/model_carryover.py new file mode 100644 index 0000000..ccd7aab --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/scripts/model_carryover.py @@ -0,0 +1,66 @@ +"""Which metrics carry over from the unrestricted model to the countable one? (§9) + +Most of the report's studies predate the countable default. They reproduce exactly under `--old`, +so the question is not whether they are valid runs — it is whether their *numbers* still describe +the countable design. §9 used to answer that wholesale ("no difference is resolvable in the design +regime, so those findings carry over"), and that is how the §8.4 capstone went stale: its `p_ref` +was quoted at 1.000 for weeks after the redesign made the true value 0.944. + +The wholesale answer is wrong because the carry-over is metric-specific. Accuracy barely moves +between the models at `rho < 1` — that is the §3.2a finding. But `p_ref` is not a small difference, +it is an artefact: under the unrestricted rule EVERY in-window orphan is referenceable, so +`p_ref ~ 1` is a restatement of the model rather than a measurement of the design. + +This prints the per-metric verdict off the committed paired sweep, where both arms share the stake +draw, the peering graph and every lottery outcome, so the uncle rule is the only difference and the +comparison is a paired one. Any metric that fails here must be re-measured rather than carried over. + +Run: python scripts/model_carryover.py +""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd + +HERE = Path(__file__).resolve().parent.parent +RUNS = HERE / "runs" + +# The design band: the operating regime the recommendation lives in. U = 0 is excluded because +# with no uncle slots the two models are identical by construction and would dilute the contrast. +METRICS = ["mean_ratio", "fork_rate", "mean_orphan_rate", "agreement_tip", "p_ref"] +CELL = ["blend_delay_max", "max_uncles", "replicate"] + + +def _load(label: str) -> pd.DataFrame: + """Equilibrium tail of the newest paired run with this label.""" + src = sorted(RUNS.glob(f"*_{label}/results.parquet"))[-1] + df = pd.read_parquet(src) + return df[(df.epoch >= df.epochs.iloc[0] // 2) & (df.max_uncles > 0)] + + +def main() -> None: + countable, unrestricted = _load("fine-paired-countable"), _load("fine-paired-old") + print("=== countable vs unrestricted ceiling, PAIRED, design band (delta_max 1-5, U >= 1) ===") + print(f"{'metric':>18} {'countable':>11} {'ceiling':>11} {'paired diff':>20} {'t':>7} verdict") + for m in METRICS: + if m not in countable.columns: + continue + a = countable.groupby(CELL)[m].mean() + b = unrestricted.groupby(CELL)[m].mean() + i = a.index.intersection(b.index) + d = b[i] - a[i] + # Paired t over cells. A metric "carries over" if the gap is negligible against the + # +-0.9% per-epoch noise floor of Appendix B, NOT if it merely fails to resolve --- with + # 40 paired replicates even a 0.06% gap resolves, and that one is still immaterial. + t = d.mean() / d.sem() if d.std(ddof=1) > 0 else float("nan") + verdict = "carries over" if abs(d.mean()) < 0.009 else "RE-MEASURE" + print(f"{m:>18} {a[i].mean():11.4f} {b[i].mean():11.4f} " + f"{d.mean():+12.5f}+-{d.sem():.5f} {t:7.1f} {verdict}") + print("\np_ref is the one that fails: the unrestricted model makes every in-window orphan\n" + "referenceable, so its ~1.0 is a property of the model, not of the design.") + + +if __name__ == "__main__": + main()