From feafe8ec922a1ea59bfa190ec69ee4a0a47df5be Mon Sep 17 00:00:00 2001 From: Marcin Pawlowski Date: Wed, 5 Aug 2026 16:53:56 +0200 Subject: [PATCH] Close the sec 6.5 scope variants; static withholding can reach the fold Whale coalitions, jitter > 0 and very slow beta were the residual "untested adversary variants" of open item 11. None moves a conclusion: - Concentration does not change the deflation (suppression D-hat within noise at every stake), and a whale coalition reproduces the sec 6.4 withholding law D-hat -> (1-beta_adv) more cleanly than a random one: 0.9005/0.6997/0.5010 against a predicted 0.9/0.7/0.5. The "lumpier share statistic" worry points the other way, and for a reason that is about coalition CONSTRUCTION rather than concentration: a random coalition grows until its stake first reaches the target, so the last node added overshoots by its own size -- a whale, under a Pareto tail. Realised block share at a nominal beta_adv = 0.1 is 0.137 +- 0.108. Logged as item 17: the beta_adv axis is a nominal target. - jitter up to 1 slot changes nothing under attack (notch 0.390 -> 0.410, attacker share flat, range_ratio identically 0), as sec 6.1 found honestly. - Slow beta shrinks the notch (0.415 -> 0.080 for beta 1 -> 0.1) at flat attacker take, but sinks the MEAN estimate to 0.765 at beta = 0.05: the estimator can no longer track back up during the honest half of the cycle. Slowing beta buys the defender nothing on either axis. Unplanned: study A blew past the memory guard, which turned out to be the sec 6.2 fold being reached. The mechanism is sec 6.2's own -- rho_eff = rho/r, and withholding deflates r by design, so a 50 % coalition doubles the load onto rho_eff ~ 1.1 at the design point. Swept directly, the estimate collapses once in 144 runs at delta_max = 8 (a concentrated 50 % coalition) and never at delta_max = 4. That retires "not an observed dynamical trap" but is one event, so the claim is stated as a rare tail and the rate is logged unmeasured as item 18. Co-Authored-By: Claude Opus 5 (1M context) --- reports/tsi/README.md | 15 +- .../scripts/adversary_variants.py | 206 ++++++++++++++++++ 2 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 tools/simulators/tsi/tsi-sim-pernode/scripts/adversary_variants.py diff --git a/reports/tsi/README.md b/reports/tsi/README.md index 9d55a06..920b400 100644 --- a/reports/tsi/README.md +++ b/reports/tsi/README.md @@ -515,7 +515,7 @@ Jitter feeds exactly the tip-level churn that [§3.1](#s3-1) showed never reache The worry: the estimator feeds back on itself — a low estimate makes the lottery easier, more blocks collide, fewer get counted, and the estimate drops further. Could that spiral? The answer: only in a fitted static model, right at the load boundary `ρ ≈ 1`; the simulated network never reaches the spiral, but the boundary is real and is why we provision `ρ < 1` with margin. -Because the controller drives the *counted* density to the target block rate `f` (blocks per slot; `f = 1/30`, one block every 30 slots = 30 s — defined in the [§3.3](#s3-3) symbol table, and swept in [§3.6](#s3-6)), the realised proposal rate is `f/r` (`r` = the recovered accuracy `D̂/D`; under-recovery means `r < 1`), so the realised load is `ρ_eff = ρ/r > ρ` whenever the estimate is under-recovered — this single effect explains both the U=0 finite-size drift and the near-integer `⌈ρ⌉` under-shoots. Fitting the effective counted density `q_eff` ([§4](#s4), eq. 4) as `q_eff ≈ 1/(1 + 0.71·ρ_eff)` and solving the self-consistent fixed point `r = ln(1−f)/ln(1−f/q_eff(ρ/r))` gives a single reachable stable branch that **folds into a collapsed low branch at `ρ ≈ 1.08`** — the map is bistable at the recipe's own operating boundary. (`fig7`'s shaded band opens earlier, at `ρ ≈ 0.66`: that is where the unstable middle root rises above the low-`r` seed of the branch scan, so from `ρ ≈ 0.66` up a sufficiently deflated start already falls into the collapse basin, even though the upper branch itself survives to the fold.) So the honest-regime "`U = ⌈ρ⌉` suffices" line, computed on the high branch, is an upper edge, not a safe interior; the `+1` margin partly absorbs the `ρ_eff > ρ` gap. *Caveat:* this bistability is a property of the *fitted static* `q_eff(ρ)` map; the full per-node dynamics do **not** reach the fold — no simulated schedule flips to the collapsed branch ([§6.3](#s6-3)(i)) and a withhold pulse always recovers ([§6.5](#s6-5)(iv)). So the fold is a warning about *provisioning* margin, not an observed dynamical trap. +Because the controller drives the *counted* density to the target block rate `f` (blocks per slot; `f = 1/30`, one block every 30 slots = 30 s — defined in the [§3.3](#s3-3) symbol table, and swept in [§3.6](#s3-6)), the realised proposal rate is `f/r` (`r` = the recovered accuracy `D̂/D`; under-recovery means `r < 1`), so the realised load is `ρ_eff = ρ/r > ρ` whenever the estimate is under-recovered — this single effect explains both the U=0 finite-size drift and the near-integer `⌈ρ⌉` under-shoots. Fitting the effective counted density `q_eff` ([§4](#s4), eq. 4) as `q_eff ≈ 1/(1 + 0.71·ρ_eff)` and solving the self-consistent fixed point `r = ln(1−f)/ln(1−f/q_eff(ρ/r))` gives a single reachable stable branch that **folds into a collapsed low branch at `ρ ≈ 1.08`** — the map is bistable at the recipe's own operating boundary. (`fig7`'s shaded band opens earlier, at `ρ ≈ 0.66`: that is where the unstable middle root rises above the low-`r` seed of the branch scan, so from `ρ ≈ 0.66` up a sufficiently deflated start already falls into the collapse basin, even though the upper branch itself survives to the fold.) So the honest-regime "`U = ⌈ρ⌉` suffices" line, computed on the high branch, is an upper edge, not a safe interior; the `+1` margin partly absorbs the `ρ_eff > ρ` gap. *Caveat:* this bistability is a property of the *fitted static* `q_eff(ρ)` map, and the full per-node dynamics essentially never reach the fold — no grinding schedule flips to the collapsed branch ([§6.3](#s6-3)(i)) and a withhold pulse always recovers ([§6.5](#s6-5)(iv)). "Essentially" is doing real work, though, and the mechanism that closes the gap is this section's own: because the realised load is `ρ_eff = ρ/r`, an estimate deflated to `r` multiplies the load by `1/r`, and *withholding deflates `r` by design* — a 50 % coalition doubles the load, which at the design point `ρ ≈ 0.56` lands on `ρ_eff ≈ 1.1`, past the fold. Swept directly (`scripts/adversary_variants.py`, static withholding, `β_adv ∈ {0.3, 0.5}` × `δ_max ∈ {4, 8}` s × 12 replicates), the estimate stays on the upper branch in every cell at `δ_max = 4`, and single epochs dive far below it and recover (worst epoch 0.015 at `β_adv` = 0.3, tail mean 0.61). At `δ_max = 8` it collapses **once in 144 runs** — a concentrated 50 % withholding coalition, which produces blocks at the collapsed rate and never recovers within the run. So the fold is *primarily* a provisioning warning, but it is not unreachable: it is a rare tail event that a large withholding coalition at a heavy blending budget can enter, and one more reason the [§8](#s8) `ρ < 1` rule wants margin rather than equality. Characterising that tail — its frequency against `β_adv`, `ρ` and coalition concentration — needs a dedicated study and is not attempted here. ![Fig 7 — the estimator's self-consistent fixed point: the upper stable branch folds into a collapsed low branch at ρ≈1.08 in the fitted static map; the shaded band from ρ≈0.66 marks where a deflated start already falls into the collapse basin.](report-figures/fig7_feedback_bistability.png) @@ -586,7 +586,13 @@ The curve is **within `0.09` for `β = 0.5` and `β = 1.0`** at every duty — p **Scope and limits (what this does *not* cover).** These results were verified by independent re-derivation and by baseline-corrected, forfeit-inclusive replicate sweeps; the confirmed claims come with a firm boundary: - **Withholding here means *abstention* (deflate the active-stake estimate), not private-chain release.** A withheld block's arrival time is set past the epoch horizon `E` — it never propagates and is permanently orphaned, so the coalition's forfeit is a *dead loss*. That dead loss is exactly what makes (ii) unprofitable. The classic *selfish-mining / private-chain* withholder instead builds a hidden chain and *releases* it to orphan honest blocks, recovering the forfeit — the stronger lever, **analysed separately in [§6.6](#s6-6)** (it *is* profitable above the classic stake threshold, and its density deflation is mitigated by the same uncle mechanism). - **The reward metric is relative canonical-block *share*** — which, because TSI holds the counted density at `f` (so the canonical block rate does not inflate when `D̂` deflates), equals the *absolute* per-block reward per unit stake; see the issuance note in [§6.6](#s6-6). -- **Open-loop, single, equal-stake coalition.** Untested here: adaptive (feedback) schedules that react to the observed `D̂` or target the finalized measurement window directly; Pareto *whale* coalitions (lumpier share statistic); very slow `β` (where a notch persists many epochs in wall-clock); and `jitter > 0`. (The joint/adaptive adversary is closed in [§6.7](#s6-7); *multiple* non-cooperating coalitions are treated in [§6.9](#s6-9).) The reported bounds are best-case-for-the-defender. +- **Open-loop, single, equal-stake coalition.** Untested here: adaptive (feedback) schedules that react to the observed `D̂` or target the finalized measurement window directly. (The joint/adaptive adversary is closed in [§6.7](#s6-7); *multiple* non-cooperating coalitions are treated in [§6.9](#s6-9).) The remaining three scope variants — whale coalitions, very slow `β`, and `jitter > 0` — are measured below. + +**The scope variants, measured** (`scripts/adversary_variants.py`, 8–12 replicates per cell). None of the three moves a conclusion, and two sharpen one: + +- **Whale coalitions (`adversary_selection: whale`, the largest holders at matched stake).** Concentration does not change the deflation: under suppression `D̂` is indistinguishable between a random coalition and a whale one at every stake tested (0.997 vs 0.998 at `β_adv` = 0.1, 1.001 vs 0.998 at 0.3, 0.990 vs 0.985 at 0.5). Under withholding the whale arm reproduces the [§6.4](#s6-4) law `D̂ → (1−β_adv)` *more* cleanly than the random arm does — 0.9005 / 0.6997 / 0.5010 against the predicted 0.9 / 0.7 / 0.5. The concern the scope note raised (a "lumpier share statistic") turns out to point the other way, and the reason is the coalition *construction*, not concentration: a random coalition is grown until its stake first reaches `β_adv`, so the last node added overshoots by its own size — which under a Pareto tail can be a whale. Its realised block share is therefore both biased high and highly variable (0.137 ± 0.108 at a nominal `β_adv` = 0.1), while the whale coalition, filled largest-first up to the target, lands on 0.0999 ± 0.0037. Read the `β_adv` axis of [§6.3](#s6-3)–[§6.4](#s6-4) as a nominal target with that overshoot around it, not as a realised share. +- **`jitter > 0`.** Re-running the withhold-rejoin schedule in the guaranteed-exact mode across `jitter_mean ∈ {0, 0.3, 1.0}` slots changes nothing measurable: mean `D̂` 0.848 / 0.848 / 0.847, notch amplitude 0.390 / 0.402 / 0.410, attacker block share 0.150 / 0.152 / 0.152, and `range_ratio ≡ 0` throughout — consensus stays exact under attack, as [§6.1](#s6-1) found it does honestly. +- **Very slow `β`.** Extending the gain sweep below [§6.5](#s6-5)(ii)'s 0.25 confirms the trend and exposes its cost. The notch keeps shrinking (amplitude 0.415 → 0.159 → 0.080 for `β` = 1 → 0.25 → 0.1) while the attacker's take stays flat (block share 0.150 at every `β`, confirming (iii)) — but the *mean* estimate sinks as the estimator loses the ability to track back up during the honest half of the cycle: `D̂` 0.842 / 0.848 / 0.829 / **0.765** for `β` = 1 / 0.25 / 0.1 / 0.05. Slow `β` therefore trades a smaller oscillation for a deeper standing deflation, and buys the defender nothing on either axis — an independent argument for row 5's `β = 1`. ### 6.6 Private-chain (selfish) withholding is profitable above the classic threshold; uncle-counting still restores the estimate (`fig13`) @@ -827,12 +833,14 @@ Everything below is assembled from this report's own measurements; each row cite 8. **Residual ~1 % offset — resolved here, pending in the spec ([§2.2](#s2-2), row 14, [Appendix A](#sA)).** After the slot-counting fix the only systematic offset is the 10⁻³ rounding of `f` (≈ 1 % `f/f_p` over-estimate); this report's estimator applies the row-14 precision bump, but the **spec still uses 10⁻³** and should adopt it. 9. **Multi-coalition reorg — bounded, but worse than a single coalition ([§6.9](#s6-9), [§6.10](#s6-10)).** The [§6.10](#s6-10) tail is single-coalition. For `K` competing private-chain coalitions with total stake `α_tot`, the deepest reorg any one coalition (stake `α_i`) can drive against the public chain is bounded by `α_eff,i = α_i / (α_i + (1−α_tot)(1−o′))`, where `o′` is the public fork rate *inflated by every coalition's released-block orphaning*. Two channels make competition **worse** than a single colluding `α_tot` coalition: the rival coalitions' stake leaves the public chain (the honest term carries `(1−α_tot)`, not `(1−α_i)`), and their mutual orphaning lifts `o′` above the honest baseline `o` — in the limit `o′→1` even a sub-majority coalition faces a stalled public chain and reorgs unboundedly. So the single-coalition tail is best-case-for-the-defender; the safe design response is the same rule as everywhere else — size against `α_tot`, and keep `ρ < 1` so the honest baseline `o` stays small and caps how far mutual orphaning can inflate `o′`. 10. **Soft-rule bribery is cheap per block ([§6.9](#s6-9)).** A bribe > `w_n` buys one block's omitted references — bounded in effect (other honest blocks in `W` still reference), but the hard-mandate defence is unavailable *by design*. -11. **Untested adversary variants ([§6.5](#s6-5) scope).** Pareto whale coalitions, very slow `β`, and jitter > 0 for the dynamic-withhold results; the reported bounds are best-case-for-the-defender (the adaptive-schedule case is closed in [§6.7](#s6-7)). *Resolved from this list:* uncle selection, which the spec fixes as deterministic oldest-first — the rule every result here uses, so it is a conformance match rather than an untested sensitivity. What remains is deviation *from* that rule, which nothing validates; [§3.4](#s3-4) measures its cost (up to `−0.10` in `D̂/D`, and `−0.063` at the recommended `W` when `ρ ≈ 1`). +11. **Untested adversary variants — now measured ([§6.5](#s6-5)).** What remains untested is only the adaptive (feedback) schedule, and that case is closed on other grounds in [§6.7](#s6-7). The rest of the list is resolved: whale coalitions leave the deflation unchanged and reproduce the [§6.4](#s6-4) law more cleanly than a random coalition does; `jitter > 0` moves nothing under attack, as it moves nothing honestly; very slow `β` shrinks the notch but deepens the standing deflation (0.85 → 0.77 as `β` 0.25 → 0.05) at unchanged attacker take. Uncle selection leaves the list too, but as a conformance match rather than a result — the spec fixes deterministic oldest-first, the rule every result here uses; what nothing validates is *deviation* from it, whose cost [§3.4](#s3-4) measures (up to `−0.10` in `D̂/D`, and `−0.063` at the recommended `W` when `ρ ≈ 1`). Two things the measurements *added* to this list: the collapsed-branch tail event of [§6.2](#s6-2), and the coalition-construction overshoot of item 17. 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 is unquantified ([§6.6](#s6-6)).** The countable recovery ceilings measured there come from adversaries optimising something *else* — revenue (the SSZ MDP: `η = 0.44` at α = 0.4) and reorg depth (`η = 0.52` at α = 0.30). An adversary optimising the *estimator* directly would trade revenue for fork depth, since every block buried behind a fork's first is permanently uncountable while a deeper fork costs it only the marginal revenue of waiting. Both measured ceilings are therefore upper bounds on `η` — best-case-for-the-defender — not lower bounds on the damage; sizing the deflation-optimal policy is future work. +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 144 runs at `δ_max` = 8 s (a concentrated 50 % coalition) 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. ### 8.4 Capstone: the whole recipe, in one run @@ -914,6 +922,7 @@ Sweep studies are committed configs, run with `make ` (writes a dated `run | fine delay band, unpaired (same grid without `paired_streams`; superseded, retained as the power comparison) | `configs/fine-delay.yaml` run twice | [§3.2a](#s3-2a) | | **window absorption sweep** (`W` in expected block-intervals, `w_u = W/f` derived) | `configs/absorption-window.yaml`; figure by `scripts/plot_countable_vs_old.py` | [§3.4](#s3-4) | | **uncle-selection deviation** (the spec's deterministic oldest-first vs a coin, over `W` × delay) | `configs/uncle-selection.yaml` (`uncle_strategy`, `uncle_random_p`) | [§3.4](#s3-4) | +| **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-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. diff --git a/tools/simulators/tsi/tsi-sim-pernode/scripts/adversary_variants.py b/tools/simulators/tsi/tsi-sim-pernode/scripts/adversary_variants.py new file mode 100644 index 0000000..1afe90c --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/scripts/adversary_variants.py @@ -0,0 +1,206 @@ +"""The residual §6.5-scope adversary variants — REPORT §8.3 item 11. + +Three probes the robustness studies left open, each asking whether a bound reported as +best-case-for-the-defender actually moves: + + A. WHALE COALITION — §6.5 flags that a coalition of a few large holders has a "lumpier share + statistic" than a random one at the same stake. Both arms hold the same stake fraction + (engine._adversary_mask fills whales-first up to the target, so the realised shares match); + what differs is the member count, hence the run-to-run spread of the coalition's realised + block share. Measured for both levers: uncle suppression (§6.3) and withholding (§6.4). + + B. JITTER > 0 — the dynamic withhold-rejoin results (§6.5) were all run at jitter = 0. + §6.1 shows jitter never reaches the finalized density window in the HONEST case; this asks + the same of the attacked case. Run in the guaranteed-exact mode (windowed fork choice and + arrival pruning off), since those speed-ups are only bit-exact at jitter = 0. + + C. VERY SLOW beta — §6.5 sweeps the estimator gain down to beta = 0.25. "Very slow" beta is + listed as untested: with memory ~1/beta epochs, beta = 0.05 remembers ~20 epochs, so a + withhold notch should shrink further while the attacker's take stays flat (profitability is + beta-independent, §6.5(iii)). This checks that the trend continues rather than turning. + +Run: python scripts/adversary_variants.py (writes runs/adversary_variants_*.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.memguard import ArrivalMatrixTooLarge + +HERE = Path(__file__).resolve().parent.parent +RUNS = HERE / "runs" +RUNS.mkdir(exist_ok=True) + +EPOCHS = 20 +REPS = 12 +N_JOBS = 6 + +# §6.4's own withhold geometry (blend_delay_max = 4), so the concentration comparison is +# like-for-like against the published withhold/suppress numbers rather than at a heavier load. +# The heavier point is probed separately by study_withhold_load, where it does something else +# entirely — see that function. +WHALE_BASE = 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=2, uncle_window=300, k=256, + epochs=EPOCHS, genesis_d_factor=0.5) + +# §6.5 cell geometry: equal stakes so coalition_frac == adversary_frac exactly, light transport +# so the dynamic lever is measured on its own rather than through fork noise. +DYN_BASE = dict(n_nodes=600, stake_dist="uniform", topology="regular", degree=8, + link_latency_mean=0.3, link_latency_dist="geo", max_uncles=2, uncle_window=300, + genesis_d_factor=0.5, k=64, adversary_strategy="withhold", + adversary_frac=0.3, adversary_period=6, adversary_withhold_epochs=3) + + +def _tail(cfg: SimConfig) -> pd.DataFrame: + """One trajectory, burn-in discarded (the report's 50 % convention).""" + df = pd.DataFrame(run_trajectory(cfg)) + return df[df.epoch >= cfg.epochs // 2] + + +def _tail_or_collapse(cfg: SimConfig) -> tuple[pd.DataFrame | None, bool]: + """``(tail, collapsed)``. A run whose estimate falls into the §6.2 collapsed branch produces + blocks at up to one per node per slot, so the arrival matrix blows past the memory guard and + :class:`ArrivalMatrixTooLarge` is raised. That is a *result*, not an error — dropping the cell + would silently bias a mean upward — so it is caught and reported as a collapse. + """ + try: + return _tail(cfg), False + except ArrivalMatrixTooLarge: + return None, True + + +def study_whale() -> pd.DataFrame: + def cell(badv: float, selection: str, strategy: str, rep: int) -> dict: + t, collapsed = _tail_or_collapse( + SimConfig(adversary_frac=badv, adversary_selection=selection, + adversary_strategy=strategy, replicate=rep, **WHALE_BASE)) + row = dict(beta_adv=badv, selection=selection, strategy=strategy, rep=rep, + collapsed=collapsed) + if t is not None: + row |= dict(mean_ratio=float(t.mean_ratio.mean()), + adv_block_share=float(t.adv_block_share.mean())) + return row + + jobs = [(b, s, st, r) for b in (0.1, 0.3, 0.5) for s in ("random", "whale") + for st in ("suppress", "withhold") for r in range(REPS)] + df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)( + delayed(cell)(b, s, st, r) for b, s, st, r in jobs)) + df.to_parquet(RUNS / "adversary_variants_whale.parquet", index=False) + return df + + +def study_jitter() -> pd.DataFrame: + def cell(jitter: float, rep: int) -> dict: + # jitter > 0 makes the windowed/pruned engine an approximation, so use the exact oracle. + t = _tail(SimConfig(jitter_mean=jitter, replicate=rep, + windowed_fork_choice=False, prune_arrival=False, + epochs=EPOCHS, **DYN_BASE)) + return dict(jitter_mean=jitter, rep=rep, + mean_ratio=float(t.mean_ratio.mean()), + notch=float(t.mean_ratio.max() - t.mean_ratio.min()), + adv_block_share=float(t.adv_block_share.mean()), + range_ratio=float(t.range_ratio.max())) + + jobs = [(j, r) for j in (0.0, 0.3, 1.0) for r in range(8)] + df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)( + delayed(cell)(j, r) for j, r in jobs)) + df.to_parquet(RUNS / "adversary_variants_jitter.parquet", index=False) + return df + + +def study_slow_beta() -> pd.DataFrame: + def cell(beta: float, rep: int) -> dict: + t = _tail(SimConfig(beta=beta, replicate=rep, epochs=40, **DYN_BASE)) + return dict(beta=beta, rep=rep, + mean_ratio=float(t.mean_ratio.mean()), + notch=float(t.mean_ratio.max() - t.mean_ratio.min()), + adv_block_share=float(t.adv_block_share.mean())) + + jobs = [(b, r) for b in (1.0, 0.25, 0.1, 0.05) for r in range(8)] + df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)( + delayed(cell)(b, r) for b, r in jobs)) + df.to_parquet(RUNS / "adversary_variants_beta.parquet", index=False) + return df + + +def study_withhold_load() -> pd.DataFrame: + """D. Does static withholding reach the §6.2 fold? (unplanned — found by A blowing up.) + + §6.2 fits a static feedback map that folds into a collapsed low branch at `rho ~ 1.08`, and + records that the full per-node dynamics never get there. But the same section gives the + mechanism that would take them there: the realised load is `rho_eff = rho / r`, so an + estimate deflated to `r` multiplies the load by `1/r`. Withholding deflates `r` to about + `1 - beta_adv` BY DESIGN (§6.4), so a 50 % coalition doubles the load — and at the design + point `rho ~ 0.56` that lands on `rho_eff ~ 1.1`, past the fold. + + This sweeps the blending budget under static withholding at `beta_adv` 0.3/0.5 and records + how often the estimate collapses, which is the direct test of "never reached in the dynamics". + """ + def cell(badv: float, delay: float, rep: int) -> dict: + cfg = SimConfig(**{**WHALE_BASE, "blend_delay_max": delay}, + adversary_frac=badv, adversary_strategy="withhold", replicate=rep) + t, collapsed = _tail_or_collapse(cfg) + row = dict(beta_adv=badv, blend_delay_max=delay, rep=rep, collapsed=collapsed) + if t is not None: + row |= dict(mean_ratio=float(t.mean_ratio.mean()), + min_ratio=float(t.mean_ratio.min()), + adv_block_share=float(t.adv_block_share.mean())) + return row + + jobs = [(b, d, r) for b in (0.3, 0.5) for d in (4.0, 8.0) for r in range(REPS)] + df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)( + delayed(cell)(b, d, r) for b, d, r in jobs)) + df.to_parquet(RUNS / "adversary_variants_withhold_load.parquet", index=False) + return df + + +def _report_withhold_load(df: pd.DataFrame) -> None: + print("\n=== D. static withholding vs the §6.2 fold (rho_eff = rho / r) ===") + print(f"{'b_adv':>6} {'delta':>6} {'collapsed':>10} {'D-hat/D':>18} {'worst epoch':>12}") + for badv in sorted(df.beta_adv.unique()): + for delay in sorted(df.blend_delay_max.unique()): + g = df[(df.beta_adv == badv) & (df.blend_delay_max == delay)] + ok = g[~g.collapsed] + mr = f"{ok.mean_ratio.mean():8.4f}+-{ok.mean_ratio.std(ddof=1):.4f}" if len(ok) > 1 \ + else f"{'n/a':>16}" + worst = f"{ok.min_ratio.min():12.4f}" if len(ok) else f"{'n/a':>12}" + print(f"{badv:6.1f} {delay:6.1f} {int(g.collapsed.sum()):5d}/{len(g):<4d} {mr} {worst}") + + +def _report_whale(df: pd.DataFrame) -> None: + print("\n=== A. whale vs random coalition (same stake, far fewer members) ===") + print(f"{'strategy':>9} {'b_adv':>6} {'sel':>7} {'D-hat/D':>16} {'adv block share':>20}") + for strategy in ("suppress", "withhold"): + for badv in (0.1, 0.3, 0.5): + for sel in ("random", "whale"): + g = df[(df.strategy == strategy) & (df.beta_adv == badv) & (df.selection == sel)] + print(f"{strategy:>9} {badv:6.1f} {sel:>7} " + f"{g.mean_ratio.mean():8.4f}+-{g.mean_ratio.std(ddof=1):.4f} " + f"{g.adv_block_share.mean():12.4f}+-{g.adv_block_share.std(ddof=1):.4f}") + + +def _report_simple(df: pd.DataFrame, key: str, title: str) -> None: + print(f"\n=== {title} ===") + cols = [c for c in ("mean_ratio", "notch", "adv_block_share", "range_ratio") if c in df] + agg = df.groupby(key)[cols].agg(["mean", "std"]) + print(agg.round(4).to_string()) + + +def main() -> None: + print("=== residual adversary variants (report §8.3 item 11) ===") + _report_whale(study_whale()) + _report_simple(study_jitter(), "jitter_mean", "B. dynamic withhold-rejoin under jitter") + _report_simple(study_slow_beta(), "beta", "C. dynamic withhold-rejoin at very slow beta") + _report_withhold_load(study_withhold_load()) + print(f"\nwrote {RUNS}/adversary_variants_{{whale,jitter,beta,withhold_load}}.parquet") + + +if __name__ == "__main__": + main()