diff --git a/docs/analysis/horizon/finality-and-weight.md b/docs/analysis/horizon/finality-and-weight.md new file mode 100644 index 00000000..ec6a1c76 --- /dev/null +++ b/docs/analysis/horizon/finality-and-weight.md @@ -0,0 +1,389 @@ +# Horizon lane 3: finality and weight + +Date: 6 October 2026, evening UK (written 19:30Z to 21:00Z, while the live devnet's finality was paused). Lane: finality-and-weight (the measured behaviour of the weight rule and the designs that extend it; lane 1 holds the attack catalogue and the 51 percent paper, cross-referenced by name). Worktree: `/Users/joshm/Projects/igneum-wt-horizon` (branch `horizon`). Models: `sim/horizon/finality-and-weight/` (README there says how to run every number). + +What was read: `docs/spec/03-finality.md` (whole, 3.11 included), `04-seeds-and-vdf.md`, `10-light-client.md`, `06-open-items.md` (O-3.1 to O-3.19), the fud-close worktree's `docs/spec/03-finality.md` 3.4.2 (the proposed vote and bitmap bounds, decided 6 Oct 2026 per `docs/plans/ledger-decisions.md` line 57); `docs/fud-ledger.md` F1 to F25 (F9, F14, F16, F18, F19, F20, F21 via its status lines, F22, F23, F24), P3, P4, P22, X20; `sim/README.md`, `sim/results_v2.md` (A to M), `sim/finality_v2.py` (this worktree's and fud-close `1544c63` with the block reading and scenario O); `docs/benchmarks/finality-v3-2026-10-04/` (fold-v2, fold-v3, split50-v2, split50-v3, split70-v3), `docs/benchmarks/round4-consensus-2026-10-04/results-final2.md`; `tools/finality-attacks/README.md`; `docs/plans/finality-v3-rollout-devnet.md`, `finality-v3-devnet-publish.md`; `docs/bench-log.md` entries of 4 to 6 October mentioning finality (floor 2/3, first live lock, rule v3, the C4 fix, round-4 items, the finality route, the rental cost of hash at line 2582); the gpu-fleet worktree's `docs/bench-log.md`, `docs/plans/`, `tools/fleet/` (grep for finality, lock, pause, voters, weight: the fleet has written no lock-delay or voter-count row yet; `docs/analysis/block-rate-devnet2.md` is still the template with RUN_A and RUN_B empty at 19:45Z), `docs/analysis/prover-tiers-real-cards.md`; the observer database (read-only SELECTs over `live_checkpoints`, `live_certificates`, `live_blocks`, `live_events`, `live_state`), node 1's log `/tmp/igneum-devnet/node1.out` on the Mac; `vendor/igneum-node` `Cargo.toml` and `consensus/core/src/finality.rs` for the BLS crate; the SP1 6.8.1 crates in the cargo registry (no SP1 clone exists under `vendor/`). + +## 1. The three findings first + +1. **Tonight's pause was the rule, not the aggregation path, and it was the frozen table that held it past 19:14Z.** The 20 keys that left the live chain between 17:20Z and 18:30Z held 3,026 of 7,083 blue blocks of the table frozen at the last lock (42.7 percent; the 13 that left with the 18:27 to 18:30Z rehearsal job alone 36.5 percent). The first unlocked checkpoint, 6843 at DAA 209,233 (about 18:40Z), had 75 of 93 voters' votes and 53.1 percent of total weight on the observer's node, under the two-thirds floor; certificates had kept forming for 18 checkpoints while node 1 and the observer were down (6824 to 6842, 18:30 to 18:39Z, 83 signers, 78.5 to 79.7 percent of total). From 19:14:53Z (checkpoint 6912) the stayers held 74.9 percent of the sliding table and still did not lock, because they hold 57.3 percent of the frozen table of lock 6842, which stands until DAA 216,402 (about 20:40Z). Under rule v2 the first lock would have come at 6912, 35 minutes after the last; under v3 the pause is one window, 2 hours on the devnet and 30 days on mainnet (spec 3.7 item 2, the price the project lead took on 4 October). +2. **Of the four candidate rules, only the departure announcement keeps the one-third bound.** In the simulator (3 seeds, mainnet scale) the decaying denominator and the hysteresis floor both restore liveness after tonight's departure in under an hour and both reopen the partition double lock (fast decay: both sides of every 360-minute partition lock alone from minute 120, 467 to 473 conflicting locks in the 50/50 honest split and 17 to 264 in the poisoned eclipse; slow decay: both sides of the 12-day splits lock alone at day 0.5 to 0.9, 31,545 to 32,246 conflicts; hysteresis: a 20 percent equivocator conflicts from minute 60, 565 to 597 locks, the 13.3 percent bound of 3 October back). The leave rule locks 1 hour after the departure (0.04 days; under 4 devnet minutes) with 0 conflicting locks in every partition, eclipse and equivocator row, and an attacker who buys keys to make them leave gains nothing it would not get by signing with them (w + L must still reach 2/3). The two-tier report never conflicts in its final tier by construction and shows 516 to 1,062 conflicting PROVISIONAL locks in every 360-minute partition and about 34,000 in the 12-day splits, so it is a reporting layer with a health warning, not a rule. +3. **Weight costs USD 8,424 x N x W / (1 - W) to rent for the full window** at the measured USD 11.7 per GH/s-hour: a veto (34 percent) against a 1 GH/s network is USD 4,300 over 30 days (0.52 x N of hash, a +52 percent step on the chart from day 1), against 1 TH/s USD 4.3 M; locking alone (67 percent) is 2.03 x N for 30 days (USD 17,100 per GH/s of network, USD 17 M at 1 TH/s), and faster is dearer (22 days: 10.6 x N). Buying old keys costs the seller's own rental equivalent, decays to nothing in 30 days (sim K), and nothing in the protocol makes weight unbuyable; what keeps the price at the rental cost is that the seller keeps a copy and one equivocation strips the key. + +## 2. Method + +Measured: the observer's Neon database (tables written by `tools/observer/observer.mjs`: `live_checkpoints` per index with state, signed and total weight, votes seen and voter count; `live_certificates` with the voter table and bitmap per certificate; `live_blocks` with `vote_key_hash` per block; `live_events`), read with SELECTs only through a scratchpad script (`fetch` to the Neon SQL endpoint, refusing any statement that is not SELECT; the queries are quoted inline). Node 1's log on the Mac for determination-to-lock delays (the `determined` and `LOCKED` lines per index; the log ends at 18:46:32Z when node 1 stopped). The departed keys were matched from certificate voter tables (48-byte public keys) to block producers (`vote_key_hash`) with BLAKE2b-256 keyed by `IgneumVoteKeyHash` (the fork's domain, `consensus/core/src/finality.rs`), 93 of 93 keys matched. + +Simulated: `sim/horizon/finality-and-weight/finality_horizon.py`, a copy of `sim/finality_v2.py` (fud-close `1544c63`) with four candidate rules and three scenarios (T, P, Q), run on igneum-build-1 (`/srv/builds/horizon-finality-and-weight/sim/`, Python 3.12, numpy 1.26.4, `nice -n 19`, one process per candidate, seeds 7, 11 and 13, about 25 minutes wall while the box carried other agents' builds at load 20 to 60); the smoke run on the Mac under `tools/lock/with-lock.sh run`. The model is the one `sim/results_v2.md` describes (1,000 Pareto keys, three regions, 2-s inter-region delay, 97 and 99.5 percent uptime, no DAG) at mainnet scale (30-day window); the devnet's window is 7,200 DAA s, so a mainnet day is four devnet minutes. Arithmetic scripts: `weight_capture.py`, `lightclient_cost.py`. + +Not run: the fast-time node harness (`tools/finality-attacks`) for the leave message (no such message exists in the node); any BLS timing on this machine (the figures are approximate from the crate's published benchmarks, anchored to the one measured pure-JavaScript verifier); the fleet's block-rate run (its file was still a template at 19:45Z). + +## 3. Evidence + +### 3.1 Tonight's pause, from the observer rows and node 1's log + +Times UTC. DAA scores advance about 1 per second on the live devnet. The observer's node is the view throughout; "votes" are the votes that node had seen for the index. + +| When | What | Source | +|---|---|---| +| 17:20 to 17:55Z | Four keys mine their last blocks on the live chain (92376b1a 105 blocks of the later frozen table, d1ee753c 22, 25dbfb0b 155, c4eb3431 131: 413 blocks, 5.8 percent) | `live_blocks`, max(received_at) per key before 18:43Z | +| 18:27 to 18:30Z | Thirteen fleet keys mine their last blocks (d5996917 248, 39d2dafc 246, 52d9d8c8 236, 3ca93140 227, 8debbb2d 219, 92dabf9d 216, f155fac3 215, 6cd0935e 212, 0cf69e5c 208, 11047080 200, b42641ab 144, cf860e4d 121, 2aaa021b 91: 2,583 blocks, 36.5 percent of the frozen table): the class v4 rehearsal job stopping their miners | `live_blocks`; CLAUDE.md 6 Oct rules | +| 18:30:02Z | Node 1's last own lock, 6823 (DAA 208,631), 78 signers, 68.7 percent of total; node 1 and the observer go down with the desktop app until 18:42:08Z (`Observer reconnected to the node`) | node1.out; `live_events` | +| 18:30 to about 18:39Z | Locks 6824 to 6842 form without the hub (DAA 208,660 to 209,202): 83 signers, 78.5 to 79.7 percent of total; aggregators 3ca93140, 8debbb2d, 69570532 and the zero fallback | `live_checkpoints` (ingested at 18:42:09Z), `live_certificates` bitmaps | +| about 18:39:40Z | The last lock, 6842 at DAA 209,202, 79 of 91 signers. Its frozen table (the voter table at 6850 the observer stored with it): 93 keys, 7,083 blocks; the 20 departed keys hold 3,026 (42.7 percent), the stayers 4,057 (57.3 percent) | `live_certificates` 6842 voters, matched to `live_blocks` | +| about 18:40Z | 6843 at DAA 209,233 determined and never locked: 75 votes, 3,757 of 7,080 = 53.1 percent of total. The departed boxes' nodes have left the live chain (their blocks had already stopped), the signing weight is under two thirds: the rule pauses | `live_checkpoints` | +| 18:42:08Z | The observer reconnects and the pause becomes visible on the hub; node 1 ingests 6828's certificate by gossip (`70.3% of the table frozen at lock 6827`) | `live_events`, node1.out | +| about 18:50Z | Twenty indices without a lock (6862, DAA 209,800): `finality_active` false, reason `paused` (spec 3.9) | `live_checkpoints` | +| 18:58 to 19:03Z | Votes seen fall to 49 (48.5 percent): five more keys quiet for five minutes (the hands' own restarts, approximate) | `live_checkpoints` 6876 to 6885 | +| 19:14:53Z | 6912 at DAA 211,301: 79 votes, 5,355 of 7,152 = 74.9 percent of the SLIDING table, over two thirds; no lock. The stayers hold 57.3 percent of the table frozen at 6842 (Q5), under two thirds: "held by the frozen table" | `live_checkpoints`; spec Q5 | +| 19:29Z (write-up) | Still paused: 6938 proposed at 79.2 percent with 78 votes. Expected first lock when the frozen table expires at DAA 216,402 (209,202 + 7,200), about 20:40Z, or when departed keys holding 9.4 points of the frozen table return | `live_checkpoints`; arithmetic | + +Under rule v2 (sliding table only) the stayers' share rises as the departed blocks age out: from 53.1 percent at 6843 to two thirds after 7,200 x (1 - 1/(3 x 0.469)) = 2,082 DAA (spec 3.3.1's churn formula at the devnet window), which is checkpoint 6912 at 19:14:53Z: a 35-minute pause. Under v3 it is one window: 2 hours here, 30 days on mainnet (spec 3.7 item 2; `sim/results_v2.md` M4). The coordinator's working hypothesis of 19:3xZ (topology: the hub was down and the fleet's votes could not reach the VRF-picked aggregators) is refuted by three rows above: certificates formed while the hub was down (6824 to 6842), the zero-aggregator fallback of Q4 is in routine use (64 of the 251 certificates stored between 16:30 and 19:00Z name aggregator `00000000`, the next most frequent key 34), and the pause began at the checkpoint where the signing weight fell to 53.1 percent, which is the rule's threshold and not a routing failure. The observer's node itself held 74.9 percent of the sliding weight in votes from 19:14Z and did not certify, which only Q5 explains. + +Per tier: a home miner, rig or pool user on the live devnet saw `finality_active` false for 2 hours and lost nothing (blocks, execution and payouts continued; the exchange guidance of 3.9 applies); a prover's records were still paid; the fleet operator learned that a standing box never leaves the live chain for an experiment (CLAUDE.md, the standing-fleet rule). On mainnet the same event, 43 percent of weight leaving in three minutes, is a 30-day pause under v3 and a 7.7-day one under v2. + +### 3.2 Lock delay against voter count (measured) + +Determination-to-lock on node 1 (the `determined` and `LOCKED` lines per index, 6 October 2026, per UTC hour; voter counts from the observer's `live_checkpoints` for the same hour). + +| Voters above dust | UTC hours | Locks | Delay p50 | p90 | p99 | Max | Source | +|---|---|---|---|---|---|---|---| +| 4 to 9 | 01 to 06 | 119 to 120 per hour | 0.86 to 0.97 s | 1.17 to 1.31 s | 1.53 to 1.80 s | 1.59 to 2.29 s | node1.out | +| 17 to 24 | 07 to 11 | 118 to 120 | 0.82 to 0.97 s | 1.09 to 1.29 s | 1.53 to 1.83 s | 2.18 s (the 111-s p90 of 08Z is a restart) | node1.out | +| 24 to 30 | 12 to 14 | 104 to 122 | 0.94 to 1.32 s | 1.36 s (quiet hours) | 48 s (restarts) | | node1.out | +| 43 | 16 | 54 (hour cut by a restart) | 0.92 s | 1.17 s | | | node1.out | +| 63 | 17 | 124 | 1.13 s | 1.44 s | 1.47 s | 8.8 s | node1.out | +| 92 to 93 | 18 | 125 (to 18:30Z) | 1.26 s | 1.50 s | 1.82 s | 1.82 s | node1.out | +| 6 (fast time, 300-ms proxied links) | 4 Oct | 11 to 12 per node | 1.008 s | | | | `docs/benchmarks/finality-v3-2026-10-04/fold-v3.md` | +| 12 (cloud devnet, 5 locations) | 4 Oct | 212 indices | 1.24 s to the first certificate (p99 1.71 s), the last vote 1.45 s (p90 2.36 s) | | | | ledger F22, `infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md` | +| 1,000 (simulator, 2-s inter-region delay) | | 172,883 | 2.5 s | | 4.6 s | 6.1 s | `sim/results_v2.md` A | + +The devnet's delay is the miner's 1-s template poll plus one gossip round (the 250-ms gossip pump per hop, ledger F22): 0.9 s at a handful of voters, 1.26 s at 93. A straight line through the devnet rows is 0.9 s + 4 ms per voter (approximate; 93 points on one topology), which puts 1,000 voters near 5 s and 8,192 near 34 s, past the 30-s interval. Section 4.3 says why that line does not hold and what does. + +### 3.3 Sizes and crates (from the fork) + +| Item | Value | Source | +|---|---|---| +| BLS crate | `blst = "0.3.17"` (workspace), min-pubkey variant: 48-byte G1 keys, 96-byte G2 signatures | `vendor/igneum-node/Cargo.toml` line 238, `consensus/core/Cargo.toml` line 20, `consensus/core/src/finality.rs` line 17 (`blst::min_pk`) | +| Aggregation and verification | `AggregateSignature::aggregate` over the votes' signatures (line 230); `fast_aggregate_verify` over the summed public keys and one vote message (line 239 to 253) | `consensus/core/src/finality.rs` | +| Vote item | 281 B (tag 1, index 8, checkpoint 32, key 48, signature 96, sortition proof 96) | spec 3.4.2 item 1 (fud-close), the fork's `Vote::LEN` | +| Certificate | 273 B plus ceil(V/8) B of bitmap: 285 B at 93 voters, 398 at 1,000, 1,297 at 8,192, 8,465 at 65,536 | same | +| Bitmap wire bound | 1 MiB today (`Certificate::read`, `bitmap_len > 1 << 20`, line 429); 8,192 B proposed and decided (65,536 voters, 8x the S2 switch) | finality.rs; spec 3.4.2 item 3; ledger-decisions line 57 | +| Per-block vote bound | 48 on the devnet; 384 on mainnet proposed and decided (107,904 B, 21.6 percent of the compute mass; a checkpoint's 8,192 votes drain in 21.3 blocks) | spec 3.10 Q1/Q2 row; 3.4.2 item 2 | +| Votes per checkpoint, single-vote carriage | 93 voters: 26,133 B; 1,000: 281,000 B; 8,192: 2,301,952 B (4.6x one block's mass); 65,536: 18.4 MB | 281 x V | +| Votes per checkpoint, aggregated carriage (Q2 allows one BLS signature plus a bitmap per (index, hash)) | about 0.4 KB at 93 voters, 1.2 KB at 8,192, 8.6 KB at 65,536 | spec 3.4.2 item 2 | + +### 3.4 What the simulator already measured (cited, `sim/results_v2.md`) + +| Fact | Value | Section | +|---|---|---| +| Churn under v2: first lock after a set holding x stops mining and signing | 35 percent: 1.7 days (analytic 1.4); 50 percent: 10.1 to 10.3 days (analytic 10.0) | L2, D | +| Churn under v3 | 30.00 days at 35 and 50 percent (the frozen table's expiry) | M4 | +| Silent set that keeps mining | 34 percent and above: no lock for as long as it is silent; first lock 0 min after it returns | J, L1 | +| Equivocator across a 50/50 split | 33 percent: 0 conflicts; 34 percent: 2 to 54 conflicts from minute 2 to 77 (the one-third bound) | H, M5 | +| Long honest partition, view-local weight, v2 against v3 | 50/50: both sides lock alone from day 10.1 to 10.3 under v2, never in 12 days under v3, both at day 30.00 of a 31-day split | L4, M2, M3 | +| Acquired keys worth 40 percent, attacker at 30 percent of hash | veto from day 1 to day 19 or 20, 30 percent on day 30; silent, 63,307 to 68,716 of 86,400 checkpoints stalled | K | + +## 4. Model + +### 4.1 The pause arithmetic (spec 3.3.1 and 3.7, restated with tonight's inputs) + +Let x be the share of the window weight that stops mining and signing at once, W the window (2,592,000 DAA s on mainnet, 7,200 on the devnet). + +| Rule | First lock after the departure | Tonight (x = 0.469 on the observer's node at 6843, W = 7,200) | Mainnet, same x | +|---|---|---|---| +| v2, sliding table | W x (1 - 1/(3x)) (never for x at or under 1/3) | 2,082 DAA, 35 min: measured as the moment the sliding share crossed two thirds (6912) | 7.7 days | +| v3, frozen table (live) | W after the last lock, whatever x over 1/3 | 7,200 DAA, 2 h (expected 20:40Z) | 30 days | +| (iv) leave, delay D | D after the signed leave (0 if sent D before the stop) | 1 h, or 0 with notice | 1 h | +| (i) decay, grace T, rate r per hour | at most T + (1/r) x (1 - (1 - x)/(2x)) hours: the departed weight decays until the stayers hold two thirds of what is left | T 1 h, r 0.5: 1 h 17 min (x 0.469); T 6 h, r 1/24: 20 h | the same hours | +| (iii) hysteresis, H hours, low floor f | H hours, then only if the stayers hold f x 2/3 of total | f = 0.85: 56.7 percent needed, the stayers held 53.1 then 57.3 percent: after H plus the ageing to 56.7 percent, 1 to 1.5 h | about 1 day | +| (ii) two-tier | provisional at once (2/3 of the active denominator); final as v3 | provisional 0 min, final 2 h | provisional minutes, final 30 days | + +### 4.2 Why the view-dependent candidates fail (and the sim's confirmation) + +Spec 3.11.2's bound comes from counting: two certificates at one index need 2/3 of the denominator each, 4/3 in all, so a third signed both and that third is equivocating. The denominator has to be the same number on both sides of a partition for that sum to mean anything. A rule that removes weight on what a view has not seen (a vote missing for T hours, blocks missing) gives each side a different denominator: side A removes side B's keys, side B removes A's, and both sides' own share rises toward 1 at the same rate. For a 50/50 split under decay(T, r) each side's own share reaches 2/3 when the other side's factor is 0.5, at T + 1/(2r) hours (2 hours at T 1 h, r 0.5; 18 hours at T 6 h, r 1/24), and every checkpoint after that is a conflicting lock, the hazard of `sim/results_v2.md` E in a new coat. The frozen table does not save it when the decay is applied to the frozen table too (which is the only way decay helps tonight). The hysteresis floor is view-dependent in the same way (each side measures its own connected share), so after H hours both sides run the 0.85 rule and the 13.3 percent equivocator bound of 3 October returns (the sim's 33 percent row under `hyst` shows one side locking at minute 59). A rule that removes weight on what a view has seen, a signed leave carried in the DAG or equivocation evidence, is seen by both sides at the heal and by at most one side during the split; during the split the side that saw the leave removes L from its denominator and needs 2/3 (1 - L) of signers while the other side still needs 2/3 of the full table; both locking needs s_A + s_B at least 2/3 (2 - L), more than the 1 - L available without an equivocator, so the one-third bound survives (with an equivocator a, the bound is a at least (1 - L)/3 of the remaining weight, the same statement over the reduced table). + +Why leaving bought keys buys nothing (Q2 in the sim and arithmetic): an attacker holding w of the window who buys L and makes it leave holds w / (1 - L) of what remains; to lock alone it needs w at least 2/3 (1 - L), so w + L at least 2/3 + L/3, never under two thirds of the window, and signing with the bought keys (w + L at least 2/3) is the cheaper use of the same purchase. In the model the attacker's share after leaving its bought 40 percent was 4.8 percent on day 3, the position of its own hash alone. + +### 4.3 Lock delay as a function of voters and message delay + +delay = template poll (1 s on the devnet; the node's own determination on mainnet, 0) + hop_1 (block to voter) + hop_2 (vote to aggregator) + processing + certificate gossip (one hop). The simulator's two hops at Delta give median 0.7 s at 0.5 s, 2.5 s at 2 s, 6.2 s at 5 s (A); the devnet's 0.9 s is the poll plus a 250-ms pump. The per-voter term is the aggregator's verification of each vote as it arrives: one BLS verify is a pairing, about 1.6 ms (approximate, blst 0.3.17 published figures; the fork verifies each vote on ingest, `verify_vote_signature`, finality.rs line 192), which is 150 ms per checkpoint at 93 voters, 1.6 s at 1,000, 13 s at 8,192 and 105 s at 65,536 on one core: past 1,000 voters the single-vote path is a CPU bound before it is a bandwidth bound, and at 8,192 it is more than a quarter of every core's time on every node (every node verifies every vote it relays). The fix is already in the spec's text (Q2 aggregated carriage) and in the crate (`AggregateSignature::aggregate` then one `fast_aggregate_verify`): aggregate first, verify once per (index, hash), which costs V G1 additions (about 1 us each) plus one pairing: 1.7 ms at 93, 2.6 ms at 1,000, 10 ms at 8,192, 67 ms at 65,536. Its price is the batch-poisoning vector (one invalid vote fails the batch and forces bisection); S2's sub-user sortition at 8,192 bounds the signer count at about 4,000 expected either way. + +| Voters | Vote bytes per checkpoint (single) | Verify per checkpoint, single votes (approximate) | Aggregate-first (approximate) | Lock delay, model (Delta 2 s) | Bitmap | +|---|---|---|---|---|---| +| 12 | 3.4 KB | 19 ms | 1.6 ms | 2.5 s (sim A, 1,000 keys) ; 1.24 s measured | 2 B | +| 40 | 11 KB | 64 ms | 1.6 ms | about 2.5 s | 5 B | +| 100 | 28 KB | 160 ms | 1.7 ms | about 2.6 s; 1.26 s measured at 93 on the devnet | 13 B | +| 1,000 | 281 KB | 1.6 s | 2.6 ms | about 4 s single, 2.5 s aggregated | 125 B | +| 8,192 | 2.3 MB (4.6x block mass) | 13 s per node per checkpoint: breaks the 30-s cadence on a shared core | 10 ms | 2.5 s aggregated; S2 switches to about 4,000 sub-users here | 1,024 B | +| 65,536 | 18.4 MB | 105 s: impossible single | 67 ms (3.9 s of key decompression once) | 2.5 s aggregated | 8,192 B, the proposed wire bound | + +Where the aggregator path breaks down: not at the 8 VRF-picked aggregators (anyone MAY aggregate, Q4's fallback at 15 DAA is in routine use tonight: 26 percent of certificates) but at per-vote verification above about 1,000 voters and at the per-block vote carriage above 8,192 (spec 3.4.2 item 2's 384-per-block bound drains a checkpoint in 21 blocks; participation accounting lags, locks do not, because certificates form from gossiped votes). The message-delay term scales the two hops and nothing else; at 5 s inter-region delay the slowest region already loses participation to the 15-s grace (A). + +### 4.4 Weight capture cost (task 3; `weight_capture.py`) + +share(t) = (t/30) x A/(N + A) for A rented against N for t days (spec 3.1, sim B within 0.04 points). To hold W at day t: A = N q/(1 - q), q = 30W/t (needs t over 30W). Cost = A x t x 24 x USD 11.7 per GH/s-hour (measured 6 Oct 2026, bench-log "Rental cost of hash": 1,748 MH/s for USD 20.44/h on RunPod community pods; the 8x 4090 rig USD 5.92/h for 459 MH/s). Cost falls with t, so the cheapest attack takes the full window: A = N W/(1 - W), cost = 8,424 x N x W/(1 - W) USD per GH/s of network. + +| Target | Hash to rent | Day noticed (the chart step) | N = 1 GH/s | N = 10 GH/s | N = 100 GH/s | N = 1 TH/s | +|---|---|---|---|---|---|---| +| 34 percent in 30 days (veto) | 0.52 x N | day 1: +52 percent | USD 4,300 | USD 43 k | USD 434 k | USD 4.3 M | +| 34 percent in 21 days | 0.94 x N | day 1: +94 percent | USD 6 k | USD 56 k | USD 557 k | USD 5.6 M | +| 51 percent in 30 days | 1.04 x N | +104 percent | USD 8.8 k | USD 88 k | USD 877 k | USD 8.8 M | +| 67 percent in 30 days (locks alone) | 2.03 x N | +203 percent | USD 17 k | USD 171 k | USD 1.71 M | USD 17.1 M | +| 67 percent in 25 days | 4.10 x N | +410 percent | USD 29 k | USD 288 k | USD 2.9 M | USD 28.8 M | +| 67 percent in 22 days | 10.6 x N | +1,058 percent | USD 65 k | USD 654 k | USD 6.5 M | USD 65 M | + +What the market supplies: RunPod gave 0 of 20 pods asked at 18:59Z to 19:15Z (bench-log); 38 pods were 1.75 GH/s. So at tonight's 1.16 GH/s devnet every row of the first column is a dinner; at 100 GH/s the 52 GH/s for a veto did not exist on the one market asked (approximate). The alarm that sees the step is lane 1's detector. + +Buying old keys (F19): a key is a 32-byte scalar named in headers by `vote_key_hash`; it can be handed over, W5 succession moves its history once (not implemented, O-3.11), and the seller can keep a copy. Its worth is its blocks: keys worth b of the window are the position of having rented b/(1 - b) x N for 30 days (USD 2,100 per GH/s of network at b 20 percent, 4,300 at 34, 5,600 at 40), and that position decays as b (1 - t/30) + r t/30 (sim K, within 0.6 points). What makes weight unbuyable: nothing in the protocol. What makes bought weight a bad buy: it ages out in 30 days whatever the buyer does, a seller's copy can equivocate it away (3.6), and a pool's key is its payout identity, so the price is the pool. What the header does not do: it does not tell anyone the key changed hands until its blocks stop matching its old profile (the detector's job). A rule that would make it harder, decaying a key whose block profile breaks, is view-dependent in a partition (section 4.2) and is not recommended. + +Per tier: a home miner's or rig's key (one per machine under 3.4.2 item 4) is worth its 30 days of blocks and nothing a buyer would pay for; a pool's key is the only one worth buying and the only one whose sale is visible; a holder's finality rests on the 2/3 of weight no one can rent cheaply past a few GH/s; a rollup customer's bridge inherits the same bound. + +### 4.5 Long-range and checkpoint sync for light clients (task 4; `lightclient_cost.py`) + +What a node joining after 60 days trusts (spec 10.1 and 10.3): the trusted checkpoint shipped in its release, refreshed from N of M seed nodes (M 5, N 3, O-10.5), and from there every certificate it fetches is verified against the voter set, which in checkpoint mode it takes from nodes (N of M agreement) and in full-header mode recomputes from 30 days of headers (W2). The cold-sync node of X20 selects the heaviest DAG then follows certificates found in it (F5), so its first 30 days of history are proof of work in the sense of 3.9. The weak-subjectivity window Igneum has in fact is one weight window: a certificate older than 30 days can be checked only against a voter table the client cannot recompute from less than 30 days of headers, and under v3 the frozen table expires 30 days after a lock, so a node offline longer than 30 days cannot tell a certified chain from a chain certified by keys that have since left the window; the same class of assumption as Ethereum's weak-subjectivity period for a sync-committee checkpoint (not cloned here; approximate), with the window the parameter. + +| Mode, per year of chain | 93 voters | 1,000 | 8,192 | 65,536 | Source | +|---|---|---|---|---|---| +| Every certificate (1,051,200) plus its header, bytes | 720 MB | 839 MB | 1.78 GB | 9.3 GB | 285 to 8,465 B per certificate plus 400 B header | +| Verify time, one laptop core (approximate: V G1 adds plus hash-to-G2 plus two pairings, 1.8 to 67 ms each) | 32 min | 48 min | 2.9 h | 20 h | `lightclient_cost.py` | +| On a phone core (3x, approximate) | 1.6 h | 2.4 h | 8.7 h | 59 h | | +| One certificate per presence window (4,380 a year, spec 10.3 item 3) | 3.0 MB, 8 s | 3.5 MB, 12 s | 7.4 MB, 44 s | 39 MB, 4.9 min | the voter set at each stop is not paid for | +| Full-header mode, headers alone | 12.6 GB a year at 400 B per header | | | | | + +The measured anchor: the pure-JavaScript verifier of a 16-signer certificate took 58 to 68 ms warm on the M5 Max (bench-log, sweep round 6, P3), about 30x the native estimate here; a phone has not been measured (O-10.3). + +The ZK light client (phase two, O-10.8), designed: one recursive proof per checkpoint whose step statement is "certificate i verifies under voter table T_i; T_i follows from T_(i-1) by the interval's 30 blue headers and the window's ageing; the signers hold at least two thirds of T_i and of the frozen table; C_i's selected chain passes through C_(i-1)", with the previous step's proof verified inside (SP1's deferred-proof path, `VERIFY_SP1_PROOF` in `sp1-core-executor-6.8.1/src/syscall_code.rs`; the recursion crates are `sp1-recursion-{circuit,compiler,executor,machine,gnark-ffi}-6.8.1` in the cargo registry, no SP1 clone under `vendor/`). What the circuit costs, approximate: key aggregation V x BLS12381_ADD (about 500 cycles each: 46,500 cycles at 93 voters, 0.5 M at 1,000, 4.1 M at 8,192); hash-to-G2 about 0.3 M (SHA-256 is precompiled, the Fp2 arithmetic is BLS12381_FP2_*); the two pairings 10 to 30 M cycles, the dominant term, because 6.8.1 has Fp and Fp2 precompiles for BLS12-381 (ADD, DOUBLE, FP_ADD/SUB/MUL, FP2_ADD/SUB/MUL) and no pairing precompile; 30 BLAKE2b header hashes and the table transition 1 to 2 M (no BLAKE2b precompile). Against the measured shard curve (`docs/analysis/prover-tiers-real-cards.md`: 4.7 M cycles compressed in 4.8 to 14.4 s alone, 10.7 to 37.5 s beside the miner) a 15 to 35 M cycle step is 15 to 100 s alone and 40 to 260 s beside a miner, plus the recursion step measured at 2.2 to 2.5 s idle and 7.9 to 9.7 s beside the miner on the 5090 (bench-log, agg-cost and `chain-pc2-pv1c`). One checkpoint every 30 s therefore needs 1 to 4 proving-only cards (or 2 to 9 mining ones) at it continuously. A Groth16 wrap for the phone is the unbuilt R4 (P3). + +What it buys each tier: a phone wallet verifies one wrapped proof per open (about 400 B, milliseconds once the wrapper exists) instead of a certificate chain and a trusted voter set, and the "voter set: from nodes" status disappears (spec 10.4); a bridge verifies one proof per checkpoint it settles on and never a BLS certificate on-chain (an on-chain BLS12-381 aggregate verify at 1,000 voters is about 1,000 G1 additions and one pairing, which on Ethereum is the point-evaluation and pairing precompile budget, approximate); a rollup customer gets a finality statement its own verifier can check without Igneum's voter list; a node operator pays nothing (full nodes keep the native rule); a prover tier gains a steady job (one proof per 30 s) at the cycle counts above; a home miner with one 12 GB card beside its miner (27 to 37 s per 4.7 M-cycle shard) cannot keep up with a 30-s cadence alone and joins as one of several; a 24 or 32 GB card alone does it in the interval. + +### 4.6 Prover attestations as a second finality leg (task 5) + +Design: a checkpoint locks when (a) its certificate carries two thirds of weight (Q3, Q5) AND (b) proof records covering every chain block in (C_(i-1), C_i] from at least k distinct prover keys are in the past of some block the certificate's signers could see. Measured inputs: the proof lag on the live devnet, block to carried record, p50 44 s, p90 52 s, p99 62 s, max 65 s (bench-log, proving v1 coverage windows, 5 Oct); coverage 2.4 to 4.7 percent of blocks with one prover (the same rows); the chain-mode cost 17 s per empty block on a mining 5090, about 5 s proving-only; a 12 GB card beside its miner 27 to 37 s per v1 shard (prover-tiers); a mandatory rule needs about 6 proving-only 5090s or 18 mining ones for an empty-block chain at 1 block/s (bench-log table), 45 proving-only at B_p. + +| Measure | Weight alone (today) | Weight AND k-prover attestations | Label | +|---|---|---|---| +| Lock delay after the checkpoint block | 1.26 s at 93 voters (3.2) | at least the slowest block's proof lag inside the interval: p99 62 s today, so about 60 to 70 s; the transaction-to-lock figure of C1 rises from 90 to 120 s to about 150 to 190 s | measured lag, derived sum | +| Checkpoints that could lock on tonight's devnet | all with two thirds signing | 2.4 to 4.7 percent (one prover): finality paused 95 percent of the time until proving is mandatory and the fleet is 6 to 18 cards | measured coverage | +| What it stops that weight does not | nothing for a full node: it re-executes and vetoes a statement that is not the native one (spec 7.2 item 5, the native veto) | a two-thirds weight holder cannot lock a checkpoint whose execution has no valid proof, which protects the LIGHT client, who trusts certificates and cannot execute (10.1); the design already gives the light client that by requiring the segment proof beside the certificate (10.4 item 4), so the leg moves the requirement from the client into the lock | design | +| Withholding to pause | a silent third pauses (L1) | a prover set that withholds proofs pauses finality for as long as no one else proves; the shard sortition names 8 provers by weight with a 10-s exclusive window and then anyone MAY prove (spec 7.2), so the price of a pause is out-proving every honest card for the whole pause, which in a thin market (tonight: one prover at times) is one card's outage | design, measured market | +| Per tier | unchanged | a 12 GB card beside its miner proves one 4.7 M-cycle shard in 27 to 37 s, so k = 2 provers per block means 37k mining 12 GB cards (or 10k proving-only 4070s at 12 s) kept busy for an empty chain, approximate; a pool user nothing; a holder a longer wait; a rollup customer the same proof it already needs | prover-tiers, derived | + +Verdict: not as a lock condition now. The leg converts "locked" into "locked and proven" at the cost of a minute of lock delay and a pause whenever proving coverage drops, which tonight is almost always. The design's four-state interface (included, executed, proven, locked; O-7.2) already gives the exchange and the wallet the conjunction as a reading. Gate before it could become a rule: 99 percent of chain blocks proven within 60 s for 7 days on the public testnet with at least 3 distinct provers per block, measured by `tools/proving-v1/coverage.mjs`. + +## 5. Results of the candidate runs (`finality_horizon.py`, seeds 7, 11, 13) + +### 5.1 T. Tonight's departure: first lock after x of weight stops mining and signing at once (31 days, seeds 7, 11, 13) + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | v2 | 0.77 to 0.87 d (devnet 3 to 3 min) | | 4057 to 4727 | 1531 to 2489 | 0 | | +| 34% | v3 | 30.00 d (devnet 120 min) | | 86388 to 86472 | 0 to 31 | 0 | | +| 34% | leave | 0.04 to 0.04 d (devnet 0 to 0 min) | | 119 to 521 | 0 to 398 | 0 | | +| 34% | decay1 | 0.05 to 0.05 d (devnet 0 to 0 min) | | 133 to 534 | 0 to 398 | 0 | | +| 34% | decay6 | 0.30 to 0.30 d (devnet 1 to 1 min) | | 897 to 1298 | 24 to 442 | 0 | | +| 34% | hyst | 0.04 to 0.04 d (devnet 0 to 0 min) | | 881 to 1445 | 761 to 1325 | 0 | | +| 34% | twotier | 30.00 d (devnet 120 min) | provisional 0.000 to 0.004 d (devnet 0.0 to 0.0 min) | 86388 to 86472 | 0 to 31 | 0 | 0 | +| 45% | v2 | 7.85 to 8.03 d (devnet 31 to 32 min) | | 23947 to 25153 | 1369 to 2065 | 0 | | +| 45% | v3 | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 45% | leave | 0.04 d (devnet 0 min) | | 119 to 204 | 0 to 83 | 0 | | +| 45% | decay1 | 0.07 to 0.08 d (devnet 0 to 0 min) | | 224 to 296 | 9 to 83 | 0 | | +| 45% | decay6 | 0.64 to 0.67 d (devnet 3 to 3 min) | | 1946 to 1985 | 0 to 129 | 0 | | +| 45% | hyst | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 45% | twotier | 30.00 d (devnet 120 min) | provisional 0.031 to 0.034 d (devnet 0.1 to 0.1 min) | 86382 to 86420 | 0 to 22 | 0 | 0 | +| 50% | v2 | 10.09 to 10.34 d (devnet 40 to 41 min) | | 30107 to 31264 | 1065 to 1456 | 0 | | +| 50% | v3 | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | +| 50% | leave | 0.04 d (devnet 0 min) | | 118 to 490 | 0 to 371 | 0 | | +| 50% | decay1 | 0.08 to 0.09 d (devnet 0 to 0 min) | | 244 to 609 | 0 to 371 | 0 | | +| 50% | decay6 | 0.76 to 0.77 d (devnet 3 to 3 min) | | 2231 to 2543 | 0 to 371 | 0 | | +| 50% | hyst | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | +| 50% | twotier | 30.00 d (devnet 120 min) | provisional 0.042 d (devnet 0.2 min) | 86387 to 86514 | 0 to 10 | 0 | 0 | + +### 5.2 P1. Partitions of 360 minutes (each side retargets and counts only its own blocks) + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / 279 (never in 2 of 3) | yes | 0 to 0 | 0 | +| v2 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 142 to 291 | | 14 to 77 | 11 to 48 / 0 to 76 | yes | 0 to 0 | 0 | +| v2 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v2 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v2 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| v3 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| v3 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v3 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| leave | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| leave | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| leave | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| decay1 | 50/50 honest, 0% attacker | 360 | 467 to 473 | | 121 to 126 | 120 to 122 / 121 to 123 | yes | 0 | 0 | +| decay1 | 50/50 + 20% equivocator (sides 60/60) | 360 | 528 to 535 | | 93 to 94 | 91 / 92 to 94 | yes | 0 | 0 | +| decay1 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 587 to 595 | | 64 to 65 | 62 / 63 to 65 | yes | 0 to 0 | 0 | +| decay1 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 575 to 612 | | 14 to 62 | 12 to 50 / 0 to 62 | yes | 0 to 0 | 0 | +| decay1 | 40/40/20 honest | 360 | 815 to 822 | | 142 to 144 | 140 to 142 / 140 to 140 / 166 to 166 | yes | 0 to 0 | 0 | +| decay1 | 60/40 honest | 360 | 435 to 436 | | 142 to 142 | 92 to 96 / 141 to 142 | yes | 0 to 0 | 0 | +| decay1 | 70/30 honest | 360 | 403 to 414 | | 154 to 156 | 0 to 4 / 154 to 156 | yes | 0 | 0 | +| decay6 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| decay6 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| decay6 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| hyst | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| hyst | 50/50 + 20% equivocator (sides 60/60) | 360 | 565 to 597 | | 60 to 61 | 58 to 61 / 58 to 61 | yes | 0 | 0 | +| hyst | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 596 to 603 | | 60 to 62 | 59 to 60 / 60 to 62 | yes | 0 to 0 | 0 | +| hyst | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 160 to 342 | | 14 to 60 | 12 to 50 / 0 to 60 | yes | 0 to 0 | 0 | +| hyst | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| hyst | 60/40 honest | 360 | 0 | | never | 58 to 61 / never | yes | 0 to 0 | 0 | +| hyst | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| twotier | 50/50 honest, 0% attacker | 360 | 0 | 589 to 603 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | 653 to 660 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | 705 to 715 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | 713 to 720 | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| twotier | 40/40/20 honest | 360 | 0 | 1056 to 1062 | never | never / never / never | yes | 0 to 0 | 0 | +| twotier | 60/40 honest | 360 | 0 | 516 to 564 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 70/30 honest | 360 | 0 | 523 to 532 | never | 0 to 4 / never | yes | 0 | 0 | + + +P2. The poisoned eclipse (a 34% attacker plus a 20% pool; the eclipsed side holds 54% of total) + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| v2 | 1 | 0 | | never | 0 | 0 | 0 | +| v2 | 2 | 0 | | never | 0 | 0 | 0 | +| v2 | 4 | 0 | | never | 0 | 0 | 0 | +| v3 | 1 | 0 | | never | 0 | 0 | 0 | +| v3 | 2 | 0 | | never | 0 | 0 | 0 | +| v3 | 4 | 0 | | never | 0 | 0 | 0 | +| leave | 1 | 0 | | never | 0 | 0 | 0 | +| leave | 2 | 0 | | never | 0 | 0 | 0 | +| leave | 4 | 0 | | never | 0 | 0 | 0 | +| decay1 | 1 | 0 | | never | 0 | 0 | 0 | +| decay1 | 2 | 17 to 24 | | 109 to 111 | 22 to 24 | 0 | 0 | +| decay1 | 4 | 257 to 264 | | 109 to 111 | 263 to 265 | 0 | 0 | +| decay6 | 1 | 0 | | never | 0 | 0 | 0 | +| decay6 | 2 | 0 | | never | 0 | 0 | 0 | +| decay6 | 4 | 0 | | never | 0 | 0 | 0 | +| hyst | 1 | 0 | | never | 0 | 0 | 0 | +| hyst | 2 | 0 | | never | 0 | 0 | 0 | +| hyst | 4 | 0 | | never | 0 | 0 | 0 | +| twotier | 1 | 0 | 19 to 24 | never | 0 | 0 | 0 | +| twotier | 2 | 0 | 137 to 145 | never | 0 | 0 | 0 | +| twotier | 4 | 0 | 377 to 385 | never | 0 | 0 | 0 | + + +P3. Long honest partitions with view-local weight, 12 days + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 | 12 | 10.15 to 10.18 / 10.12 to 10.34 | 2890 to 3550 | | 10.22 to 10.35 d | yes | 0 | +| v2 | 60/40 | 12 | 5.15 to 5.27 / never | 0 | | never | yes | 0 to 0 | +| v3 | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| v3 | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | +| leave | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| leave | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | +| decay1 | 50/50 | 12 | 0.08 to 0.08 / 0.08 to 0.09 | 34146 to 34212 | | 0.08 to 0.09 d | yes | 0 | +| decay1 | 60/40 | 12 | 0.06 to 0.07 / 0.10 to 0.10 | 33931 to 34254 | | 0.10 to 0.10 d | yes | 0 to 0 | +| decay6 | 50/50 | 12 | 0.77 to 0.77 / 0.75 to 0.78 | 32120 to 32246 | | 0.77 to 0.78 d | yes | 0 | +| decay6 | 60/40 | 12 | 0.52 / 0.92 to 0.93 | 31545 to 31873 | | 0.93 to 0.93 d | yes | 0 to 0 | +| hyst | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| hyst | 60/40 | 12 | 0.04 to 0.04 / never | 0 | | never | yes | 0 to 0 | +| twotier | 50/50 | 12 | never / never | 0 | 34267 to 34418 | never | yes | 0 | +| twotier | 60/40 | 12 | never / never | 0 | 34094 to 34381 | never | yes | 0 to 0 | + +### 5.3 Q1. Silent weight that keeps mining for 6 hours, then resumes + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| v2 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v2 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v2 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v3 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v3 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v3 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| leave | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| leave | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| leave | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| decay1 | 30% | 6 | 0 | 0 | 1 | 0 to 0 | 0 | +| decay1 | 34% | 6 | 66 to 68 | 134 to 135 | 66 to 68 | 0 to 0 | 0 | +| decay1 | 45% | 6 | 108 to 112 | 217 to 229 | 108 to 112 | 0 to 0 | 0 | +| decay6 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| decay6 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| decay6 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| hyst | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| hyst | 34% | 6 | 59 to 61 | 120 | 59 to 61 | 0 to 0 | 0 | +| hyst | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| twotier | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| twotier | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| twotier | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + +Q2. Acquired keys that sign, stay silent or leave, under v3 + leave 1 h (30 days) + +| bought weight | bought keys | attacker's peak share of the denominator | share at the end | holds the veto (1/3) | stalled checkpoints | conflicting locks | +|---|---|---|---|---|---|---| +| 40% | sign | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 0 | 0 | +| 40% | silent | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 86402 to 86491 | 0 | +| 40% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 119 to 144 | 0 | +| 49% | sign | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 0 | 0 | +| 49% | silent | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 86371 to 86408 | 0 | +| 49% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 143 to 188 | 0 | + +Under v3 the silent bought 40 percent stalls every checkpoint of the 30 days (86,402 to 86,491), where `sim/results_v2.md` K at v2 measured 63,307 to 68,716: the frozen table keeps the bought weight in the denominator after the sliding table has aged it out, so a silent buyer pauses finality for a window, not until day 19 to 20. The leaving buyer holds 30.0 percent at most (its own hash), never the veto. + +### 5.4 Reading + +What holds. Every candidate keeps 0 conflicting final locks in every honest partition under two thirds (50/50, 40/40/20, 60/40, 70/30 for 360 minutes) except the fast decay, and every candidate conflicts at the 34 percent equivocator (139 to 612 locks in 360 minutes, the one-third bound of 3.11.2, unchanged). The leave rule's rows equal v3's in every partition, eclipse and equivocator case (no key leaves in those scenarios, which is the point: a partition does not sign leaves) and it is the only candidate that both ends tonight's pause in under an hour (0.04 days at 34, 45 and 50 percent: the one-hour delay) and keeps 0 conflicts in the 12-day splits. + +What breaks. The fast decay (T 1 h, r 0.5/h) conflicts in EVERY 360-minute partition, including 50/50 honest with no attacker (467 to 473 conflicting locks, both sides locking alone at minute 120 to 123, exactly T + 1/(2r) = 2 h) and the poisoned eclipse at 2 and 4 hours (17 to 264 conflicts, the eclipsed side locking the attacker's fork after 109 to 111 minutes); it also fails the 12-day splits in 2 hours (34,146 to 34,254 conflicts). The slow decay (T 6 h, r 1/24 per h) passes every 360-minute row because the decay has not started, then both sides of the 50/50 split lock alone at day 0.75 to 0.78 and the 60/40 at 0.52 and 0.93 (31,545 to 32,246 conflicts in 12 days): the hazard moved to the day scale, not removed. The hysteresis floor keeps the honest splits clean (the 60 side of 60/40 locks alone at minute 58 to 61, 0 conflicts) and reopens the equivocator bound: 20 percent across a 50/50 split gives 565 to 597 conflicting locks from minute 60 (the 13.3 percent bound of 3 October is back after H hours), and it does nothing for tonight's 45 percent departure (the stayers' 55 percent is under its 56.7 percent floor: 30.00 days, the same as v3). The two-tier's provisional tier conflicts in every partition and eclipse (516 to 1,062 provisional locks per 360 minutes, 19 to 385 in the eclipses, 34,000 in 12 days) while its final tier equals v3; it is a report of "the connected majority agrees", never a lock. + +The pass line (0 conflicting final locks in every scenario AND tonight's pause under an hour) is met by one candidate: the departure announcement. v2 would have met the hour on the devnet (35 minutes measured, 31 to 32 simulated at 45 percent) and not on mainnet (7.85 to 8.03 days); v3 meets neither (30.00 days, 120 devnet minutes, the frozen table's expiry, as the live chain is showing at the time of writing: 89.2 percent of the sliding table signing at 19:50Z and no lock). + +### 5.5 The aggregation path tonight (the coordinator's question of 19:3xZ) + +Measured: the 8 VRF-picked aggregators are drawn by weight (spec S1, fin-fixes); the fallback (any node, 15 DAA after the determination, aggregator `00000000`) produced 64 of the 251 certificates stored between 16:30 and 19:00Z; locks 6824 to 6842 formed while node 1 and the observer were down; the observer's node received 75 then 79 of 93 voters' votes through its 3 peers during the pause. The star the fleet forms is around the seed (`docs/bench-log.md`, the finality route: "on a Vast box the seed is the only peer"), not the Mac; if the seed fell, a one-peer box would lose blocks as well as votes, and the right fix is a peer floor (at least 4 outbound peers from the address book before a node reports synced), which is lane 5's bandwidth and p2p lane. Model of P(certificate | hub down) under the rule as written: with the fallback, a certificate forms whenever any node connected to two thirds of the signing weight exists, so the probability is 1 for any topology in which votes reach any node; without the fallback and with a star through the hub it is 0 for 8 aggregators or 800. The rule is already the right one; the harness case to add is `tools/finality-attacks` s4's shape (the eclipse) with the hub cut instead of a pool: N boxes peered to the seed and the hub, the hub killed for 300 s, pass line a lock within 2 checkpoints of the cut while two thirds of weight stays connected through the seed, and 0 conflicting certificates at the hub's return. Vote bytes per checkpoint are in 3.3 (26 KB at 93 voters, 2.3 MB at 8,192 single, about 1.2 KB aggregated). + +## 6. Ranked proposals + +| Rank | Proposal | Evidence | Model | Hours | Consequence per tier | Gate | +|---|---|---|---|---|---|---| +| 1 | The departure announcement (candidate iv): a `leave` item (key, DAA score, signature) carried in blocks; D = 1 h after inclusion the key is in no denominator (sliding and frozen) and its votes are invalid; the fleet and app send it on a clean stop | tonight's pause (3.1): 42.7 percent left in three minutes and the frozen table held finality for a window; sim T: first lock 1 h after the departure at 34, 45 and 50 percent, 0 conflicts in every P row, Q2: leaving bought keys gains the attacker nothing | section 4.2 arithmetic; `finality_horizon.py` `leave` | 6 (spec text 3.1 W7 and 3.3; node: the item, its carriage, `voters_at` and `frozen_table` exclusion, unit test; app and fleet library: send on stop; fast-time harness case) | home miner, rig: the app sends the leave on Stop, so a clean exit never holds the network; a crash still ages out over 30 days (v3) unless the operator sends the leave on return, which the app offers; pool: one leave per server on maintenance; holder: fewer and shorter pauses; rollup customer: the same; node operator: one more item type | harness: 45 percent of weight stops with leaves, first lock within D + 1 checkpoint, 0 conflicts in the 50/50 and 60/40 splits and the 34 percent eclipse; sim T and P rows reproduced on the node | +| 2 | Operational rule, no protocol change: a standing box never leaves the live chain for an experiment, and any orchestrated departure over 10 percent of weight is staged in slices under 10 percent an hour | the rehearsal took 36.5 percent at once; sim L2 and M4: a gradual departure costs nothing (every lock re-freezes the table) | spec 3.7 item 2 | 1 (the fleet library refuses to swap a standing box's chain; a `--slice` on the rehearsal script) | fleet operator: the swap takes longer; everyone else: no pause | the next rehearsal: `finality_active` stays true throughout | +| 3 | Aggregate-first vote verification and aggregated in-block carriage as the mainnet default (spec 3.4.2 item 2, decided) | 4.3: per-vote verification is 1.6 s per checkpoint per node at 1,000 voters and 13 s at 8,192 (approximate); the crate already has `aggregate` and `fast_aggregate_verify` | 4.3 table | 8 (node: batch the votes for one (index, hash) and verify once, bisect on failure; the in-block aggregate item; measure on the fast-time harness at 1,000 synthetic keys) | home miner, rig, pool: a node that stays under one core at 1,000 voters; node operator: the same; holder: lock delay flat at 2 to 3 s to 8,192 voters | fast-time harness with 1,000 and 8,000 synthetic voters: lock delay p50 under 3 s, CPU under 25 percent of one core, 0 conflicts | +| 4 | Report the two-tier state (candidate ii) as `finality_provisional` beside `finality_active`, never as a lock | sim P: 516 to 1,062 provisional conflicts per 360-minute partition, 19 to 385 per eclipse, about 34,000 in 12 days, final 0; sim T: provisional 0 to 0.2 devnet minutes after tonight's departure | 4.1 | 3 (node RPC field, explorer and wallet copy; spec 3.9 row) | exchanges: a third row in the guidance table ("provisional: proof of work plus a majority of the connected weight; credit nothing on it"); a holder sees why the pause is a pause | the explorer shows the field through a forced pause on the devnet; the guidance text reviewed by an operator (O-3.13) | +| 5 | The ZK light client's circuit as a phase-two design doc with a cycle measurement | 4.5: 15 to 35 M cycles per step, approximate; the pairing is the term to measure | `lightclient_cost.py` | 10 (an SP1 guest that verifies one certificate at 93 and 1,000 voters with the Fp2 precompiles; cycle count on the 5090 and a 12 GB card) | phone, bridge, rollup customer: the per-year columns of 4.5 become one proof; prover tiers: a steady 30-s job | measured cycles within 2x of the estimate; proof per checkpoint under 30 s on a proving-only 5090 | +| 6 | Hub-cut harness case for the aggregation path | 5.5: the fallback carried 26 percent of tonight's certificates; the seed, not the Mac, is the fleet's star | 5.5 | 3 | fleet operator: a proven answer to tonight's question | the case passes as written in 5.5 | +| 7 | Do NOT adopt the decaying denominator (i) or the hysteresis floor (iii) | sim P1 and P3 (5.2): decay1 467 to 473 conflicting locks in a 360-minute 50/50 honest split and 34,146 to 34,254 in 12 days; decay6 31,545 to 32,246 in 12 days; hyst 565 to 597 at a 20 percent equivocator from minute 60 | 4.2 | 0 | a holder keeps the one-third bound in every view | none: a negative result | +| 8 | Do NOT make prover attestations a lock condition before the coverage gate | 4.6: coverage 2.4 to 4.7 percent tonight, lag p99 62 s | 4.6 table | 0 now; 12 after the gate | holder: no new pause source; rollup customer: nothing lost, the four-state reading exists | 99 percent of blocks proven within 60 s for 7 days, 3 provers per block | + +**1. The departure announcement.** Tonight's cost was a window-long pause caused by keys that left on purpose, under a job that knew it was taking them. The frozen table (F21) exists so that a side of a partition cannot fill its own table, and it does that; its price is that it cannot tell a departure from a partition. A signed leave is the one thing a departing key can give that a partitioned key cannot: it is seen, not inferred. Section 4.2 shows the one-third bound survives it in both halves of a split, the sim shows 0 conflicting locks in every partition, eclipse and equivocator row under it and a first lock one hour after a 34, 45 or 50 percent departure where v3 waits 30 days, and Q2 shows an attacker who buys keys to leave them is worse off than one who signs with them. The hour is a parameter: it must exceed the certificate relay plus one presence of the leave in blocks (minutes), and shorter is better for the operator; one hour matches the merge-depth bound and gives a key that leaves by mistake time to see it. What it does not cover: a crash, a power cut, a region going dark, which still age out as today; the app's Stop button and the fleet library send the leave, and a node that restarts after an unplanned outage can send it on return to shorten the pause from that point. + +**2. Staged departures.** Every lock re-freezes the table, so weight that leaves while locks continue ages out of the frozen table as it ages out of the sliding one (M4's "gradual departure costs nothing"). Ten percent an hour keeps the stayers over two thirds at every step for any total departure under a third per three hours. This is a fleet-library rule, one afternoon, and it would have kept tonight's finality on without any protocol change; proposal 1 covers the case where staging is not possible. + +**3. Aggregate-first verification.** The delay data of 3.2 is flat to 93 voters because the devnet's cost is the poll and the pump, not the pairings; the arithmetic of 4.3 says the pairings take over near 1,000 voters and break the 30-s cadence near 8,192. The crate the fork already uses has both halves of the fix; what is missing is the batching in `ingest` and the in-block aggregate item that 3.4.2 item 2 proposes. The gate is a synthetic-voter harness, which `tools/finality-attacks` can host (its `lib/net.mjs` starts nodes; a vmine with 1,000 keys is a flag away). + +**4. The two-tier report.** The provisional tier is the active-denominator rule the project rejected on 3 October, and the sim says again why: it conflicts in every partition. As a reported state it is useful to a holder who wants to know whether the pause is a silent third (provisional true: the connected majority still agrees) or a split (provisional conflicting on the two sides), and useless to an exchange, which must credit nothing on it. Three hours, mostly copy. + +**5 and 6** are measurements with a design attached, priced above. **7 and 8** are the negatives this lane is confident about: a denominator that shrinks on silence is the 3 October rule under another name, and a lock that waits for proofs is a lock that pauses whenever the proving market is thin, which it is. + +## 7. Open questions and what could not be run + +- The fleet wrote no lock-delay or voter-count row by 19:45Z (`block-rate-devnet2.md` is a template); the 93-voter figures here are node 1's own log. When RUN_A and RUN_B land, the 10 blocks/s row should be checked against 4.3's claim that the hop term, not the voter term, sets the delay. +- The leave item does not exist in the node; the harness case of proposal 1 is designed, not run. Its interaction with W5 succession (O-3.11) and with a key that leaves and keeps mining (its blocks earn nothing, as the spec says for a succeeded key) needs the spec text. +- BLS costs are approximate (crate benchmarks from memory, anchored by one measured JavaScript run). A `cargo bench` of `fast_aggregate_verify` at 93, 1,000 and 8,192 keys on the Mac is one hour and belongs with proposal 3. +- The ZK light client's pairing cost in SP1 6.8.1 is the number the whole of 4.5 turns on; it is approximate until the guest of proposal 5 is counted. +- The pause's end was not observed at the time of writing (expected about 20:40Z at the frozen table's expiry, or earlier if the departed boxes return); the observer rows will show which. +- The box ran the sims at nice 19 under other agents' load; the tables are counts and days, not timings, so the load does not touch them. + +## 8. Summary for the coordinator + +Tonight's finality pause (first unlocked checkpoint 6843 at about 18:40Z, still paused at 19:30Z) is the two-thirds rule doing what it says, then the frozen table doing what F21 asked: 20 keys holding 42.7 percent of the frozen table left the live chain, the stayers held 53.1 percent at the first unlocked checkpoint and 74.9 percent of the sliding table from 19:14:53Z, and only Q5 explains why 74.9 percent did not lock; certificates formed through the hub outage and the aggregator fallback carried a quarter of them, so the topology hypothesis is refuted. The three findings: (1) under v2 the pause would have ended at 19:14:53Z, 35 minutes in, and on mainnet the same event is 7.7 days (v2) or 30 days (v3); (2) of the four candidate rules only the departure announcement keeps the one-third bound (0 conflicts in every partition row, first lock one hour after the departure), while decay and hysteresis reopen the double lock and the two-tier is a report; (3) renting a veto costs USD 8,424 x N x 0.52 for 30 days (USD 4,300 per GH/s of network), locking alone 2.03 x N for 30 days, and bought keys cost the same and decay in 30 days. diff --git a/sim/horizon/finality-and-weight/README.md b/sim/horizon/finality-and-weight/README.md new file mode 100644 index 00000000..f0497645 --- /dev/null +++ b/sim/horizon/finality-and-weight/README.md @@ -0,0 +1,45 @@ +# sim/horizon/finality-and-weight + +Models behind `docs/analysis/horizon/finality-and-weight.md` (Horizon lane 3, 6 October 2026). + +## finality_horizon.py + +A copy of `sim/finality_v2.py` as of fud-close `1544c63` (the block reading of Q2, scenario O) plus four candidate rules and three scenarios. The base rule in every candidate is the live one: active denominator under the block reading, floor 1.0 (a lock needs 2/3 of total), each partition side retargets (+daa) and counts only its own blocks (+local), and `frozen=True` for rule v3 (the frozen table, ledger F21). + +Candidates (`--cands`, comma-separated keys or `all`): + +| key | rule | +|---|---| +| `v2` | sliding table only (the rule before F21) | +| `v3` | plus the frozen table (the live rule since N3) | +| `leave` | (iv) departure announcement: a key's signed leave removes it from every denominator 1 h later | +| `decay1` | (i) decaying denominator: a key the view has not seen vote for 1 h loses 0.5 of its denominator weight per hour (gone after 3 h) | +| `decay6` | (i) slow decay: 6 h grace, 1/24 per hour (gone after 30 h) | +| `hyst` | (iii) floor with hysteresis: after 1 h under 2/3 signing the floor drops to 0.85 x 2/3 (sliding and frozen), back after 1 h at or above 2/3 | +| `twotier` | (ii) a provisional lock at 2/3 of the active denominator (block reading, no floor), reported beside the final lock, never certifying | + +Scenarios: `T` tonight's departure (34, 45, 50 percent of weight stops mining and signing at once, 31 days), `P` the partition suite (H, I, E, L3 eclipse, L4 12-day splits), `Q` silent weight (L1) and acquired keys that leave (Q2, leave candidate only). + +Runs behind the analysis (igneum-build-1, Python 3.12, numpy 1.26.4, `nice -n 19`, one process per candidate, seeds 7, 11, 13; about 25 minutes wall): + + for c in v2 v3 leave decay1 decay6 hyst twotier; do + nice -n 19 python3 finality_horizon.py --scenarios T,P,Q --seeds 7,11,13 --cands $c > out-$c.md 2> err-$c.log & + done + +Smoke run on the Mac (under the main checkout's run lock, about one minute): + + /Users/joshm/Projects/igneum/tools/lock/with-lock.sh run nice -n 19 python3 finality_horizon.py --quick --scenarios T,P,Q --seeds 7 + +Outputs of the full run are in `results/` (`out-.md`, `err-.log` with the per-scenario timings); `python3 merge_results.py` merges them into `results/combined.md`, the tables section 5 of the analysis quotes. + +## weight_capture.py + +Arithmetic of task 3 (rented hash against the 30-day window at the measured USD 11.7 per GH/s-hour; bought keys). `python3 weight_capture.py`. + +## lightclient_cost.py + +Arithmetic of task 4 (bytes and verification time of a year of certificates by voter count; the ZK light client's per-checkpoint cycles, approximate). `python3 lightclient_cost.py`. + +## obs-query.mjs (not in this directory) + +The observer rows of tonight's pause were read with read-only SELECTs against the observer's Neon database (the script lived in the session scratchpad; it is `fetch` to the Neon SQL endpoint with `DATABASE_URL` from `~/.config/igneum/env`, refusing anything but SELECT). The queries are quoted in the analysis document. diff --git a/sim/horizon/finality-and-weight/finality_horizon.py b/sim/horizon/finality-and-weight/finality_horizon.py new file mode 100644 index 00000000..f6c30f3f --- /dev/null +++ b/sim/horizon/finality-and-weight/finality_horizon.py @@ -0,0 +1,2229 @@ +#!/usr/bin/env python3 +"""Igneum finality rule V2: checkpoint-level simulation with latency, partitions and eclipses. + +Rule under test (CLAUDE.md, FINALITY RULE V2, review round 2, 3 October 2026): + * Vote weight of a key = its blue blocks over a flat trailing 30-day window (DAA time, + 86,400 blocks a day). No damping. Dust threshold DUST blocks: a key below it is not a + voter and is not counted in any denominator. + * A checkpoint forms every 30 blocks (blue score 30i). Every voter signs every checkpoint + it sees while online. Aggregators collect votes; a certificate (a lock) exists once the + collected signatures reach QUORUM (2/3) of the denominator. + * ACTIVE denominator: sum over eligible keys of weight x participation, where participation + = min(1, certified votes of that key over the last PRESENCE (240) checkpoint indices / 240). + A key whose first block is less than 240 checkpoints old counts as participation 1. + * TOTAL denominator (the alternative): sum of weight over every eligible key, no factor. + * Equivocation (one key signing two different checkpoint blocks at one index) zeroes the + key's weight for the rest of the window once the evidence is seen. + +Participation bookkeeping has three readings, selected with --pmode. The difference only shows +when a checkpoint index gets no certificate: + cert (the brief, literal) an uncertified index contributes 0 certified votes to EVERY key, + so a stall decays everyone's participation and the denominator shrinks until the + present signers reach 2/3 of it. Self-healing, and the partition hazard. + seen an uncertified index credits the keys whose votes the node observed on the wire. + Silent keys decay, present keys do not. Not objective: each node counts its own view. + frozen the window is over the last 240 CERTIFIED indices, so a stall freezes participation. + Fails safe and never recovers liveness within the window. + block (spec 3.3 Q2, the rule since 3 October 2026, ledger F3) a key is credited at an index when any + block in the checkpoint's past carries its vote, as a vote or inside a certificate. Every honest + producer carries every vote it received (the carriage rule), so in this model every vote an + online signing key issues is credited whatever certificate forms. Objective: every node reads + the same blocks. Scenario O runs it against a hostile aggregator (P.hostile). + +Model (all assumptions, repeated in results_v2.md): + * Time step = one 30-s slot. The network mines Poisson(30) blocks per slot, split per key by + hashrate share (perfect difficulty retarget). Every block is blue (GHOSTDAG abstracted). + * Weight window = 720 hourly buckets of blocks per key (30 days), rolled every hour. + * A checkpoint index forms on a side each time that side's blue score passes a multiple of + 30, so a partition side mining a fraction s of the hashrate forms checkpoints at s per slot. + Checkpoint blocks on different sides are different blocks at the same index. + * Regions: a one-way delay matrix, DELAY between regions, INTRA inside one, lognormal jitter + per hop. A vote for checkpoint i issued by a key in region r reaches the aggregator in + region a at t0 + d(src, r) + d(r, a) (+ a per-key extra delay for an eclipsed key). + One aggregator per region on the side; the certificate forms at the first one to reach + quorum; its signer set is every vote that arrived there by max(threshold time, t0 + GRACE). + * Honest keys follow a two-state uptime chain: availability UPTIME (UPTIME_BIG for keys at or + above 1% of hashrate, a pool with redundant infrastructure), mean outage OUTAGE slots. + * A partition splits regions into sides, each with its own view (certificates, participation + ring, blue score). At the heal the views merge: certificates are unioned, two certificates + at one index with different blocks count as a CONFLICTING LOCK, participation rings are + OR-merged by index, and any key that signed on two sides at a common index is stripped. + * Weights are global (a side does not see the other side's blocks for the partition's + duration; at most 150 minutes of a 30-day window, ignored). + +Standard library plus numpy. Deterministic for a given --seed. + +Usage: + python3 finality_v2.py # every scenario, markdown on stdout + python3 finality_v2.py --scenarios A,E --delay 5 + python3 finality_v2.py --quick # shortened runs for development +""" + +import argparse +import os +import sys +import time + +import numpy as np + +SLOT_S = 30.0 +BLOCKS_PER_CP = 30 +SLOTS_PER_HOUR = 120 +SLOTS_PER_DAY = 2_880 +WINDOW_HOURS = 720 +WINDOW_BLOCKS = 86_400 * 30 +N_HONEST = 1_000 +PARETO_SHAPE = 1.0 +GEOGRAPHY = (0.45, 0.35, 0.20) # honest hashrate per region, model assumption +TWO_THIRDS = 2.0 / 3.0 + + +class P: + """Parameters. Keyword overrides.""" + + def __init__(self, **kw): + self.delay = 2.0 # one-way inter-region delay, seconds + self.intra = 0.1 # one-way intra-region delay, seconds + self.jitter = 0.25 # lognormal sigma per hop + self.grace = 15.0 # seconds after t0 during which late votes still enter the certificate + self.uptime = 0.97 # availability of an honest key under 1% of hashrate + self.uptime_big = 0.995 # availability of a key at or above 1% of hashrate (redundant pool infrastructure) + self.big_share = 0.01 + self.outage = 20 # mean outage length, slots (10 minutes) + self.denom = "active" # "active" or "total" + self.pmode = "cert" # "cert", "seen", "frozen" + self.presence = 240 # checkpoints in the participation window + self.dust = 100 # blocks + self.quorum = TWO_THIRDS + self.floor = 0.0 # hybrid: active denominator never below floor x total weight (0 = off; 1.0 = the rule of 4 Oct 2026, a lock needs 2/3 of total) + self.daa = "none" # "none": a partition side mines at its hashrate share; "full": each side retargets to 30 blocks/slot at once + self.local = False # True: a partition side's weight table counts only the blocks it has seen (its own after the split), as a real node's window does + self.frozen = False # True: rule v3 (4 Oct 2026, ledger F21): a lock also needs 2/3 of the weight table FROZEN at the view's last + # certified checkpoint, signers counted at their frozen weights; the frozen table expires one window (30 days) + # after its checkpoint, after which the sliding table alone applies (as today) + self.hostile = None # ledger F3 / O-3.3 (scenario O): (target key mask, from slot, to slot). Between those slots a + # chosen aggregator drops the target's votes from every certificate it builds, and its + # certificate is the one carried whenever the other votes reach quorum at all (the strongest + # form: the block producer carries the certificate it likes, F3's premise) + # ---- Horizon lane 3 (6 Oct 2026), candidate rules modelled against tonight's pause (docs/analysis/horizon/finality-and-weight.md) + self.decay = None # (i) decaying denominator: (T hours, r per hour): a key this VIEW has not seen vote for more than T hours + # counts max(0, 1 - r (silent - T)) of its weight in every denominator (sliding and frozen); signed weight unchanged + self.twotier = False # (ii) two-tier: a PROVISIONAL lock at 2/3 of the active (presence, block reading) denominator with no floor, + # reported beside the final lock and never certifying; final = the rule as configured + self.hyst = None # (iii) floor with hysteresis: (H hours, low floor factor): after the view's signing share of total has sat + # under 2/3 for H hours the floor (sliding and frozen) drops to low x 2/3, back to the full floor after H hours at or above 2/3 + self.leave = None # (iv) departure announcement: delay in hours after a key's signed leave (Sim.leave_slot) before it is in no + # denominator and can no longer vote; a partition side never sees the other side leave, so nothing changes there + self.wform = "hour" # the W2 form (ledger F14, scenario N): "hour" = the hourly ring above (a perfect retarget makes + # it the DAA window); "daa" = blocks over the trailing 2,592,000 blocks whatever the wall clock; + # "median" = a key's share of each 60-s wall-clock bucket summed over the trailing 30 days, x60 + self.__dict__.update(kw) + + def label(self): + if self.denom == "total": + return "total" + s = "active/" + self.pmode + if self.floor > 0: + s += "+floor%.2f" % self.floor + if self.daa != "none": + s += "+daa" + if self.local: + s += "+local" + if self.frozen: + s += "+frozen" + if self.hostile is not None: + s += "+hostile" + if self.decay is not None: + s += "+decay(T%gh,r%g/h)" % self.decay + if self.twotier: + s += "+twotier" + if self.hyst is not None: + s += "+hyst(%gh,%.2f)" % self.hyst + if self.leave is not None: + s += "+leave%gh" % self.leave + return s + + +class View: + """One side's view: participation ring, checkpoint counter, certificates.""" + + def __init__(self, sid, regions, n, presence, next_idx): + self.sid = sid + self.regions = list(regions) + self.ring = np.zeros((presence, n), dtype=np.int8) + self.ring_idx = np.full(presence, -1, dtype=np.int64) + self.pcount = np.zeros(n, dtype=np.int32) + self.next_idx = int(next_idx) + self.blue = 0.0 + self.certs = {} # idx -> (sid, slot, latency_s) + self.stalls = [] # (idx, slot) + self.key_mask = None # keys whose region is on this side + self.mine_mask = None + self.excl = np.zeros(n) # blocks mined off this side since the split, unseen by it (only used with P.local) + self.frozen_wt = None # rule v3: the weight table at this view's last certified checkpoint (None before the first) + self.frozen_slot = -1 + # Horizon lane 3 bookkeeping + self.last_seen = np.full(n, -1, dtype=np.int64) # slot at which this view last received a vote from each key (-1 = never) + self.born_slot = 0 + self.hyst_low = False + self.hyst_lo_cnt = 0 + self.hyst_hi_cnt = 0 + self.pring = np.zeros((presence, n), dtype=np.int8) # provisional tier: participation ring under the block reading + self.pring_idx = np.full(presence, -1, dtype=np.int64) + self.ppcount = np.zeros(n, dtype=np.int32) + self.prov_certs = {} # idx -> sid (provisional locks, two-tier) + + def clone_ring_from(self, other): + self.last_seen[:] = other.last_seen + self.born_slot = other.born_slot + self.hyst_low, self.hyst_lo_cnt, self.hyst_hi_cnt = other.hyst_low, other.hyst_lo_cnt, other.hyst_hi_cnt + self.pring[:] = other.pring + self.pring_idx[:] = other.pring_idx + self.ppcount[:] = other.ppcount + self.ring[:] = other.ring + self.ring_idx[:] = other.ring_idx + self.pcount[:] = other.pcount + self.frozen_wt = None if other.frozen_wt is None else other.frozen_wt.copy() + self.frozen_slot = other.frozen_slot + + +class Sim: + def __init__(self, p, rng, n_regions=3): + self.p = p + self.rng = rng + self.R = int(n_regions) + self.D = np.full((self.R, self.R), p.delay) + np.fill_diagonal(self.D, p.intra) + self.N = 0 + self.hash = np.zeros(0) + self.region = np.zeros(0, dtype=np.int64) + self.online = np.zeros(0, dtype=bool) + self.flaky = np.zeros(0, dtype=bool) + self.signs = np.zeros(0, dtype=bool) + self.equiv = np.zeros(0, dtype=bool) + self.stripped = np.zeros(0, dtype=bool) + self.extra_delay = np.zeros(0) + self.first_idx = np.zeros(0, dtype=np.int64) + self.buckets = np.zeros((WINDOW_HOURS, 0)) + self.weight = np.zeros(0) + self.slot = 0 + self.views = [] + self.next_sid = 1 + self.records = [] # (slot, idx, sid, latency_s or -1, signed/denom, denom/total) + self.conflicts = [] # (idx, slot the second certificate existed) + self.q_on = 1.0 / p.outage + self.q_off = np.zeros(0) + self.leave_slot = np.full(0, -1, dtype=np.int64) # Horizon (iv): slot of each key's signed leave, -1 = none + self.pconflicts = [] # Horizon (ii): conflicting PROVISIONAL locks at the heal + # scenario N (ledger M14/F14): a block supply from a difficulty trace, fn(slot) -> blocks per key, in place of + # the Poisson(30) draw; and the ledgers of the two W2 forms (10-minute buckets of 20 slots) + self.supply = None + self.wq = [] # daa form: [(bucket vector, bucket total)] oldest first; median form: [group vector] + self.wq_head = 0 + self.wq_total = 0.0 # daa form: blocks in the queued buckets + self.wcur = None # the bucket or group being filled + self.wpair = None # median form: the 60-s pair being filled + + # ------------------------------------------------------------- keys + def add_keys(self, hashes, regions, flaky=True, equiv=False, signs=True): + hashes = np.asarray(hashes, dtype=float) + regions = np.asarray(regions, dtype=np.int64) + n = hashes.size + self.hash = np.concatenate([self.hash, hashes]) + self.region = np.concatenate([self.region, regions]) + self.online = np.concatenate([self.online, np.ones(n, dtype=bool)]) + self.flaky = np.concatenate([self.flaky, np.full(n, flaky, dtype=bool)]) + self.signs = np.concatenate([self.signs, np.full(n, signs, dtype=bool)]) + self.equiv = np.concatenate([self.equiv, np.full(n, equiv, dtype=bool)]) + self.stripped = np.concatenate([self.stripped, np.zeros(n, dtype=bool)]) + self.extra_delay = np.concatenate([self.extra_delay, np.zeros(n)]) + self.first_idx = np.concatenate([self.first_idx, np.full(n, -1, dtype=np.int64)]) + self.leave_slot = np.concatenate([self.leave_slot, np.full(n, -1, dtype=np.int64)]) + self.buckets = np.concatenate([self.buckets, np.zeros((WINDOW_HOURS, n))], axis=1) + self.weight = np.concatenate([self.weight, np.zeros(n)]) + first = self.N + self.N += n + self.q_off = np.concatenate([self.q_off, np.zeros(n)]) + self.set_uptime() + return np.arange(first, self.N) + + def set_uptime(self): + """Per-key off-switch probability per slot from the size-dependent availability. Call after hashrate changes.""" + tot = self.hash.sum() + share = self.hash / tot if tot > 0 else np.zeros(self.N) + u = np.where(share >= self.p.big_share, self.p.uptime_big, self.p.uptime) + self.q_off = self.q_on * (1.0 - u) / u + + def warm_start(self): + """Fill the 30-day window as if the mining keys had been mining at their share for 30 days.""" + share = self.hash / self.hash.sum() + lam = 3_600.0 * share + for h in range(WINDOW_HOURS): + self.buckets[h] = self.rng.poisson(lam) + self.weight = self.buckets.sum(axis=0) + if self.p.wform == "daa": + # 4,320 ten-minute buckets of Poisson(600 x share): a full window of 2,592,000 blocks at 1 block/s + self.wq = [] + for _ in range(WINDOW_BLOCKS // 600): + v = self.rng.poisson(600.0 * share).astype(float) + self.wq.append((v, float(v.sum()))) + self.wq_head = 0 + self.wq_total = sum(t for _, t in self.wq) + self.weight = np.sum([v for v, _ in self.wq], axis=0) + self.wcur = np.zeros(self.N) + elif self.p.wform == "median": + # 4,320 ten-minute groups, each the sum of ten 60-s bucket shares x 60: a steady share s weighs 2,592,000 s + self.wq = [] + for _ in range(WINDOW_BLOCKS // 600): + v = self.rng.poisson(600.0 * share).astype(float) + tot = v.sum() + self.wq.append(600.0 * v / tot if tot > 0 else np.zeros(self.N)) + self.wq_head = 0 + self.weight = np.sum(self.wq, axis=0) + self.wcur = np.zeros(self.N) + self.wpair = np.zeros(self.N) + self.first_idx[self.hash > 0] = -10 ** 9 + self.slot = 0 + + def init_views(self, warm): + v = View(0, range(self.R), self.N, self.p.presence, next_idx=(WINDOW_BLOCKS // BLOCKS_PER_CP if warm else 0)) + self._set_masks(v) + if warm: + elig = (self.weight >= self.p.dust) & self.signs + v.ring[:] = elig.astype(np.int8)[None, :] + v.ring_idx[:] = np.arange(v.next_idx - self.p.presence, v.next_idx) + v.pcount[:] = v.ring.sum(axis=0) + v.pring[:] = v.ring + v.pring_idx[:] = v.ring_idx + v.ppcount[:] = v.pcount + v.last_seen[elig] = self.slot + v.born_slot = self.slot + self.views = [v] + self.next_sid = 1 + + def _set_masks(self, v): + v.key_mask = np.isin(self.region, v.regions) + v.mine_mask = v.key_mask.copy() + + # ------------------------------------------------------------- partitions + def split(self, groups): + base = self.views[0] + new = [] + for g in groups: + v = View(self.next_sid, g, self.N, self.p.presence, next_idx=base.next_idx) + self.next_sid += 1 + v.clone_ring_from(base) + v.born_slot = self.slot + self._set_masks(v) + new.append(v) + self.split_idx = base.next_idx + self.views = new + + def heal(self): + views = self.views + P_ = self.p.presence + nxt = max(v.next_idx for v in views) + m = View(self.next_sid, range(self.R), self.N, P_, next_idx=nxt) + self.next_sid += 1 + self._set_masks(m) + for i in range(max(0, nxt - P_), nxt): + row = i % P_ + acc = np.zeros(self.N, dtype=np.int8) + hit = False + for v in views: + if v.ring_idx[row] == i: + acc |= v.ring[row] + hit = True + if hit: + m.ring[row] = acc + m.ring_idx[row] = i + m.pcount[:] = m.ring.sum(axis=0) + # Horizon: the provisional ring merges like the final one; last_seen is the latest any side saw; hysteresis stays low only if every side was low + for i in range(max(0, nxt - P_), nxt): + row = i % P_ + acc = np.zeros(self.N, dtype=np.int8) + hit = False + for v in views: + if v.pring_idx[row] == i: + acc |= v.pring[row] + hit = True + if hit: + m.pring[row] = acc + m.pring_idx[row] = i + m.ppcount[:] = m.pring.sum(axis=0) + m.last_seen[:] = np.max(np.stack([v.last_seen for v in views]), axis=0) + m.born_slot = min(v.born_slot for v in views) + m.hyst_low = all(v.hyst_low for v in views) + for v in views: + for idx, rec in v.prov_certs.items(): + if idx in m.prov_certs and m.prov_certs[idx][0] != rec[0]: + self.pconflicts.append(idx) + else: + m.prov_certs.setdefault(idx, rec) + newest = max(views, key=lambda v: v.frozen_slot) + m.frozen_wt = None if newest.frozen_wt is None else newest.frozen_wt.copy() + m.frozen_slot = newest.frozen_slot + # certificates and conflicts + for v in views: + for idx, (sid, slot, lat) in v.certs.items(): + if idx in m.certs and m.certs[idx][0] != sid: + self.conflicts.append((idx, max(slot, m.certs[idx][1]))) + else: + m.certs.setdefault(idx, (sid, slot, lat)) + m.stalls.extend(v.stalls) + # equivocation evidence: an equivocating key signed every side's checkpoint at every common index + common = min(v.next_idx for v in views) > self.split_idx + if common and self.equiv.any(): + self.stripped |= self.equiv + self.strip_slot = self.slot + self.views = [m] + + # ------------------------------------------------------------- stepping + def run(self, n_slots, snap=None): + """snap = (every_n_slots, fn(sim)) called before the step on matching slots.""" + for _ in range(int(n_slots)): + if snap is not None and self.slot % snap[0] == 0: + snap[1](self) + self.step() + + def step(self): + p = self.p + slot = self.slot + tot = self.hash.sum() + if self.supply is not None: + blocks = self.supply(slot) + elif p.daa == "full" and len(self.views) > 1: + blocks = np.zeros(self.N) + for v in self.views: + side_tot = self.hash[v.mine_mask].sum() + if side_tot > 0: + blocks[v.mine_mask] = self.rng.poisson(BLOCKS_PER_CP * self.hash[v.mine_mask] / side_tot) + else: + blocks = self.rng.poisson(BLOCKS_PER_CP * self.hash / tot) if tot > 0 else np.zeros(self.N) + if p.wform == "daa": + # the window is a block count: the newest blocks enter at once, the oldest 10-minute buckets leave once the + # queued blocks exceed the window (the edge moves by whole buckets, at most 600 base blocks plus a burst) + self.weight += blocks + self.wcur += blocks + if slot % 20 == 19: + t = float(self.wcur.sum()) + self.wq.append((self.wcur, t)) + self.wq_total += t + self.wcur = np.zeros(self.N) + while self.wq_head < len(self.wq) - 1 and self.wq_total - self.wq[self.wq_head][1] >= WINDOW_BLOCKS: + v, t = self.wq[self.wq_head] + self.weight -= v + self.wq_total -= t + self.wq[self.wq_head] = None + self.wq_head += 1 + elif p.wform == "median": + # a 60-s bucket is two slots: each key's share of it (x60) enters the weight; a 10-minute group of ten + # buckets leaves 30 days later + self.wpair += blocks + if slot % 2 == 1: + t = float(self.wpair.sum()) + sh = 60.0 * self.wpair / t if t > 0 else np.zeros(self.N) + self.weight += sh + self.wcur += sh + self.wpair = np.zeros(self.N) + if slot % 20 == 19: + self.wq.append(self.wcur) + self.wcur = np.zeros(self.N) + while len(self.wq) - self.wq_head > WINDOW_BLOCKS // 600: + self.weight -= self.wq[self.wq_head] + self.wq[self.wq_head] = None + self.wq_head += 1 + else: + hp = (slot // SLOTS_PER_HOUR) % WINDOW_HOURS + if slot % SLOTS_PER_HOUR == 0: + self.weight -= self.buckets[hp] + self.buckets[hp] = 0.0 + self.buckets[hp] += blocks + self.weight += blocks + if p.local and len(self.views) > 1: + # a side's window holds only the blocks it has seen: the other sides' post-split blocks are unseen + for v in self.views: + v.excl += blocks * ~v.mine_mask + gidx = max(v.next_idx for v in self.views) + newly = (blocks > 0) & (self.first_idx == -1) + if newly.any(): + self.first_idx[newly] = gidx + r = self.rng.random(self.N) + flip = np.where(self.online, r < self.q_off, r < self.q_on) & self.flaky + self.online ^= flip + for v in self.views: + v.blue += blocks[v.mine_mask].sum() + while v.blue >= BLOCKS_PER_CP: + v.blue -= BLOCKS_PER_CP + self.checkpoint(v, blocks) + self.slot += 1 + + def participation(self, v, idx): + part = np.minimum(1.0, v.pcount / float(self.p.presence)) + new = (self.first_idx > idx - self.p.presence) & (self.first_idx >= 0) + part[new] = 1.0 + return part + + def checkpoint(self, v, blocks): + p = self.p + idx = v.next_idx + v.next_idx += 1 + t0 = self.slot * SLOT_S + # miner of the checkpoint block: a key on this side weighted by its blocks this slot + bm = blocks * v.mine_mask + cum = np.cumsum(bm) + if cum[-1] > 0: + j = int(np.searchsorted(cum, self.rng.random() * cum[-1], side="right")) + src = int(self.region[min(j, self.N - 1)]) + else: + src = v.regions[0] + jit1 = np.exp(self.rng.normal(0.0, p.jitter, self.R)) + jit2 = np.exp(self.rng.normal(0.0, p.jitter, (self.R, self.R))) + # the weight table this side computes: global, or (P.local) less the blocks it has not seen since the split + wt = np.maximum(self.weight - v.excl, 0.0) if p.local else self.weight + elig = (wt >= p.dust) & ~self.stripped + # Horizon (iv): a key whose signed leave is `leave` hours old is in no denominator and cannot vote + left = np.zeros(self.N, dtype=bool) + if p.leave is not None: + left = (self.leave_slot >= 0) & (self.slot >= self.leave_slot + int(round(p.leave * SLOTS_PER_HOUR))) + elig &= ~left + voters = elig & self.online & self.signs & (v.key_mask | self.equiv) + # Horizon (i): the decaying denominator, from THIS view's own observation of who has voted + df = np.ones(self.N) + if p.decay is not None: + T_h, r_h = p.decay + seen = np.where(v.last_seen >= 0, v.last_seen, v.born_slot) + silent_h = (self.slot - seen) / float(SLOTS_PER_HOUR) + df = np.clip(1.0 - r_h * np.maximum(0.0, silent_h - T_h), 0.0, 1.0) + wd = wt * df + if p.denom == "active": + denom = float((wd * self.participation(v, idx))[elig].sum()) + else: + denom = float(wd[elig].sum()) + total = float(wd[elig].sum()) + total_plain = float(wt[elig].sum()) + # Horizon (iii): the floor with hysteresis + floor_eff = p.floor + if p.hyst is not None and v.hyst_low: + floor_eff = p.hyst[1] + if p.denom == "active" and floor_eff > 0: + denom = max(denom, floor_eff * total) + need = p.quorum * denom + vi = np.flatnonzero(voters) + signed_w = float(wt[vi].sum()) + ratio = signed_w / denom if denom > 0 else 0.0 + # rule v3 (frozen): signers also need 2/3 of the table frozen at the view's last certified checkpoint, counted at + # their frozen weights, while that checkpoint is less than one window old + wf, need_f = None, 0.0 + if p.frozen and v.frozen_wt is not None and (self.slot - v.frozen_slot) < WINDOW_HOURS * SLOTS_PER_HOUR: + felig = (v.frozen_wt >= p.dust) & ~self.stripped & ~left + total_f = float((v.frozen_wt * df)[felig].sum()) + if total_f > 0: + wf = np.where(felig, v.frozen_wt, 0.0)[vi] + need_f = p.quorum * total_f * ((floor_eff / p.floor) if (p.hyst is not None and p.floor > 0) else 1.0) + # Horizon (ii): the provisional tier, 2/3 of the active denominator under the block reading, no floor, never certifies + if p.twotier: + part_p = np.minimum(1.0, v.ppcount / float(p.presence)) + newk = (self.first_idx > idx - p.presence) & (self.first_idx >= 0) + part_p[newk] = 1.0 + denom_p = float((wt * part_p)[elig].sum()) + if denom_p > 0 and signed_w >= p.quorum * denom_p: + v.prov_certs[idx] = (v.sid, self.slot) + prow = idx % p.presence + v.ppcount -= v.pring[prow] + v.pring[prow] = voters.astype(np.int8) + v.ppcount += v.pring[prow] + v.pring_idx[prow] = idx + # Horizon bookkeeping: what this view saw, and the hysteresis counters (signing share of the undecayed total) + if vi.size: + v.last_seen[vi] = self.slot + if p.hyst is not None: + share = signed_w / total_plain if total_plain > 0 else 0.0 + if share < p.quorum: + v.hyst_hi_cnt = 0 + v.hyst_lo_cnt += 1 + else: + v.hyst_lo_cnt = 0 + v.hyst_hi_cnt += 1 + H = int(round(p.hyst[0] * SLOTS_PER_HOUR)) + if not v.hyst_low and v.hyst_lo_cnt >= H: + v.hyst_low = True + elif v.hyst_low and v.hyst_hi_cnt >= H: + v.hyst_low = False + def form(sel): + # the certificate an aggregator builds from the votes in `sel` (a mask over vi): the first region to reach + # quorum, or None when those votes never do + if denom <= 0 or not sel.any(): + return None + w = wt[vi][sel] + reg = self.region[vi][sel] + eq = self.equiv[vi][sel] + hop1 = np.where(eq, p.intra, self.D[src, reg] * jit1[reg]) + issue = t0 + hop1 + self.extra_delay[vi][sel] + found = None + for a in v.regions: + hop2 = np.where(eq, p.intra, self.D[reg, a] * jit2[reg, a]) + arr = issue + hop2 + order = np.argsort(arr, kind="stable") + cw = np.cumsum(w[order]) + k = int(np.searchsorted(cw, need)) + if wf is not None: + k = max(k, int(np.searchsorted(np.cumsum(wf[sel][order]), need_f))) + if k < cw.size: + thr = float(arr[order[k]]) + if found is None or thr < found[0]: + found = (thr, arr, sel) + return found + best = None + if p.hostile is not None and p.hostile[1] <= self.slot < p.hostile[2] and vi.size > 0: + # the hostile aggregator drops the target's votes; its certificate is carried whenever the rest reach quorum + best = form(~p.hostile[0][vi]) + if best is None and vi.size > 0: + best = form(np.ones(vi.size, dtype=bool)) + if best is not None: + thr, arr, sel = best + lat = thr - t0 + seal = max(thr, t0 + p.grace) + mask = np.zeros(self.N, dtype=np.int8) + mask[vi[sel][arr <= seal]] = 1 + v.certs[idx] = (v.sid, self.slot, lat) + if p.frozen: + v.frozen_wt = wt.copy() + v.frozen_slot = self.slot + # cert reading: the certificate's signers are credited; block reading (Q2): every vote issued is carried by + # some block in the checkpoint's past, whatever the aggregator kept + self._push(v, idx, voters.astype(np.int8) if p.pmode == "block" else mask) + self.records.append((self.slot, idx, v.sid, lat, ratio, denom / total if total > 0 else 0.0)) + else: + v.stalls.append((idx, self.slot)) + if p.pmode == "cert": + self._push(v, idx, np.zeros(self.N, dtype=np.int8)) + elif p.pmode in ("seen", "block"): + self._push(v, idx, voters.astype(np.int8)) + # frozen: no ring update + self.records.append((self.slot, idx, v.sid, -1.0, ratio, denom / total if total > 0 else 0.0)) + + def _push(self, v, idx, mask): + row = idx % self.p.presence + v.pcount -= v.ring[row] + v.ring[row] = mask + v.pcount += mask + v.ring_idx[row] = idx + + # ------------------------------------------------------------- queries + def recs(self): + return np.array(self.records, dtype=float).reshape(-1, 6) + + def elig_mask(self): + return (self.weight >= self.p.dust) & ~self.stripped + + def share(self, keys): + e = self.elig_mask() + tot = self.weight[e].sum() + if tot <= 0: + return 0.0 + m = np.zeros(self.N, dtype=bool) + m[keys] = True + return float(self.weight[m & e].sum() / tot) + + +# ---------------------------------------------------------------- helpers + +def pareto_hashrates(rng, n, total=1.0): + h = rng.pareto(PARETO_SHAPE, n) + 1.0 + return h * (total / h.sum()) + + +def assign_regions(hashes, targets): + """Largest key first, each to the region with the largest remaining hashrate deficit.""" + targets = np.asarray(targets, dtype=float) + tot = hashes.sum() + filled = np.zeros(targets.size) + reg = np.zeros(hashes.size, dtype=np.int64) + for i in np.argsort(-hashes): + r = int(np.argmax(targets * tot - filled)) + reg[i] = r + filled[r] += hashes[i] + return reg + + +def pick_weight_subset(rng, weights, frac): + """Random keys whose weight sums to about frac of total, never over.""" + target = frac * weights.sum() + mask = np.zeros(weights.size, dtype=bool) + acc = 0.0 + for i in rng.permutation(weights.size): + if acc + weights[i] <= target: + mask[i] = True + acc += weights[i] + return mask, acc / weights.sum() + + +def gini(x): + x = np.sort(np.asarray(x, dtype=float)) + n = x.size + if n == 0 or x.sum() == 0: + return 0.0 + cum = np.cumsum(x) + return (n + 1 - 2.0 * cum.sum() / cum[-1]) / n + + +def pct(x, d=1): + return ("%%.%df%%%%" % d) % (100.0 * x) + + +def md_table(headers, rows): + out = ["| " + " | ".join(str(h) for h in headers) + " |", "|" + "---|" * len(headers)] + for r in rows: + out.append("| " + " | ".join(str(c) for c in r) + " |") + return "\n".join(out) + + +def lat_stats(recs, s_from=0, s_to=None): + if s_to is None: + s_to = np.inf + sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to)] + lat = sel[:, 3] + ok = lat >= 0 + n = int(sel.shape[0]) + st = int((~ok).sum()) + cps = np.floor(lat[ok] / SLOT_S) + d = dict(n=n, stalls=st, c0=int((cps == 0).sum()), c1=int((cps == 1).sum()), c2=int((cps >= 2).sum())) + if ok.any(): + d.update(med=float(np.median(lat[ok])), p99=float(np.percentile(lat[ok], 99)), mx=float(lat[ok].max()), + ratio_min=float(sel[ok, 4].min()), ratio_med=float(np.median(sel[ok, 4]))) + else: + d.update(med=float("nan"), p99=float("nan"), mx=float("nan"), ratio_min=float("nan"), ratio_med=float("nan")) + return d + + +def lat_row(label, d): + return [label, d["n"], d["c0"], d["c1"], d["c2"], d["stalls"], "%.1f" % d["med"], "%.1f" % d["p99"], "%.1f" % d["mx"], + "%.3f" % d["ratio_min"]] + + +LAT_HEADERS = ["run", "checkpoints", "locked in slot 0", "slot 1", "slot 2+", "stalled", "median s", "p99 s", "max s", + "min signed/denominator"] + + +def first_lock_after(recs, slot, sid=None): + sel = recs[(recs[:, 0] >= slot) & (recs[:, 3] >= 0)] + if sid is not None: + sel = sel[sel[:, 2] == sid] + if sel.shape[0] == 0: + return None + return int(sel[0, 0]) + + +def stalls_between(recs, s_from, s_to, sid=None): + sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] < 0)] + if sid is not None: + sel = sel[sel[:, 2] == sid] + return int(sel.shape[0]) + + +def fmt_min(slots): + if slots is None: + return "never" + m = slots * SLOT_S / 60.0 + if m < 120: + return "%.0f min" % m + if m < 48 * 60: + return "%.1f h" % (m / 60.0) + return "%.1f d" % (m / 1440.0) + + +def fmt_days(slots): + return "never" if slots is None else "%.1f" % (slots / SLOTS_PER_DAY) + + +def build_honest(p, seed, n_regions=3, geography=GEOGRAPHY, big_pool=None): + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=n_regions) + if big_pool is None: + h = pareto_hashrates(rng, N_HONEST) + reg = assign_regions(h, geography) + sim.add_keys(h, reg, flaky=True) + pool = None + else: + h = pareto_hashrates(rng, N_HONEST - 1, total=1.0 - big_pool) + reg = assign_regions(h, geography) + sim.add_keys(h, reg, flaky=True) + pool = int(sim.add_keys([big_pool], [3], flaky=False)[0]) + return sim, rng, pool + + +# ---------------------------------------------------------------- scenarios + +def scenario_a(args): + out = ["### A. Steady state from zero history, 1,000 honest keys, 3 regions %s, %d days" % ( + "/".join(pct(g, 0) for g in GEOGRAPHY), args.days_a), ""] + days = args.days_a + snaps = {} + lat_rows = [] + prop_rows = [] + for denom in ("active", "total"): + p = P(denom=denom, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + sim.init_views(warm=False) + series = [] + + def snap(s, series=series): + e = s.elig_mask() + series.append((s.slot // SLOTS_PER_DAY, s.weight.sum() / WINDOW_BLOCKS, int((~e).sum()))) + + sim.run(days * SLOTS_PER_DAY, snap=(SLOTS_PER_DAY, snap)) + snap(sim) + recs = sim.recs() + snaps[denom] = series + lat_rows.append(lat_row("%s, days 0 to 1" % denom, lat_stats(recs, 0, SLOTS_PER_DAY))) + lat_rows.append(lat_row("%s, days 1 to 30" % denom, lat_stats(recs, SLOTS_PER_DAY, 30 * SLOTS_PER_DAY))) + lat_rows.append(lat_row("%s, days 30 to %d" % (denom, days), lat_stats(recs, 30 * SLOTS_PER_DAY, None))) + w = sim.weight + ws = w / w.sum() + hs = sim.hash / sim.hash.sum() + order = np.argsort(-hs) + rel = ws / hs + prop_rows.append([denom, "%.5f" % np.corrcoef(hs, ws)[0, 1], "%.3f / %.3f" % (gini(hs), gini(ws)), + pct(hs[order[0]]) + " / " + pct(ws[order[0]]), + pct(hs[order[:10]].sum()) + " / " + pct(ws[order[:10]].sum()), + pct(hs[order[500:]].sum()) + " / " + pct(ws[order[500:]].sum()), + "%.3f / %.3f" % (rel.min(), rel.max()), int((rel < 0.9).sum()), int((~sim.elig_mask()).sum())]) + # genesis stall run + first = first_lock_after(recs, 0) + snaps[denom + "_first"] = first + s = snaps["active"] + ramp = [] + for d in (1, 2, 5, 10, 20, 30, 31, 45, days): + row = [x for x in s if x[0] == d] + if row: + ramp.append([d, pct(row[0][1]), row[0][2]]) + out.append("Weight ramp (active run; the total run mines the same blocks):") + out.append("") + out.append(md_table(["day", "total weight / full window", "keys under dust (100 blocks)"], ramp)) + out.append("") + out.append("Weight against hashrate at day %d:" % days) + out.append("") + out.append(md_table(["denominator", "corr(hash, weight)", "Gini hash / weight", "top-1 hash / weight", + "top-10 hash / weight", "bottom-500 hash / weight", "min / max weight:hash", "keys under 0.9x", + "dust keys"], prop_rows)) + out.append("") + out.append("Lock latency (seconds from checkpoint block to quorum; slot = 30 s), inter-region delay %.1f s:" % args.delay) + out.append("") + out.append(md_table(LAT_HEADERS, lat_rows)) + out.append("") + out.append("First lock from genesis: active at slot %s, total at slot %s (the first checkpoints have no key above dust)." % ( + snaps["active_first"], snaps["total_first"])) + out.append("") + # delay comparison, short warm-started runs + rows = [] + for delay in (0.5, 2.0, 5.0): + for grace in (args.grace,): + p = P(denom="active", delay=delay, grace=grace) + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(args.days_delay * SLOTS_PER_DAY) + recs = sim.recs() + d = lat_stats(recs) + # participation of the slowest region + v = sim.views[0] + part = sim.participation(v, v.next_idx) + by_region = ["%.3f" % np.average(part[sim.region == r], weights=sim.weight[sim.region == r]) for r in range(3)] + rows.append(lat_row("delay %.1f s, grace %.0f s, %d days warm" % (delay, grace, args.days_delay), d) + ["/".join(by_region)]) + out.append("Delay sweep, warm-started (steady-state weights), active denominator. Last column: weight-averaged participation per region.") + out.append("") + out.append(md_table(LAT_HEADERS + ["participation r0/r1/r2"], rows)) + return "\n".join(out) + + +def scenario_b(args): + out = ["### B. Rental burst at day 60, one public key with a x honest hashrate, signs every checkpoint", ""] + mults = (1, 2, 4, 9) + series = {} + cross = {} + for a in mults: + p = P(denom="active", delay=args.delay) + sim, rng, _ = build_honest(p, args.seed, n_regions=4) + att = int(sim.add_keys([0.0], [3], flaky=False)[0]) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) # one hour of baseline + t_event = sim.slot + sim.hash[att] = float(a) + s = [] + c13 = c23 = None + for day in range(1, args.days_b + 1): + for _ in range(SLOTS_PER_DAY // 24): + sim.run(24) + sh = sim.share([att]) + if c13 is None and sh >= 1.0 / 3.0: + c13 = sim.slot - t_event + if c23 is None and sh >= TWO_THIRDS: + c23 = sim.slot - t_event + s.append((day, sim.share([att]))) + recs = sim.recs() + series[a] = s + cross[a] = (c13, c23, lat_stats(recs, t_event, None)) + pick = [d for d in (1, 5, 10, 15, 20, 25, 30, 35) if d <= args.days_b] + rows = [] + for d in pick: + row = ["+%d" % d] + for a in mults: + sim_v = dict(series[a])[d] + formula = min(d / 30.0, 1.0) * a / (1.0 + a) + row.append("%s / %s" % (pct(sim_v), pct(formula))) + rows.append(row) + out.append("Attacker weight share, simulated / formula (t/30) x a/(1+a):") + out.append("") + out.append(md_table(["day after burst"] + ["a=%d (%s of hashrate)" % (a, pct(a / (1.0 + a), 0)) for a in mults], rows)) + out.append("") + ev = [ + ["crosses 1/3 (honest alone can no longer lock), sim day"] + [fmt_days(cross[a][0]) for a in mults], + ["crosses 1/3, formula 10(1+a)/a"] + ["%.1f" % (10.0 * (1 + a) / a) for a in mults], + ["crosses 2/3 (locks alone), sim day"] + [fmt_days(cross[a][1]) for a in mults], + ["crosses 2/3, formula 20(1+a)/a"] + ["%.1f" % (20.0 * (1 + a) / a) if 20.0 * (1 + a) / a <= 30 else "never (ceiling %s)" % pct(a / (1 + a), 0) for a in mults], + ["max abs deviation sim vs formula, days 1 to 30, points"] + [ + "%.2f" % (100 * max(abs(v - min(d / 30.0, 1.0) * a / (1.0 + a)) for d, v in series[a] if d <= 30)) for a in mults], + ["stalled checkpoints after the burst"] + [cross[a][2]["stalls"] for a in mults], + ] + out.append(md_table(["event"] + ["a=%d" % a for a in mults], ev)) + return "\n".join(out) + + +def scenario_c(args): + out = ["### C. Silent set: a random set holding x of weight stops signing at day 60 (hour 3 of the run) and keeps mining", ""] + fracs = (0.34, 0.40, 0.45, 0.50, 0.55) + configs = [("active", "cert", args.hours_c, 0.0, 240), ("active", "seen", args.hours_c, 0.0, 240), + ("active", "frozen", args.hours_c, 0.0, 240), ("active", "cert", args.hours_c_total, 0.0, 2880), + ("active", "cert", args.hours_c_total, 0.8, 240), ("active", "cert", args.hours_c_total, 0.85, 240), + ("active", "cert", args.hours_c_total, 1.0, 240), ("total", "cert", args.hours_c_total, 0.0, 240)] + labels = [] + for denom, pmode, hours, floor, presence in configs: + lab = P(denom=denom, pmode=pmode, floor=floor, presence=presence).label() + if presence != 240: + lab += ", presence %d" % presence + labels.append(lab) + rows = [] + detail = [] + for frac in fracs: + row = [pct(frac, 0)] + for denom, pmode, hours, floor, presence in configs: + p = P(denom=denom, pmode=pmode, delay=args.delay, floor=floor, presence=presence) + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(3 * SLOTS_PER_HOUR) + silent, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[silent] = False + t_event = sim.slot + v0 = sim.views[0] + e0 = sim.elig_mask() + s_on = float(sim.weight[e0 & sim.online & ~silent].sum() / sim.weight[e0].sum()) + p0 = float((sim.weight * sim.participation(v0, v0.next_idx))[e0].sum() / sim.weight[e0].sum()) + predicted = max(0, int(round(p.presence * (p0 - 1.5 * s_on)))) + sim.run(int(hours * SLOTS_PER_HOUR)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + v = sim.views[0] + part = sim.participation(v, v.next_idx) + sil_part = float(np.average(part[silent], weights=sim.weight[silent])) + # stalls after the first lock (does it stay locked?) + later = stalls_between(recs, fl, sim.slot) if fl is not None else st + fl_txt = "never (%s)" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event) + row.append("%s, %d stalled" % (fl_txt, st)) + if denom == "active" and pmode == "cert" and floor == 0 and presence == 240: + tail = recs[(recs[:, 0] >= sim.slot - SLOTS_PER_HOUR) & (recs[:, 3] >= 0)] + detail.append([pct(frac, 0), pct(got), int(silent.sum()), pct(s_on), "%.3f" % p0, predicted, fl_txt, st, later, "%.3f" % sil_part, + "%.3f" % (np.median(tail[:, 4]) if tail.shape[0] else float("nan")), + "%.3f" % (np.median(tail[:, 5]) if tail.shape[0] else float("nan"))]) + rows.append(row) + out.append("Time from the event to the first lock, and checkpoints stalled in the run (%d h for the first three columns, %d h for the rest):" % ( + args.hours_c, args.hours_c_total)) + out.append("") + out.append(md_table(["silent weight"] + labels, rows)) + out.append("") + out.append("Active/cert detail. Predicted stalls = presence x (p0 - 1.5 x online signing share), where p0 is the weight-averaged participation at the event:") + out.append("") + out.append(md_table(["silent weight", "picked", "keys", "online signing share at event", "p0", "predicted stalls", "first lock", "stalled", "stalled after first lock", + "silent participation at end", "signed/denominator at end (median, last hour)", + "active/total at end"], detail)) + return "\n".join(out) + + +def scenario_d(args): + out = ["### D. Churn: a random set holding x of weight stops mining and signing at day 60 (hour 3 of the run)", ""] + fracs = (0.35, 0.50) + rows = [] + dconfigs = [P(denom="active", delay=args.delay), P(denom="active", presence=2880, delay=args.delay), + P(denom="active", floor=0.8, delay=args.delay), P(denom="active", floor=0.85, delay=args.delay), + P(denom="active", floor=1.0, delay=args.delay), P(denom="total", delay=args.delay)] + for frac in fracs: + for p in dconfigs: + days = args.days_d35 if frac < 0.4 else args.days_d50 + denom = p.label() + (", presence 2880" if p.presence == 2880 else "") + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(3 * SLOTS_PER_HOUR) + gone, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[gone] = False + sim.online[gone] = False + sim.flaky[gone] = False + sim.hash[gone] = 0.0 + t_event = sim.slot + live = ~gone + sim.run(int(days * SLOTS_PER_DAY)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + later = stalls_between(recs, fl, sim.slot) if fl is not None else 0 + live_share_at = None + rows.append([pct(frac, 0), pct(got), int(gone.sum()), denom, + "never in %s" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event), + st, later, pct(sim.share(np.flatnonzero(live)))]) + out.append(md_table(["churn weight", "picked", "keys", "denominator", "first lock after the event", "stalled checkpoints", + "stalled after first lock", "live share of total weight at end of run"], rows)) + out.append("") + out.append("Analytic (perfect retarget): live share of total weight on day t = 1 - x(30 - t)/30, so the total " + "denominator recovers at t = 30(1 - 1/(3x)): never for x <= 1/3, day 1.4 at 35%, day 10 at 50%.") + return "\n".join(out) + + +def run_partition(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180): + rng = np.random.default_rng(seed) + nR = max(3, len(fracs) + 1) + sim = Sim(p, rng, n_regions=nR) + h = pareto_hashrates(rng, N_HONEST) + reg = assign_regions(h, fracs) + sim.add_keys(h, reg, flaky=True) + groups = [[i] for i in range(len(fracs))] + att = None + if att_share > 0: + att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0]) + groups[0].append(len(fracs)) + sim.warm_start() + sim.init_views(warm=True) + sim.run(pre_min * 2) + t_split = sim.slot + sim.split(groups) + sides = [v.sid for v in sim.views] + sim.run(dur_min * 2) + t_heal = sim.slot + sim.heal() + sim.run(post_min * 2) + recs = sim.recs() + res = dict(conflicts=len(sim.conflicts), t_split=t_split, t_heal=t_heal) + res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None + res["side_first_lock"] = [] + res["side_locks"] = [] + res["side_stalls"] = [] + for sid in sides: + fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid) + res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0) + sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)] + res["side_locks"].append(int((sel[:, 3] >= 0).sum())) + res["side_stalls"].append(int((sel[:, 3] < 0).sum())) + res["post_stalls"] = stalls_between(recs, t_heal, sim.slot) + fl = first_lock_after(recs, t_heal) + res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0 + res["att_share"] = sim.share([att]) if att is not None else 0.0 + return res + + +def fm(m): + return "never" if m is None else "%.0f" % m + + +def scenario_e(args): + out = ["### E. Partition: honest weight split across sides for a set time, then healed", ""] + configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay), + P(denom="total", delay=args.delay)] + q = getattr(args, "quick", False) + splits = [("50/50", [0.5, 0.5])] if q else [("50/50", [0.5, 0.5]), ("33/33/34", [0.33, 0.33, 0.34])] + rows_conf = [] + rows_first = [] + for name, fr in splits: + for att in ((0.0,) if q else (0.0, 0.34)): + for dur in ((30,) if q else (30, 90, 150)): + rc = [name, pct(att, 0), dur] + rf = [name, pct(att, 0), dur] + for p in configs: + r = run_partition(args.seed, fr, att, dur, p, **({"pre_min": 15, "post_min": 30} if q else {})) + rc.append("%d%s" % (r["conflicts"], "" if r["conflicts"] == 0 else " (first at %s min)" % fm(r["first_conflict_min"]))) + rf.append(" / ".join(fm(x) for x in r["side_first_lock"]) + " ; post-heal stalls %d" % r["post_stalls"]) + rows_conf.append(rc) + rows_first.append(rf) + labels = [p.label() for p in configs] + out.append("Conflicting locks (two certificates at one index, different blocks). Attacker = equivocating key holding the stated share of total weight, honest weight split as stated. Pass needs 0 at 0% attacker for 30, 90 and 150 min.") + out.append("") + out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_conf)) + out.append("") + out.append("Minutes after the split until each side's first lock (side order as in the split; attacker mines on the first side) and stalls in the 3 hours after the heal:") + out.append("") + out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_first)) + out.append("") + # supplementary: more splits, 0% attacker, plus the DAA-retarget and floor variants + configs2 = configs + [P(denom="active", pmode="cert", daa="full", delay=args.delay), + P(denom="active", pmode="seen", daa="full", delay=args.delay), + P(denom="active", pmode="cert", floor=0.8, daa="full", delay=args.delay), + P(denom="active", pmode="cert", floor=0.85, daa="full", delay=args.delay), + P(denom="active", pmode="cert", floor=1.0, daa="full", delay=args.delay)] + labels2 = [p.label() for p in configs2] + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33", [0.67, 0.33]), ("80/20", [0.8, 0.2]), + ("33/33/34", [0.33, 0.33, 0.34])): + for dur in (150, 360): + rc = [name, dur] + for p in configs2: + r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120) + rc.append("%d; %s" % (r["conflicts"], " / ".join(fm(x) for x in r["side_first_lock"]))) + rows.append(rc) + out.append("Supplementary, 0% attacker, 150 and 360 min. Cell = conflicting locks; minutes to each side's first lock. " + "'+daa' = each side retargets to 1 block/s at once (worst case for the presence clock); " + "'+floor0.80' = active denominator never below 80% of total weight (a lock needs at least 53.3% of total), " + "'+floor0.85' needs 56.7%, '+floor1.00' needs 66.7% (the rule of 4 October 2026; arithmetically the total column).") + out.append("") + out.append(md_table(["honest split", "partition min"] + labels2, rows)) + out.append("") + # presence window sweep, active/cert, 0% attacker + rows = [] + for presence in (240, 720, 2880): + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("33/33/34", [0.33, 0.33, 0.34])): + dur = {240: 360, 720: 720, 2880: 1500}[presence] + p = P(denom="active", pmode="cert", presence=presence, delay=args.delay) + r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120) + s = min(fr) + pred = presence * (1.0 - 1.5 * s) / s / 2.0 if s < TWO_THIRDS else None + rows.append([presence, "%.1f h" % (presence / 120.0), name, dur, r["conflicts"], + " / ".join(fm(x) for x in r["side_first_lock"]), "%.0f" % pred if pred is not None else "no"]) + out.append("Presence window sweep, active/cert, 0% attacker. Prediction for the smallest side (share s): " + "first lock after presence x (1 - 1.5 s) / s slots, in minutes = that / 2.") + out.append("") + out.append(md_table(["presence (checkpoints)", "presence (hours)", "honest split", "partition min", "conflicts", + "first lock per side, min", "predicted smallest-side lock, min"], rows)) + return "\n".join(out) + + +def run_eclipse(seed, dur_h, poisoned, p, pre_min=60, post_h=5): + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=5) + # shares of TOTAL weight: pool 20%, attacker 34% when poisoned, the rest honest + others = 0.8 if not poisoned else 0.46 + h = pareto_hashrates(rng, N_HONEST - 1, total=others) + reg = assign_regions(h, GEOGRAPHY) + sim.add_keys(h, reg, flaky=True) + K = int(sim.add_keys([0.2], [3], flaky=False)[0]) + att = None + if poisoned: + att = int(sim.add_keys([0.34], [4], flaky=False, equiv=True)[0]) + sim.warm_start() + sim.init_views(warm=True) + track = [] + + def snap(s): + v = [x for x in s.views if 0 in x.regions][0] + track.append((s.slot, float(min(1.0, v.pcount[K] / p.presence)))) + + sim.run(pre_min * 2, snap=(10, snap)) + t0 = sim.slot + if poisoned: + sim.split([[0, 1, 2], [3, 4]]) + else: + sim.extra_delay[K] = dur_h * 3600.0 + sim.run(int(dur_h * SLOTS_PER_HOUR), snap=(10, snap)) + t_end = sim.slot + if poisoned: + sim.heal() + else: + sim.extra_delay[K] = 0.0 + sim.run(post_h * SLOTS_PER_HOUR, snap=(10, snap)) + snap(sim) + recs = sim.recs() + tr = np.array(track) + during = tr[(tr[:, 0] >= t0) & (tr[:, 0] <= t_end)] + after = tr[tr[:, 0] >= t_end] + res = dict(min_part=float(tr[:, 1].min()), part_at_end=float(during[-1, 1]) if during.shape[0] else 1.0) + below = tr[(tr[:, 0] >= t0) & (tr[:, 1] < 0.999)] + res["drop_min"] = None if below.shape[0] == 0 else (below[0, 0] - t0) / 2.0 + rec = after[after[:, 1] >= 0.999] + res["recover_min"] = None if rec.shape[0] == 0 else (rec[0, 0] - t_end) / 2.0 + res["conflicts"] = len(sim.conflicts) + res["pconflicts"] = len(sim.pconflicts) + res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t0) / 2.0 if sim.conflicts else None + honest_sid = [v for v in [1]] if poisoned else [0] + res["honest_stalls"] = stalls_between(recs, t0, t_end, sid=(1 if poisoned else 0)) + res["ecl_locks"] = int(((recs[:, 2] == 2) & (recs[:, 3] >= 0) & (recs[:, 0] >= t0) & (recs[:, 0] < t_end)).sum()) if poisoned else 0 + res["post_stalls"] = stalls_between(recs, t_end, sim.slot) + return res + + +def scenario_f(args): + out = ["### F. Eclipse of one pool holding 20% of weight", ""] + configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay), + P(denom="total", delay=args.delay)] + q = getattr(args, "quick", False) + rows = [] + for dur in ((1,) if q else (1, 2, 4)): + for p in configs: + r = run_eclipse(args.seed, dur, False, p) + rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["drop_min"]), fm(r["recover_min"]), r["conflicts"], + r["honest_stalls"], r["post_stalls"]]) + out.append("F1. Delayed view: the pool receives every block and vote %s late, votes for the right blocks, late. Participation as the rest of the network computes it." % "D hours") + out.append("") + out.append(md_table(["eclipse h", "denominator", "pool participation, minimum", "first drop below 1, min after start", + "back to 1, min after end", "conflicting locks", "stalls during", "stalls after"], rows)) + out.append("") + rows = [] + configs2 = configs + [P(denom="active", pmode="cert", floor=0.8, delay=args.delay), + P(denom="active", pmode="cert", floor=0.85, delay=args.delay), + P(denom="active", pmode="cert", floor=1.0, delay=args.delay)] + for dur in ((1,) if q else (1, 2, 4)): + for p in configs2: + r = run_eclipse(args.seed, dur, True, p) + rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["recover_min"]), r["conflicts"], fm(r["first_conflict_min"]), + r["ecl_locks"], r["honest_stalls"], r["post_stalls"]]) + out.append("F2. Poisoned view: an attacker holding 34% of weight feeds the pool a private fork for the eclipse, signs both forks, and is stripped at the heal. The pool votes for the attacker's checkpoints. Honest side = the other 46%.") + out.append("") + out.append(md_table(["eclipse h", "denominator", "pool participation, minimum (honest view)", "back to 1, min after end", + "conflicting locks", "first conflict, min after start", "locks on the eclipsed side", + "honest-side stalls during", "stalls after heal"], rows)) + return "\n".join(out) + + +def scenario_g(args): + out = ["### G. Honest doubling overnight at day 60: 1,000 new keys, fresh Pareto draw, same total hashrate as the old 1,000", ""] + rows_share = [] + rows_lat = [] + ev = [] + for denom in ("active", "total"): + p = P(denom=denom, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + h_new = pareto_hashrates(rng, N_HONEST, total=1.0) + new = sim.add_keys(np.zeros(N_HONEST), assign_regions(h_new, GEOGRAPHY), flaky=True) + old = np.arange(N_HONEST) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + t_event = sim.slot + sim.hash[new] = h_new + sim.set_uptime() + series = [] + last_23 = None + for day in range(1, args.days_g + 1): + sim.run(SLOTS_PER_DAY) + so = sim.share(old) + sn = sim.share(new) + dust_new = int((sim.weight[new] < p.dust).sum()) + series.append((day, so, sn, dust_new)) + if so >= TWO_THIRDS: + last_23 = day + recs = sim.recs() + if denom == "active": + for d in (1, 5, 10, 15, 20, 21, 25): + r = [x for x in series if x[0] == d] + if r: + rows_share.append(["+%d" % d, pct(r[0][1]), pct(r[0][2]), pct(min(d / 60.0, 0.5)), r[0][3]]) + rows_lat.append(lat_row("%s, day before" % denom, lat_stats(recs, 0, t_event))) + rows_lat.append(lat_row("%s, days 1 to 5 after" % denom, lat_stats(recs, t_event, t_event + 5 * SLOTS_PER_DAY))) + rows_lat.append(lat_row("%s, days 5 to %d after" % (denom, args.days_g), lat_stats(recs, t_event + 5 * SLOTS_PER_DAY, None))) + r45 = next((d for d, so, sn, _ in series if sn >= 0.45), None) + r49 = next((d for d, so, sn, _ in series if sn >= 0.49), None) + ev.append([denom, last_23, "not in run" if r45 is None else r45, "not in run" if r49 is None else r49, + stalls_between(recs, t_event, sim.slot)]) + out.append("Weight shares (active run):") + out.append("") + out.append(md_table(["day after doubling", "old cohort", "new cohort", "new cohort formula t/60", "new keys under dust"], rows_share)) + out.append("") + out.append(md_table(["denominator", "last day old cohort holds 2/3 (locks alone)", "new cohort reaches 45%", "new cohort reaches 49%", + "stalled checkpoints"], ev)) + out.append("") + out.append(md_table(LAT_HEADERS, rows_lat)) + return "\n".join(out) + + +# ---------------------------------------------------------------- additions, 3 October 2026, for section 3.11 Guarantees +# Scenarios H to K run the rule exactly as Q3 specifies it (active denominator, cert reading, the floor at FLOOR_F x 2/3 of +# total) over several seeds. Floor factor FLOOR_F: 0.85 until 4 October 2026 (a lock needed 2/3 of active and 17/30 of +# total); 1.0 since (decision of 4 October 2026, O-3.15: a lock needs 2/3 of total, which implies the active test). +# `--floor 0.85` reproduces the 3 October tables. The floor-1.0 rule is arithmetically the "total" denominator of A to G. + +NEW_SEEDS = (7, 11, 13, 17, 19) +FLOOR_F = 1.0 +P_FLOOR = FLOOR_F * TWO_THIRDS + + +def set_floor(f): + """Set the floor factor for rule_p and the predictions of H to L (called from main with --floor).""" + global FLOOR_F, P_FLOOR + FLOOR_F = float(f) + P_FLOOR = FLOOR_F * TWO_THIRDS + + +def rule_p(delay, **kw): + """Q3 as specified: active/cert with the floor at FLOOR_F x 2/3 of total.""" + base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay) + base.update(kw) + return P(**base) + + +def rule_name(): + return "active/cert + floor %.2f (a lock needs %s of total)" % (FLOOR_F, pct(P_FLOOR)) + + +def seeds_of(args): + s = getattr(args, "seeds", "") or "" + out = tuple(int(x) for x in s.split(",") if x.strip()) + return out or NEW_SEEDS + + +def span(vals, fmt="%d"): + """'a' when every seed agrees, else 'a to b'.""" + vals = list(vals) + lo, hi = min(vals), max(vals) + return (fmt % lo) if lo == hi else (fmt % lo) + " to " + (fmt % hi) + + +def span_min(vals): + """Minutes over seeds, 'never' when no seed produced the event, 'never in k of n' when some did.""" + vals = list(vals) + got = [v for v in vals if v is not None] + if not got: + return "never" + s = span(got, "%.0f") + if len(got) < len(vals): + s += " (never in %d of %d)" % (len(vals) - len(got), len(vals)) + return s + + +def lock_gaps_min(recs, s_from, s_to): + """Longest run of consecutive slots without a lock inside [s_from, s_to), in minutes; the 'finality unavailable' interval.""" + locks = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] >= 0), 0] + edges = np.concatenate([[s_from], np.unique(locks), [s_to]]) + return float(np.max(np.diff(edges)) * SLOT_S / 60.0) + + +def run_partition2(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180): + """run_partition with the heal check of section 3.11 item 4: every certificate any side held before the heal + is in the merged view afterwards (a lock is never reversed), plus per-side lock counts.""" + rng = np.random.default_rng(seed) + nR = max(3, len(fracs) + 1) + sim = Sim(p, rng, n_regions=nR) + h = pareto_hashrates(rng, N_HONEST) + reg = assign_regions(h, fracs) + sim.add_keys(h, reg, flaky=True) + groups = [[i] for i in range(len(fracs))] + att = None + if att_share > 0: + att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0]) + groups[0].append(len(fracs)) + sim.warm_start() + sim.init_views(warm=True) + sim.run(pre_min * 2) + t_split = sim.slot + sim.split(groups) + sides = [v.sid for v in sim.views] + sim.run(dur_min * 2) + t_heal = sim.slot + before = {} + for v in sim.views: + for idx, c in v.certs.items(): + before.setdefault(idx, set()).add(c[0]) + sim.heal() + merged = sim.views[0].certs + kept = all(idx in merged for idx in before) + sim.run(post_min * 2) + recs = sim.recs() + res = dict(conflicts=len(sim.conflicts), kept=kept, pre_locks=len(before), + att_share=sim.share([att]) if att is not None else 0.0, pconflicts=len(sim.pconflicts)) + res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None + res["side_first_lock"] = [] + res["side_locks"] = [] + for sid in sides: + fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid) + res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0) + sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)] + res["side_locks"].append(int((sel[:, 3] >= 0).sum())) + fl = first_lock_after(recs, t_heal) + res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0 + res["post_stalls"] = stalls_between(recs, t_heal, sim.slot) + return res + + +def scenario_h(args): + """Partition with an equivocator under the rule as specified: the 4/30 bound of section 3.11.""" + seeds = seeds_of(args) + q = getattr(args, "quick", False) + durs = (60,) if q else (150, 360) + atts = (0.0, 0.10, 0.13, 0.14, 0.20, 0.30, 0.33, 0.34) + out = ["### H. Partition of a 50/50 honest network with an equivocating attacker, rule as specified (%s), " + "each side retargets at once (+daa, the median-time clock of Q1), seeds %s" % (rule_name(), ",".join(str(s) for s in seeds)), ""] + out.append("Attacker = one key holding the stated share of TOTAL weight, mining on the first side, voting on both. Each side holds " + "(1 - a)/2 + a of total. Prediction (section 3.11 item 2): a side holding s of total locks alone once s >= the floor (%s) and its " + "view's active weight has decayed to 1.5 s, which under the cert reading is P x (1 - 1.5 s) slots after the split " + "(P = 240, so 2 h x (1 - 1.5 s), 0 at s >= 2/3); two sides over the floor need a >= %s." % (pct(P_FLOOR), pct(2 * P_FLOOR - 1))) + out.append("") + rows = [] + for a in atts: + s = (1.0 - a) / 2.0 + a + pred = "no (side holds %s < %s)" % (pct(s), pct(P_FLOOR)) if s < P_FLOOR else "%.0f min" % (120.0 * max(0.0, 1.0 - 1.5 * s)) + for dur in durs: + rs = [run_partition2(sd, [0.5, 0.5], a, dur, rule_p(args.delay, daa="full")) for sd in seeds] + rows.append([pct(a, 0), pct(s), dur, pred, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2)), + "yes" if all(r["kept"] for r in rs) else "NO", span(r["post_stalls"] for r in rs)]) + out.append(md_table(["attacker (of total)", "each side holds", "partition min", "predicted first conflict", "conflicting locks", + "first conflict, min", "first lock per side, min", "every pre-heal lock kept at the heal", "stalls in 3 h after heal"], rows)) + return "\n".join(out) + + +def scenario_i(args): + """The 40/40/20 split, three readings.""" + seeds = seeds_of(args) + q = getattr(args, "quick", False) + durs = (60,) if q else (150, 360) + out = ["### I. The 40/40/20 split, rule as specified (%s), +daa, seeds %s" % (rule_name(), ",".join(str(s) for s in seeds)), ""] + cases = [("honest 40/40/20, no attacker", [0.4, 0.4, 0.2], 0.0), + ("honest 40/40/20 plus a 10% equivocator (sides 36+10 / 36+10 / 18+10)", [0.4, 0.4, 0.2], 0.10), + ("honest 40/40/20 plus a 20% equivocator (sides 32+20 / 32+20 / 16+20)", [0.4, 0.4, 0.2], 0.20), + ("honest 40/40 plus a 20% equivocator reaching both (sides 40+20 / 40+20)", [0.5, 0.5], 0.20), + ("honest 40/40 plus a 34% equivocator reaching both (sides 33+34 / 33+34)", [0.5, 0.5], 0.34)] + rows = [] + for name, fr, a in cases: + for dur in durs: + rs = [run_partition2(sd, fr, a, dur, rule_p(args.delay, daa="full")) for sd in seeds] + n = len(fr) + rows.append([name, dur, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), + " / ".join(span([r["side_locks"][i] for r in rs]) for i in range(n)), + "yes" if all(r["kept"] for r in rs) else "NO", + span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append(md_table(["case", "partition min", "conflicting locks", "first conflict, min", "locks per side during", + "every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows)) + return "\n".join(out) + + +def run_silent_resume(seed, frac, hours, p, pre_h=3, post_h=3): + sim, rng, _ = build_honest(p, seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(pre_h * SLOTS_PER_HOUR) + silent, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[silent] = False + t0 = sim.slot + sim.run(int(hours * SLOTS_PER_HOUR)) + t1 = sim.slot + sim.signs[silent] = True + sim.run(post_h * SLOTS_PER_HOUR) + recs = sim.recs() + fl = first_lock_after(recs, t0) + fr = first_lock_after(recs, t1) + return dict(got=got, stalls=stalls_between(recs, t0, t1), first_lock=None if fl is None or fl >= t1 else (fl - t0) / 2.0, + gap=lock_gaps_min(recs, t0, t1), resume=None if fr is None else (fr - t1) / 2.0, + post_stalls=stalls_between(recs, t1, sim.slot), conflicts=len(sim.conflicts), + locked_share=float(((recs[:, 0] >= t0) & (recs[:, 0] < t1) & (recs[:, 3] >= 0)).sum()) / max(1, int(((recs[:, 0] >= t0) & (recs[:, 0] < t1)).sum()))) + + +def scenario_j(args): + """Signing stops while mining continues, for 1, 6 and 24 hours, then resumes.""" + seeds = seeds_of(args) + q = getattr(args, "quick", False) + hours = (1,) if q else (1, 6, 24) + fracs = (0.34, 0.40, 0.45) + out = ["### J. Signing stops while mining continues for 1, 6 and 24 hours, then resumes; rule as specified, seeds %s" % ",".join(str(s) for s in seeds), ""] + out.append("A random set holding x of weight stops signing at hour 3 and resumes after the stated time. Its weight never ages out because it keeps mining. " + "'Longest gap' is the longest interval without a lock while they are silent: the time the node reports finality unavailable.") + out.append("") + rows = [] + for frac in fracs: + for h in hours: + rs = [run_silent_resume(sd, frac, h, rule_p(args.delay)) for sd in seeds] + rows.append([pct(frac, 0), h, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs), + span(int(round(100 * r["locked_share"])) for r in rs) + "%", + span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["post_stalls"] for r in rs), + span(r["conflicts"] for r in rs)]) + out.append(md_table(["silent weight", "silent hours", "first lock after the stop, min", "stalled checkpoints while silent", + "checkpoints locked while silent", "longest gap without a lock, min", "first lock after resume, min", + "stalls in 3 h after resume", "conflicting locks"], rows)) + return "\n".join(out) + + +def run_acquired(seed, bought, att_hash, signs, days, p): + """An attacker buys keys holding `bought` of window weight and starts mining at `att_hash` of network hashrate. + The sellers keep their rigs and mine on under fresh keys.""" + sim, rng, _ = build_honest(p, seed) + # the bought set is picked by hashrate, which the warm start turns into weight at the same share + mask, got = pick_weight_subset(rng, sim.hash, bought) + bidx = np.flatnonzero(mask) + seller_hash = sim.hash[bidx].copy() + fresh = sim.add_keys(np.zeros(bidx.size), sim.region[bidx].copy(), flaky=True) + att = int(sim.add_keys([0.0], [0], flaky=False, signs=signs)[0]) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + honest = sim.hash.sum() + sim.hash[fresh] = seller_hash + sim.hash[bidx] = 0.0 + sim.flaky[bidx] = False + sim.online[bidx] = True + sim.signs[bidx] = signs + sim.hash[att] = honest * att_hash / (1.0 - att_hash) + sim.set_uptime() + got = float(sim.share(bidx)) + owned = np.concatenate([bidx, [att]]) + t0 = sim.slot + series = [] + above13 = None + last13 = None + for day in range(1, days + 1): + s0 = sim.slot + sim.run(SLOTS_PER_DAY) + sh = sim.share(owned) + recs = sim.recs() + series.append((day, sh, stalls_between(recs, s0, sim.slot))) + if sh >= 1.0 / 3.0: + last13 = day + if above13 is None: + above13 = day + recs = sim.recs() + return dict(got=got, series=series, above13=above13, last13=last13, stalls=stalls_between(recs, t0, sim.slot), + max_share=max(s for _, s, _ in series), end_share=series[-1][1], conflicts=len(sim.conflicts)) + + +def scenario_k(args): + """Acquired old keys against fresh hashrate.""" + seeds = seeds_of(args) + q = getattr(args, "quick", False) + days = 3 if q else 30 + att_hash = 0.30 + out = ["### K. Acquired keys: an attacker buys keys holding 20%% or 40%% of window weight and mines at 30%% of network hashrate from day 0; " + "rule as specified, %d days, seeds %s" % (days, ",".join(str(s) for s in seeds)), ""] + out.append("The sellers keep their rigs and mine on under fresh keys (so honest hashrate is unchanged and the fresh keys start under dust). " + "Formula (section 3.11 item 5): share(t) = b (1 - t/30) + 0.30 t/30, the bought blocks age out of the window as the attacker's own blocks enter it. " + "'Fresh hash only' is b = 0: share(t) = 0.30 t/30.") + out.append("") + configs = [(0.0, True), (0.20, True), (0.40, True), (0.20, False), (0.40, False)] + results = {} + for b, signs in configs: + results[(b, signs)] = [run_acquired(sd, b, att_hash, signs, days, rule_p(args.delay)) for sd in seeds] + pick = [d for d in (1, 5, 10, 15, 20, 25, 30) if d <= days] + rows = [] + for d in pick: + row = ["+%d" % d] + for b, signs in configs: + if not signs: + continue + vals = [dict((x[0], x[1]) for x in r["series"])[d] for r in results[(b, signs)]] + formula = b * (1.0 - d / 30.0) + att_hash * d / 30.0 + row.append("%s / %s" % (span((100 * v for v in vals), "%.1f") + "%", pct(formula))) + rows.append(row) + out.append("Attacker weight share, simulated over the seeds / formula (attacker signs every checkpoint):") + out.append("") + out.append(md_table(["day after purchase"] + ["bought %s" % pct(b, 0) for b, s in configs if s], rows)) + out.append("") + ev = [] + for b, signs in configs: + rs = results[(b, signs)] + ev.append([pct(b, 0), "signs" if signs else "silent", span((100 * r["got"] for r in rs), "%.1f") + "%", + span((100 * r["max_share"] for r in rs), "%.1f") + "%", span((100 * r["end_share"] for r in rs), "%.1f") + "%", + "never" if all(r["above13"] is None for r in rs) else "from day %s until day %s" % (span(r["above13"] for r in rs), span(r["last13"] for r in rs)), + span(r["stalls"] for r in rs), span(r["conflicts"] for r in rs)]) + out.append(md_table(["bought weight", "attacker", "bought, as picked", "peak share", "share at day %d" % days, + "holds at least 1/3 (can veto)", "stalled checkpoints in %d days" % days, "conflicting locks"], ev)) + out.append("") + srows = [] + for b, signs in configs: + if signs: + continue + rs = results[(b, signs)] + for d in pick: + vals = [dict((x[0], x[2]) for x in r["series"])[d] for r in rs] + srows.append([pct(b, 0), "+%d" % d, span(vals)]) + out.append("Stalled checkpoints per day while the attacker withholds its votes (2,880 checkpoints a day):") + out.append("") + out.append(md_table(["bought weight", "day", "stalled that day"], srows)) + return "\n".join(out) + + +def run_churn(seed, frac, days, p): + """Scenario D as a function: a set holding `frac` of weight stops mining and signing at hour 3; survivors inherit the block supply.""" + sim, rng, _ = build_honest(p, seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(3 * SLOTS_PER_HOUR) + gone, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[gone] = False + sim.online[gone] = False + sim.flaky[gone] = False + sim.hash[gone] = 0.0 + t_event = sim.slot + sim.run(int(days * SLOTS_PER_DAY)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + later = stalls_between(recs, fl, sim.slot) if fl is not None else 0 + return dict(got=got, first_lock_days=None if fl is None else (fl - t_event) / float(SLOTS_PER_DAY), stalls=st, later=later, + live_share=float(sim.share(np.flatnonzero(~gone))), conflicts=len(sim.conflicts)) + + +def scenario_l(args): + """The floor at 2/3 of total (4 October 2026): the cases the decision turns on, over seeds. Rule as rule_p (FLOOR_F).""" + seeds = seeds_of(args) + few = seeds[:3] + q = getattr(args, "quick", False) + out = ["### L. The floor at %s of total (floor factor %.2f): silent weight, churn, the eclipse, and long partitions with view-local " + "weight; seeds %s (churn and long partitions: %s)" % (pct(P_FLOOR), FLOOR_F, ",".join(str(s) for s in seeds), ",".join(str(s) for s in few)), ""] + # (a) silent weight, fine resolution around one third + hours = (1,) if q else (1, 6) + fracs = (0.25, 0.30, 0.32, 0.33, 0.34, 0.40, 0.45) + out.append("L1. Signing stops while mining continues (as J), finer around one third. The floor needs the signing weight at or above %s of total; " + "the model's resting participation is 0.978, so the online signing share is about 0.978 x (1 - silent)." % pct(P_FLOOR)) + out.append("") + rows = [] + for frac in fracs: + for h in hours: + rs = [run_silent_resume(sd, frac, h, rule_p(args.delay)) for sd in seeds] + rows.append([pct(frac, 0), pct(0.978 * (1.0 - frac)), h, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs), + span(int(round(100 * r["locked_share"])) for r in rs) + "%", + span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["post_stalls"] for r in rs), + span(r["conflicts"] for r in rs)]) + out.append(md_table(["silent weight", "online signing share, about", "silent hours", "first lock after the stop, min", "stalled checkpoints while silent", + "checkpoints locked while silent", "longest gap without a lock, min", "first lock after resume, min", + "stalls in 3 h after resume", "conflicting locks"], rows)) + out.append("") + # (b) churn + d35 = 1 if q else 3 + d50 = 1 if q else 12 + out.append("L2. Churn (as D): a set stops mining and signing; survivors inherit the block supply (perfect retarget). Analytic first lock at day " + "30 (1 - (1 - %s) / x) for a set holding x: %s." % (pct(P_FLOOR), ", ".join("%s: %s" % (pct(x, 0), "never" if 1 - (1 - P_FLOOR) / x <= 0 else "day %.1f" % (30 * (1 - (1 - P_FLOOR) / x))) for x in (0.35, 0.50)))) + out.append("") + rows = [] + for frac, days in ((0.35, d35), (0.50, d50)): + rs = [run_churn(sd, frac, days, rule_p(args.delay)) for sd in few] + rows.append([pct(frac, 0), days, span_min(None if r["first_lock_days"] is None else r["first_lock_days"] * 1440.0 for r in rs) if days <= 1 + else ("never in %d days" % days if all(r["first_lock_days"] is None for r in rs) else span((r["first_lock_days"] for r in rs if r["first_lock_days"] is not None), "%.1f") + " days"), + span(r["stalls"] for r in rs), span(r["later"] for r in rs), span((100 * r["live_share"] for r in rs), "%.1f") + "%", span(r["conflicts"] for r in rs)]) + out.append(md_table(["churn weight", "days run", "first lock after the event", "stalled checkpoints", "stalled after the first lock", + "live share of total weight at the end", "conflicting locks"], rows)) + out.append("") + # (c) the poisoned eclipse of F2 + out.append("L3. The poisoned eclipse (as F2): a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total.") + out.append("") + rows = [] + for dur in ((1,) if q else (1, 2, 4)): + rs = [run_eclipse(sd, dur, True, rule_p(args.delay)) for sd in seeds] + rows.append([dur, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), span(r["ecl_locks"] for r in rs), + span(r["honest_stalls"] for r in rs), span(r["post_stalls"] for r in rs), span(("%.3f" % r["min_part"] for r in rs), "%s")]) + out.append(md_table(["eclipse h", "conflicting locks", "first conflict, min", "locks on the eclipsed side", "honest-side stalls during", + "stalls after heal", "pool participation, minimum"], rows)) + out.append("") + # (d) long honest partitions with view-local weight tables: the window bound of attack scenario 6A + days = 1 if q else 12 + out.append("L4. Long honest partitions with view-local weight ('+local'): after the split a side's window holds only the blocks it has seen, so its own " + "share of its own table rises as s + (1 - s) T / 30 on day T (each side retargets, +daa). Prediction: a side with pre-split share s locks " + "alone from day 30 (floor - s) / (1 - s), 0 if s is already at the floor; %d days, no attacker, seeds %s. The 3 October tables kept weights " + "global (results_v2.md, 'Weights in a partition'), which hid this bound; attack scenario 6A found it on the devnet." % (days, ",".join(str(s) for s in few))) + out.append("") + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("55/45", [0.55, 0.45])): + for f in sorted({FLOOR_F, 0.85}, reverse=True): + pf = f * TWO_THIRDS + preds = [] + for s in fr: + preds.append("0" if s >= pf else ("%.1f" % (30.0 * (pf - s) / (1.0 - s)) if 30.0 * (pf - s) / (1.0 - s) <= days else "> %d" % days)) + p = P(denom="active", pmode="cert", floor=f, daa="full", local=True, delay=args.delay) + rs = [run_partition2(sd, fr, 0.0, days * 1440, p, pre_min=60, post_min=180) for sd in few] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([name, "%.2f (%s)" % (f, pct(pf)), days, " / ".join(preds), + " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.1f") + ("" if all(x is not None for x in col) else " (never in %d of %d)" % (sum(x is None for x in col), len(col)))) for col in fl), + span(r["conflicts"] for r in rs), + ("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.1f") + " days"), + "yes" if all(r["kept"] for r in rs) else "NO", span(r["post_stalls"] for r in rs)]) + out.append(md_table(["honest split", "floor factor (lock needs, of the side's own table)", "partition days", "predicted first lock per side, day", + "first lock per side, day", "conflicting locks", "first conflict", "every pre-heal lock kept", "stalls in 3 h after heal"], rows)) + return "\n".join(out) + + +# ---------------------------------------------------------------- addition, 4 October 2026 (evening): rule v3, ledger F21 +# The frozen-table rule ('+frozen', P.frozen): a lock needs two thirds of the sliding table at C_i (Q3 as today) AND two +# thirds of the table frozen at the view's last certified checkpoint, signers counted at their frozen weights. The frozen +# table rolls forward only when a checkpoint certifies and expires one window (30 days) after its checkpoint, after which +# the sliding table alone applies. Scenario M measures what that does to the window bound of 3.7 item 9 (L4, 6A), to 6B, +# to the heal, to churn (L2) and to the equivocator bound (H). + +WINDOW_SLOTS = WINDOW_HOURS * SLOTS_PER_HOUR + + +def rule_v3(delay, **kw): + base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True, frozen=True) + base.update(kw) + return P(**base) + + +def rule_v2_local(delay, **kw): + base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True) + base.update(kw) + return P(**base) + + +def scenario_m(args): + seeds = seeds_of(args)[:2] + q = getattr(args, "quick", False) + out = ["### M. Rule v3, the frozen weight table (ledger F21): partitions, the heal, churn and the equivocator bound; " + "v2 = the rule as specified today with view-local weights (+local), v3 = v2 plus the frozen table (+frozen); seeds %s" % ",".join(str(s) for s in seeds), ""] + out.append("Prediction for v3: a side of an honest partition holds its pre-split share s of the frozen table for as long as the table stands, so " + "no side under two thirds locks until the frozen checkpoint is one window old (day 30 after the last lock, whatever s), after which the " + "sliding table applies and the v2 bound (already crossed) locks both sides. A side at or above two thirds (6B) locks at once under both. " + "A departed set stalls the survivors until day 30 under v3 (v2: 30 (1 - 1/(3x)) days). The equivocator bound of 3.11.2 is unchanged: an " + "equivocator at a of total gives each side (1 - a)/2 + a of the frozen table, two thirds at a = 1/3.") + out.append("") + # M1: 6A and 6B at devnet lengths (minutes), v2 against v3 + rows = [] + cases = [("50/50 (6A)", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33 (6B, the 4/2 split)", [0.667, 0.333]), ("70/30", [0.7, 0.3])] + durs = (60,) if q else (150, 360) + for name, fr in cases: + for dur in durs: + for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)): + rs = [run_partition2(sd, fr, 0.0, dur, mk(args.delay), pre_min=60, post_min=180) for sd in seeds] + rows.append([name, dur, lab, span(r["conflicts"] for r in rs), " / ".join(span([r["side_locks"][i] for r in rs]) for i in range(2)), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2)), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("M1. Honest partitions at devnet lengths, no attacker, each side retargets and counts only its own blocks:") + out.append("") + out.append(md_table(["honest split", "partition min", "rule", "conflicting locks", "locks per side during", "first lock per side, min", + "every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows)) + out.append("") + # M2: L4 rerun, 12 days + days = 1 if q else 12 + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("55/45", [0.55, 0.45])): + for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)): + rs = [run_partition2(sd, fr, 0.0, days * 1440, mk(args.delay), pre_min=60, post_min=180) for sd in seeds] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([name, lab, days, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.1f")) for col in fl), + span(r["conflicts"] for r in rs), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("M2. L4 again (long honest partitions, %d days): v2 locks alone from day 30 (2/3 - s) / (1 - s), v3 not before day 30:" % days) + out.append("") + out.append(md_table(["honest split", "rule", "partition days", "first lock per side, day", "conflicting locks", "every pre-heal lock kept", + "first lock after heal, min", "stalls in 3 h after heal"], rows)) + out.append("") + # M3: the expiry of the frozen table: 50/50 for 31 days under v3 + days3 = 2 if q else 31 + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4])): + rs = [run_partition2(sd, fr, 0.0, days3 * 1440, rule_v3(args.delay), pre_min=60, post_min=180) for sd in seeds] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([name, days3, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.2f")) for col in fl), + span(r["conflicts"] for r in rs), + ("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.2f") + " days")]) + out.append("M3. The frozen table expires one window after its checkpoint (%d-day partitions, v3): the bound moves to day 30 for every split under two thirds:" % days3) + out.append("") + out.append(md_table(["honest split", "partition days", "first lock per side, day", "conflicting locks", "first conflict"], rows)) + out.append("") + # M4: churn under v3 against v2 + rows = [] + for frac in (0.35, 0.50): + for lab, mk in (("v2", rule_p), ("v3", lambda d: rule_p(d, frozen=True))): + rs = [run_churn(sd, frac, days3, mk(args.delay)) for sd in seeds] + rows.append([pct(frac, 0), lab, days3, + "never in %d days" % days3 if all(r["first_lock_days"] is None for r in rs) else span((r["first_lock_days"] for r in rs if r["first_lock_days"] is not None), "%.2f") + " days", + span(r["stalls"] for r in rs), span(r["later"] for r in rs), span(r["conflicts"] for r in rs)]) + out.append("M4. Churn (as L2): a set holding x of weight stops mining and signing at once; v2 recovers at 30 (1 - 1/(3x)) days, v3 when the frozen table expires:") + out.append("") + out.append(md_table(["churn weight", "rule", "days run", "first lock after the event", "stalled checkpoints", "stalled after the first lock", "conflicting locks"], rows)) + out.append("") + # M5: the equivocator bound (H) under v3 + rows = [] + for a in (0.30, 0.33, 0.34): + s_ = (1.0 - a) / 2.0 + a + rs = [run_partition2(sd, [0.5, 0.5], a, 60 if q else 150, rule_v3(args.delay)) for sd in seeds] + rows.append([pct(a, 0), pct(s_), span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2))]) + out.append("M5. The equivocator across a 50/50 split (as H), v3: each side holds (1 - a)/2 + a of the frozen table, so the one-third bound stands:") + out.append("") + out.append(md_table(["attacker (of total)", "each side holds", "conflicting locks", "first conflict, min", "first lock per side, min"], rows)) + return "\n".join(out) + + +def run_pulse_sim(job): + """Scenario N worker: the honest network (1,000 keys) plus one renter key, the block supply from a difficulty trace + (daa_trace.pulse_trace: a 50x pulse for 10 min of every hour under a lagging retarget), the weight kept in the W2 + form asked. Returns the renter's weight share at the end of every day, the day it first holds a third, stalls and + conflicts.""" + rule, seed, days, form, delay = job + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import daa_trace as DT + tr = DT.cached_trace(rule, seed, days) + s = tr["settle_slots"] + base, rent = tr["blocks"][0, s:], tr["blocks"][1, s:] + hb, hr = tr["hashes"][0, s:], tr["hashes"][1, s:] + p = rule_p(delay, wform=form) + sim, rng, _ = build_honest(p, seed) + att = int(sim.add_keys([0.0], [0], flaky=False, signs=True)[0]) + sim.warm_start() + sim.init_views(warm=True) + shares = sim.hash[:att] / sim.hash[:att].sum() + n_keys = sim.N + + def supply(slot): + v = np.zeros(n_keys) + i = min(slot, base.size - 1) + nb = int(base[i]) + if nb > 0: + v[:att] = rng.multinomial(nb, shares) + v[att] = rent[i] + return v + + sim.supply = supply + series = [] + cross = None + t0 = time.time() + for day in range(1, days + 1): + s0 = sim.slot + sim.run(SLOTS_PER_DAY) + sh = float(sim.share([att])) + recs = sim.recs() + sel = recs[(recs[:, 0] >= s0) & (recs[:, 0] < sim.slot)] + locks = int((sel[:, 3] >= 0).sum()) if sel.size else 0 + stalls = stalls_between(recs, s0, sim.slot) if recs.size else 0 + lo, hi = max(0, (day - 30) * SLOTS_PER_DAY), day * SLOTS_PER_DAY + hs = float(hr[lo:hi].sum() / max(hr[lo:hi].sum() + hb[lo:hi].sum(), 1e-9)) + series.append((day, sh, stalls, locks, hs)) + if cross is None and sh >= 1.0 / 3.0: + cross = day + return dict(rule=rule, seed=seed, form=form, days=days, series=series, cross=cross, peak=max(x[1] for x in series), + end=series[-1][1], hash_end=series[-1][4], stalls=sum(x[2] for x in series), locks=sum(x[3] for x in series), + conflicts=len(sim.conflicts), wall=time.time() - t0, keys=n_keys) + + +def scenario_n(args): + """The M14/F14 run (O-3.14): a 50x rented pulse for 10 minutes of every hour against a lagging retarget, Kaspa's + sampled DAA and the Igneum rule v2, the weight counted under both W2 forms.""" + from multiprocessing import Pool + seeds = seeds_of(args)[:3] if not getattr(args, "seeds", "") else seeds_of(args) + q = getattr(args, "quick", False) + days = 2 if q else int(getattr(args, "days", 30)) + rules = getattr(args, "rules", "igneum-v2,kaspa").split(",") + jobs_n = int(getattr(args, "jobs", 6)) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import daa_trace as DT + t0 = time.time() + # the traces first (one per rule and seed, cached under daa_trace.CACHE_DIR), then the sims (one per form) + with Pool(min(jobs_n, len(rules) * len(seeds))) as pool: + traces = pool.map(DT.job, [(r, sd, days) for r in rules for sd in seeds]) + t_trace = time.time() - t0 + jobs = [(r, sd, days, form, args.delay) for r in rules for sd in seeds for form in ("daa", "median")] + with Pool(min(jobs_n, len(jobs))) as pool: + res = pool.map(run_pulse_sim, jobs) + tr = {(t["rule"], t["seed"]): t for t in traces} + out = ["### N. Pulsed rental against a lagging retarget (ledger M14, F14; O-3.14): a renter brings 50x the honest hashrate for " + "10 minutes of every hour for %d days; the block supply comes from the difficulty controller in the loop " + "(sim/daa_trace.py: Kaspa's sampled DAA and the Igneum rule v2 of sim/difficulty/sim.py), the weight is counted under " + "both W2 forms; rule %s, seeds %s, delay %.1f s; traces %.0f s, sims %.0f s wall" % ( + days, rule_name(), ",".join(str(s) for s in seeds), args.delay, t_trace, time.time() - t0 - t_trace), ""] + out.append("Hash share = the renter's hashes over everyone's in the trailing 30 days of the trace (the pulse is 500 of 560 " + "hash-minutes an hour, 89.3%). Weight share over hash share is the amplifier the critic names; 1.0 means the " + "window pays blocks per hash at par, below 1.0 the pulse buys weight at a loss. Under a perfect retarget " + "(results_v2.md) the renter's share would be its block share.") + out.append("") + rows = [] + for r in rules: + t = [tr[(r, sd)] for sd in seeds] + rows.append([r, "trace (aggregate, no finality)", span((100 * x["hash_share_30d"] for x in t), "%.1f") + "%", + span((100 * x["block_share"] for x in t), "%.1f") + "%", span((x["bph_ratio"] for x in t), "%.3f"), + "%s / %s" % (span((x["cross_daa"] or 0 for x in t)) if all(x["cross_daa"] for x in t) else "never", + span((x["cross_median"] or 0 for x in t)) if all(x["cross_median"] for x in t) else "never"), + "%s / %s" % (span((100 * x["end_daa"] for x in t), "%.1f") + "%", span((100 * x["end_median"] for x in t), "%.1f") + "%"), + "%s / %s" % (span((x["end_daa"] / x["hash_share_30d"] for x in t), "%.2f"), span((x["end_median"] / x["hash_share_30d"] for x in t), "%.2f")), + span((x["blocks_per_day"] for x in t), "%.0f"), span((x["peak_blocks_per_min"] for x in t)), span((x["worst_gap_s"] for x in t), "%.0f"), "", ""]) + for form in ("daa", "median"): + rs = [x for x in res if x["rule"] == r and x["form"] == form] + rows.append([r, "finality sim, W2 %s" % form, span((100 * x["hash_end"] for x in rs), "%.1f") + "%", "", "", + span((x["cross"] or 0 for x in rs)) if all(x["cross"] for x in rs) else ("never" if not any(x["cross"] for x in rs) else "/".join(str(x["cross"] or "never") for x in rs)), + span((100 * x["end"] for x in rs), "%.1f") + "% (peak " + span((100 * x["peak"] for x in rs), "%.1f") + "%)", + span((x["end"] / x["hash_end"] for x in rs), "%.2f"), "", "", "", span((x["stalls"] for x in rs)) + " of " + span((x["stalls"] + x["locks"] for x in rs)), span((x["conflicts"] for x in rs))]) + out.append(md_table(["controller", "row", "renter hash share (30 d)", "renter block share", "blocks per hash, renter over base", + "day the renter first holds 1/3 (DAA form / median form)", "renter weight share at day %d" % days, + "weight share over hash share", "chain blocks per day", "peak blocks per minute", "worst gap (s)", + "stalled checkpoints of all", "conflicting locks"], rows)) + out.append("") + pick = [d for d in (1, 2, 3, 5, 7, 10, 12, 15, 20, 25, 30) if d <= days] + out.append("Renter weight share at the end of the day, finality sim, seed %d (DAA form / median form):" % seeds[0]) + out.append("") + srows = [] + for r in rules: + d_ = {x["form"]: x for x in res if x["rule"] == r and x["seed"] == seeds[0]} + srows.append([r] + ["%.1f / %.1f" % (100 * d_["daa"]["series"][d - 1][1], 100 * d_["median"]["series"][d - 1][1]) for d in pick]) + out.append(md_table(["controller"] + ["+%d" % d for d in pick], srows)) + out.append("") + out.append("Same, from the trace alone (the renter against the whole honest network, exact to the slot):") + out.append("") + srows = [] + for r in rules: + t = tr[(r, seeds[0])] + srows.append([r] + ["%.1f / %.1f" % (100 * t["daily_daa"][d - 1], 100 * t["daily_median"][d - 1]) for d in pick]) + out.append(md_table(["controller"] + ["+%d" % d for d in pick], srows)) + out.append("") + out.append("Sim walls: " + ", ".join("%s/%s/s%d %.0f s" % (x["rule"], x["form"], x["seed"], x["wall"]) for x in res) + ".") + return "\n".join(out) + + +def run_hostile(seed, hostile, pmode, delay, pool_share=0.20, pre_h=3, attack_h=2, post_h=2): + """Ledger F3 / O-3.3: a chosen aggregator drops a 20% pool's votes from every certificate it builds for attack_h hours.""" + p = rule_p(delay, pmode=pmode) + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=4) + h = pareto_hashrates(rng, N_HONEST - 1, total=1.0 - pool_share) + reg = assign_regions(h, GEOGRAPHY) + sim.add_keys(h, reg, flaky=True) + K = int(sim.add_keys([pool_share], [3], flaky=False)[0]) + sim.warm_start() + sim.init_views(warm=True) + track = [] # (slot, pool participation, pool share of the active denominator, pool share of total weight) + + def snap(s): + v = s.views[0] + part = s.participation(v, v.next_idx) + e = s.elig_mask() + act = s.weight * part + track.append((s.slot, float(part[K]), float(act[K] / act[e].sum()), float(s.weight[K] / s.weight[e].sum()))) + + sim.run(pre_h * SLOTS_PER_HOUR, snap=(10, snap)) + t0 = sim.slot + if hostile: + target = np.zeros(sim.N, dtype=bool) + target[K] = True + p.hostile = (target, t0, t0 + attack_h * SLOTS_PER_HOUR) + sim.run(attack_h * SLOTS_PER_HOUR, snap=(10, snap)) + t1 = sim.slot + p.hostile = None + sim.run(post_h * SLOTS_PER_HOUR, snap=(10, snap)) + snap(sim) + recs = sim.recs() + tr = np.array(track) + before = tr[tr[:, 0] < t0] + during = tr[(tr[:, 0] >= t0) & (tr[:, 0] <= t1)] + after = tr[tr[:, 0] > t1] + back = after[after[:, 1] >= 0.999] + d_before = lat_stats(recs, t0 - SLOTS_PER_HOUR, t0) + d_during = lat_stats(recs, t0, t1) + d_after = lat_stats(recs, t1, sim.slot) + return dict(weight_share=float(during[-1, 3]), part_min=float(during[:, 1].min()), part_end=float(during[-1, 1]), + active_before=float(before[-1, 2]), active_end=float(during[-1, 2]), + recover_min=None if back.shape[0] == 0 else (back[0, 0] - t1) / 2.0, + before=d_before, during=d_during, after=d_after, conflicts=len(sim.conflicts)) + + +def scenario_o(args): + """Hostile aggregator (ledger F3, O-3.3): the cert reading against the block reading of Q2, three seeds.""" + seeds = seeds_of(args)[:3] + q = getattr(args, "quick", False) + attack_h = 1 if q else 2 + out = ["### O. Hostile aggregator (ledger F3, O-3.3): a chosen aggregator drops a 20%% pool's votes from every certificate it builds for %d h; %s; seeds %s" % ( + attack_h, rule_name(), ",".join(str(s) for s in seeds)), ""] + out.append("The pool holds 20% of total weight in its own region, always online. From hour 3 the hostile aggregator's certificate is the one carried " + "whenever the other 80% reach quorum without the pool (the strongest form of F3's premise: the producer carries the certificate it likes). " + "'cert' credits participation from the certificate's signers (the simulation's reading until tonight); 'block' is spec 3.3 Q2, every vote " + "carried by any block. Weight share = the pool's share of total weight (W2, blocks); active share = its share of the active denominator " + "(weight x participation, the liveness-side report of Q3; the 2/3-of-total floor decides the lock either way). Lock latency is the " + "certificate's time after the checkpoint block, median and p99 over the hour before, the attack, and the 2 h after.") + out.append("") + rows = [] + for pmode in ("cert", "block"): + for hostile in (False, True): + for sd in seeds: + r = run_hostile(sd, hostile, pmode, args.delay, attack_h=attack_h, pre_h=1 if q else 3, post_h=1 if q else 2) + rows.append([pmode, "yes" if hostile else "no", sd, pct(r["weight_share"]), "%.3f" % r["part_min"], "%.3f" % r["part_end"], + pct(r["active_before"]), pct(r["active_end"]), fm(r["recover_min"]), + "%.1f / %.1f" % (r["before"]["med"], r["before"]["p99"]), "%.1f / %.1f" % (r["during"]["med"], r["during"]["p99"]), + "%.1f / %.1f" % (r["after"]["med"], r["after"]["p99"]), r["during"]["stalls"], r["during"]["n"], r["conflicts"]]) + out.append(md_table(["reading", "hostile aggregator", "seed", "pool weight share at the end of the attack", "pool participation, minimum", "at the end of the attack", + "pool share of active, before", "at the end of the attack", "participation back to 1, min after the attack", + "lock latency before, median / p99 s", "during the attack", "after", "stalled checkpoints during", "checkpoints during", "conflicting locks"], rows)) + return "\n".join(out) + + + +# ---------------------------------------------------------------- Horizon lane 3 (6 October 2026): candidate rules against tonight's pause +# docs/analysis/horizon/finality-and-weight.md. The base is the live rule's shape: active denominator under the block reading of +# Q2, floor 1.0 (a lock needs 2/3 of total), each partition side retargets (+daa) and counts only its own blocks (+local). +HORIZON_BASE = dict(denom="active", pmode="block", floor=1.0, daa="full", local=True) +HORIZON_CANDS = [ + ("v2", "v2: sliding table only (the rule before F21)", dict()), + ("v3", "v3: plus the frozen table (the live rule since N3, 5 Oct 2026)", dict(frozen=True)), + ("leave", "v3 + (iv) departure announcement: weight out of every denominator 1 h after the signed leave", dict(frozen=True, leave=1.0)), + ("decay1", "v3 + (i) decaying denominator, T 1 h, r 0.5 per h (a silent key is gone from the denominator after 3 h)", dict(frozen=True, decay=(1.0, 0.5))), + ("decay6", "v3 + (i) decaying denominator, T 6 h, r 1/24 per h (gone after 30 h)", dict(frozen=True, decay=(6.0, 1.0 / 24.0))), + ("hyst", "v3 + (iii) floor with hysteresis: after 1 h under 2/3 signing the floor drops to 0.85 x 2/3, back after 1 h at or above 2/3", dict(frozen=True, hyst=(1.0, 0.85))), + ("twotier", "v3 + (ii) two-tier: a provisional lock at 2/3 of active (block reading, no floor) reported beside the final lock", dict(frozen=True, twotier=True)), +] + + +def horizon_cands(args): + want = [c.strip() for c in (getattr(args, "cands", "") or "all").split(",")] + return [c for c in HORIZON_CANDS if "all" in want or c[0] in want] + + +def cand_p(kw, delay): + base = dict(HORIZON_BASE) + base.update(kw) + return P(delay=delay, **base) + + +def prov_first_after(sim, slot): + s = [rec[1] for rec in sim.views[0].prov_certs.values() if rec[1] >= slot] + return min(s) if s else None + + +def fmt_days_dev(days): + """Mainnet days and the devnet equivalent (the devnet window is 7,200 DAA s, 1/360 of mainnet's: one mainnet day = 4 devnet minutes).""" + if days is None: + return "never" + return "%.2f d (devnet %.0f min)" % (days, days * 4.0) + + +def run_churn_h(seed, frac, days, p): + """Tonight's shape: a set holding `frac` of weight stops mining AND signing at once at hour 3 (the 15 prover boxes plus + the keys that left with them, 44.8% of the live devnet's window weight at 18:40Z). With P.leave the departing keys + sign a leave at the moment they stop (the orchestrated case: the fleet job knew it was taking them).""" + sim, rng, _ = build_honest(p, seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(3 * SLOTS_PER_HOUR) + gone, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[gone] = False + sim.online[gone] = False + sim.flaky[gone] = False + sim.hash[gone] = 0.0 + t_event = sim.slot + if p.leave is not None: + sim.leave_slot[gone] = t_event + sim.run(int(days * SLOTS_PER_DAY)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + later = stalls_between(recs, fl, sim.slot) if fl is not None else 0 + pf = prov_first_after(sim, t_event) if p.twotier else None + return dict(got=got, first_lock_days=None if fl is None else (fl - t_event) / float(SLOTS_PER_DAY), stalls=st, later=later, + prov_first_days=None if pf is None else (pf - t_event) / float(SLOTS_PER_DAY), + live_share=float(sim.share(np.flatnonzero(~gone))), conflicts=len(sim.conflicts), pconflicts=len(sim.pconflicts)) + + +def scenario_t(args): + seeds = seeds_of(args)[:3] + q = getattr(args, "quick", False) + days = 2 if q else 31 + out = ["### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock " + "after it under each candidate rule; %d days, seeds %s" % (days, ",".join(str(s) for s in seeds)), ""] + out.append("Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. " + "Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, " + "the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left).") + out.append("") + rows = [] + for frac in (0.34, 0.45, 0.50): + for key, name, kw in horizon_cands(args): + rs = [run_churn_h(sd, frac, days, cand_p(kw, args.delay)) for sd in seeds] + fl = [r["first_lock_days"] for r in rs] + pf = [r["prov_first_days"] for r in rs] + rows.append([pct(frac, 0), key, "never in %d d" % days if all(x is None for x in fl) else span((x for x in fl if x is not None), "%.2f") + " d (devnet %s min)" % span((x * 4 for x in fl if x is not None), "%.0f"), + ("provisional " + ("never" if all(x is None for x in pf) else span((x for x in pf if x is not None), "%.3f") + " d (devnet %s min)" % span((x * 4 for x in pf if x is not None), "%.1f"))) if kw.get("twotier") else "", + span(r["stalls"] for r in rs), span(r["later"] for r in rs), span(r["conflicts"] for r in rs), span(r["pconflicts"] for r in rs) if kw.get("twotier") else ""]) + out.append(md_table(["departed weight", "rule", "first final lock after the departure", "provisional tier", "stalled checkpoints", "stalled after the first lock", + "conflicting final locks", "conflicting provisional locks"], rows)) + return "\n".join(out) + + +def scenario_p(args): + """The partition suite under each candidate: the equivocator across a 50/50 split (H), the three-way and 60/60 cases (I), + honest 60/40 and 70/30 (E), the poisoned eclipse (L3) and the 12-day honest partitions with view-local weight (L4).""" + seeds = seeds_of(args)[:3] + q = getattr(args, "quick", False) + dur = 60 if q else 360 + ldays = 1 if q else 12 + out = ["### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds %s" % ",".join(str(s) for s in seeds), ""] + out.append("Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see " + "(silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the " + "two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see " + "(a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count.") + out.append("") + rows = [] + cases = [("50/50 honest, 0% attacker", [0.5, 0.5], 0.0), ("50/50 + 20% equivocator (sides 60/60)", [0.5, 0.5], 0.20), + ("50/50 + 33% equivocator (sides 66.5/66.5)", [0.5, 0.5], 0.33), ("50/50 + 34% equivocator (sides 67/67, the bound)", [0.5, 0.5], 0.34), + ("40/40/20 honest", [0.4, 0.4, 0.2], 0.0), ("60/40 honest", [0.6, 0.4], 0.0), ("70/30 honest", [0.7, 0.3], 0.0)] + for key, name, kw in horizon_cands(args): + for cname, fr, a in cases: + rs = [run_partition2(sd, fr, a, dur, cand_p(kw, args.delay), pre_min=60, post_min=120) for sd in seeds] + n = len(fr) + rows.append([key, cname, dur, span(r["conflicts"] for r in rs), span(r["pconflicts"] for r in rs) if kw.get("twotier") else "", + span_min(r["first_conflict_min"] for r in rs), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(n)), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("P1. Partitions of %d minutes:" % dur) + out.append("") + out.append(md_table(["rule", "case", "partition min", "conflicting final locks", "conflicting provisional locks", "first conflict, min", "first lock per side, min", + "every pre-heal lock kept", "first lock after heal, min", "stalls in 2 h after heal"], rows)) + out.append("") + rows = [] + for key, name, kw in horizon_cands(args): + for h in ((1,) if q else (1, 2, 4)): + rs = [run_eclipse(sd, h, True, cand_p(kw, args.delay)) for sd in seeds] + rows.append([key, h, span(r["conflicts"] for r in rs), span(r["pconflicts"] for r in rs) if kw.get("twotier") else "", span_min(r["first_conflict_min"] for r in rs), + span(r["ecl_locks"] for r in rs), span(r["honest_stalls"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total):") + out.append("") + out.append(md_table(["rule", "eclipse h", "conflicting final locks", "conflicting provisional locks", "first conflict, min", "locks on the eclipsed side", + "honest-side stalls during", "stalls after heal"], rows)) + out.append("") + rows = [] + for key, name, kw in horizon_cands(args): + for cname, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4])): + rs = [run_partition2(sd, fr, 0.0, ldays * 1440, cand_p(kw, args.delay), pre_min=60, post_min=180) for sd in seeds] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([key, cname, ldays, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.2f")) for col in fl), + span(r["conflicts"] for r in rs), span(r["pconflicts"] for r in rs) if kw.get("twotier") else "", + ("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.2f") + " d"), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs)]) + out.append("P3. Long honest partitions with view-local weight (L4), %d days:" % ldays) + out.append("") + out.append(md_table(["rule", "honest split", "days", "first lock per side, day", "conflicting final locks", "conflicting provisional locks", "first conflict", "every pre-heal lock kept", + "first lock after heal, min"], rows)) + return "\n".join(out) + + +def run_acquired_h(seed, bought, att_hash, mode, days, p): + """Scenario K with a third attacker behaviour: `mode` = sign, silent or leave (the bought keys announce a leave at purchase, + which under rule (iv) removes them from every denominator after the delay and raises every remaining key's share, the attacker's own included).""" + sim, rng, _ = build_honest(p, seed) + mask, got = pick_weight_subset(rng, sim.hash, bought) + bidx = np.flatnonzero(mask) + seller_hash = sim.hash[bidx].copy() + fresh = sim.add_keys(np.zeros(bidx.size), sim.region[bidx].copy(), flaky=True) + att = int(sim.add_keys([0.0], [0], flaky=False, signs=True)[0]) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + honest = sim.hash.sum() + sim.hash[fresh] = seller_hash + sim.hash[bidx] = 0.0 + sim.flaky[bidx] = False + sim.online[bidx] = True + sim.signs[bidx] = mode == "sign" + sim.hash[att] = honest * att_hash / (1.0 - att_hash) + sim.set_uptime() + t0 = sim.slot + if mode == "leave": + sim.leave_slot[bidx] = t0 + series = [] + for day in range(1, days + 1): + s0 = sim.slot + sim.run(SLOTS_PER_DAY) + e = sim.elig_mask() + if p.leave is not None: + e &= ~((sim.leave_slot >= 0) & (sim.slot >= sim.leave_slot + int(round(p.leave * SLOTS_PER_HOUR)))) + tot = sim.weight[e].sum() + own = np.zeros(sim.N, dtype=bool) + own[att] = True + if mode != "leave": + own[bidx] = True + sh = float(sim.weight[own & e].sum() / tot) if tot > 0 else 0.0 + recs = sim.recs() + series.append((day, sh, stalls_between(recs, s0, sim.slot))) + recs = sim.recs() + veto = [d for d, s, _ in series if s >= 1.0 / 3.0] + return dict(got=got, series=series, veto_from=min(veto) if veto else None, veto_to=max(veto) if veto else None, + max_share=max(s for _, s, _ in series), end_share=series[-1][1], stalls=stalls_between(recs, t0, sim.slot), conflicts=len(sim.conflicts)) + + +def scenario_q(args): + seeds = seeds_of(args)[:3] + q = getattr(args, "quick", False) + out = ["### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds %s" % ",".join(str(s) for s in seeds), ""] + rows = [] + hours = 1 if q else 6 + for key, name, kw in horizon_cands(args): + for frac in (0.30, 0.34, 0.45): + rs = [run_silent_resume(sd, frac, hours, cand_p(kw, args.delay)) for sd in seeds] + rows.append([key, pct(frac, 0), hours, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs), + span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["conflicts"] for r in rs)]) + out.append("Q1. A set holding x of weight stops signing but keeps mining for %d h, then resumes (its weight never ages out):" % hours) + out.append("") + out.append(md_table(["rule", "silent weight", "silent hours", "first lock after the stop, min", "stalled while silent", "longest gap, min", "first lock after resume, min", "conflicting locks"], rows)) + out.append("") + days = 3 if q else 30 + rows = [] + kw_leave = dict(frozen=True, leave=1.0) + if not any(c[0] == "leave" for c in horizon_cands(args)): + return "\n".join(out) + for bought in (0.40, 0.49): + for mode in ("sign", "silent", "leave"): + rs = [run_acquired_h(sd, bought, 0.30, mode, days, cand_p(kw_leave, args.delay)) for sd in seeds] + rows.append([pct(bought, 0), mode, span((100 * r["max_share"] for r in rs), "%.1f") + "%", span((100 * r["end_share"] for r in rs), "%.1f") + "%", + "never" if all(r["veto_from"] is None for r in rs) else "day %s to %s" % (span(r["veto_from"] for r in rs if r["veto_from"] is not None), span(r["veto_to"] for r in rs if r["veto_to"] is not None)), + span(r["stalls"] for r in rs), span(r["conflicts"] for r in rs)]) + out.append("Q2. Under v3 + leave 1 h: an attacker at 30%% of hashrate buys keys worth 40%% or 49%% of the window and makes them sign, stay silent, or LEAVE " + "(the leave removes the bought weight from every denominator after 1 h, so the attacker's own share of what remains is w / (1 - L)). " + "Arithmetic: to lock alone it needs w >= 2/3 (1 - L), so w + L >= 2/3 + L/3 >= 2/3 of the window either way; leaving bought keys is never cheaper than signing with them. %d days:" % days) + out.append("") + out.append(md_table(["bought weight", "bought keys", "attacker's peak share of the denominator", "share at the end", "holds the veto (1/3)", "stalled checkpoints", "conflicting locks"], rows)) + return "\n".join(out) + + +SCENARIOS = {"A": scenario_a, "B": scenario_b, "C": scenario_c, "D": scenario_d, "E": scenario_e, "F": scenario_f, "G": scenario_g, + "T": scenario_t, "P": scenario_p, "Q": scenario_q, + "H": scenario_h, "I": scenario_i, "J": scenario_j, "K": scenario_k, "L": scenario_l, "M": scenario_m, "N": scenario_n, "O": scenario_o} + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Igneum finality rule V2 simulation") + ap.add_argument("--seed", type=int, default=7) + ap.add_argument("--scenarios", default="A,B,C,D,E,F,G") + ap.add_argument("--delay", type=float, default=2.0, help="one-way inter-region delay in seconds for the main runs") + ap.add_argument("--grace", type=float, default=15.0) + ap.add_argument("--quick", action="store_true", help="shortened runs for development") + ap.add_argument("--seeds", default="", help="comma-separated seeds for scenarios H to L (default 7,11,13,17,19)") + ap.add_argument("--floor", type=float, default=FLOOR_F, + help="floor factor f for the rule of H to L and the floor columns of C to F: a lock needs f x 2/3 of total (1.0 = the rule of 4 Oct 2026; 0.85 = the 3 Oct tables)") + ap.add_argument("--rules", default="igneum-v2,kaspa", help="scenario N: difficulty controllers of sim/difficulty/sim.py to put in the loop") + ap.add_argument("--days", type=int, default=30, help="scenario N: days of the pulse (2 under --quick)") + ap.add_argument("--jobs", type=int, default=6, help="scenario N: worker processes") + ap.add_argument("--cands", default="all", help="scenarios T, P, Q: comma-separated candidate keys (v2,v3,leave,decay1,decay6,hyst,twotier) or all") + args = ap.parse_args(argv) + set_floor(args.floor) + q = args.quick + # --quick is the smoke run: every scenario end to end at a length that finishes in under two minutes on a + # 2-vCPU CI runner (the full set takes about an hour). The numbers it prints are not the results of + # results_v2.md; only the full run is. + args.days_a = 1 if q else 60 + args.days_delay = 1 if q else 3 + args.days_b = 1 if q else 35 + args.hours_c = 1 if q else 6 + args.hours_c_total = 1 if q else 72 + args.days_d35 = 1 if q else 3 + args.days_d50 = 1 if q else 12 + args.days_g = 1 if q else 25 + print("# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules)") + print() + p = P() + print("seed %d, slot %.0f s, %d blocks per checkpoint, window %d h, presence %d checkpoints, dust %d, quorum 2/3, " + "floor factor %.2f (a lock needs %s of total under the rule as specified), " + "inter-region delay %.1f s (intra %.1f s, jitter sigma %.2f), grace %.0f s, uptime %.2f (mean outage %d slots), " + "uptime %.3f for keys at or above %s of hashrate, honest keys %d Pareto %.1f, geography %s%s" % ( + args.seed, SLOT_S, BLOCKS_PER_CP, WINDOW_HOURS, p.presence, p.dust, FLOOR_F, pct(P_FLOOR), args.delay, p.intra, p.jitter, + args.grace, p.uptime, p.outage, p.uptime_big, pct(p.big_share, 0), N_HONEST, PARETO_SHAPE, "/".join(pct(g, 0) for g in GEOGRAPHY), + ", QUICK" if q else "")) + print() + for s in args.scenarios.split(","): + s = s.strip().upper() + if s not in SCENARIOS: + print("unknown scenario %s" % s, file=sys.stderr) + return 2 + t = time.time() + print(SCENARIOS[s](args)) + print() + print("(%s took %.0f s)" % (s, time.time() - t), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sim/horizon/finality-and-weight/lightclient_cost.py b/sim/horizon/finality-and-weight/lightclient_cost.py new file mode 100644 index 00000000..cee5b77a --- /dev/null +++ b/sim/horizon/finality-and-weight/lightclient_cost.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Light-client cost of a certificate chain (Horizon lane 3, task 4): bytes and verification time per year of chain, by voter +count and mode, and the ZK light client's per-checkpoint work. Inputs labelled measured / cited / approximate. + +Sizes (fork, consensus/core/src/finality.rs, cited in spec 3.4.2 item 1): vote item 281 B; certificate 273 B plus ceil(V/8) B of +bitmap; header 400 B working figure (spec 10.5, approximate); 2,880 checkpoints a day at 1 block/s (spec C1). +Verification (approximate, blst 0.3.17 min_pk, the crate the fork uses: vendor/igneum-node/Cargo.toml line 238; per-operation costs +from memory of blst's published benchmarks, labelled approximate): one G1 affine addition about 1 us, hash-to-G2 about 150 us, +two pairings about 1.6 ms, decompressing and subgroup-checking one 48-byte G1 key about 60 us (done once per key when the voter +list is loaded, not per certificate). Measured anchor: the pure-JavaScript verifier of a 16-signer certificate took 58 to 68 ms warm +on the M5 Max (docs/bench-log.md, FUD ledger sweep round 6, P3), about 30x the native estimate. + +Run: python3 lightclient_cost.py +""" +VOTE_B = 281 +CERT_FIXED_B = 273 +HEADER_B = 400 +CP_PER_DAY = 2880 +DAYS = 365 +G1_ADD_US = 1.0 +H2G2_US = 150.0 +PAIRINGS_US = 1600.0 +KEY_DECOMP_US = 60.0 + + +def cert_bytes(V): + return CERT_FIXED_B + (V + 7) // 8 + + +def verify_ms(V): + return (V * G1_ADD_US + H2G2_US + PAIRINGS_US) / 1000.0 + + +def fmt_b(b): + if b >= 1e9: + return "%.2f GB" % (b / 1e9) + if b >= 1e6: + return "%.1f MB" % (b / 1e6) + return "%.0f KB" % (b / 1e3) + + +def fmt_t(ms): + if ms >= 3.6e6: + return "%.1f h" % (ms / 3.6e6) + if ms >= 60e3: + return "%.1f min" % (ms / 60e3) + if ms >= 1e3: + return "%.1f s" % (ms / 1e3) + return "%.1f ms" % ms + + +def main(): + n = CP_PER_DAY * DAYS + print("## A year of certificates, checkpoint mode (spec 10.3 item 3), one certificate per index") + print() + print("| voters | certificate bytes | per year: certificates + headers | verify per certificate (native, approximate) | per year on one laptop core | on a phone core (3x, approximate) | voter list once |") + print("|---|---|---|---|---|---|---|") + for V in (12, 93, 1000, 8192, 65536): + print("| %d | %d B | %s | %.2f ms | %s | %s | %s |" % (V, cert_bytes(V), fmt_b(n * (cert_bytes(V) + HEADER_B)), verify_ms(V), + fmt_t(n * verify_ms(V)), fmt_t(3 * n * verify_ms(V)), fmt_b(V * 60 + 0.0) + " plus %s of key decompression" % fmt_t(V * KEY_DECOMP_US / 1000.0))) + print() + print("## Skipping: one certificate per presence window (240 indices), spec 10.3 item 3's allowance") + print() + print("| voters | certificates per year | bytes per year | verify per year, laptop core |") + print("|---|---|---|---|") + for V in (93, 1000, 8192, 65536): + k = n // 240 + print("| %d | %d | %s | %s |" % (V, k, fmt_b(k * (cert_bytes(V) + HEADER_B)), fmt_t(k * verify_ms(V)))) + print() + print("What skipping does not pay for: the voter set at each skipped-to checkpoint (spec 10.4 item 3), which is 30 days of headers (W2) " + "or a trusted answer from N of M nodes. Full-header mode per year: %s of headers at 1 block/s (86,400 x 365 x %d B), plus the bodies' finality sections (O-10.1)." % (fmt_b(86400 * 365 * HEADER_B), HEADER_B)) + print() + print("## The ZK light client: one recursive proof per checkpoint") + print() + print("Per-step statement: certificate i verifies under the voter table T_i; T_i follows from T_(i-1) by the 30 blue headers of the interval and the window's ageing; " + "signers hold at least 2/3 of T_i and of the frozen table (Q3, Q5); C_i's selected chain passes through C_(i-1). Cycle estimates in the SP1 6.8.1 zkVM, approximate, " + "from the precompile list of sp1-core-executor-6.8.1/src/syscall_code.rs (BLS12381_ADD, BLS12381_DOUBLE, BLS12381_FP_ADD/SUB/MUL, BLS12381_FP2_ADD/SUB/MUL; no pairing precompile):") + print() + print("| Part | Work | Approximate cycles | Note |") + print("|---|---|---|---|") + for V in (93, 1000, 8192): + print("| key aggregation, %d voters | %d x BLS12381_ADD | %s | one precompile call per set bit, about 500 cycles each (approximate) |" % (V, V, "{:,}".format(V * 500))) + print("| hash to G2 | SHA-256 (precompiled) plus Fp2 arithmetic | about 300,000 | approximate |") + print("| two pairings | Miller loops and the final exponentiation in Fp2/Fp6/Fp12 arithmetic built from the Fp2 precompiles | 10 to 30 million | approximate; no pairing precompile in 6.8.1, this is the dominant term |") + print("| 30 header hashes and the table transition | 30 x BLAKE2b (no precompile, about 30,000 cycles each) plus a Merkle update per key touched | about 1 to 2 million | approximate |") + print("| recursion: verify the previous step's proof | VERIFY_SP1_PROOF (the deferred-proof path) | the measured aggregation step | measured on the RTX 5090: 2.2 to 2.5 s idle, 7.9 to 9.7 s beside the miner (bench-log, agg-cost and chain-pc2-pv1c) |") + print() + print("Prover time per checkpoint, from the measured shard curve (docs/analysis/prover-tiers-real-cards.md: 4,717,439 cycles compressed in 4.8 to 14.4 s alone, 10.7 to 37.5 s beside the miner): " + "a 15 to 35 million cycle step is about 3 to 7 shards' worth, so 15 to 100 s alone and 40 to 260 s beside a miner, approximate; one checkpoint every 30 s therefore needs 1 to 4 proving-only cards, or 2 to 9 mining cards, kept at it continuously, plus the recursion step. " + "A phone then verifies one wrapped proof (phase two, spec 10.4), the certificate chain never: the per-year columns above fall to one proof of about 400 B (approximate, O-10.7).") + + +if __name__ == "__main__": + main() diff --git a/sim/horizon/finality-and-weight/merge_results.py b/sim/horizon/finality-and-weight/merge_results.py new file mode 100644 index 00000000..e3558e58 --- /dev/null +++ b/sim/horizon/finality-and-weight/merge_results.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Merge the per-candidate outputs (results/out-.md) of finality_horizon.py into combined tables, one per scenario +section, in candidate order. Writes results/combined.md. Run after the box runs: python3 merge_results.py""" +import os +import re + +HERE = os.path.dirname(os.path.abspath(__file__)) +RES = os.path.join(HERE, "results") +ORDER = ["v2", "v3", "leave", "decay1", "decay6", "hyst", "twotier"] +SECTIONS = [("T", "### T."), ("P1", "P1."), ("P2", "P2."), ("P3", "P3."), ("Q1", "Q1."), ("Q2", "Q2.")] + + +def tables(text): + """Return {section: (header lines, row lines)} for the section markers above.""" + out = {} + lines = text.splitlines() + for key, marker in SECTIONS: + try: + i = next(k for k, l in enumerate(lines) if l.startswith(marker)) + except StopIteration: + continue + j = i + while j < len(lines) and not lines[j].startswith("|"): + j += 1 + head = lines[j:j + 2] if j + 1 < len(lines) else [] + rows = [] + k = j + 2 + while k < len(lines) and lines[k].startswith("|"): + rows.append(lines[k]) + k += 1 + out[key] = (head, rows, lines[i]) + return out + + +def main(): + per = {} + for c in ORDER: + p = os.path.join(RES, "out-%s.md" % c) + if os.path.exists(p) and os.path.getsize(p) > 0: + per[c] = tables(open(p).read()) + titles = {"T": "T. Tonight's departure: first lock after x of weight stops mining and signing at once (31 days, seeds 7, 11, 13)", + "P1": "P1. Partitions of 360 minutes (each side retargets and counts only its own blocks)", + "P2": "P2. The poisoned eclipse (a 34% attacker plus a 20% pool; the eclipsed side holds 54% of total)", + "P3": "P3. Long honest partitions with view-local weight, 12 days", + "Q1": "Q1. Silent weight that keeps mining for 6 hours, then resumes", + "Q2": "Q2. Acquired keys that sign, stay silent or leave, under v3 + leave 1 h (30 days)"} + out = ["# Combined results of finality_horizon.py (candidates: %s)" % ", ".join(per), ""] + for key, _ in SECTIONS: + head = None + rows = [] + for c in ORDER: + if c in per and key in per[c]: + h, r, _ = per[c][key] + head = head or h + if key == "T": + # rows are per departed weight then rule; keep the candidate's rows, sorted later by weight + rows.extend(r) + else: + rows.extend(r) + if head is None: + continue + if key == "T": + def wkey(row): + m = re.match(r"\| (\d+)% \| (\w+) ", row) + return (int(m.group(1)), ORDER.index(m.group(2))) if m else (99, 99) + rows.sort(key=wkey) + out.append("## " + titles[key]) + out.append("") + out.extend(head) + out.extend(rows) + out.append("") + open(os.path.join(RES, "combined.md"), "w").write("\n".join(out)) + print("\n".join(out)) + + +if __name__ == "__main__": + main() diff --git a/sim/horizon/finality-and-weight/results/combined.md b/sim/horizon/finality-and-weight/results/combined.md new file mode 100644 index 00000000..35cddabf --- /dev/null +++ b/sim/horizon/finality-and-weight/results/combined.md @@ -0,0 +1,163 @@ +# Combined results of finality_horizon.py (candidates: v2, v3, leave, decay1, decay6, hyst, twotier) + +## T. Tonight's departure: first lock after x of weight stops mining and signing at once (31 days, seeds 7, 11, 13) + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | v2 | 0.77 to 0.87 d (devnet 3 to 3 min) | | 4057 to 4727 | 1531 to 2489 | 0 | | +| 34% | v3 | 30.00 d (devnet 120 min) | | 86388 to 86472 | 0 to 31 | 0 | | +| 34% | leave | 0.04 to 0.04 d (devnet 0 to 0 min) | | 119 to 521 | 0 to 398 | 0 | | +| 34% | decay1 | 0.05 to 0.05 d (devnet 0 to 0 min) | | 133 to 534 | 0 to 398 | 0 | | +| 34% | decay6 | 0.30 to 0.30 d (devnet 1 to 1 min) | | 897 to 1298 | 24 to 442 | 0 | | +| 34% | hyst | 0.04 to 0.04 d (devnet 0 to 0 min) | | 881 to 1445 | 761 to 1325 | 0 | | +| 34% | twotier | 30.00 d (devnet 120 min) | provisional 0.000 to 0.004 d (devnet 0.0 to 0.0 min) | 86388 to 86472 | 0 to 31 | 0 | 0 | +| 45% | v2 | 7.85 to 8.03 d (devnet 31 to 32 min) | | 23947 to 25153 | 1369 to 2065 | 0 | | +| 45% | v3 | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 45% | leave | 0.04 d (devnet 0 min) | | 119 to 204 | 0 to 83 | 0 | | +| 45% | decay1 | 0.07 to 0.08 d (devnet 0 to 0 min) | | 224 to 296 | 9 to 83 | 0 | | +| 45% | decay6 | 0.64 to 0.67 d (devnet 3 to 3 min) | | 1946 to 1985 | 0 to 129 | 0 | | +| 45% | hyst | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 45% | twotier | 30.00 d (devnet 120 min) | provisional 0.031 to 0.034 d (devnet 0.1 to 0.1 min) | 86382 to 86420 | 0 to 22 | 0 | 0 | +| 50% | v2 | 10.09 to 10.34 d (devnet 40 to 41 min) | | 30107 to 31264 | 1065 to 1456 | 0 | | +| 50% | v3 | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | +| 50% | leave | 0.04 d (devnet 0 min) | | 118 to 490 | 0 to 371 | 0 | | +| 50% | decay1 | 0.08 to 0.09 d (devnet 0 to 0 min) | | 244 to 609 | 0 to 371 | 0 | | +| 50% | decay6 | 0.76 to 0.77 d (devnet 3 to 3 min) | | 2231 to 2543 | 0 to 371 | 0 | | +| 50% | hyst | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | +| 50% | twotier | 30.00 d (devnet 120 min) | provisional 0.042 d (devnet 0.2 min) | 86387 to 86514 | 0 to 10 | 0 | 0 | + +## P1. Partitions of 360 minutes (each side retargets and counts only its own blocks) + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / 279 (never in 2 of 3) | yes | 0 to 0 | 0 | +| v2 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 142 to 291 | | 14 to 77 | 11 to 48 / 0 to 76 | yes | 0 to 0 | 0 | +| v2 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v2 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v2 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| v3 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| v3 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v3 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| leave | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| leave | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| leave | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| decay1 | 50/50 honest, 0% attacker | 360 | 467 to 473 | | 121 to 126 | 120 to 122 / 121 to 123 | yes | 0 | 0 | +| decay1 | 50/50 + 20% equivocator (sides 60/60) | 360 | 528 to 535 | | 93 to 94 | 91 / 92 to 94 | yes | 0 | 0 | +| decay1 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 587 to 595 | | 64 to 65 | 62 / 63 to 65 | yes | 0 to 0 | 0 | +| decay1 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 575 to 612 | | 14 to 62 | 12 to 50 / 0 to 62 | yes | 0 to 0 | 0 | +| decay1 | 40/40/20 honest | 360 | 815 to 822 | | 142 to 144 | 140 to 142 / 140 to 140 / 166 to 166 | yes | 0 to 0 | 0 | +| decay1 | 60/40 honest | 360 | 435 to 436 | | 142 to 142 | 92 to 96 / 141 to 142 | yes | 0 to 0 | 0 | +| decay1 | 70/30 honest | 360 | 403 to 414 | | 154 to 156 | 0 to 4 / 154 to 156 | yes | 0 | 0 | +| decay6 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| decay6 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| decay6 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| hyst | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| hyst | 50/50 + 20% equivocator (sides 60/60) | 360 | 565 to 597 | | 60 to 61 | 58 to 61 / 58 to 61 | yes | 0 | 0 | +| hyst | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 596 to 603 | | 60 to 62 | 59 to 60 / 60 to 62 | yes | 0 to 0 | 0 | +| hyst | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 160 to 342 | | 14 to 60 | 12 to 50 / 0 to 60 | yes | 0 to 0 | 0 | +| hyst | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| hyst | 60/40 honest | 360 | 0 | | never | 58 to 61 / never | yes | 0 to 0 | 0 | +| hyst | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | +| twotier | 50/50 honest, 0% attacker | 360 | 0 | 589 to 603 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | 653 to 660 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | 705 to 715 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | 713 to 720 | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| twotier | 40/40/20 honest | 360 | 0 | 1056 to 1062 | never | never / never / never | yes | 0 to 0 | 0 | +| twotier | 60/40 honest | 360 | 0 | 516 to 564 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 70/30 honest | 360 | 0 | 523 to 532 | never | 0 to 4 / never | yes | 0 | 0 | + +## P2. The poisoned eclipse (a 34% attacker plus a 20% pool; the eclipsed side holds 54% of total) + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| v2 | 1 | 0 | | never | 0 | 0 | 0 | +| v2 | 2 | 0 | | never | 0 | 0 | 0 | +| v2 | 4 | 0 | | never | 0 | 0 | 0 | +| v3 | 1 | 0 | | never | 0 | 0 | 0 | +| v3 | 2 | 0 | | never | 0 | 0 | 0 | +| v3 | 4 | 0 | | never | 0 | 0 | 0 | +| leave | 1 | 0 | | never | 0 | 0 | 0 | +| leave | 2 | 0 | | never | 0 | 0 | 0 | +| leave | 4 | 0 | | never | 0 | 0 | 0 | +| decay1 | 1 | 0 | | never | 0 | 0 | 0 | +| decay1 | 2 | 17 to 24 | | 109 to 111 | 22 to 24 | 0 | 0 | +| decay1 | 4 | 257 to 264 | | 109 to 111 | 263 to 265 | 0 | 0 | +| decay6 | 1 | 0 | | never | 0 | 0 | 0 | +| decay6 | 2 | 0 | | never | 0 | 0 | 0 | +| decay6 | 4 | 0 | | never | 0 | 0 | 0 | +| hyst | 1 | 0 | | never | 0 | 0 | 0 | +| hyst | 2 | 0 | | never | 0 | 0 | 0 | +| hyst | 4 | 0 | | never | 0 | 0 | 0 | +| twotier | 1 | 0 | 19 to 24 | never | 0 | 0 | 0 | +| twotier | 2 | 0 | 137 to 145 | never | 0 | 0 | 0 | +| twotier | 4 | 0 | 377 to 385 | never | 0 | 0 | 0 | + +## P3. Long honest partitions with view-local weight, 12 days + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 | 12 | 10.15 to 10.18 / 10.12 to 10.34 | 2890 to 3550 | | 10.22 to 10.35 d | yes | 0 | +| v2 | 60/40 | 12 | 5.15 to 5.27 / never | 0 | | never | yes | 0 to 0 | +| v3 | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| v3 | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | +| leave | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| leave | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | +| decay1 | 50/50 | 12 | 0.08 to 0.08 / 0.08 to 0.09 | 34146 to 34212 | | 0.08 to 0.09 d | yes | 0 | +| decay1 | 60/40 | 12 | 0.06 to 0.07 / 0.10 to 0.10 | 33931 to 34254 | | 0.10 to 0.10 d | yes | 0 to 0 | +| decay6 | 50/50 | 12 | 0.77 to 0.77 / 0.75 to 0.78 | 32120 to 32246 | | 0.77 to 0.78 d | yes | 0 | +| decay6 | 60/40 | 12 | 0.52 / 0.92 to 0.93 | 31545 to 31873 | | 0.93 to 0.93 d | yes | 0 to 0 | +| hyst | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| hyst | 60/40 | 12 | 0.04 to 0.04 / never | 0 | | never | yes | 0 to 0 | +| twotier | 50/50 | 12 | never / never | 0 | 34267 to 34418 | never | yes | 0 | +| twotier | 60/40 | 12 | never / never | 0 | 34094 to 34381 | never | yes | 0 to 0 | + +## Q1. Silent weight that keeps mining for 6 hours, then resumes + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| v2 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v2 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v2 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v3 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v3 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v3 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| leave | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| leave | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| leave | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| decay1 | 30% | 6 | 0 | 0 | 1 | 0 to 0 | 0 | +| decay1 | 34% | 6 | 66 to 68 | 134 to 135 | 66 to 68 | 0 to 0 | 0 | +| decay1 | 45% | 6 | 108 to 112 | 217 to 229 | 108 to 112 | 0 to 0 | 0 | +| decay6 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| decay6 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| decay6 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| hyst | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| hyst | 34% | 6 | 59 to 61 | 120 | 59 to 61 | 0 to 0 | 0 | +| hyst | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| twotier | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| twotier | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| twotier | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + +## Q2. Acquired keys that sign, stay silent or leave, under v3 + leave 1 h (30 days) + +| bought weight | bought keys | attacker's peak share of the denominator | share at the end | holds the veto (1/3) | stalled checkpoints | conflicting locks | +|---|---|---|---|---|---|---| +| 40% | sign | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 0 | 0 | +| 40% | silent | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 86402 to 86491 | 0 | +| 40% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 119 to 144 | 0 | +| 49% | sign | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 0 | 0 | +| 49% | silent | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 86371 to 86408 | 0 | +| 49% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 143 to 188 | 0 | diff --git a/sim/horizon/finality-and-weight/results/err-decay1.log b/sim/horizon/finality-and-weight/results/err-decay1.log new file mode 100644 index 00000000..da660639 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-decay1.log @@ -0,0 +1,3 @@ +(T took 212 s) +(P took 77 s) +(Q took 4 s) diff --git a/sim/horizon/finality-and-weight/results/err-decay6.log b/sim/horizon/finality-and-weight/results/err-decay6.log new file mode 100644 index 00000000..0458d1a9 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-decay6.log @@ -0,0 +1,3 @@ +(T took 215 s) +(P took 78 s) +(Q took 4 s) diff --git a/sim/horizon/finality-and-weight/results/err-hyst.log b/sim/horizon/finality-and-weight/results/err-hyst.log new file mode 100644 index 00000000..46b1a31d --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-hyst.log @@ -0,0 +1,3 @@ +(T took 196 s) +(P took 68 s) +(Q took 3 s) diff --git a/sim/horizon/finality-and-weight/results/err-leave.log b/sim/horizon/finality-and-weight/results/err-leave.log new file mode 100644 index 00000000..5a4e3e07 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-leave.log @@ -0,0 +1,3 @@ +(T took 205 s) +(P took 68 s) +(Q took 508 s) diff --git a/sim/horizon/finality-and-weight/results/err-twotier.log b/sim/horizon/finality-and-weight/results/err-twotier.log new file mode 100644 index 00000000..0b29aa68 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-twotier.log @@ -0,0 +1,3 @@ +(T took 204 s) +(P took 73 s) +(Q took 3 s) diff --git a/sim/horizon/finality-and-weight/results/err-v2.log b/sim/horizon/finality-and-weight/results/err-v2.log new file mode 100644 index 00000000..2040d260 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-v2.log @@ -0,0 +1,3 @@ +(T took 173 s) +(P took 59 s) +(Q took 3 s) diff --git a/sim/horizon/finality-and-weight/results/err-v3.log b/sim/horizon/finality-and-weight/results/err-v3.log new file mode 100644 index 00000000..372df15d --- /dev/null +++ b/sim/horizon/finality-and-weight/results/err-v3.log @@ -0,0 +1,3 @@ +(T took 191 s) +(P took 66 s) +(Q took 3 s) diff --git a/sim/horizon/finality-and-weight/results/out-decay1.md b/sim/horizon/finality-and-weight/results/out-decay1.md new file mode 100644 index 00000000..22086dd1 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-decay1.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | decay1 | 0.05 to 0.05 d (devnet 0 to 0 min) | | 133 to 534 | 0 to 398 | 0 | | +| 45% | decay1 | 0.07 to 0.08 d (devnet 0 to 0 min) | | 224 to 296 | 9 to 83 | 0 | | +| 50% | decay1 | 0.08 to 0.09 d (devnet 0 to 0 min) | | 244 to 609 | 0 to 371 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| decay1 | 50/50 honest, 0% attacker | 360 | 467 to 473 | | 121 to 126 | 120 to 122 / 121 to 123 | yes | 0 | 0 | +| decay1 | 50/50 + 20% equivocator (sides 60/60) | 360 | 528 to 535 | | 93 to 94 | 91 / 92 to 94 | yes | 0 | 0 | +| decay1 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 587 to 595 | | 64 to 65 | 62 / 63 to 65 | yes | 0 to 0 | 0 | +| decay1 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 575 to 612 | | 14 to 62 | 12 to 50 / 0 to 62 | yes | 0 to 0 | 0 | +| decay1 | 40/40/20 honest | 360 | 815 to 822 | | 142 to 144 | 140 to 142 / 140 to 140 / 166 to 166 | yes | 0 to 0 | 0 | +| decay1 | 60/40 honest | 360 | 435 to 436 | | 142 to 142 | 92 to 96 / 141 to 142 | yes | 0 to 0 | 0 | +| decay1 | 70/30 honest | 360 | 403 to 414 | | 154 to 156 | 0 to 4 / 154 to 156 | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| decay1 | 1 | 0 | | never | 0 | 0 | 0 | +| decay1 | 2 | 17 to 24 | | 109 to 111 | 22 to 24 | 0 | 0 | +| decay1 | 4 | 257 to 264 | | 109 to 111 | 263 to 265 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| decay1 | 50/50 | 12 | 0.08 to 0.08 / 0.08 to 0.09 | 34146 to 34212 | | 0.08 to 0.09 d | yes | 0 | +| decay1 | 60/40 | 12 | 0.06 to 0.07 / 0.10 to 0.10 | 33931 to 34254 | | 0.10 to 0.10 d | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| decay1 | 30% | 6 | 0 | 0 | 1 | 0 to 0 | 0 | +| decay1 | 34% | 6 | 66 to 68 | 134 to 135 | 66 to 68 | 0 to 0 | 0 | +| decay1 | 45% | 6 | 108 to 112 | 217 to 229 | 108 to 112 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/results/out-decay6.md b/sim/horizon/finality-and-weight/results/out-decay6.md new file mode 100644 index 00000000..e294d97f --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-decay6.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | decay6 | 0.30 to 0.30 d (devnet 1 to 1 min) | | 897 to 1298 | 24 to 442 | 0 | | +| 45% | decay6 | 0.64 to 0.67 d (devnet 3 to 3 min) | | 1946 to 1985 | 0 to 129 | 0 | | +| 50% | decay6 | 0.76 to 0.77 d (devnet 3 to 3 min) | | 2231 to 2543 | 0 to 371 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| decay6 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| decay6 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| decay6 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| decay6 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| decay6 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| decay6 | 1 | 0 | | never | 0 | 0 | 0 | +| decay6 | 2 | 0 | | never | 0 | 0 | 0 | +| decay6 | 4 | 0 | | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| decay6 | 50/50 | 12 | 0.77 to 0.77 / 0.75 to 0.78 | 32120 to 32246 | | 0.77 to 0.78 d | yes | 0 | +| decay6 | 60/40 | 12 | 0.52 / 0.92 to 0.93 | 31545 to 31873 | | 0.93 to 0.93 d | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| decay6 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| decay6 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| decay6 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/results/out-hyst.md b/sim/horizon/finality-and-weight/results/out-hyst.md new file mode 100644 index 00000000..7358ae6f --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-hyst.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | hyst | 0.04 to 0.04 d (devnet 0 to 0 min) | | 881 to 1445 | 761 to 1325 | 0 | | +| 45% | hyst | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 50% | hyst | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| hyst | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| hyst | 50/50 + 20% equivocator (sides 60/60) | 360 | 565 to 597 | | 60 to 61 | 58 to 61 / 58 to 61 | yes | 0 | 0 | +| hyst | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 596 to 603 | | 60 to 62 | 59 to 60 / 60 to 62 | yes | 0 to 0 | 0 | +| hyst | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 160 to 342 | | 14 to 60 | 12 to 50 / 0 to 60 | yes | 0 to 0 | 0 | +| hyst | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| hyst | 60/40 honest | 360 | 0 | | never | 58 to 61 / never | yes | 0 to 0 | 0 | +| hyst | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| hyst | 1 | 0 | | never | 0 | 0 | 0 | +| hyst | 2 | 0 | | never | 0 | 0 | 0 | +| hyst | 4 | 0 | | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| hyst | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| hyst | 60/40 | 12 | 0.04 to 0.04 / never | 0 | | never | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| hyst | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| hyst | 34% | 6 | 59 to 61 | 120 | 59 to 61 | 0 to 0 | 0 | +| hyst | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/results/out-leave.md b/sim/horizon/finality-and-weight/results/out-leave.md new file mode 100644 index 00000000..1506d984 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-leave.md @@ -0,0 +1,66 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | leave | 0.04 to 0.04 d (devnet 0 to 0 min) | | 119 to 521 | 0 to 398 | 0 | | +| 45% | leave | 0.04 d (devnet 0 min) | | 119 to 204 | 0 to 83 | 0 | | +| 50% | leave | 0.04 d (devnet 0 min) | | 118 to 490 | 0 to 371 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| leave | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| leave | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| leave | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| leave | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| leave | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| leave | 1 | 0 | | never | 0 | 0 | 0 | +| leave | 2 | 0 | | never | 0 | 0 | 0 | +| leave | 4 | 0 | | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| leave | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| leave | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| leave | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| leave | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| leave | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + +Q2. Under v3 + leave 1 h: an attacker at 30% of hashrate buys keys worth 40% or 49% of the window and makes them sign, stay silent, or LEAVE (the leave removes the bought weight from every denominator after 1 h, so the attacker's own share of what remains is w / (1 - L)). Arithmetic: to lock alone it needs w >= 2/3 (1 - L), so w + L >= 2/3 + L/3 >= 2/3 of the window either way; leaving bought keys is never cheaper than signing with them. 30 days: + +| bought weight | bought keys | attacker's peak share of the denominator | share at the end | holds the veto (1/3) | stalled checkpoints | conflicting locks | +|---|---|---|---|---|---|---| +| 40% | sign | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 0 | 0 | +| 40% | silent | 39.8 to 39.8% | 30.0 to 30.0% | day 1 to 19 | 86402 to 86491 | 0 | +| 40% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 119 to 144 | 0 | +| 49% | sign | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 0 | 0 | +| 49% | silent | 48.5 to 48.6% | 30.0 to 30.0% | day 1 to 23 to 24 | 86371 to 86408 | 0 | +| 49% | leave | 30.0 to 30.0% | 30.0 to 30.0% | never | 143 to 188 | 0 | + diff --git a/sim/horizon/finality-and-weight/results/out-twotier.md b/sim/horizon/finality-and-weight/results/out-twotier.md new file mode 100644 index 00000000..c75da400 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-twotier.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | twotier | 30.00 d (devnet 120 min) | provisional 0.000 to 0.004 d (devnet 0.0 to 0.0 min) | 86388 to 86472 | 0 to 31 | 0 | 0 | +| 45% | twotier | 30.00 d (devnet 120 min) | provisional 0.031 to 0.034 d (devnet 0.1 to 0.1 min) | 86382 to 86420 | 0 to 22 | 0 | 0 | +| 50% | twotier | 30.00 d (devnet 120 min) | provisional 0.042 d (devnet 0.2 min) | 86387 to 86514 | 0 to 10 | 0 | 0 | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| twotier | 50/50 honest, 0% attacker | 360 | 0 | 589 to 603 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | 653 to 660 | never | never / never | yes | 0 | 0 | +| twotier | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | 705 to 715 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | 713 to 720 | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| twotier | 40/40/20 honest | 360 | 0 | 1056 to 1062 | never | never / never / never | yes | 0 to 0 | 0 | +| twotier | 60/40 honest | 360 | 0 | 516 to 564 | never | never / never | yes | 0 to 0 | 0 | +| twotier | 70/30 honest | 360 | 0 | 523 to 532 | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| twotier | 1 | 0 | 19 to 24 | never | 0 | 0 | 0 | +| twotier | 2 | 0 | 137 to 145 | never | 0 | 0 | 0 | +| twotier | 4 | 0 | 377 to 385 | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| twotier | 50/50 | 12 | never / never | 0 | 34267 to 34418 | never | yes | 0 | +| twotier | 60/40 | 12 | never / never | 0 | 34094 to 34381 | never | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| twotier | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| twotier | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| twotier | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/results/out-v2.md b/sim/horizon/finality-and-weight/results/out-v2.md new file mode 100644 index 00000000..045e9d1e --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-v2.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | v2 | 0.77 to 0.87 d (devnet 3 to 3 min) | | 4057 to 4727 | 1531 to 2489 | 0 | | +| 45% | v2 | 7.85 to 8.03 d (devnet 31 to 32 min) | | 23947 to 25153 | 1369 to 2065 | 0 | | +| 50% | v2 | 10.09 to 10.34 d (devnet 40 to 41 min) | | 30107 to 31264 | 1065 to 1456 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v2 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / 279 (never in 2 of 3) | yes | 0 to 0 | 0 | +| v2 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 142 to 291 | | 14 to 77 | 11 to 48 / 0 to 76 | yes | 0 to 0 | 0 | +| v2 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v2 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v2 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| v2 | 1 | 0 | | never | 0 | 0 | 0 | +| v2 | 2 | 0 | | never | 0 | 0 | 0 | +| v2 | 4 | 0 | | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| v2 | 50/50 | 12 | 10.15 to 10.18 / 10.12 to 10.34 | 2890 to 3550 | | 10.22 to 10.35 d | yes | 0 | +| v2 | 60/40 | 12 | 5.15 to 5.27 / never | 0 | | never | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| v2 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v2 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v2 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/results/out-v3.md b/sim/horizon/finality-and-weight/results/out-v3.md new file mode 100644 index 00000000..23452be5 --- /dev/null +++ b/sim/horizon/finality-and-weight/results/out-v3.md @@ -0,0 +1,56 @@ +# finality_horizon output (sim/horizon/finality-and-weight/finality_horizon.py, a copy of sim/finality_v2.py at fud-close 1544c63 plus the candidate rules) + +seed 7, slot 30 s, 30 blocks per checkpoint, window 720 h, presence 240 checkpoints, dust 100, quorum 2/3, floor factor 1.00 (a lock needs 66.7% of total under the rule as specified), inter-region delay 2.0 s (intra 0.1 s, jitter sigma 0.25), grace 15 s, uptime 0.97 (mean outage 20 slots), uptime 0.995 for keys at or above 1% of hashrate, honest keys 1000 Pareto 1.0, geography 45%/35%/20% + +### T. Tonight's departure (6 Oct 2026, 18:40Z): a set holding x of weight stops mining and signing at once; first lock after it under each candidate rule; 31 days, seeds 7,11,13 + +Mainnet scale (30-day window). The devnet's window is 7,200 DAA s, so divide by 360: one mainnet day is four devnet minutes. Analytic: v2 recovers at 30 (1 - 1/(3x)) days (never at x <= 1/3), v3 at day 30 (the frozen table's expiry), the leave rule after its delay, the decay rule after T + 1/r x (1 - (1 - x) / (2 x)) hours at the latest (the departed weight has to decay until the stayers hold 2/3 of what is left). + +| departed weight | rule | first final lock after the departure | provisional tier | stalled checkpoints | stalled after the first lock | conflicting final locks | conflicting provisional locks | +|---|---|---|---|---|---|---|---| +| 34% | v3 | 30.00 d (devnet 120 min) | | 86388 to 86472 | 0 to 31 | 0 | | +| 45% | v3 | 30.00 d (devnet 120 min) | | 86382 to 86420 | 0 to 22 | 0 | | +| 50% | v3 | 30.00 d (devnet 120 min) | | 86387 to 86514 | 0 to 10 | 0 | | + +### P. The partition suite under each candidate rule (every side retargets and counts only its own blocks), seeds 7,11,13 + +Pass line: 0 conflicting FINAL locks in every row. A candidate that removes weight from a denominator on what a view does NOT see (silence, missing blocks) is view-dependent: each side of a partition sees the other side as silent, so both denominators shrink and the two sides lock alone, the active-denominator hazard of `sim/results_v2.md` E in a new coat. A candidate that removes weight on what a view DOES see (a signed leave, equivocation evidence) keeps the 4/3 arithmetic of spec 3.11.2 over the weight both sides count. + +P1. Partitions of 360 minutes: + +| rule | case | partition min | conflicting final locks | conflicting provisional locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 2 h after heal | +|---|---|---|---|---|---|---|---|---|---| +| v3 | 50/50 honest, 0% attacker | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 20% equivocator (sides 60/60) | 360 | 0 | | never | never / never | yes | 0 | 0 | +| v3 | 50/50 + 33% equivocator (sides 66.5/66.5) | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 50/50 + 34% equivocator (sides 67/67, the bound) | 360 | 139 to 283 | | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 to 0 | 0 | +| v3 | 40/40/20 honest | 360 | 0 | | never | never / never / never | yes | 0 to 0 | 0 | +| v3 | 60/40 honest | 360 | 0 | | never | never / never | yes | 0 to 0 | 0 | +| v3 | 70/30 honest | 360 | 0 | | never | 0 to 4 / never | yes | 0 | 0 | + +P2. The poisoned eclipse (L3: a 34% attacker feeds a 20% pool a private fork and signs both; the eclipsed side holds 54% of total): + +| rule | eclipse h | conflicting final locks | conflicting provisional locks | first conflict, min | locks on the eclipsed side | honest-side stalls during | stalls after heal | +|---|---|---|---|---|---|---|---| +| v3 | 1 | 0 | | never | 0 | 0 | 0 | +| v3 | 2 | 0 | | never | 0 | 0 | 0 | +| v3 | 4 | 0 | | never | 0 | 0 | 0 | + +P3. Long honest partitions with view-local weight (L4), 12 days: + +| rule | honest split | days | first lock per side, day | conflicting final locks | conflicting provisional locks | first conflict | every pre-heal lock kept | first lock after heal, min | +|---|---|---|---|---|---|---|---|---| +| v3 | 50/50 | 12 | never / never | 0 | | never | yes | 0 | +| v3 | 60/40 | 12 | never / never | 0 | | never | yes | 0 to 0 | + +### Q. Silent weight that keeps mining (L1) under each candidate, and acquired keys that LEAVE under the leave rule; seeds 7,11,13 + +Q1. A set holding x of weight stops signing but keeps mining for 6 h, then resumes (its weight never ages out): + +| rule | silent weight | silent hours | first lock after the stop, min | stalled while silent | longest gap, min | first lock after resume, min | conflicting locks | +|---|---|---|---|---|---|---|---| +| v3 | 30% | 6 | 0 | 0 to 40 | 1 to 8 | 0 to 0 | 0 | +| v3 | 34% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | +| v3 | 45% | 6 | never | 714 to 722 | 360 | 0 to 0 | 0 | + + diff --git a/sim/horizon/finality-and-weight/weight_capture.py b/sim/horizon/finality-and-weight/weight_capture.py new file mode 100644 index 00000000..5aa02517 --- /dev/null +++ b/sim/horizon/finality-and-weight/weight_capture.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Weight capture cost (Horizon lane 3, task 3): what it costs in rented hash to hold W of the 30-day window weight +against a network of N GH/s, and what buying old keys is worth. Pure arithmetic from spec 03 W2 (weight = blue blocks over a +flat 30-day window) and the rental price of the fleet's bench entry. + +Model (every input labelled): + * Weight share of an attacker who rents A GH/s against a network of N GH/s for t days (t <= 30), spec 3.1 headline, + verified by sim/results_v2.md B to 0.04 points: share(t) = (t / 30) x A / (N + A). + * To hold W at day t: A = N x q / (1 - q) with q = 30 W / t, which needs t > 30 W (q < 1). + * Cost = A x t x 24 x price, price = USD 11.7 per GH/s-hour (MEASURED: docs/bench-log.md "Rental cost of hash, + 6 October 2026", 1,748 MH/s for USD 20.44/h on RunPod community pods, USD 0.0117 per MH/s-hour). + * Cost is decreasing in t (cost = 24 x price x N x 30 W / (1 - 30 W / t)), so the cheapest attack takes the whole window: + t = 30, A = N W / (1 - W), cost = 8,424 x N x W / (1 - W) USD per GH/s of network. + * A bought key is worth the blocks it holds and nothing more (spec 3.11.5, sim K): keys worth b of the window are the + position of having rented b / (1 - b) x N for 30 days, so their replacement cost is the same formula at W = b. + +Run: python3 weight_capture.py (markdown tables on stdout) +""" +PRICE_GHS_HOUR = 11.7 # USD, measured 6 Oct 2026 (bench-log "Rental cost of hash") +PRICE_LABEL = "USD 11.7 per GH/s-hour, measured 6 Oct 2026 on RunPod community pods (bench-log)" + + +def rented(N, W, t): + q = 30.0 * W / t + if q >= 1.0: + return None + return N * q / (1.0 - q) + + +def cost(N, W, t): + A = rented(N, W, t) + return None if A is None else A * t * 24.0 * PRICE_GHS_HOUR + + +def fmt_usd(x): + if x is None: + return "impossible (needs t > 30 W days)" + if x >= 1e6: + return "USD %.2f M" % (x / 1e6) + if x >= 1e3: + return "USD %.0f k" % (x / 1e3) + return "USD %.0f" % x + + +def main(): + print("## Weight capture by rented hash (price %s)" % PRICE_LABEL) + print() + print("Share of the 30-day window after t days at A GH/s against N GH/s: (t/30) x A/(N + A). The hashrate step the detector sees on day 1: +A/N.") + print() + for W, what in ((0.34, "34%: blocks every lock (the veto)"), (0.51, "51%"), (0.67, "67%: locks alone")): + print("### W = %s" % what) + print() + rows = [] + for t in (30, 25, 22, 21): + r = rented(1.0, W, t) + rows.append(["%d days" % t, "impossible" if r is None else "%.2f x N (hash step +%.0f%%)" % (r, 100 * r)] + + [fmt_usd(cost(N, W, t)) for N in (1, 10, 100, 1000)]) + print("| held from day | rented hash | N = 1 GH/s | N = 10 GH/s | N = 100 GH/s | N = 1 TH/s |") + print("|---|---|---|---|---|---|") + for r in rows: + print("| " + " | ".join(r) + " |") + print() + print("### What the market could supply tonight (measured): asked for 20 pods of any of 8 card types at 18:59Z to 19:15Z, RunPod gave 0; 38 pods were 1.75 GH/s. " + "So at N = 1 GH/s the 0.52 x N for a veto is rentable for the price of a dinner; at N = 100 GH/s the 52 GH/s does not exist to rent on any one market (approximate: the fleet asked one provider).") + print() + print("## Buying old keys (F19) against renting") + print() + print("| Route | What it buys | Price floor | Decay | Detectable |") + print("|---|---|---|---|---|") + for b in (0.20, 0.34, 0.40): + print("| keys worth %d%% of the window | %d%% of weight on day 0, falling as b (1 - t/30) + r t/30 (sim K, within 0.6 points) | the seller's own 30-day rental equivalent: %s per GH/s of network (same formula at W = b); a pool's key is also its payout identity and reputation, so the price is the pool, not the key | gone in 30 days unless the buyer mines | the key's blocks stop matching its hash (the detector of lane 1); W5 succession is public |" + % (100 * b, 100 * b, fmt_usd(cost(1.0, b, 30)))) + print("| renting the same share | the same weight 30 days later, in public on the hashrate chart from day 1 | the table above | the same | day 1: the hashrate step |") + print() + print("What makes weight unbuyable: nothing; what makes it decay: the window (every block leaves 30 days after it was mined whoever holds the key). " + "What keeps the price at the rental cost: a key is one 32-byte scalar, the seller can keep a copy, and one equivocation by either holder strips it for 30 days (spec 3.6), " + "so a buyer pays for weight the seller can destroy. What the header does: it names vote_key_hash, so a sale is invisible until the key's blocks stop matching its old hash profile.") + + +if __name__ == "__main__": + main()