Make the model carry-over metric-specific instead of wholesale

§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 <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-10 11:47:51 +02:00
parent c4577e7062
commit 74ee06047c
No known key found for this signature in database
2 changed files with 81 additions and 2 deletions

View File

@ -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.930.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, 2040 % 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 <name>` (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 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) |
| **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` 15, `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)).

View File

@ -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()