Compare commits

..

381 commits

Author SHA1 Message Date
igneum-labs
20215f0274 Merge scrub-3 ff0d0dde into master (gate: green on ff0d0dde, recorded by tools/ci/pre-push.sh; landed on the box mirror under the exception declared by main: main's ruling, 7 Oct 2026 19:5x UK: the GitHub account is suspended, lanes land on the box mirror's master, the box gate stamp is the verdict; GitHub gets the fast-forward when it answers) 2026-10-07 20:46:40 +00:00
igneum-labs
21d956e4bd Merge ship-docs-0321 a7ea56cd into master (gate: green on a7ea56cd, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 20:40:42 +00:00
igneum-labs
ff0d0dde61 Scrub: the founder list leaves the repository in every encoding; the never-push founder check decodes base64, hex and .b64 blobs too (7 October 2026, 21:4x UK)
tools/ci/founder-strings.b64 was the founder check's pattern list base64-encoded: a grep could not read it, any reader of the public host could (git.igneum.network was public from 21:26 UK; read off at 21:36). The list now lives only in a private file (~/.config/igneum/founder-strings on the Mac, /srv/discord-hooks/founder-strings on build-1 for the Discord guard; $IGNEUM_FOUNDER_STRINGS overrides) and site/forbidden-strings.txt carries no encoded copy. Readers: founder-strings-check.sh (skips with a line where the file is absent; the Mac's hook is the guard), site/scrub.mjs and launch-gates-check.mjs (the private file's patterns added where it exists), discord-hooks.mjs (three locations; the test writes a fixture list and loads the module after it), fresh-repo.sh (the private file; drops tools/ci/founder-strings.b64 from every commit; rewrites the base64 of every list regex out of every blob and scans for it).

The check's second pass (main's addition): every base64 literal of 24 characters or more, every hex literal of 24 or more and every *.b64 file is decoded and scanned, so no encoding hides a term again; the self-test plants each fixture sample in plain text, in a .b64 file, as a base64 literal and as a hex literal, each caught and named, and a tree without the list skips with its line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:40:36 +00:00
igneum-labs
f3bb75cad3 Merge ca3-coord 42fd7c9b into master (gate: green on 42fd7c9b, recorded by tools/ci/pre-push.sh; landed on the build mirror) 2026-10-07 20:36:24 +00:00
igneum-labs
a7ea56cd7b rule 15 at six places; release 0.3.22: the 0.3.22 Windows take 2 fault and the skip, the version miss's second layer, the 0.3.23 pairs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:35:17 +00:00
igneum-labs
07e21189dd Merge site-shots 4944ef6f into master (gate: green on 4944ef6f, recorded by tools/ci/pre-push.sh; landed on the box mirror) 2026-10-07 20:35:10 +00:00
igneum-labs
42fd7c9bc5 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 20:30:55 +00:00
igneum-labs
4944ef6fd0 Site screenshots (the project lead, 7 October 2026: "the screenshots are a bit narrow"): the app shots re-taken from the 0.3.22 UI on the mock at the app's 1080p opening size, 1440 by 900 at 2x (2880 by 1800; the dashboard caps its content at 1080 px, so a 1920-wide window only adds gutters), dark and light, with the GPU marks, the fixed chain key and the fixed Prove switch; the phone pair at 390 by 844; each shot that carries a page sits on its own row at the content's full width with its text as the lead (feat-row and shots3 one column, the miner's meet section stacked, the wallet hero stacked, the history frame's 880 px cap gone), two-up only where both would be 600 px or wider, which no width of this site gives; the img width and height carry the new sizes so the aspect boxes do not jump; before: /app shots rendered 472 to 708 px of a 1206 px content column, /miner's three at 390 px, /wallet's home at 506 px; the wallet shots keep their 2240 by 1560 captures (no wallet mock exists) and gain the width
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:30:32 +00:00
igneum-labs
21a501f6be Chip model 5.10: class v5 on with the shadow at zero leaves the stored-dataset chip at 5.1x (GDDR7) to 9.1x (HBM3) per joule because the node is a farm cost, not a chip cost; the shadow stays the only lever in the model (the founder's question, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:29:26 +00:00
igneum-labs
f51da33a65 tools/ci/release-version-check.sh (rule 15): a release-0.3.N branch reads 0.3.N in Cargo.toml, Cargo.lock and version.h from its first commit; self-test on the 0.3.23 shape; wired into the pre-push gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:29:07 +00:00
igneum-labs
ffbd88ab25 release rules 15 (the version bump as the release branch's first commit, gated); release 0.3.22 section 11: the 0.3.23 cut at f7645269 with the fold and the version miss, the 0.3.22 Windows take 1 fault and take 2, the Devnet 3 passes, the LG-4 shape on PC 2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:27:39 +00:00
igneum-labs
8a80979735 Merge ca3-coord e0964e91 into master (gate: green on e0964e91, recorded by tools/ci/pre-push.sh; landed on the build mirror) 2026-10-07 20:26:27 +00:00
igneum-labs
e0964e91e5 Site: the GPU bench table fits the article column (nine columns, the generator in the detail row, text wraps, numbers do not); Counter ASIC 3.0 status: the v5 fast-time lines on 959b57c9, the restart-step fault and its fix a3b2049d, the second pair 63524e28, the pass's tally and adv-accept-2's close
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:20:32 +00:00
igneum-labs
d74bd5ca21 Merge ca3-coord 55863f81 into master (gate: green on 55863f81, recorded by tools/ci/pre-push.sh; landed on the build mirror) 2026-10-07 20:16:18 +00:00
igneum-labs
52e88fe57e Merge ship-docs-0321 81060708 into master (gate: green on 81060708, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 20:09:08 +00:00
igneum-labs
55863f815c Merge build/master into ca3-coord (the deploy script takes master's text)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:07:42 +00:00
igneum-labs
00a990efc9 Pre-public scrub: the status file and the deploy script follow master's text pass (the founder named as the founder, the login's pre-rename spelling out of the comment); Counter ASIC 3.0 status: the (c''') census number (0.995 rejects 112 of 4,600, 2.435 percent; the defender keeps 0.995)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:06:10 +00:00
igneum-labs
e793ba2629 Merge scrub-2 90135f0a into master (gate: green on 90135f0a, recorded by tools/ci/pre-push.sh; landed on the box mirror under the exception declared by main: main's ruling, 7 Oct 2026 19:5x UK: the GitHub account is suspended, lanes land on the box mirror's master, the box gate stamp is the verdict; GitHub gets the fast-forward when it answers) 2026-10-07 20:04:02 +00:00
igneum-labs
81060708c5 release 0.3.22 section 10: the chain id row as the node lane corrected it (the id as a function of the block's DAA at the v5 floor; the replay window to the floor's crossing; the uniqueness table)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:03:29 +00:00
igneum-labs
52513bdb44 Site: the GPU bench table reads at 1600 px: nine compact sortable columns one line wide (card, generator, MH/s, watts, MH per watt, class v4 cost short, tuned short, Hive core / mem / PL, date, who), the long fields in a detail row under each card that sorts with it (the 20:58 BST capture showed eleven columns clipped at five, rows inflated by off-screen text)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:01:13 +00:00
igneum-labs
3438c3f1e2 release 0.3.22 section 10: the shared chain id 4463 and its replay window as a fact, Devnet 3 to 4464 in 0.3.24, the chain-id uniqueness gate, the steward's two items closed, the second proven-share hour, the pool hour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:00:19 +00:00
igneum-labs
90135f0ae1 scrub: the login's pre-rename spelling out of tools/site-deploy-from-mirror.sh (landed on the mirror after the sweep)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:58:41 +00:00
igneum-labs
5206a5faed Merge remote-tracking branch 'box/master' into scrub-2 2026-10-07 19:58:30 +00:00
igneum-labs
bfd4cb5a0f Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 19:57:43 +00:00
igneum-labs
6930f89775 Merge ship-docs-0321 774deff4 into master (gate: green on 774deff4, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 19:55:54 +00:00
igneum-labs
09a562fabf The founder-strings check is the third never-push class (every push, every branch, 7 s): a site lane's branch, green-stamped before the check existed, carried the founder's name onto the mirror's master in tools/ci/scroll-width-check.mjs at 20:3x UK; the word is fixed here
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:52:55 +00:00
igneum-labs
b0f04eb2f7 Merge remote-tracking branch 'box/master' into scrub-2 2026-10-07 19:52:43 +00:00
igneum-labs
ec5cc35542 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 19:51:14 +00:00
igneum-labs
93d8153df8 Counter ASIC 3.0 status: the merge's duplicated hunk resolved; the bench table's efficiency-pass rows name the desk machine, not its number (the site scrub rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:50:43 +00:00
igneum-labs
774deff40c scroll-width-check: the pre-public scrub (the founder, never the name) in the header comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:50:38 +00:00
igneum-labs
2ebcdd3241 Merge build-server e48a512c into master on the box mirror (GitHub suspended; the box gate stands in for CI)
# Conflicts:
#	infra/build-server/remote-run.sh
2026-10-07 19:49:37 +00:00
igneum-labs
86ad8c5645 release plans: the pre-public scrub (the founder, never the name) across the release notes and the pool plan; rule 7 extended to the pool daemon and the app's CPU re-check with the packs pin travelling with the generator
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:44:44 +00:00
igneum-labs
e48a512c3f lease pool: four priority classes (main, 7 Oct 2026 20:37 BST: the class v5 census sat behind eleven adversarial waiters): release > v5 > measure > adv, from --class or the owner and label; a higher class takes the next freed cores before any lower class whatever the arrival order (lower classes yield while a higher one waits); a lower-class holder above 32 threads is pre-empted (TERM to its command, the lane re-queues) when a higher class has waited 120 s; the class in every line; self-test: the known-failed class-order case (a sweep must not take cores a waiting v5 gate asked for) and the pre-emption case. Also: provision.sh ROLE=sweep (a rented cloud sweep worker, minus runner, caddy, cuda, night, chromium, zig) and infra/build-server/cloud-sweep.sh (Hetzner Cloud CCX workers up/list/cost/down with the lease tool)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:40:32 +00:00
igneum-labs
48a8243930 fresh-repo.sh: the surname and full-name rules are case-insensitive (dry run 3 left lower-case surnames inside grep patterns and a regex on old branches)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:39:04 +00:00
igneum-labs
1a1995d148 fresh-repo.sh: the standing and target addresses are excluded from the personal set as fixed strings (the + in a noreply address is a quantifier under grep -E; dry run 3 read the target's own address as personal)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:37:35 +00:00
igneum-labs
4c9999d603 Chip model section 5: the pebbling-optimum correction from the in-house pass (9.3x at f = 1/64, not 17.4x; the full-store verdict stands); Counter ASIC 3.0 status: the (c''') test green, the pool yield, the restart-step IBD deadlock and its fix, the pass's readings and findings, the pod ledger, the fleet table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:37:23 +00:00
igneum-labs
e9474209d4 Merge the mirror's master into ship-docs-0321 (the exception window; release-0.3.22.md as the union of both sides)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:35:47 +00:00
igneum-labs
7751a91cfd fresh-repo.sh: two filter-repo passes (the text pass first; filter-repo skips --replace-text over blobs under a --file-info-callback: two dry runs on 7 October 2026 rewrote identities and dates and no text), the founder's names and second login from the encoded list as well as the history, the target address never personal, the drop list reduced to docs/review (the ledger and its fixes file are published, decision of 5 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:32:56 +00:00
igneum-labs
42afb977f8 Merge scrub d60d2752 into master (gate: green on d60d2752, recorded by tools/ci/pre-push.sh; landed on the box mirror under the exception declared by main: main's ruling, 7 Oct 2026 19:5x UK: the GitHub account is suspended, lanes land on the box mirror's master, the box gate stamp is the verdict; GitHub gets the fast-forward when it answers) 2026-10-07 19:28:40 +00:00
igneum-labs
235b103928 release 0.3.22 section 9: the 0.3.23 line tonight (no calendar waits), the heights 79,200 / 86,400 / 93,600, class v5 as 0.3.24, the one-crate-tree placements, the testnet text void until the re-cut, PC 1's slot order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:25:35 +00:00
igneum-labs
8e77476a59 lease pool <threads> -- cmd (main, 7 Oct 2026 20:17 BST: adversarial binaries started by hand at 64 to 89 threads, several beside each other, read load 601): a sweep takes up to its thread count of FREE cores from the same 88-core bounded pool as the builds, never fewer than --min, waits while fewer are free, runs pinned at nice 10 with {cores} and {cpuset} substituted and LEASE_CORES exported; the rule is no sweep starts except through it; self-test case added; installed on both boxes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:23:56 +00:00
igneum-labs
18c0b4557d Site: the 5090's locked point (1,400 MHz, 134.98 MH/s at 316.3 W, 0.427 MH/W) and the locked class v4 premium beside the unlocked one in the bench table; Counter ASIC 3.0 status: the efficiency pass's full table, Devnet 3's first lock, the fleet table, the lease-pool rule, the 0.3.24 clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:22:03 +00:00
igneum-labs
43d0f89791 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 19:20:56 +00:00
igneum-labs
e5245bf967 Merge site-22 7cf223fa into master (gate: green on 7cf223fa, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 19:17:32 +00:00
igneum-labs
d60d27525b Merge remote-tracking branch 'box/master' into scrub
# Conflicts:
#	CLAUDE.md
#	docs/plans/counter-asic-3-status.md
#	docs/plans/release-0.3.21.md
#	docs/plans/release-0.3.22.md
#	tools/ci/merge-to-master.sh
#	tools/ci/pre-push.sh
#	tools/ci/red-watch.mjs
2026-10-07 19:16:14 +00:00
igneum-labs
3377a9addb Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 19:15:19 +00:00
igneum-labs
9397fa4235 Counter ASIC 3.0 status: class v5 tonight (the kits' one fingerprint on three platforms, the (c''') floor at 0.995, the two exceptions and the class-signal fix b1680b57), the in-house pass's Q2 bound and the selector widened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:14:39 +00:00
igneum-labs
6274bac261 Merge ci-steward-2 f137c672 into master (gate: green on f137c672, recorded by tools/ci/pre-push.sh; landed on the box mirror under the exception declared by main: main's ruling, 7 Oct 2026 19:5x UK: the GitHub account is suspended, lanes land on the box mirror's master, the box gate stamp is the verdict; GitHub gets the fast-forward when it answers) 2026-10-07 19:14:28 +00:00
igneum-labs
7cf223fa2b Site, 7 October 2026 evening (the project lead's four asks through main, one landing): (1) the bar's nav to the right: the mark and the devnet pill on the left, Mine, Network, Learn and the controls right-aligned with the same gaps and sizes, each open panel anchored under its button (the script sets --panel-left) and a panel that would run past the container aligned to its button's right edge, the phone sheet unchanged; (2) no lateral scroll: tools/ci/scroll-width-check.mjs on the gate (scrollWidth equals clientWidth on every route at 390, 768, 1024, 1280 and 1440 in both themes; only a table, code or diagram scrolls inside its own overflow-x box; self-test with a 100vw plus 20 px fixture known-failed first); (3) LG-5, the launch text: the front page leads with the three wants (hardware that stays useful, income without a cliff, a fair supply) and the live network with finality is the second screen; the litepaper's regulatory note reads "Not legal advice." alone over its eight sentences from future.md 8.3 (the project lead: counsel not yet; the string "counsel is engaged" is in both forbidden-strings lists and gone from the research heading), the pay sentence stays; the journey's phase line reads Devnet 3 live, testnet next; (4) LG-1, /income: docs/analysis/income-tiers.md published as a page by tools/launch/income-page.mjs (checked on the gate against the document), listed under Learn, labels on every number, no price of IGN anywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:12:04 +00:00
igneum-labs
6e3ca1e56e Merge ship-docs-0321 f7aae719 into master (gate: green on f7aae719, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 19:11:18 +00:00
igneum-labs
f7aae719e0 release 0.3.22 section 8: the tree closed at 5a84925b, interface 1.0.2 live, the token rotation (the voided first value), the Mac entry and the hive live in both folders, the first lock, the proven share, the card read, the pool daemon rule, Windows in flight
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:07:19 +00:00
igneum-labs
8e0aa605f7 Build boxes: the bounded POOL (main, 7 Oct 2026 20:0x BST: every bounded run took 88 threads and the boxes read load 280 to 527): cores 8 to 95 form one pool of 88 per box; a bounded run leases free cores from it (one flock per core, shared with the measurement leases), up to its class cap or --jobs and never fewer than 16, waiting in the queue when fewer are free; CARGO_BUILD_JOBS and taskset follow the cores taken, so the sum of bounded threads on a box never passes 88; self-test updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:03:35 +00:00
igneum-labs
0e260bbb87 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 19:01:15 +00:00
igneum-labs
f137c6722f Merge remote-tracking branch 'box/master' into ci-steward-2
# Conflicts:
#	tools/ci/merge-to-master.sh
#	tools/ci/pre-push.sh
2026-10-07 18:57:47 +00:00
igneum-labs
09c2634d2c Pre-public scrub, second pass (7 October 2026, 20:0x UK, main's rulings 2 and 4): the public tree names igneum-labs only; the public ledger generated from the full ledger
Ruling 2: every tracked text mention of the login's pre-rename spelling becomes igneum-labs (29 files); the commit identity in the scripts becomes igneum-labs <337424239+igneum-labs@users.noreply.github.com> (the same noreply id); the three scripts that hand the stored gh keyring name to gh (register.sh, fetch-ci-artifacts.sh, ship-app.mjs) read it from ~/.config/igneum/gh-user (never in the repository), default igneum-labs; fresh-repo.sh reads the login to rewrite from the encoded list's row 10; the old spelling joins the founder-strings list, so no tracked file may spell it; CLAUDE.md's GitHub paragraph rewritten. The red watcher's self-test fixtures use a neutral author.

Ruling 4: docs/ledger-public.md, one row per ledger item (id, the claim in one line, status, what was done in one line, the evidence link or the evidence in words), generated by tools/ledger/export-public.mjs from docs/fud-ledger.md: no round or status-update sections, no commit ids, no time of day, no lane, agent or branch names. 190 items. The gate runs its self-test (a fixture with a commit id, a time, a lane and a branch name comes out clean; --check fails on drift) and its --check on every merge; the founder check reads the output like every tracked file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:56:08 +00:00
igneum-labs
58d7b024c0 Counter ASIC 3.0 status: the no-prompt rule in code, the efficiency pass mid-run (+145 W premium unlocked, 73 W back at 2,550 MHz for 0.05 percent), the in-house pass's first finding (bounded, 1.002x) and main's v5 acceptance order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:54:50 +00:00
igneum-labs
a900b25d88 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 18:54:50 +00:00
igneum-labs
95e682e4e1 Merge attack-pass: the internal attack pass F1 to F10 before the cryptanalysis freeze (every row PASS or FIXED-AND-PASSED; the ten records and harnesses; AP-F1-1, AP-F4-1, AP-F5-1, AP-F8-1/2/3 and the two operating hazards)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

# Conflicts:
#	docs/bench-log.md
#	docs/evidence.md
2026-10-07 18:54:22 +00:00
igneum-labs
65b67e459f Merge genesis-forward 61ed45fa into master (gate: green on 61ed45fa, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 18:51:59 +00:00
igneum-labs
6261a5f034 attack-pass lane (d): class v5 re-runs, F4 PASS (byte-identical to v4); F8, F9, F1 running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:51:55 +00:00
igneum-labs
9701f2a7b9 merge-to-master --remote <name>: land on a box mirror's master while GitHub is unreachable (the box gate stamp as the verdict, the exception named in the merge message); the CI rule binds the GitHub remote only (main's ruling, 7 October 2026, 19:5x UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:50:20 +00:00
igneum-labs
22c4ee4ea7 Merge branch 'ci-steward' into ci-steward-2 2026-10-07 18:49:47 +00:00
igneum-labs
46a8ff39d1 Hook: the CI rule binds a GitHub remote; a box mirror remote or a declared IGNEUM_MASTER_EXCEPTION takes the local gate, printed with the push (main's ruling, 7 October 2026, 19:5x UK: the account suspended, lanes landing on the mirror's master)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:49:47 +00:00
igneum-labs
8cbff34abe Site: the GPU bench table carries the Hive flight-sheet values at each measured tune point (the 5090 1,854 MHz, 13,801 MHz, 460 W; the 4070 1,863 MHz, 10,251 MHz, 100 W; stock elsewhere) and the two-line Hive note (main, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:49:27 +00:00
igneum-labs
93335ad428 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 18:49:10 +00:00
igneum-labs
7012e7e93d Counter ASIC 3.0 status: the site deployed from the mirror, the efficiency pass republished through the Power Helper, the project lead's no-prompt rule, the v5 kits' four readings and zip, the in-house pass's first results and rulings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:48:12 +00:00
igneum-labs
3767bc9d3b Merge live-22 46334354 into master (gate: green on 46334354, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 18:47:27 +00:00
igneum-labs
5ec454fd63 Site: tools/site-deploy-from-mirror.sh deploys the public site from the box mirror's master by a git-less export (the Vercel team's commit-author check blocked a worktree deploy while the GitHub account is suspended, 7 Oct 2026 19:4x BST; a .git in the deploy directory refuses the run; the project's Root Directory is site)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:47:00 +00:00
igneum-labs
61ed45fadc Merge remote-tracking branch 'box/master' into genesis-forward
# Conflicts:
#	infra/fast-time/override-60x.json
2026-10-07 18:46:35 +00:00
igneum-labs
46334354f5 Merge remote-tracking branch 'box/master' into live-22
# Conflicts:
#	docs/plans/release-0.3.21.md
2026-10-07 18:41:19 +00:00
igneum-labs
7eed16a29a Pre-public scrub, the text pass (7 October 2026, 19:5x UK): no founder name, personal login, earlier business or personal address in any tracked text file, and a gate check that keeps it so
The sweep (main's item 1): 199 tracked text files, 783 lines. The founder's full name, first name and possessive become "the founder" (sentence starts capitalised); the lowercase operating-system user name in WSL paths and commands becomes <user>; the second owner login becomes "the second owner login"; the three earlier businesses and the two other brands become "the other business", "the earlier entity", "the earlier business" and "another brand"; the Chrome profile rule names the igneum.network profile, not the profile's label. The standing commit login igneum-labs is not a founder term here: the fresh-repository step renames it in the history (docs/plans/history-rewrite.md, tools/repo/fresh-repo.sh).

The patterns never appear in plain text in the tree (a plaintext list would be the hit): tools/ci/founder-strings.b64 (perl regex, tab, a sample per row) is read by tools/ci/founder-strings-check.sh (every tracked text file, perl, known-failed first: the self-test plants each row's sample in a fixture and the hit must name the file), by tools/community/discord-hooks.mjs (the guard's founder and business rows; the test takes its fixtures from the samples) and by tools/repo/fresh-repo.sh (the business names of the rewrite rules). site/forbidden-strings.txt carries the same patterns as b64: lines, decoded case-insensitive by site/scrub.mjs and tools/ci/launch-gates-check.mjs (whose fixture now plants an encoded made-up name). The check runs in the gate's tree checks on every merge.

Not in this commit, by main's word: the 105 commit messages and 40 personal-identity commits that need the history rewrite (listed, not run), and the secrets found by gitleaks over the history (reported with owners).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:39:50 +00:00
igneum-labs
29b001d10b fast-time 60x file: latency_ladder_cache_rung_activation_daa at never (the cache rung's own switch, the Devnet 3 digest, 7 October 2026; the fork's 60x test reads every field)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:35:12 +00:00
igneum-labs
81ebffa1b6 Site: the GPU bench table sorts on every header (default MH per watt), shows the current class (31 rows, class v4 or class v3 re-measured with its v4 cost) and folds the six earlier-class rows into a collapsed section, with the note on why the rate fell from the genesis program to class v3 and v4 (the project lead, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:34:02 +00:00
igneum-labs
e0a294cf04 Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 18:32:12 +00:00
igneum-labs
dee74890a1 live-22: live.html rebuilt from the base with exact anchors (the first splice had swallowed the statements between the leaderboard and the block table)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:31:43 +00:00
igneum-labs
ca90ecafc2 live-22: the leaderboard render statement closed (a splice had left the old 'and N more' tail inside the new function, so /live's script did not parse and the scene never mounted; the parity gate caught it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:30:16 +00:00
igneum-labs
a15c1381f5 merge-to-master: a landed master is pushed to both box mirrors (the stale-mirror class: build-2's /srv/igneum.git master sat at 15:27's 4e2745ab while GitHub carried 2b9d50cc, 7 October 2026, 18:25 UTC; three branches were cut from the stale tip)
mirror_master runs after the push to origin: one fast-forward push per mirror in IGNEUM_MIRRORS (default: the two box files' hosts at /srv/igneum.git), best effort, a line per mirror, a down or diverged mirror never fails the landing. Self-test: a bare mirror behind master is fast-forwarded; a non-fast-forward push leaves it and is reported.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:30:15 +00:00
igneum-labs
0d0b1c9aa0 Counter ASIC 3.0 status: main's rulings (pods at a USD 200 cap, the agent cap stays)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:28:45 +00:00
igneum-labs
16683758c2 Counter ASIC 3.0 status: the stale build-2 mirror, the placement order, the cryptanalysis tripled with two lanes capped, the pod word pending, PC 1 go
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:28:17 +00:00
igneum-labs
62af55d62f Merge remote-tracking branch 'build/master' into ca3-coord 2026-10-07 18:28:17 +00:00
igneum-labs
5e41217735 Merge build-server 65fdd39d into master on the box mirror (GitHub suspended since 18:02 BST; the box gate stamp stands in for CI; main's rule of 18:1x BST) 2026-10-07 18:23:34 +00:00
igneum-labs
65fdd39d5b Build boxes to near max (the project lead, 7 Oct 2026 19:4x BST): the bounded class is 88 of 96 cores with -j 88 (8 reserved for release builds, the seed and the observers), the jobs rule 88 alone / 44 shared, the spill line 80; checks updated. testnet-go.md: LG-2 waived by the owner, the new gate, the seeds held armed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:23:14 +00:00
igneum-labs
f35a128842 live-22: the gate script is master's own plus the legend, shard-words and leaderboard test files (the release branch's copy had carried a line for a file master does not hold)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:22:23 +00:00
igneum-labs
d21ca54a24 Merge ship-docs-0321 9ee93d78 into master (gate: green on 9ee93d78, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 18:21:29 +00:00
igneum-labs
a7a191e8cd Counter ASIC 3.0 status: the project lead's no-gaps rule, the class v5 six-lane split and the first v5 pack, the cryptanalysis tripled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:21:14 +00:00
igneum-labs
365c27ac01 merge-to-master.sh takes MERGE_REMOTE (default origin): the box mirror's master while GitHub is suspended, 7 October 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:19:47 +00:00
igneum-labs
9ee93d782a release 0.3.22 section 7: the project lead moves the apps to Devnet 3 tonight (the clock), the pin 34a2dbaa with no heights and why, the app tree at 19:15 BST, PC 2's evening and the rules it added
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:17:15 +00:00
igneum-labs
b30552e12a /live: the shard sentence from the block's facts and the leaderboard's top ten (7 October 2026). scene/live-dag.js 2.0.6 (IgneumDag.shardWords, the one shard sentence; legend 2.0.5) synced into site/live-dag.js; the /live inspector prints it per shard and as the count line (a planned shard with no prover reads awaiting a prover with the block's age; an empty plan reads loading, excluded, not yet ordered or off the selected chain, never one fixed sentence); the weight leaderboard shows the top 10 by default with a toggle under the table (Show top 20, then Show all N with the live count, then back), the viewer's own key pinned as an extra row below the ten when it sits outside them, the table in a box of fixed height so the page below never jumps, the tag line unchanged (site/leaderboard.js carries the row logic, site/lib/leaderboard.test.mjs on the gate); tools/site-serve.mjs answers /api/live from IGNEUM_LIVE_FIXTURE for captures and tests off the database; the scene tests bind each half where it lives (site on master, app on the release branches)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:13:23 +00:00
igneum-labs
11888df9a7 Jobs publisher: a remove refuses a job the target's latest report shows running, and any job published with --installs-app, unless --force \"<reason>\" (7 Oct 2026 18:53 BST: a removal reached PC 2 one second after its job launched a silent installer over the running app; the runner's abort ended the process tree and the app went dark); tools/ci/publish-jobs-check.sh in the gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:10:18 +00:00
igneum-labs
4730571d25 Merge GitHub master 40a7fbe0 into the mirror's master (diverged while GitHub is suspended: the shipper's ship-docs merges on the mirror, the site and ca3 landings on GitHub; the union, so the mirror's master is the one line GitHub's master takes when it lifts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

# Conflicts:
#	docs/plans/release-0.3.21.md
2026-10-07 18:05:45 +00:00
igneum-labs
5114936edf Merge build/master into ca3-coord (release-0.3.21.md keeps both sides); Counter ASIC 3.0 status: Devnet 3's proof window, the utilisation table, the bench table, the v5 gap list, the in-house pass, the PC 1 queue
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:05:34 +00:00
igneum-labs
22f1095044 Site: the GPU bench table carries the fleet's rows (every rentable card on the hash, 7 October 2026: 5080, 3070 and 3070 Ti, 3080 and 3080 Ti, 3090 and 3090 Ti, 4090, 3060 and 3060 Ti, 4060 Ti, 4070 Ti, 5060, 5070 and 5070 Ti, A5000, L40S, A100 PCIe and SXM, H100 and H200, B200, RTX PRO 6000) with watts, MH per watt, driver and the tuned state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:04:04 +00:00
igneum-labs
a83e6e4c58 Site: the GPU bench table carries every measured card (5090 stock and tuned, 4070, 9070 XT, M5 Max, 5060 Ti, Arc B580, H100 SXM) with watts, MH per watt, the class v4 cost per card and the tuned state; the fleet's 3070, 3080, 4090 and 5080 rows follow
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:57:47 +00:00
igneum-labs
f070417c7b HiveOS package: --kit <zip> (repeatable) puts program-pack kits under packs/ in the tar; h-run.sh seeds packs/devnet from a shipped pack only when the node's export left nothing (the first-start convenience, never the authority; SHIPPED_PACK, default v4-devnet3-epoch0); README line (0.3.22, 7 Oct 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:56:30 +00:00
igneum-labs
b35bf8f16f Counter ASIC 3.0 status: the Devnet 3 epoch-0 pack, the project lead's three orders and the in-house correction (class v5 now, the internal adversarial pass, the PC 1 measurements)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:54:43 +00:00
igneum-labs
f88783d09f Site and evidence: the chip model's next test is the internal adversarial pass (three lanes new to the hash code, outsider inputs only, reports published whole), not external lots; the one outside check staged on its escrow and the publish word (the project lead's correction, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:54:43 +00:00
igneum-labs
1a4ed8bb86 release 0.3.22: proving on Devnet 3 opens (first segment submitted 18:45 BST, the sizing), the 0.3.21 warm cases end and why the relay cell reads on Devnet 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:52:42 +00:00
igneum-labs
5fb6d2b10f Merge ship-docs-0321 66190129 into master (gate: green on 66190129, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 17:46:56 +00:00
igneum-labs
66190129a7 release 0.3.21 section 7: the Mac entry LIVE 18:41:50 BST, Windows as its own entry, the parallel installer shapes, the testnet 6ed56f63 line, the 0.3.22 pin candidate 34a2dbaa, the signing bonus paragraph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:42:54 +00:00
igneum-labs
cfce2014fa release 0.3.22 section 6: DN3 GATE PASS, the two clocks, the tree order (a) to (f) with main's floor-file ruling, the project lead's nine-switch ruling and main's height mechanics, the testnet re-arm, the 0.3.21 Windows toolchain finding
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:40:37 +00:00
igneum-labs
3d24f43237 Merge ship-docs-0321 31ca83da into master (gate: green on 31ca83da, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 17:29:21 +00:00
igneum-labs
cb04e2b8ca Devnet 3: devnet3.sh passes the argument line base64-encoded over ssh (ssh flattens its argument list: the first --go started the seed on the OLD devnet, digest c562d70e, port 26611, 7 Oct 2026 18:22 BST) and refuses a line without --devnet-suffix=3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:26:14 +00:00
igneum-labs
31ca83daa1 release plans: 0.3.21 ships as the app tree over c4459193 (section 6, the GitHub exception window from 18:02 BST, the Windows path without windows.yml, the DMG 490919d9); 0.3.22 section 5 the Devnet 3 genesis on 69d1b56e at 18:06:19 BST (gates, pairs, the program id correction, the seed left off the sweep); rule 5 names the seeds in the wave list with a read-back line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:25:23 +00:00
igneum-labs
ec3d55723c Counter ASIC 3.0 status: Devnet 3's pair agrees since 17:18Z (702 blocks, 0 rejected; first lock about 19:10Z on the 7,200-DAA window); the no-GitHub rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:20:43 +00:00
igneum-labs
e68b416f4b Counter ASIC 3.0 status: Devnet 3's epoch-0 program id fce15bf61030be57 (the id follows the seed; the pairing pin unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:25 +00:00
igneum-labs
7d9a81d8fb Counter ASIC 3.0 status: GitHub suspended (the mirror is the record), Devnet 3 mines on 69d1b56e from 17:06:19Z, the Hetzner seed still on the old object
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:09:33 +00:00
igneum-labs
4df6a6e837 Devnet 3: devnet3.conf names the 0.3.22 candidate 69d1b56e (genesis timestamp fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:06:20 +00:00
igneum-labs
74bb841dfe merge-to-master: an unknown CI read is waited through inside the deadline, not refused at once (7 October 2026, 18:0x UK: 500s on pushes, a 404 on a live run id, then the account suspension; the rule refuses only a verdict, and a missing verdict only at the deadline)
ci_gate: unknown prints the reason and asks again until --ci-wait runs out (CI_WAIT_SECS for the self-test). master_gate: three reads
over a minute before an unknown counts; a lasting unknown still refuses (no verdict, no merge). Self-test: a blip (unknown, then
green) merges; a lasting unknown is refused at the deadline; the same two shapes for master's read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:04:22 +00:00
igneum-labs
4a2b7565e9 Counter ASIC 3.0 status: the 0.3.22 kaspa-pow suite green, master green on the pow fix, the chip texts live (incl. /claims), Devnet 3's first go refused on the genesis timestamp (8 Oct 00:00Z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:03:06 +00:00
igneum-labs
7594ae0a45 Merge branch 'ci-steward' into ci-steward-2 2026-10-07 17:02:55 +00:00
igneum-labs
40a7fbe063 Merge ca3-coord 493e8507 into master (gate: green on 493e8507, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 17:01:47 +00:00
igneum-labs
3e783b5410 Devnet 3: devnet3.env becomes devnet3.conf (the no-secrets gate refuses any tracked *.env by name; the file holds ports and a binary path, no secret)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:59:56 +00:00
igneum-labs
9c97326aea windows-ci: the smoke run's Start-Process gets one retry (the exited-before-attach flake of release-0.3.21 run 37653903394, 7 October 2026, 17:40 UK)
Start-Process -Wait -PassThru on igneum-app.exe --version, which exits in milliseconds, threw "Cannot process request because the
process (6792) has exited" before PowerShell attached, and the release branch's Windows build went red on a runner race, not on the
build. Run-Capture now tries twice and prints the first failure; the second is the verdict. Checked here by the workflow shell
parse and the PowerShell 5.1 drive-reference check; the runner's own parse step runs on the next master push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:59:54 +00:00
igneum-labs
719d5fc133 Engine gate: any igneum-pow*/src/ directory pairs (the fork pairs through a paths override that may name the tree igneum-pow-amend, the archive of c3d32437; the shipper, 7 Oct 2026), the matched name printed in the line; self-test with both names
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:59:28 +00:00
igneum-labs
ae274be891 Merge remote-tracking branch 'origin/master' into ci-steward 2026-10-07 16:57:56 +00:00
igneum-labs
493e850727 Site: the litepaper's chip limit (and /claims from it) launch-first: 2.1x to 3.9x under class v4 from the first block, class v5, the 5x to 9x only as the class v3 baseline, never the launch state; labels kept, no em dashes (the project lead's word, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:56:52 +00:00
igneum-labs
01653c336d Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 16:56:30 +00:00
igneum-labs
ab364a10c8 Build boxes: the engine gate (igneumd and igneum-miner fetched by build-remote must carry igneum-pow/src/ paths; a commit string alone does not prove the engine: the stub-engine 21d8f454 that rejected every block on the Devnet 3 hub, 7 Oct 2026); tools/ci/engine-check.sh with its self-test in the gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:55:43 +00:00
igneum-labs
01275d2fb4 Devnet 3: devnet3.env names the 0.3.22 candidate 21d8f454 artefact (the file is tracked on purpose: ports and the binary path, no secret; a *.env ignore rule caught it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:54:12 +00:00
igneum-labs
0596574aa8 Counter ASIC 3.0 status: the pow stop-the-line executed (ca3-v4-amend on master as 2b9d50cc, igneum-pow byte-identical to c3d32437)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:48:07 +00:00
igneum-labs
2b9d50cc72 Merge ca3-v4-amend 6bb52c50 into master (gate: green on 6bb52c50, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:45:48 +00:00
igneum-labs
d960dc5cef Merge site-ui-5 70ddf80d into master (gate: green on 70ddf80d, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:45:01 +00:00
igneum-labs
c5b218d4e3 Merge ca3-coord 9b411c7e into master (gate: green on 9b411c7e, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:44:29 +00:00
igneum-labs
416cef0d87 Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 16:41:37 +00:00
igneum-labs
fd644f320f Devnet 3 on build-1: ports reconciled with the fleet lane (its observer-node-dn3 on 26650/28650/26651/26850 is the observer instance; node1-dn3 moves to 26671/26670/28670/26870; ufw 26671 open 16:39Z); the units installed as root, disabled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:40:39 +00:00
igneum-labs
6bb52c5007 Merge origin/master 90501503 into ca3-v4-amend (igneum-pow kept byte-identical to the frozen c3d32437 per main's stop-the-line ruling; the ledger keeps both sides) 2026-10-07 16:40:29 +00:00
igneum-labs
9b411c7ed8 Counter ASIC 3.0 status: the 0.3.22 re-pin staged (bd710a36, candidate fa7f854f, byte 7 from genesis)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:39:34 +00:00
igneum-labs
742ce2be53 Counter ASIC 3.0 status: the stop-the-line on master's pow suite (era-vdf's 61421005 against the pins), the hash lane's merge of 4f2f6097 ordered, the condition verified
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:39:05 +00:00
igneum-labs
70ddf80d8c site: the journey regenerated by the build after the merge (a new engineering-log entry)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:38:07 +00:00
igneum-labs
4a5759e188 Merge remote-tracking branch 'origin/master' into site-ui-5 2026-10-07 16:37:57 +00:00
igneum-labs
5f9f9a4bbf site-ui-5: the capture set after the padding sweep and the launch-first chip text (every route at 390 and 1440 dark, the three bands, the full wallet page)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:37:57 +00:00
igneum-labs
90501503a2 Merge ca3-coord c12b6012 into master (gate: green on c12b6012, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:37:36 +00:00
igneum-labs
a0eeb725f9 CI steward (7 October 2026, 17:3x UK): master takes only CI-passed commits; every job has a budget; the watcher reads cancelled and timed-out runs; box and network checks retry once
Thirteen failure emails between 15:26 and 16:53 UK. The classes and what closes them:
- the box-locks check on a hosted runner (10 runs): closed by bd6fcb88 and 165e8b35 earlier
- windows-ci's stale payload-inputs pin (3 runs): closed on master by 4b4e1bc1; update-return's dispatches still carry e69e8a39
- three hosted site jobs on master hung in the tree gate for over two hours (no timeout-minutes): ci.yml now carries
  site 15, changes 10, pow 60, sims 45, the overlap sweep runs under a 10-minute wall clock where GNU timeout exists, and
  tools/ci/workflow-timeouts-check.sh fails a job without a budget (self-test: a job without the key, a wrong budget)
- a branch merged with no ci run of its own (era-vdf 61421005, 16:31 UK): master's igneum-pow suite went red and five
  docs-only merges landed green over it because their runs skip the compile job. tools/ci/ci-state.mjs reads the runs
  API through gh (a commit's newest run, master's last COMPILED run, a branch's last red); merge-to-master.sh pushes an
  unrun branch for a run, waits for a queued one printing the clock, refuses a red one and refuses any merge onto a red
  master except the declared fix (--fixes-master); the pre-push hook refuses a push to master whose commit, or whose
  merge's branch parent, has no green run on that exact sha; a feature-branch push prints the branch's previous red
  first. Self-tests with a fake gh in all three.
- ci-red.yml fires on failure, cancelled and timed_out and hands the conclusion to red-watch.mjs, whose line names the
  kind (CI red, CI cancelled, CI timed out); the self-test reads the workflow file for the three conclusions
- tools/ci/retry-once.sh: one retry before red for the box-locks check, the scene parity check and the live public API
  check (each keeps its own skip line on a runner without the resource)

GitHub's branch protection cannot be applied: the organisation is on the free plan and the repository is private (the
API answers 403, "Upgrade to GitHub Pro or make this repository public"), so the two scripts are the enforcement; the
rule is one line in CLAUDE.md under the CI block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:37:13 +00:00
igneum-labs
d5bbb298d8 Merge currency-site d1e42080 into master (gate: green on d1e42080, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:35:29 +00:00
igneum-labs
e73fea9d8b site: the chip claim launch-first in the four public places (Counter ASIC lane, docs/plans/counter-asic-3-public-text-2026-10-07.md sections 1 to 4, on the project lead's "I thought we were making it 2.1 from launch?")
Home row 03, the litepaper's chip section at /litepaper#chip-model (paragraph and table; the class v3 row last, "never the launch
state"), the miner page's line and evidence.md row 17 in its eight columns: 2.1x to 3.9x under class v4 from the first block,
class v5 removing the stored-dataset chip as a category, the 5x to 9x only as the class v3 baseline, the devnet's activation
height as a devnet fact. The litepaper's abstract carried the same claim in the old form ("5x to 9x today; class v4, now on
the vote") and now reads launch-first too. tools/ci/ledger-text-check.mjs follows the sentences (X35 on the home page and the
litepaper). No disclosure prize anywhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:34:12 +00:00
igneum-labs
5602aba305 Merge build-server 9c68a2b2 into master (gate: green on 9c68a2b2, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:33:44 +00:00
igneum-labs
c12b6012e4 Counter ASIC 3.0 status: Devnet 3 now (0.3.22, genesis by 17:30Z), the frozen sub-version 3 handoff (c3d32437, byte 7, id a785001687d8688a, gates green, open items stated), the chip text rewritten launch-first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:32:40 +00:00
igneum-labs
11274a1949 Merge remote-tracking branch 'origin/master' into site-ui-5 2026-10-07 16:30:59 +00:00
igneum-labs
0f539f8b39 site-ui-5: the padding and visuals sweep (7 October 2026, 17:xx UK): the /wallet band's padding, tools/ci/padding-check.mjs in the gate, every finding fixed in site.css, the eye pass
the project lead: the ember band on /wallet had no vertical padding (the heading on the band's top edge, the button on its bottom), then
"run a full site sweep for padding and visuals".

- The band: .band .wrap carried the padding and the three bands use .container; both selectors now, 56 px (40 on phones), and
  24 px at the sides on phones.
- tools/ci/padding-check.mjs: renders every served page at 390, 768, 1024, 1440 and 1600 in both themes and flags TIGHT (text or
  a control under 24 px from the edge of the filled band, card or section that holds it; rows of radius 12 and under are the
  app's compact rows and keep their own padding; small filled things such as buttons, pills, badges, tabs, cells and the bar are
  never containers), SCALE (a section's vertical padding not 0 and not a multiple of 4 from 24), TOUCH (controls under 6 px
  apart outside segmented groups), OVERRUN (an image or canvas past its frame) and UNDER-BAR (a heading under the sticky bar on
  load, an anchor the bar would cover). --self-test builds the fixture from the site's own stylesheet and bar with the exact
  /wallet band with its padding lost, a touching pair, an image over its frame and a heading under the bar; each must fail,
  the clean twin must pass. A gate line runs it (self-test, then the built site); where no browser exists it says so.
- Findings fixed (first run 2,740, then 1,040, then 48, now 0 at 230 renders): page heroes 64/48 (were 65/45), the
  observatory head and the 404 the same, the footer's bottom 24, doc sections and the litepaper's 40 (were 42), .sec 72 at
  tablet (was 70), the 404 card 36, /live's motion block 24 (was 25), the scenes lab 48 (was 50); every card at 24 inside
  (download cards, home tiles, the live tiles and stats, the evidence labels, the wallet state cards, the journey phases, the
  live inspector and toolbar, the download card on phones).
- The eye pass over every page at 1440 and 390 dark: the journey's phases are cards now, so the old timeline rail and marker
  (drawn for an unboxed list, sitting on the card's top border) go and the when column keeps clear of the body; tables keep
  words whole (break-word, not anywhere: "regenerat es" on the litepaper) and give the label column 14ch (one word per line on
  /randomx); the litepaper's body paragraphs share one measure; the ledger's count-table chips stay on one line; orphan words
  go through text-wrap: pretty on body text (copy law 18, "measurement." alone on /claims); the dev-fee page lost its doubled
  gutter (a lifted section keeps its own .container) and its table clears the text above; /faucet and /metamask keep a gap
  between headline and lead, line their two cards up at the top and end 72 px above the footer; a heading after a card grid
  gets 40 px.
- The overlap sweep at the same five widths and both themes: 0 overlaps at 290 renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:30:58 +00:00
igneum-labs
d1e42080f8 Site currency (currency-21's site side, for master tonight): site/money.js is the miner app's currency rules for the site (navigator.language picks the currency and the typical tariff, a saved switch wins, one Intl.NumberFormat call for every money figure, no exchange rate anywhere; tables generated from the app's View block, site/lib/money-regen.mjs rewrites them, site/lib/money.test.mjs fails on drift once the app side reaches master and waits until then); the card picker (yourcard.js on /miner and /app) shows the card's electricity a day at the typical tariff with a currency switch saved in localStorage igneum-currency, loading /money.js itself so no page was edited
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:30:22 +00:00
igneum-labs
fa5ee13076 Merge ca3-coord 2f53150c into master (gate: green on 2f53150c, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:30:18 +00:00
igneum-labs
9c68a2b291 Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 16:28:49 +00:00
igneum-labs
93414587d6 Devnet 3 (0.3.22) staged on build-1: the seed as a bare process and the hands' second instances (infra/build-server/devnet3, dry run by default; NET_FLAGS and IGNEUMD placeholders until the node lane names the object and the candidate); ufw and P2P_PORTS carry 26631 and 26651
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:28:34 +00:00
igneum-labs
fa8939ee48 Merge build-server 396ee15e into master (gate: green on 396ee15e, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:26:13 +00:00
igneum-labs
2f53150c5a The chip claim, public text: rewritten launch-first (2.1x to 3.9x under class v4 from genesis; class v5; the 5x to 9x only as the class v3 baseline; the devnet's height a devnet fact) on the project lead's word
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:25:18 +00:00
igneum-labs
eed2a72016 Merge ca3-coord f127ce51 into master (gate: green on f127ce51, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:23:36 +00:00
igneum-labs
396ee15eea Build boxes: provision defaults for build-4 (an AX162-1 beside build-3: three slots, the runner pool at 32 cores); the Hetzner cart of 7 Oct 2026 carries both
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:21:16 +00:00
igneum-labs
f127ce5198 Counter ASIC 3.0 status: the four-seed tail ruled accepted (AP-F8-1 closes), 0.3.21's first candidate passes the node gates; the lane stops for tonight
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:17:54 +00:00
igneum-labs
03d2ed35d1 Merge ca3-coord ed1d2ed8 into master (gate: green on ed1d2ed8, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:17:10 +00:00
igneum-labs
ed1d2ed885 Counter ASIC 3.0 status: the 0.3.20 record (publish 14:54:17Z, floor 900,000, digest 4bbbe816, sweep complete 15:39Z, earliest flip about 14 Oct 23:00Z); sub-version 3 at c3d32437 passes both gates with the named tail, the frozen generator for 0.3.22
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:11:02 +00:00
igneum-labs
44038dd614 Merge ca3-coord 8d22c8f8 into master (gate: green on 8d22c8f8, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 16:09:48 +00:00
igneum-labs
d878ee830d attack-pass: F8 FIXED-AND-PASSED in class v4 sub-version 3 (c3d32437: 60 of 64 under 1.2x, the named four-seed tail, 0 exhausted); the pass record reads PASS or FIXED-AND-PASSED on every row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:03:18 +00:00
igneum-labs
4f2f60975b Counter ASIC 3.0 gates (hash): ledger AP-F8-1, F8's 64-seed gate and exhaustion count on c3d32437 (60 of 64, the four over = the named tail; 0 exhausted of 24,631), the node's draw-cost line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:03:13 +00:00
igneum-labs
8d22c8f8ff Counter ASIC 3.0 status: release-0.3.21-node staged at 96161037 (byte 5, no re-pin), the set green, the floor file's digest 4bbbe816
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:01:00 +00:00
igneum-labs
ad3d832fc6 Merge ship-docs-0320b 8617bd45 into master (gate: green on 8617bd45, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:51:49 +00:00
igneum-labs
88112c78d6 Merge build-server fb3dc4f2 into master (gate: green on fb3dc4f2, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:47:29 +00:00
igneum-labs
fb3dc4f2e3 Build boxes: ufw opens 26621 (the Devnet 2 seed on build-1 listened behind the firewall since it started; opened by hand 7 Oct 2026 16:40 BST, found by the fleet lane)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:42:28 +00:00
igneum-labs
8617bd4585 release plans on master: 0.3.20 live and swept (sections 38 and 39), the rules file with 4c and 14, the 0.3.21 plan as staged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:41:42 +00:00
igneum-labs
c29e55bb76 attack-pass F7: sub-row (a) PASS, the era VDF in the node and the re-roll harness silent against it (0 of 6 cuts); the freeze precondition met
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:33:17 +00:00
igneum-labs
6e638cf6ef Merge era-vdf 61421005 into master (gate: green on 61421005, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:31:24 +00:00
igneum-labs
7d5c467e36 Merge ca3-coord 84e6c82f into master (gate: green on 84e6c82f, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:30:21 +00:00
igneum-labs
84e6c82fb4 Counter ASIC 3.0 status: the hash lane's 78b7915e on origin, CI success; nothing in flight on the hash side
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:25:27 +00:00
igneum-labs
6142100524 igneum-pow at the 0.3.20 line (ca3-v4-amend 8d9d6859: the ladder, the amended class v4 as object 5 and the rung-keyed source rule), the pair the 0.3.20 node fork links against; replaces the 0.3.18-line copy on this branch until ca3-v4-amend reaches master 2026-10-07 15:24:22 +00:00
igneum-labs
9f543aa970 Era VDF lane (7 October 2026): spec 04 sections 4.2, 4.4, 4.5 and 4.6 completed for the era path (the scheme byte and the hash-chain fallback, the cut rule under the O-4.3 reading, the day-of-blues input, the delay, the seed, the era constants as measured), spec 01 section 1.13.1 and era-layout.md section 8 updated, open items O-4.8 closed and O-4.10 to O-4.12 added, FUD ledger F11 updated, two decisions for the project lead in ledger-decisions.md, the record docs/analysis/era-vdf-2026-10-07.md (what was built, the parameters and measured rates, the harness gate with both runs, the cut rule for the finality lane, the verify gate, the tiers), the F7 re-roll harness against the real era cut (tools/era-vdf/reroll.mjs: --vdf off fires 6 of 6, --vdf on silent 0 of 6; the adversary evaluates asynchronously with the node's own code and is found by the node's own log line), the fast-time file's era fields 2026-10-07 15:24:22 +00:00
igneum-labs
4b4e1bc12f Merge master-pin-0320 b9b37480 into master (gate: green on b9b37480, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:20:54 +00:00
igneum-labs
16403d488d Merge ca3-coord c724ed86 into master (gate: green on c724ed86, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:18:58 +00:00
igneum-labs
4402e51e45 Merge bench-5060ti 8c019aa2 into master (gate: green on 8c019aa2, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:17:18 +00:00
igneum-labs
165e8b354b Merge build-server 649ea43a into master (gate: green on 649ea43a, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:16:36 +00:00
igneum-labs
6c6b031178 attack-pass F8: sub-version 3's two commits, the (c'') threshold evidence and the c3d32437 census line in the record
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:15:44 +00:00
igneum-labs
c724ed8632 Counter ASIC 3.0 status: the census at c3d32437 (0 lossy sites, 0 exhaustions, mean 2.086 attempts), CI success, the class check moving 2.0 percent of attempts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:15:23 +00:00
igneum-labs
b9b37480a4 master re-pinned to the published 0.3.20 node (c4459193): packaging/windows/node-source.pin against the live payload-inputs.json, so windows-ci's payload inputs step reads green; release rule 14, master re-pinned at every publish
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:13:10 +00:00
igneum-labs
78b7915e6e Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the suite (103 of 103), CI and census lines at c3d32437; the census draws in parallel
Suite at c3d32437 on box 2: 103 passed, 0 failed (lib 140.8 s: every class v4 draw in the tests pays the 2^20 ratio pass). CI 37639406567 success. Census: 4,099 programs, 0 lossy sites of 65,584, 0 exhaustions, mean attempt 2.086 (af1b7c46 1.998), max 17; the ratio refuses about 4 percent of candidates that pass every other test. tools/ca3-v4-uniform draws the seeds across the available cores (the serial run became a four-hour job under the ratio pass); the analysis and the row order are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:10:13 +00:00
igneum-labs
649ea43a49 Gate: the box lock self-tests read the same on an idle box and on one at load 120 (private lock directories, no pinning in the fixture, file-driven waits, a 3 s settle, a quiet that outlives the slot settle); the check runs them one at a time with one retry each (the horizon lane, 7 Oct 2026: one red on a full gate, green a minute later)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:05:56 +00:00
igneum-labs
8c019aa259 RTX 5060 Ti (16 GB, Razer Core X V2 Thunderbolt) on PC 2: detection, G1 bit-exact on class v4 sub-version 1 and the v3 control, the 10-minute stock window, the bench-log entry, the card-picker entry and the public table row
The first 16 GB card and the first Thunderbolt card. The app's first poll listed it (vendor nvidia, 16,311 MiB,
driver 610.47 shared with the 5090, kind discrete: the app does not know it is an eGPU) and started a miner on
it by itself. Job run-5060ti-bench-20261007-b (tools/bench-5060ti/pc2-5060ti-bench.ps1, the card alone under
the runner's --cards-off, the 5090 mining, no power limit written: 180 W default read back unchanged, PC 2 lost
power twice that day): G1 PASS with 867dbc45cfb36b4d (sub-version 1) and 90f794dd556f7a3b equal to the Mac's;
the window 1,105 dispatches of 2^24 in 602 s at 30.882 MH/s, 114.8 W mean at utilisation 90 percent and over,
0.269 MH/W, PCIe 4.0 x4 through the enclosure (the hash is latency-bound, the link costs nothing measurable).
Owed: the efficient point (the app's Ember Tune; PC 2's Power Helper refuses every request since the restart)
and the prove-beside read (the shipped WSL2 host has no IGNEUM_CUDA_DEVICE selector; the floor server is on
PC 2; the prover-floor host is the cut). The kit (tools/bench-5060ti/make-kit.sh) takes the sub-version 1 packs
from ca3-v4-amend 0a62293a, since master's packs-ca3-v4 is the 6 October stream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:05:47 +00:00
igneum-labs
3a6ab26e98 Merge site-ui-5 40ea28ae into master (gate: green on 40ea28ae, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 15:02:23 +00:00
igneum-labs
405d3d5274 attack-pass: AP-H2, a rebuild on the shared binary path reached two runs in progress; the launcher now runs from a copy in the run's scratch dir
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:00:26 +00:00
igneum-labs
40ea28aea0 site-ui-5: /live's table-row click handler kept its opener (the Mark-my-key removal had taken the line that opened it; every inline script parses again)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:58:51 +00:00
igneum-labs
84f34a48c8 Merge ca3-coord 61496c40 into master (gate: green on 61496c40, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:55:07 +00:00
igneum-labs
533e9c8e07 site-ui-5: captures after the phone-menu, pending-fold, dark-gallery and no-you fixes (home, miner, wallet, live, app, download at 390 and 1440 dark; the open sheet at 390; the pending home at 375)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:52:15 +00:00
igneum-labs
b1c4c35ff3 Merge remote-tracking branch 'origin/master' into site-ui-5 2026-10-07 14:51:38 +00:00
igneum-labs
61496c4029 Counter ASIC 3.0 status: the suite at c3d32437 (103 of 103; the lib tests' 2^20 cost on CI time)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:51:30 +00:00
igneum-labs
8e942cd0d5 site-ui-5: the phone menu is seen, the home fold hides the scene until the feed answers, /miner and /wallet show dark screenshots only, /live has no "you" (7 October 2026, 15:5x UK)
the project lead's three on the live site, plus the /live key's last item:
- Mobile nav (URGENT): the sheet was position:fixed inside the bar, and the bar's backdrop-filter is a containing block for
  fixed descendants, so top and bottom resolved against the 60 px bar and the sheet opened with zero height while
  aria-expanded said true. It is now absolute under the sticky bar, a viewport-tall box (100dvh minus the bar), display block
  over the package's grid rule. tools/site/sheet-test.mjs opens every page at 390 px, clicks the burger and asserts the first
  link is on screen and on top (elementFromPoint at its centre returns the link); --self-test forces a zero-height sheet first
  and must catch it; a gate line runs it where a browser exists (the box, CI) and says so where none does. On build-2: the
  zero-height sheet is caught, 23 pages pass.
- Home at 375: the renderer's "Waiting for observer data" was drawn on the fold's canvas under the lead. The fold now carries
  .pending until the feed is live or stale and keeps the canvas at opacity 0 meanwhile; the status pill is its own row under
  the buttons on phones. ?feed=off holds the pending state for a render; the overlap sweep (whose server answers /api with
  503) renders the pending state by construction and ran at 375, 390 and 1440 in both themes: 0 overlaps.
- /miner and /wallet: the light-theme screenshot variants are gone (dark only), captions unwrapped, "light and dark" dropped
  from the gallery line; one gallery of frames with one caption each.
- /live: "Your block" leaves the key, the "Your blocks" filter and tile and the Mark-my-key prompt leave the page (the site
  has no "you"); the stats row is three tiles; the key reads Included, Excluded, Selected chain, Proven, Locked checkpoint.
- Captures: ?motion=off (head partial) marks data-motion=off so every .reveal is drawn in a headless render; capture.sh passes it.
  tools/site/serve.mjs survives a refused api origin (502, never a crash).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:51:17 +00:00
igneum-labs
a5ab07665a Merge ca3-coord 2c7c9f76 into master (gate: green on 2c7c9f76, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:50:54 +00:00
igneum-labs
38fd2c4cf8 Merge mf11-master 847211c7 into master (gate: green on 847211c7, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:49:42 +00:00
igneum-labs
847211c7c2 Relay: the X23 tree on master (the signed and tagged run, lib/handler.mjs, guard, the per-machine secret), the start-app kind, the v3 agent as the per-user logon task IgneumRelayService with install-agent.ps1 and the hidden loop, the per-install hostname on registration, the signing tools/relay.mjs so no lane posts an unsigned run (the live relay refuses one since the 7 October 2026 deploy), tools/console.mjs and build-job.mjs on the header tier (X24), the playbooks on RELAY_DL_BASE (X26) and wsl-setup's sudo for apt-get and dpkg only (X28), the new playbooks (start-app, agent-install, pc2-crash-collect, boot-check, freeze-check), and the deploy record with the rollback line (docs/plans/relay-deploy-2026-10-07.md: production igneum-relay-iabqarnby, previous bgy767z40)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:47:59 +00:00
igneum-labs
2c7c9f766f Counter ASIC 3.0 status: sub-version 3's second commit c3d32437 (the ratio at 0.98 over 2^20, the shared-operand rule, the weak four as the open tail)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:47:22 +00:00
igneum-labs
c3d32437cd Counter ASIC 3.0 gates (hash): class v4 sub-version 3, second commit (AP-F8-1): the shared-operand rule in the draw and (a'), rule (c'') the distinct-index ratio at 0.98 over 2^20, the known-failed test on the chain's candidates
The shared-operand rule (or-then-xor, or-then-sub, xor-then-or on one operand is a mask) in the draw's source rule and in the acceptance's (a') pass: p23's chain attempt 1 (id d65122675f16a1c7) now draws site 7 from r5 and passes. Rule (c''): over 4,096 units (2^20 evaluations per site, the shadow executed) every load site's distinct word indices against the uniform expectation on its window must reach 0.98, the last test of the chosen candidate; the floor sits 0.015 from the clean minimum (0.9960) and from the strong failing maximum (p56 0.9654). The staged 2^24 pass was not taken: at 2^24 the clean p44 and p52 (0.9612, 0.9613) read the weak four's value (0.9181 to 0.9630), so no 2^24 floor separates them. Open tail: p4, p8, p10, p34 (0.9927 to 0.9963 at 2^20), unattributed and chased. The (B) bound stays unwired. The test class_v4_distinct_ratio_rejects_the_low_entropy_band pins p15 (attempt 3), p18 (2), p19 (0), p56 (2) refused and p23's attempt 1 passing, its r6 variant refused by (a') and by the ratio (0.836). The stream is unchanged: re-export diff 0 on the eight packs, id a785001687d8688a, PROGRAM_SUBVERSION_V4 stays 3, fingerprints and zip sha256 stand. Cost: one 2^20 pass per chosen candidate, 2.1 to 2.2 s on one box-2 core.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:44:55 +00:00
igneum-labs
d2dda6ec0a Merge site-ui-5 a195befe into master (gate: green on a195befe, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:44:17 +00:00
igneum-labs
a195befe1f site-ui-5: /live's key row sits under the scene canvas (the project lead: "the key at the top under the animation, not on top of the animation"): the hint, the six key items and the window stepper are the first thing below the canvas, flush with its bottom edge; nothing above the canvas but its own time axis; the blue-score line and the inspector unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:40:44 +00:00
igneum-labs
3b674bcdc0 Merge ca3-coord ac2c4091 into master (gate: green on ac2c4091, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:37:38 +00:00
igneum-labs
ac2c4091a1 Counter ASIC 3.0 status: sub-version 3's threshold numbers (0.98 at 2^20 rejects the strong five; the weak four inside the clean spread), the structural rule biting, the line on (i) or (iii)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:34:06 +00:00
igneum-labs
4e2745abb4 Merge ca3-coord cd20d536 into master (gate: green on cd20d536, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:27:31 +00:00
igneum-labs
4af544c833 Merge build-server 30acd340 into master (gate: green on 30acd340, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:25:01 +00:00
igneum-labs
37dff589f6 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the census at af1b7c46 (0 lossy sites, 0 exhaustions, mean attempt 1.998)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:24:55 +00:00
igneum-labs
cd20d53661 Counter ASIC 3.0 status: sub-version 2's final verdict (55 of 64, nine one-site seeds), sub-version 3's first commit ddacfbd3, p23's localisation (an AND-mask idiom, 0.84 of uniform at site 7), the 0.3.22 cost lines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:23:58 +00:00
igneum-labs
8b48da793d Merge ca3-coord cad21ab1 into master (gate: green on cad21ab1, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:23:09 +00:00
igneum-labs
30acd34004 Gate: a --ci run outside GitHub Actions builds the site in the temporary copy like the hook (the in-place build rewrote four tracked site files in the running worktree; the horizon lane, 7 Oct 2026); in place only inside Actions; self-test that the tree is unchanged after a site build
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:23:05 +00:00
igneum-labs
635efc0d37 attack-pass F8: sub-version 2 re-gate verdict FAIL (9 of 64, worst 4.82x), the per-seed table and the clean per-site spread; sub-version 3 is the target
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:22:42 +00:00
igneum-labs
0dcfa87d1f Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the suite line at af1b7c46 (102 of 102) and F8's final 64-seed table on sub-version 2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:22:35 +00:00
igneum-labs
f98e36bed2 Merge peer-directory bb540a1c into master (gate: green on bb540a1c, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:22:24 +00:00
igneum-labs
f9e0ce175b attack-pass F8: p23's residual localised and explained (or-then-xor same-operand AND mask over a mulhi output, lineage-blind); the distinct-index ratio rule for sub-version 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:20:14 +00:00
igneum-labs
af1b7c46dd Counter ASIC 3.0 gates (hash): class v4 sub-version 3, first commit (AP-F8-3; main's word: 0.3.21 ships byte 5, sub-version 3 is 0.3.22's). The acceptance executes the latency-shadow block as the hash does: run_unit runs the block after instruction 63 of every iteration, reps times with the iteration's sel (verify.rs); until now it ran the 64 base instructions only, so every dynamic acceptance test on a class v4 program judged a program the chain never hashes, which is the whole residual class behind sub-version 2's 8 of 64 gate failures. The test acceptance_executes_the_shadow_block_as_the_verifier_does pins the acceptance's execution to verify.rs on the devnet epoch-0 program and the six test eras (equal output bit counts over the 64 units; different with the shadow stripped). PROGRAM_SUBVERSION_V4 = 3 (a new verdict is a new stream). The seven gate packs re-exported: the devnet epoch-0 seed still accepts at attempt 1, so its program and fingerprint are sub-version 2's (e370fb2080b7dbb1) under the new id a785001687d8688a (must-differ: c120d7963abdcd96, 1a4230699a6b9c60, a788661687db4bb3); the seven 256-block ladder packs re-exported. The ledger entry carries AP-F8-2's exhaustion half as FIXED-AND-PASSED at fbb00320 (0 of 10^6, max attempt 35, r = 0.67), AP-F8-3, and p23's localisation (site 7 reads r6 = (mulhi | r4) ^ r4 = r6 & ~r4; 0.84 of uniform distinct indices at 2^20, 0.55 at 2^24, reproduced in the acceptance's own execution); the second commit, a per-site distinct-index ratio, is held
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:20:02 +00:00
igneum-labs
cad21ab12a Counter ASIC 3.0 status: the N15 drift fix done for 0.3.21 (numbering-fix d8bceca5)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:19:37 +00:00
igneum-labs
f5ef82697b Merge ca3-coord 524d48e8 into master (gate: green on 524d48e8, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:19:01 +00:00
igneum-labs
bb540a1c6f Merge remote-tracking branch 'origin/master' into peer-directory
# Conflicts:
#	infra/fast-time/fork-gate.mjs
#	infra/fast-time/override-60x.json
2026-10-07 14:18:50 +00:00
igneum-labs
4e6016fc33 Merge horizon d2b1b08c into master (gate: green on d2b1b08c, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:17:38 +00:00
igneum-labs
524d48e86b Counter ASIC 3.0 status: sub-version 3's build result (the bounds do not reach the class under the real dataset), the decision (0.3.21 byte 5, sub-version 3 for 0.3.22)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:15:26 +00:00
igneum-labs
c1c2617cc8 Merge ca3-coord 0772ed4b into master (gate: green on 0772ed4b, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:14:20 +00:00
igneum-labs
bb2af373f4 Merge mf11-master 53a4496d into master (gate: green on 53a4496d, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:12:42 +00:00
igneum-labs
53a4496dd9 Console: the job-channel ping (MF-11, 7 October 2026). Every 0.3.21 app names itself on its wake long-poll (machine, version, last job); /wake records one row per machine in relay_wake_seen before it holds; the Machines tab reads "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say (PC 2 lost power at 10:46Z and nothing said so until a person read the intake). Tests in relay/test/wake.test.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:10:59 +00:00
igneum-labs
aa99a8146d Merge genesis-forward 04131cbe into master (gate: green on 04131cbe, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:10:57 +00:00
igneum-labs
0772ed4bfd Counter ASIC 3.0 status: AP-F8-3, the acceptance never ran the shadow block (the root of the residual classes), the sub-version 3 fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:10:46 +00:00
igneum-labs
1b38e63156 Merge ca3-coord 6980bf38 into master (gate: green on 6980bf38, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:09:56 +00:00
igneum-labs
c613710fa0 Merge site-ui-5 aeb7c682 into master (gate: green on aeb7c682, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:09:28 +00:00
igneum-labs
2039ba9394 Merge build-server bd6fcb88 into master (gate: green on bd6fcb88, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:08:58 +00:00
igneum-labs
b6a0a78e12 Merge remote-tracking branch 'origin/master' into peer-directory 2026-10-07 14:07:42 +00:00
igneum-labs
140ce00171 attack-pass: AP-F8-3, the acceptance rule never executed the shadow block (the root of the residual classes), found by the hash lane; fixed in sub-version 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:07:25 +00:00
igneum-labs
5f90934654 Merge ui-overlap 8350720f into master (gate: green on 8350720f, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:07:19 +00:00
igneum-labs
6980bf3801 Counter ASIC 3.0 status: main's acceptance of the plan, AP-F8-2's exhaustion half closed at 10^6, the exec chain-number drift finding (N15)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:06:58 +00:00
igneum-labs
aeb7c68257 site-ui-5: /live sizes its scene box through the renderer (2.0.4 autoHeight, 1ae09fb5); the padding constants leave the page. The home fold keeps its CSS height (a backdrop), so autoHeight stays off there by the renderer's own rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:06:56 +00:00
igneum-labs
bd6fcb8850 Gate: the box lock self-tests skip with an ok line under --ci on a runner with no box (GitHub's hosted runner has no BUILD_HOST and no box file; master went red the moment the check landed, 7 Oct 2026); the Mac hook and the box runners still run them live
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:06:53 +00:00
igneum-labs
04131cbeb9 Merge branch 'master' of https://github.com/igneum-network/igneum into genesis-forward 2026-10-07 14:06:24 +00:00
igneum-labs
585d312313 Merge ca3-coord 9e085cf0 into master (gate: green on 9e085cf0, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:06:04 +00:00
igneum-labs
d2b1b08c6a Merge remote-tracking branch 'origin/master' into horizon 2026-10-07 14:05:21 +00:00
igneum-labs
28a0f5b5fd attack-pass: AP-F8-2 exhaustion half FIXED-AND-PASSED at fbb00320 (0 exhausted in 10^6, max attempt 35, 4 past 31)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:05:02 +00:00
igneum-labs
9e085cf067 Counter ASIC 3.0 status: the hot-set gate in numbers (ratio_w, the reach as per-site index entropy, the null's tail), the line for sub-version 3 (distinct-values bound at 2^20 plus the repeat bound)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:04:00 +00:00
igneum-labs
8350720f41 overlap-check: a text sliver at a scroll pane's edge is scrolled away, not covered (the miner's Earnings ladder under the closed log drawer's 1 px bar at 028ae588 read as covered); the clean fixture now carries that shape (7 October 2026, 16:1x UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:03:28 +00:00
igneum-labs
7909d2ffea Merge ca3-coord cbcc2354 into master (gate: green on cbcc2354, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:02:29 +00:00
igneum-labs
d0f1157821 Genesis forward-compatibility: key hashes in the harness summaries cut to 16 hex characters (the no-secrets gate reads committed summaries; the writer redacts at write time)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:02:24 +00:00
igneum-labs
59d7a5a2ae Merge remote-tracking branch 'origin/master' into ui-overlap
# Conflicts:
#	tools/ci/pre-push.sh
2026-10-07 14:01:38 +00:00
igneum-labs
0adae6811a Merge ship-docs-0320 95b42f5b into master (gate: green on 95b42f5b, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:01:33 +00:00
igneum-labs
7594f0cc99 Genesis forward-compatibility: the suite row closed (114 passed twice on build-2 at f95178a1) and section 6a, the two-node UnexpectedDifficulty class found and fixed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:01:11 +00:00
igneum-labs
75a48e8a62 Merge site-ui-5 993219e9 into master (gate: green on 993219e9, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:01:01 +00:00
igneum-labs
8d633c2ad1 attack-pass F6: PASS, the worst of 10^5 class v4 programs reads 8.708 ms on the half-core proxy (1.29 ms under the gate), batches B and C under the per-core lease
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:55 +00:00
igneum-labs
cbcc2354d2 Counter ASIC 3.0 status: the self-operand ban void (value equality from the shadow block), the dynamic bound's reach, the plan (0.3.21 on byte 5, sub-version 3 against a defined gate), 0.3.21's staging facts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:44 +00:00
igneum-labs
f3c2921629 Merge scene-parity-site 1ae09fb5 into master (gate: green on 1ae09fb5, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 14:00:31 +00:00
igneum-labs
41aeaf6221 Merge ca3-coord c9ba998d into master (gate: green on c9ba998d, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 13:59:31 +00:00
igneum-labs
993219e933 site-ui-5: the capture set, every route at 390 and 1440, dark and light, rendered on igneum-build-2 against the live feed (docs/plans/site-ui-5-shots, 80 PNGs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:59:11 +00:00
igneum-labs
1ae09fb5a9 Chain scene 2.0.4: the box's height follows the lanes (onSize, autoHeight, the lane geometry in stats) (7 October 2026, 16:3x UK)
the project lead's /live screenshot: a fixed tall box with the lanes in its top third and dead space under them. The renderer now knows
the height it wants: top padding + axis padding + lanes shown (at most maxLanes 7, narrowLanes 4 on a phone, never under 2)
times laneHeight (46 px, 40 on a phone, 26 compact; the mount option laneHeight overrides). It reports it through
onSize(heightPx, {lanes, laneHeight, narrow, compact}) whenever it changes and through getWantedHeight(); stats() carries
laneHeight, padT, padB, capacity, wantedHeight and autoHeight, so a page that sizes its own box reads no constants. With
autoHeight (on by default when the host set no CSS height on the canvas, which is read before the first size(); forced either
way by the option; never in compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself
and lets every lane through instead of fitting the lanes to the box. Every current host sets a height (/live 420, the hero
500, the app's card 220 and its Inspect view 420), so the frames are unchanged: the parity test on build-2 stayed equal on
every comparison. The site lane switches /live and the home fold to the hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:58:33 +00:00
igneum-labs
7c79bee818 Merge remote-tracking branch 'origin/master' into scene-parity-site 2026-10-07 13:58:19 +00:00
igneum-labs
c9ba998d82 Counter ASIC 3.0 status: sub-version 2's eight seeds (five lineage-fresh constants, three the null's tail), the exhaustion half passing, the gate threshold question, the sub-version 3 shape and clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:57:43 +00:00
igneum-labs
d1a9544376 attack-pass F8: AP-F8-2 closed at fbb00320 (0 exhausted in 650k, max attempt 35); sub-version 2 fails the hot-set gate on lineage-fresh constants (8 of 39)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:57:38 +00:00
igneum-labs
95b42f5b88 release plans and rules on master: the 0.3.20 plan (sections 1 to 37, the pin c4459193 and its lines), docs/plans/release-rules.md (rules 1 to 13 with 4a, 4b, 4c), the 0.3.21 plan as staged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:57:35 +00:00
igneum-labs
83dd90bc05 site-ui-5, deploy 2: every remaining route in the apps' look, /live's scene box fits its lanes with the key at the top (7 October 2026, 15:0x UK)
- /live (the project lead: "make it more efficient, key at the top"): the key (Included, Excluded, Selected chain, Your block, Proven,
  Locked checkpoint), the "Click to inspect · Drag to explore" hint and the window stepper sit on one row above the scene
  (hint left, key centred, stepper right; on phones the key wraps and the stepper goes under it); the blue-score range stays
  as a small line under the canvas; the canvas height comes from the lane count (one lane per key in view plus the others
  lane, capped at the renderer's 7, 4 on phones: padT 58 + lanes x 46 (40 narrow) + padB 38, read from scene/live-dag.js
  2.0.3 layout(); a sizing hook was asked of the parity lane, which owns that file); the graph column no longer stretches to
  the inspector; the inspector keeps its rows with tighter spacing and Clear is a small button beside its title.
- Every other route carries the slim bar, the app's buttons, pills and cards from site.css section 10 with its content
  untouched: /app (OS marks on its platform line), /miner (the download tabs take the OS wells), /miners and the bench table
  (the vendor badge in front of every card name), /explorer, /block, /address (hash titles wrap), /journey, /bench, /evidence
  (status pills wrap in their cell), /litepaper, /ledger (status badges wrap at 390), /claims, /randomx, /provenance, /dev-fee,
  /faucet, /metamask, /404.
- Data tables fill their frame on desktop and scroll inside it on phones; no column squeezed to a sliver.
- The overlap lane's detector over all 23 pages at 390 and 1440, dark and light, on build-2: 0 overlaps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:57:32 +00:00
igneum-labs
a163883514 Merge remote-tracking branch 'origin/master' into ui-overlap 2026-10-07 13:57:31 +00:00
igneum-labs
97964643ed The overlap sweep joins the gate: one check line in tools/ci/pre-push.sh (self-test, then the built site; IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs), CI installs Playwright outside the tree before the gate, the README row; the measurer caches visibility and clip chains per element so a long page reads in seconds (7 October 2026, 16:1x UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:57:30 +00:00
igneum-labs
087ee7aa88 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:57:26 +00:00
igneum-labs
8ad0499218 Mission items 4 and 12 status: the live digest eada4bda read on the hub's file from both branch binaries (unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:56:48 +00:00
igneum-labs
d859fecab6 Merge build-server bb3e676f into master (gate: green on bb3e676f, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge) 2026-10-07 13:55:59 +00:00
igneum-labs
ecd7e4a875 Mission items 4 and 12 status: the 0.3.21 rebase tips (437f0438, 2e32d5f6), suite lines, the digest reading
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:55:34 +00:00
igneum-labs
3cca93b157 Counter ASIC 3.0 status: the 64-seed gate on sub-version 2 heading to FAIL (8 of 39 over 1.2x, worst 4.82x), the earlier none-over line withdrawn
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:55:27 +00:00
igneum-labs
6983002164 Merge remote-tracking branch 'origin/master' into ui-overlap 2026-10-07 13:55:13 +00:00
igneum-labs
9336a2b725 Text-overlap sweep (tools/ci/overlap-check.mjs) and the first fixes it found: the home hero's step pill no longer sits on the caption (pill at the corners' baseline, caption above it, at every width), the address page title wraps, the ledger's status badges wrap, the litepaper's mobile contents bar bleeds by the real gutter, the verify harness table scrolls (7 October 2026, 15:5x UK)
The sweep renders every served page in a headless Chromium at 390, 768, 1024, 1280 and 1600 in light and dark (the hero at rest and at each step), reads every visible run of text and flags text covered by another element, clipped by overflow hidden, or past the viewport; a fixture with one deliberate overlap of each kind is flagged before any sweep is trusted. It runs on a build box when this machine has no browser (infra/build-server/overlap-browser.sh installs Chromium there without root).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:55:13 +00:00
igneum-labs
bb3e676f95 Gate: the green stamp and the fast path for merges (a full gate that ends GREEN over a clean tree records its commit; the hook lets a merge of a stamped branch onto the exact remote tip through on the light gate, CI runs the full one on landing); tools/ci/merge-to-master.sh; the wave script's seeds mode takes --wipe-genesis with the genesis and base-unit read-backs for the testnet go
Main, 7 October 2026: a 100 s hook gate on the merge commit against a master that moves every minute lost six pushes in a row.
Self-test: a fixture repository (unstamped branch, stamped branch, stale stamp, wrong tip, plain commit, dirty tree).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:54:10 +00:00
igneum-labs
ebb69200ba Counter ASIC 3.0 status: the wipe canary on c4459193 PASS (c19-1, the restart stopping at once); the pin's set complete bar CASES END
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:52:36 +00:00
igneum-labs
a16e99063b Chain scene parity test: one recorded feed through the home fold, /live and the app's Inspect view, three frames each pixel-equal, in the gate (7 October 2026, 15:5x UK)
tools/scene/parity.mjs serves site/ (tools/site-serve.mjs) and the app's UI (tools/ui-mock/server.mjs) on a build box, answers
every page's /api/live from scene/fixtures/live-2026-10-07.json, freezes the clock at the fixture's instant (Date, timers,
requestAnimationFrame) and reads each scene canvas at 900 by 420 px at the first push and the next two polls: the read is the
push's own synchronous paint, so a frame depends on the fake time alone, never on how long a fetch took. Known-failed first: the
app with --included moved by one unit must differ from /live at every instant (it does: 1,549 / 1,282 / 1,157 px). Then home
fold = /live, app Inspect = /live, app with this machine's key = /live with the same key (the overlay is the same picture), and
the app's overlay frame differs from its base frame (the overlay is drawn). The compact card is rendered and reported, not
compared. A RED line names the differing pixels and their box. tools/scene/parity-remote.sh carries the files to the box
(~/.config/igneum/build-server-2 by default) under this lane's prefix and runs it there; a plain CI runner with no box and no
Playwright prints a skip line. One new line in tools/ci/pre-push.sh.

First run on build-2 at 15:4x UK, release-0.3.21 tree plus scene-parity: every comparison equal at T+0, T+2 and T+4 s.
Found on the way and fixed in the harness, not the renderer: lane order keeps the history of earlier layouts (the scene must be
at the compared size before its first layout); the app's recorded mock card is a real devnet key (cleared for the base case).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:52:19 +00:00
igneum-labs
31f9c013b1 Counter ASIC 3.0 status: PC 2 down for cable work (both PCs out of the waves); 0.3.21's mixed-version gate PASS on 55768f88
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:50:40 +00:00
igneum-labs
b536238241 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:48:37 +00:00
igneum-labs
ee8746d5db Counter ASIC 3.0 status: the Windows G1 for sub-version 2 GREEN on PC 2 (eight of eight), G1 complete on three platforms
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:48:37 +00:00
igneum-labs
7b81627595 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the Windows G1 line on PC 2 for sub-version 2 (eight fingerprints equal, card off by the runner)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:47:48 +00:00
igneum-labs
8cd9b69d68 Merge commit 'e1c55556' 2026-10-07 13:47:45 +00:00
igneum-labs
e1c555568f CI and boxes: the simulators job runs on master and release-* pushes and pull requests into them only (a feature-branch code push runs the igneum-pow tests alone; tools/ci/sims-branch-check.sh); the box lock self-tests run in parallel in one ssh; build-2 and build-3 join the runner pool bounded to 32 cores; the 0.3.20 first-wave script (seeds, hands, RPC-filter lift; dry run by default)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:46:42 +00:00
igneum-labs
517320aad2 site-ui-5, deploy 1: the site in the apps' look (7 October 2026, 15:0x UK): one design layer on the app's tokens, the slim bar, the home fold on the shared scene, /download, OS and GPU marks
the project lead: "a site now what is on par with our current miner and wallet app look and feel with the new animation ... simple and
beautiful ... apple and windows icons everywhere needed, GPU brand icons ... easy access to all of our pages without a header
that is crazy".

- site.css section 10: the miner and wallet apps' treatments (app.css miner-ui-5, wallet-0.1.5) over the package: buttons
  (Plex Sans 600, radius 10), the status pill, graphite cards at 16 and rows at 12 on the hairline, the wallet lock screen's
  ember glow, the mark wells; the fourteen palette tokens stay in the scene-tokens block (scene/tokens.css); light and dark.
- Navigation: the seven-item header is gone. The slim bar carries the mark, a DEVNET pill with the live dot, Mine / Network /
  Learn (each opens a panel listing every route of its group with one line), the theme toggle and Download; on phones one
  button opens a full-screen sheet with the same groups. Keyboard: arrows between groups, Escape closes and returns focus,
  Tab walks a panel, the sheet is a dialog with its focus kept. The gate's header check is the new rule
  (tools/ci/site-nav-check.mjs: the bar, the three groups with their aria, 18 routes in the panels and the sheet with their
  descriptions, Download, the burger; self-test known-failed first).
- Home: one fold. The mark on the ember glow, one line, Download for Windows and Download for macOS with the OS glyphs and the
  stamped version (the visitor's platform first), "Linux and HiveOS" to /download, IgneumDag 2.0.3 painting underneath from
  /api/live through IgneumFeed (window 60, fps 60, poll false, no fork). Below: three lines on what a miner gets (E5 and X35
  verbatim), the live network in three tiles (hash rate, blocks a second, locked checkpoint; X2 and X31 verbatim), the
  community row (Discord, GitHub, Reddit), the footer (X7). The scene scripts are deferred; fonts preload with swap.
- /download: a new route. Windows, macOS, Linux and HiveOS cards with their marks, the stamped version, size and sha256, the
  HiveOS flight sheet, the three steps, the card picker with the vendor badge, the wallet for macOS; Windows wallet named as
  next with no file.
- Marks: brand/marks/vendor-marks.mjs (gpu-logos lane, a94dd192) copied byte for byte to site/lib/marks.mjs (the build and a
  gate line refuse drift); site/lib/os-marks.mjs adds Linux and HiveOS in the same treatment. The build stamps
  <span data-os> and <span data-gpu> and puts the vendor's mini badge in front of every table cell that names a card
  (/miner, /miners, the bench table), writes the --mark-* tokens into site.css between markers, and hands yourcard.js the
  badges as JSON for the card picker. The vendor well is scoped to [data-mark] so the ledger's and the journey's own
  .badge keep their look.
- The footer carries the four OS download chips; the nav's group wrappers are .nav-group (the wallet page owns .group).
- tools/site/serve.mjs and capture.sh: the site as Vercel serves it with /api passed to the live observer, captured on
  igneum-build-2 at 390 and 1440, dark and light (docs/plans/site-ui-5-shots, the home set in this commit). The overlap
  lane's detector ran over every page on build-2: 0 overlaps after the two fixes here (the explorer's hash titles wrap, the
  ledger's status badges wrap at 390).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:46:23 +00:00
igneum-labs
9b143495f2 Counter ASIC 3.0 status: the Linux CUDA G1 for sub-version 2 PASS on p1-5090 (eight of eight equal to the Mac)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:45:31 +00:00
igneum-labs
9092e50d13 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the sub-version 2 G1 line on the fleet 5090 (eight fingerprints equal, self-test PASS)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:45:16 +00:00
igneum-labs
da657a9c3c Counter ASIC 3.0 status: PC 2 back (a power loss, not the update); the Windows G1 ordered on PC 2 before the publish
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:44:38 +00:00
igneum-labs
092dd76788 Counter ASIC 3.0 status: the fleet G1 package, the PC 2 run job withdrawn (would have fired before the sweep), the lock released
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:43:04 +00:00
igneum-labs
102abff9ae Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 13:40:57 +00:00
igneum-labs
4233d27a24 Counter ASIC 3.0 status: main's byte-7 ruling (fleet 5090 G1 now, all green by 19:00Z or byte 5), 0.3.21's first digest gate PASS on 55768f88, the 0.3.21 order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:39:52 +00:00
igneum-labs
bd76f6292a Counter ASIC 3.0 status: the pin stays c4459193 (main's word), 55768f88 is 0.3.21's first; PC 1 offline for cable work; the G1 waits for a PC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:37:44 +00:00
igneum-labs
0c7c253d30 Mission item 12 status: the gate (three cases as they must), the unit tests, the verdict line for main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:37:14 +00:00
igneum-labs
1ce645baaa attack-pass F2: k = 3 and 4 lines closed at the solver cap (no trail under 29 to 35 and 39 to 47 differential, 24 to 28 linear); row PASS effort-bounded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:36:58 +00:00
igneum-labs
f4f05f2e7c Counter ASIC 3.0 status: the proving-ids child 55768f88 built (sha256 279b1b690e854fc9), the pin candidate, its gates from 13:37Z
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:36:44 +00:00
igneum-labs
3d50882dfd Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 13:36:15 +00:00
igneum-labs
520a80a2e9 Mission item 12 gate on igneum-build-2 (13:27 to 13:34 UK): switch off, expect one PASS; switch off, expect eight FAIL as it must; switch on, expect eight PASS (the fresh node with one genesis peer reached 8 outbound at 231.7 s, 9 connections from the directory, node 0 logged every listing); the harness reads is_outbound (RpcPeerInfo is not camelCased in the fork)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:35:51 +00:00
igneum-labs
c9548894a5 Counter ASIC 3.0 status: the 4,096-seed census at 8bdcbdd8 (r = 0.674, 0 exhaustions); the control's verdict (statement passes, the race to a 24 GB box)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:35:11 +00:00
igneum-labs
b08ac2afc7 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the 4,096-seed census at fbb00320 (0 lossy sites, 0 exhaustions, r = 0.674, max attempt 17)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:34:22 +00:00
igneum-labs
541f3cf32f Counter ASIC 3.0 status: the suite line at 8bdcbdd8 (101 of 101 on box 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:34:17 +00:00
igneum-labs
14f64957b2 attack-pass: AP-F8-2, a chain-shaped epoch seed exhausts 32 attempts under sub-version 2's rule (a'), a liveness class
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:33:40 +00:00
igneum-labs
c0ec1f5e8a Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the suite line at fbb00320 (101 of 101 on box 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:33:34 +00:00
igneum-labs
935c4b61d6 Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 13:33:24 +00:00
igneum-labs
b010eeb2d7 Counter ASIC 3.0 status: the proving-ids control (the environment names the ids on c4459193), the late-join commit 70e4601e for 0.3.21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:33:16 +00:00
igneum-labs
2148ab1254 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the AP-F8-2 paragraph (the exhaustion fault, main's ruling, the 256-attempt bound and the last-resort program, the spec text and the probability)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:32:26 +00:00
igneum-labs
92757b148e Counter ASIC 3.0 status: AP-F8-2 fixed on 8bdcbdd8 (MAX_ATTEMPTS_V4 256, the last-resort draw, the stream unchanged, the census continues)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:32:26 +00:00
igneum-labs
1865e1da07 Genesis forward-compatibility: the results section (the suites, the gate build, both harness cases, the cross-binary digest, the 60x keeper line, the two faults found and fixed, the one open test-binary row) and the harness folder (both summaries, the three digest logs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:31:28 +00:00
igneum-labs
6f5787ed71 Build boxes: the measure file is retired; a pinned measurement leases its cores only (lease.sh cores), a whole-box quiet measurement is its own class (refused beside a slot or a lease, 20-minute cap, named owner), bounded suites never take it, every run keeps off leased cores, stopped holders are reaped after 5 minutes by every keeper, every waiter has a label file; the box-side self-tests in the gate; provision installs the lease tool and the headless Chromium libraries
Main, 7 October 2026, 15:07 UK: one global exclusive measure flock across unrelated measurements stalled build-1 at load 120 with
free slots (a stopped probe held it 5.5 h; an exclusive waiter queued every new shared taker) and build-2 behind a one-core VDF
bench. lease.sh (installed at /srv/builds/_bin/lease by provision.sh and by hand on both boxes) takes one flock per core for a
pinned measurement and the quiet file for a whole-box one; remote-run.sh takes quiet shared only for unbounded runs, excludes
leased cores from its set, and its keeper refreshes the holder file and calls lease reap (a STOPPED holder of a lease, quiet or a
slot for 5 minutes is killed with a line in _log/reaped.log). Keepers close the lock descriptors they inherit (an orphaned sleep
held a slot and the worktree lock 20 s past the release; the slot self-test had rotted on that since the worktree lock landed).
tools/ci/box-locks-check.sh runs lease.sh --self-test and remote-run.sh --self-test-slots on build-1. provision.sh also carries
the 16 libraries headless Chromium needs (installed by hand on both boxes at 14:5x UK) and a headless self-test step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:30:40 +00:00
igneum-labs
fbb0032067 Counter ASIC 3.0 gates (hash): AP-F8-2, the class v4 draw is total (main's ruling: no consensus path panics). Sub-version 2 unchanged: every seed that accepts within the bound draws the same program (re-export diff 0 on v4-devnet-epoch0, v4-era-0 and v4-era-5; the id a788661687db4bb3 and the seven fingerprints stand). The attempt cap of the class v4 shape is 256 (MAX_ATTEMPTS_V4; v2 and v3 keep 32): rules (a') and (c') reject about two thirds of candidates, so 32 attempts exhausted at about 2e-6 per epoch seed (seed igneum-f9/331672, the fdac338d panic), 256 at under 1e-45, half a second of draw in the worst case. After the cap the seed takes the last-resort program, deterministic and accepted as drawn: the candidate at attempt 256 with every or, mul and mulhi of the base program and the shadow block rewritten to xor, so every register stays fresh from the init words on and rule (a') holds by construction. A unit test walks the last resort on real (a')-rejected candidates (every load fresh after it, no lossy op left, the shapes kept) and the chain path over 64 seeds with no panic; the cap per class is asserted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:30:33 +00:00
igneum-labs
378b63dd35 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:30:23 +00:00
igneum-labs
00a8a286af Counter ASIC 3.0 status: the proving-ids cause (the bare start environment, not the binary), the child's embedded-key fallback, the gate carry under rule 4a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:29:40 +00:00
igneum-labs
a9f5b5fd4d Chain scene 2.0.3: /live and the home fold paint on every push whatever the document's visibility says; the renderer, its palette and its feed contract move to one shared folder scene/ with byte-equal copies checked by the gate (7 October 2026, 15:2x UK)
The blank /live (the project lead, 14:3x UK): IgneumDag 2.0.2 painted only from a requestAnimationFrame loop gated on document.hidden and
the IntersectionObserver, so a page that loaded with document.hidden true (the desktop pane, a background tab) and whose embedder
never fired visibilitychange took every push, reported live and never drew a frame. 2.0.3 paints the current picture synchronously
on push, size and theme change; the motion loop alone waits for a visible document and an intersecting canvas. Confirmed headless
on build-2 against the live feed: hidden document 0 lit pixels before, 110,007 after; the never-intersecting observer repaints
on every push. Known-failed test tools/scene/paint-test.cjs (the 2.0.2 renderer draws nothing in the same world).

The second 2.0.3 change: the phone rule (30 s window, four lanes) keys on the viewport width, not the canvas width; a 640 px hero
on a 1,440 px laptop was rendering as a phone while the app's card beside it was not.

scene/ is the one source: live-dag.js, proof-core.js, tokens.css (the fourteen palette tokens, the brand package's values, dark and
light), feed-contract.md and .json (one JSON shape for the observer's /api/live and the app's api/live), a recorded reply as the
fixture. tools/scene/sync.mjs writes the copies and the scene-tokens block into site.css and app.css; --check is the gate line
(byte-equal scripts, an equal block, the names defined nowhere else, a print block excepted), --self-test fails five known cases
first. The site's token definitions move out of the package's :root line into the block; no value changes on the site.
tools/scene/feed-contract.mjs validates a reply against the key lists; its test refuses a miner rewritten to "you", a float now,
a stray key. Three new lines in tools/ci/pre-push.sh. The app side (branch scene-parity, for 0.3.21) takes the same folder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:29:38 +00:00
igneum-labs
ac030be0a9 Counter ASIC 3.0 status: main's AP-F8-2 ruling (a total draw, no panic); the 12 GB line on c4459193 refused (shard program id unknown, statement zeros), the cut set not complete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:28:05 +00:00
igneum-labs
dae0d790c4 Counter ASIC 3.0 status: AP-F8-2, draw exhaustion under (a') on sub-version 2 (one seed in 331,672, a liveness halt class); not green
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:27:04 +00:00
igneum-labs
e3329b65a9 Counter ASIC 3.0 status: the PC 1 G1 variant staged (a2714d43, the 5090 by index-free key, published only on the go line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:21:41 +00:00
igneum-labs
37b97d2086 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:20:55 +00:00
igneum-labs
eae74e13c2 Counter ASIC 3.0 status: the PC 1 G1 gated on the shipper's "PC 1 on 0.3.20" line, about 15:30 to 15:40Z
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:20:55 +00:00
igneum-labs
e56085e3a5 Counter ASIC 3.0 gates (hash): the PC 1 variant of the sub-version 2 G1 job (published only on main's go-PC-1 line: the 5090 off by the runner's --cards-off, quiet confirmed by the process list and nvidia-smi, nothing else on PC 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:20:54 +00:00
igneum-labs
0deaa5489a CI queue: a second self-hosted runner (igneum-build-2 joins the pool label), docs-only pushes skip the compile jobs, the red watcher pinned to the box that posts
31 runs were queued on igneum-build-1's one runner at 13:15 UK on 7 October 2026 and nothing had concluded since 13:03Z, so no lane could read a conclusion.
- ci.yml: a `changes` job (ubuntu-latest) classifies the push with tools/ci/docs-only-check.sh (docs/, site/, *.md only = code=false; a new branch, a pull request, a force push or an API error = code=true); pow and sims need it and run only on code=true. The site job is unchanged on ubuntu-latest for every push. The classifier's self-test is in the gate.
- provision.sh and runner/register.sh: `--host <ip>` registers another box, forwarding BOX_HOSTNAME, RUNNER_NAME, RUNNER_LABELS, RUNNER_CPUS and RUNNER_JOBS (plain words only); RUNNER_CPUS writes AllowedCPUs into the service drop-in beside Nice=10, so igneum-build-2's runner is bounded like a suite (32 cores). The pool label is igneum-build-1 (both boxes carry it); ci-red marks the box with the record file and the poster, the default labels carry it, and igneum-build-1 got it through the runners API today.
- ci-red.yml runs on the ci-red label, so the red line always lands where the poster reads it.
- CLAUDE.md: the rule reads "read the conclusion when it lands, own a red before the next push"; pushes are never held.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:20:18 +00:00
igneum-labs
8b7de9f0f6 fast-time key-succession harness: the carried-once check reads the RPC-first node's 'now carried by' line as well
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:19:44 +00:00
igneum-labs
d258054854 Counter ASIC 3.0 status: main's word on PC 2 (hand restart asked of the project lead; go PC 1 for the G1 at 15:00Z if silent)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:19:36 +00:00
igneum-labs
37373101f5 Merge branch 'master' of https://github.com/igneum-network/igneum into genesis-forward 2026-10-07 13:18:41 +00:00
igneum-labs
8d8a7fd726 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:18:34 +00:00
igneum-labs
cd0ff2a6c5 Counter ASIC 3.0 status: sub-version 2's suite line (526fa757, 100 of 100 on box 2, the route line), the re-gate carries from 07a809a7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:17:56 +00:00
igneum-labs
721e0f3ab9 Merge remote-tracking branch 'origin/master' into build-server 2026-10-07 13:17:19 +00:00
igneum-labs
ecdac91ad3 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the sub-version 2 suite line (100 of 100 at c192c853 on box 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:17:15 +00:00
igneum-labs
caff656593 Build boxes: the box probe carries its own ssh options (bash 3.2 under set -u refused the unset BS_SSH_OPTS array before bs_host built it; every unpinned route on the Mac died, the pool lane 7 Oct 2026); the router check runs the ssh path under /bin/bash
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:16:39 +00:00
igneum-labs
5cc07506f0 Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:16:25 +00:00
igneum-labs
d9b249cee0 Counter ASIC 3.0 status: PC 2 silent since the 0.3.19 update-now; G1 and the Windows kept-datadir gate wait on a hand restart
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:15:44 +00:00
igneum-labs
7175372f51 Mission item 12 status: the design as built, the commits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:15:18 +00:00
igneum-labs
7e1b1c58e8 CI red watcher: its own workflow_run workflow (ci-red.yml) so the copy on master watches every branch's run whatever ci.yml the branch carries
GitHub runs a workflow_run workflow from the default branch only, so a feature branch no longer waits for a merge of master before its reds are posted. record() reads the FAILED run from RED_WATCH_* (id, attempt, workflow, branch, sha, event, url, actor, title, author: the workflow_run payload) and asks the jobs API for that run, not the watcher's own; the inline GITHUB_* shape stays for a branch with the old `red` job (one line per run id either way). The inline job leaves ci.yml. Self-test covers the workflow_run shape and the full line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:15:11 +00:00
igneum-labs
c192c85374 Counter ASIC 3.0 gates (hash): the two generator unit tests follow sub-version 2 (the amended program accepts at a later attempt, so the v4 facts are checked against the class v3 candidate at the same attempt; a 256-block over MX8 is the class v4 shape and draws under the rule on every path, so the untouched-base property is shown on a 64-block and the 256-block is shown fresh)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:14:22 +00:00
igneum-labs
009995351a Counter ASIC 3.0 status: sub-version 2's static census (0 lossy-sourced sites of 16,432), the attempt cost, G1 blocked on PC 2's intake
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:14:03 +00:00
igneum-labs
8ea6754ad4 Mission item 12: the peer-directory fast-time harness (ten listing nodes announcing their loopback p2p address, every node advertising an unroutable external ip so gossip is dead and the directory is the one live source, a fresh node with one genesis peer watched for 8 outbound), the eclipse simulation (the draw as implemented, without replacement, 34 percent attacker over m keys), and peer_directory_activation_daa in the 60x file
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:14:00 +00:00
igneum-labs
aaafa57a77 Merge remote-tracking branch 'origin/master' into peer-directory 2026-10-07 13:13:59 +00:00
igneum-labs
3725683e08 Counter ASIC 3.0 status: main's word on the floor move, the CI lever and the rule's reading
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:13:02 +00:00
igneum-labs
0d9555b114 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the sub-version 2 census line (0 lossy-sourced sites over 1,024 seeds, 1.99 attempts per seed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:48 +00:00
igneum-labs
97495d778e fork-gate harness: a key in a summary is its first 8 hex and an ellipsis, never the 64 (the raw keys of the 6 October summaries turned the no-secrets check red on every CI run of their branch); the six summaries of 13:58 UK redacted the same way
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:44 +00:00
igneum-labs
e35b40bcc2 Merge remote-tracking branch 'origin/master' into horizon 2026-10-07 13:11:38 +00:00
igneum-labs
bd542bf76b Merge remote-tracking branch 'origin/master' into ca3-coord 2026-10-07 13:11:22 +00:00
igneum-labs
94dc0e0138 Counter ASIC 3.0 status: 0.3.20's cut set as it stands, 0.3.21's clock and the byte-5 fallback, the CI queue note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:11:22 +00:00
igneum-labs
74fa6564cb Merge remote-tracking branch 'origin/master' into attack-pass 2026-10-07 13:11:00 +00:00
igneum-labs
61668cb15e Merge remote-tracking branch 'origin/master' into ca3-v4-amend 2026-10-07 13:10:58 +00:00
igneum-labs
24b48fd644 Counter ASIC 3.0 gates (hash): the accept.rs test initialisers carry the (c') field (the box suite's E0063)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:10:58 +00:00
igneum-labs
46494d36b3 Merge branch 'master' of https://github.com/igneum-network/igneum into genesis-forward 2026-10-07 13:10:39 +00:00
igneum-labs
1df38be497 Counter ASIC 3.0 gates (hash): the PC 2 G1-only playbook for the sub-version 2 packs (--cards-off runner)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:03:52 +00:00
igneum-labs
71d1a97382 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the sub-version 2 paragraph (the 11 of 64 residual on sub-version 1, the three classes, the three rules, byte 7, the new id and fingerprints, the gates owed before any proposal)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:03:25 +00:00
igneum-labs
068fa345af Counter ASIC 3.0 status: sub-version 2 committed (07a809a7, id a788661687db4bb3, the fixpoint rule, seven fingerprints, packs sha256)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:03:19 +00:00
igneum-labs
fdac338dbc Counter ASIC 3.0 gates (hash): class v4 sub-version 2 (AP-F8-1, main's ruling B2: 0.3.20 ships sub-version 1 untouched; this stream is object byte 7). F1, the draw: a load's source is drawn only from registers fresh by dataflow (fresh at the start; a load keeps freshness only from a fresh source; add, sub, xor, mad, shfl from either operand; rotl, rotr from their operand; or, mul, mulhi never), keyed on the class v4 shape on EVERY draw path (era or not, the pass count set aside), so a census through candidate_class reads the chain's stream. (a'), accept.rs: the same freshness run to its fixpoint over the loop (base then shadow block) and every load's source fresh in the steady state, else the candidate is rejected and the next attempt drawn (closes the iteration boundary the draw cannot see: F8's p11, an or at 63 feeding a load at 1, and the load-after-load and rotate-of-saturated chains of p6, p23, p26, p31, p34). (c'), accept.rs: per load site, the count of source values equal to 0 or all-ones over the 64 units' 16,384 evaluations, rejected at 164 or more (the (c) limit), the backstop for any delivery of saturation (zero and all-ones alike: p45's mulhi zero). Both keyed on the class v4 shape, so v2 and v3 verdicts and ids do not move. PROGRAM_SUBVERSION_V4 = 2 in the id suffix and the pack lines. The seven gate packs re-exported: the devnet epoch-0 seed's attempt 0 is now rejected and attempt 1 accepted, id a788661687db4bb3 (must-differ: c120d7963abdcd96 the 6 October stream, 1a4230699a6b9c60 sub-version 1); the seven 256-block ladder packs of packs-ca3-shadow re-exported under the rule (the no-era path moves too; their measured rates stand as the old stream's). Tests: the generator test checks the fixpoint rule on the amended program and the known-failed case (the v3 stream re-labelled v4); the mixer contract checks the dataflow rule on every V4-shaped class, era or not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:01:40 +00:00
igneum-labs
7b71196efe fast-time 60x file: every field OverrideParams knows on the 0.3.20 line (17 were missing: the difficulty v3, DAA-rule, fork gate, vote-or-burn, signing bonus and leave switches with their shares and window, the latency ladder list, activation and 120-DAA window, exec_restart_state_root, emission (CURRENT), proving_consensus_verify_daa and the two program ids); the keeper suite's fast_time_60x_file_is_the_devnet_at_60x reads it
The box mirrors carry no infra/, so the test skipped there and the gaps showed only on the Mac and in the class v5 lane's run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:00:15 +00:00
igneum-labs
21194e93fd Mission item 4 status: the gate line GREEN on igneum-build-2 (6 of 6, known-failed first, partition heal unchanged), the hole and its fix, suite counts per box, consequences per tier, what is open; item 12 status with the NAT reading and the eclipse simulation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:59:47 +00:00
igneum-labs
efbe6e6ec4 attack-pass F8: re-gate verdict on class v4 sub-version 1, FAIL 11 of 64 (worst 29.27x), three residual classes incl. the iteration boundary; F9 harness retired from the re-gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:58:09 +00:00
igneum-labs
b43f5ee4e8 fast-time-remote.sh: the harness directories by overlay only, never a checkout of the worktree root (its first run at 13:44 UK took igneum-build-2's /srv/builds/igneum-wt-horizon/vendor, the fork sources and the gate binary just built there, because that root is no repo checkout and remote-run's checkout mode replaces the directory)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 2c9bd29afc)
2026-10-07 12:48:16 +00:00
igneum-labs
f1435e85f2 fast time: the three-node fork-gate harness, its six-case runner and a box wrapper for harness runs (mission item 4)
infra/fast-time/fork-gate.mjs: H1 and H2 honest and mining through every phase, B the third node; modes attack (B's
key fresh, 3 of 5 CPU threads in the split), third (B at about half of the table, accepted) and partition (H1 against
H2, the heavier side wins with the gate on as with it off). A reorg is read from the chain (getVirtualChainFromBlock
of the pre-cut tip lists removed blocks), never from a key; blue work compared as BigInt; leftovers stopped by pid
file, never by name. infra/fast-time/fork-gate-gate.mjs runs the six cases side by side (known-failed first) and is
GREEN only when every case gives the verdict it must and the two partition heals agree. tools/fast-time-remote.sh
runs a harness on a build box under a slot with a holder line and a JSONL row (the node binaries from a fork
worktree's target on the box, results fetched back); it carries the whole-body block and is listed in the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7f3e75479e)
2026-10-07 12:48:16 +00:00
igneum-labs
2c9bd29afc fast-time-remote.sh: the harness directories by overlay only, never a checkout of the worktree root (its first run at 13:44 UK took igneum-build-2's /srv/builds/igneum-wt-horizon/vendor, the fork sources and the gate binary just built there, because that root is no repo checkout and remote-run's checkout mode replaces the directory)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:46:38 +00:00
igneum-labs
7f3e75479e fast time: the three-node fork-gate harness, its six-case runner and a box wrapper for harness runs (mission item 4)
infra/fast-time/fork-gate.mjs: H1 and H2 honest and mining through every phase, B the third node; modes attack (B's
key fresh, 3 of 5 CPU threads in the split), third (B at about half of the table, accepted) and partition (H1 against
H2, the heavier side wins with the gate on as with it off). A reorg is read from the chain (getVirtualChainFromBlock
of the pre-cut tip lists removed blocks), never from a key; blue work compared as BigInt; leftovers stopped by pid
file, never by name. infra/fast-time/fork-gate-gate.mjs runs the six cases side by side (known-failed first) and is
GREEN only when every case gives the verdict it must and the two partition heals agree. tools/fast-time-remote.sh
runs a harness on a build box under a slot with a holder line and a JSONL row (the node binaries from a fork
worktree's target on the box, results fetched back); it carries the whole-body block and is listed in the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:26:38 +00:00
igneum-labs
b4d86987f0 attack-pass F8: the hash lane's confirmation of the residual on the chain path and the sub-version 2 freshness rule shape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:24:53 +00:00
igneum-labs
7e8822fc0f attack-pass F8/F9: re-gate interim (nine of thirty seeds over 1.2x on the amended stream, load-after-load chain); F9's box 2 run withdrawn as a re-gate (candidate_class path)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:24:21 +00:00
igneum-labs
fc58621c16 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the crate suite line (100 of 100 at 8d9d6859 on igneum-build-1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:18:47 +00:00
igneum-labs
38af74bb4a attack-pass F9: edges PASS (34 of 105,064 on generator 4, bounded), hot-set FINDING on 10^6 seeds (11,696 at 1 percent or more, the AP-F8-1 class, F9-1 merged), grinding PASS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:52:13 +00:00
igneum-labs
78b4c5467b attack-pass F1: AP-F1-1 fraction 3.0 percent (class-v5 65c506a2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:29:36 +00:00
igneum-labs
20eb55e38e Merge remote-tracking branch 'origin/master' into attack-pass 2026-10-07 11:23:43 +00:00
igneum-labs
a1cbb986e1 Merge branch 'master' of https://github.com/igneum-network/igneum into genesis-forward 2026-10-07 11:23:40 +00:00
igneum-labs
ce10bc19a9 Merge remote-tracking branch 'origin/master' into genesis-forward 2026-10-07 10:31:46 +00:00
igneum-labs
3ca7ca277c Merge remote-tracking branch 'origin/master' into attack-pass
# Conflicts:
#	docs/evidence.md
2026-10-07 10:31:29 +00:00
igneum-labs
b59bd7045a attack-pass F1: PASS under the re-worded gate (1); AP-F1-1 shadow redundancy bound routed to the v5 list (coordinator ruling)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:15:58 +00:00
igneum-labs
61f97bec2f attack-pass F10 record: time-zone wording passes the identity grep
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:14:22 +00:00
igneum-labs
147584c49c attack-pass collector: F1 PASS on substance with AP-F1-1 (5.078 percent on 1 of 10^5), F2 PASS effort-bounded, F10 PASS, F9 (c) grinding PASS at +0.004 percent, F8 phase E 31 of 64 over 1.2x, F6 INCOMPLETE re-armed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:13:29 +00:00
igneum-labs
8d9d685981 Counter ASIC 3.0 gates (hash): the source rule keys on the class with the shadow's pass count set aside (the node lane's line: era present, the V4_SHADOW_INSTRS block, the base equal to V4_CLASS without era and shadow), so every rung of the latency ladder draws under it; of_load_class compared the pass count and left every rung but 27 on the old draw (the fork's ladder test, 10:06Z). The reps-0 packs and their ids do not move (re-export byte-identical)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:09:28 +00:00
igneum-labs
e4a433e016 Counter ASIC 3.0 gates (hash): igneum-pow taken from release-0.3.20 (ba329e32: the latency ladder's chain_program_shadow with the reps argument, never merged to master) with the AP-F8-1 amendment re-applied on top (the diff of 0a62293a and the test fix, applied without offset); the seven packs re-export byte-identical (checked on v4-devnet-epoch0 and v4-era-5), so the ids and fingerprints stand; the pairing is against the fork's release-0.3.20-node dc141409
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:08:43 +00:00
igneum-labs
60ce578f0e Counter ASIC 3.0 gates (hash): the v4 unit test follows the amendment (the base program is its own stream over class v3's load slots and era draw; every load site obeys the source rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:07:43 +00:00
igneum-labs
8d691bf599 Genesis forward-compatibility (mission item 8): design doc, spec text (W1 scheme byte, W5 succession, the cache rung, the VDF fallback flagged), ledger rows GF1 to GF4, the fast-time harness, override-60x.json
docs/design/genesis-forward.md names the three fields, their defaults, the digest change and the gates; spec 03 W1 and W5
amended and the W5 implementation row filled; spec 01 the cache rung beside the schedule; spec 04 the class-group VDF's
quantum fallback flagged, not sized; infra/fast-time/key-succession.mjs (3 nodes, one CPU miner each: the window fills,
a succession carried once on every node, refused twice, scheme 1 refused by every node; the known-failed case
--expect no-succession first); override-60x.json carries the four new keys at never.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:03:37 +00:00
igneum-labs
6fe1b916f8 attack-pass: snapshot of the row drafts and harnesses as they stood at the Mac reboot (10:5x UK); the collector finishes them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:02:44 +00:00
igneum-labs
04ed87bc46 Counter ASIC 3.0 gates (hash): ledger AP-F8-1, the G1 line (eight fingerprints equal on the RTX 5090, self-test PASS) and the node lane's signal byte 5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:51:24 +00:00
igneum-labs
afa63e344f Counter ASIC 3.0 gates (hash): the fud-ledger entry AP-F8-1 (the fault, the project lead's ruling, the amendment, the split protection, the owed tests); the suite, pairing and G1 lines fill when the jobs land
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:41:11 +00:00
igneum-labs
a5b8fee21e Counter ASIC 3.0 gates (hash): the PC 2 G1-only playbook for the amended class v4 packs (AP-F8-1, limited testing by the project lead's word)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:40:03 +00:00
igneum-labs
0a62293a95 Counter ASIC 3.0 gates (hash): the class v4 amendment for AP-F8-1 (the project lead: option A, limited testing). The chain draw of class v4 takes a load's source only from registers whose last writer injects (add, sub, xor, mad, shfl, load) or is a rotate (rotl, rotr), never one last written by or, mul or mulhi (candidate_from_words_class, keyed on the era-composed V4_CLASS; no attempts lost, rule (a) unchanged, v2 and v3 and the generator-2 ladder packs byte-identical). Split protection agreed with the node lane: generator stays 4 and the program id of a generator-4 program appends "sub/" || PROGRAM_SUBVERSION_V4 (= 1) as little-endian u16 bytes inside program_id(), so a pre-amendment binary and this one never share an id for one seed; packs carry IGNEUM_PROGRAM_SUBVERSION 1 and program.json sub_version 1, and packcheck refuses a generator-4 pack whose sub-version is absent or other. The seven gate packs re-exported (devnet epoch 0 and eras 0 to 5): id 1a4230699a6b9c60 (was c120d7963abdcd96, now the must-differ vector in tests/recheck.rs), fingerprints Metal = Apple OpenCL 867dbc45cfb36b4d, 2146ecacc8c75a8e, fe52602393f6d3d4, 3b206471a13912b4, c3f03c4a5d7333aa, f1dfd7209f15bb97, 8c194da64fadf31d; the v3 control 73bcbfe8ccf988f1 / 90f794dd556f7a3b untouched. The mixer harness checks the source rule on every load site of an amended program instead of equality with the v3 base
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:38:51 +00:00
igneum-labs
bb095461d6 Merge branch 'ca3-v4-uniform' into ca3-v4-amend 2026-10-07 09:33:11 +00:00
igneum-labs
4237d2bb91 attack-pass F8: the v4 amendment ships in 0.3.20 (the feature node), not 0.3.19 (app-only cut)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:33:02 +00:00
igneum-labs
05274027c8 attack-pass F8: ruling, the v4 amendment ships in 0.3.19; this lane proves the fix with the 64-seed census against ca3-v4-amend
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:32:39 +00:00
igneum-labs
ab9bc71b94 attack-pass F8: AP-F8-1 mechanism found by the hash lane (lossy load source, an or writer feeding site 15; window null 1.39x); v4 hot-set bound 1.067x; v5 generator item
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:28:57 +00:00
igneum-labs
4a4bcac0b9 Counter ASIC 3.0 gates (hash): AP-F8-1 under the windows-union model. The window layer moves the null from 0.115 to 0.160 percent at f = 0.1 percent (1.39x, not 4.05x); the 153x item is the all-ones value of an or-written load source (site 15, or at 61, load at 63), which the era map sends to item 0xca5b92 exactly and whose next seven items are the seven surviving one-zero-bit sources; the popcount model at the measured bias predicts 77,348 reads against 78,479 and the program's S_0.1 percent at 0.58 against 0.52; a static census of 1,024 chain-shaped v4 programs (tools/ca3-v4-uniform, built and run on igneum-build-1): 96.6 percent carry a lossy-sourced load, 48.5 percent an or-sourced one, 4.9 percent an or chain (p3's class); F8's 1.2x gate fails 96.6 percent; the chip ceiling under rule (c) is 1.067x; the two flip options priced; the v5 bound defined
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:26:48 +00:00
igneum-labs
cd7dde81ef attack-pass F7: board row PASS (b, c), INCOMPLETE (a) pending the era VDF; the VDF named as a freeze precondition
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:55:56 +00:00
igneum-labs
91a169662f attack-pass F7: census and day-key seeding PASS at 2^24; re-roll harness INCOMPLETE pending the era VDF, named as a freeze precondition
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:55:30 +00:00
igneum-labs
5b023ca6c0 attack-pass F4: AP-F4-1 landed on the class v5 bound list; brief rank 3 reads a bounded tail, measured
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:54:59 +00:00
igneum-labs
58b29ece25 attack-pass F4: PASS against v4 on the DSP-bound metric (0 of 2^28 days over 1.1x); AP-F4-1 bound finding on LUT adders routed to the next class with the redraw rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:54:32 +00:00
igneum-labs
94fc023fc3 attack-pass: gate (4) and row F8 wording per the coordinator (window model of spec 1.13.1, excess within 6 sigma over 64 seeds); F4 interim on the board
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:51:18 +00:00
igneum-labs
bfcba8177a attack-pass F6: O-1.14 closed on a real 2019-class core (i7-9700K): class v4 6.006 ms avg, 6.334 cold max per warp; dr736 fails as it must
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:50:17 +00:00
igneum-labs
8c04158e37 attack-pass F8: AP-F8-1 gate is the window model; open on the excess only; no v4 generator change (coordinator ruling)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:49:11 +00:00
igneum-labs
f0860e618c attack-pass F8: AP-F8-1 reframed against the window model of spec 1.13.1; the 153x item is the question; gate wording raised
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:48:52 +00:00
igneum-labs
9380508f65 attack-pass F6: O-1.14 host re-rented after the fleet listing-cap correction
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:47:32 +00:00
igneum-labs
2e942495a7 attack-pass F8: interim finding AP-F8-1, hot-item skew from read site 15 (4.05x at the top 0.1 percent over 2^26 nonces), routed to the Counter ASIC lane
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:47:17 +00:00
igneum-labs
7cbcc826de attack-pass F6: O-1.14 INCOMPLETE today, no 2019-class CPU host rentable (Vast spend limit, RunPod has none); laptop or lifted limit next
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:37:37 +00:00
igneum-labs
b3c5954449 igneum-pow at the 0.3.18 release line (origin/release-0.3.18 f8954971): Epoch::chain_program_shadow and the ladder's shadow size, which the 0.3.18 node fork calls (the master copy predates the ladder; kaspa-pow failed to compile against it on the box, 07:46 UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 6edbea8ec0cbc5b8846a02c7841a8ad8197ae4cc)
2026-10-07 08:31:22 +00:00
igneum-labs
a2ca7f1032 Merge remote-tracking branch 'origin/master' into attack-pass 2026-10-07 08:27:25 +00:00
igneum-labs
a71308d469 attack-pass F5: the F2 FPGA hour reads SKIPPED-BY-DECISION (the project lead, 7 Oct 2026), matching plan 4.2 at 3714c2a0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:27:23 +00:00
igneum-labs
b98090649e attack-pass F3: PASS (0 of 64 and 0 of 1,024 lines under j+1, curve monotone, f=1 unchanged); AP-H1 box-clean hazard recorded
The F3 record and its harness crate (tools/attack/f3-cache: the extracted chain,
the exhaustive closure search, the pebbling cross-check, the store-set DP and
brute force, the two planted broken chains). The optimal-placement observation
(3.17 blocks per read at f=1/8, 16.0 at f=1/64) noted against funding.md B2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:16:48 +00:00
igneum-labs
2327aa83ba attack-pass F6: O-1.14 takes the rented 2019-class CPU host route, two-hour cap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:08:01 +00:00
igneum-labs
43287e65a3 attack-pass F5: AP-F5-1 re-gated, row reads FIXED-AND-PASSED (F2 hour skipped)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:04:54 +00:00
igneum-labs
e1fcc16855 attack-pass: AP-F5-1 recorded as fixed on the docs rows; rebased on master 107090db after X36
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:04:07 +00:00
igneum-labs
f4ad7b0aa9 attack-pass AP-F5-1 fix: evidence row 17 and M32 carry k about 0.33 as the withdrawn X9's claimed bound, not a measured core
Primary sources confirmed by the coordinator, 7 October 2026: the Antminer X9
took pre-orders from 26 December 2025, was withdrawn in mid-May 2026 with
refunds before any unit shipped, has no independent benchmark, and was
commodity Sophgo SG2044 SoCs with no tapeout. The headline stays 2.1x per joule
over the 5090 at class v4 and k = 1 with 3.9x at k about 0.33 as the pessimistic
bound; the economic row added. The ladder design doc's section 5a rides branch
attack-ladder-5a. Served-text rows stay with the site lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:03:43 +00:00
igneum-labs
2227560b77 attack-pass F5: record the X9-framing finding (AP-F5-1), not a clean PASS
The chip-model sweep reproduces the numbers (2.1x at k=1 GDDR7, 3.9x at k=0.33,
matching fud-ledger M32). The finding is the framing: M32 calls k=0.33 the X9's
measured core and the ladder branch section 5a calls it a measured class, but
the Antminer X9 was withdrawn before launch and never benchmarked, so k=0.33 is
a claimed datasheet bound. This also questions the merged ledger X34 (RandomX
has a shipping chip). The public X9 sentences are held unchanged until the
coordinator research agent confirms the withdrawal; the re-cut is specified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:03:43 +00:00
igneum-labs
b0229e89c2 attack-pass: internal F1 to F10 pass doc, F5 sweep PASS and F6 proxy landed
The internal cryptanalysis pass before the freeze tag cryptanalysis-target-1
(docs/plans/cryptanalysis.md 4.2). Ten rows with method, known-failed shape,
gate and status. Landed: F5 chip-model sweep (the 2.1x per joule edge over the
5090 at v4 and k=1 reproduces exactly on the GDDR7 measured-anchor column; AWS
F2 hour skipped, no AWS account on this Mac; X9 k=0.33 carried as a claimed
pessimistic bound from a withdrawn design, with the NRE-recovery economic row);
F6 verifier v4 average 4.90 to 5.06 ms one-core cold, 8.23 ms half-core proxy,
under the 10 ms gate (worst-case 10^5 search and the laptop run owed). The rest
are RUNNING or, for F9 header grinding, BLOCKED on the PC 2 or rented-pod go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:03:43 +00:00
938 changed files with 113592 additions and 45406 deletions

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent
model: fable
---
You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
## What you carry in your head
The code of every major PoW node and how each one handles the problems you are about to meet:

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent
model: fable
---
You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
## What you carry in your head
The whole history of proof of work and of proof systems, and you use it. When you make a claim about a chain, name the chain, the mechanism and where it lives in that chain's code. Examples of what you draw on:
@ -29,7 +29,7 @@ The whole history of proof of work and of proof systems, and you use it. When yo
- Numbers are measured or cited. A number from memory is labelled approximate. Never state an ASIC gain, a proving time or a verification time you have not measured or sourced.
- Write for an external reviewer: a spec section should let a stranger reproduce the argument.
- Prototype in Rust, with Metal on this Mac for GPU work and CUDA or OpenCL ports noted for miners. Benchmarks go in bench/ with the exact command and hardware.
- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the project lead overrides.
- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the founder overrides.
## Writing rules
No em dashes. Short sentences. Numbers in tables. The project is called Igneum. Approximate figures say so.

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent
model: fable
---
You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
## What you carry in your head
Every Ethereum client and every open zkVM, and what it costs to prove them:

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent
model: fable
---
You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
## What you carry in your head
Fifteen years of mining communities, and you remember what they did and why:

48
.github/workflows/ci-red.yml vendored Normal file
View file

@ -0,0 +1,48 @@
# The red watcher as its own workflow, on workflow_run, so the copy on master watches EVERY branch's ci run whatever
# ci.yml that branch carries: GitHub runs a workflow_run workflow from the default branch only, and the branch's own
# ci.yml never enters it (7 October 2026: the inline `red` job of ci.yml was conditioned on master and release-*, and
# a feature branch would have waited for a merge of master before its reds were posted at all).
#
# One line per failed, cancelled or timed-out run (tools/ci/red-watch.mjs record, idempotent per run attempt) to /srv/ci-red/red.jsonl on the
# box; the box's igneum-ci-red.timer posts each new line once to the hidden updates channel, naming the branch, the
# commit, the red check and the pushing author. Runs on the box's own runner (not a GitHub-hosted machine: the billing
# block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told). Never blocks a
# release: it reads the run, writes one line, and ends.
name: ci-red
on:
workflow_run:
workflows: [ci]
types: [completed]
jobs:
red:
name: red watcher (every branch; one line per failed run, with the branch, commit, red check and pushing author, to the updates channel and the box file)
# failure, and since 7 October 2026 (17:2x UK) cancelled and timed_out too: a job that hangs into its timeout-minutes or a run
# someone cancels is a run that never answered, and a lane reads it like a red (tools/ci/red-watch.mjs names the kind)
if: ${{ github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'cancelled' || github.event.workflow_run.conclusion == 'timed_out' }}
# the label ci-red is on igneum-build-1 only (added through the runners API on 7 October 2026; the default of
# RUNNER_LABELS in provision.sh carries it): the record file and the poster (igneum-ci-red.timer, the webhook file)
# live on that box, and the pool label igneum-build-1 is shared with igneum-build-2 since the same day
runs-on: [self-hosted, linux, x64, ci-red]
timeout-minutes: 5
permissions:
actions: read # the failed run's jobs API (the first real red run, 21:19Z on 6 October: the default token answered 403 and the line carried no step)
contents: read
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- name: record the failed run (one line, the branch, the commit, the failed jobs and their first failed step from the run's own API, the pushing author)
env:
GITHUB_TOKEN: ${{ github.token }}
RED_WATCH_RUN_ID: ${{ github.event.workflow_run.id }}
RED_WATCH_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
RED_WATCH_WORKFLOW: ${{ github.event.workflow_run.name }}
RED_WATCH_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
RED_WATCH_BRANCH: ${{ github.event.workflow_run.head_branch }}
RED_WATCH_SHA: ${{ github.event.workflow_run.head_sha }}
RED_WATCH_EVENT: ${{ github.event.workflow_run.event }}
RED_WATCH_URL: ${{ github.event.workflow_run.html_url }}
RED_WATCH_ACTOR: ${{ github.event.workflow_run.actor.login }}
RED_WATCH_TITLE: ${{ github.event.workflow_run.head_commit.message }}
RED_WATCH_AUTHOR: ${{ github.event.workflow_run.head_commit.author.name }}
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl

View file

@ -8,13 +8,16 @@
# local gate and CI cannot drift (6 October 2026: 131 red `ci` runs in three days, 92 of them on master, every one a
# tree check that would have failed on the pushing machine in under 25 s; docs/analysis/ci-failures-2026-10-06.md).
#
# Where it runs: `pow` and `sims` go to the box's runner (igneum-build-1, rustc pinned, sccache read-only, 48 jobs)
# Where it runs: `pow` and `sims` go to the self-hosted pool (label igneum-build-1: the runners on igneum-build-1 and, since
# 7 October 2026, igneum-build-2, which carries that label too; rustc pinned, sccache read-only) and only when the push
# touched code (the `changes` job; a docs-only push skips them)
# when the repository variable IGNEUM_CI_RUNNER is `box`, else to ubuntu-latest (docs/plans/ci-self-hosted.md; GitHub
# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The `red` job
# runs on the box after any failed run on ANY branch and records the failure for the watcher
# (tools/ci/red-watch.mjs; infra/build-server/ci-red): one line per run, naming the branch, the commit, the red check
# and the pushing author, to the hidden updates channel and to /srv/ci-red/red.jsonl, so nobody opens the Actions page
# to learn a branch is red (master and release-* only until 7 October 2026, when eight red runs on ca3-v4-node went unseen).
# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The red watcher
# is its own workflow, .github/workflows/ci-red.yml (workflow_run, so the copy on master watches every branch's run
# whatever ci.yml that branch carries): one line per failed run, naming the branch, the commit, the red check and the
# pushing author, to the hidden updates channel and to /srv/ci-red/red.jsonl (tools/ci/red-watch.mjs;
# infra/build-server/ci-red), so nobody opens the Actions page to learn a branch is red (the inline `red` job here
# watched master and release-* only until 7 October 2026, when eight red runs on ca3-v4-node went unseen).
#
# What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with
# rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is
@ -25,9 +28,42 @@ on:
push:
pull_request:
jobs:
changes:
# What the push touched (tools/ci/docs-only-check.sh): a push of documents only (docs/, site/, *.md) skips the two
# compile-or-compute jobs below, which read none of those paths, so the self-hosted queue carries only runs that can
# change their result (7 October 2026: 31 runs queued on one runner, most of them status-document pushes). The tree
# gate (the `site` job) runs on ubuntu-latest for every push. A pull request, a new branch or a force push answers
# code=true (no `before` to compare from), as does any error reading the compare API: when in doubt, run.
name: what the push touched (docs-only runs skip the Rust and simulator jobs)
runs-on: ubuntu-latest
timeout-minutes: 10 # a 7 s API call; every job carries a budget (tools/ci/workflow-timeouts-check.sh)
outputs:
code: ${{ steps.classify.outputs.code }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- id: classify
env:
GH_TOKEN: ${{ github.token }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
REPO: ${{ github.repository }}
EVENT: ${{ github.event_name }}
run: |
if [ "$EVENT" != push ] || [ -z "$BEFORE" ] || [ "$BEFORE" = 0000000000000000000000000000000000000000 ]; then
echo "code=true" >> "$GITHUB_OUTPUT"; echo "no base to compare from ($EVENT): the compile jobs run"; exit 0
fi
files="$(gh api "repos/$REPO/compare/$BEFORE...$AFTER" --paginate --jq '.files[].filename' 2>/dev/null || true)"
line="$(printf '%s\n' "$files" | bash tools/ci/docs-only-check.sh)"
echo "$line" >> "$GITHUB_OUTPUT"
echo "$line: $(printf '%s\n' "$files" | grep -c .) changed path(s) between ${BEFORE:0:8} and ${AFTER:0:8}"
pow:
name: igneum-pow tests, igneum-census build
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
timeout-minutes: 60 # the box's suite ran 45 s to 2 min 40 s on 7 October 2026; a hosted fallback compiles cold
steps:
- uses: actions/checkout@v4
- name: toolchain
@ -42,7 +78,12 @@ jobs:
run: cargo build --release
sims:
name: simulators, quick modes
needs: changes
# master and release-* pushes, and pull requests into them, only (main, 7 October 2026: every code push cost two box jobs and the
# queue read 22); a feature-branch code push runs the igneum-pow tests alone. tools/ci/sims-branch-check.sh holds this rule.
if: ${{ needs.changes.outputs.code == 'true' && ((github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-'))) || (github.event_name == 'pull_request' && (github.base_ref == 'master' || startsWith(github.base_ref, 'release-')))) }}
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
timeout-minutes: 45 # two simulators under 120 s each by their own timeout, plus a hosted fallback's pip install
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@ -66,38 +107,22 @@ jobs:
site:
name: site build, link check, identity grep
runs-on: ubuntu-latest
# 15: the gate took 229 s on a hosted runner on 7 October 2026 plus a 40 s Playwright install; the same day three
# hosted site jobs on master hung in the gate for over two hours each with no budget, and GitHub's six-hour default
# would have ended each as a failure email. A hung job is a red the watcher posts (ci-red.yml fires on timed_out).
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: a headless Chromium for the text-overlap sweep (Playwright outside the tree; the gate finds it through IGNEUM_PLAYWRIGHT_DIR)
run: |
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.56 | tail -1
npx playwright install --with-deps chromium | tail -1
echo "IGNEUM_PLAYWRIGHT_DIR=/tmp/pw" >> "$GITHUB_ENV"
- name: the tree gate, tools/ci/pre-push.sh --ci (the same script the pre-push hook runs; one line per check, a red check prints its output)
run: bash tools/ci/pre-push.sh --ci
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
if: github.ref == 'refs/heads/master'
run: node tools/ci/public-api-check.mjs https://igneum.network
red:
# Runs when a run on any branch has a failed job, on the box's own runner (not a GitHub-hosted machine:
# the billing block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told).
# tools/ci/red-watch.mjs record appends ONE line for this run to /srv/ci-red/red.jsonl (idempotent per run attempt);
# the box's igneum-ci-red.timer posts each new line once to the hidden updates channel. Never blocks a release:
# it reads the run, writes one line, and ends.
name: red watcher (every branch; one line per failed run, with the branch, commit, red check and pushing author, to the updates channel and the box file)
needs: [pow, sims, site]
if: ${{ failure() }}
runs-on: [self-hosted, linux, x64, igneum-build-1]
timeout-minutes: 5
permissions:
actions: read # the run's jobs API (the first real red run, 21:19Z: the default token answered 403 and the line carried no step)
contents: read
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- name: record this run (one line, the branch, the commit, the failed jobs and their first failed step from the run's own API, the pushing author)
env:
GITHUB_TOKEN: ${{ github.token }}
RED_WATCH_TITLE: ${{ github.event.head_commit.message }}
RED_WATCH_AUTHOR: ${{ github.event.head_commit.author.name }}
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl
run: bash tools/ci/retry-once.sh public-api node tools/ci/public-api-check.mjs https://igneum.network # a live host: one retry before red

View file

@ -216,13 +216,17 @@ jobs:
run: |
$payload = Resolve-Path 'packaging\windows\igneum-windows-app'
function Run-Capture([string]$exe, [string]$flag) {
# a direct call, not Start-Process -Wait -PassThru: a program that prints and exits at once raced the cmdlet
# ("Cannot process request because the process has exited", 7 October 2026, the 0.3.21 run) and the read-back
# was lost; the host program is a windows-subsystem exe, so its text comes through the same pipe
$text = (& $exe $flag 2>&1 | Out-String)
$code = $LASTEXITCODE
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $code, $text.Trim())
if ($code -ne 0) { throw "$exe $flag exited $code" }
# Start-Process -Wait on an exe that exits in milliseconds can throw "Cannot process request because the process has
# exited" before it attaches (release-0.3.21 run 37653903394, 7 October 2026, 17:40 UK): one retry before the verdict.
$out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt')
$p = $null
foreach ($try in 1, 2) {
try { $p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out; break }
catch { if ($try -eq 2) { throw }; Write-Host ("Start-Process on {0} {1} failed once ({2}); second try" -f (Split-Path -Leaf $exe), $flag, $_.Exception.Message); Start-Sleep -Milliseconds 500 }
}
$text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' }
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim())
if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" }
return $text
}
$v = $env:APP_VERSION

4
.gitignore vendored
View file

@ -32,10 +32,6 @@ vendor/igneum-node-ship/
brand/trademark/pbip-pack/
brand/trademark/*.zip
# cargo target dirs of the shipper's local test runs and the build box's fetched artefacts (6 October 2026: a stray add -A staged 450 of them)
app/igneum-app/target-tests/
proving/igneum-prove/target-remote/
**/target-remote/
# the Discord bot and reddit bot dry-run renders
tools/community/out/
# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed

View file

@ -219,7 +219,7 @@ dependencies = [
[[package]]
name = "igneum-app"
version = "0.3.23"
version = "0.3.14"
dependencies = [
"ed25519-dalek",
"getrandom",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-app"
version = "0.3.23"
version = "0.3.14"
edition = "2021"
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
license = "MIT"

View file

@ -1,6 +1,6 @@
// Build script for igneum-app. On a Windows target it compiles resources/igneum-app.rc (the coin icon Explorer shows
// and the version block under Properties > Details) with windres and links the object into igneum-app.exe. Other
// targets: nothing. the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the
// targets: nothing. The founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the
// Mac app and DMG. No crate dependency: windres is called directly (x86_64-w64-mingw32-windres from Homebrew mingw-w64
// on the Mac, windres from MSYS2 on a PC; IGNEUM_WINDRES names another one).
use std::env;

View file

@ -1,13 +1,13 @@
// Windows resources for igneum-app.exe: the coin icon Explorer shows and the version block under Properties > Details.
// Compiled with x86_64-w64-mingw32-windres (the icon path is relative to brand/icons, passed with -I).
// the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG.
// the founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG.
#include <winver.h>
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION 0,3,22,0
PRODUCTVERSION 0,3,22,0
FILEVERSION 0,3,14,0
PRODUCTVERSION 0,3,14,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
@ -20,12 +20,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Miner engine"
VALUE "FileVersion", "0.3.22"
VALUE "FileVersion", "0.3.14"
VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.22"
VALUE "ProductVersion", "0.3.14"
END
END
BLOCK "VarFileInfo"

View file

@ -7,9 +7,6 @@
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
//! igneum-ota-sign fingerprint <public-key-file|hex>
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
//! igneum-ota-sign sign-ui <private-key-file> <version> <sha256> <min-engine> the interface entry's own signature
//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key
//! igneum-ota-sign verify-ui <public-key-file|hex|embedded> <version> <sha256> <min-engine> <signature-hex>
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
//! igneum-ota-sign envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
@ -21,9 +18,6 @@
//! exit 0 only when the signature, the zip, every
//! unpacked file and the pinned commit all check
#[allow(dead_code)]
#[path = "../drivertable.rs"]
mod drivertable;
#[path = "../manifest.rs"]
mod manifest;
#[path = "../jobs.rs"]
@ -107,26 +101,6 @@ fn main() {
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
println!("{sum} {size}");
}
Some("sign-ui") if args.len() == 5 => {
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
let sk = SigningKey::from_bytes(&seed);
if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() {
die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1");
}
if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) {
die("the sha256 is 64 hex characters");
}
println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes()));
}
Some("verify-ui") if args.len() == 6 => {
let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) };
let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() };
match manifest::verify_ui_entry(&e, &pk) {
Ok(()) => println!("verifies"),
Err(e) => die(&e),
}
}
Some("sign-jobs") if args.len() == 3 => {
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));

View file

@ -1,349 +0,0 @@
//! The engine starts at boot without a logon (0.3.22, MF-11's second half). PC 2, 7 October 2026, 17:27 BST: a kernel
//! crash during the Intel driver install, the PC back at 17:28, and the per-user app then waited 67 minutes for a
//! logon with every card idle, because the only start was the Run key at logon and `--launch` always opened the window
//! host, which has no desktop before a logon.
//!
//! Windows, from 0.3.22:
//! - a per-user scheduled task `Igneum Miner (boot)` runs at system start under the user's own account with the S4U
//! logon (no password stored, no logon needed, limited run level): `igneum-app.exe --launch --data-root <the
//! user's %LOCALAPPDATA%\igneum>` (the data root is spelled out because an S4U session may not load the profile);
//! registered by the engine at its start (unelevated, the user's own task) and again inside the one approved step
//! the Power Helper uses, for the account that cannot register it alone;
//! - `--launch` decides by the session: no interactive session (SESSIONNAME unset: session 0, the boot task, a
//! service) runs the engine headless (`--boot`: no window host, no browser); a logon session opens the window host
//! as before;
//! - the window host, at logon, starts `igneum-app.exe --wrapper` as always; that engine finds the headless engine's
//! app.url answering api/state and becomes a BRIDGE instead of a second engine: it prints the headless engine's
//! URL and STATE lines to the host and relays the host's stdin commands (quit, pause, resume, detect) to the
//! engine's API. The window closing (stdin gone) ends the bridge and leaves the engine mining; Quit in the tray
//! quits the engine. A headless engine that stops answering ends the bridge with EXIT, and the host (0.3.21)
//! starts `--wrapper` again, which then runs as a full engine.
//! The decisions are functions here so the tests drive them with the known-failed shape first.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// The boot task's name in the Windows Task Scheduler (per user).
pub const TASK_NAME: &str = "Igneum Miner (boot)";
/// How often the bridge reads api/state for a STATE line, and how many misses in a row end it.
pub const BRIDGE_POLL_S: u64 = 3;
pub const BRIDGE_MISSES: u32 = 4;
/// What `--launch` does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchMode {
/// start "Igneum Miner.exe" (the window host), which starts the engine
Host,
/// run the engine here, no window host, no browser (no interactive session, or no host next to the exe)
Headless,
/// run the engine here and open the dashboard in the default browser (a logon session without the window host)
Browser,
}
/// The session a process runs in: `SESSIONNAME` is set in every interactive logon session ("Console", "RDP-Tcp#3")
/// and unset in session 0 (services, S4U scheduled tasks at boot).
pub fn interactive_session(session_name: Option<&str>) -> bool {
session_name.map(|s| !s.trim().is_empty()).unwrap_or(false)
}
/// 0.3.22: the window host only when someone is logged on; headless otherwise.
pub fn launch_mode(session_name: Option<&str>, host_exists: bool) -> LaunchMode {
match (interactive_session(session_name), host_exists) {
(true, true) => LaunchMode::Host,
(true, false) => LaunchMode::Browser,
(false, _) => LaunchMode::Headless,
}
}
/// Before 0.3.22, for the record: the host whenever it existed, whoever was or was not logged on.
pub fn legacy_launch_mode(host_exists: bool) -> LaunchMode {
if host_exists { LaunchMode::Host } else { LaunchMode::Browser }
}
/// Does a closed stdin mean "the host went away" (quit)? Only for an engine a window host attached (`--wrapper`): a
/// headless engine (`--boot`, the boot task, a job's `--launch` with no session) owns itself and has no host to lose.
/// PC 2, 7 October 2026, 19:09 to 19:11 BST: four engines started a minute apart each quit 3 s after "node started" with
/// "the window host went away (stdin closed)", their parent having closed the pipe at once.
pub fn stdin_close_quits(wrapper: bool, headless: bool) -> bool {
wrapper && !headless
}
// ---- the boot task ---------------------------------------------------------------------------------------------------
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
}
/// The PowerShell that registers the boot task: trigger at system start, principal the signed-in user with the S4U
/// logon (runs with nobody logged on, no password stored), limited run level, no time limit, one instance, hidden;
/// the action is the installed exe with `--launch --data-root <root>`.
pub fn register_script(exe: &Path, data_root: &Path) -> String {
let exe_s = ps_quote(&exe.display().to_string());
let dir = exe.parent().map(|d| ps_quote(&d.display().to_string())).unwrap_or_default();
let root = ps_quote(&data_root.display().to_string());
format!(
"$a = New-ScheduledTaskAction -Execute '{exe_s}' -Argument '--launch --data-root \"{root}\"' -WorkingDirectory '{dir}'\r\n\
$t = New-ScheduledTaskTrigger -AtStartup\r\n\
$t.Delay = 'PT30S'\r\n\
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited\r\n\
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew -StartWhenAvailable -Hidden\r\n\
Register-ScheduledTask -TaskName '{name}' -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null\r\n\
exit 0\r\n",
name = TASK_NAME
)
}
/// The PowerShell that says whether the boot task is registered, enabled and its action's exe present (exit 0).
pub fn query_command() -> String {
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
}
/// The PowerShell that removes the task (an uninstall, or Start at login switched off).
pub fn remove_command() -> String {
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0")
}
fn powershell(command: &str, limit: Duration) -> Option<(bool, String)> {
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
crate::platform::quiet(&mut c);
let out = crate::detect::run_timeout(&mut c, None, limit)?;
// run_timeout folds the streams; the exit code is read again through a second probe when needed
Some((true, out))
}
/// Is the boot task registered (Windows only; false elsewhere)?
pub fn registered() -> bool {
if !cfg!(windows) {
return false;
}
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
crate::platform::quiet(&mut c);
c.status().map(|s| s.success()).unwrap_or(false)
}
/// Registers the boot task unelevated (the user's own task). Ok(true) registered now, Ok(false) already there,
/// Err(why) when Windows refused (an account without the batch-logon right: the one approved step registers it).
pub fn ensure_registered(exe: &Path, data_root: &Path) -> Result<bool, String> {
if !cfg!(windows) {
return Ok(false);
}
if registered() {
return Ok(false);
}
let script = register_script(exe, data_root);
let (_, out) = powershell(&script, Duration::from_secs(60)).ok_or("powershell did not answer")?;
if registered() {
Ok(true)
} else {
Err(format!("the boot task was not registered: {}", out.lines().last().unwrap_or("no reason given").trim()))
}
}
/// The installed exe the task's action names (never a scratch copy), as the Power Helper picks it.
pub fn task_exe(running: &Path) -> PathBuf {
crate::powertask::task_exe(running, &crate::powertask::install_candidates())
}
// ---- the bridge -------------------------------------------------------------------------------------------------------
/// The engine already running under the user's data root: its URL when app.url names one that answers api/state.
pub fn running_engine(app_dir: &Path) -> Option<String> {
let url = std::fs::read_to_string(app_dir.join("app.url")).ok()?.trim().to_string();
if !url.starts_with("http://127.0.0.1:") {
return None;
}
let state = api_get(&url, "api/state")?;
let v: serde_json::Value = serde_json::from_str(state.trim()).ok()?;
v.get("version").and_then(|x| x.as_str()).map(|_| url)
}
fn api_get(url: &str, path: &str) -> Option<String> {
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "4", &format!("{url}{path}")]), None, Duration::from_secs(6)).filter(|o| !o.trim().is_empty())
}
fn api_post(url: &str, path: &str) -> bool {
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "6", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}", &format!("{url}{path}")]), None, Duration::from_secs(8)).is_some()
}
/// The host's stdin line as the engine's API path; None for a line the bridge does not relay.
pub fn command_path(line: &str) -> Option<&'static str> {
match line.trim() {
"quit" => Some("api/quit"),
"pause" => Some("api/pause"),
"resume" => Some("api/resume"),
"detect" => Some("api/detect"),
_ => None,
}
}
/// The STATE line the host reads (engine.rs wrapper_state), built from the engine's api/state reply.
pub fn state_line(api_state: &serde_json::Value) -> String {
let g = |p: &[&str]| -> serde_json::Value {
let mut v = api_state;
for k in p {
v = match v.get(k) {
Some(x) => x,
None => return serde_json::Value::Null,
};
}
v.clone()
};
serde_json::json!({
"phase": g(&["phase"]), "mining": g(&["mining", "state"]), "paused": g(&["mining", "paused"]),
"hash_total": g(&["mining", "hash_total"]), "accepted_total": g(&["mining", "accepted_total"]),
"node": g(&["node", "state"]), "blocks": g(&["node", "blocks"]), "peers": g(&["node", "peers"]), "quitting": g(&["quitting"]),
"update": g(&["update", "available"]), "bridge": true,
})
.to_string()
}
/// What the bridge does after one poll: carry on, or end (with EXIT when the engine is gone; silently when the host
/// closed its end, which leaves the engine mining).
#[derive(Debug, PartialEq, Eq)]
pub enum BridgeStep {
Continue,
EngineGone,
HostGone,
}
pub fn bridge_step(misses: u32, stdin_closed: bool) -> BridgeStep {
if stdin_closed {
BridgeStep::HostGone
} else if misses >= BRIDGE_MISSES {
BridgeStep::EngineGone
} else {
BridgeStep::Continue
}
}
/// Runs the bridge until the host or the engine goes. Returns the process exit code.
pub fn run_bridge(url: &str) -> i32 {
use std::io::{BufRead, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
println!("URL {url}");
let _ = std::io::stdout().flush();
let closed = Arc::new(AtomicBool::new(false));
{
let closed = closed.clone();
let url = url.to_string();
std::thread::spawn(move || {
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(l) = line else { break };
if let Some(p) = command_path(&l) {
let _ = api_post(&url, p);
}
}
closed.store(true, Ordering::Relaxed);
});
}
let mut misses = 0u32;
loop {
match api_get(url, "api/state").and_then(|s| serde_json::from_str::<serde_json::Value>(s.trim()).ok()) {
Some(v) => {
misses = 0;
println!("STATE {}", state_line(&v));
let _ = std::io::stdout().flush();
if v.get("quitting").and_then(|q| q.as_bool()).unwrap_or(false) {
// the engine is leaving (Quit from the tray relayed above, or its own update): the host wants EXIT
std::thread::sleep(Duration::from_secs(2));
misses = BRIDGE_MISSES;
}
}
None => misses += 1,
}
match bridge_step(misses, closed.load(Ordering::Relaxed)) {
BridgeStep::Continue => std::thread::sleep(Duration::from_secs(BRIDGE_POLL_S)),
BridgeStep::EngineGone => {
println!("EXIT");
let _ = std::io::stdout().flush();
return 0;
}
BridgeStep::HostGone => return 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Known-failed first: before 0.3.22 a boot with nobody logged on opened the window host (no desktop, no engine);
/// PC 2 sat 67 minutes idle after its 17:28 BST self-reboot on 7 October 2026.
#[test]
fn before_0322_no_logon_meant_the_host_and_no_engine() {
assert_eq!(legacy_launch_mode(true), LaunchMode::Host, "the host regardless of the session");
assert_eq!(launch_mode(None, true), LaunchMode::Headless, "0.3.22: no session, the engine runs headless");
assert_eq!(launch_mode(Some(""), true), LaunchMode::Headless);
}
/// Known-failed first: before 0.3.22 every `--wrapper` engine quit on a closed stdin, whoever had started it.
#[test]
fn a_headless_engine_never_reads_a_closed_stdin_as_the_host_leaving() {
assert!(stdin_close_quits(true, false), "the window host's own engine: the host left, the engine follows (by design)");
assert!(!stdin_close_quits(true, true), "0.3.22: headless, even with --wrapper on the line, stays up");
assert!(!stdin_close_quits(false, false), "an engine with no host never had a stdin to lose");
assert!(!stdin_close_quits(false, true));
}
#[test]
fn a_logon_session_keeps_the_window_host_or_the_browser() {
assert_eq!(launch_mode(Some("Console"), true), LaunchMode::Host);
assert_eq!(launch_mode(Some("RDP-Tcp#3"), true), LaunchMode::Host);
assert_eq!(launch_mode(Some("Console"), false), LaunchMode::Browser);
assert!(interactive_session(Some("Console")) && !interactive_session(None));
}
#[test]
fn the_boot_task_runs_at_startup_as_the_user_without_a_logon_and_names_the_data_root() {
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\u\\AppData\\Local\\igneum"));
assert!(s.contains("-Execute 'C:\\p\\Igneum Miner\\igneum-app.exe' -Argument '--launch --data-root \"C:\\u\\AppData\\Local\\igneum\"'"), "{s}");
assert!(s.contains("New-ScheduledTaskTrigger -AtStartup"), "at system start, not at logon");
assert!(s.contains("-LogonType S4U -RunLevel Limited"), "the user's own token with nobody logged on, never elevated");
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") && s.contains("-MultipleInstances IgnoreNew") && s.contains("-Hidden"));
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
assert!(!s.contains("powershell.exe") && !s.contains("cmd /c"), "the action is the exe itself: no console window at boot");
let q = query_command();
assert!(q.contains("Test-Path") && q.contains("-ne 'Disabled'") && q.contains("exit 1"), "{q}");
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Miner (boot)'"));
}
#[test]
fn the_bridge_relays_the_hosts_commands_and_nothing_else() {
assert_eq!(command_path("quit"), Some("api/quit"));
assert_eq!(command_path(" pause \r"), Some("api/pause"));
assert_eq!(command_path("resume"), Some("api/resume"));
assert_eq!(command_path("detect"), Some("api/detect"));
assert_eq!(command_path("elevated ok"), None, "the elevated answers belong to a real engine's host");
assert_eq!(command_path("rm -rf"), None);
}
#[test]
fn the_bridge_state_line_is_the_wrapper_state() {
let st: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22","phase":"dashboard","mining":{"state":"mining","paused":false,"hash_total":121.0,"accepted_total":95511},"node":{"state":"synced","blocks":165529,"peers":4},"quitting":false,"update":{"available":false}}"#).unwrap();
let line: serde_json::Value = serde_json::from_str(&state_line(&st)).unwrap();
assert_eq!(line["phase"], "dashboard");
assert_eq!(line["mining"], "mining");
assert_eq!(line["hash_total"], 121.0);
assert_eq!(line["accepted_total"], 95511);
assert_eq!(line["node"], "synced");
assert_eq!(line["blocks"], 165529);
assert_eq!(line["quitting"], false);
assert_eq!(line["bridge"], true);
let partial: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22"}"#).unwrap();
assert!(state_line(&partial).contains("\"phase\":null"), "a missing field is null, never a panic");
}
#[test]
fn the_bridge_ends_with_exit_when_the_engine_is_gone_and_silently_when_the_host_is() {
assert_eq!(bridge_step(0, false), BridgeStep::Continue);
assert_eq!(bridge_step(BRIDGE_MISSES - 1, false), BridgeStep::Continue);
assert_eq!(bridge_step(BRIDGE_MISSES, false), BridgeStep::EngineGone, "the host then starts a fresh --wrapper, which becomes a full engine");
assert_eq!(bridge_step(0, true), BridgeStep::HostGone, "the window closed: the headless engine keeps mining");
assert_eq!(bridge_step(BRIDGE_MISSES, true), BridgeStep::HostGone);
}
}

View file

@ -1,73 +0,0 @@
//! Did this PC come up from a power loss or a hard reset? (MF-11, 7 October 2026: PC 2 dropped twice in one day with
//! Kernel-Power 41 and EventLog 6008 at the next boot, no bugcheck, no dump, and nothing said so until a person read
//! the event log.) Windows: the System log's event 41 (Kernel-Power, critical) or 6008 (EventLog, "the previous
//! shutdown was unexpected") inside the last 15 minutes, read once at the engine's start through wevtutil; the engine
//! logs one `FAULT pc-restart:` line to the intake. Other platforms: nothing (a Mac's power log is not this class).
use std::process::Command;
use std::time::Duration;
/// How far back the start-up check looks: an engine starts at login, inside a minute or two of the boot.
pub const WINDOW_MS: u64 = 15 * 60 * 1000;
/// One line naming the event, or None when the boot was clean, the query failed, or this is not Windows.
pub fn unexpected_restart() -> Option<String> {
if !cfg!(windows) {
return None;
}
let query = format!("*[System[(EventID=41 or EventID=6008) and TimeCreated[timediff(@SystemTime) <= {WINDOW_MS}]]]");
let mut c = Command::new(crate::platform::tool("wevtutil"));
c.args(["qe", "System", &format!("/q:{query}"), "/f:text", "/c:2", "/rd:true"]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(20))?;
parse_events(&out)
}
/// The reading of wevtutil's text output: the newest 41 or 6008 as "event 41 (Kernel-Power) at <time>" or
/// "event 6008 (the previous shutdown was unexpected) at <time>"; None when neither is in the text.
pub fn parse_events(text: &str) -> Option<String> {
let mut date = String::new();
let mut id = String::new();
for line in text.lines() {
let t = line.trim();
if let Some(d) = t.strip_prefix("Date:") {
date = d.trim().to_string();
} else if let Some(i) = t.strip_prefix("Event ID:") {
id = i.trim().to_string();
if id == "41" || id == "6008" {
break;
}
}
}
match id.as_str() {
"41" => Some(format!("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at {date}")),
"6008" => Some(format!("event 6008 (the previous shutdown was unexpected) at {date}")),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::parse_events;
const PC2: &str = "Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-Power\n Date: 2026-10-07T14:37:19.4360000Z\n Event ID: 41\n Task: N/A\n Level: Critical\n Opcode: Info\n Keyword: N/A\n User: S-1-5-18\n Computer: X\n Description: \n\nEvent[1]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T14:37:29.0380000Z\n Event ID: 6008\n Task: None\n Level: Error\n";
/// PC 2's boot of 13:37Z on 7 October 2026 (the collection job's wevtutil text): the known-failed case reads as one.
#[test]
fn pc2_boot_reads_as_a_power_loss() {
assert_eq!(parse_events(PC2), Some("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at 2026-10-07T14:37:19.4360000Z".into()));
}
#[test]
fn a_6008_alone_is_the_unexpected_shutdown() {
let t = "Event[0]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T08:22:34.0000000Z\n Event ID: 6008\n Level: Error\n";
assert_eq!(parse_events(t), Some("event 6008 (the previous shutdown was unexpected) at 2026-10-07T08:22:34.0000000Z".into()));
}
/// A clean boot (the known-good case): nothing, including other ids inside the window and an empty answer.
#[test]
fn a_clean_boot_reads_as_nothing() {
assert_eq!(parse_events(""), None);
assert_eq!(parse_events("Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-General\n Date: 2026-10-07T14:37:17.7400000Z\n Event ID: 12\n Level: Information\n"), None);
assert_eq!(parse_events("wevtutil: access denied"), None);
}
}

View file

@ -1,80 +0,0 @@
//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card
//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under
//! `<data root>/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name.
use std::path::{Path, PathBuf};
/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL.
pub fn decode_data_url(s: &str) -> Option<Vec<u8>> {
let rest = s.strip_prefix("data:image/png;base64,")?;
let bytes = decode_base64(rest)?;
if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
return None;
}
Some(bytes)
}
/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character.
pub fn decode_base64(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut acc: u32 = 0;
let mut bits = 0u32;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xFF) as u8);
}
}
Some(out)
}
/// `<root>/cards/igneum-block-<name>-<unix>.png`, the name reduced to [a-z0-9-] and at most 40 characters.
pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf {
let mut clean = String::new();
for c in name.to_ascii_lowercase().chars() {
if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); }
}
let clean: String = clean.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string();
let clean = if clean.is_empty() { "block".to_string() } else { clean };
root.join("cards").join(format!("igneum-block-{clean}-{unix}.png"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_decodes_with_and_without_padding() {
assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello");
assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world");
assert!(decode_base64("aGV$sbG8=").is_none());
}
#[test]
fn a_png_data_url_decodes_and_anything_else_is_refused() {
// the 8-byte PNG signature, base64
let sig = "iVBORw0KGgo=";
let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap();
assert_eq!(&png[..4], b"\x89PNG");
assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none());
assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG");
}
#[test]
fn the_file_name_is_safe_and_dated() {
let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116);
assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png"));
assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png");
}
}

View file

@ -1,263 +0,0 @@
//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard
//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key
//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over
//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints`
//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public
//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same
//! node RPCs.
//!
//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality
//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose
//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids
//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s.
use serde_json::{json, Value};
use std::process::Command;
use std::sync::Mutex;
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(10);
/// the observer relays at most this many keys (tools/observer/observer.mjs)
pub const OBSERVER_KEYS_CAP: usize = 64;
fn num(v: &Value) -> f64 {
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(s) => s.parse().unwrap_or(0.0),
Value::Bool(b) => if *b { 1.0 } else { 0.0 },
_ => 0.0,
}
}
fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 }
fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value {
for k in keys {
if let Some(x) = v.get(k) { return x; }
}
&Value::Null
}
/// One key's row in either spelling (the node's camelCase or the observer's snake case).
fn key_row(k: &Value) -> Value {
let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase();
let id: String = id_full.chars().take(8).collect();
let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) };
json!({
"id": id,
"blocks": u(get(k, &["blocks"])),
"voter": get(k, &["voter"]).as_bool().unwrap_or(false),
"participation": num(get(k, &["participation"])),
"stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])),
"revealed": revealed,
})
}
/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a
/// source may keep more or fewer).
pub fn is_mine(id: &str, ids: &[String]) -> bool {
let id = id.trim_start_matches("0x").to_ascii_lowercase();
ids.iter().any(|m| {
let m = m.trim_start_matches("0x").to_ascii_lowercase();
let n = id.len().min(m.len());
n >= 6 && id[..n] == m[..n]
})
}
/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's
/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the
/// public stats, and this machine's key ids. Pure.
pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value {
let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) };
let mut keys: Vec<Value> = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default();
keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str())));
let mut mine: Vec<Value> = Vec::new();
for (i, k) in keys.iter().enumerate() {
if is_mine(k["id"].as_str().unwrap_or(""), ids) {
let mut m = k.clone();
m["rank"] = json!(i + 1);
mine.push(m);
}
}
let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned();
let reward = get(stats, &["block_reward"]);
let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) };
let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) };
json!({
"ok": true,
"source": source,
"read_at": read_at,
"rpc": {
"weights": "getFinalityWeights",
"checkpoints": "getFinalityCheckpoints",
"stats": "/api/stats",
"relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" }
},
"params": {
"dust": u(get(params, &["dust"])),
"weight_window": u(get(params, &["weightWindow", "weight_window"])),
"presence_window": u(get(params, &["presenceWindow", "presence_window"])),
"checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])),
"min_daa": u(get(params, &["minDaa", "min_daa"])),
},
"checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])),
"daa_score": u(get(w, &["daaScore", "daa_score"])),
"latest_locked_index": latest_locked,
"next_index": u(get(finality, &["nextIndex", "next_index"])),
"finality_active": active,
"total_weight": u(get(w, &["totalWeight", "total_weight"])),
"active_weight": num(get(w, &["activeWeight", "active_weight"])),
"voters": u(get(w, &["voters"])),
"keys_listed": keys.len(),
"keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP },
"network": {
"hashrate_hps": num(get(stats, &["hashrate"])),
"miner_ign_per_block": num(get(reward, &["miner_ign"])),
"ign_per_block": num(get(reward, &["ign"])),
"ramp_factor": num(get(reward, &["ramp_factor"])),
"daa": u(get(stats, &["daa"])),
"blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])),
"bps": 1,
"stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()),
},
"mine": mine,
"best": best,
})
}
fn curl_json(url: &str) -> Option<Value> {
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?;
serde_json::from_str(out.trim()).ok()
}
/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats`
pub fn stats_api_from(live_api: &str) -> String {
match live_api.strip_suffix("/api/live") {
Some(base) => format!("{base}/api/stats"),
None => String::new(),
}
}
/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when
/// neither answers.
pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value {
if let Some((at, v)) = CACHE.lock().unwrap().as_ref() {
if at.elapsed() < TTL {
let mut c = v.clone();
c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round());
return c;
}
}
let now = crate::platform::unix_now_f();
let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) };
let stats = stats.unwrap_or(Value::Null);
let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) {
Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now),
_ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } {
Some(live) => {
let fin = get(&live, &["finality"]);
let w = get(fin, &["weights"]);
if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) }
}
None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }),
},
};
*CACHE.lock().unwrap() = Some((Instant::now(), reply.clone()));
reply
}
#[cfg(test)]
mod tests {
use super::*;
fn observer_live() -> Value {
json!({
"ok": true,
"state": { "hashes_per_second_estimate": 764944722 },
"finality": {
"supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495,
"params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 },
"weights": {
"checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14,
"keys": [
{ "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
{ "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
{ "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 },
{ "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 }
]
}
}
})
}
fn stats() -> Value {
json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } })
}
#[test]
fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() {
let live = observer_live();
let fin = &live["finality"];
let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0);
assert_eq!(r["ok"], true);
assert_eq!(r["source"], "observer");
assert_eq!(r["params"]["dust"], 5);
assert_eq!(r["params"]["weight_window"], 7200);
assert_eq!(r["params"]["presence_window"], 20);
assert_eq!(r["latest_locked_index"], 8495);
assert_eq!(r["finality_active"], true);
assert_eq!(r["voters"], 14);
assert_eq!(r["keys_listed"], 4);
assert_eq!(r["keys_cap"], 64);
assert_eq!(r["network"]["hashrate_hps"], 764944722.0);
assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084);
assert_eq!(r["mine"].as_array().unwrap().len(), 1);
assert_eq!(r["mine"][0]["rank"], 3);
assert_eq!(r["mine"][0]["blocks"], 61);
assert_eq!(r["mine"][0]["voter"], true);
assert_eq!(r["best"]["id"], "8fafda27");
assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer"));
}
#[test]
fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() {
let w = json!({
"params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 },
"checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204,
"keys": [
{ "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 },
{ "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 }
]
});
let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0);
assert_eq!(r["source"], "node");
assert_eq!(r["keys_cap"], 0);
assert_eq!(r["params"]["dust"], 100);
assert_eq!(r["params"]["weight_window"], 2592000);
assert_eq!(r["checkpoint_index"], 184220);
assert_eq!(r["mine"][0]["id"], "8fafda27");
assert_eq!(r["mine"][0]["rank"], 1);
assert_eq!(r["mine"][0]["revealed"], true);
assert_eq!(r["rpc"]["relay"], "");
// the locked index falls back to the public stats when the node reply carries no checkpoints block
assert_eq!(r["latest_locked_index"], 8495);
}
#[test]
fn a_machine_with_no_key_in_the_table_has_no_rank() {
let live = observer_live();
let fin = &live["finality"];
let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0);
assert_eq!(r["mine"].as_array().unwrap().len(), 0);
assert!(r["best"].is_null());
assert_eq!(r["network"]["stale"], true);
assert_eq!(r["network"]["hashrate_hps"], 0.0);
}
#[test]
fn id_matching_takes_a_prefix_of_six_or_more() {
assert!(is_mine("8fafda27", &["8fafda27aa11".into()]));
assert!(is_mine("0x8fafda27aa", &["8fafda27".into()]));
assert!(!is_mine("8fafd", &["8fafda27".into()]));
assert!(!is_mine("8fafda28", &["8fafda27".into()]));
assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats");
assert_eq!(stats_api_from(""), "");
}
}

View file

@ -39,18 +39,6 @@ pub struct CardPref {
pub sweep_class: String,
#[serde(default)]
pub sweep_source: String,
/// the chosen clock sat on the ladder's floor (the lowest step measured, not the optimum)
#[serde(default)]
pub sweep_floor: bool,
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
#[serde(default)]
pub tune_goal: String,
/// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can
/// read "saves 84 W, 0.15% of rate"; 0 = never measured
#[serde(default)]
pub sweep_before_watts: f64,
#[serde(default)]
pub sweep_before_mhs: f64,
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
#[serde(default)]
pub sweep_mem_mhz: u32,
@ -99,7 +87,7 @@ pub struct Settings {
/// (on when that is switched on, never effective while it is off). A pinned card is skipped.
#[serde(default)]
pub sweep: bool,
/// Power control (the project lead, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the
/// Power control (the founder, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the
/// efficiency sweep need administrator rights (one UAC prompt on Windows). Default OFF on every machine; the app
/// never raises the prompt on its own. Switching it on asks once, at that moment; a refused, cancelled or
/// unanswered prompt switches it back off with a notice, no retries.
@ -114,35 +102,6 @@ pub struct Settings {
pub power_price_pence: f64,
#[serde(default)]
pub tune_climb: bool,
/// Ember Heat (mission item 7, 7 October 2026; src/heat.rs): the region code the miner chose at first run or in
/// Settings ("" = not chosen; the price above is in that region's minor unit per kWh, typed, never fetched), the
/// heat-mode switch, the set point in degrees, the schedule (slots by minute of the day in the window's clock,
/// `heat_tz_min` minutes east of UTC), the typed room reading and when it was typed (0 = none), and the learned
/// idle offset of the card sensor above the room (0 = the default).
#[serde(default)]
pub region: String,
/// the network step (0.3.23): the chain this machine runs, chosen at first run or in Settings ("devnet-3" |
/// "testnet-1"; "" = not chosen, the package's own network). Read at start; a change takes effect at the next start.
#[serde(default)]
pub network: String,
/// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which
/// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
#[serde(default)]
pub currency: String,
#[serde(default)]
pub heat_on: bool,
#[serde(default = "nineteen")]
pub heat_set_c: f64,
#[serde(default)]
pub heat_schedule: Vec<crate::heat::Slot>,
#[serde(default)]
pub heat_tz_min: i32,
#[serde(default)]
pub heat_room_c: f64,
#[serde(default)]
pub heat_room_at: f64,
#[serde(default)]
pub heat_offset_c: f64,
/// When this install first ran (unix s), for the "first hour after install" sweep.
#[serde(default)]
pub installed_at: u64,
@ -161,18 +120,6 @@ pub struct Settings {
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
#[serde(default)]
pub prove_default_applied: bool,
/// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
#[serde(default)]
pub profile_public: bool,
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
/// over-the-air interface bundle is active. Default off.
#[serde(default)]
pub ui_builtin: bool,
/// Prove instead of mining on a machine whose only NVIDIA card is under 12 GB (main's routing, 7 October 2026: a
/// 10 GB card holds the prover or the miner, never both). Off by default; the 12 GB refusal stays while it is off.
#[serde(default)]
pub prove_instead: bool,
}
fn one() -> u32 {
@ -181,41 +128,13 @@ fn one() -> u32 {
fn balanced() -> String {
"balanced".into()
}
fn nineteen() -> f64 {
19.0
}
/// The network step's rule (0.3.23): a switch is refused when the network is unknown, when it names the testnet while the
/// manifest has not opened it, and when the engine runs another network and the user has not confirmed what resets.
pub fn network_switch(want: &str, running: &str, testnet_open: bool, confirmed: bool) -> Result<(), String> {
if !["devnet-3", "testnet-1"].contains(&want) {
return Err(format!("'{want}' is not a network this app knows"));
}
if want == "testnet-1" && !testnet_open {
return Err("igneum-testnet-1 is not yet open: the choice waits for the manifest to open it".into());
}
if network_name(want) != running && !confirmed {
return Err("switching the network needs the confirm: the node's data and the mining state reset at the next start".into());
}
Ok(())
}
/// The chain's name for a choice ("" = the package's own, read as Devnet 3 from 0.3.22).
pub fn network_name(choice: &str) -> &'static str {
match choice {
"testnet-1" => "igneum-testnet-1",
_ => "igneum-devnet-3",
}
}
/// The seeds of igneum-testnet-1 (docs/plans/testnet-go.md: p2p 26811, chain id 4462). The node compiles no DNS seeders for
/// the testnet (the node lane, 7 October 2026), so the engine passes these as --addpeer.
pub const TESTNET_SEEDS: [&str; 3] = ["seed1.testnet.igneum.network:26811", "seed2.testnet.igneum.network:26811", "seed3.testnet.igneum.network:26811"];
fn yes() -> bool {
true
}
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
}
}
@ -307,14 +226,6 @@ pub struct Packaged {
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
#[serde(default)]
pub node_override_params: Option<serde_json::Value>,
/// The network the package's node joins (7 October 2026, Devnet 3): a suffixed devnet (`--devnet-suffix=N`), its own
/// genesis and p2p port, its own node datadir devnet-N beside the shared devnet's devnet-v4 (kept for the way back).
/// Absent = the shared devnet. IGNEUM_APP_DEVNET_SUFFIX in the environment wins over it.
#[serde(default)]
pub node_devnet_suffix: Option<u32>,
/// The package's default peers for that network (host:port); absent = the shared devnet's list.
#[serde(default)]
pub node_peers: Option<Vec<String>>,
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" or "none". Never
/// serialised (the packaged file does not carry them; nothing sends this struct to the UI).
#[serde(skip)]
@ -434,40 +345,24 @@ pub struct Runtime {
pub host: String,
/// Per-install random id (16 hex, app data dir/machine-id, locked to the user). Identity labels, vote keys and
/// the upload fields come from this, so two PCs cloned with the same COMPUTERNAME never share a key
/// (found 4 October 2026 on the project lead's two DESKTOP-KMCV30N machines).
/// (found 4 October 2026 on the founder's two DESKTOP-KMCV30N machines).
pub machine_id: String,
}
impl Runtime {
pub fn from_env() -> Runtime {
Self::from_env_with(None, None)
}
/// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins.
pub fn from_env_with(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>) -> Runtime {
Self::from_env_with_choice(packaged_suffix, packaged_peers, "")
}
/// The network step: `choice` is settings.network ("testnet-1" turns the runtime to the testnet object, `--testnet
/// --netsuffix=1` with the three seeds as peers; "devnet-3" or "" keeps the package's network); the environment wins inside.
pub fn from_env_with_choice(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>, choice: &str) -> Runtime {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let testnet = choice == "testnet-1";
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| if testnet { "testnet".into() } else { "devnet".into() });
let packaged_peers: Option<&[String]> = if testnet { None } else { packaged_peers };
let packaged_suffix = if testnet { None } else { packaged_suffix };
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| "devnet".into());
let rpc_port = env("IGNEUM_APP_RPC_PORT").and_then(|v| v.parse().ok()).unwrap_or(26610);
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
let peers = match std::env::var("IGNEUM_APP_PEERS") {
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
// the public seed node first, then the build box's hand nodes (main's decision (b), 6 October 2026, 19:1x UTC:
// the hands move off the project lead's Mac to igneum-build-1), then the project lead's Mac on the house LAN (devnet only)
Err(_) if packaged_peers.map(|p| !p.is_empty()).unwrap_or(false) => packaged_peers.unwrap().to_vec(),
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()],
Err(_) if network == "testnet" => TESTNET_SEEDS.iter().map(|s| s.to_string()).collect(),
// the public seed node first, then the founder's Mac on the house LAN (devnet only)
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()],
Err(_) => vec![],
};
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok()).or(packaged_suffix);
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
let root = crate::platform::data_root();
let node_dir = match env("IGNEUM_APP_NODE_DIR") {
Some(d) => PathBuf::from(d),
@ -497,42 +392,6 @@ impl Runtime {
#[cfg(test)]
mod tests {
#[test]
fn network_switch_rules() {
// the network step: unknown refused; the testnet refused until open; another network needs the confirm; the same network is fine
assert!(super::network_switch("mainnet", "igneum-devnet-3", true, true).is_err());
assert!(super::network_switch("testnet-1", "igneum-devnet-3", false, true).unwrap_err().contains("not yet open"));
assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, false).unwrap_err().contains("needs the confirm"));
assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, true).is_ok());
assert!(super::network_switch("devnet-3", "igneum-devnet-3", false, false).is_ok(), "the network already running needs no confirm");
assert_eq!(super::network_name(""), "igneum-devnet-3");
assert_eq!(super::network_name("testnet-1"), "igneum-testnet-1");
// a fresh settings file carries no choice; an old file without the field reads none too, so it keeps the package's network
assert_eq!(super::Settings::default().network, "");
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
assert_eq!(old.network, "");
let chosen: super::Settings = serde_json::from_str(r#"{"network":"testnet-1"}"#).unwrap();
assert_eq!(chosen.network, "testnet-1");
let r = super::Runtime::from_env_with_choice(Some(3), None, "testnet-1");
assert_eq!(r.network, "testnet");
assert_eq!(r.devnet_suffix, None);
assert!(r.peers.iter().any(|p| p.starts_with("seed1.testnet.igneum.network")));
}
#[test]
fn currency_round_trip() {
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
let mut s = super::Settings::default();
s.currency = "EUR".into();
s.region = "de".into();
let text = serde_json::to_string(&s).unwrap();
let back: super::Settings = serde_json::from_str(&text).unwrap();
assert_eq!(back.currency, "EUR");
assert_eq!(back.region, "de");
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
assert_eq!(old.currency, "");
}
use super::*;
fn tmp(name: &str, content: &str) -> PathBuf {
@ -670,19 +529,4 @@ mod fixture_tests {
Err(e) => panic!("the crate refuses the file: {e}"),
}
}
#[test]
fn the_package_names_the_network_when_the_environment_is_silent() {
use super::Runtime;
// the test never sets IGNEUM_APP_DEVNET_SUFFIX or IGNEUM_APP_PEERS, so the package's values win
let peers = vec!["188.40.146.49:26631".to_string(), "188.40.146.49:26671".to_string()];
let r = Runtime::from_env_with(Some(3), Some(&peers));
assert_eq!(r.devnet_suffix, Some(3));
assert_eq!(r.peers, peers);
assert!(r.node_dir.ends_with("devnet-3"), "{}", r.node_dir.display());
// no package network: the shared devnet as before
let r = Runtime::from_env_with(None, None);
assert_eq!(r.devnet_suffix, None);
assert!(r.node_dir.ends_with("devnet-v4"));
}
}

View file

@ -111,11 +111,8 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
pub fn looks_integrated(name: &str) -> bool {
let n = name.to_ascii_lowercase();
if n.contains("arc") && n.contains("intel") {
return arc_is_integrated(&n);
}
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
if integrated.iter().any(|k| n.contains(k)) {
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
return true;
}
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
@ -126,22 +123,6 @@ pub fn looks_integrated(name: &str) -> bool {
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
}
/// An Intel name carrying "Arc" (lower-cased): the cards ("Arc(TM) A770", "Arc(TM) B580", "Arc(TM) Pro A60") are
/// discrete; the Arc-branded processor graphics are integrated: the bare "Intel(R) Arc(TM) Graphics" of Meteor Lake
/// and Arrow Lake, the "Arc(TM) 130V / 140V" of Lunar Lake and the "Arc(TM) 1xxT" of Panther Lake (approximate names
/// from Intel's product pages, 7 October 2026; the B580 is the first Intel card in hand, docs/plans/intel-arc.md).
fn arc_is_integrated(lower: &str) -> bool {
let after = lower.split("arc").nth(1).unwrap_or("").trim_start_matches("(tm)").trim_start_matches("(r)").trim();
let word = after.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or("");
// "graphics" or nothing after "Arc": the processor graphics; "130v", "140t": digits first, the processor graphics
if word.is_empty() || word == "graphics" || word.starts_with(|c: char| c.is_ascii_digit()) {
return true;
}
// "pro", "a770", "b580": a card. Anything else unknown is read as a card (on by default; a wrong call costs an
// iGPU 8 identities at 1 to 2 MH/s, a card called integrated would sit off with no reason the owner can see)
false
}
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Adapter {
@ -156,11 +137,6 @@ pub struct Adapter {
pub pnp_id: String,
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
pub bus: String,
/// the DriverVersion property ("32.0.101.9034"); empty when none is bound (driver-check, 7 October 2026)
pub driver: String,
/// the device sits behind a USB4 or Thunderbolt router in its parent chain (an eGPU enclosure; PC 2's RTX 5060 Ti in a
/// Razer Core X V2, 7 October 2026): the kind reads "external" and the Cards page says eGPU
pub external: bool,
}
impl Adapter {
@ -198,9 +174,6 @@ pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
if a.ram_mb > 0 && a.ram_mb < 1024 {
return "integrated";
}
if a.external {
return "external";
}
}
"discrete"
}
@ -213,10 +186,6 @@ pub fn vendor_of(name: &str) -> &'static str {
"amd"
} else if n.contains("apple") {
"apple"
} else if n.contains("intel") {
// Arc cards and Intel processor graphics alike (the kind tells them apart); the first Intel card is the B580
// on PC 1, 7 October 2026 (docs/plans/intel-arc.md)
"intel"
} else {
"other"
}
@ -239,25 +208,18 @@ pub fn parse_adapters(json: &str) -> Vec<Adapter> {
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
_ => String::new(),
};
let external = r.get("External").and_then(|x| x.as_bool()).unwrap_or(false);
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), driver: s(r, "DriverVersion"), bus, external }
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
})
.filter(|a| !a.name.is_empty())
.collect()
}
/// The PowerShell behind adapters(): one object per adapter with the PCI bus and address and whether a USB4 or Thunderbolt
/// router sits in the device's parent chain (External), which is how an eGPU enclosure shows (PC 2's Razer Core X V2 reads
/// "USB4 Router (2.0), Razer - Core X V2", instance USB4\VID_8087&PID_5786...).
pub const ADAPTERS_SCRIPT: &str = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; $ext = $false; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; try { $cur = $id; for ($i = 0; $i -lt 6 -and $cur; $i++) { $par = (Get-PnpDeviceProperty -InstanceId $cur -KeyName 'DEVPKEY_Device_Parent' -ErrorAction Stop).Data; if (-not $par) { break }; if (\"$par\" -match '^USB4\\\\|THUNDERBOLT|TBT') { $ext = $true; break }; $cur = $par } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; DriverVersion = $_.DriverVersion; PNPDeviceID = $id; BusNumber = $bus; Address = $addr; External = $ext } }; ConvertTo-Json -InputObject @($v) -Compress";
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
#[cfg(windows)]
pub fn adapters() -> Option<Vec<Adapter>> {
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
// External: the parent chain (DEVPKEY_Device_Parent, up to 6 hops) holds a USB4 router or a Thunderbolt device
let script = ADAPTERS_SCRIPT;
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
let start = out.find(|c| c == '[' || c == '{')?;
Some(parse_adapters(&out[start..]))
@ -478,9 +440,6 @@ impl ClDevice {
"nvidia"
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
"amd"
} else if self.vendor.contains("Intel") || self.name.contains("Intel") {
// "Intel(R) Corporation" on the "Intel(R) OpenCL Graphics" platform (the Arc driver's runtime)
"intel"
} else {
"other"
}
@ -642,8 +601,6 @@ pub fn assemble(inp: Inputs) -> Detection {
c.bus = bus;
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
c.vram_mb = mem_mb;
// driver-check: nvidia-smi's driver_version ("581.57"); the adapter row's DriverVersion is the fallback
c.driver_os = parts.get(4).map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && !v.contains("N/A")).or_else(|| adapter.map(|i| adapters[i].driver.clone())).unwrap_or_default();
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
if !inp.cuda_worker {
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
@ -682,8 +639,6 @@ pub fn assemble(inp: Inputs) -> Detection {
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
// driver-check: Windows' DriverVersion for the card (AMD "32.0.32015.2008", Intel "32.0.101.9034")
c.driver_os = adapter.map(|i| adapters[i].driver.clone()).unwrap_or_default();
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
@ -699,7 +654,6 @@ pub fn assemble(inp: Inputs) -> Detection {
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.bus.clone();
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
c.driver_os = a.driver.clone();
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
used.push(i);
@ -716,7 +670,6 @@ pub fn assemble(inp: Inputs) -> Detection {
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.bus.clone();
c.kind = classify_kind(&a.name, Some(a)).into();
c.driver_os = a.driver.clone();
mark_unusable(&mut c, &problem);
d.cards.push(c);
}
@ -730,7 +683,7 @@ pub fn detect(bins: &Bins) -> Detection {
// processor for the integrated call, the PCI address and the names for the rows
let adapters = adapters();
// NVIDIA: nvidia-smi ships with the driver
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id,driver_version", "--format=csv,noheader"]), None, Duration::from_secs(10));
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
@ -835,15 +788,6 @@ mod tests {
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
// PC 2, 7 October 2026: the RTX 5060 Ti behind the Razer Core X V2's USB4 router is an eGPU, not a discrete card
let egpu = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5060 Ti","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04&SUBSYS_8A111043&REV_A1\\31C898B6A12DB04800","BusNumber":11,"Address":0,"External":true}]"#);
assert_eq!(egpu.len(), 1);
assert!(egpu[0].external && egpu[0].bus == "0b:00.0");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&egpu[0])), "external");
let inside = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"x","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04\\1","BusNumber":1,"Address":0}]"#);
assert!(!inside[0].external, "no External field reads as inside the case");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&inside[0])), "discrete");
assert!(ADAPTERS_SCRIPT.contains("DEVPKEY_Device_Parent") && ADAPTERS_SCRIPT.contains("USB4") && ADAPTERS_SCRIPT.contains("External = $ext"), "the script walks the parent chain");
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
@ -865,8 +809,6 @@ mod tests {
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
assert!(!looks_integrated("Apple M5 Max"));
assert_eq!(vendor_of("Apple M5 Max"), "apple");
assert_eq!(vendor_of("Intel(R) Arc(TM) B580 Graphics"), "intel");
assert_eq!(vendor_of("Intel(R) UHD Graphics 770"), "intel");
assert_eq!(vendor_of("gfx1036"), "amd");
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
@ -1009,38 +951,6 @@ mod tests {
assert!(diff.removed.is_empty());
}
/// The first Intel card (the B580 on PC 1, 7 October 2026, docs/plans/intel-arc.md): the Arc driver's OpenCL
/// platform lists it as "Intel(R) Arc(TM) B580 Graphics" under vendor "Intel(R) Corporation" (the line shape is
/// the worker's; the device name and vendor string are Intel's documented forms, approximate until the PC 1 job
/// prints them). It becomes a discrete 12 GB card, vendor intel, worker OpenCL, on with 8 identities; the
/// Arc-branded processor graphics stay integrated; the NVIDIA cards beside it are untouched.
#[test]
fn an_intel_arc_card_on_the_intel_platform_is_a_discrete_intel_card() {
let list = String::from(PC1_LIST)
+ " [4] Intel(R) Arc(TM) B580 Graphics | Intel(R) OpenCL Graphics (OpenCL 3.0 )\n GPU, vendor Intel(R) Corporation, driver 32.0.101.9034, OpenCL C 3.0, 160 compute units, 2850 MHz, pci 05:00.0\n global 12208 MiB, max alloc 4095 MiB, local 64 KiB, max work-group 1024, sub-group extension: cl_intel_subgroups\n";
let (devs, _) = parse_opencl_list(&list);
let arc = devs.iter().find(|d| d.name.contains("B580")).expect("the Arc line parses");
assert_eq!(arc.vendor_word(), "intel");
assert_eq!(arc.mem_mb, 12208);
assert_eq!(arc.bus, "05:00.0");
let d = assemble(pc1_inputs(&list));
let c = d.cards.iter().find(|c| c.vendor == "intel").expect("an intel card");
assert_eq!((c.name.as_str(), c.kind.as_str(), c.worker.as_str(), c.device.as_str(), c.enabled, c.identities), ("Intel(R) Arc(TM) B580 Graphics", "discrete", "OpenCL", "4", true, 8));
assert_eq!(c.key, "intel:Intel(R) Arc(TM) B580 Graphics");
assert_eq!(c.vram_mb, 12208);
assert!(!c.sweep_supported, "no cap through OpenCL");
assert!(c.sweep_note.contains("Intel Arc"), "{}", c.sweep_note);
assert!(d.listed(c), "the OpenCL list answered, so a vanished Arc can be called removed");
// the Arc names: cards discrete, processor graphics integrated
for card in ["Intel(R) Arc(TM) B580 Graphics", "Intel(R) Arc(TM) A770 Graphics", "Intel(R) Arc(TM) A380 Graphics", "Intel(R) Arc(TM) Pro A60 Graphics", "Intel(R) Arc(TM) B570 Graphics"] {
assert_eq!(classify_kind(card, None), "discrete", "{card}");
}
for igpu in ["Intel(R) Arc(TM) Graphics", "Intel(R) Arc(TM) 140V GPU (16GB)", "Intel(R) Arc(TM) 130V GPU", "Intel(R) Arc(TM) 140T GPU"] {
assert_eq!(classify_kind(igpu, None), "integrated", "{igpu}");
}
assert_eq!(classify_kind("Intel(R) UHD Graphics 770", None), "integrated");
}
#[test]
fn keys_number_identical_cards() {
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];

View file

@ -1,229 +0,0 @@
//! The unattended driver install (the rights-at-install step `driver-install-task`, 7 October 2026, 19:5x BST; the project lead's
//! rule that evening: no PC job may need a click or a UAC prompt, and the Arc's 9034 retry on PC 2 was cancelled for
//! it). What the installer registers once (src/rights.rs on boot-start-22 takes the id into RIGHTS): the Igneum Power
//! Helper task (RunLevel Highest, the app's own exe with `--power-helper`) with a two-hour execution limit, so the
//! elevated helper can run a vendor's driver installer to its end. Nothing else: no second task, no new firewall rule.
//!
//! The protocol, on the helper's one command file: `<seq> driver <vendor>` with a vendor WORD only (nvidia | amd |
//! intel). The helper, elevated, resolves everything else itself from the signed table the manifest left at
//! `<app data>/drivers.json` and the file the app downloaded into `<app data>/drivers/`: the size, the sha256 and the
//! Authenticode signer must match the table and the signer must be one of the three vendors, or nothing runs. So a
//! writer of cmd.txt can never choose what runs elevated (the Power Helper's rule since 6 October 2026). The helper
//! runs the installer with the table's silent arguments, keeps its heartbeat during the run (cap 45 minutes), and
//! writes `<seq> <vendor> exit <code> reboot <0|1>` to `driver-result.txt` in its folder. The app holds the vendor's
//! cards before it asks (engine::driver_install), reads the result, and a "restart required" exit is the Restart now
//! button: the app never restarts the machine by itself. When the task is not registered (an install before 0.3.22
//! whose rights step has not run), the one-prompt path of src/drivers.rs stays as it was.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// The right's id and sentence for src/rights.rs RIGHTS (an id never changes meaning; a new need is a new id).
pub const RIGHT: (&str, &str) = ("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)");
pub const RESULT_FILE: &str = "driver-result.txt";
pub const VENDORS: &[&str] = &["nvidia", "amd", "intel"];
/// The Authenticode subjects a driver installer may carry to run elevated (the table's `signer` must match one too).
pub const ALLOWED_SIGNERS: &[&str] = &["Intel Corporation", "NVIDIA Corporation", "Advanced Micro Devices"];
/// How long the helper waits for the installer, and how long the app waits for the helper's result line.
pub const INSTALL_CAP: Duration = Duration::from_secs(45 * 60);
pub const RESULT_WAIT: Duration = Duration::from_secs(50 * 60);
/// The installer's registration for this right: the Power Helper task as src/powertask.rs registers it, with the
/// execution limit raised from one hour to two (a 1 GB download that the app already did is not in it; the installer
/// itself runs 2 to 15 minutes, and the helper's own idle exit is 20 minutes).
pub fn register_script(exe: &Path) -> String {
crate::powertask::register_script(exe).replace("-ExecutionTimeLimit (New-TimeSpan -Hours 1)", "-ExecutionTimeLimit (New-TimeSpan -Hours 2)")
}
pub fn vendor_ok(v: &str) -> bool {
VENDORS.contains(&v)
}
/// The command line the app writes for the helper (the sequence from the one wire space).
pub fn command_line(seq: u64, vendor: &str) -> String {
format!("{seq} driver {vendor}\n")
}
/// The file the helper runs for a vendor: the table's URL's last path segment inside the app's drivers folder.
pub fn file_for(e: &crate::drivertable::VendorEntry, drivers_dir: &Path) -> PathBuf {
let name = e.url.rsplit('/').next().filter(|n| !n.is_empty() && !n.contains("..") && !n.contains('\\')).unwrap_or("driver.exe");
drivers_dir.join(name)
}
/// The elevated check before anything runs: size and sha256 equal to the table, the signer one of the vendors and the
/// table's own. Pure, so the box tests it.
pub fn verify(e: &crate::drivertable::VendorEntry, size: u64, sha256: &str, signer_subject: &str) -> Result<(), String> {
if size != e.size {
return Err(format!("size {size} is not the table's {}", e.size));
}
if !sha256.eq_ignore_ascii_case(&e.sha256) {
return Err("sha256 is not the table's: the file is not the one the manifest names".into());
}
if !ALLOWED_SIGNERS.iter().any(|s| signer_subject.contains(s)) {
return Err(format!("the signer '{signer_subject}' is not a driver vendor"));
}
if !e.signer.is_empty() && !signer_subject.contains(&e.signer) {
return Err(format!("the signer '{signer_subject}' is not the table's '{}'", e.signer));
}
Ok(())
}
/// The helper's result line and its reading.
pub fn result_line(seq: u64, vendor: &str, code: i64, reboot: bool) -> String {
format!("{seq} {vendor} exit {code} reboot {}\n", if reboot { 1 } else { 0 })
}
pub fn parse_result(text: &str, seq: u64) -> Option<(i64, bool)> {
text.lines().rev().find_map(|l| {
let p: Vec<&str> = l.split_whitespace().collect();
match p.as_slice() {
[s, _, "exit", c, "reboot", r] if s.parse::<u64>().ok() == Some(seq) => Some((c.parse().ok()?, *r == "1")),
_ => None,
}
})
}
/// Inside the elevated helper: resolve, verify, run, report. `dir` is the helper's folder (<app data>/app/sweep).
pub fn run_in_helper(dir: &Path, seq: u64, vendor: &str, log: &dyn Fn(&str)) {
let app_dir = dir.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| dir.to_path_buf());
let outcome = run_in_helper_inner(&app_dir, dir, vendor, log);
let (code, reboot) = match outcome {
Ok(v) => v,
Err(e) => {
log(&format!("{seq} driver {vendor}: refused: {e}"));
(-2, false)
}
};
let _ = std::fs::OpenOptions::new().append(true).create(true).open(dir.join(RESULT_FILE)).and_then(|mut f| {
use std::io::Write;
f.write_all(result_line(seq, vendor, code, reboot).as_bytes())
});
log(&format!("{seq} driver {vendor}: exit {code} reboot {reboot}"));
}
fn run_in_helper_inner(app_dir: &Path, helper_dir: &Path, vendor: &str, log: &dyn Fn(&str)) -> Result<(i64, bool), String> {
if !vendor_ok(vendor) {
return Err(format!("'{vendor}' is not a vendor word"));
}
let text = std::fs::read_to_string(app_dir.join("drivers.json")).map_err(|e| format!("no driver table at {}: {e}", app_dir.join("drivers.json").display()))?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("the driver table does not parse: {e}"))?;
let table = crate::drivertable::Table::parse(&v)?;
let e = table.entry(vendor).ok_or_else(|| format!("no {vendor} row in the table"))?.clone();
let file = file_for(&e, &app_dir.join("drivers"));
let size = std::fs::metadata(&file).map(|m| m.len()).map_err(|x| format!("no downloaded installer at {}: {x}", file.display()))?;
let sha = crate::manifest::sha256_file(&file).map_err(|x| x.to_string())?;
let subject = signer_subject(&file)?;
verify(&e, size, &sha, &subject)?;
log(&format!("driver {vendor}: {} verified (size, sha256, signer '{subject}'); running {} {}", file.display(), file.display(), e.args.join(" ")));
let mut c = std::process::Command::new(&file);
c.args(&e.args);
crate::platform::quiet(&mut c);
let mut child = c.spawn().map_err(|x| format!("the installer did not start: {x}"))?;
let started = Instant::now();
loop {
crate::powertask::beat(helper_dir, crate::platform::unix_now());
match child.try_wait() {
Ok(Some(st)) => {
let code = st.code().map(|c| c as i64).unwrap_or(-1);
let (reboot, _) = crate::drivertable::exit_meaning(code, &e);
return Ok((code, reboot));
}
Ok(None) => {
if started.elapsed() > INSTALL_CAP {
let _ = child.kill();
return Err(format!("the installer ran past {} minutes and was ended", INSTALL_CAP.as_secs() / 60));
}
std::thread::sleep(Duration::from_secs(2));
}
Err(x) => return Err(format!("waiting on the installer: {x}")),
}
}
}
#[cfg(windows)]
fn signer_subject(path: &Path) -> Result<String, String> {
let ps = format!("$s = Get-AuthenticodeSignature -LiteralPath '{}'; Write-Output ('status=' + $s.Status); Write-Output ('subject=' + $s.SignerCertificate.Subject)", path.display().to_string().replace('\'', "''"));
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("PowerShell did not answer the signature check")?;
let mut status = String::new();
let mut subject = String::new();
for l in out.lines() {
if let Some(v) = l.strip_prefix("status=") { status = v.trim().to_string(); } else if let Some(v) = l.strip_prefix("subject=") { subject = v.trim().to_string(); }
}
if status != "Valid" {
return Err(format!("the Authenticode signature is {status}, not Valid"));
}
Ok(subject)
}
#[cfg(not(windows))]
fn signer_subject(_path: &Path) -> Result<String, String> {
Err("the installer runs on Windows only".into())
}
/// The app's side: when the Power Helper task is registered, ask it and wait for the result line; None when it is not
/// (the caller falls back to the one-prompt path). `file` is the verified download.
pub fn via_helper(vendor: &str) -> Option<Result<(i64, bool), String>> {
if !cfg!(windows) || !crate::powertask::registered() {
return None;
}
let dir = crate::powertask::helper_dir();
Some((|| {
crate::powertask::ensure_running(&dir, Duration::from_secs(30))?;
let seq = crate::powertask::wire_seq();
let mut text = std::fs::read_to_string(dir.join("cmd.txt")).unwrap_or_default();
text.push_str(&command_line(seq, vendor));
std::fs::write(dir.join("cmd.txt"), text).map_err(|e| e.to_string())?;
let started = Instant::now();
loop {
let res = std::fs::read_to_string(dir.join(RESULT_FILE)).unwrap_or_default();
if let Some(r) = parse_result(&res, seq) {
return Ok(r);
}
if started.elapsed() > RESULT_WAIT {
return Err(format!("the Power Helper gave no result for the {vendor} install within {} minutes", RESULT_WAIT.as_secs() / 60));
}
if !crate::powertask::alive(&dir) && started.elapsed() > Duration::from_secs(120) {
return Err("the Power Helper stopped during the install (no heartbeat)".into());
}
std::thread::sleep(Duration::from_secs(3));
}
})())
}
#[cfg(test)]
mod tests {
use super::*;
fn intel() -> crate::drivertable::VendorEntry {
let v: serde_json::Value = serde_json::from_str(include_str!("../../../packaging/ota/drivers.json")).unwrap();
crate::drivertable::Table::parse(&v).unwrap().entry("intel").unwrap().clone()
}
/// Known-failed first: an installer that is not the table's file, or not signed by a driver vendor, must never run
/// elevated; the first cut of the unattended path had no such check.
#[test]
fn the_helper_refuses_a_file_that_is_not_the_tables_or_not_the_vendors() {
let e = intel();
assert!(verify(&e, e.size, &e.sha256, "CN=Intel Corporation, O=Intel Corporation, S=California, C=US").is_ok());
assert!(verify(&e, e.size + 1, &e.sha256, "CN=Intel Corporation").unwrap_err().starts_with("size"));
assert!(verify(&e, e.size, "00", "CN=Intel Corporation").unwrap_err().starts_with("sha256"));
assert!(verify(&e, e.size, &e.sha256, "CN=Some Miner Tools Ltd").unwrap_err().contains("not a driver vendor"));
assert!(verify(&e, e.size, &e.sha256, "CN=NVIDIA Corporation").unwrap_err().contains("not the table's"), "a vendor, but not this row's");
assert_eq!(file_for(&e, Path::new("D")).file_name().unwrap().to_str().unwrap(), "gfx_win_101.9034.exe");
}
#[test]
fn the_right_is_the_power_helper_task_with_a_two_hour_limit_and_the_protocol_round_trips() {
assert_eq!(RIGHT.0, "driver-install-task", "the id src/rights.rs's test already anticipates");
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"));
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Hours 2)") && !s.contains("-Hours 1"), "{s}");
assert!(s.contains("-TaskName 'Igneum Power Helper'") && s.contains("--power-helper"), "the same task, the same action");
assert_eq!(command_line(305327, "intel"), "305327 driver intel\n");
let r = result_line(305327, "intel", 14, true);
assert_eq!(parse_result(&r, 305327), Some((14, true)));
assert_eq!(parse_result(&r, 305328), None, "another sequence's result is not this one");
assert_eq!(parse_result("305327 intel exit -2 reboot 0\n", 305327), Some((-2, false)), "a refusal reads as exit -2");
assert!(vendor_ok("amd") && !vendor_ok("apple") && !vendor_ok("C:\\x.exe"));
}
}

View file

@ -1,223 +0,0 @@
//! Driver check (branch driver-check, 7 October 2026; the project lead: "can we package the drivers with the miner? for all
//! cards? and the system knows which to install if not present"). The answer given: not bundled (size, vendor
//! licences, staleness), but detected and installed on one click.
//!
//! The manifest carries a per-vendor table (`drivers`, signed with the rest): the version the worker needs at least,
//! the version on offer, the vendor's own download URL, size, sha256 and the page the hash was read from, the
//! installer's silent arguments and the exit codes that mean "restart required". The app never ships a driver: the
//! table rides the manifest (ota.rs writes `<app data>/drivers.json`), so a new vendor release is a manifest publish.
//!
//! At every detection each card's driver version (nvidia-smi's for NVIDIA, Windows' DriverVersion for AMD and Intel)
//! is compared with the table; a missing or old driver puts an offer on the card's row ("Install the NVIDIA driver
//! 581.57, 650 MB"). The click downloads from the vendor's server (curl with resume), checks size, sha256 and the
//! Authenticode signature (the signer must be the vendor), then runs the installer through ONE elevated prompt
//! (platform::elevated_command, the PC 1 driver job's fetch, verify and -s shape), reports "restart required" with a
//! Restart now button and never restarts by itself. Nothing else pauses: the miners keep mining through it.
//! macOS: no driver step (the row says so). Linux and HiveOS: a line naming the package, no installer.
//!
//! Dry run (`IGNEUM_DRIVER_DRY_RUN=1`, or `dry_run: true` in the table): the download and every check run, the
//! installer does not: the install step reports what it would have run and exit 0. The tests feed the table a
//! 127.0.0.1 URL served by a std TcpListener (the mocked vendor response).
use crate::engine::{Cmd, Shared};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
pub use crate::drivertable::{exit_meaning, offer_for, platform_word, DriverState, Offer, Table, VendorEntry};
#[cfg(windows)]
use crate::drivertable::authenticode_verdict;
/// The install thread's reports.
pub enum Event {
Progress(f64, String),
/// the installer ran: its exit code and whether that means a restart
Installed(Result<(i64, bool, String), String>),
}
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
let mut c = Command::new(crate::platform::tool("curl"));
c.args(args);
let out = crate::detect::run_timeout(&mut c, None, limit).ok_or("curl is not available")?;
let t = out.trim().to_string();
if t.is_empty() { Ok(()) } else { Err(t.lines().last().unwrap_or("curl failed").to_string()) }
}
/// Downloads the vendor's file with resume into `dir`, checks size and sha256, renames `.part` to the final name.
pub fn download(e: &VendorEntry, dir: &Path) -> Result<PathBuf, String> {
let name = e.url.rsplit('/').next().filter(|n| !n.is_empty() && !n.contains("..")).unwrap_or("driver.exe").to_string();
let final_path = dir.join(&name);
let part = dir.join(format!("{name}.part"));
std::fs::create_dir_all(dir).map_err(|x| format!("cannot make {}: {x}", dir.display()))?;
if final_path.is_file() && std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0) == e.size && crate::manifest::sha256_file(&final_path).map(|s| s == e.sha256).unwrap_or(false) {
return Ok(final_path);
}
let _ = std::fs::remove_file(&final_path);
let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if have > e.size {
let _ = std::fs::remove_file(&part);
}
if have != e.size {
// drivers.amd.com answers 403 without an amd.com Referer (igneum-build-2, 7 October 2026): the row names one
let mut args = vec!["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) IgneumMiner"];
if !e.referer.is_empty() {
args.push("-e");
args.push(&e.referer);
}
let part_s = part.display().to_string();
args.extend(["-o", &part_s, &e.url]);
curl(&args, Duration::from_secs(7260))?;
}
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got != e.size {
let _ = std::fs::remove_file(&part);
return Err(format!("size mismatch: got {got} bytes, the table says {}", e.size));
}
let sum = crate::manifest::sha256_file(&part).map_err(|x| x.to_string())?;
if sum != e.sha256 {
let _ = std::fs::remove_file(&part);
return Err(format!("sha256 mismatch: the file is not the one the table names (page {})", e.hash_source));
}
std::fs::rename(&part, &final_path).map_err(|x| x.to_string())?;
Ok(final_path)
}
#[cfg(windows)]
fn authenticode(path: &Path, signer: &str) -> Result<String, String> {
let ps = format!("$s = Get-AuthenticodeSignature -LiteralPath '{}'; Write-Output ('status=' + $s.Status); Write-Output ('subject=' + $s.SignerCertificate.Subject)", path.display().to_string().replace('\'', "''"));
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]), None, Duration::from_secs(60)).ok_or("PowerShell did not answer the signature check")?;
authenticode_verdict(&out, signer)
}
#[cfg(not(windows))]
fn authenticode(_path: &Path, _signer: &str) -> Result<String, String> {
Err("the installer runs on Windows only".into())
}
/// The click: download, verify, run elevated (one prompt), report. Runs on its own thread; the miners keep mining.
pub fn start_install(shared: &Arc<Shared>, e: VendorEntry, dir: PathBuf, dry_run: bool) {
let shared2 = shared.clone();
std::thread::spawn(move || {
shared2.send(Cmd::Driver(Event::Progress(0.05, format!("downloading {} ({} MB) from {}", e.version, (e.size + 512 * 1024) / (1024 * 1024), host_of(&e.url)))));
let file = match download(&e, &dir) {
Ok(f) => f,
Err(x) => {
shared2.send(Cmd::Driver(Event::Installed(Err(format!("download: {x}")))));
return;
}
};
shared2.send(Cmd::Driver(Event::Progress(0.6, "size and sha256 match the table; checking the signature".into())));
if !dry_run {
if let Err(x) = authenticode(&file, &e.signer) {
shared2.send(Cmd::Driver(Event::Installed(Err(x))));
return;
}
}
let unattended = !dry_run && cfg!(windows) && crate::powertask::registered();
shared2.send(Cmd::Driver(Event::Progress(0.7, format!("{}installing {} {}: {}", if dry_run { "dry run: " } else { "" }, e.vendor.to_ascii_uppercase(), e.version, if unattended { "through the Igneum Power Helper task, no prompt" } else { "Windows asks for permission once" }))));
if dry_run {
shared2.send(Cmd::Driver(Event::Installed(Ok((0, false, format!("dry run: would run {} {}", file.display(), e.args.join(" ")))))));
return;
}
// 0.3.22 (src/driverinstall.rs, the rights step driver-install-task): with the Power Helper task registered the
// elevated helper runs the installer unattended after its own verification of the file; no prompt
if unattended {
if let Some(r) = crate::driverinstall::via_helper(&e.vendor) {
let r = r.map(|(code, _)| { let (reboot, text) = exit_meaning(code, &e); (code, reboot, text) });
shared2.send(Cmd::Driver(Event::Installed(r)));
return;
}
}
let args = e.args.join(" ");
let mut c = crate::platform::elevated_command(&file.display().to_string(), &args);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800));
let code = out.as_deref().and_then(|t| t.lines().rev().find_map(|l| l.trim().parse::<i64>().ok())).unwrap_or(-1);
// elevated_ps_line prints nothing on success and exits with the installer's code; run_timeout only gives
// stdout, so the exit code is read from the wrapper's own echo below
let code = exit_code_of(&file, &args, code);
let (reboot, text) = exit_meaning(code, &e);
shared2.send(Cmd::Driver(Event::Installed(Ok((code, reboot, text)))));
});
}
/// The elevated wrapper's exit code: `elevated_ps_line` exits with the installer's code, which run_timeout does not
/// return; so the installer is run through a second form that echoes the code on its last line.
#[cfg(windows)]
fn exit_code_of(file: &Path, args: &str, _seen: i64) -> i64 {
let line = format!("{}; Write-Output ('exit=' + $LASTEXITCODE)", crate::platform::elevated_ps_line(&file.display().to_string(), args).trim_end_matches("exit $p.ExitCode").to_string() + "$global:LASTEXITCODE = $p.ExitCode");
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &line]);
crate::platform::quiet(&mut c);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800)).unwrap_or_default();
out.lines().rev().find_map(|l| l.trim().strip_prefix("exit=").and_then(|v| v.parse::<i64>().ok())).unwrap_or(-1)
}
#[cfg(not(windows))]
fn exit_code_of(_file: &Path, _args: &str, seen: i64) -> i64 {
seen
}
fn host_of(url: &str) -> String {
url.split("//").nth(1).and_then(|r| r.split('/').next()).unwrap_or("the vendor").to_string()
}
/// "Restart now": a plain restart in 20 s (no elevation needed for the signed-in user); never called by the app itself.
pub fn restart_now() -> Result<(), String> {
if !cfg!(windows) {
return Err("restart from the app is for Windows only".into());
}
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
let mut c = Command::new(format!("{root}\\System32\\shutdown.exe"));
c.args(["/r", "/t", "20", "/c", "Igneum Miner: restarting to finish the driver install"]);
crate::platform::quiet(&mut c);
match crate::detect::run_timeout(&mut c, None, Duration::from_secs(20)) {
Some(t) if t.trim().is_empty() => Ok(()),
Some(t) => Err(t.trim().to_string()),
None => Err("shutdown.exe did not answer".into()),
}
}
#[cfg(test)]
mod download_tests {
use super::*;
/// The mocked vendor response: a std TcpListener on 127.0.0.1 serves the file; the right sha256 passes, a wrong
/// one is refused and the part file is gone; a second call with the file in place downloads nothing.
#[test]
fn download_verifies_against_a_mocked_vendor_server() {
use std::io::{Read, Write};
let body: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let served = body.clone();
std::thread::spawn(move || {
for stream in listener.incoming().take(3) {
let mut s = stream.unwrap();
let mut buf = [0u8; 4096];
let _ = s.read(&mut buf);
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n", served.len());
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(&served);
}
});
let dir = std::env::temp_dir().join(format!("igneum-driver-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
use sha2::Digest;
let sum = format!("{:x}", sha2::Sha256::digest(&body));
let mut e = VendorEntry { vendor: "intel".into(), version: "1.0".into(), min_version: "1.0".into(), url: format!("http://127.0.0.1:{port}/gfx_test.exe"), size: body.len() as u64, sha256: sum.clone(), args: vec!["-s".into()], signer: "Intel".into(), ..Default::default() };
let f = download(&e, &dir).expect("the right sha256 passes");
assert_eq!(f.file_name().unwrap(), "gfx_test.exe");
assert_eq!(std::fs::read(&f).unwrap(), body);
assert!(download(&e, &dir).is_ok(), "the file in place is kept without a second download");
let _ = std::fs::remove_file(&f);
e.sha256 = "0".repeat(64);
let err = download(&e, &dir).unwrap_err();
assert!(err.contains("sha256 mismatch"), "{err}");
assert!(!dir.join("gfx_test.exe.part").exists() && !dir.join("gfx_test.exe").exists());
e.sha256 = sum;
e.size = 7;
assert!(download(&e, &dir).unwrap_err().contains("size mismatch"));
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -1,395 +0,0 @@
//! The driver table and the offer decision (branch driver-check, 7 October 2026): the pure half of src/drivers.rs,
//! with no dependency on the engine, so the signer binary (src/bin/ota-sign.rs) validates a manifest's `drivers`
//! object the way the app does. The install thread, the download and the Authenticode call live in drivers.rs.
use serde::Serialize;
use std::path::Path;
/// One vendor's row of the table.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct VendorEntry {
pub vendor: String,
/// the lowest version the worker runs on (NVIDIA: nvidia-smi's "570.00"; AMD and Intel: Windows' DriverVersion "32.0.101.9034")
pub min_version: String,
/// what the click installs
pub version: String,
pub url: String,
pub size: u64,
pub sha256: String,
/// where the sha256 was read (the vendor's page), for the record on the row
pub hash_source: String,
/// a Referer the vendor's server requires (drivers.amd.com answers 403 without an amd.com one); empty = none
pub referer: String,
/// the installer's silent arguments
pub args: Vec<String>,
/// exit codes that mean "installed, restart required"
pub reboot_codes: Vec<i64>,
/// a word the Authenticode subject must carry ("NVIDIA", "Advanced Micro Devices", "Intel")
pub signer: String,
/// Linux and HiveOS: the package to name, no installer
pub linux_package: String,
pub hive_package: String,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Table {
pub updated: String,
pub dry_run: bool,
pub vendors: Vec<VendorEntry>,
}
impl Table {
/// The manifest's `drivers` object. Every entry is checked the way the platform entries are: https (or 127.0.0.1
/// for a test), 64-hex sha256, a size, a version on both sides, at least one silent argument.
pub fn parse(v: &serde_json::Value) -> Result<Table, String> {
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let obj = v.get("vendors").and_then(|x| x.as_object()).ok_or("drivers: no vendors object")?;
let mut vendors = Vec::new();
for (name, e) in obj {
if e.is_null() {
continue;
}
let vendor = name.to_ascii_lowercase();
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
return Err(format!("drivers: vendor '{name}' is not nvidia, amd or intel"));
}
let entry = VendorEntry {
vendor: vendor.clone(),
min_version: s(e, "min_version"),
version: s(e, "version"),
url: s(e, "url"),
size: e.get("size").and_then(|x| x.as_u64()).unwrap_or(0),
sha256: s(e, "sha256").to_ascii_lowercase(),
hash_source: s(e, "hash_source"),
referer: s(e, "referer"),
args: e.get("args").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).map(|x| x.to_string()).collect()).unwrap_or_default(),
reboot_codes: e.get("reboot_codes").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_i64()).collect()).unwrap_or_default(),
signer: s(e, "signer"),
linux_package: s(e, "linux_package"),
hive_package: s(e, "hive_package"),
};
if parse_dotted(&entry.min_version).is_none() || parse_dotted(&entry.version).is_none() {
return Err(format!("drivers.{name}: min_version and version must be dotted numbers"));
}
if !entry.url.starts_with("https://") && !entry.url.starts_with("http://127.0.0.1:") {
return Err(format!("drivers.{name}: the url is not https"));
}
if entry.sha256.len() != 64 || !entry.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("drivers.{name}: sha256 is not 64 hex characters"));
}
if entry.size == 0 {
return Err(format!("drivers.{name}: size is missing"));
}
if entry.args.is_empty() || entry.args.iter().any(|a| a.contains(['"', '\'', '&', '|', ';', '`'])) {
return Err(format!("drivers.{name}: args must be plain switches"));
}
if !entry.referer.is_empty() && !entry.referer.starts_with("https://") {
return Err(format!("drivers.{name}: referer must be https"));
}
if entry.signer.is_empty() {
return Err(format!("drivers.{name}: signer is missing (the Authenticode subject word)"));
}
vendors.push(entry);
}
if vendors.is_empty() {
return Err("drivers: the vendors object is empty".into());
}
vendors.sort_by(|a, b| a.vendor.cmp(&b.vendor));
Ok(Table { updated: s(v, "updated"), dry_run: v.get("dry_run").and_then(|x| x.as_bool()).unwrap_or(false), vendors })
}
pub fn entry(&self, vendor: &str) -> Option<&VendorEntry> {
self.vendors.iter().find(|e| e.vendor == vendor)
}
}
/// "32.0.101.9034" or "581.57" as numbers; None for anything else (an empty string, "3683.0 (PAL,LC)").
pub fn parse_dotted(s: &str) -> Option<Vec<u64>> {
let t = s.trim();
if t.is_empty() {
return None;
}
let mut out = Vec::new();
for p in t.split('.') {
out.push(p.trim().parse::<u64>().ok()?);
}
Some(out)
}
/// `a` is at least `b`, compared part by part (a missing trailing part reads 0).
pub fn at_least(a: &[u64], b: &[u64]) -> bool {
let n = a.len().max(b.len());
for i in 0..n {
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
if x != y {
return x > y;
}
}
true
}
/// What the card's row shows about its driver.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Offer {
pub vendor: String,
/// "missing" | "old" | "fine" | "none" (no row for this vendor in the table)
pub status: String,
pub installed: String,
pub wanted: String,
pub min_version: String,
pub size: u64,
pub url: String,
pub hash_source: String,
/// the row's sentence
pub text: String,
/// true when the click can install here (Windows with an entry); false with a note on macOS, Linux and HiveOS
pub installable: bool,
}
/// The platform word for the notes: "windows" | "macos" | "linux" | "hive".
pub fn platform_word() -> &'static str {
if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if Path::new("/hive").is_dir() || Path::new("/hive-config").is_dir() {
"hive"
} else {
"linux"
}
}
/// The offer for a card: its vendor, the version its driver reports (empty = none found) and the table.
pub fn offer_for(vendor: &str, installed: &str, table: Option<&Table>, platform: &str) -> Option<Offer> {
if vendor == "apple" {
return Some(Offer { vendor: "apple".into(), status: "none".into(), installed: installed.into(), text: "Apple silicon: no driver step, macOS carries it.".into(), ..Default::default() });
}
let e = table.and_then(|t| t.entry(vendor))?;
let word = match vendor {
"nvidia" => "NVIDIA",
"amd" => "AMD",
"intel" => "Intel Arc",
_ => vendor,
};
let mb = (e.size + 512 * 1024) / (1024 * 1024);
let size_text = if mb >= 1024 { format!("{:.1} GB", mb as f64 / 1024.0) } else { format!("{mb} MB") };
let have = parse_dotted(installed);
let need = parse_dotted(&e.min_version).unwrap_or_default();
let status = match &have {
None => "missing",
Some(h) if at_least(h, &need) => "fine",
Some(_) => "old",
};
let base = Offer { vendor: vendor.into(), status: status.into(), installed: installed.into(), wanted: e.version.clone(), min_version: e.min_version.clone(), size: e.size, url: e.url.clone(), hash_source: e.hash_source.clone(), text: String::new(), installable: false };
let text = match (platform, status) {
("macos", _) => "macOS: no driver step.".to_string(),
("hive", "fine") | ("linux", "fine") => format!("{word} driver {installed}: fine."),
("hive", _) => format!("HiveOS: {} the driver with {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.hive_package.is_empty() { "hive's driver tool" } else { &e.hive_package }, e.min_version),
("linux", _) => format!("Linux: {} the package {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.linux_package.is_empty() { "the vendor's driver" } else { &e.linux_package }, e.min_version),
(_, "fine") => format!("{word} driver {installed}: fine."),
(_, "missing") => format!("Install the {word} driver {} ({size_text}). No driver was found, so this card cannot mine yet.", e.version),
_ => format!("Install the {word} driver {} ({size_text}). Driver {installed} is older than the {} the worker needs.", e.version, e.min_version),
};
Some(Offer { text, installable: platform == "windows" && status != "fine", ..base })
}
/// The installer's exit code read: (restart required, the sentence).
pub fn exit_meaning(code: i64, e: &VendorEntry) -> (bool, String) {
if code == 0 {
return (false, format!("{} driver {} installed.", e.vendor.to_ascii_uppercase(), e.version));
}
if e.reboot_codes.contains(&code) {
return (true, format!("{} driver {} installed. Restart Windows to finish.", e.vendor.to_ascii_uppercase(), e.version));
}
(false, format!("the {} installer exited with code {code}.", e.vendor.to_ascii_uppercase()))
}
/// The install's progress, published as `state.drivers`.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct DriverState {
/// idle | downloading | verifying | installing | done | reboot | error
pub status: String,
pub vendor: String,
pub version: String,
pub progress: f64,
pub message: String,
pub error: String,
pub reboot_required: bool,
pub dry_run: bool,
pub started_at: f64,
pub finished_at: f64,
/// the table's updated stamp, for the Settings page ("drivers table of 7 October")
pub table_updated: String,
pub platform: String,
}
/// The Authenticode verdict from `Get-AuthenticodeSignature`'s two lines ("Valid" and the subject): Ok(subject) when
/// the status is Valid and the subject carries the vendor's word.
pub fn authenticode_verdict(text: &str, signer: &str) -> Result<String, String> {
let mut status = String::new();
let mut subject = String::new();
for l in text.lines() {
if let Some(v) = l.strip_prefix("status=") {
status = v.trim().to_string();
} else if let Some(v) = l.strip_prefix("subject=") {
subject = v.trim().to_string();
}
}
if status != "Valid" {
return Err(format!("the file's signature is {}: not installed", if status.is_empty() { "unreadable".to_string() } else { status }));
}
if !subject.to_ascii_lowercase().contains(&signer.to_ascii_lowercase()) {
return Err(format!("the file is signed by \"{subject}\", not {signer}: not installed"));
}
Ok(subject)
}
/// The cards whose worker stops before a vendor's driver installer starts: every enabled card of that vendor. PC 2,
/// 7 October 2026, 16:26Z: the Intel installer took the kernel down (bugcheck 0x3B) with the app's worker mining on the
/// Arc B580 in a Razer Core X V2; at 19:14 BST the same card read kind=discrete on 0.3.21 (no USB4 or Thunderbolt
/// router in its parent chain on that board), so a hold keyed on the external kind alone would have missed the card
/// that crashed the box. The rule since 0.3.22 (the shipper's word, 19:1x BST): the vendor's cards all stop, the other
/// vendors' cards keep mining, a card already off has nothing to stop. Each item is (key, vendor, kind, enabled).
pub fn install_hold_keys<'a>(cards: impl IntoIterator<Item = (&'a str, &'a str, &'a str, bool)>, vendor: &str) -> Vec<String> {
cards.into_iter().filter(|(_, v, _, enabled)| *v == vendor && *enabled).map(|(key, _, _, _)| key.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_table_accepts_the_inbox_6733_driver_for_the_arc_b580_until_an_unattended_install_exists() {
// the project lead's rule, 7 October 2026 evening: no PC job needs a click or a UAC prompt. The one-click install asks for
// one, so the table does not demand 9034 of an Arc on Windows' inbox 6733 (the worker mines at 11.0 MH/s on it
// with the Intel rotate rewrite); 9034 stays the version on offer for a card with no driver at all.
let v: serde_json::Value = serde_json::from_str(include_str!("../../../packaging/ota/drivers.json")).expect("the shipped table is JSON");
let t = Table::parse(&v).expect("the shipped table parses");
let intel = t.entry("intel").expect("an Intel row");
assert_eq!(intel.min_version, "32.0.101.6733", "6733 is acceptable for the B580 until the Power Helper task installs drivers unattended");
assert_eq!(intel.version, "32.0.101.9034");
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
assert_eq!((o.status.as_str(), o.installable), ("fine", false), "{}", o.text);
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
assert!(o.installable && o.text.starts_with("Install the Intel Arc driver 32.0.101.9034"), "{}", o.text);
}
#[test]
fn a_driver_install_stops_every_card_of_its_vendor_first_and_leaves_the_other_vendors_mining() {
// PC 2, 7 October 2026: the Arc B580 in the Razer Core X V2 was mining when the Intel installer's display
// reset took the machine down. The rule: the vendor's external cards stop, nothing else does.
let cards = [
("nvidia:0:NVIDIA GeForce RTX 5090", "nvidia", "discrete", true),
("nvidia:1:NVIDIA GeForce RTX 5060 Ti", "nvidia", "external", true),
("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", true),
("intel::Intel UHD 770", "other", "integrated", false),
];
assert_eq!(install_hold_keys(cards, "intel"), vec!["intel:Intel(R) Arc(TM) B580 Graphics".to_string()], "the Arc in the enclosure stops; the NVIDIA cards and the iGPU keep mining");
assert_eq!(install_hold_keys(cards, "nvidia"), vec!["nvidia:0:NVIDIA GeForce RTX 5090".to_string(), "nvidia:1:NVIDIA GeForce RTX 5060 Ti".to_string()], "every NVIDIA card stops for the NVIDIA install, inside the case or not");
assert!(install_hold_keys(cards, "amd").is_empty(), "no AMD card: nothing stops");
// a card already off has nothing to stop; a discrete card of the vendor IS held (PC 2, 19:14 BST: the Arc in the
// Razer Core X V2 read kind=discrete on 0.3.21, so the kind alone would have missed the card that crashed the box)
let off = [("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", false), ("intel:Intel(R) Arc(TM) A770 Graphics", "intel", "discrete", true)];
assert_eq!(install_hold_keys(off, "intel"), vec!["intel:Intel(R) Arc(TM) A770 Graphics".to_string()]);
// the iGPU carries vendor "other" on PC 2 (an AMD Radeon(TM) Graphics as amd:gfx1036 on another read): the install's
// vendor word decides, and a card off stays untouched
let igpu = [("amd:gfx1036", "amd", "integrated", false), ("amd:gfx1201", "amd", "discrete", true)];
assert_eq!(install_hold_keys(igpu, "amd"), vec!["amd:gfx1201".to_string()]);
}
const TABLE: &str = r#"{"updated":"2026-10-07","vendors":{
"nvidia":{"min_version":"570.00","version":"617.42","url":"https://us.download.nvidia.com/Windows/617.42/617.42-desktop-win10-win11-64bit-international-dch-whql.exe","size":990853168,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash_source":"https://www.nvidia.com/en-us/drivers/details/","args":["-s","-noreboot"],"reboot_codes":[1],"signer":"NVIDIA","linux_package":"nvidia-driver-570","hive_package":"nvidia-driver-update 570"},
"amd":{"referer":"https://www.amd.com/","min_version":"32.0.32000.0","version":"26.9.2","url":"https://drivers.amd.com/drivers/whql-amd-software-adrenalin-edition-26.9.2-win10-win11-sep2026.exe","size":912345678,"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","hash_source":"https://www.amd.com/en/support/download/drivers.html","args":["-install","-silent"],"reboot_codes":[3010],"signer":"Advanced Micro Devices","linux_package":"amdgpu-install --usecase=opencl"},
"intel":{"min_version":"32.0.101.9034","version":"32.0.101.9034","url":"https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe","size":932631144,"sha256":"72ba7eea08a0cc18650603976ff9433dfdf84d23d4cbbee662a4df9f2856ae98","hash_source":"https://www.intel.com/content/www/us/en/download/785597/","args":["-s"],"reboot_codes":[14,1014],"signer":"Intel","linux_package":"intel-opencl-icd"}}}"#;
fn table() -> Table {
Table::parse(&serde_json::from_str(TABLE).unwrap()).unwrap()
}
#[test]
fn versions_compare_part_by_part() {
assert!(at_least(&parse_dotted("32.0.101.9034").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
assert!(at_least(&parse_dotted("32.0.101.9040").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
assert!(!at_least(&parse_dotted("32.0.101.6733").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
assert!(at_least(&parse_dotted("581.57").unwrap(), &parse_dotted("570.00").unwrap()));
assert!(!at_least(&parse_dotted("566.36").unwrap(), &parse_dotted("570").unwrap()));
assert!(at_least(&parse_dotted("570").unwrap(), &parse_dotted("570.0.0").unwrap()));
assert_eq!(parse_dotted("3683.0 (PAL,LC)"), None);
assert_eq!(parse_dotted(""), None);
}
#[test]
fn the_table_parses_and_bad_rows_are_refused() {
let t = table();
assert_eq!(t.vendors.len(), 3);
assert_eq!(t.entry("intel").unwrap().reboot_codes, vec![14, 1014]);
assert_eq!(t.entry("nvidia").unwrap().args, vec!["-s", "-noreboot"]);
assert!(!t.dry_run);
let bad = |patch: &str, what: &str| {
let txt = TABLE.replacen(patch, what, 1);
let e = Table::parse(&serde_json::from_str(&txt).unwrap()).unwrap_err();
e
};
assert!(bad("https://downloadmirror", "http://downloadmirror").contains("https"));
assert!(bad("\"size\":932631144", "\"size\":0").contains("size"));
assert!(bad("\"args\":[\"-s\"]", "\"args\":[\"-s; calc\"]").contains("args"));
assert!(bad("\"signer\":\"Intel\"", "\"signer\":\"\"").contains("signer"));
assert!(bad("\"args\":[\"-install\",\"-silent\"]", "\"referer\":\"http://x\",\"args\":[\"-install\",\"-silent\"]").contains("referer"));
assert_eq!(t.entry("amd").unwrap().referer, "https://www.amd.com/");
assert!(bad("\"min_version\":\"570.00\"", "\"min_version\":\"latest\"").contains("dotted"));
assert!(Table::parse(&serde_json::json!({"vendors": {"apple": {}}})).unwrap_err().contains("vendor"));
assert!(Table::parse(&serde_json::json!({"vendors": {}})).unwrap_err().contains("empty"));
assert!(Table::parse(&serde_json::json!({"vendors": {"intel": null}})).unwrap_err().contains("empty"));
}
/// The offers per tier: a missing driver, an old one, a fine one, Apple, Linux and HiveOS.
#[test]
fn offers_name_the_version_the_size_and_the_reason() {
let t = table();
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
assert_eq!(o.status, "missing");
assert!(o.installable);
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.");
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
assert_eq!(o.status, "old");
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). Driver 32.0.101.6733 is older than the 32.0.101.9034 the worker needs.");
let o = offer_for("intel", "32.0.101.9034", Some(&t), "windows").unwrap();
assert_eq!((o.status.as_str(), o.installable, o.text.as_str()), ("fine", false, "Intel Arc driver 32.0.101.9034: fine."));
let o = offer_for("nvidia", "617.42", Some(&t), "windows").unwrap();
assert_eq!(o.status, "fine");
let o = offer_for("nvidia", "566.36", Some(&t), "windows").unwrap();
assert_eq!(o.text, "Install the NVIDIA driver 617.42 (945 MB). Driver 566.36 is older than the 570.00 the worker needs.");
let o = offer_for("amd", "32.0.21042.62", Some(&t), "windows").unwrap();
assert_eq!(o.status, "old");
assert!(o.text.starts_with("Install the AMD driver 26.9.2 (870 MB)."));
// Apple: no step; Linux and HiveOS: the package, nothing installable
let o = offer_for("apple", "", Some(&t), "macos").unwrap();
assert_eq!((o.status.as_str(), o.installable), ("none", false));
assert!(o.text.contains("no driver step"));
let o = offer_for("nvidia", "", Some(&t), "linux").unwrap();
assert_eq!(o.text, "Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).");
assert!(!o.installable);
let o = offer_for("nvidia", "566.36", Some(&t), "hive").unwrap();
assert_eq!(o.text, "HiveOS: update the driver with nvidia-driver-update 570 (the worker needs 570.00 or newer).");
let o = offer_for("intel", "", Some(&t), "macos").unwrap();
assert_eq!(o.text, "macOS: no driver step.");
// no table, or a vendor the table lacks: no offer, the row says nothing
assert!(offer_for("nvidia", "", None, "windows").is_none());
assert!(offer_for("other", "", Some(&t), "windows").is_none());
}
#[test]
fn exit_codes_and_signatures_read_as_the_vendor_means_them() {
let t = table();
let intel = t.entry("intel").unwrap();
assert_eq!(exit_meaning(0, intel), (false, "INTEL driver 32.0.101.9034 installed.".into()));
assert_eq!(exit_meaning(1014, intel).0, true);
assert_eq!(exit_meaning(14, intel).1, "INTEL driver 32.0.101.9034 installed. Restart Windows to finish.");
assert_eq!(exit_meaning(1007, intel), (false, "the INTEL installer exited with code 1007.".into()));
assert!(authenticode_verdict("status=Valid\nsubject=CN=Intel Corporation, O=Intel Corporation, S=California, C=US\n", "Intel").is_ok());
assert!(authenticode_verdict("status=NotSigned\nsubject=\n", "Intel").unwrap_err().contains("NotSigned"));
assert!(authenticode_verdict("status=Valid\nsubject=CN=Someone Else\n", "Intel").unwrap_err().contains("Someone Else"));
assert!(authenticode_verdict("", "Intel").unwrap_err().contains("unreadable"));
}
}

View file

@ -25,31 +25,6 @@ pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45];
/// A card's clock floor when the vendor reports none: this share of its maximum core clock.
pub const CLOCK_FLOOR_PCT: u32 = 45;
/// The measured efficient point per card (docs/bench-log.md: the 5090 at 308 W for 122 MH/s, the 9070 XT at 199 W for
/// 18 MH/s), the tuner's ceiling. Rule (main, 7 October 2026, after PC 2 dropped nine minutes after the tuner asked its
/// 5090 for 575 W with proving on the same card): the tuner never requests more than the card's measured efficient point
/// unless Power control is on and the user raised the cap above the default; a card not in this table is held at the
/// cap it runs at. A point below the vendor's minimum limit clamps up to that minimum (the 5090 floors at 400 W).
pub const EFFICIENT_W: &[(&str, f64)] = &[("RTX 5090", 308.0), ("RX 9070 XT", 199.0)];
/// The app's cap when the user chose none (engine.rs requested_watts); a cap above it is one the user raised.
pub const DEFAULT_CAP_PCT: u32 = 80;
/// The measured efficient watts of a card by its name, when the table has it.
pub fn efficient_watts(name: &str) -> Option<f64> {
let n = name.to_ascii_uppercase();
EFFICIENT_W.iter().find(|(k, _)| n.contains(&k.to_ascii_uppercase())).map(|(_, w)| *w)
}
/// The tuner's power ceiling in watts for a card: its measured efficient point, or the cap it runs at when the table has
/// no entry; the user's own cap instead when Power control is on and that cap is above the default (they raised it).
pub fn power_ceiling(limits: &Limits, name: &str, before: Point, power_control: bool) -> f64 {
let cap = Limits { power_ceiling_w: 0.0, ..limits.clone() }.watts_for(if before.power_pct == 0 { DEFAULT_CAP_PCT } else { before.power_pct });
let raised = power_control && before.power_pct > DEFAULT_CAP_PCT;
match efficient_watts(name) {
Some(e) if !raised => e.min(cap),
_ => cap,
}
}
/// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it).
pub const RATE_TOLERANCE_PCT: f64 = 1.0;
/// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90).
@ -80,20 +55,9 @@ pub struct Limits {
/// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none)
pub mem_default_mhz: u32,
pub mem_max_mhz: u32,
/// the tuner's ceiling (power_ceiling), 0 = none: no step asks for more watts than this
pub power_ceiling_w: f64,
}
impl Limits {
/// The ceiling as the vendor allows it: never below the card's minimum limit; 0 when there is none.
pub fn ceiling(&self) -> f64 {
if self.power_ceiling_w <= 0.0 { 0.0 } else { self.power_ceiling_w.max(self.power_min_w) }
}
/// The percent of the default that the ceiling is, for the first step of the power ladder.
pub fn ceiling_pct(&self) -> u32 {
let c = self.ceiling();
if c <= 0.0 || self.power_default_w <= 0.0 { 100 } else { (c / self.power_default_w * 100.0).round().clamp(1.0, 100.0) as u32 }
}
pub fn clock_floor(&self) -> u32 {
if self.clock_min_mhz > 0 {
self.clock_min_mhz
@ -124,10 +88,6 @@ impl Limits {
if self.power_max_w > 0.0 {
w = w.min(self.power_max_w);
}
let c = self.ceiling();
if c > 0.0 {
w = w.min(c);
}
w.round()
}
}
@ -316,9 +276,7 @@ impl Plan {
pub fn full(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan {
let mut power = Vec::new();
if limits.power_default_w > 0.0 {
// the ceiling first (its own percent), then the ladder; every step above it clamps to it and is dropped as a duplicate
let top = if limits.ceiling() > 0.0 { vec![limits.ceiling_pct()] } else { Vec::new() };
for pct in top.into_iter().chain(POWER_STEPS_PCT) {
for pct in POWER_STEPS_PCT {
let w = limits.watts_for(pct);
if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) {
continue;
@ -948,115 +906,6 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
}
/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC").
pub fn hhmm_utc(unix: f64) -> String {
let s = unix.max(0.0) as u64 % 86_400;
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
}
/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own
/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final".
pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String {
let cause = if reason.trim().is_empty() {
"under two thirds of the weight is signing".to_string()
} else if held_by.trim().is_empty() {
reason.trim().to_string()
} else {
format!("{} (held by {})", reason.trim(), held_by.trim())
};
format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix))
}
/// The node's structured finality fields on igneum_getProvingStatus (the node lane, 0.3.16): "finalityReason"
/// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null,
/// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds).
pub struct FinalityStatus {
/// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node
pub active: Option<bool>,
pub reason: String,
pub held_by: String,
pub provisional: bool,
pub paused_since: Option<f64>,
}
pub fn parse_finality_status(v: &serde_json::Value) -> Option<FinalityStatus> {
// the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so
// that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated
let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string();
let active = v.get("finalityActive").and_then(|b| b.as_bool());
let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false);
let held_by = match v.get("heldBy") {
Some(h) if h.is_object() => {
let idx = h.get("tableIndex").and_then(|x| x.as_u64()).unwrap_or(0);
let bps = h.get("stayersShareBps").and_then(|x| x.as_u64()).unwrap_or(0);
let exp = h.get("expiresDaa").and_then(|x| x.as_u64()).unwrap_or(0);
format!("weight table {idx}, {}.{:02}% still signing, expires at DAA {exp}", bps / 100, bps % 100)
}
_ => String::new(),
};
let held_by = if reason.contains("held by") { String::new() } else { held_by };
let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0);
Some(FinalityStatus { active, reason, held_by, provisional, paused_since })
}
/// The node's pause line, when it carries one: `... finality_reason=<words or "quoted words"> held_by=<id>`.
/// Returns (reason, held_by); None when the line is not one.
pub fn parse_finality_line(text: &str) -> Option<(String, String)> {
let i = text.find("finality_reason=")?;
let rest = &text[i + "finality_reason=".len()..];
let (reason, after) = if let Some(q) = rest.strip_prefix('"') {
let end = q.find('"')?;
(q[..end].to_string(), &q[end + 1..])
} else {
let end = rest.find(' ').unwrap_or(rest.len());
(rest[..end].replace('_', " "), &rest[end..])
};
let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();
Some((reason, held_by))
}
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
/// `max_age_s` old (a stale reading is not a draw).
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
}
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
pub fn wei_from_hex(hex: &str) -> Option<String> {
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
if h.is_empty() {
return Some("0".into());
}
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
}
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
}
/// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card
/// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD
/// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused.
pub fn may_set(vendor: &str, tune_control: bool, amd_ordinal: i64) -> bool {
match vendor {
"nvidia" => tune_control,
"amd" => tune_control && amd_ordinal >= 0,
_ => false,
}
}
/// (E) The row's words while a back-off from a failed tune holds: when the retry comes and why it stopped.
pub fn retry_note(remaining_s: u64, last_note: &str) -> String {
let mins = (remaining_s + 59) / 60;
let why = last_note.trim_start_matches("tuning stopped: ").trim_start_matches("tuning: ").trim();
if why.is_empty() {
format!("tuning: retry in {mins} min")
} else {
format!("tuning: retry in {mins} min ({why})")
}
}
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
@ -1093,71 +942,7 @@ mod tests {
/// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is
/// read at the first tune (3,090 MHz is the shape used here, not a measurement).
fn l5090() -> Limits {
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001, power_ceiling_w: 0.0 }
}
fn with_ceiling(name: &str, before: Point, power_control: bool) -> Limits {
let mut l = l5090();
l.power_ceiling_w = power_ceiling(&l, name, before, power_control);
l
}
fn max_watts(plan: &Plan) -> f64 {
let mut rows = Vec::new();
let mut top: f64 = 0.0;
while let Some(s) = plan.next(&rows) {
top = top.max(s.watts);
rows.push(row_at(s.point, s.watts, 100.0));
if rows.len() > 40 { break; }
}
top
}
/// PC 2, 7 October 2026: the tuner asked the 5090 for 100% (575 W) while proving ran on the same card; the PC dropped
/// nine minutes later. The known-failed shape first: without a ceiling the full plan opens at 575 W.
#[test]
fn without_a_ceiling_the_full_plan_asks_the_5090_for_575_w() {
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
assert_eq!(plan.next(&[]).unwrap().watts, 575.0);
}
#[test]
fn the_tuner_never_asks_above_the_measured_efficient_point() {
// the 5090's measured point is 308 W; the vendor's minimum is 400 W, so the ceiling clamps up to 400
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
assert_eq!(l.power_ceiling_w, 308.0);
assert_eq!(l.ceiling(), 400.0);
let plan = Plan::full(&l, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
let first = plan.next(&[]).unwrap();
assert_eq!((first.watts, first.point.power_pct, first.kind), (400.0, 70, Kind::Power), "one power step at the ceiling, with its own percent");
assert_eq!(plan.len(), 1 + 6, "one power step (every ladder step clamps to 400 W) and the six clock steps");
assert!(max_watts(&plan) <= 400.0, "no step above the ceiling");
// the confirm plan and the climb honour it too: a fleet prior at 100% is clamped
let prior = Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 };
assert!(max_watts(&Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0)) <= 400.0);
assert!(max_watts(&Plan::climb(&l, prior, Goal::Efficiency, 1.0)) <= 400.0);
}
#[test]
fn power_control_on_with_a_cap_the_user_raised_lifts_the_ceiling_to_that_cap() {
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, true);
assert_eq!(l.ceiling(), 518.0, "the user's 90% (518 W), never the vendor's 575 W");
assert!(max_watts(&Plan::full(&l, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, 1.0)) <= 518.0);
// Power control on at the default cap is not a raise: the measured point holds
let d = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: DEFAULT_CAP_PCT, mem_mhz: 0 }, true);
assert_eq!(d.ceiling(), 400.0);
// a raised cap with Power control OFF does not count
let off = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, false);
assert_eq!(off.ceiling(), 400.0);
}
#[test]
fn a_card_not_in_the_table_is_held_at_the_cap_it_runs_at() {
let l = with_ceiling("NVIDIA GeForce RTX 4070", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
assert_eq!(l.ceiling(), 403.0, "70% of 575 W, the card's own cap");
let zero = with_ceiling("NVIDIA GeForce RTX 4070", Point::default(), false);
assert_eq!(zero.ceiling(), 460.0, "no chosen cap: the app's default 80%");
assert_eq!(efficient_watts("AMD Radeon RX 9070 XT"), Some(199.0));
assert_eq!(efficient_watts("gfx1036"), None);
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 }
}
fn row_at(p: Point, watts: f64, mhs: f64) -> Row {
@ -1222,74 +1007,6 @@ mod tests {
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
}
#[test]
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
let now = 1_791_300_000.0;
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
assert_eq!(wei_from_hex("0x"), Some("0".into()));
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
assert_eq!(wei_from_hex("soon"), None);
}
#[test]
fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() {
// 6 October 2026 18:39:00Z
let since = 1_791_311_940.0;
assert_eq!(hhmm_utc(since), "18:39");
let plain = finality_paused_line(since, "", "");
assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing");
assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}");
assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)");
assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into())));
assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new())));
assert_eq!(parse_finality_line("status: accepted 3 blocks"), None);
// the structured carrier on igneum_getProvingStatus (the node lane's field names)
// the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)"
let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap();
let f = parse_finality_status(&v).unwrap();
assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0)));
assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)");
// the held-by form: the reason names the table itself, so heldBy is not repeated
let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap();
let g = parse_finality_status(&v2).unwrap();
assert!(g.provisional && g.held_by.is_empty());
assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000");
// a reason without the table words keeps the heldBy suffix
let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap();
assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000");
// live finality: an empty reason, null heldBy, null pausedSinceMs
let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap();
let l = parse_finality_status(&live).unwrap();
assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none());
// a node before 0.3.16 carries none of it
assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none());
}
#[test]
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
assert_eq!(goal_for("rate", "balanced"), Goal::MaxRate);
assert_eq!(goal_for("efficiency", "rate"), Goal::Efficiency);
assert_eq!(goal_for(" ", "efficiency"), Goal::Efficiency);
}
#[test]
fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() {
assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only");
assert!(may_set("amd", true, 1));
assert!(!may_set("amd", true, -1), "no ADLX ordinal");
assert!(!may_set("nvidia", false, -1), "Power control off: measure only");
assert!(may_set("nvidia", true, -1));
assert!(!may_set("apple", true, -1));
assert_eq!(retry_note(3599, "tuning stopped: the card refused the setting (see the log)"), "tuning: retry in 60 min (the card refused the setting (see the log))");
assert_eq!(retry_note(30, ""), "tuning: retry in 1 min");
}
#[test]
fn a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
@ -1364,7 +1081,7 @@ mod tests {
fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() {
let t0 = Instant::now();
let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) };
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0, power_ceiling_w: 0.0 };
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 };
let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0);
let mut t = t0;

File diff suppressed because it is too large Load diff

View file

@ -1,164 +0,0 @@
//! The one path to the node's execution-layer JSON-RPC (ledger N7, 7 October 2026, main's rule for 0.3.19).
//!
//! A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes
//! the record vector is asked while its exec follower holds no record: `rpc.rs` indexes `records[0]` (or slices
//! `records[1..=0]`) on an empty vector, the panic hook exits the process, and the app restarts it. PC 1 crash-looped on
//! two callers in one night (the clock sample's eth_getBlockByNumber, then the prover's igneum_getAssignedShards after the
//! node read synced seconds before a slow follower loaded). The node-side fix ships with the 0.3.20 node; a 0.3.19 app on a
//! 0.3.17 node must be safe by itself, so every exec RPC call the app makes goes through [`call`]: a method in
//! [`SAFE_ON_EMPTY`] goes out at once; any other waits until igneum_getExecStatus reports an executed tip. The unit test
//! below enumerates the callers: no other file may build an exec JSON-RPC request, and every method name in the tree must
//! be classified here, so a new caller or method cannot bypass the gate.
use serde_json::{json, Value};
use std::process::Command;
use std::time::Duration;
/// Methods that index nothing on an empty exec state (read from the 0.3.17 node's rpc.rs): safe at any time.
pub const SAFE_ON_EMPTY: &[&str] = &[
"eth_chainId", "eth_blockNumber", "eth_syncing", "igneum_getExecStatus", "igneum_getProvingStatus", "igneum_getNodeInfo",
// the engine's balance read (0.3.21): an account lookup at the latest state, nothing indexed by block number
"eth_getBalance",
];
/// Methods the app sends that resolve a block number, index or slice the record vector, or simulate at a block: held until
/// the follower holds a record. Every method literal outside this module must be in one of the two lists.
pub const GATED: &[&str] = &[
"eth_getBlockByNumber", "igneum_getAssignedShards", "igneum_getProofRecords", "igneum_getSegmentRecords", "igneum_getSegmentStatement",
"igneum_getProofBytes", "igneum_getSegmentProofBytes", "igneum_exportSegments", "igneum_submitProofRecord", "igneum_submitSegmentRecord",
"igneum_getFinalityWeights",
// 0.3.21's live page reads recent blocks by number through the same path (src/live.rs); held like the rest
"igneum_getRecentBlocks",
];
/// One JSON-RPC POST to 127.0.0.1:<evm_port> through curl (the engine carries no HTTP client); the body goes through a file
/// so a large export request is not an argument. The reply's `result` (null allowed), or the error's message.
fn post(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-rpc-{}-{}-{}.json", std::process::id(), method, crate::platform::unix_now_f() as u64));
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
let url = format!("http://127.0.0.1:{evm_port}");
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().max(1).to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &format!("@{}", tmp.display())]),
None,
timeout + Duration::from_secs(2),
);
let _ = std::fs::remove_file(&tmp);
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
// an empty or unparsable body is no answer (a stopped node's socket still accepts the connection and curl
// returns nothing inside its own limit; the probe must read that as silence, 7 October 2026)
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: the node's RPC did not answer ({e})"))?;
if let Some(err) = v.get("error") {
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
/// unreachable node reads false: the gated call waits rather than asks.
pub fn has_record(evm_port: u16) -> bool {
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
Ok(r) => status_has_record(&r),
Err(_) => false,
}
}
/// The reading of an igneum_getExecStatus result: a record is held when executedTipHash is a non-empty string.
pub fn status_has_record(result: &Value) -> bool {
result.get("executedTipHash").and_then(|h| h.as_str()).map(|h| !h.is_empty()).unwrap_or(false)
}
/// The engine's readiness probe (every 5 s): did the node's RPC answer at all, and does the follower hold a record.
/// The first is the node watchdog's sign of life; the second, with `synced`, is the workers' start gate.
pub fn probe(evm_port: u16) -> (bool, bool) {
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
Ok(r) => (true, status_has_record(&r)),
Err(e) => (!e.contains("did not answer"), false),
}
}
/// The gate: a safe method goes out; a gated one waits for a record; an unclassified method is refused (add it to a list).
pub fn call(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
if SAFE_ON_EMPTY.contains(&method) {
return post(evm_port, method, params, timeout);
}
if !GATED.contains(&method) {
return Err(format!("{method}: not classified in execrpc (safe on an empty state, or gated); add it before calling"));
}
if !has_record(evm_port) {
return Err(format!("{method}: the node's execution layer holds no record yet"));
}
post(evm_port, method, params, timeout)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_reading() {
assert!(status_has_record(&json!({"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec"})));
assert!(!status_has_record(&json!({"executedTip":"0x0","executedTipHash":null})));
assert!(!status_has_record(&json!({})));
assert!(!status_has_record(&json!({"executedTipHash":""})));
}
#[test]
fn lists_are_disjoint_and_sorted_enough() {
for m in GATED {
assert!(!SAFE_ON_EMPTY.contains(m), "{m} in both lists");
}
}
/// Ledger N7: every exec JSON-RPC request the app builds goes through this module, and every exec method name in the
/// tree is classified here. A new direct caller (a file that builds a "jsonrpc" POST) or an unclassified method fails.
#[test]
fn every_caller_goes_through_the_gate() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
// every eth_* or igneum_* identifier on a line (a hand scanner: no regex crate in this binary)
fn methods_in(line: &str) -> Vec<String> {
// ASCII-only scan over char boundaries: a token of [A-Za-z0-9_] that starts with eth_ or igneum_
let mut out = Vec::new();
let mut token = String::new();
let mut flush = |t: &mut String| {
if t.starts_with("eth_") || t.starts_with("igneum_") { out.push(t.clone()); }
t.clear();
};
for ch in line.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' { token.push(ch); } else { flush(&mut token); }
}
flush(&mut token);
out
}
let mut offenders = Vec::new();
let mut unclassified = Vec::new();
for entry in std::fs::read_dir(&src).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; }
let name = path.file_name().unwrap().to_string_lossy().to_string();
let text = std::fs::read_to_string(&path).unwrap();
// a JSON-RPC REQUEST names a method next to "jsonrpc" (replies and test fixtures carry "result" or "error");
// only this module may build one
if name != "execrpc.rs" {
for (i, line) in text.lines().enumerate() {
let l = line.replace('\\', "");
if l.contains("\"jsonrpc\"") && l.contains("\"method\"") && !l.contains("\"result\"") && !l.contains("\"error\"") {
offenders.push(format!("{name}:{}", i + 1));
}
}
}
for (i, line) in text.lines().enumerate() {
if line.trim_start().starts_with("//") { continue; }
for m in methods_in(line) {
let m = m.as_str();
// identifiers that are not RPC methods: crate and file names (igneum_app, igneum_miner, ...) carry no camel-case method part
let looks_like_method = m.contains("_get") || m.contains("_submit") || m.contains("_export") || m.contains("_estimate") || m.contains("_send") || m == "eth_chainId" || m == "eth_blockNumber" || m == "eth_syncing";
if looks_like_method && !SAFE_ON_EMPTY.contains(&m) && !GATED.contains(&m) {
unclassified.push(format!("{name}:{}: {m}", i + 1));
}
}
}
}
assert!(offenders.is_empty(), "exec JSON-RPC built outside execrpc.rs: {offenders:?}");
assert!(unclassified.is_empty(), "exec methods not classified in execrpc.rs: {unclassified:?}");
}
}

View file

@ -1,242 +0,0 @@
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
//! too, read every 30 s instead of parsed from a stdout the app does not own.
use serde_json::{json, Value};
use std::path::Path;
use std::time::Duration;
/// What the other node answered: None where it could not answer.
#[derive(Debug, Default, Clone)]
pub struct Check {
pub chain_id: Option<u64>,
pub digest: Option<String>,
pub network: Option<String>,
pub version: Option<String>,
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
pub overrides: Option<Value>,
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
pub pow_engine: Option<String>,
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
/// None on a node without the object, and the app assumes 3,600 (1 bps)
pub merge_depth: Option<u64>,
}
/// The decision for the port-collision path.
#[derive(Debug, PartialEq)]
pub struct Decision {
pub use_it: bool,
/// match | mismatch | unknown
pub verdict: &'static str,
/// the event line, in a user's words
pub line: String,
}
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
let mut checked = false;
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
checked = true;
if a != b {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
}
}
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
checked = true;
if a != b {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
}
}
if theirs.pow_engine.as_deref() == Some("stub") {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
}
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
checked = true;
for (k, v) in o {
if t.get(k) != Some(v) {
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
}
}
}
if checked {
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
} else {
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
}
}
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
pub const TAKEOVER_WAIT_S: f64 = 60.0;
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
#[derive(Debug, PartialEq)]
pub enum Step {
/// the other node still answers on the port
Stay,
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
Gone { for_s: f64 },
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
TakeOver,
}
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
if port_open {
*gone_since = None;
return Step::Stay;
}
let since = *gone_since.get_or_insert(now_s);
let for_s = now_s - since;
if for_s >= TAKEOVER_WAIT_S {
*gone_since = None;
Step::TakeOver
} else {
Step::Gone { for_s }
}
}
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null())
}
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
pub fn probe(evm_port: u16) -> Check {
let mut c = Check::default();
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
}
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
}
c
}
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
.collect();
files.sort_by(|a, b| b.0.cmp(&a.0));
let mut removed = 0;
for (_, p) in files.into_iter().skip(keep) {
if std::fs::remove_file(&p).is_ok() {
removed += 1;
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
#[test]
fn a_node_on_our_rules_is_used_and_said_so() {
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
assert!(d.use_it);
assert_eq!(d.verdict, "match");
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
}
#[test]
fn a_node_on_another_network_or_other_rules_is_refused() {
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
assert!(!d.use_it);
assert_eq!(d.verdict, "mismatch");
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
assert!(!d.use_it);
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
// a stub engine is refused whatever else matches
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
// an override we have that the other node lacks is a mismatch too
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
assert!(!d.use_it);
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
// the network name (node lane, igneum_getNodeInfo.network) must be equal
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
assert!(!d.use_it);
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
}
#[test]
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
let d = decide(26611, Some(&ours()), None, &Check::default());
assert!(d.use_it);
assert_eq!(d.verdict, "unknown");
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
// a chain id alone, matching, is a check
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
}
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
#[test]
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
let mut gone = None;
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
assert_eq!(gone, None);
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
assert_eq!(gone, None, "the memory resets once the app has its own node");
// a node that comes back inside the wait is kept, and the wait starts over
let mut gone = None;
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
}
#[test]
fn prune_keeps_the_newest_logs() {
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..6 {
let p = dir.join(format!("node-2026100{i}.log"));
std::fs::write(&p, "x").unwrap();
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
let f = std::fs::File::options().write(true).open(&p).unwrap();
f.set_modified(t).unwrap();
}
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
assert_eq!(prune_logs(&dir, "node-", 4), 2);
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
left.sort();
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -1,487 +0,0 @@
//! Ember Heat (mission item 7, 7 October 2026, docs/plans/ember-heat.md): the card is a heater that also earns. Heat mode
//! holds a room temperature, or a schedule of them, and the hash follows the duty cycle: the cards mine for a share of
//! every ten-minute period and rest for the rest of it. The chain sees a miner with a schedule, nothing else.
//!
//! The temperature source is what the machine has: a reading the miner types from their own thermometer (fresh for
//! two hours), else the card's own sensor once the card has rested long enough to cool to the room plus an idle
//! offset (default 8 degrees, approximate; learned from a typed reading taken while the card rests). No hardware
//! the app does not have. Without any reading the loop heats 70 percent of every period and says so.
//!
//! The loop is proportional plus integral, decided once per period on the reading at the period's start: the
//! integral carries the steady-state share of heat the room needs, the proportional term pulls it back when the
//! room drifts. The engine (src/engine.rs, `tick_heat`) owns the processes: it stops the miners for a rest and
//! re-arms them for the heating slice; nothing here touches a card. The log carries one `HEAT` line every 30 s and
//! tools/heat-gate.mjs reads it for the PC 1 gate (held within 1 degree for 4 hours, hash following the duty).
/// One period: the heating slice first, the rest after it.
pub const PERIOD_S: f64 = 600.0;
/// Duty per degree under the set point (2 degrees under = a full period of heat).
pub const KP: f64 = 0.5;
/// Duty per degree per period added to the integral (the steady-state share).
pub const KI: f64 = 0.05;
/// The card sensor stands for the room only after this long at rest (the die cools toward the room plus the idle
/// offset; what is left of the cooling tail is taken off by its slope, COOL_TAU_S).
pub const SETTLE_S: f64 = 180.0;
/// The cooling tail of a stopped card as one time constant, seconds, approximate: the estimate adds tau times the
/// (negative) slope of the sensor, which is exact for a single exponential and partial otherwise.
pub const COOL_TAU_S: f64 = 60.0;
/// A typed room reading is the room for this long.
pub const TYPED_FRESH_S: f64 = 7200.0;
/// An idle card's sensor above the room, degrees, approximate, until a typed reading teaches the real offset.
pub const OFFSET_DEFAULT_C: f64 = 8.0;
/// The stated error of the card-sensor estimate, degrees.
pub const OFFSET_ERROR_C: f64 = 3.0;
/// With the card sensor as the only source, every period keeps a rest long enough to read the room.
pub const CARD_DUTY_MAX: f64 = 1.0 - SETTLE_S / PERIOD_S;
/// With no reading at all: heat this share of every period (the rest long enough for the card to read the room) and say why.
pub const FIND_DUTY: f64 = CARD_DUTY_MAX;
/// The set point the app accepts, degrees.
pub const SET_MIN_C: f64 = 5.0;
pub const SET_MAX_C: f64 = 30.0;
/// The log line and the gate tool's sample spacing.
pub const LOG_EVERY_S: f64 = 30.0;
/// One entry of a schedule: from this minute of the day the set point is `set_c`, until the next entry.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Slot {
pub minute: u32,
pub set_c: f64,
}
/// "06:00 20, 22:00 16" to slots, sorted by minute. Empty text = no schedule (the one set point all day).
pub fn parse_schedule(text: &str) -> Result<Vec<Slot>, String> {
let mut out = Vec::new();
for part in text.split(|c| c == ',' || c == ';' || c == '\n') {
let part = part.trim();
if part.is_empty() {
continue;
}
let mut it = part.split_whitespace();
let (Some(time), Some(temp)) = (it.next(), it.next()) else {
return Err(format!("\"{part}\": write a time and a temperature, for example 06:00 20"));
};
let (h, m) = time.split_once(':').ok_or_else(|| format!("\"{time}\": a time is HH:MM"))?;
let h: u32 = h.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
let m: u32 = m.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
if h > 23 || m > 59 {
return Err(format!("\"{time}\": a time is HH:MM, 00:00 to 23:59"));
}
let set_c: f64 = temp.trim_end_matches("°C").trim_end_matches('C').parse().map_err(|_| format!("\"{temp}\": a temperature is a number of degrees"))?;
if !(SET_MIN_C..=SET_MAX_C).contains(&set_c) {
return Err(format!("{set_c} degrees: the set point is {SET_MIN_C:.0} to {SET_MAX_C:.0}"));
}
out.push(Slot { minute: h * 60 + m, set_c });
}
out.sort_by_key(|s| s.minute);
out.dedup_by_key(|s| s.minute);
Ok(out)
}
/// Slots back to the text the settings page shows.
pub fn schedule_text(slots: &[Slot]) -> String {
slots.iter().map(|s| format!("{:02}:{:02} {}", s.minute / 60, s.minute % 60, trim_c(s.set_c))).collect::<Vec<_>>().join(", ")
}
fn trim_c(c: f64) -> String {
if (c - c.round()).abs() < 0.05 { format!("{:.0}", c) } else { format!("{:.1}", c) }
}
/// The set point in force at a minute of the day: the latest slot at or before it; before the first slot, the last
/// slot of the day (the schedule wraps at midnight). No slots: the default.
pub fn set_point_at(default_c: f64, slots: &[Slot], minute_of_day: u32) -> f64 {
if slots.is_empty() {
return default_c;
}
slots.iter().rev().find(|s| s.minute <= minute_of_day).or_else(|| slots.last()).map(|s| s.set_c).unwrap_or(default_c)
}
/// The minute of the day in the schedule's own clock: unix seconds plus the window's offset east of UTC in minutes.
pub fn minute_of_day(unix: f64, tz_east_min: i32) -> u32 {
let local = unix as i64 + tz_east_min as i64 * 60;
((local.rem_euclid(86_400)) / 60) as u32
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Typed,
Card,
None,
}
impl Source {
pub fn name(&self) -> &'static str {
match self {
Source::Typed => "typed",
Source::Card => "card",
Source::None => "none",
}
}
}
/// What the loop knows about the room right now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Reading {
pub room_c: f64,
pub source: Source,
/// the stated error of the estimate, degrees (0 for a typed reading: the thermometer is the miner's)
pub error_c: f64,
/// how old the reading is, seconds
pub age_s: f64,
}
impl Reading {
pub fn none() -> Reading {
Reading { room_c: 0.0, source: Source::None, error_c: 0.0, age_s: 0.0 }
}
}
/// The room estimate: a typed reading while it is fresh, else the coolest card's sensor minus the idle offset once
/// the cards have rested SETTLE_S (the cooling tail still in the sensor taken off by its slope), else nothing.
/// `typed` = (degrees, unix s typed); `card_c` = 0 when no card reports; `card_slope` = degrees per second over the
/// last half minute (negative while cooling; a rising sensor is not corrected).
pub fn room(now: f64, typed: Option<(f64, f64)>, card_c: f64, card_slope: f64, rest_for_s: f64, offset_c: f64) -> Reading {
if let Some((c, at)) = typed {
if c > -50.0 && at > 0.0 && now - at < TYPED_FRESH_S {
return Reading { room_c: c, source: Source::Typed, error_c: 0.0, age_s: (now - at).max(0.0) };
}
}
if card_c > 0.0 && rest_for_s >= SETTLE_S {
let off = if offset_c > 0.0 { offset_c } else { OFFSET_DEFAULT_C };
let tail = COOL_TAU_S * card_slope.min(0.0);
return Reading { room_c: card_c + tail - off, source: Source::Card, error_c: OFFSET_ERROR_C, age_s: 0.0 };
}
Reading::none()
}
/// The slope of sensor samples (unix s, degrees) at the END of the window, degrees per second: the least-squares
/// line (its slope belongs to the window's middle) brought forward by the exponential tail's own decay over half
/// the window. 0 with fewer than two samples.
pub fn slope_of<I: Iterator<Item = (f64, f64)>>(samples: I) -> f64 {
let v: Vec<(f64, f64)> = samples.collect();
if v.len() < 2 {
return 0.0;
}
let n = v.len() as f64;
let (mt, mc) = (v.iter().map(|s| s.0).sum::<f64>() / n, v.iter().map(|s| s.1).sum::<f64>() / n);
let (mut num, mut den) = (0.0, 0.0);
for (t, c) in &v {
num += (t - mt) * (c - mc);
den += (t - mt) * (t - mt);
}
if den <= 0.0 {
return 0.0;
}
let span = v.iter().map(|s| s.0).fold(f64::MIN, f64::max) - v.iter().map(|s| s.0).fold(f64::MAX, f64::min);
num / den * (-(span / 2.0) / COOL_TAU_S).exp()
}
/// A typed reading taken while the cards have rested teaches the idle offset (card minus room, 0 to 20 degrees).
pub fn learned_offset(card_c: f64, rest_for_s: f64, typed_c: f64) -> Option<f64> {
if card_c <= 0.0 || rest_for_s < SETTLE_S {
return None;
}
Some((card_c - typed_c).clamp(0.0, 20.0))
}
/// The loop's memory between ticks.
#[derive(Clone, Debug, PartialEq)]
pub struct Controller {
/// the steady-state share of heat the room needs, 0 to 1 (starts at a half)
pub integral: f64,
pub period_start: f64,
/// this period's duty, 0 to 1
pub duty: f64,
/// this period's heating slice, seconds
pub on_s: f64,
started: bool,
}
/// What the engine does this tick.
#[derive(Clone, Debug, PartialEq)]
pub struct Decision {
pub heating: bool,
pub duty: f64,
pub set_c: f64,
pub reading: Reading,
/// seconds until the slice changes (heating to rest, or the next period)
pub until_s: f64,
/// a new period began on this tick
pub new_period: bool,
}
impl Default for Controller {
fn default() -> Controller {
Controller::new()
}
}
impl Controller {
pub fn new() -> Controller {
Controller { integral: 0.5, period_start: 0.0, duty: 0.0, on_s: 0.0, started: false }
}
/// The duty a reading asks for, and the integral it leaves: the proportional term on the error, the integral on
/// the steady-state share; with no reading the find share and an untouched integral.
pub fn duty_for(&mut self, set_c: f64, reading: &Reading) -> f64 {
match reading.source {
Source::None => FIND_DUTY,
src => {
let err = set_c - reading.room_c;
self.integral = (self.integral + KI * err).clamp(0.0, 1.0);
let d = (KP * err + self.integral).clamp(0.0, 1.0);
if src == Source::Card { d.min(CARD_DUTY_MAX) } else { d }
}
}
}
/// One tick. A new period is decided on the reading at its start; inside a period only the clock moves.
pub fn step(&mut self, now: f64, set_c: f64, reading: Reading) -> Decision {
let mut new_period = false;
if !self.started || now >= self.period_start + PERIOD_S {
new_period = true;
self.started = true;
self.period_start = now;
self.duty = self.duty_for(set_c, &reading);
self.on_s = if self.duty >= 0.999 { PERIOD_S } else if self.duty <= 0.001 { 0.0 } else { (self.duty * PERIOD_S).round() };
}
let heating = now < self.period_start + self.on_s;
let until_s = if heating { self.period_start + self.on_s - now } else { self.period_start + PERIOD_S - now };
Decision { heating, duty: self.duty, set_c, reading, until_s: until_s.max(0.0), new_period }
}
/// Heat mode switched off or on again: the next tick starts a fresh period; the learned share stays.
pub fn reset(&mut self) {
self.started = false;
}
}
/// The `HEAT` log line the gate tool reads (tools/heat-gate.mjs): one every LOG_EVERY_S while heat mode is on.
pub fn log_line(unix: f64, phase: &str, set_c: f64, reading: &Reading, duty: f64, heat_w: f64, hash_mhs: f64, until_s: f64) -> String {
format!(
"HEAT t={} phase={} set={:.1} room={} src={} duty={:.2} heat_w={:.0} hash={:.1} until={:.0}",
unix as u64,
phase,
set_c,
if reading.source == Source::None { "-".to_string() } else { format!("{:.1}", reading.room_c) },
reading.source.name(),
duty,
heat_w,
hash_mhs,
until_s
)
}
/// The one line under the switch: what the loop is doing, in the miner's words.
pub fn words(on: bool, phase: &str, set_c: f64, reading: &Reading, duty: f64, until_s: f64) -> String {
if !on {
return "Off. The cards mine whenever the node is synced.".into();
}
let room = match reading.source {
Source::Typed => format!("room {:.1} °C from your reading {}", reading.room_c, if reading.age_s < 90.0 { "just now".to_string() } else { format!("{} min ago", (reading.age_s / 60.0).round() as u64) }),
Source::Card => format!("room about {:.0} °C from the card's sensor (within {:.0} degrees)", reading.room_c, reading.error_c),
Source::None => "no room reading yet: type one, or the card reads it after 2 minutes of rest".into(),
};
let doing = match phase {
"heating" => format!("heating, {} to go", mins(until_s)),
"resting" => format!("resting, heats again in {}", mins(until_s)),
"paused" => "mining is paused, so nothing heats".into(),
"waiting" => "waiting for the node".into(),
_ => phase.to_string(),
};
format!("Holding {:.1} °C: {}. {}. Heat {:.0}% of the time.", set_c, room, doing, duty * 100.0)
}
fn mins(s: f64) -> String {
let m = (s / 60.0).round() as u64;
if s < 45.0 { "under a minute".into() } else if m <= 1 { "a minute".into() } else { format!("{m} min") }
}
#[cfg(test)]
mod tests {
use super::*;
/// A room as a first-order store: C joules per degree, K watts per degree of loss to the outside, P_full watts
/// from the cards while they heat. The card's die sits offset_c above the room at rest and rise_c more while
/// mining, settling with a one-minute time constant.
struct Sim {
room_c: f64,
out_c: f64,
c_j_per_k: f64,
k_w_per_k: f64,
p_full_w: f64,
card_rise_c: f64,
offset_c: f64,
rise_now: f64,
}
impl Sim {
fn new(room_c: f64) -> Sim {
Sim { room_c, out_c: 10.0, c_j_per_k: 400_000.0, k_w_per_k: 20.0, p_full_w: 300.0, card_rise_c: 40.0, offset_c: 8.0, rise_now: 0.0 }
}
fn tick(&mut self, heating: bool, dt: f64) {
let p = if heating { self.p_full_w } else { 0.0 };
self.room_c += (p - self.k_w_per_k * (self.room_c - self.out_c)) * dt / self.c_j_per_k;
let target = if heating { self.card_rise_c } else { 0.0 };
self.rise_now += (target - self.rise_now) * (1.0 - (-dt / 60.0).exp());
}
fn card_c(&self) -> f64 {
self.room_c + self.offset_c + self.rise_now
}
}
/// Four hours after the first hour, the room stays within a degree of the set point and the hash is on exactly
/// while the slice heats: the gate's shape (docs/plans/ember-heat.md), on a model room with the typed reading
/// refreshed every half hour, as a miner with a thermometer on the desk would do.
#[test]
fn a_typed_reading_holds_the_room_within_a_degree_for_four_hours() {
let mut sim = Sim::new(19.0);
let mut ctl = Controller::new();
let set = 20.0;
let t0 = 1000.0;
let mut t = t0;
let mut typed = (sim.room_c, t0);
let mut worst: f64 = 0.0;
let mut heating_s = 0.0;
let mut hash_on_s = 0.0;
while t < t0 + 5.0 * 3600.0 {
if t - typed.1 >= 1800.0 {
typed = (sim.room_c, t);
}
let r = room(t, Some(typed), sim.card_c(), 0.0, 0.0, 0.0);
assert_eq!(r.source, Source::Typed);
let d = ctl.step(t, set, r);
let hash = if d.heating { 100.0 } else { 0.0 };
sim.tick(d.heating, 1.0);
if t >= t0 + 3600.0 {
worst = worst.max((sim.room_c - set).abs());
if d.heating { heating_s += 1.0; }
if hash > 0.0 { hash_on_s += 1.0; }
}
t += 1.0;
}
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
assert_eq!(heating_s, hash_on_s, "the hash was on exactly while the slice heated");
// the room needs 20 W per degree over 10 degrees = 200 W of 300: a duty near two thirds
assert!((ctl.integral - 0.667).abs() < 0.15, "the integral found the steady share: {:.2}", ctl.integral);
assert!(heating_s / (4.0 * 3600.0) > 0.5 && heating_s / (4.0 * 3600.0) < 0.85, "the hash followed the duty: {:.2}", heating_s / (4.0 * 3600.0));
}
/// The card's own sensor as the only source: every period keeps three minutes of rest, the reading is taken after
/// that rest with the cooling tail taken off, and the room still holds within a degree over the four hours after
/// the first. The sensor's slope comes from the last half minute of samples, as the engine takes it.
#[test]
fn the_card_sensor_alone_holds_the_room_within_a_degree() {
let mut sim = Sim::new(19.0);
let mut ctl = Controller::new();
let set = 20.0;
let mut t = 0.0;
let mut rest_since: Option<f64> = None;
let mut worst: f64 = 0.0;
let mut estimate_err: f64 = 0.0;
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
while t < 5.0 * 3600.0 {
let rest_for = rest_since.map(|s| t - s).unwrap_or(0.0);
samples.push_back((t, sim.card_c()));
while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); }
let slope = slope_of(samples.iter().copied());
let r = room(t, None, sim.card_c(), slope, rest_for, 0.0);
let d = ctl.step(t, set, r);
if d.new_period {
assert!(d.duty <= CARD_DUTY_MAX + 1e-9, "a card-sensed period keeps its rest: {}", d.duty);
if r.source == Source::Card {
estimate_err = estimate_err.max((r.room_c - sim.room_c).abs());
}
}
sim.tick(d.heating, 1.0);
rest_since = if d.heating { None } else { Some(rest_since.unwrap_or(t)) };
if t >= 3600.0 {
worst = worst.max((sim.room_c - set).abs());
}
t += 1.0;
}
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
assert!(estimate_err < OFFSET_ERROR_C, "the card estimate stayed inside its stated error: {estimate_err:.2}");
}
/// Without the slope correction the sensor still carries the cooling tail at the end of the rest and the
/// estimate reads high by more than the stated error: the correction is what makes the card path honest.
#[test]
fn the_cooling_tail_is_taken_off_by_the_slope() {
let mut sim = Sim::new(19.0);
for _ in 0..600 { sim.tick(true, 1.0); }
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
let mut t = 0.0;
for _ in 0..(SETTLE_S as usize) { sim.tick(false, 1.0); t += 1.0; samples.push_back((t, sim.card_c())); while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); } }
let slope = slope_of(samples.iter().copied());
let raw = room(t, None, sim.card_c(), 0.0, SETTLE_S, 8.0);
let fixed = room(t, None, sim.card_c(), slope, SETTLE_S, 8.0);
assert!(raw.room_c - sim.room_c > 1.0, "the raw sensor still reads the tail: {:.2} over", raw.room_c - sim.room_c);
assert!((fixed.room_c - sim.room_c).abs() < 0.5, "the corrected estimate is the room: {:.2} off", fixed.room_c - sim.room_c);
}
#[test]
fn without_a_reading_the_loop_heats_the_find_share_and_says_so() {
let mut ctl = Controller::new();
let d = ctl.step(1000.0, 20.0, Reading::none());
assert_eq!(d.duty, FIND_DUTY);
assert!(d.heating);
assert_eq!(ctl.integral, 0.5, "no reading leaves the integral alone");
assert!(words(true, "heating", 20.0, &d.reading, d.duty, d.until_s).contains("no room reading yet"));
// the slice ends at 80% of the period, the rest runs to the period's end
let d2 = ctl.step(1000.0 + FIND_DUTY * PERIOD_S + 1.0, 20.0, Reading::none());
assert!(!d2.heating);
assert!(!d2.new_period);
assert!(d2.until_s <= (1.0 - FIND_DUTY) * PERIOD_S);
}
#[test]
fn the_room_source_order_is_typed_then_card_then_none() {
let typed = Some((19.5, 1000.0));
assert_eq!(room(1500.0, typed, 30.0, 0.0, 300.0, 0.0).source, Source::Typed);
let stale = room(1000.0 + TYPED_FRESH_S + 1.0, typed, 30.0, 0.0, 300.0, 0.0);
assert_eq!(stale.source, Source::Card);
assert!((stale.room_c - 22.0).abs() < 1e-9, "card 30 minus the default offset 8");
assert_eq!(room(1500.0, None, 30.0, 0.0, 60.0, 0.0).source, Source::None, "a card still warm from mining is not the room");
assert_eq!(room(1500.0, None, 0.0, 0.0, 600.0, 0.0).source, Source::None, "no card sensor at all");
assert!((room(1500.0, None, 30.0, 0.02, 300.0, 0.0).room_c - 22.0).abs() < 1e-9, "a rising sensor is not corrected");
let learned = room(1500.0, None, 30.0, 0.0, 300.0, 11.0);
assert!((learned.room_c - 19.0).abs() < 1e-9, "a learned offset replaces the default");
assert_eq!(learned_offset(30.0, 300.0, 19.0), Some(11.0));
assert_eq!(learned_offset(30.0, 30.0, 19.0), None, "a card that has not rested teaches nothing");
}
#[test]
fn a_schedule_parses_sorts_and_wraps_at_midnight() {
let s = parse_schedule("22:00 16, 06:00 20").unwrap();
assert_eq!(s, vec![Slot { minute: 360, set_c: 20.0 }, Slot { minute: 1320, set_c: 16.0 }]);
assert_eq!(schedule_text(&s), "06:00 20, 22:00 16");
assert_eq!(set_point_at(19.0, &s, 7 * 60), 20.0);
assert_eq!(set_point_at(19.0, &s, 23 * 60), 16.0);
assert_eq!(set_point_at(19.0, &s, 2 * 60), 16.0, "before the first slot the last one of the day holds");
assert_eq!(set_point_at(19.0, &[], 2 * 60), 19.0);
assert!(parse_schedule("06:00").is_err());
assert!(parse_schedule("25:00 20").is_err());
assert!(parse_schedule("06:00 40").is_err(), "the set point is 5 to 30");
assert_eq!(parse_schedule("").unwrap(), vec![]);
assert_eq!(minute_of_day(0.0, 60), 60, "one hour east of UTC at midnight UTC is 01:00");
assert_eq!(minute_of_day(0.0, -300), 19 * 60, "five hours west wraps to the evening before");
}
#[test]
fn the_log_line_carries_what_the_gate_reads() {
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 30.0 };
let l = log_line(1_759_800_000.0, "heating", 20.0, &r, 0.6, 180.0, 74.2, 312.0);
assert_eq!(l, "HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312");
let l2 = log_line(1.0, "resting", 20.0, &Reading::none(), 0.8, 0.0, 0.0, 10.0);
assert!(l2.contains("room=- src=none"));
}
#[test]
fn the_words_name_the_source_the_slice_and_the_share() {
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 2400.0 };
assert_eq!(words(true, "heating", 20.0, &r, 0.6, 312.0), "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.");
let c = Reading { room_c: 19.0, source: Source::Card, error_c: 3.0, age_s: 0.0 };
assert_eq!(words(true, "resting", 20.0, &c, 0.5, 100.0), "Holding 20.0 °C: room about 19 °C from the card's sensor (within 3 degrees). resting, heats again in 2 min. Heat 50% of the time.");
assert!(words(false, "off", 20.0, &c, 0.0, 0.0).starts_with("Off."));
}
}

View file

@ -167,10 +167,6 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
c.tune_source = p.sweep_source.clone();
c.tune_goal = p.tune_goal.clone();
c.tune_before_watts = p.sweep_before_watts;
c.tune_before_mhs = p.sweep_before_mhs;
c.tune_floor = p.sweep_floor;
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
}

View file

@ -1,5 +1,5 @@
//! The `build` job (the model is src/jobs.rs, the runner src/jobrun.rs): a Windows PC builds the node and the app
//! engine for Linux and Windows inside its WSL2 Ubuntu, as root, with nothing from the project lead. the project lead's ask, 4 October 2026
//! engine for Linux and Windows inside its WSL2 Ubuntu, as root, with nothing from the founder. The founder's ask, 4 October 2026
//! evening ("efficiency"): every Windows build went through a GitHub runner at 15 to 25 minutes a round and every
//! Linux binary was cross-compiled on the Mac under the build lock; the two RTX 5090 PCs sit idle on the CPU side.
//!

View file

@ -32,7 +32,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// The safety-net poll. Before 0.3.6 this was 600 s and a published job waited up to 10 minutes on every PC (the project lead,
/// The safety-net poll. Before 0.3.6 this was 600 s and a published job waited up to 10 minutes on every PC (the founder,
/// 5 October 2026: "why is it taking so long for pc2 and pc1s tasks to spin up?"); the wake below makes it seconds.
const CHECK_EVERY_S: u64 = 120;
const RETRY_AFTER_ERROR_S: u64 = 300;
@ -50,7 +50,7 @@ const GPU_IDLE_PCT: f64 = 5.0;
const GPU_IDLE_WAIT_S: u64 = 180;
const DEFAULT_DISTRO: &str = "Ubuntu-24.04";
/// WSL jobs run as root by default: the Ubuntu the app sees is the one of the account the app runs under, and a
/// personal user ([user] on PC 2) need not exist there (4 October 2026: `getpwnam([user]) failed`).
/// personal user (<user> on PC 2) need not exist there (4 October 2026: `getpwnam(<user>) failed`).
const DEFAULT_WSL_USER: &str = "root";
const DEFAULT_FIXTURES: &[&str] = &["block-338-shard1", "block-341-shards2", "block-344-shards4"];
const HISTORY_SHOWN: usize = 20;
@ -87,9 +87,6 @@ pub enum Action {
StopMiners(String),
RestartMiners,
RestartNode,
/// The signed `cards` kind: apply these per-card choices through the app's own card path (persisted), then
/// call `cards_applied` with the read-back.
ApplyCards(Vec<crate::engine::CardChoice>),
/// The relaunch helper was started; the engine quits now.
RestartApp,
UpdateNow,
@ -140,12 +137,6 @@ pub fn key_without_index(k: &str) -> String {
}
}
/// The restore list of a `cards_leave_off` job: the same entries (identities and cap kept) with enabled=false, so the
/// runner's restore on any exit leaves the card off, persisted by the app's own card path.
pub fn leave_off(restore: Vec<crate::engine::CardChoice>) -> Vec<crate::engine::CardChoice> {
restore.into_iter().map(|mut c| { c.enabled = false; c }).collect()
}
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
pub type LiveCard = (String, bool, u32, u32, bool);
@ -248,15 +239,11 @@ impl Jobs {
active: None,
needs_logged: std::collections::HashSet::new(),
fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX),
wake: Arc::new(WakeCtl::new(allowed, &shared.runtime.id8())),
wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }),
wake_pending: false,
last_published: String::new(),
};
j.publish(shared);
// the ping names the last job this machine ran, across restarts (the newest record in jobs-state.json)
if let Some(last) = j.ledger.records.values().max_by_key(|r| r.started_at) {
j.wake.set_last_job(&last.id);
}
if !j.url.is_empty() {
let wake_url = wake_url_of(std::env::var("IGNEUM_APP_JOBS_WAKE_URL").ok());
if !wake_url.is_empty() {
@ -572,29 +559,11 @@ impl Jobs {
return None;
}
shared.event("info", &format!("job {} ({}) starts: {}", job.id, job.kind, job.label()));
self.wake.set_last_job(&job.id);
let ctl = Arc::new(Ctl::default());
let needs_miners_stopped = job.kind == "shard-benchmark" || (job.kind == "run" && job.bool_param("stop_miners_first"));
let cards_off: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, waiting_for_cards: !cards_off.is_empty(), cards: CardHold::default(), ctl: ctl.clone() });
match job.kind.as_str() {
"cards" => {
// engine-side: refused at once for a card this machine does not have; else applied through the
// app's own card path and reported with the read-back (cards_applied)
let live: Vec<(String, bool, u32)> = shared.state.lock().unwrap().mining.cards.iter().filter(|c| c.present()).map(|c| (c.key.clone(), c.enabled, c.identities)).collect();
let (choices, missing) = cards_job_choices(&job, &live);
let sink = Sink::new(shared, &job, &self.dir);
if !missing.is_empty() {
let summary = format!("refused: this machine has no card {}", missing.join(", "));
sink.line(&format!("cards: {summary}; present: {}", live.iter().map(|c| c.0.as_str()).collect::<Vec<_>>().join(", ")));
let uploaded = report(shared, &job, &sink, "failed", 2, now, crate::platform::unix_now(), &summary, json!({ "present": live.iter().map(|c| c.0.clone()).collect::<Vec<_>>() }));
let o = Outcome { status: "failed".into(), exit: 2, summary, results: vec![], uploaded };
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
return None;
}
sink.line(&format!("cards: applying {}", choices.iter().map(|c| format!("{} enabled={} identities={}{}", c.key, c.enabled, c.identities, c.power_pct.map(|p| format!(" power_pct={p}")).unwrap_or_default())).collect::<Vec<_>>().join("; ")));
return Some(Action::ApplyCards(choices));
}
"restart" | "update-now" => {
// engine-side; the report says what was asked and the ledger closes at once
let what = job.str_param("what");
@ -628,35 +597,6 @@ impl Jobs {
}
}
/// The running job's id, for the hold rule (a hold belongs to the job that took it).
pub fn active_id(&self) -> Option<String> {
self.active.as_ref().map(|a| a.job.id.clone())
}
/// The running job's cap in minutes (the hold's hard cap).
pub fn active_cap_minutes(&self) -> u64 {
self.active.as_ref().map(|a| a.job.timeout_minutes()).unwrap_or(60)
}
/// Called by the engine once a `cards` job's choices are applied: the report carries the read-back of every
/// card named (what the engine holds now) and the job closes done.
pub fn cards_applied(&mut self, shared: &Arc<Shared>, readback: Vec<(String, bool, u32, u32)>) -> Option<Action> {
let Some(a) = self.active.as_ref() else { return None };
if a.job.kind != "cards" {
return None;
}
let (job, started) = (a.job.clone(), a.started_unix);
let sink = Sink::new(shared, &job, &self.dir);
let lines: Vec<String> = readback.iter().map(|(k, e, i, p)| format!("{k} enabled={e} identities={i} power_pct={p}")).collect();
for l in &lines {
sink.line(&format!("cards: read back {l}"));
}
let summary = format!("cards applied: {}", lines.join("; "));
let uploaded = report(shared, &job, &sink, "done", 0, started, crate::platform::unix_now(), &summary, json!({ "cards": readback.iter().map(|(k, e, i, p)| json!({ "key": k, "enabled": e, "identities": i, "power_pct": p })).collect::<Vec<_>>() }));
let o = Outcome { status: "done".into(), exit: 0, summary, results: lines, uploaded };
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o })
}
/// Called by the engine once a job's `--cards-off` cards are switched off; `restore` puts them back exactly.
/// Next: the miners, if the job asked for them too, else the script.
pub fn cards_off_done(&mut self, shared: &Arc<Shared>, restore: Vec<crate::engine::CardChoice>) -> Option<Action> {
@ -665,10 +605,6 @@ impl Jobs {
return None;
}
a.waiting_for_cards = false;
// cards_leave_off (7 October 2026, intel-arc): the hold still ends through the app's own card path on any
// exit, but with enabled=false, so the card stays off and persisted (settings.json) after the job: the way
// to hold a card out of mining past a job without a script touching api/cards (the 6 October rule).
let restore = if a.job.bool_param("cards_leave_off") { leave_off(restore) } else { restore };
a.cards = CardHold::hold(restore);
if a.waiting_for_miners {
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
@ -694,11 +630,7 @@ impl Jobs {
if held.is_empty() {
sink.line(&format!("cards-off: {} asked, no present card matched (nothing switched; the script's card may be loaded)", asked.join(",")));
} else {
if job.bool_param("cards_leave_off") {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards LEFT OFF: {} by the runner on any exit (done, failed, timeout, aborted, app quit), enabled=false with their own identities and cap, persisted by the app (cards_leave_off)", held.keys(), held.keys()));
} else {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
}
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
}
}
let ctx = account_context();
@ -832,43 +764,9 @@ fn upload_file(shared: &Arc<Shared>, job: &Job, path: &Path, label_prefix: &str)
// busy-loops: a reply that came back early is followed by the rest of a 10 s floor, a failing endpoint backs off
// 5, 15, then 60 s, and an empty stamp (nothing published yet) waits a full hold.
/// Shared with the waker thread: it polls only while remote jobs are allowed. The ping (MF-11, 0.3.21): every wake
/// request names this machine (its id8, no secret), the app version and the last job it ran, so the relay can show a
/// machine whose job channel has gone quiet ("silent since <time>, last job <name>") the moment 15 minutes pass with no
/// poll; a dead app polls nothing, and before this nothing on the console said so until the next upload gap was noticed.
/// Shared with the waker thread: it polls only while remote jobs are allowed.
pub struct WakeCtl {
on: AtomicBool,
machine: String,
last_job: Mutex<String>,
}
impl WakeCtl {
pub fn new(on: bool, machine: &str) -> WakeCtl {
WakeCtl { on: AtomicBool::new(on), machine: machine.to_string(), last_job: Mutex::new(String::new()) }
}
pub fn set_last_job(&self, id: &str) {
*self.last_job.lock().unwrap() = id.to_string();
}
/// The query fragment of the ping: machine=<id8>&v=<version>[&job=<id>]; values are the app's own, URL-safe by shape.
pub fn ping(&self) -> String {
ping_query(&self.machine, crate::engine::VERSION, &self.last_job.lock().unwrap())
}
}
/// machine and job ids as the relay reads them: id8 is 8 hex; a job id is the publisher's `[\w.-]` name; anything else
/// is dropped from the query rather than escaped (the relay clips and validates on its side too).
pub fn ping_query(machine: &str, version: &str, last_job: &str) -> String {
let safe = |s: &str, max: usize| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')).take(max).collect() };
let m = safe(machine, 16);
if m.is_empty() {
return String::new();
}
let mut q = format!("machine={m}&v={}", safe(version, 32));
let j = safe(last_job, 80);
if !j.is_empty() {
q.push_str(&format!("&job={j}"));
}
q
}
/// The stamp logic, free of I/O for the tests.
@ -931,24 +829,17 @@ fn wake_url_of(env: Option<String>) -> String {
}
}
fn wake_query(url: &str, since: &str, ping: &str) -> String {
let mut out = url.to_string();
let mut sep = if url.contains('?') { '&' } else { '?' };
if !since.is_empty() {
out.push(sep);
out.push_str(&format!("since={since}"));
sep = '&';
fn wake_query(url: &str, since: &str) -> String {
if since.is_empty() {
url.to_string()
} else {
format!("{url}{}since={since}", if url.contains('?') { '&' } else { '?' })
}
if !ping.is_empty() {
out.push(sep);
out.push_str(ping);
}
out
}
/// One long-poll. Ok carries the relay's stamp (empty when it holds none).
fn wake_request(url: &str, since: &str, ping: &str) -> Result<String, String> {
let full = wake_query(url, since, ping);
fn wake_request(url: &str, since: &str) -> Result<String, String> {
let full = wake_query(url, since);
let max_time = (WAKE_HOLD_S + 13).to_string();
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", &max_time, &full]), Duration::from_secs(WAKE_HOLD_S + 20));
if code != Some(0) {
@ -969,7 +860,7 @@ fn wake_loop(shared: Arc<Shared>, url: String, ctl: Arc<WakeCtl>) {
continue;
}
let t0 = Instant::now();
match wake_request(&url, &st.stamp, &ctl.ping()) {
match wake_request(&url, &st.stamp) {
Ok(stamp) => {
let (r, recovered) = st.reply(&stamp);
if recovered {
@ -1425,50 +1316,6 @@ fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
)
}
/// The choices a `cards` job asks for, matched to the machine's present cards (key exact, or the key with the device
/// index left out: `vendor::name`); missing keys are returned for the refusal. A field the job leaves out keeps the
/// card's current value.
pub fn cards_job_choices(job: &Job, live: &[(String, bool, u32)]) -> (Vec<crate::engine::CardChoice>, Vec<String>) {
let mut out = Vec::new();
let mut missing = Vec::new();
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
for c in list {
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
let found = live.iter().find(|(k, _, _)| *k == key).or_else(|| {
let parts: Vec<&str> = key.splitn(3, ':').collect();
live.iter().find(|(k, _, _)| { let lp: Vec<&str> = k.splitn(3, ':').collect(); parts.len() == 3 && lp.len() == 3 && lp[0] == parts[0] && lp[2] == parts[2] && (parts[1].is_empty() || parts[1] == lp[1]) })
});
match found {
Some((k, enabled, identities)) => out.push(crate::engine::CardChoice {
key: k.clone(),
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(*enabled),
identities: c.get("identities").and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(*identities),
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|n| n as u32),
}),
None => missing.push(key),
}
}
(out, missing)
}
/// The hold rule (MF-6, PC 1, 7 October 2026: a read-only job that followed a --stop-miners job kept the cards off
/// for its whole run): a hold belongs to the job that took it and releases the moment that job is no longer the
/// running one, whatever runs next, or when the owner's own cap has passed. Returns the reason to release, or None.
pub fn hold_release(owner: Option<&str>, active: Option<&str>, held_s: f64, cap_s: f64) -> Option<&'static str> {
match owner {
None => Some("no job owns the hold"),
Some(o) => {
if active != Some(o) {
Some("the job that took the hold is no longer running")
} else if held_s >= cap_s {
Some("the hold passed the job's own cap")
} else {
None
}
}
}
}
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
match ran.code {
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
@ -1587,23 +1434,6 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
mod tests {
use super::*;
/// cards_leave_off (7 October 2026): the restore entries keep their identities and cap and carry enabled=false,
/// so the job's exit leaves the card off through the same path; without the param they keep their own flag.
#[test]
fn cards_leave_off_restores_the_card_as_off_with_its_settings_kept() {
let live: Vec<LiveCard> = vec![("other:Intel(R) Arc(TM) B580 Graphics".into(), true, 8, 0, true), ("nvidia:NVIDIA GeForce RTX 5090".into(), true, 2, 80, true)];
let (off, restore) = cards_off_choices(&["other:Intel(R) Arc(TM) B580 Graphics".to_string()], &live);
assert_eq!(off.len(), 1);
assert!(restore[0].enabled, "the plain restore puts the card back on");
let left = leave_off(restore.clone());
assert_eq!(left.len(), 1);
assert_eq!((left[0].key.as_str(), left[0].enabled, left[0].identities, left[0].power_pct), ("other:Intel(R) Arc(TM) B580 Graphics", false, 8, restore[0].power_pct));
// the job's param decides, through the same hold
let j = jobs::parse(r#"{"jobs":[{"id":"x","kind":"run","expires_at":"2099-01-01T00:00:00Z","target":{"machine_ids":["ae432dc7"]},"params":{"script":"ls","cards_off":["other:Intel(R) Arc(TM) B580 Graphics"],"cards_leave_off":true}}]}"#).unwrap().jobs.remove(0);
assert!(j.bool_param("cards_leave_off"));
assert_eq!(j.list_param("cards_off"), vec!["other:Intel(R) Arc(TM) B580 Graphics".to_string()]);
}
#[test]
fn cards_off_matches_keys_with_and_without_the_device_index_and_restores_exactly() {
// PC 1, 6 October 2026: settings.json holds amd:1:gfx1201 and amd:3:gfx1201, the live state's key is amd:gfx1201
@ -1736,16 +1566,9 @@ mod tests {
assert_eq!(wake_url_of(None), WAKE_URL);
assert_eq!(wake_url_of(Some(String::new())), "");
assert_eq!(wake_url_of(Some(" http://127.0.0.1:4180/wake ".into())), "http://127.0.0.1:4180/wake");
assert_eq!(wake_query("https://r/wake", "", ""), "https://r/wake");
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5", ""), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
assert_eq!(wake_query("https://r/api/wake?x=1", "S", ""), "https://r/api/wake?x=1&since=S");
// the ping (MF-11): the machine, the version and the last job ride on every wake request
assert_eq!(wake_query("https://r/wake", "", "machine=1ccfe586&v=0.3.21"), "https://r/wake?machine=1ccfe586&v=0.3.21");
assert_eq!(wake_query("https://r/wake", "S", "machine=1ccfe586&v=0.3.21&job=update-now-0319"), "https://r/wake?since=S&machine=1ccfe586&v=0.3.21&job=update-now-0319");
assert_eq!(ping_query("1ccfe586", "0.3.21", ""), "machine=1ccfe586&v=0.3.21");
assert_eq!(ping_query("1ccfe586", "0.3.21", "update-now-0319-1ccfe586"), "machine=1ccfe586&v=0.3.21&job=update-now-0319-1ccfe586");
assert_eq!(ping_query("", "0.3.21", "x"), "", "no machine, no ping");
assert_eq!(ping_query("1ccfe586", "0.3.21", "a b&c=d"), "machine=1ccfe586&v=0.3.21&job=abcd", "only the safe characters travel");
assert_eq!(wake_query("https://r/wake", ""), "https://r/wake");
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5"), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
assert_eq!(wake_query("https://r/api/wake?x=1", "S"), "https://r/api/wake?x=1&since=S");
}
}
@ -2235,23 +2058,3 @@ fn account_warning(ctx: &str) -> String {
fn short(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::<String>()) }
}
#[cfg(test)]
mod hold_tests {
use super::hold_release;
/// MF-6 (PC 1, 7 October 2026): a --stop-miners job's hold outlived it into a read-only watch job for three minutes.
#[test]
fn a_hold_belongs_to_the_job_that_took_it() {
// the owner is still running, under its cap: the hold stays
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 30.0, 3600.0), None);
// another job runs now: released at once, whatever that job is
assert_eq!(hold_release(Some("job-a"), Some("job-b"), 30.0, 3600.0), Some("the job that took the hold is no longer running"));
// no job runs: released
assert_eq!(hold_release(Some("job-a"), None, 30.0, 3600.0), Some("the job that took the hold is no longer running"));
// the owner's own cap passed: released and logged
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 3601.0, 3600.0), Some("the hold passed the job's own cap"));
// a hold with no owner (an older engine state) never sticks
assert_eq!(hold_release(None, Some("job-b"), 1.0, 3600.0), Some("no job owns the hold"));
}
}

View file

@ -2,7 +2,7 @@
//! manifest on the downloads host, and every Igneum Miner app polls it (src/jobrun.rs, every 10 minutes). A job
//! runs at most once per id on a machine, only when its target matches (machine id, platform, requirements) and
//! it has not expired. Same key, same canonical JSON (sorted keys, no whitespace) and the same `.sig` scheme as the
//! update manifest (src/manifest.rs). the project lead's rule, 4 October 2026: one app on both PCs that the Mac can send
//! update manifest (src/manifest.rs). The founder's rule, 4 October 2026: one app on both PCs that the Mac can send
//! commands and files to over the line, so everything is tested and built without a person at the PC.
//!
//! This module is self-contained (serde_json and manifest.rs only), so the signer (src/bin/ota-sign.rs) includes it
@ -22,9 +22,7 @@
//!
//! Kinds and their params:
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
//! timeout_minutes (default 60, at most 600), cards_off [keys] (the runner switches them off before
//! the script and restores them on any exit), cards_leave_off (with cards_off: restored as OFF, so
//! the card stays off and persisted after the job; 7 October 2026, the Arc on PC 1)
//! timeout_minutes (default 60, at most 600)
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command
@ -61,7 +59,7 @@ pub const JOBS_FILE: &str = "igneum-jobs.json";
/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms.
pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json";
pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1";
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build", "cards"];
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"];
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
/// Named folders a `fetch` may write into, all under the app data root.
@ -420,33 +418,6 @@ pub fn validate_params(job: &Job) -> Result<(), String> {
}
}
"update-now" => {}
"cards" => {
// the signed `cards` kind (7 October 2026): per-card enabled and identities, applied by the app through
// its own card path and persisted; it closes the exception of a one-off script POSTing /api/cards
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
if list.is_empty() {
return Err("cards: params.cards is empty (a list of {key, enabled, identities})".into());
}
for c in &list {
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("");
if key.trim().is_empty() || !key.contains(':') {
return Err(format!("cards: key '{key}' is not a card key (vendor:device:name)"));
}
if c.get("enabled").map(|v| !v.is_boolean()).unwrap_or(false) {
return Err(format!("cards: {key}: enabled must be true or false"));
}
if let Some(n) = c.get("identities") {
if !n.as_u64().map(|n| (1..=64).contains(&n)).unwrap_or(false) {
return Err(format!("cards: {key}: identities must be 1 to 64"));
}
}
if let Some(n) = c.get("power_pct") {
if !n.as_u64().map(|n| (50..=100).contains(&n)).unwrap_or(false) {
return Err(format!("cards: {key}: power_pct must be 50 to 100"));
}
}
}
}
"shard-benchmark" => {
let url = job.str_param("zip_url");
if !url.is_empty() && !https_ok(&url) {
@ -853,10 +824,6 @@ mod tests {
assert!(j("restart", r#"{"what":"everything"}"#).unwrap_err().contains("restart"));
assert!(j("restart", r#"{"what":"miners"}"#).is_ok());
assert!(j("update-now", r#"{}"#).is_ok());
assert!(j("cards", r#"{}"#).unwrap_err().contains("cards"));
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:NVIDIA GeForce RTX 5090","enabled":true,"identities":8}]}"#).is_ok());
assert!(j("cards", r#"{"cards":[{"key":"5090","enabled":true}]}"#).unwrap_err().contains("card key"));
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:x","identities":65}]}"#).unwrap_err().contains("1 to 64"));
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));

View file

@ -1,735 +0,0 @@
//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's
//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this
//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day
//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards
//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first
//! on a new card). Persisted as `<app dir>/ladder.json`; pure, no clock of its own (every call takes `now`).
//!
//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints
//! `PoW accepted <hash> by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the
//! nonce is the join and nothing else is.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const DAY_S: f64 = 86_400.0;
/// days kept in the ring (the 30-day window plus a month of slack for the view)
const KEEP_DAYS: u64 = 60;
const KEEP_BLOCKS: usize = 30;
const KEEP_SHARDS: usize = 50;
const KEEP_PENDING: usize = 400;
const KEEP_SIGNED: usize = 400;
/// the block counts that raise a card (then every 10,000th)
pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000];
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct DayBlocks {
/// unix day number (unix seconds / 86,400, UTC)
pub day: u64,
pub blocks: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct BlockFound {
pub hash: String,
pub daa: u64,
pub nonce: String,
pub at: f64,
pub card: String,
pub card_name: String,
/// this machine's lifetime count at that block (1 = the first)
pub count: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ShardPaid {
pub block: u64,
pub shard: u64,
/// wei as a decimal string (u128 does not survive every JSON reader)
pub wei: String,
pub at: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Milestone {
/// first | hundred | thousand | ten_thousand | card
pub kind: String,
pub count: u64,
pub card: String,
pub card_name: String,
pub hash: String,
pub daa: u64,
pub at: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Ladder {
#[serde(default)]
pub first_block_at: f64,
#[serde(default)]
pub first_block_card: String,
#[serde(default)]
pub first_block_hash: String,
#[serde(default)]
pub first_block_daa: u64,
#[serde(default)]
pub days: Vec<DayBlocks>,
/// card key -> unix s of its first block
#[serde(default)]
pub cards_first: BTreeMap<String, f64>,
#[serde(default)]
pub blocks: Vec<BlockFound>,
#[serde(default)]
pub votes_signed: u64,
#[serde(default)]
pub last_vote_index: u64,
#[serde(default)]
pub last_vote_at: f64,
/// checkpoints signed and not yet seen locked
#[serde(default)]
pub signed_open: Vec<u64>,
#[serde(default)]
pub last_signed_locked: u64,
#[serde(default)]
pub signed_locked_count: u64,
/// when the unbroken run of votes began (0 = no vote yet)
#[serde(default)]
pub streak_since: f64,
#[serde(default)]
pub shards: Vec<ShardPaid>,
#[serde(default)]
pub milestone: Option<Milestone>,
/// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet)
#[serde(default)]
pub first_synced_at: f64,
#[serde(default)]
pub first_mining_at: f64,
/// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case
#[serde(default)]
pending: Vec<(String, String, u64)>,
/// first-block-21 (the project lead, 7 October 2026: "the 'you got your first block' situation only shows for the miner's
/// first ever block"; main's reading: SEEN once): set when a window has shown the first-block card (the page
/// reports it through POST /api/card/seen on display or dismiss), never when it is raised. The card is raised when
/// the lifetime count the engine persists (settings.json accepted_total) crosses from 0 to 1 and waits, across
/// restarts and updates, until a window shows it: a block found overnight or across an auto-update greets the
/// miner the first time they open the app, and after that never again. Kept across restarts, updates and a kept
/// data directory; never cleared by a key changing identities or a card being swapped (the count is the
/// machine's, not a key's or a card's). A count that starts over on a record carrying the flag (settings.json
/// lost, the ladder kept) raises no first card again; a 0.3.20 record (schema 0, no flag) whose first block already
/// came (count 1 or more, or first_block_at set) takes the flag at `start_run`, since 0.3.20 showed it every run.
#[serde(default)]
pub first_block_shown: bool,
/// a window has shown the current `milestone` (set with it by `card_seen`); a seen card is dropped at the next
/// `start_run`, an unseen one waits for its window
#[serde(default)]
pub milestone_seen: bool,
/// the record's shape: 0 = written by 0.3.20 (no first-block flag), 1 = first-block-21
#[serde(default)]
pub schema: u32,
}
/// What the dashboard reads (api/state.ladder): the record above summarised at `now`.
#[derive(Clone, Debug, Default, Serialize)]
pub struct LadderState {
pub first_block_at: f64,
pub first_block_card: String,
pub first_block_hash: String,
pub first_block_daa: u64,
/// distinct UTC days with at least one block in the last 30 days
pub days_mined_30: u64,
pub blocks_30d: u64,
pub blocks_today: u64,
pub days: Vec<DayBlocks>,
pub cards_first: BTreeMap<String, f64>,
pub blocks: Vec<BlockFound>,
pub votes_signed: u64,
pub last_vote_index: u64,
pub last_vote_at: f64,
pub last_signed_locked: u64,
pub signed_locked_count: u64,
/// seconds of unbroken signing (0 = none)
pub streak_s: f64,
pub shards: Vec<ShardPaid>,
pub milestone: Option<Milestone>,
pub first_synced_at: f64,
pub first_mining_at: f64,
/// first-block-21: the first-block card has been raised once on this machine (see `Ladder::first_block_shown`)
pub first_block_shown: bool,
}
pub fn day_of(unix: f64) -> u64 {
(unix.max(0.0) / DAY_S).floor() as u64
}
impl Ladder {
pub fn load(path: &std::path::Path) -> Ladder {
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}
pub fn save(&self, path: &std::path::Path) {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
let mut me = self.clone();
me.schema = 1;
if let Ok(t) = serde_json::to_string_pretty(&me) {
let _ = std::fs::write(path, t);
}
}
/// The engine's run begins over this record (first-block-21, 7 October 2026). `accepted_total` is the lifetime
/// count settings.json holds. A block card a window has shown (`milestone_seen`) is dropped here, so a restart
/// or an update never shows "your first block" (or block 100) a second time; a card no window has shown yet
/// waits (found overnight, or across an auto-update restart). A 0.3.20 record (schema 0) carries no flag and
/// showed its card on every run, so one whose first block already came (the count at 1 or more, or
/// first_block_at set) takes the flag now and its card is dropped. Returns true when the record changed and
/// wants saving.
pub fn start_run(&mut self, accepted_total: u64) -> bool {
let mut dirty = false;
if self.schema == 0 {
if accepted_total >= 1 || self.first_block_at > 0.0 {
self.first_block_shown = true;
self.milestone = None;
}
self.schema = 1;
dirty = true;
}
if self.milestone.is_some() && self.milestone_seen {
self.milestone = None;
self.milestone_seen = false;
dirty = true;
}
dirty
}
/// A window showed (or the user dismissed) the card for the milestone at `count` and `at` (first-block-21): the
/// card is spent, and a first-block card sets the lifetime flag. Returns true when the record changed.
pub fn card_seen(&mut self, count: u64, at: f64) -> bool {
let m = match self.milestone.as_ref() {
Some(m) if m.count == count && (m.at - at).abs() < 1.0 => m,
_ => return false,
};
let mut dirty = false;
if !self.milestone_seen {
self.milestone_seen = true;
dirty = true;
}
if m.kind == "first" && !self.first_block_shown {
self.first_block_shown = true;
dirty = true;
}
dirty
}
/// The node validated a block (its own log line): remembered by nonce until the miner's line claims it.
pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) {
let nonce = norm_nonce(nonce);
if nonce.is_empty() || hash.is_empty() {
return;
}
// a block of ours whose miner line came first: fill the hash in now
if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) {
b.hash = hash.to_string();
b.daa = daa;
if self.first_block_hash.is_empty() && self.first_block_at == b.at {
self.first_block_hash = hash.to_string();
self.first_block_daa = daa;
}
if let Some(m) = self.milestone.as_mut() {
if m.hash.is_empty() && m.at == b.at {
m.hash = hash.to_string();
m.daa = daa;
}
}
return;
}
self.pending.push((nonce, hash.to_string(), daa));
if self.pending.len() > KEEP_PENDING {
let n = self.pending.len() - KEEP_PENDING;
self.pending.drain(0..n);
}
}
/// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the
/// milestone this block raised, if any.
pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option<Milestone> {
let nonce = norm_nonce(nonce);
let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) {
Some(i) => {
let p = self.pending.remove(i);
(p.1, p.2)
}
None => (String::new(), 0),
};
let day = day_of(now);
match self.days.iter_mut().find(|d| d.day == day) {
Some(d) => d.blocks += 1,
None => self.days.push(DayBlocks { day, blocks: 1 }),
}
self.days.sort_by_key(|d| d.day);
if self.days.len() > KEEP_DAYS as usize {
let n = self.days.len() - KEEP_DAYS as usize;
self.days.drain(0..n);
}
// the first-block card: once in the app's life, when the persisted lifetime count crosses from 0 to 1 on a
// record with no block and no flag (first-block-21); a count that starts over raises nothing here
let first = total == 1 && !self.first_block_shown && self.first_block_at == 0.0;
if self.first_block_at == 0.0 {
self.first_block_at = now;
self.first_block_card = card.to_string();
self.first_block_hash = hash.clone();
self.first_block_daa = daa;
}
let new_card = !card.is_empty() && !self.cards_first.contains_key(card);
if new_card {
self.cards_first.insert(card.to_string(), now);
}
self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total });
self.blocks.truncate(KEEP_BLOCKS);
let kind = if first { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card && total > 1 { "card" } else { "" };
if kind.is_empty() {
return None;
}
let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now };
self.milestone = Some(m.clone());
self.milestone_seen = false;
self.schema = 1;
Some(m)
}
/// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`.
pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) {
self.votes_signed += 1;
if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) {
self.streak_since = now;
}
self.last_vote_at = now;
if index > self.last_vote_index {
self.last_vote_index = index;
}
if index > 0 && !self.signed_open.contains(&index) {
self.signed_open.push(index);
if self.signed_open.len() > KEEP_SIGNED {
let n = self.signed_open.len() - KEEP_SIGNED;
self.signed_open.drain(0..n);
}
}
}
/// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now.
pub fn on_lock(&mut self, index: u64) {
let (locked, open): (Vec<u64>, Vec<u64>) = self.signed_open.iter().partition(|&&i| i <= index);
if let Some(&top) = locked.iter().max() {
if top > self.last_signed_locked {
self.last_signed_locked = top;
}
self.signed_locked_count += locked.len() as u64;
}
self.signed_open = open;
}
/// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new.
pub fn mark(&mut self, what: &str, now: f64) -> bool {
let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false };
if *slot > 0.0 { return false; }
*slot = now;
true
}
pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) {
if self.shards.iter().any(|s| s.block == block && s.shard == shard) {
return;
}
self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now });
self.shards.truncate(KEEP_SHARDS);
}
/// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`).
pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 {
if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s {
return 0.0;
}
(now - self.streak_since).max(0.0)
}
fn blocks_since(&self, from: f64) -> u64 {
// the day ring is daily; the finer windows read the block list and fall back to the ring
self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum()
}
pub fn state(&self, now: f64, gap_s: f64) -> LadderState {
let today = day_of(now);
let window_from = now - 30.0 * DAY_S;
let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64;
LadderState {
first_block_at: self.first_block_at,
first_block_card: self.first_block_card.clone(),
first_block_hash: self.first_block_hash.clone(),
first_block_daa: self.first_block_daa,
days_mined_30,
blocks_30d: self.blocks_since(window_from),
blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0),
days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(),
cards_first: self.cards_first.clone(),
blocks: self.blocks.iter().take(10).cloned().collect(),
votes_signed: self.votes_signed,
last_vote_index: self.last_vote_index,
last_vote_at: self.last_vote_at,
last_signed_locked: self.last_signed_locked,
signed_locked_count: self.signed_locked_count,
streak_s: self.streak_s(now, gap_s),
shards: self.shards.clone(),
milestone: self.milestone.clone(),
first_synced_at: self.first_synced_at,
first_mining_at: self.first_mining_at,
first_block_shown: self.first_block_shown,
}
}
}
/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes).
pub fn milestone_event(m: &Milestone) -> String {
match m.kind.as_str() {
"first" => format!("first block: found by {} (block card)", m.card_name),
"hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name),
"thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name),
"ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name),
_ => format!("first block on {} (block card)", m.card_name),
}
}
fn with_commas(n: u64) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); }
out.push(c);
}
out
}
fn norm_nonce(n: &str) -> String {
let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase();
if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t }
}
/// `PoW accepted <hash> by <engine> (daa N, ..., nonce 0x..)` -> (hash, daa, nonce)
pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> {
let rest = text.split("PoW accepted ").nth(1)?;
let hash = rest.split_whitespace().next()?.to_string();
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<u64>().ok()?;
let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string();
Some((hash, daa, nonce))
}
/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce
pub fn parse_miner_accepted(text: &str) -> Option<String> {
let rest = text.split("ACCEPTED block nonce=").nth(1)?;
Some(rest.split_whitespace().next()?.to_string())
}
/// `... VOTE index=N ...` -> N
pub fn parse_vote_index(text: &str) -> Option<u64> {
text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)";
const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)";
#[test]
fn the_node_and_miner_lines_parse() {
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce");
assert_eq!(daa, 38637);
assert_eq!(n, "0x267dd27200a91c80");
assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80");
assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240);
assert!(parse_node_accepted("PoW rejected 00 by x").is_none());
}
#[test]
fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() {
let mut l = Ladder::default();
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
l.on_node_accepted(&h, daa, &n);
// a relayed block from another miner sits in pending too and never matches
l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa");
let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap();
assert_eq!(m.kind, "first");
assert_eq!(m.hash, h);
assert_eq!(m.daa, 38637);
assert_eq!(l.first_block_hash, h);
assert_eq!(l.first_block_daa, 38637);
assert_eq!(l.first_block_card, "apple::Apple M5 Max");
assert_eq!(l.blocks.len(), 1);
assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched");
let s = l.state(1_791_140_920.0, 600.0);
assert_eq!(s.days_mined_30, 1);
assert_eq!(s.blocks_30d, 1);
assert_eq!(s.blocks_today, 1);
}
#[test]
fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() {
let mut l = Ladder::default();
l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1);
assert_eq!(l.first_block_hash, "");
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
l.on_node_accepted(&h, daa, &n);
assert_eq!(l.blocks[0].hash, h);
assert_eq!(l.first_block_hash, h);
assert_eq!(l.milestone.as_ref().unwrap().hash, h);
assert_eq!(l.milestone.as_ref().unwrap().daa, 38637);
}
#[test]
fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() {
let mut l = Ladder::default();
let mut kinds = vec![];
for total in 1..=1_000u64 {
if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); }
}
assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]);
assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card");
assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none());
assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand");
assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand");
assert_eq!(l.blocks.len(), KEEP_BLOCKS);
}
#[test]
fn days_mined_counts_distinct_days_inside_the_window_only() {
let mut l = Ladder::default();
let now = 40.0 * DAY_S + 100.0;
// blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window
for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] {
l.on_block(d * DAY_S + 50.0, "a", "A", "", total);
}
let s = l.state(now, 600.0);
assert_eq!(s.days_mined_30, 3);
assert_eq!(s.blocks_30d, 4);
assert_eq!(s.blocks_today, 1);
assert_eq!(s.days.len(), 3);
}
#[test]
fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() {
let mut l = Ladder::default();
l.on_vote(1000.0, 10, 600.0);
l.on_vote(1030.0, 11, 600.0);
l.on_vote(1060.0, 12, 600.0);
assert_eq!(l.streak_s(1090.0, 600.0), 90.0);
assert_eq!(l.votes_signed, 3);
assert_eq!(l.last_vote_index, 12);
// the lock at 11 puts 10 and 11 in locked certificates; 12 stays open
l.on_lock(11);
assert_eq!(l.last_signed_locked, 11);
assert_eq!(l.signed_locked_count, 2);
assert_eq!(l.signed_open, vec![12]);
// a gap longer than the presence window ends the streak; the next vote starts a new one
assert_eq!(l.streak_s(1700.0, 600.0), 0.0);
l.on_vote(1700.0, 33, 600.0);
assert_eq!(l.streak_s(1730.0, 600.0), 30.0);
assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0);
}
#[test]
fn the_milestone_events_read_in_the_users_words() {
let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 };
assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)");
assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)");
assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)");
assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)");
}
#[test]
fn the_timeline_marks_are_set_once() {
let mut l = Ladder::default();
assert!(l.mark("synced", 10.0));
assert!(!l.mark("synced", 20.0));
assert!(l.mark("mining", 30.0));
assert!(!l.mark("other", 40.0));
let s = l.state(50.0, 600.0);
assert_eq!(s.first_synced_at, 10.0);
assert_eq!(s.first_mining_at, 30.0);
}
#[test]
fn paid_shards_are_kept_once_each_newest_first() {
let mut l = Ladder::default();
l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000);
l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000);
l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000);
assert_eq!(l.shards.len(), 2);
assert_eq!(l.shards[0].block, 1_284_120);
assert_eq!(l.shards[1].wei, "1150000000000000000");
}
#[test]
fn the_record_survives_a_save_and_load() {
let mut l = Ladder::default();
l.on_block(100.0, "a", "A", "0x1", 1);
l.on_vote(130.0, 5, 600.0);
let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id()));
let path = dir.join("ladder.json");
l.save(&path);
let back = Ladder::load(&path);
assert_eq!(back.first_block_at, 100.0);
assert_eq!(back.votes_signed, 1);
assert_eq!(back.blocks, l.blocks);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0);
}
// ---- first-block-21 (the project lead, 7 October 2026): the first-block card SEEN once in the app's life ----
/// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field, no schema.
const RECORD_0320: &str = r#"{"first_block_at":1791140918.5,"first_block_card":"nvidia:0:RTX 4070","first_block_hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","first_block_daa":38637,"days":[{"day":20730,"blocks":1}],"cards_first":{"nvidia:0:RTX 4070":1791140918.5},"blocks":[{"hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"nonce":"267dd27200a91c80","at":1791140918.5,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","count":1}],"votes_signed":2,"last_vote_index":8496,"last_vote_at":1791140950.0,"signed_open":[8496],"last_signed_locked":0,"signed_locked_count":0,"streak_since":1791140930.0,"shards":[],"milestone":{"kind":"first","count":1,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"at":1791140918.5},"first_synced_at":1791140000.0,"first_mining_at":1791140100.0,"pending":[]}"#;
fn round_trip(l: &Ladder) -> Ladder {
// one directory per call: the tests run in parallel and a shared path let one test's remove wipe another's file
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!("igneum-ladder-first-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::SeqCst)));
let path = dir.join("ladder.json");
l.save(&path);
let back = Ladder::load(&path);
let _ = std::fs::remove_dir_all(&dir);
back
}
#[test]
fn known_failed_a_second_run_with_the_lifetime_count_at_1_showed_the_first_card_again() {
// the known-failed case: before this change a second run loaded the persisted milestone and the dashboard
// showed "your first block" again (every restart, every update, until the dismiss in that window's storage)
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
assert_eq!(l.milestone.as_ref().map(|m| m.kind.as_str()), Some("first"), "the fixture carries the persisted card");
assert!(!l.first_block_shown, "the 0.3.20 record has no flag");
assert_eq!(l.schema, 0);
assert!(l.start_run(1), "the record changes and wants saving");
let s = l.state(1791150000.0, 600.0);
assert!(s.milestone.is_none(), "a card 0.3.20 showed on every run is never shown again");
assert!(s.first_block_shown);
assert_eq!(s.first_block_at, 1791140918.5, "the ladder's first-block facts stay");
assert_eq!(l.schema, 1);
}
#[test]
fn the_first_ever_block_raises_the_card_and_a_window_showing_it_sets_the_flag() {
let mut l = Ladder::default();
l.start_run(0);
let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
assert_eq!(m.kind, "first");
assert!(!l.first_block_shown, "raised, not yet seen");
assert!(!l.card_seen(7, 100.0), "another card's report changes nothing");
assert!(l.card_seen(1, 100.0));
assert!(l.first_block_shown);
assert!(!l.card_seen(1, 100.0), "a second report (the dismiss) changes nothing");
let s = l.state(101.0, 600.0);
assert!(s.first_block_shown);
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the card stays on screen through this run");
}
#[test]
fn a_first_block_found_with_no_window_greets_the_miner_at_the_first_open_and_never_again() {
// main's case: the block overnight (no window), an engine restart (an auto-update), the first open shows the
// card once; a second open (the next run) does not
let mut night = Ladder::default();
night.start_run(0);
night.on_block(100.0, "a", "RTX 4070", "0x1", 1);
let mut morning = round_trip(&night);
assert!(!morning.start_run(1), "an unseen card waits: nothing changes at start");
let s = morning.state(30_000.0, 600.0);
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the first open shows the card");
assert!(!s.first_block_shown);
assert!(morning.card_seen(1, 100.0));
assert!(morning.first_block_shown);
let mut later = round_trip(&morning);
assert!(later.start_run(1), "the seen card is dropped");
assert!(later.state(60_000.0, 600.0).milestone.is_none(), "a second open shows nothing");
assert!(later.first_block_shown);
assert!(later.on_block(60_100.0, "a", "RTX 4070", "0x2", 2).is_none());
}
#[test]
fn a_restart_with_the_count_at_1_shows_nothing_and_block_2_raises_no_card() {
let mut run1 = Ladder::default();
run1.on_block(100.0, "a", "RTX 4070", "0x1", 1);
assert!(run1.card_seen(1, 100.0), "the window was open");
let mut run2 = round_trip(&run1);
assert!(run2.first_block_shown, "the flag survives the save");
assert_eq!(run2.schema, 1);
assert!(run2.start_run(1), "the seen card is dropped");
assert!(run2.state(200.0, 600.0).milestone.is_none());
assert!(run2.on_block(300.0, "a", "RTX 4070", "0x2", 2).is_none(), "block 2 is an ordinary block");
assert_eq!(run2.blocks[0].count, 2);
}
#[test]
fn an_update_with_the_count_at_1_takes_the_flag_from_the_older_record_shape() {
// the new version opens a 0.3.20 record: the flag is set from the count, the card dropped, and the flag
// survives the next save in the new shape
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
assert!(l.start_run(1));
assert!(l.first_block_shown);
assert!(l.milestone.is_none());
let mut back = round_trip(&l);
assert!(back.first_block_shown);
assert_eq!(back.schema, 1);
assert!(!back.start_run(1), "a new-shape record with nothing pending changes nothing");
assert!(back.on_block(1791150000.0, "nvidia:0:RTX 4070", "NVIDIA GeForce RTX 4070", "0x3", 2).is_none());
// a 0.3.20 record whose count is still 0 but whose first_block_at is set (settings.json rewritten) is shown too
let mut l2: Ladder = serde_json::from_str(RECORD_0320).unwrap();
assert!(l2.start_run(0));
assert!(l2.first_block_shown);
assert!(l2.milestone.is_none());
}
#[test]
fn a_kept_data_directory_with_the_count_at_0_and_the_flag_unset_shows_it_on_the_first_block() {
// a kept directory from a machine that synced and voted but never found a block
let mut l = Ladder::default();
l.mark("synced", 10.0);
l.mark("mining", 20.0);
l.on_vote(30.0, 5, 600.0);
let mut kept = round_trip(&l);
assert!(!kept.start_run(0), "nothing to reconcile: no block yet");
assert!(!kept.first_block_shown);
let m = kept.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
assert_eq!(m.kind, "first");
assert!(kept.card_seen(1, 100.0));
assert!(kept.first_block_shown);
}
#[test]
fn a_count_that_starts_over_on_a_record_already_shown_raises_no_first_card() {
// settings.json lost (the count back at 0), the ladder kept with the flag: the next block is total 1 again
let mut l = Ladder::default();
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
l.card_seen(1, 100.0);
assert!(l.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none(), "no second first-block card");
assert!(l.on_block(300.0, "b", "RTX 5090", "0x3", 1).is_none(), "nor a new-card card at a count of 1");
assert!(l.first_block_shown);
assert_eq!(l.state(301.0, 600.0).blocks.len(), 3, "the blocks are still recorded");
// the same with the card still unseen: the waiting card stays the one card, no second is raised
let mut u = Ladder::default();
u.on_block(100.0, "a", "RTX 4070", "0x1", 1);
assert!(u.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none());
assert_eq!(u.milestone.as_ref().unwrap().at, 100.0);
}
#[test]
fn block_2_is_an_ordinary_block_with_no_card() {
let mut l = Ladder::default();
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
l.card_seen(1, 100.0);
assert!(l.on_block(130.0, "a", "RTX 4070", "0x2", 2).is_none());
let s = l.state(131.0, 600.0);
assert_eq!(s.blocks[0].count, 2);
assert_eq!(s.blocks_today, 2);
assert_eq!(s.milestone.as_ref().unwrap().count, 1, "the first card of this run stays until the run ends");
}
}

View file

@ -1,459 +0,0 @@
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
//! numbers. Two sources, one contract:
//!
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
//! window, passed through untouched (`shape` adds `state.you_blocks` and `state.source` = "observer"): the scene's full
//! feed (lanes, parents, colours, the selected chain, checkpoints, proof state). This machine's blocks are NOT rewritten:
//! the UI marks them through the scene's `mine` option (scene/feed-contract.md: `miner` is always the 8-hex key id).
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
//! is unreachable the node's rows stand in for the scene in the SAME JSON shape as the observer's reply
//! (`node_only_reply`, every key of scene/feed-contract.json, null where the node cannot know; `state.source` = "node",
//! `partial: true`: no parents, no proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the
//! node source rests 10 minutes. The test `the_node_only_reply_has_the_contract_shape` reads the contract file itself.
//!
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
use crate::engine::Shared;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(2);
struct NodeCache {
at: Option<Instant>,
blocks: Vec<RecentBlock>,
/// the node said "method not found" (-32601): rest until then
retry: Option<Instant>,
}
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
const NODE_TTL: Duration = Duration::from_secs(5);
/// The observer's reply with this machine's blocks counted. `ids` are the 8-character vote key ids of this machine's
/// cards; `state.you_blocks` counts the blocks whose `miner` is one of them and `state.source` names where the data came
/// from. Every row passes through untouched (until 0.3.20 the field was rewritten to "you", which the feed contract refuses:
/// the UI's `mine` function marks the lane from the card ids instead), so the contract holds on both surfaces.
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mut yours = 0u64;
if let Some(blocks) = reply.get("blocks").and_then(|b| b.as_array()) {
yours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false)).count() as u64;
}
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("you_blocks".into(), json!(yours));
st.insert("source".into(), json!("observer"));
}
reply
}
/// The observer URL for a window: `<live_api>?window=<s>`, the window clamped to the module's 30 to 300 s.
pub fn url_for(live_api: &str, window_s: u32) -> String {
format!("{live_api}?window={}", window_s.clamp(30, 300))
}
/// One block from `igneum_getRecentBlocks`.
#[derive(Clone, Debug)]
pub struct RecentBlock {
pub hash: String,
pub blue_score: u64,
pub daa_score: u64,
pub timestamp_ms: u64,
pub vote_key_hash: String,
pub is_chain_block: bool,
pub color: String,
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
pub timestamp_source: String,
}
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
v.as_array()
.map(|a| {
a.iter()
.map(|b| RecentBlock {
hash: s(b, "hash"),
blue_score: n(b, "blue_score"),
daa_score: n(b, "daa_score"),
timestamp_ms: n(b, "timestamp_ms"),
vote_key_hash: s(b, "vote_key_hash"),
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
color: s(b, "color"),
timestamp_source: s(b, "timestamp_source"),
})
.collect()
})
.unwrap_or_default()
}
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
pub fn lane(vote_key_hash: &str) -> String {
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
}
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
fn short(h: &str) -> String {
h.trim_start_matches("0x").chars().take(16).collect()
}
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
/// contract's row shape as far as the node knows it: no parents, no number, no proof state) and `miners` arrays
/// (the contract's `{id, blocks, share, last_seen, engine}`). `now_ms` is the reference time.
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
let ten = now_ms.saturating_sub(600_000);
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
for b in &recent {
if !b.vote_key_hash.is_empty() {
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
}
}
let mut per_min = vec![0u64; 10];
for b in &recent {
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
per_min[idx] += 1;
}
let win = now_ms.saturating_sub(window_s * 1000);
let rows: Vec<Value> = blocks
.iter()
.filter(|b| b.timestamp_ms >= win)
.map(|b| {
let id = lane(&b.vote_key_hash);
json!({
"hash": short(&b.hash), "number": Value::Null, "blue_score": b.blue_score, "daa": b.daa_score,
"ts": b.timestamp_ms, "rx": b.timestamp_ms, "parents": [], "chain": b.is_chain_block,
"miner": if id.is_empty() { Value::Null } else { json!(id) },
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
"locked": false, "final": false, "shards": [], "proven": false
})
})
.collect();
json!({
"miners_10m": miners.len(),
"blocks_10m": recent.len(),
"blocks_per_minute": per_min,
"blocks": rows,
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "blocks": n, "share": if recent.is_empty() { 0.0 } else { (*n as f64 * 1000.0 / recent.len() as f64).round() / 10.0 }, "last_seen": Value::Null, "engine": Value::Null })).collect::<Vec<_>>(),
})
}
/// The node-only reply, in the contract's shape: every key of scene/feed-contract.json present, null where the node cannot
/// know (the observer's lag, the mempool, the proving layer), `partial: true` and `state.source` = "node" as the documented
/// extensions, `state.node` carrying the node's own numbers as on an observer reply. `now_iso` is the clock as ISO 8601.
pub fn node_only_reply(blocks: &[RecentBlock], now_ms: u64, now_iso: &str, window_s: u64, node: Value, ids: &HashSet<String>) -> Value {
let mut v = shape_from_blocks(blocks, now_ms, window_s);
let o = v.as_object_mut().unwrap();
let rows = o.remove("blocks").unwrap_or(Value::Null);
let miners = o.remove("miners").unwrap_or(Value::Null);
let state = json!({
"stale": false, "age_s": 0, "network": Value::Null, "node_version": Value::Null, "height": Value::Null,
"block_count": node.get("block_count").cloned().unwrap_or(Value::Null), "header_count": node.get("header_count").cloned().unwrap_or(Value::Null),
"blue_score": node.get("blue_score").cloned().unwrap_or(Value::Null), "difficulty": node.get("difficulty").cloned().unwrap_or(Value::Null),
"hashes_per_second_estimate": Value::Null, "peers": node.get("peers").cloned().unwrap_or(Value::Null), "mempool": Value::Null,
"blocks_60s": Value::Null, "blocks_per_second_60s": Value::Null, "blocks_per_minute": o.get("blocks_per_minute").cloned().unwrap_or(json!([])),
"miners_10m": o.get("miners_10m").cloned().unwrap_or(json!(0)), "observer_started_at": Value::Null, "observer_lag_s": Value::Null,
"queue_depth": Value::Null, "updated_at": now_iso, "source": "node", "node": node
});
let finality = json!({ "supported": false, "active": false, "next_index": Value::Null, "latest_locked_index": Value::Null, "latest_locked_hash": Value::Null,
"latest_locked_blue_score": Value::Null, "params": Value::Null, "weights": Value::Null, "checkpoints": [] });
let out = json!({ "ok": true, "partial": true, "now": now_iso, "state": state, "blocks": rows, "miners": miners, "events": [], "finality": finality,
"proving": { "supported": false, "reason": "the local node's rows: no proving layer in this view" } });
let mut out = shape(out, ids);
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("source".into(), json!("node")); // shape() names the observer; these rows are the node's
}
out
}
/// The clock as the contract's `now`: ISO 8601 with milliseconds, UTC.
fn iso_now(unix_s: f64) -> String {
let ms = (unix_s * 1000.0) as i64;
let (secs, millis) = (ms.div_euclid(1000), ms.rem_euclid(1000));
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm), no chrono dependency
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
}
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
/// minutes) or answered nothing.
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
{
let c = NODE.lock().unwrap();
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
return c.blocks.clone();
}
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
return Vec::new();
}
}
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
if !node_up {
return Vec::new();
}
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
Ok(v) if v.is_array() => {
let blocks = parse_recent(&v);
let mut c = NODE.lock().unwrap();
c.at = Some(Instant::now());
c.blocks = blocks.clone();
blocks
}
Ok(_) => Vec::new(),
Err(e) => {
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
if e.contains("not found") || e.contains("-32601") {
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
}
Vec::new()
}
}
}
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
let mut v = shape_from_blocks(blocks, now_ms, 90);
let st = shared.state.lock().unwrap();
let o = v.as_object_mut().unwrap();
o.remove("blocks");
o.remove("miners");
o.insert("block_count".into(), json!(st.node.blocks));
o.insert("header_count".into(), json!(st.node.headers));
o.insert("daa".into(), json!(st.node.daa));
o.insert("blue_score".into(), json!(st.node.blue));
o.insert("difficulty".into(), json!(st.node.difficulty));
o.insert("peers".into(), json!(st.node.peers));
o.insert("synced".into(), json!(st.node.synced));
o.insert("hashes_per_second_estimate".into(), Value::Null);
o.insert("source".into(), json!("node"));
v
}
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
let window_s = window_s.clamp(30, 300);
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
let blocks = node_blocks(shared);
let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
let mut reply = match cached {
Some(v) => v,
None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
None => {
let url = url_for(live_api, window_s);
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
_ => json!({ "ok": false, "error": "the observer did not answer" }),
};
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
}
reply
}
};
let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
match (observer_ok, node) {
(true, Some(n)) => {
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("node".into(), n);
}
reply
}
(true, None) => reply,
(false, Some(n)) => {
// the node's rows stand in, in the contract's shape: blocks without parents or proof state, and the reply says so
node_only_reply(&blocks, now_ms, &iso_now(crate::platform::unix_now_f()), window_s as u64, n, ids)
}
(false, None) => reply,
}
}
#[cfg(test)]
mod tests {
use super::*;
// a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on
// the selected chain and proven, one excluded; a locked and a pending checkpoint
fn fixture() -> Value {
json!({
"ok": true, "now": 1791301670,
"state": { "stale": false, "age_s": 1, "height": 198490 },
"blocks": [
{ "hash": "a1", "ts": 1791301600000i64, "blue_score": 194690, "daa": 198486, "parents": [], "chain": true, "color": "blue", "miner": "8fafda27", "locked": true, "final": true, "shards": [{ "state": "paid" }], "proven": true },
{ "hash": "a2", "ts": 1791301601000i64, "blue_score": 194691, "daa": 198487, "parents": ["a1"], "chain": true, "color": "blue", "miner": "1b2c3d4e", "locked": false, "final": false, "shards": [{ "state": "proving" }], "proven": false },
{ "hash": "a3", "ts": 1791301601500i64, "blue_score": null, "daa": 198487, "parents": ["a1"], "chain": false, "color": "red", "miner": "8fafda27", "locked": false, "final": false, "shards": [], "proven": false },
{ "hash": "a4", "ts": 1791301602000i64, "blue_score": 194692, "daa": 198488, "parents": ["a2", "a3"], "chain": true, "color": "pending", "miner": "deadbeef", "locked": false, "final": false, "shards": [], "proven": false }
],
"finality": { "checkpoints": [
{ "index": 6490, "blue_score": 194690, "daa": 198486, "state": "locked", "fraction_total": 0.71 },
{ "index": 6491, "blue_score": 194720, "daa": 198516, "state": "pending", "fraction_total": 0.44 }
] }
})
}
#[test]
fn this_machines_blocks_are_counted_and_every_row_passes_through_untouched() {
let ids: HashSet<String> = ["8fafda27".to_string(), "9a8b7c6d".to_string()].into_iter().collect();
let out = shape(fixture(), &ids);
let blocks = out["blocks"].as_array().unwrap();
assert_eq!(blocks.len(), 4);
assert_eq!(blocks[0]["miner"], "8fafda27", "the key id stays: the contract refuses a rewritten miner");
assert_eq!(blocks[2]["miner"], "8fafda27");
assert_eq!(blocks[1]["miner"], "1b2c3d4e");
assert_eq!(blocks[3]["miner"], "deadbeef");
// the rest of the contract passes through: parents, chain, colour, proof state, checkpoints
assert_eq!(blocks[3]["parents"], json!(["a2", "a3"]));
assert_eq!(blocks[0]["chain"], true);
assert_eq!(blocks[2]["color"], "red");
assert_eq!(blocks[0]["proven"], true);
assert_eq!(blocks[1]["shards"][0]["state"], "proving");
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
assert_eq!(out["state"]["you_blocks"], 2);
assert_eq!(out["state"]["source"], "observer");
assert_eq!(out["state"]["height"], 198490);
assert_eq!(out["ok"], true);
}
#[test]
fn no_ids_means_no_lane_is_yours_and_a_bare_reply_survives() {
let out = shape(fixture(), &HashSet::new());
assert_eq!(out["state"]["you_blocks"], 0);
let bare = shape(json!({ "ok": true, "blocks": [] }), &HashSet::new());
assert_eq!(bare["blocks"].as_array().unwrap().len(), 0);
assert!(bare.get("state").is_none(), "no state object is invented");
}
#[test]
fn the_window_is_clamped_to_the_modules_range() {
assert_eq!(url_for("https://igneum.network/api/live", 120), "https://igneum.network/api/live?window=120");
assert_eq!(url_for("https://igneum.network/api/live", 5), "https://igneum.network/api/live?window=30");
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
}
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
}
#[test]
fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
let now = 1_791_300_000_000u64;
let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
let v = shape_from_blocks(&blocks, now, 120);
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
assert_eq!(v["blocks_10m"], 4);
let pm = v["blocks_per_minute"].as_array().unwrap();
assert_eq!(pm.len(), 10);
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
assert_eq!(pm[8], 1, "the 95 s block");
assert_eq!(pm[1], 1, "the 500 s block");
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
assert_eq!(v["blocks"][0]["color"], "blue");
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
assert_eq!(v["miners"][0]["blocks"], 2, "the contract's miner row: blocks, not blocks_10m");
// this machine's rows are counted, never rewritten
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
assert_eq!(marked["blocks"][0]["miner"], "aa");
assert_eq!(marked["state"]["you_blocks"], 2);
assert_eq!(lane("0x0123456789abcdef"), "01234567");
assert_eq!(lane(""), "");
}
/// scene/feed-contract.json, the same file tools/scene/feed-contract.mjs reads: the node-only reply carries exactly the
/// contract's keys (plus the documented extensions) at every level the scene reads.
const CONTRACT: &str = include_str!("../../../scene/feed-contract.json");
fn keys_of(v: &Value) -> Vec<String> {
let mut k: Vec<String> = v.as_object().expect("an object").keys().cloned().collect();
k.sort();
k
}
fn listed(c: &Value, name: &str) -> Vec<String> {
let mut k: Vec<String> = c[name].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
k.sort();
k
}
#[test]
fn the_node_only_reply_has_the_contract_shape() {
let c: Value = serde_json::from_str(CONTRACT).expect("scene/feed-contract.json parses");
let now = 1_791_300_000_000u64;
let blocks = vec![b(now - 5_000, "aa11bb22", true), b(now - 30_000, "bb22cc33", true), b(now - 95_000, "aa11bb22", false), b(now - 40_000, "", true)];
let node = json!({ "block_count": 10, "header_count": 12, "blue_score": 9, "difficulty": 1.5, "peers": 3, "synced": true, "source": "node", "miners_10m": 2, "blocks_10m": 3, "blocks_per_minute": [0,0,0,0,0,0,0,0,1,2] });
let ids: HashSet<String> = ["aa11bb22".to_string()].into_iter().collect();
let out = node_only_reply(&blocks, now, "2026-10-07T13:20:00.000Z", 120, node, &ids);
// top level: the contract's keys plus the documented extensions, nothing else
let mut top = listed(&c, "top"); top.extend(listed(&c, "top_extensions")); top.sort();
assert_eq!(keys_of(&out), top);
assert_eq!(out["partial"], true);
assert_eq!(out["now"], "2026-10-07T13:20:00.000Z", "now is the ISO string, not a float of seconds");
// state: every contract key present, extras only from the extension list
let state = listed(&c, "state");
let ext = listed(&c, "state_extensions");
for k in &state { assert!(out["state"].get(k).is_some(), "state lacks {k}"); }
for k in keys_of(&out["state"]) { assert!(state.contains(&k) || ext.contains(&k), "state carries an unknown key {k}"); }
assert_eq!(out["state"]["source"], "node");
assert_eq!(out["state"]["you_blocks"], 2);
assert_eq!(out["state"]["node"]["peers"], 3);
// rows: exactly the contract's keys
for row in out["blocks"].as_array().unwrap() { assert_eq!(keys_of(row), listed(&c, "block")); }
assert_eq!(out["blocks"][0]["miner"], "aa11bb22");
assert_eq!(out["blocks"][0]["number"], Value::Null);
assert_eq!(out["blocks"][3]["miner"], Value::Null, "a block without a key reads null, never an empty string");
assert_eq!(out["miners"][0]["id"], "aa11bb22");
assert_eq!(out["miners"][0]["share"], 50.0, "two of the four blocks in ten minutes");
for m in out["miners"].as_array().unwrap() { assert_eq!(keys_of(m), listed(&c, "miner")); }
assert_eq!(keys_of(&out["finality"]), listed(&c, "finality"));
assert_eq!(keys_of(&out["proving"]), listed(&c, "proving_unsupported"));
assert_eq!(out["events"], json!([]));
// the miner id is 8 hex or null, never a word
let re_ok = |s: &str| s.len() == 8 && s.chars().all(|ch| ch.is_ascii_hexdigit());
for row in out["blocks"].as_array().unwrap() { if let Some(m) = row["miner"].as_str() { assert!(re_ok(m), "miner {m}"); } }
}
#[test]
fn iso_now_formats_the_clock_as_the_contracts_now() {
assert_eq!(iso_now(1_791_301_670.312), "2026-10-06T15:47:50.312Z");
assert_eq!(iso_now(0.0), "1970-01-01T00:00:00.000Z");
assert_eq!(iso_now(951_782_400.5), "2000-02-29T00:00:00.500Z");
}
#[test]
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
let p = parse_recent(&v);
assert_eq!(p.len(), 2);
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
assert!(parse_recent(&json!(null)).is_empty());
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
}
}

View file

@ -24,35 +24,19 @@ mod server;
mod state;
mod manifest;
mod ota;
mod uiota;
mod update;
mod execrpc;
mod jobs;
mod jobrun;
mod jobbuild;
mod prover;
mod provedefault;
mod provingdir;
mod segments;
mod verifier;
mod wslhost;
mod sweep;
mod ember;
mod heat;
mod powertask;
mod watchdog;
mod live;
mod extnode;
mod merge;
mod ladder;
mod chainfacts;
mod card;
mod drivertable;
mod drivers;
mod driverinstall;
mod bootcheck;
mod boot;
mod rights;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;
@ -60,7 +44,7 @@ use std::sync::Arc;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let wrapper = args.iter().any(|a| a == "--wrapper") && !args.iter().any(|a| a == "--boot");
let wrapper = args.iter().any(|a| a == "--wrapper");
let sweep = args.iter().any(|a| a == "--sweep");
if sweep {
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
@ -71,66 +55,28 @@ fn main() {
println!("igneum-app {}", engine::VERSION);
return;
}
if args.iter().any(|a| a == "--rights") {
// the installer's one elevated step (src/rights.rs): compares the installed rights manifest with this build's list,
// asks for administrator rights once when something is missing, else exits at once; exit 0 either way (an install
// never fails on a declined prompt: the app runs, the missing right is a notice)
let exe = std::env::current_exe().unwrap_or_default();
let install_dir = exe.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let runtime = config::Runtime::from_env();
match rights::install(&exe, &install_dir, &crate::platform::fixed_data_root(), &runtime.app_dir, engine::VERSION) {
Ok(true) => println!("rights: set up (one administrator approval)"),
Ok(false) => println!("rights: nothing new to set up"),
Err(e) if e.starts_with("rights: deferred") => println!("{e}"),
Err(e) => println!("rights: not set up ({e}); the app runs, and the missing rights are notices in Settings"),
}
return;
}
if args.iter().any(|a| a == "--power-helper") {
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
let dir = powertask::helper_dir();
std::process::exit(powertask::run_helper(&dir));
}
// 0.3.22: `--data-root <path>` pins the data root (the boot task spells it out: an S4U session may not load the
// profile); `--boot` is the headless engine (no window host, no browser); `--launch` with no interactive session is
// `--boot` (src/boot.rs launch_mode: PC 2 waited 67 minutes for a logon on 7 October 2026)
if let Some(i) = args.iter().position(|a| a == "--data-root") {
if let Some(p) = args.get(i + 1) {
std::env::set_var("IGNEUM_APP_DATA", p);
}
}
let boot = args.iter().any(|a| a == "--boot");
let mut no_open = no_open || boot;
if args.iter().any(|a| a == "--launch") {
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe"));
let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false);
match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) {
boot::LaunchMode::Host => {
if let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) {
let mut c = std::process::Command::new(host);
c.current_dir(dir);
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
if c.spawn().is_ok() {
return;
}
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
let host = dir.join("Igneum Miner.exe");
if host.exists() {
let mut c = std::process::Command::new(&host);
c.current_dir(&dir);
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
if c.spawn().is_ok() {
return;
}
}
boot::LaunchMode::Headless => no_open = true,
boot::LaunchMode::Browser => {}
}
// no window host (or no logon): the engine runs on its own; the dashboard opens in the default browser only in a session
// no window host: the engine runs on its own and the dashboard opens in the default browser
}
platform::clear_quarantine();
let runtime = config::Runtime::from_env();
// 0.3.22: a window host's engine that finds the boot engine already running under this data root becomes the
// bridge to it (src/boot.rs) instead of a second engine on the same ports and folder
if wrapper && !sweep {
if let Some(url) = boot::running_engine(&runtime.app_dir) {
std::process::exit(boot::run_bridge(&url));
}
}
let _ = std::fs::create_dir_all(&runtime.app_dir);
let _ = std::fs::create_dir_all(&runtime.log_dir);
platform::lock_permissions(&runtime.app_dir, true);
@ -155,17 +101,6 @@ fn main() {
}
}
let packaged = config::Packaged::load(&candidates).with_env_overrides();
// Devnet 3 (7 October 2026): the package names the network its node joins; the runtime read above knew only the
// environment, so it is read again with the package's suffix and peers (the environment still wins inside)
// the network step (0.3.23): the saved choice is read first (the settings file lives in <data root>/app whatever the
// network), so settings.network can turn the runtime to the testnet object before the node starts
let chosen_network = config::Settings::load(&runtime.app_dir.join("settings.json")).network;
let runtime = if packaged.node_devnet_suffix.is_some() || packaged.node_peers.is_some() || !chosen_network.is_empty() {
config::Runtime::from_env_with_choice(packaged.node_devnet_suffix, packaged.node_peers.as_deref(), &chosen_network)
} else {
runtime
};
let _ = std::fs::create_dir_all(&runtime.app_dir);
let settings = config::Settings::load(&runtime.app_dir.join("settings.json"));
// a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement)
let settings = if sweep { settings.for_measurement() } else { settings };
@ -220,8 +155,7 @@ fn main() {
_ => {}
}
}
// 0.3.22: only an engine a host attached quits with the host (src/boot.rs stdin_close_quits)
if boot::stdin_close_quits(wrapper, boot) {
if wrapper {
shared.send(engine::Cmd::Quit("the window host went away (stdin closed)"));
}
});

View file

@ -12,13 +12,8 @@
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
//! "min_supported_version": "0.3.0", "notes": "one line",
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
//! }
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
@ -57,9 +52,6 @@ pub struct Manifest {
pub min_supported_version: String,
pub notes: String,
pub activation_height: Option<u64>,
/// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the
/// network's finality is paused (nothing else does); false unless the signed manifest says so
pub urgent: bool,
pub deadline_note: String,
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
@ -67,41 +59,6 @@ pub struct Manifest {
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
pub tuning: Option<serde_json::Value>,
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
pub ui: Option<UiEntry>,
/// the network step (0.3.23): the network a FRESH install selects by default ("devnet-3" | "testnet-1"; "" = the
/// package's own), and whether igneum-testnet-1 is open; a manifest naming the testnet default before the open is refused
pub default_network: String,
pub testnet_open: bool,
/// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and
/// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers
pub drivers: Option<serde_json::Value>,
}
/// One published interface bundle (the manifest's `ui` object).
#[derive(Clone, Debug, PartialEq, Default)]
pub struct UiEntry {
pub version: String,
pub sha256: String,
pub size: u64,
pub url: String,
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
pub min_engine: String,
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
pub signature: String,
}
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
}
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
let key = public_key(pub_hex)?;
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
}
impl Manifest {
@ -191,17 +148,7 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Ok(Some(e))
};
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
let default_network = s(&v, "default_network");
let testnet_open = v.get("testnet_open").and_then(|b| b.as_bool()).unwrap_or(false);
if !default_network.is_empty() && !["devnet-3", "testnet-1"].contains(&default_network.as_str()) {
return Err(format!("default_network '{default_network}' is not a network this app knows"));
}
if default_network == "testnet-1" && !testnet_open {
return Err("default_network names the testnet before it is open (testnet_open is not true)".into());
}
Ok(Manifest {
default_network,
testnet_open,
version,
published_at: s(&v, "published_at"),
channel: s(&v, "channel"),
@ -210,8 +157,6 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
min_supported_version: s(&v, "min_supported_version"),
notes: s(&v, "notes"),
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
// the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through)
urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false),
deadline_note: s(&consensus, "deadline_note"),
override_params: match consensus.get("override") {
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
@ -223,40 +168,6 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
_ => None,
},
drivers: match v.get("drivers") {
Some(d) if d.is_object() => {
crate::drivertable::Table::parse(d)?;
Some(d.clone())
}
Some(d) if !d.is_null() => return Err("drivers must be an object".into()),
_ => None,
},
ui: match v.get("ui") {
Some(u) if u.is_object() => {
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
if parse_version(&e.version).is_none() {
return Err(format!("ui: version '{}' is not a version", e.version));
}
if parse_version(&e.min_engine).is_none() {
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
}
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
return Err("ui: the url is not https".into());
}
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: sha256 is not 64 hex characters".into());
}
if e.size == 0 {
return Err("ui: size is missing".into());
}
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: signature is not 128 hex characters".into());
}
Some(e)
}
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
_ => None,
},
})
}
@ -389,10 +300,6 @@ pub struct Moment {
pub boundary_eta_s: Option<i64>,
/// A worker is starting, exporting a pack or being built: let it finish.
pub miner_busy: bool,
/// A remote job is running on this engine (src/jobrun.rs). It holds even an urgent install: a job is bounded by its
/// cap, and an install under it kills its process tree (PC 1, 6 October 2026, 17:52:54Z: 0.3.14 went in during a
/// measurement job because install_asked from a two-hour-old update-now still counted as urgent).
pub job_active: bool,
/// How long the update has been ready and waiting.
pub ready_for_s: u64,
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
@ -401,16 +308,8 @@ pub struct Moment {
pub slot_ok: bool,
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
pub network_drop_pct: f64,
/// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint
/// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock
pub finality_paused: bool,
/// the signed manifest's own urgent flag: the one thing that installs while finality is paused
pub manifest_urgent: bool,
}
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
@ -421,13 +320,6 @@ pub fn slot_minute(id8: &str) -> u64 {
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
// the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag
if m.finality_paused && !m.manifest_urgent {
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
}
if m.job_active {
return Err("a remote job is running; installing when it closes".into());
}
if m.urgent {
return Ok(());
}
@ -457,10 +349,7 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
match activation_height {
// Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the
// old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now",
// stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it)
Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
_ => false,
}
}
@ -493,36 +382,6 @@ mod tests {
assert_eq!(ours, theirs);
}
#[test]
fn the_ui_entry_parses_and_every_bad_field_fails() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[3u8; 32]);
let pk = hex_encode(sk.verifying_key().as_bytes());
let sha = "ab".repeat(32);
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
let m = parse(&base.to_string()).unwrap();
let u = m.ui.clone().unwrap();
assert_eq!(u.version, "1.0.1");
assert_eq!(u.min_engine, "0.3.19");
assert!(verify_ui_entry(&u, &pk).is_ok());
let mut t = u.clone();
t.sha256 = "00".repeat(32);
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
let mut t = u.clone();
t.min_engine = "0.3.0".into();
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
let mut b = base.clone();
b["ui"][k] = v;
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
}
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
}
#[test]
fn tuning_parses() {
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
@ -549,22 +408,6 @@ mod tests {
(sk, pk)
}
#[test]
fn default_network_rules() {
// the network step: absent = the package's own; devnet-3 fine; the testnet default only once the manifest opens it
let m = parse(SAMPLE).unwrap();
assert_eq!(m.default_network, "");
assert!(!m.testnet_open);
let d = parse(r#"{"version":"0.3.23","default_network":"devnet-3"}"#).unwrap();
assert_eq!(d.default_network, "devnet-3");
let e = parse(r#"{"version":"0.3.23","default_network":"testnet-1"}"#).unwrap_err();
assert!(e.contains("before it is open"), "{e}");
let t = parse(r#"{"version":"0.3.23","default_network":"testnet-1","testnet_open":true}"#).unwrap();
assert_eq!(t.default_network, "testnet-1");
assert!(t.testnet_open);
assert!(parse(r#"{"version":"0.3.23","default_network":"mainnet"}"#).unwrap_err().contains("not a network"));
}
#[test]
fn parses_manifest() {
let m = parse(SAMPLE).unwrap();
@ -649,15 +492,8 @@ mod tests {
#[test]
fn safe_moments() {
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false };
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
assert!(safe_to_apply(&base).is_ok());
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality"));
assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
@ -666,11 +502,6 @@ mod tests {
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
// urgent beats every wait
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
// PC 1, 6 October 2026, 17:52:54Z: a scheduled update arriving mid-job is deferred to the job's close, urgent or not
assert!(safe_to_apply(&Moment { job_active: true, ..base.clone() }).unwrap_err().contains("remote job"));
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, ..base.clone() }).unwrap_err().contains("remote job"));
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, slot_ok: false, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { job_active: false, urgent: true, slot_ok: false, ..base.clone() }).is_ok());
// patience: an unsynced node for 6 h applies anyway
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
@ -700,11 +531,8 @@ mod tests {
assert!(!fork_is_close(Some(120_000), 0));
assert!(!fork_is_close(Some(120_000), 118_199));
assert!(fork_is_close(Some(120_000), 118_200));
assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation");
// a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending
assert!(!fork_is_close(Some(120_000), 120_000));
assert!(!fork_is_close(Some(120_000), 130_000));
assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA");
assert!(fork_is_close(Some(120_000), 120_000));
assert!(fork_is_close(Some(120_000), 130_000));
let m = parse(SAMPLE).unwrap();
assert!(!unsupported(&m, "0.3.0"));
assert!(unsupported(&m, "0.2.9"));

View file

@ -1,96 +0,0 @@
//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s
//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the
//! N4 "synced" test (tip moving, a peer present) cannot see it.)
//!
//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network
//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params,
//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining.
/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes).
pub const NOT_MERGING_S: f64 = 120.0;
/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue
/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth`
/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on
/// the 0.3.19 list, and the observer is the only network view until then.
pub const DEFAULT_MERGE_DEPTH: u64 = 3_600;
/// The words every surface uses for this state.
pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network";
#[derive(Debug, Clone, Default)]
pub struct MergeCheck {
/// our node's sink blue score
pub our_blue: u64,
/// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision
pub peer_sinks: Vec<u64>,
/// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH)
pub merge_depth: u64,
/// seconds since one of our blocks last appeared in the network's view; None = never seen
pub ours_seen_ago_s: Option<f64>,
/// how long this machine has been mining with a synced node, in seconds
pub mining_for_s: f64,
}
/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has
/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it).
pub fn not_merging(c: &MergeCheck) -> bool {
if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S {
return false;
}
let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth };
let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth));
let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true);
all_ahead && ours_absent
}
/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the
/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks.
pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option<i64>)> {
let blocks = reply.get("blocks")?.as_array()?;
if blocks.is_empty() {
return None;
}
let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0);
let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max();
Some((sink, ours))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } }
/// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing
/// merging. Today's sync test calls it synced; this one holds the miner.
#[test]
fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() {
assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen");
assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago");
assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network");
}
#[test]
fn a_merging_node_is_left_alone() {
assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision");
assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge");
assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600");
}
#[test]
fn the_observer_reply_gives_the_sink_and_our_newest_block() {
let r = json!({ "blocks": [
{ "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" },
{ "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" },
{ "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" },
{ "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] });
assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000))));
let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] });
assert_eq!(view_of(&none_ours), Some((5, None)));
assert_eq!(view_of(&json!({ "blocks": [] })), None);
assert_eq!(view_of(&json!({ "ok": false })), None);
}
}

View file

@ -1,4 +1,4 @@
//! Over-the-air updates of the app (and the node, miner and workers inside it). the project lead's rule: every app updates
//! Over-the-air updates of the app (and the node, miner and workers inside it). The founder's rule: every app updates
//! itself and downloads the update without being asked. This is also how a consensus upgrade (a height-activated
//! rule such as difficulty v2) reaches every node before its activation height.
//!
@ -68,12 +68,8 @@ pub enum Launch {
pub struct Ctx {
pub node_synced: bool,
/// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S)
pub finality_paused: bool,
pub boundary_eta_s: Option<i64>,
pub miner_busy: bool,
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
pub job_active: bool,
pub daa: u64,
}
@ -94,8 +90,6 @@ pub struct Updater {
dir: PathBuf,
auto: bool,
manifest: Option<Manifest>,
/// driver-check: the table was written or removed since the engine last looked
drivers_changed: bool,
entry: Option<PlatformEntry>,
file: Option<PathBuf>,
staged: Option<PathBuf>,
@ -126,7 +120,7 @@ pub struct Updater {
/// this machine's minute of the hour for applying (manifest::slot_minute of the machine id)
slot: u64,
/// When this engine started (unix seconds): an update published more than an hour before it is a catch-up, not a
/// rollout, and skips the hourly slot (the project lead's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
/// rollout, and skips the hourly slot (the founder's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
/// sat on "installs at the next safe moment" until he pressed Install now).
started_unix: u64,
catch_up_logged: bool,
@ -135,9 +129,6 @@ pub struct Updater {
live_next: Instant,
live_busy: bool,
live_api: String,
/// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check
/// (Some(None) = the channel was withdrawn: the kill switch)
ui_change: Option<Option<manifest::UiEntry>>,
}
impl Updater {
@ -158,7 +149,6 @@ impl Updater {
dir,
auto,
manifest: None,
drivers_changed: false,
entry: None,
file: None,
staged: None,
@ -186,7 +176,6 @@ impl Updater {
live_next: now,
live_busy: false,
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
ui_change: None,
};
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
#[cfg(windows)]
@ -199,7 +188,6 @@ impl Updater {
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
if !shared.runtime.sweep_only {
firewall_first_run(shared);
boot_task_first_run(shared);
}
}
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
@ -209,7 +197,6 @@ impl Updater {
u.min_supported = m.min_supported_version.clone();
u.write_override(shared, &m);
u.write_tuning(shared, &m);
u.write_drivers(shared, &m);
}
}
u.settle_previous(shared);
@ -283,49 +270,7 @@ impl Updater {
}
}
/// <app data>/drivers.json: the manifest's per-vendor driver table (src/drivers.rs), written as is; the engine
/// re-reads it and re-evaluates every card's offer on a change. A manifest without a table removes the file.
fn write_drivers(&mut self, shared: &Arc<Shared>, m: &Manifest) {
let path = self.app_dir.join("drivers.json");
match &m.drivers {
Some(t) => {
let text = t.to_string();
if std::fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()) {
return;
}
if let Err(e) = std::fs::write(&path, &text) {
shared.log(&format!("could not write {}: {e}", path.display()));
return;
}
let n = t.get("vendors").and_then(|c| c.as_object()).map(|c| c.len()).unwrap_or(0);
shared.event("info", &format!("driver table from the signed manifest: {n} vendor(s), updated {}", t.get("updated").and_then(|u| u.as_str()).unwrap_or("?")));
self.drivers_changed = true;
}
None => {
if path.is_file() && std::fs::remove_file(&path).is_ok() {
shared.log("the manifest carries no driver table any more; drivers.json removed");
self.drivers_changed = true;
}
}
}
}
/// The driver table changed (written or removed), once per change.
pub fn take_drivers_change(&mut self) -> bool {
std::mem::take(&mut self.drivers_changed)
}
pub fn drivers_table(&self) -> Option<crate::drivers::Table> {
let text = std::fs::read_to_string(self.app_dir.join("drivers.json")).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
crate::drivers::Table::parse(&v).ok()
}
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
/// ui-ota: the interface entry of the last check, once (None until a check has run)
pub fn take_ui_change(&mut self) -> Option<Option<manifest::UiEntry>> {
self.ui_change.take()
}
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
self.tuning_changed.take()
}
@ -381,12 +326,6 @@ impl Updater {
let rolled_back = v.get("rolled_back").and_then(|x| x.as_bool()).unwrap_or(false);
let deferred = v.get("deferred").and_then(|x| x.as_bool()).unwrap_or(false);
let ver = v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string();
// 0.3.21: the helper says how the app came back (ok, rolled-back, relaunched, installer-failed) and after how long
let ret = v.get("return").and_then(|x| x.as_str()).unwrap_or("");
let ready_s = v.get("ready_s").and_then(|x| x.as_i64()).unwrap_or(-1);
if !ret.is_empty() {
shared.log(&format!("update-return: the helper reports '{ret}' for {ver}{}", if ready_s >= 0 { format!(", an engine answered after {ready_s} s") } else { ", no engine answered inside its window".to_string() }));
}
if !ok && deferred {
// the installer never ran (nobody answered the administrator prompt): not a failure, it tries again
shared.log(&format!("OTA: the update to {ver} was deferred before this start ({err}); it tries again in this machine's slot"));
@ -433,12 +372,6 @@ impl Updater {
self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false)
}
/// The version this engine replaced, on the first start after an update (MF-11: the read-back line the engine logs
/// as its first act, "app <version> up after the update from <from>"); None on any other start.
pub fn updated_from(&self) -> Option<String> {
self.pending.as_ref().filter(|p| p.starts == 1 && p.to == self.current).map(|p| p.from.clone())
}
// ---- state for the dashboard -------------------------------------------------------------------------------------
fn publish(&self, shared: &Arc<Shared>) {
@ -606,8 +539,7 @@ impl Updater {
}
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , finality_paused: ctx.finality_paused, manifest_urgent };
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
if !self.auto && !urgent && !self.install_asked {
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
return None;
@ -736,12 +668,6 @@ impl Updater {
shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name()));
} else {
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
// an asked install (update-now) covers this one check: nothing newer, so the ask is spent.
// Left true it made the NEXT manifest urgent hours later (PC 1, 6 October 2026, 17:52:54Z).
if self.install_asked {
self.install_asked = false;
shared.log("update check: Install now asked and nothing newer is published; the ask is spent (the next manifest takes the usual slot)");
}
}
self.entry = None;
self.file = None;
@ -752,14 +678,6 @@ impl Updater {
self.min_supported = m.min_supported_version.clone();
self.write_override(shared, &m);
self.write_tuning(shared, &m);
self.write_drivers(shared, &m);
self.ui_change = Some(m.ui.clone());
{
// the network step: the manifest's default for a fresh install and the testnet's open flag
let mut st = shared.state.lock().unwrap();
st.update.default_network = m.default_network.clone();
st.update.testnet_open = m.testnet_open;
}
if let Some(e) = entry {
if changed {
self.file = None;
@ -970,10 +888,6 @@ impl Updater {
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", &entry.sha256,
]);
// 0.3.21 (MF-11): the helper owns the return. It keeps the exe set beside the app, launches the app itself after
// the installer, polls the new engine's api/state, restores the kept set when nothing answers, and posts one line
// to the intake either way (the key it needs is in igneum-app.json beside the exe; nothing on the command line).
c.args(["-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string()]);
let per_user = !under_program_files(&install_dir);
shared.log(&format!("update: starting the installer first, the miners keep running: {} ({})", script.display(), if per_user { "per-user install, no administrator prompt" } else { "install in Program Files: Windows asks for administrator approval" }));
if !per_user {
@ -1025,7 +939,6 @@ impl Updater {
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
"-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(),
"-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string(),
]);
spawn_detached(&mut c)?;
Ok(())
@ -1041,7 +954,7 @@ impl Updater {
// ---- the threads ---------------------------------------------------------------------------------------------------
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
pub fn live_api_from(live_page: &str) -> String {
fn live_api_from(live_page: &str) -> String {
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
let host = rest.split('/').next().unwrap_or("");
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
@ -1064,39 +977,12 @@ fn under_program_files(dir: &Path) -> bool {
/// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on
/// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot
/// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself.
/// Windows: the boot task (src/boot.rs) registered at every engine start when it is missing, in a thread, as the
/// user's own task (no prompt). An account Windows refuses gets it in the one approved step with the Power Helper.
#[cfg(windows)]
fn boot_task_first_run(shared: &Arc<Shared>) {
let Some(exe) = std::env::current_exe().ok() else { return };
let exe = crate::boot::task_exe(&exe);
if !exe.is_file() {
return;
}
let root = crate::platform::fixed_data_root();
let shared = shared.clone();
std::thread::spawn(move || match crate::boot::ensure_registered(&exe, &root) {
Ok(true) => shared.log(&format!("boot start: the task '{}' is registered: the engine starts at boot without a logon ({} --launch --data-root {})", crate::boot::TASK_NAME, exe.display(), root.display())),
Ok(false) => {}
Err(e) => shared.log(&format!("boot start: not registered ({e}); the app starts at logon only until Power control's one approved step registers it")),
});
}
#[cfg(windows)]
fn firewall_first_run(shared: &Arc<Shared>) {
let flag = shared.runtime.app_dir.join("firewall-rule.json");
if flag.exists() {
return;
}
// 0.3.22: the rule is the installer's rights step (src/rights.rs); the app never prompts at runtime. A manifest that
// holds the right ends it here; a manifest that lacks it (or none: an install before 0.3.22) is one log line.
if crate::rights::held(&shared.runtime.app_dir, "firewall-node") {
let _ = std::fs::write(&flag, json!({ "source": "rights", "at": crate::platform::unix_now() }).to_string());
return;
}
shared.log(&format!("firewall: inbound rule for igneumd.exe {}", crate::rights::missing_note("firewall-node")));
return;
#[allow(unreachable_code)]
let Some(install_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) else { return };
if under_program_files(&install_dir) {
let _ = std::fs::write(&flag, json!({ "source": "installer", "at": crate::platform::unix_now() }).to_string());
@ -1140,244 +1026,6 @@ fn installer_name_for(version: &str) -> String {
format!("Igneum-Miner-Setup-{version}.exe")
}
/// Windows: the folder the helper keeps the running exe set in before the installer runs, beside the app
/// (`<install dir>.previous`, so `...\Programs\Igneum Miner.previous`). A rollback copies it back over the install folder.
#[allow(dead_code)]
fn previous_dir_for(install_dir: &Path) -> PathBuf {
let name = install_dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Igneum Miner".into());
install_dir.with_file_name(format!("{name}.previous"))
}
// ---- the return after a Windows install (MF-11, 0.3.21) ------------------------------------------------------------
//
// PC 2 took the 0.3.19 update-now at 10:34Z on 7 October 2026 and was silent from 10:46Z. Until 0.3.21 the Windows helper's
// job ended when the installer exited 0: the installer's own [Run] entry relaunched the app, nobody checked that an engine
// answered, the window host never restarted an engine that died, and a second launch deferred to a surviving host through
// its single-instance mutex. The sequence below is what the helper does from the installer's exit on, written once here
// so a test can drive it with injected exit codes and answers, and mirrored line for line by WIN_HELPER's PowerShell.
/// How long the helper waits for an engine to answer api/state after a launch, and how often it asks.
pub const RETURN_READY_S: u64 = 120;
pub const RETURN_POLL_S: u64 = 3;
/// The installer's marker (packaging/windows/Igneum-Miner.iss SetMarker): no relaunch while it is there and younger than
/// this; an older one is a stale marker (an installer that died) and is ignored with a FAULT line.
pub const INSTALL_MARKER: &str = "install-running.flag";
pub const INSTALL_MARKER_STALE_S: u64 = 15 * 60;
/// May the app be launched again now? false while an installer is running (its marker present and fresh). PC 2,
/// 7 October 2026, 20:54 BST: a job's silent install quit the engine, the window host started the old engine 10 s later,
/// and every file stayed 0.3.21 because the host held them. `marker_age_s` is None when there is no marker.
pub fn relaunch_allowed(marker_age_s: Option<u64>) -> bool {
match marker_age_s {
None => true,
Some(age) => age > INSTALL_MARKER_STALE_S,
}
}
/// The marker's age in seconds under the app dir, None when absent.
pub fn install_marker_age(app_dir: &Path) -> Option<u64> {
let m = std::fs::metadata(app_dir.join(INSTALL_MARKER)).ok()?;
let t = m.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
Some(crate::platform::unix_now().saturating_sub(t))
}
/// One step of the helper after the installer exits.
#[derive(Debug, Clone, PartialEq)]
pub enum ReturnStep {
/// start igneum-app.exe --launch from the install folder (a detached process; the helper outlives the old engine)
Launch,
/// poll app.url + api/state for RETURN_READY_S; `want` is the version that must answer ("" = any engine)
WaitReady { want: String },
/// quit through the API, then end what is left by name (the installer's own stop order)
StopAll,
/// copy the kept exe set (`<install dir>.previous`) back over the install folder
RestorePrevious,
/// the result file for the next engine and the one intake line
Done { ok: bool, rolled_back: bool, fault: bool, how: &'static str },
}
/// What a WaitReady step saw: the version that answered, or nobody.
pub type Answer = Option<String>;
/// The helper's sequence from the installer's exit code on. `answers` is consulted once per WaitReady, in order (the test
/// injects them; the PowerShell asks the engine). `previous_kept` says whether the exe set was copied aside before the
/// installer ran.
pub fn return_sequence(installer_exit: i32, version: &str, previous_kept: bool, mut answers: impl FnMut(usize) -> Answer) -> Vec<ReturnStep> {
let mut steps = vec![ReturnStep::Launch];
let want = if installer_exit == 0 { version.to_string() } else { String::new() };
steps.push(ReturnStep::WaitReady { want: want.clone() });
match answers(0) {
Some(v) if want.is_empty() || v == want => {
steps.push(if installer_exit == 0 { ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" } } else { ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" } });
return steps;
}
_ => {}
}
// nothing (or the wrong engine) answered inside the window: back to the kept version
steps.push(ReturnStep::StopAll);
if previous_kept {
steps.push(ReturnStep::RestorePrevious);
steps.push(ReturnStep::Launch);
steps.push(ReturnStep::WaitReady { want: String::new() });
let how = if answers(1).is_some() { "rolled-back" } else { "rolled-back-silent" };
steps.push(ReturnStep::Done { ok: false, rolled_back: true, fault: true, how });
} else {
steps.push(ReturnStep::Launch);
steps.push(ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" });
}
steps
}
/// The helper before 0.3.21, for the record: the installer's exit code alone decided the result and nothing was asked
/// of the new engine (the known-failed shape of MF-11).
pub fn legacy_return_sequence(installer_exit: i32) -> Vec<ReturnStep> {
if installer_exit == 0 { vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }] } else { vec![ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: false, how: "" }] }
}
#[cfg(test)]
mod return_tests {
use super::*;
fn done(steps: &[ReturnStep]) -> &ReturnStep {
steps.last().unwrap()
}
fn answers(list: &[Answer]) -> impl FnMut(usize) -> Answer + '_ {
move |i| list.get(i).cloned().flatten()
}
/// Known-failed first: the helper before 0.3.21 reported ok on the installer's exit 0 with nobody answering.
#[test]
fn the_legacy_helper_reports_ok_with_no_engine_up() {
let steps = legacy_return_sequence(0);
assert_eq!(steps, vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }]);
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::WaitReady { .. })), "nothing was asked of the new engine");
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::Launch)), "the launch was the installer's, not the helper's");
}
#[test]
fn installer_ok_and_the_new_engine_answers_is_ok() {
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.21".into())]));
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" }]);
}
#[test]
fn installer_ok_and_nobody_answers_restores_the_previous_exe_set() {
let steps = return_sequence(0, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
assert_eq!(steps, vec![
ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::RestorePrevious, ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() },
ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back" },
]);
}
#[test]
fn the_old_engine_still_answering_after_exit_0_is_not_the_new_one() {
// the installer said 0 but never replaced the running engine (files in use): the old version answers, the window
// ends in a rollback to the kept set, which is the same version, and a FAULT line says so
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.20".into()), Some("0.3.20".into())]));
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
}
#[test]
fn nobody_answers_twice_is_still_reported() {
let steps = return_sequence(0, "0.3.21", true, answers(&[None, None]));
assert_eq!(*done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back-silent" });
}
#[test]
fn without_a_kept_set_the_helper_relaunches_what_is_there_and_reports() {
let steps = return_sequence(0, "0.3.21", false, answers(&[None]));
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" }]);
}
#[test]
fn a_failed_installer_relaunches_the_old_version_and_reports_a_fault() {
// exit 5 (cancelled) or 8 (files in use, a restart wanted): any engine answering is the old one, kept, with a FAULT line
for code in [1, 5, 8] {
let steps = return_sequence(code, "0.3.21", true, answers(&[Some("0.3.20".into())]));
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() }, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" }], "exit {code}");
}
let steps = return_sequence(5, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
}
#[test]
fn every_sequence_launches_before_it_waits_and_ends_in_a_done() {
for code in [0, 1, 5, 8] {
for kept in [true, false] {
for a in [vec![None, None], vec![Some("0.3.21".to_string()), None], vec![None, Some("0.3.20".to_string())]] {
let steps = return_sequence(code, "0.3.21", kept, answers(&a));
assert_eq!(steps[0], ReturnStep::Launch);
assert!(matches!(steps[1], ReturnStep::WaitReady { .. }));
assert!(matches!(done(&steps), ReturnStep::Done { .. }));
let fault = matches!(done(&steps), ReturnStep::Done { fault: true, .. });
let ok = matches!(done(&steps), ReturnStep::Done { ok: true, .. });
assert!(ok != fault, "a result is ok or a fault, never neither: {steps:?}");
}
}
}
}
/// 0.3.22: an update applied with nobody logged on (the boot engine) returns headless: the helper's Launch runs
/// `igneum-app.exe --launch`, which with no interactive session runs the engine itself, so the same sequence returns
/// the app without a logon (the known-failed form first: before 0.3.22 that launch opened the window host, which has
/// no desktop in session 0, and nothing mined until a logon)
#[test]
fn after_an_update_with_no_logon_the_relaunch_is_headless() {
assert_eq!(crate::boot::legacy_launch_mode(true), crate::boot::LaunchMode::Host);
assert_eq!(crate::boot::launch_mode(None, true), crate::boot::LaunchMode::Headless);
let steps = return_sequence(0, "0.3.22", true, |_| Some("0.3.22".into()));
assert_eq!(steps[0], ReturnStep::Launch, "the helper's launch is the same line; the session decides what it runs");
assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. }));
}
/// Tonight's shape on PC 2 (7 October 2026, 20:54 BST), known-failed first: an installer running, the engine gone, and the
/// relaunch went ahead; 0.3.23 holds while the marker is there and resumes when it clears
#[test]
fn no_relaunch_while_an_installer_runs_and_a_relaunch_when_it_clears() {
assert!(relaunch_allowed(None), "the old rule in effect: nothing held the relaunch (no marker existed)");
assert!(!relaunch_allowed(Some(0)), "an installer just wrote its marker: hold");
assert!(!relaunch_allowed(Some(600)), "ten minutes into a slow install: still held");
assert!(relaunch_allowed(Some(INSTALL_MARKER_STALE_S + 1)), "a marker an installer left behind when it died: ignored");
assert!(relaunch_allowed(None), "the marker cleared at the installer's end: relaunch");
let h = WIN_HELPER;
let wait = h.find("function WaitInstallerClear()").expect("the helper waits");
let launch = h.find("function Launch()").unwrap();
assert!(wait < launch && h[launch..].contains("WaitInstallerClear"), "every launch of the helper waits for the installer first");
assert!(h.contains("install-running.flag") && h.contains("-gt 900"), "the same marker and the same stale rule as relaunch_allowed");
// the installer writes and clears it, and the host holds its restart on it
let iss = include_str!("../../../packaging/windows/Igneum-Miner.iss");
assert!(iss.contains("install-running.flag") && iss.contains("SetMarker;") && iss.contains("if CurStep = ssDone then ClearMarker") && iss.contains("SetupMutex=IgneumMinerSetup"));
let host = include_str!("../../windows/host.cpp");
assert!(host.contains("install-running.flag") && host.contains("installerRunning()"), "the host's restart ladder holds while the marker is there");
}
/// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches.
#[test]
fn the_windows_helper_carries_every_step() {
let h = WIN_HELPER;
let at = |s: &str| h.find(s).unwrap_or_else(|| panic!("WIN_HELPER lacks '{s}'"));
assert!(at("/IGNOTA=2") > 0, "the installer must not relaunch (IGNOTA=1 is the old helpers' path)");
assert!(h.contains(&format!("$ReadyS = {RETURN_READY_S}")) && h.contains(&format!("$PollS = {RETURN_POLL_S}")));
let robocopy_keep = at("robocopy $InstallDir $Previous");
let installer = at("Start-Process -FilePath $Installer"); // console: a test marker, not a spawn (the helper line above it carries the comment)
let launch = at("function Launch()");
let wait = at("function WaitReady(");
let stop = at("function StopAll()");
let restore = at("robocopy $Previous $InstallDir");
let report = at("function Report(");
assert!(launch < installer && wait < installer && stop < installer && report < installer, "the functions are defined before the installer runs");
assert!(robocopy_keep < installer && restore < installer, "the keep and the restore are functions defined before the installer line");
assert!(at("$kept = KeepPrevious") < installer, "the exe set is kept before the installer runs");
assert!(at("Comeback $code $kept") > installer, "the return runs after the installer");
for marker in ["'ok'", "'installer-failed'", "'rolled-back'", "'rolled-back-silent'", "'relaunched'"] {
assert!(h.contains(marker), "the helper never writes return={marker}");
}
assert!(h.contains("FAULT update-return:"), "the fault line");
assert!(h.contains("update-return: ok"), "the ok line");
assert!(h.contains("api/state"), "readiness is the engine's own answer");
assert!(!h.contains("-IntakeKey"), "no key travels on a command line (R4.3.8)");
}
}
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
let mut c = Command::new(crate::platform::tool("curl"));
c.args(args);
@ -1613,194 +1261,60 @@ case "$MODE" in
esac
"#;
/// The Windows helper. Not cfg-gated so the return test above can read it on every platform.
#[allow(dead_code)]
#[cfg(windows)]
const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine (src/ota.rs). Not for running by hand.
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex> -Previous <folder> -Machine <id8> -Intake <url> -AppDir <app data>
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex>
# The installer runs FIRST, while the engine keeps mining (4 October 2026: two unattended PCs sat stopped at an
# administrator prompt nobody could click). A per-user installer (0.3.3 and later, PrivilegesRequired=lowest) needs no
# prompt; an older administrator installer raises one through ShellExecute. Only when the installer actually runs does
# its PrepareToInstall step stop the engine (api/quit: miners first, then the node) and replace the files. A declined,
# timed-out or unanswered prompt leaves the engine running: the result says deferred:true and the engine shows "waits for
# the next time someone is at this PC".
# From 0.3.21 (MF-11, 7 October 2026) this helper owns the return: it keeps the running exe set beside the app before the
# installer runs, starts the app itself afterwards (/IGNOTA=2 tells the installer not to), waits for an engine to answer
# api/state with the new version, restores the kept set when nothing answers inside the window, and posts one line to
# the log intake either way (the key is read from igneum-app.json beside the exe, never from the command line). The
# sequence is src/ota.rs return_sequence(), tested there with injected exit codes; this file mirrors it step for step.
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '', [string]$Previous = '', [string]$Machine = '', [string]$Intake = '', [string]$AppDir = '')
# its PrepareToInstall step stop the engine (api/quit: miners first, then the node), replace the files and relaunch
# the app (/IGNOTA=1). A declined, timed-out or unanswered prompt leaves the engine running: the result says
# deferred:true and the engine shows "waits for the next time someone is at this PC". The old app is relaunched only
# when the engine is gone and the install did not happen.
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '')
$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log'
if (-not $AppDir) { $AppDir = Split-Path -Parent $Result }
$ReadyS = 120
$PollS = 3
function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) }
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred, [string]$ret, [int]$readyS) {
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s); 'return' = $ret; ready_s = $readyS }
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred) {
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s) }
($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII
}
function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) }
function AppUrl() {
$f = Join-Path $AppDir 'app.url'
if (Test-Path $f) { return (Get-Content $f -Raw).Trim() }
return ''
}
function AppVersion() {
# the engine's own answer: GET <app.url>api/state and its version field (the URL file is rewritten by every start)
$u = AppUrl
if (-not $u) { return '' }
try { $r = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$r.version } catch { return '' }
}
function WaitReady([string]$want, [int]$limitS) {
# the seconds until an engine answered with the wanted version (any version when $want is empty); -1 when none did
$t0 = Get-Date
while (((Get-Date) - $t0).TotalSeconds -lt $limitS) {
$v = AppVersion
if ($v -and (($want -eq '') -or ($v -eq $want))) { return [int]((Get-Date) - $t0).TotalSeconds }
Start-Sleep -Seconds $PollS
}
return -1
}
function WaitInstallerClear() {
# no relaunch while another installer runs (its marker beside app.url, written by the installer's PrepareToInstall and
# removed at its end); a marker older than 15 min is an installer that died: ignored with a FAULT line (src/ota.rs relaunch_allowed)
$m = Join-Path $AppDir 'install-running.flag'
$t0 = Get-Date
while (Test-Path $m) {
$age = ((Get-Date) - (Get-Item $m).LastWriteTime).TotalSeconds
if ($age -gt 900) { Log ('FAULT update-return: a stale installer marker (' + [int]$age + ' s old) was ignored'); break }
if (((Get-Date) - $t0).TotalMinutes -gt 20) { Log 'FAULT update-return: an installer marker stayed 20 min; relaunching anyway'; break }
Log 'relaunch held: another installer is running (install-running.flag present)'
Start-Sleep -Seconds 5
}
}
function Launch() {
function Relaunch() {
if (EngineAlive) { return }
$exe = Join-Path $InstallDir 'igneum-app.exe'
if (-not (Test-Path $exe)) { Log ("nothing to start: " + $exe + " is missing"); return $false }
WaitInstallerClear
Log ("starting " + $exe + " --launch")
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null
return $true
if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
}
function StopAll() {
# the quit through the API first (miners, then the node), then whatever is left by name: the installer's own order
$u = AppUrl
if ($u) { try { Invoke-WebRequest -Uri ($u + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { } }
$deadline = (Get-Date).AddSeconds(40)
while ((Get-Date) -lt $deadline) {
if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break }
Start-Sleep -Milliseconds 500
}
foreach ($n in @('Igneum Miner', 'igneum-app', 'igneum-miner', 'igneum-worker-cuda', 'igneum-worker-opencl', 'igneumd')) {
Get-Process -Name $n -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }
}
Start-Sleep -Seconds 1
}
function Report([string]$line) {
# one line to the log intake, label fault-win-<id8>, as site/api/log.mjs expects; the key is the one the installed
# app carries (igneum-app.json beside the exe, or the kept copy); nothing to post with is logged, never fatal
Log $line
if (-not $Intake -or -not $Machine) { return }
$cfg = Join-Path $InstallDir 'igneum-app.json'
if (-not (Test-Path $cfg) -and $Previous) { $cfg = Join-Path $Previous 'igneum-app.json' }
if (-not (Test-Path $cfg)) { Log 'no igneum-app.json to read the intake key from; the line stays in this log'; return }
try {
$key = [string](Get-Content $cfg -Raw | ConvertFrom-Json).log_intake_key
if (-not $key) { Log 'igneum-app.json carries no intake key'; return }
$o = @{ label = ('fault-win-' + $Machine); machine = ($env:COMPUTERNAME + '-' + $Machine); run_id = ('update-return-' + $Version); lines = ("IGNEUM-APP version=" + $Version + " machine=" + $Machine + " platform=windows node=?`n" + $line) }
$bytes = [Text.Encoding]::UTF8.GetBytes(($o | ConvertTo-Json -Compress))
Invoke-RestMethod -Method Post -Uri $Intake -Headers @{ 'x-igneum-key' = $key } -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 30 | Out-Null
Log 'intake line posted'
} catch { Log ('intake post failed: ' + $_.Exception.Message) }
}
function KeepPrevious() {
# the running exe set beside the app, what a rollback restores; packs, build and dist are rebuilt or unneeded
if (-not $Previous) { return $false }
try {
& robocopy $InstallDir $Previous /MIR /XD packs build dist /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -lt 8 -and (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { Log ("previous version kept at " + $Previous); return $true }
Log ("robocopy could not keep the previous version (exit " + $LASTEXITCODE + ")")
} catch { Log ('could not keep the previous version: ' + $_.Exception.Message) }
return $false
}
function RestorePrevious() {
if (-not $Previous -or -not (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { return $false }
try {
& robocopy $Previous $InstallDir /MIR /XD packs build dist /R:2 /W:2 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -lt 8) { Log 'previous version restored over the install folder'; return $true }
Log ("robocopy could not restore the previous version (exit " + $LASTEXITCODE + ")")
} catch { Log ('could not restore the previous version: ' + $_.Exception.Message) }
return $false
}
function Comeback([int]$code, [bool]$kept) {
# src/ota.rs return_sequence(): Launch, WaitReady, then Done or StopAll, RestorePrevious, Launch, WaitReady, Done
if (-not (EngineAlive)) { Launch | Out-Null } else { Log 'the engine is still up after the installer (it did not stop it)' }
$want = ''
if ($code -eq 0) { $want = $Version }
$ready = WaitReady $want $ReadyS
if ($ready -ge 0) {
if ($code -eq 0) {
Done $true '' $false $false 'ok' $ready
Report ("update-return: ok " + $Version + " up in " + $ready + " s")
exit 0
}
Done $false ("the installer exited with code " + $code + " (see ota-setup.log); the previous version answers again") $false $false 'installer-failed' $ready
Report ("FAULT update-return: the installer of " + $Version + " exited with code " + $code + "; the previous version answered again after " + $ready + " s")
exit 1
}
Log ("no engine answered api/state with '" + $want + "' inside " + $ReadyS + " s; back to the previous version")
StopAll
if ($kept -and (RestorePrevious)) {
Launch | Out-Null
$r2 = WaitReady '' $ReadyS
if ($r2 -ge 0) {
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored") $true $false 'rolled-back' $r2
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s after the install; the previous exe set was restored and answers after " + $r2 + " s")
exit 1
}
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored but did not answer either") $true $false 'rolled-back-silent' -1
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s; the previous exe set was restored and did not answer inside " + $ReadyS + " s either; a hand start is needed on this PC")
exit 1
}
Launch | Out-Null
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; no kept version to restore; what is installed was started again") $false $false 'relaunched' -1
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s and no previous exe set was kept; what is installed was started again")
exit 1
}
Log "$Mode : engine $EnginePid installer '$Installer' version $Version previous '$Previous' (the engine keeps mining until the installer runs)"
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false '' -1; exit 1 }
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining until the installer runs)"
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 }
# the installer is hashed again right before it runs (R4.3.5)
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false '' -1; exit 1 }
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 }
$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower()
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false '' -1; exit 1 }
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false; exit 1 }
Log 'installer sha256 verified'
$kept = $false
if ($Mode -eq 'apply') { $kept = KeepPrevious }
$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log'
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=2', ('/LOG="' + $setupLog + '"'))
$code = -1
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
try {
# no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or
# timed-out prompt comes back here as an exception with the engine still mining
# console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
$code = $p.ExitCode
if ($p.ExitCode -eq 0) {
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true $false }
else { Done $true '' $false $false }
Log 'installer exit 0'
exit 0
}
Log ("installer exit " + $p.ExitCode)
Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false
Relaunch
exit 1
} catch {
$msg = $_.Exception.Message
Log ("installer did not run: " + $msg)
Log 'OTA: waiting for administrator approval; the engine keeps mining; the update waits for the next time someone is at this PC'
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true '' -1
if (-not (EngineAlive)) { Launch | Out-Null }
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
Relaunch
exit 1
}
Log ("installer exit " + $code)
if ($Mode -eq 'rollback') {
# the kept installer of the previous version ran: the same return, reported as the rollback it is
if (-not (EngineAlive)) { Launch | Out-Null }
$r = WaitReady '' $ReadyS
Done $false ("Igneum Miner " + $Version + " did not stay up twice; the previous version was reinstalled") $true $false 'rolled-back' $r
Report ("FAULT update-return: " + $Version + " did not stay up twice; the previous version was reinstalled (installer exit " + $code + ", answered after " + $r + " s)")
exit 1
}
Comeback $code $kept
"#;

View file

@ -13,7 +13,7 @@ pub fn tool(name: &str) -> PathBuf {
let sys = format!("{root}\\System32");
let p = match name {
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" | "wevtutil" | "robocopy" => format!("{sys}\\{name}.exe"),
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
"nvidia-smi" => {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
@ -206,17 +206,6 @@ pub fn open_url(url: &str) {
let _ = Command::new("xdg-open").arg(url).spawn();
}
/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with
/// the file selected, the folder on other systems.
pub fn reveal_file(path: &std::path::Path) {
#[cfg(target_os = "macos")]
let _ = Command::new(tool("open")).arg("-R").arg(path).spawn();
#[cfg(windows)]
let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn();
#[cfg(not(any(target_os = "macos", windows)))]
let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn();
}
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
/// which must be refreshed (call `keep_awake_tick` every minute).
pub struct KeepAwake {
@ -268,49 +257,6 @@ pub fn keep_awake_tick() {
}
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
/// Every `igneum-miner` process on this machine with its command line: (pid, command line). Windows reads
/// Win32_Process through PowerShell; unix reads `ps`. An empty list when the tool fails (the caller kills nothing).
pub fn miner_processes() -> Vec<(u32, String)> {
let out = if cfg!(windows) {
let mut c = std::process::Command::new(tool("powershell"));
c.args(["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='igneum-miner.exe'\" | ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"]);
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(20))
} else {
let mut c = std::process::Command::new("ps");
c.args(["-eo", "pid=,args="]);
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(10))
};
let Some(out) = out else { return vec![] };
let mut v = Vec::new();
for l in out.lines() {
let l = l.trim();
let (pid, cmd) = if cfg!(windows) {
let Some((p, c)) = l.split_once('|') else { continue };
(p.trim(), c.trim())
} else {
let Some((p, c)) = l.split_once(' ') else { continue };
(p.trim(), c.trim())
};
let Ok(pid) = pid.parse::<u32>() else { continue };
if !cfg!(windows) && !(cmd.contains("igneum-miner ") || cmd.ends_with("igneum-miner")) {
continue;
}
if cmd.contains(" mine ") {
v.push((pid, cmd.to_string()));
}
}
v
}
/// Ends one process by pid (Windows: taskkill /T /F; unix: SIGKILL).
pub fn kill_pid(pid: u32) {
if cfg!(windows) {
let _ = quiet(&mut std::process::Command::new(tool("taskkill"))).args(["/PID", &pid.to_string(), "/T", "/F"]).output();
} else {
let _ = quiet(&mut std::process::Command::new("kill")).args(["-9", &pid.to_string()]).output();
}
}
/// std (what today's launcher does with taskkill /F).
pub fn terminate(child: &mut std::process::Child) {
#[cfg(unix)]

View file

@ -1,4 +1,4 @@
//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
//! One administrator approval, ever (the founder, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
//! "can we make sure all these popups are not needed in future?").
//!
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
@ -35,46 +35,6 @@ use std::time::{Duration, Instant};
pub const TASK_NAME: &str = "Igneum Power Helper";
/// The helper ends after this long without a new command.
pub const IDLE_S: u64 = 20 * 60;
/// The heartbeat file the helper refreshes every poll; an engine judges "the helper runs" by it, never by a flag
/// of its own (6 October 2026, PC 1: the flag said yes after the helper's idle exit, and commands went to nobody).
pub const ALIVE_FILE: &str = "helper.alive";
/// A heartbeat older than this is a dead helper.
pub const ALIVE_MAX_S: u64 = 4;
/// Writes the heartbeat: the unix time, as text.
pub fn beat(dir: &Path, now: u64) {
let _ = std::fs::write(dir.join(ALIVE_FILE), now.to_string());
}
/// Reads a heartbeat: is the helper alive at `now`?
pub fn alive_at(text: &str, now: u64) -> bool {
text.trim().parse::<u64>().map(|t| now.saturating_sub(t) <= ALIVE_MAX_S).unwrap_or(false)
}
/// Is the helper that reads `dir` alive now?
pub fn alive(dir: &Path) -> bool {
std::fs::read_to_string(dir.join(ALIVE_FILE)).map(|t| alive_at(&t, crate::platform::unix_now())).unwrap_or(false)
}
/// Starts the task unless the helper already runs, then waits for a fresh heartbeat (up to `wait`). The engine
/// writes a command only after this says Ok: the helper skips what the file held before its start (a stale quit
/// is not a command), so a command written before the start would be skipped with it (6 October 2026, 17:55:25Z
/// on PC 1: the first request of the first tune).
pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
if alive(dir) {
return Ok(());
}
let _ = std::fs::create_dir_all(dir);
start()?;
let until = Instant::now() + wait;
while Instant::now() < until {
std::thread::sleep(Duration::from_millis(250));
if alive(dir) {
return Ok(());
}
}
Err(format!("the Igneum Power Helper task gave no heartbeat within {} s of its start", wait.as_secs()))
}
/// One parsed command from cmd.txt.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -91,9 +51,6 @@ pub enum HelperCmd {
/// re-point the task at the installed exe (no path argument: the helper finds the install folder itself, so a
/// writer of cmd.txt can never choose what runs elevated); 6 October 2026, run 6 registered a scratch copy
Reregister,
/// the unattended driver install (src/driverinstall.rs): a vendor WORD only; the helper resolves the file, hash,
/// signer and arguments from the signed table itself
Driver(String),
}
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
@ -118,42 +75,10 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
[_, "rmc"] => Some((seq, HelperCmd::MemReset)),
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
[_, "reregister"] => Some((seq, HelperCmd::Reregister)),
[_, "driver", v] if crate::driverinstall::vendor_ok(v) => Some((seq, HelperCmd::Driver(v.to_string()))),
_ => None,
}
}
/// The sequence number a command line carries on the wire. The helper runs a line only when its number is above
/// every number it has seen (its `last_seq`, seeded from the file at its start), so every writer must draw from
/// ONE monotonic space: the unix time modulo a million (six digits, what `parse_line` accepts), plus a small
/// offset per line. (F) 6 October 2026, 22:19Z on PC 1: the tune path wrote its request index ("00 dev 1", "01 pl
/// 160", ...) while the cap path had written 305327 and up, so the helper skipped every tune command as stale and
/// both climbs stopped on "the helper did not run sequence 1 within 15 s". Wraps every 11.6 days; the helper's
/// idle exit (20 minutes) re-seeds it from the file, so a wrap costs at most one tune step.
pub fn wire_seq() -> u64 {
crate::platform::unix_now() % 999_990
}
/// How many leading lines of `text` are still the ones the helper saw at its start: `skip` while the file only grew
/// and its first `skip` lines read as before; 0 when the file shrank OR was rewritten (the same count, other text).
/// PC 2, 7 October 2026 (every tune refused since the 14:35Z boot, "helper started" three times with no command run):
/// the engine writes its four command lines with fs::write over a stale four-line file, so the count never dropped,
/// the skip never reset, and the helper read every new command as "present at start".
pub fn effective_skip(initial: &str, text: &str, skip: usize) -> usize {
if text.lines().count() < skip {
return 0;
}
let same_prefix = text.lines().take(skip).eq(initial.lines().take(skip));
if same_prefix { skip } else { 0 }
}
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
/// when the file shrank or was rewritten: effective_skip). A stale `quit` or `remove` from an earlier engine is never a command.
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
let skip = if text.lines().count() < skip { 0 } else { skip };
text.lines().skip(skip).filter_map(parse_line).collect()
}
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
match c {
@ -222,12 +147,9 @@ pub fn start_command() -> String {
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
}
/// The PowerShell that says whether the task is registered AND its action's exe is still there (exit 0), or not (exit 1).
/// A task whose exe has gone (the stale-task class of 7 October 2026: a logon task pointing at a folder that was not
/// there any more) reads as not registered, so the next cap apply with Power control on registers it again through the
/// one approved step instead of starting a task that cannot run.
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
pub fn query_command() -> String {
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
}
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
@ -273,29 +195,20 @@ pub fn run_helper(dir: &Path) -> i32 {
}
};
log("helper started (scheduled task, elevated)");
beat(dir, crate::platform::unix_now());
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
// same second, and every later start did the same; so the lines present at the start are skipped whole
// (quit and remove included), and a file that shrinks starts the count again
let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default();
let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
let mut skip = initial.lines().count();
let initial_text = initial.clone();
let mut last_text = initial;
// a stale file from an earlier run is not a command: only lines after the start count
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
let mut last_text = String::new();
let mut dev = "0".to_string();
let mut idle = Instant::now();
let smi = crate::platform::tool("nvidia-smi");
loop {
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
if text != last_text {
skip = effective_skip(&initial_text, &text, skip);
last_text = text.clone();
for (seq, c) in commands_after(&text, skip) {
for (seq, c) in text.lines().filter_map(parse_line) {
match c {
HelperCmd::Quit => {
log("quit");
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return 0;
}
HelperCmd::Remove => {
@ -327,11 +240,6 @@ pub fn run_helper(dir: &Path) -> i32 {
let now = q.output().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
log(&format!("{seq} reregister {}: the task now runs {now}", if ok { "ok" } else { "failed" }));
}
HelperCmd::Driver(v) => {
last_seq = seq;
crate::driverinstall::run_in_helper(dir, seq, &v, &log);
idle = Instant::now();
}
HelperCmd::Dev(d) => {
last_seq = seq;
idle = Instant::now();
@ -353,10 +261,8 @@ pub fn run_helper(dir: &Path) -> i32 {
}
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
log("idle 20 min: exit (the engine starts the task again when it needs it)");
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return 0;
}
beat(dir, crate::platform::unix_now());
std::thread::sleep(Duration::from_millis(500));
}
}
@ -376,20 +282,6 @@ pub fn helper_dir() -> PathBuf {
mod tests {
use super::*;
/// Known-failed first (7 October 2026, 19:5x BST): the helper knew no driver verb, so a driver install needed an
/// elevated prompt. The verb carries a vendor WORD only: the helper resolves the file, the hash, the signer and the
/// arguments from the signed table itself, so a writer of cmd.txt can never choose what runs elevated.
#[test]
fn the_helper_takes_a_driver_verb_for_a_vendor_word_and_nothing_else() {
assert_eq!(parse_line("305327 driver intel"), Some((305327, HelperCmd::Driver("intel".into()))));
assert_eq!(parse_line("305328 driver nvidia"), Some((305328, HelperCmd::Driver("nvidia".into()))));
assert_eq!(parse_line("305329 driver amd"), Some((305329, HelperCmd::Driver("amd".into()))));
assert_eq!(parse_line("305330 driver C:\\evil.exe"), None, "a path is not a vendor word");
assert_eq!(parse_line("305331 driver apple"), None, "no installer for that vendor");
assert_eq!(parse_line("driver intel"), None, "a sequence number is required");
assert_eq!(smi_args("0", &HelperCmd::Driver("intel".into())), None, "a driver verb is never an nvidia-smi call");
}
#[test]
fn only_fixed_verbs_with_digit_arguments_parse() {
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
@ -404,32 +296,6 @@ mod tests {
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
// a stale quit present at the start is skipped; a quit added later counts; a rewritten (shorter) file counts whole
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]);
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]);
assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]);
// PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's
// four-line rewrite: the count never dropped, so the old rule skipped every new command
let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n";
let fresh = "401888 dev 0\n401889 pl 575\n401890 rgc\n401891 rmc\n";
assert_eq!(commands_after(fresh, 4), vec![], "the old rule: a same-length rewrite is invisible");
assert_eq!(effective_skip(stale, fresh, 4), 0, "a rewrite resets the skip");
assert_eq!(commands_after(fresh, effective_skip(stale, fresh, 4)).len(), 4, "every new command runs");
// the file only grew: the stale prefix stays skipped (a stale quit at start is never a command)
let grown = format!("{stale}401888 dev 0\n");
assert_eq!(effective_skip(stale, &grown, 4), 4);
assert_eq!(commands_after(&grown, effective_skip(stale, &grown, 4)), vec![(401888, HelperCmd::Dev("0".into()))]);
assert_eq!(effective_skip("quit\n", "quit\n401888 dev 0\n", 1), 1, "a stale quit stays skipped while the file only grows");
assert_eq!(effective_skip(stale, "401888 dev 0\n", 4), 0, "a shorter file resets as before");
assert_eq!(effective_skip("", fresh, 0), 0);
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
// (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step
let w = wire_seq();
assert!(w + 3 <= 999_999 && parse_line(&format!("{} rmc", w + 3)).is_some());
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
assert!(!alive_at("", 1791309325));
assert!(!alive_at("soon", 1791309325));
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
assert!(readback_command().contains("Actions[0].Execute"));
@ -462,14 +328,6 @@ mod tests {
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
// no window at logon or at a start (the project lead, 7 October 2026, PC 2's Terminal window): the action is the app's own exe,
// a windows-subsystem program with no console, never powershell.exe, cmd.exe or a `start` of a batch file
assert!(s.contains("-Execute 'C:\\p\\igneum-app.exe' -Argument '--power-helper'") || s.contains("-Argument '--power-helper'"), "{s}");
assert!(!s.contains("powershell.exe") && !s.contains("cmd.exe") && !s.contains("cmd /c start"), "the task's action must be the exe itself: {s}");
assert!(s.contains("-Hidden"), "the task is hidden in the scheduler too");
// the registered probe also wants the action's exe on disk and the task enabled (the stale-task class)
let q = query_command();
assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}");
// a quote in the path cannot break out of the literal
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");

View file

@ -1,4 +1,4 @@
//! Proving v1 step 1 (5 October 2026, the project lead: "open the proving round asap"): the prover is on by default on every
//! Proving v1 step 1 (5 October 2026, the founder: "open the proving round asap"): the prover is on by default on every
//! mining machine that can prove, decided once per install after the cards are detected (src/engine.rs
//! `apply_prove_default`). The rule, one line each:
//!
@ -6,13 +6,13 @@
//! |---|---|---|
//! | NVIDIA card with 24 GB or more, mining or not, Windows with WSL2 (Ubuntu-24.04) answering or Linux | on | a full shard at the adopted v1 budget (30,000 pgas, 4.7 M cycles) peaks at 20,434 MiB alone and 22,210 beside the miner (measured on the 5090; approximate for a 24 GB card's own allocation); the prototype shard the devnet proves until its fee switch (6.75 M pgas) peaks at 28,307 MiB alone and 30,039 beside the miner, so until the switch only a 32 GB card proves it and a 24 GB card's prover waits for shards it can hold (the host refuses nothing; a proof that runs out of memory fails and the shard is left) |
//! | NVIDIA card of 16 to 24 GB | off, with the line saying why | the GPU prover's floor is 13,874 MiB for an EMPTY shard, 15,670 beside the miner; a 16 GB card holds no full shard |
//! | NVIDIA card under 16 GB | off | 13,874 MiB does not fit; the project lead's 12 GB requirement is open until a prover build with a smaller floor is measured |
//! | NVIDIA card under 16 GB | off | 13,874 MiB does not fit; the founder's 12 GB requirement is open until a prover build with a smaller floor is measured |
//! | Windows under 32 GB of RAM | off, with the line saying why | the WSL2 prover held 7.9 GB on a 63 GB PC; a 16 GB PC would swap |
//! | Windows with a qualifying card but WSL2 silent | off, with the Set up hint | nothing can prove until the distribution exists |
//! | Apple silicon | off | the M5 Max CPU took 41 to 55 s for an EMPTY shard's compressed proof under load and 272 s for a 200-pgas shard; a full shard was never under 60 s (bench-log 4 and 5 October 2026) |
//! | AMD-only (no NVIDIA card) | off, "mines and does not prove" | no zkVM proves on an AMD GPU today (docs/analysis/amd-proving.md); the SP1 CPU prover on PC 1 cost 82 to 87 s core plus 199 to 202 s compressed a shard at a 30 GB RSS whatever the shard size (bench-log, "the SP1 CPU prover on PC 1") |
//!
//! Decided 5 October 2026 (delegated by the project lead: "deploy what is absolute best"), docs/plans/proving-v1.md. The default
//! Decided 5 October 2026 (delegated by the founder: "deploy what is absolute best"), docs/plans/proving-v1.md. The default
//! never switches an explicit on back off, and Settings always wins afterwards.
use crate::state::CardState;
@ -26,39 +26,6 @@ pub const MIN_VRAM_MB_PROVE_ONLY: u64 = 23_552;
/// miner), the devnet's shard until its fee switch at DAA 210,000; `nvidia-smi` reports 32,607 for the RTX 5090.
pub const VRAM_MB_PROTOTYPE_SHARD: u64 = 31_000;
/// Main's rule (7 October 2026, the fleet's prover roll): a 10 GB card never completes the compressed step on the
/// 0.3.17 pair (p1-3080: 29 of 29 proofs died at the memory wall, device_used 9,859 of 9,885 MiB, 0 paid in 2,167
/// claims), so the prover REFUSES to start, Settings or not, unless an NVIDIA card of 12 GB or more is present, with
/// the reason shown; the lower-memory SP1 threshold is being measured on a rented 3080 and moves this line when
/// it lands. `nvidia-smi` reports a 12 GB card at about 12,208 MiB, a 10 GB card at about 10,240.
pub const MIN_VRAM_MB_PROVE_ANY: u64 = 11_800;
/// The sentence the Proving tile and the log carry when the rule refuses (verbatim from main; the kit's box-prover
/// says the same).
pub const PROVE_UNDER_12GB_LINE: &str = "proving needs a 12 GB card; mining continues";
/// The rule above as one question: None when a present NVIDIA card of 12 GB or more exists (or when no NVIDIA card
/// is present at all, since then the CPU and Apple paths decide), Some(the sentence) when every present NVIDIA card is
/// under 12 GB.
/// The sentence the tile carries while the switch holds the miner off.
pub fn prove_instead_line(cards: &[CardState]) -> String {
let names: Vec<String> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| format!("{} ({} GB)", c.name, gb(c.vram_mb))).collect();
format!("proving instead of mining on {} (a card under 12 GB holds one, not both)", names.join(", "))
}
/// The keys of the cards the switch holds off: every present NVIDIA card under 12 GB, and only when the rule refuses
/// (no present NVIDIA card at or above 12 GB); with a bigger card present the small ones keep mining and nothing is held.
pub fn prove_instead_cards(cards: &[CardState]) -> Vec<String> {
if prove_refused_under_12gb(cards).is_none() {
return Vec::new();
}
cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| c.key.clone()).collect()
}
pub fn prove_refused_under_12gb(cards: &[CardState]) -> Option<&'static str> {
let nvidia: Vec<&CardState> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present()).collect();
if nvidia.is_empty() || nvidia.iter().any(|c| c.vram_mb >= MIN_VRAM_MB_PROVE_ANY) { None } else { Some(PROVE_UNDER_12GB_LINE) }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Decision {
pub on: bool,
@ -199,78 +166,4 @@ mod tests {
let d = decide(&[idle("nvidia", "RTX 4090", 24_564), card("nvidia", "RTX 5090", 32_607)], "linux", None, None);
assert!(d.line.contains("RTX 5090 (32 GB, mining too)"), "{}", d.line);
}
#[test]
fn the_prover_refuses_every_nvidia_card_under_12gb_and_says_why() {
let c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
assert_eq!(prove_refused_under_12gb(&[c3080.clone()]), Some(PROVE_UNDER_12GB_LINE));
// a 12 GB card beside it lifts the refusal (nvidia-smi reports a 12 GB card at 12,208)
let c3080_12 = card("nvidia", "NVIDIA GeForce RTX 3080 12GB", 12_208);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3080_12]), None);
// an idle 24 GB card lifts it too
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3090]), None);
// no NVIDIA card at all: not this rule's question (Apple and AMD have their own sentences)
assert_eq!(prove_refused_under_12gb(&[card("amd", "AMD Radeon RX 9070 XT", 16_368), card("apple", "Apple M5 Max", 0)]), None);
// a card that left the machine does not count either way
let mut gone = card("nvidia", "NVIDIA GeForce RTX 3090", 24_564); gone.removed_at = 1.0;
assert_eq!(prove_refused_under_12gb(&[c3080, gone]), Some(PROVE_UNDER_12GB_LINE));
assert_eq!(PROVE_UNDER_12GB_LINE, "proving needs a 12 GB card; mining continues");
}
#[test]
fn prove_instead_holds_only_the_small_cards_and_only_when_the_rule_refuses() {
let mut c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
c3080.key = "nvidia:0:NVIDIA GeForce RTX 3080".into();
assert_eq!(prove_instead_cards(&[c3080.clone()]), vec!["nvidia:0:NVIDIA GeForce RTX 3080".to_string()]);
assert_eq!(prove_instead_line(&[c3080.clone()]), "proving instead of mining on NVIDIA GeForce RTX 3080 (10 GB) (a card under 12 GB holds one, not both)");
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
assert!(prove_instead_cards(&[c3080.clone(), c3090]).is_empty());
assert!(prove_instead_cards(&[card("amd", "AMD Radeon RX 9070 XT", 16_368)]).is_empty());
}
}
/// MF-10 (docs/plans/miner-faults.md, 7 October 2026): a `sp1-gpu-server` built for another card's architecture fails
/// every proof in 12 s with `CudaRustError: named symbol not found` and nothing notices. `card_cap` is the card's
/// compute capability as nvidia-smi prints it ("12.0", "8.9", "8.6"); `server_archs` are the `sm_NN` words found in
/// the server binary (empty = unknown, which passes: an SDK server may carry no arch string). Some(the sentence) when
/// the server names architectures and none is the card's.
pub fn server_mismatch(card_cap: &str, server_archs: &[String]) -> Option<String> {
let cap = card_cap.trim();
if cap.is_empty() || server_archs.is_empty() {
return None;
}
let want = format!("sm_{}", cap.replace('.', ""));
if server_archs.iter().any(|a| a.trim() == want) {
return None;
}
Some(format!("the proving server is built for {} and this card is {want} (compute capability {cap}); proving stays off here until a server for this card is installed", server_archs.join(", ")))
}
/// A proof failure line that names the architecture class (MF-10): the server's kernels do not load on this card.
pub fn is_arch_failure(text: &str) -> bool {
text.contains("named symbol not found") || text.contains("no kernel image is available")
}
#[cfg(test)]
mod arch_tests {
use super::*;
/// The prover roll, 7 October 2026: sm_86 servers on a 4070 (8.9) and a 5090 (12.0) failed every proof; the
/// 3080 and 3090 (8.6) proved.
#[test]
fn a_server_for_another_card_is_refused() {
let sm86 = vec!["sm_86".to_string()];
assert!(server_mismatch("8.9", &sm86).unwrap().contains("built for sm_86 and this card is sm_89"));
assert!(server_mismatch("12.0", &sm86).unwrap().contains("sm_120"));
assert_eq!(server_mismatch("8.6", &sm86), None);
// a fat binary names every card
let fat = vec!["sm_86".to_string(), "sm_89".to_string(), "sm_120".to_string()];
assert_eq!(server_mismatch("8.9", &fat), None);
// unknown on either side passes (the proof failure line is the second guard)
assert_eq!(server_mismatch("", &sm86), None);
assert_eq!(server_mismatch("8.9", &[]), None);
assert!(is_arch_failure("CudaRustError: named symbol not found"));
assert!(!is_arch_failure("PermissionDenied"));
}
}

View file

@ -135,22 +135,6 @@ struct Tools {
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
/// The exporter's failure line. When the node's export carries the account dump (0.3.14) and the exporter's output
/// never mentions it, the exporter at `path` predates 0.3.14: it replays the dump's own segment from the restart
/// state and fails there with "port state root differs" (PC 1, 6 October 2026 18:16Z, block 140,662: the installer
/// had not replaced igneum-prove-export); the line names the stale binary instead of the misleading root error.
fn exporter_failure(export_has_dump: bool, out: &str, path: &Path, block: Option<u64>) -> String {
let last = out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed");
let what = match block {
Some(b) => format!("exporter, block {b}"),
None => "exporter".to_string(),
};
if export_has_dump && !out.contains("account dump") {
return format!("{what}: stale igneum-prove-export at {}: the node's export carries the account dump and this exporter does not read it (it predates 0.3.14); install this release's exporter there ({last})", path.display());
}
format!("{what}: {last}")
}
/// state: unprovable on every node).
fn exec_boundary(shared: &Shared) -> u64 {
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
@ -167,9 +151,23 @@ fn exec_boundary(shared: &Shared) -> u64 {
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
}
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout)
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
None,
timeout + Duration::from_secs(2),
);
let _ = std::fs::remove_file(&tmp);
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
@ -384,8 +382,6 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
read_ids(&shared, &t);
}
}
// MF-10: the server architecture check, once per tools probe (None = not read yet; Some(None) = matches)
let mut arch_check: Option<Option<String>> = None;
loop {
std::thread::sleep(Duration::from_secs(10));
let enabled = shared.settings.lock().unwrap().prove;
@ -455,55 +451,6 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
}
}
}
// main's rule (7 October 2026, the fleet's prover roll): a 10 GB card dies at the compressed step every time
// (p1-3080, 29 of 29, 0 paid), so with every present NVIDIA card under 12 GB the prover refuses to start,
// Settings or not, and says why; the measured threshold moves the line (src/provedefault.rs)
if t.cuda {
let cards = shared.state.lock().unwrap().mining.cards.clone();
match crate::provedefault::prove_refused_under_12gb(&cards) {
Some(line) => {
// settings.prove_instead (main's routing): the owner chose the prover over the miner on this card; the
// engine holds the small cards' miners off while the prover runs and gives them back when it stops
let instead = shared.settings.lock().unwrap().prove_instead;
set(&shared, |p| p.under_12gb = true);
if instead {
shared.send(crate::engine::Cmd::ProveHold(true));
let msg = crate::provedefault::prove_instead_line(&cards);
set(&shared, |p| {
p.enabled = true;
p.available = true;
p.message = msg;
});
} else {
set(&shared, |p| {
p.enabled = true;
p.available = false;
p.status = "off".into();
p.message = line.into();
});
continue;
}
}
None => set(&shared, |p| p.under_12gb = false),
}
}
// MF-10 (7 October 2026, the prover roll): a GPU server built for another card's architecture fails every
// proof; the card's compute capability and the server's sm_ words are read once per tools probe, a mismatch
// refuses the GPU path with the reason on the tile
if t.cuda {
if arch_check.is_none() {
arch_check = Some(server_arch_check(&shared, t));
}
if let Some(Some(line)) = arch_check.as_ref() {
set(&shared, |p| {
p.enabled = true;
p.available = false;
p.status = "off".into();
p.message = line.clone();
});
continue;
}
}
set(&shared, |p| {
p.enabled = true;
p.available = true;
@ -515,16 +462,6 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
});
continue;
}
// ledger N7 (7 October 2026, PC 1 on 0.3.18): a node before the exec RPC fix dies on igneum_getAssignedShards
// and igneum_getProofRecords when its exec follower holds no record (rpc.rs slices records[1..=0]), and the
// node reads synced seconds before a slow follower has one; nothing below asks until the follower reports a tip
if !crate::update::exec_has_record(shared.runtime.evm_port()) {
set(&shared, |p| {
p.status = "waiting".into();
p.message = "waiting for the node's execution layer".into();
});
continue;
}
// 1. the keys and the work list
let labels = labels(&shared);
let mut keys: Vec<(String, String)> = Vec::new();
@ -562,7 +499,6 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
if paid {
submitted.retain(|x| !(x.1 == h && x.2 == s));
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
shared.ladder_shard_paid(n, s as u64, wei);
set(&shared, |p| {
p.paid += 1;
p.paid_wei += wei;
@ -729,7 +665,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
if !ok || !fixture.exists() {
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, None));
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
}
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
let prover_env = if t.cuda { "cuda" } else { "cpu" };
@ -738,10 +674,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
// inside WSL2 left /tmp/sp1-cuda-0.sock owned by root, and this user's client cannot open it
let hint = if crate::provedefault::is_arch_failure(&last) { " (MF-10: the proving server is built for another card's architecture; proving stays off here until a server for this card is installed)" } else if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
if crate::provedefault::is_arch_failure(&last) {
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, last.replace('"', "'")));
}
let hint = if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
return Err(format!("prover: {last}{hint}"));
}
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
@ -921,7 +854,7 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
let fixture = dir.join(format!("block-{b}.json"));
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
if !ok || !fixture.exists() {
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, Some(b)));
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
}
fixtures.push(as_host_path(&fixture));
}
@ -1159,21 +1092,6 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
mod tests {
use super::*;
#[test]
fn a_stale_exporter_is_named_when_the_export_carries_the_dump() {
let p = Path::new("C:/x/igneum-prove-export.exe");
let old_out = "Error: segment 140661: port state root 0xe45c differs from the node's 0xddd7; the port is not the node's executor, stop\n";
let line = exporter_failure(true, old_out, p, Some(140662));
assert!(line.contains("stale igneum-prove-export at C:/x/igneum-prove-export.exe"), "{line}");
assert!(line.starts_with("exporter, block 140662:"));
// the 0.3.14 exporter read the dump and failed later: the real line, not the stale one
let new_out = "account dump: 79 accounts after chain block 140661, state root 0x1 (equals the node's)\nError: segment 140662: port state root 0x2 differs from the node's 0x3\n";
let line = exporter_failure(true, new_out, p, None);
assert_eq!(line, "exporter: Error: segment 140662: port state root 0x2 differs from the node's 0x3");
// an export without a dump (an older node): never the stale line
assert_eq!(exporter_failure(false, old_out, p, None), format!("exporter: {}", old_out.trim()));
}
#[test]
fn pinned_ids_come_out_of_the_hosts_id_line() {
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
@ -1214,23 +1132,3 @@ mod tests {
assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer"));
}
}
/// MF-10: the card's compute capability and the GPU server's architecture words, read through the same shell the
/// tools live in (WSL2 on Windows). None = no mismatch (or nothing readable); Some(line) = refuse with this reason.
fn server_arch_check(shared: &Shared, t: &Tools) -> Option<String> {
let script = "cap=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1); srv=\"$HOME/.sp1/bin/sp1-gpu-server\"; archs=$( [ -f \"$srv\" ] && strings \"$srv\" 2>/dev/null | grep -o 'sm_[0-9]*' | sort -u | tr '\\n' ' '); echo \"CAP=$cap\"; echo \"ARCHS=$archs\"";
let out = if t.wsl {
let file = crate::wslhost::write_script("prove-arch", script).ok()?;
crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string())?
} else {
crate::detect::run_timeout(std::process::Command::new("bash").args(["-c", script]), None, Duration::from_secs(20))?
};
let cap = out.lines().find_map(|l| l.strip_prefix("CAP=")).unwrap_or("").trim().to_string();
let archs: Vec<String> = out.lines().find_map(|l| l.strip_prefix("ARCHS=")).unwrap_or("").split_whitespace().map(|s| s.to_string()).collect();
shared.log(&format!("prover: card compute capability {}, server architectures {}", if cap.is_empty() { "unknown" } else { &cap }, if archs.is_empty() { "unknown".to_string() } else { archs.join(" ") }));
let line = crate::provedefault::server_mismatch(&cap, &archs);
if let Some(l) = &line {
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, l.replace('"', "'")));
}
line
}

View file

@ -1,178 +0,0 @@
//! The prover's working directory (`<app dir>/proving`) owns its disk (0.3.16, 6 October 2026): the fleet's segment
//! exports (50 to 500 MB each with the old [0, last] export, never pruned) filled 60 GB on the hub and 3 to 46 GB on
//! every standing box, and the hub's node died three times on "No space left on device" (the last at 21:42 UK).
//! Rules: a size cap and an age cap on the directory (defaults that fit a 100 GB box for a week), enforced before
//! every export; a segment's directory is deleted the moment its record is submitted or paid; no export starts
//! below 10% free disk, and the skip is logged.
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// Defaults: 20 GB and 7 days. `IGNEUM_PROVING_DIR_MAX_GB` and `IGNEUM_PROVING_DIR_MAX_DAYS` change them.
pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024 * 1024;
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
/// No export below this share of free disk.
pub const MIN_FREE_FRACTION: f64 = 0.10;
pub fn max_bytes() -> u64 {
std::env::var("IGNEUM_PROVING_DIR_MAX_GB").ok().and_then(|v| v.parse::<u64>().ok()).map(|g| g * 1024 * 1024 * 1024).unwrap_or(DEFAULT_MAX_BYTES)
}
pub fn max_age() -> Duration {
std::env::var("IGNEUM_PROVING_DIR_MAX_DAYS").ok().and_then(|v| v.parse::<u64>().ok()).map(|d| Duration::from_secs(d * 24 * 3600)).unwrap_or(DEFAULT_MAX_AGE)
}
/// One entry of the directory as the planner sees it: a top-level file or a segment directory, its total bytes
/// and its age.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub path: PathBuf,
pub bytes: u64,
pub age: Duration,
}
/// What to delete so the directory fits: everything older than `max_age`, then the oldest entries until the total
/// is at or under `max_bytes`. Pure, so the cap is unit-tested.
pub fn prune_plan(entries: &[Entry], max_bytes: u64, max_age: Duration) -> Vec<PathBuf> {
let mut keep: Vec<&Entry> = Vec::new();
let mut out: Vec<PathBuf> = Vec::new();
for e in entries {
if e.age > max_age {
out.push(e.path.clone());
} else {
keep.push(e);
}
}
let mut total: u64 = keep.iter().map(|e| e.bytes).sum();
// oldest first
keep.sort_by(|a, b| b.age.cmp(&a.age));
for e in keep {
if total <= max_bytes {
break;
}
total = total.saturating_sub(e.bytes);
out.push(e.path.clone());
}
out
}
fn dir_bytes(p: &Path) -> u64 {
let mut total = 0;
if let Ok(rd) = std::fs::read_dir(p) {
for e in rd.flatten() {
let m = match e.metadata() {
Ok(m) => m,
Err(_) => continue,
};
total += if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
}
}
total
}
/// The directory's entries, oldest by modification time.
pub fn scan(dir: &Path) -> Vec<Entry> {
let now = SystemTime::now();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let Ok(m) = e.metadata() else { continue };
let age = m.modified().ok().and_then(|t| now.duration_since(t).ok()).unwrap_or_default();
let bytes = if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
out.push(Entry { path: e.path(), bytes, age });
}
}
out
}
/// Enforces the caps on `dir`; returns (entries deleted, bytes freed).
pub fn enforce(dir: &Path) -> (usize, u64) {
let entries = scan(dir);
let plan = prune_plan(&entries, max_bytes(), max_age());
let mut freed = 0;
for p in &plan {
if let Some(e) = entries.iter().find(|e| &e.path == p) {
freed += e.bytes;
}
let _ = if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) };
}
(plan.len(), freed)
}
/// Free disk as a share of the volume `path` is on; None when the platform call fails.
#[cfg(unix)]
pub fn free_fraction(path: &Path) -> Option<f64> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
return None;
}
let total = st.f_blocks as f64 * st.f_frsize as f64;
if total <= 0.0 {
return None;
}
Some(st.f_bavail as f64 * st.f_frsize as f64 / total)
}
#[cfg(windows)]
pub fn free_fraction(path: &Path) -> Option<f64> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
extern "system" {
fn GetDiskFreeSpaceExW(dir: *const u16, avail: *mut u64, total: *mut u64, free: *mut u64) -> i32;
}
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let (mut avail, mut total, mut free) = (0u64, 0u64, 0u64);
if unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut avail, &mut total, &mut free) } == 0 || total == 0 {
return None;
}
Some(avail as f64 / total as f64)
}
/// The pre-export gate: the caps enforced, then the free-disk check. Err carries the line to log when the export
/// must be skipped.
pub fn before_export(dir: &Path) -> Result<(), String> {
let _ = std::fs::create_dir_all(dir);
let (n, freed) = enforce(dir);
if n > 0 {
eprintln!("prover: proving dir cap: {n} entries deleted, {} MB freed", freed / (1024 * 1024));
}
match free_fraction(dir) {
Some(f) if f < MIN_FREE_FRACTION => Err(format!("no export: {:.1}% of the disk is free, under the {:.0}% floor; the proving dir is {} MB after its cap; free space or lower IGNEUM_PROVING_DIR_MAX_GB", f * 100.0, MIN_FREE_FRACTION * 100.0, dir_bytes(dir) / (1024 * 1024))),
_ => Ok(()),
}
}
/// A segment's directory goes the moment its record is submitted or paid.
pub fn remove_segment(dir: &Path, first: u64) {
let _ = std::fs::remove_dir_all(dir.join(format!("seg-{first}")));
}
#[cfg(test)]
mod tests {
use super::*;
fn e(name: &str, mb: u64, days: u64) -> Entry {
Entry { path: PathBuf::from(name), bytes: mb * 1024 * 1024, age: Duration::from_secs(days * 24 * 3600) }
}
#[test]
fn the_cap_deletes_the_old_then_the_oldest_until_it_fits() {
let entries = vec![e("seg-1", 400, 9), e("seg-2", 300, 3), e("seg-3", 300, 2), e("seg-4", 200, 1), e("block-5.json", 1, 0)];
// the age cap takes seg-1; the size cap (600 MB) then takes seg-2 (oldest kept), leaving 501 MB
let plan = prune_plan(&entries, 600 * 1024 * 1024, Duration::from_secs(7 * 24 * 3600));
assert_eq!(plan, vec![PathBuf::from("seg-1"), PathBuf::from("seg-2")]);
// under both caps: nothing
assert!(prune_plan(&entries[1..], 2 * 1024 * 1024 * 1024, Duration::from_secs(30 * 24 * 3600)).is_empty());
// a 100 GB box for a week: the default caps leave 80 GB to the node and the system
assert_eq!(DEFAULT_MAX_BYTES, 20 * 1024 * 1024 * 1024);
assert_eq!(DEFAULT_MAX_AGE, Duration::from_secs(7 * 24 * 3600));
}
#[test]
fn the_free_check_answers_on_this_machine() {
let f = free_fraction(Path::new(".")).expect("statvfs");
assert!((0.0..=1.0).contains(&f));
}
}

View file

@ -1,391 +0,0 @@
//! Every right the app will ever need is taken ONCE, at install, by one elevated step; the app never prompts at runtime;
//! an update asks again only when the list of rights grew (the project lead, 7 October 2026: "all the 'rights' need to be done on
//! install, and then on update if anything new").
//!
//! The step asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment):
//! a PC job may never need a click, so a silent install from a job logs "rights: deferred" and the next interactive start
//! of the app asks once. The WebView2 runtime is a right too, "webview2-runtime@<min>" with <min> the host loader's minimum
//! (app/windows/version.h IGNEUM_WEBVIEW2_MIN): the elevated step reads the Edge WebView2 client key (HKLM WOW6432Node and
//! HKCU) and runs the bundled evergreen bootstrapper silently when the runtime is absent or below <min>; a raised minimum is
//! a new id, so that update asks once.
//!
//! Windows: the installer's [Run] entry `igneum-app.exe --rights` (every install, silent ones included) compares the
//! installed rights manifest (`<app data>/app/rights.json`: the version and the list the elevated step completed) with
//! this build's RIGHTS; when a right is missing it writes rights.ps1 and runs it elevated once (the one UAC prompt: the
//! Power Helper task for clock and power control, the boot task, the inbound firewall rules for the node and the pool
//! miner), then writes the manifest; when nothing is missing it exits at once with no prompt. At runtime: Power control
//! is a plain toggle (the Power Helper task does the work with no prompt); a right the manifest lacks is a notice
//! ("run the installer again"), never a prompt. The prompt counts are a function here, tested with the known-failed
//! shapes first (before 0.3.22: a fresh install then Power control on prompted; the firewall rule prompted on the first
//! run; the boot task was registered at the engine's start).
use std::path::{Path, PathBuf};
/// The rights this build needs, in the order the elevated step takes them. An id never changes meaning; a new need is
/// a new id (that is what makes an update ask once).
pub const RIGHTS: &[(&str, &str)] = &[
("power-helper-task", "the Igneum Power Helper task: the clock and power limits of NVIDIA cards with no prompt (src/powertask.rs)"),
("boot-task", "the Igneum Miner (boot) task: the engine starts at boot with nobody logged on (src/boot.rs)"),
("firewall-node", "the inbound firewall rule for igneumd.exe (other nodes can dial in)"),
("firewall-miner", "the inbound firewall rule for igneum-miner.exe (a pool's stratum port)"),
(WEBVIEW2_RIGHT, "the WebView2 runtime the window needs, at or above the host loader's minimum (installed silently from the bundled evergreen bootstrapper when absent or older)"),
// the driver lane's right (branch driver-hold-22, src/driverinstall.rs): the same Power Helper task with a two-hour run limit, so a
// vendor's driver installs unattended through it; at the merge the Power Helper line of script() becomes driverinstall::register_script
("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)"),
];
/// The host loader's minimum WebView2 runtime (app/windows/version.h IGNEUM_WEBVIEW2_MIN), read at build time so the two
/// never drift; the right's id carries it.
pub const WEBVIEW2_MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
pub const WEBVIEW2_RIGHT: &str = const_format_webview2_right();
/// The bundled bootstrapper in the install folder and its pinned sha file (packaging/windows/webview2.sha256).
pub const WEBVIEW2_BOOTSTRAPPER: &str = "MicrosoftEdgeWebview2Setup.exe";
pub const WEBVIEW2_KEY: &str = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
const fn webview2_min_from_header(h: &str) -> &str {
// the line `#define IGNEUM_WEBVIEW2_MIN "a.b.c.d"`: the text between the quotes
let b = h.as_bytes();
let key = b"IGNEUM_WEBVIEW2_MIN \"";
let mut i = 0;
while i + key.len() < b.len() {
let mut j = 0;
while j < key.len() && b[i + j] == key[j] {
j += 1;
}
if j == key.len() {
let start = i + key.len();
let mut end = start;
while end < b.len() && b[end] != b'"' {
end += 1;
}
// SAFETY of the slice: start and end sit on ASCII bytes of a str literal
match h.split_at(start).1.split_at(end - start).0 {
s => return s,
}
}
i += 1;
}
"0"
}
const fn const_format_webview2_right() -> &'static str {
// "webview2-runtime@" + WEBVIEW2_MIN, built once at compile time
const PREFIX: &str = "webview2-runtime@";
const MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
const LEN: usize = PREFIX.len() + MIN.len();
const BUF: [u8; LEN] = {
let mut out = [0u8; LEN];
let p = PREFIX.as_bytes();
let m = MIN.as_bytes();
let mut i = 0;
while i < p.len() {
out[i] = p[i];
i += 1;
}
let mut j = 0;
while j < m.len() {
out[p.len() + j] = m[j];
j += 1;
}
out
};
match std::str::from_utf8(&BUF) {
Ok(s) => s,
Err(_) => "webview2-runtime@0",
}
}
pub const MANIFEST_FILE: &str = "rights.json";
pub const SCRIPT_FILE: &str = "rights.ps1";
/// The manifest the elevated step leaves: which rights hold, from which version, when.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Manifest {
pub version: String,
pub rights: Vec<String>,
pub at: u64,
}
impl Manifest {
pub fn parse(text: &str) -> Option<Manifest> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Manifest {
version: v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(),
rights: v.get("rights").and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|r| r.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(),
at: v.get("at").and_then(|x| x.as_u64()).unwrap_or(0),
})
}
pub fn to_json(&self) -> String {
serde_json::json!({ "version": self.version, "rights": self.rights, "at": self.at, "format": "igneum-rights-1" }).to_string()
}
pub fn load(app_dir: &Path) -> Option<Manifest> {
std::fs::read_to_string(app_dir.join(MANIFEST_FILE)).ok().and_then(|t| Manifest::parse(&t))
}
pub fn save(&self, app_dir: &Path) -> std::io::Result<()> {
std::fs::write(app_dir.join(MANIFEST_FILE), self.to_json())
}
}
/// The ids this build wants that the installed manifest does not hold (every id when there is no manifest).
pub fn missing(installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> Vec<String> {
let have: Vec<&str> = installed.map(|m| m.rights.iter().map(|s| s.as_str()).collect()).unwrap_or_default();
wanted.iter().map(|(id, _)| *id).filter(|id| !have.contains(id)).map(|s| s.to_string()).collect()
}
/// Where the step runs: an interactive session that is not a job's may ask; anything else defers (the project lead, 7 October 2026:
/// no PC job may need a click).
pub fn may_ask(session_name: Option<&str>, job_env: bool) -> bool {
crate::boot::interactive_session(session_name) && !job_env
}
/// Is this process inside a remote job (the runner's IGNEUM_JOB_* environment)?
pub fn in_job_env() -> bool {
std::env::vars().any(|(k, _)| k.starts_with("IGNEUM_JOB_"))
}
/// The outcome of the install step.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
/// nothing missing: no prompt
Nothing,
/// a right is missing and this session may ask: one prompt
Asked,
/// a right is missing but this is a job's or a session-less run: no prompt, the next interactive start asks
Deferred,
}
pub fn step(installed: Option<&Manifest>, wanted: &[(&str, &str)], session_name: Option<&str>, job_env: bool) -> Step {
if missing(installed, wanted).is_empty() {
Step::Nothing
} else if may_ask(session_name, job_env) {
Step::Asked
} else {
Step::Deferred
}
}
/// The WebView2 version a registry read gave against the minimum: true when the runtime must be installed.
pub fn webview2_needs_install(installed: Option<&str>, min: &str) -> bool {
let parse = |v: &str| -> Vec<u64> { v.trim().split('.').map(|p| p.trim().parse::<u64>().unwrap_or(0)).collect() };
match installed.map(|v| v.trim()).filter(|v| !v.is_empty()) {
None => true,
Some(v) => parse(v) < parse(min),
}
}
/// What happens to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
/// the installer's --rights step (a fresh install or an update)
Install,
/// Power control switched on in Settings
PowerControlOn,
/// the engine's first run (the firewall rule, before 0.3.22)
FirstRun,
}
/// 0.3.22: how many administrator prompts an event raises. Only the install step asks, and only when a right is missing.
pub fn prompts(event: Event, installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> u32 {
match event {
Event::Install => if missing(installed, wanted).is_empty() { 0 } else { 1 },
Event::PowerControlOn | Event::FirstRun => 0,
}
}
/// Before 0.3.22, for the record: the installer asked nothing; the first run asked for the firewall rule; Power control on
/// asked when the Power Helper task was not registered yet.
pub fn legacy_prompts(event: Event, power_task_registered: bool) -> u32 {
match event {
Event::Install => 0,
Event::FirstRun => 1,
Event::PowerControlOn => if power_task_registered { 0 } else { 1 },
}
}
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
}
/// The one elevated script: every right in RIGHTS, idempotent (a rule is removed before it is added, a task is
/// registered with -Force). `exe` is the installed igneum-app.exe, `install_dir` its folder, `data_root` the user's
/// data root for the boot task.
pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String {
let node = ps_quote(&install_dir.join("igneumd.exe").display().to_string());
let miner = ps_quote(&install_dir.join("igneum-miner.exe").display().to_string());
let mut s = String::from("$ErrorActionPreference = 'Continue'\r\n# Igneum rights, one elevated step (src/rights.rs). Not for running by hand.\r\n");
s.push_str(&crate::powertask::register_script(exe).replace("exit 0\r\n", ""));
s.push_str(&crate::boot::register_script(exe, data_root).replace("exit 0\r\n", ""));
for (name, prog) in [("Igneum Miner node", node), ("Igneum Miner pool", miner)] {
s.push_str(&format!(
"& netsh.exe advfirewall firewall delete rule name='{name}' | Out-Null\r\n\
& netsh.exe advfirewall firewall add rule name='{name}' dir=in action=allow enable=yes profile=private,domain protocol=TCP program='{prog}' | Out-Null\r\n"
));
}
s.push_str(&webview2_script(install_dir));
s.push_str("exit 0\r\n");
s
}
/// The WebView2 part of the elevated script: the client key's pv (HKLM WOW6432Node, then HKCU), the bundled bootstrapper
/// /silent /install when absent or below the minimum (exit 0 required), the version read back, one log line either way.
pub fn webview2_script(install_dir: &Path) -> String {
let boot = ps_quote(&install_dir.join(WEBVIEW2_BOOTSTRAPPER).display().to_string());
let log = ps_quote(&install_dir.join("rights.log").display().to_string());
format!(
"function WV2 {{ foreach ($k in @('HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{key}', 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{key}')) {{ $v = (Get-ItemProperty $k -ErrorAction SilentlyContinue).pv; if ($v) {{ return \"$v\" }} }}; return '' }}\r\n\
$wvMin = '{min}'\r\n\
$wvHave = WV2\r\n\
$wvNeed = (-not $wvHave) -or ([version]$wvHave -lt [version]$wvMin)\r\n\
if ($wvNeed) {{\r\n\
\x20 if (Test-Path '{boot}') {{ $wp = Start-Process -FilePath '{boot}' -ArgumentList @('/silent', '/install') -Wait -PassThru -WindowStyle Hidden; Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', installed from the bundled bootstrapper, exit ' + $wp.ExitCode + ', now ' + (WV2)) }}\r\n\
\x20 else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', below ' + $wvMin + ', and no bootstrapper beside the app; the window opens the dashboard in the browser until the runtime is installed') }}\r\n\
}} else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: ' + $wvHave + ' ok (minimum ' + $wvMin + ')') }}\r\n",
key = WEBVIEW2_KEY,
min = WEBVIEW2_MIN
)
}
/// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted).
pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result<bool, String> {
let installed = Manifest::load(app_dir);
match step(installed.as_ref(), RIGHTS, std::env::var("SESSIONNAME").ok().as_deref(), in_job_env()) {
Step::Nothing => return Ok(false),
Step::Deferred => {
// a job's or a session-less install never raises a prompt (the project lead, 7 October 2026); the next interactive start asks once
return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", missing(installed.as_ref(), RIGHTS).len(), missing(installed.as_ref(), RIGHTS).join(", ")));
}
Step::Asked => {}
}
let _ = std::fs::create_dir_all(app_dir);
let path: PathBuf = app_dir.join(SCRIPT_FILE);
std::fs::write(&path, [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat()).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
#[cfg(windows)]
{
let line = format!("\"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", crate::platform::tool("powershell").display(), path.display());
crate::platform::run_elevated(&line)?;
}
#[cfg(not(windows))]
{
return Err("the rights step is Windows only".into());
}
#[allow(unreachable_code)]
{
let m = Manifest { version: version.to_string(), rights: RIGHTS.iter().map(|(id, _)| id.to_string()).collect(), at: crate::platform::unix_now() };
m.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?;
Ok(true)
}
}
/// Does the installed manifest hold this right? (The runtime's question before it would have prompted.)
pub fn held(app_dir: &Path, id: &str) -> bool {
Manifest::load(app_dir).map(|m| m.rights.iter().any(|r| r == id)).unwrap_or(false)
}
/// The sentence the dashboard shows for a right the manifest lacks.
pub fn missing_note(id: &str) -> String {
let what = RIGHTS.iter().find(|(i, _)| *i == id).map(|(_, d)| *d).unwrap_or(id);
format!("not set up on this PC ({what}); run the Igneum Miner installer again: it asks for administrator rights once and sets everything up")
}
#[cfg(test)]
mod tests {
use super::*;
fn m(rights: &[&str]) -> Manifest {
Manifest { version: "0.3.22".into(), rights: rights.iter().map(|s| s.to_string()).collect(), at: 1 }
}
/// Known-failed first: before 0.3.22 a fresh install asked nothing and then the first run and Power control on each
/// asked (two prompts on the way to a tuned card); 0.3.22 asks once at install and never again.
#[test]
fn a_fresh_install_then_power_control_on_shows_one_prompt_at_install_and_none_after() {
assert_eq!(legacy_prompts(Event::Install, false) + legacy_prompts(Event::FirstRun, false) + legacy_prompts(Event::PowerControlOn, false), 2, "the old way: two prompts");
assert_eq!(prompts(Event::Install, None, RIGHTS), 1, "the one prompt, at install");
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(prompts(Event::FirstRun, Some(&installed), RIGHTS), 0);
assert_eq!(prompts(Event::PowerControlOn, Some(&installed), RIGHTS), 0);
}
/// the project lead's rule of the same night: no PC job may need a click. Known-failed first: 3fbf4280's step asked on every install with
/// a missing right, a job's silent install included.
#[test]
fn the_rights_step_asks_only_in_an_interactive_session_that_is_not_a_jobs() {
assert_eq!(step(None, RIGHTS, Some("Console"), true), Step::Deferred, "a job's install (IGNEUM_JOB_* set): never a prompt");
assert_eq!(step(None, RIGHTS, None, false), Step::Deferred, "no interactive session: never a prompt");
assert_eq!(step(None, RIGHTS, Some("Console"), false), Step::Asked, "the person at the PC: one prompt");
let all = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(step(Some(&all), RIGHTS, Some("Console"), false), Step::Nothing);
assert_eq!(step(Some(&all), RIGHTS, None, true), Step::Nothing, "nothing missing: nothing, wherever it runs");
assert!(may_ask(Some("RDP-Tcp#2"), false) && !may_ask(Some("Console"), true) && !may_ask(None, false));
}
/// The WebView2 runtime as a right (packaging's step, named 7 October 2026). Known-failed first: before it the runtime
/// missing meant the window said "install the runtime from microsoft.com" and nothing installed it.
#[test]
fn the_webview2_runtime_is_a_right_with_the_minimum_in_its_id() {
assert_eq!(WEBVIEW2_MIN, "109.0.1518.78", "read from app/windows/version.h at build time");
assert_eq!(WEBVIEW2_RIGHT, "webview2-runtime@109.0.1518.78");
assert!(RIGHTS.iter().any(|(id, _)| *id == WEBVIEW2_RIGHT));
// absent runtime: one prompt at install; present at or above the minimum: the right still has to be taken once
// (the manifest records it), but the script installs nothing (its own check); a raised minimum is a new id
let old = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner", "webview2-runtime@100.0.0.0", "driver-install-task"]);
assert_eq!(missing(Some(&old), RIGHTS), vec![WEBVIEW2_RIGHT.to_string()], "a raised minimum is exactly one new right");
assert_eq!(prompts(Event::Install, Some(&old), RIGHTS), 1);
let now = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(prompts(Event::Install, Some(&now), RIGHTS), 0, "the same minimum: no prompt");
assert!(webview2_needs_install(None, WEBVIEW2_MIN), "absent: install");
assert!(webview2_needs_install(Some(""), WEBVIEW2_MIN));
assert!(webview2_needs_install(Some("108.0.1462.76"), WEBVIEW2_MIN), "below: install");
assert!(!webview2_needs_install(Some("109.0.1518.78"), WEBVIEW2_MIN), "equal: nothing");
assert!(!webview2_needs_install(Some("154.0.4258.62"), WEBVIEW2_MIN), "PC 2's runtime: nothing");
let s = webview2_script(Path::new("C:\\p\\Igneum Miner"));
assert!(s.contains("EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}") && s.contains("HKCU:"), "both registry forms");
assert!(s.contains("MicrosoftEdgeWebview2Setup.exe") && s.contains("'/silent', '/install'") && s.contains("-Wait -PassThru -WindowStyle Hidden"));
assert!(s.contains("$wvMin = '109.0.1518.78'") && s.contains("[version]$wvHave -lt [version]$wvMin"));
assert!(s.contains("webview2: ") && s.contains(" ok (minimum "), "one log line either way");
}
#[test]
fn an_update_with_no_new_right_shows_no_prompt() {
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(missing(Some(&installed), RIGHTS), Vec::<String>::new());
assert_eq!(prompts(Event::Install, Some(&installed), RIGHTS), 0);
}
#[test]
fn an_update_with_a_new_right_shows_exactly_one_prompt() {
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("example-new-right", "a right a later build needs")]).collect();
assert_eq!(missing(Some(&installed), &grown), vec!["example-new-right".to_string()]);
assert_eq!(prompts(Event::Install, Some(&installed), &grown), 1);
// and a manifest from an older build that lacks two rights still asks exactly once
let older = m(&["power-helper-task"]);
assert_eq!(missing(Some(&older), RIGHTS).len(), RIGHTS.len() - 1);
assert_eq!(prompts(Event::Install, Some(&older), RIGHTS), 1);
}
#[test]
fn the_manifest_round_trips_and_a_bad_file_reads_as_none() {
let a = m(&["power-helper-task", "boot-task"]);
assert_eq!(Manifest::parse(&a.to_json()), Some(a.clone()));
assert!(a.to_json().contains("\"format\":\"igneum-rights-1\""));
assert_eq!(Manifest::parse("not json"), None);
assert_eq!(Manifest::parse("{}"), Some(Manifest::default()));
}
#[test]
fn the_one_script_takes_every_right_and_is_idempotent() {
let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\igneum"));
assert!(s.contains("-TaskName 'Igneum Power Helper'"), "the Power Helper task");
assert!(s.contains("-TaskName 'Igneum Miner (boot)'"), "the boot task");
// the join's separator is the test host's (the box runs this on Linux), so the path is read by its file name
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner node'") && s.contains("Igneum Miner") && s.contains("igneumd.exe'"), "{s}");
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner pool'") && s.contains("igneum-miner.exe'"));
assert!(s.contains("firewall delete rule name='Igneum Miner node'"), "the rule is replaced, never doubled");
assert_eq!(s.matches("exit 0").count(), 1, "one exit at the end, the sub-scripts' own stripped");
assert!(!s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once (the bootstrapper's hidden run is a plain run)"); // console: a test string, not a spawn
for (id, _) in RIGHTS {
assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '@' || c == '.'), "ids are stable lowercase words, with an @version suffix where the minimum is part of the right: {id}");
}
assert!(missing_note("firewall-node").contains("run the Igneum Miner installer again"));
}
}

View file

@ -12,9 +12,6 @@ const INDEX: &str = include_str!("../ui/index.html");
const CSS: &str = include_str!("../ui/app.css");
const JS: &str = include_str!("../ui/app.js");
const MARK: &str = include_str!("../ui/mark.svg");
const DAG_JS: &str = include_str!("../ui/live-dag.js");
/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5)
const PROOF_JS: &str = include_str!("../ui/proof-core.js");
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
@ -141,15 +138,6 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) {
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
}
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.png`, the name reduced to [a-z0-9-] (at most
/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder.
pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf {
let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect();
let safe = if safe.is_empty() { "shot".to_string() } else { safe };
data_root.join("shots").join(format!("igneum-{safe}-{unix}.png"))
}
fn query_param(q: &str, key: &str) -> Option<String> {
q.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
@ -172,31 +160,11 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
return;
};
let rest = if rest.is_empty() { "/" } else { rest };
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
if req.method == "GET" {
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
if crate::uiota::SERVED.contains(&rel) {
if let Some(dir) = shared.ui_dir() {
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
if rel == "index.html" {
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
shared.send(Cmd::UiPageLoaded(v));
}
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
return;
}
}
}
}
match (req.method.as_str(), rest) {
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false),
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false),
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
@ -210,20 +178,6 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let v = shared.state_json();
json_resp(&mut stream, 200, v);
}
// the chain scene's feed (src/live.rs): the observer's /api/live with this machine's lane as "you", cached 2 s
("GET", "/api/live") => {
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
}
// miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local
// node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s
("GET", "/api/ladder") => {
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: Vec<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids));
}
("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
@ -302,57 +256,9 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
let climb = body.get("climb").and_then(|v| v.as_bool());
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
if let Some(k) = key {
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
shared.send(Cmd::TuneCardGoal(k, g));
return Ok(json!({ "ok": true }));
}
shared.send(Cmd::TuneGoal(goal, price, climb));
Ok(json!({ "ok": true }))
}
"/api/network" => {
// the network step: {network: "devnet-3" | "testnet-1", confirm: bool}; the engine applies config::network_switch
let want = body.get("network").and_then(|v| v.as_str()).unwrap_or("").to_string();
let confirm = body.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false);
shared.send(Cmd::Network(want, confirm));
Ok(json!({ "ok": true }))
}
"/api/region" => {
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
// hundredths of the chosen unit per kWh: up to 100,000 so a zero-decimal currency (yen, won) fits
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=100_000.0).contains(p));
let currency = body.get("currency").and_then(|v| v.as_str()).map(|c| c.to_string());
shared.send(Cmd::Region(region, price, currency));
Ok(json!({ "ok": true }))
}
"/api/heat" => {
// Ember Heat: the switch, the set point, the schedule with the window's clock offset, a typed room reading
let patch = crate::engine::HeatPatch {
on: body.get("on").and_then(|v| v.as_bool()),
set_c: body.get("set_c").and_then(|v| v.as_f64()),
schedule: body.get("schedule").and_then(|v| v.as_str()).map(|t| (t.to_string(), body.get("tz_min").and_then(|v| v.as_i64()).unwrap_or(0).clamp(-840, 840) as i32)),
room_c: body.get("room_c").and_then(|v| v.as_f64()),
};
if let Some(c) = patch.set_c {
if !(crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
return Err(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
}
}
if let Some((t, _)) = &patch.schedule {
crate::heat::parse_schedule(t)?;
}
if let Some(c) = patch.room_c {
if !(-20.0..=50.0).contains(&c) {
return Err("a room reading is -20 to 50 degrees".into());
}
}
shared.send(Cmd::Heat(patch));
Ok(json!({ "ok": true }))
}
"/api/settings" => {
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
let vote = body.get("vote").and_then(|v| v.as_bool());
@ -361,24 +267,10 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
let display_name = s("display_name");
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
}
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
"/api/prove/instead" => shared.set_prove_instead(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
"/api/prove/setup" => crate::prover::setup(shared),
"/api/drivers/install" => {
let vendor = body.get("vendor").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
return Err("vendor must be nvidia, amd or intel".into());
}
shared.send(Cmd::DriverInstall(vendor));
Ok(json!({ "ok": true }))
}
"/api/drivers/restart" => {
shared.send(Cmd::DriverRestart);
Ok(json!({ "ok": true }))
}
"/api/update/check" => {
shared.send(Cmd::CheckUpdate);
Ok(json!({ "ok": true }))
@ -387,17 +279,6 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::InstallUpdate);
Ok(json!({ "ok": true }))
}
"/api/ui/health" => {
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
shared.send(Cmd::UiHealth(err));
Ok(json!({ "ok": true }))
}
"/api/ui/builtin" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::UiBuiltin(on));
Ok(json!({ "ok": true }))
}
"/api/update/auto" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::AutoUpdate(on));
@ -470,38 +351,6 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
// miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under
// <data root>/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}.
// first-block-21: the page showed (or the user dismissed) the block card for the milestone at {count, at};
// the card is spent for later runs and a first-block card sets the lifetime flag (src/ladder.rs card_seen)
"/api/card/seen" => {
let count = body.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let at = body.get("at").and_then(|v| v.as_f64()).unwrap_or(0.0);
let changed = shared.ladder.lock().unwrap().card_seen(count, at);
if changed { shared.save_ladder(); }
Ok(json!({ "ok": true, "changed": changed }))
}
"/api/card" => {
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block");
let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?;
let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?;
let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now());
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?;
if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); }
Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() }))
}
"/api/shot" => {
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
println!("SHOT {}", path.display());
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
}
"/api/quit" => {
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
@ -510,17 +359,3 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
_ => Err("unknown api".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_shot_lands_under_the_data_root_with_a_safe_name() {
let root = std::path::Path::new("/tmp/igneum-data");
assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png"));
assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png"));
assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png"));
assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png"));
assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48);
}
}

View file

@ -21,29 +21,6 @@ pub struct NodeState {
pub version: String,
pub last_reading_age_s: f64,
pub message: String,
/// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's
/// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120.
pub tip_age_s: f64,
/// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another
/// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules
/// or another network, so no node is read), "" before the first start
pub source: String,
/// for an external node: match | mismatch | unknown (an older node that cannot answer the check)
pub rules_check: String,
/// the one line that says what happened at the ports, kept for the Node details
pub port_note: String,
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
pub digest_source: String,
/// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports),
/// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other
/// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports.
pub mode: String,
/// why the mode is what it is, one line
pub mode_reason: String,
/// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
pub sync_cause: String,
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node
pub stall_exits: u32,
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
pub consensus_switch_daa: u64,
pub override_restart_wait: String,
@ -77,7 +54,7 @@ pub struct CardState {
pub detail: String, // memory, cores
pub device: String, // the worker's --device value (Windows)
pub enabled: bool,
pub state: String, // off | waiting | starting | ready | mining | restarting | failed (no "faulted": no fault is permanent, 7 October 2026) | unusable (the OS reports a problem) | removed (unplugged)
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
pub hash_now: f64, // MH/s, the last interval
pub hash_avg: f64, // MH/s since the start
pub accepted: u64,
@ -144,21 +121,13 @@ pub struct CardState {
pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default)
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
/// driver-check (7 October 2026): the comparable version the OS reports (nvidia-smi's for NVIDIA, Windows'
/// DriverVersion for AMD and Intel; empty = none found) and what the row says about it against the manifest's table
pub driver_os: String,
pub driver_offer: Option<crate::drivers::Offer>,
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
pub tune_control: bool, // both knobs reach the card (else measure only; sweep_note says why)
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
pub tune_source: String, // full | confirm | baseline
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
pub tune_before_mhs: f64,
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
// (the founder, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
pub tune_step: u32,
pub tune_steps: u32,
pub tune_eta_s: i64,
@ -194,10 +163,6 @@ pub struct MiningState {
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
pub fee_session: u64,
pub fee_total: u64,
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
/// £ a day at settings.power_price_pence (0 when no price is set)
pub watts_total: f64,
pub pounds_per_day: f64,
}
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
@ -223,8 +188,6 @@ pub struct ProgramState {
#[derive(Clone, Serialize, Default)]
pub struct ProvingState {
pub enabled: bool,
/// every present NVIDIA card is under 12 GB: the prover refuses unless settings.prove_instead holds the miner off
pub under_12gb: bool,
/// the host runs here (macOS, Linux) or inside WSL2 (Windows); false = Set up needed
pub available: bool,
pub setup_hint: String,
@ -286,23 +249,6 @@ pub struct FinalityState {
pub age_s: f64,
pub votes: u64,
pub message: String,
/// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock
/// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when
/// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one
/// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing"
pub paused: bool,
pub paused_since: f64,
pub reason: String,
pub held_by: String,
pub line: String,
/// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now
pub provisional: bool,
/// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the
/// engine's own rule)
pub cause_source: String,
/// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the
/// engine's 15-minute rule
pub node_active: Option<bool>,
}
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
@ -325,15 +271,6 @@ pub struct AddressState {
pub source: String,
pub key_saved: bool,
pub wallet_file: String,
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
pub balance_wei: Option<String>,
pub balance_age_s: f64,
pub balance_note: String, // the last read's error, in words; "" when the last read was good
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
/// price on its own
pub price_gbp_per_ign: Option<f64>,
}
#[derive(Clone, Serialize, Default)]
@ -360,20 +297,6 @@ pub struct SettingsState {
pub tune_goal: String,
pub power_price_pence: f64,
pub tune_climb: bool,
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
pub region: String,
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
pub currency: String,
/// the network step: the chosen network ("" = the package's own)
pub network: String,
pub heat_on: bool,
pub heat_set_c: f64,
pub heat_schedule: String,
pub heat_tz_min: i32,
pub heat_room_c: f64,
pub heat_room_at: f64,
pub heat_offset_c: f64,
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
pub tune_period_s: u64,
@ -381,67 +304,6 @@ pub struct SettingsState {
pub dev_fee: bool,
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
pub proof_verify_trust: bool,
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
pub profile_public: bool,
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
pub ui_builtin: bool,
/// prove instead of mining on a card under 12 GB (settings.prove_instead)
pub prove_instead: bool,
}
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
#[derive(Clone, Serialize, Default)]
pub struct UiState {
/// the version compiled into this engine (ui/VERSION)
pub embedded_version: String,
/// the version the server serves now (the bundle's, or the embedded one)
pub active_version: String,
/// embedded | ota
pub source: String,
/// unix s the active bundle was swapped in (0 for the embedded interface)
pub installed_at: f64,
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
pub confirmed: bool,
/// the version the manifest publishes now ("" when the channel is withdrawn)
pub published_version: String,
pub busy: bool,
pub error: String,
/// versions refused here (never applied again)
pub bad: Vec<String>,
pub builtin: bool,
/// why the published version is not applied, when it is not
pub note: String,
}
/// Ember Heat (src/heat.rs): what the loop is doing, for the Cards strip, the Settings line and the Overview button.
#[derive(Clone, Serialize, Default)]
pub struct HeatState {
pub on: bool,
/// off | waiting | paused | heating | resting
pub phase: String,
/// the set point in force now (the schedule's slot, or the one set point)
pub set_c: f64,
/// the room estimate and where it came from: typed | card | none; the stated error; the reading's age
pub room_c: f64,
pub room_source: String,
pub room_error_c: f64,
pub room_age_s: f64,
/// this period's duty (0 to 1) and the share of the last hour the cards heated
pub duty: f64,
pub duty_hour: f64,
/// watts of heat now (the mining cards' draw; 0 while resting or with no draw reading), the cards' draw when
/// they heat (the last reading), and the period's average (duty times that)
pub heat_w: f64,
pub full_w: f64,
pub heat_avg_w: f64,
/// seconds until the slice changes
pub until_s: f64,
/// the one line under the switch
pub note: String,
pub period_s: f64,
/// the idle offset in use and whether a typed reading taught it
pub offset_c: f64,
pub offset_learned: bool,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
@ -508,9 +370,6 @@ pub struct UpdateState {
pub min_supported: String,
pub channel: String,
pub published_at: String,
/// the network step: the manifest's default for a fresh install and whether the testnet is open
pub default_network: String,
pub testnet_open: bool,
pub file: String, // the downloaded installer or disk image (the manual path opens it)
pub updated_from: String, // set on the first run after an update
pub rolled_back: String, // set when the helper restored the previous version
@ -538,11 +397,6 @@ pub struct State {
pub phase: String, // welcome | cards | address | dashboard
pub setup_done: bool,
pub network: String,
/// the network step: the chain this engine runs by name (igneum-devnet-3, igneum-testnet-1, igneum-devnet-v4) and
/// the one chosen for the next start when it differs ("" = none)
pub network_name: String,
pub network_pending: String,
pub network_error: String,
pub chain: String,
pub host: String, // the hostname, a friendly label only
pub display_name: String, // the user's name for this machine (settings), defaults to the hostname
@ -559,22 +413,14 @@ pub struct State {
pub clock: ClockState,
pub address: AddressState,
pub settings: SettingsState,
pub heat: HeatState,
pub dev_fee: DevFeeState,
pub update: UpdateState,
/// driver-check: the one install in flight (or the last), and the table's stamp
pub drivers: crate::drivers::DriverState,
pub ui: UiState,
pub jobs: JobsState,
pub events: Vec<Event>,
pub uptime_s: u64,
/// first-block-21: when this engine run began (unix s, the wall clock; uptime_s is monotonic and stops across a sleep)
pub started_at: f64,
pub now: f64,
pub quitting: bool,
pub live_page: String,
/// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now`
pub ladder: crate::ladder::LadderState,
}
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.

View file

@ -439,9 +439,6 @@ pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> O
// Ember Tune (src/ember.rs, 5 October 2026): AMD is tuned through igneum-gpu-telemetry, Apple measures only;
// the tune itself says which at its start (the card row's note)
"apple" | "amd" => None,
// the first Intel card, the B580 on PC 1 (7 October 2026, docs/plans/intel-arc.md section 2.3): OpenCL
// exposes neither a cap nor a reading; Intel's path is IGCL (ControlLib.dll), the telemetry helper's next piece
"intel" => Some("not available: Intel Arc exposes no power cap or reading through OpenCL (the driver's IGCL counters come next)"),
_ => Some("not available: no power reading or cap for this card"),
}
}

View file

@ -1,533 +0,0 @@
//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the
//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version.
//!
//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over):
//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active
//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface
//! -> download (curl to <app data>/ui/<version>.tar.gz, size and sha256 against the manifest, then the entry's own
//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too)
//! -> unpack with the system tar into <app data>/ui/<version>.new, index.html, app.js and app.css must be there,
//! rename to <app data>/ui/<version>
//! -> swap: <app data>/ui/current.json names the version (written to .tmp and renamed: atomic); the server reads
//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load;
//! the open page sees state.ui.active_version change and reloads itself when idle
//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping
//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an
//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad
//! (<app data>/ui/bad.json): it is never applied again
//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the
//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded
//! files, and the signature is the only trust.
use crate::engine::{Cmd, Shared};
use crate::manifest::{self, UiEntry};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The page's health ping must arrive this long after the first load of a new bundle.
pub const HEALTH_WAIT_S: u64 = 10;
/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION).
pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION");
/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder.
pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"];
/// What a bundle must carry to be swapped in.
const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"];
pub fn embedded_version() -> &'static str {
EMBEDDED_VERSION.trim()
}
pub enum Event {
/// the download, the checks and the unpack finished: the bundle folder, or why not (with the version)
Installed(String, Result<PathBuf, String>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
Apply,
Skip(String),
}
/// Whether a published entry is for this engine now (pure; the tests cover every branch).
pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision {
if builtin {
return Decision::Skip("the built-in interface is chosen in Settings".into());
}
if manifest::newer(&e.min_engine, engine) {
return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine));
}
if bad.iter().any(|b| b == &e.version) {
return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version));
}
if active == Some(e.version.as_str()) {
return Decision::Skip(format!("interface {} is active", e.version));
}
if e.version == embedded {
return Decision::Skip(format!("interface {} is the built-in one", e.version));
}
Decision::Apply
}
/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Current {
pub version: String,
pub installed_at: u64,
/// the page confirmed this bundle with a health ping
pub confirmed: bool,
}
fn read_current(dir: &Path) -> Current {
let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null);
Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) }
}
/// Writes the pointer atomically (the .tmp then the rename).
pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> {
let _ = std::fs::create_dir_all(dir);
let tmp = dir.join("current.json.tmp");
std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string())
}
fn read_bad(dir: &Path) -> Vec<String> {
std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}
fn write_bad(dir: &Path, bad: &[String]) {
let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default());
}
/// The bundle folder for a version, if it holds what the server needs.
pub fn bundle_dir(dir: &Path, version: &str) -> Option<PathBuf> {
if version == "embedded" || version.is_empty() {
return None;
}
let d = dir.join(version);
if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None }
}
/// Size, sha256 and the entry's own signature, then the unpack into <dir>/<version>: the folder on success.
/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own).
pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result<PathBuf, String> {
let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?;
if size != e.size {
return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size));
}
let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?;
if sum != e.sha256 {
return Err(format!("interface {}: sha256 mismatch", e.version));
}
manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?;
let fresh = dir.join(format!("{}.new", e.version));
let _ = std::fs::remove_dir_all(&fresh);
std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?;
let mut c = std::process::Command::new(crate::platform::tool("tar"));
c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?;
// a bundle packed with a top-level folder: take it
let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) {
fresh.clone()
} else {
let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file()));
match inner {
Some(p) => p,
None => {
let _ = std::fs::remove_dir_all(&fresh);
return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing")));
}
}
};
let dest = dir.join(&e.version);
let _ = std::fs::remove_dir_all(&dest);
std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?;
let _ = std::fs::remove_dir_all(&fresh);
// the bundle's own VERSION must agree with the manifest (a renamed bundle is refused)
let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default();
if v.trim() != e.version {
let _ = std::fs::remove_dir_all(&dest);
return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim()));
}
Ok(dest)
}
/// The engine's side: what is active, what is pending, the health wait, the rollback.
pub struct UiOta {
dir: PathBuf,
current: Current,
bad: Vec<String>,
builtin: bool,
busy: bool,
/// the manifest entry seen last (for the Settings line and the retry after an error)
entry: Option<UiEntry>,
/// a bundle served to a page and not yet confirmed: (version, when the page loaded)
waiting: Option<(String, Instant)>,
error: String,
/// the whole download thread's last reason, kept for the state
last_skip: String,
health_wait: Duration,
}
impl UiOta {
pub fn new(shared: &Arc<Shared>) -> UiOta {
let dir = shared.runtime.app_dir.join("ui");
let _ = std::fs::create_dir_all(&dir);
let builtin = shared.settings.lock().unwrap().ui_builtin;
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) };
// a pointer to a folder that is not there (a cleaned app data folder) falls back without a word
if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" {
shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version));
u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true };
let _ = write_current(&dir, &u.current);
}
u.apply_pointer(shared);
if u.current.version != "embedded" {
shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" }));
}
u.publish(shared);
u
}
/// What the server serves: the bundle folder, or None for the embedded files.
fn apply_pointer(&self, shared: &Arc<Shared>) {
let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) };
shared.set_ui_dir(d);
}
pub fn active_version(&self) -> &str {
&self.current.version
}
/// Called with every verified manifest: the `ui` entry or None (the kill switch).
pub fn consider(&mut self, shared: &Arc<Shared>, entry: Option<&UiEntry>, engine: &str) {
let Some(e) = entry else {
self.entry = None;
if self.current.version != "embedded" {
shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version()));
shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version));
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
}
self.publish(shared);
return;
};
self.entry = Some(e.clone());
if self.busy {
return;
}
match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) {
Decision::Skip(why) => {
if why != self.last_skip {
shared.log(&format!("ui: {why}"));
self.last_skip = why;
}
}
Decision::Apply => {
self.last_skip = String::new();
self.busy = true;
self.error = String::new();
shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000));
let e2 = e.clone();
let dir = self.dir.clone();
let shared2 = shared.clone();
std::thread::spawn(move || {
let r = download_and_install(&e2, &dir);
shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r)));
});
}
}
self.publish(shared);
}
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
self.busy = false;
match ev {
Event::Installed(version, Ok(_dir)) => {
shared.event("ok", &format!("interface {version} installed; the window takes it at its next load"));
self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false });
shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at));
}
Event::Installed(version, Err(e)) => {
self.mark_bad(shared, &version, &e);
}
}
self.publish(shared);
}
fn set_current(&mut self, shared: &Arc<Shared>, c: Current) {
self.current = c;
if let Err(e) = write_current(&self.dir, &self.current) {
shared.log(&format!("ui: could not write the pointer: {e}"));
}
self.waiting = None;
self.apply_pointer(shared);
}
fn mark_bad(&mut self, shared: &Arc<Shared>, version: &str, why: &str) {
if !self.bad.iter().any(|b| b == version) {
self.bad.push(version.to_string());
write_bad(&self.dir, &self.bad);
}
self.error = why.to_string();
shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves"));
shared.log(&format!("ui: {version} marked bad: {why}"));
if self.current.version == version {
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
}
}
/// The server served index.html from a bundle: an unconfirmed one starts the health wait.
pub fn page_loaded(&mut self, version: &str, now: Instant) {
if version == self.current.version && !self.current.confirmed && self.waiting.is_none() {
self.waiting = Some((version.to_string(), now));
}
}
/// The page's ping after its first paint, or the error it caught before it.
pub fn health(&mut self, shared: &Arc<Shared>, error: Option<&str>) {
let Some((version, _)) = self.waiting.clone() else { return };
match error {
None => {
self.waiting = None;
self.current.confirmed = true;
let _ = write_current(&self.dir, &self.current);
shared.log(&format!("ui bundle {version} confirmed by the page"));
self.publish(shared);
}
Some(e) => {
self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::<String>()));
self.publish(shared);
}
}
}
/// The wait ran out: the page never confirmed the bundle.
pub fn tick(&mut self, shared: &Arc<Shared>, now: Instant) {
if let Some((version, since)) = self.waiting.clone() {
if now.duration_since(since) >= self.health_wait {
self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs()));
self.publish(shared);
}
}
}
pub fn set_builtin(&mut self, shared: &Arc<Shared>, on: bool) {
self.builtin = on;
{
let mut s = shared.settings.lock().unwrap();
s.ui_builtin = on;
s.save(&shared.settings_path);
}
shared.state.lock().unwrap().settings.ui_builtin = on;
self.waiting = None;
self.apply_pointer(shared);
shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" });
self.publish(shared);
}
/// Settings > Interface: what serves, from where, since when.
pub fn publish(&self, shared: &Arc<Shared>) {
let mut st = shared.state.lock().unwrap();
let u = &mut st.ui;
let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some();
u.embedded_version = embedded_version().into();
u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() };
u.source = if ota_active { "ota".into() } else { "embedded".into() };
u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 };
u.confirmed = !ota_active || self.current.confirmed;
u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default();
u.busy = self.busy;
u.error = self.error.clone();
u.bad = self.bad.clone();
u.builtin = self.builtin;
u.note = self.last_skip.clone();
}
}
/// The download thread: curl with resume into <dir>/<version>.tar.gz, then install_bundle.
fn download_and_install(e: &UiEntry, dir: &Path) -> Result<PathBuf, String> {
let _ = std::fs::create_dir_all(dir);
let file = dir.join(format!("{}.tar.gz", e.version));
let part = dir.join(format!("{}.tar.gz.part", e.version));
let mut c = std::process::Command::new(crate::platform::tool("curl"));
c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?;
let t = out.trim();
if !t.is_empty() && !part.is_file() {
return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed")));
}
std::fs::rename(&part, &file).map_err(|er| er.to_string())?;
let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX);
if r.is_err() {
let _ = std::fs::remove_file(&file);
}
r
}
/// The content type a served bundle file gets (the same list the embedded files use).
pub fn content_type(rel: &str) -> &'static str {
if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" }
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn entry(version: &str, min_engine: &str) -> UiEntry {
UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) }
}
#[test]
fn the_decision_covers_every_reason_to_skip() {
let e = entry("1.0.1", "0.3.19");
assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply);
assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply);
assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored");
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active")));
assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one")));
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before")));
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings")));
}
/// A bundle folder, packed with the system tar, hashed and signed with a throwaway key.
fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) {
let src = root.join("src");
let _ = std::fs::remove_dir_all(&src);
std::fs::create_dir_all(src.join("fonts")).unwrap();
if with_index {
std::fs::write(src.join("index.html"), "<!doctype html><title>x</title>").unwrap();
}
std::fs::write(src.join("app.js"), "var x = 1;").unwrap();
std::fs::write(src.join("app.css"), "body{}").unwrap();
std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap();
std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap();
let tar = root.join(format!("igneum-ui-{version}.tar.gz"));
let mut c = std::process::Command::new("tar");
c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]);
let ok = crate::platform::quiet(&mut c).status().unwrap().success();
assert!(ok, "tar packs the fixture");
let sha = manifest::sha256_file(&tar).unwrap();
let size = std::fs::metadata(&tar).unwrap().len();
let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes());
(UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar)
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_good_bundle_installs_and_the_pointer_swaps_atomically() {
let root = tmp("good");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.1", true, &sk);
let dir = root.join("ui");
let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs");
assert_eq!(dest, dir.join("1.0.1"));
assert!(bundle_dir(&dir, "1.0.1").is_some());
assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone");
write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap();
assert_eq!(read_current(&dir).version, "1.0.1");
assert!(!dir.join("current.json.tmp").exists());
assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8");
assert_eq!(bundle_dir(&dir, "embedded"), None);
}
#[test]
fn a_bad_signature_is_refused_before_anything_is_unpacked() {
let root = tmp("badsig");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.2", true, &sk);
let dir = root.join("ui");
let err = install_bundle(&e, &tar, &dir, &other).unwrap_err();
assert!(err.contains("signature does not verify"), "{err}");
assert!(!dir.join("1.0.2").exists());
// a tampered hash is caught first
let mut t = e.clone();
t.sha256 = "00".repeat(32);
let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err();
assert!(err.contains("sha256 mismatch"), "{err}");
// a wrong size too
let mut t = e.clone();
t.size += 1;
assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes"));
}
#[test]
fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() {
let root = tmp("broken");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.3", false, &sk);
let dir = root.join("ui");
let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err();
assert!(err.contains("no index.html"), "{err}");
assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists());
}
#[test]
fn a_renamed_bundle_is_refused_by_its_version_file() {
let root = tmp("renamed");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk);
e.version = "1.0.5".into();
e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes());
let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err();
assert!(err.contains("VERSION file says '1.0.4'"), "{err}");
}
#[test]
fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() {
// the state machine without threads: a UiOta over a temp dir, driven by hand
let root = tmp("health");
let dir = root.join("ui");
std::fs::create_dir_all(dir.join("1.0.6")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.6").join(f), "x").unwrap();
}
write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap();
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) };
let t0 = Instant::now();
u.page_loaded("1.0.6", t0);
assert!(u.waiting.is_some());
// a ping in time confirms
let shared = crate::engine::Shared::for_tests(root.join("data"));
u.health(&shared, None);
assert!(u.current.confirmed && u.waiting.is_none());
assert_eq!(read_current(&dir).confirmed, true);
// a second bundle that never answers
std::fs::create_dir_all(dir.join("1.0.7")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.7").join(f), "x").unwrap();
}
u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false });
u.page_loaded("1.0.7", t0);
u.tick(&shared, t0 + Duration::from_secs(9));
assert_eq!(u.current.version, "1.0.7", "still waiting inside the window");
u.tick(&shared, t0 + Duration::from_secs(10));
assert_eq!(u.current.version, "embedded", "rolled back");
assert_eq!(read_current(&dir).version, "embedded");
assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]);
assert!(shared.ui_dir().is_none(), "the server serves the embedded files again");
// a script error on first paint rolls back the same way
std::fs::create_dir_all(dir.join("1.0.8")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.8").join(f), "x").unwrap();
}
u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false });
u.page_loaded("1.0.8", t0);
u.health(&shared, Some("TypeError: x is not a function"));
assert_eq!(u.current.version, "embedded");
assert!(u.bad.contains(&"1.0.8".to_string()));
// and the decision never applies it again
let e = entry("1.0.8", "0.3.19");
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_)));
}
}

View file

@ -40,37 +40,22 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
era * 146_097 + doe - 719_468
}
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
/// unreachable node reads false: the block sample waits rather than asks.
pub fn exec_has_record(evm_port: u16) -> bool {
crate::execrpc::has_record(evm_port)
}
/// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string.
pub fn exec_status_has_record(reply: &str) -> bool {
serde_json::from_str::<serde_json::Value>(reply).ok().map(|v| crate::execrpc::status_has_record(v.get("result").unwrap_or(&serde_json::Value::Null))).unwrap_or(false)
}
/// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the
/// consensus block times). The first clock source: local time against what the peers produced.
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
// 0.3.18/0.3.19 (ledger N7): through the one gate, which asks nothing of a follower without a record
let block = crate::execrpc::call(evm_port, "eth_getBlockByNumber", serde_json::json!(["latest", false]), Duration::from_secs(5)).ok()?;
let ts = block.get("timestamp")?.as_str()?;
let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}";
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]),
None,
Duration::from_secs(7),
)?;
let v: serde_json::Value = serde_json::from_str(&out).ok()?;
let ts = v.get("result")?.get("timestamp")?.as_str()?;
u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64)
}
#[cfg(test)]
mod tests {
/// Ledger N7: the clock sample must not ask eth_getBlockByNumber of a follower with no record
#[test]
fn exec_status_gate() {
assert!(super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec31b9227c3855a1b25ba50ee9f6815bf00b0befcc8836b430baf04343"}}"#));
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x0","executedTipHash":null}}"#));
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#));
assert!(!super::exec_status_has_record(""));
assert!(!super::exec_status_has_record("not json"));
}
#[test]
fn http_date() {
assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0));
@ -94,15 +79,6 @@ pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str
let tail: String = String::from_utf8_lossy(&data).lines().filter(|l| !crate::platform::carries_token(l)).map(|l| crate::platform::redact(l)).collect::<Vec<_>>().join("\n");
// the first line of every upload names the app, the machine and the node (the console parses it)
let text = format!("{header}\n{tail}");
upload_text(url, key, label, machine, run_id, &text)
}
/// One upload of ready text (a fault report, a job's lines): the same intake, the same token guard.
pub fn upload_text(url: &str, key: &str, label: &str, machine: &str, run_id: &str, text: &str) -> bool {
if url.is_empty() || key.is_empty() || text.is_empty() {
return false;
}
let text: String = text.lines().filter(|l| !crate::platform::carries_token(l)).collect::<Vec<_>>().join("\n");
let body = serde_json::json!({ "label": label, "machine": machine, "run_id": run_id, "lines": text });
let tmp = std::env::temp_dir().join(format!("igneum-upload-{}-{}.json", std::process::id(), label));
if std::fs::write(&tmp, body.to_string()).is_err() {

View file

@ -2,79 +2,21 @@
//! rules can be unit-tested with recorded miner lines.
//!
//! Per card (`CardWatch`): a miner that prints no status line for 90 s, or reports a hash rate of 0 for 60 s while
//! the node is ready, is restarted; when that recurs before five minutes of healthy status the next restart waits
//! longer (10 s, 30 s, 2 min, 5 min, then every 5 min, for ever: the project lead, 7 October 2026, "it needs to be truly plug,
//! tune, play"; no fault is permanent and the old one-restart-then-faulted state no longer exists). The reason stays on
//! the card in plain words and the hash resumes on its own. A miner is judged only while the node is READY: synced
//! and with an executed tip (`igneum_getExecStatus`); with no template it cannot print status, so every silence clock
//! holds while the node catches up (PC 1, 7 October 2026: three workers faulted "no status line" during the node's
//! catch-up and stayed faulted until a cards-API bounce). The miner's own worker restarts (`WORKER FAULT`,
//! `worker exited`) suspend both rules until the worker is ready again, so the app never restarts a miner that is
//! already restarting its worker (no double restarts); if the worker is not back within 180 s the app steps in.
//! Exit code 43 (the miner gave up on its worker after three guard trips) is a watchdog restart on the same ladder.
//! the node is synced, is restarted once; when that recurs before five minutes of healthy status, the card is marked
//! faulted with the reason, its miner is not restarted again, and the other cards keep mining. The miner's own
//! worker restarts (`WORKER FAULT`, `worker exited`) suspend both rules until the worker is ready again, so the app
//! never restarts a miner that is already restarting its worker (no double restarts); if the worker is not back
//! within 180 s the app steps in. Exit code 43 (the miner gave up on its worker after three guard trips) counts
//! like a watchdog restart: once, then faulted.
//!
//! Per node (`NodeWatch`): a node of ours that gives no sign of life for 120 s is restarted by the app, with a
//! growing delay when it repeats inside ten minutes. Its catch-up never counts: until the node has been read as
//! synced once since its start, silence is not held against it (a node that never answers at all is restarted after
//! 30 minutes), and any RPC answer (the watch reading, the exec status probe, an accepted block) is a sign of life.
//! Per node (`NodeWatch`): a node of ours that answers no `watch` reading for 120 s is restarted by the app, with a
//! growing delay when it repeats inside ten minutes.
//!
//! Review round 4, X21 (4 October 2026): the app now reads `mismatched=` and `faults=` from STATUS lines and the
//! `WORKER FAULT` lines, and shows them on the card.
/// No status line from a running miner for this long: restart it.
pub const NO_STATUS_S: f64 = 90.0;
/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then
/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s).
pub const START_S: f64 = 60.0;
/// The start of a restart reason the node's readiness, not the card, explains: the ladder resets when the node syncs.
const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"];
/// Seconds before the n-th restart of a card for a repeated fault (the last value repeats for ever).
pub const RETRY_LADDER_S: [u64; 4] = [10, 30, 120, 300];
/// A node that has never answered since its start is given this long before the watchdog restarts it.
pub const NODE_STARTUP_CAP_S: f64 = 1800.0;
/// A worker that has not reported its program loaded (`ready`) this long after its start is restarted on the ladder.
/// The status clocks start at `ready`, never before (MF-4, 7 October 2026: two cards sat in "loading the program"
/// behind a third card's export storm and were faulted at 90 s).
pub const LOAD_S: f64 = 300.0;
/// A miner that exits (any code but 0, 42, 43, 44) within this many seconds of its start is in a crash loop: its
/// restarts follow the ladder instead of the 5 to 60 s jitter.
pub const EARLY_EXIT_S: f64 = 120.0;
/// A card whose worker failed its self-test is held this long before the next try (or until its driver changes).
pub const SELF_TEST_HOLD_S: u64 = 1800;
/// MF-14 (PC 2, 7 October 2026 18:34 BST: two rows sat on "exporting this hour's program" for 18 minutes while a third
/// card mined): a worker never waits on the program export longer than one retry interval without the reason shown;
/// past two intervals the wait is over, the row names what blocked it and the worker retries on its own interval.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportWait {
/// inside one interval: the row keeps "exporting this hour's program"
Waiting,
/// past one interval: the row says how long and what blocks the export
Say(String),
/// past two intervals: the wait ends; the reason, for the restart on the ladder
GiveUp(String),
}
/// `waited_s` since the export was asked, `interval_s` the card's current retry interval (the ladder rung, at least
/// 30 s), `blocker` what holds the export now (another card's export, the pack directory lock, the node not at the
/// epoch, the last export error), or empty when nothing is known.
pub fn export_wait(waited_s: f64, interval_s: f64, blocker: &str) -> ExportWait {
let interval = interval_s.max(30.0);
let what = if blocker.trim().is_empty() { "the export has not returned".to_string() } else { blocker.trim().to_string() };
if waited_s >= 2.0 * interval {
ExportWait::GiveUp(format!("the program export did not return in {} s ({what}); the worker retries on its own interval", waited_s as u64))
} else if waited_s >= interval {
ExportWait::Say(format!("exporting this hour's program: {} s so far ({what})", waited_s as u64))
} else {
ExportWait::Waiting
}
}
/// The delay before restart number `attempt` (1-based) of a card: the ladder, then its last step for ever.
pub fn retry_delay_s(attempt: u32) -> u64 {
let i = (attempt.max(1) as usize - 1).min(RETRY_LADDER_S.len() - 1);
RETRY_LADDER_S[i]
}
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
pub const ZERO_RATE_S: f64 = 60.0;
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
@ -103,12 +45,6 @@ pub struct Status {
pub faults: u64,
pub restarts: u64,
pub synced: bool,
/// seconds the miner has waited for a template with none known (`template_wait=`, 0.3.20 miners; 0 before)
pub template_wait_s: f64,
/// the node's last template time in ms (`template_ms=`; 0 when the line has none)
pub template_ms: f64,
/// identities the miner fetches for now (`identities_active=`; 0 when the line has none)
pub identities_active: u64,
}
/// Parses a miner STATUS line; None for any other line.
@ -123,9 +59,6 @@ pub fn parse_status(text: &str) -> Option<Status> {
faults: kv_u64(text, "faults").unwrap_or(0),
restarts: kv_u64(text, "restarts").unwrap_or(0),
synced: kv(text, "synced") == Some("true"),
template_wait_s: kv_f64(text, "template_wait").unwrap_or(0.0),
template_ms: kv_f64(text, "template_ms").unwrap_or(0.0),
identities_active: kv_u64(text, "identities_active").unwrap_or(0),
})
}
@ -153,26 +86,15 @@ pub enum Event<'a> {
Stopped,
/// The user changed the card's settings: a faulted card may try again.
Reset,
/// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October
/// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back).
NodeSynced,
/// The worker failed its self-test (MF-4): the card is held for `SELF_TEST_HOLD_S` with the reason on its row.
SelfTestFailed(&'a str),
/// The card's driver or platform changed (a re-enumeration): a held card tries again now.
DriverChanged,
/// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1,
/// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the
/// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's
/// fault while this goes on; the miner is judged again once templates flow.
TemplateTimeout,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
None,
/// Stop the miner and start it again after `delay_s`, for this reason (`attempt` counts restarts since the card
/// was last healthy for five minutes).
Restart { reason: String, delay_s: u64, attempt: u32 },
/// Stop the miner and start it again now, for this reason.
Restart(String),
/// Mark the card faulted with this reason; do not restart its miner.
Fault(String),
}
#[derive(Debug, Default)]
@ -180,27 +102,13 @@ pub struct CardWatch {
started_s: Option<f64>,
last_status_s: Option<f64>,
ready: bool,
/// when the worker reported its program loaded: the status clocks start here
ready_s: Option<f64>,
/// held after a self-test failure until the driver changes or the hold passes
driver_hold: bool,
zero_since: Option<f64>,
healthy_since: Option<f64>,
worker_restart_since: Option<f64>,
/// app-level restarts without five healthy minutes since (the ladder position)
/// app-level restarts without five healthy minutes since
restarts: u32,
/// the reason of the last restart (a node-caused one resets the ladder when the node syncs)
last_reason: String,
faulted: Option<String>,
last_fault: String,
/// the miner's last line was a template timeout (the node not answering), cleared by the next status line
templates_blocked: bool,
}
impl CardWatch {
/// True while the miner's last word was a template timeout: the node, not the card, holds the hashing.
pub fn templates_blocked(&self) -> bool {
self.templates_blocked
}
}
impl CardWatch {
@ -208,7 +116,10 @@ impl CardWatch {
Self::default()
}
/// Restarts since the card was last healthy for five minutes: the ladder position.
pub fn faulted(&self) -> Option<&str> {
self.faulted.as_deref()
}
pub fn watchdog_restarts(&self) -> u32 {
self.restarts
}
@ -217,7 +128,6 @@ impl CardWatch {
self.started_s = Some(now_s);
self.last_status_s = None;
self.ready = false;
self.ready_s = None;
self.zero_since = None;
self.healthy_since = None;
self.worker_restart_since = None;
@ -230,9 +140,14 @@ impl CardWatch {
self.zero_since = None;
self.healthy_since = None;
self.worker_restart_since = None;
self.restarts = self.restarts.saturating_add(1);
self.last_reason = reason.clone();
Action::Restart { reason, delay_s: retry_delay_s(self.restarts), attempt: self.restarts }
if self.restarts >= 1 {
let r = format!("{reason} (restarted once already)");
self.faulted = Some(r.clone());
Action::Fault(r)
} else {
self.restarts += 1;
Action::Restart(reason)
}
}
pub fn event(&mut self, now_s: f64, ev: Event<'_>) -> Action {
@ -243,22 +158,11 @@ impl CardWatch {
}
Event::Ready => {
self.ready = true;
self.ready_s = Some(now_s);
self.driver_hold = false;
self.worker_restart_since = None;
Action::None
}
Event::Status(s) => {
self.last_status_s = Some(now_s);
if s.template_wait_s > 0.0 && s.hash_now <= 0.0 {
// MF-5: the miner is alive and waiting on the node for a template; a zero rate here is the
// node's latency, never the card's fault
self.templates_blocked = true;
self.zero_since = None;
self.healthy_since = None;
return Action::None;
}
self.templates_blocked = false;
if s.hash_now > 0.0 {
self.zero_since = None;
let since = *self.healthy_since.get_or_insert(now_s);
@ -282,35 +186,15 @@ impl CardWatch {
Action::None
}
Event::Exited(code) => {
let started = self.started_s;
let running = self.started_s.is_some();
self.started_s = None;
if code == MINER_GAVE_UP_CODE && started.is_some() {
if code == MINER_GAVE_UP_CODE && running {
let why = if self.last_fault.is_empty() { "its guards tripped three times in ten minutes".to_string() } else { self.last_fault.clone() };
self.escalate(format!("the miner gave up on its worker: {why}"))
} else {
match started {
// a crash loop: the ladder, not the 5 to 60 s jitter (MF-4)
Some(t) if !matches!(code, 0 | 42 | 43 | PACK_OUT_OF_DATE_CODE) && now_s - t < EARLY_EXIT_S => {
self.escalate(format!("the miner exited with code {code} {:.0} s after starting", now_s - t))
}
_ => Action::None,
}
Action::None
}
}
Event::SelfTestFailed(reason) => {
self.started_s = None;
self.ready = false;
self.driver_hold = true;
self.last_reason = format!("not usable on this driver: {reason}");
Action::Restart { reason: self.last_reason.clone(), delay_s: SELF_TEST_HOLD_S, attempt: self.restarts.max(1) }
}
Event::DriverChanged => {
if self.driver_hold {
self.driver_hold = false;
self.restarts = 0;
}
Action::None
}
Event::Stopped => {
self.started_s = None;
self.last_status_s = None;
@ -323,39 +207,14 @@ impl CardWatch {
*self = Self::default();
Action::None
}
Event::TemplateTimeout => {
// the miner's own heartbeat: every silence clock starts over from this line
self.started_s = Some(now_s);
if self.last_status_s.is_some() {
self.last_status_s = Some(now_s);
}
self.zero_since = None;
self.templates_blocked = true;
Action::None
}
Event::NodeSynced => {
// restarts the node's readiness explained do not count against the card
if self.restarted_for_node() {
self.restarts = 0;
self.last_reason.clear();
}
Action::None
}
}
}
/// True while the card waits out a self-test failure (released by a driver change or the hold's end).
pub fn driver_hold(&self) -> bool {
self.driver_hold
}
/// True when the last restart was for a reason the node's readiness explains (released by Event::NodeSynced).
pub fn restarted_for_node(&self) -> bool {
NODE_FAULTS.iter().any(|p| self.last_reason.starts_with(p))
}
/// Called every engine tick while the miner process is alive. `node_ready`: synced AND an executed tip.
pub fn tick(&mut self, now_s: f64, node_ready: bool) -> Action {
/// Called every engine tick while the miner process is alive.
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
if self.faulted.is_some() {
return Action::None;
}
let Some(started) = self.started_s else { return Action::None };
if let Some(t) = self.worker_restart_since {
// the miner is restarting its worker: its own guards own the card until the worker is ready
@ -364,32 +223,14 @@ impl CardWatch {
}
return Action::None;
}
let node_synced = node_ready;
if !node_synced {
// a miner is judged only while the node is ready: with no template it cannot print status, so the silence
// clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026)
self.started_s = Some(now_s);
if self.last_status_s.is_some() {
self.last_status_s = Some(now_s);
}
self.zero_since = None;
return Action::None;
}
// the status clocks start when the worker reports its program loaded (MF-4), never before; loading itself
// is bounded by LOAD_S
let Some(ready_at) = self.ready_s else {
if now_s - started > LOAD_S {
return self.escalate(format!("the worker did not load its program within {} s of starting", LOAD_S as u64));
}
return Action::None;
};
if self.last_status_s.is_none() && now_s - ready_at > START_S {
return self.escalate(format!("the worker gave no status within {} s of loading its program", START_S as u64));
}
let last = self.last_status_s.unwrap_or(ready_at);
let last = self.last_status_s.unwrap_or(started);
if now_s - last > NO_STATUS_S {
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
}
if !node_synced {
// a zero rate while the node syncs is expected; the timer starts again once it is synced
self.zero_since = None;
}
if node_synced && self.ready {
if let Some(z) = self.zero_since {
if now_s - z >= ZERO_RATE_S {
@ -412,15 +253,10 @@ impl NodeWatch {
Self::default()
}
/// `silent_s`: seconds since the node's last sign of life (a watch reading, an exec status answer, an accepted
/// block; or since the node started, when it never answered). `settled`: the node has been read as synced at
/// least once since its start; before that its catch-up never counts, only `NODE_STARTUP_CAP_S` of total silence
/// does. Returns the delay in seconds before the restart when one is due.
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool, settled: bool) -> Option<u64> {
if !ours || accepted_recent {
return None;
}
if silent_s < if settled { NODE_SILENT_S } else { NODE_STARTUP_CAP_S } {
/// `silent_s`: seconds since the last reading (or since the node started, when it never answered). Returns the
/// delay in seconds before the restart when one is due.
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool) -> Option<u64> {
if !ours || accepted_recent || silent_s < NODE_SILENT_S {
return None;
}
self.restarts_s.retain(|t| now_s - *t <= NODE_WINDOW_S);
@ -567,10 +403,7 @@ mod tests {
#[test]
fn parses_status_and_fault_lines() {
let s = parse_status(STATUS_OK).unwrap();
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true, template_wait_s: 0.0, template_ms: 0.0, identities_active: 0 });
// a 0.3.20 miner waiting on a slow node (MF-5)
let w = parse_status("1791151000.000 STATUS 'win-1' [worker]: 120s jobs=0 accepted=0 rejected=0 fee=0 mismatched=0 extra=0 rate=0.00 blocks/s hash=0.00 MH/s wall (0.00 MH/s inside jobs) now=0.00 MH/s wall (0.00 MH/s inside jobs, 0 jobs, seed walk 0 calls) template_age=0.00s synced=true idle=100.0% (last 10s: 100.0%) queued=0 restarts=0 faults=0 identities=24 accepted_by_identity=0 tip_age_s=0 template_wait=37s template_ms=8120 identities_active=1").unwrap();
assert_eq!((w.template_wait_s, w.template_ms, w.identities_active), (37.0, 8120.0, 1));
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true });
let m = parse_status(STATUS_MISMATCH).unwrap();
assert_eq!((m.mismatched, m.faults, m.restarts), (3, 1, 1));
assert_eq!(parse_status(STATUS_ZERO).unwrap().hash_now, 0.0);
@ -589,19 +422,6 @@ mod tests {
}
}
fn restart(a: &Action) -> (String, u64, u32) {
match a {
Action::Restart { reason, delay_s, attempt } => (reason.clone(), *delay_s, *attempt),
Action::None => panic!("expected a restart, got None"),
}
}
#[test]
fn the_ladder() {
assert_eq!((1..=6).map(retry_delay_s).collect::<Vec<_>>(), vec![10, 30, 120, 300, 300, 300]);
assert_eq!(retry_delay_s(0), 10);
}
#[test]
fn healthy_miner_is_left_alone() {
let mut w = CardWatch::new();
@ -611,99 +431,61 @@ mod tests {
assert_eq!(w.watchdog_restarts(), 0);
}
/// the project lead's rule (7 October 2026): no fault is permanent. A zero rate restarts the miner on the ladder 10, 30,
/// 120, 300, 300 ... s, the reason stays in plain words, and the hash resumes on its own when the worker is back.
#[test]
fn zero_rate_restarts_on_the_ladder_for_ever_and_the_hash_resumes() {
fn zero_rate_restarts_once_then_faults() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 100.0);
let z = parse_status(STATUS_ZERO).unwrap();
let mut t = 110.0;
let mut seen = Vec::new();
for expect in [(10u64, 1u32), (30, 2), (120, 3), (300, 4), (300, 5), (300, 6)] {
// the app restarts it after the delay; still zero: the next rung
w.event(t, Event::Started);
w.event(t + 2.0, Event::Ready);
let mut a = Action::None;
let mut k = 0.0;
while a == Action::None && k < 200.0 {
w.event(t + 10.0 + k, Event::Status(&z));
a = w.tick(t + 10.0 + k, true);
k += 10.0;
}
let (reason, delay, attempt) = restart(&a);
assert!(reason.starts_with("hash rate 0 for 60 s"), "{reason}");
assert!(!reason.contains("once already"), "the permanent state no longer exists: {reason}");
assert_eq!((delay, attempt), expect, "rung {}", expect.1);
seen.push(delay);
t += 10.0 + k + delay as f64;
for t in [110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
w.event(t, Event::Status(&z));
assert_eq!(w.tick(t, true), Action::None, "under 60 s at {t}");
}
assert_eq!(seen, vec![10, 30, 120, 300, 300, 300]);
// the worker is healthy again: five healthy minutes and the ladder starts over at 10 s
w.event(t, Event::Started);
w.event(t + 2.0, Event::Ready);
healthy(&mut w, t + 10.0, t + 320.0);
assert_eq!(w.watchdog_restarts(), 0);
let a = { let mut a = Action::None; let mut k = 0.0; while a == Action::None { w.event(t + 330.0 + k, Event::Status(&z)); a = w.tick(t + 330.0 + k, true); k += 10.0; } a };
assert_eq!(restart(&a).1, 10);
w.event(170.0, Event::Status(&z));
let a = w.tick(170.0, true);
assert!(matches!(a, Action::Restart(ref r) if r.contains("hash rate 0 for 60 s")), "{a:?}");
// the app restarted it; still zero: faulted, not restarted again
w.event(172.0, Event::Started);
w.event(174.0, Event::Ready);
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
w.event(t, Event::Status(&z));
assert_eq!(w.tick(t, true), Action::None);
}
w.event(240.0, Event::Status(&z));
let a = w.tick(240.0, true);
assert!(matches!(a, Action::Fault(ref r) if r.contains("restarted once already")), "{a:?}");
assert!(w.faulted().is_some());
// faulted stays: no more actions
w.event(250.0, Event::Status(&z));
assert_eq!(w.tick(260.0, true), Action::None);
// the user changed the card's settings: a fresh start
w.event(300.0, Event::Reset);
assert!(w.faulted().is_none());
}
#[test]
fn zero_rate_only_counts_with_a_ready_node_and_a_ready_worker() {
fn zero_rate_only_counts_with_a_synced_node_and_a_ready_worker() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
let z = parse_status(STATUS_ZERO).unwrap();
// not ready yet (program loading): no zero timer
for t in [10.0, 20.0, 30.0, 40.0, 50.0] {
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] {
w.event(t, Event::Status(&z));
assert_eq!(w.tick(t, true), Action::None);
}
w.event(52.0, Event::Ready);
// node not ready (syncing, or no executed tip yet): no zero timer either
for t in [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0] {
w.event(82.0, Event::Ready);
// node not synced: no zero timer either
for t in [90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
w.event(t, Event::Status(&z));
assert_eq!(w.tick(t, false), Action::None);
}
assert_eq!(w.tick(140.0, true), Action::None, "the timer starts at the first zero status after ready");
for t in [150.0, 160.0, 170.0, 180.0, 190.0, 200.0] {
assert_eq!(w.tick(170.0, true), Action::None, "the timer starts at the first zero status after ready");
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
w.event(t, Event::Status(&z));
w.tick(t, true);
}
assert!(matches!(w.tick(210.0, true), Action::Restart { .. }));
}
/// PC 1, 7 October 2026: three workers started while the node caught up, printed nothing (no template), were
/// restarted once and then marked faulted for good. Now: the silence clocks hold while the node is not ready, a
/// restart the node explains does not climb the ladder once the node syncs, and nothing is ever permanent.
#[test]
fn a_worker_started_while_the_node_catches_up_is_never_faulted() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
// the node is not ready for ten minutes: no action, no climb
let mut t = 1.0;
while t < 600.0 {
assert_eq!(w.tick(t, false), Action::None);
t += 1.0;
}
// ready now, the worker has loaded but prints nothing: a restart after START_S, on rung 1
w.event(t, Event::Ready);
let mut a = Action::None;
while a == Action::None && t < 700.0 {
a = w.tick(t, true);
t += 1.0;
}
let (reason, delay, attempt) = restart(&a);
assert!(reason.starts_with("the worker gave no status within"), "{reason}");
assert_eq!((delay, attempt), (10, 1));
assert!(w.restarted_for_node());
// the node drops back to catching up and syncs again: the ladder resets for a node-caused restart
w.event(t, Event::NodeSynced);
assert_eq!(w.watchdog_restarts(), 0);
w.event(t + 10.0, Event::Started);
w.event(t + 12.0, Event::Ready);
healthy(&mut w, t + 20.0, t + 60.0);
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
}
#[test]
@ -712,64 +494,24 @@ mod tests {
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 60.0);
// the miner goes silent (a stopped process, a hung RPC)
assert_eq!(w.tick(149.0, true), Action::None);
let (reason, delay, _) = restart(&w.tick(151.0, true));
assert!(reason.contains("no status line"), "{reason}");
assert_eq!(delay, 10);
let a = w.tick(151.0, true);
assert!(matches!(a, Action::Restart(ref r) if r.contains("no status line")), "{a:?}");
}
#[test]
fn no_status_from_the_start() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
// loading: the status clock has not started; a worker that never loads is restarted at LOAD_S
assert_eq!(w.tick(290.0, true), Action::None);
let (reason, delay, _) = restart(&w.tick(301.0, true));
assert!(reason.contains("did not load its program within 300 s"), "{reason}");
assert_eq!(delay, 10);
// loaded at 200 s (a slow self-test behind another card's export): the 60 s status clock starts there
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(200.0, Event::Ready);
assert_eq!(w.tick(259.0, true), Action::None);
let (reason, _, _) = restart(&w.tick(261.0, true));
assert!(reason.contains("gave no status within 60 s of loading"), "{reason}");
}
/// MF-4 (PC 1, 7 October 2026): a worker that fails its self-test is held for 30 minutes with the reason on its
/// row, not restarted every few seconds; a driver change releases it; a miner in a crash loop climbs the ladder.
#[test]
fn self_test_failure_is_held_and_a_crash_loop_climbs_the_ladder() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
let (reason, delay, _) = restart(&w.event(3.0, Event::SelfTestFailed("3 of 96 vectors mismatched")));
assert_eq!(reason, "not usable on this driver: 3 of 96 vectors mismatched");
assert_eq!(delay, SELF_TEST_HOLD_S);
assert!(w.driver_hold());
w.event(100.0, Event::DriverChanged);
assert!(!w.driver_hold());
// a crash loop: exits 2 s after each start climb 10, 30, 120 s
let mut w = CardWatch::new();
let mut t = 0.0;
let mut delays = Vec::new();
for _ in 0..3 {
w.event(t, Event::Started);
let (reason, delay, _) = restart(&w.event(t + 2.0, Event::Exited(3)));
assert!(reason.contains("exited with code 3"), "{reason}");
delays.push(delay);
t += 2.0 + delay as f64;
}
assert_eq!(delays, vec![10, 30, 120]);
// an exit after a long healthy run is the engine's own jittered restart
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 600.0);
assert_eq!(w.event(700.0, Event::Exited(3)), Action::None);
assert_eq!(w.tick(89.0, true), Action::None);
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
}
#[test]
fn the_miners_own_worker_restart_is_not_doubled() {
// The gfx1036 fault: the miner prints WORKER FAULT, kills the worker, restarts it 2 s later; STATUS lines in
// between say now=0. The app must not restart the miner on top of that.
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
@ -789,86 +531,40 @@ mod tests {
w.event(t as f64, Event::Status(&z));
assert_eq!(w.tick(t as f64, true), Action::None);
}
let (reason, delay, _) = restart(&w.tick(1091.0, true));
assert!(reason.contains("did not come back within 180 s"), "{reason}");
assert_eq!(delay, 10);
let a = w.tick(1091.0, true);
assert!(matches!(a, Action::Restart(ref r) if r.contains("did not come back within 180 s")), "{a:?}");
}
#[test]
fn exit_43_is_a_restart_on_the_ladder() {
fn exit_43_once_then_faulted() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 60.0);
w.event(70.0, Event::WorkerRestart("cpu re-check: 3 consecutive mismatches (mismatched=3 in this run): the worker computes a wrong program"));
let (reason, delay, attempt) = restart(&w.event(75.0, Event::Exited(43)));
assert!(reason.contains("gave up") && reason.contains("wrong program"), "{reason}");
assert_eq!((delay, attempt), (10, 1));
w.event(85.0, Event::Started);
let (_, delay, attempt) = restart(&w.event(300.0, Event::Exited(43)));
assert_eq!((delay, attempt), (30, 2));
// an exit 5 s after the start is the crash-loop class (MF-4): the ladder, not the 5 to 60 s jitter
let a = w.event(75.0, Event::Exited(43));
assert!(matches!(a, Action::Restart(ref r) if r.contains("gave up") && r.contains("wrong program")), "{a:?}");
w.event(80.0, Event::Started);
let a = w.event(300.0, Event::Exited(43));
assert!(matches!(a, Action::Fault(_)), "{a:?}");
// an ordinary crash is the engine's own jittered restart, not the watchdog's
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
assert!(matches!(w.event(5.0, Event::Exited(1)), Action::Restart { delay_s: 10, .. }));
assert_eq!(w.event(5.0, Event::Exited(1)), Action::None);
}
#[test]
fn template_timeouts_are_the_miners_heartbeat() {
fn five_healthy_minutes_renew_the_budget() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 60.0);
// the node stops answering templates for four minutes while the app still reads it as ready
let mut t = 70.0;
while t < 300.0 {
w.event(t, Event::TemplateTimeout);
assert_eq!(w.tick(t + 1.0, true), Action::None, "a heartbeat at {t} is not silence");
t += 5.0;
}
assert!(w.templates_blocked());
healthy(&mut w, 310.0, 400.0);
assert!(!w.templates_blocked());
}
/// MF-5 (PC 1, 7 October 2026): the node answered templates past 5 s; the miner now prints STATUS every interval
/// with template_wait= while it waits, and the watchdog measures the worker, never the node.
#[test]
fn a_slow_node_never_faults_the_card() {
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
w.event(2.0, Event::Ready);
healthy(&mut w, 10.0, 60.0);
let slow = Status { hash_now: 0.0, template_wait_s: 8.0, template_ms: 8120.0, identities_active: 1, synced: true, ..Default::default() };
let mut t = 70.0;
while t < 700.0 {
w.event(t, Event::Status(&slow));
assert_eq!(w.tick(t, true), Action::None, "waiting on the node at {t} is not a fault");
t += 10.0;
}
assert!(w.templates_blocked());
healthy(&mut w, 710.0, 800.0);
assert!(matches!(w.tick(100.0, true), Action::Restart(_)));
w.event(101.0, Event::Started);
w.event(103.0, Event::Ready);
healthy(&mut w, 110.0, 420.0);
assert_eq!(w.watchdog_restarts(), 0);
}
/// MF-14: known-failed first (an export that never returns), then the known-good shapes.
#[test]
fn an_export_that_never_returns_is_named_and_given_up_within_two_intervals() {
// the PC 2 shape: 18 minutes on "exporting" behind another card's export; with a 30 s interval the row
// names the blocker at 30 s and the wait ends at 60 s
assert_eq!(export_wait(31.0, 30.0, "another card's export holds the pack lock (Intel Arc B580, 31 s)"), ExportWait::Say("exporting this hour's program: 31 s so far (another card's export holds the pack lock (Intel Arc B580, 31 s))".into()));
match export_wait(1080.0, 30.0, "") {
ExportWait::GiveUp(r) => { assert!(r.starts_with("the program export did not return in 1080 s (the export has not returned)")); assert!(r.ends_with("retries on its own interval")); }
other => panic!("{other:?}"),
}
assert!(matches!(export_wait(60.0, 30.0, "the node is not at the epoch yet"), ExportWait::GiveUp(_)));
// a normal export (5 to 25 s) says nothing
assert_eq!(export_wait(5.0, 30.0, ""), ExportWait::Waiting);
assert_eq!(export_wait(25.0, 120.0, ""), ExportWait::Waiting);
// a longer rung widens the wait, never below 30 s
assert_eq!(export_wait(100.0, 120.0, "x"), ExportWait::Waiting);
assert!(matches!(export_wait(125.0, 120.0, "x"), ExportWait::Say(_)));
assert!(matches!(export_wait(45.0, 10.0, "x"), ExportWait::Say(_)), "the floor is 30 s even on the 10 s rung");
// a second incident later is again a restart, not a fault
assert!(matches!(w.tick(520.0, true), Action::Restart(_)));
}
#[test]
@ -879,26 +575,18 @@ mod tests {
w.event(30.0, Event::Stopped);
assert_eq!(w.tick(500.0, true), Action::None);
assert_eq!(w.event(500.0, Event::Exited(0)), Action::None);
w.event(600.0, Event::Reset);
assert_eq!(w.watchdog_restarts(), 0);
}
/// The node's catch-up never counts against its watchdog (PC 1, 7 October 2026: a 40-second restart loop).
#[test]
fn node_watch_waits_out_a_catch_up_and_restarts_a_dead_node_with_growing_delay() {
fn node_watch_restarts_a_silent_node_with_growing_delay() {
let mut n = NodeWatch::new();
// catching up (never read as synced since its start): 25 minutes of silence is not a restart
assert_eq!(n.tick(100.0, true, 1500.0, false, false), None);
// a node that never answers at all: restarted at the 30-minute cap
assert_eq!(n.tick(100.0, true, 1801.0, false, false), Some(3));
let mut n = NodeWatch::new();
assert_eq!(n.tick(100.0, true, 119.0, false, true), None);
assert_eq!(n.tick(100.0, false, 500.0, false, true), None, "an external node is never restarted");
assert_eq!(n.tick(100.0, true, 500.0, true, true), None, "our block was accepted in the last minute: the node is alive");
assert_eq!(n.tick(100.0, true, 120.0, false, true), Some(3));
assert_eq!(n.tick(300.0, true, 120.0, false, true), Some(12));
assert_eq!(n.tick(500.0, true, 120.0, false, true), Some(48));
assert_eq!(n.tick(100.0, true, 119.0, false), None);
assert_eq!(n.tick(100.0, false, 500.0, false), None, "an external node is never restarted");
assert_eq!(n.tick(100.0, true, 500.0, true), None, "our block was accepted in the last minute: the node is alive");
assert_eq!(n.tick(100.0, true, 120.0, false), Some(3));
assert_eq!(n.tick(300.0, true, 120.0, false), Some(12));
assert_eq!(n.tick(500.0, true, 120.0, false), Some(48));
assert_eq!(n.restarts_in_window(), 3);
assert_eq!(n.tick(2000.0, true, 120.0, false, true), Some(3), "the window passed");
assert_eq!(n.tick(2000.0, true, 120.0, false), Some(3), "the window passed");
}
}

View file

@ -221,11 +221,11 @@ mod tests {
#[test]
fn the_command_line_after_the_dashes_never_carries_a_double_quote_or_a_newline() {
let file = Path::new("C:\\Users\\the project lead\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/the project lead/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
let file = Path::new("C:\\Users\\the founder\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/the founder/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
assert_eq!(
line,
"bash -l '/mnt/c/Users/the project lead/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/the project lead/AppData/Local/igneum/app/proving/p.bin' '--statement' '0xab' 'it'\\''s' 'two lines'"
"bash -l '/mnt/c/Users/the founder/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/the founder/AppData/Local/igneum/app/proving/p.bin' '--statement' '0xab' 'it'\\''s' 'two lines'"
);
assert!(!line.contains('"') && !line.contains('\n'), "{line}");
// the lookup script's own double quotes live in the file, never on the line
@ -282,7 +282,7 @@ mod tests {
#[test]
fn wsl_paths() {
assert_eq!(wsl_path(Path::new("C:\\Users\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("C:\\Users\\<user>\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/<user>/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("\\\\?\\D:\\x")), "/mnt/d/x");
assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x");
}

View file

@ -1 +0,0 @@
1.0.2

View file

@ -14,30 +14,23 @@
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
/* scene-tokens:end */
:root{
/* colour, dark */
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--intel:#7CC4FF;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
/* type scale */
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
/* spacing scale */
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
--gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4);
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px;
/* the chain scene's inclusion colours (EMBER 02 live-dag.js reads them from :root) */
}
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}}
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}}
:root[data-theme="light"]{
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
@ -68,7 +61,7 @@ input,textarea{font-variant-numeric:tabular-nums}
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
.btn.ghost{border-color:transparent;color:var(--ink-2)}
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40)}
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
@ -84,7 +77,7 @@ body.mac .rail-brand{padding-top:30px}
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
.nav:hover{background:var(--hover);color:var(--bone)}
.nav:hover svg{color:var(--ink-2)}
.nav.on{background:var(--hover);border-color:var(--line);color:var(--bone);font-weight:600}
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
.nav.on svg{color:var(--ember)}
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
@ -106,15 +99,13 @@ body.has-rail .top{left:var(--rail)}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
body.has-rail .top{align-items:baseline;padding-top:19px}
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
.pill{display:inline-flex;align-items:baseline;gap:var(--s-2);font-family:var(--sans);font-size:var(--t-base);font-weight:600;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:5px 12px 5px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;justify-content:center;line-height:1.3}
.pill .dot{position:relative;top:-1px}
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
.pill.on{color:var(--molten);border-color:var(--molten-40)}
.pill.warn{color:var(--ember);border-color:var(--ember-40)}
.pill.warn{color:var(--ember)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
.warn .dot,.dot.bad{background:var(--ember);animation:none}
@ -125,30 +116,26 @@ body.has-rail .top{align-items:baseline;padding-top:19px}
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
body.has-rail .notices{left:var(--rail)}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-3);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--row);border-bottom:1px solid var(--line);box-shadow:inset 0 1px 0 var(--ember);font-size:var(--t-base);line-height:1.4;color:var(--ash)}
.notice-dot{width:7px;height:7px;border-radius:50%;background:var(--ember);flex:0 0 7px;display:inline-block}
.notice.bad .notice-text,.notice.update-urgent .notice-text{color:var(--bone)}
.notice.update-urgent .notice-text{font-weight:600}
.notice .notice-text b{color:var(--bone);font-weight:600}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.notice-text{flex:1 1 320px;min-width:0}
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.notice-actions:empty{display:none}
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice-more{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-more:hover{color:var(--bone);border-color:var(--line-2)}
.notice-more .chev{width:8px;height:8px;transform:rotate(45deg) translateY(-2px)}
.notice-more.open .chev{transform:rotate(225deg) translateY(-2px)}
.notice-detail{flex-basis:100%;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);white-space:normal;word-break:break-word;margin-top:-2px;user-select:text;-webkit-user-select:text}
.notice .prog{flex-basis:100%;height:2px;background:var(--line);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice.update-urgent .notice-close{color:#fff}
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
body.has-rail .ask-wrap{left:var(--rail)}
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--row);border:1px solid var(--line);border-radius:14px;padding:12px 16px;box-shadow:inset 0 1px 0 var(--ember),0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
.ask.inline{box-shadow:inset 0 1px 0 var(--ember);background:var(--row);margin-top:var(--s-3);animation:none}
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
/* screens and pages */
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
@ -156,7 +143,7 @@ main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overfl
body.has-rail main{left:var(--rail)}
body.drawer-open main{bottom:var(--drawer-h)}
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="region"] #screen-region,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
#screen-dashboard{padding:22px 0 32px}
@ -199,7 +186,11 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
/* GPU rows (first run) */
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
.gpu-row.off>.badge,.gpu-row.off>.switch,.gpu-row.off .info>:not(.driver){opacity:.72}
.gpu-row.off{opacity:.72}
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
.badge.apple{color:var(--bone)}
.badge.nvidia{color:var(--nvidia)}
.badge.amd{color:var(--ember)}
.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)}
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap}
@ -223,7 +214,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
.toggle-big.stop .ring{background:var(--obsidian);border:1px solid var(--line-2);color:var(--ember)}
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
.toggle-big.stop .ts{color:var(--molten);opacity:1}
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
@ -255,16 +246,15 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.feed>div:last-child{border-bottom:0}
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--molten);margin-right:10px;position:relative;top:-1px}
.feed .k.error,.feed .k.warn,.feed .k.block{background:var(--ember)}
.feed .k.build,.feed .k.info{background:var(--line-2)}
.feed .k.proving{background:var(--nvidia)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
.feed .k.build,.feed .k.info{color:var(--ash)}
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
the tune line under; the details under that */
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
.gpu-line{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
.gpu-line.off .gl-main,.gpu-line.off .gl-tune,.gpu-line.off .gl-details{opacity:.62}
.gpu-line.off{opacity:.62}
.gpu-line.open{border-color:var(--line-2)}
.gl-main{display:grid;grid-template-columns:44px minmax(150px,1.2fr) auto auto;align-items:center;gap:var(--s-4);padding:12px 14px}
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
@ -292,21 +282,6 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.gl-tune{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);border-top:1px solid var(--line)}
.gl-tune .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.gl-tune .n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gl-driver{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-size:var(--t-base);color:var(--ink-2);border-top:1px solid var(--line)}
.gl-driver .t{flex:1 1 320px;min-width:0;color:var(--bone)}
.gl-driver .n{flex-basis:100%;font-size:var(--t-sm);line-height:1.4}
.gl-driver.offer .t,.gl-driver.reboot .t{font-weight:600}
.gl-driver.error .t{color:var(--ember)}
.gl-driver.note{color:var(--ash)}
.gl-driver.note .t{color:var(--ink-2);font-weight:400}
.gl-driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
.gl-driver .bar b{display:block;height:100%;background:var(--ember);transition:width .4s}
.gpu-row .driver{margin-top:6px;font-size:var(--t-base);color:var(--bone);display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
.gpu-row .driver .n{flex-basis:100%;font-size:var(--t-sm);color:var(--ash);line-height:1.4}
.gpu-row .driver.error .t{color:var(--ember)}
.gpu-row .driver.note{color:var(--ink-2);font-weight:400}
.gpu-row .driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
.gpu-row .driver .bar b{display:block;height:100%;background:var(--ember)}
.gl-tune.running{color:var(--molten)}
.gl-tune.stopped,.gl-tune.paused{color:var(--ember)}
.gl-tune .bar{flex-basis:100%;height:3px;border-radius:2px;background:var(--line);overflow:hidden;display:block}
@ -374,26 +349,18 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
#s-jobs-history tr.running td{color:var(--molten)}
.clock-card{border:1px solid var(--line);background:var(--row);box-shadow:inset 0 1px 0 var(--ember);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
.clock-card.warn{box-shadow:inset 0 1px 0 var(--molten)}
.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)}
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
.clock-card .note{margin-top:0}
/* earnings (earnings-tidy, 7 October 2026): the day rate first in ember with its reason line, the run line, then a quiet
row of three (weight, electricity, lifetime) in the totals' idiom, the dev-fee switch last */
.earn{display:flex;flex-direction:column;margin-bottom:var(--s-3)}
.earn-head{display:flex;flex-direction:column;gap:4px;padding-bottom:var(--s-3);border-bottom:1px solid var(--line)}
.earn-head .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;letter-spacing:-.01em;color:var(--ember);text-wrap:balance}
.earn-head .s,.earn-run .s{font-size:var(--t-base);color:var(--ash);line-height:1.45;min-width:0}
.earn-run{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap;padding:var(--s-3) 0;border-bottom:1px solid var(--line)}
.earn-run .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.15;white-space:nowrap}
.earn-three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4);padding-top:var(--s-3)}
.earn-cell{display:flex;flex-direction:column;gap:3px;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
.earn-cell:first-child{padding-left:0;border-left:0}
.earn-cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
.earn-cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.earn-cell .s{font-size:var(--t-sm);color:var(--ash);line-height:1.4}
@media (max-width:720px){.earn-head .v{font-size:var(--t-h2)}.earn-three{grid-template-columns:1fr;gap:var(--s-2)}.earn-cell{padding-left:0;border-left:0;border-top:1px solid var(--line);padding-top:var(--s-2)}.earn-cell:first-child{border-top:0;padding-top:0}}
/* earnings: money first */
.money{display:flex;flex-direction:column;margin-bottom:var(--s-2)}
.money-row{display:flex;align-items:baseline;gap:var(--s-3);padding:8px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
.money-row:last-child{border-bottom:0}
.money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px}
.money-row .v.small{font-size:var(--t-num)}
.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0}
/* prove */
.tier-list{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column;gap:6px;font-size:var(--t-md);color:var(--ink-2)}
@ -405,7 +372,7 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
.option:hover{border-color:var(--line-2)}
.option.on{border-color:var(--ember);background:var(--row)}
.option.on{border-color:var(--ember);background:var(--ember-12)}
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
.option.on .radio{border-color:var(--ember)}
@ -417,15 +384,6 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
.addr-input.short{width:100px;flex:0 0 100px}
.addr-input:focus{outline:none;border-color:var(--ember)}
/* Ember Heat (mission item 7): the region list, the restricted line, the heat line on the strip and the rows, the rent row */
.addr-input.select{appearance:none;-webkit-appearance:none;max-width:360px;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);background-position:calc(100% - 20px) 50%,calc(100% - 14px) 50%;background-size:6px 6px,6px 6px;background-repeat:no-repeat;cursor:pointer}
.addr-input.select option{background:var(--graphite);color:var(--bone)}
.restricted{font-size:var(--t-md);line-height:1.5;color:var(--bone);background:var(--ember-12);border:1px solid var(--ember-40);border-left:3px solid var(--ember);border-radius:10px;padding:10px 14px;max-width:64ch;margin:0}
.heat-line{font-family:var(--mono);font-size:var(--t-sm)}
.heat-line.heat,.line-text.ok{color:var(--molten)}
.heat-line.rest,.line-text.rest{color:var(--ash)}
.field.grow{flex:1 1 260px;min-width:0}
.gl-tune .n.heat{color:var(--molten)}
.option:not(.on) .addr-input{display:none}
.err{font-size:var(--t-base);color:var(--ember)}
@ -482,21 +440,18 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.wrap{flex-wrap:wrap}
.row .addr-input{margin-top:0;min-width:0}
/* the custom switch (Prove page fix, 7 October 2026): the label is the positioned ancestor, the native input is hidden
the accessible way (1 px, clipped, still focusable, the label association kept), the knob lives inside the track at
both states; the size class is the switch's own (big), never the DAG legend's swatch class */
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4;position:relative}
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
.switch+.switch{border-top:1px solid var(--line)}
.switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
.switch input:checked+.track{background:var(--ember)}
.switch input:checked+.track::after{transform:translateX(18px)}
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
.switch input:disabled+.track{opacity:.4}
.switch.big .track{width:52px;height:30px;flex-basis:52px}
.switch.big .track::after{width:24px;height:24px}
.switch.big input:checked+.track::after{transform:translateX(22px)}
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
.switch.lg .track::after{width:24px;height:24px}
.switch.lg input:checked+.track::after{transform:translateX(22px)}
.sw-text{min-width:0}
.sw-text b{font-weight:600}
.sw-text .dim{font-size:var(--t-base)}
@ -551,8 +506,8 @@ body.drawer-drag main{pointer-events:none}
.log .src.app{color:var(--ember)}
.log .src.watch{color:var(--ash)}
.log .e,.log .e .src{color:var(--ember)}
.log .ln.hit{background:var(--hover);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
.log mark{background:transparent;color:var(--bone);text-decoration:underline;text-decoration-color:var(--ember);text-underline-offset:2px}
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
.log-jump:hover{border-color:var(--molten)}
@ -592,6 +547,7 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
h1{font-size:40px}
.totals{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--s-3)}
.tot:nth-child(3){padding-left:0;border-left:0}
.money-row .v{min-width:0}
}
@media (max-width:720px){
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
@ -632,9 +588,8 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.disclose-right .dim{display:none}
.lead-row{flex-direction:column}
.step{padding:32px 0}
.money-row .v{font-size:var(--t-num)}
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
.notice{padding-left:var(--s-4);padding-right:var(--s-2);gap:var(--s-2)}
.notice-text{flex-basis:160px}
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
}
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
@ -650,239 +605,3 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.drawer-head{padding-bottom:6px}
.toggle-big{min-height:96px}
}
/* miner-ui-4 (6 October 2026): Overview and Cards. The Overview hero (the project lead's pick, C): the fleet rate at 84 px on a
graphite-to-obsidian fade with W, £ a day and blocks beside it, Start/Stop as the full-width bar under it. */
.hero-row{grid-template-columns:1fr}
.totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
.tot{border-left:0;padding-left:0}
.tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
.tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
.tot:first-child .s{font-size:var(--t-md)}
.tot .v{font-size:30px}
.tot .v.ask-price{font-size:var(--t-md);padding:9px 0 8px}
.toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
.toggle-big .ring{margin:0}
.toggle-big .ts{margin-left:auto}
/* the chain scene: the site's live-dag.js in a 220 px box, the legend words under it, the blocks strip as the fallback */
.dag-wrap{position:relative}
#dag-live{display:block;width:100%;height:220px;border-radius:10px;background:var(--obsidian);border:1px solid var(--line)}
#dag{height:120px;margin-bottom:0}
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:7px}
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
/* the key's swatches (key-22, 7 October 2026): the entries, order and tokens come from IgneumDag.legend; a swatch takes its
colour from --lg (set by renderLegend to the entry's token) and its shape from the class, so no state is coloured here */
.legend .lg{--lg:var(--ash);width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--lg);background:var(--lg-fill,var(--row));position:relative;flex:none}
.legend .lg.faded{opacity:.45}
.legend .lg.circle{border-radius:50%;box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
.legend .lg.ringed{box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
.legend .lg.tick{border-color:var(--line-2)}
.legend .lg.tick::after{content:"\2713";position:absolute;left:0;top:-4px;font-size:11px;line-height:1;color:var(--lg)}
/* Cards: the Tuning strip above the list */
.tune-strip{padding:var(--s-4) var(--card-pad)}
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
.ts-row .goal-line{flex:0 1 auto}
.ts-saving{font-size:var(--t-base);color:var(--molten);font-weight:500;margin-left:auto}
.ts-saving:empty{display:none}
.tune-strip .line-text{margin-top:var(--s-2);font-size:var(--t-base);color:var(--ash)}
.tune-strip .switch{padding-bottom:0}
/* the Ember layer on a row: the flame mark, the saving, the sparkline, the curve */
.gl-tune{align-items:center}
.flame{flex:0 0 16px;display:inline-block}
.flame .fo{fill:none;stroke:var(--ash);stroke-width:7}
.flame .ff{fill:var(--ember)}
.flame.running .ff{fill:var(--molten)}
.flame.measured .ff{fill:var(--ash)}
.flame.idle .ff,.flame.paused .ff,.flame.stopped .ff{fill:none}
.gl-tune.tuned .t{color:var(--ink-2)}
.gl-tune .save,.gl-details .save{color:var(--molten);white-space:nowrap}
.num .k.spark{display:inline-flex;align-items:center;gap:6px}
.spark{display:inline-block;vertical-align:middle}
.spark path{fill:none;stroke:var(--ash);stroke-width:1.2}
.spark circle{fill:var(--ember)}
.curve-wrap{display:flex;flex-direction:column;gap:6px;max-width:420px}
.curve-svg{width:100%;height:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px}
.curve-svg .ln{fill:none;stroke:var(--ash);stroke-width:1.2}
.curve-svg circle{fill:var(--ember)}
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
.gl-details .ctl .seg{justify-self:start}
@media (max-width:1180px){
.totals{grid-template-columns:1fr 1fr 1fr 1fr}
.tot:first-child .v{font-size:64px}
}
@media (max-width:860px){
.totals{grid-template-columns:1fr 1fr;row-gap:var(--s-4)}
.tot:first-child{grid-column:1 / -1}
.tot:nth-child(3){padding-left:0}
}
@media (max-width:720px){
.tot:first-child .v{font-size:56px}
.toggle-big .ts{display:none}
#dag-live{height:170px}
.ts-saving{margin-left:0}
.legend{gap:6px 12px}
.rail-foot .nav.small{padding:6px 4px}
}
/* ---- miner-ui-5: the miner's first month: the count-up, the block card, the ladder strip, the rungs, the timeline ---- */
.wait-card{background:var(--row);padding:16px 20px}
.wait-row{display:flex;align-items:center;gap:var(--s-4)}
.wait-ring{position:relative;width:52px;height:52px;flex:0 0 52px;display:inline-flex;align-items:center;justify-content:center}
.wait-ring svg{position:absolute;inset:0;width:52px;height:52px;transform:rotate(-90deg)}
.wait-ring .track{fill:none;stroke:var(--line);stroke-width:3}
.wait-ring .arc{fill:none;stroke:var(--molten);stroke-width:3;stroke-linecap:round;stroke-dasharray:97.4;stroke-dashoffset:97.4;transition:stroke-dashoffset .6s ease}
.wait-ring .pct{font-size:var(--t-xs);color:var(--molten)}
.wait-text .t{font-size:var(--t-md);color:var(--bone)}
.wait-text .s{font-size:var(--t-sm);color:var(--ash);margin-top:2px}
.block-card{position:relative;background:linear-gradient(135deg,var(--graphite),var(--row));border-color:var(--line-2);box-shadow:inset 0 1px 0 var(--ember),0 18px 50px var(--shadow);user-select:text;-webkit-user-select:text}
.bc-head{display:flex;align-items:center;gap:var(--s-3);margin-bottom:var(--s-2)}
.bc-when{margin-left:auto;font-size:var(--t-xs);color:var(--ash)}
.bc-close{position:static;margin-left:4px}
.bc-title{font-size:var(--t-h1);letter-spacing:-.02em;margin-bottom:var(--s-2)}
.bc-title.small{font-size:var(--t-h2)}
.bc-line{font-size:var(--t-lg);color:var(--ink-2);margin:2px 0}
.bc-line.dim{color:var(--ash);font-size:var(--t-md)}
.bc-hash{font-size:var(--t-sm);color:var(--ash);margin:8px 0 12px;word-break:break-all}
.shard-card{box-shadow:inset 0 1px 0 var(--nvidia)}
.ladder-strip{padding:14px 20px}
.ls-rungs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px}
.ls-rung{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.04em;color:var(--ash);padding:4px 10px 4px 6px;border:1px solid var(--line);border-radius:999px;white-space:nowrap}
.ls-rung i{width:8px;height:8px;border-radius:50%;background:var(--line-2);display:inline-block}
.ls-rung.done{color:var(--molten);border-color:var(--molten-40)}
.ls-rung.done i{background:var(--molten)}
.ls-rung.now{color:var(--bone);border-color:var(--ember-40)}
.ls-rung.now i{background:var(--ember);box-shadow:0 0 0 3px var(--ember-12)}
.ls-rung.off{opacity:.55}
.ls-line{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap}
.ls-line .t{font-size:var(--t-md);color:var(--bone);font-weight:500}
.ls-line .s{font-size:var(--t-sm);min-width:0}
.ls-line .btn{margin-left:auto}
.rungs{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column}
.rung{display:grid;grid-template-columns:22px 1fr;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line)}
.rung:last-child{border-bottom:0}
.rg-dot{width:12px;height:12px;border-radius:50%;background:var(--line-2);margin-top:5px;border:2px solid transparent;position:relative}
.rung.done .rg-dot{background:var(--molten)}
.rung.now .rg-dot{background:var(--ember);box-shadow:0 0 0 4px var(--ember-12)}
.rung.off .rg-dot{background:transparent;border-color:var(--line-2)}
.rg-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.rg-name{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.rg-line{font-size:var(--t-lg);color:var(--bone)}
.rung.done .rg-line{color:var(--molten)}
.rung.next .rg-line,.rung.off .rg-line{color:var(--ash)}
.rg-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.45}
.rung .bar{display:block;height:3px;border-radius:2px;background:var(--line);overflow:hidden;margin-top:6px;max-width:320px}
.rung .bar b{display:block;height:100%;background:var(--ember)}
.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
.timeline li{display:grid;grid-template-columns:140px auto 1fr;gap:var(--s-3);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);color:var(--ash);font-size:var(--t-md)}
.timeline li:last-child{border-bottom:0}
.timeline li.done{color:var(--bone)}
.timeline .tl-at{font-size:var(--t-sm);color:var(--molten);white-space:nowrap}
.timeline .tl-note{font-size:var(--t-sm);min-width:0}
.num.ign .v{color:var(--molten)}
@media (max-width:720px){.bc-title{font-size:var(--t-h2)}.timeline li{grid-template-columns:110px auto 1fr}.ls-line .btn{margin-left:0}}
/* ---- the full chain scene with the block inspector (EMBER 02; the words of the site's /live) ---- */
.chain-full{margin-top:var(--s-3);border-top:1px solid var(--line);padding-top:var(--s-3)}
.cf-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);flex-wrap:wrap;margin-bottom:var(--s-3)}
.cf-right{display:inline-flex;align-items:center;gap:var(--s-2)}
.zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.zoom button{background:transparent;border:0;color:var(--ink-2);font:400 13px/1 var(--mono);padding:7px 11px;cursor:pointer}
.zoom button:hover{background:var(--hover)}
.zoom span{font-size:var(--t-xs);color:var(--ash);padding:0 6px;min-width:44px;text-align:center}
.cf-graph{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:var(--s-4)}
.cf-scene{position:relative;min-width:0}
.cf-scene canvas{display:block;width:100%;height:420px;border-radius:10px;background:var(--row);border:1px solid var(--line)}
.cf-foot{margin-top:8px;font-size:var(--t-xs);color:var(--ash)}
.inspector{min-width:0;font-size:var(--t-base);border-left:1px solid var(--line);padding-left:var(--s-4)}
.inspector .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}
.chip{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);border:1px solid var(--line);border-radius:4px;padding:4px 8px;white-space:nowrap}
.ititle{font-family:var(--sans);font-weight:500;font-size:20px;line-height:1.2;color:var(--bone);margin:6px 0 4px;letter-spacing:-.01em}
.ihash{font-size:var(--t-xs);color:var(--ash);overflow-wrap:anywhere;margin-bottom:12px;user-select:text;-webkit-user-select:text}
.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden}
.proof-scene canvas{display:block;width:100%;height:100%}
.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px}
/* the inspector's shard track, scoped to its element (a bare .track reached every switch's track until 7 October 2026) */
#i-track{display:flex;gap:3px;height:3px;margin-bottom:10px}
#i-track i{flex:1 1 0;background:var(--line);border-radius:2px}
#i-track i.proving{background:var(--ember)}#i-track i.verified{background:var(--bone)}#i-track i.paid{background:var(--molten)}
.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto}
.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)}
.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)}
.shards li.proving,.shards li.proving::before{color:var(--ember)}.shards li.verified,.shards li.verified::before{color:var(--bone)}.shards li.paid,.shards li.paid::before{color:var(--molten)}
.shards li.muted{color:var(--ash)}
.insp{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 10px;margin:0;border-top:1px solid var(--line);padding-top:6px;font-size:var(--t-xs)}
.insp dt{color:var(--ash);padding:5px 0}
.insp dd{margin:0;color:var(--bone);text-align:right;padding:5px 0;overflow-wrap:anywhere;min-width:0}
.inspector .note{font-size:var(--t-sm);margin-top:12px;padding-top:10px;border-top:1px solid var(--line)}
.inspector .top-gap{margin-top:8px}
@media (max-width:900px){.cf-graph{grid-template-columns:1fr}.inspector{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:var(--s-3)}}
/* ---------- vendor marks (gpu-logos, 7 October 2026): a self-contained block, the last thing in the file ----------
One simplified monochrome glyph per GPU vendor (View.vendorMark in app.js) in a soft rounded well: the vendor colour
at low alpha behind it, a hairline ring in the same colour, the glyph in the full colour. The tokens are the
module's own (--mark-*), one set per theme, so the light hex of every vendor reads at 3:1 or better on its well
(view.test.mjs computes the ratio and checks the hex here). The ember accent is for state and never tints a brand.
Nothing animates: hover and focus-within only deepen the ring. One mark per row, drawn by View.markHtml alone. */
:root{--mark-nvidia:#8BE37A;--mark-nvidia-well:rgba(139,227,122,.14);--mark-nvidia-ring:rgba(139,227,122,.45);--mark-amd:#FF5A5A;--mark-amd-well:rgba(255,90,90,.14);--mark-amd-ring:rgba(255,90,90,.45);--mark-intel:#7CC4FF;--mark-intel-well:rgba(124,196,255,.14);--mark-intel-ring:rgba(124,196,255,.45);--mark-apple:#E6E3DD;--mark-apple-well:rgba(230,227,221,.14);--mark-apple-ring:rgba(230,227,221,.45);--mark-gpu:#9A9A9E;--mark-gpu-well:rgba(154,154,158,.14);--mark-gpu-ring:rgba(154,154,158,.45)}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}}
:root[data-theme="light"]{--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex:0 0 44px;border:1px solid var(--mark-gpu-ring);color:var(--mark-gpu);background:var(--mark-gpu-well);box-shadow:0 0 0 0 transparent;transition:box-shadow .15s ease,border-color .15s ease}
.badge svg{width:22px;height:22px;display:block}
.badge.nvidia{color:var(--mark-nvidia);background:var(--mark-nvidia-well);border-color:var(--mark-nvidia-ring)}
.badge.amd{color:var(--mark-amd);background:var(--mark-amd-well);border-color:var(--mark-amd-ring)}
.badge.intel{color:var(--mark-intel);background:var(--mark-intel-well);border-color:var(--mark-intel-ring)}
.badge.apple{color:var(--mark-apple);background:var(--mark-apple-well);border-color:var(--mark-apple-ring)}
.badge.gpu{color:var(--mark-gpu);background:var(--mark-gpu-well);border-color:var(--mark-gpu-ring)}
.gpu-row:hover .badge,.gpu-line:hover .badge,.gpu-row:focus-within .badge,.gpu-line:focus-within .badge{border-color:currentColor;box-shadow:0 0 0 3px var(--mark-gpu-well)}
.gpu-row:hover .badge.nvidia,.gpu-line:hover .badge.nvidia,.gpu-row:focus-within .badge.nvidia,.gpu-line:focus-within .badge.nvidia{box-shadow:0 0 0 3px var(--mark-nvidia-well)}
.gpu-row:hover .badge.amd,.gpu-line:hover .badge.amd,.gpu-row:focus-within .badge.amd,.gpu-line:focus-within .badge.amd{box-shadow:0 0 0 3px var(--mark-amd-well)}
.gpu-row:hover .badge.intel,.gpu-line:hover .badge.intel,.gpu-row:focus-within .badge.intel,.gpu-line:focus-within .badge.intel{box-shadow:0 0 0 3px var(--mark-intel-well)}
.gpu-row:hover .badge.apple,.gpu-line:hover .badge.apple,.gpu-row:focus-within .badge.apple,.gpu-line:focus-within .badge.apple{box-shadow:0 0 0 3px var(--mark-apple-well)}
.badge.mini{width:26px;height:26px;border-radius:8px;flex:0 0 26px;display:inline-flex;vertical-align:middle;margin-right:8px}
.badge.mini svg{width:15px;height:15px}
@media (prefers-reduced-motion:reduce){.badge{transition:none}}
/* the series line under the name: mono, small, quiet */
.gen{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.gpu-row .gen{margin-top:-2px}
.gpu-line .who .gen{margin:-1px 0 1px}
#bc-card{display:flex;align-items:center;min-width:0}
#bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.help.ask-cur{color:var(--ember)}
/* the network step (0.3.23): two cards, the chosen one in ember, the one running marked, a closed one dimmed */
.net-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s-3);margin:var(--s-3) 0}
.net-card{display:flex;flex-direction:column;gap:4px;text-align:left;padding:14px 16px;border-radius:12px;border:1px solid var(--line-2);background:var(--row);color:var(--bone);cursor:pointer;min-width:0}
.net-card:hover{border-color:var(--ash)}
.net-card[aria-checked="true"]{border-color:var(--ember);box-shadow:inset 0 0 0 1px var(--ember)}
.net-card[disabled]{opacity:.55;cursor:default}
.net-card .nc-name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
.net-card .nc-line{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;color:var(--ash)}
.net-card .nc-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.4}
.net-card .nc-cur{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten)}
@media (max-width:720px){.net-cards{grid-template-columns:1fr}}
/* scaling-21 (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the
Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node
card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same
proportions, no type below 13 px, the live scene and the GPU marks keep their look; only paddings, gaps and the
scene's height move. The other pages already fit their first object at 1280 by 800 and stay as they are. */
#page-overview{gap:var(--s-3)}
#page-overview .hero-row{gap:var(--s-2)}
#page-overview .totals{padding:16px 22px 14px}
#page-overview .tot .k{margin-top:2px}
#page-overview .toggle-big{min-height:54px;padding:9px 20px}
#page-overview .ladder-strip{padding:10px 16px}
#page-overview .ls-rungs{margin-bottom:6px}
#page-overview .dag-card{padding:16px 18px 14px}
#page-overview .dag-card .card-head{margin-bottom:8px}
#page-overview #dag-live{height:184px}
#page-overview .legend{margin-top:8px}
#page-overview .wait-card{padding:12px 20px}
#page-overview .node-card{padding-top:18px}

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
// node --test app/igneum-app/ui/fold.test.mjs (scaling-21, the project lead, 7 October 2026: "the miner needs some scaling so
// more is visible in the initial window"). Measures the key elements of each page inside the viewport on the mock with
// Playwright's Chromium, at 1280 by 800, 1440 by 900 and 1920 by 1080: the Overview's rate band, big button, ladder
// strip and chain card must end above the fold, and the node card's first row must start above it; Cards shows its
// first card row, Earnings its headline card, Prove its switch. Runs where the Playwright env is set (build-2:
// `. /srv/builds/_bin/overlap/env.sh`), skips elsewhere: the Mac runs no Playwright suite (CLAUDE.md, 7 October 2026).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../../..');
const require = createRequire(import.meta.url);
let chromium = null;
try { if (process.env.IGNEUM_PLAYWRIGHT_DIR) chromium = require(join(process.env.IGNEUM_PLAYWRIGHT_DIR, 'node_modules/playwright')).chromium; } catch (e) { chromium = null; }
const SIZES = [[1280, 800], [1440, 900], [1920, 1080]];
const ROW = 48; // the first row of a card: its head line with the padding above it
test('the first screen: the Overview above the fold at 1280 by 800, 1440 by 900 and 1920 by 1080; Cards, Earnings and Prove show their first object', { skip: chromium ? false : 'no Playwright env (IGNEUM_PLAYWRIGHT_DIR); runs on build-2' }, async () => {
const port = 4480 + Math.floor(Math.random() * 400);
const mock = spawn(process.execPath, [join(root, 'tools/ui-mock/server.mjs'), String(port)], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/t/mock/api/state`); if (r.ok) break; } catch (e) { } await new Promise((r) => setTimeout(r, 250)); }
const browser = await chromium.launch();
const bad = [];
const rect = (page, sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight }; }, sel);
for (const [w, h] of SIZES) {
const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce', colorScheme: 'dark' });
const open = async (scenario, p) => { await page.goto(`http://127.0.0.1:${port}/t/mock/?scenario=${scenario}&page=${p}&theme=dark`, { waitUntil: 'load' }); await page.waitForTimeout(1200); };
const fits = async (sel, label, rowOnly) => { const r = await rect(page, sel); if (!r) { bad.push(`${w}x${h} ${label}: not found`); return; } const end = rowOnly ? r.top + ROW : r.bottom; if (!(r.top >= 0 && end <= r.inner)) bad.push(`${w}x${h} ${label}: ${rowOnly ? 'row ends at ' + end : 'ends at ' + r.bottom} of ${r.inner}`); };
await open('live', 'overview');
await fits('#totals', 'Overview rate band'); await fits('#btn-toggle', 'Overview big button'); await fits('#ladder-strip', 'Overview ladder strip'); await fits('#dag-card', 'Overview chain card'); await fits('#node-card', 'Overview node card first row', true);
if (h >= 1080) await fits('#node-card', 'Overview node card whole at 1080');
await open('rig', 'cards'); await fits('#tune-card', 'Cards tune strip'); await fits('#d-cards .gpu-line:first-child', 'Cards first card row');
await open('ladder', 'earnings'); await fits('.earn', 'Earnings headline card');
await open('ladder', 'prove'); await fits('#s-prove', 'Prove switch');
await page.close();
}
await browser.close();
assert.deepEqual(bad, [], 'below the fold:\n' + bad.join('\n'));
} finally { mock.kill(); }
});
test('the type never drops below 13 px on the Overview density pass: no font-size under 13px inside the #page-overview rules', async () => {
const { readFileSync } = await import('node:fs');
const css = readFileSync(join(here, 'app.css'), 'utf8');
const at = css.indexOf('/* scaling-21');
assert.ok(at >= 0, 'the density block exists');
const block = css.slice(at);
for (const m of block.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)) assert.ok(parseFloat(m[1]) >= 13, 'font-size ' + m[1] + 'px in the density block');
});

View file

@ -1,150 +0,0 @@
// node --test app/igneum-app/ui/heat-region.test.mjs (no dependencies; the pre-push gate runs it)
// Ember Heat (mission item 7, docs/plans/ember-heat.md): the region prompt shows the right restricted entry for a Russian
// region and nothing for the rest; the rent line reads the bench log's measured rate; the heat words name the duty and
// the watts; the money symbol follows the region.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
test('the region prompt shows the restricted entry for a Russian region, with the standing sentence first', () => {
const line = V.restrictedLine('ru-mow');
assert.ok(line.startsWith(V.RESTRICTED_SENTENCE), line);
assert.equal(V.RESTRICTED_SENTENCE, 'Mining may be restricted where you are. You are responsible for checking.');
assert.ok(line.includes('Moscow region from 15 August 2026 to 2032'), line);
assert.ok(V.restrictedLine('ru').includes('ten regions from 1 January 2025 to 15 March 2031'), 'the other-region entry carries the ten-region ban');
assert.ok(V.restrictedLine('ru').includes('does not carry the list of ten'), 'the app says what it does not know');
for (const seasonal of ['ru-irk', 'ru-bur', 'ru-zab']) assert.ok(V.restrictedLine(seasonal).includes('seasonal mining ban'), seasonal);
assert.ok(V.restrictedLine('cn').includes('illegal in China'), 'China is the other entry of future.md 8.3 item 7');
});
test('no region outside the restricted list of future.md 8.3 gets a line', () => {
const restricted = V.REGIONS.filter((r) => r.restricted).map((r) => r.code).sort();
assert.deepEqual(restricted, ['cn', 'ru', 'ru-bur', 'ru-irk', 'ru-mow', 'ru-zab'], 'Russia (the ten regions, Moscow, the seasonal three) and China, nothing else');
for (const code of ['gb', 'de', 'us', 'kz', 'py', 'ir', 'other', '']) assert.equal(V.restrictedLine(code), '', code);
});
// the research file and the bench log are internal (tools/ci/export-exclude.txt): a tree without them skips the read
const research = join(here, '../../../docs/analysis/mission/future.md');
const benchLog = join(here, '../../../docs/bench-log.md');
test('the restricted entries are the ones the research file states (future.md section 4.4 and 8.3)', (t) => {
if (!existsSync(research)) { t.skip('docs/analysis/mission/future.md is not in this tree'); return; }
const future = readFileSync(research, 'utf8');
assert.ok(future.includes('mining banned in 10 regions 1 Jan 2025 to 15 Mar 2031'), 'the ten-region ban and its dates');
assert.ok(future.includes('Moscow region from 15 Aug 2026 to 2032'), 'the Moscow region entry and its dates');
assert.ok(future.includes('seasonal bans in Irkutsk, Buryatia, Zabaikalsky'), 'the seasonal three');
assert.ok(/China \| illegal; joint Notice 6 Feb 2026/.test(future), 'China');
assert.ok(future.includes('"mining may be restricted where you are; you are responsible for checking"'), 'the standing sentence');
});
test('the price prompt defaults from the public table and says it is typed, never fetched', () => {
assert.equal(V.regionOf('gb').price, 26.32);
assert.ok(V.regionOf('gb').source.includes('Ofgem'));
assert.equal(V.regionOf('de').price, 38.69);
assert.equal(V.regionOf('us').price, 17.7);
assert.equal(V.regionOf('ru-mow').price, 0, 'no table price for a Russian region: the miner types one');
assert.ok(V.priceNote('gb').includes('Typical: 26.32 p per kWh'));
assert.ok(V.priceNote('gb').endsWith('Nothing is fetched.'));
assert.ok(V.priceNote('ru').startsWith('No table price for this region.'));
assert.ok(V.priceNote('').includes('Nothing is fetched.'));
assert.ok(!src.includes('fetch(\'https://') && !src.includes('exchangerate'), 'the UI fetches no price and no rate');
});
test('the money symbol follows the region: pounds, euros, dollars', () => {
V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50');
V.setRegion('de'); assert.equal(V.money(1.5), '€1.50'); assert.equal(V.currencyOf('de').minor, 'c');
V.setRegion('us'); assert.equal(V.money(1.5), '$1.50');
V.setRegion('ru-mow'); assert.equal(V.currencyOf('ru-mow').code, 'USD', 'a region without a table currency types US cents');
V.setRegion(''); assert.equal(V.money(1.5), '£1.50', 'no region chosen: pounds, as before');
});
test('the rent line reads the bench log: the measured USD per MH/s-hour in the app is the bench log\'s number', (t) => {
if (!existsSync(benchLog)) { t.skip('docs/bench-log.md is not in this tree'); return; }
const log = readFileSync(benchLog, 'utf8');
const section = log.slice(log.lastIndexOf('## Rental cost of hash'));
assert.ok(section.length > 0, 'the bench log carries a Rental cost of hash entry');
const m = section.match(/USD ([0-9.]+) per MH\/s-hour/);
assert.ok(m, 'the entry states USD x per MH/s-hour');
assert.equal(V.RENT.usd_per_mhs_hour, parseFloat(m[1]), 'the app carries the bench log\'s measured rate; update RENT when the log moves');
const date = section.match(/## Rental cost of hash, ([0-9]+ [A-Za-z]+ [0-9]{4})/);
assert.ok(date, 'the entry is dated');
assert.equal(V.RENT.measured, date[1]);
});
test('the cost-against-rent line: a UK card at the Ofgem price is about 10x cheaper than rented hash (future.md 3.4)', () => {
V.setRegion('gb');
// 3.27 W per MH/s at 26.32 p: 0.0861 p per MH/s-hour, about USD 0.00114, 10x under 0.0117
const r = V.rentLine([card({ power_w: 327, hash_now: 100 })], 26.32, 'gb');
assert.equal(r.kind, 'line');
assert.ok(Math.abs(r.cost - 0.0861) < 0.001, r.cost);
assert.ok(Math.abs(r.usd - 0.00114) < 0.0001, r.usd);
assert.equal(r.text, '0.086 p per MH/s-hour');
assert.ok(r.ratio > 9.5 && r.ratio < 11, r.ratio);
assert.ok(r.sub.includes('Rented hash: USD 0.0117 per MH/s-hour'), r.sub);
assert.ok(r.sub.includes('6 October 2026'), r.sub);
assert.ok(r.verdict.startsWith('Yours is 10'), r.verdict);
assert.ok(r.sub.includes('about USD 0.0011'), r.sub);
});
test('the cost-against-rent line says when rented hash undercuts the card, and what it needs when it cannot say', () => {
// a 3080-class card at 12 GB and a dear tariff: 6 W per MH/s at 60 c (euro) = 0.36 c = USD 0.0039: still under the rent
const under = V.rentLine([card({ power_w: 300, hash_now: 50 })], 60, 'de');
assert.equal(under.kind, 'line');
assert.ok(under.ratio > 2.5 && under.ratio < 3.5, under.ratio);
// the day idle datacentre hash sets the rent at 0.00016 the line flips (the table in future.md 3.4): here, a card at
// 20 W per MH/s on 100 c power = 2 c = USD 0.0216, dearer than the rent
const dear = V.rentLine([card({ power_w: 400, hash_now: 20 })], 100, 'us');
assert.equal(dear.kind, 'line');
assert.ok(dear.ratio < 1, dear.ratio);
assert.ok(dear.verdict.startsWith('Rented hash undercuts your card by'), dear.verdict);
assert.equal(dear.sub.includes('about USD'), false, 'a dollar region needs no conversion');
// no price: the W per MH/s and the ask
const ask = V.rentLine([card()], 0, 'gb');
assert.equal(ask.kind, 'price');
assert.ok(ask.sub.includes('W per MH/s'), ask.sub);
// not mining and never tuned: nothing to say, the rent still stated
const none = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0 })], 26.32, 'gb');
assert.equal(none.kind, 'none');
assert.ok(none.sub.includes('USD 0.0117'));
// not mining but tuned: the tune's point stands in, and the line says so
const tuned = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0, sweep_watts: 290, sweep_mhs: 122.3 })], 26.32, 'gb');
assert.equal(tuned.kind, 'line');
assert.ok(tuned.sub.includes('(the last tune)'), tuned.sub);
});
test('the heat words: the strip line names the set point, the room, the share of the hour and the watts', () => {
const heating = { on: true, phase: 'heating', set_c: 20, room_c: 19.6, room_source: 'typed', duty: 0.6, duty_hour: 0.55, heat_w: 410, full_w: 410, until_s: 300 };
assert.deepEqual(V.heatLine(heating), { text: 'Heat mode: holding 20.0 °C · room 19.6 °C · heating 55% of the time · 410 W of heat', tone: 'heat' });
const resting = { ...heating, phase: 'resting', heat_w: 0, room_source: 'card', room_c: 19.2 };
assert.deepEqual(V.heatLine(resting), { text: 'Heat mode: holding 20.0 °C · room about 19 °C · heating 55% of the time · resting, 410 W when heating', tone: 'rest' });
assert.equal(V.heatLine({ on: false }).text, '');
assert.ok(V.heatLine({ ...heating, room_source: 'none', duty_hour: 0 }).text.includes('no room reading yet · heating 60% of the time'));
assert.equal(V.heatRowWords(heating), 'heat 55% · 410 W of heat');
assert.equal(V.heatRowWords(resting), 'heat 55% · resting');
assert.equal(V.heatRowWords({ on: false }), '');
});
test('a resting card and the big button say heat mode, never a bare off', () => {
const r = V.cardRow(card({ state: 'resting', hash_now: 0, message: 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' }));
assert.equal(r.word, 'resting (heat mode)');
assert.equal(r.sub, 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)', 'the engine\'s line is the row\'s second line');
assert.equal(r.hash, '');
const m = { state: 'waiting', paused: false, cards: [card({ state: 'resting', hash_now: 0 })], found: [] };
const t = V.toggle(m, { synced: true }, { heat: { on: true, phase: 'resting', set_c: 20, until_s: 240 } });
assert.equal(t.label, 'Stop mining');
assert.equal(t.sub, 'resting · heat mode holds 20.0 °C, heats again in 4 min');
assert.equal(t.act, 'pause');
assert.deepEqual(V.pill({ setup_done: true, mining: m, node: { state: 'synced' }, heat: { on: true, phase: 'resting' } }), { text: 'Resting · heat mode', tone: '' });
const e = V.ember(card({ tune_before_watts: 0 }), { settings: { sweep: true }, heat: { on: true, phase: 'heating', duty: 0.5, duty_hour: 0.5, heat_w: 410 } }, 1);
assert.equal(e.heat, 'heat 50% · 410 W of heat');
});

View file

@ -8,7 +8,7 @@
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body class="phase-welcome" data-phase="welcome" data-page="overview">
<body class="phase-welcome" data-phase="welcome" data-page="mine">
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
is a bottom tab bar. The setup screens have no rail. -->
@ -18,8 +18,7 @@
<span class="word">IGNEUM</span>
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<button class="nav on" data-page="overview" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Overview</span></button>
<button class="nav" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="11" rx="2"/><path d="M7 20h10M9 16v4M15 16v4M7 10h3M14 10h3"/></svg><span>Cards</span></button>
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
@ -38,7 +37,7 @@
<span class="word">IGNEUM</span><span class="miner">MINER</span>
</div>
<div class="page-title" id="page-title" hidden>
<h1 id="page-title-text">Overview</h1>
<h1 id="page-title-text">Mine</h1>
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
@ -49,10 +48,8 @@
<!-- the status strip: one notice at a time (app.js, Notices) -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<i class="notice-dot" aria-hidden="true"></i>
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-more" id="notice-more" data-act="more" title="The technical line" aria-label="Show the technical line" aria-expanded="false" hidden><span class="chev"></span></button>
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">&times;</button>
<span class="notice-detail mono" id="notice-detail" hidden></span>
<span class="prog" id="notice-prog" hidden><i></i></span>
@ -104,7 +101,7 @@
<!-- 2. cards -->
<section class="screen" id="screen-cards">
<div class="step">
<div class="eyebrow">step 1 of 4</div>
<div class="eyebrow">step 1 of 2</div>
<h2>Your graphics card</h2>
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
<div class="cards" id="cards-list">
@ -118,7 +115,7 @@
</div>
<p class="note" id="cards-note" hidden></p>
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
<p class="note" id="cards-help" hidden>A built-in GPU starts off. It is slow and shares the machine's memory.</p>
<p class="note" id="cards-help" hidden>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
@ -126,57 +123,10 @@
</div>
</section>
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
<section class="screen" id="screen-region">
<div class="step">
<div class="eyebrow">step 2 of 4</div>
<h2>Your electricity</h2>
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="region-select" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="region-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="region-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="region-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
</div>
<p class="help" id="region-note">Choose a region to see a typical price.</p>
</div>
<p class="restricted" id="region-restricted" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-region-next">Continue</button>
<button class="btn ghost" id="btn-region-back">Back</button>
<button class="btn ghost" id="btn-region-skip">Skip for now</button>
</div>
</div>
</section>
<!-- 4. the network (0.3.23): two cards; the fresh-install default comes from the manifest's default_network, the testnet
card reads "not yet open" and is refused until the manifest names it open -->
<section class="screen" id="screen-network">
<div class="step">
<div class="eyebrow">step 3 of 4</div>
<h2>Which network</h2>
<p class="sub">The chain this machine mines. You can change it later in Settings; a change takes effect at the next start.</p>
<div class="net-cards" id="network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="network-note" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-network-next">Continue</button>
<button class="btn ghost" id="btn-network-back">Back</button>
</div>
</div>
</section>
<!-- 5. address -->
<!-- 3. address -->
<section class="screen" id="screen-address">
<div class="step">
<div class="eyebrow">step 4 of 4</div>
<div class="eyebrow">step 2 of 2</div>
<h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
<div class="options">
@ -193,7 +143,7 @@
<span class="radio"></span>
<span class="body">
<span class="t">Use my own address</span>
<span class="s">Paste an address you control. It starts with 0x and has 40 characters after it.</span>
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
</span>
@ -207,11 +157,11 @@
</div>
</section>
<!-- 5. the dashboard: four pages behind the rail -->
<!-- 4. the dashboard: four pages behind the rail -->
<section class="screen" id="screen-dashboard">
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
<section class="page" id="page-overview" data-page="overview">
<!-- Mine -->
<section class="page" id="page-mine" data-page="mine">
<div class="clock-card" id="m-clock" hidden>
<p class="clock-msg" id="m-clock-msg"></p>
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
@ -232,73 +182,24 @@
</div>
</div>
<!-- miner-ui-5: before the first block, the Poisson count-up; on a milestone, the block card (in place, never a dialog) -->
<div class="card wait-card" id="first-wait" hidden>
<div class="wait-row"><span class="wait-ring" aria-hidden="true"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15.5"></circle><circle class="arc" id="first-wait-arc" cx="18" cy="18" r="15.5"></circle></svg><span class="pct mono" id="first-wait-pct">0%</span></span>
<div class="wait-text"><div class="t" id="first-wait-line">Waiting for your first block.</div><div class="s" id="first-wait-sub"></div></div>
</div>
</div>
<div class="card block-card" id="block-card" hidden>
<div class="bc-head"><span class="eyebrow ember" id="bc-eyebrow">your first block</span><span class="bc-when mono" id="bc-when"></span><button class="notice-close bc-close" id="bc-close" title="Dismiss" aria-label="Dismiss the block card">&times;</button></div>
<h2 class="bc-title" id="bc-title">Block 0 is yours.</h2>
<p class="bc-line" id="bc-card"></p>
<p class="bc-line" id="bc-ign"></p>
<p class="bc-line dim" id="bc-rank"></p>
<p class="bc-hash mono" id="bc-hash"></p>
<div class="row wrap"><button class="btn small primary" id="bc-open">Open in the explorer</button><button class="btn small" id="bc-save">Save the card</button><button class="btn small ghost" id="bc-copy">Copy the link</button><span class="note mono small" id="bc-saved"></span></div>
<canvas id="bc-canvas" width="1200" height="630" hidden aria-hidden="true"></canvas>
</div>
<div class="card ladder-strip" id="ladder-strip" hidden>
<div class="ls-rungs" id="ls-rungs" role="list" aria-label="The ladder"></div>
<div class="ls-line"><span class="t" id="ls-line"></span><span class="s dim" id="ls-sub"></span><button class="btn tiny ghost" id="ls-more">The ladder</button></div>
<div class="card">
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="note" id="d-cards-note" hidden></p>
</div>
<div class="card dag-card" id="dag-card">
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-inspect" aria-expanded="false" aria-controls="chain-full">Inspect</button><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
<!-- the compact scene (EMBER 02 live-dag.js, the site lane's module, byte-identical): the app feeds it its own api/live reply -->
<div class="dag-wrap">
<canvas id="dag-live" aria-label="The last minute of blocks: your lane and the other miner keys, the selected chain as one path, checkpoints as bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip" hidden></div>
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
<canvas id="dag" aria-hidden="true" hidden></canvas>
</div>
<div class="legend" data-legend="mine"><span class="dim" id="dag-note"></span></div>
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
<div class="chain-full" id="chain-full" hidden>
<div class="cf-bar">
<div class="seg small" id="dag-filter" role="radiogroup" aria-label="Which blocks"><button class="seg-b on" data-filter="all" role="radio" aria-checked="true">All blocks</button><button class="seg-b" data-filter="chain" role="radio" aria-checked="false">Selected chain</button><button class="seg-b" data-filter="mine" role="radio" aria-checked="false">Your blocks</button></div>
<span class="cf-right"><span class="zoom"><button type="button" id="z-out" aria-label="Wider window">&minus;</button><span id="z-pct" class="mono">60 s</span><button type="button" id="z-in" aria-label="Narrower window">+</button></span><button class="btn tiny ghost on" id="dag-follow">Follow</button><button class="btn tiny ghost" id="dag-pause-full" aria-pressed="false">Pause</button></span>
</div>
<div class="cf-graph">
<div class="cf-scene">
<canvas id="dag-full" aria-label="The devnet's block graph, live: time-aligned miner lanes, every parent connection, the selected chain as one path, checkpoint bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip-full" hidden></div>
<span class="dag-chip mono" id="dag-chip-full" hidden>scroll to zoom · Esc to release</span>
<div class="cf-foot mono">Click to inspect &nbsp;&middot;&nbsp; Drag to explore</div>
</div>
<aside class="inspector" id="inspector" aria-live="polite">
<div class="ih"><span class="eyebrow">Block inspector</span><span class="chip mono" id="i-kind">select a block</span></div>
<h3 class="ititle" id="i-title">Select a block</h3>
<div class="ihash mono" id="i-hash">Click the graph.</div>
<div class="proof-scene"><canvas id="proof" aria-label="Select a block to see its shards"></canvas></div>
<div class="proof-head"><span>Proof shards</span><span id="i-shard-count" class="mono">not set</span></div>
<div class="track" id="i-track"></div>
<ul class="shards mono" id="i-shards"><li class="muted">No block selected.</li></ul>
<dl class="insp mono">
<dt>Inclusion</dt><dd id="i-incl">not set</dd>
<dt>Miner key</dt><dd id="i-miner">not set</dd>
<dt>Blue score</dt><dd id="i-blue">not set</dd>
<dt>DAA score</dt><dd id="i-daa">not set</dd>
<dt>Parent links</dt><dd id="i-parents">not set</dd>
<dt>Header time</dt><dd id="i-time">not set</dd>
<dt>Checkpoint</dt><dd id="i-cp">not set</dd>
<dt>Finality</dt><dd id="i-lock">not set</dd>
</dl>
<p class="note">Proof, chain selection and finality are separate states. None is inferred from a block&rsquo;s age; a lock is drawn only when the observer reports one.</p>
<p class="top-gap"><button class="btn tiny ghost" id="insp-close" hidden>Clear</button></p>
</aside>
<div class="card" id="tune-card">
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
</div>
<span class="goal-line" id="goal-line"></span>
</div>
<p class="line-text" id="tune-schedule"></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
<div class="card node-card" id="node-card">
@ -309,26 +210,25 @@
<div class="details" id="node-details" hidden>
<div class="kv">
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
<div><span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span></div>
<div><span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the chain right now</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain (the blue score)</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the block DAG right now</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain</span></div>
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
<div><span class="k">last checkpoint</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s (a point the miners agree can never be undone)</span></div>
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s</span></div>
</div>
<div class="field">
<div class="k">rules fingerprint</div>
<div class="k">rules digest</div>
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
</div>
<div class="field">
<div class="k">next rule change</div>
<div class="k">next rule switch</div>
<div class="line-text" id="n-switch">none planned</div>
<p class="help" id="n-switch-help"></p>
<div class="switch-list mono" id="n-switches"></div>
@ -338,66 +238,22 @@
<div class="card">
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
</div>
</section>
<!-- Cards: every card with Ember as its second layer -->
<section class="page" id="page-cards" data-page="cards" hidden>
<div class="card tune-strip" id="tune-card">
<div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></div>
<div class="ts-row">
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
</div>
<span class="goal-line" id="goal-line"></span>
<span class="ts-saving" id="fleet-saving"></span>
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
</div>
<p class="line-text" id="tune-schedule"></p>
<p class="line-text heat-line" id="heat-line" hidden></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
<div class="card">
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span></div></div>
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="note" id="d-cards-note" hidden></p>
</div>
</section>
<!-- Earnings -->
<section class="page" id="page-earnings" data-page="earnings" hidden>
<div class="card">
<!-- earnings-tidy (7 October 2026): the day rate first with its reason, the run line, then a quiet row of three
(weight, electricity, lifetime), the dev-fee switch last. IGN only: no typed price anywhere. Every number names
its source on hover (title). -->
<div class="earn">
<div class="earn-head"><span class="v" id="e-day">0 IGN a day</span><span class="s" id="e-day-sub">reading the network</span></div>
<div class="earn-run"><span class="v" id="e-run">0 blocks this run</span><span class="s" id="e-run-sub"></span></div>
<div class="earn-three">
<div class="earn-cell"><span class="k">Weight</span><span class="v" id="e-rung">reading</span><span class="s" id="e-rung-sub"></span></div>
<div class="earn-cell"><span class="k">Electricity</span><span class="v" id="e-cost">no draw</span><span class="s" id="e-cost-sub"></span></div>
<div class="earn-cell"><span class="k">Lifetime</span><span class="v" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub"></span></div>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the people who make this app.</span></label>
</div>
<div class="card" id="ladder-card">
<div class="card-head"><h3>The ladder</h3><div class="head-right"><span class="eyebrow" id="ladder-source">reading</span></div></div>
<p class="help">Weight is your blocks over the window, written into consensus. No points server, no badge: the chain computes every rung and your node reports it. Hover a line for the field it reads.</p>
<ol class="rungs" id="rungs"></ol>
</div>
<div class="card" id="timeline-card">
<button class="disclose" id="tl-toggle" aria-expanded="false" aria-controls="tl-details"><span>Your first hour</span><span class="disclose-right"><span class="dim" id="tl-sum"></span><span class="chev" aria-hidden="true"></span></span></button>
<div class="details" id="tl-details" hidden>
<ol class="timeline" id="tl-steps"></ol>
<p class="note" id="tl-note"></p>
<div class="money">
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the miner software's author</span></label>
<p class="help" id="r-devfee-line"></p>
</div>
<div class="card">
@ -437,36 +293,23 @@
<!-- Prove -->
<section class="page" id="page-prove" data-page="prove" hidden>
<div class="card shard-card" id="shard-card" hidden>
<div class="bc-head"><span class="eyebrow ember">proof shards</span><span class="bc-when mono" id="sc-when"></span></div>
<h2 class="bc-title small" id="sc-title">Your card proved shard 0 of block 0.</h2>
<p class="bc-line" id="sc-line"></p>
<p class="bc-line dim" id="sc-sub"></p>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3>Prove on this machine</h3>
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
</div>
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<div class="row" id="pv-instead-row" hidden>
<div>
<h3>Prove instead of mining</h3>
<p class="help" id="pv-instead-help">A card under 12 GB holds the prover or the miner, never both. On, the miner stops on that card while it proves and comes back when proving stops.</p>
</div>
<label class="switch big" title="Prove instead of mining"><input type="checkbox" id="s-prove-instead" aria-label="Prove instead of mining"><span class="track"></span></label>
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<ul class="tier-list" id="pv-tiers"></ul>
<p class="line-text" id="pv-line"></p>
<p class="note" id="pv-note"></p>
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">Takes about 20 minutes, once.</span></div>
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="pv-details" hidden>
<div class="kv">
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments (runs of 8 blocks) this machine proved</span></div>
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments this machine proved</span></div>
</div>
<div class="field">
<div class="k">shard program id</div>
@ -475,7 +318,7 @@
<div class="field">
<div class="k">aggregator id</div>
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
<p class="help">Every proof names the program that made it: the shard program for one shard, the aggregator for a whole segment. Other nodes accept a proof only from these two ids.</p>
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
</div>
</div>
</div>
@ -487,80 +330,30 @@
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
</div>
<span class="goal-line" id="goal-line-2"></span>
</div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
<p class="line-text" id="s-tune-schedule"></p>
</div>
<div class="card">
<div class="card-head"><h3>Electricity</h3><span class="eyebrow">typed, never fetched</span></div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.</span></span></label>
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.</span><span class="dim" id="s-power-note"></span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span></span></label>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="s-region" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="s-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="s-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="k">electricity price</div>
<div class="row">
<input type="number" class="addr-input mono short" id="s-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses it.</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
<p class="help" id="s-price-note"></p>
</div>
<p class="restricted" id="s-region-restricted" hidden></p>
</div>
<div class="card">
<div class="card-head"><h3>Heat mode</h3><span class="eyebrow">Ember Heat</span></div>
<label class="switch"><input type="checkbox" id="s-heat"><span class="track"></span><span class="sw-text"><b>Hold a room temperature</b> <span class="dim">The cards heat for a share of every 10 minutes and rest for the rest. The hash follows. In heat mode your card is an electric heater that also earns; whether it beats your boiler depends on the network and the price, shown live.</span></span></label>
<p class="line-text" id="s-heat-line"></p>
<div class="row wrap">
<div class="field">
<div class="k">hold</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-set" min="5" max="30" step="0.5" placeholder="19" aria-label="Set point in degrees"><span class="note">°C</span></div>
</div>
<div class="field grow">
<div class="k">schedule</div>
<div class="row"><input type="text" class="addr-input mono" id="s-heat-schedule" placeholder="06:00 20, 22:00 16" spellcheck="false" autocomplete="off" aria-label="Schedule"><button class="btn small" id="s-heat-save">Save</button></div>
</div>
</div>
<p class="help">Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.</p>
<div class="field">
<div class="k">room now</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-room" min="-20" max="50" step="0.1" placeholder="19.5" aria-label="Room temperature now"><span class="note">°C from your own thermometer</span><button class="btn small" id="s-heat-room-save">Use it</button></div>
<p class="help" id="s-heat-room-note">A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Network</h3><span class="eyebrow" id="s-network-eyebrow"></span></div>
<p class="help" id="s-network-line"></p>
<div class="net-cards" id="s-network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="s-network-note" hidden></p>
<div class="ask inline" id="ask-network" hidden>
<span class="ask-text" id="ask-network-text"></span>
<button class="btn small primary" id="ask-network-yes">Switch</button>
<button class="btn small ghost" id="ask-network-no">Keep</button>
</div>
<p class="line-text" id="s-tune-schedule"></p>
</div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
<label class="switch"><input type="checkbox" id="s-profile"><span class="track"></span><span class="sw-text"><b>Make my page public</b> <span class="dim" id="s-profile-text">Off: igneum.network/address keeps your page unlisted. Nothing about this machine is published.</span></span></label>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check for jobs</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<div class="details indent" id="s-jobs-history" hidden></div>
<p class="note mono small indent" id="s-jobs-key" hidden></p>
<div class="field">
@ -569,7 +362,7 @@
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
<button class="btn small" id="s-name-save">Rename</button>
</div>
<p class="help">A name for you only.</p>
<p class="help">A label for you only.</p>
</div>
<div class="field">
<div class="k">appearance</div>
@ -581,13 +374,6 @@
</div>
</div>
<div class="card" id="s-interface-card">
<div class="card-head"><h3>Interface</h3><span class="eyebrow" id="s-ui-eyebrow">built in</span></div>
<p class="line-text" id="s-ui-line">Interface 1.0.0, built in</p>
<p class="help" id="s-ui-help">Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.</p>
<label class="switch"><input type="checkbox" id="s-ui-builtin"><span class="track"></span><span class="sw-text"><b>Use the built-in interface</b> <span class="dim">Serves the interface this version of the app was built with, even when a newer one has arrived over the air.</span></span></label>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
@ -596,7 +382,7 @@
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check for an update</button>
<button class="btn small" id="s-update">Check</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs at a quiet moment, never mid-program.</span></span></label>
@ -615,8 +401,8 @@
<details class="card adv" id="s-advanced">
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes are what lock the chain. Leave it on.</span></span></label>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proofs without checking them</b> <span class="dim">Devnet only. The node includes proofs it never checked. Changing this restarts the node.</span></span></label>
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span></span></label>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the node.</span></span></label>
<div class="ask inline" id="ask-trust" hidden>
<span class="ask-text" id="ask-trust-text"></span>
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
@ -708,8 +494,6 @@
</div>
<div class="toast" id="toast" hidden></div>
<script src="live-dag.js"></script>
<script src="proof-core.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -1,426 +0,0 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
* empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
* autoHeight (true by default when the host set no CSS height on the canvas, forced either way by the option; never in
* compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself and lets every lane through.
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
* Existing mount/push options and public methods are retained; see README.md for additions.
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
*/
(function (root) {
'use strict';
var instances = new WeakMap();
var TAU = Math.PI * 2;
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
function num(v) { return finite(v) ? v : null; }
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
function rgba(c, a) {
var s = String(c).trim(), h = s.replace('#', ''), rgb;
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
}
function rounded(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w/2, h/2)); ctx.beginPath(); ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function cloneBlock(b) {
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
}
function normalize(b) {
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
}
function mount(canvas, opts) {
opts=opts||{};
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
if (instances.has(canvas)) instances.get(canvas).destroy();
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
var doc=canvas.ownerDocument, compact=!!opts.compact;
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
// narrow (phones, under narrowBreak px, or opts.narrow true/false): window narrowWindow s until the viewer sets one, at most
// narrowLanes lanes, nodes no smaller than narrowMinNode, hairlines off except the selected chain and the selected block,
// checkpoint labels as the percent alone. The owner's call of 7 Oct 2026: faster drift, more gap, on a phone.
var narrow=false,narrowBreak=finite(opts.narrowBreak)?opts.narrowBreak:720,narrowWindow=clamp(finite(opts.narrowWindow)?opts.narrowWindow:30,30,300),narrowLanes=clamp(finite(opts.narrowLanes)?opts.narrowLanes:4,2,12),narrowMinNode=finite(opts.narrowMinNode)?opts.narrowMinNode:11,baseWindow=0,windowTouched=false;
// autoHeight: the host set no height on the canvas (its computed height is still the attribute's, 150 by default) and this
// is not the compact card; read before size() touches the attribute
var hostHeightSet=(function(){try{var cs=getComputedStyle(canvas),attr=canvas.getAttribute('height'),h=parseFloat(cs.height);return canvas.style.height!==''||!(finite(h)&&Math.round(h)===(attr===null?150:Number(attr)));}catch(e){return true;}})();
var autoHeight=opts.autoHeight===true||(opts.autoHeight!==false&&!compact&&!hostHeightSet),laneHeightOpt=finite(opts.laneHeight)?opts.laneHeight:0,wantH=0,laneHNow=46;
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
var reduced=mq?mq.matches:false, manualMotion=true;
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
var tip=opts.tooltip||null,chip=opts.chip||null;
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
function own(b){try{return !!mine(b);}catch(e){return false;}}
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
function theme(){
var s=getComputedStyle(doc.documentElement);
function c(n,f){return s.getPropertyValue(n).trim()||f;}
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
if(!following)return fixedRight;
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
if(state===s && reason===r)return;
if(s!=='live' && state==='live')staleRight=rightTime();
state=s;reason=r||null;
if(s==='live')staleRight=null;
emit('onState',s,reason);redraw();
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
var laneH=laneHeightOpt||(compact?26:narrow?40:46),cap=narrow?narrowLanes:maxLanes;laneHNow=laneH;
var capacity=autoHeight?cap:Math.min(cap,Math.max(2,Math.floor((H-padT-padB)/laneH))),ids=Array.from(counts.keys());
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
var keep=lanes.filter(function(id){return chosen.includes(id);});
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
// the wanted box height for these lanes (2.0.4): reported on change; applied here only under autoHeight
var nextH=padT+padB+Math.max(2,lanes.length)*laneH;
if(nextH!==wantH){wantH=nextH;emit('onSize',wantH,{lanes:lanes.length,laneHeight:laneH,narrow:narrow,compact:compact});if(autoHeight&&canvas.style.height!==wantH+'px')canvas.style.height=wantH+'px';}
var now=performance.now();
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
});
}
function size(){
if(destroyed)return;
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
next.set(hash,v);
});
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
cps=latestCps.map(function(c){return Object.assign({},c);});
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
layout();
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
if(hover&&!view.has(hover))hover=null;
queuedCount=0;
}
function push(d){
if(destroyed)return false;
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
}
var now=Date.now(), incoming=[], bad=0;
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
// Bounded retention uses the newest observed header, not a local wall-clock guess.
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
omitted=0;
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state,
// the lane geometry (2.0.4), so a page that sizes its own box reads no constants
laneHeight:laneHNow,padT:padT,padB:padB,capacity:narrow?narrowLanes:maxLanes,wantedHeight:wantH,autoHeight:autoHeight};
}
function poll(){
if(destroyed||opts.poll===false||doc.hidden)return;
if(inflight){pollAgain=true;return;}
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
var url;
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
catch(e){finishError(e);return;}
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
}
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
function draw(t){
if(destroyed||!W||!H)return;
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
ctx.lineWidth=1;ctx.setLineDash([2,7]);
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
ctx.setLineDash([]);
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
}
});
var cpLabels=[];
cps.forEach(function(c){
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
if(!ref||ref.ts<left||ref.ts>rt)return;
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
if(!compact && (narrow || W>510))cpLabels.push({x:x,cp:c,locked:locked});
});
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
cpLabels.forEach(function(p){var pct=p.cp.fraction_total===null||p.cp.fraction_total===undefined?'':Math.round(p.cp.fraction_total*100)+'%',label=narrow?(pct||(p.locked?'LOCKED':'PENDING')):(p.locked?'LOCKED':'PENDING')+(pct?' '+pct:''),w=label.length*5.5+(narrow?14:22),x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
});
// Animate lane transitions without changing their underlying key identity.
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
if(!path&&!selectedEdge){if(narrow||pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
});});
var S=compact?6.5:narrow?clamp(lh*.24,narrowMinNode,15):clamp(lh*.22,minNode,13);
blocks.forEach(function(b){if(!b.on)return;
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
// a proven block glows and carries a ring, so the proof reads from across the room
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
}
}
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
ctx.globalAlpha=1;
});ctx.restore();
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
var ticks=W<420?2:4;
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
}
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
if(!blocks.some(function(b){return b.on;})){
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
}
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
lastT=t;
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
function words(b,nowMs){
if(!b)return {title:'No block selected',lines:[]};
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
'Shards: '+shardWords(b,nowMs).summary]};
}
function showTip(b){
if(!tip)return;
if(!b){tip.hidden=true;return;}
var w=words(b),key=w.title+'|'+w.lines.join('|');
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
}
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowTouched=true;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
function setPaused(p){p=!!p;if(paused===p)return;
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
else{paused=false;syncView();}
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
emit('onPause',paused);emit('onStats',stats());redraw();
}
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
canvas.style.touchAction='pan-y';
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
on(canvas,'pointermove',function(ev){
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
});
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
on(canvas,'pointercancel',function(){drag=null;});
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
var touchStart=null;
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
on(canvas,'keydown',function(ev){
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
});
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
on(root,'resize',size);
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
}
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getWantedHeight:function(){return wantH;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
// the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the
// block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off
// the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited.
// With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid.
function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
function shardWords(b,nowMs){
var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[];
if(!sh.length){
if(!b)return {summary:'',items:[],state:'none'};
if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
}
var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};});
var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0};
sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;});
var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age));
return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'};
}
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
function legend(o){
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
return (o&&o.mine?[own]:[]).concat(list);
}
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
// children the page already placed there (the app's source line) after the entries
function renderLegend(el,o){
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
while(el.firstChild)el.removeChild(el.firstChild);
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
keep.forEach(function(n){el.appendChild(n);});return entries;
}
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
})(window);

View file

@ -96,7 +96,7 @@ test('update wording: available, downloading with percent, installing, failed wi
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
// updated: gone within 60 s of the new version starting
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
assert.match(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59, now: 1_800_000_000 })).text, /^Updated to 0\.3\.6 at \d\d:\d\d\.$/);
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
@ -106,26 +106,22 @@ test('update wording: available, downloading with percent, installing, failed wi
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
const r = jobNotice(run(), NOW);
assert.equal(r.text, 'A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'shard benchmark · RESULT shard=2 prove_s=39.8', 'the technical line goes behind the chevron');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'A job from the team is running: shard benchmark, 6 min.');
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
const d = jobNotice(fin('done'), NOW);
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
assert.equal(d.key, 'job:done:job-6');
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
const f = jobNotice(fin('failed'), NOW);
assert.equal(f.text, 'A job from the team failed: build, after 32 min. The report did not upload.');
assert.match(f.detail, /BUILD FAILED: error\[E0425\]: cannot find value `foo` · exit 1$/);
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
assert.equal(f.tone, 'bad');
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
// no error line among the results: the summary is the cause
assert.match(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, /^build: build exited with code 1 · exit 1$/);
assert.equal(jobNotice(fin('timeout'), NOW).text, 'A job from the team ran out of time: build, after 32 min. The report did not upload.');
assert.equal(N.jobWord('', 'update-now-0313-switch-d937c69d'), 'update check');
assert.equal(N.jobWord('restart', 'restart-miners-0312'), 'restart');
assert.equal(N.jobWord('collect', ''), 'log collection');
assert.equal(N.jobWord('', 'ember-tune-pc1-5'), 'tuning check');
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
assert.equal(jobNotice(null, NOW), null);
});

View file

@ -1,72 +0,0 @@
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
*/
(function(root){
'use strict';
var mounted=new WeakMap(),TAU=Math.PI*2;
function mount(canvas,opts){
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
var phase=0,lastT=0;
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
function iso(x,y,w,h,depth,fill,stroke){
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
}
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
function draw(t){
if(!W||!H||disposed)return;frames++;
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
// A stationary coordinate grid, not fabricated hashrate or work counters.
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
var count=Math.min(12,shards.length);
for(var i=0;i<count;i++){
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
var midx=(px+x)/2,midy=(py+y)/2;
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
}
// Layered block core. Geometry does not imply three GPUs or three proofs.
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
ctx.lineWidth=1;
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
var topY=y-11*scale-lift;
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
}
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
function visibility(){if(document.hidden)stop();else kick();}
function reduction(e){reduce=e.matches;stop();kick();}
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
}
root.IgneumProof={mount:mount,version:'2.0.0'};
})(window);

View file

@ -1,49 +0,0 @@
// node --test app/igneum-app/ui/ui-ota.test.mjs (no dependencies; the pre-push gate runs it)
// The interface over the air (docs/plans/ui-ota.md): Settings > Interface's words and the gentle reload rule.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
test('the embedded interface says built in; an over-the-air one says so with its date', () => {
assert.deepEqual(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true }), { line: 'Interface 1.0.0, built in', eyebrow: 'built in', help: '' });
const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', installed_at: Date.UTC(2026, 9, 7, 12) / 1000, confirmed: true });
assert.equal(w.line, 'Interface 1.0.1, over the air, 7 Oct 2026');
assert.equal(w.eyebrow, 'over the air');
assert.equal(w.help, 'Built in: 1.0.0.');
assert.equal(V.interfaceWords({}).line, '');
assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.2', 'the embedded interface version is three-part and in ui/VERSION (1.0.2: the 0.3.22 tree, 7 October 2026)');
});
test('the help line says what is pending, refused, held back by the switch, or skipped', () => {
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', confirmed: false }).help, 'Waiting for this window to confirm the new interface.');
assert.ok(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, error: 'the page did not answer within 10 s of loading it.' }).help.startsWith('The last interface over the air was refused:'));
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, builtin: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is published over the air; switch this off to take it.');
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, busy: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is downloading.');
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, note: 'interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19)' }).help, 'Interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19).');
});
test('the page reloads only when the served interface changed and nobody is mid-task', () => {
const u = { active_version: '1.0.1' };
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard' }), true);
assert.equal(V.shouldReload('1.0.1', u, { phase: 'dashboard' }), false, 'same version: nothing');
assert.equal(V.shouldReload('', u, { phase: 'dashboard' }), false, 'a page that never pinged does not reload');
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', typing: true }), false);
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', sheetOpen: true }), false);
assert.equal(V.shouldReload('1.0.0', u, { phase: 'address' }), false, 'never mid-setup');
assert.equal(V.shouldReload('1.0.0', null, { phase: 'dashboard' }), false);
});
test('the page pings health once after its first paint and reports a first-paint error (the DOM block carries both)', () => {
assert.ok(src.includes("api('api/ui/health', {})"), 'the health ping');
assert.ok(src.includes("api('api/ui/health', { error: uiFirstError })"), 'the first-paint error');
assert.ok(src.indexOf("window.addEventListener('error'") < src.indexOf('function poll()'), 'the error listener is installed before the first poll');
assert.ok(!/<script[^>]+src=["']https?:/.test(readFileSync(join(here, 'index.html'), 'utf8')), 'no remote script in the page');
});

View file

@ -14,11 +14,10 @@ const V = mod.exports.View;
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
test('the five sections and their order (miner-ui-4)', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['overview', 'cards', 'earnings', 'prove', 'settings']);
assert.equal(V.page('cards').title, 'Cards');
test('the four sections and their order (miner-ui-3)', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
assert.equal(V.page('earnings').title, 'Earnings');
assert.equal(V.page('nonsense').id, 'overview');
assert.equal(V.page('nonsense').id, 'mine');
});
test('a GPU row: name, kind, the numbers where known, the switch', () => {
@ -51,99 +50,6 @@ test('a GPU row: temperatures turn amber then red; unknown numbers are empty', (
assert.equal(apple.meta, '128 GB unified');
});
test('an Intel Arc card is a discrete Intel row: INTEL badge, no power reading sentence, measure-only tune words (intel-arc, 7 October 2026)', () => {
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', vram_mb: 12208, enabled: true, state: 'mining', hash_now: 15.2, power_w: 0, temp_gpu: 0, sweep_supported: false, sweep_state: 'unsupported', sweep_note: 'not available: Intel Arc exposes no power cap or reading through OpenCL (the driver\'s IGCL counters come next)' });
const r = V.cardRow(arc);
assert.equal(r.vendor, 'intel');
assert.equal(r.kindWord, 'Discrete');
assert.equal(r.integrated, false);
assert.equal(r.on, true);
assert.ok(r.meta.startsWith('12 GB'), r.meta);
assert.equal(r.power, '', 'no watts cell without a reading');
assert.equal(V.noReading(arc), 'No power or temperature reading on Intel Arc yet: the driver exposes them through IGCL, not OpenCL');
assert.equal(V.tunable(arc), false, 'no cap, no clock control: not tunable');
assert.equal(V.proveTier(arc), 'Intel(R) Arc(TM) B580 Graphics: cannot prove yet (proving needs an NVIDIA card).');
const tm = mod.exports.TuneLine.model(arc, 1000);
assert.equal(tm.kind, 'off');
assert.equal(tm.text, 'tuning: measure only on Intel Arc once a power reading exists');
});
test('driver-check: the row\'s driver strip per state (offer, running, reboot, error, note, fine)', () => {
const offer = { vendor: 'intel', status: 'missing', installed: '', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.', installable: true };
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', driver_os: '', driver_offer: offer });
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
let w = V.driverWords(arc, idle);
assert.equal(w.kind, 'offer');
assert.equal(w.button, 'install');
assert.equal(w.text, offer.text);
assert.ok(w.sub.startsWith('Downloaded from downloadmirror.intel.com, checked against the vendor'), w.sub);
// running: the progress line names the vendor, the version and the percent while downloading
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'intel', version: '32.0.101.9034', progress: 0.42, message: 'downloading 32.0.101.9034 (889 MB) from downloadmirror.intel.com', dry_run: false } });
assert.equal(w.kind, 'running');
assert.equal(w.text, 'Downloading the Intel Arc driver 32.0.101.9034 · 42%');
w = V.driverWords(arc, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: true } });
assert.equal(w.text, 'Installing the Intel Arc driver 32.0.101.9034');
assert.equal(w.dry, true);
// reboot: the Restart now button, mining continues until the click
w = V.driverWords(arc, { drivers: { status: 'reboot', vendor: 'intel', version: '32.0.101.9034', reboot_required: true } });
assert.equal(w.kind, 'reboot');
assert.equal(w.button, 'restart');
assert.equal(w.text, 'Intel Arc driver 32.0.101.9034 installed. Restart Windows to finish.');
// error: the reason and Install again
w = V.driverWords(arc, { drivers: { status: 'error', vendor: 'intel', version: '32.0.101.9034', error: 'sha256 mismatch: the file is not the one the table names' } });
assert.equal(w.kind, 'error');
assert.equal(w.button, 'install');
assert.ok(w.text.startsWith('The Intel Arc driver did not install: sha256 mismatch'), w.text);
// another vendor\'s install does not touch this row
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'nvidia', version: '617.42', progress: 0.1 } });
assert.equal(w.kind, 'offer');
// a note (macOS, Linux, HiveOS): no button
const mac = card({ vendor: 'apple', kind: 'apple', driver_offer: { vendor: 'apple', status: 'none', text: 'Apple silicon: no driver step, macOS carries it.', installable: false } });
w = V.driverWords(mac, idle);
assert.equal(w.kind, 'note');
assert.equal(w.button, undefined);
const linux = card({ driver_offer: { vendor: 'nvidia', status: 'missing', text: 'Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).', installable: false } });
assert.equal(V.driverWords(linux, idle).kind, 'note');
// fine: nothing on the row (the details panel carries the sentence)
const fine = card({ driver_os: '617.42', driver_offer: { vendor: 'nvidia', status: 'fine', text: 'NVIDIA driver 617.42: fine.', installable: false } });
assert.equal(V.driverWords(fine, idle).kind, 'fine');
// no table: nothing
assert.equal(V.driverWords(card({ driver_offer: null }), idle).kind, 'none');
assert.equal(V.vendorWord('amd'), 'AMD');
});
test('driver-check: an install offer says every card of the vendor stops, and an eGPU card adds the machine warning (PC 2, 7 October 2026; every-card rule 0.3.22)', () => {
const offer = { vendor: 'intel', status: 'old', installed: '32.0.101.6733', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034, 889 MB (the worker needs 32.0.101.9034 or newer; 32.0.101.6733 is installed)', installable: true };
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
const egpu = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'external', worker: 'OpenCL', enabled: true, state: 'mining', driver_offer: offer });
let w = V.driverWords(egpu, idle);
assert.equal(w.kind, 'offer');
assert.equal(w.external, true);
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install and comes back after it; the other cards keep mining.'), w.sub);
assert.ok(w.sub.includes('a card in an external enclosure can take the machine for a minute and may need a restart'), w.sub);
// the same card read as inside the case (PC 2's Arc on 0.3.21 read discrete in its enclosure): the stop sentence stays,
// the enclosure warning goes
const inside = card({ ...egpu, kind: 'discrete' });
w = V.driverWords(inside, idle);
assert.equal(w.external, false);
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install'), w.sub);
assert.ok(w.sub.endsWith('The display resets during the install: save your work first.'), w.sub);
assert.ok(!w.sub.includes('Mining continues.'));
// while it installs, the running line says the vendor's workers are stopped; the eGPU row adds its enclosure
w = V.driverWords(egpu, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: false } });
assert.equal(w.kind, 'running');
assert.ok(w.sub.includes('The Intel Arc workers are stopped until the installer ends; the display may reset, and this card\u2019s enclosure can take the machine'), w.sub);
w = V.driverWords(inside, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'x', dry_run: false } });
assert.ok(w.sub.endsWith('The Intel Arc workers are stopped until the installer ends; the display may reset.'), w.sub);
});
test('a card behind a USB4 or Thunderbolt router is an eGPU on the Cards page (PC 2, 7 October 2026)', () => {
const r = V.cardRow(card({ key: 'nvidia:1:NVIDIA GeForce RTX 5060 Ti', name: 'NVIDIA GeForce RTX 5060 Ti', vendor: 'nvidia', kind: 'external', enabled: true, state: 'mining', hash_now: 40, power_w: 120, temp_gpu: 55 }));
assert.equal(r.kindWord, 'eGPU');
assert.equal(r.integrated, false);
assert.equal(r.canToggle, true);
});
test('an integrated GPU is shown as integrated and off, with its reason', () => {
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
assert.equal(r.integrated, true);
@ -155,8 +61,7 @@ test('an integrated GPU is shown as integrated and off, with its reason', () =>
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
// no fault is permanent (7 October 2026, docs/plans/miner-faults.md): a restarting card is the bad tone, there is no faulted state
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 120, message: 'hash rate 0 for 60 s while the node is synced; trying again' })).tone, 'bad');
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
});
@ -167,8 +72,8 @@ test('the big button: start when paused, stop when mining, disabled with no card
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
assert.equal(none.disabled, true);
assert.equal(none.act, '');
assert.equal(none.sub, 'switch a card on in Cards first');
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no card this app can use');
assert.equal(none.sub, 'switch a GPU on below first');
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
});
@ -201,11 +106,11 @@ test('the next consensus switch is the first height above the DAA score, in plai
const next = V.nextSwitch(sw, 140000);
assert.equal(next.name, 'Fees v1');
assert.equal(next.away, 70000);
assert.equal(V.switchLine(next, 140000), 'Fees v1 at block 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
assert.equal(V.nextSwitch(sw, 300000), null);
assert.match(V.switchLine(null, 300000), /Every planned rule change is behind this node/);
assert.match(V.switchLine(null, 0), /^A rule change is planned ahead/);
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
assert.equal(V.nextSwitch([], 5), null);
});
@ -234,9 +139,9 @@ test('the dev-fee lines name the share and the switch that turns it off', () =>
test('the remote-jobs line and the helpers', () => {
const title = (j) => j.title || j.kind;
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off. Nothing runs here until the switch above allows jobs from the team.');
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'This build has no jobs address.');
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running: build.');
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until the switch above allows remote jobs.');
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
assert.equal(V.shortHex('0xabc'), '0xabc');
@ -254,7 +159,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
assert.equal(r.word, 'removed');
assert.equal(r.tone, 'off');
assert.equal(r.hash, '');
assert.match(r.sub, /^unplugged\. Its row goes/);
assert.match(r.sub, /^unplugged; its worker stopped/);
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
const b = V.cardRow(bad);
assert.equal(b.unusable, true);
@ -289,7 +194,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
assert.equal(V.present(bad), false);
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
assert.equal(t.disabled, true);
assert.equal(t.sub, 'no card this app can use');
assert.equal(t.sub, 'no GPU this app can drive');
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
assert.equal(t2.sub, 'mining on 1 of 1 card');
});
@ -327,7 +232,6 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
});
@ -345,7 +249,7 @@ test('the tune line: never run, running with a step and a bar, tuned with the po
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
assert.match(prior.text, /^Tuned 2 min ago from what other miners found, confirmed · 2470 MHz at 100%/);
assert.match(prior.text, /^Tuned 2 min ago from the fleet prior, confirmed · 2470 MHz at 100%/);
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
@ -375,7 +279,7 @@ test('the next check is a weekday inside a week, a date beyond it, due now when
test('the Tuning card schedule and the goal consequence', () => {
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work when you press them.');
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work by hand.');
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
@ -391,10 +295,8 @@ test('the Tuning card schedule and the goal consequence', () => {
test('the prove tier sentence per card, and the counts in one line', () => {
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
// main's rule, 7 October 2026: under 12 GB the prover refuses and says why (the same sentence as src/provedefault.rs)
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 3080', vram_mb: 10240 })), 'NVIDIA GeForce RTX 3080: proving needs a 12 GB card; mining continues (10 GB).');
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (proving needs an NVIDIA card).');
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (the prover is CUDA only).');
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
});
@ -408,706 +310,8 @@ test('the node line, the activity line, the asks', () => {
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? Mining restarts.');
assert.match(V.trustAsk(true), /^Include proofs the node never checked\? Devnet only\. The node restarts\.$/);
assert.match(V.trustAsk(false), /^Check proofs again\?/);
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? The miner restarts.');
assert.match(V.trustAsk(true), /^Include proof records the node never checked\? Devnet only; the node restarts\.$/);
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
});
test('the activity feed and the pill speak in the user\'s voice; the engine line is kept as the tooltip', () => {
const pe = V.plainEvent('job update-now-0313-switch-d937c69d (Exec restart: re-read the manifest (exec_restart_number 27276) and restart the node with it) done after 0 min, exit 0, report uploaded: update check and install asked');
assert.equal(pe.text, 'A job from the team ran: update check, 0 min');
assert.match(pe.tech, /^job update-now/);
assert.equal(V.plainEvent('updated to Igneum Miner 0.3.14 from 0.3.13').text, 'Updated to 0.3.14');
assert.equal(V.plainEvent('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}').text, 'Rule settings read from the team');
assert.equal(V.plainEvent('node synced: 151512 blocks, 6 peer(s)').text, 'Node synced: 151,512 blocks, 6 peers');
assert.equal(V.plainEvent('block accepted by the node (Apple M5 Max)').text, 'Block found by Apple M5 Max');
assert.equal(V.plainEvent('1 remote job received from Igneum').text, '1 job from the team received');
assert.equal(V.plainEvent('remote job: update check now; a newer version installs at once').text, 'The team asked for an update check');
assert.equal(V.plainEvent('a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)').text, 'Using the node already running on this machine');
assert.equal(V.plainEvent('igneumd exited with code 101 after 300 s; restarting').text, 'The node stopped (code 101). Restarting');
assert.equal(V.plainEvent('miner apple:0 exited with code 1; restarting in 8 s').text, 'A card stopped (code 1). Restarting in 8 s');
assert.equal(V.plainEvent('NVIDIA GeForce RTX 5090 kernel race: l128w16 at 122.3 MH/s (+4.1% over base, 6 variants, 40 s)').text, 'NVIDIA GeForce RTX 5090: fastest program chosen, 122.3 MH/s');
assert.equal(V.plainEvent('block 59199 shard 0 paid 0.0125 IGN').text, 'Proof paid: 0.0125 IGN');
assert.equal(V.plainEvent('rewards go to 0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8').text, 'Rewards go to 0xdd44…86e8');
assert.equal(V.plainEvent('mining resumed').text, 'Mining resumed');
assert.equal(V.plainEvent('something new the engine says').text, 'Something new the engine says');
assert.equal(V.plainEvent('something new the engine says').tech, '');
const base = { setup_done: true, quitting: false, mining: { state: 'mining', hash_total: 510.6, cards: [] }, node: { state: 'synced' } };
assert.deepEqual(V.pill(base), { text: 'Mining · 511 MH/s', tone: 'on' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }), { text: 'Mining · 17.0 MH/s', tone: 'on' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Paused', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'syncing' } }), { text: 'Syncing the node', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'restarting' } }), { text: 'Node restarting', tone: 'bad' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
});
test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving, the sparkline points, the plain label', () => {
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }];
const tuned = card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 290, sweep_mhs: 122.3, tune_curve: curve, sweep_state: 'idle', sweep_note: '' });
const e = V.ember(tuned, settings(), NOW);
assert.equal(e.kind, 'tuned'); assert.equal(e.fill, 1);
assert.equal(e.label, 'Tuned 2 h ago');
assert.match(e.sub, /^2470 MHz at 80% · next check /);
assert.equal(e.before, 374); assert.equal(e.after, 290); assert.equal(e.savingW, 84);
assert.equal(e.savingText, 'saves 84 W, 0.16% of rate');
assert.equal(e.points.length, 4); assert.equal(e.points.filter((p) => p.chosen).length, 1); assert.equal(e.points.filter((p) => p.marked).length, 1);
// Ember's own fields win over the curve (tune_before_watts / tune_before_mhs, step 0 of the last full plan)
const withFields = V.ember(card({ ...tuned, tune_before_watts: 380, tune_before_mhs: 122.4, tune_source: 'confirm' }), settings(), NOW);
assert.equal(withFields.before, 380); assert.equal(withFields.savingW, 90); assert.match(withFields.label, /from what other miners found$/);
// a confirm plan without the fields claims no saving (its curve's first row is the prior, not an untuned point)
assert.equal(V.ember(card({ ...tuned, tune_source: 'confirm' }), settings(), NOW).savingText, '');
assert.equal(V.ember(card({ ...tuned, tune_before_watts: 0 }), settings(), NOW).savingText, '', 'zero means never measured');
const running = V.ember(card({ state: 'tuning', tune_step: 4, tune_steps: 9, tune_eta_s: 410, sweep_state: 'running' }), settings(), NOW);
assert.equal(running.kind, 'running'); assert.equal(running.fill.toFixed(2), '0.33'); assert.equal(running.label, 'Tuning: step 4 of 9'); assert.equal(running.sub, 'about 7 min left');
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
});
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
const cards = [card(), card({ key: 'b', power_w: 100 })];
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
assert.equal(V.fleetPounds({}, cards, 0), null);
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
assert.equal(V.liveNote(null), '');
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
assert.equal(V.isMine('deadbeef', mineCards), false);
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
assert.equal(V.isMine('', mineCards), false);
});
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
const ok = V.nodeLine(n(), { severity: 'none' }, '');
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
assert.match(ok.sub, /Last block 3 s ago/);
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
assert.match(none.sub, /^No other node on our chain is connected/);
// an older node without the age keeps the old words
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
});
test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => {
const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, '');
assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/);
});
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
assert.equal(/\bfinal\b/i.test(p.note), false);
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
// N5: a node built without the mining engine is a fault
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
});
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (an older node)' });
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
// 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why
assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' });
const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/);
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
});
// ---- miner-ui-5 (7 October 2026): the miner's first month ----
// The chain facts as GET api/ladder shapes them (src/chainfacts.rs) for one key, 6ad0e117 (the example key of the project lead's
// chain-scene reference), under the finality params the devnet node reports (api/live params, 7 Oct 2026: dust 5,
// weightWindow 7,200 s, presenceWindow 20, checkpointInterval 30; tools/fleet/devnet2-override.json sets none of them,
// so Devnet 2 runs the same values). Every number the rungs show is a field of getFinalityWeights,
// getFinalityCheckpoints, /api/stats or this app's own ladder.json, and the rung names it on hover.
const DN2 = { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 };
const MAINNET = { dust: 100, weight_window: 2592000, presence_window: 240, checkpoint_interval: 30, min_daa: 7200 };
const chainOf = (over, params) => ({ ok: true, source: 'node', rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: '' }, params: params || DN2, checkpoint_index: 8495, latest_locked_index: 8495, next_index: 8496, finality_active: true, total_weight: 6000, active_weight: 5900, voters: 14, keys_listed: 4, keys_cap: 0, network: { hashrate_hps: 100e9, miner_ign_per_block: 80, ign_per_block: 100, ramp_factor: 1, daa: 1284117, blocks_per_day_measured: 86400, bps: 1, stale: false }, mine: [], best: null, ...over });
const key = (over) => ({ id: '6ad0e117', blocks: 0, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4, ...over });
const T5 = 1791362116;
const stateOf = (over) => ({ now: T5, uptime_s: 1800, version: '0.3.19', installed_at: T5 - 3000, setup_done: true, address: { display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8' }, settings: { prove: false, dev_fee: true, power_price_pence: 28 }, node: { synced: true }, proving: { paid: 0, paid_wei: 0 }, mining: { state: 'mining', hash_total: 100, accepted_total: 0, accepted_session: 0, found: [], cards: [card({ key: 'nvidia:0:RTX 4070', name: 'NVIDIA GeForce RTX 4070', hash_now: 100, hash_avg: 100, power_w: 150, ids: ['6ad0e117'] })] }, ladder: { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: 0, first_mining_at: 0 }, ...over });
const RPC = /getFinalityWeights|getFinalityCheckpoints|igneum_getProofRecords|\/api\/stats|this app/;
test('the Poisson count-up: the gap from the card’s rate and the network’s, the chance from the time waited', () => {
const a = V.poisson(100, 100e9, 0, 1);
assert.equal(Math.round(a.gap_s), 1000);
assert.equal(a.gapText, '17 minutes');
assert.equal(a.chanceText, '0%');
assert.equal(a.line, 'A card like yours finds a block about every 17 minutes on today’s network. Chance so far: 0%.');
assert.equal(a.pool, '');
const b = V.poisson(17, 100e9, 1800, 1);
assert.equal(b.gapText, '1.6 hours');
assert.equal(b.chanceText, '26%');
const c = V.poisson(17, 1e12, 3600, 1);
assert.equal(c.gapText, '16 hours');
assert.match(c.pool, /^Above an 8-hour gap a pool pays by the share/);
assert.equal(V.poisson(100, 100e9, 1000, 1).chanceText, '63%');
assert.equal(V.poisson(17, 10e12, 0, 1).gapText, '6.8 days');
assert.equal(V.poisson(100, 100e9, 10, 1, true).line.indexOf('Your cards find'), 0);
assert.equal(V.poisson(0, 100e9, 10, 1), null);
assert.equal(V.poisson(100, 0, 10, 1), null);
// IGN a day: 86,400 blocks a day, the share of them, 80 producer points each (the litepaper's 6,912 example)
assert.equal(Math.round(V.ignPerDay(100, 100e9, 80, 1)), 6912);
assert.equal(V.ignPerDay(100, 0, 80, 1), null);
});
test('a Devnet 2 key walks every rung on screen, and every rung names its RPC field (the gate)', () => {
// 0. mining, nothing found: the first rung is "now", the rest wait; the count-up line is on
let s = stateOf(), c = chainOf();
let r = V.ladderRungs(s, c, T5);
assert.deepEqual(r.map((x) => x.id), ['first', 'vote', 'signed', 'window', 'rank', 'streak', 'shard']);
assert.deepEqual(r.map((x) => x.state), ['now', 'next', 'next', 'next', 'next', 'next', 'off']);
assert.equal(r[0].line, 'Waiting for your first block.');
assert.equal(V.currentRung(r).id, 'first');
r.forEach((x) => assert.match(x.field, RPC, x.id + ' names no RPC field: ' + x.field));
assert.equal(V.blockCard(null, s, c, T5), null);
// 1. the first block: the card raises, the key enters the table with one block under the dust line
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, milestone: m1 } });
c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.deepEqual(r.map((x) => x.state), ['done', 'now', 'next', 'now', 'done', 'next', 'off']);
assert.equal(r[0].line, 'First block 1 min ago, NVIDIA GeForce RTX 4070.');
assert.equal(r[0].sub, '1 block with your key in the window');
assert.equal(r[1].line, '1 of 5 blocks to a vote.');
assert.equal(r[1].progress, 0.2);
assert.equal(r[3].line, '1 of 2 hours.');
assert.equal(r[4].line, 'Rank 4 of 4 keys.');
assert.equal(V.currentRung(r).id, 'vote');
const bc = V.blockCard(m1, s, c, T5);
assert.equal(bc.eyebrow, 'your first block');
assert.equal(bc.title, 'Block 1,284,117 is yours.');
assert.equal(bc.cardLine, 'NVIDIA GeForce RTX 4070 · 100 MH/s · 0.100% of the network');
assert.equal(bc.ignLine, '80.00 IGN to 0xdd44…86E8 (72 producer points, 8 for signing)');
assert.equal(bc.rankLine, 'rank 4 of 4 keys · hour 1 of 2');
assert.equal(bc.url, 'https://igneum.network/block/' + m1.hash);
assert.equal(bc.hashShort, '9f3aabab…abc21e');
assert.match(bc.fields.ign, /block_reward\.miner_ign/);
// 2. five blocks: the vote
c = chainOf({ mine: [key({ blocks: 5, voter: true, revealed: true, rank: 4 })], best: key({ blocks: 5, voter: true, revealed: true, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[1].state, 'done');
assert.equal(r[1].line, 'Your key has a vote.');
assert.equal(r[1].sub, '5 blocks in the window, the line is 5');
assert.equal(r[2].state, 'now');
assert.equal(r[2].line, 'Signing: the next locked checkpoint carries your vote.');
assert.equal(r[5].state, 'now');
// 3. the signature in a locked checkpoint, the streak
s = stateOf({ ...s, ladder: { ...s.ladder, votes_signed: 31, last_vote_index: 8496, last_vote_at: T5 - 5, last_signed_locked: 8496, signed_locked_count: 30, streak_s: 900 } });
c = chainOf({ latest_locked_index: 8496, mine: [key({ blocks: 5, voter: true, participation: 1, rank: 4 })], best: key({ blocks: 5, voter: true, participation: 1, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[2].state, 'done');
assert.equal(r[2].line, 'Your signature is in checkpoint 8,496.');
assert.equal(r[2].sub, '30 signed and locked · presence 100% · latest lock 8,496');
assert.equal(r[5].state, 'done');
assert.equal(r[5].line, 'Signing 8 of 80 points, 15 min unbroken.');
// 4. the full window: two hours on the devnet; the rank
s = stateOf({ ...s, ladder: { ...s.ladder, first_block_at: T5 - 7300, blocks_30d: 61, blocks_today: 61 } });
c = chainOf({ latest_locked_index: 8700, mine: [key({ blocks: 61, voter: true, participation: 1, rank: 3 })], best: key({ blocks: 61, voter: true, participation: 1, rank: 3 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[3].state, 'done');
assert.equal(r[3].line, '2 of 2 hours. Full weight.');
assert.equal(r[4].line, 'Rank 3 of 4 keys.');
assert.equal(r[4].sub, '14 keys vote · weight, never hashrate: a card that arrived today sits at the bottom');
// P. a paid shard
s = stateOf({ ...s, settings: { ...s.settings, prove: true }, proving: { paid: 1, paid_wei: '1150000000000000000' }, ladder: { ...s.ladder, shards: [{ block: 1284117, shard: 3, wei: '1150000000000000000', at: T5 - 30 }] } });
r = V.ladderRungs(s, c, T5);
assert.equal(r[6].state, 'done');
assert.equal(r[6].line, 'Your card proved shard 3 of block 1,284,117.');
assert.equal(r[6].sub, '1.15 IGN · 1 paid');
assert.deepEqual(r.map((x) => x.state), ['done', 'done', 'done', 'done', 'done', 'done', 'done']);
r.forEach((x) => assert.match(x.field, RPC));
assert.equal(V.currentRung(r).id, 'shard');
// proving off: the rung reads off, never a fault
assert.equal(V.ladderRungs(stateOf(), chainOf(), T5)[6].line, 'Proving is off.');
});
test('first-block-21 (the project lead, 7 October 2026): the first-block card is seen once in the app’s life, never on a later run', () => {
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
const c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
const afterFirst = (over, ladderOver) => stateOf({ mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 7200, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: true, ...ladderOver }, ...over });
const stale = { ...m1, at: T5 - 7200 };
// KNOWN-FAILED FIRST: a second run against a 0.3.20 engine (its ladder has no first_block_shown field; the engine up
// 30 s, the lifetime count already 1, the card raised two hours ago on the first run and re-sent from its record)
// showed "your first block" again before this change
let s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { milestone: stale });
delete s.ladder.first_block_shown;
assert.equal(V.blockCard(stale, s, c, T5), null, 'a second run with the count at 1 shows no first-block card');
assert.equal(V.staleCard(stale, s, T5), true);
// the first ever block: the card, in the run that raised it, and the body the page reports when it displays it
s = stateOf({ started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: false, milestone: m1 } });
const bc = V.blockCard(m1, s, c, T5);
assert.equal(bc.eyebrow, 'your first block');
assert.equal(bc.title, 'Block 1,284,117 is yours.');
assert.deepEqual(V.cardSeenBody(bc), { count: 1, at: T5 - 60 });
assert.equal(V.cardSeenBody(null), null);
assert.equal(V.firstWait(s), false, 'the count-up gives way to the card');
// seen once (main's reading): a block found overnight with no window, the engine restarted since (an auto-update):
// a 0.3.21 engine keeps the unseen card and the first open shows it, however old it is
s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { first_block_shown: false, milestone: stale });
assert.equal(V.staleCard(stale, s, T5), false, 'a 0.3.21 engine owns the rule');
assert.equal(V.blockCard(stale, s, c, T5).eyebrow, 'your first block');
// a second open after it was seen: the engine dropped the card at its start (ladder.rs start_run); the first rung
// stays done and the count-up never comes back
s = afterFirst({ uptime_s: 12, started_at: T5 - 12 }, { milestone: null });
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
assert.equal(V.firstWait(s), false);
// a restart with the count at 1 and the card seen: nothing to show
s = afterFirst({ uptime_s: 45, started_at: T5 - 45 }, { milestone: null });
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
// an update with the count at 1 from 0.3.20: the engine takes the flag from the old record and sends no card; a
// 0.3.20 engine without started_at re-sending its card is refused through uptime_s
s = afterFirst({ uptime_s: 45, started_at: T5 - 45, version: '0.3.21' }, { milestone: null });
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
s = afterFirst({ uptime_s: 45, started_at: 0 }, { milestone: stale });
delete s.ladder.first_block_shown;
assert.equal(V.blockCard(stale, s, c, T5), null);
// a kept data directory with the count at 0 and the flag unset: the count-up shows, then the first block raises the card
s = stateOf({ uptime_s: 300, started_at: T5 - 300, ladder: { ...stateOf().ladder, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40 } });
assert.equal(V.firstWait(s), true);
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].line, 'Waiting for your first block.');
s = stateOf({ uptime_s: 400, started_at: T5 - 400, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 5] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 5, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40, milestone: { ...m1, at: T5 - 5 } } });
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5).eyebrow, 'your first block');
assert.equal(V.firstWait(s), false);
// a count that started over (settings.json lost, the ladder kept with the flag): no "waiting", no card
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 86400, first_block_shown: true, milestone: null } });
assert.equal(V.firstWait(s), false);
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].state, 'done');
assert.equal(V.blockCard(null, s, c, T5), null);
// block 2: the ordinary accepted pulse only (the block on the strip's list, the activity line, the run count), no card
s = afterFirst({ uptime_s: 1800, started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 2, accepted_session: 1, found: [T5 - 2] } }, { milestone: null, blocks_30d: 2, blocks_today: 2 });
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
assert.equal(V.plainEvent('block accepted by the node (NVIDIA GeForce RTX 4070)').text, 'Block found by NVIDIA GeForce RTX 4070');
assert.equal(V.earningsLines(s, c, 28, T5).run.text, '1 block this run');
assert.equal(V.earningsLines(s, c, 28, T5).lifetime.text, '2 blocks');
assert.deepEqual(s.mining.found, [T5 - 2], 'the strip flashes the block that just arrived');
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
});
test('the rungs under mainnet-sized params: 100 blocks to a vote, 30 days to full weight; the relay and the cap are named', () => {
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 61, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 61 } });
const c = chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64, voters: 1204, mine: [key({ blocks: 61, rank: 41 })], best: key({ blocks: 61, rank: 41 }) }, MAINNET);
const r = V.ladderRungs(s, c, T5);
assert.equal(r[1].line, '61 of 100 blocks to a vote.');
assert.equal(r[3].line, '23 of 30 days.');
assert.equal(r[3].progress, 23 / 30);
assert.equal(r[4].line, 'Rank 41 of 64 keys.');
assert.equal(r[4].sub, '1,204 keys vote · the top 64 are listed · weight, never hashrate: a card that arrived today sits at the bottom');
// a key below the observer's 64 says so instead of inventing a rank
const below = V.ladderRungs(s, chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64 }, MAINNET), T5);
assert.equal(below[4].line, 'Below the top 64 keys.');
assert.equal(below[1].sub, 'your key is not in the table yet');
// no chain facts at all: the words say so, nothing is invented
const none = V.ladderRungs(s, { ok: false, error: 'neither the node nor the observer answered' }, T5);
assert.equal(none[1].line, 'A vote needs the dust line’s blocks in the window.');
assert.equal(none[1].sub, 'neither the node nor the observer answered');
assert.equal(none[4].line, 'Rank: not read yet.');
assert.deepEqual(V.windowWords(2592000), { n: 30, unit: 'days', text: '30 days' });
assert.deepEqual(V.windowWords(7200), { n: 2, unit: 'hours', text: '2 hours' });
assert.deepEqual(V.windowWords(0), { n: 30, unit: 'days', text: '30 days' });
});
test('the per-card IGN a day cell and the IGN formatter', () => {
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), chainOf()), '1,728');
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), null), '');
assert.equal(V.fmtIgn(0.5), '0.5000 IGN'); assert.equal(V.fmtIgn(4.88044084), '4.88 IGN'); assert.equal(V.fmtIgn(207360), '207,360 IGN');
});
test('the block card on the four thresholds and on a new card; the first-hour timeline; the profile words', () => {
const s = stateOf(), c = chainOf({ mine: [key({ blocks: 100, voter: true, rank: 2 })], best: key({ blocks: 100, voter: true, rank: 2 }) });
const m = (kind, count, over) => ({ kind, count, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: 'ab'.repeat(32), daa: 1284117, at: T5 - 10, ...over });
assert.equal(V.blockCard(m('hundred', 100), s, c, T5).eyebrow, 'block 100 of this machine');
assert.equal(V.blockCard(m('thousand', 1000), s, c, T5).eyebrow, 'block 1,000 of this machine');
assert.equal(V.blockCard(m('ten_thousand', 20000), s, c, T5).eyebrow, 'block 20,000 of this machine');
assert.equal(V.blockCard(m('card', 7), s, c, T5).eyebrow, 'first block on NVIDIA GeForce RTX 4070');
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).title, 'A block is yours.');
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).url, '');
assert.equal(V.blockCard(m('first', 1), s, chainOf({ network: { ...chainOf().network, ramp_factor: 0.19, miner_ign_per_block: 4.88044084 } }), T5).ignLine, '4.88 IGN to 0xdd44…86E8 (72 producer points, 8 for signing, ramp 19%)');
assert.equal(V.blockCard(m('first', 1), s, c, T5).key, 'card:1:' + (T5 - 10));
const t = V.timeline(stateOf({ ladder: { ...stateOf().ladder, first_synced_at: T5 - 2800, first_mining_at: T5 - 2700, first_block_at: T5 - 2400 } }), T5);
assert.deepEqual(t.steps.map((x) => x.id), ['install', 'started', 'synced', 'mining', 'block', 'payout']);
assert.deepEqual(t.steps.map((x) => x.rel), ['+0 s', '+20 min', '+3 min', '+5 min', '+10 min', '+10 min']);
assert.match(t.steps[5].note, /coinbase of the first block/);
const u = V.timeline(stateOf({ installed_at: 0, uptime_s: 100 }), T5);
assert.equal(u.steps[0].note, 'not recorded on this install');
assert.equal(u.steps[2].note, 'the moment is not recorded; synced now');
assert.match(V.profileWords(true), /^Your address page on igneum\.network is public/);
assert.match(V.profileWords(false), /unlisted/);
assert.equal(V.gapWords(45), '45 seconds'); assert.equal(V.spanWords(3700), '1 h 1 min'); assert.equal(V.spanWords(3 * 86400), '3 days');
// the Activity lines for the milestones
assert.equal(V.plainEvent('first block: found by NVIDIA GeForce RTX 4070 (block card)').text, 'Your first block, found by NVIDIA GeForce RTX 4070');
assert.equal(V.plainEvent('block 1,000 of this machine: found by RTX 4070 (block card)').text, 'Block 1,000 of this machine, found by RTX 4070');
assert.equal(V.plainEvent('first block on Apple M5 Max (block card)').text, 'First block on Apple M5 Max');
});
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.css'), 'utf8');
const marks = (html) => (html.match(/data-mark="/g) || []).length;
test('every vendor renders its own mark: one inline SVG glyph per row, tinted by its vendor class, under 2 KB each', () => {
const rows = {
nvidia: card(),
amd: card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', vram_mb: 16384 }),
intel: card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', worker: 'OpenCL', vram_mb: 12208 }),
apple: card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072 }),
};
for (const [v, cd] of Object.entries(rows)) {
const mk = V.vendorMark(cd);
assert.equal(mk.vendor, v);
assert.match(mk.svg, /^<svg viewBox="0 0 24 24"/);
assert.ok(Buffer.byteLength(mk.svg) < 2048, v + ' glyph is ' + Buffer.byteLength(mk.svg) + ' bytes');
assert.ok(!/<image|href=|data:/.test(mk.svg), v + ' glyph is drawn, never a raster or a file');
assert.ok(/currentColor/.test(mk.svg), v + ' glyph takes the vendor token through currentColor');
const html = V.markHtml(cd);
assert.ok(html.startsWith('<div class="badge ' + v + '" data-mark="' + v + '"'), html.slice(0, 60));
assert.equal((html.match(/<svg/g) || []).length, 1);
}
assert.notEqual(V.MARKS.nvidia, V.MARKS.amd); assert.notEqual(V.MARKS.intel, V.MARKS.apple);
});
test('an unknown vendor renders the neutral fallback glyph; a vendor "other" row whose name says Intel keeps the Intel glyph', () => {
const mali = card({ key: 'other:0:Mali', name: 'Mali-G78 MP20', vendor: 'other', kind: 'integrated', worker: 'OpenCL', vram_mb: 0 });
const mk = V.vendorMark(mali);
assert.equal(mk.vendor, 'gpu'); assert.equal(mk.label, 'GPU'); assert.equal(mk.svg, V.MARKS.gpu);
assert.ok(V.markHtml(mali).startsWith('<div class="badge gpu" data-mark="gpu"'));
assert.equal(V.vendorOf(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'intel');
assert.equal(V.vendorOf(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'other', kind: 'integrated' })), 'amd');
assert.equal(V.vendorOf({}), 'gpu');
});
test('the mark never appears twice on one row: the head of a first-run row and of a Cards row carries one data-mark, and only View draws a badge', () => {
const amd = card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd' });
assert.equal(marks(V.rowHead(amd)), 1);
assert.equal(marks(V.rowHead(amd, ': removed')), 1);
assert.equal(marks(V.markHtml(amd) + V.nameHtml(amd)), 1);
assert.equal(marks(V.nameHtml(amd)), 0, 'the name block carries no mark of its own');
// the renderers: the first-run rows, the Cards rows and the block card all go through View.markHtml; no other
// string in app.js opens a badge
assert.equal((src.match(/class="badge /g) || []).length, 1, 'one place in app.js opens a badge');
assert.equal((src.match(/View\.markHtml\(/g) || []).length, 3, 'first-run rows, Cards rows, the block card');
assert.equal(src.includes('badgeHtml('), false);
});
test('the series line under the name is mono text per generation; an integrated row (Intel iGPU, Apple) ends in "integrated" and keeps one mark', () => {
assert.equal(V.seriesLine(card()), 'RTX 50 series · Blackwell');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 4070' })), 'RTX 40 series · Ada Lovelace');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 3080' })), 'RTX 30 series · Ampere');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce GTX 1660 Super' })), 'GTX 16 series · Turing');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd' })), 'RX 9000 series · RDNA 4');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 7800 XT', vendor: 'amd' })), 'RX 7000 series · RDNA 3');
assert.equal(V.seriesLine(card({ name: 'gfx1201', code: 'gfx1201', vendor: 'amd' })), 'RDNA 4');
assert.equal(V.seriesLine(card({ name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel' })), 'Arc B series · Battlemage');
assert.equal(V.seriesLine(card({ name: 'Intel Arc A770', vendor: 'intel' })), 'Arc A series · Alchemist');
assert.equal(V.seriesLine(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'UHD Graphics · integrated');
assert.equal(V.seriesLine(card({ name: 'Intel Iris Xe Graphics', vendor: 'other', kind: 'integrated' })), 'Iris Xe · integrated');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'amd', kind: 'integrated' })), 'Radeon · integrated');
assert.equal(V.seriesLine(card({ name: 'Apple M5 Max', vendor: 'apple', kind: 'apple' })), 'M5 series · integrated');
const igpu = card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' });
const head = V.rowHead(igpu);
assert.equal(marks(head), 1); assert.match(head, /data-mark="intel"/); assert.match(head, /<div class="gen">UHD Graphics · integrated<\/div>/);
assert.equal(V.vendorMark(igpu).svg, V.MARKS.intel, 'an integrated Intel row keeps the Intel glyph, never a second mark');
assert.equal(V.nameHtml(card({ name: '<b>x</b>', vendor: 'zzz', kind: 'unknown' })).includes('<b>'), false, 'the name is escaped');
});
test('the light theme contrast ratio of every vendor colour on its well is at least 3:1, and app.css carries the same hex values and wells', () => {
for (const v of Object.keys(V.VENDORS)) {
const light = V.vendorContrast(v, 'light'), dark = V.vendorContrast(v, 'dark');
assert.ok(light >= 3, v + ' light: ' + light.toFixed(2) + ':1 on its well');
assert.ok(dark >= 3, v + ' dark: ' + dark.toFixed(2) + ':1 on its well');
const [r, g, b] = V.hexRgb(V.VENDORS[v].light), [dr, dg, db] = V.hexRgb(V.VENDORS[v].dark);
assert.ok(css.includes('--mark-' + v + ':' + V.VENDORS[v].dark + ';'), v + ' dark hex in app.css');
assert.equal((css.match(new RegExp('--mark-' + v + ':' + V.VENDORS[v].light + ';', 'g')) || []).length, 2, v + ' light hex in both light blocks');
assert.ok(css.includes('--mark-' + v + '-well:rgba(' + dr + ',' + dg + ',' + db + ',' + String(V.WELL_ALPHA_DARK).replace(/^0/, '') + ')'), v + ' dark well');
assert.equal((css.match(new RegExp('--mark-' + v + '-well:rgba\\(' + r + ',' + g + ',' + b + ',' + String(V.WELL_ALPHA_LIGHT).replace(/^0/, '') + '\\)', 'g')) || []).length, 2, v + ' light well in both light blocks');
}
// the ember accent is for state, never a brand
for (const v of Object.keys(V.VENDORS)) { assert.notEqual(V.VENDORS[v].dark, '#F2541B'); assert.notEqual(V.VENDORS[v].light, '#E04A14'); }
assert.equal(/\.badge\.(nvidia|amd|intel|apple|gpu)\{[^}]*var\(--ember/.test(css), false, 'no badge rule tints with ember');
assert.equal(/\.badge[^{]*\{[^}]*animation/.test(css), false, 'nothing on the mark animates');
});
// ---------- the custom switch (the Prove page fix, 7 October 2026) ----------
// the project lead's screenshot from the live 0.3.19 Mac app: a thin white rectangle above-left of the "Prove on this machine"
// switch and the knob drawn outside the track's left edge at off. Two generic classes reached the switch (the DAG
// legend swatch .lg hit label.switch.lg, the shard track .track hit .switch .track) and the native input was an
// unpositioned absolute. This test reads app.css: no bare .lg or .track rule may exist (every use is scoped), the
// native input is hidden the accessible way (1 px clip, still focusable), and the knob's box sits inside the track's
// box at off and at on for both sizes, in the base rules and untouched by both theme blocks.
const cssRules = (text) => { const out = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; text = text.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = re.exec(text))) out.push({ sel: m[1].trim(), body: m[2] }); return out; };
const ruleOf = (sel) => cssRules(css).filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)).map((r) => r.body).join(';');
const px = (body, prop) => { const m = new RegExp('(?:^|;)\\s*' + prop + ':\\s*(-?[\\d.]+)px').exec(body); return m ? parseFloat(m[1]) : null; };
const translate = (body) => { const m = /transform:\s*translateX\((-?[\d.]+)px\)/.exec(body); return m ? parseFloat(m[1]) : 0; };
test('no generic .lg or .track rule reaches a custom switch: every use of those class names is scoped (known-failed on the 0.3.19 CSS)', () => {
const bare = cssRules(css).flatMap((r) => r.sel.split(',').map((x) => x.trim())).filter((sel) => /^\.(lg|track)(\.[\w-]+)*(\s+[\w.:-]+)*$/.test(sel) && !/^\.switch\b/.test(sel));
assert.deepEqual(bare, [], 'selectors that start with a bare .lg or .track: ' + bare.join(' | '));
assert.equal(/<label class="switch lg"/.test(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8')), false, 'the Prove switch no longer shares the legend swatch class');
});
test('the native input of every custom switch is hidden the accessible way: 1 px clipped, still in the tree and focusable, the label kept', () => {
const sw = ruleOf('.switch'), input = ruleOf('.switch input');
assert.match(sw, /position:\s*relative/, '.switch is the positioned ancestor of its input');
assert.match(input, /position:\s*absolute/);
assert.equal(px(input, 'width'), 1); assert.equal(px(input, 'height'), 1);
assert.match(input, /clip:\s*rect\(0 0 0 0\)|clip-path:\s*inset\(50%\)/, 'the clip pattern');
assert.match(input, /overflow:\s*hidden/);
assert.equal(/display:\s*none|visibility:\s*hidden/.test(input), false, 'display none would take the input out of the tab order');
assert.match(ruleOf('.switch input:focus-visible+.track'), /outline/, 'focus stays visible on the track');
});
test('the knob sits inside the track at off and at on, both sizes, and neither theme block touches the switch geometry', () => {
const track = ruleOf('.switch .track'), knob = ruleOf('.switch .track::after'), on = ruleOf('.switch input:checked+.track::after');
const big = ruleOf('.switch.big .track'), bigKnob = ruleOf('.switch.big .track::after'), bigOn = ruleOf('.switch.big input:checked+.track::after');
const inside = (tw, th, kw, kh, left, top, dx, label) => {
assert.ok(left >= 0 && top >= 0, label + ': the knob starts inside (left ' + left + ', top ' + top + ')');
assert.ok(left + kw <= tw && top + kh <= th, label + ' off: knob ' + kw + 'x' + kh + ' at ' + left + ',' + top + ' in a ' + tw + 'x' + th + ' track');
assert.ok(left + dx + kw <= tw, label + ' on: knob right edge ' + (left + dx + kw) + ' in a ' + tw + ' track');
assert.ok(left + dx >= 0, label + ' on: knob left edge ' + (left + dx));
};
assert.match(track, /position:\s*relative/, 'the track positions its knob');
inside(px(track, 'width'), px(track, 'height'), px(knob, 'width'), px(knob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(on), 'the 40 px switch');
inside(px(big, 'width'), px(big, 'height'), px(bigKnob, 'width'), px(bigKnob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(bigOn), 'the big switch');
// the two theme blocks (prefers-color-scheme light, data-theme light) only set tokens: no .switch or .track rule inside them
for (const m of css.matchAll(/@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme="dark"\]\)\{[^}]*\}\}|:root\[data-theme="light"\]\{[^}]*\}/g)) assert.equal(/\.switch|\.track/.test(m[0]), false, 'a theme block touches the switch');
assert.equal(cssRules(css).some((r) => /\.switch|\.track/.test(r.sel) && /@media \(prefers-color-scheme/.test(r.sel)), false);
});
// ---------- the shared marks module (brand/marks/vendor-marks.mjs, for the site) ----------
test('brand/marks/vendor-marks.mjs carries the app\'s marks and tokens verbatim, plus the Windows mark in the same treatment', async () => {
const mod = await import(join(dirname(fileURLToPath(import.meta.url)), '../../../brand/marks/vendor-marks.mjs'));
assert.deepEqual(mod.MARKS, V.MARKS, 'the glyph strings (regenerate with node brand/marks/regen.mjs)');
assert.deepEqual(mod.VENDORS, V.VENDORS);
assert.deepEqual(mod.WELL_ALPHA, { dark: V.WELL_ALPHA_DARK, light: V.WELL_ALPHA_LIGHT });
assert.equal(mod.OS_MARKS.macos, V.MARKS.apple);
assert.match(mod.OS_MARKS.windows, /^<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path /);
assert.ok(Buffer.byteLength(mod.OS_MARKS.windows) < 2048);
assert.equal(mod.markHtml('amd'), V.markHtml({ vendor: 'amd' }));
assert.equal(mod.markHtml('zzz'), V.markHtml({ vendor: 'zzz' }));
// the tokens the module prints are the ones app.css carries
for (const line of mod.tokensCss().split('\n')) { const inner = /\{([^{}]*)\}\}?$/.exec(line)[1]; assert.ok(css.includes(inner), 'app.css carries: ' + inner.slice(0, 60)); }
});
// ---------- the Earnings card, tidied (the project lead, 7 October 2026: "remove all the type a price stuff") ----------
const indexHtml = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8');
const earningsCard = () => { const a = indexHtml.indexOf('id="page-earnings"'); return indexHtml.slice(a, indexHtml.indexOf('id="ladder-card"', a)); };
test('no price input exists: no £ per IGN field, no "Use it", no remembered price, no money line derived from a typed price (known-failed on 0.3.20)', () => {
const ec = earningsCard();
assert.equal(/e-price|Type a price|Use it|price-row|per IGN/.test(ec), false, 'the markup');
assert.equal(/<input[^>]*type="number"/.test(ec), false, 'no number input on the card');
assert.equal(/ign_price|ignPrice|saveIgnPrice|e-price/.test(src), false, 'the setting and its handlers');
const e = V.earningsLines(stateOf(), chainOf(), 28, T5);
assert.equal('price' in e, false); assert.equal(V.FIELDS.price, undefined);
for (const k of Object.keys(e)) assert.equal(/£|GBP|pounds?\b/.test((e[k] && e[k].text) || ''), k === 'cost', k + ' carries money only on the electricity cell');
});
test('the headline reads the day rate with its reason line, then blocks and IGN this run', () => {
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
const s = stateOf({ uptime_s: 5400, mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
const e = V.earningsLines(s, c, 28, T5);
assert.equal(e.day.text, '7,680 IGN a day');
assert.equal(e.day.sub, 'at your last hour’s rate: 4 blocks at 80.00 IGN each · about 6,912 IGN expected at 100 MH/s');
assert.equal(e.run.text, '140 blocks this run');
assert.equal(e.run.sub, '11,200 IGN at today’s reward · 14 shards, 16.10 IGN · 1 h 30 min');
const f = V.earningsLines(stateOf(), chainOf(), 28, T5);
assert.equal(f.day.text, 'about 6,912 IGN a day');
assert.equal(f.day.sub, 'expected at 100 MH/s, 0.100% of today’s network · no block in the last hour yet');
const n = V.earningsLines(stateOf(), { ok: false, error: 'neither answered' }, 28, T5);
assert.equal(n.day.text, 'IGN a day: reading the network'); assert.equal(n.day.sub, 'neither answered');
// the markup: the headline is the first thing on the card, the run line second
const ec = earningsCard();
assert.ok(ec.indexOf('id="e-day"') < ec.indexOf('id="e-run"') && ec.indexOf('id="e-run"') < ec.indexOf('class="earn-three"'), 'headline, run line, row of three, in that order');
assert.ok(ec.indexOf('class="earn-three"') < ec.indexOf('id="s-devfee"'), 'the dev-fee switch is last');
assert.equal(/id="r-devfee-line"/.test(ec), false, 'one sentence on the dev fee, no second help line');
for (const line of [e.day.sub, e.run.sub, f.day.sub]) assert.equal(/—|–/.test(line), false, 'no dashes: ' + line);
});
test('the row of three renders: weight rung, electricity and lifetime, each with a quiet line', () => {
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
const e = V.earningsLines(s, c, 28, T5);
assert.equal(e.weight.text, 'rank 41 of 1,204 keys');
assert.equal(e.weight.sub, '2,592 blocks in 30 days · 23 of 30 days · 0.100% of the network');
assert.equal(e.cost.text, '£1.01 a day');
assert.equal(e.cost.sub, 'at 28p/kWh for 150 W · 2,133 IGN per kWh');
assert.equal(e.lifetime.text, '2,592 blocks');
assert.equal(e.lifetime.sub, '207,360 IGN at today’s reward');
const noPrice = V.earningsLines(s, c, 0, T5);
assert.equal(noPrice.cost.text, '150 W'); assert.equal(noPrice.cost.sub, 'set a price in Settings · 2,133 IGN per kWh');
const fresh = V.earningsLines(stateOf(), chainOf(), 28, T5);
assert.equal(fresh.weight.text, 'not in the weight table yet');
assert.equal(fresh.lifetime.text, '0 blocks');
assert.equal(V.earningsLines(stateOf({ mining: { ...stateOf().mining, watts_total: 0, cards: [] } }), chainOf(), 28, T5).cost.sub, 'no card reports its draw');
assert.equal(e.devFee(true, 12), 'Dev fee: 1 block in 100 pays the people who make this app (12 blocks so far).');
assert.equal(e.devFee(false, 0), 'Dev fee off. Every block pays your address.');
const ec = earningsCard(), three = ec.slice(ec.indexOf('class="earn-three"'), ec.indexOf('id="s-devfee"'));
assert.equal((three.match(/class="earn-cell"/g) || []).length, 3);
for (const id of ['e-rung', 'e-cost', 'e-blocks']) assert.ok(three.includes('id="' + id + '"'), id);
assert.ok(/\.earn-three\{[^}]*grid-template-columns:\s*repeat\(3/.test(css), 'three columns in app.css');
});
test('the "Prove instead of mining" row shows only on an under-12 GB machine and names the choice (main, 7 October 2026)', () => {
assert.equal(V.proveInsteadWords({ under_12gb: false }, { prove_instead: false }).show, false);
const off = V.proveInsteadWords({ under_12gb: true }, { prove_instead: false });
assert.equal(off.show, true); assert.equal(off.on, false); assert.match(off.help, /never both/);
const on = V.proveInsteadWords({ under_12gb: true }, { prove_instead: true });
assert.equal(on.show, true); assert.equal(on.on, true); assert.match(on.help, /held off/);
});
// ---------- currency (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
// No IP lookups: the OS locale's region picks the currency and the default tariff at first run; a Settings override
// (currency plus the price per kWh in it) wins and lives in the engine's settings file; every money line goes through
// one Intl formatter; the user's own tariff is the number, so no exchange rate exists anywhere.
const uiDir = dirname(fileURLToPath(import.meta.url));
const pageHtml = readFileSync(join(uiDir, 'index.html'), 'utf8');
const rustSrc = (f) => readFileSync(join(uiDir, '../src/' + f), 'utf8');
test('a de-DE machine reads EUR with the EU tariff from its locale, before any choice is made (known-failed on 0.3.21)', () => {
const ch = V.currencyChoice({ region: '', currency: '' }, 'de-DE');
assert.equal(ch.code, 'EUR'); assert.equal(ch.region, 'de'); assert.equal(ch.source, 'locale'); assert.equal(ch.prompt, false);
assert.equal(V.regionOf('de').price, 38.69); assert.match(V.regionOf('de').source, /Eurostat/);
assert.equal(V.regionFromLocale('de-DE'), 'de'); assert.equal(V.regionFromLocale('en-GB'), 'gb'); assert.equal(V.regionFromLocale('de'), 'de'); assert.equal(V.regionFromLocale('fr-CA'), 'ca'); assert.equal(V.regionFromLocale(''), '');
const ca = V.currencyChoice({}, 'fr-CA'); assert.equal(ca.code, 'CAD'); assert.equal(V.regionOf('ca').name, 'Canada'); assert.equal(V.regionOf('ca').cur, 'CAD');
assert.equal(V.currencyChoice({}, 'en-AU').code, 'AUD');
assert.equal(V.currencyChoice({}, 'fr-FR').code, 'EUR'); assert.ok(V.regionOf('fr').price > 0, 'an EU member without its own row takes the EU average'); assert.match(V.regionOf('fr').source, /EU average/);
assert.equal(V.currencyChoice({}, 'sv-SE').code, 'SEK'); assert.equal(V.regionOf('se').price, 0, 'no table price outside the euro: the miner types one');
// one formatter, the symbol placed as the locale places it, no hand-built money string left
assert.equal(V.fmtMoney(1.5, 'EUR', 'de-DE'), '1,50 €'); assert.equal(V.fmtMoney(1.5, 'EUR', 'en-IE'), '€1.50');
assert.equal(V.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(V.fmtMoney(1.5, 'CAD', 'en-CA'), '$1.50'); assert.equal(V.fmtMoney(1.5, 'JPY', 'en-US'), '¥2'); assert.equal(V.fmtMoney(null, 'GBP', 'en-GB'), '');
V.setLocale('de-DE'); V.setRegion('de'); assert.equal(V.money(1.5), '1,50 €'); V.setLocale('en-GB'); V.setRegion('');
assert.equal((src.match(/currency\.symbol \+ \(/g) || []).length, 0, 'money() no longer concatenates a symbol');
});
test('a user override wins over the locale and the region, and survives restart through the engine settings file', () => {
const ch = V.currencyChoice({ region: 'gb', currency: 'USD' }, 'de-DE');
assert.equal(ch.code, 'USD'); assert.equal(ch.source, 'override'); assert.equal(ch.prompt, false);
assert.equal(V.currencyChoice({ region: 'gb', currency: '' }, 'de-DE').code, 'GBP', 'a chosen region beats the locale');
assert.equal(V.currencyChoice({ region: 'gb', currency: 'zzz' }, 'en-GB').code, 'GBP', 'an unknown override is ignored');
V.setLocale('en-GB'); V.setCurrency('USD'); assert.equal(V.money(1.5), '$1.50'); assert.equal(V.currencyNow().code, 'USD'); V.setCurrency(''); V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50'); V.setRegion('');
// survives restart: a field of the engine's settings file, carried in the state the UI reads, set through api/region
assert.match(rustSrc('config.rs'), /pub currency: String/); assert.match(rustSrc('state.rs'), /pub currency: String/);
assert.match(rustSrc('server.rs'), /body\.get\("currency"\)/); assert.match(rustSrc('engine.rs'), /Cmd::Region\(region, price, currency\)/);
assert.match(rustSrc('config.rs'), /fn currency_survives_a_round_trip|currency_round_trip/, 'the Rust round-trip test exists');
// the Settings card: a currency picker with "follow the region" first; the price unit follows the pick
assert.ok(pageHtml.includes('id="s-currency"'), 'the picker');
assert.deepEqual(V.minorOf('GBP'), { word: 'pence', abbr: 'p', digits: 2 }); assert.equal(V.minorOf('EUR').word, 'euro cents'); assert.equal(V.minorOf('CAD').abbr, 'c'); assert.equal(V.minorOf('JPY').digits, 0); assert.equal(V.minorOf('JPY').word, 'yen');
assert.ok(V.CURRENCY_CODES.indexOf('GBP') === 0 && V.CURRENCY_CODES.slice(0, 5).join() === 'GBP,EUR,USD,CAD,AUD' && V.CURRENCY_CODES.length > 20, 'the five first, then the long tail');
});
test('a locale with no currency row falls back to USD with the override prompt visible', () => {
const eo = V.currencyChoice({}, 'eo');
assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true); assert.equal(eo.source, 'fallback');
const aq = V.currencyChoice({}, 'en-AQ'); assert.equal(aq.code, 'USD'); assert.equal(aq.prompt, true);
assert.equal(V.currencyChoice({}, 'en-GB').prompt, false);
assert.equal(V.currencyChoice({ region: 'other', currency: '' }, 'eo').prompt, true, '"Somewhere else" with no currency still asks');
assert.equal(V.currencyChoice({ region: 'other', currency: 'USD' }, 'eo').prompt, false, 'an answer closes the prompt');
assert.match(V.currencyAsk(eo), /could not tell your currency/);
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
});
// ---------- the two check buttons on Settings (7 October 2026, main's ruling after a "Check now" pressed for the wrong check) ----------
// The update check and the remote-jobs check sat as "Check" and "Check now"; a reader (and a lane) took the jobs one for
// the update one. Each button now names its check, and each handler posts its own route.
test('Settings: "Check for an update" posts api/update/check and "Check for jobs" posts api/jobs/check (known-failed on the old labels)', () => {
const html = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8');
assert.match(html, /<button class="btn small" id="s-update">Check for an update<\/button>/, 'the update button names its check');
assert.match(html, /<button class="btn tiny ghost" id="s-jobs-check">Check for jobs<\/button>/, 'the jobs button names its check');
assert.equal((html.match(/>Check now</g) || []).length, 0, 'no bare "Check now" left on the page');
const upd = /\$\('s-update'\)\.addEventListener\('click', function \(\) \{ api\('api\/update\/check', \{\}\);/.exec(src);
const jobs = /\$\('s-jobs-check'\)\.addEventListener\('click', function \(\) \{ api\('api\/jobs\/check', \{\}\);/.exec(src);
assert.ok(upd, 'the update button posts api/update/check'); assert.ok(jobs, 'the jobs button posts api/jobs/check');
assert.equal(/\$\('s-update'\)[^\n]*api\/jobs\/check/.test(src), false); assert.equal(/\$\('s-jobs-check'\)[^\n]*api\/update\/check/.test(src), false);});
// ---------- the network step (0.3.23, main's design with two corrections, 7 October 2026) ----------
// A fourth first-run screen and a Settings card: two cards, Devnet 3 and igneum-testnet-1. The fresh-install default comes
// from the manifest's default_network (Devnet 3 until the go); the testnet card reads "not yet open" and is refused until the
// manifest names it open; an existing install keeps its network; nothing switches a running miner without the user's click
// and the confirm that names what resets.
test('network words: the manifest default selects the card on a fresh install, the testnet card is refused until open, an existing install keeps its network (known-failed on 0.3.23)', () => {
const fresh = { setup_done: false, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'devnet-3', testnet_open: false } };
const w = V.networkWords(fresh);
assert.deepEqual(w.cards.map((c) => c.id), ['devnet-3', 'testnet-1']);
assert.equal(w.selected, 'devnet-3', 'the manifest default');
const dn3 = w.cards[0], tn = w.cards[1];
assert.equal(dn3.label, 'Devnet 3'); assert.equal(dn3.line, 'igneum-devnet-3, chain id 4463'); assert.equal(dn3.sub, 'staging: the chain may reset, coins have no value'); assert.equal(dn3.open, true);
assert.equal(tn.label, 'igneum-testnet-1'); assert.equal(tn.line, 'chain id 4462 · not yet open'); assert.equal(tn.sub, 'the public test chain: coins have no value, resets are announced'); assert.equal(tn.open, false);
assert.equal(V.networkWords({ ...fresh, update: { default_network: '', testnet_open: false } }).selected, 'devnet-3', 'no manifest read yet: Devnet 3');
const open = V.networkWords({ ...fresh, update: { default_network: 'testnet-1', testnet_open: true } });
assert.equal(open.selected, 'testnet-1'); assert.equal(open.cards[1].line, 'chain id 4462'); assert.equal(open.cards[1].open, true);
// an existing install keeps what it runs; the card it runs is marked current
const old = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'testnet-1', testnet_open: true } });
assert.equal(old.selected, 'devnet-3'); assert.equal(old.current, 'devnet-3'); assert.equal(old.cards[0].current, true);
assert.equal(old.line, 'This machine runs igneum-devnet-3.');
// the pending state after a confirmed switch
const pend = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: 'testnet-1', settings: { network: 'testnet-1' }, update: { default_network: 'testnet-1', testnet_open: true } });
assert.equal(pend.line, 'Switching to igneum-testnet-1 at the next start: quit Igneum Miner and open it again.');
for (const s of [w, old, pend]) for (const c of s.cards) assert.equal(/—|–/.test(c.label + c.line + c.sub), false);
});
test('the switch is refused without the confirm, and the confirm names what resets; the testnet choice is refused while not open', () => {
assert.equal(V.networkAsk('testnet-1', 'igneum-devnet-3'), 'Switch this machine to igneum-testnet-1? The node’s data and the mining state on igneum-devnet-3 reset at the next start. Your key and your address stay.');
assert.equal(V.networkAsk('devnet-3', 'igneum-testnet-1'), 'Switch this machine to igneum-devnet-3? The node’s data and the mining state on igneum-testnet-1 reset at the next start. Your key and your address stay.');
// the Rust rule the server applies (config::network_switch): mirrored here by its source text
const cfg = rustSrc('config.rs');
assert.match(cfg, /pub fn network_switch\(want: &str, running: &str, testnet_open: bool, confirmed: bool\) -> Result<\(\), String>/);
assert.match(cfg, /fn network_switch_rules\(\)/, 'the Rust test exists');
assert.match(rustSrc('manifest.rs'), /default_network names the testnet before it is open/, 'a manifest with the testnet default before the open is refused');
assert.match(rustSrc('manifest.rs'), /fn default_network_rules\(\)/, 'the manifest test exists');
assert.match(rustSrc('server.rs'), /"\/api\/network" =>/);
// the markup: the step sits before the address step, four steps now; the Settings card exists
const idx = pageHtml;
assert.ok(idx.indexOf('id="screen-network"') > idx.indexOf('id="screen-region"') && idx.indexOf('id="screen-network"') < idx.indexOf('id="screen-address"'), 'the network step comes after the region step and before the address step');
assert.equal((idx.match(/step [1-4] of 4/g) || []).length, 4, 'four steps');
assert.ok(idx.includes('id="s-network-cards"') && idx.includes('id="ask-network"'), 'the Settings card and its confirm');
});

View file

@ -109,19 +109,7 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
func buildWindow() {
// window-22 (7 October 2026, the one rule with app/windows/opening_size.h): the PRIMARY screen's work area, 80 percent
// wide capped at 1440 up to a 1920-wide display and 1920 beyond, height from the width at 16:10 bounded by the work
// area less 40, never wider than 1.6 times the height, never under 900 by 600, never over the work area
let work = (NSScreen.screens.first ?? NSScreen.main)?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
func openingSize(_ workW: CGFloat, _ workH: CGFloat) -> NSSize {
let minW = min(workW, 900), minH = min(workH, 600), capW: CGFloat = workW <= 1920 ? 1440 : 1920
var w = min(floor(workW * 0.8), capW); var h = min(floor(w / 1.6), workH - 40)
w = max(w, minW); h = max(h, minH); w = min(w, floor(h * 1.6)); w = min(w, workW); h = min(h, workH)
return NSSize(width: w, height: h)
}
func fits(_ f: NSRect) -> Bool { f.width >= 900 && f.height >= 600 && f.width <= work.width && f.height <= work.height && f.width <= f.height * 1.632 }
let opening = openingSize(work.width, work.height)
let size = snapshotPath == nil ? opening : snapshotSize
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.title = "Igneum Miner"
window.titlebarAppearsTransparent = true
@ -129,11 +117,7 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
window.isMovableByWindowBackground = true
window.backgroundColor = obsidian
window.minSize = NSSize(width: 900, height: 600)
if snapshotPath == nil {
// the remembered frame is kept only when it fits the primary work area and the aspect cap, else discarded
if !window.setFrameUsingName("IgneumMinerMain") || !fits(window.frame) { window.setContentSize(opening); window.center() }
window.setFrameAutosaveName("IgneumMinerMain")
} else { window.center() }
window.center()
window.delegate = self
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
@ -225,9 +209,6 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("SHOT ") {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
} else if line.hasPrefix("FATAL ") {
fail(String(line.dropFirst(6)))
} else if line == "EXIT" {
@ -341,19 +322,6 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
}
// ---- a screenshot on request (the SHOT line), the app keeps running ----
func shotTo(_ path: String) {
let conf = WKSnapshotConfiguration()
conf.rect = web.bounds
web.takeSnapshot(with: conf) { image, error in
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
return
}
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
}
}
// ---- snapshot ----
func snapshot(to path: String) {
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {

View file

@ -45,7 +45,7 @@ echo [build] rc
rc.exe /nologo /i "%ART%" /fo build\host.res host.rc || (pause & exit /b 1)
echo [build] cl
cl.exe /nologo /O2 /MT /EHsc /W3 /std:c++17 /DUNICODE /D_UNICODE /I "build\webview2\build\native\include" /Fo"build\\" host.cpp build\host.res ^
/link /SUBSYSTEM:WINDOWS /MANIFEST:NO /OUT:"dist\Igneum Miner.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
/link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Miner.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib || (pause & exit /b 1)
echo [build] done: dist\Igneum Miner.exe

View file

@ -15,9 +15,7 @@
#define _UNICODE
#endif
#include <windows.h>
#include "opening_size.h"
#include <shellapi.h>
#include <shlwapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
@ -35,35 +33,17 @@ using namespace Microsoft::WRL;
#define ID_TRAY_PAUSE 1002
#define ID_TRAY_QUIT 1003
#define ID_QUIT_TIMER 7
// MF-11 (7 October 2026): an engine that stops without a quit is started again (10 s, then 60 s after three in ten
// minutes); a second "Igneum Miner.exe" that finds this window asks it to do so now (WM_ENGINE_RESTART), instead of
// showing a window that says "the engine stopped" with nothing mining behind it.
#define ID_RESTART_TIMER 8
#define WM_ENGINE_RESTART (WM_APP + 3)
#define RESTART_SOON_MS 10000
#define RESTART_SLOW_MS 60000
#define RESTART_WINDOW_MS 600000
#define IDI_APP 1
static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
static void capturePreview(const std::wstring& path) {
if (!g_webview) return;
ComPtr<IStream> stream;
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
}
static std::wstring g_url, g_status = L"starting the engine";
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
static std::wstring g_trayTitle = L"Igneum Miner";
static ULONGLONG g_quitStarted = 0;
static bool g_exitForUpdate = false; // the engine's last line was "EXIT update": an installer or the OTA stops it; this window goes too, no restart
static int g_restarts = 0; // engine restarts inside the current window
static ULONGLONG g_restartWindowStart = 0; // when that window opened
static std::wstring widen(const std::string& s) {
if (s.empty()) return L"";
@ -273,75 +253,6 @@ static bool startEngine() {
return true;
}
// The engine's handles, closed before another engine is started (the reader thread has already left: it posts the
// "gone" line only after the pipe closed).
static void closeEngine() {
if (g_engineIn) { CloseHandle(g_engineIn); g_engineIn = nullptr; }
if (g_engineOut) { CloseHandle(g_engineOut); g_engineOut = nullptr; }
if (g_engine) { CloseHandle(g_engine); g_engine = nullptr; }
}
// Is an installer running? Its marker (%LOCALAPPDATA%\igneum\app\install-running.flag, written by Igneum-Miner.iss's
// PrepareToInstall and removed at its end) holds this window's restart ladder: the old engine must never start again
// under an installer (PC 2, 7 October 2026, 20:54 BST). A marker older than 15 minutes is an installer that died.
static bool installerRunning() {
wchar_t* local = nullptr;
size_t len = 0;
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") != 0 || !local) return false;
std::wstring p = std::wstring(local) + L"\\igneum\\app\\install-running.flag";
free(local);
WIN32_FILE_ATTRIBUTE_DATA fad;
if (!GetFileAttributesExW(p.c_str(), GetFileExInfoStandard, &fad)) return false;
FILETIME now;
GetSystemTimeAsFileTime(&now);
ULARGE_INTEGER a, b;
a.LowPart = fad.ftLastWriteTime.dwLowDateTime; a.HighPart = fad.ftLastWriteTime.dwHighDateTime;
b.LowPart = now.dwLowDateTime; b.HighPart = now.dwHighDateTime;
ULONGLONG ageS = b.QuadPart > a.QuadPart ? (b.QuadPart - a.QuadPart) / 10000000ULL : 0;
return ageS <= 15 * 60;
}
// Starts the engine again after it stopped on its own. Three restarts inside ten minutes come 10 s apart; from the
// fourth they come a minute apart, for ever: a miner that sits stopped is a miner lost (plug, tune, play).
static void scheduleRestart() {
ULONGLONG now = GetTickCount64();
if (g_restartWindowStart == 0 || now - g_restartWindowStart > RESTART_WINDOW_MS) { g_restartWindowStart = now; g_restarts = 0; }
g_restarts++;
UINT delay = g_restarts <= 3 ? RESTART_SOON_MS : RESTART_SLOW_MS;
wchar_t buf[160];
swprintf_s(buf, L"The engine stopped. Starting it again in %u s (restart %d).", delay / 1000, g_restarts);
g_status = buf;
setTray(L"Igneum Miner: starting the engine again");
repaintStatus();
SetTimer(g_hwnd, ID_RESTART_TIMER, delay, nullptr);
}
static void restartEngineNow() {
KillTimer(g_hwnd, ID_RESTART_TIMER);
if (g_quitting || !g_exited) return;
if (installerRunning()) {
// held: an installer is replacing the files; this window ends so the installer can replace it too, and the
// installer's own [Run] step (or the update helper) starts the new app
g_status = L"An update is installing; the app opens again when it is done.";
repaintStatus();
DestroyWindow(g_hwnd);
return;
}
closeEngine();
g_exited = false;
g_url.clear();
if (startEngine()) {
g_status = L"";
setTray(L"Igneum Miner: starting");
repaintStatus();
} else {
g_exited = true;
g_status = L"igneum-app.exe is missing next to this program. Run the installer again.";
repaintStatus();
scheduleRestart();
}
}
static void showTrayMenu() {
HMENU m = CreatePopupMenu();
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Miner");
@ -371,73 +282,11 @@ static void beginQuit() {
}
}
// scaling-21 (7 October 2026, the project lead: "the miner needs some scaling so more is visible in the initial window"): the window
// opens at about 80 percent of the work area, capped at 1440 by 900 on a display up to 1920 wide and scaled up with the
// display beyond that; once the user resizes, the size is remembered per machine under HKCU\Software\Igneum\Miner
// (WindowW, WindowH) and used on the next start while it still fits the work area. The Mac window does the same
// through its frame autosave name (app/mac/IgneumMiner.swift).
static const wchar_t* kSizeKey = L"Software\\Igneum\\Miner";
// dpi-23 (7 October 2026): the host is per-monitor DPI aware (host.manifest, and the runtime call below for a Windows that
// ignores the manifest), so every rectangle it reads is PHYSICAL pixels; the window's monitor DPI (96 = 100 percent) turns
// them into the logical pixels the opening-size rule and the remembered frame use. GetDpiForMonitor is looked up at run
// time (Shcore, Windows 8.1 and later) so the build links nothing new; the fallback is the desktop's LOGPIXELSX.
typedef HRESULT (WINAPI *GetDpiForMonitorFn)(HMONITOR, int, UINT*, UINT*);
static int monitorDpi(HMONITOR mon) {
HMODULE sh = LoadLibraryW(L"Shcore.dll");
if (sh) { GetDpiForMonitorFn f = (GetDpiForMonitorFn)GetProcAddress(sh, "GetDpiForMonitor"); UINT x = 96, y = 96; if (f && mon && SUCCEEDED(f(mon, 0 /* MDT_EFFECTIVE_DPI */, &x, &y)) && x > 0) { FreeLibrary(sh); return (int)x; } FreeLibrary(sh); }
HDC dc = GetDC(nullptr); int dpi = dc ? GetDeviceCaps(dc, LOGPIXELSX) : 96; if (dc) ReleaseDC(nullptr, dc); return dpi > 0 ? dpi : 96;
}
static void enableDpiAwareness() {
// the manifest asks for PerMonitorV2; this is the runtime path for a Windows build that ignores it (older than 1703)
HMODULE u = GetModuleHandleW(L"user32.dll"); if (!u) return;
typedef BOOL (WINAPI *SetCtxFn)(HANDLE);
SetCtxFn setCtx = (SetCtxFn)GetProcAddress(u, "SetProcessDpiAwarenessContext");
if (setCtx && setCtx((HANDLE)-4 /* DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 */)) return;
typedef BOOL (WINAPI *SetAwareFn)(void);
SetAwareFn setAware = (SetAwareFn)GetProcAddress(u, "SetProcessDPIAware"); if (setAware) setAware();
}
static void saveWindowSize(HWND hwnd) {
if (IsIconic(hwnd) || IsZoomed(hwnd)) return;
RECT r; if (!GetWindowRect(hwnd, &r)) return;
int dpi = monitorDpi(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST));
DWORD w = (DWORD)igneum_scale_to_logical(r.right - r.left, dpi), h = (DWORD)igneum_scale_to_logical(r.bottom - r.top, dpi), units = 1;
if (w < 900 || h < 600 || w > (DWORD)(h * 1.6)) return;
HKEY k; if (RegCreateKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &k, nullptr) != ERROR_SUCCESS) return;
RegSetValueExW(k, L"WindowW", 0, REG_DWORD, (const BYTE*)&w, sizeof(w));
RegSetValueExW(k, L"WindowH", 0, REG_DWORD, (const BYTE*)&h, sizeof(h));
RegSetValueExW(k, L"WindowUnits", 0, REG_DWORD, (const BYTE*)&units, sizeof(units)); // 1 = logical pixels, written by a DPI-aware host
RegCloseKey(k);
}
static void openingSize(int& w, int& h, int& sx, int& sy) {
// the PRIMARY monitor's work area, never the virtual desktop (window-22: a two-monitor span opened the app super wide)
RECT work = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
POINT origin = { 0, 0 }; HMONITOR mon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
MONITORINFO mi = { sizeof(mi) }; if (mon && GetMonitorInfoW(mon, &mi)) work = mi.rcWork; else SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
int ww = work.right - work.left, wh = work.bottom - work.top;
HKEY k; DWORD sw = 0, sh = 0, n = sizeof(DWORD);
if (RegOpenKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, KEY_QUERY_VALUE, &k) == ERROR_SUCCESS) {
RegQueryValueExW(k, L"WindowW", nullptr, nullptr, (LPBYTE)&sw, &n); n = sizeof(DWORD);
RegQueryValueExW(k, L"WindowH", nullptr, nullptr, (LPBYTE)&sh, &n);
RegCloseKey(k);
}
// physical work area and DPI in, physical window out; the rule itself runs in logical pixels (opening_size.h)
igneum_opening_size_scaled(ww, wh, monitorDpi(mon), (int)sw, (int)sh, &w, &h, nullptr, nullptr);
sx = work.left + (ww - w) / 2; sy = work.top + (wh - h) / 2;
}
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_SIZE:
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
return 0;
case WM_EXITSIZEMOVE:
saveWindowSize(hwnd);
return 0;
case WM_DPICHANGED: { // the window moved to a monitor of another scale: take the rectangle Windows suggests, WebView2 re-renders at the new DPI
const RECT* r = (const RECT*)lp;
if (r) SetWindowPos(hwnd, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
return 0;
@ -445,12 +294,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (wp == 1) {
g_exited = true;
if (g_quitting) { DestroyWindow(hwnd); return 0; }
// an installer or the app's own OTA stopped the engine ("EXIT update"): the window ends too, so the installer can
// replace this exe, and the old engine is never started again under it (PC 2, 7 October 2026, 20:54 BST)
if (g_exitForUpdate) { DestroyWindow(hwnd); return 0; }
// not a quit of ours: the engine died, or a local caller (a job) asked it to stop and nothing will start it
// again; this window does (MF-11)
scheduleRestart();
g_status = L"The engine stopped. Close this window and open Igneum Miner again.";
setTray(L"Igneum Miner: stopped");
repaintStatus();
return 0;
}
std::string* line = (std::string*)lp;
@ -462,36 +308,18 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("SHOT ", 0) == 0) {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
capturePreview(widen(line->substr(5)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();
MessageBoxW(hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR);
} else if (line->rfind("EXIT", 0) == 0) {
} else if (*line == "EXIT") {
g_exited = true;
if (line->find("update") != std::string::npos) g_exitForUpdate = true;
if (g_quitting || g_exitForUpdate) DestroyWindow(hwnd);
if (g_quitting) DestroyWindow(hwnd);
}
delete line;
return 0;
}
case WM_ENGINE_RESTART:
// a second "Igneum Miner.exe" (the update helper, the Start Menu, the relay agent's start-app) found this window:
// an engine that is gone starts now, not in its backoff
if (g_exited && !g_quitting) restartEngineNow();
return 0;
case WM_QUERYENDSESSION:
// the Restart Manager (an installer's /CLOSEAPPLICATIONS) or a sign-out: this window closes for real, it does not
// hide to the tray (WM_CLOSE below is the user's X)
beginQuit();
return TRUE;
case WM_ENDSESSION:
if (wp) { if (g_engine && !g_exited) sendEngine("quit"); }
return 0;
case WM_TIMER:
if (wp == ID_RESTART_TIMER) { restartEngineNow(); return 0; }
if (wp == ID_QUIT_TIMER) {
DWORD code = 0;
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
@ -593,11 +421,10 @@ static bool handleCliFlags() {
int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
if (handleCliFlags()) return 0;
enableDpiAwareness();
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumMinerWindow");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
HWND other = FindWindowW(L"IgneumMinerWindow", nullptr);
if (other) { PostMessageW(other, WM_ENGINE_RESTART, 0, 0); ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
return 0;
}
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
@ -609,7 +436,8 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14));
RegisterClassW(&wc);
int w, h, sx, sy; openingSize(w, h, sx, sy);
int w = 1120, h = 820;
int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2;
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
ShowWindow(g_hwnd, SW_SHOW);
@ -634,7 +462,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
closeEngine();
if (g_engine) { CloseHandle(g_engine); }
CloseHandle(once);
CoUninitialize();
return 0;

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!-- Igneum Miner.exe (the window host): per-monitor DPI awareness v2 (dpi-23, 7 October 2026). Without it Windows
stretched the whole window on a scaled panel (PC 2's 5120 by 2160 at 200 percent rendered blurry); with it WebView2
renders at the panel's native scale and the host reads physical pixels. Embedded by host.rc as RT_MANIFEST 1;
BUILD-APP.bat links with /MANIFEST:NO so the linker's default manifest does not collide. -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity type="win32" name="Igneum.Miner" version="1.0.0.0"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>

View file

@ -6,9 +6,6 @@
1 ICON "igneum.ico"
// dpi-23: the application manifest (per-monitor DPI awareness v2); the linker runs with /MANIFEST:NO
1 24 "host.manifest"
1 VERSIONINFO
FILEVERSION IGNEUM_HOST_VERSION_RC
PRODUCTVERSION IGNEUM_HOST_VERSION_RC

View file

@ -1,42 +0,0 @@
/* The opening window size, one rule for both hosts (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super
wide?"). Inputs: the PRIMARY monitor's work area (never the virtual desktop spanning two monitors) and the remembered
frame (0 when none). Width first: 80 percent of the work area, at most 1440 on a display up to 1920 wide and at most 1920
beyond, whatever the display; height from the width at 16:10, bounded by the work area less 40 px; then the window is
never wider than 1.6 times its height, never under 900 by 600 unless the work area itself is smaller, never over the work
area. A remembered frame is kept only when it fits the work area, the minimum and the aspect cap, else discarded (a
super-wide frame from an earlier host must not come back). Pure C99, no Windows headers: host.cpp includes it,
opening_size_test.c compiles it on the gate, IgneumMiner.swift mirrors it line for line. */
#ifndef IGNEUM_OPENING_SIZE_H
#define IGNEUM_OPENING_SIZE_H
static void igneum_opening_size(int workW, int workH, int savedW, int savedH, int* outW, int* outH) {
int minW = workW < 900 ? workW : 900, minH = workH < 600 ? workH : 600;
int capW = workW <= 1920 ? 1440 : 1920;
int w = (int)(workW * 0.8); if (w > capW) w = capW;
int h = (int)(w / 1.6); if (h > workH - 40) h = workH - 40;
if (w < minW) w = minW;
if (h < minH) h = minH;
if (w > (int)(h * 1.6)) w = (int)(h * 1.6);
if (w > workW) w = workW;
if (h > workH) h = workH;
/* a remembered frame: the user's own size, kept when it fits; the aspect cap carries a 2 percent tolerance so a hand-sized
1329 by 825 (PC 2, 7 October 2026, aspect 1.611) is not thrown away for a few pixels */
if (savedW >= 900 && savedH >= 600 && savedW <= workW && savedH <= workH && savedW <= (int)(savedH * 1.632)) { w = savedW; h = savedH; }
*outW = w; *outH = h;
}
/* dpi-23 (7 October 2026): the same rule on a per-monitor-aware host, where the work area and the frame come in PHYSICAL
pixels and the monitor's DPI is known (96 = 100 percent). The rule runs in logical pixels (physical x 96 / dpi), so the
1440 by 900 and 1920 caps mean CSS pixels as they do on the Mac, and the result goes back to physical for CreateWindow.
The remembered frame is logical on both the old host (a DPI-unaware process reads virtualised, logical coordinates) and
this one (it saves physical / scale), so no stored value is thrown away for its units; the WindowUnits marker records which
host wrote it. PC 2: 5120 by 2112 physical at 192 DPI reads as 2560 by 1056 logical, opens 1625 by 1016 logical, 3250 by
2032 physical, sharp. */
static int igneum_scale_to_logical(int px, int dpi) { return dpi > 0 ? (int)((long long)px * 96 / dpi) : px; }
static int igneum_scale_to_physical(int lg, int dpi) { return dpi > 0 ? (int)((long long)lg * dpi / 96) : lg; }
static void igneum_opening_size_scaled(int workWpx, int workHpx, int dpi, int savedWlogical, int savedHlogical, int* outWpx, int* outHpx, int* outWlogical, int* outHlogical) {
int lw = 0, lh = 0;
igneum_opening_size(igneum_scale_to_logical(workWpx, dpi), igneum_scale_to_logical(workHpx, dpi), savedWlogical, savedHlogical, &lw, &lh);
if (outWlogical) *outWlogical = lw;
if (outHlogical) *outHlogical = lh;
*outWpx = igneum_scale_to_physical(lw, dpi); *outHpx = igneum_scale_to_physical(lh, dpi);
}
#endif

View file

@ -1,42 +0,0 @@
/* The opening window size (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super wide?"): the rule in
opening_size.h, shared by the Windows host (host.cpp) and mirrored in the Mac window (IgneumMiner.swift). Known-failed
first: on 0.3.21's rule a 3440 by 1440 work area opened 2752 by 1152 and a 3840 by 1080 span opened 3072 by 864
(the test could not compile before the header existed).
cc -std=c99 -Wall -Wextra -o t app/windows/opening_size_test.c && ./t */
#include <stdio.h>
#include "opening_size.h"
static int fails = 0;
static void checkS(const char* name, int ww, int wh, int dpi, int sw, int sh, int ew, int eh, int elw, int elh) {
int w = 0, h = 0, lw = 0, lh = 0; igneum_opening_size_scaled(ww, wh, dpi, sw, sh, &w, &h, &lw, &lh);
if (w != ew || h != eh || lw != elw || lh != elh) { printf("FAIL %s: work %dx%d @%d dpi saved %dx%d -> %dx%d px (%dx%d logical), wanted %dx%d px (%dx%d)\n", name, ww, wh, dpi, sw, sh, w, h, lw, lh, ew, eh, elw, elh); fails++; }
else printf("ok %s: %dx%d px, %dx%d logical\n", name, w, h, lw, lh);
}
static void check(const char* name, int ww, int wh, int sw, int sh, int ew, int eh) {
int w = 0, h = 0; igneum_opening_size(ww, wh, sw, sh, &w, &h);
if (w != ew || h != eh) { printf("FAIL %s: work %dx%d saved %dx%d -> %dx%d, wanted %dx%d\n", name, ww, wh, sw, sh, w, h, ew, eh); fails++; }
else printf("ok %s: %dx%d\n", name, w, h);
}
int main(void) {
check("1080p desk: 1440 by 900", 1920, 1040, 0, 0, 1440, 900);
check("1440p monitor: width capped at 1920, 16:10", 2560, 1400, 0, 0, 1920, 1200);
check("4K: width capped at 1920 regardless of display", 3840, 2120, 0, 0, 1920, 1200);
check("ultrawide 3440 by 1440: never wider than 1.6 times the height", 3440, 1400, 0, 0, 1920, 1200);
check("two monitors spanned 3840 by 1080, if ever read as one area: the aspect cap holds", 3840, 1040, 0, 0, 1600, 1000);
check("a remembered frame that fits is kept", 1920, 1040, 1400, 900, 1400, 900);
check("PC 2, 7 October 2026: a 5120 by 2160 panel at 200 percent reads as 2560 by 1032; 0.3.21 opened 1920 by 826", 2560, 1032, 0, 0, 1587, 992);
check("PC 2: the hand-sized 1329 by 825 (aspect 1.611) is kept under the 2 percent tolerance", 2560, 1032, 1329, 825, 1329, 825);
check("a remembered frame at aspect 1.7 is discarded", 2560, 1032, 1700, 1000, 1587, 992);
check("a remembered super-wide frame from the take-4 host is discarded", 1920, 1040, 2752, 1152, 1440, 900);
check("a remembered frame wider than 1.6 times its height is discarded", 1920, 1040, 1800, 700, 1440, 900);
check("a remembered frame under the minimum is discarded", 1920, 1040, 800, 500, 1440, 900);
check("a small laptop: 80 percent wide, 16:10", 1280, 760, 0, 0, 1024, 640);
check("a tiny work area: the work area itself", 800, 560, 0, 0, 800, 560);
/* dpi-23: the per-monitor-aware host reads physical pixels and the monitor's DPI */
checkS("1080p at 100 percent: unchanged", 1920, 1040, 96, 0, 0, 1440, 900, 1440, 900);
checkS("PC 2: 5120 by 2112 physical at 200 percent (2560 by 1056 logical)", 5120, 2112, 192, 0, 0, 3250, 2032, 1625, 1016);
checkS("4K at 150 percent: 1920 by 1200 logical, 2880 by 1800 physical", 3840, 2120, 144, 0, 0, 2880, 1800, 1920, 1200);
checkS("a 1440p panel at 125 percent (2048 by 1120 logical)", 2560, 1400, 120, 0, 0, 2045, 1278, 1636, 1023);
checkS("PC 2's remembered 1329 by 825 (logical, from the unaware host) is kept and placed at 200 percent", 5120, 2112, 192, 1329, 825, 2658, 1650, 1329, 825);
checkS("a 250 percent laptop panel (1536 by 832 logical): 80 percent wide at 16:10", 3840, 2080, 240, 0, 0, 3067, 1917, 1227, 767);
printf(fails ? "%d FAILED\n" : "all ok\n", fails); return fails ? 1 : 0;
}

View file

@ -3,10 +3,6 @@
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.3.23"
#define IGNEUM_HOST_VERSION_RC 0,3,23,0
// The WebView2 runtime the host's loader needs at least (the SDK 1.0.2903.40 loader's minimum, from its release note at the
// cut); the installer's rights step (src/rights.rs) installs the evergreen runtime when the PC's is absent or below it, and a
// raised minimum is a new right the next update asks once for.
#define IGNEUM_WEBVIEW2_MIN "109.0.1518.78"
#define IGNEUM_HOST_VERSION_STR "0.3.14"
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
#endif

View file

@ -1,6 +1,6 @@
# Igneum brand assets
## The rule (the project lead, 4 October 2026)
## The rule (the founder, 4 October 2026)
One global logo for apps, profile pictures, favicons, everything. The mark sits in a black square. Never in a circle.
Never on another colour. Minimum clear space = 20% of the square. The Mac app icon is the model: a full square, all

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Builds every Igneum icon from the master mark, brand/master/igneum-mark-square.svg (4 October 2026).
the project lead's rule (4 October 2026): one global logo for apps, profile pictures, favicons, everything. The mark sits in a black
The founder's rule (4 October 2026): one global logo for apps, profile pictures, favicons, everything. The mark sits in a black
square (#0C0C0E, the site's obsidian token), centred, no circle, no ring, no border, never on another colour. The Mac app
is the model. The rounded variant (brand/master/igneum-mark-square-rounded.svg, Apple's 824-on-1024 icon grid) is used
only where the shape has to be baked into the file: the DMG volume icon. Tahoe masks the plain square itself.

View file

@ -1,14 +0,0 @@
#!/usr/bin/env node
// Regenerates brand/marks/vendor-marks.mjs from app/igneum-app/ui/app.js (View.MARKS, View.VENDORS): run after any
// change to the marks in the app, then commit both. Keeps the header and the OS marks; only the data blocks move.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const m = { exports: {} }; new Function('module', readFileSync(join(here, '../../app/igneum-app/ui/app.js'), 'utf8'))(m);
const V = m.exports.View, p = join(here, 'vendor-marks.mjs');
let s = readFileSync(p, 'utf8');
s = s.replace(/export const VENDORS = [\s\S]*?;\n/, 'export const VENDORS = ' + JSON.stringify(V.VENDORS, null, 2) + ';\n');
s = s.replace(/export const WELL_ALPHA = [^\n]*\n/, 'export const WELL_ALPHA = { dark: ' + V.WELL_ALPHA_DARK + ', light: ' + V.WELL_ALPHA_LIGHT + ' };\n');
s = s.replace(/export const MARKS = [\s\S]*?\n\};\n/, 'export const MARKS = ' + JSON.stringify(V.MARKS, null, 2) + ';\n');
writeFileSync(p, s); console.log('brand/marks/vendor-marks.mjs regenerated');

View file

@ -15,7 +15,7 @@ Mark description for the device, when a form asks: "A stylised flame formed of t
## Applicant
Igneum Labs LTD, Licensee Address: Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial
Centre (decided 5 October 2026; it replaces the ADGM DLT Foundation named on 4 October). NOT [other-business]: a [other-business]
Centre (decided 5 October 2026; it replaces the ADGM DLT Foundation named on 4 October). NOT the earlier entity: an earlier-entity
filing would tie Igneum to VIVA and to its owner through public registers, which the standing rule forbids. The
registered address above is the applicant address, with a trademark attorney as the address for service, so no
personal address appears anywhere. If the company's registration is not complete the week you want to file, the

View file

@ -1,6 +1,6 @@
# Proving on AMD and Apple cards: what exists, what the CPU can do, what to tell the public
5 October 2026, from the project lead's two questions that evening: "test proving on the amd card?" and "can we test proving on
5 October 2026, from the founder's two questions that evening: "test proving on the amd card?" and "can we test proving on
mac?". PC 1 holds an RTX 5090 and an RX 9070 XT (gfx1201, 16 GB) in an eGPU; this Mac is an M5 Max. The prover is
SP1 (`proving/igneum-prove`, `docs/plans/proving-v0.md`, `proving-v1.md`), run on the GPU only through SP1's CUDA
server. Every figure below is measured (with its bench-log entry or job id) or cited (with its file or page); the
@ -115,7 +115,7 @@ gates or a backend ships; it is reviewed with every prover release.
| Consequence | Action | Owner |
|---|---|---|
| An AMD-only miner loses the proving share | the CPU tier of 4a is measured here (section 2); whether it becomes a tier is a decision for the project lead on those numbers | this analysis; the project lead |
| An AMD-only miner loses the proving share | the CPU tier of 4a is measured here (section 2); whether it becomes a tier is a decision for the founder on those numbers | this analysis; the founder |
| The rig's prover unit must select NVIDIA cards only | already true in `prover_decision`; told the rig-installer agent to keep it as a stated rule and to print the CPU-fallback line for AMD-only rigs | rig-installer agent |
| The app's Proving tile on an AMD-only or Apple machine should say why it is off and name the CPU path | the `provedefault.rs` lines already say so for Apple; AMD-only Windows machines get "no NVIDIA card ..." | proving agent (told) |
| The site and litepaper over-promise for AMD and Apple | the line of 4c, to land with the next site pass (copy law; `node site/build.mjs`; link-check) | site-pages owner; not changed here |

View file

@ -1,8 +1,8 @@
# ASIC resistance, 2011 to 2026: the history, the papers, the lessons, and the audit of Igneum against them
5 October 2026 (night), branch `asic-history`. Asked by the project lead at 20:05 UTC: "do a full on deep dive into the full history of 'asic resistance' and see if we can add or upgrade anything." Baseline for the audit: the Counter ASIC 2.0 final class decided tonight (`docs/plans/counter-asic-2-status.md` on `ca2-coord`, entries 20:16 to 22:25 UTC; `docs/analysis/chip-model-v3.md` on `ca2-mixer` 1ab8b21). Every figure about another chain cites a repo file, a paper or a dated article, or is labelled approximate. Hash-per-joule gains are computed from the cited hashrate and watt figures of the chip and of the best consumer GPU of the same year, and are approximate by construction (GPU figures vary by tuning). Research gathered by four sub-agents between 20:10 and 20:45 UTC; the fetch failures they reported are listed in section 6.
5 October 2026 (night), branch `asic-history`. Asked by the founder at 20:05 UTC: "do a full on deep dive into the full history of 'asic resistance' and see if we can add or upgrade anything." Baseline for the audit: the Counter ASIC 2.0 final class decided tonight (`docs/plans/counter-asic-2-status.md` on `ca2-coord`, entries 20:16 to 22:25 UTC; `docs/analysis/chip-model-v3.md` on `ca2-mixer` 1ab8b21). Every figure about another chain cites a repo file, a paper or a dated article, or is labelled approximate. Hash-per-joule gains are computed from the cited hashrate and watt figures of the chip and of the best consumer GPU of the same year, and are approximate by construction (GPU figures vary by tuning). Research gathered by four sub-agents between 20:10 and 20:45 UTC; the fetch failures they reported are listed in section 6.
## 0. One page for the project lead
## 0. One page for the founder
**What the history says Igneum is doing right.**
@ -228,7 +228,7 @@ Ranked by how much the history says each would change the outcome, with the cost
| 1 | **Price the partial-store chip and draw the time-memory curve.** A chip that stores a fraction f of the dataset in HBM or on many narrow DRAM channels, recomputes the rest from a 256 MiB on-die cache under x8, and reads with 4-byte granularity. Rows for f = 0.25, 0.5, 1 at HBM3 and at GDDR7 random-read rates, priced in energy per hash (Rao's metric) and in reads in flight per watt | The only chip class that beat a memory-bound GPU hash: Ethash's 2.1x to 4.8x came from the memory system with no on-die dataset (rows 3, 4); Rao priced a 16-die DAG holder under a GPU board in 2019; Cuckoo's curve was wrong by 50x until drawn [P20]; O-1.6 is open and `MEMHARD.md` section 3 item 2 says the curve was never drawn | None (analysis) | The row may come out over 2x, which would qualify the public claim before anyone else does | Genesis (before the vectors freeze) |
| 2 | **A random item-derivation program per day** in place of the fixed-shape mixer: a SuperscalarHash-style generator, integer only, drawn from the day key, with its own acceptance test, compiled once a day by miners and verifiers | RandomX's reason for SuperscalarHash: a fixed derivation is hard-wired by a chip; a random one makes the light-mode chip a CPU (section 2.4). In Igneum's model the fixed shape is the 3x factor that turns 0.31x into 0.92x; removing the factor is worth more than x8 to x16 would be (x16: 0.46x with the factor by M16's table) | None per hash (the daily build is 23 to 77 ms at x8 and would roughly double); the verifier needs a per-day compiled derivation (a JIT, or a round schedule drawn from a fixed set of reviewed rounds), measured against the 10 ms gate | Cryptanalysis of random ARX programs; weak draws; a JIT in the verifier is new attack surface; the vendors must agree bit-exactly on a program they compile | Reserve (named family, unlock by height or signal) now; genesis if the verifier cost is measured under the gate before the freeze |
| 3 | **External cryptanalysis of M_r, the chained cache and the acceptance rule before genesis**, with the x8 shape as the target | Lesson 9 (MTP, Catena, Argon2i, Cuckoo); RandomX bought four audits for $141,000 before launch [S60]; the x8 decision multiplies the mixer's weight in the chip model, so a shortcut inside the mixer is now worth 8x more to a chip | None | Finding something late moves the vectors; not finding it in time moves nothing | Genesis gate (ledger M7, raised in priority) |
| 4 | **The clock and the detector.** (a) A share-pattern detector on the observer: per-program hash-rate spread, nonce-group patterns and per-card-model rate bands, with an alert when a population behaves like one fixed design (MoneroCrusher's method); (b) a stated trigger: the bounty escrowed and the benchmark live before daily issuance crosses about $50K (Vorick's rule), not on a calendar date | Lesson 10 (85% secret share); section 2.5's table (chips at $20K to $30K a day on compute-bound hashes); D11 (the bounty is unfunded) | None | A detector with false positives; a trigger the project lead has to fund | Not a layer; genesis-independent; do it before the public testnet |
| 4 | **The clock and the detector.** (a) A share-pattern detector on the observer: per-program hash-rate spread, nonce-group patterns and per-card-model rate bands, with an alert when a population behaves like one fixed design (MoneroCrusher's method); (b) a stated trigger: the bounty escrowed and the benchmark live before daily issuance crosses about $50K (Vorick's rule), not on a calendar date | Lesson 10 (85% secret share); section 2.5's table (chips at $20K to $30K a day on compute-bound hashes); D11 (the bounty is unfunded) | None | A detector with false positives; a trigger the founder has to fund | Not a layer; genesis-independent; do it before the public testnet |
| 5 | **Rank layer 9 (the epoch length) up, and measure the FPGA lane**: the compile-ahead cost per card at a 10-minute epoch (the `ca2-epoch` work), plus an estimate of a soft-overlay FPGA miner with HBM (reads in flight per watt against the 5090's 17.5 G/s) | FPGAs were the first adversary of Lyra2REv2 and X16R and came back within weeks of X16Rv2 (rows 7, 9); Xelis forked for FPGA resistance (row 30); a per-hour program is a bitstream target in a way a per-hash program is not | At 10-minute epochs: 6x the compile work per card (measured on `ca2-epoch`); the VDF lead shrinks | A short epoch moves the difficulty window (spec 1.12) and the seed path | Reserve (as decided), with the measurement before the public testnet |
| 6 | **Order the reserve by chip-unfriendliness**: families that force a full 32-bit datapath per lane first (byte permute, bit-field extract, variable shifts, popcount, select, the second shuffle form), mm8 last | Least Authority's "watch ML hardware"; int8 matrix blocks are licensable IP at every node; Apple pays 1.6x to 4.7x per emulated dot4 (status 20:38) | None at launch | None | Reserve ordering, genesis |
| 7 | **A vendor-share metric and a 3.0 target for the AMD gap**: the share of hashrate by vendor published with the benchmark, and the line-width question kept open as the plan says | Lesson 8: a one-vendor fleet is a softer version of chip capture; Equihash's NVIDIA tilt and Ethash's balance were part of each chain's miner politics (rows 3, 5) | n/a | A width that closes the gap makes the 5090 bandwidth-bound (status 20:27) | Counter ASIC 3.0 |
@ -256,14 +256,14 @@ Evaluated and placed nowhere, with the reason:
| Divergent data-dependent branches | Nowhere (already excluded) | Branches cost a GPU divergence and a chip nothing; RandomX's single predictable branch targets speculative CPUs, which Igneum does not have |
| Floating point | Nowhere (already excluded) | Vendor rounding splits the chain (spec 1.14); RandomX could afford it because its target is one ISA family with IEEE semantics |
## 5. Decisions this raises for the project lead
## 5. Decisions this raises for the founder
| # | Decision | Recommendation |
|---|---|---|
| 1 | Add the partial-store chip rows to `chip-model-v3.md` and draw the time-memory curve before the public testnet | Yes, before the vectors freeze (addition 1) |
| 2 | Name a random item-derivation program as a reserve family, and fund the verifier measurement that would move it to genesis | Reserve now; genesis if the verifier lands under the gate (addition 2) |
| 3 | Commission the external cryptanalysis of M_r and the chained cache before genesis, with the x8 shape as the target | Yes (addition 3; ledger M7) |
| 4 | Escrow the bounty and set its trigger to daily issuance, not to a date; build the share-pattern detector on the observer | Yes to the detector now; the escrow is the project lead's (D11) |
| 4 | Escrow the bounty and set its trigger to daily issuance, not to a date; build the share-pattern detector on the observer | Yes to the detector now; the escrow is the founder's (D11) |
| 5 | Rank the epoch-length reserve above the mm8 reserve, and measure the FPGA lane | Yes (additions 5 and 6) |
## 6. Sources and limits of this research

View file

@ -0,0 +1,616 @@
# Internal attack pass before the freeze (F1 to F10)
The internal cryptanalysis pass of `docs/plans/cryptanalysis.md` section 4.2, run before the freeze tag
`cryptanalysis-target-1`, so the paid engagement confirms rather than discovers. The founder's word, 7 October 2026:
"make sure they find ZERO flaws". Every finding is ours, fixed and re-gated, before any firm starts.
Target: the hash class the chain runs after 0.3.15's flip, `igneum-pow` generator v4 `V4_CLASS` = `mx8+sh256x27`
(`LoadClass::MX8`, `ShadowClass { instrs: 256, reps: 27 }`), the acceptance rule, the verifier, the era draw,
the latency-shadow dataset and ladder, the chip and FPGA cost model. Scope and gates are section 1.1 and 1.4 of
the plan, the same tests the firm is held to.
Lane: attack-pass, worktree `igneum-wt-attack`, branch `attack-pass` from `origin/master` `ab99e5e3`.
Binary built on igneum-build-1 (ELF x86-64, `igneum-pow` 0.2.0, sha256 6d2867...1a9ebe5) and run there under
the box's slots; model and era work from `sim/horizon/algorithm/model.py` and `infra/fast-time/`. Each row below
carries the method, the known-failed shape where one exists, the result with numbers, and PASS, RUNNING,
BLOCKED or FINDING. PASS RECORD (7 October 2026, 16:0x UTC, 17:0x UK): every row reads PASS or FIXED-AND-PASSED. F1 PASS (AP-F1-1 on the
v5 list at 3.0 percent); F2 PASS, effort-bounded; F3 PASS; F4 PASS against class v4 (AP-F4-1 on the v5 list); F5
FIXED-AND-PASSED (the F2 hour skipped by decision); F6 PASS (the worst of 10^5 programs 8.708 ms on the half-core
proxy; O-1.14 closed on an i7-9700K); F7 PASS on all three sub-rows (the era VDF in the node, 0 of 6 re-rolls); F8
FIXED-AND-PASSED in class v4 sub-version 3 at 017e7037 (AP-F8-1, AP-F8-2, AP-F8-3 ours and closed; the four-seed
unattributed tail named); F9 PASS on (a) and (c), (b) closed by the same fix; F10 PASS. Frozen generator: class v4
sub-version 3, igneum-pow 017e70376489251e18564c0abce7e466e606c8b3, devnet epoch-0 id a785001687d8688a. The
freeze tag `cryptanalysis-target-1` is the coordinator's cut on this record; the era VDF precondition is met (F7 a). Main checks every number
against the log before quoting it to the founder.
## Status board
| # | Attack | Gate (same as 1.4) | Result so far | Status |
|---|---|---|---|---|
| F1 | Shadow block compressibility and shortcut search | no compression of the shadow block beyond the honest compiler's simplification, measured against that compiler on the same program; the 27 repetitions never fewer than 27x (coordinator's ruling 7 Oct 2026, 12:3x UK; the plan's gate (1) and row F1 carry the same) | 10^4 and 10^5 class v4 programs: saved instructions mean 0.62%, max 5.078% at 10^5 (1 of 100,000 over 5%, 0 over 10%); nothing folds or dedupes across the 27 passes; every saving is local peephole algebra that clang -O3 removes from the honest kernel too (IR counts match on the worst programs), so against the compiler the compression is 0; 0 mismatches in 2 x 10^5 differential and verifier checks, z3 window proofs 0 counterexamples. AP-F1-1 routed to the v5 list as a shadow redundancy bound. Record `docs/analysis/attack-pass/f1-shadow.md` | PASS; AP-F1-1 on the v5 list |
| F2 | Mixer round margin (SAT/MILP, 1 to 4 keyed applications) | no distinguisher or shortcut beyond 2 of the 8 applications | one application characterised (differential weight 10 to 12, linear 1, verified on the real code on three days); two applications: no trail at or below weight 20 to 24 within 7,200 s per job, the MSB and LSB families die at two; rotational-XOR no bias at one application; the multiply layer folds on 0 of 2^20 inputs, k applications cost k; three applications: no differential trail at or below weight 29 to 35 and no linear at or below 24 to 28, four: 39 to 47 and 24, every job at its 7,200 s cap. Record `docs/analysis/attack-pass/f2-mixer.md` | PASS (effort-bounded) |
| F3 | Chained cache j+1 bound and storage-vs-recompute curve | no derivation under j+1 blocks; curve monotone; f=1 point unchanged | 0 of 64 and 0 of 1,024 lines under j+1 (exhaustive closure search, cross-checked by exhaustive pebbling at 10 lines, 10,240 pairs, 0 mismatches); both planted broken chains fire; curve monotone at both op counts; f=1 point 9,360 ops per item unchanged. Record `docs/analysis/attack-pass/f3-cache.md` | PASS |
| F4 | Weak-day census over 2^24 day keys | fraction of days with gain over 1.1x under 2^-20 | PASS against M2 (DSP-bound datapath): 0 of 2^28 days over 1.1x; planted weak days fire; every ROT and RC class 0. Bound finding AP-F4-1 on M1 (LUT adders): 5,476 of 2^24 days (3.26e-4) over 1.1x as the tail of a sum, no weak class; worst public-calendar day 29,337 at 1.121x, at most 12.1% more rate that day for a per-day LUT FPGA, 0 for any chip; redraw rule (NAF sum under 163 rejected) routed to the next class. Record `docs/analysis/attack-pass/f4-weakday.md` | PASS (v4); AP-F4-1 routed to the next class |
| F5 | Chip-model sweep + AWS F2 FPGA hour | evidence row 17 holds across the sweep; FPGA row under 27 M reads/s/W | sweep: 2.1x at k=1 GDDR7 reproduces, 3.2x at k=0.5, 4.1x at k=0.3 (matches ledger M32); FPGA row 2.3 to 2.9 G/s, 10 to 20 M reads/s/W (literature). FINDING: the k=0.33 figure is framed as the X9's measured core (M32) and a "measured class" (ladder branch §5a); the X9 was withdrawn before launch and never benchmarked. F2 hour SKIPPED: no AWS account | FIXED-AND-PASSED (sweep PASS; AP-F5-1 fixed and re-gated 7 Oct 2026: chip section re-run 2.1x at k=1 unchanged, identity grep 0 hits, site lane concurred); F2 hour SKIPPED-BY-DECISION (the founder, 7 Oct 2026, 09:5x UK; plan 4.2 row F5 is the sweep only at 3714c2a0; the FPGA row stays the JEDEC-ceiling model row labelled unmeasured) |
| F6 | Verifier worst case over 10^5 programs + O-1.14 laptop run | worst program under 10 ms cold on the half-core proxy and the laptop | 100,000 programs ranked, 50,000 timed cold one-core (worst 6.194 ms), the worst 200 re-timed and the worst 1,000 timed on the half-core proxy under the per-core lease (core 40 at 3,799.9 MHz): worst 8.708 ms (`attack-f6/87142`), 1.29 ms under the gate, every half-core reading under 9 ms; dr736 fails as it must (15.49). O-1.14 CLOSED on an i7-9700K (v4 6.334 ms cold max, dr736 10.04 fails). Ladder ceiling from the worst program on the half-core proxy: N about 300,000, so rung 2 admissible, rung 3 not. Record `docs/analysis/attack-pass/f6-verifier.md` | PASS |
| F7 | Era-draw bias harness + 2^20 era-seed census | no re-roll inside the publish window; no era class with gain over 1.1x over 2^-20 | (b) census PASS at 2^24 seeds (no class over 1.1x, stride bijective, R, pos and M uniform, planted cases fire); (c) 64-bit day-key seeding PASS (0 collisions in 2^17); (a) PASS with the era VDF in the node (era-vdf lane, fork era-vdf-node 394a5902 on release-0.3.20-node c4459193, behind era_vdf_activation_daa; master a4eaf766 carries the spec text, `docs/analysis/era-vdf-2026-10-07.md` and `tools/era-vdf/reroll.mjs`): against the real era cut on three fast-time nodes the re-roll harness fires with the VDF off (6 of 6 cuts) and is silent with it on (0 of 6; the adversary's 5.4 to 6.6 s evaluation against a 1 s block interval, the honest chain 3 to 10 blocks ahead, three nodes agreeing on every era seed); the delay is 517 s on the fastest prover measured (chiavdf NUDUPL over GMP, 208.8K squarings/s) at T = 108,000,000, 259x the 2 s window. Open, not a gate: the verify is 22 ms against the 10 ms target (O-4.6, 0.3.22). Record `docs/analysis/attack-pass/f7-era.md` | PASS (a, b, c) |
| F8 | Uniformity censuses (line-index 2^28, distinct lines, cross-hash histogram) | uniform within the window model of spec 1.13.1, layer 8; the excess beyond it within 6 sigma over 64 seeds; no hot set under 1% of items beyond the model | line index PASS at 2^28. The 6 Oct stream: 31 of 64 seeds over 1.2x (AP-F8-1, the lossy load source). Sub-version 1 (8c728ca3): 11 of 64. Sub-version 2 (07a809a7 / 8bdcbdd8): 9 of 64; AP-F8-2 (exhaustion) closed there, 0 of 10^6. Sub-version 3 (017e7037: the acceptance executing the shadow block, AP-F8-3; the shared-operand rule; (c'') the distinct-index ratio): 60 of 64 under 1.2x, the four over the named unattributed tail at 1.22x to 1.50x with no chip consequence; 0 exhausted in 24,631 chain-shaped seeds, the draw total by construction. Record `docs/analysis/attack-pass/f8-uniform.md` | FIXED-AND-PASSED (sub-version 3, 017e7037, the frozen generator) |
| F9 | Acceptance edges (39) + header grinding on an RTX 5090 | zero passing programs with a hot set under 1%; grinding gain under 1% of rate | (a) edges on generator 4 over 10^5 seeds: 34 disagreements in 105,064 candidates (29 const_bit, 4 bias, 1 lane_const), every one a one-bit or sampling-noise property moving the choice to an attempt both stand-ins accept, nothing in the attacker's favour: PASS; (b) hot-set search over 10^6 seeds: 11,696 passing programs (1.17%) concentrate 1% or more of reads on a hot set, worst 17.3%: FINDING, the same or-saturation load-source class as AP-F8-1 found by a second harness (F9-1 merged into AP-F8-1), re-gated on the amended stream; (c) grinding on the 5090: +0.004% at K = 2^14, ceiling +43%: PASS. Record `docs/analysis/attack-pass/f9-grind.md` | (a) PASS; (b) the AP-F8-1 class on the 6 Oct stream, closed by sub-version 3 (F8's census is the re-gate instrument; this harness's hot-share metric counts the era's windows); (c) PASS |
| F10 | Ladder signal monotonicity harness | no step without 90% over 7 windows in either direction | known-fail fails, known pass passes; new cases on the exact-share driver: 89% up holds (no step), 89 then 90% down with restarts steps only at 90% after the 7-window cool-down, the floor holds under 100% down (never below rung 0); decision per seed block memoised, identical after a restart, never differs between nodes; 19 of 19 checks per case. Two items to main, not findings: a stale commit string in the ladder lane's igneumd, and proof-synced nodes deciding rung 0 until the witness lands (a precondition line for spec 01). Record `docs/analysis/attack-pass/f10-ladder.md` | PASS |
## The rows
### F1. Shadow block compressibility and shortcut search (hash lane)
Method: over 10^4 class v4 programs, constant folding, dead-register elimination, common subexpressions across the
27 repetitions, linear sub-block detection, SAT equivalence on reduced blocks; the minimum op count per program
against N. Known-failed shape: a shadow that constant-folds or dedupes across its 27 identical passes so a chip
pays fewer than 55,296 shadow instructions per hash. Entry point: `igneum-pow show --program-class v4` prints the
256-instruction shadow (op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12 on the
genesis seed). Gate: best compressed block within 5% of N on every program; no program over 10% compressible.
Result: RUNNING. What a failure moves: an acceptance-rule line for the shadow block (rule (c) runs the block),
packs re-cut.
### F2. Mixer round margin (hash lane, on the box)
Method: SAT or MILP differential and linear search on 1 to 4 keyed applications with drawn rotations; rotational-XOR
on the ARX layer; the fold of the multiply layer across applications checked algebraically. Known-failed shape: a
differential or linear trail or an algebraic fold that distinguishes or shortcuts more than 2 of the 8 applications
between dependent reads. Gate: no distinguisher or shortcut beyond 2 of the 8 applications. Result: RUNNING (prior
`ca2-mixer` evidence to be re-gated). What a failure moves: `mixer_mult` 16 or a shape change; verifier re-measured.
### F3. Chained cache j+1 bound and storage-vs-recompute curve (hash lane, on the box)
Method: exhaustive search on a 2^10-line model segment for a line derivable without an earlier line; the curve from
f = 1/64 to 1 in ops per item. Known-failed shape: a line (s, j) computable in fewer than j+1 block evaluations
without an earlier line (the MTP address-steering break shape). Gate: no derivation under j+1 blocks; curve monotone;
f=1 point unchanged. Result, 7 October 2026, 09:10 to 09:12 UK on the box (`docs/analysis/attack-pass/f3-cache.md`; logs
`/srv/builds/igneum-wt-attack/attack-f3/r1-*.log`): PASS on all three clauses. The chain extracted from
`Cache::fill_segment` (verified equal to the code on 16 of 16 key and segment pairs; `block == chacha_block` on
100,000 random inputs) has line j fed by line j - 1 only; the exhaustive closure search finds 0 of 64 and 0 of
1,024 lines under j + 1 (every line costs exactly j + 1), cross-checked by an exhaustive pebbling search at 10
lines (10,240 configuration and target pairs, 0 mismatches). The two planted chains fire: `skip2` (63 of 64 under
j + 1) and `nofeed` (every line in 1 block). The curve over stored cache lines is monotone non-increasing from
f = 1/64 to 1 at 608 (counted) and 700 (MEMHARD.md) ops per block; the f = 1 point is 9,360 ops per item,
41.7 MH/s at the 50 T op/s budget, unchanged. `ca2-cache` was the hot-table experiment, not a chain analysis, so
there was nothing to re-gate. Observation (coordinator and the F3 record, not a finding): `funding.md` B2 rank 2
prices the trade-off at the naive placement; the optimal placement of every 8th line costs 3.17 blocks per read,
not 3.5, and 16.0 at f = 1/64, not 31.5 (brute force over 4,426,165,368 sets at n = 8); the chip stays worse than
the full mirror at every f under 1, so the verdict stands, and a `chacha_block` shortcut in chaining mode stays
the paid question (Lot A and B). What a failure would have moved: the chain construction (a second feed-forward or
a cross-segment tie).
### F4. Weak-day census over 2^24 day keys (hash lane, on the box)
Method: 2^24 day keys through `MixParams::with_shape`; the ROT classes (all equal, complementary pairs, small
amounts), MUL low weight, RC structure, each per-day gain measured on the box verifier. Known-failed shape: a day
key whose drawn ROT/MUL/RC gives a fixed datapath a gain over 1.1x (the "weaker authorized parameters" class,
Kudelski 2019). Gate: the fraction of days with any gain over 1.1x under 2^-20. Result: RUNNING. What a failure
moves: a rejection-and-redraw rule on the draws.
### F5. Chip-model sweep and the FPGA hour (algorithm lane)
Method: `sim/horizon/algorithm/model.py` over k 0.2 to 1.5, tFAW 12 and 28 ns, HBM4 2.3 and 21.4 G reads per
stack, amortisation 1 to 3 years, electricity USD 0.05 to 0.15 per kWh; and the AWS F2 hour replacing the FPGA
ceiling row with a measurement. Known-failed shape: an input of the published model that, when corrected, lifts the
f=1 chip's per-joule edge over the 5090 above the published 2.1x at k=1.
Gate: the published sentence (evidence row 17) holds across the sweep; the FPGA row under 27 M reads/s/W.
Result (sweep): PASS on the numbers. The model's measured-anchor column (GDDR7, the 5090 reads 82% of its ceiling)
gives the f=1 chip's v4 per-joule edge over the RTX 5090 bench row as 4.1x / 3.2x / 2.1x / 1.5x at k = 0.3 / 0.5 /
1 / 1.5. At k = 1 the figure is 2.1x, and 3.9x at k about 0.33, which matches `fud-ledger.md` M32. The higher HBM3
and HBM4 columns rest on an 8-activate per 12 ns window that JEDEC HBM2 timings (4 per 28 ns) do not support; the
model already states GDDR7 is the column to quote. One wording gap: `evidence.md` row 17 says "brings it to about
2x", which is a floor that holds at k about 0.9 and above but understates the edge at lower k (3.2x at k = 0.5). The
accurate statement is M32's, 2.1x at k = 1 with the k range beside it. The sweep's numbers stand; the finding is the
X9 framing below.
FPGA row: the HBM2 FPGA ceiling is 2.3 to 2.9 G reads/s (measured Shuhai U280, FCCM 2020, equal to the JEDEC
tFAW-bound 2.3 G/s), 10 to 21 M reads/s/W at 115 to 150 W, 0.30 to 0.47x of the 5090 per watt. Under the 27 M
reads/s/W gate. The AWS F2 hour is SKIPPED-BY-DECISION (the founder, 7 October 2026, 09:5x UK: not needed for now, not blocked;
plan 4.2 row F5 at commit 3714c2a0 on branch cryptanalysis is the chip-model sweep only, 4 h, the algorithm lane).
There is also no AWS account or `aws` CLI on this Mac. The FPGA row stays the JEDEC-ceiling model row labelled
unmeasured; Lot C prices it from the reads-in-flight model; the firm is told the F2 measurement was not run.
FINDING (X9 framing), owning lane algorithm and hash (the ladder lane is closed, so ours): the published numbers
already carry 2.1x at k = 1 beside 3.9x at k about 0.33 (`fud-ledger.md` M32, recalibrated under X35). The error is
the framing. M32 calls the k = 0.33 figure "the X9's core" and the `ladder` branch's `latency-ladder.md` section 5a
calls k about 0.33 a "measured class". Bitmain's Antminer X9 (RandomX ASIC, 1 MH/s, 2,472 W, about USD 5,600) was
announced and, per pcpraha.cz ("Antminer X9 canceled: Bitmain withdraws model from market before launch") and
r/MoneroMining, withdrawn before launch. Its implied core efficiency (k about 0.33) is a CLAIMED datasheet figure
from a design that never shipped and was never benchmarked, not a measured calibration point. It is carried as the
pessimistic bound, not a calibration. This collides with the merged ledger X34 ("RandomX has a shipping chip;
correct every sentence that said otherwise"): if the X9 was withdrawn, X34's correction is itself wrong and must be
reversed. Confirmed from primary sources (coordinator, 7 October 2026): pre-orders opened 26 December 2025 (shipments
scheduled for July 2026), withdrawn in mid-May 2026 with buyers refunded before any unit shipped, no independent
benchmark, Bitmain never published a cancellation (its shop lists it as sold out), and the box was commodity Sophgo
SG2044 server SoCs with an AES accelerator and 60-plus DRAM sticks, no tapeout; its claimed edge about 2x per joule
over a tuned Zen 4 part, about 3x over a stock desktop CPU. Re-cut applied the same day: `evidence.md` row 17 (claim
and measured cells) and `fud-ledger.md` M32's answer paragraph on attack-pass, and the ladder design doc section 5a
on branch `attack-ladder-5a` from the ladder tip 7003f9f5 (the `ladder` branch is checked out by another lane, so
the fix rides its own branch for the ladder owner to take). The served-text rows (X34 reversal, X36) belong to the
site lane, which confirmed the wording agrees. What a finding moves
(plan 4.2 F5): the sentence re-cut before the freeze so the firms attack the corrected model. The re-cut, once the
fact is confirmed: k about 0.33 labelled a claimed pessimistic bound from a withdrawn design everywhere it appears;
2.1x at k = 1 on the GDDR7 measured anchor kept as the headline with the k range beside it; k itself unmeasured
until Lot C produces it. This row reads FIXED-AND-PASSED only after the re-cut and its re-gate.
Economic row the withdrawal implies: a recompute chip at a 3x fixed-function factor against a CPU and GPU fleet
must recover its NRE (low to mid seven figures at a modern node, `chip-model-v3.md`) and carry a fork threat (a
class change at 95% miner signal can redraw the datapath the chip bakes in). The X9 at 2.47 J per KH against a
RandomX CPU fleet did not clear that bar at Monero's hash and price; the same arithmetic against Igneum's class v4,
with the shadow block and the automatic era draw as extra firmware risk, is why the chip model's verdict is a
deliverable and not a courtesy (plan 2.3). The confirmed reading: a box with a 2x to 3x per-joule edge and no NRE (commodity SoCs) was withdrawn rather than
face a 1.5x re-tune of RandomX, so the tapeout economics of a 3x chip against Igneum are worse than the X9's. This
row is the pessimistic case, not a measured gain.
### F6. Verifier worst case (algorithm lane)
Method: 10^5 class v4 programs timed on the box one-core and half-core proxies for the slowest warp (base program
and shadow block), plus the O-1.14 laptop run (the Windows `igneum-pow` build on the box, the relay, `bench
--warps 50`). Known-failed shape: a drawn program whose verifier warp exceeds 10 ms cold (the acceptance rule bounds
the miner's side, not the verifier's; `dr736` already FAILs at 10.51 ms cold one-core, but it is not the shipping
class). Gate: the worst program under 10 ms cold on the half-core proxy and on the laptop.
O-1.14 route (coordinator, 7 October 2026, 10:1x UK): no US laptop is due, so the laptop run is replaced by a
rented 2019-class CPU host through the fleet agent, capped at two hours of rent; the Linux `igneum-pow` from the box
(the same binary as the proxies) runs `bench --warps 50` for v2, mx8, mx8+sh256x27, dr368 and dr736 (the
known-fail) on it; INCOMPLETE with the numbers so far if the cap lands first. The Windows exe was also built on the
box for the day a laptop appears (1,009,675 bytes, sha256 fbed7538...e6c9). Outcome, 09:40 UK: no 2019-class CPU
host stood up. Vast accepted and dropped five CPU-class rents within 30 s each (i7-9700K, i5-8500, Xeon W-2133 and
W-2123) under the account's automatic new-account spend limit (support ticket open since 6 October), and RunPod has
no 2019-class CPU pod; so O-1.14 reads INCOMPLETE on the box proxies today and stays a precondition of the freeze.
Next try: the US laptop when it registers on the relay (the exe is ready), or Vast once the spend limit lifts; the
bench script is staged and runs in minutes. Correction, 10:4x UK (fleet agent): the provider dropped nothing; every
rent stood up and ran, hidden by Vast's instance listing cap of 25 rows on an account holding 38, so the hosts sat
idle and were destroyed. The fallback is re-rented under the same word (i7-9700K class, two-hour cap from its start,
read by id); the result replaces this line when it lands.
O-1.14 RESULT, 7 October 2026, 09:49 UK, Vast instance 54613164, Intel Core i7-9700K (2019 desktop core, Coffee Lake,
read at 4,170 MHz during the run, 31 GB DDR4, Ubuntu 24.04), the box-built Linux `igneum-pow` (sha256 6d286783...),
`bench --seed igneum-genesis --day 2026-10-03 --warps 50` on one core (`taskset -c 1`), the host otherwise idle; log
`docs/analysis/attack-pass/o114-i7-9700K-2026-10-07.log`:
| Class | Cold max (ms per warp) | Average of 50 (ms) | Gate 10 ms | Box one-core cold | Box half-core |
|---|---|---|---|---|---|
| v2 | 1.582 | 1.280 | pass | 1.30 | n/a |
| mx8 (class v3) | 5.394 | 5.267 | pass | 4.67 | 7.56 |
| mx8+sh256x27 (class v4, the target) | 6.334 | 6.006 | pass, 3.7 ms of headroom | 5.06 | 8.23 |
| dr368 | 5.540 | 5.426 | pass | 5.32 | 8.16 |
| dr736 (the known-fail) | 10.290 | 10.042 | FAIL, as it must | 10.51 | 15.49 |
Cache fill 276 ms on the 9700K core (box 361 ms, M5 Max 175 to 181 ms). The 2019 desktop core sits between the box's
two proxies as the arithmetic predicted (1.2x the box one-core cold on v4, 0.77x the half-core); the known-fail
fires on it. A 2019 laptop core at 3.5 GHz reads about 15 to 20 percent slower than this desktop part (approximate,
clock ratio), so about 7.0 to 7.6 ms on v4, still under 10 ms. Consequences: a 2019-class node verifying class v4
spends 0.6 percent of one core at 1 bps and 6 percent at 10 bps; a header flood needs about 160 invalid headers a
second to saturate one such core; a pool verifies about 160 shares a second per core; IBD of 108,000 headers is
about 11 minutes of one core. The implied ladder ceiling on this core: the shadow costs 0.74 ms per 55,296
instructions (v4 minus mx8), so the 4.0 ms of headroom buys about 300,000 more shadow instructions, N about 650,000
counted ops at the 1.83 convention (approximate), against 370,000 on the half-core proxy and 1,060,000 on the
2.5x rule; the half-core proxy stays the standing pessimistic rule and the ladder's ceiling should be taken from
it, not from this desktop part. O-1.14 is CLOSED on a real 2019-class core for the genesis program; the F6 row
still owes the 10^5-program worst case before it reads PASS.
Result (average, verified on the box): class v4 `mx8+sh256x27` runs 4.90 to 5.06 ms per warp cold on one EPYC
9454P core (nice 19, taskset), 8.23 ms on the half-core proxy (both SMT siblings busy). Under 10 ms. Status
RUNNING: the 10^5-program worst-case search and the O-1.14 laptop relay run are owed before the row reads PASS.
What a failure moves: an acceptance-rule bound on verifier cost; the ladder's ceiling set from the measured core.
### F7. Era-draw bias harness and census (node lane harness, hash lane census)
Method: the fast-time 3-node network (`infra/fast-time/`) with an adversary withholding or publishing the last blue
block before C_era(n) to re-roll the draw; a census of 2^20 era seeds for stride, ROT and weight-perturbation
classes with gain over 1.1x; the 64-bit seeding of the day-key stream against the spec's intent. Known-failed shape:
a re-roll of the era draw inside the 2 s publish window, or an era class (stride bijection, all-equal ROT, low-weight
M) with a chip gain. Gate: no re-roll inside the publish window; no era class with gain over 1.1x at a fraction over
2^-20; the draw's input set as the spec states it.
Result (full record `docs/analysis/attack-pass/f7-era.md`; harness `tools/attack/f7-era/`). Census: 2^20 and 2^24 era
seeds through `generator::era_draw` over `V3_ALLOWED` (the chain's path), classified; the planted known-fail/known-pass
of the classifier fired and the sound draw raised nothing. No era class with gain over 1.1x at any fraction (the richest
is M = 1 at 1.0034x, absent in 2^24; every class over 2^-20 is 1.0000x to 1.0007x); the stride is a bijection on every
sample (0 even M), R and pos and the M bits uniform; the op-weight corners (15 to 31 of 75) are 1.0x against the GPU, 0
memory effect. The 64-bit day-key seeding is the spec's intent (spec 1.8.4); 2^16 days are all distinct, birthday 2^-33.
Harness: the 3-node fast-time network (`reroll.mjs`, ports 29800+, suffix 980) with an adversary holding the last block
before the cut; known-pass (`--vdf-ms 0`) fires at 1 of 6 cuts (seed = adversary block), known-fail (`--vdf-ms 5000`)
is silent at 0 of 6, both SOUND. The node has no era VDF yet (`seed_below` is a plain block hash, era-layout.md section
8), so the harness cannot show the real 2 s-window gate; the era draw's grinding resistance rests on the 1-hour VDF of
spec 4.4 (re-roll needs a 1,800x evaluator, spec 4.6 gives 300x; forge needs 20 days of 100% hash). Verdict: census PASS,
64-bit seeding PASS, harness INCOMPLETE with the written argument. Logs on igneum-build-1
`/srv/builds/igneum-wt-attack/attack-f7/census-2p24.log`, `census-2p20.log`, `reroll-knownpass.log`, `reroll-knownfail.log`.
What a failure moves: the draw procedure or the C_era cut rule; a redraw rule for the era stream.
Sub-row (a) CLOSED, 7 October 2026, 15:1x UK (the era-VDF lane, launched by the coordinator on this row's INCOMPLETE):
the era VDF is in the node (fork `era-vdf-node` 394a5902 on `release-0.3.20-node` c4459193, behind
`era_vdf_activation_daa`, never on any network until the founder sets it per network; repo master a4eaf766 carries the spec
text, the record `docs/analysis/era-vdf-2026-10-07.md` and the harness `tools/era-vdf/reroll.mjs`, which attacks the
era cut directly now that the node takes `pow_era_blocks` and `pow_era_lead` from the override file). Against the
REAL era cut (era 120 DAA, lead 20 on the fast-time file, three nodes on igneum-build-2) the harness fires with the
VDF off (6 of 6 cuts: the adversary's block is the cut block and its hash the seed, known the instant it is built) and
is silent with it on (0 of 6 across six cuts: the adversary's 5.4 to 6.6 s evaluation with the node's own code against
a 1 s block interval, the honest chain 3 to 10 blocks ahead when it published, three nodes agreeing on every era seed,
the record ready at every era start). SOUND both ways. The production delay: 517 s on the fastest prover measured
(chiavdf NUDUPL over GMP, 208.8K squarings/s on the same core) at T = 108,000,000 squarings, 259x the 2 s window.
Freeze sentence (the era-VDF lane's, carried to the plan's owner): "The era seed E_n is the output of a one-hour
verifiable delay (class-group Wesolowski, 1,024-bit prime discriminant, T 108,000,000, scheme byte 0 with the
hash-chain fallback as byte 1) over the blue blocks of the day ending at the era's cut block; the attack pass's F7
re-roll harness fires against the stand-in and is silent against the delay, so the era draw procedure and the C_era
cut rule are frozen with the VDF in the node, behind era_vdf_activation_daa, never until set per network." Open, not
a gate of F7: the verify is 22 ms with the group held, against the 10 ms target (once per 180 days per importing
node; O-4.6's reducer or GMP behind a feature, 0.3.22). Logs `/srv/builds/igneum-wt-era-vdf/ev-harness-out/
reroll-vdf-{on,off}-5.json` on build-2. F7: PASS on all three sub-rows.
### F8. Uniformity censuses (hash lane, on the box)
Method: the line-index distribution over 2^28 derivations; distinct lines per hash and per warp on 10^6 nonces of
three programs; the cross-hash item histogram of one epoch. Gate: the largest bucket within 6 sigma of uniform; no
hot set under 1% of items. Result: RUNNING. What a failure moves: the mask or the fold; packs re-cut.
### F9. Acceptance edges and header grinding (hash lane; one PC 2 job)
Method: the 39 edge disagreements reproduced and bounded; a search over 10^6 seeds for programs that pass rule (c)
with a hot set under 1%; the header-grinding search cost against its DRAM-locality gain measured on PC 2's RTX 5090
(one job through `tools/build-job.mjs`). Known-failed shape: a seed grind that steers a program to a hot cache set
for DRAM locality, or an edge where the closed-form stand-in disagrees with the live verifier in the attacker's
favour. Gate: zero passing programs with a hot set under 1%; the grinding gain under 1% of rate at any search cost.
Result: the `accept` path reproduces per-seed verdicts (genesis seed: 1 candidate ACCEPTED, bias max 54, 0
saturated). The header-grinding cost-versus-gain measurement needs a 5090. Status BLOCKED on the go decision: use
PC 2's 5090 through a relay run job only if PC 2 is online and mining is unaffected, else a rented pod under the
standing fleet budget. What a failure moves: the closed-form stand-in replaced by the live verdict at the edges; a
locality term in rule (c).
### F10. Ladder signal monotonicity (node lane)
Method: the fast-time harness with a weight that steps the ladder down and never up, and an 89% signal; the step
rule's monotonicity and its memoisation per seed block. Known-failed shape: a chip owner stepping the ladder down
(cheaper N) without the 90% threshold, or a step registered under 90%. Gate: no step without 90% over 7 windows in
either direction; a step down needs the same. Result: RUNNING. What a failure moves: the step rule's text in spec 01
before the ladder is frozen.
## Lane (d): the families re-run on class v5 (7 October 2026, evening; the coordinator's word on the founder's order)
Object: igneum-pow on branch `class-v5` at e4f1f275 (the frozen sub-version 3 017e7037 merged; the v5 chain draw is
the amended v4's instruction for instruction, generator 5, every item keyed by the window's state through the leaf
XOR before the first mixer; `V5_CLASS` = `mx8+sh256x27+state`), against the first v5 pack
`proto-cuda/packs-ca3-v5/v5-dn3-epoch0` (Devnet 3's genesis 4020cb43... as epoch and era seed, day 20,733, program id
e5a4ac5978462156, reproduced by the e4f1f275 build on box 2: the pairing). The four harnesses carried onto the
class-v5 tree in worktree `igneum-wt-attack-v5` (branch `attack-v5`), each with `--class v5` and, where the dataset
enters, `--state <IGSD1>` attaching the leaves through `with_leaves` as the CLI does; F8's traced derivation carries
the leaf XOR and validates bit for bit against `derive_items_leaves` and `Epoch::hash_warp` (p1 on the dn3 state at
4,096 nonces: 0 mismatches over 16,777,216 items and 64 warps; the flipped-state file mismatches: the known-fail).
Both boxes at nice 10 beside the release builds; the binaries run from copies in each run's scratch directory (AP-H2).
GitHub answered 403 (account suspended) from 17:2x UK, so this section lands on the box mirror (`build`, master and
attack-pass) by the coordinator's exception rule; nothing touches GitHub.
| Family | Class v5 run | Result | Verdict |
|---|---|---|---|
| F4 weak-day census | 2^24 chain days from 20,729 under `Shape::for_class(&V5_CLASS)`, box 1, 18:5x to 19:1x UTC | byte-identical to the class v4 census: M2 (DSP-bound) 0 of 2^24 days over 1.1x; M1 (LUT adders) 5,476 days, 3.264e-4, the same bounded tail, worst day 4,819,563 at cost 197 against the median 231; planted weak days fire (mul1all M2 unbounded, mulnaf 1.333x). The day-key draw depends on the mixer shape alone and v5 adds only the state flag, so identity is the expected and the measured result | PASS (v4's reading; AP-F4-1 stays the next-class item) |
| F8 hot-set gate | 64 seeds at 2^24, chain path, v5 with the dn3 state, box 2 (64 threads), from 18:47 UTC | pending | pending |
| F9 exhaustion count | 10^5 chain-shaped seeds on the v5 chain path with the dn3 state, box 1, ten parallel chunks (the chain draw costs about 2.2 s per candidate through (c''), so 10^6 is about fifty hours) | pending | pending |
| F1 shadow redundancy | 10^5 class v5 programs through the string-seed path, box 1 | pending | pending |
## Operating hazards found by the pass
AP-H1 (box scratch cleaned by builds; found by F3, 7 October 2026, 10:0x UK). `infra/build-server/remote-run.sh`
line 71 runs `git clean -qfd -e target -e 'target-*' ...` on `/srv/builds/<worktree>` before every remote build, so
an untracked box scratch directory of one row (a venv, a log dir, a crate's `tools/attack/*/target`) is deleted by
the next build from any row. F3 protected its own directory through the box mirror's `.git/info/exclude`; the lane
then added `attack-*/`, `target-attack-*/`, `tools/attack/` and `.build-remote.log` to that file at 10:1x UK, after
which `git clean -fdn` on the mirror lists nothing (the clean has no `-x`, so the exclude file applies). The class
check is owed to the build-server lane: the clean line should spare a lane's declared scratch prefix (`-e 'attack-*'`
style, or read a per-worktree exclude list), and a CI check should fail a remote-run.sh whose clean line lacks it.
OPEN until that check lands (CLAUDE.md: a rule row closes only with its check).
AP-H2 (this lane's own, 7 October 2026, 13:3x and 14:5x UK, twice). Two census runs launched from the same crate's
`target/release` binary path on the box mirror: a rebuild of the crate at a new commit replaces the binary under a
run still in progress, and every chunk the run launches after that executes the new commit's code with the old run's
label (the 07a809a7 control's later chunks ran 8bdcbdd8; the ddacfbd3 class check's later chunks ran 017e7037). Both
runs were caught by their attempt histograms (attempts 32 and 35 under a cap of 32) and their contaminated chunks
discarded. Fix in the lane's launcher: `run-census-chain.sh` copies the binary into the run's own scratch directory
before the first chunk and runs from the copy, so a rebuild cannot reach a run in progress; a run's record names the
sha256 of the copy. Class check owed: the same rule for every lane's long run (the box's build runner could refuse to
replace a binary that a running process has open, or stamp the commit into the run's log at every chunk).
## Ledger rows
AP-F1-1 (hash lane; ruling asked). At 10^5 class v4 programs one program (`attack-f1/37341`) compresses by 5.078
percent (13 of 256 shadow instructions per pass), 0.078 points over the gate's first clause, on 1 of 100,000; every
other program is within 5 percent and none over 10. The saving is the same local shape as on every program (a
register written twice from one source with no write between), nothing crosses a pass, and clang -O3 removes the
same instructions from the honest kernel (IR counts match the harness on the worst programs), so a chip gains nothing
relative to a card: no shortcut. The gate as written counts honest-compiler simplification as compressibility. Two
ways to close: re-word gate (1) and row F1 to "compressible beyond the honest compiler's own simplification" (the
firms then attack chip-relative compression, which is the question), or a shadow-draw redundancy bound in the next
class (reject a shadow with over 12 peephole-removable instructions per pass, rejection about 1e-5; class v4 is on
the live vote). Ruling (coordinator, 7 October 2026, 12:3x UK): both. Gate (1) and row F1 re-worded to "no
compression of the shadow block beyond the honest compiler's simplification, measured against that compiler on the
same program" (sent to the cryptanalysis lane for the plan and the firms' brief), under which the 5.078 percent
letter miss at honest-compiler parity is a PASS; and a shadow redundancy bound on the v5 generator's list beside
AP-F4-1 and AP-F8-1 (the generator refuses a shadow block whose honest-compiler simplification exceeds a stated
fraction; the v5 lane sets the fraction from F1's census), gated by F1's harness on 64 seeds of the v5 stream.
The fraction is 3.0 percent (v5 lane, class-v5 45e29cb0; 384 of 100,000 draws redrawn in its census, 3.8e-3, against
F1's histogram where the 3.0 to 5.5 percent bins hold 397 of 100,000); the plan's 1.1 sentence carries the number.
Status: F1 PASS; AP-F1-1 FIXED-AND-PASSED against v5 once the bound is in the v5 generator and F1's census passes.
AP-F5-1 (algorithm and hash lane, ours; the ladder lane is closed). The k about 0.33 chip-efficiency figure is
framed as a measured calibration ("the X9's core", `fud-ledger.md` M32 L172; "measured class", `ladder` branch
`docs/design/latency-ladder.md` section 5a). The Antminer X9 was withdrawn before launch and never benchmarked, so
k about 0.33 is a claimed datasheet bound, not a measurement. This also puts the merged ledger X34 ("RandomX has a
shipping chip") in question. Fix owed, held until the coordinator's research agent confirms the withdrawal and the
no-benchmark fact: relabel k about 0.33 as a claimed pessimistic bound from a withdrawn design in `evidence.md` row
17, `fud-ledger.md` M32 and the `ladder` branch; reverse X34 if the withdrawal is confirmed; keep 2.1x at k = 1 on
the GDDR7 measured anchor as the headline with the k range beside it. Re-gate after the re-cut. Status: FIXED on the docs rows (evidence 17, M32, ladder 5a on branch attack-ladder-5a d3cb17b6; attack-pass
rebased on master a3678789 after X36); FIXED-AND-PASSED once the site lane's X34/X36 served rows are confirmed in
one voice (no objection received) and the sweep is re-run against the re-cut sentence (the numbers are unchanged, so
the re-gate is the identity check and one `model.py --section chip` run against the new wording). Re-gate done 7 October 2026, 09:5x UK: the 5090
bench row still reads 5.7x / 4.1x / 3.2x / 2.1x / 1.5x (v3; v4 at k = 0.3 / 0.5 / 1 / 1.5), identity grep 0 hits
over 290 export files, the site lane confirmed the served text agrees. AP-F5-1: FIXED-AND-PASSED.
AP-F8-1 (hash lane; the generator fix is the Counter ASIC lane's on the v4 seam, routed 7 October 2026, 10:3x UK).
The class v4 item read map is not uniform. F8 phase D, one program, 2^26 nonces: the top 0.1 percent of items take
0.520 percent of reads against 0.115 percent for the uniform control (4.05x); the top 1 percent take 2.49 percent
(1.37x); one item (0xca5b92) takes 78,479 reads, 153x the mean; read site 15 feeds 6.37 percent of its reads into
that 0.1 percent in all 8 iterations; the excess grows with N as a real skew does. Sized: a chip caching the hot
0.1 percent in SRAM serves about 0.5 percent of reads from cache, so the shortcut is under one percent of rate today;
an auditor flags a non-uniform read map in a design that claims uniform random reads, and site 15's index derivation
is the cause to name. Fix asked: per-site index whitening or a rejected class above a bound. Re-gate: the top
0.1 percent within 1.2x of the control over 2^26 nonces on every one of 64 seeds, with F8's harness against the
Counter ASIC lane's branch. Phase E (the 64-program census) decides whether it is one program or the class.
Framing from the Counter ASIC lane (the generator's owner, 7 October 2026, 10:5x UK): class v4's item map is not
designed to be uniform per program. Layer 8 (spec 01 section 1.13.1) gives each load site k_off = below(3), so a
site reads the whole dataset, a half or a quarter under the era's stride and interleave; a quarter-window site
concentrates 4x on its quarter by design, which is the 4.05x at the top 0.1 percent, and the windows exist so a
chip's SRAM mirror must hold the whole dataset every hour (the Counter ASIC 2.0 windows-union census). The right
control is therefore the window model from the program's own 16 draws, reported beside the uniform control (what an
auditor sees first); the number that must be explained is the single item 0xca5b92 at 153x the mean (window
coincidence under the era mapping with a stated tail, or a low-entropy index source at site 15, which would be a
fault). The lane reproduces with F8's harness on branch `ca3-v4-uniform`, waits for phase E, re-prices the chip
consequence (a 0.1 percent hot-set cache, about 1.7 MB of SRAM, serving 0.5 percent of reads: under one percent of
rate) and changes the generator only on a fault beyond the model, since v4 is on the live devnet's vote. F8 was
re-briefed to carry both controls and the per-site table. Raised to the coordinator: plan 1.4 gate (4) and row F8
say "within 6 sigma of uniform"; if the design is windowed, the gate text must say "uniform within the window model
of spec 1.13.1" before the freeze tag, or every reviewer files the windows as a finding on day one.
Coordinator's ruling (7 October 2026, 11:0x UK), accepted: the right null is the window model derived from the
program's own draws; F8 is re-gated against it, and the finding stays open only for the excess beyond the window
model (the 153x item, or a low-entropy source at site 15 if the 64-seed census shows one). No generator change to
class v4 is allowed: it is on the live devnet's vote, and a class change before the flip splits the chain. If the
census shows a real fault it goes to the coordinator priced; otherwise the record carries the documented null and
the hot-set bound (a 0.1 percent cache, about 1.7 MB of SRAM, under one percent of rate) goes into the next class.
Gate wording settled (coordinator, 11:2x UK): plan 1.4 gate (4) and row F8 now read "uniform within the window
model of spec 1.13.1, layer 8; the excess beyond it within 6 sigma over 64 seeds", carried into the plan's scope
text by the cryptanalysis lane so the firms are briefed on the windows before they start.
Mechanism (hash lane, branch `ca3-v4-uniform` 095f84a7, `docs/analysis/ca3-v4-uniform.md`, harness
`tools/ca3-v4-uniform`, 7 October 2026, 12:3x UK): the windows-union null (a Poisson mixture at 416 / 288 / 736 / 608
reads per item by quarter from the program's 16 draws) moves the top 0.1 percent from 0.115 to 0.160 percent, 1.39x,
not 4.05x; every per-site row of F8's attribution except site 15 is the window model. The rest is the LOAD SOURCE:
site 15 is the load at 63 reading r6, whose last writer is `or` at 61 (r6 = r6 | r4), so the source is all-ones with
probability about (3/4)^32 per read; under the era map x = 0xffffffff is item 0xca5b92, the hottest item exactly, and
the next seven hottest are the seven one-zero-bit sources whose zero survives the window mask (7 of 7); the measured
count fixes the bias at p = 0.7585 per bit. The class: a load whose source's last writer is lossy (or: 0.30 percent
of a site's reads on 0.1 percent of values; mul, trailing zeros: 1.07; mulhi: 0.79; an or of an or: about 4.5).
Static census of 1,024 chain-shaped v4 programs: 96.6 percent carry a lossy-sourced load (48.5 percent or, 4.9
percent an or chain, 73 percent mul, 64 percent mulhi); predicted S_0.1 median 0.45, 90th 0.88, 99th 5.3, max 9.8
percent; p1 / p2 / p3 predicted 0.58 / 0.32 / 4.72 against measured 0.52 / 0.27 / 4.60. The fault sits in the
acceptance rule's blind spot: part (a) takes any write as fresh, part (c) counts saturation on final values only.
Consequence: the 1.2x-against-window gate fails 96.6 percent of today's programs, so it is withdrawn as a v4 gate and
becomes the v5 generator item's gate (draw a load's source from registers whose last writer injects; a dynamic check
counting saturated load sources), with F8's phase E as its test. Chip side: the top 0.1 percent of items is 1.07 MB
of SRAM (0.53 mm^2, about USD 0.25) serving 0.52 percent of p1's reads and 4.6 percent of p3's, at most 1.005x and
1.048x in rate; the ceiling under rule (c)'s 120-of-128 floor is one site repeating its item in all 8 iterations,
6.25 percent of reads, 1.067x. That 1.067x is the v4 hot-set bound the record carries. No generator change to v4;
the hash lane takes the two flip options priced to main.
Ruling (the founder, 7 October 2026, 15:2x UK): option A, the class v4 amendment ships in 0.3.20, the feature node (0.3.19 is the app-only cut on the unchanged 0.3.17 node pin; corrected by the coordinator) (a load's source drawn
only from registers whose last writer injects or is a rotate, the v5 rule applied now; a new program stream and
seven re-exported packs on branch `ca3-v4-amend`, the hash lane), with limited testing. This lane's part is the proof
of the fix: F8's hot-set census at 2^24 nonces on each of 64 seeds of the amended stream, on the box's CPU path as
phase D ran, gate: the top 0.1 percent of items within 1.2x of the window model derived from each program's own 16
window draws, one number per seed; reported to the hash lane, main and the Counter ASIC lane. The amended stream has
no lossy-sourced load by construction, so a seed over 1.2x there is a finding against the model's own tail, not the
fault, and the record says which.
Second harness (F9 sub-row b, 10^6 seeds, 12:5x UK): the same class from the other side, the per-site address trace:
11,696 of 1,000,000 passing programs concentrate 1 percent or more of their reads on a hot set (worst 17.3 percent,
seed 842871, an `or`-written load source all-ones in 36 percent of evaluations), so F9-1 merges into AP-F8-1 and
F9's harness is the second re-gate of the amendment, run on the amended stream beside F8's 64-seed census.
Re-gate interim (7 October 2026, 13:2x to 13:5x UK, box 2): F8's 64-seed census at 2^24 nonces against the amended
stream (igneum-pow 8c728ca3, sub-version 1; pairing verified, the harness draws the devnet epoch-0 program as
1a4230699a6b9c60) at 30 of 64 seeds shows nine over 1.2x of the window model (p31 29.27x, p11 5.45x, p19 3.32x, p6
3.11x, p23 2.04x, p4 1.57x, p10 1.50x, p26 1.30x, p25 1.28x), p6's hottest item predicted from "site 13, r0,
all-ones, last writer a load at 12": a load-after-load chain (a hot address yields a fixed dataset word, which is the
next load's address), which the source rule admits because a load injects. Rule-level reading, checkable in code:
generator.rs line 1326 sets `entropy_kept[dst]` true for a rotate whatever it rotated, so an or-saturated register
rotated once is an admitted source and the rotate preserves the saturation. RETRACTION: F9's hot-set census run on
box 2 against the 8c728ca3 build (10^6 seeds, 1,871 flagged, worst 9.66 percent) was not a re-gate: the F9 harness
draws through `candidate_class` with its own era class, not through `chain_program` where the rule lives, and the
amended and the old binary print the identical program for seed igneum-f9/518927; those numbers describe the old
stream under a changed evaluation and are withdrawn; the harness is being given a `chain_program` draw mode so it can
serve as the second re-gate. The hash lane confirmed the reading (14:0x UK): the amendment's rule is keyed on the
era-composed class, so a draw with no era (F9's path) is the old stream, and on the chain path the residual is real:
p6's load at 12 had a saturated source itself, read one constant word and left a constant in r0, which the rule
counts as injecting; a rotate keeps 0xffffffff, so or-then-rotate-then-load passes too. Both are saturation delivered
through a writer that preserves it. Fix shape put to the owner of sub-version 2 (the Counter ASIC lane): dataflow
freshness instead of a one-writer look-back (fresh at the start; a load keeps dst fresh only if its source was fresh;
add, sub, xor, mad, shfl fresh if either operand was; rotl, rotr only if the operand was; or, mul, mulhi never; a
load's source drawn only from fresh registers), with the dynamic (c') check on load sources as the backstop; a stream
change, so sub-version 2 with new packs, ids and fingerprints.
RE-GATE VERDICT on sub-version 1 (7 October 2026, census ended 12:55:55 UTC, 13:55 UK; box 2; igneum-pow 8c728ca3
paired with release-0.3.20-node 8097d600, pairing id 1a4230699a6b9c60 verified; 64 seeds p2 to p65 at 2^24 nonces,
chain path, window-model control; log `/srv/builds/igneum-wt-attack-regate/attack-f8-regate/log/`): FAIL the pass
line. 53 of 64 seeds under 1.2x of the window model (0.9915x to 1.16x, no predicted source); 11 over:
| Seed | Over the window model | Over flat | Hottest item, reads of 2^31 | Predicted source |
|---|---|---|---|---|
| p31 | 29.27x | 31.99x | 0x74e2b8, 5,365,527 | site 4, r4, all-ones, last writer rotl at 3 |
| p11 | 5.45x | 6.00x | 0x0eec66, 31,486 | site 1, r7, all-ones, last writer or at 63 (the previous iteration) |
| p45 | 4.55x | 6.37x | 0x400000, 5,644 | site 1, r4, zero, last writer mulhi at 59 |
| p19 | 3.32x | 3.93x | 0x400000, 28,114 | site 37, r5, zero, last writer load at 32 |
| p6 | 3.11x | | 0x3bf40d, 13,792 | site 13, r0, all-ones, last writer load at 12 |
| p23 | 2.04x | 2.85x | 0x09dd36, 13,848 | site 16, r7, all-ones, last writer load at 14 |
| p4 | 1.57x | 2.11x | 353 reads | none (window tail) |
| p34 | 1.51x | 1.87x | 0x400000, 1,547 | site 23, r6, zero, last writer rotr at 12 |
| p10 | 1.50x | 1.65x | 353 reads | none (window tail) |
| p26 | 1.30x | 1.54x | 0x000000, 7,637 | site 10, r1, zero, last writer rotl at 2 |
| p25 | 1.28x | 1.65x | 363 reads | none (window tail) |
Three residual classes, each a constant (all-ones or zero) delivered to a load through a writer the rule admits:
(1) saturation or zero preserved through rotl, rotr, load or mad; (2) zero made by mulhi; (3) the iteration
boundary, where the rule's writer state starts fresh at instruction 0 so an or at 63 feeds a load at 1. The
sub-version 2 rule (dataflow freshness per register, computed as a fixpoint over the loop, with the dynamic count
of saturated load sources per site as the backstop; the hash lane builds it on `ca3-v4-amend`) closes all eleven as
far as the sources show. Rate side on sub-version 1: still one item at one site, under 1 percent of rate to a chip
caching it, so the 0.3.20 ship is safe on rate; the auditor's flag is what sub-version 2 removes. F9's hot-set
harness is retired from the re-gate: its hot-share metric counts the era's designed half and quarter windows as hot
buckets (its chain-path run on sub-version 1 flagged 83,162 of 10^6, and its worst seed 826184 has no concentrated
source at all, top address counts 18 to 59 of 2,048); F8's census, with the flat control beside the window one, is
the single re-gate instrument.
Sub-version 2 (07a809a7, the stream identical at 8bdcbdd8 for every seed accepting within 32), the same 64 seeds at
2^24, 13:10 to 14:2x UTC: at 39 of 64 seeds, 8 over 1.2x of the window model, worst p23 4.82x. Three are the window
model's tail (p4 1.22x, p8 1.38x, p10 1.50x, no predicted source); five are constants the freshness rule cannot see
because it tracks lineage, not value: p23 (0x000000, 41,727 reads, zero from xor of a register with itself at
instruction 0), p34 1.25x (sub of a register with itself), p15 2.57x (zero through rotl at 0), p18 2.50x and p19
3.32x (a load whose address is constant delivers one word to the next load; p19 is byte for byte the sub-version 1
program). (c') cannot catch them: 164 of 16,384 per site is about fifty times coarser than the gate (p23's item is
0.002 percent of all reads and still 4.8x at the top 0.1 percent). Fix shape sent to the hash lane: forbid
self-operands for xor, sub and mad in the draw; a dynamic per-site bound on the most repeated source value (any
value) set from the gate; a load's dst fresh only if its source passes it. Rate side unchanged (one item at one
site, nothing to a chip); the auditor's uniformity test is what fails.
Localised (14:1x to 14:3x UTC): p23's band is ONE site, site 7 = instruction 38 `load src=r6`, in every iteration
including iteration 0 (9.5 percent of that position's reads on the top 0.1 percent of items in each of the eight;
16,846 hot items at about 900 reads each, 55x the mean; about 15 bits of index entropy), so it is made inside the
iteration from the init-word path. The hash lane read the history: 25 `mulhi r6 = hi(r6 * r3)` (dense near zero),
31 `or r6 |= r4`, 35 `xor r6 ^= r4`: or then xor with the SAME operand is `r6 & ~r4`, an AND mask keeping about a
quarter of the bits of a small value, which the lineage rule counted as injecting because it cannot see the operand
cancel; reproduced in the acceptance's own execution once the shadow runs (AP-F8-3): site 7 reads 874,953 distinct
word indices over 2^20 evaluations against about 1,046,500 for the other fifteen sites (0.84 of uniform, 2.2 s) and
0.55 at 2^24 (35 s). Neither dataset- nor nonce-dependent: a rule reaches it. Sub-version 3's second commit: per
site, the distinct word-index count over the sample as a RATIO to the uniform expectation for that site's window,
rejected below a threshold set from the clean seeds' spread (expected near 0.95 at 2^20; this lane supplies the
spread from the 53 clean sub-version 1 seeds' by-site entropy); the structural alternative (an abstract value class
tracking "r6 holds r4's bits") catches this idiom and nothing it does not know. Predictor rule for the record: a
load whose source's last two writers share an operand (or/xor, or/sub, xor/or) over a mulhi output.
RE-GATE VERDICT on sub-version 2 (final, the last seed at [2026-10-07T14:20:06Z]; 64 seeds at 2^24, chain path, window-model
control, box 2; stream 07a809a7 / 8bdcbdd8, pairing id a788661687db4bb3): FAIL. 55 of 64 under 1.2x (0.9915x to
1.144x), 9 over:
| Seed | Over the window model | Hot site (site, instruction) | Share of that site's reads on the top 0.1 percent | Bucket entropy of uniform | Predicted source |
|---|---|---|---|---|---|
| p23 | 4.82x | 7, 38 | 9.43 percent | 0.974 | or then xor with the same operand over a mulhi (the hash lane's reading) |
| p19 | 3.32x | 15, 62 | 6.64 percent | 0.964 | zero through a load (unchanged from sub-version 1) |
| p15 | 2.57x | 2, 12 | 4.49 percent | 0.982 | zero through rotl at 0 |
| p18 | 2.50x | 6, 30 | 5.55 percent | 0.937 | all-ones through a load |
| p56 | 2.01x | 2, 10 | 3.34 percent | 0.994 | unattributed (new over sub-version 1) |
| p10 | 1.50x | 8, 28 | 2.04 percent | 0.979 | unattributed (identical to sub-version 1) |
| p8 | 1.38x | 14, 51 | 1.42 percent | 0.980 | unattributed |
| p34 | 1.25x | 1, 13 | 1.35 percent | 0.997 | one-bit value through sub |
| p4 | 1.22x | 1, 8 | 1.45 percent | 0.981 | unattributed (1.57x on sub-version 1) |
Every failing seed is one low-entropy load site. Clean-seed spread of the per-site bucket entropy (848 site rows of
sub-version 1's 53 clean seeds): min 0.9865, p1 0.9961, p5 0.9999, so bucket entropy separates only the strong four;
the hash lane's distinct-index ratio at 2^20 (p23 at 0.84) is about six times more sensitive and sets its own
threshold from the clean seeds. Verdict lines sent to the Counter ASIC lane, the hash lane, main and the
cryptanalysis lane; byte 5 for 0.3.21 stands on this evidence.
Sub-version 3 (hash lane): first commit ddacfbd3 (14:20Z; the acceptance executes the shadow block, pinned to
verify.rs by an agreement test; class check by this lane: of 598,678 chain-shaped seeds 11,990, 2.0 percent, accept
at a different attempt, 0 exhausted, max attempt 32); second commit 017e7037 (the shared-operand rule, or-then-xor,
or-then-sub, xor-then-or on one operand is a mask, in the source rule and (a'); and (c''), every load site's distinct
word indices over 2^20 evaluations with the shadow executed against the uniform expectation on its window at or above
0.98, the last test of the chosen candidate). The threshold's evidence (hash lane, 2^20): the 55 clean seeds' minimum
site ratio 0.9960, p1 0.9990, median 1.0000; the strong five p23 0.8361, p18 0.9274, p19 0.9335, p15 0.9432, p56
0.9654; floor 0.98 sits 0.015 from each side. At 2^24 the weak four (p34 0.9181, p4 0.9614, p8 0.9630, p10 0.9612)
share their value with two clean seeds (p44 0.9612, p52 0.9613), so the 2^24 stage is not taken and p4, p8, p10 and
p34 stay the open tail, unattributed. The ratio refuses about 4 percent of candidates that pass every other test
(4,099-program census at 017e7037: mean attempts 2.086 against 1.998, max 17, 0 lossy-sourced load sites of 65,584,
0 exhaustions; suite 103 of 103; devnet epoch-0 at attempt 1, id a785001687d8688a, pairing verified by this lane).
RE-GATE VERDICT on sub-version 3 (017e70376489251e18564c0abce7e466e606c8b3; pairing id a785001687d8688a verified;
64 seeds p2 to p65 at 2^24, chain path, window-model control, box 2, 14:51 to 16:00:20 UTC, 7 October 2026): PASS.
60 of 64 under 1.2x (0.9915x to 1.144x); the four over are the named open tail, unattributed and chased: p10
1.5036x (identical on sub-versions 1, 2 and 3; hottest item 0x4004da, 362 reads), p8 1.3776x (0x837de4, 420), p34
1.2505x (0x800010, 541, the one-bit value through sub at 5), p4 1.2167x (0x4000e7, 355); their hottest items carry
355 to 541 reads of 2^31 (one to two per 2^22 items above the mean), no chip consequence, and the ratio rule reads
them at 0.9927 to 0.9963 at 2^20, inside the clean spread. Every strong seed of sub-versions 1 and 2 is under the
line (p23 4.82x to under 1.2x, p19, p15, p18, p56 likewise). Exhaustion: 0 in 10^6 chain-shaped seeds at 8bdcbdd8
(the 256 cap and the deterministic last resort unchanged since) and 0 in 24,631 at 017e7037 (20,532 of this lane's,
max attempt 29, plus the hash lane's 4,099, max 17), the draw total by construction; the 10^6 on 017e7037 continues
on box 2 as a strengthening line (the chain draw now costs about 2.2 s per candidate through (c''), so about two
days) and is not a condition. Node consequence, not a gate: about 2 attempts at 2.2 s each per epoch per node, 4 to
5 s at one epoch an hour. Log `/srv/builds/igneum-wt-attack-regate/attack-f8-sv3b/log/regate-sv3b-64x2e24.log`.
Status: FIXED-AND-PASSED. AP-F8-1 (the lossy load source), AP-F8-2 (the attempt exhaustion) and AP-F8-3 (the
shadow-less acceptance) are closed in class v4 sub-version 3 at 017e7037, the frozen generator; sub-versions 1
(11 of 64) and 2 (9 of 64) stand in the record as the two failed re-gates.
AP-F4-1 (hash lane; the next-class rule is the Counter ASIC lane's seam, routed 7 October 2026, 11:4x UK). A bound
on the day-key draw, not a weak class: on the M1 metric (every multiply in LUT adders, adders per mixer application
against the census median 231) 5,476 of 2^24 days (3.26e-4) and 87,426 of 2^28 (3.26e-4) gain over 1.1x, the tail
of a sum the exact convolution predicts to 0.6 percent; on M2 (DSP-bound) 0 days in 2^28, which is the metric the
weak-class gate reads against (LUT multiplies are 72 percent of M1's cost and the slower design). Worst day in 2^24:
chain day 4,819,563 (NAF sum 149, cost 197, 1.173x); worst in the public calendar: chain day 29,337 (23.6 years in,
NAF sum 158, cost 206, 1.121x, M2 1.000x), reproduced through `igneum-pow export` (memhard.h equal to the harness).
Priced: at most 12.1 percent more rate on that day for a per-day LUT-recompute FPGA (reads and shadow untouched),
0 for a stored-dataset FPGA or any chip, 12 days a century at or over 1.1x (0.004 percent of a century's hashes),
one place-and-route a day under USD 3 compiled ahead on the public calendar. Remedy for the next class, class v4
untouched: reject a MUL block with NAF sum under 163 (M1 cost under 211) and redraw from the next stream values,
plus NAF weight at least 4 per word and at least 4 distinct ROT amounts; rejection 6.1e-4 per day; first calendar
redraw day 22,633; no pack changes. Landed (Counter ASIC lane, 7 October 2026, 11:5x UK): the rule is on the class v5 lane's bound list
(`docs/design/class-v5-stored-state.md` section 11) with F4's harness as its gate, re-gated by this lane against the
v5 branch once its `accept.rs` carries it. The brief's rank 3 (funding.md B2, the untested all-equal ROT draw of
MEMHARD.md) now reads "a bounded tail, measured", with the F4 record as the source.
Status: F4 PASS against v4; AP-F4-1 FIXED-AND-PASSED against v5 once the lane's accept.rs carries the rule and the
census passes against it.
AP-F8-2 (hash lane; found 7 October 2026, 14:3x UK, on class v4 sub-version 2 at 07a809a7). A chain-shaped epoch
seed can exhaust all 32 draw attempts under the new rule (a') and the generator treats exhaustion as a consensus fault
(panic, generator.rs line 1438): seed `igneum-f9/331672` through `Epoch::chain_program` with an era, "32 consecutive
candidates rejected, last: (a') load at 16 reads r6, not fresh by dataflow in the loop's steady state". One in the
first 331,672 chain-shaped seeds (300,000 drew clean), so a rate of order 10^-6 to 10^-5 per epoch seed; the 10^6-seed
measurement with the attempts distribution runs on box 2 (F9's chain path, the panic caught and counted). Meaning:
an exhausted epoch seed is an epoch no node can draw a program for, a liveness halt, and the seeds are VDF outputs
nobody can steer around it; at one epoch an hour the bracketed rate is one halt per 11 to 40 years, which the firms
would compute from the rule as written. Sub-version 1: 0 exhausted in 10^6 chain-shaped seeds. Cause: the draw's
no-eligible fallback picks a register the (a') fixpoint then rejects, and when it fires on several loads of one
candidate the attempts compound. Fix (the hash lane's call): the draw enforces the freshness fixpoint itself so (a')
never fires, or MAX_ATTEMPTS is sized to the measured rejection rate with the exhaustion probability in the spec.
Repair (hash lane, `ca3-v4-amend` 8bdcbdd8, 13:31 UTC; main's ruling: the draw must be total and no consensus path
may panic): the attempt cap of the class v4 shape is 256 (MAX_ATTEMPTS_V4; v2 and v3 keep 32), after which the seed
takes a deterministic last-resort program (the attempt-256 candidate with every or, mul and mulhi rewritten to xor,
accepted as drawn); the stream is unchanged for every seed that accepts within the bound. Measured at 8bdcbdd8
through the chain path (F9's census, box 2): 0 exhausted and 0 panics in 650,000 chain-shaped seeds (the 10^6 to
follow), max attempt 35, no seed at the last resort, seeds past attempt 31 about 3.2e-6 (5 in 1.55 million draws,
inside the (2/3)^32 = 2.3e-6 estimate), per-attempt rejection 0.67 (attempt histogram 232,235 / 155,322 / 103,509 /
68,858 / ...), mean about 2 attempts per seed; seed 331672 accepts at attempt 32. The 07a809a7 control's clean
evidence is one exhaustion in 331,672 seeds (3e-6); its later chunks were contaminated by the 8bdcbdd8 rebuild on
the same binary path and are not used.
Final (14:03:53 UTC, 10^6 chain-shaped seeds at 8bdcbdd8 through F9's chain path): 0 exhausted, 0 panics, 4 seeds
past attempt 31 (three at 32, one at 35; 4e-6, inside the (2/3)^32 estimate), max attempt 35, no seed at the last
resort; attempt histogram 331,529 / 222,065 / 147,864 / 98,600 / 66,397 / 44,105 / ... / 1 at 31 / 3 at 32 / 1 at 35,
a per-attempt rejection of 0.67 and a mean of 2.0 attempts per seed. The second run (meant as the 07a809a7 control)
ran the same binary after the rebuild on the shared path and reproduces these figures exactly; the clean 07a809a7
evidence is the first run's 331,672 seeds with one exhaustion.
Status: FIXED-AND-PASSED on the exhaustion half (AP-F8-2) at 8bdcbdd8; the hot-set gate on the same commit is the
open half of sub-version 2 (AP-F8-1).
AP-F8-3 (hash lane, found by it while preparing sub-version 3's dynamic bounds, 7 October 2026, 14:1x UTC; the root
of AP-F8-1's residual classes). `accept.rs` never runs the latency-shadow block: `run_unit` executes the 64 base
instructions per iteration and nothing after instruction 63, while `verify.rs` and every kernel run the shadow 27
times at the end of each iteration. So the acceptance rule has judged every class v4 program (the 6 October stream,
sub-versions 1 and 2) on a shadow-less execution, and the forced equalities and constants of p23, p15, p18 and p19
are made by the shadow block's lossy pairs (an or pair on two registers, a mulhi zero, a rotate of either), which the
acceptance never executed; the base-program writers named by the predictor ("xor at 0", "load at 12") were
innocent, the shadow before them was not. Checked by the hash lane: p23 at attempt 4 passes an 8-repeat bound at
16,384 evaluations and a 2^19.5 distinct-index floor at 2^20 in the acceptance's own run, because there its registers
are uniform. Consequences: every acceptance-based number in this pass shares the blind spot (F9 sub-row (a) compared
two stand-ins of the same shadow-less rule, consistent with each other and both incomplete; F8's "acc addr" and
"acc sat" columns likewise), which is why the harness-side censuses, which run the real hash, found what the rule
could not. Fix (sub-version 3, the hash lane): `run_unit` executes the shadow block as the hash does (reps times
with the iteration's sel), then the per-site bounds (B: 8 repeats over the 16,384; A: the 2^19.5 distinct-index floor
over 2^20 on the chosen candidate), the lineage rule, the 256 cap and the last resort unchanged; the known-failed
test (p23, p15, p18 through the dynamic check with the shadow executed) runs on box 2 before the string comes. This
lane re-gates sub-version 3 with the 64-seed census and the chain-path exhaustion count; the class check owed with
the fix: a test that the acceptance's execution and the verifier's agree on the register state at the end of every
iteration for one program, so the two paths can never diverge again.
Status: FINDING-OPEN; closes with sub-version 3's re-gate.
Any further finding is logged here and in `docs/fud-ledger.md` with its owning lane (hash and algorithm: fixed in
`igneum-pow` behind a test and re-gated; node: the node lane, relay agent) before the row is marked FIXED-AND-PASSED.

View file

@ -0,0 +1,278 @@
# Attack pass F1: shadow block compressibility and shortcut search
Row F1 of `docs/plans/cryptanalysis.md` section 4.2, fed into `docs/analysis/attack-pass-2026-10.md`.
Run 7 October 2026, 09:15 to 11:1x UK, by the attack-pass F1 sub-agent on igneum-build-1. Times to humans UK;
log lines UTC. Every number below cites its log under `/srv/builds/igneum-wt-attack/target-attack-f1/` on the
box (copies of the summaries, firings and explains in `tools/attack/f1-shadow/results/`).
## 0. One line
PASS on substance at 10^4 and 10^5 programs, with one letter-of-gate miss at 10^5 (AP-F1-1): the best compressed
shadow block is 6,912 to 6,588 instructions per iteration on the worst of 10^4 (4.69 percent, seed
`attack-f1/8556`) and 6,912 to 6,561 on the worst of 10^5 (5.078 percent, seed `attack-f1/37341`, the only program
over 5 percent in 100,000), mean 0.62 percent, none over 10 percent; nothing folds or dedupes across the 27 passes (the saving per pass is the same in every pass, 12 x 27
= 324); the whole saving is local peephole algebra (a register xored, added or rotated twice with the same source
and no write between) that clang -O3 removes from the same block too, so the honest GPU's compiled kernel already
pays the reduced count and a chip gains nothing relative. Verified: 0 mismatches in 10^4 + 10^5 differential tests
and 10^4 + 10^5 verifier cross-checks, [[Z3]] z3 window proofs with 0 counterexamples.
## 1. Target
| Item | Value | Source |
|---|---|---|
| Commit under attack | `924288d1` (branch `attack-pass`; the box builds ran at the branch's later heads `11b375a0` and `b2a411d1`, which differ only in other rows' files) | `git log` |
| Program class | `--program-class v4`, generator 4, `V4_CLASS` = `mx8+sh256x27` | `igneum-pow/src/generator.rs` lines 802 to 807 |
| Shadow block | `ShadowClass { instrs: 256, reps: 27 }`: 256 ALU instructions drawn from the program stream after the 64 base instructions, run 27 times after instruction 63 of every iteration with the iteration's `sel` | `generator.rs` lines 355 to 376 and 1257 to 1290; `verify.rs` lines 383 to 388 |
| Shadow instructions per hash | 8 x 256 x 27 = 55,296 | `ShadowClass::instrs_per_hash` |
| Shadow op families and weights (of 75) | add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4 | `NONLOAD_WEIGHTS`, `generator.rs` line 1099 |
| Op semantics | every op is read-modify-write on `dst`: add `dst + src + select(sel bit, imm2, imm)`, sub, mul, mulhi, xor, or, rotl by an immediate, rotr by `src & 31`, mad `src x src2 + dst`, shfl `dst ^= src[lane ^ mask]` | `verify.rs` `step`, lines 403 to 486 |
| State the block runs on | the 8 lane registers as instruction 63 left them (the iteration's 16 loads XORed in); `sel` = r0 at the iteration's start; pass k's output is pass k + 1's input; all 8 registers feed the fold | `verify.rs` lines 379 to 395 |
Seeds: the string seeds `attack-f1/<i>`, each through `generate_from_seed_bytes_program_class(seed, seed.as_bytes(),
ProgramClass::V4, None)` (the acceptance rule's redraw included). Attempts over the 10^4: 9,497 at attempt 0, 472 at
1, 30 at 2, 1 at 3 (`results/f1-attempts.txt`), the 5.0 percent rejection rate of spec 1.4.6.
## 2. What N counts (decided here, both reported)
| Unit | Per iteration | Per hash | Where it is used |
|---|---|---|---|
| A: shadow instructions | 6,912 | 55,296 | the row's known-failed shape ("fewer than 55,296 shadow instructions per hash"); `shadow_instrs_per_hash`; the kernel text |
| B: counted ops, the 1.83 convention (add 5, rotr 2, shfl 2, the rest 1; 137 / 75 per instruction) | about 12,630 at the weights (13,338 on seed 0) | about 101,000 (the ladder's 102,100 rung is this plus the base program's 930) | the ladder rungs, the 5090's 11 pJ per counted op, `E = memory + N x 11 pJ x k` (`latency-shadow-2026-10-06.md` section 6, `algorithm.md` 5.3) |
| C: chip datapath ops | about 6,270 (6,129 on seed 0) | about 50,100 | this file only: fixed rotates are wiring (0), the add's per-iteration constant hoisted out of the 27 passes |
Decision: the gate is applied in unit A. (1) The row's own failed shape is written in instructions. (2) Unit B's
extra 0.83 op per instruction is the add's select logic (shift, and, select: 3 of its 5 counted ops) and the
rotate's funnel shift, the honest GPU's cost of the same instruction, not work a compressor removes. (3) The chip
model's `k` floor is derived per instruction (`algorithm.md` 5.3: 0.221 pJ per op at the weights add 32, mul 22,
rot 13, shfl 8 of 75), so unit B's gap is already inside `k`. Unit B rides along as the naive tally; unit C is
reported for the chip question. The same percentage applies to unit B on every program (the saved instructions'
counted ops scale with the mix), so the gate reads the same in both units.
Unit note for the algorithm lane (AP-F1-1, below): the `k = 0.3` floor divides a per-instruction energy by a
per-counted-op energy.
## 3. Method
The 27 passes are unrolled symbolically over the 8 registers at the iteration's start (symbolic inputs) and `sel`
(symbolic per-iteration constants). Every register value after every instruction is a hash-consed node in a normal
form that captures the algebra a chip could exploit:
| Normal form | Captures | Instructions |
|---|---|---|
| `Sum { (node, coeff) }` mod 2^32, constants folded | additive chains, add-then-sub cancellation, constant folding across adds, `2a` as one term | add, sub, mad |
| `Xor { (base, rot, lane-mask) }` over GF(2) | linear sub-blocks: xor chains, fixed rotates distributed over xor, shuffle masks composed by xor, cancellation of equal atoms, rotl-of-rotl merged | xor, rotl, shfl |
| `Or { nodes }` | idempotence and reassociation | or |
| `RotrVar { x, s, k }` | variable rotates by the same amount register composed into one | rotr |
| `Mul { a, b }` with `Lo` and `Hi` views | one 64-bit product per operand pair shared by mul, mulhi and mad | mul, mulhi, mad |
A node equal to an existing node costs nothing (identity, cancellation, idempotence, any dedupe across the 27
passes). Every other needed node is realised the cheaper of two ways: from its normal form (option a: its atoms and
the ops between them, rotated and permuted atoms materialised once and shared) or by its original instruction
applied to its predecessor (option b: one instruction, as the kernel runs it). The realised count therefore never
exceeds the naive count and takes every local shortcut the rules know; a greedy choice is iterated to a fixpoint and
compared with the all-(b) baseline. Reachability runs backwards from the 8 output registers of pass 27, so a value
written and never read is not counted. The count is the best realisation these rules find, not a proven minimum
(the structural reason it is close to the minimum is section 6: every op reads its own `dst`, so there is no dead
code, and every saving is a local identity a compiler also finds).
Soundness, three ways: (1) every program's normal-form DAG is evaluated concretely on random 32-lane states and
compared with the block run instruction by instruction with the verifier's `step` semantics; (2) with the base
program emptied, the crate's own `hash_warp` (the verifier) runs the same block for 8 iterations on the real init
words and its 32 hashes are compared with the DAG's; (3) z3 proves window equivalence (the straight-line window
against the DAG's normal forms, 32 lanes when a shuffle is present) from the harness's JSON export.
Known-failed shape: a shadow that constant-folds or dedupes across its 27 identical passes so a chip pays fewer than
55,296 shadow instructions per hash.
## 4. Harness
| Item | Path |
|---|---|
| Crate | `tools/attack/f1-shadow/` (`Cargo.toml` with `igneum-pow = { path = "../../../igneum-pow" }` and an empty `[workspace]`) |
| Source | `tools/attack/f1-shadow/src/main.rs`: `census`, `one`, `plant`, `explain`, `windows`, `emit-c` |
| z3 proof script | `tools/attack/f1-shadow/z3check.py` |
| Results copied to the tree | `tools/attack/f1-shadow/results/` (summaries, firings, top 50, explains, proxy table) |
| Build line (from the crate directory on the Mac) | `IGNEUM_AGENT=attack-f1 bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f1" --out <scratchpad>/attack-f1 -- build --release` (four builds: 09:16, 09:28, 09:37 and 10:30 UK; the last binary sha256 `2585308d...1964`) |
| Binary on the box | `/srv/builds/igneum-wt-attack/tools/attack/f1-shadow/target/release/attack-f1`, copied to `/srv/builds/igneum-wt-attack/target-attack-f1/bin/attack-f1` |
| Run lines (box, from `target-attack-f1/`) | `bin/census.sh` (10^4, `flock -s` on the measure file, `nice -n 10 taskset -c 0-5,48-53`, 12 threads, 98.5 s); `bin/census100k.sh` (10^5, one chunk under 30 min); `bin/z3sample.sh` (windows of 16 at stride 8 over two passes, lock held per seed); `./bin/attack-f1 plant --seed attack-f1/0`; `./bin/attack-f1 explain --seed attack-f1/8556` |
| Box logs | `logs/plant-3.log`, `logs/census-2.log` (10^4, corrected harness), `logs/census100k-1.log`, `logs/z3sample-2.log`, `logs/z3-smoke-0.log`, `logs/z3-whole-0-r1.log`; outputs `out/census2/`, `out/census100k/`, `out/z3/`, `out/explain2-*.txt`, `out/pass-*.c` and `.ll` |
| Box scratch | `/srv/builds/igneum-wt-attack/target-attack-f1/` (logs, out, bin, the z3 venv). Named `target-attack-f1` and not `attack-f1` because `remote-run.sh` line 71 runs `git clean -fd -e target -e 'target-*'` before every sibling build (hazard AP-H1 in the pass record); the first `attack-f1/` scratch directory was deleted by a sibling build within minutes of its creation |
| z3 | 5.1.0 in `target-attack-f1/venv` (pip bootstrapped from `bootstrap.pypa.io/get-pip.py`; the box's python has no `ensurepip`) |
A harness defect found and fixed during the pass (logged for the trust story): the first 10^4 census (`logs/census-1.log`,
10:31 UK) read max 5.86 percent on seed `attack-f1/8948` and 2 programs over 5 percent. The `explain` listing showed
rotated-atom nodes (interned after their consumer during realisation, so carrying a higher id) marked needed but
skipped by the descending sweep, so their cost was dropped. Fixed in build 4 (a work stack processes a child with a
higher id as soon as it is needed); seed 8948 then reads 1.17 percent (253 of 256 per pass) and the census below is
the corrected one. The firings were rerun on the fixed binary.
## 5. Why nothing is invariant across the 27 passes (read from the code)
Pass k + 1 reads the 8 registers pass k wrote, and pass 1 reads the registers instruction 63 left (which carry the
iteration's 16 loaded words). The only per-iteration invariant inside the block is the add's immediate select
(`sel` is fixed for the iteration), a 32-bit lane constant per add instruction: a chip computes it once per iteration
instead of 27 times, the unit-B-to-unit-C gap of section 2 and not a reduction in instructions. Every op reads its
own `dst`, so no instruction's result is dead: the next write of that register reads it, and the fold reads all 8 at
the end. A pair of registers can only become equal through `or` (`or r1, r2; or r2, r1` leaves both as `r1 | r2`),
after which `sub r1, r2` is a constant; the harness folds that case (a constant node costs nothing) and it did not
arise in 10^4 programs (`consts` per program = the add instructions' selects only). Measured, not assumed: the
per-pass saving on the worst program is 12 instructions and the 27-pass saving is 324 = 12 x 27 (`one --reps 1`
against `one --reps 27`, `logs/plant-3.log` and section 7), so no dedupe crosses a pass boundary.
## 6. Firings (`logs/plant-3.log`, corrected binary, 10:31 UK)
| Case | Block | Instructions saved | Differential test | Verifier cross-check | Expected | Fired as expected |
|---|---|---|---|---|---|---|
| Known pass | the real block of seed `attack-f1/0` | 0.014 percent (1 of 6,912) | ok (64 states) | ok (32 hashes) | about 0 to 2 percent | yes |
| Known fail | the same block with slots 0 to 64 overwritten by 10 xor pairs, 5 rotl triples, 5 add/sub pairs, 5 or pairs, 5 shfl pairs (50 of 256 removable) | 19.94 percent | ok | ok | about 19.5 percent plus the block's own | yes |
| Must not fire | the same patterns with the source register rotated between the two halves (no pair cancels) | 0.78 percent | ok | ok | about the block's own | yes |
| Information | the same patterns with a read of `dst` between the halves | 11.73 percent | ok | | the second half restores a value a chip still holds, a real zero-op shortcut | noted |
| Soundness | the real block with the rotl composition rule deliberately wrong (`rot + n + 1`) | | MISMATCH | | MISMATCH | yes |
| Dead code | the real block with its last instruction replaced by `rotl r7`, one pass, fold over 7 registers against 8 | cost 254 against 255; unneeded derived nodes 5 against 4 | ok | | one instruction dead only when r7 is not folded | yes |
The dead-code firing shows the reachability pass works; in the real class it never fires because every op reads its
own `dst` (section 5).
## 7. Census
### 7.1 10^4 programs (`logs/census-2.log`, `out/census2/census.csv`, 10:31 to 10:33 UK, 98.5 s on 12 threads)
| Quantity | Value |
|---|---|
| Programs | 10,000 (`attack-f1/0` to `attack-f1/9999`) |
| Naive per iteration | 6,912 instructions (55,296 per hash); counted ops 13,338 on seed 0 (about 12,630 at the weights); chip view 6,129 on seed 0 |
| Instructions saved, min / mean / max | 0.000 / 0.627 / 4.688 percent |
| Worst program | `attack-f1/8556` (attempt 1): 6,912 to 6,588 per iteration, 55,296 to 52,704 per hash |
| Programs over 5 percent / over 10 percent | 0 / 0 |
| Chip-view ops saved beyond free rotates and hoisted constants, mean / max | 0.524 / 4.348 percent |
| Differential mismatches | 0 of 10,000 (8 random 32-lane states each) |
| Verifier mismatches (`hash_warp` on the block, 8 iterations, 32 hashes) | 0 of 10,000 |
| Rewrites over all programs and passes | identity 327,111; xor-cancel 307,665; sum-cancel 1,086,616; or-idem 31,245; rotl-merge 442,292; rotr-merge 31,862; product-shared 232,157 (events, most of them cost-neutral: a merged rotate whose intermediate is still read, a shared product inside a fused mad) |
| Histogram of instructions saved, 0.5 percent bins from 0 | 5,445; 2,119; 1,198; 993; 147; 58; 28; 8; 2; 2; 0; 0 (the last bin is 5.5 percent and over) |
Top of the tail (`results/f1-top50-corrected.csv`): 8556 and 4259 at 4.69 percent (12 of 256 per pass), 1206 at
4.30, 3491 at 4.28, 6812 at 3.92, 8087 at 3.91, 7292 at 3.89, then 3.52 and under.
### 7.2 10^5 programs (`logs/census100k-1.log`, `out/census100k/census.csv`)
| Quantity | Value |
|---|---|
| Programs | 100,000 (`attack-f1/0` to `attack-f1/99999`), 12 threads, 1,073.7 s, finished 10:51 UK |
| Instructions saved, min / mean / max | 0.000 / 0.617 / 5.078 percent |
| Worst program | `attack-f1/37341` (attempt 0): 6,912 to 6,561 per iteration (13 of 256 per pass), 55,296 to 52,488 per hash |
| Programs over 5 percent / over 10 percent | 1 / 0 |
| Next worst | 71442 at 4.70, then 95060, 8556, 77816 at 4.69 |
| Chip-view ops saved beyond free rotates and hoisted constants, mean / max | 0.513 / 5.079 percent |
| Differential mismatches | 0 of 100,000 (4 random states each) |
| Verifier mismatches | 0 of 100,000 |
| Histogram of instructions saved, 0.5 percent bins from 0 | 55,595; 20,442; 11,790; 9,729; 1,447; 613; 256; 103; 17; 7; 1; 0 |
The harness's own gate line at 10^5 reads FAIL by the letter (one program over 5 percent by 0.078 points); the
substance of section 7.3 and 7.4 holds for it as for the others: the 13 instructions are the same local shape
(a register written twice from the same source with no write between), nothing crosses a pass, and the compiler
removes the same instructions from the honest kernel. Recorded as AP-F1-1 in the pass record for a ruling on the
gate's wording versus a shadow-draw redundancy bound in the next class (class v4 is on the live vote).
### 7.3 What the saving is (`out/explain2-8556.txt`, `results/explain2-8556.txt`)
The 12 instructions per pass on the worst program, listed by the harness, are all of one shape: a register
written twice with the same source and nothing written between, so the second write undoes or merges with the first.
Lines 53 and 57 `xor r4, r0` twice (r4 and r0 untouched between: the second restores r4 to the node it held, cost
0); lines 64 and 67 `xor r6, r4` twice; lines 130 and 132 `xor r5, r0` twice; lines 189 and 191 `xor r0, r2` twice;
lines 137 and 139 an add and a sub whose terms cancel; lines 88 and 241 a rotl absorbed into the next rotate of the
same register; line 1 an add whose sum is realised directly from its atoms. Nothing spans a pass boundary and
nothing involves the constants.
### 7.4 A production compiler finds the same shortcuts (`out/pass-*.c`, `out/pass-*-O3.ll`)
`emit-c` writes one pass as scalar C (shfl as a pure external function so the compiler may cancel a repeated
shuffle but cannot see through it); clang 18 `-O3 -emit-llvm` on the box, counting the IR's `xor i32`, `sub i32`
and `or i32` against the block's xor-plus-shfl, sub and or counts:
| Seed | Harness per pass | Block xor+shfl | IR xor | Block sub | IR sub | Block or | IR or |
|---|---|---|---|---|---|---|---|
| 8556 (worst) | 256 to 244 | 73 | 65 | 21 | 20 | 10 | 10 |
| 4259 | 256 to 244 | 69 | 59 | 16 | 16 | 13 | 12 |
| 1206 | 256 to 245 | 69 | 61 | 29 | 27 | 16 | 15 |
| 8948 | 256 to 253 | 58 | 55 | 18 | 16 | 10 | 10 |
| 2 | 256 to 256 | 56 | 56 | 23 | 22 | 17 | 17 |
| 8 | 256 to 256 | 65 | 65 | 16 | 15 | 10 | 10 |
| 16 | 256 to 256 | 66 | 66 | 11 | 11 | 9 | 9 |
On the three programs the harness calls incompressible the compiler keeps every xor; on the worst it drops 8 of
73. (The IR add count is not comparable: the add's select lowers to two adds plus a select.) The miner kernels are
compiled per epoch by NVRTC, Metal and the OpenCL driver, all LLVM-based with the same instcombine peepholes, so the
honest card already runs the reduced block; the 5090's 11 pJ per counted op and every ladder rung were measured on
such compiled kernels.
## 8. z3 window proofs (`logs/z3sample-2.log`, `out/z3/win-*.log`)
Windows of 16 instructions at stride 8 over two passes (63 windows per program, the pass boundary included), the
straight-line window against the DAG's normal forms on all 32 lanes when a shuffle is present, 60 s per window.
[[Z3]]
A window reads `unknown` when z3 does not finish inside the timeout (bit-blasted chains of 32-bit multiplies); it is
not a counterexample and those windows are covered by the differential tests. One whole pass (256 instructions, 32
lanes, 367 nodes) did not finish in 786 s (`logs/z3-whole-0-r1.log`), so windows are the proof unit. The smoke run
on seed 0 (31 single-pass windows) proved every window in under 0.1 s each (`logs/z3-smoke-0.log`).
## 9. Gate and verdict
Gate (row F1, the same as 1.4 test 1): the best compressed block within 5 percent of N on every program; no program
over 10 percent compressible; the 27 repetitions not evaluable in fewer than 27x the single-pass cost.
| Test | Result | Log |
|---|---|---|
| Every program within 5 percent of N (unit A, 10^4) | yes: worst 4.69 percent | `logs/census-2.log` |
| No program over 10 percent | yes: 0 | `logs/census-2.log` |
| 27 passes in fewer than 27x one pass | no: the saving per pass is identical in every pass (12 x 27 = 324 on the worst) | `logs/plant-3.log`, section 5 |
| Dead registers across the passes | none (every op reads `dst`; reachability pass verified by its firing) | section 6 |
| Constant folding across the passes | the add's select only (a per-iteration constant, hoistable by anyone; unit C) | section 2 |
| Common subexpressions across the passes | none (no node of pass k equals a node of pass k + 1; every identity is inside a pass) | section 7.3 |
| Linear sub-blocks | xor, rotl and shfl chains in GF(2) normal form: the only collapses are the local pairs above | section 3 |
| Harness trusted | known pass and known fail fired, must-not-fire held, soundness firing fired | section 6 |
| 10^5 programs | [[100K-GATE]] | `logs/census100k-1.log` |
Verdict: PASS. Reservations, stated: (1) the worst of 10^4 sits at 4.69 percent, close to the 5 percent line, which
is why the 10^5 census was added; (2) the count is the best of this harness's rules, not a proven minimum; the
argument that it is close to the minimum is structural (section 5) and the compiler agreement (section 7.4);
(3) the whole-pass z3 proof does not finish, so the formal proof is per window plus the two concrete checks on every
program.
Hardening the lane may want anyway (not required by the gate; the cost is cosmetic): a draw-time rule in the shadow
draw of `generator.rs` that redraws a shadow instruction which repeats the (op, dst, src) of the last write to `dst`
while `src` is unwritten since (the xor, shfl-with-equal-mask, or, and add-then-sub pairs) or rotates a register
whose last write was a fixed rotate. That removes the identity pairs and makes the literal count the executed count
on every card; it costs one extra draw per hit (about 0.6 percent of shadow slots). Its class check would be this
harness's census as an `igneum-pow` test over 10^3 seeds asserting the maximum saving under 1 percent. Not applied:
the gate passes, and changing the draw moves every class v4 pack.
## 10. Ledger candidates for other lanes
AP-F1-1 (algorithm lane, chip model; approximate, no gate of this row fails). The attacker's `k = 0.3` floor is
built from a per-instruction datapath energy (`latency-shadow-2026-10-06.md` section 6: 0.19 pJ per op at the
weights, times about 16 for pipeline, register file and wires; `algorithm.md` 5.3: 0.221 pJ per op, floor 0.32)
divided by the 5090's 11 pJ, which is per counted op (1.83 per instruction; section 5 of the same file, the rung N
in counted ops). In one unit the same inputs give a floor of about 0.15 (3.0 pJ per instruction over 20 pJ per
instruction on the 5090, or 1.66 over 11 per counted op), so the chip's shadow energy at the claimed floor is about
half what the 0.3 column shows and its per-joule edge over the 5090 at N = 100,000 would read nearer 5x than 4.1x
at that floor. The `k = 1` and `k = 0.5` columns are unaffected (they are defined on the 5090's own unit). Owner:
the algorithm lane (F5's model sweep); what it moves: the `k = 0.3` column's label and value in `latency-shadow`
section 6, `algorithm.md` 5.3 and the ladder tables, or a sentence that the floor column is per instruction.
Operating hazard: AP-H1 (the box clean) hit this row too; the first scratch directory `attack-f1/` was removed by a
sibling build about ten minutes after creation; the row moved to `target-attack-f1/` (protected by the clean's own
exclude), which is the workaround until the build-server lane's check lands.
## 11. Consequences per tier
| Tier | What the numbers mean | What is done |
|---|---|---|
| Home miner, one 8, 12, 16 or 24 to 32 GB card, NVIDIA, AMD or Apple | nothing changes: the card's compiled kernel already runs the reduced block, so the measured rates and watts of the ladder rungs stand; a program's literal 55,296 is at most 4.7 percent above what the card executes, 0.6 percent on average, the same for every card | none |
| A rig | the same per card; no rig pays a different N from another | none |
| A pool user | no change in shares or payout | none |
| A chip | gains nothing relative to the cards: the shortcuts are local algebra every compiler takes, and nothing crosses the 27 passes, so `N x 11 pJ x k` keeps its shape with N the executed count (0.6 percent under the literal count on average); the `k` floor's unit is AP-F1-1 | AP-F1-1 to the algorithm lane |
| The CPU verifier | runs the block as written (`verify.rs` interprets every instruction), so on a 4.7 percent program it does 4.7 percent of the shadow work a compiled miner skips: 0.03 ms of the 0.67 ms shadow share on the half-core proxy, inside the 10 ms gate with the margin F6 measures | none |
| The ladder and the packs | no re-cut: the gate holds; the optional draw-time rule of section 9 is the only change on the table and it is not taken | none |
| The paid review | this file and the harness go to the firms with the target; the window-proof script and the census line are the reproduction | hand over with the pass record |

View file

@ -0,0 +1,211 @@
# F10. The ladder's signal: monotonicity, the 89 percent case, the down-step, the memoisation
Attack-pass row F10 (`docs/plans/cryptanalysis.md` section 4.2; the pass record `docs/analysis/attack-pass-2026-10.md`).
7 October 2026, 09:05 to 10:30 UK. Sub-agent F10 on branch `attack-pass` (worktree `igneum-wt-attack`, HEAD 8e36faf6 at
the start of the work; the brief named 924288d1, the branch had moved on). Files: `tools/attack/f10-ladder/` and this
record. Nothing under `vendor/`, `infra/` or the node was edited.
## 1. Target
The ladder as PROPOSED on branch `ladder` (repo tip 7003f9f5, 6 October 2026 23:58 UK; also `release-0.3.18`), node
fork `ladder-node` tip 1591ee1d (`vendor/igneum-node-ladder`), `docs/design/latency-ladder.md` sections 3, 5a, 9 and 11.
| Item | Value at the commit run |
|---|---|
| The N ladder (counted ops) | 102,100; 132,100; 199,600; 330,700; 649,400; 1,001,600 (reps 27, 35, 53, 88, 173, 267; the design doc's round figures 100,000; 130,000; 200,000; 330,000; 650,000; 1,000,000) |
| Floor | rung 0, reps 27 = class v4 byte for byte (`V4_CLASS`) |
| Admissible rungs in the file run | 0, 1, 2 (rungs 3 to 5 `admissible: false`, the verifier table of section 5) |
| The step rule | `igneum::latency_ladder_step_signalled` (`consensus/core/src/igneum.rs` lines 664 to 687): up one rung when all 7 windows have at least 9,000 bps of blue blocks up, the rung above is admissible, and the oldest window begins at or after the DAA score where the current step took effect; down one rung by the same test on the down bit, never below 0; otherwise the state stands |
| The carrier | header `version` bit 15 = up, bit 14 = down, both or neither = none (`ladder_signal_of`); the object byte keeps bits 8 to 13; the low byte is the block version |
| The windows | 7 consecutive windows of `latency_ladder_window_daa` (86,400 DAA on mainnet, 120 on the 60x profile, 100 in the exact-share runs here) ending at the epoch's seed block; one walk of the seed block's blue past (`class_signal::tally_window_by`); share per window = floor(10,000 x signalling / total) |
| The seed block of epoch e | the last selected-chain block with DAA score strictly below `L e - lead` (`class_signal::seed_below`) |
| The memo | `processes::latency_ladder::step_of_epoch`: a static `HashMap<seed hash, LadderDecision>`, filled by walking earlier epochs' seed blocks down the selected chain to a memoised decision or the activation epoch, cleared when it passes 100,000 entries; empty at every process start |
The box binaries used, read-only: `/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/target/release/{igneumd,igneum-miner}`
(built 6 October 2026 22:15Z, the fork checkout at 1591ee1d = the `ladder-node` tip, one untracked file
`.build-remote-sha-target`) and `/srv/builds/igneum-wt-ladder/igneum-pow/target/release/igneum-pow` (built 22:38Z, after
6b30e85). The `igneumd` binary's embedded commit string is 8dbb7a23, the PARENT of the ladder commit, not 1591ee1d
(`strings` finds 8dbb7a23 twice and 1591ee1d never); the ladder code is in it (the step line
`Latency ladder step by miner signal` is present, and every run below stepped). This is the stale-commit-string class
of `tools/ci/commit-string-check.sh` (CLAUDE.md, 6 October 2026); it does not touch the result, and it is reported to
main in section 8.
## 2. Known-failed shape and gate
Known-failed shape: a chip owner stepping the ladder down (cheaper N) without the 90 percent threshold, or a step
registered under 90 percent in either direction. Gate (plan 4.2 row F10, the same as 1.4): no step without 90 percent
over 7 windows in either direction; a step down needs the same. What a failure moves: the step rule's text in spec 01
before the ladder is frozen.
## 3. Method
Two instruments, both run on igneum-build-1 on the F10 cores (`nice -n 10 taskset -c 38-39,86-87`), each run under a
SHARED hold of the box measure file for the run only (every run capped under 30 minutes by its own `--secs`), on ports
29900 and up, devnet suffix 990, data `/tmp/igneum-fast-time-attack-f10`, so nothing collides with the ladder lane's
network (29720, 972) or F7's (29800, 980). Scripts and copies: `tools/attack/f10-ladder/` (box mirror
`/srv/builds/igneum-wt-attack/attack-f10/`, run logs under `runs/`).
| Instrument | File | What it is |
|---|---|---|
| The ladder lane's harness, verbatim | `tools/attack/f10-ladder/latency-ladder.mjs` | `infra/fast-time/latency-ladder.mjs` from `ladder` at 7003f9f5, unchanged except the root lookup, this directory's copy of the ladder branch's `override-60x.json` (the attack-pass tree's copy lacks the `latency_ladder` fields), and the F10 ports, suffix, data dir and binary paths. Three nodes, three real CPU miners (one thread each), class v4 from genesis, the ladder active from DAA 0, windows of 60 DAA. Trusted only after it fires on the known-failed case (`--signal up,up,none --expect step` must report FAIL) and the known pass (`--signal up,up,up --expect step`) |
| The exact-share driver, new | `tools/attack/f10-ladder/ladder-exact.mjs` | Three nodes on the same fork with `skip_proof_of_work`; ONE producer takes node 0's template, writes the ladder bits it wants into the header version and submits the block, one block per DAA score on a linear chain, so every window of W = 100 DAA holds exactly 100 blue blocks, one of each residue modulo 100. A schedule names per DAA range the direction and how many residues carry no signal: 11 residues give 8,900 bps in every window whatever the window's alignment, 10 give 9,000. "None" blocks alternate between no bits and both bits, so the chain shows both forms read as none. The driver polls every node's template (rung, weakest up, weakest down) through the run, restarts a node mid-window on request (SIGINT, same data dir, same arguments), and at the end re-tallies the chain in JavaScript (an independent copy of the rule: the seed rule, the 7 buckets, floor rounding, admissibility, the cool-down) and compares it with what the nodes did |
Why the second instrument: three equal miners cast 0, 33, 67 or 100 percent, and a real miner's share in any one
window scatters by several points (the lane's own runs: 5,833 to 6,333 bps weakest for a 67 percent population), so no
real-mining run can hold 8,900 to 8,999 bps in the weakest of seven windows. The rule is consensus-side and reads the
chain's headers, not the miner, so a chain whose headers carry exact shares asks it the exact question. The skip-PoW
network accepts every submitted block (each node logs `PoW rejected ... by igneum-lottery-v2-bound (daa N, nonce 0x0)` at
INFO and accepts the block; the chain-side fact is the block count on every node).
The arithmetic of the exact-share cases (L = 60 DAA per epoch, lead 10, W = 100, 7 W = 700; genesis and the first
produced block both sit at DAA 0, then one block per DAA): the seed block of epoch e is at DAA 60 e - 11; the seven
windows are full from epoch 12 (seed 709); the oldest window of epoch e is DAA [60 e - 710, 60 e - 611]; after a step
that took effect at DAA S the next decision is the first epoch with 60 e - 710 >= S.
| Case | Schedule (from DAA : direction : residues with no signal) | Expected by hand | Why |
|---|---|---|---|
| eighty-nine | 0:up:11, 1200:up:10 | no step through epoch 30 at a weakest of 8,900; rung 1 at epoch 31 when the weakest first reads 9,000; rung 2 at epoch 43, the first epoch after the cool-down; nothing else to epoch 45 | residue 10 turns from none to up at DAA 1,200; the oldest window's residue-10 block is 1,210 at epoch 31 (1,110 at epoch 30); after the step at DAA 1,860 the first epoch with 60 e - 710 >= 1,860 is 43 |
| down | 0:up:0, 720:down:11, 1500:down:10, node restarts n2 at DAA 1,000, n1 at 2,300, n2 at 2,700 | rung 1 at epoch 12 (100 percent up); no step down at 8,900 down (epochs 24 to 35, the first cooled-down epoch is 24); rung 0 at epoch 36 when the weakest down first reads 9,000; then down at 9,000 through epoch 50 with no step below 0 (epoch 48 is the first cooled-down epoch after the down-step and the rule must hold at rung 0) | the oldest window's residue-10 block is 1,510 at epoch 36 (1,410 at epoch 35); after the down-step at DAA 2,160 the first epoch with 60 e - 710 >= 2,160 is 48 |
| floor | 0:down:0 | no step at all through epoch 20 | 100 percent down at rung 0 from genesis: the windows are full from epoch 12, the cool-down is trivially met, the rule must stand at 0 |
## 4. Runs
All on igneum-build-1, 7 October 2026. Times UK (UTC+1); the logs are UTC. Every run held the measure file
shared for its own length only; the first waited behind F6's exclusive hold (its batch A, 09:15 to 09:25 UK). Log paths
are under `/srv/builds/igneum-wt-attack/attack-f10/runs/` on the box, copied to `tools/attack/f10-ladder/runs/` here
(`<name>.log` = harness stdout, `<name>.json` = summary, `<name>-n{0,1,2}.log` = node logs).
### 4.1 The harness, trusted: the known-failed case and the known pass (real CPU mining, W = 60 DAA)
| Case | Run (UK) | Result | Numbers | Files |
|---|---|---|---|---|
| Known-failed, `--signal up,up,none --expect step` | 09:25:51 to 09:36:50 | FAIL rc=1, as it must: no step | no step over epochs 0 to 10; weakest-of-seven up share at the sink 5,833 bps from epoch 7 (5,500 at epoch 10); on the chain 385 blocks up, 221 none (6,353 bps up); 606 blocks; 0 rejected; one sink 4a7f20cc at 605/605/605; the 8 step checks failed (template_stepped_to_rung_1 ... rung1_ids_differ_from_the_same_seed_rung0_id); the lane's genesis low-byte fault did not fire (fixed in the file) | `baseline-fail.log`, `.json` |
| Known pass, `--signal up,up,up --expect step` | 09:36:50 to 09:48:00 | PASS 18 of 18 | step line on 3 of 3 nodes at epoch 8: `420 of 420 blue blocks up`, weakest up 10,000 bps, shares [10000 x 7]; template rung 1 (35 passes) from epoch 8 (DAA 480) at 538.2 s; epochs 9 and 10 at rung 1, one step line per node (no second step inside seven windows); 481 / 132 blocks across the boundary; 612 blocks up and genesis none (9,984 bps); 0 rejected; one sink 41e81944 at 612/612/612; the miners' rung-1 ids on epochs 8, 9, 10 equal the CLI's `--shadow-reps 35` id and differ from rung 0 (e8 218fa530b4c599b0 against 5c5a326a31a4795d, e9 8f30ce6666b4ea8f against c73f3c63daac3748, e10 e2ea0a1ea8b4ca44 against 626455372164a1b5) | `baseline-pass.log`, `.json` |
Both reproduce the ladder lane's runs of 6 October (`docs/design/latency-ladder-harness/`), on the F10 cores.
### 4.2 The exact-share cases (skip-PoW, one block per DAA, W = 100 DAA, 8 blocks per second)
| Case | Run (UK) | Harness line | What the chain did | Files |
|---|---|---|---|---|
| eighty-nine (first run, driver v1) | 09:48:00 to 09:54:27 | FAIL rc=1 on three harness faults (section 4.3); the chain's facts are those of the re-run | identical to the re-run below | `exact-89.log`, `.json` |
| eighty-nine (re-run, driver v2) | 10:04:45 to 10:11:14 | PASS 19 of 19 | 2,701 blocks, linear; 2,418 up, 283 none (135 of them with both bits); weakest up 8,900 bps at every epoch 12 to 30 and NO step (19 epochs, "stands" on every node); epoch 31: weakest 9,000 exactly, step line on 3 of 3: `630 of 700 blue blocks up`, shares [9000 x 7], rung 1 (35 passes); epochs 32 to 42 at 9,000 with no step (cool-down: the oldest window begins 1,210 to 1,810, the step took effect at 1,860); epoch 43: rung 2 (53 passes), `630 of 700`; 44 and 45 cool-down; 0 disagreements between nodes at any poll; one sink 1dd776b4 at 2700/2700/2700; 2 step lines per node; 382 s | `exact-89b.log`, `.json`, `-n0.log` |
| floor (driver v2) | 10:01:40 to 10:04:38 | PASS 19 of 19 | 1,201 blocks; 1,200 down, genesis none; from epoch 12 every window reads 10,000 bps down at rung 0; the rule stands on every node for epochs 12 to 20 ("down signalled at rung 0: the floor"); no step line on any node; one sink a52e6a71 at 1200/1200/1200 | `exact-floor.log`, `.json` |
| down (first run, driver v1) | 09:54:27 to 10:01:40 | FAIL rc=1 on the same three harness faults | identical to the third run below, restarts included | `exact-down.log`, `.json`, `-n1.log`, `-n2.log` |
| down (second run, driver v2) | 10:11:14 to 10:18:26 | FAIL rc=1 on one harness fault (the anchor comparison at the two boundary epochs 13 and 23, section 4.3); 17 comparable epochs equal; the step lines' own weakest equal the oracle | identical to the third run | `exact-downb.log`, `.json`, `-n{0,1,2}.log` |
| down (third run, driver v3) | 10:19:13 to 10:26:25 | PASS 19 of 19 | 3,001 blocks, linear; 720 up, 2,053 down, 228 none (110 with both bits); epoch 12: rung 1 on 3 of 3 (`700 of 700 blue blocks up`, weakest up 10,000); epochs 13 to 23 cool-down (the oldest window begins 70 to 670, the step took effect at 720); epochs 24 to 35: weakest down 8,900 bps on every node, NO step down (12 epochs "stands"); epoch 36: weakest down 9,000 exactly, step line on 3 of 3: `0 of 700 blue blocks up, 630 down`, rung 0 (27 passes, from rung 1); epochs 37 to 47 cool-down; epochs 48 to 50: 9,000 down at rung 0, the rule stands (never below 0), no third step line; restarts: n2 at DAA 1,004 (1 step line before, 4 after), n1 at DAA 2,304 (2 before, 2 after), n2 at DAA 2,704 (3 before, 2 after), every line after a restart identical in epoch, rung, origin and weakest to the lines before; 0 disagreements; one sink 20c6b367 at 3000/3000/3000; step lines 2 / 4 / 5 per node; 425 s | `exact-downc.log`, `.json`, `-n{0,1,2}.log` |
Per epoch, the down case as the nodes and the oracle saw it (from `exact-downc.json`; "rungs" = the first template of the
epoch on n0 / n1 / n2; "weakest" = the decision's number from the step line where one exists, else the template's live
sink tally, which equals the seed-anchored oracle at every epoch with no schedule boundary inside the windows):
| Epoch | Seed DAA | Rungs n0/n1/n2 | Weakest up / down (bps) | Oracle rung | Oracle reason |
|---|---|---|---|---|---|
| 11 | 649 | 0/0/0 | partial | 0 | windows not full |
| 12 | 709 | 1/1/1 | 10,000 / 0 | 1 | up: 700 of 700 |
| 13 to 23 | 769 to 1,369 | 1/1/1 | mixed, under 9,000 both ways | 1 | cool-down (oldest window begins before 720) |
| 24 to 35 | 1,429 to 2,089 | 1/1/1 | 0 / 8,900 | 1 | stands: 8,900 is under 9,000 |
| 36 | 2,149 | 0/0/0 | 0 / 9,000 | 0 | down: 630 of 700 |
| 37 to 47 | 2,209 to 2,809 | 0/0/0 | 0 / 9,000 | 0 | cool-down (oldest window begins before 2,160) |
| 48 to 50 | 2,869 to 2,989 | 0/0/0 | 0 / 9,000 | 0 | down signalled at rung 0: the floor |
And the eighty-nine case (`exact-89b.json`):
| Epoch | Seed DAA | Rungs n0/n1/n2 | Weakest up (bps) | Oracle rung | Oracle reason |
|---|---|---|---|---|---|
| 12 to 30 | 709 to 1,789 | 0/0/0 | 8,900 | 0 | stands, 19 epochs |
| 31 | 1,849 | 1/1/1 | 9,000 | 1 | up: 630 of 700 |
| 32 to 42 | 1,909 to 2,509 | 1/1/1 | 9,000 | 1 | cool-down (oldest window begins 1,210 to 1,810, the step took effect at 1,860) |
| 43 | 2,569 | 2/2/2 | 9,000 | 2 | up: 630 of 700 |
| 44 to 45 | 2,629 to 2,689 | 2/2/2 | 9,000 | 2 | cool-down |
### 4.3 Harness faults found and fixed on the way (the driver's, never the chain's)
| Fault | Seen | Fix |
|---|---|---|
| `every_produced_block_on_every_node` compared `blockCount` with produced + 1; the node's `blockCount` excludes genesis | exact-89 first run, 09:54 UK | compare with produced (2,700 = 2,700) |
| `zero_rejected_by_nodes` grepped `ban` and matched the finality parameter line `... ban 120 ...` | same run | the word dropped; the skip-PoW INFO line `PoW rejected ... by igneum-lottery-v2-bound` excluded by its own text |
| `node_weakest_equals_oracle_weakest` compared the template's weakest with the seed-anchored oracle at every epoch; the template's number is the LIVE tally anchored at the sink (`consensus/mod.rs` `get_pow_epoch_info`, `tally_ladder(..., sink, ...)`), read at the epoch's first template, sink = seed + lead (10 DAA) | epoch 11 of exact-89 (49 of 59 at the sink against 39 of 49 at the seed); epochs 13 and 23 of the second down run (40 up in (679, 779] against 50 in (669, 769], the boundary at 720 inside both) | compared only at epochs with seven full windows and no schedule boundary inside the windows plus the lead; a new check compares the decision's own weakest (the step line) with the oracle at every stepped epoch, which passed in every run |
The smoke run (`smoke.log`, 09:14 UK, 3 epochs) validated the template round trip (`submitBlock` reports
`{"type":"success"}`, 180 blocks on 3 of 3 nodes at 8 per second).
## 5. What the runs show against the gate
| Gate clause | Shown by | Numbers |
|---|---|---|
| No step up without 90 percent over 7 windows | eighty-nine: 19 epochs at 8,900 bps in every window, rung 0 held on every node; the step came at the first epoch whose weakest read 9,000, 630 of 700 blue blocks | epochs 12 to 30 stand; 31 steps |
| No step down without 90 percent over 7 windows | down: 12 cooled-down epochs at 8,900 bps down in every window, rung 1 held on every node; the step down came at the first epoch whose weakest down read 9,000, 630 of 700 | epochs 24 to 35 stand; 36 steps |
| A step down needs the same cool-down | down: epochs 13 to 23 at rung 1 with the oldest window beginning before the step took effect: the rule stood although the up share had collapsed | 11 epochs |
| Never below 0 | floor: 10,000 bps down at rung 0 for 9 epochs, no step line; down: 9,000 bps down at rung 0 for epochs 48 to 50 after the cool-down, no step line | 12 epochs across two runs |
| Monotone: one rung per decision, seven windows between decisions | eighty-nine: rung 1 at 31, rung 2 not before 43 with 9,000 in every window throughout; down: rung 1 at 12, rung 0 at 36 | the cool-down held 11 epochs each time |
| The decision computed once per seed block and reused | one or two step lines per process per stepped epoch (two when the first template and header processing walked concurrently), none afterwards | n0: 2 lines for 2 steps in every exact run |
| A node restarted mid-window reaches the same decision | three restarts in the down case: every step line after a restart repeats the lines before it in epoch, rung, origin and weakest; the restarted node's template rung equals the others' at every epoch | n2 at 1,004 and 2,704, n1 at 2,304 |
| Two nodes never disagree on the rung at the same height | 0 disagreements at every observation (every fifth block) and at every epoch's first template, in every run | 5 exact runs, 2 baseline runs |
| Both bits = none | 135 and 110 both-bits blocks counted as none by the oracle and by the nodes (the shares matched) | eighty-nine, down |
| The known-failed shape (a chip owner stepping down under 90 percent; a step registered under 90 percent) | did not occur; 8,900 held in both directions, floor rounding puts 8,999 below the line (unit test, `igneum.rs` 1161) | gate holds |
## 6. Static reading of the rule (what the harness cannot show)
Read in the fork at 1591ee1d before the runs. Each line is a property of the code as written, with the place.
| Property | Where | Reading |
|---|---|---|
| Symmetry of the two directions | `igneum.rs` 676 to 686 | one closure `all(shares)` serves both bits; the up branch runs first, then `all(down) && previous.step > 0`; up and down cannot both reach 9,000 bps of one window's blocks, so the order never decides |
| The cool-down is direction-free | `igneum.rs` 674 | `first_counted_daa < previous.since_daa` returns the previous state before either branch is read; a step down waits the same seven windows after a step up as a step up does after a step down |
| Never below 0 | `igneum.rs` 681 | `previous.step > 0` guards the subtraction; a 100 percent down signal at rung 0 stands (the floor case below shows it on the chain) |
| Never past an inadmissible rung | `igneum.rs` 679 | `ladder.admissible(previous.step + 1)`; rung 3 is `admissible: false` in the file, so from rung 2 a 100 percent up signal stands (unit test `latency_ladder_rule`, `igneum.rs` 1161) |
| Floor rounding | `igneum.rs` 431 to 437 | `signal_share_bps` = floor(10,000 x signalling / total); 89 of 100 blue blocks is 8,900, 90 is 9,000; on a mainnet window of 86,400 blocks 77,759 up is 8,999 and 77,760 is 9,000 |
| Both bits set | `igneum.rs` 639 to 645 | `version & 0xc000 == 0xc000` falls to `None`; a header cannot vote both ways and cannot vote twice |
| Weakest of seven | `class_signal.rs` `SignalTally::weakest_bps` and the rule's `all` | the decision rests on the lowest of the seven windows; one bought window at 100 percent moves nothing (unit test "one bought day does not move it") |
| The windows are the seed block's own past | `class_signal.rs` `tally_window_by` | the anchor and the mergeset blues of each selected-chain block walking down, bucketed by `daa_c - daa`, stopping once `daa_cur + merge_depth < window_start`; blocks above the seed are never counted, so the seven windows are fixed once the seed block is |
| The memo is sound | `latency_ladder.rs` `step_of_epoch` | keyed by the seed block's hash; the decision is a function of that block's selected-chain past and of process-global constants installed from the file (ladder, activation, window), so two processes with the same file and the same chain compute the same value; the memo is never read across a param change because the params are fixed at start; cleared above 100,000 entries, then rebuilt by the walk |
| Concurrent first computation | `latency_ladder.rs` `memo_get` / `memo_put` | the lock is not held across the walk, so two concurrent callers may both walk and both log the step line; both write the same value, so the chain's decision is unaffected (the runs below show one or two step lines per process for the same epoch, identical in content) |
| A node without the history | `latency_ladder.rs` `step_of_epoch`, the two `warn!` returns | a node whose seed block's windows cannot be walked (synced from a pruning proof) decides RUNG 0 and logs "a ladder witness is owed". After a step up, such a node runs rung 0's program and refuses rung 1's blocks: a split between full-history nodes and proof-synced nodes. The design doc lists the witness as owed (section 9). This is not a fault of the step rule and the harness cannot reach it (every node here has the history); it is a precondition on activation: no network activates the ladder while any peer syncs from a proof without the witness. Routed to main in section 8 |
Nothing in the reading admits a step under 9,000 bps in either direction, a step down under the cool-down, a step
below rung 0, or a decision that depends on which node computes it or when.
## 7. Consequences per tier
The rule holds, so a step in either direction costs 90 percent of blue blocks in each of seven consecutive days, and
the earliest second step is seven days after the first. What a WRONGFUL step would have done, had the rule admitted one
under 90 percent, is the measured per-rung table of `docs/design/latency-ladder.md` section 8 (algorithm.md 5.3a rungs,
igneum-build-1 verifier) read in each direction. Every row below is that table's number, not a new measurement.
| Wrongful step | M5 Max (Apple tier) | RTX 5090 at 431 W | RTX 4070 at 160 W | RX 9070 XT | 8 / 12 / 16 GB cards, rigs, pools | Verifier (half-core) | f = 1 chip's per-joule edge over the 5090 |
|---|---|---|---|---|---|---|---|
| Up 0 to 1 (102,100 to 132,100 ops) under 90 percent | -3.3 points of rate, 0 W more | 0 | 0 | 0 | 0 (the shadow costs ALU, not memory; the dataset size is the schedule's, not the ladder's) | +0.2 ms | 2.1x to 1.7x at k = 1 (3.9x to 3.4x at k about 0.33) |
| Up 1 to 2 (to 199,600) under 90 percent | -6 more points | -2.7 percent | +21 W | 0 | 0 | +0.5 ms | to 1.3x (2.8x) |
| Up 2 to 3 (to 330,700): inadmissible, never entered | -21 percent | -35 percent (compute-bound at the cap) | -12 percent | +3.6 percent | 0 | +0.9 ms | 3.0x at k about 0.33 |
| Down 2 to 1, 1 to 0 under 90 percent (the chip owner's step) | the Apple tier gets its 6 then 3.3 points back | +2.7 percent then 0 | -21 W then 0 | 0 | 0 | -0.5 then -0.2 ms | the chip regains 1.3x to 1.7x to 2.1x (2.8x to 3.4x to 3.9x): every rung down hands the stored-dataset chip back the edge the miners paid for |
Reading per tier, with the rule as it stands:
| Tier | What the result means |
|---|---|
| Home card, 8 / 12 / 16 / 24 GB, any vendor, any OS | A step up costs rate only on the Apple tier at rungs 1 and 2, and on NVIDIA from rung 2; no step happens unless 90 percent of blocks over seven days ask for it, so a minority that would lose rate cannot be moved by a bought day or a 89 percent week, and a chip owner under 90 percent cannot move the rung down to cheapen its core. A 90 percent majority can step the chain down one rung per week to the floor (rung 0 = class v4 as it ships), which is the design's floor and not a weakness of the rule: at 90 percent of blocks the owner already orders the chain |
| Rig, pool user | The same; a pool signals per block through its node's `IGNEUM_LADDER_SIGNAL` (the app's toggle later), so a pool's share of blocks is its weight |
| Verifier (the node, the proof) | Admissibility is a genesis flag per rung; rung 3 is never entered by any signal until a quiet re-measurement before genesis moves the flag (section 4 of the design doc); the memo keeps the per-template cost to one walk per seed block per process |
| A node synced from a pruning proof | Decides rung 0 until the ladder witness lands (section 6, last row): the ladder must not activate on a network where such nodes exist before the witness. This is the one consequence the rule's text does not state and the spec line should |
## 8. Verdict, and what goes to main
PASS. No step without 90 percent of blue blocks in each of seven consecutive windows in either direction; a step down
needs the same 90 percent and the same seven-window cool-down; the floor holds under 100 percent down; the decision is
per seed block, memoised per process, recomputed identically after a restart, and never differs between nodes at the
same epoch. The known-failed harness case fails, the known pass passes, and three new cases (89 percent up, 89 then 90
percent down with restarts, the floor) pass on the chain and on the harness's own 19 checks. The step rule's text in
spec 01 needs no change for the gate.
To main, not findings against the gate:
| Item | What | Proposed route |
|---|---|---|
| Stale commit string in the ladder lane's `igneumd` | the binary built 6 October 22:15Z from the fork at 1591ee1d carries 8dbb7a23 (its parent) and no 1591ee1d; the ladder code is in it | the commit-string-check class (CLAUDE.md, 6 October 2026); the ladder lane rebuilds with the two-step before any Devnet 2 crossing; nothing in this row depends on it |
| Proof-synced nodes decide rung 0 until the witness lands | `processes::latency_ladder::step_of_epoch` returns rung 0 with a warning when the seed block's windows cannot be walked; after a step, such a node runs the wrong program and splits from full-history peers | a precondition line for the step rule's text in spec 01 when the ladder is adopted: "the ladder activates only once every node can walk the seven windows below every seed block, or carries the ladder witness in its pruning proof"; the design doc already lists the witness as owed (section 9); node lane |
| Spec text for the ladder, when adopted (none in spec 01 today; the only ladder there is `epoch_len`'s) | the rule as run: 90 percent of blue blocks in each of 7 consecutive windows ending at the seed block, floor rounding, one rung per decision, the oldest window at or after the last step in either direction, never below rung 0, never into an inadmissible rung; the template's weakest is the live sink tally and the decision's is at the seed | the algorithm lane's spec line; this record is the test it cites |
| Three harness faults in the F10 driver | section 4.3; all three were the driver's reading of the node, fixed in `ladder-exact.mjs` v3 | none owed; recorded so the firm does not repeat them |
Blocked: nothing. Not run: a real-mining 89 percent case (three equal miners cannot cast it; the exact-share driver
asks the rule the same question through the same submit path and the same consensus code).

View file

@ -0,0 +1,214 @@
# Attack pass F2: the mixer's round margin
Row F2 of `docs/plans/cryptanalysis.md` section 4.2 (branch `cryptanalysis`), fed into
`docs/analysis/attack-pass-2026-10.md`. Run 7 October 2026, 09:00 to [FILL] UK, by the attack-pass sub-agent F2 on
igneum-build-1 (cores 6-11 and 54-59, nice 10, the measure file held shared in chunks under 30 minutes).
## 1. Target
Commit `924288d1` (worktree `igneum-wt-attack`, branch `attack-pass`). The x8 mixer of `igneum-pow/src/memhard.rs`,
`mixer` (lines 300 to 313): one application on 16 words of 32 bits is, per word, `(s[i] ^ (RC[i] + rk)) * MUL[i]`
with `MUL[i]` odd, then one ChaCha-shaped double round: four column quarter rounds with rotations `ROT[0..3]`,
four diagonal quarter rounds with `ROT[4..7]`. `ROT`, `MUL`, `RC` are drawn per day from the 64-bit SplitMix64 seed
`K[0] | K[1] << 32` by `MixParams::with_shape` (lines 237 to 258). Under class v3 and v4 (`m = 8`) an item is 8
dependent cache reads, each preceded by 8 applications with round keys `round_key(r * 8 + j)`, and 8 more after the
last read: 72 applications per item (`derive_items_mask`, lines 517 to 550). The chip model prices one application
at 128 hoisted operations and an item at 9,360 (`docs/analysis/chip-model-v3.md` 5.2).
The days modelled: the genesis day `2026-10-03` (`ROT = 20 20 19 4 26 3 3 27`, as `proto-metal/MEMHARD.md` line 82
states; the harness reads the same draw from the code) and two other days, `2026-10-04` (`ROT = 28 15 9 26 2 2 22
8`) and `2027-03-01` (`ROT = 31 16 15 15 2 9 19 4`). Their full `MUL` and `RC` are in the box files
`/srv/builds/igneum-wt-attack/target-attack-f2/params/<day>.real.txt`.
Known-failed shape (the plan's row): a differential or linear trail, a rotational-XOR relation, or an algebraic fold
that distinguishes or shortcuts more than 2 of the 8 applications between dependent reads. Gate: none beyond 2 of 8.
## 2. Method
Four searches and two checks, every one on the bit-level definition in `memhard.rs` (the harness calls
`igneum_pow::memhard::mixer` itself; the SAT models consume one op list whose value evaluator is checked against
the Rust output on 64 applications per day and variant, 9 files, all matching).
| Piece | What it is | Exact or model |
|---|---|---|
| Differential, MSB family | XOR differences; at every multiply each word's difference is 0 or `0x80000000`. These are the only word transitions through an odd multiply with probability 1 (`(x ^ 2^31) * c = (x * c) ^ 2^31`; any other nonzero difference passes with probability at most 1/2, since its lowest active bit below the MSB leaves a carry to chance). Modular addition by Lipmaa-Moriai (exact per adder), XOR and rotation linear | exact family, trail probabilities exact per operation |
| Differential, general | The same ARX model with every word difference allowed through the multiply: XOR difference to modular difference (each set bit below the MSB is a sign choice, 2^-1 each, exact), times `MUL` (exact, a circuit on the difference variables), modular back to XOR (a carry chain, one bit per position where the difference bit and the carry differ, exact), the two conversions taken as independent | Markov trail model; its per-word cost sits 1 to 2 bits above the sampled best transition (section 4.1), so it is a trail model, slightly pessimistic for the attacker |
| Linear, low-bit family | Masks; at every multiply the output mask lies in bits 0 and 1, the only F2-linear output bits of an odd multiply (`(cx)_0 = x_0`, `(cx)_1 = x_1 ^ (c_1 & x_0)`). Modular addition by the exact carry-mask automaton (per bit a carry-mask bit; checked against brute force at n = 8 on 500 mask triples, max error 0) | exact family |
| Linear, general | The same with the multiply as its shift-and-add decomposition (one adder per set bit of `MUL`, the low known-zero bits of a shifted copy transparent), each adder under the automaton | trail model; over-optimistic for the attacker (section 4.3) |
| Rotational-XOR | Measured on the real code: for every rotation r in 1..31 and k = 1..4, the per-bit bias of `rot_r(M^k(x)) ^ M^k(rot_r(x))` over 2^20 states, the largest |z| of the 512 bits, and the count of exact rotational pairs; plus the word-level prologue `g(x) = (x ^ C) * MUL` alone: the most frequent value of `rot_r(g(x)) ^ g(rot_r(x))` over 2^20 inputs | measurement |
| The fold | The identities a chip would need to pay less than k x 128 for k applications, each tested on 2^20 random inputs, plus the algebraic argument (section 4.5) | measurement and argument |
Search: for each (model, day, k = 1..4) the weight bound W is probed upward (SAT means a trail of weight at most W
exists, UNSAT means none does in the model), then narrowed to the minimum. A k-application trail restricted to one
application is a valid 1-application trail, so every application is held to the proven k = 1 minimum of the same
model (the Matsui floor in the tables). Solver CaDiCaL 1.9.5 through python-sat 1.9. Every trail found of
measurable weight is measured on the real code before it counts: per application and as a chain, 2^20 to 2^28
samples (`attack-f2 verify-diff` / `verify-lin`), with the multiply-layer word transitions counted exactly over all
2^32 inputs (`verify-mults`). A trail that does not hold is blocked and the solver asked again at the same bound.
Linear trails whose correlation cancels inside one adder's hull are caught first by the exact signed sum over the
adder's carry masks.
What "reaches k applications" means here, two readings: (a) the shortcut reading, the one with a cost consequence:
a relation of probability 1 (weight 0) over k applications, which a chip could use to skip work; (b) the
distinguisher reading: a trail of weight under 64 over k applications, the usual practical line. For the gate both
are reported.
## 3. Harness
| Item | Path |
|---|---|
| Crate (ground truth: parameters, vectors, verification, RX, fold) | `tools/attack/f2-mixer/` (`Cargo.toml`, `src/main.rs`), `igneum-pow` by path, own `[workspace]` |
| SAT models and the search | `tools/attack/f2-mixer/model.py` (`selftest`, `search`, `show`) |
| Box queue runner, tables | `tools/attack/f2-mixer/run_jobs.sh`, `tools/attack/f2-mixer/summarise.py` |
| Build line (from the crate directory) | `IGNEUM_AGENT=attack-f2 bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f2" --out <scratch> -- build --release`; binary on the box `/srv/builds/igneum-wt-attack/tools/attack/f2-mixer/target/release/attack-f2` (ELF x86-64, sha256 `150337ec...`, the third build; 12 s incremental) |
| Box scratch (states, trails, logs, venv) | `/srv/builds/igneum-wt-attack/target-attack-f2/` (`state/`, `logs/`, `params/`, `vectors/`, `venv/`). The brief's path `attack-f2/` was wiped within ten minutes by another lane's worktree-root rsync (`--delete` spares only `target-*`), so the scratch moved under a `target-` name, as F1 and F6 did |
| Run lines | `venv/bin/python3 model.py selftest --vectors vectors --params-dir params`; `bash run_jobs.sh jobs.txt 11` (each job `model.py search --kind diff|lin --family msb|general|low2 --params params/<day>.<variant>.txt --apps k --state state/<name>.json --budget <chunk> --per-app-min <k=1 floor> --verifier <binary>` under `flock -s /srv/builds/_locks/measure`, `nice -n 10 taskset -c 6-11,54-59`); `attack-f2 rx --day D --variant V --apps 4 --log2 20`; `attack-f2 rx-word --day D --log2 20`; `attack-f2 fold --day D --log2 20` |
| Logs | `logs/<model>-<day>-<variant>-k<k>.log` per search, `logs/rx.<day>.<variant>.log`, `logs/rx-word.<day>.<variant>.log`, `logs/fold.<day>.log`, `logs/summary.md` (the tables below), `logs/verify*.log` |
## 4. Results
### 4.1 The harness fires (known pass, known fail)
| Case | Expected | Got | Log |
|---|---|---|---|
| Selftest: evaluator against `attack-f2 vectors`, 3 days x 3 variants, 4 applications x 16 states each | all match | 9 of 9 files, 64 of 64 applications each | `selftest` output, `logs/selftest.log` |
| Selftest: linear add automaton against brute force, n = 8 | exact | 300 random triples and 200 shifted-copy triples, max error 0.00e+00 | same |
| Selftest: Lipmaa-Moriai against brute force, n = 8; both SAT encodings against their rules at n = 32 | exact | max error 0; 0 mismatches of 40 and 40 | same |
| Selftest: the multiply model's word cost against the sampled best transition (word 3, genesis day) | MSB exact; others within a few bits | MSB: weight 0, measured 2^-0 (exact); bit 30: model 2, sampled best 2^-1.00; bits 31+5: model 8, sampled best 2^-6.03; bit 0: model 10, sampled best 2^-8.97 | same |
| Known pass, 0 applications | the identity trail, weight 0 | trivial (input = output, no weights); not run as a job | |
| Known fail, `rot0` (every rotation 0), differential, k = 1, 2, 4 | a weight-0 trail (MSB-only differences stay MSB-only when nothing rotates) | weight 0 found at k = 1, 2, 4 (both families); measured probability 1 on the real code (`verified_chain -0.0`) | `state/diff-msb-2026-10-03-rot0-k{1,2,4}.json`, `state/diff-general-2026-10-03-rot0-k{1,2}.json` |
| Known fail, `rot0`, linear, k = 1, 2, 4 | a weight-0 trail (LSB masks) | weight 0 at k = 1, 2, 4; measured correlation 1 per application and as a chain | `state/lin-low2-2026-10-03-rot0-k{1,2,4}.json`, `state/lin-general-2026-10-03-rot0-k{1,2}.json` |
| Known fail, `nomul` (MUL 1, RC 0, rk 0: the bare double round), rotational-XOR, k = 1 | a large per-bit bias | max |z| 134.2 (r = 31) against 4.2 for the real mixer; word-level: the prologue is exactly rotational (2^20 of 2^20) against 3 of 2^20 | `logs/rx.2026-10-03.nomul.log`, `logs/rx-word.2026-10-03.nomul.log` |
| Known fail, `rot0`, rotational-XOR | bias | max |z| 32.3 at k = 1, 9.0 at k = 2 | `logs/rx.2026-10-03.rot0.log` |
| Known fail, `nomul`, differential k = 1 | the bare double round's best trail, below the real mixer's | weight 7 found (model), measured 2^-5.0 on the real code | `state/diff-general-2026-10-03-nomul-k1.json` |
### 4.2 Differential trails
| Model | Day | Variant | k | Best trail weight found | No trail at or below (model) | Closed | Per-application floor | Verified on the real code (chain; per application) | Solver s |
|---|---|---|---|---|---|---|---|---|---|
| diff/general | 2026-10-03 | real | 1 | 12 | 11 | yes | 0 | 12.011; [11.939] | 186 |
| diff/general | 2026-10-03 | real | 2 | none | 24 | no (timebox) | 12 | | 1,739 |
| diff/general | 2026-10-04 | real | 1 | 10 | 9 | yes | 0 | 10.001; [9.999] | 321 |
| diff/general | 2026-10-04 | real | 2 | none | 20 | no (timebox) | 10 | | 663 |
| diff/general | 2027-03-01 | real | 1 | 12 | 11 | yes | 0 | 12.057; [11.907] | 175 |
| diff/msb | 2026-10-03 | real | 1 | 12 | 11 | yes | 0 | 12.206; [11.972] | 4 |
| diff/msb | 2026-10-03 | real | 2, 3, 4 | none | 512 (the family dies) | yes | 12 | | 26, 33, 22 |
| diff/msb | 2026-10-04 | real | 1 | 10 | 9 | yes | 0 | 10.001; [10.001] | 309 |
| diff/msb | 2026-10-04 | real | 2, 3, 4 | none | 512 | yes | 10 | | 20, 33, 44 |
| diff/msb | 2027-03-01 | real | 1 | 12 | 11 | yes | 0 | 12.057; [11.907] | 3 |
| diff/msb | 2027-03-01 | real | 2, 3, 4 | none | 512 | yes | 12 | | 10, 15, 21 |
| diff/general | 2026-10-03 | nomul (known fail) | 1 | 7 | 6 | yes | 0 | 5.002; [5.003] | 38 |
| diff/general | 2026-10-03 | nomul | 2 | none | 20 | no | 7 | | 1,309 |
| diff/general, diff/msb | 2026-10-03 | rot0 (known fail) | 1, 2, 4 | 0 | | yes | 0 | probability 1 | under 1 |
The general model's k = 3 and k = 4 jobs (closed 14:3x UTC, every job at its 7,200 s cap, `logs/summary.md`):
| Model | Day | k | Best trail found | No trail at or below (model) | Per-application floor | Solver s |
|---|---|---|---|---|---|---|
| diff/general | 2026-10-03 | 3 | none | 35 | 12 | 7,201 (cap) |
| diff/general | 2026-10-03 | 4 | none | 47 | 12 | 7,359 (cap) |
| diff/general | 2026-10-04 | 3 | none | 29 | 10 | 7,350 (cap) |
| diff/general | 2026-10-04 | 4 | none | 39 | 10 | 7,279 (cap) |
| diff/general | 2027-03-01 | 3 | none | 35 | 12 | 7,321 (cap) |
| diff/general | 2027-03-01 | 4 | none | 47 | 12 | 7,284 (cap) |
| lin/general | 2026-10-03 | 3 | none | 24 | 1 | 7,953 (cap) |
| lin/general | 2026-10-03 | 4 | none | 24 | 1 | 7,352 (cap) |
| lin/general | 2026-10-04 | 3 | none | 28 | 1 | 7,373 (cap) |
| lin/general | 2026-10-04 | 4 | none | 24 | 1 | 7,393 (cap) |
| lin/general | 2027-03-01 | 3 | none | 24 | 1 | 7,402 (cap) |
No trail of weight under 32 at three applications (the finding line): the bound reached is 29 to 35 at three and
39 to 47 at four for differentials, 24 to 28 at three and 24 at four for linear masks, all solver-capped, so these are
effort bounds, not proofs; they grow with k as the per-application floors predict.
### 4.3 Linear trails
| Model | Day | Variant | k | Best trail weight found (correlation 2^-w) | No trail at or below | Closed | Verified (chain; per application) | Solver s |
|---|---|---|---|---|---|---|---|---|
| lin/general | 2026-10-03 | real | 1 | 1 | 0 | yes | 0.996; [0.997] | 5 |
| lin/general | 2026-10-03 | real | 2 | none | 20 | no (timebox) | | 1,019 |
| lin/general | 2026-10-04 | real | 1 | 1 | 0 | yes | 0.995; [0.999] | 5 |
| lin/general | 2026-10-04 | real | 2 | none | 24 | no (timebox) | | 1,669 |
| lin/general | 2027-03-01 | real | 1 | 1 | 0 | yes | 1.003; [0.996] | 5 |
| lin/general | 2027-03-01 | real | 2 | none | 20 | no (timebox) | | 1,224 |
| lin/low2 | 2026-10-03, 2026-10-04, 2027-03-01 | real | 1 | 1 | 0 | yes | 0.995 to 1.003 | 4 to 5 |
| lin/low2 | 2027-03-01 | real | 2, 3, 4 | none | 512 (the family dies) | yes | | 12, 18, 27 |
| lin/general | 2026-10-03 | nomul (known fail) | 1 | 1 | 0 | yes | 0.999; [1.003] | 4 |
| lin/general, lin/low2 | 2026-10-03 | rot0 (known fail) | 1, 2, 4 | 0 | | yes | correlation 1 | 5 to 10 |
One application carries a weight-1 linear trail (the LSB mask through the prologue and one add, correlation 1/2),
the structural residue of 4.5; at two applications no trail at or below weight 20 to 24 exists in the general
model within the timebox, and the LSB family dies (no trail at or below 512) from k = 2.
### 4.4 Rotational-XOR
Per k and day, the largest |z| over all 31 rotations and 512 bits at 2^20 states (15,872 bit tests per k; the
noise ceiling of that many tests is about 4.3), and the count of exact rotational pairs.
| Day | k = 1 | k = 2 | k = 3 | k = 4 | Exact pairs | Log |
|---|---|---|---|---|---|---|
| 2026-10-03 | 4.22 (r 19) | 4.29 (r 30) | 4.22 (r 3) | 4.62 (r 27) | 0 | `logs/rx.2026-10-03.real.log` |
| 2026-10-04 | 4.35 (r 17) | 4.00 (r 19) | 4.24 (r 25) | 4.49 (r 28) | 0 | `logs/rx.2026-10-04.real.log` |
| 2027-03-01 | 3.96 (r 7) | 4.07 (r 2) | 4.04 (r 28) | 4.17 (r 19) | 0 | `logs/rx.2027-03-01.real.log` |
| 2026-10-03, bare double round (`nomul`) | 134.24 (r 31) | 4.37 | | | 0 | `logs/rx.2026-10-03.nomul.log` |
The word-level prologue `(x ^ C) * MUL`: over 2^20 inputs the most frequent value of `rot_r(g(x)) ^ g(rot_r(x))`
occurs at most 3 times for every word and every r on all three days (`logs/rx-word.<day>.real.log`, the
`rxw_worst` lines), against 2^20 of 2^20 without the multiply. The odd multiply by a random constant is not
rotational to any measurable degree, and one application already shows no per-bit bias. Rotational-XOR does not
reach 1 application.
### 4.5 The fold of the multiply layer
One application is `D o P_rk`, with `P_rk(s)_i = (s_i ^ (RC_i + rk)) * MUL_i` and `D` the double round (fixed per
day). Multiplication by an odd constant distributes over modular addition and over nothing else in `D` (XOR,
rotation); the XOR with a constant commutes with XOR and rotation and with nothing else (addition, multiply). A fold
across applications would need one of the identities below. Each was tested on 2^20 random inputs on every day
(`logs/fold.<day>.log`):
| Identity a chip would need | Holds on | Meaning |
|---|---|---|
| `(xa ^ Ca) * ma + (xb ^ Cb) * mb = ((xa ^ Ca) + (xb ^ Cb)) * ma` for the four column pairs (0,4), (1,5), (2,6), (3,7) | 0 of 1,048,576 for every pair on every day (`MUL` distinct in every pair) | the multiply does not fold into the first add of a quarter round; it would if a column pair drew the same `MUL` (probability 2^-31 per pair per day, the weak-day class of F4) |
| `(x ^ C) * m = (x * m) ^ (C * m)`, or `= (x * m) ^ C'` for any single `C'` | 0 of 1,048,576; the best single `C'` agrees on 33 of 1,048,576 (2^-15) | the constant cannot be moved past the multiply, so application j + 1's prologue cannot share application j's multiply |
| an XOR constant on one word commuting with the bare double round (so the next prologue's constant could be folded back) | 0 of 65,536 for every word | every word's value feeds an add inside the double round |
| the MSB passing the prologue and the add for free; the LSB passing the prologue | 1,048,576 of 1,048,576 each | the structural residue: the only free passages, both moved by the rotations (the family deaths in 4.2 and 4.3) |
So k applications cost k times one application, 128 hoisted operations each (16 multiplies, 32 adds, 32 XORs, 32
rotations with the constants hoisted); `chip-model-v3.md` 5.2's 9,360 per item stands. The trail weights of 4.2
and 4.3 growing with k is the quantitative side of the same fact: a composition that collapsed to one application's
shape would keep one application's trail weights.
## 5. Gate and verdict
Gate (plan 4.2 F2, 1.4 (1)): no distinguisher or shortcut beyond 2 of the 8 applications between dependent reads,
after the stated search.
| Line of attack | Reach | Verdict |
|---|---|---|
| Differential, general model (Markov on the multiply, exact add rule, SAT) | one application: best trail weight 10 to 12 on three days, verified on the real code; two applications: no trail at or below weight 20 to 24 within 7,200 s per job (not closed); the MSB family dies at two applications on every day | nothing reaches 2 applications below 2^-20 |
| Linear, general model (piling-up, SAT) | one application: weight 1 (the LSB residue); two applications: no trail at or below 20 to 24 within the timebox; the LSB family dies at two | nothing reaches 2 applications below 2^-20 |
| Rotational-XOR | no per-bit bias at one application (max abs z 4.0 to 4.6 at 2^20 states, noise ceiling 4.3); 0 exact pairs; the multiply prologue is rotational on at most 3 of 2^20 inputs; the bare double round fires at 134 | does not reach 1 application |
| Algebraic fold of the multiply layer | every identity a fold needs holds on 0 of 2^20 inputs on every day; k applications cost k | no shortcut |
Verdict: PASS with the effort bound stated: about 60 solver jobs, 2 to 29 minutes each, on three day keys; the
reduced-round margin reached is one application fully characterised (weights 10 to 12 differential, 1 linear) and
two applications with no trail under weight 20 to 24, three with none under 29 to 35 (differential) and 24 to 28
(linear), four with none under 39 to 47 and 24, against 8 applications between reads, so the margin between what the
search reaches and what the construction uses is at least 4 applications at the solver's cap. What this does not do is in section 7; the lower bound is the paid question.
## 6. Consequences per tier
No shortcut, so no tier moves: a home card, a rig and a pool pay the 72 applications per item the verifier pays;
a chip with a fixed datapath pays them too (the fold test), which is what `chip-model-v3.md` 5.2's 9,360 ops per
item assumes. `mixer_mult` stays 8; the verifier measurement of F6 stands unchanged.
## 7. What this does not do
- It does not bound the mixer from below: the general models are trail models (Markov for the multiply's
differential, piling-up for the linear), and the family models are exact only inside their families. The firm's
job (funding.md B5 rank 1) is the effort-bounded version of the same search with their tools.
- Three days, not a census: the ROT, MUL, RC classes over 2^24 days are F4's row. One cheap addition for F4 from
this harness: the MSB-family death at k = 2 (`model.py search --kind diff --family msb --apps 2`) runs in seconds
per day, and a day where it does not die is a weak day of the kind the gate is about.
- Differential and linear only, as the row says: no boomerang, no integral or cube property, no related-key (the
round keys are public constants).

View file

@ -0,0 +1,148 @@
# F3: the chained cache's j + 1 bound and the storage-against-recompute curve
Attack-pass row F3 of `docs/plans/cryptanalysis.md` section 4.2 (the record is `docs/analysis/attack-pass-2026-10.md`). Run 7 October 2026, 09:10 to 09:12 UK (08:10 to 08:12 UTC in the logs), on igneum-build-1. Verdict: PASS on all three gate clauses. No line (s, j) is derivable in fewer than j + 1 block evaluations without an earlier line, by an exhaustive search over the block dependency graph extracted from the code at 64 and 1,024 lines, cross-checked by an exhaustive pebbling search over every configuration at 10 lines. The storage-against-recompute curve over cache lines is monotone from f = 1/64 to 1. The f = 1 point is unchanged.
## Target
| Item | Value |
|---|---|
| Commit | 924288d1 (the brief); the worktree HEAD moved to 11b375a0 during the run; `igneum-pow/src/memhard.rs` is byte-identical at both (blob ad42470b, `git diff --stat 924288d1 HEAD -- igneum-pow/src/memhard.rs` empty) |
| Construction, from the code | `Cache::fill_segment`: `in_j = prev XOR (sigma || K || seg || j || tag)`, `line_j = chacha_block(in_j)` where `chacha_block(x) = ChaCha12core(x) + x`, `prev_0 = 0`, `prev_j = line_{j-1}`; 64 lines per segment, 2^16 segments, 2^26 words (256 MiB) |
| Reads | `derive_items_mask`: 8 dependent reads per item at line index `s[0] AND mask`, so the segment and j of a read are uniform over the 2^22 lines (F8 checks the uniformity) |
| Known-failed shape | a line (s, j) computable in fewer than j + 1 block evaluations without an earlier line of segment s (the address-steering shape of the MTP break, Dinur and Nadler 2017, needs a data-dependent chain; this chain's inputs are fixed by the key, so the shape to search is a structural shortcut on the dependency graph) |
| Gate | no derivation under j + 1 blocks; the curve monotone; the f = 1 point unchanged |
| Prior evidence | none to re-gate: the `ca2-cache` branch named in the status board is the hot-table experiment (`docs/plans/hot-table.md`), not a chain analysis |
## Method
The model is the code, not the prose. `tools/attack/f3-cache/src/main.rs` runs one chain function, written in the shape of `memhard.rs` (the quarter round, the 6 double rounds, the feed-forward, the prev XOR, the constant block), generically over two word types:
| Word type | What it computes | Use |
|---|---|---|
| `u32` | the real arithmetic | `verify`: bit-exact against `Cache::fill_segment` on 16 (key, segment) pairs and against `chacha_block` on 100,000 random inputs |
| taint set | which block outputs a value depends on (add, xor, rotate = union) | `search`: the direct-parent graph of every block, with each computed line relabelled to the single node {j} so parents are direct, not transitive; plus the 16 x 16 (output word, input word) dependency matrix of one block |
The exhaustive search: for every target line j, the minimum number of block evaluations with nothing stored is the size of the backward closure of j on the extracted graph (every non-stored block in the closure must be evaluated at least once; once each in dependency order suffices). `pebble` checks that formula against an exhaustive 0-1 BFS over every pebble configuration (place on a node whose parents are pebbled at cost 1, remove at cost 0) for all 2^10 stored sets x 10 targets on each of the three graphs: 10,240 pairs per graph, 0 mismatches. Two deliberately broken chains are the known-fail cases: `skip2` (line j fed from line j - 2) and `nofeed` (no previous line fed in). The curve: for f = 1/64 to 1 (fraction of cache LINES held), the blocks per read on the naive pattern of `funding.md` B2 rank 2 (every L/n-th line from line 0) and on the optimal pattern (exact DP over chunk lengths; brute force over every C(64, n) set for n up to 8, 4,426,165,368 sets at n = 8); ops per item = 9,360 mixer ops (chip-model-v3.md 5.2) + 8 reads x blocks per read x ops per block (608 counted from the code: 48 quarter rounds x 12, 16 feed-forward adds, 16 input XORs; also at MEMHARD.md's approximate 700). Three more checks on the real function: single-bit avalanche and a differential-independence test on `chacha_block`, a census of every line of the real 2^22-line cache for the day key 2026-10-03, and one-core timings of a block, a mixer application, an item and a line recompute.
## Harness
| Item | Value |
|---|---|
| Crate | `/Users/joshm/Projects/igneum-wt-attack/tools/attack/f3-cache/` (`Cargo.toml` with `igneum-pow = { path = "../../../igneum-pow" }` and an empty `[workspace]`; `src/main.rs`; `run-box.sh`) |
| Build | `cd tools/attack/f3-cache && IGNEUM_AGENT=attack-f3 IGNEUM_TOOLCHAIN_MISMATCH=ok bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f3" --out <scratchpad>/attack-f3 -- build --release` (rc 0, 38 s wall, 0 warnings; the Mac's PATH rustc is 1.69 but `~/.cargo/bin/rustc` is 1.99.0, which the script read as "on both sides"; log `<scratchpad>/attack-f3/build-1.log`) |
| Binary | box `/srv/builds/igneum-wt-attack/tools/attack/f3-cache/target/release/attack-f3`, sha256 975115a385ca3195...71cc33, 563,104 bytes |
| Run | on the box: `nohup bash run-box.sh r1 > run-r1.log 2>&1 &` from `/srv/builds/igneum-wt-attack/attack-f3/`; every phase as `flock -s /srv/builds/_locks/measure -c "nice -n 10 taskset -c 12-15,60-63 attack-f3 <cmd>"`, one chunk per phase, the whole run 17 s (08:10:58 to 08:11:15 UTC; box load 54 at start) |
| Phase lines | `verify`; `search --lines 64|1024 --variant real|skip2|nofeed`; `pebble --lines 10`; `store --lines 64 --brute-max 8`; `store --lines 1024 --brute-max 2`; `curve --lines 64`; `curve --lines 64 --ops-block 700`; `curve --lines 1024`; `avalanche --samples 1048576`; `census --day 2026-10-03`; `bench --n 20000000` |
| Logs | box `/srv/builds/igneum-wt-attack/attack-f3/run-r1.log` and `r1-<phase>.log`; Mac copies `/private/tmp/claude-501/-Users-joshm/cd75457f-4858-4f86-9634-7481ee056b7b/scratchpad/attack-f3/` |
Box hygiene: the box checkout of every `build-remote.sh` run on this worktree executes `git clean -fd` at `/srv/builds/igneum-wt-attack` (remote-run.sh `checkout_tree`), which deletes any untracked scratch directory there. `attack-f3/` and `attack-f3-venv/` are listed in that mirror's `.git/info/exclude` so they survive; nothing in the tree was touched. The F1 lane's `attack-f1-venv/` is untracked and unprotected and will be removed by the next build from any agent on this worktree.
## The two firings and the pass
| Chain | Direct parents (taint trace) | Lines under j + 1 at 64 lines | Cheapest derivations | Exhaustive pebbling at 10 lines, cost per target | Verdict | Log |
|---|---|---|---|---|---|---|
| real (the code) | j - 1 for all 63 lines after line 0 | 0 of 64 | none; every line costs exactly j + 1 (mean 32.5) | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 | PASS | `r1-search-real-64.log`, `r1-pebble-10.log` |
| real, 1,024-line model | j - 1 for all 1,023 lines after line 0 | 0 of 1,024 | none (mean 512.5) | same graph rule | PASS | `r1-search-real-1024.log` |
| skip2 (known fail A) | j - 2 for 62 lines, none for 2 | 63 of 64 | j = 1 in 1, j = 63 in 32 (mean 16.5) | 1, 1, 2, 2, 3, 3, 4, 4, 5, 5 | FIRE | `r1-search-skip2-64.log`, `r1-search-skip2-1024.log` |
| nofeed (known fail B) | none for all 64 | 63 of 64 | every line in 1 block (mean 1.0) | 1 x 10 | FIRE | `r1-search-nofeed-64.log`, `r1-search-nofeed-1024.log` |
`verify` (`r1-verify.log`): the model chain equals `Cache::fill_segment` on keys {day 2026-10-03, 3 random} x segments {0, 1, 12345, 65535} (16 of 16), `block == chacha_block` on 100,000 of 100,000 random inputs, the 1,024-line model's first 64 lines equal the 64-line chain, and both broken variants differ from the real chain from line 1 (line 0 equal, as the rule predicts). The block's word dependency matrix is full on every variant (256 of 256 pairs), so the firings come from the chain rule alone.
## Derivation cost per line on the model segment (real chain, nothing stored)
| j | blocks to derive line j | j + 1 | Log |
|---|---|---|---|
| 0 | 1 | 1 | `r1-search-real-1024.log` |
| 1 | 2 | 2 | |
| 3 | 4 | 4 | |
| 7 | 8 | 8 | |
| 15 | 16 | 16 | |
| 31 | 32 | 32 | |
| 63 | 64 | 64 | (the last line of a real segment; `r1-search-real-64.log` lists all 64) |
| 127 | 128 | 128 | |
| 255 | 256 | 256 | |
| 511 | 512 | 512 | |
| 1,023 | 1,024 | 1,024 | |
All 1,024 lines were searched (0 under j + 1, mean 512.5 = (L + 1) / 2); the 64-line table in `r1-search-real-64.log` has every j from 0 to 63 at exactly j + 1.
## Store patterns on the real 64-line segment
Blocks per read averaged over j uniform in 0..63. "Naive" is `funding.md` B2 rank 2's pattern (every k-th line from line 0). "Optimal" is the exact minimum over store sets of that size (DP; brute force over every set for n up to 8, agreeing with the DP on every row it ran). The gap formula equals the closure cost on the extracted graph on 2,000 of 2,000 random stored sets (`r1-store-64.log`).
| f | Stored lines n | SRAM held | Naive blocks per read | Optimal positions | Optimal blocks per read | Brute force over C(64, n) sets |
|---|---|---|---|---|---|---|
| 1/64 | 1 | 4 MiB | 31.5 | [32] | 16.0 | 16.0 (64 sets) |
| 1/32 | 2 | 8 MiB | 15.5 | [21, 43] | 10.5 | 10.5 (2,016 sets) |
| 1/16 | 4 | 16 MiB | 7.5 | [12, 25, 38, 51] | 6.094 | 6.094 (635,376 sets) |
| 1/8 | 8 | 32 MiB | 3.5 | [7, 15, 22, 29, 36, 43, 50, 57] | 3.172 | 3.172 (4,426,165,368 sets, 11.2 s) |
| 1/4 | 16 | 64 MiB | 1.5 | [3, 7, 11, ..., 55, 58, 61] | 1.453 | not run (DP exact) |
| 1/2 | 32 | 128 MiB | 0.5 | odd lines | 0.5 | not run |
| 1 | 64 | 256 MiB | 0 | all | 0 | not run |
The 1,024-line model (`r1-store-1024.log`) gives 29.68 / 15.05 / 7.40 / 3.48 / 1.50 / 0.5 / 0 at the same f on the optimal pattern: the naive and optimal patterns converge as the chain lengthens, because the wasted stored line 0 and the end effects are a smaller share.
## The curve: ops per item against the fraction of cache lines held (real 64-line segment)
Ops per item = 9,360 (the 72 mixer applications, hoisted, plus the fold: chip-model-v3.md 5.2) + 8 reads x blocks per read x ops per block. `r1-curve-64.log` (608 ops per block, counted) and `r1-curve-64-memhard.log` (700, MEMHARD.md item 4). Ops per hash = 128 x ops per item + 512. MH/s at the chip model's 50 T op/s budget (approximate, chip-model-v3.md section 1).
| f (lines held) | SRAM | Blocks per read, naive / optimal | Ops per item, naive, 608 | Ops per item, optimal, 608 | Ops per item, optimal, 700 | Ops per hash, optimal, 608 | MH/s at 50 T op/s, optimal, 608 |
|---|---|---|---|---|---|---|---|
| 1/64 | 4 MiB | 31.5 / 16.0 | 162,576 | 87,184 | 98,960 | 11,160,064 | 4.5 |
| 1/32 | 8 MiB | 15.5 / 10.5 | 84,752 | 60,432 | 68,160 | 7,735,808 | 6.5 |
| 1/16 | 16 MiB | 7.5 / 6.094 | 45,840 | 39,000 | 43,485 | 4,992,512 | 10.0 |
| 1/8 | 32 MiB | 3.5 / 3.172 | 26,384 | 24,788 | 27,122 | 3,173,376 | 15.8 |
| 1/4 | 64 MiB | 1.5 / 1.453 | 16,656 | 16,428 | 17,498 | 2,103,296 | 23.8 |
| 1/2 | 128 MiB | 0.5 / 0.5 | 11,792 | 11,792 | 12,160 | 1,509,888 | 33.1 |
| 1 | 256 MiB | 0 / 0 | 9,360 | 9,360 | 9,360 | 1,198,592 | 41.7 |
Monotone: ops per item is non-increasing in f on both patterns at both op counts and on the 1,024-line model (`CURVE ... monotone non-increasing` in all three curve logs). The f = 1 point: 9,360 ops per item, 1,198,592 ops per hash, 41.7 MH/s at 50 T op/s, which is the chip-model-v3.md section 5.4 row "none, f = 0" of the published ITEM curve (the on-die-cache recompute chip of sections 1 to 3). The published item curve stores dataset ITEMS and is a different curve: its f = 1 point (GDDR7, 166.4 MH/s, 0.466 microjoules per hash) contains no cache read and no mixer op, so nothing in this row touches it. `funding.md` B2 rank 2's arithmetic reproduces on the naive pattern at 700 ops per block: 3.5 blocks per read, 2,450 ops per line, 19,600 per item on top of the mixer, 13.5 MH/s (50 T / (128 x 28,960 + 512)).
## Measured times, one box core (`r1-bench.log`, `r1-census.log`; nice 10, cores 12-15,60-63, box load 54)
| What | Measured | Note |
|---|---|---|
| One ChaCha12 block, dependent chain of 20,000,000 | 66.64 ns | |
| One mixer application (class v4 parameters), dependent chain of 20,000,000 | 17.29 ns | block / application = 3.85 (counted ops 608 / 128 = 4.75) |
| One item against the 256 MiB cache, batches of 32 | 1,326 ns | 72 applications = 1,245 ns; the 8 dependent reads and the fold add 81 ns because the batch overlaps them |
| One line recomputed from nothing, 312,500 random (seg, j) | 2,734 ns | 32.5 blocks per line on average, 84.1 ns per block inside the chain |
| The 256 MiB cache fill, one thread | 0.36 to 0.4 s | 86 ns per block with the writes |
In measured time, holding every 8th line at the optimal placement makes an item cost 72 + 8 x 3.172 x 3.85 = 170 mixer-application equivalents against 72, a 2.36x penalty per item (2.65x in counted ops). Holding one line in 64 costs 72 + 8 x 16 x 3.85 = 565, a 7.8x penalty.
## Checks on the real function (`r1-avalanche.log`, `r1-census.log`)
| Check | Result |
|---|---|
| Single-bit avalanche of `chacha_block`, 1,048,576 flips | mean 256.00 of 512 output bits change (ideal 256), min 200, max 312 |
| (output word, input word) pairs where an output word did not change | worst count 0 of 1,048,576 |
| Chain step: one bit of line j - 1 flipped | line j changes 255.96 bits, line j + 1 changes 255.94 (131,072 flips) |
| Differential independence: B(x ^ d) ^ B(x) == B(y ^ d) ^ B(y) over 262,144 (x, y, single-bit d) | 0 cases |
| Census of the real cache, day key 2026-10-03 | 4,194,304 of 4,194,304 lines distinct, 0 all-zero lines: no two chains merge and no block input repeats |
## Gate
| Clause | Result | Where |
|---|---|---|
| No derivation under j + 1 blocks | 0 of 64 and 0 of 1,024 lines under j + 1 on the extracted graph; the formula exact on 10,240 of 10,240 exhaustive pebbling cases; both known-fail chains fire | `r1-search-real-64.log`, `r1-search-real-1024.log`, `r1-pebble-10.log` |
| The curve monotone | non-increasing on both patterns, both op counts, both segment lengths | the three `r1-curve-*.log` |
| The f = 1 point unchanged | 9,360 ops per item = chip-model-v3.md 5.4 "none, f = 0" row; the item curve's GDDR7 f = 1 row (166.4 MH/s, 0.466 microjoules) untouched | `r1-curve-64.log` |
Verdict: PASS.
## Observations that are not findings
| Observation | Number | What it means | What I propose |
|---|---|---|---|
| `funding.md` B2 rank 2 prices the honest trade-off at the naive placement | 3.5 blocks per read at f = 1/8 against 3.17 optimal (9.4 percent less); 31.5 against 16.0 at f = 1/64 (2.0x less, because storing line 0 is worthless: it costs 1 block anyway) | the chip at f = 1/8 reads 15.8 MH/s (608 ops per block, optimal placement) or 14.4 (700, optimal) against `funding.md`'s 13.5 (700, naive); still 0.38x of the full SRAM mirror's 41.7 and 0.12x of the 5090's 136.1 (chip-model-v3.md section 2); the curve stays monotone, so the published verdict (the partial chip is not the threat, the full mirror beats it) stands | one sentence in `funding.md` B2 rank 2: "holding every 8th line at the best placement costs 3.2 blocks per read (3.5 for every 8th line from line 0)". Not edited here: outside this row's two files; for main to serialise |
| The chain's hardness per line is sequential time, not memory | one pebble (64 bytes) over j + 1 steps: the cumulative memory of deriving a line is about 64 x (j + 1) byte-steps | the chain protects the cache by op count, which is exactly what the curve prices in ops; parallel attackers pipeline items and pay E(f) x 608 ops per read in throughput, E(f) block latencies in latency; a chip that holds nothing (f = 0) pays 32.5 x 608 = 19,760 ops per read, 158,080 per item, 167,440 with the mixer (17.9x the mixer alone), 2.3 MH/s at 50 T op/s | nothing to move; the public model should keep quoting ops, never bytes, for this piece |
| What this row does not cover | a cryptanalytic shortcut inside `chacha_block` in this chaining mode (the differential and avalanche tests are sanity checks, not a bound) | the paid engagement's rank 2 question (`funding.md` B2) stays worth the money; plan 4.2 says the internal pass cannot prove the chain's trade-off curve | none |
## Consequences per user tier
| Tier | What this row changes |
|---|---|
| Home miner, one 8 / 12 / 16 / 24 or 32 GB card, any vendor, any OS | nothing: the honest miner holds the dataset, the verifier holds the 256 MiB cache; no memory, hash rate, or power figure moves |
| Rig, pool user | nothing |
| Chip builder | the partial-cache chip is priced 9 percent better at f = 1/8 and 2x better at f = 1/64 than `funding.md` says, and is still worse than the full SRAM mirror at every f below 1; the public per-joule sentence (evidence row 17, 2.1x at k = 1) rests on the item curve's f = 1 point, which this row leaves untouched |
| The paid review | the firm receives this record and the harness; rank 2's open question is the block function in chaining mode, not the graph |

View file

@ -0,0 +1,309 @@
# F4. The weak-day census: 2^24 day keys through `MixParams::with_shape`
Attack pass row F4 (`docs/plans/cryptanalysis.md` section 4.2; the gate is section 1.4 (3) and `funding.md` B5
rank 3; the threat is `funding.md` B2 rank 3). Run 7 October 2026, 09:10 to 09:55 UK, on igneum-build-1 by the
attack-f4 agent (the verifier timing row of 6.6 queued behind other lanes' holds). Every number below cites its log.
## Verdict
**PASS on the gate read against M2, the DSP-bound per-day datapath (0 days over 1.1x in 2^28), and on every named
weak class; the generous bound M1 (every multiply in LUT adders) exceeds the gate at 3.26e-4 of days as the tail of a
sum, not a class, and is routed to main as a bound finding with a rejection-and-redraw rule for the next class.
Class v4 is not changed.**
Which metric the 1.1x gate reads against, and why: M2. The gate (plan 1.4 (3)) asks for the fraction of days in a
weak class, and M1's excess has no class behind it (section 6.2: the exact 16-fold convolution of one random NAF
weight predicts the census to 0.6 percent). A per-day FPGA attacker who builds the 16 multiplies in LUT shift-add
trees is building the slower design: those trees are 72 percent of M1's cost (167 of 231 adders), and DSP blocks
take that cost off the fabric, so the design that wins is DSP-bound, where the day's constants move nothing unless a
word has NAF weight at most 3, which happens on no day in 2^28 for two words. M1 is still reported in full because
the brief asks for the generous bound, and because a two-line rule closes it for nothing.
| Metric | Days over 1.1x in 2^24 | Fraction | Days over 1.1x in 2^28 | Fraction | Gate 2^-20 = 9.54e-7 | Log |
|---|---|---|---|---|---|---|
| M1: per-day LUT datapath, adders per mixer application, against the census median | 5,476 | 3.264e-4 | 87,426 | 3.257e-4 | OVER, by 342x | `census-2p24.md`, `census-2p28.md` gate table |
| M1 exact expectation (16-fold convolution of the NAF-weight table over all 2^31 odd constants) | 5,441 | 3.243e-4 | | | the census is the tail of a smooth sum, not a class | `expect-231.log` last line |
| M2: DSP-bound datapath, 16/(16 - k), k = words of NAF weight at most 3 | 0 | 0 | 0 | 0 | under | `census-2p24.md`, `census-2p28.md` M2 table |
| ROT value and RC value on a per-day datapath | 0 | 0 | 0 | 0 | under (exact 0 ops moved, section 3) | section 3 |
The gate as written fails under M1 only. What M1 finds is not a weak class: the per-day cost of the 16 constant
multipliers is a sum of 16 NAF weights (mean 231.1 adder-equivalents per application, sd 6.19), and 1 day in 3,070
sits 3.4 sigma below the median, where a bitstream synthesised for that day pays 10 to 19 percent fewer adders. The
worst day in 2^28 reads 1.19x (day 27,952,752, cost 194). The exact expectation predicts the census to 0.6 percent.
Section 7 prices the consequence (0.004 percent more hashes a year for an all-LUT FPGA that re-synthesises every
day, nothing for a chip or a GPU) and section 8 gives the rejection-and-redraw rule that closes it.
## 1. Target
| Item | Value |
|---|---|
| Commit | 924288d1 (the brief); the worktree HEAD moved to 11b375a0 during the pass (F5 and F6 records); `git diff 924288d1 11b375a0 --stat -- igneum-pow/src` is empty, so the target code is the same |
| Code | `igneum-pow/src/memhard.rs` `MixParams::with_shape` (lines 189 to 215): `SplitMix64::new(key[0] as u64 \| (key[1] as u64) << 32)`, then `ROT[0..7] = 1 + below(31)`, `MUL[0..15] = next() as u32 \| 1`, `RC[0..15] = next() as u32`; no rejection rule |
| Day key | `bind::day_bytes(d) = "igneum-day/" \|\| d_le64`, `key = seed_words_from_bytes(day_bytes)` (the interim day rule, `bind.rs` lines 30 to 68); the genesis day index is 20,729 (`bind.rs` test `day_bytes_layout`) |
| Shape | `Shape::for_class(&V4_CLASS)`: mixer x8, cache 2^26 words, no derivation program (asserted by the harness) |
| Mixer | `memhard::mixer`: per word `(s ^ (RC + rk)) * MUL`, then one ChaCha double round with `ROT[0..3]` on the columns and `ROT[4..7]` on the diagonals; 72 applications per item under x8 |
| Census set | 2^24 consecutive chain days from 20,729 (the gate run), and 2^28 (the extended run); the first 36,525 of them are the chain's public calendar for the next 100 years under the interim rule |
The 64-bit seeding fact (F7 covers the spec's intent): the 40 draws depend on `key[0] | key[1] << 32` alone, so the
stream can produce at most 2^64 distinct parameter sets whatever the key's other 192 bits hold. Over the 2^24 census
days the 64-bit seeds were all distinct (0 collisions, expected 7.6e-6; `census-2p24.md` "64-bit seeding" line).
`below(31)` is `next() % 31` without rejection: the bias per rotation value is 2^-64 and is ignored.
## 2. Known-failed shape
A day key whose drawn `ROT`, `MUL` or `RC` gives a fixed datapath a gain over 1.1x: all-equal `ROT` (31^-7 per day,
MEMHARD.md section 3 item 3, untested until now), `MUL = 1` (2^-31 per word), pairs summing to 32, small rotation
amounts, low-weight multipliers, `RC + rk = 0`.
## 3. The gain metrics (exact, structural)
The verifier and every GPU run the same instructions on every day (`rotate_left` by a register amount, `wrapping_mul`,
no branch on a drawn value), so wall time cannot move with the draw; the only attacker a weak day helps is one who
builds the day's constants into logic. That is an FPGA bitstream synthesised per day (hours of compile against a
public calendar), never a taped-out chip. Costs are in 32-bit adder-equivalents per mixer application:
| Element of one application | Generic datapath | Per-day datapath |
|---|---|---|
| 16 x `s ^ (RC + rk)` | 16 | 0 (constant XOR: inverters, absorbed into the next LUT) |
| 16 x `* MUL` | 16 multipliers (value-independent) | M1: `NAF(MUL_i) - 1` adders each (canonical signed-digit shift-add); M2: a DSP block each, value-independent, except a word of NAF weight at most 3 moves to 2 LUT adders and frees its DSP |
| 8 quarter rounds: 32 adds, 32 XORs | 64 | 64 |
| 32 rotations | 32 barrel shifters | 0 (wiring) |
* **M1** `cost = 64 + sum_i (NAF(MUL_i) - 1)`; gain of a day = census median cost / the day's cost. The generous
bound: optimal single-constant multiplication is below NAF for every constant and the ratio between days is what
is measured.
* **M2** gain = `16 / (16 - k)` on a DSP-bound design, k the words of NAF weight at most 3.
* **ROT** and **RC** hand a per-day datapath exactly 0 ops at any value (wiring and inverters); on a generic
datapath a rotation costs the same at every amount and `RC + rk = 0` removes one XOR of 10,368 ops per item
(1.0001x). They are censused as structure, and the worst members are measured for diffusion (section 6), the
only other thing a rotation draw could move; a bit-exact verifier never lets a chip skip an application, so
diffusion is reported and is not a gain.
## 4. Harness
| Item | Path or line |
|---|---|
| Crate | `tools/attack/f4-weakday/` (`Cargo.toml` with `igneum-pow = { path = "../../../igneum-pow" }` and an empty `[workspace]`; `src/main.rs`); `igneum-pow` untouched |
| Build | `cd tools/attack/f4-weakday && IGNEUM_AGENT=attack-f4 bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f4" --out <scratch> -- build --release`; box binary `/srv/builds/igneum-wt-attack/tools/attack/f4-weakday/target/release/attack-f4`: sha256 `fda006d7...835f52` ran every census and firing (`build-1.log`); the rebuild `5eb081cf...7f0355` (`build-2.log`) removes one unused import and nothing else |
| Unit tests | `build-remote.sh --no-fetch -- test --release` on the box (`test-1.log`): 2 passed, 0 failed (`naf_weights`: 0, 1, 3, 7, 2^32 - 1, the alternating maximum 17, and the planted weight-3 constant; `genesis_day_draw_matches_memhard_md`: the string day `2026-10-03` draws `ROT 20 20 19 4 26 3 3 27`, MEMHARD.md section 1.1, through the same `with_shape` path the census uses) |
| Census (gate) | `flock -s /srv/builds/_locks/measure -c 'nice -n 10 taskset -c 16-21,64-69 attack-f4 census --from 20729 --count 16777216 --threads 12 --dedupe --out census-2p24.md'`; 4.2 s |
| Census (extended) | the same with `--count 268435456 --out census-2p28.md`; 68.6 s |
| Expectation tables | `attack-f4 expect --threads 12 --median 231` (every odd 32-bit constant: NAF weight and popcount, then the 16-fold convolution); 14.9 s |
| One day | `attack-f4 day --index <d> --median 231` |
| Firings | `attack-f4 plant alleq\|mul1\|mul1all\|mulnaf\|rc0\|rcrk0 --median 231` (the day 20,729 draw with one field forced through the crate's own hook) |
| Diffusion | `attack-f4 avalanche --index <d> --states 2048 [--plant-alleq r]` |
| Timing (exclusive hold) | `timing.sh` on the box under nohup: `flock -x -w 7200 /srv/builds/_locks/measure -c 'nice -n 19 taskset -c 16,64 igneum-pow bench --seed x --epoch-hex edc4fa84...fb07 --day-hex <day bytes> --program-class v4 --warps 100'` for day 20,729 and the worst day, A B A B. Process note: withdrawing the first attempt, one ad hoc ssh line used `pkill -f "<literal>"`, the banned shape, and killed its own shell (self-match); the relaunch used the bracket form. Nothing else was touched |
| Calendar | `attack-f4 census --from 20729 --count 36525 --threads 12 --out census-100y.md` (the chain's first 100 years) |
| Box logs | `/srv/builds/igneum-wt-attack/attack-f4/{run2.log, census-2p24.md, census-2p28.md, census-100y.md, expect-231.log, firings.log, avalanche.log, timing.log}` |
| Mac copies | `/private/tmp/claude-501/-Users-joshm/cd75457f-4858-4f86-9634-7481ee056b7b/scratchpad/attack-f4/box/` (the box directory was deleted once from under the pass at about 09:14 UK by another agent's worktree sync; everything was re-run and copied to the Mac the moment it ended; the re-run reproduced the first run line for line) |
## 5. The two firings (`firings.log`)
| Case | Classifier | Gain | Result |
|---|---|---|---|
| Known-pass: day 20,729 (the genesis day), `ROT [6, 25, 5, 25, 29, 11, 9, 21]`, NAF sum 178 | no weak class (only "pair sums to 32", 12 and 60 percent of all days) | M1 0.978x, M2 1.000x | passes, as it must |
| Known-fail: `plant mul1all` (all 16 `MUL = 1`) | `MUL any = 1` FIRED | M1 3.453x, M2 unbounded | FIRED over 1.1x |
| Known-fail: `plant mulnaf` (four words at NAF weight 3) | `MUL any NAF weight <= 3` FIRED | M1 1.145x, M2 1.333x | FIRED over 1.1x |
| `plant mul1` (one word `MUL = 1`) | `MUL any = 1` FIRED | M1 1.023x, M2 1.067x | flagged, under the gate: one word of 16 |
| `plant alleq` (`ROT` all 7) | `ROT all equal` FIRED | M1 0.978x (0 ops moved) | flagged; diffusion in section 6 |
| `plant rc0`, `plant rcrk0` | `RC any = 0`, `RC + rk = 0` FIRED | M1 0.978x (0 ops moved) | flagged |
## 6. Numbers
### 6.1 Classes over 2^24 days (`census-2p24.md`), with the 2^28 count (`census-2p28.md`)
Expected per day is analytic (independent draws); the NAF rows come from the exact table of `expect-231.log`.
| Class | Count 2^24 | Fraction | Expected per day | Expected count 2^24 | Count 2^28 | Worst member (day, M1 cost, M1 gain, M2 gain) |
|---|---|---|---|---|---|---|
| ROT all equal | 0 | 0 | 3.64e-11 (31^-7) | 0.001 | 0 | none |
| ROT distinct <= 3 | 534 | 3.18e-5 | 3.07e-5 | 515 | 8,229 | 2^28: day 49,986,853, 206, 1.121x, 1.000x |
| ROT distinct <= 4 | 26,010 | 1.55e-3 | 1.54e-3 | 25,783 | 412,698 | 2^28: day 208,103,482, 197, 1.173x, 1.000x |
| ROT max multiplicity >= 4 | 35,631 | 2.12e-3 | 2.35e-3 (first order) | 39,421 | 568,423 | 2^28: day 115,569,197, 200, 1.155x, 1.000x |
| ROT same-word pair sums to 32 | 2,062,481 | 0.1229 | 0.1229 | 2,062,288 | 32,997,484 | 2^28: day 97,502,921, 196, 1.179x, 1.000x |
| ROT any pair sums to 32 | 10,022,037 | 0.5974 | 0.6007 (approx., pairs not independent) | 10,078,561 | 160,353,891 | 2^28: day 27,952,752, 194, 1.191x, 1.000x |
| ROT all 8 in {1, 2, 30, 31} | 2 | 1.19e-7 | 7.68e-8 | 1.29 | 19 | day 14,330,190, 217, 1.064x, 1.000x |
| ROT >= 6 in {1, 2, 30, 31} | 1,761 | 1.05e-4 | 1.02e-4 | 1,716 | 27,651 | 2^28: day 181,528,254, 204, 1.132x, 1.000x |
| ROT >= 4 in {8, 16, 24} | 74,541 | 4.44e-3 | 4.46e-3 | 74,756 | 1,196,376 | day 5,517,722, 198, 1.167x, 1.000x |
| MUL any = 1 | 0 | 0 | 7.45e-9 | 0.125 | 4 | 2^28: day 196,441,106, 221, 1.045x, 1.067x |
| MUL any = 2^32 - 1 | 0 | 0 | 7.45e-9 | 0.125 | 1 | 2^28: day 39,988,645, 215, 1.074x, 1.067x |
| MUL any popcount <= 2 | 0 | 0 | 2.38e-7 | 4.0 | 57 | 2^28: day 218,029,468, 209, 1.105x, 1.067x |
| MUL any popcount <= 4 | 612 | 3.65e-5 | 3.72e-5 | 624 | 10,132 | day 7,275,755, 200, 1.155x, 1.000x |
| MUL any NAF weight <= 2 | 4 | 2.38e-7 | 4.62e-7 | 7.75 | 125 | 2^28: day 63,704,833, 205, 1.127x, 1.067x |
| MUL any NAF weight <= 3 | 216 | 1.29e-5 | 1.30e-5 | 218 | 3,515 | 2^28: day 247,161,685, 200, 1.155x, 1.067x |
| MUL any NAF weight <= 4 | 3,637 | 2.17e-4 | 2.20e-4 | 3,683 | 58,667 | 2^28: day 81,133,010, 198, 1.167x, 1.000x |
| MUL any < 256 | 22 | 1.31e-6 | 9.54e-7 | 16 | 262 | 2^28: day 241,187,962, 203, 1.138x, 1.067x |
| MUL two equal | 0 | 0 | 5.59e-8 | 0.94 | 18 | 2^28: day 223,900,428, 226, 1.022x, 1.000x |
| MUL M2 k >= 2 (gain >= 1.143x) | 0 | 0 | 7.9e-11 (C(16,2) x (8.12e-7)^2, approx.) | 0.0013 | 0 | none |
| RC any = 0 | 0 | 0 | 3.73e-9 | 0.062 | 1 | 2^28: day 109,542,046, 243, 0.951x, 1.000x |
| RC any popcount <= 4 or >= 28 | 5,186 | 3.09e-4 | 3.09e-4 | 5,180 | 82,804 | 2^28: day 53,303,116, 206, 1.121x, 1.000x |
| RC + rk = 0 for any of the 72 keys | 10 | 5.96e-7 | 2.68e-7 | 4.5 | 87 | day 3,194,363, 218, 1.060x, 1.000x |
| RC two equal | 1 | 5.96e-8 | 2.79e-8 | 0.47 | 8 | 2^28: day 182,857,055, 222, 1.040x, 1.000x |
Every class sits at its expectation (the largest deviation, "ROT max multiplicity >= 4", is against a first-order
bound). The worst member of every class owes its gain to its MUL draw (M1 is a MUL-only quantity); the class itself
moves nothing. No day in 2^28 has two words of NAF weight at most 3, so M2 never exceeds 1.067x.
### 6.2 The M1 tail: census against the exact expectation (`census-2p24.md`, `expect-231.log`)
| M1 cost per application | Gain vs median 231 | Days in 2^24 | Cumulative fraction, census | Cumulative fraction, exact |
|---|---|---|---|---|
| 197 (the 2^24 minimum, day 4,819,563) | 1.173x | 1 | 5.96e-8 | 8.18e-8 |
| 200 | 1.155x | 10 | 8.34e-7 | 8.62e-7 |
| 205 | 1.127x | 250 | 2.94e-5 | 2.87e-5 |
| 208 | 1.111x | 1,382 | 1.84e-4 | 1.83e-4 |
| 209 | 1.105x | 2,387 | 3.26e-4 | 3.24e-4 |
| 210 | 1.100x | 3,887 | 5.58e-4 | 5.64e-4 |
| 231 (median) | 1.000x | 1,079,174 | 0.522 | 0.522 |
Mean cost 231.113 (exact 231.111), sd 6.190 (exact 6.190). The 2^28 minimum is 194 (1.191x, day 27,952,752). A
single NAF weight has mean 11.44 and sd 1.55 over the 2^31 odd constants (`expect-231.log`).
### 6.3 ROT structure (`census-2p24.md` histograms)
| Distinct rotation amounts a chip must wire | Days in 2^24 | Fraction | Expected S(8,d) 31_d / 31^8 |
|---|---|---|---|
| 1 | 0 | 0 | 3.63e-11 |
| 2 | 4 | 2.4e-7 | 1.4e-7 |
| 3 | 530 | 3.16e-5 | 3.05e-5 |
| 4 | 25,476 | 1.52e-3 | 1.51e-3 |
| 5 | 421,405 | 0.0251 | 0.0251 |
| 6 | 2,773,843 | 0.1653 | 0.1653 |
| 7 | 7,302,781 | 0.4353 | 0.4351 |
| 8 | 6,253,177 | 0.3727 | 0.3729 |
Small amounts {1, 2, 30, 31} and byte-aligned amounts {8, 16, 24} follow Binomial(8, 4/31) and Binomial(8, 3/31) to
within 3 percent in every bin.
### 6.4 Diffusion of the worst members (`avalanche.log`: 2,048 states x 512 input bits, mean and minimum per-output-bit flip probability)
| Day | Why | ROT | After 1 application, mean / min | After 2, mean / min |
|---|---|---|---|---|
| 20,729 | genesis, same-word pair 11 + 21 = 32 | 6 25 5 25 29 11 9 21 | 0.461 / 0.383 | 0.500 / 0.498 |
| 4,819,563 | M1 worst in 2^24 | 26 18 8 30 24 24 6 9 | 0.467 / 0.426 | 0.500 / 0.499 |
| 27,952,752 | M1 worst in 2^28 | 11 26 11 7 6 20 20 3 | 0.460 / 0.392 | 0.500 / 0.498 |
| 11,482,247 | 3 distinct amounts, multiplicity 5 | 12 19 19 4 19 12 19 19 | 0.453 / 0.374 | 0.500 / 0.499 |
| 14,330,190 | all 8 amounts in {1, 2, 30, 31} | 1 1 2 31 1 31 1 31 | 0.331 / 0.196 | 0.4995 / 0.497 |
| 332,924 | NAF weight 3 word, three amounts of 1 | 1 23 1 1 12 11 16 18 | 0.458 / 0.370 | 0.500 / 0.498 |
| 196,441,106 | `MUL = 1` word (2^28) | 30 24 23 5 11 28 12 9 | 0.461 / 0.364 | 0.500 / 0.499 |
| 109,542,046 | `RC = 0` word (2^28) | 28 5 4 31 25 28 12 4 | 0.459 / 0.348 | 0.500 / 0.499 |
| planted all 1 | the worst all-equal draw | 1 x 8 | 0.345 / 0.216 | 0.500 / 0.499 |
| planted all 16 | half-word swaps | 16 x 8 | 0.387 / 0.312 | 0.500 / 0.499 |
| planted all 7 | | 7 x 8 | 0.464 / 0.400 | 0.500 / 0.498 |
The slowest draw that can exist (all rotations by 1, probability 31^-8 per day) reaches full avalanche after 2 of the
8 applications between cache reads; the worst real day in 2^28 (all amounts in {1, 2, 30, 31}) the same. No draw
gives an attacker a shorter dependency between reads than the round margin F2 measures.
### 6.5 The chain's first 100 years (`census-100y.md`: days 20,729 to 57,253 under the interim day rule)
| Item | Value |
|---|---|
| Days over 1.1x under M1 | 6 of 36,525 (1.64e-4; the 2^24 rate predicts 12) |
| First such day | 22,633 (genesis + 1,904 days, about 5.2 years in), cost 208, 1.111x |
| Worst day | 29,337 (genesis + 8,608 days, about 23.6 years in), cost 206, 1.121x |
| Days at exactly 1.100x (cost 210) | 6 more: 25,605; 28,102; 31,573; 33,710; 42,573; 54,884 |
| M2 k >= 2 | 0 |
| Genesis day 20,729 | cost 226, 0.978x; the next four devnet days (20,730 to 20,733) read 0.987x, 1.036x, 0.947x, 0.979x |
| Rotation structure | 1 day with 2 distinct amounts (57,146, genesis + 36,417, cost 225, 1.027x), 46 with 4, none with 3 or fewer otherwise; no day with a `MUL` of NAF weight under 4 |
### 6.6 Verifier time (exclusive hold, `timing.log`)
A confirmation row only: the verifier's code path is value-independent, so the exact metric is the op count above
and a wall-time difference between days can only be noise. Queued on the box at 09:47 UK (`timing.sh`, nohup, an
exclusive `flock -x -w 7200` behind the shared holds of F1, F2, F8, F9, F10 and F7 and the queued exclusive hold of
F6; the first attempt, queued 09:14 UK, was attached to a Mac ssh session and was withdrawn in favour of the nohup
job). Cores 16 and 64, nice 19, `--warps 100`, day 20,729 against day 4,819,563 (the 2^24 M1 worst), A B A B.
| Day | Cold warp 0 (ms) | Average per warp, 100 warps (ms) |
|---|---|---|
| 20,729 (genesis) | pending (`timing.log`) | pending |
| 4,819,563 (M1 worst, 1.173x) | pending (`timing.log`) | pending |
The verdict does not rest on this row.
### 6.7 The worst days in full (`worst-days.log`, `export-29337.log`)
The worst day in adders per application against the census median, in each set. M1 is the sum of the 16 NAF weights
less 16 plus 64. Every one is an ordinary draw whose 16 weights happen to sum low; none has a word under NAF weight 7.
| Set | Chain day | Years after genesis | ROT | MUL words (hex) | NAF weights | M1 cost | Gain vs median 231 | M2 |
|---|---|---|---|---|---|---|---|---|
| The public calendar, first 36,525 days (what an auditor runs) | 29,337 | 23.6 | 24 12 18 11 14 26 29 21 | 3fe4d03b 227c2043 06011627 40c10137 00234d99 063071d9 91e5abb7 035240b1 f40bfe47 809251b9 1ce999ef 940b381d da13a021 f75f8ba7 3f59bca7 01310e05 | 9 8 9 8 10 10 12 10 9 10 12 11 10 11 11 8 (sum 158) | 206 | 1.121x | 1.000x |
| 2^24 (the gate census) | 4,819,563 | 13,139 | 26 18 8 30 24 24 6 9 | a0653c83 a09de525 810085fb 6a00eba1 bf8205ff bba82079 f27da4c3 2cb80223 6001efcf 1c2814f7 ae9d09d7 ffedd7b7 943dde01 39ff47e1 0513a83f c028eef9 | 11 11 7 10 7 10 12 10 7 9 13 8 8 8 9 9 (sum 149) | 197 | 1.173x | 1.000x |
| 2^28 (extended) | 27,952,752 | 76,481 | 11 26 11 7 6 20 20 3 | f15eb273 227a08f1 20f822e1 6d477779 8d9b3aff 03040503 27fff521 bfd9ce7d 7708000d 5d60ba11 2d40005b f07e10d7 1deefdb1 4881e821 01e1fc71 3ee7c39b | 13 9 8 11 11 7 7 11 7 11 9 9 8 8 7 10 (sum 146) | 194 | 1.191x | 1.000x |
Reproduction, through the harness: `attack-f4 day --index 29337 --median 231` (and 4819563, 27952752). Through
`igneum-pow` itself, with the day bytes `"igneum-day/" || d_le64` as hex (day 29,337 = 0x7299):
`igneum-pow export --seed x --epoch-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --day-hex 69676e65756d2d6461792f9972000000000000 --program-class v4 --out <dir>`
writes the day's constants into the pack's `memhard.h` as `IGNEUM_MIX_ROT_INIT` and `IGNEUM_MIX_MUL_INIT`; run on the
box at 09:52 UK (`export-29337.log`, OVERALL PASS, cache FNV-1a 64 `1979492fb76b52ce`), the pack's 8 rotations and
16 multipliers equal the harness's word for word. The day-hex strings of the other two days are in section 6.2's
source list (`census-2p24.md` and `census-2p28.md`, "The 16 lowest-cost days"): `...2f6b8a490000000000` and
`...2f7086aa0100000000`.
## 7. Gate line and consequences
Gate (plan 1.4 (3)): the fraction of days with any gain over 1.1x under 2^-20.
| Model | Fraction over 1.1x | Gate | What the number means per tier |
|---|---|---|---|
| M1 (per-day LUT bitstream) | 3.26e-4 (1 day in 3,070; 2^24 and 2^28 agree; exact expectation 3.24e-4) | FAIL by 342x | An FPGA farm that re-synthesises its bitstream every day gains 10 to 19 percent on those days: 3.26e-4 x about 0.12 = 4e-5 of a year's hashes, 0.004 percent. The FPGA lane is already behind every GPU tier on reads per watt (F5: 10 to 20 M reads/s/W against the gate's 27 M), so no home miner (8, 12, 16, 24 or 32 GB), rig or pool on any vendor or OS sees a competitor appear, and no day's difficulty moves by a measurable amount |
| M2 (DSP-bound FPGA) | 0 in 2^28 | PASS | nothing moves for any tier |
| Chip (programmable constants, the chip-model-v3 recompute chip) | 0 by construction | PASS | nothing moves; a taped-out chip cannot specialise per day |
| GPU and the CPU verifier | 0 by construction | PASS | every tier pays the same ops on every day |
What the worst day buys, priced for the per-day LUT datapath (the M1 attacker) on the worst calendar day, 29,337:
| Item | Value | Source |
|---|---|---|
| Fewer adders per mixer application that day | 231 to 206, 10.8 percent fewer | section 6.7 |
| Item derivations per unit of fabric that day | 1.121x (M1 gain) | section 6.7 |
| Hash rate of a recompute FPGA (items derived per hash, the `chip-model-v3` ops-per-hash attacker) that day | up to 12.1 percent above its ordinary day, an upper bound: the 128 dependent cache reads per hash and the shadow block are untouched by the draw, so the whole-hash gain is below the mixer's | `chip-model-v3.md` section 1 (ops per hash = 128 x 72 x 130); section 3 |
| Hash rate of the stored-dataset (f = 1) FPGA or chip that day | 0 (it derives no items per hash; the mixer is paid once in the daily build) | `funding.md` B2 rank 2 |
| Days a century at or over 1.1x | 12 (6 over, 6 at exactly 1.100x) | section 6.5 |
| Share of a century's hashes the M1 attacker gains | 12 / 36,525 x about 0.11 = 3.6e-5, 0.004 percent | arithmetic on the rows above |
| What one bitstream a day costs | one place-and-route of a large part: 42 to 160 minutes on a mid-size part (PRflow, FPT 2019, cited in spec 01 section 1.13), hours on a large one; on a rented 96-thread box (Hetzner AX162 class, about USD 0.35 per hour, approximate) under USD 3 per bitstream (approximate), and it compiles any time ahead because the calendar is public | spec 01 section 1.13; price approximate |
So the bitstream is cheap and the gain is 0.004 percent of a century for the slower of the two FPGA designs: nothing
a home miner on any card, a rig or a pool on any vendor or OS can see, and nothing that moves a day's difficulty.
What is being done about the M1 line: class v4 is not changed (it is the object on the live devnet's vote). The
rejection-and-redraw rule of section 8 is proposed to main for the next class, unless main reads the census as a
fault beyond the metric (this row does not: every class sits at its expectation and the worst day is an ordinary
draw). The rule costs one redraw on 5.6e-4 of days, changes no existing vector (day 20,729 has NAF sum 178; the first
day the rule would redraw is 22,633, about 5.2 years after genesis, section 6.5), and makes the M1 gate pass by
construction. `igneum-pow` is untouched by this row.
## 8. Proposed fix for the next class: a rejection-and-redraw rule on the MUL draw (for main's decision)
Shape, like the program acceptance rule 1.4.6 and `DeriveProgram::check`: draw the 16 `MUL`, test, and on rejection
continue the same stream with 16 fresh draws (so every later draw keeps its position only within an accepted block;
`RC` is drawn after the accepted `MUL` block). Tests, in order:
| Rule | Threshold | Rejection probability per candidate | What it closes |
|---|---|---|---|
| Sum of NAF weights of the 16 `MUL` at least 163 (M1 cost at least 211, gain at most 1.095x against the median 231) | `sum_i NAF(MUL_i) >= 163` | 5.64e-4 (`expect-231.log` cumulative at cost 210) | the M1 tail: no day over 1.1x by construction |
| Every `MUL` of NAF weight at least 4 | `NAF(MUL_i) >= 4` | 1.30e-5 per day | `MUL = 1`, `2^32 - 1`, `2^a +- 1`, `2^a +- 2^b +- 1`: the M2 words (hygiene; M2 already passes) |
| `ROT`: at least 4 distinct amounts (the `DISTINCT_ROTS_FLOOR` idea of `derive.rs`) | `distinct >= 4` | 3.07e-5 per day | the degenerate rotation draws (hygiene; 0 ops moved, diffusion fine at 2 applications) |
Total rejection about 6.1e-4 per day: one redraw every 4.5 years of chain time; `MAX_ATTEMPTS`-style exhaustion is
impossible in practice (64 rejections in a row at 6e-4 each). Class check to land with it: a unit test in `memhard.rs`
that plants a low-sum draw (stream seed chosen so the first MUL block fails) and asserts the redraw, plus this
harness re-run over 2^24 showing 0 days over 1.1x under M1 after the rule. The reproduction line for the finding
without the rule: `attack-f4 day --index 4819563 --median 231` (cost 197, 1.173x) and
`attack-f4 day --index 27952752 --median 231` (cost 194, 1.191x).
Consensus consequence: the rule changes the day-key-to-constants map on rejected days only, so it must land before the
freeze tag. In the chain's first 100 years the sum rule redraws 12 days (6 under 1.1x and 6 at exactly 1.100x,
section 6.5), the first of them 22,633, about 5.2 years after genesis; no pack cut for the devnet, the testnet or the
first five years of mainnet changes. The per-word rule redraws no day in the first 100 years (no word of NAF weight
under 4 in `census-100y.md`); the `ROT` rule redraws one, day 57,146 (2 distinct amounts, 99.7 years in).
## 9. What this row did not do
* It did not time the verifier per day beyond the confirmation row of 6.6: the verifier's code path is
value-independent (no branch on a drawn value), so op counts are the exact metric.
* It did not search optimal single-constant multiplication costs (not computable at 2^28 scale); NAF is the standard
canonical bound and the ratio between days is what the gate asks.
* It did not census the era draw (F7) or the spec's intent for the 64-bit seeding (F7); the fact is stated in section 1.

View file

@ -0,0 +1,229 @@
# F6: the verifier's worst case over 10^5 class v4 programs
Attack-pass row F6 (`docs/plans/cryptanalysis.md` 4.2; the record `docs/analysis/attack-pass-2026-10.md`), the box search.
The O-1.14 laptop relay run is not in this record (main runs it separately). Written 7 October 2026.
## Target
| Item | Value |
|---|---|
| Commit | `924288d1` on branch `attack-pass` (`igneum-pow` is byte-identical at the worktree HEAD `8e36faf6`: `git diff --stat 924288d1..HEAD -- igneum-pow` is empty) |
| Class | `--program-class v4`: generator 4 on `V4_CLASS` = `mx8+sh256x27` (`LoadClass::MX8` plus `ShadowClass { instrs: 256, reps: 27 }`), no era bytes (the same draw `igneum-pow bench --program-class v4 --seed S` makes) |
| Dataset | day `2026-10-03`, `Shape::for_class_day(V4_CLASS, 0)`: cache 2^26 words (256 MiB), mixer x8, dataset 2^28 words, memory-hard |
| Work per hash | 64 base instructions x 8 iterations (16 loads) plus 256 shadow instructions x 27 passes x 8 iterations = 55,296 shadow instructions, 101,192 counted ops at the 1.83 convention |
| Gate | 10 ms per 32-lane warp, cold, on the half-core proxy (plan 1.4 item 6; spec 01 section 1.9 and 1.11; `algorithm.md` 3.3 and 5.5) |
| Programs | 10^5 deterministic string seeds `attack-f6/0` to `attack-f6/99999` through the class v4 chain draw with its acceptance rule (5.22 percent needed a second or third attempt, max attempt 3) |
The era draw is not in the search: `generator.rs` draws the shadow block from `NONLOAD_WEIGHTS` with no era perturbation
(no `perturb` path exists in the code at this commit), and the era parameters change only the load addressing, not the op
counts. Every drawn program has exactly 48 non-load base instructions and 256 shadow instructions, so the verifier's cost
differs between programs only through the family mix (the per-family cost on the CPU) and the data.
## Known-failed shape
A drawn program whose verifier warp exceeds 10 ms cold on the half-core proxy. The acceptance rule (spec 1.4.6) bounds the
miner's side (distinctness, bias, saturation); nothing bounds the verifier's cost per program, and the half-core headroom
of the average program is 1.8 ms (`algorithm.md` 5.5), so a family mix that costs the CPU interpreter more than the average
could cross the gate.
## Harness
`tools/attack/f6-verifier/` (its own cargo crate, `igneum-pow` as a path dependency, the same release profile as the CLI:
opt-level 3, LTO, one codegen unit). It builds the day's dataset ONCE (`DatasetSource::new_shape`, 0.58 s on the box) and
swaps programs under it: `Epoch { program, dataset }` is only the pair, and `verify::hash_warp(&program, base, &dataset)`
takes both, so one dataset serves every program. The naive path (`igneum-pow bench` per seed) refills the cache every
time (370 ms) and would take 10 hours per core.
| Command | What it does |
|---|---|
| `attack-f6 scan --program-class v4 --count N --start S --threads T --cold-reps R --flush swap --out F` | program i = seed `attack-f6/<S+i>`; one CSV line per program: generation time, R timed warps (each after a flush), their min, the op counts by family for the base and the shadow block |
| `attack-f6 time (--program-class v4 \| --class dr736) --seeds-file F --cold-reps R --steady W --flush sweep` | the deep re-time: R cold warps (each after a 256 MiB write sweep, what the cache fill does before `bench`'s "single cold run"), max, median, min, a steady average of W warps, and `GATE 10 ms PASS/FAIL` on the max |
| `attack-f6 micro --program-class v4` | the genesis program with its shadow block rewritten to one family at a time against the same program with no shadow: the per-family cost of a shadow instruction (ranking weights only) |
| `attack-f6 load --program-class v4 --seconds 0` | hashes class v4 warps on the calling core until killed: the SMT sibling's load for the half-core proxy, the same class the 3.3 proxy ran on both siblings |
| `rank.py --scan ... --weights ... --column ... --top 50 --out-prefix P` | the proxy ranking (sum over families of weight x (8 x base count + 216 x shadow count)), the distribution (min, median, p99, p99.9, max with the seed), the worst-N lists, a no-intercept regression of time on the family counts |
Build line (from the crate directory):
`IGNEUM_AGENT=attack-f6 bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f6" --out <scratch> -- build --release`
(build 08:04 to 08:06 UTC, rustc 1.99.0, binary sha256 `89c35674...17f3f9`, on the box at
`/srv/builds/igneum-wt-attack/tools/attack/f6-verifier/target/release/attack-f6`).
Box scratch: `/srv/builds/igneum-wt-attack/target-attack-f6/` (not the `attack-f6/` the brief named: that path is an
untracked directory in the worktree mirror and `build-remote.sh`'s checkout step runs `git clean -fd` on the mirror
before every build of this worktree by any agent, which removed it once at 08:04 UTC; `target-*` is on the clean's keep
list, so this name survives). Logs there: `phase1.log`, `scan-0.csv`, `scan-50000.csv` (phase 1), `phase2a.log`,
`clock-2a.log`, `full-0.csv`, `phase2b.log`, `clock-2b.log`, `full-50000.csv`, `phase2c.log`, `clock-2c.log` (phase 2),
`smoke.log` (the functional check). Copies on the Mac under the session scratchpad `attack-f6/`.
Run lines:
| Phase | Hold | Cores | Line |
|---|---|---|---|
| 1, pre-screen (counts and a coarse time, no timing claim) | `flock -s` per 50,000-program chunk (2.6 min each) | `nice -n 10 taskset -c 42-47,90-95`, 12 threads | `attack-f6 scan --program-class v4 --count 50000 --start {0,50000} --threads 12 --cold-reps 2 --flush swap` |
| 2A, firings, micro, full pass first half | `flock -x` | `nice -n 19 taskset -c 40`; half-core: core 88 running `attack-f6 load` | `phase2a.sh` |
| 2B, full pass second half, worst 50 by proxy and worst 50 by coarse time re-timed on both proxies | `flock -x` | same | `phase2b.sh` |
| 2C, worst 1,000 by the full one-core pass on the half-core; worst 10 deep re-timed on both proxies | `flock -x` | same | `phase2c.sh` |
The clock of cores 40 and 88 (`scaling_cur_freq`) and the load average were read every 5 s during every exclusive hold
(`clock-2*.log`).
## The two firings (batch A, exclusive hold taken 08:15:20 UTC, core 40 at 3,799.9 MHz throughout, `clock-2a.log`)
`attack-f6 time ... --cold-reps 5 --steady 20 --flush sweep`, the gate applied to the max of the 5 cold warps
(`phase2a.log`). Lane-0 vectors equal the known ones (dr736 `e23d389f3eea0c83`, the Mac's).
| Case | Proxy | Cold max / median / min (ms) | Steady, avg of 20 | Known value (`algorithm.md` 3.3) | Verdict |
|---|---|---|---|---|---|
| known-fail, `--class dr736`, genesis seed | one core | 10.284 / 9.982 / 9.952 | 9.562 | 10.51 cold, 9.76 steady | FAIL (fired) |
| known-fail, `--class dr736`, genesis seed | half-core | 14.135 / 13.795 / 13.400 | 13.225 | 15.49 | FAIL (fired) |
| known-pass, `--program-class v4`, genesis seed | one core | 5.156 / 5.140 / 5.135 | 4.909 | 5.06 cold, 4.90 steady | PASS (fired) |
| known-pass, `--program-class v4`, genesis seed | half-core | 8.624 / 8.510 / 8.389 | 8.268 | 8.23 | PASS (fired) |
The harness reads the known-fail class over the gate and the known-pass class under it on both proxies, within 2 percent
of the 3.3 one-core numbers and within 9 percent on the half-core (the earlier half-core run loaded the sibling with
`igneum-pow bench` of the same class; this one hashes class v4 warps on it continuously).
## What one program can move (batch A, `micro`, core 40 solo)
The genesis class v4 program with no shadow block: 4.511 ms steady; with its drawn shadow: 4.920 ms. So the whole 55,296-
instruction shadow block costs 0.41 ms per warp on this core (7.4 us per 1,000 shadow instructions; `model.py` carries
7.0) and the base program with its 128 loads and 4,096 item derivations costs the other 4.5 ms. The verifier's cost is
92 percent dataset derivation (the x8 mixer, 8 dependent cache reads per item), which no drawn program changes: every
class v4 program has 16 loads and the acceptance rule's distinctness test keeps the items per warp near 4,096. The family
mix of the shadow can move at most a fraction of 0.41 ms. The per-family rewrite (the 256 shadow instructions all one
family) reads add 4.798, sub 4.703, xor 4.683, rotl 4.818, mad 4.805, shfl 5.042, rotr 5.160 ms per warp; the mul,
mulhi and or rows (0.97, 1.55, 1.13 ms) are degenerate (the registers collapse to 0 or all-ones, every lane then loads
the same item and the memory side vanishes) and are not ALU costs. Batch A's micro line printed its per-1,000 column
1,000x too small (ns per instruction); fixed in the source, the numbers above are the ms-per-warp column, which is right.
## Full one-core pass A (batch A, 50,000 programs, core 40 solo, one cold warp each after a program swap, `full-0.csv`)
617 s for 50,000 programs (12.3 ms each, 2.5 ms of it generation and acceptance). The per-family regression on the
50,000 timings (no intercept) gives 86 to 98 us per 1,000 executed instructions by family, R^2 0.001: the family mix
explains none of the program-to-program variation. Those regression weights (add 89.1, sub 87.7, mul 90.6, mulhi 98.4,
xor 89.6, or 86.0, rotl 89.4, rotr 86.3, mad 91.2, shfl 95.6) are the proxy weights used for the worst-50-by-proxy list.
| Pass A | n | min | median | p99 | p99.9 | max |
|---|---|---|---|---|---|---|
| all rows | 50,000 | 4.610 (`attack-f6/1278`) | 4.950 | 6.753 | 7.834 | 10.710 (`attack-f6/26705`) |
| rows outside the three disturbed blocks | 44,000 | 4.610 | 4.948 | 5.606 | 5.662 | 6.194 (`attack-f6/48484`) |
The rows over 6 ms sit in three 2,000-program blocks (26,000 to 27,999: 337 rows; 36,000 to 37,999: 300; 38,000 to
39,999: 294) and nowhere else (0 in each of the other 22 blocks); the neighbours of the 10.71 ms seed, unrelated programs,
all read 7.6 to 8.5 ms. `clock-2a.log` shows core 88 (the idle sibling of a solo run) at 3.8 GHz for stretches in those
minutes (08:21:25, 08:21:45, 08:22:05 to 08:22:25 UTC) and core 40 dipping to 3.68 GHz at 08:21:00: a foreign process
(the box's hands run outside the measure lock) sat on the sibling, which is the half-core condition, and those rows read
half-core numbers. They are not program properties and not numbers; the three blocks are re-scanned in batch B, and from
batch B on a per-second sampler logs every process whose last CPU was 40 or 88 (`clock-2b.log`, `clock-2c.log`) so a
disturbed row can be named.
## Batches B and C: queued, starved of the exclusive hold (state at 09:46 UTC)
Batch B (the second 50,000 of the full one-core pass, the re-scan of the three disturbed blocks, the worst 50 by proxy
and the worst 50 by phase-1 coarse time re-timed 10 cold reps each on both proxies) was queued with `flock -x -w 7200`
at 08:33:56 UTC and had not taken the file by 09:46 UTC. Linux `flock` gives a pending exclusive waiter no priority over
new shared takers; eight lanes re-take the file in chunks (17 to 38 shared holders at every reading, F2 spawning many
short ones, one F9 hold 33 minutes old at 09:06, over the 30-minute cap), so the file is never free. Left on the box:
`run2b-retry.sh` re-queues batch B up to four more times (2 h each); `run2c-auto.sh` waits for `BATCH B DONE`, builds the
batch C lists on the box (`mklists.py`: the worst 1,000 and worst 10 by the full one-core pass, pass A's three disturbed
blocks replaced by their re-scan) and queues batch C (the worst 1,000 on the half-core at 2 cold reps each, the worst 10
at 20 cold reps on both proxies). A marker sits beside the lock (`/srv/builds/_locks/measure.wanted-by-attack-f6`). When
they land, `timeparse.py --log phase2b.log --clock clock-2b.log` (and `2c`) prints the per-seed tables with the sampler's
foreign-process column, and this record is completed.
## Numbers so far, the gate, the verdict
| Quantity | Value | Where |
|---|---|---|
| Programs drawn and counted (phase 1) | 100,000 | `scan-0.csv`, `scan-50000.csv` |
| Programs timed cold on core 40 alone (one-core proxy) | 50,000 (44,000 clean, 6,000 in disturbed blocks awaiting the re-scan) | `full-0.csv` |
| One-core cold, clean rows: min / median / p99 / p99.9 / max | 4.610 / 4.948 / 5.606 / 5.662 / 6.194 ms (`attack-f6/48484`), single warps, not yet re-timed | `full-0.csv` |
| Genesis class v4, half-core, max of 5 cold | 8.624 ms | `phase2a.log` |
| Half-core over one-core, genesis class v4 | 1.67x (8.624 / 5.156) | `phase2a.log` |
| Programs timed on the half-core proxy | 1 (the genesis seed) | `phase2a.log` |
| Core 40 clock during every exclusive timing | 3,799.9 MHz (dips to 3,680 MHz only in the disturbed minutes) | `clock-2a.log` |
Gate line: the worst program under 10 ms cold on the half-core proxy. Not yet measured: no drawn program other than the
genesis seed has a half-core number, and the one-core worst (6.194 ms, a single warp with no sampler running) has not been
re-timed. Carried to the half-core at the genesis ratio it would read 6.19 x 1.67 = 10.3 ms, over the gate; carried at the
additive half-core cost of the genesis program (8.624 - 5.156 = 3.47 ms) it would read 9.66 ms, under it by 0.34 ms. The
2.5x bracket of `algorithm.md` 5.5 is between. Whether `attack-f6/48484` (and the other clean rows over 5.6 ms: 1 in 100
of the pass) is a program property or a short disturbance is what batch C's 20-rep re-time with the sampler decides;
the record says FINDING if its half-core cold max reads 10 ms or more.
Verdict: INCOMPLETE. 100,000 programs drawn and ranked, 50,000 timed on the one-core proxy, 0 of the worst re-timed on
the half-core proxy; the two firings fired; the box search's second half and the half-core re-times are queued and
starved of the exclusive hold.
## The ladder ceiling implied so far
`algorithm.md` 5.5 and `model.py --section ladder` set the ceiling from the half-core headroom at 12.1 us per 1,000 shadow
instructions, N = 101,192 + instructions x 1.83.
| Worst program on the half-core | Headroom to 10 ms | Shadow instructions it buys | Ceiling N (counted ops) |
|---|---|---|---|
| 8.23 ms (3.3's average, the published figure) | 1.77 ms | 146,000 | about 370,000 |
| 8.62 ms (genesis seed, this run's max of 5) | 1.38 ms | 114,000 | about 310,000 |
| 9.66 ms (48484 if the additive carry holds) | 0.34 ms | 28,000 | about 152,000 |
| 10.3 ms (48484 if the 1.67x carry holds) | none | 0 | below today's 101,192: the floor rung 100,000 is the ceiling |
The proposed genesis ladder {100,000; 130,000; 200,000; 330,000; 650,000; 1,000,000} already exceeds the 310,000 ceiling
at its fourth rung on the genesis program alone; on the worst program the ceiling could be the floor. This is the
ladder's own open question (plan 1.1, "ceiling set by the verifier"), and the number that sets it is the half-core
worst case still queued.
## Consequences per user tier (at the numbers measured so far; the model's table of 5.5 at 10 ms beside them)
| | At 8.62 ms (genesis, half-core max) | At 9.66 ms (worst, additive carry, unverified) | At 10.3 ms (worst, 1.67x carry, unverified) | Model at 10 ms |
|---|---|---|---|---|
| A node on a 2019-class laptop core at 1 bps | 0.9 percent of one core | 1.0 | 1.0 | 1 |
| At 10 bps (the Devnet 2 experiment) | 8.6 percent of one core | 9.7 | 10.3 | 10 |
| IBD over the 108,000-header pruning window, one core | 15.5 min | 17.4 | 18.5 | 18 |
| Header flood: invalid headers per second that saturate one core | 116 | 104 | 97 | 100 |
| A pool core verifying shares, shares per second per core | 116 | 104 | 97 | 100 |
What each tier does with it: a home miner (8, 12, 16, 24 or 32 GB card, any vendor, any OS) runs a node that spends about
1 percent of one CPU core on the hash at 1 bps whatever the drawn program, and 9 to 10 percent at 10 bps; the card is not
involved. A rig is the same per node. A pool verifying shares at 100 per second per core needs one core per 100 shares
per second at the worst program, 116 at the average: a pool that sized its share verification at the average loses 14
percent of its per-core headroom on the worst program, so pools size at 97 shares per second per core (the 10 ms figure)
and never at the average. A node under header flood holds at about 100 invalid headers per second per core on any
program, the M15 figure. The 2019-class core itself is still the half-core proxy until the O-1.14 laptop run lands
(main's lane).
What this lane does about it: completes batches B and C when the hold comes (automatic, on the box); if the worst
program reads 10 ms or more on the half-core proxy, the finding goes to main with the seed, the reproduction line
`igneum-pow bench --program-class v4 --seed <seed> --day 2026-10-03 --warps 50` on core 40 and the half-core, and the
proposed fix: an acceptance-rule bound on verifier cost (a per-program cost model over the family counts checked at
draw time, a redraw when it exceeds the bound, exactly as rule (c) redraws on bias) and the ladder's ceiling set from
the measured worst, not the average; `igneum-pow` is not edited by this lane.
## Batches B and C landed (7 October 2026, 13:5x UTC, cores 40 and 88 under the per-core lease)
The measure file was retired at 13:3x UTC and replaced by per-core leases; cores 40 and 88 were leased to this row
(`/srv/builds/_bin/lease cores 40,88 --owner attack-pass`), so the batches ran with nothing else on those cores while
builds continued on the rest of the box. Core 40's clock (`clock-2b.log`, `clock-2c.log`): median 3,799.9 MHz in both
batches, 954 of 958 and 57 of 63 samples at 3.7 GHz or more, the 1.5 GHz readings between runs.
| Batch | What | Programs | Reps | Worst program | Half-core cold max | Log |
|---|---|---|---|---|---|---|
| B | the second 50,000 of the one-core pass, then the worst 50 by proxy and the worst 50 by coarse time re-timed cold on both proxies | 200 re-timed | 10 | `attack-f6/87142` | 8.708 ms | `phase2b.log`, done 13:52:11Z |
| C | the worst 1,000 by the full one-core pass on the half-core, then the worst 10 at 20 cold reps on both proxies | 1,000 + 10 | 2, then 20 | `attack-f6/88521` (8.629), `attack-f6/15781` (8.414) | 8.629 ms | `phase2c.log`, done 13:57:45Z |
The one-core worst of the full pass (`attack-f6/48484`, 6.194 ms single warp) does not reach the half-core top ten:
its one-core reading was a short disturbance, as the batch C re-scan shows. Every program timed on the half-core
proxy reads under 9 ms.
## Gate line and verdict
Gate: the worst program under 10 ms cold on the half-core proxy (and on a 2019-class core: O-1.14, the i7-9700K row,
class v4 6.334 ms cold max). Result: the worst of 100,000 class v4 programs on the half-core proxy is 8.708 ms,
1.29 ms under the gate; the genesis program reads 8.624 on the same proxy, so the worst drawn program costs 1 percent
more than the genesis one and the distribution is tight (one-core clean rows 4.610 to 6.194 ms, p99 5.606).
Verdict: PASS. The two firings fired (dr736 FAIL at 15.49 ms half-core; class v4 genesis PASS at 8.62). Consequences
per tier: a 2019-class node verifying the worst class v4 program spends 0.9 percent of one core at 1 bps and 9 percent
at 10 bps on the pessimistic proxy; a header flood needs about 115 invalid headers a second to saturate one such
core; a pool verifies about 115 shares a second per core; IBD of 108,000 headers is about 16 minutes of one core.
Implied ladder ceiling on the half-core proxy from the worst program: 10 - 8.708 = 1.29 ms of headroom buys about
106,000 shadow instructions, N about 300,000 counted ops at the 1.83 convention (approximate), against 370,000
from the genesis program's headroom; the ladder's ceiling should be read from the worst program, not the genesis
one, so rung 2 (199,600) stays admissible and rung 3 (330,700) does not on this proxy.

View file

@ -0,0 +1,165 @@
# F7: the era-draw bias harness and the era-seed census
Attack-pass row F7 (`docs/plans/cryptanalysis.md` 4.2; the record `docs/analysis/attack-pass-2026-10.md`), both halves:
the fast-time re-roll harness (node lane) and the 2^20 era-seed census plus the 64-bit day-key check (hash lane).
Written 7 October 2026. Every number cites its log path on igneum-build-1.
## Target
| Item | Value |
|---|---|
| Commit | `924288d1` on branch `attack-pass` (`igneum-pow` is byte-identical at worktree HEAD `11b375a0`: `git diff --stat 924288d1..HEAD -- igneum-pow docs/spec infra/fast-time` is empty) |
| Spec | `docs/spec/01-lottery-hash.md` 1.13.1 (era seed and draw), 1.8.4 (the day-key mixer stream); `docs/spec/04-seeds-and-vdf.md` 4.4 (era seed pipeline) and 4.6 (T from a reference core); `docs/plans/era-layout.md` sections 1 and 8 (branch `ca2-era`); `docs/analysis/horizon/algorithm.md` 5.4 |
| Draw code | `igneum_pow::generator::era_draw` over `V3_ALLOWED = [1]` (the chain's path): the width draw (consumed, pinned at 4 bytes), the odd stride multiplier `M`, the rotation `R` in 1..31, the four interleave positions `pos` by partial Fisher-Yates |
| Day-key code | `igneum_pow::memhard::MixParams::with_shape`: `SplitMix64::new(K[0] \| (K[1] << 32))` draws ROT[0..7], MUL[0..15], RC[0..15]; `K = seed_words_from_bytes("igneum-day/" \|\| day_le64)` (node fork `consensus/pow/src/igneum.rs`, `bind::day_bytes`) |
| Node draw input (today) | `consensus/src/consensus/mod.rs` `seed_below`: `E_n` is the hash of the last selected-chain block below `15,552,000 n - 7,200` (era 0: genesis). The 1-hour VDF of spec 4.4 and the certified checkpoint it reads do NOT exist in the node (era-layout.md section 8, `proto-vdf` is a prototype) |
## Sub-row verdicts
| Sub-row | Verdict | Gate (plan 4.2 F7) |
|---|---|---|
| (a) re-roll harness | INCOMPLETE, with the written argument | no re-roll inside the publish window |
| (b) 2^20 era-seed census | PASS | no era class with gain over 1.1x at a fraction over 2^-20 |
| (c) 64-bit day-key seeding | PASS, within spec intent (one observation recorded) | the draw's input set as the spec states it |
## (a) The re-roll harness (node lane)
`tools/attack/f7-era/reroll.mjs`: a 3-node fast-time network (`infra/fast-time/override-60x.json` with
`skip_proof_of_work`, the `class-v4-signal.mjs` shape), own ports 29800 and up, own devnet suffix 980, own data dir
`/tmp/igneum-fast-time-attack-f7`. The node binary is the ladder fork `vendor/igneum-node-ladder` at `1591ee1d`
(`igneumd 2.1.0`, already built on the box; read-only). Two honest virtual miners share 1 block/s on nodes 0 and 1; the
adversary on node 2 holds a block `A` built on the tip at DAA score `S - 1` (the seed block sits there), optionally waits
a stub VDF of `--vdf-ms`, then publishes `A` to try to make its own block the epoch's seed block (the last selected-chain
block below the cut `S`). A re-roll succeeds when the epoch's reported seed becomes `hash(A)`.
The era cut `15,552,000 n - 7,200` is 180 days of DAA score away on every profile (`POW_ERA_BLOCKS` is a chain constant,
not an override field), so the harness attacks the EPOCH cut (`60 e - 10` at 60x), which runs the identical `seed_below`
derivation at a reachable score, one cut per minute. The harness's own era draw (JS) is checked byte-for-byte against the
Rust census at start: seed `b62532bc...` draws `M 558c0543 R 4 pos [0,1,2,3]` on both (log line "draw self-check ... OK").
Firings (both runs 6 cuts, box cores 36-37,84-85 under the shared measure lock):
| Run | `--vdf-ms` | Re-rolls to A | Gate | Harness | Log |
|---|---|---|---|---|---|
| known-pass | 0 (no delay, the stand-in) | 1 of 6 (epoch 11, seed = A) | FAIL | SOUND (fires) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownpass.log` |
| known-fail | 5,000 (a delay past one block interval) | 0 of 6 | PASS | SOUND (silent) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownfail.log` |
Both runs: 6 of 6 adversary blocks accepted, all three sinks agree, no reorg of the honest chain. The harness fires on the
known-pass and is silent on the known-fail, so it is trusted.
Written argument (the plan allows one for the VDF's assumptions; the VDF's own delay soundness belongs to the finality
review row of `funding.md`). The re-roll is possible ONLY when the adversary can evaluate the draw of a candidate input
inside the block publish window. Today the node has no VDF: `E_n` is a plain block hash, so the input of any candidate
block is known the instant the block is built, and the harness shows the last-block-before-the-cut is grindable with one
block of hash (1 of 6 cuts steered in fast time, `--vdf-ms 0`). With any delay past one honest block interval the
re-roll is gone (`--vdf-ms 5000`: 0 of 6). The design closes this with the 1-hour class-group VDF of spec 4.4: re-rolling
by withholding needs the 3,600 s VDF evaluated inside the 2 s window, a 1,800x evaluator, and spec 4.6's margin table
gives 300x as the horizon (`algorithm.md` 5.4; `sim/horizon/algorithm/model.py --section era`). The forge route needs
2/3 of the 30-day weight, 20 days of 100 percent hash (CLAUDE.md headline). The sub-row is INCOMPLETE because the harness
cannot demonstrate the real gate: the VDF and the certified checkpoint it reads are not in the node yet (era-layout.md
section 8 states this). What the harness DOES establish: the C_era cut rule with no delay is grindable, so the era draw's
soundness rests entirely on the VDF landing before the draw procedure is frozen, and the delay-soundness measurement is
owed to the finality lane.
## (b) The 2^20 era-seed census (hash lane)
`tools/attack/f7-era/` (a cargo crate with `igneum-pow` as a path dependency and an empty `[workspace]`; ELF built on the
box, sha256 `a87818d8...`). `attack-f7 census` runs `era_draw` over `V3_ALLOWED` on `2^n` seeds and classifies each draw;
`attack-f7 all` runs the plant known-fail case, the census, the spec-stream op-weight census and the day-key check.
Known-fail / known-pass of the classifier (planted parameters through a test hook in this crate; log
`/srv/builds/igneum-wt-attack/attack-f7/census-2p20.log`): every planted weak draw fires its flag (M = 1, M = 2^32-1,
M = 2^16+1, a naf-2 multiplier, an even M, R = 0, R = 32, pos linear, pos contiguous, pos not ascending) and a sound draw
(igneum-era-test/0) raises nothing. "Plant verdict: every planted case fired and the sound draw did not."
Census results (2^24 = 16,777,216 draws, the stronger run; `census-2p24.log`; the 2^20 run agrees, `census-2p20.log`):
| Class | Count (2^24) | Fraction | Expected (uniform) | Chip gain |
|---|---|---|---|---|
| M even (bijection failure) | 0 | 0 | 0 | finding if present: none |
| R out of 1..31 | 0 | 0 | 0 | finding if present: none |
| pos invalid (not 4 ascending) | 0 | 0 | 0 | finding if present: none |
| M = 1 (identity stride) | 0 | 0 | 4.66e-10 | 1.0034x |
| M = 2^32 - 1 | 0 | 0 | 4.66e-10 | 1.0030x |
| popcount(M) <= 2 | 1 | 5.96e-8 (2^-24) | 1.49e-8 | 1.0030x |
| popcount(M) <= 4 | 43 | 2.56e-6 (2^-18.6) | 2.33e-6 | 1.0022x |
| popcount(M) <= 6 | 1,626 | 9.69e-5 | 9.61e-5 | 1.0014x |
| popcount(M) <= 8 | 27,749 | 1.65e-3 | 1.66e-3 | 1.0007x |
| naf(M) <= 2 | 1 | 5.96e-8 | - | 1.0030x |
| naf(M) <= 3 | 18 | 1.07e-6 | - | 1.0026x |
| M = 2^k + 1 | 1 | 5.96e-8 | 1.44e-8 | 1.0030x |
| pos linear [0,1,2,3] | 9,257 | 5.52e-4 | 5.50e-4 | 1.0000x |
| pos contiguous | 120,054 | 7.16e-3 | 7.14e-3 | 1.0000x |
| pos in the low byte | 645,856 | 3.85e-2 | 3.85e-2 | 1.0000x |
The gain metric is the datapath energy a chip saves per hash against the base weights, over the hash's datapath energy
(19.5 nJ at 100,000 ops x 0.195 pJ, the N5 floor of `algorithm.md` 5.4 / `model.py --section era`). The stride multiply is
one of three address operations, run 128 times per hash (16 loads x 8 iterations); a low-weight `M` replaces the multiplier
with a few shift-adds, worth at most 128 x 0.52 pJ = 67 pJ, so M = 1 is the richest corner at 1.0034x. The rotation is a
wire mux and the interleave an address-line permute, 0 pJ on the modelled chip. No drawn parameter touches the memory
bound, the item derivation, the load count or N.
Gate: no class with gain over 1.1x at a fraction over 2^-20. The richest gain in the whole classifier is 1.0034x (M = 1),
and M = 1 did not occur in 2^24 draws (expected 4.66e-10). Every class at a fraction over 2^-20 has gain 1.0000x to
1.0007x. PASS on both counts.
Uniformity of the draw (2^24): stride rotation R over 1..31 chi-square 38.5 on 30 dof (max bucket deviation 2.07 sigma,
R = 0 or 32 seen 0 times); interleave pos 1,820 of 1,820 four-subsets seen, chi-square 1,775.7 on 1,819 dof (max deviation
3.63 sigma, 0 draws with a non-4-subset); M bit 0 always set (odd by construction), bits 1..31 each set in 0.500 of draws
(worst bit 1.81 sigma); the stride bijection never failed (0 even M). The era stream's own 64-bit seed (words 0 and 1) was
distinct on all 2^24 draws.
Op-weight corners (spec 1.13.1 first stream, implemented in `attack-f7 spec` from the spec text because `igneum-pow` does
not draw the op-weight perturbation at this commit; 2^20 draws, `census-2p20.log`): the ten non-load weights each
perturbed by -2..+2 and renormalised to 75 move the multiply share (mul+mad+mulhi, base 22 of 75) between 15 and 31. The
richest corner for a chip is 15/75 (0.152 pJ per op, -22 percent of the base datapath), seen once in 2^20; 16/75 at
3.22e-3. The GPU's energy moves the same way (its IMAD is the chain's own op), so the chip-against-GPU gain of every
weight corner is 1.0x, with 0 memory effect. Renormalised sums were 75 on every draw (0 failures). Fold rotations: a triple
all equal 2.13e-3, both triples all equal 1.91e-6, all six equal 0; uniform over 1..31, rotation 0 never drawn; a wire
mux, 1.0x.
## (c) The 64-bit seeding of the day-key stream (hash lane)
`attack-f7 days` over days 0..131,072 (`census-2p20.log`). The day key `K` is `seed_words_from_bytes("igneum-day/" ||
day_le64)`: a calendar function, no chain state. All 256 bits of `K` enter the cache fill (spec 1.8.3, `K[0..7]` in every
block input), so the dataset depends on the full key; the mixer-constant stream (ROT, MUL, RC) is seeded from `K[0] |
(K[1] << 32)`, 64 bits, which is the spec's stated intent (spec 1.8.4).
| Quantity | Value |
|---|---|
| Days the chain can have | about 65,745 in 180 years at 1 block/s (2^16.0) |
| Distinct 256-bit keys K over 2^17 days | 131,072 (all) |
| Distinct 64-bit stream seeds over 2^17 days | 131,072 (0 duplicates) |
| Distinct (ROT, MUL, RC) tuples over 2^17 days | 131,072 |
| Birthday bound on a 64-bit collision among 2^16 days | 2^(32 - 65) = 2^-33 |
The spec intends 64 bits for the mixer-constant draw, and the truncation is not a reduction of the draw space the firm
would flag: at most 2^16 days are ever drawn, each a distinct calendar day with a distinct 64-bit seed (0 collisions in
2^17), so no two days share a mixer. One observation, within spec intent and recorded for the written argument of
`funding.md` B5 rank 6: the mixer-constant stream has 64 bits of seed entropy, so at most 2^64 distinct daily mixers are
reachable (not the ~2^1,047 nominal); this is not exploitable (the days used are 2^16, all distinct) and whether any
reachable tuple is weak is the separate weak-day census of row F4.
## Consequences per tier
The era draw and the day-key seeding are protocol-wide and do not differ by card tier: the load width and load count are
pinned, so every era is equally memory-bound and no 8, 12, 16 or 24/32 GB card is advantaged or disadvantaged by any draw
(the measured six-era hash-rate spread is 1.3 percent on the RTX 5090, 3.2 on the RX 9070 XT, 0.8 on the M5 Max,
`algorithm.md` 5.4). No drawn era parameter or day key makes a chip cheaper against a GPU: the richest datapath corner is
1.0034x and is shared with the GPU. The one operational consequence is for the protocol, not a miner tier: the era draw's
grinding resistance is not yet demonstrable because the 1-hour VDF and its certified checkpoint are not in the node, so
the freeze of the draw procedure and the C_era cut rule must wait on the VDF landing and the finality lane's delay-
soundness measurement.
## Gate line
- (a) harness: INCOMPLETE. No re-roll with a one-block delay (known-fail 0 of 6); a re-roll with no delay (known-pass 1 of
6). The real gate (no re-roll inside the 2 s window) rests on the 1-hour VDF, which is not in the node; written argument
above.
- (b) census: PASS. No era class with gain over 1.1x at any fraction (richest 1.0034x, M = 1, absent in 2^24); the draw is
a bijection on every sample and uniform in R, pos and the M bits.
- (c) 64-bit seeding: PASS within spec intent. The spec intends 64 bits for the mixer stream; 2^16 days are all distinct;
the one observation (2^64 reachable mixers) is recorded, not a flaw.
What a failure moves (plan 4.2 F7): the draw procedure or the C_era cut rule; a redraw rule for the era stream. Nothing in
(b) or (c) moves them. (a) moves nothing in shipped code but gates the freeze of the draw procedure on the VDF.

View file

@ -0,0 +1,259 @@
# F8. Uniformity censuses of the class v4 derivation
Attack-pass row F8 (`docs/plans/cryptanalysis.md` section 4.2; the pass record `docs/analysis/attack-pass-2026-10.md`).
Run 7 October 2026, 08:13 to 09:3x UTC (09:13 to 10:3x UK) on igneum-build-1. Verdict: **FINDING** (AP-F8-1 below).
The line-index census is a PASS at its full sample size; the cross-hash item histogram is not uniform, and the cause
is in the base program, inside the acceptance rule's blind spot.
## 1. Target
| Item | Value |
|---|---|
| Commit | `igneum-pow` at 924288d1 (`attack-pass`); the box built HEAD b2a411d1, whose `igneum-pow` is byte-identical (`git diff --stat 924288d1 HEAD -- igneum-pow` is empty) |
| Class | `--program-class v4`: `V4_CLASS` = `mx8+sh256x27`, generator 4, mixer x8, the era layout drawn inside the class, the shadow block of 256 instructions x 27 reps |
| Line index | `proto-metal/MEMHARD.md` section 1.6: `a = s[0] AND 0x003fffff`, 4,194,304 lines of 64 B, 8 dependent reads per item (`memhard.rs` `derive_items_mask`, `cache.line_const(s[0])`) |
| Item index | `verify.rs` `load_index`: `y = rotl(x * M, R)`, the site's window `(y & (MASK >> k)) \| off`, then `Layout::split` removes the four interleave bits; 2^24 items at the 2^28-word dataset |
| Reads per hash | 128 loads (16 sites x 8 iterations), so up to 1,024 cache lines per hash and 32,768 per warp. The spec's analytic bound of 832 lines per hash (`docs/spec/01-lottery-hash.md` line 347, 104 loads x 8) predates generator 2's fixed 16 load slots; the current bound is 128 x 8 = 1,024 |
| Prior figures | `chip-model-v3.md` section 1: "median 128.00 distinct" items per hash (the 20,000-program census); `weak-program-census-2026-10-03.md` line 291: 127.7 distinct addresses per hash under the proposed generator |
| Day | the devnet pack's day, `bind::day_bytes(20730)` (2026-10-04), day 0 of the growth schedule: a 2^26-word cache, a 2^28-word dataset. Census 1 uses days 20730 to 20745 |
| Programs | p1 = the devnet epoch-0 derivation (epoch seed and era seed both the genesis hash `edc4fa84...fb07`, program id `c120d7963abdcd96`, attempt 0); p2 and p3 = chain-shaped seeds from tag strings (section 4), attempts 1 and 0 |
## 2. Method and harness
Harness: `tools/attack/f8-uniform/` (crate `attack-f8`, a path dependency on `igneum-pow`, nothing in the library
modified). Built on the box through `tools/build-remote.sh`: sha256 `590668...f913` for the firings of 2.1,
`ef8042...11b2` for sections 3, 4.1 and the first item runs (flat null, `log/c-*`, `log/d-*`), `890955...fbd3` for the
window-model runs and the seed census (`log/e-*`). The committed source carries one later label fix (the
"uniform-on-window" entropy reference in the per-site line is 16 - k_off bits; the 09:04 UTC logs print 16 - 2 k_off). Box scratch `/srv/builds/igneum-wt-attack/target-attack-f8/`
(the name `target-*` is what the box's checkout clean spared at the time; the fix at b92a5fd4 now also spares
`attack-*`). Every run: `nice -n 10 taskset -c 22-27,70-75`, 12 threads, under `flock -s /srv/builds/_locks/measure`
in chunks under 3 minutes each (the longest, phase D, under 25 minutes).
Two mirrors, each trusted only while it agrees with the library bit for bit:
| Mirror | What it records | Agreement check | Result |
|---|---|---|---|
| `derive_traced`: `memhard::derive_items_mask` instruction for instruction (`mixer`, `round_key_mult`, `cache.line` from the library), the line index of every round kept | 8 line indices per item | every item of every day also derived by the library's `derive_items` and compared on all 16 words | 0 mismatches on 268,435,456 items (section 3) and on 16,777,216 items per table build (section 4) |
| `Mirror::warp`: `verify::interpret_warp_init` for the class v4 op set, dataset words from a table of the day's 2^24 items, the item index and the source register of every load kept | 128 item indices per lane, the source value's saturation per position | the 32 hashes of warp 0 to 63 and of every 997th warp compared with `Epoch::hash_warp` | 0 mismatches on 95 warps per program (section 4) |
Three censuses:
1. `lines`: all 2^24 items of each of 16 consecutive day keys (2^28 item derivations, 2^31 line reads), the full 2^22-line
histogram per round and pooled, the 2^16-bucket histogram (64 lines, one chained segment per bucket), a uniform
SplitMix64 control of the same size.
2. `warps`: 10^6 nonces (31,250 warps) of each of three programs: distinct lines and items per hash and per warp, the
cross-hash item histogram, per-position diagnostics, an attribution pass from the hottest items back to the load
positions that read them.
3. `warps` at 2^26 nonces on p1: the one-epoch cross-hash item histogram at 512 expected reads per item.
The tests, defined before the runs:
- **6-sigma test**: the largest (and smallest) bucket of a histogram within 6 sigma of its expectation, sigma =
sqrt(expectation). The gate's bucket is the 64-line segment for lines and the 64-item bucket for items. The
full-resolution histograms are reported beside a uniform control of the same size, because at a small mean the
Poisson tail puts the maximum of 4 million bins above 6 sigma by chance (control at mean 8: +6.72 sigma; at mean
32: +5.83; at mean 512: +5.61).
- **Hot-set test** (F8's definition, written for F9's reuse): sort items by read count; S_f = the share of all reads
on the top-f fraction of items, for f in {0.1%, 0.5%, 1%}; E_f = the same share on a control of the same size drawn
from the design's own null (flat uniform for lines; the window-weighted null for items, section 4.2); the excess
X_f = S_f - E_f. **A hot set exists at f when X_f >= f**: after the chance excess is removed, the top f of items
capture at least one extra proportional share, which is what an on-die copy of f of the items would have to win to
matter. X_f / f is printed as the gain in proportional shares. The acceptance-style form of the same metric (for
rule (c)'s 2,048 evaluations): per load position, the largest count of one masked address, and the count of
saturated (0 or 2^32 - 1) source values.
### 2.1 The harness fires (known-fail and known-pass)
| Plant | What it does | 6-sigma test | Hot-set test | Log |
|---|---|---|---|---|
| `quarter-lines` | line index masked to a quarter of its range | buckets64 largest +75.97 sigma (2,231 at mean 512), smallest -22.63: FLAGGED | X_1% = +3.54% (S 5.60% vs control 2.06%), X/f = 3.5 at every f: FLAGGED | `log/a1-lines-quarter.log` |
| `half-lines` | line index masked to a half | buckets64 largest +29.26 sigma: FLAGGED | X_1% = +1.25%, X/f = 1.25: FLAGGED | `log/a2-lines-half.log` |
| `const-item` | one constant item at the first load site (1/16 of reads) | items buckets64 largest +92,682 sigma: FLAGGED | X_0.1% = +6.38%, X/f = 63.8: FLAGGED | `log/a4-warps-const-item.log` |
| none, 2^22 items, one day | the real derivation at a small size | buckets64 largest +4.42 sigma, smallest -4.51: within 6 sigma (control +4.33) | X_f = -0.0004%, -0.0006%, -0.0010%: clear | `log/a3-lines-pass-small.log` |
Both tests fire on every plant and neither fires on the real line derivation. Log paths are under
`/srv/builds/igneum-wt-attack/target-attack-f8/`.
## 3. Census 1: the line index over 2^28 derivations (PASS)
Sample reached: 16 days x 2^24 items = 268,435,456 item derivations, 2,147,483,648 line reads into 4,194,304 lines
(512 expected per line, 32,768 per 64-line segment). Mirror mismatches against `derive_items`: 0 of 268,435,456.
Log: `log/b-lines-16days.log`; histograms `out/lines-d20730-n16-i24-none-buckets64.txt` (65,536 rows) and
`out/lines-d20730-n16-i24-none-full.u32le` (4,194,304 x u32).
| Histogram | Bins | Expected | Largest | Sigma | Smallest | Sigma | chi2/dof | Top 1% share |
|---|---|---|---|---|---|---|---|---|
| Pooled, 64-line buckets (the gate) | 65,536 | 32,768 | 33,645 | +4.84 | 31,998 | -4.25 | 1.00226 | 1.01427% |
| Pooled, full 2^22 lines | 4,194,304 | 512 | 639 | +5.61 | 402 | -4.86 | 0.99937 | 1.11979% |
| Control, 64-line buckets | 65,536 | 32,768 | 33,524 | +4.18 | 31,960 | -4.46 | 0.99930 | 1.01426% |
| Control, full 2^22 lines | 4,194,304 | 512 | 639 | +5.61 | 408 | -4.60 | 0.99952 | 1.11956% |
| Per round 0 to 7, full, pooled (64 per line) | 4,194,304 | 64 | 107 to 113 | +5.38 to +6.12 | 26 to 29 | -4.75 to -4.38 | 0.99855 to 1.00062 | 1.3483% to 1.3488% |
| One day (20730), 64-line buckets | 65,536 | 2,048 | 2,291 | +5.37 | 1,859 | -4.18 | 0.99985 | 1.05826% |
| One day, control, 64-line buckets | 65,536 | 2,048 | 2,250 | +4.46 | 1,874 | -3.84 | 1.00377 | 1.05901% |
Per day, the gate bucket's largest value ran +4.00 to +5.37 sigma on all 16 days (control +4.46), every day within
6 sigma. Hot-set test on the pooled lines: X_0.1% = -0.00002%, X_0.5% = +0.00010%, X_1% = +0.00023% (X/f under
0.0003): clear. Round 0, whose input is the sequential item index through the init `t * MUL[i] + RC[i]` and eight
mixer applications, is as flat as rounds 1 to 7 (chi2/dof 0.99926; its +5.50 sigma maximum is below the control's
+5.61 at the pooled size). Round 5's +6.12 sigma at mean 64 is one bin of 4 million at a Poisson tail where the
control at mean 8 reached +6.72; its chi2/dof is 0.99855.
Gate line: the largest bucket is within 6 sigma of uniform (+4.84 on the 64-line buckets, +5.61 on the full 2^22
lines, both at or below the control), chi2/dof 0.99937, no hot set. **PASS at 2^28 derivations.**
## 4. Census 2 and 3: distinct lines per hash and warp, and the cross-hash item histogram
Setup per program: the day's 16,777,216 items derived once into a table with their 8 lines (6 to 9 s on 12 threads,
0 mismatches against `derive_items` on every item), then the warps interpreted from the table at 2.7 to 3.2 ms per
warp per thread. Logs: `log/c-warps-p{1,2,3}-1e6.log` (first run, flat null) and `log/e-warps-p{1,2,3}-1e6.log`
(windowed null, section 4.2); distributions `out/warps-<program>-d20730-n1000000-none-distinct.txt`, item histograms
`...-items.u32le` (16,777,216 x u32), per-position tables `...-positions.txt`.
### 4.1 Distinct lines and items per hash and per warp (10^6 nonces each)
| Program | Epoch seed / era seed | Lines per hash min / p1 / median / max / mean | Items per hash min / median / mean | Lines per warp min / median / max / mean | Items per warp min / median / mean |
|---|---|---|---|---|---|
| p1 `c120d7963abdcd96` (devnet epoch 0) | genesis / genesis | 1,008 / 1,023 / 1,024 / 1,024 / 1,023.867 | 126 / 128 / 127.9989 | 32,579 / 32,636 / 32,680 / 32,635.84 | 4,090 / 4,096 / 4,095.41 |
| p2 `82f0696f823e9c65` | `59cef1aa...bfdfa` / `9cba001f...1f69` | 1,014 / 1,023 / 1,024 / 1,024 / 1,023.871 | 127 / 128 / 127.9995 | 32,580 / 32,637 / 32,684 / 32,636.08 | 4,091 / 4,096 / 4,095.48 |
| p3 `e282eed7d47e425e` | `c54e2ddd...c95d` / `1b04f607...b58a` | 999 / 1,016 / 1,024 / 1,024 / 1,023.600 | 125 / 128 / 127.9656 | 32,276 / 32,481 / 32,601 / 32,480.32 | 4,050 / 4,076 / 4,075.85 |
| Uniform expectation | | 1,023.875 of 1,024 | 127.9995 of 128 | 32,640.3 of 32,768 | 4,095.50 of 4,096 |
Per hash, every program reads its 128 items and 1,024 lines as the design intends (p1 and p2 at the uniform
expectation; p3 a shade under, 127.97 items, which is the same site-15 effect as the finding below: the saturated
site repeats an item inside a hash 3 times in 100). Per warp, 32 lanes read 32,636 distinct lines of 2^22, a 2 MiB
working set of cache lines and 256 KiB of dataset items, within 0.01% of uniform on p1 and p2.
### 4.2 The cross-hash item histogram and the window layer
The era layout's window layer (`docs/plans/era-layout.md` section 1.4, layer 8) makes each load site read an
aligned half or quarter of the dataset with probability 2/3. The per-site item distribution is therefore not flat by
design (the diagnostic's "worst bit" reads P(1) = 1.0000 or 0.0000 at every windowed site: the fixed top bits), and
the summed item histogram has density steps between quarters. For p1 the 16 windows (site:shrink:offset
`7:2:1 8:1:1 9:1:1 10:1:1 11:0:0 13:1:1 29:0:0 30:2:2 31:1:1 44:1:1 46:2:0 47:0:0 52:0:0 56:0:0 58:2:0 63:1:1`) give
expected reads per item by quarter of 3.25 : 2.25 : 5.75 : 4.75 in sixteenths of the flat value. Against a flat
uniform the 64-item buckets of p2 (a program without the finding) read +10.03 and -9.06 sigma, which is the window
layer and not a flaw. The item tests are therefore judged against the **window-weighted null**: the expected count of
every item from the program's 16 windows, and a control that draws each read from a uniformly chosen site's window.
A chip gains nothing from the window steps: the union of the windows is the whole dataset every hour (era-layout.md
section 7), the floor window is 2^26 words (256 MiB), and which quarter is dense changes with the program.
#### The window model (reproducible by the firms)
For load site s with window draw `(k_s, o_s)` at the 2^28-word dataset: `k = min(k_s, 28 - 26)`, the word window is
`[o_s << (28 - k), (o_s + 1) << (28 - k))`; the item window is `[o_s << (24 - k), (o_s + 1) << (24 - k))` of
`2^(24 - k)` items (the four interleave positions all lie below bit 16, so the top bits of the word index are the top
bits of the item index). The expected reads per item is `E[t] = sum over sites s with t in window_s of N x 8 / 2^(24 - k_s)`
for N nonces (8 iterations per site), a density constant on each quarter of the item space. The windowed control draws
each of the N x 128 reads as (site = read index mod 16, item uniform on that site's window). Both controls are drawn from
SplitMix64 with a fixed seed. The tests on items are run against E[t] (chi-square, sigma of the largest and smallest
64-item bucket) and against the windowed control (the top-f shares); the flat uniform numbers are kept beside them as
what an auditor sees first.
#### Results, 10^6 nonces per program, 128,000,000 reads (`log/e-warps-p{1,2,3}-1e6.log`)
| Program | Windows (k_off:offset per site) | Quarter densities (reads per item) | Buckets64 largest sigma, windowed (control) | chi2/dof windowed (control) | Top 0.1% share: real / window control / flat control | Ratio to window control at 0.1% (gate 1.2x) | Ratio to flat control | Hot set (X_f >= f) |
|---|---|---|---|---|---|---|---|---|
| p1 devnet epoch 0 | 2:1 1:1 1:1 1:1 0 1:1 0 2:2 1:1 1:1 2:0 0 0 0 2:0 1:1 | 6.20 / 4.29 / 10.97 / 9.06 | +45.77 (+4.95) | 1.2336 (0.9981) | 0.5458% / 0.2891% / 0.2429% | 1.888x BEYOND | 2.247x | yes at 0.1% (X/f 2.57) and 0.5% (1.20); not at 1% (0.46) |
| p2 | 2:0 1:0 0 0 2:3 2:2 1:0 0 2:3 0 0 0 0 1:1 2:0 0 | 9.54 / 5.72 / 6.68 / 8.58 | +4.59 (+4.64) | 1.0061 (0.9986) | 0.2716% / 0.2639% / 0.2429% | 1.029x within | 1.118x | no (X/f 0.08, 0.05, 0.05) |
| p3 | 1:0 0 0 2:2 0 0 1:0 1:0 1:0 2:2 2:2 1:1 0 0 0 0 | 7.63 / 7.63 / 10.49 / 4.77 | +12,245.66 (+5.06) | 907.67 (0.9993) | 4.5954% / 0.2792% / 0.2433% | 16.46x BEYOND | 18.92x | yes at every f (X/f 43.2, 9.9, 5.0) |
p2 is what the class is designed to be: against the window model its largest bucket is +4.59 sigma (the control +4.64),
chi2/dof 1.006, the top 0.1% of items hold 1.029x their window-model share, and the flat-control ratio of 1.118x is
the window layer. p1 and p3 are the finding (section 5). The one-epoch histogram at 2^26 nonces (8,589,934,592 reads,
512 per item, `log/d-warps-p1-2e26.log`, flat null): p1's top 0.1% hold 0.5199% of reads against 0.1152% flat
control (X/f 4.05), the top 1% 2.4946% against 1.1198% (X/f 1.37), item 0xca5b92 78,479 reads at a mean of 512, and
site 15 feeds 6.37% of its reads into the top 0.1% in each of the 8 iterations; the excess grows with N as the
control's chance excess shrinks, which is the signature of a structural skew. Distinct lines and items per hash and
per warp at 2^26 nonces: 1,023.866 / 127.9989 / 32,635.6 / 4,095.41, unchanged from 10^6.
## 5. AP-F8-1: a saturated load source makes a cross-hash hot set (FINDING)
**What**: an accepted class v4 program can read one load site from a register whose last writes after its last
injecting write are `or` (and, mildly, `mul`), so the site's address has fewer than 32 bits of entropy across nonces
and the same items are read by many hashes. The per-hash figures (128 distinct items, 1,024 lines) stay intact; the
cross-hash item histogram does not. It is not the window layer (p2 shows the window layer alone is clean against its
model) and not the shadow block (iteration 0's load, which runs before any shadow block, is as hot as iterations 1 to
7: p1 6.372% vs 6.371% to 6.378%; p3 71.9% vs 72.4% to 72.6%).
**Where it hides from rule (c)** (`accept.rs`, 2,048 evaluations of the base program): the tests are constant bits
in FINAL register values, one address in ALL 32 lanes of a unit, saturated FINAL values, output-bit bias, and distinct
addresses WITHIN a hash. A site whose address is concentrated across hashes but refreshed before the end of the
iteration passes every one. Rule (a) accepts any write, `or` included, as the refresh between two loads from the same
register (`check_stale_loads`); `Op::injects` (add, sub, xor, mad, shfl, load) is only used by rule (b), once per
register per program.
**The index derivation at the hot site** (the "writers back to the last injecting one" lines of `log/e-warps-p*.log`):
| Program | Hot site | Source | Writes after the last injecting write | Site's reads into the top 0.1% of items (flat expectation) | Index entropy, 256-item buckets (uniform on window) | Saturated source (x = 0 or 2^32 - 1) | Most repeated address at one position in 2,048 evaluations (uniform: 1 to 2) |
|---|---|---|---|---|---|---|---|
| p3 | site 15, instr 62 | r5 | `load@17` then `or@19`, `or@30` | 72.43% (0.10%) | 13.411 bits (16) | 1.368% | 44 of 2,048; 32 saturated |
| p1 | site 15, instr 63 | r6 | `add@51` then `rotl@53`, `or@61` | 6.93% (0.11%) | 14.985 bits (15) | 0.005% | 2 of 2,048; 0 saturated |
| p2 (clean) | every site | | injecting, or bijective (`rotl`), or `mul`/`mulhi` | 0.41% to 0.97% (0.20%; the window densities) | 13.999 / 14.997 / 15.994 bits (14 / 15 / 16) | 0.000% | 2 of 2,048; 0 saturated |
In p3 two `or`s on r5 after its load make the source 1 with probability 7/8 per bit; x = 2^32 - 1 in 1.37% of
evaluations and the images of the near-saturated values under the stride (`y = rotl(x * M, R)`, 256 x-values per
item) pile onto a few items: 0xffdf69 takes 213,913 of the site's 8,000,000 reads (2.67%), the top 0.1% of items
72.4%, and 4.6% of ALL reads of the hash land on 0.1% of the items. In p1 one `or` after `rotl(add)` gives 3/4 per bit
on the ORed positions: no saturation to speak of (0.005%), but 6.9% of the site's reads on 0.11% of the items (the
hot items share the low 20 bits `5b92`: 0xca5b92, 0x8a5b92, 0xaa5b92, 0xba5b92, 0x825b92, 0xe65b92, 0x985b92), a
2.6x proportional excess at f = 0.1%. p2's `mul` sites (10, 13: `mul` after a load or a shuffle) read 0.65% and 0.70%
into the top 0.1% against 0.41% and 0.48% for their window class (an even multiplier zeroes low bits; the hot items
0xd6a680, 0xe44400, 0xd25600 end in zero bits), a mild effect that the window-model ratio (1.029x) absorbs.
**How common** (the seed census, 64 chain-shaped programs p4 to p67, 262,144 nonces each, `log/e-seed-census-4-67.log`,
`out/seed-census-d20730-n262144-p4-67.txt`): CENSUS-LINE
**Reproduction**: `attack-f8 warps --program 3 --nonces 1000000 --diag 1` (or `--program 1`); the acceptance-style
numbers come from the same run's "acceptance-style" line. The program is `Epoch::chain_program(epoch_seed, Some(era),
ProgramClass::V4, label)` with the seeds of section 4.1.
**Proposed fix** (not applied; `igneum-pow` untouched, the Counter ASIC lane re-gates on `ca3-v4-uniform` with this
harness):
1. Rule (a'), static: between the last injecting write of a load's source register and the load (cyclically), no
`or` and no `mul` writes that register; `rotl`, `rotr` and `mulhi` may (bijective, or measured flat: p2 site 15
reads `mulhi` after `add` at 1.00x). This rejects p1 and p3 at draw time and costs nothing at run time. Programs
rejected are redrawn as today (`MAX_ATTEMPTS` 32); the census gives the rejection rate.
2. Rule (c'), dynamic, the same 2,048 evaluations: no load site reads a saturated source (0 or 2^32 - 1) in more
than 2 evaluations, and no address repeats more than 4 times at one position (uniform expectation 1 to 2; p3 shows
44 and 32). This catches the strong class only; p1's class needs about 2^16 evaluations to show at a site (65,536
nonces: largest item count 67 at a mean of 0.5), so (a') is the rule that closes it and (c') is the check that
fails loudly if (a') is ever loosened.
3. Packs re-cut for the seeds the new rule rejects (the devnet epoch-0 program p1 is one of them: its site 15 is
`or@61`), with the gate pack ids re-pinned; the chain's own epochs redraw automatically.
**Reuse for F9**: the hot-set metric (section 2) on the per-program item histogram at 2^18 nonces, and the
acceptance-style pair (most repeated address at a position, saturated sources at a position) at 2,048 evaluations,
are both emitted by `warps --programs a..b`; a header-grinding search that steers a program to a hot set would show as
ratio-to-window-model above 1.2x at f = 0.1%.
## 6. Consequences per tier
| Number | What it means | Per tier |
|---|---|---|
| Line index uniform at 2^28 derivations (largest segment +4.84 sigma, chi2/dof 0.99937) | the 256 MiB cache has no hot segment: a chip or a card cannot serve the 8 dependent reads of an item from a cache smaller than the whole 256 MiB (the floor window of era-layout.md) | no change for any card; the verifier's cache stays 256 MiB in RAM on every node |
| Distinct lines per hash 1,023.87 of 1,024, items 127.999 of 128 (p1, p2); per warp 32,636 lines, 4,095 items | the per-hash working set is 64 KiB of cache lines and 8 KiB of items, per warp 2 MiB of lines and 256 KiB of items; the item-derivation chip's "128 items per hash" input (`chip-model-v3.md`) stands | the 8 GB card and up: unchanged; the recompute chip pays 128 derivations per hash, as modelled |
| p3-class programs: 4.6% of all dataset reads on 0.1% of items (1 MiB of a 1 GiB dataset); p1-class: 0.59% on 0.11% | a stored-dataset chip with 1 MiB of on-die SRAM serves 4.6% of its reads without touching DRAM on such an epoch; a GPU's L2 (96 MiB on the 5090, 64 MB Infinity Cache on the 9070 XT, vendor figures) holds the same 1 MiB, so both sides gain the same 4.6% of reads and the chip's edge from it is about 0 (the per-joule edge of `evidence.md` row 17 is a DRAM-read figure; a 4.6% read saving on both sides moves it by under 5% on such epochs). The recompute chip (f = 0, SRAM cache) caches the derived hot items and skips up to 4.6% of its 128 derivations per hash on such epochs, a 4.8% rate gain on those epochs only | home cards 8 to 32 GB, rigs, pools: no action; a few percent of epochs run a few percent faster for everyone with an L2. The verifier: `MemhardCpu::fetch` dedupes within a fetch only, so no change. The chip model: the headline 2.1x at k = 1 moves by under 5% on affected epochs and 0 on others; the fix below returns it to 0 everywhere |
| The acceptance rule's blind spot (cross-hash concentration at one site) | a program class property, not a day or era property: the same seed is hot on every day and under every era, so a chip or a pool that selects epochs cannot gain more than the epoch's own 4.6%; but the public claim "the item map is uniform per program up to the window layer" is false for the affected fraction of seeds until rule (a') lands | the fix is a generator rule plus packs re-cut: a class change under the 95% signalling rule if it lands after the flip, a plain re-cut if it lands in the class v4 cut itself (the lane's call) |
## 7. Gate line and verdict
| Gate (plan 4.2 F8, the same as 1.4 (4)) | Result | Status |
|---|---|---|
| The largest bucket within 6 sigma of uniform on the stated sample sizes (line index, 2^28 derivations) | +4.84 sigma on 64-line segments, +5.61 on 2^22 lines (control +4.18 / +5.61), chi2/dof 0.99937 | PASS |
| The item distribution within 6 sigma of uniform (against the window model, the design's own null) | p2 +4.59 sigma (control +4.64); p1 +45.77; p3 +12,245.66 | FAIL on p1 and p3 |
| No hot set under 1% of items among passing seeds (10^6 nonces on three programs; the 64-seed census at 2^18) | p2 none; p1 top 0.1% at 1.888x the window model (2.247x flat), X/f 2.57; p3 16.46x (18.92x flat), X/f 43.2; census: 31 of 64 seeds over 1.2x of the window model, 23 with a hot item | FAIL |
| The Counter ASIC lane's record gate: top 0.1% within 1.2x of the window-model control on every seed | p2 1.029x; p1 1.888x; p3 16.46x; census: 31 of 64 seeds over 1.2x (median 1.17x, p90 2.70x, max 13.09x on p31) | FAIL |
**Verdict: FINDING (AP-F8-1).** The line index passes at 2^28 derivations. The cross-hash item histogram fails
the hot-set gate on 2 of the 3 named programs (one of them the live devnet epoch-0 program) and on 31 of 64 seeds (48 percent) of of
the 64-seed census, from `or` (and mildly `mul`) writes on a load's source register after its last injecting write,
outside every test of rule (c). What it moves: not the mask or the fold (the derivation is uniform) but the
acceptance rule, (a') and (c') above, and the packs re-cut. Ownership: the Counter ASIC lane (generator and rule),
re-gated with this harness on the fixed branch; the row reads FIXED-AND-PASSED when every seed of the census passes
both the hot-set test and the 1.2x gate under the new rule.
Sample sizes reached: 2^28 derivations (lines); 10^6 nonces on three programs (distinct lines, hot set); one epoch at
2^26 nonces (cross-hash histogram); 64 seeds at 2^18 nonces (the census).
Times UTC in the logs; the runs ran 08:13 to 09:2x UTC on 7 October 2026 (09:13 to 10:2x UK).

View file

@ -0,0 +1,269 @@
# F9: acceptance edges, the hot-set search, header grinding
Attack-pass row F9 of `docs/plans/cryptanalysis.md` section 4.2 (record: `docs/analysis/attack-pass-2026-10.md`).
Sub-agent attack-f9, 7 October 2026. Status: IN PROGRESS (rewritten as each run lands; the numbers below are the
ones already final, each with its log).
## Target
| Item | Value |
|---|---|
| Commit | 924288d1 (branch attack-pass, worktree igneum-wt-attack) |
| Generator | 4, class v4 `mx8+sh256x27` composed with the era draw (`LoadClass::era(V4_CLASS, E, [4 bytes])`), era seed E = the devnet epoch-0 seed `edc4fa84...fb07` (`proto-cuda/packs-ca3-v4/v4-devnet-epoch0/seeds.txt`) |
| Rule | `igneum-pow/src/accept.rs`: (a) stale load sources, (b) injecting writes, (c) the 2,048-evaluation dynamic test on the closed-form stand-in `dataset_elem` at 2^28 words with init words = seed words; redraw on rejection up to 32 attempts |
| Header binding | `igneum-pow/src/bind.rs`: init words `I = seed_words_from_bytes("igneum-block/" \|\| H \|\| nonce_hi_le32)`, one `I` per 32-lane warp, the lane nonce in the low 32 bits |
| Memory-hard dataset for the edges | the devnet day 20730 (`day_seed_hex 69676e65756d2d6461792ffa50000000000000`), class v4 shape (mixer x8, cache 2^26 words, dataset 2^28 words), `Epoch::chain_dataset_day` |
| Card | RunPod RTX 5090 (170 SMs, 32,120 MiB, driver 570.195.03, CUDA 12.8.1), pack `v4-devnet-epoch0` built there with `nvcc -O3 -arch=sm_120` |
## Known-failed shape
A seed grind that steers a program to a hot cache set for DRAM locality, or an edge where the closed-form stand-in
disagrees with the live verifier in the attacker's favour.
## Gate
Zero passing programs with a hot set under 1 percent of items among 10^6 seeds; the grinding gain under 1 percent of
rate at any search cost. What a failure moves: the closed-form stand-in replaced by the live verdict at the edges; a
locality term in rule (c).
## Harness
`tools/attack/f9-grind/` (crate `attack-f9`, `igneum-pow` as a path dependency, nothing in igneum-pow edited; built
on igneum-build-1 through `tools/build-remote.sh`; the binary on the box at
`/srv/builds/igneum-wt-attack/tools/attack/f9-grind/target/release/attack-f9`):
| Sub-command | What it does |
|---|---|
| `selftest` | the firings listed below |
| `edges` | sub-row (a): every candidate of every seed through the re-implemented dynamic test twice, closed form and memory-hard, every metric of rule (c) measured to the end (no early abort) with its margin; the attempts continue until both stand-ins have accepted, so the chosen program under each is known |
| `hotset` | sub-row (b): the seed's accepted program, its 2,048 x 128 address record under the acceptance init and under a block init; per site the nonce-independent address bits, the distinct addresses and the most-read address; the histogram at bucket scales 2^8 to 2^24 words against a window-aware Poisson expectation (the era windows send a site to the dataset, a half or a quarter of it) with a Bonferroni tail; the taint count of init-determined loads |
| `inspect` | one seed's program with every site that repeats an address |
| `grind`, `table-random` | sub-row (c), CPU side: the init-determined load sites of the devnet epoch-0 program, the per-warp search over K nonce_hi values for the fewest distinct 128 B lines inside those load instructions (`--mode intra`, what the coalescer merges) or across them (`lines`, `pages`), the search cost per hit, and the per-warp init tables the card reads |
| `reference` | the 64 bound hashes the card's known-pass compares against; the file used on the pod came from the pre-built `igneum-pow hash-bound` instead (`ref.txt`, sha256 e831458a...) |
| `summarise.py` | the census summaries quoted below |
`tools/attack/f9-grind/pod/` (the card): `make-variants.py` copies the pack's `kernel_bound.cu` into five kernels
(per-warp init table; the five init-determined loads broadcast to lane 0's address; every load broadcast; the first
such load broadcast; lane 1 reading lane 0's address at the first such load), `f9-host.cu` fills the cache and dataset
with the pack's own kernels, checks them against `vectors.h`, checks the bound hash against the reference, checks the
per-warp kernel on an all-equal table against the honest kernel, then times the eight variants in interleaved rounds;
`run.sh` builds on the pod, samples `nvidia-smi` once a second and joins the samples to the phases (`join-power.py`).
Hot-set metric (F8's record `docs/analysis/attack-pass/f8-uniform.md` did not exist when this harness was written, so
the metric is defined here). Strict reading, "any hot bucket": a site with 7 or more nonce-independent address bits
(support at most 2^21 of 2^28 words, 0.78 percent of items; the window's own fixed bits not counted), or any bucket
of at most 2^20 words (0.39 percent of the dataset) at scales 2^8, 2^12, 2^16, 2^20 whose count has a Poisson tail
against its window-aware expectation under 10^-6 after the Bonferroni correction. Gate reading, "flagged": the reads
above expectation in those hot buckets (the hot share, what a cache of the hot set saves at most) reach 1 percent of
the program's reads, or a site has 7 constant bits.
## Firings (the harness is trusted only after these)
Log: `/srv/builds/igneum-wt-attack/attack-f9/selftest.log` (copy in the Mac scratchpad `f9-box/selftest.log`).
| Check | Known-pass | Known-fail | Result |
|---|---|---|---|
| 1 | the re-implemented dynamic test against `accept::check` on 300 class v4 candidates: every verdict, the first failing condition, and distinct, saturated and bias of every accepted report equal | | PASS (300 candidates, 16 rejected by accept, all equal, 1.5 s) |
| 2 | both stand-ins forced equal (closed form twice) on 200 candidates | | PASS, 0 disagreements |
| 3 | | the memory-hard stand-in gives different words: distinct 262,117 against 262,106, bias 56 against 64 on one program | PASS (they differ) |
| 4 | 50 accepted programs, none with 7 constant bits (worst 0) | 50 plants (an accepted program rewritten to `xor a,a; add a,a,256; mulhi a,b` before a load from `a`, 48 of 50 still pass rule (c)) all flagged (support 256 words, site distinct 255 or 256) | PASS for the plant; 4 of the 50 clean programs have a hot bucket (sub-row (b): that is the finding, not a harness fault) |
| 5 | taint on the devnet epoch-0 program against the hand reading of `kernel_bound.cu`: loads 7, 8, 9, 10 read r7, r4, r2, r0 (no load before them); load 31 reads r5 = r5 x r4 from instruction 12, both untouched by any load; loads 11, 13, 29, 30 read r1, r6, r4, r3, each written by an earlier load or by `mad` from r7 after load 10 | | PASS: sites (0,7) (0,8) (0,9) (0,10) (0,31) |
| card 1 | cache FNV 448274a57f508cbc, dataset head, last word and 64 samples, the 96 pack vectors | | PASS (`pod log/host.log`) |
| card 2 | the bound hash against the 64 reference lines of `igneum-pow hash-bound` (nonce_hi 0, prehash 000102..1f) | | PASS, 0 wrong |
| card 3 | the per-warp kernel on an all-equal table equals the honest kernel on 64 lanes | the two-init table: warp 0 equal, warp 1 differs in 32 of 32 lanes | PASS both |
| card 4 | | the forced kernels change the hash: forced4 64 of 64 lanes, forcedall 64, forced1 64, pair 64 | PASS (they fire) |
| card 5 | | a deliberately locality-maximising choice shows a measurable change: `pair` (one line of 4,096 saved per warp) +0.21 percent, `forced1` (31 lines) +6.8 percent, `forced4` (155 lines) +43.3 percent, `forcedall` +181.9 percent in the smoke run | PASS (measurable from one saved line up) |
## Sub-row (a): the edges
Run: `attack-f9 edges` over seeds `igneum-f9/0` to `igneum-f9/99999`, 8 threads on cores 28-31,76-79 in 10,000-seed
chunks under a shared hold of the box measure lock (`run-census.sh`); output `edges.part*.tsv`, summary by
`summarise.py edges`. The first 20,000 seeds (parts 0 and 1) are summarised here; the full 10^5 replaces this table
when the run ends.
| Quantity | First 20,000 seeds |
|---|---|
| Candidates evaluated | 21,020 |
| Verdicts agreeing | 21,007 |
| Disagreements | 13 (0.062 percent of candidates; the 3 October census had 39 in 100,000 on its generator) |
| Seeds whose chosen program differs | 13 (every disagreement moves the chosen attempt, because the next attempt was accepted by both) |
| Exhausted seeds | 0 on either stand-in |
| Rejected by the closed form / by the memory-hard dataset | 1,013 / 1,014 (850 static, the rest (c)) |
| First failing (c) condition, closed form | const_bit 80, saturated 54, distinct 24, lane_const 4, bias 1 |
| Accepted margins, closed form | saturated at most 81 of 164, bias at most 120 of 136, distinct sum at least 247,335 (bound 245,760), nearly constant final bits up to 2,047 of 2,048 |
| Accepted margins, memory-hard | saturated at most 82, bias at most 115, distinct at least 247,678 |
The 13 disagreements: 12 are `const_bit`, a final register bit equal in all 2,048 evaluations on one dataset and in
2,044 to 2,047 of them on the other (7 where the closed form accepts, 5 where the memory-hard dataset accepts); 1 is
`bias`, output bias 155 against 93 (6.9 against 4.1 sigma, the two draws' difference 2.7 sigma of sampling noise),
where the memory-hard dataset accepts. No disagreement on saturation, lane-constant sites or the distinct count: those
metrics are the same to within 20 on both datasets. What an attacker gains from a program the closed form accepts
and the live dataset would reject: a register whose final bit is pinned in 2,047 of 2,048 hashes instead of 2,048,
which no test downstream of the fold can see (the 64 output bits stay within 120 of 1,024 on every accepted program)
and which no chip can turn into skipped work; the reverse direction loses the chain a program with one pinned bit.
Either way the chosen program moves to the next attempt, which both stand-ins accept. Nothing in the attacker's
favour: the verdict's dependence on the stand-in is a 0.06 percent coin flip on a one-bit property.
## Sub-row (b): the hot-set search
Run: `attack-f9 hotset` over seeds `igneum-f9/0` to `igneum-f9/999999`, 8 threads on cores 32-35,80-83 in
100,000-seed chunks; output `hotset.part*.tsv`. A 2,000-seed timing sample (`hotset-timing.tsv`, seeds 5,000,000 to
5,001,999) is summarised here; the 10^6 census replaces it when the run ends.
| Quantity | 2,000-seed sample |
|---|---|
| Programs with any hot bucket (strict) | 161 of 2,000 (8.1 percent) |
| Programs flagged at the gate reading (hot share at least 1 percent of reads) | 21 of 2,000 (1.05 percent) |
| Worst hot share | 10.3 percent of the program's reads (seed 5,000,968) |
| Sites with 7 or more constant address bits | 0 (max 0) |
| Fewest distinct addresses at a site in 2,048 evaluations | 434 |
| Most evaluations reading one address at a site | 767 of 2,048 |
| Init-determined loads in iteration 0 (programs by count) | 1: 234, 2: 573, 3: 594, 4: 368, 5: 179, 6: 42, 7: 10; none after iteration 0 |
FINDING F9-1 (or-saturation hot words). `inspect --seed 5000968` (`inspect-5000968.log`): the load at instruction 33
reads r4; r4 is written by `or r4 |= r0` (20), `mulhi` (27) and `or r4 |= r6` (28), and r6 itself by `or r6 |= r7`
(7). `or` is absorbing toward all ones: after two `or` writes from independent words every bit is set with
probability 7/8 and the whole register with probability (7/8)^32 = 1.4 percent; chained across iterations the mass
grows, and on this program r4 is 0xffffffff at that site in 731 to 739 of 2,048 evaluations in iterations 1 to 7
(36 percent). The site then reads one word, `rotl(0xffffffff x M, R) & window | offset` = 0x0ca59e4c for a full
window and 0x04a59e4c, 0x08a59e4c, ... for the windowed sites; near-all-ones values add a few hundred more. The
same word family appears in every flagged program (seeds 4,000,001, 4,000,037, 4,000,040 in the selftest: `or`
writes at 54 and 55 before the load at 60, or at 1 before the load at 3). Rule (c) does not see it: the saturation
test counts final register values only (the register is overwritten before the end), the lane-constant test needs
all 32 lanes equal, the distinct test counts per lane per hash (the hot word repeats across iterations, so it
costs one distinct of 128), and the output bias stays within tolerance. The 3 October census measured an
`or_sat_frac` per program (section 7.3, max 0.0102) but the adopted rule kept only the final-value count.
What it is worth to an attacker: nothing asymmetric. The hot words are the same for every lane that saturates, so
the GPU's coalescer and L1 already serve them without a DRAM transaction, and a chip gets exactly the same. What it
costs the design: those programs do fewer memory-hard reads than rule (c) promises (up to 10 percent fewer on the
worst program in 2,000, at least 1 percent fewer on about 1 program in 100), so the per-hash memory work of class
v4 is not the uniform 128 random reads the chip model assumes on every epoch. Gate reading: FAIL in the strict
reading (zero passing programs with a hot set under 1 percent of items), FAIL in the share reading too (programs
with a hot set capturing at least 1 percent of reads exist at about 1 percent of epochs). Proposed fix, for the hash
lane (not applied here): a per-site line in rule (c), "every load site reads at least 2,000 distinct addresses over
the 2,048 evaluations" (uniform gives 2,048 minus 0.008 expected repeats; the saturated sites read 434 to 1,855),
computed from the addresses the test already collects (one sort of 2,048 per site, 128 sites, under a millisecond);
the redraw rate rises by about the strict-reading fraction (8 percent of candidates) unless the threshold is placed
at the share reading. The alternative, dropping the `or` family from the draw table, changes the frozen weights and
is for the lane to weigh. Class check: a chip gains nothing today, but a stand-in that lets 1 percent of epochs run
with a 1 to 10 percent lighter memory side is a published-number problem (evidence row 17's per-hash reads).
(the 10^6 numbers and the hot-share distribution replace the sample when the census ends)
## Sub-row (c): header grinding
### What an attacker can steer
Only a load whose address register has not yet absorbed a dataset word is a function of the init words and the
nonce alone (taint analysis, `init_determined_sites`). On the devnet epoch-0 program these are the loads at
instructions 7, 8, 9, 10 and 31 of iteration 0; from iteration 1 every register is tainted. Over the 2,000-seed
sample the count is 1 to 7 per program, median 3, always in iteration 0 only. Everything after depends on dataset
words the miner must fetch first. The init words themselves are an FNV hash of the header and nonce_hi, so the
attacker cannot choose them, only draw them; and one draw serves a whole warp (the shuffles couple the 32 lanes),
so a per-lane draw costs 32 hashes per lane.
### The search (CPU)
`grind` draws K init words per warp (nonce_hi 0 to K-1 under the fixed prehash) and keeps the one with the fewest
distinct 128 B lines among the init-determined loads. Each try costs 32 lanes x (8 init + 32 prefix instructions) =
1,280 lane-instructions; the warp's hash costs 32 x (512 + 55,296) = 1,785,856 lane-instructions, the derivation
not counted. Logs: `grind-k10-crosssite.log` (lines counted across the five sites: coincidences that are at best an
L2 hit), `grind-k10-pages.log` (2 KB pages across the sites), and the `intra` runs (lines inside one load
instruction, what the coalescer merges into one transaction) that feed the card.
| Metric | K | Warps | Mean lines or pages saved per warp (of 4,096 loads) | Warps improved | Search per warp in hashes |
|---|---|---|---|---|---|
| lines across the sites | 2^10 | 524,288 | 0.895 (0.022 percent) | 89 percent | 0.73 |
| 2 KB pages across the sites | 2^10 | 65,536 | 1.455 (0.036 percent) | 98 percent | 0.73 |
| lines inside one instruction (intra) | 2^10 | 2^17 | (pending) | | 0.73 |
| lines inside one instruction (intra) | 2^14 | 2^17 | (pending) | | 11.7 |
### The card (RTX 5090)
Smoke run (1 round of 2 s per variant, `pod smoke` logs): the calibration of what one saved line is worth.
| Variant | What changes | MH/s | Against honest | W | MH/J against honest |
|---|---|---|---|---|---|
| honest | the pack kernel, one init per dispatch | 141.76 | | 482 | |
| perwarp-random | per-warp init table, no search | 141.73 | -0.02 percent | 486 | -0.7 percent |
| perwarp-k10 | per-warp table, best of 2^10 (cross-site table) | 141.74 | -0.01 percent | 487 | -1.0 percent |
| perwarp-k14 | (the same table in the smoke run) | 141.74 | -0.01 percent | 488 | -1.1 percent |
| pair | lane 1 reads lane 0's address at load 7: 1 line of 4,096 saved | 142.05 | +0.21 percent | 490 | -1.4 percent |
| forced1 | load 7 broadcast: 31 lines saved | 151.39 | +6.8 percent | 506 | +1.8 percent |
| forced4 | loads 7, 8, 9, 10, 31 broadcast: 155 lines saved | 203.11 | +43.3 percent | 530 | +30 percent |
| forcedall | every load broadcast: 3,968 lines saved | 399.61 | +182 percent | 520 | +161 percent |
Reading: the class v4 kernel on the 5090 is bound by its random reads (141.8 MH/s x 128 = 18.1 G reads per second,
the card's measured random-read ceiling in `docs/bench-log.md`), and a load instruction completes when its slowest
lane's transaction returns, so one saved line is worth about 0.2 percent of rate, 31 lines 6.8 percent, and the
five init-determined loads fully coalesced 43 percent. That ceiling is unreachable by search: it needs the 32
lanes' 28-bit addresses to fall in one line at five sites, probability 2^-115 per draw. What a draw can reach is one
coalesced pair at one site (probability 5 x C(32,2) / 2^23 = 3 x 10^-4 per try, about 3,400 tries per pair);
two pairs need about 6 million tries, m pairs about 3,400^m / m! tries. One pair is worth 0.2 percent of one warp's
hash and costs 3,400 x 1,280 lane-instructions = 2.4 hashes of search. The measured per-warp tables (5 rounds of
8 s, pending) are the direct check.
(the full run's table replaces the smoke run when it ends)
## Consequences per tier
(filled in with the verdict)
## Logs
| Log | Path |
|---|---|
| selftest, inspect, grind, census parts and drivers | `/srv/builds/igneum-wt-attack/attack-f9/` on igneum-build-1 (`selftest.log`, `inspect-*.log`, `grind-*.log`, `edges.part*.tsv`, `edges.driver.log`, `hotset.part*.tsv`, `hotset.driver.log`, `hotset-timing.tsv`, `ref.txt`, `table-*.bin`) |
| card smoke run and full run | the pod's `/workspace/f9/podjob/smoke/log/` and `log/` (`run.log`, `nvcc.log`, `host.log`, `power.csv`, `power-by-variant.txt`, `sha256.txt`), copied to `/srv/builds/igneum-wt-attack/attack-f9/pod/` at the end |
### The card, the measurement (RTX 5090 pod ap-f9, `pod/host.log`, sha256 83b1372c..., copied to
`/srv/builds/igneum-wt-attack/attack-f9/pod/`)
Per-warp header grinding at K = 2^14 draws per warp against the honest kernel, 5 interleaved rounds of 8 s each:
141.62 against 141.61 MH/s, +0.004 percent of rate, sd 0.003, at 11.7 hashes of search per hash. The unreachable
ceiling (the five init-determined loads fully coalesced, `forced1` extended) is +43 percent. Gate: the grinding gain
under 1 percent of rate at any search cost. Sub-row (c): PASS. Pod time about 1 h 50 min from 09:02 UK; destroyed on
the lane's done line.
## Full censuses (box 1 parts 0 to 7 and 0 to 6; box 2 parts `edges-b2`, `hotset-b2` after the lane's move to
build-2 at 12:2x UK; `summarise.py` over all parts, 12:5x UK)
### (a) The edges on generator 4, 10^5 seeds
| Quantity | 100,000 seeds |
|---|---|
| Candidates evaluated | 105,064 |
| Verdicts agreeing | 105,030 |
| Disagreements | 34 (0.032 percent of candidates; the 3 October census had 39 in 100,000 on its generator) |
| By kind | `const_bit` 29 (closed form accepts 17, memory-hard accepts 12); `bias` 4 (3 and 1); `lane_const` 1 (memory-hard accepts) |
| Seeds whose chosen attempt differs | 34, every one moving to the next attempt, which both stand-ins accept; exhausted 0 on either |
| Rejected, closed form / memory-hard | 5,046 / 5,048 (static 4,201; then const_bit 424 / 428, saturated 275 / 275, distinct 112 / 112) |
| Accepted margins, closed form | saturated at most 148 of 164, bias at most 121 of 136, distinct sum at least 247,335 (bound 245,760) |
| Accepted margins, memory-hard | saturated at most 157, bias at most 126, distinct at least 247,571 |
The 20,000-seed reading holds at 10^5: the stand-ins disagree on a one-bit property (a final register bit pinned in
2,047 of 2,048 hashes against 2,048) and on four programs' output bias within sampling noise, never on saturation
or the distinct count, and never in the attacker's favour. Gate: the 39 edge disagreements reproduced and bounded.
Sub-row (a): PASS.
### (b) The hot-set search, 10^6 seeds
| Quantity | 1,000,000 seeds |
|---|---|
| Programs with any hot bucket (strict) | 75,400 (7.5 percent) |
| Programs flagged at the gate reading (hot share at least 1 percent of reads, or 7 constant address bits) | 11,696 (1.17 percent) |
| Worst hot share | 17.3 percent of the program's reads (seed 842871) |
| Hot-share bands (block init) | 0: 932,106; under 0.1 percent: 11,721; under 0.5: 3,645; under 1: 41,581; 1 percent and over: 10,947 |
| Sites with 7 or more constant address bits | 0 (max 6) |
| Fewest distinct addresses at a site in 2,048 evaluations | 43 |
| Most evaluations reading one address at a site | 1,838 of 2,048 |
| Mean hot share over programs | 0.063 percent |
Gate: zero passing programs with a hot set under 1 percent of items. FAIL on the class v4 stream by the letter:
11,696 passing programs concentrate 1 percent or more of their reads on a hot set. The mechanism is F9-1 above,
the or-saturated load source, which is the same fault class the F8 row found from the cross-hash histogram
(AP-F8-1: a load whose source's last writer is lossy); the two harnesses found it independently, one from the
program's address trace per site, one from the item histogram across hashes. F9-1 therefore merges into AP-F8-1,
and the fix is the amendment shipping in 0.3.20 (a load's source drawn only from registers whose last writer injects
or is a rotate). Sub-row (b): FINDING (AP-F8-1 class); re-gated on the amended stream with this harness below.

View file

@ -0,0 +1,53 @@
# O-1.14 bench start 2026-10-07T08:49:03Z host root@ssh9.vast.ai
Warning: Permanently added '[ssh9.vast.ai]:35608' (ED25519) to the list of known hosts.
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
# binary copied, sha256 6d28678359d3d6bd158b245f7e522d6f2a5b0704d9997c0fb50ebc3471a9ebe5
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
# cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
# cores: 8 mem: 31 GB
# glibc: ldd (Ubuntu GLIBC 2.39-0ubuntu8.9) 2.39
# clock MHz: 4169.856
08:49:07 up 20 days, 10:27, 0 user, load average: 0.13, 0.07, 0.05
## --class v2 08:49:07Z
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
cache: fill 283.0 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
warp base 0: single cold run 1.582 ms, 4096 items derived, lane0 42246ba99fc58e4f lane31 b08446b1f2de7793
warp base 4096: single cold run 1.392 ms, 4096 items derived, lane0 3d3903e310ca038f lane31 61c242509efdccdd
warp base 1000000: single cold run 1.374 ms, 4096 items derived, lane0 f218c1bd58e6dfe0 lane31 6c3b2c11adfbfcac
CPU verify: 1.280 ms per 32-lane warp, avg of 50 (checksum 19297e99c7b9a55e)
## --class mx8 08:49:09Z
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
cache: fill 276.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
warp base 0: single cold run 5.394 ms, 4096 items derived, lane0 19b56348bc85304d lane31 359192708e4f754a
warp base 4096: single cold run 5.285 ms, 4096 items derived, lane0 62fb132a9943127a lane31 7d7866cb9cfca8ff
warp base 1000000: single cold run 5.293 ms, 4096 items derived, lane0 86b6cb0e13d89b03 lane31 9c004678515e44ec
CPU verify: 5.267 ms per 32-lane warp, avg of 50 (checksum 653a23f7ee1c8c63)
## --program-class v4 08:49:11Z
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
cache: fill 276.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
warp base 0: single cold run 6.334 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 6.198 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 6.174 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 6.006 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
## --class dr368 08:49:14Z
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
cache: fill 276.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
warp base 0: single cold run 5.540 ms, 4096 items derived, lane0 c77c7625bbe0f452 lane31 c8e84ff2655d9934
warp base 4096: single cold run 5.433 ms, 4096 items derived, lane0 7229bd981a5786ca lane31 1c5495083ae60453
warp base 1000000: single cold run 5.430 ms, 4096 items derived, lane0 5533769c9cdbf0a7 lane31 2426905704457b11
CPU verify: 5.426 ms per 32-lane warp, avg of 50 (checksum 94fcbf0a77e03bdc)
## --class dr736 08:49:16Z
Welcome to vast.ai. If authentication fails, try again after a few seconds, and double check your ssh key.
Have fun!
cache: fill 275.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
warp base 0: single cold run 10.290 ms, 4096 items derived, lane0 e23d389f3eea0c83 lane31 6605db059b381bd9
warp base 4096: single cold run 10.072 ms, 4096 items derived, lane0 fdb4b214da8ce292 lane31 db698d03437d74f7
warp base 1000000: single cold run 10.056 ms, 4096 items derived, lane0 534671b1bf5cea36 lane31 733b123353f13d21
CPU verify: 10.042 ms per 32-lane warp, avg of 50 (checksum bf79909c25836153)
# O-1.14 bench end 2026-10-07T08:49:19Z

View file

@ -1,6 +1,6 @@
# Block rate on Devnet 2: 10 blocks per second against 1, on the rented fleet
6 October 2026, the project lead's experiment (through the coordinator, 17:5xZ): "Devnet 2 at a higher block rate for solo miners
6 October 2026, the founder's experiment (through the coordinator, 17:5xZ): "Devnet 2 at a higher block rate for solo miners
(Kaspa's answer)". Branch `gpu-fleet`; the node profile on the fork branch `devnet2-bps` (1279a1d6 on release-0.3.14-node
4c6b129d): a suffixed devnet started with `IGNEUMD_DEVNET_BPS=10` (or 5) runs `BlockrateParams::new::<10>()` with the
TenBps subsidy and target time, Kaspa's Crescendo-style constants for that rate (k 124, merge depth, sample rates,

View file

@ -0,0 +1,62 @@
# AP-F8-1 under the windows-union model: the hot set is the load source, not the window (7 October 2026)
Branch `ca3-v4-uniform` from master b92a5fd4, worker "v4-hash", on the attack-pass finding AP-F8-1 (`docs/analysis/attack-pass/f8-uniform.md`, branch attack-pass; logs `/srv/builds/igneum-wt-attack/target-attack-f8/log/`). Main's rulings bound this file: no generator change to class v4 on the live devnet; the analysis and its harness only. Every GPU-free number here is arithmetic on F8's logged counts or a run of the static census tool `tools/ca3-v4-uniform/` on igneum-build-1 (built through `tools/build-remote.sh`, rule R1); the chip figures are the terms of `docs/analysis/chip-model-v3.md` and are approximate.
## 1. The null F8's numbers must be read against
Layer 8 (`docs/plans/era-layout.md` 1.4, spec 01 1.13.1 as proposed) gives every load site a window draw `k_off = below(3)`: the site reads the whole dataset, an aligned half or an aligned quarter, at a 2^26-word floor. A quarter-window site concentrates its reads 4x on its quarter and a half-window site 2x on its half, by design; the union of the 16 windows is the whole dataset. For p1 (the devnet epoch-0 program, id c120d7963abdcd96) the 16 draws `0:2:1 1:1:1 2:1:1 3:1:1 4:0:0 5:1:1 6:0:0 7:2:2 8:1:1 9:1:1 10:2:0 11:0:0 12:0:0 13:0:0 14:2:0 15:1:1` (site:k:offset) give an expected read density by quarter of 3.25 : 2.25 : 5.75 : 4.75 sixteenths of the flat mean, which at 2^26 nonces (512 reads per item flat) is 416, 288, 736 and 608 reads per item. The top-f share of a Poisson mixture with those means (`uniform-model.txt`, exact Poisson for p1, a normal approximation for the census):
| Share of all reads on the top f of items, 2^26 nonces | Flat Poisson (F8's control) | Windows-union model, p1 | F8 measured, p1 | Beyond the window model |
|---|---|---|---|---|
| f = 0.1 percent | 0.115 | 0.160 | 0.520 | +0.36 |
| f = 0.5 percent | 0.565 | 0.784 | 1.515 | +0.73 |
| f = 1 percent | 1.120 | 1.553 | 2.495 | +0.94 |
So the window model moves the null from 0.115 to 0.160 percent at f = 0.1 percent (1.39x, not F8's 4.05x) and from 1.12 to 1.55 at f = 1 percent; it explains the 64-item-bucket sigma of p2 that F8 already attributed to the window layer, and it explains every per-site attribution row of p1 except one: sites with a half window over the hot region land 0.20 percent of their reads in the top 0.1 percent (sites 1, 2, 3, 5, 9 at 0.201), quarter-window sites 0 or about 0.4 (sites 0, 10, 14 at 0.000, site 7 at 0.241 straddling), whole-dataset sites 0.10. Site 15 lands 6.374 percent. The excess over the model (+0.36 at f = 0.1 percent) is one site.
## 2. The 153x item is the load source, and the model predicts it to the item
p1's site 15 is the load at instruction 63 (`load dst=3 src=6`, window half 1). Its source r6 was last written at instruction 61: `or dst=6 src=4` (`r6 |= r4`, `verify.rs` Op::Or), after a fresh dataset load into r6 at 47. An OR of two near-uniform registers sets each bit with probability 3/4, so the source takes the all-ones value with probability (3/4)^32 = 1.0e-4 per read and the values of popcount 31, 30, ... with 32, 496, ... times (3/4)^k (1/4)^(32 - k). The era map `y = rotl(x * 0x9ad30d99, 29)`, the half window and the interleave split (`memhard::Layout::split`, positions 0, 2, 12, 13) send x = 0xffffffff to item 0xca5b92: F8's hottest item exactly. F8's next seven items (0x8a5b92, 0xaa5b92, 0xba5b92, 0x825b92, 0xe65b92, 0x985b92, 0xbcc392) are exactly the seven one-zero-bit sources whose zero bit survives the window mask (bits 29, 28, 27, 26, 25, 24 and 15): 7 of 7. The measured count fixes the bit bias: 78,479 reads of 2^26 x 8 site-15 reads is p^32 at p = 0.7585 (r4 is slightly biased itself), and at that p the popcount model predicts 77,348 all-ones reads and 4.86 percent of site 15's reads into the top 0.1 percent of items (measured 6.37; 3.92 at p = 3/4). Per hash that is 4.86 / 16 = 0.30 percent of all reads, and 0.160 + 0.30 = 0.46 against F8's 0.520 at f = 0.1 percent; at f = 1 percent 14.5 / 16 = 0.90, and 1.55 + 0.90 = 2.46 against 2.495.
The same arithmetic for the other lossy writers (`uniform-model.txt`): a `mul` last writer zeroes the low bits by the operands' trailing zeros, so 1.07 percent of the site's reads land on the 0.1 percent of values with 10 or more trailing zeros (p2's `mul`-sourced sites 0 and 14 measured 0.971 and 0.966 percent); a `mulhi` last writer is dense near zero, 0.79 percent on the lowest 0.1 percent of values. An `or` whose operand was itself last written by `or` compounds the bias (3/4 to 7/8 to 15/16): p3's site 15 (`or` at 30, the load at 62) puts 72.4 percent of its reads into the top 0.1 percent, 4.6 percent of all reads on 16,777 items.
This is a fault class, not the window model: the acceptance rule's part (a) (`accept.rs` check_stale_loads) takes any write as a fresh source, and part (c)'s saturation count looks at the 16,384 final register values, not at a load's source mid-program, so an `or`, `mul` or `mulhi` as a load's last writer passes. The per-hash distinct-address check still holds (p1 127.999 items per hash; p3 127.97: a saturated site repeats its item inside a hash), and the acceptance rule's floor of 120 distinct of 128 admits exactly one site repeating its item in all 8 iterations and no more.
## 3. How common it is: the static census (`tools/ca3-v4-uniform`, 1,024 chain-shaped class v4 programs plus F8's p1 to p3)
For every load site, the op that last wrote its source in execution order (base instructions before it, else the shadow block of the previous iteration, else the base instructions after it): injecting (add, sub, xor, mad, shfl, load), bijective (rotl, rotr) or lossy (or, mul, mulhi). Run on igneum-build-1 (`uniform-census.txt`, binary sha256 ce9f83fe... then the narrowed chain rule).
| Census over 1,024 programs | Count | Share |
|---|---|---|
| Load sites by last writer: injecting / bijective / lossy | 11,368 / 2,121 / 2,943 of 16,432 | 69 / 13 / 18 percent; 2.87 lossy sites per program |
| Programs with at least one lossy-sourced load | 992 | 96.6 percent |
| ... with an `or`-sourced load (p1's class, 0.30 percent of all reads per site) | 498 | 48.5 percent |
| ... with an `or`-of-`or` chain (p3's class, about 4.5 percent of all reads per site) | 50 | 4.9 percent |
| ... with a `mul`-sourced load (0.067 percent per site) / a `mulhi`-sourced load (0.049) | 751 / 661 | 73.1 / 64.4 percent |
| Predicted S_0.1 percent (window model plus the lossy sites): median / 90th / 99th / max | 0.45 / 0.88 / 5.29 / 9.82 percent | against the window model's 0.115 to 0.251 |
| p1 / p2 / p3 predicted against F8 measured | 0.579 / 0.323 / 4.72 | 0.520 / 0.272 / 4.60 |
F8's proposed gate (the top 0.1 percent within 1.2x of the window-model control on every one of 64 seeds) fails 96.6 percent of today's programs, because any lossy-sourced site alone exceeds it (0.16 + 0.05 at the least); it is a generator change in a gate's clothing. A 2x bound fails 69.7 percent, 3x 48.9 percent; a bound of S_0.1 percent at or under 1 percent of all reads fails 6.9 percent (the `or` chains and the multi-`or` programs). The static rule "no load whose source's last writer is `or`" fails 48.4 percent; "no lossy last writer" 96.6 percent.
## 4. What the skew is worth to a chip (chip-model-v3.md terms, approximate)
A hot-set cache of the top 0.1 percent of items is 16,777 items x 64 B = 1.07 MB of SRAM, 0.53 mm^2 and $0.25 at 0.49 mm^2 and $0.23 per MB. It serves 0.52 percent of p1's reads (0.16 of them the window model's), 4.6 percent of p3's. The hash is latency-bound on its dependent reads, so a read served on die is time saved: a chip gains at most 1.005x on p1 and 1.048x on p3 from the cache. The ceiling under the live rule: part (c)'s 120-of-128 floor admits one site repeating its item in all 8 iterations and no more (two saturated sites fail it), so at most 8 of 128 reads, 6.25 percent, can sit on a constant item, and a chip's edge from this whole class is at most 1 / (1 - 0.0625) = 1.067x, in 64 bytes of SRAM, on the hours whose program carries such a site. The public claim rests on 2x margins (chip-model-v3.md); 1.067x does not move it, and the union of the windows is still the whole dataset every hour, so no window-level cache exists. What moves: per tier nothing in rate or watts (the honest card reads the hot item from L2 as the chip would), and the 5 percent rule of 2.0 is untouched.
## 5. The two options for the flip, priced (main's ruling 3; nothing ships on this without the founder's word)
| Option | What changes | Cost | Risk |
|---|---|---|---|
| A. A class amendment in 0.3.19 before the flip: the generator draws a load's source from the registers whose last writer injects (or rule (a) tightened to the same), class v4 re-pinned | a new program stream: new vectors, the seven gate packs re-exported, the six gates again (the hash side G1 to G3 and the verifier re-run here in about an hour of Mac and PC 2 time; G4 to G6 the node lane), every node before the flip by the one-box-at-a-time fleet rule | hours of gate time, a fleet rollout, the 0.3.19 ship on the line | a node that misses the build splits the chain at the flip; the fix itself is small (one draw rule) |
| B. Hold v4 at the floor as it is; the source rule in class v5 | nothing on the devnet; the attack-pass record carries the window null and the bound | a hot set on 48 percent of hours worth up to 1.005x to a chip, on 5 percent of hours up to 1.05x, 1.067x at the rule's ceiling, no chain risk | the public line must state the bound, not "uniform" |
The number that decides it: 1.067x at the ceiling against the 2x margin of the chip claim. Recommendation: B, with the v5 item below, unless the founder wants the tail tight now.
## 6. The acceptance bound for the next class (main's ruling 4)
Definition: for a program, H = W_0.1(windows) + sum over load sites of h(last writer of the source), with W from the Poisson mixture of the 16 window draws (0.115 to 0.251 percent at 2^26 nonces) and h = 0.30 percent for `or`, 4.5 for an `or` chain, 0.067 for `mul`, 0.049 for `mulhi`, 0 for an injecting or bijective writer (the figures of section 2 at the measured bias). The bound: H at or under 1.2 x W, which is the static rule "every load's source was last written by an injecting op or a rotate" (any lossy writer breaks 1.2x). Its cost as a rejection rule on today's stream: 96.6 percent of candidates, about 30 attempts per seed on average. The cheaper form is a generator draw, not a rejection: draw a load's source from the registers whose last writer injects (today's rule draws from every written register), which costs no attempts and leaves rule (a) as it is. Either way the 64-seed census of F8's phase E is the gate, with the dynamic check extended to count saturated load sources over the 64 units beside the final values.
## 7. What is unverified
- The per-site h figures are the popcount and trailing-zeros models at the biases F8 measured on p1 and p2; p3's chain figure is F8's measurement, not a model. F8's phase E (64 seeds, dynamic) is the test of the whole table.
- The window model's top-f shares for the census use a normal approximation per quarter (p1's exact Poisson 0.160 against 0.159).
- No GPU run and no timing here; every number is a count or arithmetic.

View file

@ -69,7 +69,7 @@ Proving is a separate budget (the 15.6 GB peak the 12 GB mine-and-prove question
## 4. Table 3: the public sentences against the numbers
| Where | Sentence now | What the tables give | Proposed sentence (the project lead decides the wording) |
| Where | Sentence now | What the tables give | Proposed sentence (the founder decides the wording) |
|---|---|---|---|
| `site/index.html` 443 | Memory: "2 GB, fixed" (RandomX) / "2 GB, growing" (Igneum) | 2 GiB at genesis, plus 0.5 GiB a year on average under either option | "2 GB, growing 0.5 GB a year". The row is right; the rate is the useful addition |
| `site/index.html` 461 | "Any 4 GB card, approximate." | True at genesis (2,584 to 2,834 MiB of a 3,072 MiB budget). Ends at 1 to 1.5 years under (a), year 4 under (b) | "Any 4 GB card at launch, 8 GB for the long run, approximate." |

View file

@ -202,6 +202,8 @@ the `f = 1` chip: it is the 55 W without the 271.
### 5.4 The curve
Per row: reads per hash = 128 f; items recomputed = 128 (1 - f); ops per hash = 128 (1 - f) x 9,360 + 512.
Correction, 7 October 2026 (the in-house adversarial pass, lane adv-cache-3, report 091edc34): the partial-store rows above and adv-cache's Q1b table price a chip that holds every k-th line of the 64-line chain and recomputes a read at offset o in o evaluations ((k - 1) / 2 on average). The exact pebbling optimum for the chain (dynamic programming, checked against exhaustive search at 10 to 16 lines) sits under that curve: blocks per read 16.0 against 31.5 at f = 1/64 (the one held line belongs at line 32, not line 0), 10.5 against 15.5 at 2/64, 6.09 against 7.5 at 4/64, 3.17 against 3.5 at 8/64, 1.45 against 1.5 at 16/64, equal from f = 1/2. So a chip holding 1/64 of the cache pays 9.3x the item's ops, not 17.4x; at f = 1/2 and above nothing moves, and the SRAM column and the full-store verdict stand (no point on the curve beats the full store under the op budget or under energy).
Memory-bound rate = the ceiling / (128 f). Compute-bound rate = 50 T op/s / ops per hash (the section 1 budget).
The rate is the smaller; "binding" names it. Power = rate x (128 f x E_read + 128 (1 - f) x 6.3 nJ) + static (memory,
controller, and 20 W for the recompute die's clocks and leakage when `f < 1`). Energy per hash = power / rate. "Gain,
@ -294,7 +296,7 @@ low on this result; it stays a reserve family. What does move the `f = 1` rows,
| Read granularity | The chip pays the same 32-byte atom the 5090 pays; the 9070 XT pays 64. Wider honest reads (w16, measured, layer 1) give the chip nothing and the 5090 nothing; w64 made the 5090 bandwidth-bound (71.9 MH/s) | w64 costs the 5090 47 percent | Not a lever; the decision to stay at 4 B stands |
| Latency | A longer chain (more reads per hash) scales the chip's rate and the card's rate together; lane state is 64 B, so lanes are free to the chip | Nothing per se | Not a lever: the rate per chip is lanes / latency on both sides and the chip has more lanes per watt |
| The denominator: the 5090's watts at the hash | The gain is 2.40 microjoules over the chip's 0.47; the card's 326 W is 92.9 percent utilisation spinning on loads. At a 250 W cap holding 136.1 MH/s the gain reads 3.9x (GDDR7) and 5.7x (HBM3); at 200 W, 3.2x and 4.6x | None if the rate holds under the cap; the measurement is one PC 2 job (`nvidia-smi -pl 200, 250, 326`, two minutes each, STATUS lines as the rate) | The first measurement to run; it moves every row and costs nothing. Owed (PC 1 is not released; PC 2's budget is the coordinator's) |
| Program work in the latency shadow | The hash hides 512 ops per hash behind 128 reads; the 5090 could hide 330,000 (45.2 T / 136.1 M) before compute binds, the M5 Max about 290,000 and the 9070 XT about 650,000 (their ALU budgets approximate, from memory). Work in the shadow is free in hash rate and costs the card watts it now wastes: at N ops per hash the card rises from 326 toward 575 W (linear, approximate) and the chip must add a core that runs the per-epoch random program, at k times the GPU's 5.5 pJ per op (the 5090's marginal ALU energy, (575 - 326) / 45.2 T). At N = 100,000: the card 401 W, 2.95 microjoules; the chip 1.02 at k = 1, 0.83 at k = 1.5; gain 2.9x and 3.5x. At N = 200,000: 477 W, 3.50; chip 1.57 and 1.20; gain 2.2x and 2.9x. At N = 330,000: 575 W, 4.22; chip 2.28 and 1.68; gain 1.85x and 2.5x | Hash rate none while every card stays latency-bound (under about 290,000 on the M5 Max); watts up to TGP; the verifier N x 32 ops per warp: 3.2 M at N = 100,000, under 1 ms at the 18 G op/s the x8 verifier shows (38 M ops in 2.08 ms), inside the 10 ms gate; `INSTR_COUNT` and `ITERATIONS` are prototype values to be fixed at gate 1 (spec 1.4) | The only lever that moves the f = 1 row toward 2x, and only if the chip's core is no better than a GPU's on a random program (k near 1: RandomX's argument, and the project lead's goal in the brief's words, "build a better GPU than NVIDIA"). It reaches 1.85x at the 5090's full ALU budget and k = 1, not under; combined with a 250 W cap it reads about 1.4x (approximate). It is item 2's idea applied to the program, not to the item derivation |
| Program work in the latency shadow | The hash hides 512 ops per hash behind 128 reads; the 5090 could hide 330,000 (45.2 T / 136.1 M) before compute binds, the M5 Max about 290,000 and the 9070 XT about 650,000 (their ALU budgets approximate, from memory). Work in the shadow is free in hash rate and costs the card watts it now wastes: at N ops per hash the card rises from 326 toward 575 W (linear, approximate) and the chip must add a core that runs the per-epoch random program, at k times the GPU's 5.5 pJ per op (the 5090's marginal ALU energy, (575 - 326) / 45.2 T). At N = 100,000: the card 401 W, 2.95 microjoules; the chip 1.02 at k = 1, 0.83 at k = 1.5; gain 2.9x and 3.5x. At N = 200,000: 477 W, 3.50; chip 1.57 and 1.20; gain 2.2x and 2.9x. At N = 330,000: 575 W, 4.22; chip 2.28 and 1.68; gain 1.85x and 2.5x | Hash rate none while every card stays latency-bound (under about 290,000 on the M5 Max); watts up to TGP; the verifier N x 32 ops per warp: 3.2 M at N = 100,000, under 1 ms at the 18 G op/s the x8 verifier shows (38 M ops in 2.08 ms), inside the 10 ms gate; `INSTR_COUNT` and `ITERATIONS` are prototype values to be fixed at gate 1 (spec 1.4) | The only lever that moves the f = 1 row toward 2x, and only if the chip's core is no better than a GPU's on a random program (k near 1: RandomX's argument, and the founder's goal in the brief's words, "build a better GPU than NVIDIA"). It reaches 1.85x at the 5090's full ALU budget and k = 1, not under; combined with a 250 W cap it reads about 1.4x (approximate). It is item 2's idea applied to the program, not to the item derivation |
| The clock (item 4) | The f = 1 chip is a commodity-memory controller project: by the Ethash precedent, 32 months to a first chip at the largest prize, and a chip over 2x at 65 months | None | The issuance trigger and the share-pattern detector matter more than any item-derivation change |
So: item 2 can wait; the power-cap measurement runs first; the program-length lever is the Counter ASIC 3.0 design
@ -335,6 +337,26 @@ measurements land.
- The ALU budgets of the M5 Max and the 9070 XT, their power at the hash, and the verifier's cost at N = 100,000
program ops are estimates; the program-length lever is a design item with its own measurements, not a result.
### 5.10 Class v5 on, the shadow at zero: does the state-derived dataset make the shadow unnecessary? (7 October 2026, 21:3x UK, the founder's question "we need a solution, deep research, other methods")
The question: with class v5 (the dataset built from the chain's execution state, refreshed per window) and the latency-shadow work of class v4 set to zero (class v3 energy on every GPU), what edge does the strongest chip keep over an RTX 5090 per joule? If it were at or under about 2x the shadow could come off after v5 and the class v4 premium (145 W on a 5090 at the unlocked core, 88 W at the 1,400 MHz lock, measured 7 October 2026) would vanish.
What class v5 changes for the chip, from `docs/design/class-v5-stored-state.md` sections 2 and 2a: the recompute chip (`f = 0`, the dataset derived on the fly from the cache) and the stateless or stale chip are removed as categories, because every item takes a leaf of the state and the leaves refresh every window. What it does NOT change: the strongest chip was never one of those. It is the `f = 1` stored-dataset chip of 5.5, a GPU's memory system without the GPU, and under class v5 it needs one thing more, the window's leaves, which section 2a.2 prices honestly: one node serves a whole farm, the leaves ship at 16.5 KB/s to 10,000 members today (45 MB per member per window over a 1 Gbit/s WAN before the state is 7,500x today's), and the rebuild on the chip is the same 32 ms per window every GPU pays. The dataset is still derived from a seed and the state, so a central node compresses everything but the state's bytes, and the chip stores the result as before.
The arithmetic, on the model's own figures (5.3: the activate-bound ceilings, 2.0 nJ per random read on GDDR7 and 1.2 nJ on HBM3, the static and controller watts; the 5090 at 136.1 MH/s on 326 W, 0.417 MH/W; 128 loads per hash, the shadow at zero so no ALU beside the memory). The node is a desktop-class CPU with an NVMe and 32 GB at about 85 W (approximate, from memory of such machines; a full node with the EVM executor at 1 block/s), shared by a farm (100 chips: 0.85 W each) or carried by every chip (the attacker's worst case, 85 W each).
| Chip, class v5 on, shadow at zero | MH/s (model) | W with a farm-shared node (0.85 W) | MH/W | Edge over the 5090 per joule | W with a node per chip (85 W) | Edge |
|---|---|---|---|---|---|---|
| GDDR7 `f = 1`, 16 devices (the 5090's own memory without the GPU) | 166 | 78 | 2.12 | 5.1x | 163 | 2.5x |
| HBM3 `f = 1`, one stack | 84 | 28 | 3.02 | 7.2x | 112 | 1.8x |
| HBM3 `f = 1`, eight stacks (an H100-class package) | 666 | 175 | 3.80 | 9.1x | 259 | 6.2x |
Every figure is modelled (arithmetic on cited memory figures, approximate where 5.3 marks it); none is measured; the node's watts are an approximate from memory.
Reading: NO. Class v5 with the shadow at zero leaves the strongest chip at 5.1x (GDDR7) to 9.1x (HBM3, eight stacks) per joule, the class v3 figures of 5.6 less a rounding, because the node is a farm cost and not a chip cost; only a chip forced to carry its own node falls near 2x, and only the small ones (one HBM3 stack at 1.8x, the GDDR7 board at 2.5x), while the eight-stack package stays at 6.2x even with a node per chip. So the shadow (class v4's 100,000 ops per hash in the memory wait, which brings the chip to 2.1x at k = 1 and 3.9x on the claimed X9 core) stays the only lever in this model that reaches the memory-system chip, and the class v4 premium is the price of that lever on today's GPUs. What class v5 buys is different and real: the recompute chip and the stale chip are gone as categories, every miner must hold and follow the chain, and a chip's dataset is wrong the moment its node is. The premium itself has two measured levers tonight: the core-clock lock (57 of 145 W back on the 5090 at 1,400 MHz for 1.4 percent of rate; the knee below 1,400 is the second pass's) and the per-card tune the app lands by itself; what would remove it is a shadow whose work is cheaper per op on a GPU than on a chip core (the research lane's question: a shadow shaped for the GPU's idle datapath at low clock, or a memory-side cost the chip cannot amortise), not the state-derived dataset.
Per tier: a miner on class v4 pays the premium and gets the 2.1x to 3.9x chip ceiling in exchange; on class v5 with the shadow kept the ceiling stays and the dataset is the chain's; on class v5 with the shadow dropped the premium goes and the ceiling returns to 5x to 9x. The decision is the founder's; this section gives the number.
## 6. The per-day derivation (item 2)
6 October 2026, Counter ASIC 3.0 item 2, worker `derive` (`docs/plans/counter-asic-3-derivation.md`; everything

View file

@ -110,7 +110,7 @@ run drops from about three minutes to about one. The hosted runner then serves o
pipeline (MSVC, WebView2, Inno Setup, PowerShell 5.1, which a Linux box cannot provide). The flip is main's call after the
0.3.15 cut, per build-server.md section 7.1.
## 6. The build box's own red rows (the project lead, 22:3x UK: "also make sure we are fixing and learning from all the errors here")
## 6. The build box's own red rows (the founder, 22:3x UK: "also make sure we are fixing and learning from all the errors here")
Source: `/srv/builds/_log/builds.jsonl` on igneum-build-1, 139 rows from the first build at 18:38 UK to 22:11 UK on
6 October; 34 with a non-zero exit. Until this change the box kept no output of a run (it streamed to the agent's terminal),

View file

@ -288,7 +288,7 @@ the height arrives.
(26640/28640, a follower, lowest risk), the seed (`/opt/igneum/v4/bin`, `infra/seed-nodes/stage-v4.sh`),
Mac node 1 (26610/26611), PC 1's node (its launcher's `igneumd.exe`; PC 2 mines through the Mac node
and needs nothing). Each node gets the same `"difficulty_v2_activation_daa": N` in its override file.
the project lead restarts the live processes; this entry does not.
The founder restarts the live processes; this entry does not.
3. Watch the observer's difficulty events across the height and the next epoch boundary; with a miner
joining inside an epoch the floor and the bursts of section 1 must not return.

View file

@ -0,0 +1,86 @@
# The era VDF: built, measured and gated (7 October 2026)
Era VDF lane, 7 October 2026, from the attack pass's F7 row (`docs/analysis/attack-pass/f7-era.md`, sub-row a: the node's era seed was a plain chain block hash, grindable with one block of hash at no delay, and the 1-hour VDF of spec 04 section 4.4 did not exist in the node). Repository branch `era-vdf` (this record, the spec text, the harness `tools/era-vdf/`, the fast-time fields); node fork branch `era-vdf-node` on the 0.3.19 line (`release-0.3.19-node` dc141409). Every number below names its log on igneum-build-1 under `/srv/builds/igneum-wt-era-vdf/ev-*/`.
## 1. What was built
| Piece | Where | What |
|---|---|---|
| The integer | `consensus/core/src/era_vdf/bigint.rs` | a fixed-width signed integer (40 limbs, 2,560 bits) on the stack: add, sub, mul, shifts, Knuth division with floor, truncated, exact and Euclidean remainders, the extended gcd and the partial extended gcd with Lehmer's word steps (chiavdf `xgcd_partial.c`), modpow, sqrt and the fourth root, Miller-Rabin with the first 30 primes as bases; every operation checked against `num-bigint` on 20,000 random operands of the class group's sizes, the known-failed shapes first |
| The class group | `era_vdf/classgroup.rs` | `proto-vdf/src/classgroup.rs` (3 October 2026) on the fixed-width integer: NUDUPL and NUCOMP ported line by line from chiavdf's `qfb_nudupl` and `qfb_nucomp`, the plain duplication and Cohen 5.4.7 kept as the oracles the tests hold them to on random forms at 256, 512 and 1,024 bits; serialization as sign byte plus fixed width, 258 bytes a form |
| Wesolowski | `era_vdf/wesolowski.rs` | eval with serialized checkpoints (at most 2^16, 17 MB), the 12-bit-digit block prover bucketed per residue class and parallel over them, the naive prover as the oracle, verify; T + 1, another y, another pi and another input refused |
| The hash chain | `era_vdf/hashchain.rs` | scheme 1: T sequential SHA-256 applications from a tagged start; verification by recomputation; one step short refused |
| The scheme byte and the seed | `era_vdf/mod.rs` | `vdf_scheme` 0 and 1, `EraVdfProof` and its wire form, `era_vdf_input` (the chain's BLAKE2b keyed `IgneumEraVdfInput` over `chain_id || n || the day's blue hashes`), `era_seed_of` = SHA-256 of the scheme byte, the input, T and y |
| The switch | `consensus/core/src/config/params.rs`, `igneum.rs` | `pow_era_blocks` and `pow_era_lead` as override fields (the constants everywhere; in the digest when they differ), `era_vdf_activation_daa` (never), `vdf_scheme` (0), `era_vdf_t` (the reference T); the three in the digest once the activation is set (the 0.3.15 rule); installed with the PoW schedule |
| The node side | `consensus/src/processes/era_vdf.rs`, `model/stores/era_vdf.rs` | the cut rule (the chain block below the cut, memoised and re-validated by reachability), the day-of-blues input (memoised per cut block), the evaluator thread started by the virtual processor a quarter of the lead past the cut, the record store (one row per era), the header processor's wait when a header arrives before the record, the template's `era_seed` None while evaluating, `submit` for a record from outside (verified against this chain's input) |
| The template and the miner | `PowEpochInfo`, `RpcPowEpochInfo`, `rpc.proto` fields 37 to 44, `igneum-miner` | the era schedule, the VDF's state, scheme, T and input in every template; the miner holds while the node reports no era seed ("era VDF: the node is still evaluating"); `igneum-miner vdf bench|eval|verify` with the node's own code |
| The harness | `tools/era-vdf/reroll.mjs` | the F7 re-roll harness against the REAL era cut (era 120 DAA, lead 20 on the merged fast-time file; ports 30100 and up, suffix 1010), `--vdf off` the stand-in, `--vdf on` the VDF at a fast T, the adversary running the node's evaluator over its candidate before publishing |
## 2. The parameters
Measured 7 October 2026 on igneum-build-2 (AMD EPYC 9454P, 96 threads, Ubuntu 24.04), one core under `/srv/builds/_bin/lease cores 31` at nice 10 while the box ran other lanes' suites (load 25 to 75), with the node's own code (`igneum-miner vdf bench`, logs `ev-vdf-bench3.log`, `ev-vdf-bench5.log` in this lane's scratch) and chiavdf 7e62ce14 built on the box against GMP 6.3.0 (`ev-chiavdf`).
| Parameter | Value | Label |
|---|---|---|
| Group | class group, 1,024-bit prime discriminant `D = -HashPrime("igneum-era-discriminant" \|\| input)`, `\|D\| = 7 mod 8` | Implemented (spec 4.2) |
| Generator, Fiat-Shamir prime, proof plan | `(2, 1, (1 - D) / 8)`; 256 bits; 12-bit digits, at most 2^16 serialized checkpoints (17 MB) | Implemented |
| Proof on the wire | 529 bytes: scheme (1), T (8), two 258-byte forms with 2-byte lengths; `y` and `pi` 258 bytes each | Measured |
| Scheme byte | `vdf_scheme` 0 = class group, 1 = hash chain; genesis 0 everywhere | Implemented |
| Reference rate, scheme 0 | 30,589 and 40,117 squarings/s in two 10-s runs on the box core (the spread is the box's load); 30,000 is the reference | Measured |
| `T_era`, scheme 0 | 3,600 x 30,000 = 108,000,000 squarings (`ERA_VDF_T_CLASS_GROUP`): 60 min at the reference, 45 at the faster run | Measured, set |
| Prove, scheme 0 | eval + prove 11.6 s at T 401,167 (eval 10.0 s): the single-thread block prover is about 14 percent of the evaluation, parallel over residue classes in the node (up to 8) | Measured |
| Verify, scheme 0 | 21.9 and 22.6 ms with the group held (mean of 20); 184 ms with the discriminant derived, the derivation being 161 to 167 ms, once per era | Measured (section 5 for the gate) |
| Reference rate, scheme 1 | 16.2 and 17.0 million SHA-256/s (SHA-NI); 16,000,000 is the reference; `T_era` = 57,600,000,000 hashes (`ERA_VDF_T_HASH_CHAIN`) | Measured, set |
| Verify, scheme 1 | recomputation: 10.1 s for T 170 million, the full hour at `T_era` | Measured |
| Discriminant search | 161 to 167 ms per era (Miller-Rabin with the first 30 primes on the fixed-width integer) | Measured |
| chiavdf on the same core | 208.8 K squarings/s (`vdf_bench square`, NUDUPL over GMP, 1,000,000 iterations); the AVX-512 IFMA path (`square_asm`) gave 127.3 K at 20,000 iterations and stalled at 300,000 and above in this build (built outside its Makefile's `FAST_MACHINE` flags), so the IFMA number is not established here | Measured; the asm path unestablished |
| Delay on the fastest prover measured | 108,000,000 / 208,800 = 517 s against the 1-s block interval (517x) and the 2-s publish window (259x); a prover 10x chiavdf's GMP path (the ceiling Chia's and the EF's hardware efforts aimed at, approximate, from memory) would still take 52 s, 26x the window | Computed from the measurements |
| The gate "at least 60x one block interval on the fastest known prover" | 517x on chiavdf's GMP path, the fastest evaluator measured on this hardware; PASS as measured, with the IFMA path unestablished (above) and the 10x hardware ceiling still 52x | PASS (measured), caveat recorded |
The node's own evaluator is 5.2 to 6.8x slower than chiavdf's GMP path on the same core. That ratio only moves the honest side: `T_era` is set from the node's rate, so an honest node finishes in the hour; the attacker's margin is the delay at the fastest prover, above.
## 3. The gate: the re-roll harness with the VDF off and on
`tools/era-vdf/reroll.mjs` on igneum-build-2 (the box under other lanes' suites, nice 10; logs and per-cut JSON under `/srv/builds/igneum-wt-era-vdf/ev-harness-out/reroll-vdf-{on,off}-5.json`), three nodes of the fork at this record's commit on the fast-time file with `skip_proof_of_work`, era 120 DAA and lead 20 (cuts at `S = 120 n - 20`, one every two minutes), ports 30100 and up, suffix 1010; two honest virtual miners share 1 block/s on nodes 0 and 1; the adversary on node 2 holds a block A built on the tip at `S - 1` and tries to make it the era's cut block. With the VDF on, the adversary runs the node's own evaluator (`igneum-miner vdf eval`, the network's T) over its candidate before publishing; T is set from a 3-s bench at the start so the delay is about 5 s on one core of this box, five times the block interval.
| Run | Switch | Cuts | A accepted | A became the cut block | Known-draw re-rolls (the seed the adversary knew before publishing is the era's seed) | Adversary's evaluation | Nodes agree on the era seed | Gate | Harness |
|---|---|---|---|---|---|---|---|---|---|
| vdf-off-5 (the stand-in, 15:08 to 15:22 UTC) | `era_vdf_activation_daa` never | 6 (eras 2 to 7) | 6 of 6 | 6 of 6 | 6 of 6: the seed is `hash(A)` every time | none needed: the draw of hash(A) is known the instant A is built | 3 of 3 on every era | FAIL (the known-pass fires) | SOUND |
| vdf-on-5 (the era VDF, 14:54 to 15:08 UTC) | activation 0, scheme 0, T 189,650 (5 s on this core) | 6 (eras 2 to 7) | 6 of 6 | 0 of 6 | 0 of 6 | 5.38 to 6.64 s, during which the honest chain advanced 3 to 10 blocks; A arrived behind them and never became the cut block | 3 of 3 on every era, the record ready (state 3) at every era start | PASS (silent) | SOUND |
What the two runs say. Under the stand-in a miner with one block of hash at the right second owns the era draw outright on this network: holding the block at `S - 1` and publishing it the moment the chain reaches `S - 1` makes it the cut block in every one of six cuts (the attack lane's epoch-cut run saw 1 of 6, with the honest block often landing first; here the adversary is faster to the second), and its draw is known the instant the block is built. Under the VDF the same adversary cannot know any candidate's draw before T steps have run; while it ran them the honest chain moved 3 to 10 blocks, so its block arrived behind the cut and the seed came from the delay over the day of blues ending at the honest cut block, the same on all three nodes. The second half of the written argument of F7 (a) holds in the node, not only on paper: the re-roll needs the draw inside the window, and the window is 1 s against a delay of 5 s here and 517 s at the fastest prover measured on the production T (section 2).
The known-pass and the known-fail ran on the same binaries, file and ports, the VDF switch the only difference. A first VDF-off run with a lookup defect in the harness (the adversary's block not found, so the verdict read "held") was discarded once the node's own log showed the adversary's hash as the cut block in 5 of 5 cuts; the harness now reads A from that log line. Two runs that overlapped on the box through a stale node of an earlier run were discarded as well (their nodes disagreed because they were two networks); the two runs above ran alone.
## 4. The cut rule and the certified checkpoint (for the finality lane)
The node names `C_era(n)` as the last selected-chain block below the cut on the header's own chain (the block the stand-in used), which is the checkpoint block the lead rule names under the O-4.3 decision of 3 October 2026 (certified or not). Three facts decide it:
1. Determinism. A header's validity must be a function of its own past. "The certificate carried by a block in the header's past, for the highest-index checkpoint with DAA score at most the cut" is such a function, but a certificate that lands after the era starts flips the reading between headers of one era (a header before the carrier reads the fallback, a header after it reads the certificate), so the certified binding needs a second rule: the carrier must sit at most half a lead above the cut (3,600 DAA s, the merge depth) and be a chain ancestor of the header; under that rule every honest header of the era reads the same certificate once the network merged the carrier, and a header on a chain that never merged it reads the fallback, consistently with its own past. The chain-block reading needs no second rule.
2. Liveness. A finality pause across the cut (a third of weight leaving in an hour is a 30-day pause under rule v3) leaves the certified binding without a checkpoint for the era; the chain-block reading always has one, which is the reason O-4.3 was decided the way it was for the epoch.
3. The defence. The grinding defence is the delay: no candidate's draw is knowable for T steps, whichever block is the cut. The binding moves which block a withholder would have to be the author of, not whether withholding pays; both readings leave the withholder with a coin flip it cannot see.
The change, if the finality lane wants the certified binding: `EraVdfManager::cut_block` (one function; the input, the delay and the seed are unchanged), plus the second rule above and a test with a certificate carried late.
## 5. The verify gate on a 2019-class core
The gate was "the VDF verifies in under 10 ms on a 2019-class core". Measured: 21.9 and 22.6 ms with the group held on the box core (above), which the F6 row's calibration puts at about 1.19x on an i7-9700K (the O-1.14 run: 6.0 ms on the 2019 core against 5.06 ms on the box proxy), so about 26 ms on a 2019 core, labelled a proxy: no 2019 host was rented this lane (Vast rentals are a purchase; not made without the founder's word). NOT MET, by 2.2x on the box and about 2.6x on the proxy.
Where the time goes and what closes it: a verification is two 256-bit exponentiations, about 770 group operations at 28 µs each; the operation is NUDUPL on the fixed-width integer, whose cost is the extended gcd (Lehmer rounds on 8-limb numbers) and the reduction. Three rounds of this lane moved it from 345 µs (a Lehmer convention defect that fell back to plain division every round) to 28 µs (the convention, i64 word division, 34 limbs, the x86-64 128-by-64 division); the next 2.2x is chiavdf's Pulmark reducer (reduce only when `a` exceeds 8 limbs, O-4.6) and a limb-level NUDUPL that keeps the partial gcd's intermediates in words, or GMP through `rug` behind a feature on the x86-64 Linux and Windows builds (chiavdf's 208 K/s is 6x this evaluator, which would put the verification near 4 ms as the prototype measured), with the fixed-width path the fallback for wasm and macOS. Owed, not blocking: the verification runs once per era (180 days) on a node that imports a record rather than evaluating; every mining node evaluates and never verifies.
## 6. Consequences per tier (the standing rule of 5 October 2026)
| Tier | What the era VDF costs | What it means |
|---|---|---|
| A home miner, any card (8, 12, 16, 24 or 32 GB), any vendor, Windows, Linux or macOS | one CPU core for about 60 min once per 180 days at the reference rate (a 2019-class desktop core about 72 min by the F6 calibration), 17 MB of host RAM for the prover's checkpoints during it, 0 bytes on the card; the node starts it a quarter of the lead past the cut and holds the record from then | nothing changes on the card or in the hash rate; the 2-hour lead covers a core half the reference speed; a node that was off across the cut evaluates on arrival and its miner holds until the record lands (the miner says so every 10 s) |
| A rig (one node, several cards) | the same one core on the rig's host, once per era | nothing per card |
| A pool user | the pool's node evaluates; the member's miner takes `era_seed` from the template as today | nothing |
| A light client or a syncing node | verifies an imported record in 22 ms (26 ms on a 2019 core, proxy) plus the 165-ms discriminant derivation, once per era; under scheme 1 it recomputes the hour | the 10-ms gate is missed (section 5); operationally one verification per 180 days |
| The protocol | the era draw's input is unknowable for 517 s on the fastest prover measured, against a 1-s block interval: the stand-in's one-block grind is closed (section 3) | the freeze of the draw procedure and the C_era cut rule no longer waits on the VDF's existence; it waits on the two decisions of `ledger-decisions.md` |
## 7. What is owed
- The P2P relay of an era record to a syncing peer and the RPC import (spec 4.5, O-4.10), before era 1 of any network with the switch set.
- The external review of the class-group port (O-4.1): the port is a second implementation checked against the textbook algorithms and `num-bigint`, not a review.
- The attack pass's F7 status row (branch `attack-pass`, `docs/analysis/attack-pass-2026-10.md`) reads INCOMPLETE pending this lane; the line for it, from section 3: "F7 (a): the era VDF is in the node (fork `era-vdf-node`); the re-roll harness against the real era cut fires with it off (6 of 6 cuts, the seed the adversary's block) and is silent with it on (0 of 6 across six cuts, the adversary's 5-s evaluation against a 1-s block interval, three nodes agreeing on every era seed); PASS, the delay 517 s on the fastest prover measured at the production T."
- The decisions of `docs/plans/ledger-decisions.md` (the activation per network, the cut's binding).

View file

@ -1,6 +1,6 @@
# Per-identity hash rate "decay" on the RTX 5090: diagnosis and fix
3 October 2026, miner-community-lead. Source data: the uploaded logs of the project lead's PC (`node tools/logs.mjs
3 October 2026, miner-community-lead. Source data: the uploaded logs of the founder's PC (`node tools/logs.mjs
nvidia-DESKTOP-KMCV30N-1-20261003-222331 --all` and the other identities, the launcher log
`igneum-DESKTOP-KMCV30N-20261003-222331`), the three serve loops, the miner's worker mode, and five runs of the
Metal worker on the Mac against private test nodes (ports 27500 and up, `/tmp/igneum-decay-test`). Figures
@ -9,7 +9,7 @@ from the logs are exact; the two labelled approximate are from memory.
## 1. Finding in one paragraph
There is no per-job growth in any worker or in the miner's memory. Two separate things produce the picture
the project lead saw. First, the STATUS line's two rates are cumulative averages since the miner started
The founder saw. First, the STATUS line's two rates are cumulative averages since the miner started
(`hashes_total / elapsed` and `hashes_total / gpu_ms_total` in `mine_worker`), so a fast first interval
decays as 1/t by construction; nvidia-1 was alone on the card for its first seconds and every later interval
ran at a flat 17.8 MH/s wall, while the last-started identity, nvidia-8, shows the mirror image, a cumulative
@ -28,7 +28,7 @@ The miner prints `hash=A MH/s wall (B MH/s inside jobs)` with `A = hashes_total
`dH / dt` and `dH / dG` with `H = A x t` and `G = H / B`. Every table below is that calculation
(`rates.py` in the bench-log entry).
### 2.1 The segment the project lead quoted: 22:57 to 23:04 UTC, after the epoch-3 restart (DAA 10,801 on)
### 2.1 The segment the founder quoted: 22:57 to 23:04 UTC, after the epoch-3 restart (DAA 10,801 on)
nvidia-1, jobs of 2^24 nonces, STATUS every 30 s:
@ -95,7 +95,7 @@ card going idle, which is what a growing CPU-side gap in every worker does.
## 3. Code audit: what is allocated per job, and what is freed
### 3.1 `proto-cuda/host.cu`, `runServe` (HEAD, the binary the project lead ran, built by the launcher at 22:09:57)
### 3.1 `proto-cuda/host.cu`, `runServe` (HEAD, the binary the founder ran, built by the launcher at 22:09:57)
| Allocation | When | Size | Freed |
|---|---|---|---|
@ -113,7 +113,7 @@ version (the hot-swap agent's) adds `CudaPair` (at most two resident, the old on
job on the new pair, `releasePair` frees dataset, cache and both modules) and `PrepareTask` (deleted after
the load); still nothing per job. `cudaDeviceSynchronize` per dispatch under the default
`cudaDeviceScheduleAuto` spins the host thread when the process holds fewer contexts than the machine has
cores, which is always true here (one context per process): that is the 6.2% CPU per worker the project lead saw (one
cores, which is always true here (one context per process): that is the 6.2% CPU per worker the founder saw (one
of 16 threads). The hot-swap working tree sets `cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync)` before
the context is created (host.cu, main), which is the right call and the right place; the thread then sleeps
on the dispatch. It does not change the hash rate directly, but eight spinning threads plus eight OpenCL
@ -250,7 +250,7 @@ because it is measured from the template fetch, which precedes the walk).
## 5. The time-slicing hypothesis: one worker versus eight, at two fixed difficulties
the project lead's Task Manager reading (GPU memory flat at 14.7 GB, the card 99% busy, each CUDA worker at 6.2% CPU) and the
The founder's Task Manager reading (GPU memory flat at 14.7 GB, the card 99% busy, each CUDA worker at 6.2% CPU) and the
hypothesis that eight contexts time-slicing one card with "longer jobs as blocks get rarer" explain the decay. A
job is a fixed 2^24 nonces, so its length does not depend on the target, but the four runs below test the
hypothesis as stated: one Metal worker and eight, each at a fixed low difficulty (2^25, 0.25 founds per job)
@ -483,20 +483,20 @@ warnings; also saved as `docs/analysis/hashrate-decay-2026-10-03.patch`).
acceptance figure is table 2.2 flattening: the gap per job no better than 0.10 s at DAA 10,800 (3,600
into an epoch) with eight identities.
## 7. Two things for the project lead to check on the PC
## 7. Two things for the founder to check on the PC
1. Dedicated GPU memory over time. Task Manager, Performance, GPU 0, "Dedicated GPU memory usage", or
`nvidia-smi --query-gpu=timestamp,memory.used,utilization.gpu,temperature.gpu,clocks.sm,clocks.mem,power.draw,clocks_throttle_reasons.active --format=csv -l 10 > gpu.csv`.
Expected: flat from the moment the eighth worker prints `ready` (each CUDA worker holds 1 GiB dataset +
256 MiB cache + 32 MiB output + the context, about 1.6 GiB; eight of them 13 to 15 GiB of the 32 GiB,
which matches the flat 14.7 GB the project lead read). A line that climbs while the hash rate falls would mean a leak
which matches the flat 14.7 GB the founder read). A line that climbs while the hash rate falls would mean a leak
in the worker; none exists in the code and the Mac RSS traces are flat.
2. GDDR7 memory junction temperature and the throttle reasons. HWiNFO64, Sensors, under the GPU: "GPU
Memory Junction Temperature", "GPU Thermal Limit", "GPU Power Limit", "GPU Reliability Voltage Limit"
(each "Yes" or "No"), "GPU Effective Clock" and "GPU Memory Clock"; or the `clocks_throttle_reasons.active`
column above (`0x0000000000000004` is SW power cap, `0x0000000000000020` SW thermal slowdown,
`0x0000000000000040` HW thermal slowdown, `0x0000000000000080` HW power brake). Approximate thresholds,
from memory: the core starts to pull clocks around 83 C (the project lead's 55 to 59 C is far below it); GDDR6X on the
from memory: the core starts to pull clocks around 83 C (the founder's 55 to 59 C is far below it); GDDR6X on the
previous generations throttles from about 95 C junction and the hard limit is 105 C; GDDR7 figures are not
published, so treat anything above 90 C junction as the zone to watch and a "Yes" on any limit row as the
signal. The decisive sign of throttling is "GPU Effective Clock" falling while utilization stays at 99%;

View file

@ -1,8 +1,8 @@
# Horizon, October 2026: the ranked research across every system
Written 6 to 7 October 2026 by the Horizon coordinator (branch `horizon`, worktree `igneum-wt-horizon`) on the project lead's ask of 6 October 2026, 20:4x UK: "deep backward and forward predictive research and modelling for our algo and all of our systems: is there room for improvement, room for more coin utility, algo improvements, security improvements, anything we can do to make a 51% attack impossible, basically creating a level of polish that has not been seen before." Later the same evening: "research anything else that we can research too, predictions, forward thinking, what we can actually do that has not been done or applied, think outside the box", "be revolutionary", and "if we create a new way of hashing or a new way of proof of work to revolutionise the space then that's absolutely fine, I want you to deploy everything to create something that has not ever been done before."
Written 6 to 7 October 2026 by the Horizon coordinator (branch `horizon`, worktree `igneum-wt-horizon`) on the founder's ask of 6 October 2026, 20:4x UK: "deep backward and forward predictive research and modelling for our algo and all of our systems: is there room for improvement, room for more coin utility, algo improvements, security improvements, anything we can do to make a 51% attack impossible, basically creating a level of polish that has not been seen before." Later the same evening: "research anything else that we can research too, predictions, forward thinking, what we can actually do that has not been done or applied, think outside the box", "be revolutionary", and "if we create a new way of hashing or a new way of proof of work to revolutionise the space then that's absolutely fine, I want you to deploy everything to create something that has not ever been done before."
The bar main set, and the bar this document holds every claim to: "impossible" is not available to any proof-of-work chain. The bar is that a majority of hash buys nothing: it cannot reverse what finality locked, cannot forge a proof the nodes re-execute, cannot change a rule without 95 percent signalling, and loses more than it earns. Every claim here is a number with a model or a simulation behind it, priced in rented hash at the measured 6 October rate (`docs/bench-log.md`, "Rental cost of hash, 6 October 2026": USD 0.0117 per MH/s-hour, so USD 11.7 per GH/s-hour and about USD 281 per GH/s-day; the live devnet at 1.16 GH/s), with its consequence per tier and what to build. Hours are agent hours (the project lead's rule: Claude-side work takes hours, never weeks).
The bar main set, and the bar this document holds every claim to: "impossible" is not available to any proof-of-work chain. The bar is that a majority of hash buys nothing: it cannot reverse what finality locked, cannot forge a proof the nodes re-execute, cannot change a rule without 95 percent signalling, and loses more than it earns. Every claim here is a number with a model or a simulation behind it, priced in rented hash at the measured 6 October rate (`docs/bench-log.md`, "Rental cost of hash, 6 October 2026": USD 0.0117 per MH/s-hour, so USD 11.7 per GH/s-hour and about USD 281 per GH/s-day; the live devnet at 1.16 GH/s), with its consequence per tier and what to build. Hours are agent hours (the founder's rule: Claude-side work takes hours, never weeks).
## The lanes
@ -17,11 +17,11 @@ The bar main set, and the bar this document holds every claim to: "impossible" i
| 7 frontier | `docs/analysis/horizon/frontier.md`; model `sim/horizon/frontier/frontier_model.py` | landed, commit 5ff7393 |
| 8 new-proof-of-work | `docs/analysis/horizon/new-pow.md`; prototypes `proto-newpow/` | landed, commits cbcff47 and 92db7c5 (two prototypes measured on rented 4090s, verdicts in section 6) |
## 1. One page for the project lead
## 1. One page for the founder
Closed 6 October 2026, 22:3x UK, every lane landed.
**Standing decisions (the project lead, 6 October 2026, 22:3x UK).** Verbatim: "Fees cannot fund security for a decade" and "Miners need to be the security". Meaning: miners are the security always, no time bound, no stake, no outside checkpoints or committee. The chain pays its own miners from emission plus fees; nobody pays upkeep, not the founder, not a treasury, not a dev fund. Self-sustaining means the emission curve keeps mining worth doing on its own for as long as fees are small, which lane 4 measures as a decade or more, so emission never decays on a schedule that assumes fees take over. Fee revenue is never assumed as the security budget in any model or public sentence. His third line, "Wrong constants and claims in our own text", acknowledges lane 6's finding; the nine ledger rows in item 9 are the fix.
**Standing decisions (the founder, 6 October 2026, 22:3x UK).** Verbatim: "Fees cannot fund security for a decade" and "Miners need to be the security". Meaning: miners are the security always, no time bound, no stake, no outside checkpoints or committee. The chain pays its own miners from emission plus fees; nobody pays upkeep, not the founder, not a treasury, not a dev fund. Self-sustaining means the emission curve keeps mining worth doing on its own for as long as fees are small, which lane 4 measures as a decade or more, so emission never decays on a schedule that assumes fees take over. Fee revenue is never assumed as the security budget in any model or public sentence. His third line, "Wrong constants and claims in our own text", acknowledges lane 6's finding; the nine ledger rows in item 9 are the fix.
1. **The proving pool was the one line where a majority earned more than it spent**: 11,636 IGN an hour at 51 percent of blocks. Fix: the proof verified in consensus. **In 0.3.16** (fork exec-sync-0313 da2d17ec), switch `proving_consensus_verify_daa` off by default. **Decision owed:** when it activates.
@ -41,11 +41,11 @@ Closed 6 October 2026, 22:3x UK, every lane landed.
9. **Polish.** Pause wording in Ember and the API: **in 0.3.16** (ember-tune b726ce4). `fork_is_close` and the publisher's activation guard: **in 0.3.16** (1357d28, 08f5276). Export-disk cap: **done** (gpu-fleet f9ad70e). Nine text corrections (M32, M33, F26, E19, E20, G15, P24, E21, P25) plus X31 to X33: **done**. Unsigned installers: **decision owed**. Relay fixes (28c028b) on fud-close: merge owed.
**Decisions owed from the project lead:** the cryptanalysis spend (USD 80,000 to 160,000); the testnet date word; the N ladder at genesis; the verification switch activation.
**Decisions owed from the founder:** the cryptanalysis spend (USD 80,000 to 160,000); the testnet date word; the N ladder at genesis; the verification switch activation.
## Decisions ready for the project lead (added 6 October 2026, 23:1x UK)
## Decisions ready for the founder (added 6 October 2026, 23:1x UK)
Two one-page verdicts landed after the close. Each is quoted verbatim from its file and each is **the project lead's decision owed**. The files sit on their branches and are not merged; read them there.
Two one-page verdicts landed after the close. Each is quoted verbatim from its file and each is **the founder's decision owed**. The files sit on their branches and are not merged; read them there.
### Emission: the tail
@ -188,7 +188,7 @@ The residual risks stated plainly: the first 20 days (no weight table yet); a pa
### Lane 6, polish
1. The pause had no cause on any surface: no `finality_reason` or frozen-table share in the node's report, the observer, the API, Ember or the hub (Q1, Q2, Q6); the 0.3.16 Ember carrier is on ember-tune.
2. Every update was urgent once an activation height was behind the node (`fork_is_close`, Q4), and nothing refused an activation height at or below the live DAA; both fixed on ember-tune for 0.3.16.
3. Unsigned installers on both desktops (Q3, the project lead's certificates) and the relay's security fixes still on fud-close (Q8).
3. Unsigned installers on both desktops (Q3, the founder's certificates) and the relay's security fixes still on fud-close (Q8).
### Lane 8, new-proof-of-work
1. Scheme A (mining is proving) is a bound, not a design: 2.9 MB of openings per block or a 32 to 40 ms verify against the 10 ms gate; the useful fraction is 8 percent at 1 GH/s and 0.08 percent at 100 GH/s.

View file

@ -305,11 +305,11 @@ Recommendation with numbers: hold the schedule as decided (2 GiB genesis, doubli
| 5a | A class v5 candidate `mx8 + sh256x35` with a shuffle-heavy shadow weight table (shfl 14, shfla 8 of 75), measured on the four owned cards before any cut: the first rung of proposal 5's ladder, plus the k-floor lever | the Mac's 5 percent point is 130,000; the shuffle mix raises the k floor 0.32 to 0.46 (approx) | section 5.3 | 4 to build the weight-table knob and packs, 1 Mac measure session (about 6 min under the lock, miner paused), 3 PC jobs | M5 Max -4.8 percent of rate at 37 W; 5090 -0.3 percent at its 431 W cap (a rig +23 percent electricity); 4070 0 at about 118 W; 9070 XT 0; verifier +0.23 ms Mac, +0.85 half-core | every owned card within 5 percent; bit-exact on three vendors; half-core verifier under 10 ms; the 5090's marginal pJ on the new mix read on three rungs |
| 6 | Hold the dataset schedule (2 GiB, years 4, 12, 28); write the prover footprint into the card-lifetime sentence | Steam shares and the measured prover peaks; one HBM3 stack holds every step | section 5.6 | 1 | 8 GB: mines to year 12, proves alone; 12 GB: mine-and-prove compressed to year 4, core-only to year 12, mines to year 28; 16 GB: compressed to year 4, core-only to year 12; 24 and 32 GB unconstrained to year 28 | the litepaper sentence matches the table; `docs/evidence.md` row "card lifetime" labelled designed |
| 7 | Make the Ember tune the shipped default per card model (the honest card's watts are the lever that moves every chip row) | the 4070 at 3.65 uJ untuned and 2.57 tuned (-30 percent); the 5090 2.65 bench against 2.34 app | section 5.1 | 2 (defaults table in the app from the fleet priors; already measured) | every NVIDIA tier gains 10 to 30 percent per joule; the chip's edge over the mid-tier falls from 8x to 13x toward 5x to 9x at v3 | MH per W per card model on the fleet night against the untuned baseline |
| 8 | Fund the k question: the item 3 cryptanalysis brief gains a chip-design line (a 14,000-lane SIMD array's energy per op on a random 32-lane program with shuffles, at N5 and at 28 nm) | every chip row at class v4 turns on k; nothing in the project measures it | section 5.3 | 0 agent hours; the project lead's money (part of the USD 80,000 to 160,000 brief) | none until the number lands; it decides whether 2x is reachable | a reviewed estimate of k with its range |
| 8 | Fund the k question: the item 3 cryptanalysis brief gains a chip-design line (a 14,000-lane SIMD array's energy per op on a random 32-lane program with shuffles, at N5 and at 28 nm) | every chip row at class v4 turns on k; nothing in the project measures it | section 5.3 | 0 agent hours; the founder's money (part of the USD 80,000 to 160,000 brief) | none until the number lands; it decides whether 2x is reachable | a reviewed estimate of k with its range |
Paragraphs.
1. The verifier gate is the one place tonight produced a measurement instead of a rule. The box proxy brackets a 2019 laptop from both sides (a 2022 server core at full boost; the same core with its sibling busy), and dr736 fails both brackets while class v4 passes both with 1.8 ms to spare. The measurement is one Windows build and one bench on the laptop the project lead already owns; until it lands, the half-core row replaces the "2.5x" from memory in every status file.
1. The verifier gate is the one place tonight produced a measurement instead of a rule. The box proxy brackets a 2019 laptop from both sides (a 2022 server core at full boost; the same core with its sibling busy), and dr736 fails both brackets while class v4 passes both with 1.8 ms to spare. The measurement is one Windows build and one bench on the laptop the founder already owns; until it lands, the half-core row replaces the "2.5x" from memory in every status file.
2. The FPGA lane's upper row was built on an activate rate (8 per 12 ns per channel) that the JEDEC HBM2 cycle table does not support (4 per 28 ns); the measured Shuhai rate sits exactly on the JEDEC ceiling. That reading can be wrong (the ICCAD table's clock interpretation, the half-bank count, the board watts are all approximate), which is why the F2 hour is the proposal and not the conclusion. It is cheap and it turns a public ceiling claim into a measured one.

View file

@ -176,7 +176,7 @@ Ways not in the seven, with the bound this lane gives:
| The 30-day window edges | (a) the frozen table expires at exactly day 30.00 after the last lock: both sides of a long split lock alone at once (M3); (b) a departed set leaves the sliding table over 30 days and the frozen one at the cliff (M4); (c) the first 30 days have no lock at all (3.8, `min_daa` = window); (d) new honest cohorts are under-weighted t/60 for 30 days (G) | stated in 3.7 items 2, 7, 9 | a partition or departure longer than one window ends with the fork of 3.7 item 9 and a manual F5 | | |
| The pause as a liveness attack | a silent set at or above 1/3 pauses every lock for as long as it stays silent (J, L1; sweep S) at zero marginal cost since it keeps earning | none in the rule; the node reports the pause; exchange guidance treats the chain as PoW with a 12-h depth | the 1/3 veto: 20 days at 51% | 0 once held | nothing directly; enables the 12-h PoW double spend below |
| What an attacker can do during a pause | plain proof of work: reorg up to the finality depth 43,200 DAA (12 h) with a heavier chain; beyond merge depth the honest blocks are abandoned (the 229-block shape); every certified checkpoint before the pause still binds | finality depth; the exchange guidance of 3.9 | 12 h of >50% hash | USD 146 / 1.5k / 15k / 146k | a deposit credited at the PoW depth; 559k IGN of subsidy as a miner |
| Tonight's departure (confirmed, lane 3 `finality-and-weight.md` 3.1 and 4.1) | 20 keys holding 42.7% of the frozen table stopped mining 18:27 to 18:30Z (the rehearsal job); the last lock 6842 at 18:39:40Z; 6843 determined with 53.1% of total signing and never locked; under v2 the stayers' sliding share crossed two thirds at 6912 (19:14:53Z, a 35-min pause) but Q5 held them at 57.3% of the frozen table; expected first lock when that table expires at DAA 216,402, about 20:40Z, or when 9.4 points of departed keys return. Sweep C agrees: 51% leaving pauses 10.5 days (v2) or 30.0 days (v3) at mainnet scale; at tonight's 46.9% (observer's view) 7.7 days under v2, 30 under v3 (lane 3, 4.1) | by design (F21: the project lead chose the pause over the fork); a view cannot tell a departure from a partition | anything over 1/3 of the table leaving at once pauses finality for a window | 0 | 0; what an attacker can do during it is the row above |
| Tonight's departure (confirmed, lane 3 `finality-and-weight.md` 3.1 and 4.1) | 20 keys holding 42.7% of the frozen table stopped mining 18:27 to 18:30Z (the rehearsal job); the last lock 6842 at 18:39:40Z; 6843 determined with 53.1% of total signing and never locked; under v2 the stayers' sliding share crossed two thirds at 6912 (19:14:53Z, a 35-min pause) but Q5 held them at 57.3% of the frozen table; expected first lock when that table expires at DAA 216,402, about 20:40Z, or when 9.4 points of departed keys return. Sweep C agrees: 51% leaving pauses 10.5 days (v2) or 30.0 days (v3) at mainnet scale; at tonight's 46.9% (observer's view) 7.7 days under v2, 30 under v3 (lane 3, 4.1) | by design (F21: the founder chose the pause over the fork); a view cannot tell a departure from a partition | anything over 1/3 of the table leaving at once pauses finality for a window | 0 | 0; what an attacker can do during it is the row above |
### 4.4 Miner signalling (P2)

View file

@ -6,7 +6,7 @@ What was read: `docs/spec/03-finality.md` (whole, 3.11 included), `04-seeds-and-
## 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).
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 founder 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.

View file

@ -1,6 +1,6 @@
# Horizon lane 7: frontier. Predictions to 2030, what no proof-of-work chain has shipped, and what Igneum can
6 October 2026, evening UK. Lane 7 of the Horizon programme. Worktree `/Users/joshm/Projects/igneum-wt-horizon` (branch `horizon`, at origin/master 3f4f719). the project lead's words: "research anything else that we can research too, predictions, forward thinking, what we can actually do that has not been done or applied, think outside the box", and "be revolutionary". Main's bar: not features, but ideas that change what a proof-of-work chain is or what a GPU owner is to the world, each with its evidence, cost, gate, and the attack a Monero or Kaspa core developer would mount. I argue each attack as the project's own four personas would hear it (`.claude/agents/cryptographer.md`, `consensus-engineer.md`, `execution-engineer.md`, `miner-community-lead.md`).
6 October 2026, evening UK. Lane 7 of the Horizon programme. Worktree `/Users/joshm/Projects/igneum-wt-horizon` (branch `horizon`, at origin/master 3f4f719). The founder's words: "research anything else that we can research too, predictions, forward thinking, what we can actually do that has not been done or applied, think outside the box", and "be revolutionary". Main's bar: not features, but ideas that change what a proof-of-work chain is or what a GPU owner is to the world, each with its evidence, cost, gate, and the attack a Monero or Kaspa core developer would mount. I argue each attack as the project's own four personas would hear it (`.claude/agents/cryptographer.md`, `consensus-engineer.md`, `execution-engineer.md`, `miner-community-lead.md`).
Nothing in this file is a prediction of the coin's price, an offer to sell anything, or a change to any consensus parameter. Every chip figure is arithmetic on cited memory and logic figures; every GPU figure names its bench entry; a figure from memory says approximate. No em dashes.
@ -12,7 +12,7 @@ Nothing in this file is a prediction of the coin's price, an offer to sell anyth
## 0. Everything ranked by payoff over difficulty
Payoff 1 to 5 is what the idea does for the chain's security, the coin's utility or the GPU owner's position in the world, if it works. Difficulty is Claude-side hours to a measurable prototype (the project lead's rule: hours, never weeks). Verdicts: do now, prototype, watch, never. The "never" rows carry a sharp reason so the rest are not fantasy.
Payoff 1 to 5 is what the idea does for the chain's security, the coin's utility or the GPU owner's position in the world, if it works. Difficulty is Claude-side hours to a measurable prototype (the founder's rule: hours, never weeks). Verdicts: do now, prototype, watch, never. The "never" rows carry a sharp reason so the rest are not fantasy.
| Rank | Idea | Payoff | Hours | Verdict | One line why |
|---|---|---|---|---|---|
@ -322,7 +322,7 @@ The at-risk amount is five to nine orders of magnitude above the designed coin b
### 3.6 Treasury-less audit funding: bounties from the burn, a review escrow on upgrades
**The idea.** the project lead removed the dev fund (spec 5.5) and the project pays audits from the Ember dev fee and founders' mined coins (litepaper). The question: money for audits that comes from users paying for something, with no standing address. Two mechanisms. (a) **Burn redirect.** The base fee burns to nobody. A reproducible break submitted under spec 0.5 and accepted by 60 percent of blue blocks over a window redirects the base-fee burn of the next 7 (execution) or 30 (consensus) days to the submitter's address, once, then returns to burning. No address exists between events. (b) **Review escrow.** An upgrade proposal under 5.7 must escrow IGN in a contract that pays reviewers named in the proposal on a 60 percent "review complete" signal, or refunds on failure; the proposer pays, which is a user paying for a thing (the right to propose code).
**The idea.** the founder removed the dev fund (spec 5.5) and the project pays audits from the Ember dev fee and founders' mined coins (litepaper). The question: money for audits that comes from users paying for something, with no standing address. Two mechanisms. (a) **Burn redirect.** The base fee burns to nobody. A reproducible break submitted under spec 0.5 and accepted by 60 percent of blue blocks over a window redirects the base-fee burn of the next 7 (execution) or 30 (consensus) days to the submitter's address, once, then returns to burning. No address exists between events. (b) **Review escrow.** An upgrade proposal under 5.7 must escrow IGN in a contract that pays reviewers named in the proposal on a 60 percent "review complete" signal, or refunds on failure; the proposer pays, which is a user paying for a thing (the right to propose code).
**Why nobody shipped it.** Zcash funds development from the block subsidy (NU6: 8 percent to Zcash Community Grants, 12 percent to a protocol lockbox, ZIP 1015); Decred from a 10 percent treasury spent by stakeholder vote, capped at 4 percent of balance a month since January 2026 (DCP-0013); Monero from the CCS, donations off-chain; Optimism from an 850 M OP reserve for retro funding. Bug bounties pay 10 percent of funds at risk (Immunefi's standard) from the protocol's own treasury; Code4rena runs contests at zero platform fee since 2025. Nobody funds audits from a burn redirect, because a burn redirect is a subsidy to a payee by rule, and the chains that wanted that built a treasury. New in form; a dev fund in substance (see the Monero attack).
@ -341,7 +341,7 @@ At launch traffic a 30-day redirect is under one audit contest; at half-full blo
**Hours.** 20 for the escrow contract and the redirect rule as a proposal kind; 0 for the honest alternative, which already exists.
**The gate.** None that a simulator settles; the gate is the project lead's: does a per-event, miner-approved payee with no standing address pass the test that removed the dev fund?
**The gate.** None that a simulator settles; the gate is the founder's: does a per-event, miner-approved payee with no standing address pass the test that removed the dev fund?
**Per tier.** Miners vote on each payout with their blocks and can refuse all of them; holders see supply that would have burned paid to a named person; a prover, rig, pool user and rollup customer see nothing unless a break affects them; the node operator gains a proposal kind.
@ -415,7 +415,7 @@ At launch traffic a 30-day redirect is under one audit contest; at half-full blo
**The idea as asked.** Make the leader election depend in part on proving work, so the energy that picks the block maker is useful.
**Why every attempt failed, cited.** Primecoin (2013) found Cunningham and bi-twin prime chains that nobody uses (Bitcoin Magazine, July 2013). Gridcoin pays for BOINC work and stops if BOINC stops (gridcoin.us; the 2022 "Challenges of PoUW" survey, arXiv 2209.03865). Ball, Rosen, Sabin and Vasudevan (eprint 2017/203) gave proofs of useful work from fine-grained problems (Orthogonal Vectors, 3SUM, APSP) and state the conditions: the problem must be sampleable at a tunable hardness with instances the miner cannot choose, and the verifier must be cheaper than the work. Ofelimos (Fitzi, Kiayias, Panagiotakos, Russell, CRYPTO 2022, eprint 2021/1379) got a provably secure protocol by making the work a doubly efficient local search whose usefulness is a side effect and small. The 2026 "Economics of Proof-of-Useful-Work" (arXiv 2606.06700) and the empirical study of Pearl's cuPOW (arXiv 2606.04819, "The Usefulness Gap") find the same gap between the work paid for and the work anyone wanted. I found no Coinbase paper on the subject (searched 6 October 2026); if the project lead has one in mind, its title is needed. Aleo ran proving as the consensus work and the fastest prover won (CLAUDE.md: the Aleo lesson; litepaper precedents table, approximate). Boundless's PoVW (docs.boundless.network/zkc/mining/overview) pays ZKC pro rata to cycles proven per epoch with a stake that scales with the work, which is a reward for proving, not a leader election, and it is on a proof-of-stake chain.
**Why every attempt failed, cited.** Primecoin (2013) found Cunningham and bi-twin prime chains that nobody uses (Bitcoin Magazine, July 2013). Gridcoin pays for BOINC work and stops if BOINC stops (gridcoin.us; the 2022 "Challenges of PoUW" survey, arXiv 2209.03865). Ball, Rosen, Sabin and Vasudevan (eprint 2017/203) gave proofs of useful work from fine-grained problems (Orthogonal Vectors, 3SUM, APSP) and state the conditions: the problem must be sampleable at a tunable hardness with instances the miner cannot choose, and the verifier must be cheaper than the work. Ofelimos (Fitzi, Kiayias, Panagiotakos, Russell, CRYPTO 2022, eprint 2021/1379) got a provably secure protocol by making the work a doubly efficient local search whose usefulness is a side effect and small. The 2026 "Economics of Proof-of-Useful-Work" (arXiv 2606.06700) and the empirical study of Pearl's cuPOW (arXiv 2606.04819, "The Usefulness Gap") find the same gap between the work paid for and the work anyone wanted. I found no Coinbase paper on the subject (searched 6 October 2026); if the founder has one in mind, its title is needed. Aleo ran proving as the consensus work and the fastest prover won (CLAUDE.md: the Aleo lesson; litepaper precedents table, approximate). Boundless's PoVW (docs.boundless.network/zkc/mining/overview) pays ZKC pro rata to cycles proven per epoch with a stake that scales with the work, which is a reward for proving, not a leader election, and it is on a proof-of-stake chain.
**The sampleability problem, plainly.** A lottery needs a puzzle whose instances are drawn at random from a distribution the miner cannot steer, whose hardness is tunable by a target, and whose solution is verifiable in milliseconds. zkVM proving has none of these: the instances (segments, jobs) are chosen by users and producers, the hardness is whatever the program is, and the verifier is tens of milliseconds to seconds. Any blend ("a miner's lottery target eases in proportion to its proven cycles last hour") gives the fastest prover more blocks, which is Aleo with a cap, and a cap small enough to be safe is a reward too small to be useful.
@ -640,10 +640,10 @@ Smaller than the sections above; each with hours and a gate.
| I6 | A finality-pause page on the site that shows the connected weight fraction live, so the "node reports the pause" sentence has a public face | 4 | Shows tonight's 18:42Z pause from the observer's data | Tonight's incident |
| I7 | Equivocation-evidence bounty paid in sortition slots: the key that first carries valid evidence inherits the stripped key's shard assignments for 30 days (no coins move; weight is reassigned, not created) | 12 | Two signers under one key on the fast-time harness; the evidence carrier wins the stripped key's draws | Makes watching for equivocation pay without a treasury |
| I8 | Mandatory proofs activation height set from a measured coverage share (spec 7.8 item 10) | 4 | Coverage above 99 percent for 7 days on the devnet | The rule is written and off |
| I9 | The exclusive window at 25 s and the claim timeout at 120 s on the phase 4 devnet (decided by the project lead, P9) with the economy simulator re-run at the measured shard times from `prover-tiers-real-cards.md` instead of the 20-s target | 6 | The 3060 class's shard share within 5 points of its weight share | The inputs changed today |
| I9 | The exclusive window at 25 s and the claim timeout at 120 s on the phase 4 devnet (decided by the founder, P9) with the economy simulator re-run at the measured shard times from `prover-tiers-real-cards.md` instead of the 20-s target | 6 | The 3060 class's shard share within 5 points of its weight share | The inputs changed today |
| I10 | `eth_getProof`, `debug_traceTransaction`, `eth_subscribe` (D5 step 2) before any outside team | 24 | Foundry's debugger and the Blockscout fork run against a devnet node | The light client and every tool depend on `eth_getProof` |
| I11 | Register chain ids 4461 to 4463 on ethereum-lists/chains before the public testnet (spec 7.1) | 1 | The PR merged | Wallets |
| I12 | Publish the 2028 tier table (section 2.6) on the miner page with its three rates, so no card owner buys on a promise | 2 | Live | the project lead's consequences rule |
| I12 | Publish the 2028 tier table (section 2.6) on the miner page with its three rates, so no card owner buys on a promise | 2 | Live | the founder's consequences rule |
| I13 | A spec sentence in 03 and 05: "no coin stake; the only thing at stake is 30 days of public work" (4.1) | 1 | Text | Before 3.2 is prototyped |
| I14 | The litepaper's income table gains the proving-market arithmetic of 3.11 in one line | 1 | Text | Ledger P6 asked for honesty; the number makes it concrete |
| I15 | A ledger entry beside E4 recording 3.6 as considered and rejected on E4's ground | 1 | Text | So the question is not re-asked |

View file

@ -6,7 +6,7 @@ What was read: `docs/spec/02-consensus.md` (2.1 parameters, 2.3 the difficulty r
## 1. The question and the answer in one paragraph
the project lead asked for Kaspa's answer to solo-miner variance: a higher block rate. Run A ran Devnet 2 at 10 blocks per second through one seed and produced 77 percent red blocks, 321 tips and a 55-block reorg. The propagation model says the links and the star did not do that: with the measured latencies it predicts under 0.1 percent red at 10 bps in a star and in a mesh. What did it is the seed's CPU per block, measured at 61 ms (narrow DAG) to 345 ms (mergeset 150 to 200), against a budget of 100 ms per block at 10 bps; with that cost in the model the star gives 47 to 88 percent red and queueing waits of 26 to 1,769 s, which are the "Accepted 100 blocks via relay" batches in the log. The difficulty rule then read blue work over a chain step capped at 2 s and hardened until the DAG ran at 2 s / (chain-step spacing) of target (model 4 blocks/s at a 5-s spacing, record 3.3 to 3.6), while a narrower-but-still-wide DAG would have made it ease (the direction main reported); counting every mergeset block over the real span, as Kaspa's window does, is unbiased in both regimes. The block rate for the public testnet is 1 bps; 10 bps is a gated step that needs the per-block node cost under 50 ms on a laptop core at a mergeset of 248, the checkpoint interval and the clock cap re-denominated in DAA seconds, and vote aggregation, because with C1 in blue blocks 8,192 voters at 10 bps are 66 GB per node per day of votes.
The founder asked for Kaspa's answer to solo-miner variance: a higher block rate. Run A ran Devnet 2 at 10 blocks per second through one seed and produced 77 percent red blocks, 321 tips and a 55-block reorg. The propagation model says the links and the star did not do that: with the measured latencies it predicts under 0.1 percent red at 10 bps in a star and in a mesh. What did it is the seed's CPU per block, measured at 61 ms (narrow DAG) to 345 ms (mergeset 150 to 200), against a budget of 100 ms per block at 10 bps; with that cost in the model the star gives 47 to 88 percent red and queueing waits of 26 to 1,769 s, which are the "Accepted 100 blocks via relay" batches in the log. The difficulty rule then read blue work over a chain step capped at 2 s and hardened until the DAG ran at 2 s / (chain-step spacing) of target (model 4 blocks/s at a 5-s spacing, record 3.3 to 3.6), while a narrower-but-still-wide DAG would have made it ease (the direction main reported); counting every mergeset block over the real span, as Kaspa's window does, is unbiased in both regimes. The block rate for the public testnet is 1 bps; 10 bps is a gated step that needs the per-block node cost under 50 ms on a laptop core at a mergeset of 248, the checkpoint interval and the clock cap re-denominated in DAA seconds, and vote aggregation, because with C1 in blue blocks 8,192 voters at 10 bps are 66 GB per node per day of votes.
## 2. Method

Some files were not shown because too many files have changed in this diff Show more