Uncle selection: the spec fixes oldest-first, so measure deviation from it

Open item 11 listed "a random (rather than oldest-first) uncle-selection draw"
as an untested spec sensitivity. The spec does not leave it open: Uncle
Selection in cryptarchia-v1-protocol.md has the proposer take the oldest
candidates first, deterministically, because an uncle expires w_u slots after
its own slot. That is exactly what every result in the report already uses, so
the item is a conformance match, not a gap -- and the simulator comment calling
uncle_random_p "the spec's unbiased coin" cites text the spec no longer has.

What is genuinely open is deviation FROM that rule: selection is proposer-local
and the uncles field is never validated. configs/uncle-selection.yaml measures
the cost. A proposer that includes each candidate on a fair coin instead loses
up to 0.10 in D-hat/D, and 0.063 at the recommended W = 10 once rho ~ 1
(0.902 vs 0.965, t = -8.6). At the design point the margin survives but is
spent: 0.980 vs 0.997 against a 0.98 bar. The loss does not close as W grows,
because a coin wastes opportunities rather than queue capacity and a well-sized
window is precisely what keeps the queue short enough for that to bite.

This matters for the sec 8.5 reward recommendation: the spec argues a proposer
has no incentive to deviate BECAUSE uncles grant no reward, and paying them
removes that argument.

Also adds adversary_selection=whale (the largest holders at matched stake, for
the untested concentration case). The marker is appended to key() only when
non-default so every historical run's seed stays byte-identical, guarded by a
test alongside the paired_streams one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-05 15:42:16 +02:00
parent 0510b20684
commit 88f31340ad
No known key found for this signature in database
7 changed files with 178 additions and 22 deletions

View File

@ -304,7 +304,7 @@ The full sweep behind `fig3` spans hops 36 × per-hop budget 432 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 **~100200 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 **~100200 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 `(1f)^(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)(1o))`, 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 1011 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 ~12 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 <name>` (writes a dated `run
| **fine delay band, PAIRED** (δ_max 15, 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.

View File

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

View File

@ -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``).

View File

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

View File

@ -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",
)

View File

@ -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]

View File

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