Marcin Pawlowski 516783146d
Fix adversary coalition sizing; add rival coalitions and a lead cap
Three engine defects, found while building the multi-coalition study §6.9 flags
as open. The first is the serious one.

1. COALITION SIZING (engine._adversary_mask, `random` selection — the default).
   The coalition was the smallest random prefix whose stake reached the target.
   Under a Pareto tail a whale straddling the cut carries it far past its label:
   over 60 replicates, a nominal adversary_frac of 0.4 realised a MAJORITY in
   ~10% of them and reached 0.97, and 0.2 reached 0.90. The median was always
   on-label, which is why it hid — it distorts the tail, not the centre.

   Both other places in the code that size a set by stake had already rejected
   this rule: the `whale` arm uses fit-then-close, and _churn_inactive_mask
   documents the identical failure ("a 30% label realising up to ~53%"). The
   `random` arm kept it. Now fit-then-close in random order, and a draw where
   the tail leaves no subset near the label warns instead of silently running a
   different attacker. Realised stake is now within 0.1% of its label.

   Re-ran the load-bearing studies. §8.4 capstone (2 of 8 replicates
   contaminated, one a 61% majority): spec rule 0.994 -> 0.995, p_ref 0.936 ->
   0.937. The parent-anchored variant is far more sensitive — 0.974 -> 0.990,
   p_ref 0.875 -> 0.923 — because a tighter window and a larger suppressing
   coalition compound, so §8.4's argument for the W = 12 pairing rested on
   0.021 of cost that is really 0.006. The pairing itself survives re-measurement
   and is now better supported: p_ref reaches parity at W = 12 too, not at 15.
   §6.8's uncle-margin sweep and §6.5's random-arm variants are flagged as
   needing re-measurement (§8.3 item 20), not silently carried.

2. SM1 NEVER TERMINATED under a forking honest network. Textbook SM1 waits while
   it leads, assuming the lead returns to zero. But honest blocks fork against
   each other, so the public chain's HEIGHT grows at ~(1-a)*f*(1-fork) while a
   coalition sharing one view extends privately at the full a*f; past a fork rate
   of ~1 - a/(1-a) the private chain outruns the public one and `wait` never
   fires. The lead ran to thousands and every block was stranded at the epoch
   boundary — 98% of adversarial blocks at alpha=0.4, delta_max=8 — scoring an
   attacker that WON the race as having earned nothing. selfish_lead_cap
   (default: the finality depth k) publishes a lead that can no longer be caught.
   Inert unless `wait` stops terminating; pinned paired.

3. RIVAL COALITIONS (adversary_coalitions = K) for the §6.9 study: K private
   chains, each invisible to the others by the same arrival sentinel that hides
   them from honest nodes, so they orphan each other as well as the honest chain.
   Stake-balanced partition (LPT), K=1 bit-identical to the single-coalition path.

Also corrects §8.4's closing paragraph, which still quoted a pre-countable
D-hat/D of 1.001 and fork rates that contradicted its own table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:21:00 +02:00

139 lines
6.8 KiB
Python

