mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-10 21:23:12 +00:00
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>
139 lines
6.8 KiB
Python
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,
|
|
)
|