mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 19:53:10 +00:00
481 lines
23 KiB
Python
481 lines
23 KiB
Python
|
|
"""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,)
|