"""Single per-node epoch: per-node lottery -> global tree + arrival matrix -> per-node
canonical chain, density, and self-update of each node's own D_est."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from . import fork, lottery, tsi
from .blocktree import build_tree_pernode
from .config import SimConfig
from .measure import measure
@dataclass
class EpochResult:
d_next: np.ndarray # (N,) each node's updated D_est
m: np.ndarray # (N,) per-node measured slot count (canonical + recovered)
q: np.ndarray # (N,) per-node honest active-slot fraction
q_eff: np.ndarray # (N,) per-node uncle-recovered fraction
n_blocks: int # real blocks produced
n_active_window: int # global active slots in window
agreement_window: float # fraction of nodes sharing the modal window prefix
agreement_tip: float # fraction of nodes sharing the modal current tip
mean_orphan_rate: float # mean over nodes of (blocks not on my chain)/blocks
adv_blocks: int # coalition blocks on the canonical chain, in window (reward)
honest_blocks: int # non-coalition blocks on the canonical chain, in window
fork_rate: float # orphaned / total blocks in window
max_reorg_depth: int # deepest maximal orphan branch (blocks a reorg would discard)
mean_reorg_depth: float # mean maximal-orphan-branch depth
p_ref: float # emergent reference rate: in-window orphans referenced as uncles
p_ref_honest: float # ...restricted to orphans produced OUTSIDE the coalition
deep_orphan_share: float # in-window orphans deeper than their fork's first block
# (uncountable by construction, §2.1)
deep_ref_share: float # share of examined references rejected by the parent-on-chain
# (first-fork) counting rule; 0 under the old model
def _canonical_producer_split(
tree, A, coalition_mask: np.ndarray | None, T: int, cutoff: int
) -> tuple[int, int]:
"""Split the finalized canonical chain's in-window blocks by producer coalition.
The canonical chain is the best *arrived* tip's ancestry (honest longest-chain, first-seen
tie-break); past k-finality every node agrees on it, so it is the reward-bearing chain.
Returns ``(adv_blocks, honest_blocks)`` counting blocks with slot in ``[0, T)``.
A withheld block never arrives (``A[:, b] > cutoff`` at every node) yet keeps a valid height, so
it must be **excluded** from tip selection — otherwise a never-propagated coalition block could
be chosen as the canonical tip and credited a phantom reward. Only the *full* matrix carries
withheld columns; the pruned path is never used with withholding, so all blocks arrived there.
"""
nb = tree.n_blocks
if nb <= 1:
return 0, 0
ids = np.arange(nb)
if isinstance(A, np.ndarray):
arrived = (A <= cutoff).any(axis=0) # (nb,) — withheld cols (A=E+1) -> False
else:
arrived = np.ones(nb, dtype=bool) # pruned path never withholds
arrived[0] = True # genesis is known to all
# best arrived tip by (height, -slot, -id); never-arrived blocks pushed below genesis
h = np.where(arrived, tree.height, np.iinfo(np.int64).min)
best = int(np.lexsort((-ids, -tree.slot, h))[-1])
adv = honest = 0
b = best
while b > 0:
s = int(tree.slot[b])
if 0 <= s < T:
if coalition_mask is not None and coalition_mask[int(tree.leader[b])]:
adv += 1
else:
honest += 1
b = int(tree.parent[b])
return adv, honest
def simulate_epoch(
config: SimConfig,
stake: np.ndarray,
d_est: np.ndarray,
path_latency: np.ndarray,
epoch_ss: np.random.SeedSequence,
adversary_mask: np.ndarray | None = None,
coalition_mask: np.ndarray | None = None,
inactive_mask: np.ndarray | None = None,
coalition_ids: np.ndarray | None = None,
) -> EpochResult:
"""``adversary_mask`` drives BEHAVIOUR this epoch (None == honest); ``coalition_mask`` is the
fixed coalition identity used only for reward attribution (so a rejoin epoch, mask None, still
credits the coalition's honestly-produced blocks). Defaults to ``adversary_mask`` when unset.
``coalition_ids`` splits the adversary into rival selfish groups (``engine._coalition_ids``).
"""
f, T, E = config.f, config.period_T, config.epoch_len
lottery_ss, aux_ss = epoch_ss.spawn(2)
aux_rng = np.random.default_rng(aux_ss)
# per-node lottery: d_est is a VECTOR -> per-node win prob, sparse sampler unchanged
p = lottery.win_probs(stake, d_est, f)
if inactive_mask is not None:
p = np.where(inactive_mask, 0.0, p) # churned-out nodes win no slots this epoch
winner_slots, winner_nodes = lottery.sample_wins(p, E, np.random.default_rng(lottery_ss))
active_slots, groups = lottery.group_by_slot(winner_slots, winner_nodes)
tree, A = build_tree_pernode(active_slots, groups, path_latency, config, aux_rng,
adversary_mask=adversary_mask,
coalition_ids=coalition_ids)
# measurement: each node's own canonical chain, deduped by tip + numba-accelerated
ms = measure(tree, A, active_slots, T, cutoff=E,
legacy_block_count=config.legacy_block_count,
countable=config.uncle_model != "old",
w=config.effective_uncle_window,
parent_anchor=config.uncle_window_anchor == "parent")
n_active_window = int((active_slots < T).sum())
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point,
config.f_precision)
attribution = coalition_mask if coalition_mask is not None else adversary_mask
adv_blocks, honest_blocks = _canonical_producer_split(tree, A, attribution, T, E)
(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest,
deep_orphan_share) = fork.fork_stats(
tree, A, T, cutoff=E, coalition_mask=attribution)
ref_total = int(ms.ref_total.sum())
deep_ref_share = (int(ms.ref_deep.sum()) / ref_total) if ref_total else 0.0
return EpochResult(
d_next=d_next, m=ms.m, q=ms.q, q_eff=ms.q_eff, n_blocks=tree.n_blocks - 1,
n_active_window=n_active_window,
agreement_window=ms.agreement_window, agreement_tip=ms.agreement_tip,
mean_orphan_rate=float(ms.orphan_rate.mean()),
adv_blocks=adv_blocks, honest_blocks=honest_blocks,
fork_rate=fork_rate, max_reorg_depth=max_reorg_depth, mean_reorg_depth=mean_reorg_depth,
p_ref=p_ref, p_ref_honest=p_ref_honest, deep_orphan_share=deep_orphan_share,
deep_ref_share=deep_ref_share,
)