131 lines
6.3 KiB
Python
Raw Normal View History

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
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)
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
fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest = fork.fork_stats(
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,
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=p_ref, p_ref_honest=p_ref_honest, deep_ref_share=deep_ref_share,
2026-07-30 18:57:10 +02:00
)