mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 03:33:33 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
1a1548b8e5
commit
feafe8ec92
@ -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.
|
||||
|
||||

|
||||
|
||||
@ -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`.
|
||||
|
||||
<a id="s6-6"></a>
|
||||
### 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.
|
||||
|
||||
<a id="s8-4"></a>
|
||||
### 8.4 Capstone: the whole recipe, in one run
|
||||
@ -914,6 +922,7 @@ Sweep studies are committed configs, run with `make <name>` (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.
|
||||
|
||||
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user