mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-08 20:23:28 +00:00
Merge abf2c86285b2d3a83448ed6aa903f803124cd7fe into 7eced7eadf41010e88a6014886dd0a8d570b2a6f
This commit is contained in:
commit
1e8190a4a7
@ -58,7 +58,7 @@
|
||||
|
||||
---
|
||||
|
||||
> **The uncle-reference model analysed here.** Uncle references are *counting-only* — they never affect a block's validity ([§6.7](#s6-7)–[§6.8](#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](#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](#s2-1), [§3.2](#s3-2), [§9](#s9)). The result: **in the design regime `ρ < 1` the two are practically identical.** A dedicated 40-replicate **paired** sweep of the operating band (`δ_max` 1–5, `ρ ≈ 0.21–0.41`), in which both arms share the stake draw, the peering graph and every lottery outcome so the uncle rule is the only difference, puts every `U ≥ 1` cell of both models in 0.998–1.001 and pins the first-fork cost at **nothing measurable up to `δ_max` = 3 and ~0.1 % at `δ_max` = 4–5** ([§3.2a](#s3-2a)). Measured against the unrestricted baseline under common random numbers, the cost grows monotonically with load — 0.13 % at `ρ ≈ 0.36`, 0.34 % at `ρ ≈ 0.56`, 1.0 % at `ρ ≈ 0.96` and 2.3 % under overload at `ρ ≈ 1.76`, where recovery saturates near 0.95 against the unrestricted model's ~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(1−f)/log(1−f/q_u)`, predicts every countable cell to within 0.2 %.
|
||||
> **The uncle-reference model analysed here.** Uncle references are *counting-only* — they never affect a block's validity ([§6.7](#s6-7)–[§6.8](#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](#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** ceiling in which any orphan in the window is referenceable at any fork depth ([§2.1](#s2-1), [§3.2](#s3-2), [§9](#s9)) — a bound on what any counting rule could recover, not a candidate design, since the spec rejects a block carrying an entry that fails the rules. The result: **in the design regime `ρ < 1` the two are practically identical.** A dedicated 40-replicate **paired** sweep of the operating band (`δ_max` 1–5, `ρ ≈ 0.21–0.41`), in which both arms share the stake draw, the peering graph and every lottery outcome so the uncle rule is the only difference, puts every `U ≥ 1` cell of both models in 0.998–1.001 and pins the first-fork cost at **nothing measurable up to `δ_max` = 3 and ~0.1 % at `δ_max` = 4–5** ([§3.2a](#s3-2a)). Measured against the unrestricted baseline under common random numbers, the cost grows monotonically with load — 0.13 % at `ρ ≈ 0.36`, 0.34 % at `ρ ≈ 0.56`, 1.0 % at `ρ ≈ 0.96` and 2.3 % under overload at `ρ ≈ 1.76`, where recovery saturates near 0.95 against the unrestricted model's ~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(1−f)/log(1−f/q_u)`, predicts every countable cell to within 0.2 %.
|
||||
|
||||
<a id="s1"></a>
|
||||
## 1. Executive summary
|
||||
@ -72,7 +72,7 @@
|
||||
1. **All nodes always agree.** In every run, at every network size and block rate tested (1 000–10 000 nodes at the production security parameter; to 32 000 in the size-scaling study, [§3.7](#s3-7); blocks every 10–30 s), all nodes computed *exactly* the same estimate — even though they constantly disagree about the newest blocks. The reason is structural: TSI measures a window buried deep in the finalized past, where every honest node sees identical history ([§3.1](#s3-1)). Two caveats keep this honest: agreement is not accuracy — nodes can unanimously agree on a wrong value (see next point) — and agreement is inherited from the common genesis value, not rebuilt: TSI maintains agreement but cannot repair a divergence that somehow arose ([Appendix C](#sC)).
|
||||
2. **Network delay makes the estimate too low; uncle references fix it.** When blocks race, the losers ("orphans") drop off the chain and out of the count, so the network under-counts its own stake — by roughly a quarter to a third under Blend (the mix-network transport) delays, deepening with network size. The error is strictly one-sided: the equilibrium estimate is **bounded above by the true stake** — `D̂/D` cannot exceed 1 (no tested cell sits above 1 beyond sampling noise, which is why the accuracy plots are capped at 1; [§2.2](#s2-2)), so delay can only deflate the estimate, never inflate it. Letting each block also reference recent orphans ("uncles") puts them back into the count — counting *slots*, so a slot with two winners still counts once — and a single uncle reference per block restores the estimate to **exactly the true stake** (the recommended cap adds a one-uncle margin, [§8](#s8)) (the only residual is an optional ~1 % from on-chain rounding of the target rate, [§2.2](#s2-2)/[Appendix A](#sA)) ([§3.2](#s3-2)).
|
||||
3. **Two sizing rules cover the deployments tested.** How many uncle slots a block needs (`U`) is dictated by the **load** — the number of blocks the whole network produces during the time one block needs to reach everyone (written `ρ`; formally `ρ = f·D_vis`, [§3.3](#s3-3)). The load counts the concurrent blocks each new block must be able to reference, and one uncle slot drains one of them per block — so `U` must be at least the load rounded up, plus one spare. How far back a block may reach for an orphan (`W`) is dictated by block spacing, not delay: about ten block intervals. The formal equations, their calibration, and worked examples are in [§4](#s4).
|
||||
4. **Cheating doesn't pay.** Hiding blocks to deflate the estimate costs the attacker more than anyone else, heals within one epoch, and — for a full withdrawal — the lower estimate is simply the *correct* count of who is still participating ([§6.4](#s6-4)–[§6.5](#s6-5)). The one genuinely profitable attack is classic selfish mining, which needs roughly one-third of all stake and afflicts every Nakamoto-style chain, not TSI specifically; the damage it does to the estimate is *partly* repaired by the same uncle mechanism — about half of it against an optimally-played attack, because the counting rule can reach only the first block of each discarded fork ([§6.6](#s6-6)). One qualification matters for how that threshold is read: it bounds what an attacker *earns*, not what the estimate *loses*. An attacker content to earn its stake share rather than the selfish premium can push the estimate substantially further down at no cost to itself — so above the one-third threshold the estimate is exposed to a wider set of strategies than the profit analysis alone would suggest ([§6.6](#s6-6), `fig37`). Rewarding uncles compensates the honest victims of delay and makes hiding strictly worse — but the reward must remain a bonus (a *soft* rule, never a validity requirement, which an attacker could turn into a tool for censoring or forking the chain), and the uncle + nephew rewards (the nephew reward pays the block that *includes* the reference) must together stay below one block reward, or deliberately orphaning one's own blocks becomes a business ([§6.7](#s6-7)–[§6.8](#s6-8)).
|
||||
4. **Cheating doesn't pay.** Hiding blocks to deflate the estimate costs the attacker more than anyone else, heals within one epoch, and — for a full withdrawal — the lower estimate is simply the *correct* count of who is still participating ([§6.4](#s6-4)–[§6.5](#s6-5)). The one genuinely profitable attack is classic selfish mining, which needs roughly one-third of all stake and afflicts every Nakamoto-style chain, not TSI specifically; the damage it does to the estimate is *partly* repaired by the same uncle mechanism — about half of it against an optimally-played attack, because the counting rule can reach only the first block of each discarded fork ([§6.6](#s6-6)). One qualification matters for how that threshold is read: it bounds what an attacker *earns*, not what the estimate *loses*. An attacker content to earn what honest mining would pay it, rather than the selfish premium, can push the estimate substantially further down at no cost to itself — so somewhat above the one-third threshold the estimate is exposed to a wider set of strategies than the profit analysis alone would suggest ([§6.6](#s6-6), `fig37`). Rewarding uncles compensates the honest victims of delay and makes hiding strictly worse — but the reward must remain a bonus (a *soft* rule, never a validity requirement, which an attacker could turn into a tool for censoring or forking the chain), and the uncle + nephew rewards (the nephew reward pays the block that *includes* the reference) must together stay below one block reward, or deliberately orphaning one's own blocks becomes a business ([§6.7](#s6-7)–[§6.8](#s6-8)).
|
||||
5. **Reorganisations stay shallow if the load stays low.** Forks are the cost centre: a deep reorg discards confirmed blocks. Fork depth grows with delay, and uncles keep it shallow (without them the deflated estimate over-produces blocks, and honest reorgs reach ~17 deep; with them, ~4). Against a private-chain attacker holding 10–30 % of stake trying to force the deepest reorg, keeping the load below one (few honest forks) keeps reorgs bounded and shallow (single digits to low tens of blocks even at 30 % stake) — but past that, at 30 % stake and heavy delay, fork-induced orphaning tips the attacker over an effective majority and reorgs become unbounded. The same "keep `ρ < 1`" rule that keeps the estimate accurate keeps reorgs shallow ([§6.10](#s6-10)).
|
||||
|
||||
*Method note: results come from the per-node simulator at the production security parameter k = 2160, with the robustness and size-scaling studies at a reduced k = 256 where noted; the sizing rules are semi-empirical (their form follows from the fork/orphan mechanism, their constants are fitted to the sweeps) and are derived under honest, deterministic-delay conditions — noise, attacks, and incentives are treated in [§6](#s6).*
|
||||
@ -136,7 +136,7 @@ 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.64–0.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.
|
||||
- **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 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 throughout, and calls it the **unrestricted** model — but as a *ceiling*, not as a design alternative. The spec gates uncle validity: a block carrying an entry that fails the counting rules is **rejected** outright (*Block Header Validation* step 10), so a chain in which deep-fork orphans are referenced cannot exist. The unrestricted arm therefore measures the unreachable upper bound on what any counting rule could recover, and every "countable vs unrestricted" comparison below should be read as "what the deployed rule recovers, against the most any rule could".
|
||||
- **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](#s6-7)–[§6.8](#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.
|
||||
|
||||
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](#s9)).
|
||||
@ -508,6 +508,16 @@ Adding random per-delivery arrival noise changes nothing: consensus stays exact.
|
||||
|
||||
Jitter feeds exactly the tip-level churn that [§3.1](#s3-1) showed never reaches the finalized density window. The reason is structural: the density window opens a full epoch back (`E = 10·⌊k/f⌋` slots) and *closes* `E − T = 4·⌊k/f⌋` slots before the chain is snapshotted for measurement — 259 200 slots ≈ 8 640 blocks ≈ 4k at k = 2160, f = 1/30, i.e. ~3k blocks deeper than `k`-finality — so every block has reached every node by measurement time regardless of jitter, and all nodes still compute an identical `D̂`. The "one global `D̂`" claim therefore holds off the `jitter = 0` axis.
|
||||
|
||||
**How much per-recipient variance would it take to matter?** The jitter above is a *robustness* probe; the same lever also answers a modelling question, because jitter is exactly the difference between this simulator's transport and one that draws an independent delay per (block, recipient). The cascade's variance is per **block** — every recipient is moved together by the final flood — while jitter is per **recipient**, and only the second kind desynchronises views and manufactures forks deeper than one block, which the counting rule cannot recover ([§2.1](#s2-1)). Swept at the deployment's operating point (`δ_max` = 4, exact oracle, both referencing models, 480 runs):
|
||||
|
||||
| `jitter_mean` (slots) | 0 | 1 | 2 | 4 | 8 |
|
||||
|---|---|---|---|---|---|
|
||||
| `D̂/D` at `U = 1` | 0.9983 | 0.9992 | 0.9991 | 0.9971 | 0.9871 |
|
||||
| orphans below their fork's first block | 0.25 % | 0.38 % | 0.53 % | 1.20 % | 3.30 % |
|
||||
| fork rate | 0.266 | 0.272 | 0.275 | 0.310 | 0.366 |
|
||||
|
||||
Accuracy is flat to ~2 slots and only then bends: at 8 slots the deep-fork share reaches 3.3 % and the estimate falls to 0.987. Blend's own cascade delivers about **0.5 slot** of per-recipient spread — the gossip flood over a degree-6 graph ([§2](#s2)) — so the design sits an order of magnitude inside the flat region. The sensitivity is nevertheless real, and quoting it is what makes the accuracy claims conditional on a *stated* transport property rather than an unstated one. Consensus is unaffected throughout (`range_ratio = 0`, `agreement_window = 1.000` in every one of the 480 runs), so this is an accuracy sensitivity only.
|
||||
|
||||
**Clock skew: bounded, but not identically zero.** A whole-timeline shift of a node's slot clock — unlike per-arrival jitter, it shifts the node's measurement-window bounds — was tested directly (its own generator, `scripts/clock_skew.py`, [§9](#s9)). A skew of up to ±20 slots moves each node's occupied-slot count by at most the one or two blocks in the shifted window edge, an inter-node spread of at most `max(1/m, 2·skew/T)`, where `m = f·T` is the window's occupied-slot count — the discrete one-block floor `1/m` dominates for `skew < 1/(2f)`, and the measured spread is indeed a flat `1/m` at every skew from 1 to 20 slots. At the production window (`T = 6·⌊k/f⌋ = 388 800` slots, so `m = f·T = 12 960`) both terms are `≈ 1×10⁻⁴` — bounded and negligible, but, unlike jitter's exact 0, not identically zero. So bounded clock skew is a small quantifiable consensus cost, not a break ([§8.3](#s8-3) item 6).
|
||||
|
||||
<a id="s6-2"></a>
|
||||
@ -621,15 +631,18 @@ So **SM1 is a faithful proxy for selfish-mining revenue but not for TSI's estima
|
||||
|
||||
**The revenue-optimal adversary is not the estimator's worst case (`fig37`).** Every ceiling above comes from an adversary optimising something *other* than the estimate, so they bound `η` from above without bounding the damage from below. Optimising the estimator directly closes that gap — and needs no ratio transform, because each transition consumes exactly one block-finding event, so minimising `D̂/D = (canonical + p_ref·countable uncles)/events` is a plain average-reward MDP over the same transition table.
|
||||
|
||||
Asked without constraint the question degenerates, informatively: the optimum is **pure abstention** — publish nothing, adopt when overtaken — landing `D̂` on exactly `1 − α` with zero revenue. That is [§6.4](#s6-4)'s withholding, which is *correct* measurement rather than mis-measurement, since a coalition that publishes nothing genuinely is not participating. So the question that bites is what an adversary can do **while still being paid**. Sweeping the mixed objective `λ·(adversary blocks) − (contribution to D̂)` enumerates candidate policies; the line of interest is where the revenue *share* reaches `α`, i.e. where the attack costs nothing against simply mining honestly (below it, the attack is self-punishing griefing already bounded by [§6.5](#s6-5)):
|
||||
Asked without constraint the question degenerates, informatively: the optimum is **pure abstention** — publish nothing, adopt when overtaken — landing `D̂` on exactly `1 − α` with zero revenue. That is [§6.4](#s6-4)'s withholding, which is *correct* measurement rather than mis-measurement, since a coalition that publishes nothing genuinely is not participating. So the question that bites is what an adversary can do **while still being paid**. Sweeping the mixed objective `λ·(adversary blocks) − (contribution to D̂)` enumerates candidate policies; the line of interest is where the attacker's **absolute reward per unit time** matches honest mining, so that the attack costs it nothing (below that line it is self-punishing griefing, already bounded by [§6.5](#s6-5)).
|
||||
|
||||
| `α` | revenue-optimal: share / `D̂` | best break-even-paid: share / `D̂` | extra deflation |
|
||||
*Pay, not share.* The two differ, and not negligibly. A revenue *share* is measured against the canonical block rate, but what an attacker is actually paid is set by the estimator: TSI drives the **counted** density — canonical blocks plus recovered uncles — to `f` per slot, so a run yields `f/D̂` block-finding events per slot and a deflating attack makes the lottery easier for everyone. Pay per slot is therefore `adv_rate·f/D̂` against an honest miner's `α·f`, and the correction on the share ratio is `density/D̂`, below 1 whenever any uncle is recovered. Scoring the frontier on share alone credits the attacker with pay it never receives.
|
||||
|
||||
| `α` | revenue-optimal `D̂` | best *paying* policy: pay / `D̂` | extra deflation |
|
||||
|---|---|---|---|
|
||||
| 0.36 | 0.392 / 0.845 | 0.372 / **0.763** | 0.082 |
|
||||
| 0.40 | 0.489 / 0.811 | 0.400 / **0.642** | 0.170 |
|
||||
| 0.45 | 0.667 / 0.664 | 0.453 / **0.561** | 0.103 |
|
||||
| 0.36 | 0.845 | — nothing in the sweep both pays and deflates | — |
|
||||
| 0.38 | 0.830 | 1.02× / **0.749** | 0.081 |
|
||||
| 0.40 | 0.811 | 1.00× / **0.649** | 0.163 |
|
||||
| 0.45 | 0.664 | 1.18× / **0.568** | 0.096 |
|
||||
|
||||
At `α` = 0.4 an adversary content to earn exactly its stake share — no worse than honest mining — drives the estimate to **0.64** where the revenue-maximiser reaches only 0.81. The 17 points are bought with the selfish premium alone, which is a price an attacker whose goal is the estimator will happily pay. Below the `⅓` threshold nothing profitable deflates at all, so the exposure begins exactly where selfish mining does. (The sweep parameter is deliberately not monotone in revenue: selfish mining takes a larger share of a *smaller* pie, so raw block rate is maximised by honesty and large `λ` returns there. It enumerates policies; it is not a path.)
|
||||
At `α` = 0.4 an adversary earning exactly what honest mining would pay it drives the estimate to **0.65**, where the revenue-maximiser reaches only 0.81. Those 16 points are bought with the selfish premium alone — a price an attacker whose goal is the estimator will happily pay. The exposure does *not* open at the `⅓` selfish threshold, though: at `α` = 0.36 no policy in the sweep both pays and deflates, so it begins nearer 0.37–0.38, above the point where selfish mining itself turns profitable. (The sweep parameter is deliberately not monotone in revenue: selfish mining takes a larger share of a *smaller* pie, so raw block rate is maximised by honesty and large `λ` returns there — it enumerates policies, it is not a path. The pay conversion neglects the multi-winner factor `c(f) ≈ 1.017` between events and occupied slots ([§2.1](#s2-1)), small against a 16-point effect.)
|
||||
|
||||

|
||||
|
||||
@ -780,7 +793,7 @@ Every parameter a protocol designer or operator can set, in three groups: **prot
|
||||
| `w_u`, `w_n` — uncle / nephew rewards | compensation for referenced orphans / for referencing them | fairness: honest recovery 0.61 → 1.00 as `w_u` 0 → 1 (at `p_ref` = 1); hiding forfeits both rewards; safety invariant **`w_u + w_n < 1`** (else uncle farming pays); raising `w_n` alone marginally *helps* the attacker | [§6.7](#s6-7)/6.8, figs 14, 15 |
|
||||
| inclusion rule | whether references are incentivised or mandated | **soft** (reward-weighted) only — a hard validity mandate cannot be encoded fork-safely (censorship or consensus fork) | [§6.8](#s6-8) |
|
||||
| `genesis_d_factor`, `init_dest` | the initial `D̂` | a *common* wrong guess self-corrects in ~2 epochs (fig1); a *heterogeneous* init is preserved forever (fig21) — genesis `D̂` must be a shared constant | [§3.2](#s3-2), [Appendix C](#sC) |
|
||||
| `fixed_point` | on-chain integer-f truncation `f_p = ⌊f·1000⌋/1000` | the **only** residual bias after slot-counting: ≈ +1 % (`f/f_p`), removed by higher-precision `f` ([Appendix A](#sA)) | [§2.2](#s2-2), [§5](#s5) |
|
||||
| `fixed_point`, `f_precision` | whether the estimator quantises its target rate as the on-chain integer algorithm does, and at what scale (`f_p = ⌊f·f_precision⌋/f_precision`) | the **only** residual bias after slot-counting. Measured at the deployment's operating point: `D̂/D` = 0.99997 with exact `f`, **1.01026** at the spec's `f_precision` = 10³, 0.99990 at the recommended 10⁶. Default off, because a design question wants the mechanism isolated — but a *spec-faithful* arm must set `fixed_point: true, f_precision: 1000`, and that arm is the one that says what a deployed chain would read | [§2.2](#s2-2), [§5](#s5), [Appendix A](#sA) |
|
||||
|
||||
<a id="s7-2"></a>
|
||||
### 7.2 Network / environment parameters
|
||||
@ -801,6 +814,7 @@ Every parameter a protocol designer or operator can set, in three groups: **prot
|
||||
| knob | probes | result | where |
|
||||
|---|---|---|---|
|
||||
| `adversary_strategy: selfish` | private-chain (SM1) override in the per-node engine | the profitable lever, now measurable end-to-end: revenue tracks Eyal–Sirer at sub-slot latency (0.0356 vs 0.0356 at α = 0.1) and exceeds it as natural forking lifts `α_eff`; deflates `D̂` to 0.76 at α = 0.3 (δ = 8) with **no** cap able to recover it ([§6.8](#s6-8)) | [§6.6](#s6-6), [§6.8](#s6-8) |
|
||||
| `deep_orphan_share` (engine output) | share of in-window orphans sitting *below* the first block of their fork | the structural observable behind the first-fork cost, and the one `p_ref` conflates with "eligible but never picked up": these blocks are unreferenceable by construction ([§2.1](#s2-1)). 0.25 % honestly at the deployment's point, 3.3 % at 8 slots of per-recipient jitter ([§6.1](#s6-1)), 19–41 % under a private-chain attack ([§6.8](#s6-8)) | [§6.1](#s6-1), [§6.8](#s6-8) |
|
||||
| `p_ref_honest` (engine output) | reference rate over orphans produced *outside* the coalition | equals `p_ref` under suppression; diverges sharply under a private chain (0.45–0.69 vs ≈ 0.99), which is the quantity the estimator repair actually depends on | [§6.8](#s6-8) |
|
||||
| `adversary_frac` (β_adv), `adversary_strategy` | uncle suppression / abstention withholding | suppression weak at ρ < 1, a cheap lever only at ρ > 1 (1.43× at ρ = 1.36, β_adv = 0.5); withholding → `D̂ ≈ (1−β_adv)` — the *correct* active-stake answer, topology-independent | [§6.3](#s6-3)/6.4, figs 8, 9 |
|
||||
| `adversary_period`, `adversary_withhold_epochs` | dynamic withhold-rejoin schedules | unprofitable at every duty ψ > 0; griefing bounded (`deflation = β_adv·ψ`); no persistence | [§6.5](#s6-5), figs 10–12 |
|
||||
@ -837,7 +851,7 @@ Everything below is assembled from this report's own measurements; each row cite
|
||||
| 11 | reward schedule | **substantial `w_u` (Ethereum's GHOST-style uncle rewards are the precedent); `w_u + w_n < 1` strictly enforced** | farming a real leadership win into a self-uncle + self-nephew pays `w_u + w_n` against an honest block's 1 — the binding invariant is the *sum* (0.875 + 0.03125 = 0.906 is safe; 0.875 + 0.15 = 1.025 is not); `w_u` compensates honest orphans (recovery → 1.0) and makes hiding forfeit both rewards | [§6.7](#s6-7)(a), figs 14, 15 |
|
||||
| 12 | peering degree | **degree 4 is acceptable for smaller networks — up to N ≈ 2×10⁵ it keeps the load under 0.9 at the 8-s budget; use ≥ 6 beyond, and scale with the target size** | degree sets the gossip-diameter slope `ℓ_mean ≈ a_d·ln N`, and at a fixed blending budget that slope decides when `U = 1` runs out: at δ = 8 s, degree 4 exhausts U = 1 near **N* ≈ 8×10⁵** (its ladder curve already touches the recovery bar at N = 32 000), degree 6 holds to N ~ 4×10⁹, degree 8 to ≈ 6×10¹² — one degree step (4 → 6) buys ~5 000× more network at the same budget. Degree and the uncle cap are exchangeable defences against growth: densify the graph or add an uncle slot, whichever is cheaper. Plus the small-N benefit of better U = 0 recovery (tip agreement is comparable: deg 6 0.985–0.993 vs deg 4 0.988–0.994) | [§3.7](#s3-7), figs 23, 24; fig19; [Appendix C](#sC) (fig18) |
|
||||
| 13 | Blend delay budget | **choose `hops·δ_max` so `ρ ≲ 1`**; if `U ≥ ⌈ρ⌉` exceeds the uncle-slot budget, cut blending — no window fixes `U < ρ` | worked examples: 3 hops × 8 s at f = 1/30 → ρ ≈ 0.56, U = 2 recommended; the same Blend at f = 1/10 → ρ ≈ 1.7, U = 3 | [§4](#s4); [§6.3](#s6-3)(iii) |
|
||||
| 14 | on-chain `f` precision | **carry the target rate `f` at 10⁻⁶ precision** (was 10⁻³) | removes the sole residual bias after slot-counting — the ≈ 1 % `f/f_p` over-estimate (a ~1 % win-probability under-delivery and slow canonical pace); one-constant change, no dynamics cost, no fork risk. **Exceeded** in this report's estimator, which uses exact `f` (residual 0), finer than the recommended 10⁻⁶ bump (`f_p = 0.033333`, residual < 10⁻⁵) | [§2.2](#s2-2), [Appendix A](#sA) |
|
||||
| 14 | on-chain `f` precision | **carry the target rate `f` at 10⁻⁶ precision** (was 10⁻³) | removes the sole residual bias after slot-counting — the ≈ 1 % `f/f_p` over-estimate (a ~1 % win-probability under-delivery and slow canonical pace); one-constant change, no dynamics cost, no fork risk. **Now measured in the full dynamics, not just derived:** at the deployment's operating point the three arms read `D̂/D` = 0.99997 (exact `f`) / **1.01026** (the spec's 10⁻³) / 0.99990 (the recommended 10⁻⁶), against closed forms of 1.000 / 1.0101 / 1.00001. The deployed bias is real, is ~13× the first-fork cost, and is the largest single error in the deployed estimator | [§2.2](#s2-2), [Appendix A](#sA); `scripts/spec_point.py` |
|
||||
| 15 | reorg / confirmation depth | keep **`ρ < 1`** so fork-induced amplification stays below the effective-majority cliff; for soft settlement, wait `d` blocks per the [§6.10](#s6-10) tail (≈ 4/8/17 blocks for 99.9 % against 10/20/30 % stake at N = 1 000, degree 6 — deeper on larger or sparser networks, per the [§6.10](#s6-10) scale table) | reorg depth is bounded and shallow only while the honest fork rate is low; at 30 % stake past `ρ ≈ 1` it is unbounded ([§6.10](#s6-10), figs 27–28) | [§6.10](#s6-10) |
|
||||
|
||||
<a id="s8-2"></a>
|
||||
@ -849,7 +863,7 @@ Everything below is assembled from this report's own measurements; each row cite
|
||||
|
||||
**Why soft, not hard, inclusion.** "Owed" cannot be proven — no node can prove which forks a producer saw. An objective slot-gap encoding lets an attacker withhold-then-late-release an uncle to retroactively invalidate honest blocks (censorship); a visibility encoding is subjective and forks consensus with no adversary at all ([§6.8](#s6-8)). The soft rule concedes only a small residual (quantified in [§8.3](#s8-3) item 3): a bounded near-threshold backfire that vanishes as the emergent `p_ref` rises, and a per-α premium that stays ≤ +0.014 even at full inclusion.
|
||||
|
||||
**What TSI defends, and what it measures.** Abstention-style withholding is strictly unprofitable (reward/stake 0.95 → 0.31 for duty 0.10 → 0.75 at β_adv = 0.3), bounded as griefing (deflation = β_adv·ψ), non-persistent, and does not stack across coalitions ([§6.5](#s6-5), [§6.9](#s6-9)) — and a full withdrawal is *correctly measured*, not mis-measured: `D̂ → (1−β_adv)` is the right answer for the participating stake ([§6.4](#s6-4)). The one profitable lever is classic selfish mining above `(1−γ)/(3−2γ)` — a general Nakamoto-PoS result, not TSI-specific; TSI's added exposure (estimator deflation to 0.70 at α = 0.4) is *partly* repaired by uncle-counting (row 9 — to 0.81, not the 0.94 an unrestricted count would reach, because the deployed rule can reference only the first block of each discarded fork), and the profit premium is blunted, not eliminated, by the soft-rewarded references (row 10). The *profit* frontier is exactly optimal selfish mining, and no compounding lever raises it ([§6.7](#s6-7)) — but the *estimator* frontier is not the same policy: an adversary willing to settle for break-even pay deflates `D̂` to 0.64 at α = 0.4 where the revenue-maximiser reaches 0.81 ([§6.6](#s6-6), `fig37`). Profitability bounds what an attacker gains, not what the estimate loses.
|
||||
**What TSI defends, and what it measures.** Abstention-style withholding is strictly unprofitable (reward/stake 0.95 → 0.31 for duty 0.10 → 0.75 at β_adv = 0.3), bounded as griefing (deflation = β_adv·ψ), non-persistent, and does not stack across coalitions ([§6.5](#s6-5), [§6.9](#s6-9)) — and a full withdrawal is *correctly measured*, not mis-measured: `D̂ → (1−β_adv)` is the right answer for the participating stake ([§6.4](#s6-4)). The one profitable lever is classic selfish mining above `(1−γ)/(3−2γ)` — a general Nakamoto-PoS result, not TSI-specific; TSI's added exposure (estimator deflation to 0.70 at α = 0.4) is *partly* repaired by uncle-counting (row 9 — to 0.81, not the 0.94 an unrestricted count would reach, because the deployed rule can reference only the first block of each discarded fork), and the profit premium is blunted, not eliminated, by the soft-rewarded references (row 10). The *profit* frontier is exactly optimal selfish mining, and no compounding lever raises it ([§6.7](#s6-7)) — but the *estimator* frontier is not the same policy: an adversary willing to settle for the pay honest mining would give it deflates `D̂` to 0.65 at α = 0.4, where the revenue-maximiser reaches 0.81 ([§6.6](#s6-6), `fig37`). Profitability bounds what an attacker gains, not what the estimate loses.
|
||||
|
||||
<a id="s8-3"></a>
|
||||
### 8.3 Residual risks and open items
|
||||
@ -868,8 +882,8 @@ Everything below is assembled from this report's own measurements; each row cite
|
||||
12. **Network partition/heal — reasoned, not simulated ([§6.1](#s6-1)).** TSI reads its density window well past `k`-finality (it closes `4·⌊k/f⌋` slots before the estimate is used), so a partition that heals *within* the finality depth is invisible: all honest nodes reconverge on one canonical chain before the measurement window closes and compute an identical `D̂` — the same finality-window argument that makes jitter and clock skew harmless ([§6.1](#s6-1)). A partition lasting *longer* than finality is a consensus-layer safety failure (the chain forks irreparably) that TSI inherits but does not worsen — it adds no partition vulnerability of its own. A direct partition-and-heal simulation is left as future work.
|
||||
13. **Reward payout mechanism unspecified ([§8.5](#s8-5)).** Rows 10–11 give the incentive constraints, not the encoding. Header-count indistinguishability (Blend) and the equal-share, content-independent voucher of the Anonymous Leaders Reward Protocol are the binding constraints on any `w_u`/`w_n` schedule, and reconciling them — including a payout path for a producer whose block is off-chain — is spec work left open here.
|
||||
14. **Temporal resolution — `D̂` is stale by design ([§6.11](#s6-11)).** With `β = 1`, `D̂` tracks active stake at a one-epoch (≈ 7.5-day) lag, so it reflects participating stake from ~1–2 weeks ago and updates only weekly; a genuinely *sub-epoch* swing (daily or intra-week churn) falls below the estimator's Nyquist limit and is not tracked as a cycle at all. This is the correct trade for organic churn (row 5), but it bounds how fast TSI can follow real participation changes — flagged, not a defect.
|
||||
15. **Correlated latency untested ([§2](#s2)).** Both link distributions draw each link *independently*, so the sweeps probe the latency marginal, not its spatial structure; geographically **correlated** latency (regional clustering that lets co-located nodes fork as a bloc) is not modelled. In the primary Blend regime the per-hop mixing delay dominates the geographic link term, so this is expected to stay second-order — but it is untested.
|
||||
16. **The deflation-optimal adversary — measured, and it is worse than the revenue-optimal one ([§6.6](#s6-6), `fig37`).** The ceilings elsewhere come from adversaries optimising revenue or reorg depth, so they bound `η` from above rather than the damage from below. Optimising the estimate directly: unconstrained the optimum is abstention (`D̂ = 1−α`, zero revenue, and [§6.4](#s6-4)-correct measurement rather than a mis-measurement); constrained to earn at least its stake share, it reaches `D̂` = 0.64 at α = 0.4 against the revenue-optimal 0.81. What remains open is the *joint* problem — this frontier is single-coalition and, like everything in [§6.6](#s6-6), assumes `p_ref` is exogenous rather than something the attacker also manipulates (item 10).
|
||||
15. **Latency *structure* — now bounded in one direction, still untested in the other ([§2](#s2), [§6.1](#s6-1)).** The magnitude of per-recipient variance is measured: sweeping per-(block, node) jitter at the deployment's operating point leaves accuracy at 0.999 up to ~2 slots and degrades it to 0.987 by 8 slots, as unrecoverable deep-fork orphans rise 0.25 % → 3.3 %. Blend's cascade delivers roughly 0.5 slot of per-recipient spread, so the design sits an order of magnitude inside the safe range — but the sensitivity is real and the report now carries it rather than assuming it. What is still untested is the *spatial* structure: jitter is i.i.d. per (block, node), so geographically **correlated** latency, which would let co-located nodes fork as a bloc, is still not modelled.
|
||||
16. **The deflation-optimal adversary — measured, and it is worse than the revenue-optimal one ([§6.6](#s6-6), `fig37`).** The ceilings elsewhere come from adversaries optimising revenue or reorg depth, so they bound `η` from above rather than the damage from below. Optimising the estimate directly: unconstrained the optimum is abstention (`D̂ = 1−α`, zero revenue, and [§6.4](#s6-4)-correct measurement rather than a mis-measurement); constrained to earn at least what honest mining pays (in absolute reward per unit time, not revenue share — the two differ by `density/D̂`), it reaches `D̂` = 0.65 at α = 0.4 against the revenue-optimal 0.81, and the exposure opens near α ≈ 0.37–0.38 rather than at the `⅓` threshold. What remains open is the *joint* problem — this frontier is single-coalition and, like everything in [§6.6](#s6-6), assumes `p_ref` is exogenous rather than something the attacker also manipulates (item 10).
|
||||
17. **The `β_adv` axis is a nominal target, not a realised share ([§6.5](#s6-5)).** A random coalition is grown until its stake first reaches `β_adv`, so the last node added overshoots by its own size — under a Pareto tail, potentially a whale. The realised block share at a nominal `β_adv` = 0.1 is 0.137 ± 0.108, and that variance dominates the mean at small `β_adv`. The conclusions here are drawn from monotone trends across the axis and are unaffected, but a reader should not read `β_adv` as an achieved share; a whale coalition (filled largest-first to the target) lands on it precisely and is the cleaner instrument where the exact share matters.
|
||||
18. **The collapsed-branch tail is uncharacterised ([§6.2](#s6-2)).** Static withholding reaches the collapsed branch once in 96 runs — one replicate of the concentrated (whale) 50 % coalition at `δ_max` = 8 s — and never at `δ_max` = 4 s. One reproducible event is enough to retire "not an observed dynamical trap" but far too thin to give the event a rate; its frequency against `β_adv`, `ρ` and coalition concentration is unmeasured.
|
||||
19. **Structurally uncompensatable orphans ([§6.7](#s6-7)(1), [§6.8](#s6-8)).** The uncle reward refunds `w_u` per *referenced* orphan, but 20–40 % of the honest blocks a private-chain attack destroys are unreferenceable by construction — buried behind the first block of a discarded run. Those producers cannot be compensated at any `w_u`, so the fairness guarantee inherits the first-fork ceiling exactly as the density repair does. No reward schedule fixes this; it is a property of the counting rule. Whether that is acceptable — honest miners bearing an uninsurable loss precisely when attacked — is a protocol-design question this report raises rather than settles.
|
||||
@ -957,6 +971,9 @@ Sweep studies are committed configs, run with `make <name>` (writes a dated `run
|
||||
| **residual §6.5-scope variants** (whale coalitions, `jitter > 0`, very slow `β`, and the static-withholding load sweep that finds the collapsed-branch event) | `scripts/adversary_variants.py` (`adversary_selection`); `runs/adversary_variants_{whale,jitter,beta,withhold_load}.parquet` | [§6.2](#s6-2), [§6.5](#s6-5) |
|
||||
| **uncle cap under a private chain** (`U` × `W` × `α` against the SM1 engine adversary, plus the structural/queue decomposition of the honest orphans) | `scripts/selfish_uncle_margin.py` (`adversary_strategy: selfish`); `runs/selfish_uncle_margin{,_decomp}.parquet` | [§6.8](#s6-8) |
|
||||
| **profit/deflation frontier** (how far a *paid* adversary can deflate `D̂`; the deflation-optimal MDP) | `scripts/deflation_frontier.py` (`selfish_mdp.deflation_frontier`, `.deflation_optimal_stats`); fig37 | [§6.6](#s6-6) |
|
||||
| **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) |
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
161
reports/tsi/fork-loss-validation.md
Normal file
161
reports/tsi/fork-loss-validation.md
Normal file
@ -0,0 +1,161 @@
|
||||
# Validating the fork-loss claims in `analysis-total-stake-inference.md`
|
||||
|
||||
*A section quantifying what uncle references buy — § **Effect of Uncle References on Honest Slot Utilization** — was added to `docs/blockchain/raw/analysis-total-stake-inference.md` on the `analysis-fork-loss-in-tsi` branch of `logos-lips` (commit `d6fd7648`), produced by a quick standalone simulation. Several of its numbers conflict with [the TSI report](README.md) in this folder, which comes from a per-node network simulator. This document settles each claim at the **deployed spec's own operating point** — the one cell neither the section nor the report measured directly — and gives the numbers the section should carry.*
|
||||
|
||||
*Measurements: `tools/simulators/tsi/tsi-sim-pernode`, run 2026-08-06 against spec revision `d6fd7648`. Every figure below is reproducible from a committed script or config; sources are named per experiment and collected at the end.*
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**The section's headline is wrong by ~17×, and it is concerned with the wrong bias.** The residual it attributes to deep forks is 0.08 pp, not 1.4 %. Meanwhile the deployed estimator carries a **+1.0 % bias of the opposite sign** that the section does not mention — from the on-chain rounding of `f`, not from forks. Rewriting the section around the second number rather than the first is the substantive change.
|
||||
|
||||
The no-uncle loss is also **understated, not overstated**: 33 % at the operating point, not 18.7 %, and it deepens with network size. The section undersells what uncle references buy by roughly half.
|
||||
|
||||
**But the section's number is not an arithmetic error, and experiment 5 identifies what it is.** It is what this model produces once *per-recipient* delay spread reaches ~8 slots — about 16× what Blend's cascade actually delivers. The cascade's variance is per **block** (every recipient moved together, harmless to the estimate); the standalone simulation's was per **recipient** (divergent, and the only kind that manufactures unrecoverable forks). That distinction is the whole disagreement, and it belongs in the rewritten section.
|
||||
|
||||
## Summary of measurements
|
||||
|
||||
```
|
||||
1 the deployed operating point: δ_max = 4 D_vis = 8 s ρ = 0.27
|
||||
|
||||
2 at ρ = 0.27, N = 1000, 40 replicates, k = 2160, PAIRED
|
||||
D̂/D U=0: 0.6677 ± 0.0044 U=1: 0.9985 U=2: 0.9986 U=4: 0.9997
|
||||
countable − ceiling, pooled U≥1: 0.00080 ± 0.00026 (t = 3.08)
|
||||
U=0 negative control: 0.00000 ± 0.00000 (exact — shared random streams)
|
||||
at N = 5000, 12 replicates, UNPAIRED
|
||||
D̂/D U=0: 0.6537 ± 0.0251 U=1: 0.9997 U=2: 0.9979 U=4: 0.9996
|
||||
U=0 negative-control noise floor: ±0.027 → no first-fork cost resolvable at this N
|
||||
|
||||
3 largest ρ with U=4 ≥ 0.98: > 1.87 (U=3 already suffices there) margin: ~7× the deployment
|
||||
4 D̂/D vs W_abs {1,2,3,5,7,10,15,20}:
|
||||
0.854 / 0.938 / 0.967 / 0.992 / 0.999 / 0.998 / 0.999 / 1.000
|
||||
knee at W_abs ≈ 5; spec's W = 10 sits ~2× above it
|
||||
5 per-recipient jitter {0,1,2,4,8} slots, δ_max = 4, U=1, exact oracle, 12 reps:
|
||||
D̂/D 0.9983 / 0.9992 / 0.9991 / 0.9971 / 0.9871
|
||||
depth≥2 orphans 0.25 % / 0.38 % / 0.53 % / 1.20 % / 3.30 %
|
||||
consensus range_ratio = 0 and agreement = 1.000 in all 480 runs
|
||||
→ the section's 0.986 is reproducible, at ~8 slots of per-recipient variance
|
||||
6 f-precision, exact / 1e3 / 1e6: 0.99997 / 1.01026 / 0.99990 (closed form 1.000 / 1.0101 / 1.00001)
|
||||
```
|
||||
|
||||
## Claim by claim
|
||||
|
||||
| # | Claim in the added section | Measured | Verdict |
|
||||
|---|---|---|---|
|
||||
| 1 | With uncles, the residual underestimate is **1.4 %** (`q` 0.813 → 0.986) | 0.03–0.15 % at every `U ≥ 1` cell | **Refuted.** Replace with "no residual resolvable in the design regime". |
|
||||
| 2 | The residual is **deep-fork blocks** (the first-fork restriction) | Paired gap **0.08 pp ± 0.03** pooled (t = 3.08) | **Mechanism right, size ~17× overstated.** It is real and now resolved — but it is 0.0008, not 0.014. |
|
||||
| 3 | Without uncles the loss is **18.7 %** (`q` = 0.813) | **33.2 %** at N = 1000, **34.6 %** at N = 5000 (42 % / 49.5 % at δ_max = 8) | **Refuted, in the unfavourable direction.** |
|
||||
| 4 | `MAX_UNCLES` = 4 **never binds** (max 3 candidates observed) | `U = 3` still recovers at ρ = 1.87, the largest measured | **Corroborated,** with ~7× margin over the deployment's ρ = 0.27. |
|
||||
| 5 | `w_u` = 300 **never binds** (median lag 34, max 197) | Knee at `W_abs ≈ 5`; spec's 10 is ~2× above it | **Right in effect, wrong in wording.** Say "≈ 2× above the measured knee at the deployment's load". |
|
||||
| 6 | *(not mentioned)* | `PRECISION = 1e3` → **D̂/D = 1.01026 ± 0.00056** | **The section's main omission.** Larger than everything else it discusses, and opposite in sign. |
|
||||
|
||||
---
|
||||
|
||||
## 1. The deployed operating point
|
||||
|
||||
Every number the section quotes is stated at an operating point nobody had pinned down, so this comes first — it decides which measurements are even relevant.
|
||||
|
||||
`analysis-block-times-blend-network.md` sets `blending_delay = 3` s as *"seconds spent in each Blend node"* — a **fixed per-hop dwell**, neither the mean of a distribution nor a bound. The max-delay arithmetic confirms it: `3δ + 5` gives 14 s at δ = 3 and 11 s at δ = 2, matching that document's prose exactly. The profile it selects is 2 s.
|
||||
|
||||
The simulator's per-hop delay is `Uniform(0, δ_max)` with mean `δ_max/2`, and the design laws reach latency only through the mean, so the matching value is:
|
||||
|
||||
> **`δ_max = 4`**, giving `D_vis = 3·2 + 4·0.5 = 8 s` and **`ρ = f·D_vis = 0.27`**.
|
||||
|
||||
(Charging the spec's 1 s Proof-of-Leadership time as well gives ρ ≈ 0.30; nothing below changes.) This lands inside the report's committed 40-replicate paired design-band grid, so claims 1–3 are answered from data of record rather than fresh runs.
|
||||
|
||||
## 2. Accuracy and the first-fork cost
|
||||
|
||||
At ρ = 0.27 every `U ≥ 1` cell sits at **0.9985–0.9997**. The section's 1.4 % residual is not there.
|
||||
|
||||
What *is* there, now that a paired design resolves it: a first-fork cost of **0.08 pp pooled** (95 % CI [0.03, 0.13], t = 3.08). The section's *mechanism* is correct — deep-fork blocks cannot be referenced, because only the first block of a fork has a parent on the referencing chain — but at the deployment's load the effect is two orders of magnitude below the claim.
|
||||
|
||||
The `U = 0` negative control is **exactly 0.00000 ± 0.00000**. With no uncle slots the restricted and unrestricted rules are identical by construction, and because the two arms share stake draws, peering graph and lottery outcomes, they agree bit-for-bit. That is the strongest available evidence that the 0.08 pp is signal and not seed noise — an unpaired comparison at this size cannot resolve anything below ~0.15 pp.
|
||||
|
||||
At N = 5000 the runs are unpaired and that same control reads ±0.027, so nothing below ~2.7 pp is resolvable there. The N = 5000 row is an exclusion bound, not a measurement.
|
||||
|
||||
## 3. Is `MAX_UNCLES = 4` slack?
|
||||
|
||||
Yes, by a wide margin. A dedicated load sweep takes `U = 3` to `ρ = 1.87` — roughly **7× the deployment's ρ = 0.27** — and it still recovers to within noise of 1.0 across the whole range. `U = 4` is bounded below by `U = 3`, so the spec's cap does not become the binding constraint anywhere near the deployment. Claim 4 stands as written.
|
||||
|
||||
## 4. Is `w_u = 300` adequate?
|
||||
|
||||
At the deployment's operating point, sweeping the window with a single uncle slot (so the window is the only thing that can bind):
|
||||
|
||||
| `W_abs` (block-intervals) | 1 | 2 | 3 | 5 | 7 | 10 | 15 | 20 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `D̂/D` | 0.854 | 0.938 | 0.967 | 0.992 | 0.999 | 0.998 | 0.999 | 1.000 |
|
||||
|
||||
The recovery knee is at `W_abs ≈ 5` and everything above ~7 is flat. The spec's `W = 10` (`w_u = 300` slots) therefore sits about **2× above the knee** — real margin, but a margin, not an absence of a constraint. "Never binds" overstates it; "≈ 2× above the measured knee at the deployment's load" is what the data supports. This matters because the window floor is set by block *spacing* rather than by network delay, so it does not shrink as the deployment's delay shrinks.
|
||||
|
||||
## 5. Per-recipient delay variance — where the section's number comes from
|
||||
|
||||
This is the experiment that could have invalidated the *report* rather than the section. It does not — but it does something more useful than refuting the section: it says exactly what the section's number assumes.
|
||||
|
||||
The two simulations differ in transport. The standalone drew an **independent propagation delay per (block, recipient)**; the per-node simulator runs a cascade of Blend relays and then floods network-wide **from the last relay**, so nodes receive a block at nearly the same time and their views stay synchronised. Independent per-recipient draws maximise view divergence, which is exactly what manufactures forks deeper than one block — the orphans no counting rule can recover.
|
||||
|
||||
Adding per-(block, node) arrival jitter on top of the cascade interpolates between the two models:
|
||||
|
||||
| jitter (slots) | 0 | 1 | 2 | 4 | 8 |
|
||||
|---|---|---|---|---|---|
|
||||
| `D̂/D` at `U = 1` | 0.9983 | 0.9992 | 0.9991 | 0.9971 | **0.9871** |
|
||||
| orphans below their fork's first block | 0.25 % | 0.38 % | 0.53 % | 1.20 % | **3.30 %** |
|
||||
| fork rate | 0.266 | 0.272 | 0.275 | 0.310 | 0.366 |
|
||||
|
||||
Accuracy is flat to ~2 slots, then bends. At 8 slots it lands on **0.9871** — essentially the section's 0.986 — with deep orphans at 3.3 %. So the standalone result is not an arithmetic error; it is what this model produces once per-recipient spread reaches roughly eight seconds.
|
||||
|
||||
That converts the disagreement into a question with a checkable answer: **does Blend deliver ~8 slots of per-recipient spread?** Under the spec's own model it cannot come close. The blending delay is a fixed per-hop dwell, and the cascade's final step is a network-wide gossip flood from the last relay, so what varies per *recipient* is only that flood — measured at `ℓ_mean ≈ 0.5` slot over a degree-6 graph. Eight slots is ~16× that.
|
||||
|
||||
The distinction is worth stating explicitly in the rewritten section, because the two are easy to conflate:
|
||||
|
||||
> **Variance in *when a block becomes public* is harmless to the estimate. Variance in *when each node sees it* is what manufactures unrecoverable forks.** Blend's cascade produces a lot of the first and very little of the second; the standalone model charged the second.
|
||||
|
||||
Consensus is untouched throughout — `range_ratio = 0` and `agreement_window = 1.000` in all 480 runs, at every jitter level — so this is an accuracy sensitivity only, never a safety one.
|
||||
|
||||
## 6. The bias the section missed
|
||||
|
||||
Three arms, identical but for how the estimator quantises its target rate:
|
||||
|
||||
| arm | `D̂/D` | closed form |
|
||||
|---|---|---|
|
||||
| exact `f` (the report's convention) | 0.99997 ± 0.00060 | 1.00000 |
|
||||
| **the spec today, `PRECISION = 1e3`** | **1.01026 ± 0.00056** | 1.01010 |
|
||||
| recommended, `PRECISION = 1e6` | 0.99990 ± 0.00061 | 1.00001 |
|
||||
|
||||
`cryptarchia-total-stake-inference.md` carries `const PRECISION: u64 = 1e3`, so the estimator drives density to `f_p = 0.033` rather than `1/30` and the chain reads **1.0 % high** — measured in the full per-node dynamics, matching the closed form to within one standard error.
|
||||
|
||||
This is **~13× the first-fork cost the section is concerned with**, opposite in sign, and removed entirely by a one-constant change. If any single number from this exercise belongs in the section, it is this one.
|
||||
|
||||
## Two things the simulator needed, and one the report did
|
||||
|
||||
Recorded because they change how a run must be configured, not just what it reports.
|
||||
|
||||
- **`f_precision` is now a config field.** It had been a module constant pinned at the *recommended* `1e6`, which is why nobody had measured what the deployed chain would read. The report's default remains exact `f` — the right choice for a design question, since it isolates the mechanism under test — but any run answering *"what will the deployed chain read"* must set `fixed_point: true, f_precision: 1000`. Both arms are run side by side by `scripts/spec_point.py`.
|
||||
- **The derived window now floors rather than rounds**, matching the spec's `w_u := ⌊W·f⁻¹⌋`. No committed result moves — at `W = 10, f = 1/30` the quotient is exactly 300 either way — but it matters for the `W` and `f` sweeps, where the quotient is not an integer.
|
||||
- **The unrestricted arm is a *ceiling*, not a candidate design.** The spec gates uncle validity: a block carrying an entry that fails the counting rules is rejected outright (*Block Header Validation*, step 10), so a chain in which deep-fork orphans are referenced cannot exist. Every "restricted vs unrestricted" comparison here and in the report should be read as "what the deployed rule recovers, against the most any rule could recover".
|
||||
|
||||
## The validity change does not disturb the incentive results
|
||||
|
||||
The same spec revision made uncle *content* a validity condition, which is worth checking against the report's incentive analysis rather than assuming. It holds, because **inclusion stayed soft**: a proposer *"may reference fewer uncles than it could, or pass over a candidate for another, and its block remains valid"*, and the selection procedure is *"a recommendation for filling the entries well, not a consensus rule."* Therefore:
|
||||
|
||||
- The report's recommendation of a **soft, reward-weighted** inclusion rule — never a validity rule — is still satisfied. What became validity-gated is the *content* of a reference, not whether one is made.
|
||||
- The argument against a hard inclusion mandate still applies to a rule the spec did not adopt.
|
||||
- The uncle-suppression adversary — produce blocks, reference nothing — remains a legal strategy, so those results stand unchanged.
|
||||
- The restricted model's selection-time filter is now *exactly* what the protocol requires, rather than a faithful approximation of it.
|
||||
|
||||
One live consequence for the reward recommendation: the spec still argues that *"because uncle references carry no fork-choice weight and grant no reward, a proposer has no incentive to deviate"*. That clause survives in the current revision, and paying uncles — which the report recommends — removes its premise. The report already tracks this as an open spec-level item.
|
||||
|
||||
The one genuinely new consequence of the validity change: referencing an ineligible orphan now costs a proposer its whole block rather than merely failing to count. No modelled strategy does this, so no result moves — but it makes such a strategy self-defeating rather than merely ineffective, which is worth stating if junk-reference griefing is ever modelled.
|
||||
|
||||
## Reproducing these numbers
|
||||
|
||||
| § | source | data |
|
||||
|---|---|---|
|
||||
| 1 | `analysis-block-times-blend-network.md` (no simulation) | — |
|
||||
| 2 | `configs/fine-delay-paired.yaml` run twice (default and `--old`); `configs/spec-point-n5000.yaml` likewise | `runs/*fine-paired-*`, `runs/*spec-n5000*` |
|
||||
| 3 | `configs/rho-boundary.yaml` | `runs/*rho-boundary` |
|
||||
| 4 | `configs/spec-point-window.yaml` | `runs/*spec-window` |
|
||||
| 5 | `scripts/spec_jitter.py`, `configs/spec-point-jitter.yaml` | `runs/spec_jitter.parquet` |
|
||||
| 6 | `scripts/spec_point.py` | `runs/spec_point.parquet` |
|
||||
|
||||
All under `tools/simulators/tsi/tsi-sim-pernode`. Run directories are dated; the ones cited here are the latest of each label.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 228 KiB |
@ -48,8 +48,18 @@
|
||||
the pre-redesign model unchanged — window = `uncle_window` slots, any-depth orphans
|
||||
referenceable, every baked reference counted — and **bit-reproduces historical runs** (the
|
||||
old model's RNG key is byte-identical to the pre-`uncle_model` key).
|
||||
- **Adversaries** (`adversary_strategy`, over a coalition holding `adversary_frac` of stake,
|
||||
selected at random or `adversary_selection: whale` for the largest holders at matched stake):
|
||||
- `suppress` — produces normally but references no uncles, starving the recovered density.
|
||||
- `withhold` — never gossips its blocks; abstention, a dead loss to the attacker.
|
||||
- `selfish` — mines a **private chain** and releases it under Eyal–Sirer SM1 rules, orphaning
|
||||
honest work. Only visibility is modelled: a coalition member's fork choice builds on the
|
||||
private tip whenever it leads, so the chain forms and is abandoned emergently. Forces the
|
||||
exact full scan, since a hidden block breaks the windowed horizon's premise.
|
||||
- **Metrics:** per-node `D_est` spread (`range`, `IQR`), canonical-chain **agreement**
|
||||
(window prefix vs current tip), mean accuracy, and — with `init_dest=heterogeneous` —
|
||||
(window prefix vs current tip), mean accuracy, fork structure (`fork_rate`, reorg depth,
|
||||
`p_ref` and `p_ref_honest`, and `deep_orphan_share` — the share of orphans below their fork's
|
||||
first block, i.e. unreferenceable by construction), and — with `init_dest=heterogeneous` —
|
||||
transient re-convergence.
|
||||
|
||||
## Headline result
|
||||
@ -157,6 +167,9 @@ src/tsi_sim/ constants config rng stake lottery topology blocktree(+build_tree
|
||||
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)
|
||||
uncle-selection.yaml (the spec's oldest-first rule vs a deviating proposer)
|
||||
spec-point-{n5000,window,jitter}.yaml (the DEPLOYED operating point: delta_max=4
|
||||
from the spec's Blend profile — size, window and per-recipient-variance arms)
|
||||
tests/ test_{pernode,config,rng,lottery,blocktree,uncles,tsi_counting,stake,
|
||||
theory,latency,theory_convergence,countable_counting,
|
||||
countable_selfish,...}.py
|
||||
@ -164,4 +177,20 @@ scripts/ plot_countable_vs_old.py (countable-vs-unrestricted comparison f
|
||||
plot_fine_delay.py (design-band accuracy + model gap with 95% CIs)
|
||||
countable_selfish.py (first-fork ceiling under the selfish MDP; fig36)
|
||||
adversary_variants.py (whale/jitter/slow-beta variants + the withhold-load sweep)
|
||||
deflation_frontier.py (how far a PAID adversary can deflate D_est; fig37)
|
||||
selfish_uncle_margin.py (does the uncle cap need margin under a private chain?)
|
||||
spec_point.py (the deployed operating point; the three f-precision arms)
|
||||
spec_jitter.py (per-recipient delay variance — the transport diagnostic)
|
||||
```
|
||||
|
||||
### Modelling the *deployed* chain rather than the mechanism
|
||||
|
||||
Two defaults are deliberately **not** spec-faithful, because the report's job is to isolate
|
||||
mechanisms. Flip both for any run meant to answer "what would the deployed chain read":
|
||||
|
||||
| knob | default (design) | spec-faithful | why it matters |
|
||||
|---|---|---|---|
|
||||
| `fixed_point` / `f_precision` | `False` / `1e6` — exact `f` | `True` / `1000` | the spec quantises the target rate at `1e3`, which reads **+1.0 %** high; this is the largest error in the deployed estimator |
|
||||
| `uncle_model` | `countable` — the spec's rules | (same) | `--old` is an unreachable **ceiling**, not an alternative: the spec *rejects* a block carrying a reference that fails the counting rules |
|
||||
|
||||
`scripts/spec_point.py` runs both arms side by side.
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
# The diagnostic: does per-recipient delay variance reproduce the standalone result?
|
||||
#
|
||||
# The one experiment here that could invalidate the REPORT rather than the spec section it is
|
||||
# checking. The hypothesis for the discrepancy is modelling, not measurement: the
|
||||
# standalone simulation drew an INDEPENDENT propagation delay per (block, recipient), while this
|
||||
# simulator's blend topology runs a cascade of relays and then floods network-wide from the LAST
|
||||
# relay, so nodes receive a block at nearly the same time and their views stay synchronised.
|
||||
# Independent per-recipient draws maximise view divergence, which is exactly what manufactures the
|
||||
# depth->=2 forks the first-fork rule cannot recover.
|
||||
#
|
||||
# jitter_mean adds per-(block, node) arrival noise on top of the cascade, so sweeping it
|
||||
# interpolates between the two models: 0 is the report's cascade, large values approach the
|
||||
# standalone's independent-delay regime. The observable that decides it is deep_orphan_share --
|
||||
# the fraction of in-window orphans sitting deeper than the first block of their fork, which is
|
||||
# precisely the structural quantity behind the spec section's deep-fork claim.
|
||||
#
|
||||
# This is also the report's open item 15 (correlated/heterogeneous latency untested).
|
||||
#
|
||||
# EXACT ORACLE REQUIRED: the windowed fork choice and the arrival prune are only bit-exact at
|
||||
# jitter_mean == 0 (a jittered arrival can cross the deterministic horizon), so both are off. That
|
||||
# forces the full (N x n_blocks) matrix and makes each run ~17 s at k = 2160.
|
||||
#
|
||||
# Run TWICE — default (countable) and with --old. `--old` is NOT a candidate design: under the
|
||||
# current spec a block carrying a deep-fork reference is REJECTED, so it is an unreachable upper
|
||||
# bound on what any counting rule could recover, and the countable-vs-ceiling gap is the
|
||||
# first-fork cost. 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 deployment transport
|
||||
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] # the spec's Blend cascade length
|
||||
blend_delay_max: [4.0] # the spec's operating point (rho ~ 0.27)
|
||||
max_uncles: [0, 1, 2, 4] # 0 = negative control; 4 = the spec's MAX_UNCLES
|
||||
uncle_strategy: [oldest] # spec Uncle Selection
|
||||
init_dest: [common] # per-node initial D_est from agreement
|
||||
replicates: 12 # exact-oracle runs are ~17 s each; 12 x 40 cells
|
||||
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
|
||||
windowed_fork_choice: false # exact oracle: required once jitter > 0
|
||||
prune_arrival: false # ...and the prune needs the same horizon
|
||||
jitter_mean: 0.0 # OVERRIDDEN per run by scripts/spec_jitter.py
|
||||
@ -0,0 +1,35 @@
|
||||
# Does the no-uncle loss deepen with N at the SPEC's operating point?
|
||||
# Does the no-uncle loss deepen with N at the SPEC's operating point?
|
||||
#
|
||||
# The design-band study (fine-delay-paired.yaml) already settles the countable-vs-unrestricted
|
||||
# question at delta_max = 4 with 40 replicates, but only at N = 1000. The spec section's claim
|
||||
# that "without uncles the loss is 18.7 %" is the U = 0 column, and the report shows it is
|
||||
# N-dependent (0.739 -> 0.635 over N = 1k -> 10k) at delta_max = 8. This measures it at the
|
||||
# deployment's own delay instead, where nothing has been run at N > 1000.
|
||||
#
|
||||
# delta_max = 4 is the deployed operating point: the spec's blending_delay is a FIXED 2 s per-hop dwell, and the
|
||||
# simulator's Uniform(0, delta_max) matches it in the mean at 4. D_vis ~ 8 s, rho ~ 0.27.
|
||||
#
|
||||
# Run TWICE — default (countable) and with --old — so the first-fork gap is measured at this size
|
||||
# too. `--old` is NOT a candidate design: under the current spec a block carrying a deep-fork
|
||||
# reference is REJECTED, so the unrestricted arm is an unreachable upper bound on what any
|
||||
# counting rule could recover. It is kept as exactly that ceiling.
|
||||
# Latency is in SLOTS (1 slot = 1 s).
|
||||
n_nodes: [5000] # the size arm; N = 1000 is already committed
|
||||
stake_dist: [pareto] # heavy-tailed (realistic) stake distribution
|
||||
topology: [blend] # Blend mixnet — the deployment transport
|
||||
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] # the spec's Blend cascade length
|
||||
blend_delay_max: [4.0] # the spec's 2 s dwell, matched in the mean
|
||||
max_uncles: [0, 1, 2, 4] # 0 = the C3 column; 4 = the spec's MAX_UNCLES
|
||||
uncle_strategy: [oldest] # spec Uncle Selection
|
||||
init_dest: [common] # per-node initial D_est from agreement
|
||||
replicates: 12 # N = 5000 at true k is heavy; 12 gives ~±0.2 pp
|
||||
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
|
||||
@ -0,0 +1,34 @@
|
||||
# Is w_u = 300 adequate at the SPEC's operating point, and how much margin is there?
|
||||
#
|
||||
# The spec section claims the window "never binds" (median reference lag 34 slots,
|
||||
# max 197). The report puts a hard recovery floor at ~7/f = 210 slots, set by block SPACING rather than by
|
||||
# delay, and advises widening to 450-600 near rho ~ 1. "Has margin at rho < 1" is defensible;
|
||||
# "never binds" is not, and the difference is what this measures.
|
||||
#
|
||||
# absorption-window.yaml sweeps the same axis but starts at delta_max = 8; nothing has been run at
|
||||
# the deployment's delta_max = 4. Since the floor is set by block spacing the knee should sit
|
||||
# in the same place, which is itself worth confirming rather than assuming — the report's
|
||||
# delay-invariance claim is exactly what makes a single recommended W defensible.
|
||||
#
|
||||
# U = 1 isolates the window: with more uncle slots the queue drains faster and the window stops
|
||||
# being the binding constraint, which would mask the knee.
|
||||
# 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 deployment transport
|
||||
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] # the spec's Blend cascade length
|
||||
blend_delay_max: [4.0] # the spec's operating point
|
||||
window_absorption: [1, 2, 3, 5, 7, 10, 15, 20] # W in expected block-intervals; w_u = W/f slots
|
||||
max_uncles: [1] # FIXED at one uncle: the question is about W
|
||||
uncle_strategy: [oldest] # spec Uncle Selection
|
||||
init_dest: [common] # per-node initial D_est from agreement
|
||||
replicates: 12 # enough to place the knee, not to resolve 0.1 pp
|
||||
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
|
||||
@ -68,10 +68,18 @@ def sweep() -> pd.DataFrame:
|
||||
|
||||
|
||||
def best_profitable(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Per alpha, the frontier point minimising D_hat among those paying at least stake share."""
|
||||
"""Per alpha, the frontier point minimising D_hat among those that actually pay.
|
||||
|
||||
"Pays" means ABSOLUTE reward per unit time at least matching honest mining
|
||||
(``pay_vs_honest >= 1``), not merely a revenue *share* at least matching stake. The two
|
||||
differ by ``density / dhat``: the share's denominator is the canonical block rate, but the
|
||||
pay rate is set by the estimator, which holds counted density at ``f`` per slot. Using the
|
||||
share alone credits the attacker with pay it does not receive — at alpha = 0.36 it marks
|
||||
points as break-even that are ~5 % short.
|
||||
"""
|
||||
out = []
|
||||
for _a, g in df.groupby("alpha"):
|
||||
paid = g[g.reward_per_stake >= 1.0 - 1e-9]
|
||||
paid = g[g.pay_vs_honest >= 1.0 - 1e-9]
|
||||
if paid.empty:
|
||||
continue
|
||||
out.append(paid.loc[paid.dhat_countable.idxmin()])
|
||||
@ -81,18 +89,20 @@ def best_profitable(df: pd.DataFrame) -> pd.DataFrame:
|
||||
def report(df: pd.DataFrame) -> None:
|
||||
best = best_profitable(df)
|
||||
print(f"{'alpha':>6} {'rev-opt rev':>12} {'rev-opt D':>10} | "
|
||||
f"{'best paid rev':>14} {'xstake':>7} {'D':>7} {'extra deflation':>16}")
|
||||
f"{'best paid rev':>14} {'pay/hon':>8} {'D':>7} {'extra deflation':>16}")
|
||||
for a, g in df.groupby("alpha"):
|
||||
ro_r, ro_d = g.revenue_optimal.iloc[0], g.dhat_revenue_optimal.iloc[0]
|
||||
b = best[best.alpha == a]
|
||||
if b.empty:
|
||||
print(f"{a:6.3f} {ro_r:12.4f} {ro_d:10.4f} | (nothing profitable deflates)")
|
||||
print(f"{a:6.3f} {ro_r:12.4f} {ro_d:10.4f} | (no policy in the sweep both pays "
|
||||
f"and deflates)")
|
||||
continue
|
||||
b = b.iloc[0]
|
||||
print(f"{a:6.3f} {ro_r:12.4f} {ro_d:10.4f} | {b.revenue:14.4f} "
|
||||
f"{b.reward_per_stake:7.3f} {b.dhat_countable:7.4f} {ro_d - b.dhat_countable:+16.4f}")
|
||||
print("\n(extra deflation > 0 means the profitable-but-deflating policy beats the "
|
||||
"revenue-optimal one at damaging the estimate, at no cost versus honest mining)")
|
||||
f"{b.pay_vs_honest:8.3f} {b.dhat_countable:7.4f} {ro_d - b.dhat_countable:+16.4f}")
|
||||
print("\n(extra deflation > 0 means the paying-but-deflating policy beats the revenue-optimal "
|
||||
"one at damaging the estimate, at no cost versus honest mining; 'pay/hon' is ABSOLUTE "
|
||||
"reward per unit time, not revenue share)")
|
||||
|
||||
|
||||
def fig37(df: pd.DataFrame) -> None:
|
||||
|
||||
107
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_jitter.py
Normal file
107
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_jitter.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""does per-recipient delay variance reproduce the standalone result? (the diagnostic).
|
||||
|
||||
The one fork-loss experiment that could invalidate the REPORT rather than the spec section it
|
||||
is checking. The hypothesis for the original discrepancy is a modelling difference, not a
|
||||
measurement one: the standalone simulation drew an independent propagation delay per
|
||||
(block, recipient), whereas this simulator's Blend cascade floods network-wide from the last
|
||||
relay, so nodes receive a block at nearly the same time and their views stay synchronised.
|
||||
Independent per-recipient draws maximise view divergence, which is what manufactures the
|
||||
depth->=2 forks the first-fork rule cannot recover.
|
||||
|
||||
`jitter_mean` adds per-(block, node) arrival noise on top of the cascade, so sweeping it
|
||||
interpolates between the two models. The deciding observable is `deep_orphan_share`: the fraction
|
||||
of in-window orphans sitting deeper than the first block of their fork — precisely the structural
|
||||
quantity behind the spec section's deep-fork claim, and the thing `p_ref` conflates with
|
||||
"never picked up".
|
||||
|
||||
Pass / fail:
|
||||
* D-hat/D holds at ~1.000 and deep orphans stay negligible as jitter rises -> the standalone
|
||||
model was simply wrong; its numbers are artefacts and the report is robust to this failure mode.
|
||||
* accuracy degrades toward 0.986 and deep orphans reach ~1 % of blocks at some jitter level
|
||||
-> record that level and compare it to what Blend plausibly delivers; per-recipient variance
|
||||
then becomes a parameter the report must carry, and the spec section's number is defensible
|
||||
under a stated assumption.
|
||||
|
||||
Exact oracle throughout: the windowed fork choice and the arrival prune are bit-exact only at
|
||||
jitter_mean == 0, so both are disabled and the full arrival matrix is used.
|
||||
|
||||
Run: python scripts/spec_jitter.py (writes runs/spec_jitter.parquet)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from joblib import Parallel, delayed
|
||||
|
||||
from tsi_sim.config import SimConfig
|
||||
from tsi_sim.engine import run_trajectory
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent
|
||||
RUNS = HERE / "runs"
|
||||
RUNS.mkdir(exist_ok=True)
|
||||
|
||||
REPS = 12
|
||||
N_JOBS = 12
|
||||
JITTERS = [0.0, 1.0, 2.0, 4.0, 8.0]
|
||||
CAPS = [0, 1, 2, 4]
|
||||
|
||||
SPEC_POINT = dict(n_nodes=1000, stake_dist="pareto", topology="blend", degree=6,
|
||||
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
|
||||
blend_delay_max=4.0, uncle_strategy="oldest", window_absorption=10.0,
|
||||
k=2160, epochs=20, genesis_d_factor=0.5, early_stop=True,
|
||||
windowed_fork_choice=False, prune_arrival=False)
|
||||
|
||||
|
||||
def _cell(model: str, jitter: float, u: int, rep: int) -> dict:
|
||||
cfg = SimConfig(**SPEC_POINT, uncle_model=model, jitter_mean=jitter,
|
||||
max_uncles=u, replicate=rep)
|
||||
t = pd.DataFrame(run_trajectory(cfg))
|
||||
t = t[t.epoch >= t.epoch.max() // 2]
|
||||
return dict(model=model, jitter_mean=jitter, max_uncles=u, rep=rep,
|
||||
mean_ratio=float(t.mean_ratio.mean()),
|
||||
fork_rate=float(t.fork_rate.mean()),
|
||||
deep_orphan_share=float(t.deep_orphan_share.mean()),
|
||||
p_ref=float(t.p_ref.mean()),
|
||||
max_reorg_depth=int(t.max_reorg_depth.max()),
|
||||
range_ratio=float(t.range_ratio.max()),
|
||||
agreement_window=float(t.agreement_window.min()))
|
||||
|
||||
|
||||
def sweep() -> pd.DataFrame:
|
||||
jobs = [(m, j, u, r) for m in ("countable", "old") for j in JITTERS
|
||||
for u in CAPS for r in range(REPS)]
|
||||
df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)(
|
||||
delayed(_cell)(m, j, u, r) for m, j, u, r in jobs))
|
||||
df.to_parquet(RUNS / "spec_jitter.parquet", index=False)
|
||||
return df
|
||||
|
||||
|
||||
def report(df: pd.DataFrame) -> None:
|
||||
print("\n=== accuracy vs per-(block,node) jitter at the spec point (delta_max = 4) ===")
|
||||
print(f"{'jitter':>7} | " + " ".join(f"U={u}" for u in CAPS)
|
||||
+ f" | {'ceiling U=1':>11} {'gap':>8} {'deep orph':>10} {'fork':>6} {'consensus':>10}")
|
||||
for j in JITTERS:
|
||||
c = df[(df.model == "countable") & (df.jitter_mean == j)]
|
||||
o = df[(df.model == "old") & (df.jitter_mean == j)]
|
||||
cells = [f"{c[c.max_uncles == u].mean_ratio.mean():.4f}" for u in CAPS]
|
||||
c1 = c[c.max_uncles == 1].mean_ratio.mean()
|
||||
o1 = o[o.max_uncles == 1].mean_ratio.mean()
|
||||
deep = c[c.max_uncles == 1].deep_orphan_share.mean()
|
||||
fork = c[c.max_uncles == 1].fork_rate.mean()
|
||||
ok = "exact" if c.range_ratio.max() == 0 else "SPREAD"
|
||||
print(f"{j:7.1f} | " + " ".join(cells)
|
||||
+ f" | {o1:11.4f} {o1 - c1:+8.4f} {deep:10.4f} {fork:6.3f} {ok:>10}")
|
||||
print("\ndeep orph = share of in-window orphans below their fork's first block "
|
||||
"(uncountable by construction); gap = ceiling - countable at U=1")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"=== jitter sweep, exact oracle, {len(JITTERS)*len(CAPS)*REPS*2} runs ===")
|
||||
report(sweep())
|
||||
print(f"\nwrote {RUNS}/spec_jitter.parquet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
89
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py
Normal file
89
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""What the DEPLOYED chain would read at the spec's own operating point.
|
||||
|
||||
The report measures the mechanism: it drives the estimator to exact `f`, so its numbers isolate
|
||||
fork loss from every other effect. That is the right default for design questions and the wrong
|
||||
one for "what will the deployed chain read", because the spec's estimator quantises the target
|
||||
rate — `cryptarchia-total-stake-inference.md` carries `const PRECISION: u64 = 1e3`, so
|
||||
`f_p = 0.033` at `f = 1/30` and the recursion drives density to a target ~1 % below `f`.
|
||||
|
||||
The operating point comes from `analysis-block-times-blend-network.md`: `blending_delay` is a
|
||||
FIXED per-hop dwell of 2 s (the `3d+5` max-delay arithmetic gives 11 s at d=2 and 14 s at d=3,
|
||||
matching the prose), so the simulator's `Uniform(0, delta_max)` matches it in the mean at
|
||||
`delta_max = 4` -> `D_vis ~ 8 s`, `rho ~ 0.27`.
|
||||
|
||||
Three arms, everything else identical:
|
||||
|
||||
exact f fixed_point=False the report's convention -> expect 1.000
|
||||
spec fixed_point=True, 1e3 what the chain does today -> expect ~1.010
|
||||
proposed fixed_point=True, 1e6 the report's recommendation -> expect ~1.00001
|
||||
|
||||
The point of running rather than quoting `theory.fixed_point_bias`: the closed form predicts the
|
||||
offset in isolation, and this confirms it survives the full per-node dynamics at the deployment's
|
||||
actual load, alongside the fork loss rather than instead of it.
|
||||
|
||||
Run: python scripts/spec_point.py (writes runs/spec_point.parquet)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from joblib import Parallel, delayed
|
||||
|
||||
from tsi_sim.config import SimConfig
|
||||
from tsi_sim.engine import run_trajectory
|
||||
from tsi_sim.theory import fixed_point_bias
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent
|
||||
RUNS = HERE / "runs"
|
||||
RUNS.mkdir(exist_ok=True)
|
||||
|
||||
EPOCHS = 20
|
||||
REPS = 20
|
||||
N_JOBS = 6
|
||||
|
||||
# The spec's operating point , with the spec's own MAX_UNCLES rather than the report's U = 2.
|
||||
SPEC_POINT = dict(n_nodes=1000, stake_dist="pareto", topology="blend", degree=6,
|
||||
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
|
||||
blend_delay_max=4.0, max_uncles=4, uncle_strategy="oldest",
|
||||
window_absorption=10.0, k=2160, epochs=EPOCHS,
|
||||
genesis_d_factor=0.5, early_stop=True)
|
||||
|
||||
ARMS = [("exact f (report convention)", False, 1_000_000),
|
||||
("spec today (PRECISION = 1e3)", True, 1_000),
|
||||
("recommended (PRECISION = 1e6)", True, 1_000_000)]
|
||||
|
||||
|
||||
def _cell(label: str, fixed_point: bool, precision: int, rep: int) -> dict:
|
||||
cfg = SimConfig(**SPEC_POINT, fixed_point=fixed_point, f_precision=precision, replicate=rep)
|
||||
t = pd.DataFrame(run_trajectory(cfg))
|
||||
t = t[t.epoch >= t.epoch.max() // 2]
|
||||
return dict(arm=label, fixed_point=fixed_point, f_precision=precision, rep=rep,
|
||||
mean_ratio=float(t.mean_ratio.mean()),
|
||||
fork_rate=float(t.fork_rate.mean()),
|
||||
p_ref=float(t.p_ref.mean()),
|
||||
range_ratio=float(t.range_ratio.max()))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=== the spec's operating point: delta_max = 4, D_vis ~ 8 s, rho ~ 0.27, U = 4 ===")
|
||||
jobs = [(lab, fp, pr, r) for lab, fp, pr in ARMS for r in range(REPS)]
|
||||
df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)(
|
||||
delayed(_cell)(lab, fp, pr, r) for lab, fp, pr, r in jobs))
|
||||
df.to_parquet(RUNS / "spec_point.parquet", index=False)
|
||||
|
||||
f = SimConfig(**SPEC_POINT).f
|
||||
print(f"\n{'arm':>32} {'D-hat/D':>18} {'predicted':>10} {'consensus':>10}")
|
||||
for lab, fp, pr in ARMS:
|
||||
g = df[df.arm == lab]
|
||||
pred = fixed_point_bias(f, pr) if fp else 1.0
|
||||
print(f"{lab:>32} {g.mean_ratio.mean():10.5f}+-{g.mean_ratio.sem():.5f} "
|
||||
f"{pred:10.5f} {('exact' if g.range_ratio.max() == 0 else 'SPREAD'):>10}")
|
||||
print(f"\nfork rate {df.fork_rate.mean():.3f}, p_ref {df.p_ref.mean():.4f}, "
|
||||
f"{REPS} replicates, k = 2160")
|
||||
print(f"wrote {RUNS}/spec_point.parquet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -163,11 +163,15 @@ class SimConfig:
|
||||
genesis_d_factor: float = 0.5 # genesis D = factor * true total stake
|
||||
epochs: int = 40
|
||||
# If True, quantise the target rate the way an on-chain integer estimator does:
|
||||
# f_p = int(f*tsi.PRECISION)/tsi.PRECISION. With tsi.PRECISION = 1_000_000 (the report's
|
||||
# recommended 10^-6 f-precision, §8) this gives f_p = 0.033333 and a negligible residual
|
||||
# f/f_p < 1e-5 — not the ~1% overestimate the old 10^-3 truncation produced.
|
||||
# Default False keeps the analysis-faithful exact-f behaviour.
|
||||
# f_p = int(f*f_precision)/f_precision. Default False keeps the analysis-faithful exact-f
|
||||
# behaviour, which is right for DESIGN questions (it isolates the mechanism under test).
|
||||
fixed_point: bool = False
|
||||
# The on-chain fixed-point scale, read only when fixed_point is True. The deployed spec and
|
||||
# this report disagree: cryptarchia-total-stake-inference.md carries `const PRECISION = 1e3`
|
||||
# (f_p = 0.033 at f = 1/30, so f/f_p ~ 1.010 — the ~1 % over-estimate of Appendix A), while
|
||||
# the report recommends 1e6 (f_p = 0.033333, residual < 1e-5). Default is the recommendation;
|
||||
# set 1000 for a SPEC-FAITHFUL arm answering "what would the deployed chain read".
|
||||
f_precision: int = 1_000_000
|
||||
# If True, count uncle references per BLOCK ID (the pre-fix behaviour, which double-counts
|
||||
# same-slot co-winners and inflates the equilibrium by c(f)). The correct default counts
|
||||
# per SLOT (one count per slot, matching the pre-uncle design invariant). Kept as a flag
|
||||
@ -272,6 +276,8 @@ class SimConfig:
|
||||
raise ValueError(f"churn_period must be >= 1, got {self.churn_period}")
|
||||
if self.clock_skew_max < 0:
|
||||
raise ValueError(f"clock_skew_max must be >= 0, got {self.clock_skew_max}")
|
||||
if self.f_precision < 1 or self.f_precision != int(self.f_precision):
|
||||
raise ValueError(f"f_precision must be a positive integer, got {self.f_precision!r}")
|
||||
if self.adversary_selection not in ("random", "whale"):
|
||||
raise ValueError(f"adversary_selection must be random|whale, got "
|
||||
f"{self.adversary_selection!r}")
|
||||
@ -386,7 +392,11 @@ class SimConfig:
|
||||
else self._base_key() + (self.uncle_model, self.window_absorption))
|
||||
# Appended ONLY when non-default, for the same reason the uncle_model marker is: a
|
||||
# "random"-coalition run's key must stay byte-identical to every historical run's.
|
||||
return base if self.adversary_selection == "random" else base + (self.adversary_selection,)
|
||||
if self.adversary_selection != "random":
|
||||
base = base + (self.adversary_selection,)
|
||||
# Same append-only-when-non-default discipline: a run at the default precision keeps a
|
||||
# byte-identical key, so no committed result is reseeded by adding the knob.
|
||||
return base if self.f_precision == 1_000_000 else base + (self.f_precision,)
|
||||
|
||||
def seed_key(self) -> tuple:
|
||||
"""The identity the RNG root is actually derived from (see ``rng.seedseq_for``).
|
||||
|
||||
@ -24,8 +24,15 @@ W_ABS_MAX_FACTOR = 0.6 # bound: W <= W_ABS_MAX_FACTOR * k
|
||||
|
||||
|
||||
def uncle_window_slots(w_abs: float, f: float = F) -> int:
|
||||
"""Derived uncle reference window ``w_u = W / f`` in slots (countable model)."""
|
||||
return max(1, int(round(w_abs / f)))
|
||||
"""Derived uncle reference window ``w_u = floor(W / f)`` in slots (countable model).
|
||||
|
||||
The spec writes this as a **floor** (``w_u := ⌊W·f⁻¹⌋``, cryptarchia-v1-protocol.md
|
||||
Constants). At the defaults (W = 10, f = 1/30) the quotient is exactly 300 either way, so no
|
||||
committed result moves — but ``configs/absorption-window.yaml`` sweeps ``W`` and
|
||||
``configs/block-rate.yaml`` sweeps ``f``, and at a non-integer ``W/f`` rounding would put the
|
||||
derived window a slot above the spec's.
|
||||
"""
|
||||
return max(1, int(w_abs / f))
|
||||
|
||||
|
||||
# --- Real-world inter-node network latency (per gossip link) ---------------
|
||||
|
||||
@ -31,6 +31,8 @@ class EpochResult:
|
||||
mean_reorg_depth: float # mean maximal-orphan-branch depth
|
||||
p_ref: float # emergent reference rate: in-window orphans referenced as uncles
|
||||
p_ref_honest: float # ...restricted to orphans produced OUTSIDE the coalition
|
||||
deep_orphan_share: float # in-window orphans deeper than their fork's first block
|
||||
# (uncountable by construction, §2.1)
|
||||
deep_ref_share: float # share of examined references rejected by the parent-on-chain
|
||||
# (first-fork) counting rule; 0 under the old model
|
||||
|
||||
@ -109,11 +111,13 @@ def simulate_epoch(
|
||||
w=config.effective_uncle_window)
|
||||
n_active_window = int((active_slots < T).sum())
|
||||
|
||||
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point)
|
||||
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point,
|
||||
config.f_precision)
|
||||
|
||||
attribution = coalition_mask if coalition_mask is not None else adversary_mask
|
||||
adv_blocks, honest_blocks = _canonical_producer_split(tree, A, attribution, T, E)
|
||||
fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest = fork.fork_stats(
|
||||
(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest,
|
||||
deep_orphan_share) = fork.fork_stats(
|
||||
tree, A, T, cutoff=E, coalition_mask=attribution)
|
||||
ref_total = int(ms.ref_total.sum())
|
||||
deep_ref_share = (int(ms.ref_deep.sum()) / ref_total) if ref_total else 0.0
|
||||
@ -125,5 +129,6 @@ def simulate_epoch(
|
||||
mean_orphan_rate=float(ms.orphan_rate.mean()),
|
||||
adv_blocks=adv_blocks, honest_blocks=honest_blocks,
|
||||
fork_rate=fork_rate, max_reorg_depth=max_reorg_depth, mean_reorg_depth=mean_reorg_depth,
|
||||
p_ref=p_ref, p_ref_honest=p_ref_honest, deep_ref_share=deep_ref_share,
|
||||
p_ref=p_ref, p_ref_honest=p_ref_honest, deep_orphan_share=deep_orphan_share,
|
||||
deep_ref_share=deep_ref_share,
|
||||
)
|
||||
|
||||
@ -19,8 +19,9 @@ from .blocktree import BlockTree
|
||||
|
||||
|
||||
def fork_stats(tree: BlockTree, A, T: int, cutoff: int,
|
||||
coalition_mask=None) -> tuple[float, int, float, float, float]:
|
||||
"""Return ``(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest)``.
|
||||
coalition_mask=None) -> tuple[float, int, float, float, float, float]:
|
||||
"""Return ``(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest,
|
||||
deep_orphan_share)``.
|
||||
|
||||
``p_ref`` is the emergent **reference rate**: the fraction of in-window orphans that some
|
||||
canonical block references as an uncle — the quantity the §6.8 soft-inclusion argument
|
||||
@ -35,7 +36,7 @@ def fork_stats(tree: BlockTree, A, T: int, cutoff: int,
|
||||
"""
|
||||
nb = tree.n_blocks
|
||||
if nb <= 1:
|
||||
return 0.0, 0, 0.0, 1.0, 1.0
|
||||
return 0.0, 0, 0.0, 1.0, 1.0, 0.0
|
||||
ids = np.arange(nb)
|
||||
if isinstance(A, np.ndarray):
|
||||
arrived = (A <= cutoff).any(axis=0)
|
||||
@ -55,7 +56,7 @@ def fork_stats(tree: BlockTree, A, T: int, cutoff: int,
|
||||
in_win = (tree.slot >= 0) & (tree.slot < T)
|
||||
total = int(in_win.sum())
|
||||
if total == 0:
|
||||
return 0.0, 0, 0.0, 1.0, 1.0
|
||||
return 0.0, 0, 0.0, 1.0, 1.0, 0.0
|
||||
|
||||
# depth[b] = length of the non-canonical run ending at b (0 if canonical). Parent-before-child
|
||||
# holds because a block\'s parent has a strictly smaller id (built earlier).
|
||||
@ -89,4 +90,10 @@ def fork_stats(tree: BlockTree, A, T: int, cutoff: int,
|
||||
honest_orphan = orphan_in_win & ~np.asarray(coalition_mask)[tree.leader]
|
||||
n_ho = int(honest_orphan.sum())
|
||||
p_ref_honest = (int((honest_orphan & referenced).sum()) / n_ho) if n_ho else 1.0
|
||||
return fork_rate, max_depth, mean_depth, p_ref, p_ref_honest
|
||||
|
||||
# Share of in-window orphans that sit DEEPER than the first block of their fork, i.e. whose
|
||||
# parent is itself off-chain. These are exactly the blocks the countable rule can never
|
||||
# reference (§2.1), so this is the direct structural observable behind the first-fork cost —
|
||||
# p_ref conflates it with orphans that were merely never picked up.
|
||||
deep_orphan_share = (float((depth[orphan_in_win] >= 2).sum()) / n_orphan) if n_orphan else 0.0
|
||||
return fork_rate, max_depth, mean_depth, p_ref, p_ref_honest, deep_orphan_share
|
||||
|
||||
@ -21,6 +21,7 @@ _CONFIG_FIELDS = (
|
||||
"paired_streams",
|
||||
"f", "beta", "k", "genesis_d_factor", "epochs", "fixed_point", "legacy_block_count",
|
||||
"replicate",
|
||||
"f_precision",
|
||||
"adversary_frac", "adversary_strategy", "adversary_selection", "adversary_period",
|
||||
"adversary_withhold_epochs",
|
||||
)
|
||||
@ -65,6 +66,7 @@ def divergence_row(
|
||||
mean_reorg_depth=er.mean_reorg_depth,
|
||||
p_ref=er.p_ref,
|
||||
p_ref_honest=er.p_ref_honest,
|
||||
deep_orphan_share=er.deep_orphan_share,
|
||||
deep_ref_share=er.deep_ref_share,
|
||||
)
|
||||
return row
|
||||
|
||||
@ -364,12 +364,24 @@ def deflation_frontier(alpha: float, gamma: float, lam: float, p_ref: float = 1.
|
||||
canonical = rates["adv_rate"] + rates["hon_rate"]
|
||||
blocks, runs = rates["orphan_hon_blocks"], rates["orphan_hon_runs"]
|
||||
revenue = (rates["adv_rate"] / canonical) if canonical > 0 else 0.0
|
||||
dhat = canonical + float(p_ref) * runs
|
||||
|
||||
# Absolute pay, not just share. Revenue *share* understates what an attack costs the attacker,
|
||||
# because the share's denominator is the canonical rate while the pay rate is set by the
|
||||
# ESTIMATOR: TSI drives counted density (canonical + recovered uncles) to `f` per slot, so a
|
||||
# run yields `f / dhat` block-finding events per slot and a deflating attack makes the lottery
|
||||
# easier. Pay per slot is therefore `adv_rate · f / dhat` against an honest miner's `alpha · f`,
|
||||
# so the correction on the share ratio is `density / dhat` — below 1 whenever uncles are
|
||||
# recovered. (Events vs occupied slots differ by the multi-winner factor c(f) ~ 1.017 at
|
||||
# f = 1/30, §2.1, which is neglected here and is small against the effect.)
|
||||
pay_vs_honest = (rates["adv_rate"] / (alpha * dhat)) if alpha and dhat > 0 else 0.0
|
||||
return dict(
|
||||
alpha=alpha, gamma=gamma, lam=lam,
|
||||
revenue=revenue,
|
||||
reward_per_stake=(revenue / alpha) if alpha else 0.0,
|
||||
pay_vs_honest=pay_vs_honest,
|
||||
density_fraction=canonical,
|
||||
dhat_countable=canonical + float(p_ref) * runs,
|
||||
dhat_countable=dhat,
|
||||
dhat_unrestricted=canonical + float(p_ref) * blocks,
|
||||
eta=(runs / blocks) if blocks > 0 else 1.0,
|
||||
orphan_hon_blocks=blocks,
|
||||
|
||||
@ -89,34 +89,40 @@ def density_m(tree: BlockTree, canonical_ids: list[int], T: int,
|
||||
PRECISION = 1_000_000
|
||||
|
||||
|
||||
def _f_eff(f: float, fixed_point: bool) -> float:
|
||||
def _f_eff(f: float, fixed_point: bool, precision: int | None = None) -> float:
|
||||
"""Target rate used in the recursion: exact ``f``, or the spec's integer quantisation.
|
||||
|
||||
Guards the quantised path against ``f`` so small that ``int(f*PRECISION) == 0`` (e.g. f < .001),
|
||||
``precision`` defaults to :data:`PRECISION`. It is a *parameter* because the deployed spec and
|
||||
this report's recommendation disagree on it — ``cryptarchia-total-stake-inference.md`` carries
|
||||
``const PRECISION: u64 = 1e3`` while the report recommends 1e6 — so a spec-faithful arm has to
|
||||
be able to run the value the chain would actually use.
|
||||
|
||||
Guards the quantised path against ``f`` so small that ``int(f*precision) == 0`` (e.g. f < .001),
|
||||
which would make ``f_eff = 0`` and divide-by-zero in the recursion.
|
||||
"""
|
||||
if not fixed_point:
|
||||
return f
|
||||
q = int(f * PRECISION)
|
||||
p = PRECISION if precision is None else int(precision)
|
||||
q = int(f * p)
|
||||
if q == 0:
|
||||
raise ValueError(
|
||||
f"fixed_point=True with f={f} quantises the target rate to 0 "
|
||||
f"(int(f*{PRECISION})==0); use f >= 1/{PRECISION} or fixed_point=False")
|
||||
return q / PRECISION
|
||||
f"(int(f*{p})==0); use f >= 1/{p} or fixed_point=False")
|
||||
return q / p
|
||||
|
||||
|
||||
def update_D(
|
||||
d_prev: float, m: int, T: int, f: float, beta: float, fixed_point: bool = False
|
||||
d_prev: float, m: int, T: int, f: float, beta: float, fixed_point: bool = False,
|
||||
precision: int | None = None,
|
||||
) -> float:
|
||||
"""Spec TSI recursion: ``max(1, D_prev * (1 - beta*(f_eff - m/T)/f_eff))``.
|
||||
|
||||
With ``fixed_point=True`` the target rate ``f`` is quantised as the on-chain algorithm does
|
||||
(``f_p = int(f*PRECISION)/PRECISION``). At the raised ``PRECISION = 10**6`` this is
|
||||
``f_p = 0.033333`` for f=1/30, so ``f/f_p = 1.00001`` and the residual over-estimate is
|
||||
below 10^-5 (negligible) — the report's f-precision recommendation, applied. (At the
|
||||
original spec ``PRECISION = 1000`` the offset was ~1%.)
|
||||
(``f_p = int(f*precision)/precision``). At the deployed spec's ``precision = 1e3`` this is
|
||||
``f_p = 0.033`` for f=1/30, so ``f/f_p ~ 1.010`` — the ~1 % over-estimate of Appendix A. At the
|
||||
report's recommended 1e6 it is ``f_p = 0.033333``, ``f/f_p = 1.00001``, residual below 1e-5.
|
||||
"""
|
||||
f_eff = _f_eff(f, fixed_point)
|
||||
f_eff = _f_eff(f, fixed_point, precision)
|
||||
measured_density = m / T
|
||||
d_new = d_prev * (1.0 - beta * (f_eff - measured_density) / f_eff)
|
||||
return max(d_new, 1.0)
|
||||
@ -124,13 +130,14 @@ def update_D(
|
||||
|
||||
def update_D_vec(
|
||||
d_prev: np.ndarray, m: np.ndarray, T: int, f: float, beta: float, fixed_point: bool = False,
|
||||
precision: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Per-node TSI recursion: :func:`update_D` applied elementwise over ``(N,)`` arrays.
|
||||
|
||||
Each node updates its OWN estimate ``d_prev[i]`` from its OWN measured slot count
|
||||
``m[i]``. Identical formula to :func:`update_D`, clamped at 1.
|
||||
"""
|
||||
f_eff = _f_eff(f, fixed_point)
|
||||
f_eff = _f_eff(f, fixed_point, precision)
|
||||
measured_density = np.asarray(m, dtype=float) / T
|
||||
d_new = np.asarray(d_prev, dtype=float) * (1.0 - beta * (f_eff - measured_density) / f_eff)
|
||||
return np.maximum(d_new, 1.0)
|
||||
|
||||
@ -23,14 +23,14 @@ def make_tree(slots, parents, heights):
|
||||
def test_no_forks():
|
||||
# a straight chain 1->2->3, no orphans
|
||||
tree = make_tree([-1, 0, 1, 2], [-1, 0, 1, 2], [0, 1, 2, 3])
|
||||
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
fr, mx, mn, pr, _, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
assert fr == 0.0 and mx == 0 and mn == 0.0
|
||||
|
||||
|
||||
def test_single_orphan_depth_one():
|
||||
# canonical 1(s0),2(s1),4(s3); orphan 3(s2) hangs off block1 -> branch depth 1
|
||||
tree = make_tree([-1, 0, 1, 2, 3], [-1, 0, 1, 1, 2], [0, 1, 2, 2, 3])
|
||||
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
fr, mx, mn, pr, _, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
assert mx == 1
|
||||
assert abs(fr - 1 / 4) < 1e-9 # 1 orphan of 4 in-window blocks
|
||||
|
||||
@ -39,7 +39,7 @@ def test_deep_orphan_branch():
|
||||
# canonical spine 1..3 (heights 1,2,3); a 2-deep orphan branch 4->5 off block1
|
||||
# blocks: 0 gen; 1(s0,h1),2(s1,h2),3(s2,h3) canonical; 4(s1,h2)->1, 5(s2,h3)->4 orphan
|
||||
tree = make_tree([-1, 0, 1, 2, 1, 2], [-1, 0, 1, 2, 1, 4], [0, 1, 2, 3, 2, 3])
|
||||
fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
fr, mx, mn, pr, _, _ = fork_stats(tree, None, T=10, cutoff=100)
|
||||
assert mx == 2 # branch 4->5 is 2 deep
|
||||
assert abs(fr - 2 / 5) < 1e-9 # 2 orphans of 5
|
||||
|
||||
|
||||
@ -148,8 +148,12 @@ def test_uncle_counting_repairs_part_of_the_selfish_deflation():
|
||||
def test_p_ref_honest_defaults_to_p_ref_without_a_coalition():
|
||||
from tsi_sim.fork import fork_stats
|
||||
cfg, tree, A, _ = _tree()
|
||||
*_, p_ref, p_ref_h = fork_stats(tree, A, cfg.period_T, cutoff=cfg.epoch_len)
|
||||
assert p_ref == p_ref_h
|
||||
# Unpack by position, not with a splat: fork_stats has grown a field twice now, and a
|
||||
# trailing `*_, a, b` silently re-binds to different quantities each time it does.
|
||||
(_fork_rate, _max_d, _mean_d, p_ref, p_ref_honest,
|
||||
deep_orphan_share) = fork_stats(tree, A, cfg.period_T, cutoff=cfg.epoch_len)
|
||||
assert p_ref == p_ref_honest # no coalition -> the two coincide
|
||||
assert 0.0 <= deep_orphan_share <= 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["selfish", "withhold"])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user