2026-07-30 18:57:10 +02:00
|
|
|
"""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
|
Private-chain (SM1) adversary in the per-node engine
Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is
why every selfish result came from the global race model with uncle recovery as
a free knob eta -- and why open item 5 (does the uncle cap need margin under
attack-inflated orphaning?) could not be sized: a knob has no queue to overflow.
adversary_strategy="selfish" adds it. The coalition mines one shared private
chain and releases under the classic SM1 rules in (a, h) form: adopt when the
public chain wins, match at equal length, override at a one-block lead, else
wait. Only VISIBILITY is modelled -- the coalition's mining needs no special
case, because a member's fork choice already builds on the private tip whenever
it leads (that tip has the greatest height among blocks the member can see) and
falls back to the public chain exactly when the public chain overtakes, which
is the adopt branch. So the private chain forms, extends and is abandoned
emergently, and the code that had to be written is the arrival matrix.
Design notes worth keeping:
- Private blocks reuse the sentinel `withhold` already had (never-arrives), so
the existing exclusions from canonical-tip selection apply unchanged; release
flips it back and gossips DIRECTLY from the producer, bypassing Blend, since
an adversary has no privacy budget to respect and wants the race won.
- A private chain breaks the windowed horizon's premise (a hidden block is old
enough to look fully-propagated while no honest node has it, and it becomes
visible LATER, which the one-way frontier pointer cannot revisit), so selfish
forces the exact full scan and full matrix.
- Blocks still hidden at epoch end are abandoned and hidden from the coalition
too, or the canonical-tip search would crown a chain no honest node saw.
Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an
exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just
the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484
at alpha = 0.4, with fork rate 0.38).
Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the
coalition. Under a private-chain attack this diverges sharply from p_ref, and
only the honest one measures the repair the report credits to uncle counting --
an attacker's own discarded blocks are its loss to bear.
test_fork unpacks fork_stats positionally, so its three call sites take the new
fifth value. 247 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
p_ref_honest: float # ...restricted to orphans produced OUTSIDE the coalition
|
2026-08-07 11:15:55 +02:00
|
|
|
deep_orphan_share: float # in-window orphans deeper than their fork's first block
|
|
|
|
|
# (uncountable by construction, §2.1)
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
deep_ref_share: float # share of examined references rejected by the parent-on-chain
|
|
|
|
|
# (first-fork) counting rule; 0 under the old model
|
2026-07-30 18:57:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
) -> 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.
|
|
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
# measurement: each node's own canonical chain, deduped by tip + numba-accelerated
|
|
|
|
|
ms = measure(tree, A, active_slots, T, cutoff=E,
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
legacy_block_count=config.legacy_block_count,
|
|
|
|
|
countable=config.uncle_model != "old",
|
|
|
|
|
w=config.effective_uncle_window)
|
2026-07-30 18:57:10 +02:00
|
|
|
n_active_window = int((active_slots < T).sum())
|
|
|
|
|
|
Answer the fork-loss handoff: the section's residual is ~17x overstated, and it misses the real bias
Settles E1-E4 and E6 of handoff-fork-loss-validation.md against spec d6fd7648.
E1 costs nothing and reframes everything: analysis-block-times-blend-network.md
sets blending_delay as a FIXED per-hop dwell (the 3d+5 max-delay arithmetic
gives 14 s at d=3 and 11 s at d=2, matching its prose), not a mean or a bound.
The simulator's Uniform(0, delta_max) matches a 2 s dwell in the mean at
delta_max = 4, so D_vis = 8 s and rho = 0.27 -- inside the committed 40-replicate
paired design band, which answers C1-C3 from data of record.
C1 refuted: every U>=1 cell sits at 0.9985-0.9997, not 0.986. C2's mechanism is
right but its size is ~17x over: the paired first-fork cost is 0.08 pp pooled
(95% CI [0.03, 0.13], t = 3.08), resolved only because the arms share streams --
the U=0 negative control is exactly 0.00000 +- 0.00000. C3 is refuted in the
UNFAVOURABLE direction: the no-uncle loss is 33% at N=1000 and 34.6% at N=5000
(42% / 49.5% at delta_max = 8), so the section understates what uncles buy by
about half. C4 stands with ~7x margin (U=3 still recovers at rho = 1.87). C5 is
right in effect, wrong in wording -- the knee is at W_abs ~ 5, so the spec's 10
is ~2x above it, which is "has margin", not "never binds".
C6 is the section's real omission. The deployed estimator quantises the target
rate at PRECISION = 1e3, and measured in the full dynamics that reads
1.01026 +- 0.00056 against a closed form of 1.0101 -- a 1.0% bias ~13x the
first-fork cost the section is concerned with, opposite in sign, removed by a
one-constant change. It could not be measured before because PRECISION was a
module constant pinned at the RECOMMENDED 1e6; f_precision is now a config
field, appended to the RNG key only when non-default so no committed run moves.
Also from the guide: uncle_window_slots now floors rather than rounds, matching
w_u := floor(W/f) (identical at the defaults; matters only for the W and f
sweeps).
Reviewed sec 4.3's argument as sec 6 asks, and it holds -- inclusion stayed soft
("may reference fewer uncles than it could ... and its block remains valid"), so
row 10, the anti-mandate argument and the suppress adversary are all unaffected;
only the CONTENT of a reference became validity-gated. One correction: the
"no incentive to deviate" clause does still exist, so sec 8.5's implication (ii)
is live, not moot.
E5 -- the jitter diagnostic, and the only experiment that could invalidate the
report rather than the section -- is not run and is flagged as such.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:45:00 +02:00
|
|
|
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point,
|
|
|
|
|
config.f_precision)
|
2026-07-30 18:57:10 +02:00
|
|
|
|
|
|
|
|
attribution = coalition_mask if coalition_mask is not None else adversary_mask
|
|
|
|
|
adv_blocks, honest_blocks = _canonical_producer_split(tree, A, attribution, T, E)
|
2026-08-07 11:15:55 +02:00
|
|
|
(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest,
|
|
|
|
|
deep_orphan_share) = fork.fork_stats(
|
Private-chain (SM1) adversary in the per-node engine
Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is
why every selfish result came from the global race model with uncle recovery as
a free knob eta -- and why open item 5 (does the uncle cap need margin under
attack-inflated orphaning?) could not be sized: a knob has no queue to overflow.
adversary_strategy="selfish" adds it. The coalition mines one shared private
chain and releases under the classic SM1 rules in (a, h) form: adopt when the
public chain wins, match at equal length, override at a one-block lead, else
wait. Only VISIBILITY is modelled -- the coalition's mining needs no special
case, because a member's fork choice already builds on the private tip whenever
it leads (that tip has the greatest height among blocks the member can see) and
falls back to the public chain exactly when the public chain overtakes, which
is the adopt branch. So the private chain forms, extends and is abandoned
emergently, and the code that had to be written is the arrival matrix.
Design notes worth keeping:
- Private blocks reuse the sentinel `withhold` already had (never-arrives), so
the existing exclusions from canonical-tip selection apply unchanged; release
flips it back and gossips DIRECTLY from the producer, bypassing Blend, since
an adversary has no privacy budget to respect and wants the race won.
- A private chain breaks the windowed horizon's premise (a hidden block is old
enough to look fully-propagated while no honest node has it, and it becomes
visible LATER, which the one-way frontier pointer cannot revisit), so selfish
forces the exact full scan and full matrix.
- Blocks still hidden at epoch end are abandoned and hidden from the coalition
too, or the canonical-tip search would crown a chain no honest node saw.
Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an
exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just
the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484
at alpha = 0.4, with fork rate 0.38).
Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the
coalition. Under a private-chain attack this diverges sharply from p_ref, and
only the honest one measures the repair the report credits to uncle counting --
an attacker's own discarded blocks are its loss to bear.
test_fork unpacks fork_stats positionally, so its three call sites take the new
fifth value. 247 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
tree, A, T, cutoff=E, coalition_mask=attribution)
|
Countable uncle model: spec counting rules, sweeps, figures
Implement the countable uncle model from the Cryptarchia spec's
counting-only reference rules, and make it the simulator default.
Counting rules (uncles.py, measure.py):
- Only the first block of a fork (parent on the producer's chain) is
referenceable and countable, which makes every reference verifiable
from chain data alone.
- The reference window is derived from a window-absorption parameter,
w_u = W_abs/f slots (W_abs in expected block-intervals, default 10,
bounded W_abs <= 0.6*k), replacing the free-standing uncle_window.
- Selection skips slots already occupied on the producer's chain and
takes at most one uncle per slot.
- The measurement pass re-checks every rule per reference and tallies
rejections as deep_ref_share.
The pre-redesign model is preserved behind --old on tsi-sweep and
tsi-verify. Its RNG key is byte-identical to the pre-uncle_model key,
so --old bit-reproduces the historical runs.
Supporting changes: uncle_model and window_absorption config surface
with validation (config.py, constants.py); accuracy closed form over
the effective q_u (theory.py); plumbing through tsi.py, epoch.py,
sweep.py, blocktree.py, metrics.py, verify.py, figures_pernode.py.
Studies and figures:
- configs/countable-vs-old.yaml -- delay x U grid, run under both
models on the same grid.
- configs/absorption-window.yaml -- accuracy vs W_abs at U=1.
- scripts/plot_countable_vs_old.py renders fig30-fig33 into
reports/tsi/report-figures/.
Tests: tests/test_countable_counting.py (7 cases) covering first-fork
eligibility, derived-window bounds, occupied-slot exclusion, and
per-reference re-checking; extensions to test_uncles.py,
test_config.py, test_slot_counting.py. Full fast suite: 202 passed.
Also adds CLAUDE.md (graphify project instructions) and ignores
editor/local-agent state plus the vendored Equi-X benchmark clone.
The reports/tsi/ prose describing this model is held back for a
separate editorial pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:48:46 +02:00
|
|
|
ref_total = int(ms.ref_total.sum())
|
|
|
|
|
deep_ref_share = (int(ms.ref_deep.sum()) / ref_total) if ref_total else 0.0
|
2026-07-30 18:57:10 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-08-07 11:15:55 +02:00
|
|
|
p_ref=p_ref, p_ref_honest=p_ref_honest, deep_orphan_share=deep_orphan_share,
|
|
|
|
|
deep_ref_share=deep_ref_share,
|
2026-07-30 18:57:10 +02:00
|
|
|
)
|