diff --git a/reports/tsi/README.md b/reports/tsi/README.md index 6f9a6d9..b1b6633 100644 --- a/reports/tsi/README.md +++ b/reports/tsi/README.md @@ -304,7 +304,7 @@ The full sweep behind `fig3` spans hops 3–6 × per-hop budget 4–32 s × `U **Why it matters.** Too small a window silently discards orphans before any block gets a chance to reference them, and the under-count of [§3.2](#s3-2) returns no matter how many uncle slots exist; too large a window only costs a little validation state, so the risk is entirely on the small side. -**Result: the window is sized by block *spacing*, not by network delay.** Sweeping `W` at fixed U = 1 (`fig4`) gave a counter-intuitive result: the critical `W` is **~100–200 slots even for a 2-slot delay**, and barely depends on the delay. The reason is *queueing*: canonical blocks appear only every `1/f = 30` slots, and with U = 1 each references one orphan, so an orphan waits in a FIFO queue drained at ~1 per block interval. The window must span **several block intervals** for a queued orphan to reach a block with a free uncle slot before it ages out. (All runs fill uncle slots **oldest-first** — the FIFO behind this law. A random draw among the queued orphans drains one no faster than oldest-first, so the floor under it is no smaller; the margined `W = 10/f` covers that sensitivity.) Empirically: +**Result: the window is sized by block *spacing*, not by network delay.** Sweeping `W` at fixed U = 1 (`fig4`) gave a counter-intuitive result: the critical `W` is **~100–200 slots even for a 2-slot delay**, and barely depends on the delay. The reason is *queueing*: canonical blocks appear only every `1/f = 30` slots, and with U = 1 each references one orphan, so an orphan waits in a FIFO queue drained at ~1 per block interval. The window must span **several block intervals** for a queued orphan to reach a block with a free uncle slot before it ages out. (All runs fill uncle slots **oldest-first** — the FIFO behind this law, and the rule the spec itself prescribes: the proposer takes the oldest candidates first, deterministically, because an uncle expires `w_u` slots after its own slot so the oldest are closest to expiring. Selection is proposer-local and never validated, so a proposer *can* deviate; what that costs is measured below.) Empirically: > **`W_min ≈ 7/f`** (the measured recovery floor is ≈ 200 slots at f = 1/30 = 6.7 block intervals; rounded design rule 7/f = 210 slots = 7 intervals), roughly constant across delay and U, rising toward ~10/f only as the load approaches the uncle capacity. @@ -330,6 +330,17 @@ So the two levers separate cleanly: **`U` must cover the average load; `W` must **The derived window.** Sweeping the *derived* window `w_u = W_abs/f` directly (`configs/absorption-window.yaml`, U = 1, 5 replicates; `fig33`) confirms the `10/f` margin, with one correction to the shape. At the 8-s budget recovery runs 0.709 → 0.940 → **0.996** as `W_abs` goes 1 → 3 → 10; at the 16-s budget (`ρ ≈ 1`) `W_abs = 10` reaches 0.964; at the 32-s budget no window recovers what `U = 1` cannot drain. The window-miss probability `(1−f)^(W_abs/f)` falls below 10⁻⁴ by `W_abs = 10`, so past that point the window is no longer a loss channel — which is what makes `W_abs = 10` and the bound `W_abs ≤ 0.6·k` land on this section's `W = 10/f` rule. +**What deviating from the spec's selection rule costs** (`configs/uncle-selection.yaml`, U = 1, 10 replicates, N = 1 000, k = 2160). Oldest-first is the spec rule, but nothing enforces it: uncle selection is proposer-local and the `uncles` field is not validated. A proposer that instead walks the same candidates and includes each on a fair coin — so a *lone* candidate is dropped half the time — loses accuracy at every window width, and the loss is largest exactly where the window is doing its job: + +| `W` | δ_max = 8 s: oldest / coin | δ_max = 16 s: oldest / coin | +|---|---|---| +| 3 | 0.930 / 0.871 | 0.775 / 0.677 | +| 5 | 0.978 / 0.906 | 0.872 / 0.807 | +| 7 | 0.990 / 0.949 | 0.935 / 0.883 | +| 10 | **0.997 / 0.980** | **0.965 / 0.902** | + +At the design point (δ_max = 8, `ρ ≈ 0.56`) the recommended `W = 10` still clears the 0.98 recovery bar under the coin — but at 0.980 against oldest-first's 0.997, i.e. with the margin spent rather than in hand. At the load boundary (δ_max = 16, `ρ ≈ 1`) it does not: 0.902 against 0.965, a `−0.063` gap at `t = −8.6`. The mechanism is that a coin wastes *opportunities*, not queue capacity: a well-sized window keeps the orphan queue short, and a short queue is precisely where dropping a lone candidate cannot be made up by the next one — which is why the gap does not close as `W` grows at the boundary. Two consequences. The `W = 10/f` margin is sized against the deterministic rule and should not be read as covering selection deviation as well; and the spec's argument that "a proposer has no incentive to deviate" rests on uncles granting no reward — which the [§8.5](#s8-5) uncle-reward recommendation would change, making the selection rule worth stating as a SHOULD with this cost attached. + Two cautions on reading `fig33`. **Accuracy has not saturated at `W_abs ≈ 7`** — it is still climbing at every delay (8 s: 0.989 → 0.996; 16 s: 0.942 → 0.964 from 7 to 10), so the ≈ 7-block-interval queueing floor of the raw-slot sweep above is a floor on *usefulness*, not a point where widening stops paying. And the **32-s curve is noise, not a trend**: replicate SD reaches 0.22 there and the sampled points are non-monotonic (0.623 at `W_abs = 5`, 0.526 at 7, 0.617 at 10). Only its ceiling is meaningful, and that ceiling is set by `U = 1`, not by the window. ![Fig 33 — accuracy vs the window absorption parameter W_abs (derived w_u = W_abs/f) at U=1 for three mixing delays, error bars = replicate SEM: the spec default W_abs=10 recovers essentially fully at design loads and is still climbing at 7; the overloaded 32-s curve is U-limited and dominated by run-to-run spread, so its shape carries no signal.](report-figures/fig33_absorption_window.png) @@ -816,7 +827,7 @@ 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 `β`, jitter > 0 for the dynamic-withhold results, and a random (rather than oldest-first) uncle-selection draw; the reported bounds are best-case-for-the-defender (the adaptive-schedule case is closed in [§6.7](#s6-7)). +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`). 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. @@ -902,6 +913,7 @@ Sweep studies are committed configs, run with `make ` (writes a dated `run | **fine delay band, PAIRED** (δ_max 1–5, 40 replicates, common random numbers — the primary design-regime measurement) | `configs/fine-delay-paired.yaml` run twice — default and with `--old`; paired-difference tests + figures by `scripts/plot_fine_delay.py` | [§3.2a](#s3-2a) | | 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) | **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/configs/uncle-selection.yaml b/tools/simulators/tsi/tsi-sim-pernode/configs/uncle-selection.yaml new file mode 100644 index 0000000..523022c --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/configs/uncle-selection.yaml @@ -0,0 +1,41 @@ +# Does the SPEC's uncle-selection rule move the window floor? (§3.4, §8.3 item 11) +# +# Every window result in the report fills uncle slots OLDEST-FIRST, and the ~7/f floor is derived +# from exactly that: an orphan queue drained one per block interval. The spec selects with an +# unbiased per-candidate coin instead (uncle_random_p = 0.5). §3.4 argues a coin drains a queued +# orphan no faster than oldest-first, so the floor under it can only be the same or higher, and +# reads the margined W = 10/f as covering the difference. That argument has never been measured. +# +# This sweeps the window absorption parameter under BOTH selection rules at the two delays that +# bracket the design regime (rho ~ 0.6 and rho ~ 1), and asks one question: does the recovery knee +# move, and does W_abs = 10 still clear? +# +# UNPAIRED by construction: uncle_strategy is part of the RNG identity, so the two arms draw +# independent stake, graph and lottery streams — and unlike the countable-vs-old comparison there +# is no way to share them, because the coin arm consumes draws the oldest-first arm does not. +# Note the §9 negative-control idiom is NOT available here: SweepConfig.expand() collapses U=0 +# across uncle_strategy (with no uncle slots the two arms would be the identical run), so there is +# no U=0 gap to read a noise floor off. Resolution comes from the replicate SEM within each cell +# instead, which is why this runs 10 replicates against absorption-window.yaml's 5. U=0 is still +# carried as the recovery baseline the knee is measured against. +# 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 (delay stresses the window) +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] # fixed hop count; delay is the swept knob +blend_delay_max: [8.0, 16.0] # design point (rho ~ 0.6) and the boundary (rho ~ 1) +window_absorption: [1, 2, 3, 5, 7, 10] # W: window in expected block-intervals +max_uncles: [0, 1] # 0 = negative control (rule never invoked), 1 = U +uncle_strategy: [oldest, random] # THE comparison: FIFO fill vs the spec's coin +init_dest: [common] # per-node initial D_est from agreement +replicates: 10 # 2x absorption-window.yaml: the arms are unpaired +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 + uncle_random_p: 0.5 # the spec's unbiased coin (only read by `random`) diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py index 3a4bd92..62968cc 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py @@ -31,6 +31,12 @@ InitDest = Literal["common", "heterogeneous"] # canonical chain), so the counted density drops ~adversary_frac and TSI deflates D_est toward # the reduced ACTIVE stake. Stronger, but the withheld blocks earn nothing (griefing/grinding). AdversaryStrategy = Literal["suppress", "withhold"] +# WHICH nodes make up that coalition, at the same total stake: +# "random" — a uniformly random set grown until its stake reaches adversary_frac (the default; the +# block share is then smooth in adversary_frac, which is all the density levers depend on); +# "whale" — the LARGEST holders first. Same stake, far fewer nodes, so the coalition's block +# production is lumpier — the untested concentration case flagged in report §6.5's scope. +AdversarySelection = Literal["random", "whale"] @dataclass(frozen=True) @@ -113,20 +119,26 @@ class SimConfig: # back into the next epoch's difficulty — which is the effect being measured, not noise. paired_streams: bool = False max_uncles: int = 0 # U (0 = baseline, no uncles) + # "oldest" IS the spec rule: cryptarchia-v1-protocol.md (Uncle Selection) has the proposer + # take the oldest candidates first, deterministically, because an uncle expires w_u slots + # after its own slot so the oldest are the closest to expiring. Every headline result uses it. uncle_strategy: UncleStrategy = "oldest" - # Coin-flip inclusion prob for the "random" strategy. Only 0.5 reproduces the spec's - # unbiased coin (cryptarchia-v1-protocol.md); other values are a deliberate, non-spec - # sensitivity knob, not protocol behaviour. + # "random" is NOT a spec variant — it is the deviation probe: walk the same oldest-first + # candidate order but include each candidate with probability uncle_random_p, so a lone + # candidate is dropped half the time. Uncle selection is proposer-local and unvalidated, so a + # proposer CAN deviate; this measures what that costs the estimate (report §3.4). uncle_random_p: float = 0.5 # --- adversary (grinding via D_est deflation) --- # Fraction of TOTAL STAKE controlled by an adversary that suppresses uncle references in its # own blocks (references no uncles), starving the TSI density count so honest nodes under-count # blocks and infer a LOW D_est -> everyone's win probability phi(f, w/D_est) rises, which is the - # grinding payoff. 0.0 = fully honest (the studied baseline). The coalition is a RANDOM node set - # whose stake sums to adversary_frac (see engine._adversary_mask); block production is - # stake-proportional, so the deflation depends only on that summed share, not on whether the - # coalition is one whale or many small nodes. Withholding is a separate, stronger lever. + # grinding payoff. 0.0 = fully honest (the studied baseline). The coalition is by default a + # RANDOM node set whose stake sums to adversary_frac (see engine._adversary_mask); block + # production is stake-proportional, so the deflation depends only on that summed share, not on + # whether the coalition is one whale or many small nodes. Withholding is a separate, stronger + # lever, and adversary_selection controls WHICH nodes are taken at that fixed stake. adversary_frac: float = 0.0 + adversary_selection: AdversarySelection = "random" # Dynamic (withhold-then-rejoin) schedule for the withholding lever (§6.5). The coalition is # FIXED (identity from adversary_frac); this only gates whether it withholds in a given epoch. # adversary_period == 0 -> STATIC: the coalition attacks (withholds) every epoch (the §6.4 @@ -255,6 +267,9 @@ 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.adversary_selection not in ("random", "whale"): + raise ValueError(f"adversary_selection must be random|whale, got " + f"{self.adversary_selection!r}") if self.adversary_strategy not in ("suppress", "withhold"): raise ValueError(f"adversary_strategy must be suppress|withhold, got " f"{self.adversary_strategy!r}") @@ -361,9 +376,12 @@ class SimConfig: key is then byte-identical to the pre-redesign key, so ``--old`` bit-reproduces historical runs (the two models still get distinct streams from the marker). """ - if self.uncle_model == "old": - return self._base_key() # historical (pre-uncle_model) key: --old bit-compat - return self._base_key() + (self.uncle_model, self.window_absorption) + # uncle_model == "old" keeps the historical tuple exactly (--old bit-compat). + base = (self._base_key() if self.uncle_model == "old" + 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,) def seed_key(self) -> tuple: """The identity the RNG root is actually derived from (see ``rng.seedseq_for``). diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/engine.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/engine.py index 7fcbc87..eaef0c5 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/engine.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/engine.py @@ -25,24 +25,61 @@ ES_MEASURE = 10 # measurement epochs run after detection def _adversary_mask(config: SimConfig, stake: np.ndarray) -> np.ndarray | None: - """Nodes controlled by the uncle-suppressing adversary — a random coalition whose stake sums to - ``adversary_frac`` of the total, giving smooth control of the adversary's block share. (For - uncle suppression the deflation depends only on that block share, not on whether the coalition - is one whale or many small nodes, so concentration is not modelled here.) ``None`` if honest. + """Nodes controlled by the uncle-suppressing adversary — a coalition whose stake sums to + ``adversary_frac`` of the total, giving smooth control of the adversary's block share. + ``None`` if honest. + + ``adversary_selection`` picks *which* nodes, at that same total stake: + + * ``"random"`` (default) — a uniformly random set. For uncle suppression the deflation depends + only on the summed block share, not on whether the coalition is one whale or many small + nodes, so this is the neutral choice and concentration does not enter. + * ``"whale"`` — the largest holders first. Same stake in far fewer nodes, so the coalition's + block production is lumpier: it is the concentration case report §6.5 flags as untested, and + the reason to run it is the *variance* of the coalition's share, not its mean. Seeded from a standalone ``SeedSequence([root_seed, replicate, 0xADEADBEEF])`` (independent of the main spawn hierarchy), and drawn only after the ``adversary_frac <= 0`` early return, so an - ``adversary_frac == 0`` run is bit-identical to the honest baseline. + ``adversary_frac == 0`` run is bit-identical to the honest baseline. The whale order is + deterministic given the stake vector and consumes no randomness, but the seed is still drawn + first so that switching selection never perturbs the rest of the stream. """ if config.adversary_frac <= 0.0: return None adv_seed = np.random.SeedSequence([config.root_seed, config.replicate, 0xADEADBEEF]) - order = np.random.default_rng(adv_seed).permutation(config.n_nodes) + rand_order = np.random.default_rng(adv_seed).permutation(config.n_nodes) target = config.adversary_frac * float(stake.sum()) - cum = np.cumsum(stake[order]) - take = int(np.searchsorted(cum, target, side="left")) + 1 # smallest coalition >= target mask = np.zeros(config.n_nodes, dtype=bool) - mask[order[:take]] = True + + if config.adversary_selection == "whale": + # Largest stake first, ties broken by the random order so equal-stake runs stay unbiased. + # Taking whales until the cumulative sum first EXCEEDS the target would overshoot badly + # under a heavy tail (the top holder alone can be a sixth of the stake), and a coalition + # holding visibly more than adversary_frac would confound concentration with stake. So: + # walk descending and take every node that still FITS under the target, then close any + # remaining gap with the smallest node that can. The coalition is therefore dominated by + # the top holders, with small nodes only topping it up onto the target — a handful of + # members against the random arm's hundreds, at the same stake. + order = rand_order[np.argsort(-stake[rand_order], kind="stable")] + st = stake[order] + chosen = np.zeros(order.size, dtype=bool) + cum = 0.0 + for j, s in enumerate(st): + if cum + s <= target: + chosen[j] = True + cum += s + if cum < target: + # `st` is descending, so among the nodes that can close the gap the LAST is the + # smallest. Every skipped node exceeds the current gap (the gap only shrinks), so a + # candidate always exists while any node remains; the fallback is defensive only. + cand = np.nonzero(~chosen & (st >= target - cum))[0] + chosen[cand[-1] if cand.size else np.nonzero(~chosen)[0][0]] = True + mask[order[chosen]] = True + return mask + + cum_r = np.cumsum(stake[rand_order]) + take = int(np.searchsorted(cum_r, target, side="left")) + 1 # smallest coalition >= target + mask[rand_order[:take]] = True return mask diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py index 7a977f4..c6ad532 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py @@ -21,7 +21,8 @@ _CONFIG_FIELDS = ( "paired_streams", "f", "beta", "k", "genesis_d_factor", "epochs", "fixed_point", "legacy_block_count", "replicate", - "adversary_frac", "adversary_strategy", "adversary_period", "adversary_withhold_epochs", + "adversary_frac", "adversary_strategy", "adversary_selection", "adversary_period", + "adversary_withhold_epochs", ) diff --git a/tools/simulators/tsi/tsi-sim-pernode/tests/test_config.py b/tools/simulators/tsi/tsi-sim-pernode/tests/test_config.py index f868711..ff0163e 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/tests/test_config.py +++ b/tools/simulators/tsi/tsi-sim-pernode/tests/test_config.py @@ -208,6 +208,9 @@ def _perturb(v): "exp": "poisson", # jitter_dist "sine": "ramp", # churn_mode "countable": "old", # uncle_model + # adversary_selection. Keyed by VALUE, so this only fires on fields that are currently + # "random" — uncle_strategy defaults to "oldest" and keeps its own flip above. + "random": "whale", } if isinstance(v, str) and v in flips: return flips[v] diff --git a/tools/simulators/tsi/tsi-sim-pernode/tests/test_rng.py b/tools/simulators/tsi/tsi-sim-pernode/tests/test_rng.py index 8f305fd..b557189 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/tests/test_rng.py +++ b/tools/simulators/tsi/tsi-sim-pernode/tests/test_rng.py @@ -106,3 +106,47 @@ def test_paired_streams_is_recorded_in_the_output_row(): from tsi_sim.metrics import _CONFIG_FIELDS assert "paired_streams" in _CONFIG_FIELDS + + +def test_whale_selection_does_not_perturb_random_coalition_seeds(): + """A default ("random") coalition's key must stay byte-identical to every historical run's. + + Same protection as test_paired_streams_does_not_perturb_unpaired_seeds gives the uncle model: + the marker is appended only when non-default, so adding the knob rewrites no existing seed. + """ + from tsi_sim.rng import seedseq_for + + for model in ("countable", "old"): + base = SimConfig(uncle_model=model, n_nodes=50, k=32, epochs=2, adversary_frac=0.3) + explicit = SimConfig(uncle_model=model, n_nodes=50, k=32, epochs=2, adversary_frac=0.3, + adversary_selection="random") + assert base.key() == explicit.key() + assert seedseq_for(base).entropy == seedseq_for(explicit).entropy + o = SimConfig(uncle_model="old", n_nodes=50, k=32, epochs=2, adversary_frac=0.3) + assert o.key() == o._base_key() # still exactly the historical tuple + whale = SimConfig(uncle_model="old", n_nodes=50, k=32, epochs=2, adversary_frac=0.3, + adversary_selection="whale") + assert whale.key() != o.key() # ...but the whale arm is its own stream + + +def test_whale_coalition_takes_the_largest_holders_at_the_same_stake(): + from tsi_sim.engine import _adversary_mask + from tsi_sim.stake import make_stake + + cfg_r = SimConfig(n_nodes=400, stake_dist="pareto", k=32, epochs=2, adversary_frac=0.3) + cfg_w = SimConfig(n_nodes=400, stake_dist="pareto", k=32, epochs=2, adversary_frac=0.3, + adversary_selection="whale") + stake = make_stake(cfg_r, rng_for(cfg_r)) + m_r, m_w = _adversary_mask(cfg_r, stake), _adversary_mask(cfg_w, stake) + total = stake.sum() + # The point of the knob is concentration at MATCHED stake, so the realised shares must agree + # closely — a whale arm holding visibly more stake would confound the two. + assert abs(stake[m_r].sum() / total - 0.3) < 0.01 + assert abs(stake[m_w].sum() / total - 0.3) < 0.01 + # ...and the whales get there with far fewer nodes: that is the variable under test. + assert m_w.sum() * 5 < m_r.sum() + # The coalition is drawn from the top of the distribution and dominated by it: the biggest + # holders are all in, and the small top-up nodes contribute almost none of its stake. + top3 = np.argsort(-stake)[:3] + assert m_w[top3].all() + assert stake[top3].sum() / stake[m_w].sum() > 0.9