mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 03:33:33 +00:00
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:
parent
0510b20684
commit
88f31340ad
@ -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.
|
||||
|
||||

|
||||
@ -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 <name>` (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.
|
||||
|
||||
|
||||
@ -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`)
|
||||
@ -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``).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user