481 lines
23 KiB
Python
Raw Normal View History

2026-07-30 18:57:10 +02:00
"""Block tree, latency-driven forks, and honest longest-chain fork choice.
Blocks are stored in parallel arrays (id == index). A virtual genesis is block 0 at
slot -1, height 0. Every real block is produced at an active slot by one winning node
and points at the best tip *visible to that node at production time*, which is what makes
network latency (and same-slot multi-winners) produce forks.
Fork choice is honest longest-chain with a first-seen tie-break (prefer higher height,
then earlier slot, then lower id) no adversary is modelled, so the spec's density /
deep-fork rules never engage.
"""
from __future__ import annotations
import heapq
from dataclasses import dataclass
import numpy as np
from .latency import LatencyModel
from .memguard import ArrivalMatrixTooLarge, check_alloc
__all__ = ["ArrivalMatrixTooLarge", "BlockTree", "build_tree", "build_tree_pernode",
"tips_for_all_nodes"]
GENESIS = 0
@dataclass
class BlockTree:
slot: np.ndarray # int64, slot of each block (genesis = -1)
parent: np.ndarray # int64, parent id (genesis = -1)
height: np.ndarray # int64, chain height (genesis = 0)
leader: np.ndarray # int64, producing node id (genesis = -1)
uncles: list[tuple[int, ...]] # referenced uncle ids per block (filled later)
@property
def n_blocks(self) -> int:
return self.slot.shape[0]
def ancestors(self, block_id: int) -> list[int]:
"""Ancestor chain of ``block_id`` from itself down to (excluding) genesis."""
out: list[int] = []
b = block_id
while b > GENESIS:
out.append(b)
b = int(self.parent[b])
return out
def canonical_chain(self) -> list[int]:
"""Honest longest-chain: ancestors of the best tip over the whole tree.
Returns real block ids (genesis excluded), tip-first.
"""
tip = self._best_over_all()
return self.ancestors(tip)
def _rank(self, bid: int) -> tuple[int, int, int]:
# Preference order for "better tip": higher height, earlier slot, lower id.
return (int(self.height[bid]), -int(self.slot[bid]), -bid)
def _best_over_all(self) -> int:
best = GENESIS
best_rank = self._rank(GENESIS)
for bid in range(1, self.n_blocks):
r = self._rank(bid)
if r > best_rank:
best_rank, best = r, bid
return best
def build_tree(
active_slots: np.ndarray,
winners_per_slot: list[np.ndarray],
latency: LatencyModel,
rng: np.random.Generator,
) -> BlockTree:
"""Construct the block tree from grouped lottery winners under a latency model."""
# Preallocate with genesis in slot 0.
slot = [-1]
parent = [-1]
height = [0]
leader = [-1]
# global_best = best publicly-visible tip so far, as (height, slot, id).
def better(a: tuple[int, int, int], b: tuple[int, int, int]) -> tuple[int, int, int]:
# higher height, then earlier slot, then lower id
ah, as_, ai = a
bh, bs, bi = b
if ah != bh:
return a if ah > bh else b
if as_ != bs:
return a if as_ < bs else b
return a if ai < bi else b
global_best = (0, -1, GENESIS)
own_best: dict[int, tuple[int, int, int]] = {}
# min-heap of (visible_at, block_id) awaiting public visibility
pending: list[tuple[int, int]] = []
next_id = 1
for si in range(active_slots.shape[0]):
t = int(active_slots[si])
# advance visibility frontier to slot t
while pending and pending[0][0] <= t:
_, bid = heapq.heappop(pending)
cand = (height[bid], slot[bid], bid)
global_best = better(global_best, cand)
for v in winners_per_slot[si].tolist():
gb = global_best
ob = own_best.get(v, (0, -1, GENESIS))
chosen = better(gb, ob)
p_id = chosen[2]
h = chosen[0] + 1
bid = next_id
next_id += 1
slot.append(t)
parent.append(p_id)
height.append(h)
leader.append(v)
own_best[v] = (h, t, bid)
va = latency.visible_at(t, rng)
heapq.heappush(pending, (va, bid))
return BlockTree(
slot=np.asarray(slot, np.int64),
parent=np.asarray(parent, np.int64),
height=np.asarray(height, np.int64),
leader=np.asarray(leader, np.int64),
uncles=[() for _ in range(next_id)],
)
# --- Per-node engine -------------------------------------------------------
def _rank_keys(height: np.ndarray, slot: np.ndarray, ids: np.ndarray,
epoch_len: int) -> np.ndarray:
"""Composite int64 sort key so argmax reproduces the (height, slot, id) tie-break."""
n = ids.shape[0]
c2 = np.int64(n + 1)
c1 = np.int64(epoch_len + 2) * c2
return height.astype(np.int64) * c1 - slot.astype(np.int64) * c2 - ids.astype(np.int64)
@dataclass
class SlidingArrival:
"""Pruned arrival store: per-node columns only for blocks still inside the keep-span.
Blocks with ``slot <= t - horizon`` are finalized under deterministic latency every node
has received them so their per-node columns are dropped. ``buf[:, b - base]`` holds the
arrival column for any live block ``b`` (``b >= base``); a block id ``< base`` is finalized and
treated as "arrived at every node". This is what turns the ``O(N * n_blocks)`` arrival matrix
into ``O(N * keep-span-blocks)``; ``tips_for_all_nodes`` reconstructs exact per-node tips from
it. Equivalent to the full matrix when ``jitter_mean == 0``.
"""
buf: np.ndarray # (N, buf_width) base-offset column buffer of recent arrivals
base: int # absolute block id stored at buf[:, 0]
horizon: float # slot <= t - horizon => arrived at every node
n: int # N (node count)
nb: int # number of blocks
def _max_span_blocks(active_slots: np.ndarray, counts: np.ndarray, span: float) -> int:
"""Max number of blocks whose slot lies in any ``span``-wide slot window (for buffer sizing)."""
if active_slots.size == 0:
return 1
# inclusive window slot >= t - span (matches the sliding buffer's kept set / uncle window)
cum = np.concatenate([[0], np.cumsum(counts)])
best, left = 0, 0
for r in range(active_slots.shape[0]):
while active_slots[left] < active_slots[r] - span:
left += 1
best = max(best, int(cum[r + 1] - cum[left]))
return best
def build_tree_pernode(
active_slots: np.ndarray,
winners_per_slot: list[np.ndarray],
path_latency: np.ndarray,
config,
rng: np.random.Generator,
adversary_mask: np.ndarray | None = None,
):
"""Build the global block tree AND the per-node arrival matrix.
``adversary_mask[v] == True`` marks a node that suppresses uncle references in its own blocks
(references none), to deflate the TSI density count (grinding). ``None`` = fully honest.
Each winner builds on the best tip *in its own arrival-filtered view*; uncle refs are
baked at production from the producer's view. Returns ``(BlockTree, A)`` where
``A[i, b]`` is the slot block ``b`` becomes usable at node ``i``.
Fork choice full scan vs windowed horizon
-------------------------------------------
A winner ``v`` at slot ``t`` builds on the highest-key block it has received
(``A[v, b] <= t``). Naively this scans all ``nb`` blocks so far ``O(n_blocks^2)`` per
epoch. With ``config.windowed_fork_choice`` (default) we scan only a horizon and add one
representative of everything older:
* ``H = max path latency`` over the graph (for ``blend``, ``H`` also adds the whole mix
cascade: ``(blend_hops+1)*max_path_latency + blend_hops*blend_delay_max``, a hard bound
since the per-relay mixing delays are ``Uniform``-bounded). Any block with
``slot <= t - H`` has, under *deterministic* latency, reached **every** node
(``slot + propagation <= t``), so the best of them the "fully-propagated tip" ``gb``
is a valid candidate for *all* nodes and is tracked incrementally. Only blocks with
``slot > t - H`` need a per-node arrival check. Result: ``O(n_blocks * H * f)``, and
**exact** when latency is deterministic (including blend's bounded mixing delays).
CAVEAT: exactness assumes actual arrival never exceeds ``slot + H``. That holds only when
``jitter_mean == 0``. With ``jitter_mean > 0`` the stochastic jitter can delay a block past
the horizon, so ``gb`` may be offered to a node that has not actually received it, or a
node's true best old tip may sit just outside the window — a (usually tiny) approximation.
We warn in that case; a guaranteed-exact result is available via
``windowed_fork_choice=False`` (full scan). A safety clamp below still guarantees no node
ever builds on a block it has not received, so the tree stays valid regardless.
"""
import warnings
from .topology import arrival_column
from .uncles import select_uncles_at_production
n = config.n_nodes
n_blocks = 1 + sum(int(g.shape[0]) for g in winners_per_slot)
E = config.epoch_len
slot = np.empty(n_blocks, np.int64)
parent = np.empty(n_blocks, np.int64)
height = np.empty(n_blocks, np.int64)
leader = np.empty(n_blocks, np.int64)
uncles: list[tuple[int, ...]] = [() for _ in range(n_blocks)]
slot[0], parent[0], height[0], leader[0] = -1, -1, 0, -1
c2 = np.int64(n_blocks + 1)
c1 = np.int64(E + 2) * c2
key = np.empty(n_blocks, np.int64)
key[0] = np.int64(0) * c1 - np.int64(-1) * c2 - np.int64(0)
NEG = np.iinfo(np.int64).min
windowed = bool(config.windowed_fork_choice)
if not windowed:
horizon = float(E) # full scan (gb unused)
elif config.topology == "blend":
# blend arrival = cascade of (hops+1) transport legs + hops Uniform(0, delay_max) mix
# delays; all bounded, so this is a HARD upper bound on (arrival - slot) -> still exact.
max_pl = float(path_latency.max())
dmax = float(config.blend_delay_max)
horizon = (config.blend_hops + 1) * max_pl + config.blend_hops * dmax
else:
horizon = float(path_latency.max()) # H; disconnected -> full scan
if windowed and config.jitter_mean > 0.0:
warnings.warn(
"windowed_fork_choice / prune_arrival are only approximate when jitter_mean > 0: "
"stochastic arrival jitter can push a block past the deterministic horizon, so a "
"node's true best older tip may be missed. Set windowed_fork_choice=False for a "
"guaranteed-exact full scan.",
RuntimeWarning, stacklevel=2,
)
# Sliding-window prune needs the deterministic horizon, so it only applies with windowed fork
# choice AND jitter_mean == 0. With jitter the full matrix's safety clamp is required. A
# withholding adversary produces blocks that NEVER arrive (arrival > E), violating the prune's
# "finalized => arrived-everywhere" assumption, so it too forces the full matrix.
withholding = (adversary_mask is not None and config.adversary_frac > 0.0
and config.adversary_strategy == "withhold")
if config.prune_arrival and windowed and config.jitter_mean == 0.0 and not withholding:
return _build_pruned(active_slots, winners_per_slot, path_latency, config, rng,
slot, parent, height, leader, uncles, key, c1, c2,
float(horizon), n_blocks, E, n, adversary_mask)
# --- full (N x n_blocks) matrix path: the exact parity oracle -----------------
# Guard BEFORE the big allocation: A is (N x n_blocks) float64. A collapsed D_est (small
# genesis_d_factor) inflates lottery wins, so n_blocks can explode far past the ~10*k
# equilibrium and make A tens of GB. Fail loud rather than freeze the machine.
check_alloc(
n * n_blocks * 8, f"arrival matrix A (N={n} x n_blocks={n_blocks} x 8B)",
f"n_blocks={n_blocks} is ~{n_blocks / max(10 * config.k, 1):.0f}x the ~{10 * config.k} "
f"equilibrium, driven by genesis_d_factor={config.genesis_d_factor} "
f"(sum(stake)/D_est_genesis={1.0 / config.genesis_d_factor:.0f}). Raise "
f"genesis_d_factor, lower n_nodes/k, prune_arrival, or raise --mem-frac.")
# arrival times are sub-slot (float): latency is in slots and a slot is 1 s, so realistic
# inter-node latencies are fractions of a slot (see topology.build_path_latency).
A = np.full((n, n_blocks), float(E), np.float64) # sentinel = epoch_len ("never" arrives)
A[:, 0] = 0.0 # genesis known to all from slot 0
withheld = np.zeros(n_blocks, dtype=bool) # adversary "withhold": block never arrives anywhere
gb_key = key[0] # running best fully-propagated tip (slot <= t - H)
gb_id = 0
fp_idx = 1 # frontier pointer over fully-propagated blocks
nb = 1
for si in range(active_slots.shape[0]):
t = int(active_slots[si])
winners = winners_per_slot[si]
# --- fork choice: window [lo, nb) + fully-propagated best gb ---
if windowed:
thr = t - horizon
while fp_idx < nb and int(slot[fp_idx]) <= thr: # advance propagated frontier
if not withheld[fp_idx] and key[fp_idx] > gb_key: # withheld blocks reach no node
gb_key, gb_id = int(key[fp_idx]), fp_idx
fp_idx += 1
lo = int(np.searchsorted(slot[:nb], thr, side="right")) # first slot > t - H
else:
lo = 0 # full scan (gb unused)
if lo < nb:
sub = A[winners, lo:nb] <= t # (w, nb-lo)
masked = np.where(sub, key[lo:nb], NEG)
win_key = masked.max(axis=1)
parents = masked.argmax(axis=1) + lo
else:
win_key = np.full(winners.shape[0], NEG, np.int64)
parents = np.zeros(winners.shape[0], np.int64)
if windowed:
gb_ok = A[winners, gb_id] <= t # gb actually received? (jitter)
use_gb = gb_ok & (gb_key > win_key)
parents = np.where(use_gb, gb_id, parents)
# safety: never build on a block a node has not received (jitter edge) -> genesis
bad = A[winners, parents] > t
if bad.any():
parents = np.where(bad, 0, parents)
for wi in range(winners.shape[0]):
v = int(winners[wi])
p_id = int(parents[wi])
h = int(height[p_id]) + 1
b = nb
slot[b], parent[b], height[b], leader[b] = t, p_id, h, v
key[b] = np.int64(h) * c1 - np.int64(t) * c2 - np.int64(b)
adv = adversary_mask is not None and adversary_mask[v]
hide = adv and config.adversary_strategy == "withhold"
if adv: # suppress refs (both adversary modes)
uncles[b] = ()
else:
uncles[b] = select_uncles_at_production(
slot, parent, uncles, A[v], b, p_id, t, config, rng
)
col = arrival_column(path_latency, v, t, config, rng) # (rng drawn either way)
if hide:
A[:, b] = float(E) + 1.0 # withheld: never arrives -> orphan
withheld[b] = True
else:
np.maximum(col, A[:, p_id], out=col)
A[:, b] = col
A[v, b] = max(float(t), float(A[v, p_id])) # producer sees own block at its slot
nb += 1
tree = BlockTree(slot=slot, parent=parent, height=height, leader=leader, uncles=uncles)
return tree, A
def _build_pruned(active_slots, winners_per_slot, path_latency, config, rng,
slot, parent, height, leader, uncles, key, c1, c2, horizon, n_blocks, E, n,
adversary_mask=None):
"""Windowed build with a sliding-window arrival buffer (see ``SlidingArrival``).
Identical tree/uncles to the full-matrix path when ``jitter_mean == 0`` (the guaranteed regime
for ``windowed_fork_choice``): a block ``slot <= t - horizon`` is received by everyone, so its
per-node column is never needed again fork choice only scans the horizon window, the parent
clamp on a finalized parent is a no-op (its arrival ``<= t <= col``), and uncle candidates
older than the horizon are trivially received. We therefore keep columns only for blocks inside
``max(horizon, uncle_window)`` slots, in a base-offset buffer, and finalize (drop) the rest.
"""
from .topology import arrival_column
from .uncles import select_uncles_at_production
NEG = np.iinfo(np.int64).min
keepspan = max(float(horizon), float(config.uncle_window)) # columns kept within this span
counts = np.array([int(g.shape[0]) for g in winners_per_slot], dtype=np.int64)
cap = _max_span_blocks(active_slots, counts, keepspan) # max live blocks at once
max_slot = int(counts.max()) if counts.size else 0
buf_width = 2 * (cap + max_slot) + 8 # headroom => rare compaction
check_alloc(
n * buf_width * 8, f"pruned arrival buffer (N={n} x {buf_width} cols x 8B)",
f"sliding-window prune keeps ~{cap} of {n_blocks} block-columns "
f"(keepspan={keepspan:g} slots); raise --mem-frac if genuinely too large.")
buf = np.full((n, buf_width), float(E), np.float64) # sentinel = E ("never arrives")
buf[:, 0] = 0.0 # genesis (id 0) known to all
base = 0 # absolute id at buf[:, 0]
gb_key, gb_id, fp_idx = int(key[0]), 0, 1
nb = 1
for si in range(active_slots.shape[0]):
t = int(active_slots[si])
winners = winners_per_slot[si]
thr = t - horizon
while fp_idx < nb and int(slot[fp_idx]) <= thr: # advance fully-propagated frontier
if int(key[fp_idx]) > gb_key:
gb_key, gb_id = int(key[fp_idx]), fp_idx
fp_idx += 1
lo = int(np.searchsorted(slot[:nb], thr, side="right")) # first block with slot > t - H
if lo < nb:
sub = buf[winners, lo - base:nb - base] <= t # window blocks are all live
masked = np.where(sub, key[lo:nb], NEG)
win_key = masked.max(axis=1)
parents = masked.argmax(axis=1) + lo
else:
win_key = np.full(winners.shape[0], NEG, np.int64)
parents = np.zeros(winners.shape[0], np.int64)
# gb is fully-propagated (slot <= t - H) => received by all under jitter=0 (gb_ok=True), and
# the finally-chosen parent is always received, so no bad-clamp is needed (parity: the full
# path's gb_ok/bad are likewise no-ops at jitter=0).
parents = np.where(gb_key > win_key, gb_id, parents)
for wi in range(winners.shape[0]):
v = int(winners[wi])
p_id = int(parents[wi])
h = int(height[p_id]) + 1
b = nb
slot[b], parent[b], height[b], leader[b] = t, p_id, h, v
key[b] = np.int64(h) * c1 - np.int64(t) * c2 - np.int64(b)
if b - base >= buf_width: # compact: drop finalized columns
# keep slot >= t - keepspan (side="left"): the uncle window's lower bound is also
# inclusive (slot >= t-W), so base must not advance past a block it may still read.
live_lo = int(np.searchsorted(slot[:nb], t - keepspan, side="left"))
if live_lo > base:
keep = nb - live_lo
if keep > 0:
buf[:, :keep] = buf[:, live_lo - base:nb - base].copy()
base = live_lo
if adversary_mask is not None and adversary_mask[v]:
uncles[b] = () # adversary suppresses uncle refs
else:
uncles[b] = select_uncles_at_production(
slot, parent, uncles, buf[v], b, p_id, t, config, rng, arr_base=base)
col = arrival_column(path_latency, v, t, config, rng)
if p_id >= base: # live parent -> clamp; else no-op
np.maximum(col, buf[:, p_id - base], out=col)
buf[:, b - base] = col
pv = float(buf[v, p_id - base]) if p_id >= base else float(t) # finalized parent <= t
buf[v, b - base] = max(float(t), pv)
nb += 1
tree = BlockTree(slot=slot, parent=parent, height=height, leader=leader, uncles=uncles)
return tree, SlidingArrival(buf=buf, base=base, horizon=float(horizon), n=n, nb=nb)
def _tips_pruned(tree: BlockTree, arr: SlidingArrival, cutoff: int) -> np.ndarray:
"""Per-node tips from the sliding buffer: best fully-propagated block (global) vs each node's
best recent (still-in-window) arrival. Exact equivalent of the full-matrix argmax at jitter=0.
"""
nb = tree.n_blocks
key = _rank_keys(tree.height, tree.slot, np.arange(nb), cutoff + 2)
NEG = np.iinfo(np.int64).min
recent = tree.slot > (cutoff - arr.horizon) # slot > E - H: per-node arrival varies
recent[0] = False # genesis is finalized (arrived at all)
# best over finalized/"arrived-everywhere" blocks (slot <= E - H): a candidate for every node
fin_ids = np.nonzero(~recent)[0]
gb_final = int(fin_ids[np.argmax(key[fin_ids])])
recent_ids = np.nonzero(recent)[0]
if recent_ids.size == 0:
return np.full(arr.n, gb_final, np.int64)
arrived = arr.buf[:, recent_ids - arr.base] <= cutoff # (N, R) recent blocks in buffer
masked = np.where(arrived, key[recent_ids][None, :], NEG)
best_recent = recent_ids[masked.argmax(axis=1)]
use_recent = masked.max(axis=1) > int(key[gb_final])
return np.where(use_recent, best_recent, gb_final)
def tips_for_all_nodes(tree: BlockTree, arrival, cutoff: int,
row_chunk: int = 64) -> np.ndarray:
"""Per-node best tip = argmax (height, slot, id) over blocks arrived by ``cutoff``.
``arrival`` is either the full ``(N, n_blocks)`` matrix or a pruned ``SlidingArrival``; both
yield the same tips at ``jitter_mean == 0``. For the full matrix, each node's argmax is
independent, so we process it in ``row_chunk`` node-row bands capping the transient
``np.where`` mask at ``(row_chunk, nb)`` instead of a second full ``(N, nb)`` int64 array
(bitwise-identical to the unchunked argmax).
"""
if isinstance(arrival, SlidingArrival):
return _tips_pruned(tree, arrival, cutoff)
nb = tree.n_blocks
n = arrival.shape[0]
ids = np.arange(nb)
key = _rank_keys(tree.height, tree.slot, ids, cutoff + 2)
NEG = np.iinfo(np.int64).min
tips = np.empty(n, np.int64)
for lo in range(0, n, row_chunk):
hi = min(lo + row_chunk, n)
masked = np.where(arrival[lo:hi] <= cutoff, key[None, :], NEG) # (row_chunk, nb)
tips[lo:hi] = masked.argmax(axis=1)
return tips # (N,)