Compare commits

...

142 commits

Author SHA1 Message Date
igneum-labs
25e2b0b539 Plan 0.3.17: the Linux-by-glibc rule (HiveOS 2.31 from the build-server lane, seeds 2.35; the HiveOS row waits)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:27:48 +00:00
igneum-labs
95aa5f5dac build-dmg: a DMG never ships without the prover pair (the warning branch built a 0.3.17 DMG 18 MB short)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:25:32 +00:00
igneum-labs
85d3ebc7fb Plan 0.3.17: the fork gate's green line (aa0182aa rides 0.3.18; the switch is at never)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:16:21 +00:00
igneum-labs
b5daa3a2b4 Igneum Miner 0.3.17: the class v4 vote's seven-window rule and the node's new switches (every one off on the devnet), igneum_getNodeInfo, the miner's stall guard, Ember's helper fix, the Overview's node source, an external node that goes away hands the ports back
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:46:27 +00:00
igneum-labs
8df47f6c21 Plan 0.3.17: exec-sync-0313 40fd8d8c in; the final node tree 02d15a87
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:45:55 +00:00
igneum-labs
f06bf2af49 Plan 0.3.17: the final node tree 75467244 (the rebased finality and emission commits, the igneum-pow default, the N5 rule, the fork gate's open line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:42:14 +00:00
igneum-labs
d26b28c430 Plan: Igneum Miner 0.3.17 (the node and app trees as cut, what waited and why)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:38:48 +00:00
igneum-labs
6c20670f22 Merge miner-ui-4 59bbf466 for 0.3.17: an external node that goes away hands the ports to the app after 60 s; node.mode and mode_reason in api/state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:35:41 +00:00
igneum-labs
59bbf46689 miner-ui-4: an external node that goes away hands the ports to the app after 60 s
The mode (own | external | none) is re-decided every 5 s while the app reads or refuses another node; gone for 60 s, the app starts its own node and says so in Activity. node.mode and node.mode_reason in api/state and on the Node details. Pure extnode::step with the goes-away test first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:31:42 +00:00
igneum-labs
1c99053aad Merge miner-ui-4 4f6cfbfb for 0.3.17: ui/ whole (Ember's finality words folded), extnode.rs on igneum_getNodeInfo, N4 stall exits, the port-collision check, POST /api/shot; src/live.rs is ember-tune's merged module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:28:51 +00:00
igneum-labs
4f6cfbfba7 miner-ui-4: fold Ember's finality-paused words, read igneum_getNodeInfo (params, network, powEngine stub fault)
View.finalityWords with Ember's test (41 UI tests). extnode reads the node lane's reply shape: params compared value by value, network must match, a stub engine is refused and a fault on the app's own node. 168 box tests green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:28:24 +00:00
igneum-labs
49daffbf9d Merge commit '4c3d68b6' into release-0.3.17
# Conflicts:
#	docs/bench-log.md
2026-10-06 23:20:19 +00:00
igneum-labs
4c3d68b63e Ember shipped-app window playbook: the gate at 0.3.17 (0.3.16 carries cause F and would repeat the 22:16Z result)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 44e2a355c4)
2026-10-06 23:19:24 +00:00
igneum-labs
fa99216dd3 Ember: the 0.3.16 window in the plan and the bench log (A to E held, the 9070 XT measured, cause F and its fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit d5f236afc0)
2026-10-06 23:19:24 +00:00
igneum-labs
16ec189a0b Ember helper (F), from the 0.3.16 window on PC 1: the tune path wrote its request index as the wire sequence ("00 dev 1", "01 pl 160") below the cap path's unix-based numbers, so the helper skipped every tune command as stale and both climbs stopped on "the helper did not run sequence 1 within 15 s"; every writer now draws from one monotonic space (powertask::wire_seq), the acknowledgement matches the wire number; test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9007792742)
2026-10-06 23:19:08 +00:00
igneum-labs
c88504b47b Ember plan: 0.3.16 is the corrective cut of 0.3.15; the Miner UI 4 and Horizon rows ship as 0.3.17
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 454ec0a762)
2026-10-06 23:19:08 +00:00
igneum-labs
d5d8e76a7a Finality pause and /api/live pinned to the node's landed shape (ca3-v4-0316 b2e21447, eec34ac3): finalityActive decides paused when present, the node's "paused: " prefix dropped from the sentence, heldBy not repeated when the reason names the table, timestamp_source header | chain_block, a null vote_key_hash reads as empty; tests on the node's exact words
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b43d329d10)
2026-10-06 23:19:08 +00:00
igneum-labs
740c35783e Finality pause (0.3.16): the node lane's structured carrier on igneum_getProvingStatus (finalityReason, finalityProvisional, heldBy {tableIndex, stayersShareBps, expiresDaa}, pausedSinceMs) read on the 30-s RPC cadence; the node's pausedSince wins over the engine's own; state.finality.provisional and cause_source; the log line and "finality resumed" kept; tests with the node lane's field names
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a7b5ecccf0)
2026-10-06 23:19:08 +00:00
igneum-labs
c1e3204481 publish-manifest.sh: the activation height check as a function with --self-test-height (the known-bad case 33000 against 201776, at the DAA, pending, unknown), the shape the shipper's 0.3.15 guard had; the public /api/live stays the source
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e54a87bc44)
2026-10-06 23:19:08 +00:00
igneum-labs
39b8df93cd Horizon polish for 0.3.16: (Q4) fork_is_close treats a passed activation as not close (every update since the 0.3.14 manifest read it as 0 blocks away and skipped every guard), publish-manifest.sh refuses an activation height at or below the live DAA unless --allow-passed-activation; (Q83, Q84, Q2) the finality pause shown: state.finality.paused, paused_since, reason, held_by, line ("Finality paused since 18:39 UTC: under two thirds of the weight is signing", the node's cause when it carries finality_reason/held_by), on the node line, the Overview's state and the Finality card, no lock called final while paused; tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e5e1e92e37)
2026-10-06 23:18:34 +00:00
igneum-labs
447cd019b9 Updater (0.3.16, Horizon frontier lane): nothing installs while the network's finality is paused (a synced node with no checkpoint lock for 15 min); the update card reads "waiting for finality"; only the signed manifest's own urgent flag installs through a pause, a fork-close or unsupported urgency does not; slot, catch-up and patience rules unchanged; the known-failed case tested
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 700312688a)
2026-10-06 23:18:16 +00:00
igneum-labs
4e4b1fab15 Ember for Miner UI 4 (0.3.16): state.mining.watts_total and pounds_per_day (the fleet's fresh draw, the price from settings), state.address.balance_wei with balance_age_s and balance_note (eth_getBalance through the node's RPC every 30 s), state.address.price_gbp_per_ign null with its one documented source (a signed manifest field); GET /api/live from a local source (src/live.rs: igneum_getRecentBlocks when the node carries it, else the public site's reply cached 60 s, source and age_s on every reply); tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0276163301)
2026-10-06 23:16:29 +00:00
igneum-labs
98c1f05eff Publish 2 read-backs (0.3.17): a node on the app's ports is checked and said out loud (src/extnode.rs: chain id and consensus overrides over RPC; match = used and named, mismatch = 'Node not started: port N is taken', unknown = used and marked); api/state names whose node it reads (node.source, rules_check, port_note); the digest comes from igneum_getNodeInfo every 30 s (digest_source rpc | log) with the stdout line as the fallback; node logs pruned to the newest 10 per start; the Node details show whose node. Tests on decide and prune.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
172696bb26 shot_path and its test move from src/live.rs into src/server.rs beside the /api/shot route, so src/live.rs is ember-tune's file whole at the 0.3.17 merge
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
4793cca547 Miner UI 4 (b): a screenshot from the machine itself: POST /api/shot asks the window host over the SHOT line; the Mac host snapshots the web view, the Windows host captures through WebView2, both to <data root>/shots/; app-shot.ps1 plus a collect glob bring it back. 152 app tests on the box.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
24adda1025 ui-mock replays a captured machine's state and chain feed (IGNEUM_MOCK_STATE, IGNEUM_MOCK_LIVE); the fleet watts count mining and tuning cards only; PC 2's 0.3.16 state rendered in light and dark
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
6dd3c3686b relay: app-state-grab writes into the app's data root for a collect job (send.ps1 is not on every PC)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
5e3e6a1664 relay: app-state-grab playbook (two GETs of the installed app's state and chain feed, sent back through the relay; read-only)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
214333d5e6 N4: the test module imports the new helpers; app-shot-light take 3 (old headless mode, cwd fallback, Edge version and stderr in the report)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
116e767c0d N4, the app half (0.3.17): a frozen tip is never 'synced'; the miner's stall exit restarts the miner once and the node on the second; the Overview shows the tip age
engine::sync_decision_v2 gates the old decision on the watch line's tip_age_s (<= 120 s; -1 on an older node gates
nothing) and peers >= 1, with the cause word frozen | no peers | syncing | behind on node.sync_cause; the node state
reads 'behind' or 'no peers', never 'synced' on a tip that stopped moving. engine::is_stall_exit: code 45 or a
"STALLED '" tail line (the node lane, ca3-v4-0316); the first restarts the miner, the second since the node started
restarts the node and re-dials its peers (restart_node). The known-failed case is the first test: the old rule says
synced on a tip frozen 3,180 s with one peer. UI: 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'
with the cause line, 'Node no peers', the pill 'Node behind' in ember, the big button 'waiting: the node is behind
the chain'. 39 UI tests pass; the app crate's tests run on the box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
d695948d2e relay: app-shot-light uses its own Edge profile and waits for the headless browser (the first run handed off to the user's Edge and wrote nothing)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
a9c3167802 relay: app-shot-light playbook (two light-mode screenshots of the installed app, GETs and headless Edge only, for the site's showcase card)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
d969588aac miner-ui-4: the Ember line with Ember Tune off no longer promises a start ('Tune starts one by hand'); the 0.3.16 live-app screenshots (read-only, miner paused)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
011443a76f miner-ui-4: the numbering (0.3.16 = the corrective cut of 0.3.15; the engine-field pinning ships as 0.3.17)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
a621a04b29 miner-ui-4: 'you' matches the 0.3.16 engine's head6..tail6 lane key and the site's 8 hex against the cards' vote-key ids (6 hex of common head); the test file's duplicate const fixed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
93c530d0a1 miner-ui-4: 'you' on the chain scene matches a lane key of any length against the cards' 8-hex vote-key ids (the 0.3.16 engine's live.rs keeps 12 hex, the site 8)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
d0c98db2fa miner-ui-4: pinned to the 0.3.16 engine fields (ember-tune 7003126): watts_total, pounds_per_day, balance_wei with its age and note, price_gbp_per_ign, tune_floor, the chain scene's source word
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:14:29 +00:00
igneum-labs
0750b2bb35 Merge commit '85c712f' into release-0.3.17 2026-10-06 23:13:57 +00:00
igneum-labs
568395b78b Ember plan: 0.3.15 took ember-tune at 441d1ea (merge 02627f3); card.tune_floor (1f0f784) rides the next cut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 6e8a64d1a3)
2026-10-06 23:13:48 +00:00
igneum-labs
741424dfce Ember for Miner UI 4 (0.3.16): card.tune_floor (the chosen clock is the ladder's floor), persisted as CardPref.sweep_floor
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1f0f7849db)
2026-10-06 23:13:47 +00:00
igneum-labs
9d162e4302 override-60x.json: the duplicated exec_restart_state_root from the merges removed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:13:42 +00:00
igneum-labs
ced26f4e4e Merge commit 'e39d966' into release-0.3.17 2026-10-06 23:12:13 +00:00
igneum-labs
690e7dc12a Merge commit '1065b81' into release-0.3.17
# Conflicts:
#	.github/workflows/ci.yml
2026-10-06 23:10:56 +00:00
igneum-labs
b8010abf82 Merge commit '1e9ddb4' into release-0.3.17
# Conflicts:
#	docs/bench-log.md
2026-10-06 23:10:27 +00:00
igneum-labs
6084a7ea64 Plan: owed rows from publish 2's read-back (the Mac app's port collision, the node's digest line in the app log, the box's 26611, the safe-moment hold)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:06:06 +00:00
igneum-labs
708b1e0fc8 Plan: publish 2 as run (the sixteen-field object, digest eada4bda, every node moved, the vote open)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:04:35 +00:00
igneum-labs
e39d966840 latency ladder: the step2 harness case passes on all 18 checks; the four runs' summaries and logs under docs/design/latency-ladder-harness
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:58:39 +00:00
igneum-labs
770eca9daf latency ladder design doc: the no-step case passes (5,833 bps weakest of seven holds rung 0 over 10 epochs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:47:54 +00:00
igneum-labs
057b489107 latency ladder design doc: the test lines and the first two harness results (the known-failed case fails, the step case stepped at epoch 8; the two harness faults named)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:38:20 +00:00
igneum-labs
0bc7681875 igneum-pow show: --shadow-reps reaches the show path too (the step case's id check, 22:37Z: the CLI printed the rung-0 id for --shadow-reps 35 because show built its program without the rung; the three miners' rung-1 ids agreed with each other and differed from rung 0, as the chain requires)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:37:56 +00:00
igneum-labs
85c712f01a bench log: Devnet 2 zero program-id class closed (paidSegments 4 at 22:28Z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:31:16 +00:00
igneum-labs
d33fa30f89 Plan 0.3.15: the live retry and the forced poisoned-peer confirmation in the gate table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:30:19 +00:00
igneum-labs
1e8bcf7cb1 Plan: the 0.3.17 merge rule names ember-tune d5f236a (the helper's wire-sequence fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:30:05 +00:00
igneum-labs
b3cd2d21f4 Plan: the 0.3.17 merge rule names miner-ui-4 57bb4f7 (N4's app half with the node lane's miner half)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:29:37 +00:00
igneum-labs
c4d29c58fd Plan: wallet 0.1.5 published, publish 2's floor a week past publish
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:28:05 +00:00
igneum-labs
6660722bd7 latency-ladder harness: genesis (header version 0) is out of the low-byte check; the first known-failed run tripped it (a harness fault, recorded in the file)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:27:50 +00:00
igneum-labs
edf5e8831e fast-time 60x file: exec_restart_state_root, the last field the 0.3.16 node tree's every-field test asks for (never-set defaults, nothing else changes)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:20:34 +00:00
igneum-labs
b0af074fb4 Latency ladder (Horizon finding 4, 0.3.17): docs/design/latency-ladder.md (where N lives, the ladder as a genesis list, the 90 percent seven-window step rule on the class signal's machinery, the verifier bound and its measured table, the X9 chip anchor, the hostile review, consequences per tier); igneum-pow: v4_class_at, v4_rung_reps, v4_counted_ops, generate_from_seed_bytes_program_class_shadow and Epoch::chain_program_shadow (rung 0 is V4_CLASS byte for byte; a rung above carries its shadow size in the id through program_id_class), era draws 8 and 9 consumed and not used, --shadow-reps on the CLI, the known-failed test first (a changed N hashed another program under one id); tools/ladder/verify-bench-remote.sh (the bench per rung on core 40 alone and with core 88 loaded under the box's measure hold; the first run's sibling finished early and is recorded as the script's failed case) with both runs' raw lines under docs/design/latency-ladder-bench; infra/fast-time/latency-ladder.mjs (step, no-step and the known-failed case) and the 60x file's three ladder fields plus the two 0.3.16-lane fields it lacked; ledger M34; the litepaper's hash-class paragraph and the corrected RandomX precedent (Bitmain's Antminer X9, approximate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:18:48 +00:00
igneum-labs
9c9a09ff09 publish-jobs guard ignores the index's updated stamp; plan: the hive rename, the card, the finality notes' number
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:17:40 +00:00
igneum-labs
ea76eac0f8 Plan: 0.3.16 the corrective cut, every machine on f1ea7a38, the 0.3.17 tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:15:21 +00:00
igneum-labs
27677b596c ledger and bench log: the export-disk row is X34 (X31 to X33 are the site lane's) and the consensus proof verification ships as 0.3.17 (0.3.16 is tonight's corrective cut)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:00:16 +00:00
igneum-labs
30511ab700 Plan: the planned 0.3.16 tree is 0.3.17; 0.3.16 is the corrective cut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:58:58 +00:00
igneum-labs
60e78a56ba Staging rule: publish-jobs.sh --deploy ships a jobs-only change (refuses when anything else differs from the live folder); restart-seed.sh refuses a binary whose GLIBC need exceeds the seed's; the CLAUDE.md rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:58:58 +00:00
igneum-labs
10112d1df7 Igneum Miner 0.3.16: the same release as 0.3.15 under a new number, so every machine takes the final node build (f1ea7a38)
The Mac and PC 1 took earlier 0.3.15 builds (nodes 713ef876 and 7961c5f1) through deploys of the shared downloads folder before the publish; the updater compares version strings only, so the number moves them to the final build. Node pin unchanged at f1ea7a38.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:55:32 +00:00
igneum-labs
9601749fb2 Merge remote-tracking branch 'origin/master' into release-0.3.15
# Conflicts:
#	.github/workflows/ci.yml
#	tools/ci/install-hooks.sh
#	tools/ci/no-foreign-tree-writes.sh
#	tools/ci/windows-paths-check.sh
2026-10-06 21:55:30 +00:00
igneum-labs
3559537130 Plan 0.3.15: the leak (earlier 0.3.15 builds reached the Mac and PC 1 through other lanes' deploys of the shared folder), the rule rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:54:08 +00:00
igneum-labs
e976b344f3 Plan 0.3.15: publish 1 as run (deploy, update-nows, the hands, the seed with the glibc lesson)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:47:27 +00:00
igneum-labs
ea7c30d799 Plan 0.3.15: the 0.3.16 node tree's commits and the testing-integration breakage row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:26:57 +00:00
igneum-labs
c07d259323 Plan 0.3.15: the 0.3.16 merge rule names ember-tune b43d329 and the node lane's 0316 commits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:24:34 +00:00
igneum-labs
de058a4af5 Plan 0.3.15: the final Windows installer and payload on f1ea7a38, the final manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:08:20 +00:00
igneum-labs
0969bedb2e Plan 0.3.15: the X31 ledger-row collision (renumber X34 at the 0.3.16 merge)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:06:48 +00:00
igneum-labs
e49c7a058a Plan 0.3.15: the proving agent's 0.3.16 node and app tips in the owed rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:06:12 +00:00
igneum-labs
1e9ddb457e 0.3.16 app side: the forged-record harness; ledger P21 round 4 and X31 (the export-disk class); bench-log verify costs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:05:19 +00:00
igneum-labs
3ef7f901c6 Plan 0.3.15: the f1ea7a38 Windows exe, the inputs, the cancelled runs and the cross tool's swallowed defaults line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:01:12 +00:00
igneum-labs
0be32c92ce prover: the proving directory owns its disk (size and age caps, delete on submit, a free-disk floor before each export)
The fleet's segment exports (50 to 500 MB each with the old [0, last] export, never pruned) filled 42 GB on the hub
(586 segment dirs, 60 GB disk, 100 percent) and 3 to 46 GB on every standing box between about 13:00Z and 20:44Z on
6 October 2026, 4 to 6 GB an hour a box; the hub's node died on the full disk at 20:42:31Z (its two earlier deaths
at 19:58:08Z and 20:01:55Z were the sync KeyNotFound). The app's prover now:
- enforces a cap on <app dir>/proving before every export: entries older than IGNEUM_PROVING_DIR_MAX_DAYS (7) go,
  then the oldest until the total is under IGNEUM_PROVING_DIR_MAX_GB (20); the plan is a pure function with a
  unit test (provingdir::prune_plan); the defaults leave 80 GB of a 100 GB box to the node and the system
- skips the export with a logged line when the disk is under 10 percent free (statvfs on unix, GetDiskFreeSpaceExW
  on Windows), and the Prove page says so
- deletes a segment's directory (fixtures, proofs, logs) the moment its record is submitted, on the first try or on
  a retry; seq.json was already removed after the cut
The fleet's kit (box-prover.py, gpu-fleet 86c9854) carries the same rule on the boxes since 20:5xZ.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 21:00:34 +00:00
igneum-labs
fddb02d016 Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 20:57:42 +00:00
igneum-labs
359dd11d00 cross-remote: an empty CARGO_ARGS array under bash 3.2's set -u (unbound variable at the default-command test); the default command runs again
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:50:17 +00:00
igneum-labs
ebe27aae69 0.3.15: the node pin at f1ea7a38 (the receive-side header-version rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:48:43 +00:00
igneum-labs
3be454f128 Plan 0.3.15: the final node f1ea7a38 rows and the poisoned-peer gate PASS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:47:34 +00:00
igneum-labs
93b05c9b7b Plan 0.3.15: the LEAVE item's row updated (mirror branch, digest, gate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:46:24 +00:00
igneum-labs
54cc0bd6aa Plan 0.3.15: the second canary FAIL (the receive side accepts and relays version 1026), the fix, the retry form
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:39:46 +00:00
igneum-labs
12f4fdbde3 Plan 0.3.15: queue rows (wallet 0.1.5 straight after live, the miner's resubscribe, the LEAVE item's gate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:35:07 +00:00
igneum-labs
f35816ddfb Plan 0.3.15: the 0.3.16 merge rule names ember-tune a7b5ecc (its publish-manifest guard wins)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:31:43 +00:00
igneum-labs
ff320d9fe3 publish-manifest: an activation height at or below the live DAA is refused (every app would read it as urgent); --self-test-height
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:27:23 +00:00
igneum-labs
5baaa32273 Plan 0.3.15: owed rows (1065b81 with the merge, the manifest's activation-height guard)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:26:07 +00:00
igneum-labs
3fd96e4882 Plan 0.3.15: the Windows installer and payload rows, the two-row manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:25:21 +00:00
igneum-labs
547b3323d2 Plan 0.3.15: the final node 7961c5f1 build rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:19:02 +00:00
igneum-labs
3dd010bca8 Tracked paths valid on Windows: the colon in a site-ui-3 screenshot name renamed; tools/ci/windows-paths-check.sh in CI
actions/checkout on windows-latest failed with "invalid path 'docs/plans/site-ui-3-shots/after/address_igneumdev:qz9h....jpg'" (git exit 128), so every Windows build of the tree died at the checkout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:16:39 +00:00
igneum-labs
f242646a01 0.3.15: the node pin at 7961c5f1 (the version gate and the pruned-node sync fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:14:55 +00:00
igneum-labs
91c3ed747d Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 20:14:55 +00:00
igneum-labs
62736cecb5 Plan 0.3.15: the 17c60367 rebuild rows (superseded by the combined commit)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:06:10 +00:00
igneum-labs
19b802a17b The pre-push hook builds the site in a temporary copy and writes nothing in the worktree; the foreign-tree check fails a hook that builds in place
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:02:02 +00:00
igneum-labs
418801fac2 Plan 0.3.15: owed rows for 0.3.16 (the lanes' merge rule, the manifest's urgent flag, the node cut's gate line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:54:13 +00:00
igneum-labs
0aef24f747 Plan 0.3.15: the canary FAIL (block version 1026 on the thirteen-field file), the roll-back, the gate line that was missing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:52:57 +00:00
igneum-labs
7ee76db53c Plan 0.3.15: publish 1 as staged (the folder's shape, the sequence, the trigger)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:51:25 +00:00
igneum-labs
4bf858f3db Plan 0.3.15: the final DMG row (872f1c9 build, 90bdf8c8)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:47:51 +00:00
igneum-labs
8321127385 miner-ui-4 872f1c9: ui/live-dag.js as site-ui-3 aa71405 (a served comment without a name)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:46:56 +00:00
igneum-labs
f0660d59c3 Plan 0.3.15: the DMG row (7996240 build)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:46:55 +00:00
igneum-labs
6f6d058c9f publish-public: a platform the manifest lacks keeps the newest versioned file already in dl/public, stamped with its own version (a staggered publish never darkens a link or names an absent version)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:46:21 +00:00
igneum-labs
9201a07faf Merge miner-ui-4 7996240 for 0.3.15: the chain scene takes the wheel only once engaged (ctrl/cmd+wheel, pinch or click; Esc releases) with a chip saying so
Every file under app/igneum-app/ui/ is that branch's side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:44:22 +00:00
igneum-labs
7996240f6f miner-ui-4: live-dag.js from site-ui-3 6bc408b (wheel passes to the page until the scene is engaged); the engaged chip 'scroll to zoom · Esc to release' on the Overview
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:43:17 +00:00
igneum-labs
2c2859253d ship-app: the commit step pushes HEAD to the branch the run was given and only there; the CI dispatch and the behind-check follow that branch
6 October 2026: from a release worktree, `git push origin master` pushed the shared checkout's unchanged local master ref and the tool reported "pushed to origin/master" from its own arithmetic; the Windows build was then looked for on master. Master merges are main's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:41:48 +00:00
igneum-labs
e8244a7853 Plan 0.3.15: the three smoke lines and P1 PASS recorded; the evening's two blocks on the ship (Actions billing, the tool's master push)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:40:21 +00:00
igneum-labs
17e1eb4d12 ship-app: the fork commit comparison is a prefix match (short in the state, full in the inputs manifest); the manifest read bypasses the CDN cache
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:29:28 +00:00
igneum-labs
f7d5db67ad Merge remote-tracking branch 'origin/master' into release-0.3.15
# Conflicts:
#	docs/bench-log.md
2026-10-06 19:27:01 +00:00
igneum-labs
98b686059a Igneum Miner 0.3.15: class v4 signalling node (publish 2 opens the signal window), generator-4 GPU workers on every platform, miner-ui-4 (Overview and Cards), Ember tunes through the Power Helper, the Linux prover pair in the Windows payload, an update never installs under a running job
The six version files at 0.3.15 and the node pin at 713ef876 (release-0.3.15-node).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:26:14 +00:00
igneum-labs
54bcdbe510 Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 19:20:52 +00:00
igneum-labs
64e70bb27d Plan: Igneum Miner 0.3.15 (why, the two publishes and the worker rule, digests, builds, gate, incidents, owed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:20:32 +00:00
igneum-labs
8aa8f0a048 ship-app: --until <step> stages a cut (built, signed, verified locally; the deploy waits for the gate and the go)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:18:32 +00:00
igneum-labs
05a2d0599a miner-ui-4 db6ef9b: ui/live-dag.js byte for byte the site's committed copy (3e5a880)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:17:13 +00:00
igneum-labs
b371bf2bf4 miner-ui-4: live-dag.js re-copied byte for byte from site-ui-3 3e5a880 (sha256 7c5273b0...)
The site agent committed the module at 3e5a880; the app's copy now matches it exactly (the earlier copy was taken
from the working tree before the commit). Same contract; quieter colours (chain in ember, included in bone, pending
in ash), opts.mine, opts.poll:false with dag.push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:16:14 +00:00
igneum-labs
0ac8d097b2 Merge remote-tracking branch 'origin/master' into release-0.3.15
# Conflicts:
#	.gitignore
2026-10-06 19:15:08 +00:00
igneum-labs
5b4cb56a27 App node peers (devnet default): the build box's hand nodes 188.40.146.49:26611 after the public seed (the hands move off the Mac, 6 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:02:38 +00:00
igneum-labs
fa20b72fea Merge miner-ui-4 4af9ab4 for 0.3.15: the chain's own words back on user surfaces (shard, segment, checkpoint, aggregator, verifying), each with one grey explanation
Every file under app/igneum-app/ui/ is that branch's side; the Rust files are as in 7ce5965.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:02:17 +00:00
igneum-labs
4af9ab4d0a miner-ui-4: the chain's own words come back (shard, segment, checkpoint, aggregator, verifying), each explained once in grey; the rest of the sweep stands
Main's correction: the app uses the same words as the site, the litepaper and the ledger for the chain's own objects,
or users cannot match the app to the docs. Shard (not piece), segment (not run of blocks, 'runs of 8 blocks' as the
explanation), checkpoint and 'locked checkpoint' (not lock point), aggregator (not combiner), verifying (not checking
proofs). Card, app, Default · Balanced, the Ember Tune strip, the state words and the one-sentence rule stay. 37 UI
tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:59:59 +00:00
igneum-labs
409cb0f76c Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 18:59:50 +00:00
igneum-labs
7a69c03135 Merge miner-ui-4 7ce5965 for 0.3.15: GET /api/live (the observer's chain feed with this machine's lane marked), the copy sweep, the goal rulings
Every file under app/igneum-app/ui/ is that branch's side; outside ui/: src/live.rs (new), the /api/live route, mod live, ota.rs's live_api_from made pub, the copy table and the reshot screenshots.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:59:06 +00:00
igneum-labs
7ce5965e46 miner-ui-4: GET /api/live on the engine (observer-backed, this machine as 'you', cached 2 s, 4 tests); the copy sweep (150 strings); the project lead's goal rulings (Default · <shared goal>, Ember Tune strip, meanings on hover)
src/live.rs reads the observer's /api/live through curl (the URL ota.rs already polls), caches it 2 s per window,
marks this machine's blocks as the lane 'you' by matching the miner id against every card's vote-key ids, and
answers {ok:false} when the observer is unreachable so the UI draws its own strip; four unit tests shape a fixture
in the observer's reply shape. The local node speaks gRPC and wRPC only, so a local-node source stays owed.
The copy sweep: no fleet, lane, identities, prior, hill climb, sweep, DAA, digest, manifest, shard, segment,
aggregator, verifier, worker or engine on a user surface; every toast and event line one sentence; the full list
in docs/plans/miner-ui-4-copy.md. The per-card goal's inherit option reads 'Default · Balanced' and follows the
shared goal; the cap's unpin is 'Back to Default'; the Cards strip is titled Ember Tune; each goal carries its
meaning as a tooltip. 37 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:57:56 +00:00
igneum-labs
18b154de9c Windows payload: the Linux prover pair is required and travels in the inputs (flat names), placed at wsl2\bin with SHA256SUMS
The CI payload had never carried igneum-prove-host or igneum-prove-export for WSL2 (make-payload.sh's warning branch), so every PC ran a stale pair built by setup-wsl.sh from an old package; after 0.3.14 no PC verified peer proofs or exported segments. push-inputs.sh refuses to publish without the pair (IGNEUM_PROVE_LINUX names the folder) and make-payload.sh refuses a payload without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:54:44 +00:00
igneum-labs
7943f83c17 Devnet hands under launchd: hands-launchd.sh (plists, kickstart, status), restart-hand-nodes.sh hands the start to it; CLAUDE.md rule
6 October 2026, 18:2x UK: the desktop app crashed and both hand nodes, nohup children of agent shells, died with it for about 70 minutes. A LaunchAgent per hand (KeepAlive, RunAtLoad, ThrottleInterval 10, logs under ~/Library/Logs/Igneum) keeps them up; every restart goes through launchctl and prints the pids with parent 1, versions, digests and exec tips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:52:33 +00:00
igneum-labs
4f6b7810d5 Merge miner-ui-4 feb7f13 for 0.3.15: Overview (hero, chain scene, node line, activity), Cards with Ember woven in, Earnings, Prove, Settings
Every file under app/igneum-app/ui/ is that branch's side, per the merge rule; outside ui/: the GET /live-dag.js route in server.rs, the ui-mock's synthetic /api/live, the plan and its screenshots. .gitignore: the shipper's local test target dir and the build box's artefact dir.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:50:11 +00:00
igneum-labs
fc94277775 miner-ui-4: Overview (the C hero, the chain live, the node line) and Cards (every row with Ember as its second layer)
Rail: Overview, Cards, Earnings, Prove, Settings. Overview: the fleet rate at 84 px on the fade with W, £ a day and
blocks, Start/Stop as the bar under it, the site's live-dag.js on the engine's api/live with this machine's lane as
'you' (the app's blocks strip as the fallback until the engine serves api/live), the node line, the activity feed.
Cards: the Tuning strip (goal with its £ a day, the fleet saving in W and £, Tune all, the schedule, Power control
when off), the rows with a flame mark that fills with the tune's progress, before -> after watts, a sparkline of the
ladder, 'Tuned 2 h ago · 2470 MHz at 80% · next check Sunday · saves 84 W, 0.16% of rate', Retune, and under the
chevron the cap, the card's own goal, the curve with the chosen point ringed. Reads Ember's tune_before_watts,
tune_before_mhs, tune_goal, sweep_watts, sweep_mhs, tune_curve. Rust: one route, GET /live-dag.js. The ui-mock gains
/api/live. 37 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:48:26 +00:00
igneum-labs
02627f3d9f Merge commit '441d1ea' into release-0.3.15 2026-10-06 18:45:15 +00:00
igneum-labs
441d1eaf88 Ember for Miner UI 4 (0.3.16): a per-card goal (POST /api/tune/goal {key, goal}; CardPref.tune_goal; card.tune_goal, empty = global; ember::goal_for at the tune's start) and the untuned point of the last full plan on the card (tune_before_watts, tune_before_mhs, kept across confirm plans) so the row can read the saving; tests; next-cut note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:42:58 +00:00
igneum-labs
53d99b17f9 Merge ember-tune 0b48c02 for 0.3.15: the Power Helper path tunes (helper acts on cmd lines after its start, heartbeat before any command, log-line acknowledgement, no set to a measure-only card, Tune now clears the back-off)
docs/bench-log.md is the union of both sides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:40:10 +00:00
igneum-labs
3cc1b9ef11 Updater: an active remote job holds the install, urgent or not; an asked install is spent by its own check (PC 1, 6 October 2026, 17:52:54Z)
The 0.3.13 engine on PC 1 ran update-now-0313-switch at 15:43Z, found nothing newer, and install_asked stayed true; when 0.3.14's manifest arrived at 17:47Z the moment was urgent and safe_to_apply returned Ok before the slot, the patience and the busy rule, so the install went under a measurement job. Now Moment carries job_active (jobs.active(), separate from miner_busy) and safe_to_apply holds on it first; the Checked branch that finds this version current clears install_asked. Tests: urgent under an active job is held, the same with the slot closed; urgent with no job still goes. CLAUDE.md job rule row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:39:35 +00:00
igneum-labs
0b48c028f3 Ember helper for 0.3.15, the 0.3.14 window's five causes: (B, C) the helper writes a heartbeat every poll, the engine starts the task and waits for a fresh heartbeat before writing any command, the acknowledgement is the helper's own log line; (D) a measure-only card is never set (ember::may_set); (E) an explicit Tune now clears the failure back-off and a held row says when the retry comes (ember::retry_note); tests. The shipped-app window playbook's gate at 0.3.15; relay/playbooks/ember-tune-pc1.ps1 deleted (superseded); the post-mortem table and the 0.3.15 next-cut table in the plan; bench log
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:39:00 +00:00
igneum-labs
95707125c5 0.3.15: the playbook-quit allow list is only the installer's stop step; the agg-cost scripts on the runner's --cards-off; ember-tune-pc1.ps1 deleted
The dated entries of the 0.3.14 gate expired at 0.3.15. pc2-agg-cost.ps1 no longer switches the 5090 or falls back to /api/pause: the job is published with --cards-off <nvidia key>, the runner switches the card off before the script and puts it back on any exit, and the script fails loud (exit 3) if a CUDA worker is still running. pc2-agg-cost-restore.ps1 keeps the stray and socket clean-up and the read-back only. ember-tune-pc1.ps1 is superseded by the installed-tune playbook on ember-tune.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:38:32 +00:00
igneum-labs
f57c5293bc Merge exec-app-0314 0477f88 for 0.3.15: the prover names a stale exporter by path, the exec harness and the 60x row
override-60x.json keeps master's side (the rehearsal object with the fresh-rule dedup) plus exec_restart_state_root; docs/bench-log.md is the union of both sides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:35:45 +00:00
igneum-labs
3d0a6458da Merge miner-ui-3 6e9bd00 for 0.3.15: strips, in-place asks and the clock card on the brand tokens; plain-sentence strip and Activity lines; one header baseline (the Mine and Earnings screens as shipped in 0.3.14)
Every file under app/igneum-app/ui/ is that branch's side, per the merge rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:35:05 +00:00
igneum-labs
2b244d732e Ember: a reads-only log grep playbook (the app's own tune lines, the helper log, cmd.txt)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:33:19 +00:00
igneum-labs
0659fab5f2 Ember helper, two bugs from PC 1 on 6 October 2026: (A) a helper acts only on lines added after its start, so a stale quit or remove never kills a later start (commands_after + test); (B) the engine starts the task again before every task-path step and the acknowledgement is the helper's own log line for the sequence (up to 15 s), a missing line fails the step with that reason instead of a blind 4-second ack. Playbooks read the app log from <data root>\logs, where it lives
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:31:38 +00:00
igneum-labs
0477f8857d prover: a stale igneum-prove-export is named when the node's export carries the account dump and the exporter never reads it
PC 1 on 0.3.14 (6 October 2026, 18:16Z, block 140,662): "segment 140661: port state root 0xe45c... differs from the
node's 0xddd7...". The export was [140661, 140662] with the dump; the 0.3.14 exporter seeds from segment 140,661 and
never replays it, so the only exporter that replays 140,661 (from the restart state, hence the mismatch) is the one
from before 0.3.14: the installed binary was not replaced, the same class as the stale verifier host. The prover's
failure line now names the stale exporter by path when the export carries preState and the output has no "account
dump" line (exporter_failure, unit-tested with the PC 1 text); the real line stays when the exporter did read the dump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:24:40 +00:00
igneum-labs
6e9bd00dbb miner-ui-3: the banner only (the project lead: 'lets not actually change the miner page just sort the banner')
Variant C and the Earnings reshaping reverted: Mine and Earnings are exactly as shipped in 0.3.14. Kept from the
polish round: every strip, ask and clock card on the brand tokens (the row surface, a 1 px ember hairline on top,
ash text, ember only on the dot and the chevron; no filled brown or mustard field anywhere; the update strip and the
job strip share .notice), the plain-language strip and event sentences with the technical line behind the chevron,
the header baseline and the pill wording. 36 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:18:58 +00:00
igneum-labs
98bdeeb88b exec-sync harness case 7: the PC shape (tip-0 pair, peer flag) recovers over p2p; bench-log line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:13:44 +00:00
igneum-labs
5143cd2ed6 miner-ui-3: variant C is the default (the fleet rate big, the button a bar under it; Earnings the same shape); every strip on the brand tokens
the project lead picked C. The Mine hero is the fleet rate at 84 px on a graphite-to-obsidian fade with W, £ a day and blocks
beside it, Start/Stop as a full-width bar under it; Earnings carries the same shape with the £ figure big. The
?variant switch is gone. Every strip, ask and clock card is one component: the row surface, a 1 px ember hairline on
top, ash text, ember only on the dot and the chevron; no filled brown or mustard field anywhere in the app (the rail's
active item, the pill, the ghost-on button, the option, the ring, the log hit and mark lost their tints). The update
strip and the job strip share .notice. 36 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:09:55 +00:00
igneum-labs
9a45ea3785 miner-ui-3 polish: the strip, the feed and the pill in the app's voice; three Mine layouts to pick from
No raw job, manifest or relay text on a user surface: the job strip reads 'A job from the team ran: update check,
0 min.' with the technical line behind a chevron; 'Updated to 0.3.14 at 18:52.'; the Activity feed maps every engine
event string to a sentence (View.plainEvent, 80 patterns, the engine line as the tooltip) with a kind dot. The header
puts the title, subtitle and pill on one baseline; the pill is a sentence with a coloured dot (Mining · 511 MH/s,
Paused, Syncing the node, Node restarting, Engine not answering). Three Mine layouts behind ?variant=a|b|c (quieter,
denser, hero number) for the project lead to pick. 390 px strip on one line. 36 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:01:47 +00:00
igneum-labs
75a6364384 bench log: the first segment above the restart cut from a snapshot-restored node on the new export (8 blocks, roots equal node 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:57:54 +00:00
igneum-labs
bd3fb7a809 miner-ui-3: the card row is the product (audit, design, build)
The audit (docs/plans/miner-ui-3-audit.md, 27 screenshots): no money anywhere, three red n/a per Apple row,
tuning split across two pages, 370 px of controls per card, Prove's 95 words and four zeros, Node's eleven
numbers, the jobs table on every Updates page, fixes two taps away, no light mode, no layout under 900 px,
developer lines on user surfaces.

The design (docs/plans/miner-ui-3.md): four sections (Mine, Earnings, Prove, Settings); the card row carries the
state word with its reason, MH/s, W with MH/W, £ a day at the user's electricity price, °C, Tune, the switch,
and its details under a chevron (cap slider, identities, pin, telemetry, the tune table); the tune line per row
(not tuned yet / tuning: step k of n with a bar / tuned N ago · point · next check <weekday> / measured only and
why / pinned / stopped / fleet pause); Tune all; the goal (Efficiency, Balanced, Maximum) with its £ a day; Power
control as one switch where it is the fix; the node as one line with Details; updates as one card; earnings money
first, IGN second; proving as a tier sentence per card; every costly action confirmed in place (quit, change
address, show key, trust mode), no dialogs; light and dark; a bottom tab bar under 720 px.

The build: index.html, app.css, app.js rewritten on the same engine routes; the pure blocks keep their API and
carry ember-tune's tuneNotice, tuneWord, toggle states and tune-line test (0bf27b1) so the merge is clean; the
update card is named Igneum Miner; the Rust side is untouched. node --test on the four UI files: 35 pass.
Screenshots of the result: docs/plans/miner-ui-3/n00 to n26.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:55:45 +00:00
igneum-labs
ecfffa019a infra/fast-time/override-60x.json: the four exec restart fields at their never values (the devnet-profile test wants every override field present; the 0.3.14 node added them)
Measured: IGNEUM_FAST_TIME_FILE=<this file> cargo test -p kaspa-consensus-core --lib fast_time_60x: 1 passed (fork 1f59c5d0, 6 October 2026 17:54Z).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:54:52 +00:00
igneum-labs
14dc093549 Ember shipped-app window playbook: the version gate on the first line (0.3.14 or later, the climb field present, else abort before anything is asked), the prompt count from the app's log over the window, the climb against run 6's ladder floor per NVIDIA card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:00:30 +00:00
igneum-labs
1065b81d05 relay: three auth tiers, signed run tasks, machine secrets, retention; clients on headers; TZ=UTC and curl -K checks (X23 X24 X25 X26 X27 X28 X29 G13 G14)
Relay (X23, X27): the intake key is its own tier (upload and file drops only, RELAY_INTAKE_COMPAT=0 closes it);
a run task needs an Ed25519 signature by the Mac run key over {to, nonce, body sha256, flags} (RELAY_RUN_PUB,
401 without) and an HMAC tag with the target's machine secret that the agent verifies before anything runs;
results and registration are bound to the machine the secret proves (403 on a forged from).
X24: every client and Mac tool sends x-relay-token as a header to /api/relay?fn=; the path token stays for the
phone page only. X25: the agent arms the logon task only for a restart a task asked for and disarms on start
and exit. X26: 30-day retention with blob deletion, feed capped at 100, the dl base as RELAY_DL_BASE held by the
agent, never in a body. X28: GET inbox never acks (POST inbox does), RELAY-REBOOT on its own line and only with a
reboot flag, 120/min and 10 failed auths/min per IP, no username or folder on register, WSL sudo scoped to
apt-get and dpkg with SETENV, no password on a command line. X29: the intake key reaches curl through -K in
upload.sh and both upload-log.bat; tools/ci/curl-header-check.sh fails the class. G14: TZ=UTC in ship-app.mjs
and publish-jobs.sh; tools/ci/commit-tz-check.sh fails the class; history-rewrite.md names the .old-2026-10-05
files as the values in the history. The handler moved to relay/lib/handler.mjs with injected sql and blobs
(relay/lib/blob.mjs holds @vercel/blob) so relay/test/handler.test.mjs drives it without a database:
47 tests across 6 suites, all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 18:46:13 +00:00
190 changed files with 10763 additions and 1029 deletions

4
.gitignore vendored
View file

@ -32,6 +32,10 @@ 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.14"
version = "0.3.17"
dependencies = [
"ed25519-dalek",
"getrandom",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-app"
version = "0.3.14"
version = "0.3.17"
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

@ -6,8 +6,8 @@
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION 0,3,14,0
PRODUCTVERSION 0,3,14,0
FILEVERSION 0,3,17,0
PRODUCTVERSION 0,3,17,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.14"
VALUE "FileVersion", "0.3.17"
VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.14"
VALUE "ProductVersion", "0.3.17"
END
END
BLOCK "VarFileInfo"

View file

@ -39,6 +39,18 @@ 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,
@ -357,8 +369,9 @@ impl Runtime {
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 project lead'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()],
// 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 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(_) => vec![],
};
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);

View file

@ -906,6 +906,115 @@ 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 {
@ -1007,6 +1116,74 @@ 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)");

View file

@ -28,6 +28,10 @@ pub struct CardChoice {
pub enum Cmd {
Detect,
/// the payout address's balance (eth_getBalance, hex) or why it could not be read
BalanceRead(Result<String, String>),
/// the node's finality fields from igneum_getProvingStatus (None: the node carries none, or did not answer)
FinalityStatus(Option<crate::ember::FinalityStatus>),
ApplyCards(Vec<CardChoice>),
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
Detected(crate::detect::Detection),
@ -48,6 +52,10 @@ pub enum Cmd {
WorkerBuilt(usize, Result<PathBuf, String>),
/// local minus the latest block's timestamp, seconds (from the node's EVM RPC)
ClockSample(f64),
/// another node holds the app's ports: what it answered (src/extnode.rs)
ExternalNode(crate::extnode::Check),
/// the node's own description over RPC (igneum_getNodeInfo), every 30 s
NodeInfo(crate::extnode::Check),
/// local minus an HTTPS Date header, seconds; None when the check failed
ClockHttps(Option<f64>),
ClockCheck,
@ -77,6 +85,8 @@ pub enum Cmd {
TuneProgress(Value),
/// Ember 2: Settings > goal, electricity price, the hill-climb switch
TuneGoal(Option<String>, Option<f64>, Option<bool>),
/// one card's goal override (key, goal; "" = back to the global goal)
TuneCardGoal(String, String),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
/// prover found a host that was not there when the node started
RestartNode(String),
@ -118,6 +128,7 @@ impl Shared {
st.setup_done = settings.setup_done;
st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() };
st.node.state = "stopped".into();
st.node.tip_age_s = -1.0;
st.mining.state = "idle".into();
st.mining.paused = settings.paused;
st.mining.accepted_total = settings.accepted_total;
@ -402,6 +413,10 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
source: s.address_source.clone(),
key_saved: s.key_saved,
wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() },
balance_wei: None,
balance_age_s: -1.0,
balance_note: String::new(),
price_gbp_per_ign: None,
}
}
@ -532,6 +547,11 @@ pub struct Engine {
lines_tx: Sender<Line>,
node: Option<Proc>,
node_external: bool,
/// the ports are held by a node this app refused (other rules or network): re-checked, the app takes the ports when it leaves
node_refused: bool,
/// when the other node's port stopped answering (engine seconds), None while it answers
external_gone_since: Option<f64>,
node_info_at: Instant,
node_restart_at: Option<Instant>,
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log
resume_check_at: Option<Instant>,
@ -608,6 +628,10 @@ pub struct Engine {
tune_acked: Option<u64>,
/// cards whose confirm check found a better neighbour: the full plan runs next
tune_full_due: std::collections::HashSet<usize>,
/// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure)
balance_next: Option<Instant>,
balance_busy: bool,
balance_at: f64,
/// a sweep waiting for the cap-mode probe: (card index, forced)
sweep_pending: Option<(usize, bool)>,
/// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet
@ -651,6 +675,9 @@ impl Engine {
lines_tx,
node: None,
node_external: false,
node_refused: false,
external_gone_since: None,
node_info_at: Instant::now() - Duration::from_secs(25),
node_restart_at: None,
resume_check_at: None,
node_started_at: now,
@ -712,6 +739,9 @@ impl Engine {
sweep_helper_is_task: false,
tune_acked: None,
tune_full_due: std::collections::HashSet::new(),
balance_next: None,
balance_busy: false,
balance_at: 0.0,
sweep_pending: None,
sweep_direct: None,
sweep_helper: false,
@ -939,6 +969,24 @@ impl Engine {
}
}
}
Cmd::ExternalNode(check) => self.external_node_decided(check),
Cmd::NodeInfo(info) => {
if info.pow_engine.as_deref() == Some("stub") {
let first = self.st().node.state != "stub";
let mut st = self.st();
st.node.state = "stub".into();
st.node.synced = false;
st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into();
drop(st);
if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); }
}
let mut st = self.st();
if let Some(d) = info.digest.filter(|d| d.len() >= 16) {
if st.node.consensus_digest != d { st.node.consensus_digest = d; }
st.node.digest_source = "rpc".into();
}
if let Some(v) = info.version { if !v.is_empty() && st.node.version.is_empty() { st.node.version = v; } }
}
Cmd::ClockSample(d) => {
self.clock_samples.push(d);
if self.clock_samples.len() > 9 {
@ -1147,6 +1195,71 @@ impl Engine {
}
}
Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::FinalityStatus(f) => {
let mut st = self.st();
match f {
Some(f) => {
st.finality.node_active = f.active;
st.finality.reason = f.reason;
st.finality.held_by = f.held_by;
st.finality.provisional = f.provisional;
st.finality.cause_source = "node".into();
if let Some(since) = f.paused_since {
st.finality.paused_since = since;
}
}
None => {
// a node without the fields: the log line (parse_finality_line) may still have set a cause
st.finality.node_active = None;
if st.finality.cause_source != "node-line" {
st.finality.cause_source.clear();
}
}
}
}
Cmd::BalanceRead(r) => {
self.balance_busy = false;
match r {
Ok(hex) => match crate::ember::wei_from_hex(&hex) {
Some(wei) => {
let mut st = self.st();
st.address.balance_wei = Some(wei);
st.address.balance_note.clear();
drop(st);
self.balance_at = crate::platform::unix_now_f();
self.balance_next = Some(Instant::now() + Duration::from_secs(30));
}
None => {
self.st().address.balance_note = format!("the node answered {hex} for the balance, not a quantity");
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
},
Err(e) => {
self.st().address.balance_note = e;
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
}
}
Cmd::TuneCardGoal(key, goal) => {
{
let mut s = self.shared.settings.lock().unwrap();
let (enabled, identities) = self.st().mining.cards.iter().find(|c| c.key == key).map(|c| (c.enabled, c.identities)).unwrap_or((true, 1));
let e = s.cards.entry(key.clone()).or_insert_with(|| crate::config::CardPref { enabled, identities, ..Default::default() });
e.tune_goal = goal.clone();
}
self.shared.save_settings();
let name = {
let mut st = self.st();
let c = st.mining.cards.iter_mut().find(|c| c.key == key);
c.map(|c| {
c.tune_goal = goal.clone();
c.name.clone()
})
};
if let Some(name) = name {
self.shared.event("info", &format!("{name}: tune goal {}", if goal.is_empty() { "follows the global setting".to_string() } else { goal.clone() }));
}
}
Cmd::TuneGoal(goal, price, climb) => {
{
let mut s = self.shared.settings.lock().unwrap();
@ -1359,14 +1472,22 @@ impl Engine {
let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir);
self.apply_power_limits("start");
if port_open(self.shared.runtime.rpc_port) {
self.node_external = true;
self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port));
// another node holds the app's ports: check whose rules it runs before reading it (publish 2's read-back,
// 6 October 2026: the Mac read the hand node silently as its own); the answer comes back as Cmd::ExternalNode
let port = self.shared.runtime.rpc_port;
{
let mut st = self.st();
st.node.state = "syncing".into();
st.node.message = "external node".into();
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
st.node.state = "starting".into();
st.node.source = String::new();
st.node.message = format!("another node holds port {port} on this machine; checking its rules");
}
self.shared.log(&format!("port {port} is held by another node; asking it for its chain id and rules before using it"));
let shared = self.shared.clone();
let evm_port = self.shared.runtime.evm_port();
std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); });
} else {
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
self.set_node_mode("app", "own", "the ports were free at launch, so the app started its own node");
self.start_node();
}
self.plan_miners();
@ -1400,6 +1521,74 @@ impl Engine {
/// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2
/// activation height) written to <app data>/override-params.json for --override-params-file. None when the
/// package pins nothing, so the node runs on the network's defaults as before.
/// The check of another node on the app's ports came back (src/extnode.rs): use it and say so, or refuse it
fn external_node_decided(&mut self, check: crate::extnode::Check) {
let port = self.shared.runtime.p2p_port;
let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());
let network = self.shared.runtime.network.clone();
let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check);
self.node_external = d.use_it;
self.node_refused = !d.use_it;
self.external_gone_since = None;
self.set_node_mode(if d.use_it { "external" } else { "none" }, if d.use_it { "external" } else { "none" }, &d.line);
{
let mut st = self.st();
st.node.rules_check = d.verdict.into();
st.node.port_note = d.line.clone();
}
if d.use_it {
self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line);
let mut st = self.st();
st.node.state = "syncing".into();
st.node.message = "another node on this machine".into();
if let Some(dg) = check.digest.filter(|x| x.len() >= 16) { st.node.consensus_digest = dg; st.node.digest_source = "rpc".into(); }
if let Some(v) = check.version { if !v.is_empty() { st.node.version = v; } }
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
} else {
{
let mut st = self.st();
st.node.state = "stopped".into();
st.node.synced = false;
st.node.message = d.line.clone();
}
self.shared.event("error", &d.line);
self.shared.event("error", "mining waits: stop the other node or start this app with other ports, then open the app again");
}
}
/// Record which node the app reads (api/state node.source, node.mode, node.mode_reason).
fn set_node_mode(&self, source: &str, mode: &str, reason: &str) {
let mut st = self.st();
st.node.source = source.into();
st.node.mode = mode.into();
st.node.mode_reason = reason.into();
}
/// The other node left its ports for TAKEOVER_WAIT_S: the app starts its own node on them and says so.
fn take_over_ports(&mut self) {
let port = self.shared.runtime.rpc_port;
let was = if self.node_external { "the node this app was reading" } else { "the node this app refused" };
self.node_external = false;
self.node_refused = false;
self.external_gone_since = None;
let reason = format!("{was} left port {port} for {} s, so the app started its own node", crate::extnode::TAKEOVER_WAIT_S as u64);
self.shared.event("info", &format!("The other node went away; this app starts its own node on port {port}"));
self.set_node_mode("app", "own", &reason);
{
let mut st = self.st();
st.node.rules_check = String::new();
st.node.port_note = reason.clone();
st.node.synced = false;
st.node.consensus_digest = String::new();
st.node.digest_source = String::new();
st.node.version = String::new();
}
self.node_last_reading = None;
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
self.start_node();
}
fn node_override_file(&self) -> Option<std::path::PathBuf> {
if self.node_override_unusable {
return None;
@ -1447,6 +1636,9 @@ impl Engine {
let mut st = self.st();
st.node.pid = p.pid();
st.node.state = "starting".into();
st.node.stall_exits = 0;
st.node.tip_age_s = -1.0;
st.node.sync_cause = String::new();
st.node.starts = self.node_starts;
st.node.message = "opening the database".into();
st.node.restart_in_s = 0;
@ -1823,7 +2015,7 @@ impl Engine {
std::thread::spawn(move || {
let r = crate::powertask::start().and_then(|_| {
let _ = std::fs::create_dir_all(&dir);
let mut seq = crate::platform::unix_now() % 1_000_000;
let mut seq = crate::powertask::wire_seq();
let mut text = String::new();
for (dev, w) in &pairs {
seq += 1;
@ -2219,9 +2411,24 @@ impl Engine {
if c.tune_control && c.vendor == "nvidia" && !power_control && !forced {
// measure-only until Power control is on; the baseline plan covers it below
}
if self.sweep_retry.get(&c.index).map(|t| now < *t).unwrap_or(false) {
// (E) 6 October 2026, PC 1 18:18Z: three "Tune now" requests sat behind the hour's back-off from the
// 17:55 failures and the rows said "queued" with no start; an explicit request now clears the back-off,
// and a held card's row says when the retry comes
if let Some(t) = self.sweep_retry.get(&c.index).copied() {
if now < t {
if forced {
self.sweep_retry.remove(&c.index);
} else {
if c.sweep_pct == 0 || c.sweep_note.starts_with("tuning stopped") {
let note = crate::ember::retry_note((t - now).as_secs(), &c.sweep_note);
if let Some(cc) = self.st().mining.cards.get_mut(c.index) {
cc.sweep_note = note;
}
}
continue;
}
}
}
let stable = self.miners.iter().find(|m| m.card == c.index).map(|m| m.proc.as_ref().map(|p| p.started.elapsed() >= Duration::from_secs(crate::ember::STABLE_S)).unwrap_or(false) && m.last_status.is_some()).unwrap_or(false);
let wait = if c.state != "mining" {
Some(format!("tuning: waits for the card to mine ({})", c.state))
@ -2399,7 +2606,8 @@ impl Engine {
let full_due = self.tune_full_due.remove(&idx);
let (goal, climb_on) = {
let s = self.shared.settings.lock().unwrap();
(crate::ember::Goal::parse(&s.tune_goal), s.tune_climb)
// the card's own goal when set, else the global one
(crate::ember::goal_for(s.cards.get(&c.key).map(|p| p.tune_goal.as_str()).unwrap_or(""), &s.tune_goal), s.tune_climb)
};
let plan = if let Some(why) = control.as_ref() {
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
@ -2485,7 +2693,7 @@ impl Engine {
// under a scratch IGNEUM_APP_DATA), no prompt
let hdir = crate::powertask::helper_dir();
let _ = std::fs::create_dir_all(&hdir);
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::powertask::wire_seq(), c.device));
crate::powertask::start()?;
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
self.sweep_helper = true;
@ -2566,17 +2774,51 @@ impl Engine {
return;
}
let dev = device.to_string();
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), cmd);
// the helper polls twice a second and nvidia-smi answers within a second or two
// (F) the wire numbers come from the one monotonic space every writer uses (powertask::wire_seq);
// `seq` (the run's request index) stays the engine's own id for the acknowledgement
let wire = crate::powertask::wire_seq();
let cmd = format!("{} dev {dev}\n{} pl {w}\n{} {}\n{} {}\n", wire, wire + 1, wire + 2, if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, wire + 3, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
let dir = self.helper_cmd_dir();
let is_task = self.sweep_helper_is_task;
// 6 October 2026, PC 1: (B) the task's helper had exited while the engine's flag still said it ran,
// so commands went to nobody and a blind 4-second sleep called them acknowledged; (C) the first
// request of the first tune was written in the same second the helper started, and the helper
// skipped it as stale. Now, in the task case: the helper's heartbeat says whether it runs; the task
// is started and a fresh heartbeat awaited BEFORE the command is written; and the acknowledgement
// is the helper's own log line for this sequence (`<seq>3 nvidia-smi ...`), read for up to 15 s;
// a missing line is a failure with that reason
let want = format!(" {} nvidia-smi", wire + 3);
let log_file = dir.join("helper.log");
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(4));
shared.send(Cmd::TuneSet(seq, Ok("helper".into())));
if is_task {
if let Err(e) = crate::powertask::ensure_running(&dir, Duration::from_secs(12)) {
shared.send(Cmd::TuneSet(seq, Err(e)));
return;
}
}
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join("cmd.txt"), cmd);
let until = Instant::now() + Duration::from_secs(if is_task { 15 } else { 6 });
loop {
std::thread::sleep(Duration::from_millis(500));
let text = std::fs::read_to_string(&log_file).unwrap_or_default();
if let Some(line) = text.lines().rev().find(|l| l.contains(&want)) {
shared.send(Cmd::TuneSet(seq, Ok(format!("helper: {}", line.trim()))));
return;
}
if Instant::now() >= until {
shared.send(Cmd::TuneSet(seq, Err(format!("the helper did not run sequence {seq} within {} s (no line in {})", if is_task { 15 } else { 6 }, log_file.display()))));
return;
}
}
});
}
"amd" => {
let Some(exe) = self.bins.telemetry.clone() else { return };
if c.amd_ordinal < 0 {
// (D) 6 October 2026, PC 1 17:57Z: a measure-only card (the probe gave no tune line) was still sent a
// set, which the helper refused and the tune stopped; measure only means nothing is set, the run
// notices the missing acknowledgement and measures
if !crate::ember::may_set("amd", c.tune_control, c.amd_ordinal) {
return;
}
let n = c.amd_ordinal.to_string();
@ -2789,9 +3031,21 @@ impl Engine {
e.sweep_mhs = row.mhs;
e.sweep_clock_mhz = row.point.clock_mhz;
e.sweep_mem_mhz = row.point.mem_mhz;
// Miner UI 4: the row's sub-line reads "(the ladder's floor)" when the chosen clock is the lowest step
let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
e.sweep_floor = floor_point;
c.tune_floor = floor_point;
e.sweep_driver = c.driver.clone();
e.sweep_class = c.program_class.clone();
e.sweep_source = kind.name().into();
if kind == crate::ember::PlanKind::Full {
if let Some(b) = run.rows.first() {
e.sweep_before_watts = b.watts;
e.sweep_before_mhs = b.mhs;
c.tune_before_watts = b.watts;
c.tune_before_mhs = b.mhs;
}
}
if !c.pinned && kind != crate::ember::PlanKind::Baseline {
e.power_pct = row.point.power_pct;
}
@ -2907,6 +3161,12 @@ impl Engine {
self.last_awake = now;
crate::platform::keep_awake_tick();
}
if self.running && (self.node_external || self.node.is_some()) && now.duration_since(self.node_info_at) >= Duration::from_secs(30) {
self.node_info_at = now;
let shared = self.shared.clone();
let evm_port = self.shared.runtime.evm_port();
std::thread::spawn(move || { let c = crate::extnode::probe(evm_port); if c.digest.is_some() || c.version.is_some() { shared.send(Cmd::NodeInfo(c)); } });
}
if self.running {
self.tick_node(now);
self.tick_watch(now);
@ -2927,6 +3187,7 @@ impl Engine {
self.tick_sweep(now);
}
self.derive(now);
self.tick_balance(now);
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
self.last_status = now;
if self.running {
@ -2975,9 +3236,14 @@ impl Engine {
let st = self.st();
crate::ota::Ctx {
node_synced: st.node.synced && st.clock.severity != "block",
// Horizon frontier lane: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S
// (the last LOCK line's age; or, when none was ever seen this run, the engine's own uptime)
finality_paused: st.finality.paused,
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting") || self.jobs.active(),
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"),
// a remote job in progress holds the update, urgent or not (src/manifest.rs safe_to_apply; PC 1, 6 October 2026)
job_active: self.jobs.active(),
daa: st.node.daa,
}
};
@ -3155,12 +3421,24 @@ impl Engine {
}
fn tick_node(&mut self, now: Instant) {
if self.node_external {
if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
if self.node_external || self.node_refused {
// re-decided every 5 s: the other node leaving hands the ports to this app after the wait (7 October 2026)
if now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
self.last_sync_check = now;
let port = self.shared.runtime.rpc_port;
let first = self.external_gone_since.is_none();
match crate::extnode::step(port_open(port), self.secs(now), &mut self.external_gone_since) {
crate::extnode::Step::Stay => {}
crate::extnode::Step::Gone { for_s } => {
let left = (crate::extnode::TAKEOVER_WAIT_S - for_s).max(0.0) as u64;
if first && self.node_external { self.shared.event("info", &format!("The other node on port {port} went away; if it stays away, this app starts its own node in {left} s")); }
let mut st = self.st();
st.node.state = "stopped".into();
st.node.message = "the external node went away".into();
st.node.synced = false;
st.node.message = format!("the other node on port {port} went away; this app starts its own node in {left} s unless it comes back");
}
crate::extnode::Step::TakeOver => self.take_over_ports(),
}
}
return;
}
@ -3306,6 +3584,30 @@ impl Engine {
} else if verdict != crate::watchdog::Action::None {
// exit 43: the miner gave up on its worker; once more, then the card is faulted
self.watchdog_verdict(i, verdict, &tail);
} else if is_stall_exit(code, &tail) {
// N4: the miner found no new template for its stall span and stopped hashing and voting. Once:
// the miner restarts and the node gets another look. Twice since the node started: the node
// itself restarts and re-dials its peers (the seed list), because the miner alone cannot fix a
// node that fell off the network.
let n = { let mut st = self.st(); st.node.stall_exits += 1; st.node.stall_exits };
let label = self.miners[i].label.clone();
for l in &tail {
self.shared.log(&format!(" miner: {l}"));
}
if n >= 2 {
self.shared.event("error", &format!("{label}: no new block to work on, twice; the node restarts and re-dials its peers"));
self.st().node.stall_exits = 0;
self.restart_node("the miner saw no new block twice", Duration::from_secs(2));
} else {
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
self.miners[i].restart_at = Some(now + Duration::from_secs(delay));
self.shared.event("error", &format!("{label}: no new block to work on for the stall span; the miner restarts in {delay} s (a second stall restarts the node)"));
if let Some(c) = self.st().mining.cards.get_mut(card_idx) {
c.state = "restarting".into();
c.hash_now = 0.0;
c.message = "no new block to work on; waiting for the node".into();
}
}
} else {
self.miners[i].restarts += 1;
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
@ -3461,6 +3763,32 @@ impl Engine {
}
}
/// Miner UI 4: the payout address's balance through the node's own RPC, every 30 s while the node answers.
fn tick_balance(&mut self, now: Instant) {
if self.balance_busy || self.balance_next.map(|t| now < t).unwrap_or(false) {
return;
}
let (addr, node_up) = {
let st = self.st();
(st.address.value.clone(), matches!(st.node.state.as_str(), "syncing" | "synced"))
};
if addr.len() != 42 || !node_up {
self.balance_next = Some(now + Duration::from_secs(10));
return;
}
self.balance_busy = true;
self.balance_next = Some(now + Duration::from_secs(30));
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::prover::evm_rpc(&shared, "eth_getBalance", serde_json::json!([addr, "latest"]), Duration::from_secs(8)).and_then(|v| v.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_getBalance: not a string".to_string()));
shared.send(Cmd::BalanceRead(r));
// the same 30-s cadence carries the node's finality fields (the node lane, 0.3.16); a node without them
// answers without the keys and the engine's own rule stands
let f = crate::prover::evm_rpc(&shared, "igneum_getProvingStatus", serde_json::json!([]), Duration::from_secs(8)).ok().and_then(|v| crate::ember::parse_finality_status(&v));
shared.send(Cmd::FinalityStatus(f));
});
}
/// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word.
fn derive(&mut self, now: Instant) {
let unix = crate::platform::unix_now_f();
@ -3475,6 +3803,10 @@ impl Engine {
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
}
st.mining.hash_total = total;
// Miner UI 4: the fleet's draw and its £ a day (settings.power_price_pence; 0 when no price is set)
st.mining.watts_total = crate::ember::fleet_watts(st.mining.cards.iter().map(|c| (c.state.as_str(), c.power_w, c.telemetry_at)), unix, 60.0);
st.mining.pounds_per_day = if st.settings.power_price_pence > 0.0 { crate::ember::pounds_per_day(st.mining.watts_total, st.settings.power_price_pence) } else { 0.0 };
st.address.balance_age_s = if self.balance_at > 0.0 { unix - self.balance_at } else { -1.0 };
crate::hotplug::age(&mut st.mining.cards, unix);
let cut = unix - 3600.0;
st.mining.found.retain(|t| *t > cut);
@ -3496,6 +3828,22 @@ impl Engine {
st.finality.age_s = unix - st.finality.last_lock_at;
st.finality.message = String::new();
}
// Horizon polish Q83/Q84: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S (the
// last lock's age, else the engine's uptime); since the last lock (else the start); one sentence everywhere
let gap = if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 };
// the node's own word wins when it carries one (0.3.16, finalityActive); else the engine's 15-minute rule
let paused = match st.finality.node_active {
Some(active) => !active,
None => st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S,
};
if paused && !st.finality.paused && st.finality.cause_source != "node" {
st.finality.paused_since = if st.finality.last_lock > 0 { st.finality.last_lock_at } else { unix - st.uptime_s as f64 };
}
st.finality.paused = paused;
st.finality.line = if paused { crate::ember::finality_paused_line(st.finality.paused_since, &st.finality.reason, &st.finality.held_by) } else { String::new() };
if paused {
st.finality.message = st.finality.line.clone();
}
if self.running {
let mining_now = st.mining.cards.iter().any(|c| c.state == "mining");
let any_slot = !self.miners.is_empty();
@ -3545,6 +3893,7 @@ impl Engine {
if st.node.consensus_digest != d {
st.node.consensus_digest = d;
}
if st.node.digest_source.is_empty() { st.node.digest_source = "log".into(); }
return;
}
if !text.contains("too far into the future") {
@ -3650,6 +3999,8 @@ impl Engine {
let blue = kv_u64(text, "blue").unwrap_or(0);
let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0);
let flag = kv(text, "synced").map(|s| s == "true");
// N4: the sink header's age from the node (ca3-v4-0316 appends tip_age_s after synced=); -1 on an older node
let tip_age_s = kv(text, "tip_age_s").and_then(|s| s.parse::<f64>().ok()).unwrap_or(-1.0);
let was_synced = self.st().node.synced;
// Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a
// clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false).
@ -3662,7 +4013,7 @@ impl Engine {
let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0);
let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false);
let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty();
let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private);
let (synced, cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s);
self.sync_prev = Some(blocks);
// the first clock source: local time against the latest block the peers produced, once blocks arrive
if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) {
@ -3685,11 +4036,17 @@ impl Engine {
st.node.blue = blue;
st.node.difficulty = difficulty;
st.node.synced = synced;
st.node.state = if synced { "synced".into() } else { "syncing".into() };
st.node.tip_age_s = tip_age_s;
st.node.sync_cause = cause.to_string();
// the state word: "behind" for a frozen tip, "no peers" for none, "syncing" for the rest (N4: never
// "synced" on a tip that stopped moving)
st.node.state = if synced { "synced".into() } else if cause == "frozen" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() };
st.node.message = if synced {
String::new()
} else if cause == "frozen" {
format!("no new block for {} s with {peers} peer{}; the chain may have moved on without this node", tip_age_s.round() as i64, if peers == 1 { "" } else { "s" })
} else if peers == 0 {
"waiting for a peer".into()
"waiting for a peer on our chain".into()
} else if headers > blocks + 2 {
format!("{blocks} of {headers} blocks")
} else {
@ -3901,6 +4258,18 @@ impl Engine {
st.finality.age_s = 0.0;
}
}
} else if text.contains("finality_reason=") {
// the node lane's pause line (0.3.16): the cause and who holds it, shown in the one sentence
if let Some((reason, held_by)) = crate::ember::parse_finality_line(text) {
let mut st = self.st();
st.finality.reason = reason;
st.finality.held_by = held_by;
st.finality.cause_source = "node-line".into();
}
} else if text.contains("finality resumed") {
let mut st = self.st();
st.finality.reason.clear();
st.finality.held_by.clear();
} else if text.contains(" VOTE index=") {
self.st().finality.votes += 1;
} else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") {
@ -4227,6 +4596,38 @@ pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent:
caught_up && (moving || stable_s >= 60.0)
}
/// The tip age at which a node that still says "synced" is treated as frozen (ledger N4, 6 October 2026: a home
/// miner's one same-digest peer relayed nothing for 53 minutes while the app read "mining, synced").
pub const TIP_FROZEN_S: f64 = 120.0;
/// N4: what "synced" means on every surface. The old decision, AND the tip moved in the last 120 s (the watch
/// line's tip_age_s, the sink header's age; -1 = the node does not report it yet, which gates nothing so an older node
/// keeps today's words), AND at least one peer (the handshake refuses a peer with another digest, so `peers` already
/// counts peers on our chain). Returns (synced, cause): the cause is "" when synced, else "frozen" (a peer, caught up,
/// no new block for over 120 s), "no peers", "syncing", or "behind" (the node's own flag false while the count moves).
pub fn sync_decision_v2(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool, tip_age_s: f64) -> (bool, &'static str) {
if private {
return (true, "");
}
let frozen = tip_age_s > TIP_FROZEN_S && !accepted_recent;
if r.peers == 0 && !accepted_recent {
return (false, "no peers");
}
if frozen {
return (false, "frozen");
}
if sync_decision(r, moving, stable_s, accepted_recent, private) {
return (true, "");
}
(false, if r.headers > r.blocks + 2 { "syncing" } else { "behind" })
}
/// The miner's stall exit (the node lane, ca3-v4-0316): code 45, or a tail line starting "STALLED '".
pub const STALL_EXIT_CODE: i32 = 45;
pub fn is_stall_exit(code: i32, tail: &[String]) -> bool {
code == STALL_EXIT_CODE || tail.iter().any(|l| l.contains(" STALLED '") || l.starts_with("STALLED '"))
}
/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind.
fn behind_from_warning(text: &str) -> Option<f64> {
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().ok()).collect();
@ -4385,7 +4786,7 @@ mod tests {
assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None);
}
use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading};
use super::{is_stall_exit, sync_decision_v2, digest_from_line, parse_race, switches_of, sync_decision, Reading};
#[test]
fn digest_comes_from_the_nodes_own_line_only() {
@ -4457,6 +4858,44 @@ mod tests {
assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true));
}
/// Ledger N4 (6 October 2026): a home miner's node kept one same-digest peer that relayed nothing for 53 minutes;
/// today's rule said "synced" the whole time. The known-failed case first, then the fix.
#[test]
fn n4_a_frozen_tip_is_never_synced() {
// the frozen tip: caught up, one peer, the count stable for an hour, getInfo's flag true. Today: synced.
let frozen = r(133_000, 133_000, 1, Some(true));
assert!(sync_decision(&frozen, false, 3_180.0, false, false), "the old rule says synced on the frozen tip (the N4 failure)");
// the fix: the tip age gates it
let (ok, cause) = sync_decision_v2(&frozen, false, 3_180.0, false, false, 3_180.0);
assert!(!ok);
assert_eq!(cause, "frozen");
// the same node moving: synced
assert_eq!(sync_decision_v2(&frozen, true, 0.0, false, false, 4.0), (true, ""));
// exactly at the bound is still synced; one second over is not
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 120.0), (true, ""));
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 121.0), (false, "frozen"));
// an older node that does not report the age (-1) keeps today's words
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, -1.0), (true, ""));
// our own block accepted in the last minute proves the tip moves, whatever the reported age
assert_eq!(sync_decision_v2(&frozen, false, 60.0, true, false, 900.0), (true, ""));
// no peer at all: "no peers", not "frozen" and never synced
assert_eq!(sync_decision_v2(&r(133_000, 133_000, 0, Some(true)), false, 600.0, false, false, 500.0), (false, "no peers"));
// still downloading: "syncing"
assert_eq!(sync_decision_v2(&r(100, 700, 1, Some(false)), true, 0.0, false, false, 2.0), (false, "syncing"));
// caught up but the flag false and the count not yet stable: "behind"
assert_eq!(sync_decision_v2(&r(700, 700, 1, Some(false)), false, 10.0, false, false, 5.0), (false, "behind"));
// a private test node is always synced
assert_eq!(sync_decision_v2(&r(0, 0, 0, None), false, 0.0, false, true, 9_999.0), (true, ""));
}
#[test]
fn n4_the_stall_exit_is_code_45_or_the_stalled_line() {
assert!(is_stall_exit(45, &[]));
assert!(is_stall_exit(1, &["1791327000.123 STALLED 'nvidia-1ccfe586-1': no new template for 300 s (tip d3dc2f78, daa 197752); the node has fallen off the network or stopped moving; hashing and voting paused, exiting with code 45 so the launcher restarts (and after a second stall, restarts the node)".to_string()]));
assert!(!is_stall_exit(1, &["worker fault: rejected nonce".to_string()]));
assert!(!is_stall_exit(42, &[]));
}
#[test]
fn node_warning() {
let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000";

View file

@ -0,0 +1,243 @@
//! 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 0.3.17 node lane; "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::process::Command;
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 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 (it predates 0.3.17), 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> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &format!("{}", limit.as_secs().max(1)), "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", &body]),
None,
limit + Duration::from_secs(2),
)?;
let v: Value = serde_json::from_str(&out).ok()?;
v.get("result").cloned().filter(|r| !r.is_null())
}
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (0.3.17).
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
}
/// 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)");
// 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

@ -167,6 +167,10 @@ 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);
}

366
app/igneum-app/src/live.rs Normal file
View file

@ -0,0 +1,366 @@
//! 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, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents,
//! colours, the selected chain, checkpoints, proof state). `state.source` = "observer".
//! * 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 (`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.
//!
//! 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 lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mut yours = 0u64;
if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) {
for b in blocks.iter_mut() {
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
if mine {
yours += 1;
if let Some(o) = b.as_object_mut() {
o.insert("miner".into(), json!("you"));
}
}
}
}
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
/// scene's row shape as far as the node knows it: no parents) and `miners` arrays. `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| {
json!({
"hash": short(&b.hash), "ts": b.timestamp_ms, "timestamp_ms": b.timestamp_ms, "blue_score": b.blue_score, "daa": b.daa_score,
"parents": [], "chain": b.is_chain_block, "is_chain_block": b.is_chain_block,
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
"miner": lane(&b.vote_key_hash), "vote_key_hash": short(&b.vote_key_hash),
"timestamp_source": if b.timestamp_source.is_empty() { "header" } else { b.timestamp_source.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), "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
})
}
/// 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: the scene gets blocks without parents or proof state, and says so
let mut v = shape_from_blocks(&blocks, now_ms, window_s as u64);
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 mut out = json!({ "ok": true, "partial": true, "now": crate::platform::unix_now_f(), "state": n, "blocks": rows, "miners": miners, "events": [], "finality": { "checkpoints": [] } });
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"));
}
out
}
(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_become_the_lane_you_and_nothing_else_moves() {
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"], "you");
assert_eq!(blocks[2]["miner"], "you");
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!(out["blocks"].as_array().unwrap().iter().all(|b| b["miner"] != "you"));
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]["timestamp_source"], "header");
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");
// the lane "you" marks node rows too
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"], "you");
assert_eq!(marked["state"]["you_blocks"], 2);
assert_eq!(lane("0x0123456789abcdef"), "01234567");
assert_eq!(lane(""), "");
}
#[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

@ -30,6 +30,7 @@ mod jobrun;
mod jobbuild;
mod prover;
mod provedefault;
mod provingdir;
mod segments;
mod verifier;
mod wslhost;
@ -37,6 +38,8 @@ mod sweep;
mod ember;
mod powertask;
mod watchdog;
mod live;
mod extnode;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;

View file

@ -52,6 +52,9 @@ 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.
@ -157,6 +160,8 @@ 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()),
@ -300,6 +305,10 @@ 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.
@ -308,8 +317,16 @@ 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;
@ -320,6 +337,13 @@ 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(());
}
@ -349,7 +373,10 @@ 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 {
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
// 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,
_ => false,
}
}
@ -492,8 +519,15 @@ mod tests {
#[test]
fn safe_moments() {
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 };
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 };
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());
@ -502,6 +536,11 @@ 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());
@ -531,8 +570,11 @@ 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), 120_000));
assert!(fork_is_close(Some(120_000), 130_000));
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");
let m = parse(SAMPLE).unwrap();
assert!(!unsupported(&m, "0.3.0"));
assert!(unsupported(&m, "0.2.9"));

View file

@ -68,8 +68,12 @@ 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,
}
@ -539,7 +543,8 @@ 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 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 };
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 };
if !self.auto && !urgent && !self.install_asked {
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
return None;
@ -668,6 +673,12 @@ 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;
@ -954,7 +965,7 @@ impl Updater {
// ---- the threads ---------------------------------------------------------------------------------------------------
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
fn live_api_from(live_page: &str) -> String {
pub 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") }

View file

@ -35,6 +35,46 @@ 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)]
@ -79,6 +119,24 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
}
}
/// 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
}
/// 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). 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 {
@ -195,20 +253,30 @@ pub fn run_helper(dir: &Path) -> i32 {
}
};
log("helper started (scheduled task, elevated)");
// 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();
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 mut last_text = initial;
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 {
if text.lines().count() < skip {
skip = 0;
}
last_text = text.clone();
for (seq, c) in text.lines().filter_map(parse_line) {
for (seq, c) in commands_after(&text, skip) {
match c {
HelperCmd::Quit => {
log("quit");
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return 0;
}
HelperCmd::Remove => {
@ -261,8 +329,10 @@ 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));
}
}
@ -296,6 +366,18 @@ 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)]);
// 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"));

View file

@ -135,6 +135,22 @@ 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)) {
@ -151,7 +167,7 @@ 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)
}
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
pub(crate) 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())?;
@ -665,7 +681,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(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, None));
}
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" };
@ -854,7 +870,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(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, Some(b)));
}
fixtures.push(as_host_path(&fixture));
}
@ -1092,6 +1108,21 @@ 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";

View file

@ -0,0 +1,178 @@
//! 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

@ -12,6 +12,7 @@ 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");
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");
@ -138,6 +139,15 @@ 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('=')?;
@ -165,6 +175,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
("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", "/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),
@ -178,6 +189,13 @@ 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));
}
("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);
@ -256,6 +274,14 @@ 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 }))
}
@ -351,6 +377,17 @@ 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.
"/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)"));
@ -359,3 +396,17 @@ 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,6 +21,29 @@ 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,
@ -126,6 +149,10 @@ pub struct CardState {
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?")
pub tune_step: u32,
@ -163,6 +190,10 @@ 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...`).
@ -249,6 +280,23 @@ 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).
@ -271,6 +319,15 @@ 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)]

View file

@ -61,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);background:var(--molten-10)}
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40)}
.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}}
@ -77,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(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
.nav.on{background:var(--hover);border-color:var(--line);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)}
@ -99,13 +99,15 @@ 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: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{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.on{color:var(--molten);border-color:var(--molten-40)}
.pill.warn{color:var(--ember)}
.pill.warn{color:var(--ember);border-color:var(--ember-40)}
.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}
@ -116,26 +118,30 @@ body.has-rail .top{left:var(--rail)}
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-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{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-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.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-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 .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(--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{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-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
.ask.inline{box-shadow:inset 0 1px 0 var(--ember);background:var(--row);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}
@ -214,7 +220,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(--ember-12);color:var(--ember)}
.toggle-big.stop .ring{background:var(--obsidian);border:1px solid var(--line-2);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)}
@ -246,9 +252,10 @@ 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{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)}
.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)}
/* 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 */
@ -349,8 +356,8 @@ 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(--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-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-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
.clock-card .note{margin-top:0}
@ -372,7 +379,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(--ember-12)}
.option.on{border-color:var(--ember);background:var(--row)}
.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)}
@ -506,8 +513,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: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 .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-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)}
@ -590,6 +597,8 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.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 */
@ -605,3 +614,81 @@ 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}
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.lg.pending{border-color:var(--ash)}
.lg.included{border-color:var(--ember)}
.lg.excluded{border-color:var(--ash);opacity:.45}
.lg.proven{background:var(--ember);border-color:var(--ember)}
.lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* 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}
}

View file

@ -49,12 +49,27 @@ var Notices = (function () {
}
if (u.updated_from && s.uptime_s >= 0 && s.uptime_s < INSTALLED_S) {
var now = s.version || ver;
return notice('update-installed', 'update:installed:' + now, 'Updated to Igneum Miner ' + now + ' from ' + u.updated_from + '.');
var at = s.now > 0 && s.uptime_s >= 0 ? ' at ' + hhmm(s.now - s.uptime_s) : '';
return notice('update-installed', 'update:installed:' + now, 'Updated to ' + now + at + '.', { title: 'Igneum Miner ' + now + ', from ' + u.updated_from });
}
return null;
}
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (mining continues)' : j.kind); }
// the job in a user's words (miner-ui-3 polish, 6 October 2026): from its kind, else from its id's prefix
function jobWord(kind, id) {
var k = String(kind || ''), i = String(id || '');
var t = k || i;
if (/^update-now/.test(t) || /^update-now/.test(i)) return 'update check';
if (/^restart/.test(t) || /^restart/.test(i)) return 'restart';
if (/^collect/.test(t) || /^collect/.test(i)) return 'log collection';
if (/shard-benchmark|^shard/.test(t) || /shard/.test(i)) return 'shard benchmark';
if (/^build/.test(t) || /^build/.test(i)) return 'build';
if (/ember|tune|sweep/.test(t) || /ember|tune|sweep/.test(i)) return 'tuning check';
if (/script|playbook|run/.test(t)) return 'a script';
return k || 'a task';
}
function hhmm(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }
function firstErrorLine(results, summary) {
for (var i = 0; i < results.length; i++) if (/BUILD FAILED|error|failed|panic/i.test(results[i])) return firstLine(results[i]);
return firstLine(summary);
@ -65,18 +80,20 @@ var Notices = (function () {
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
var results = (j.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; });
return notice('job-running', 'job:run:' + j.id, 'Job: ' + jobTitle(j) + ' running, ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: results.length ? results[results.length - 1] : '', title: j.message || '' });
var tech = [jobTitle(j), results.length ? results[results.length - 1] : ''].filter(Boolean).join(' · ');
return notice('job-running', 'job:run:' + j.id, 'A job from the team is running: ' + jobWord(j.kind, j.id) + ', ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: tech, title: j.message || '' });
}
var l = j.last;
if (!l || !l.id) return null;
var mins = Math.max(0, Math.round((l.finished_at - l.started_at) / 60)), t = l.title || l.kind;
var report = l.uploaded ? 'Report uploaded.' : 'Report not uploaded.';
var report = l.uploaded ? '' : ' The report did not upload.', word = jobWord(l.kind, l.id), tech = t + (l.summary ? ': ' + l.summary : '');
if (l.status === 'done') {
if (l.finished_at && now - l.finished_at > JOB_DONE_S) return null;
return notice('job-done', 'job:done:' + l.id, 'Job: ' + t + ' done after ' + mins + ' min. ' + report, { title: l.summary || '' });
return notice('job-done', 'job:done:' + l.id, 'A job from the team ran: ' + word + ', ' + mins + ' min.' + report, { detail: tech, title: l.summary || '' });
}
var word = l.status === 'timeout' ? 'hit its time cap' : l.status === 'aborted' ? 'was stopped' : 'failed';
return notice('job-failed', 'job:failed:' + l.id, 'Job: ' + t + ' ' + word + ' after ' + mins + ' min, exit ' + l.exit + '. ' + report, { tone: 'bad', detail: firstErrorLine(j.last_results || [], l.summary), title: l.summary || '' });
var verb = l.status === 'timeout' ? 'ran out of time' : l.status === 'aborted' ? 'was stopped' : 'failed';
var cause = firstErrorLine(j.last_results || [], l.summary);
return notice('job-failed', 'job:failed:' + l.id, 'A job from the team ' + verb + ': ' + word + ', after ' + mins + ' min.' + report, { tone: 'bad', detail: [tech, cause !== l.summary ? cause : ''].filter(Boolean).join(' · ') + ' · exit ' + l.exit, title: l.summary || '' });
}
// c = state.clock: the engine's words, Sync clock, the manual hint underneath
@ -136,7 +153,7 @@ var Notices = (function () {
}
return best;
}
return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
return { tuneNotice: tuneNotice, jobWord: jobWord, hhmm: hhmm, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@ -249,9 +266,10 @@ var UpdateCard = (function () {
var View = (function () {
'use strict';
var PAGES = [
{ id: 'mine', title: 'Mine', sub: 'your cards, the rate, what it costs' },
{ id: 'earnings', title: 'Earnings', sub: 'what it made, where it goes' },
{ id: 'prove', title: 'Prove', sub: 'turn blocks into proofs, paid in IGN' },
{ id: 'overview', title: 'Overview', sub: 'your rate, what it costs, the chain as it grows' },
{ id: 'cards', title: 'Cards', sub: 'every card, tuned by Ember' },
{ id: 'earnings', title: 'Earnings', sub: 'what you made, where it goes' },
{ id: 'prove', title: 'Prove', sub: 'prove blocks, earn IGN' },
{ id: 'settings', title: 'Settings', sub: 'tuning, this machine, updates, logs' }
];
var DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@ -293,14 +311,14 @@ var View = (function () {
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
// the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word
var tuning = st === 'tuning', held = st === 'held';
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated errors' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0;
var sub = unusable ? (cd.reason || '') : removed ? 'unplugged; its worker stopped. The row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || '';
var sub = unusable ? (cd.reason || '') : removed ? 'unplugged. Its row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || '';
var meta = [];
if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); }
if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's'));
@ -326,8 +344,9 @@ var View = (function () {
// MH per watt: the engine's live figure, else rate over draw
function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; }
function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; }
// the watts drawn now by the present cards
function wattsTotal(cards) { return (cards || []).filter(present).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); }
// the watts drawn now by the cards that are mining or tuning (an idle card's standby draw is not the fleet's cost;
// the 0.3.17 engine's mining.watts_total counts the same way)
function wattsTotal(cards) { return (cards || []).filter(function (c) { return present(c) && (c.state === 'mining' || c.state === 'tuning'); }).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); }
// why a row has no power or temperature cell: one sentence, once, instead of three n/a
function noReading(cd) {
if (cd.power_w > 0 || cd.temp_gpu > 0) return '';
@ -379,9 +398,9 @@ var View = (function () {
var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
var nc = nextCheck(cd.sweep_at, st.tune_period_s, now);
if (cd.tune_line) {
var nums = String(cd.tune_line).replace(/^Tuned:\s*/, '');
var nums = String(cd.tune_line).replace(/^(Tuned|Measured):\s*/, '');
if (cd.tune_source === 'baseline' || !cd.tune_control) return { kind: 'measured', text: 'Measured ' + nums + ' as it runs' + (when ? ', ' + when : ''), note: reasonOf(cd.sweep_note), button: 'tune', progress: -1 };
var src = cd.tune_source === 'confirm' ? ' from the fleet prior, confirmed' : '';
var src = cd.tune_source === 'confirm' ? ' from what other miners found, confirmed' : '';
var pin = cd.pinned ? 'Your cap stays pinned at ' + (cd.power_pct || 0) + '% · ' : '';
var pt = tunePoint(cd);
var stopped = cd.sweep_note && /^tuning stopped/i.test(cd.sweep_note) ? ' · ' + cap(cd.sweep_note) : '';
@ -402,10 +421,11 @@ var View = (function () {
list.forEach(function (c) { if (c.sweep_at > 0 && (!last || c.sweep_at > last.sweep_at)) last = c; });
if (last) return 'Last tune ' + ago(now - last.sweep_at) + ' (' + last.name + ') · ' + nextCheck(last.sweep_at, st.tune_period_s, now) + '.';
if (!list.length) return 'No card here can be tuned.';
return st.sweep === false ? 'Ember Tune is off. Tune and Tune all still work by hand.' : 'Not tuned yet: the first tune starts 2 min into steady mining.';
return st.sweep === false ? 'Ember Tune is off. Tune and Tune all still work when you press them.' : 'Not tuned yet: the first tune starts 2 min into steady mining.';
}
// the goal control's consequence: goal = efficiency|balanced|rate, cards, pence
var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' };
var GOAL_NAMES = { efficiency: 'Efficiency', balanced: 'Balanced', rate: 'Maximum' };
function goalWords(goal, cards, pence) {
goal = GOALS[goal] ? goal : 'balanced';
var list = (cards || []).filter(present), w = 0;
@ -427,11 +447,11 @@ var View = (function () {
var tc = tuningCard(cards);
if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' };
var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length;
if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (heldCards && !mining) return { label: 'Held', sub: 'a job from the team is using the cards. Mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : n && n.state === 'behind' ? 'waiting: the node is behind the chain' : n && n.state === 'no peers' ? 'waiting: no peers on our chain' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' };
}
// the row's state word while the miner is paused: the card is on, the miner is not
@ -442,7 +462,10 @@ var View = (function () {
var bad = clock && clock.severity === 'block';
if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' };
switch (n.state) {
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.tip_age_s >= 0 ? ' Last block ' + Math.round(n.tip_age_s) + ' s ago.' : n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
// N4 (6 October 2026): a tip that stopped moving is never "synced"; the node says why
case 'behind': return { word: 'behind', line: (n.tip_age_s >= 0 ? 'No new block for ' + tipAge(n.tip_age_s) + '. ' : '') + 'The chain may have moved on without this node. Mining pauses until it catches up; the node re-dials its peers by itself.', tone: 'bad' };
case 'no peers': return { word: 'no peers', line: 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits.', tone: 'bad' };
case 'syncing':
var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0;
var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block');
@ -450,15 +473,19 @@ var View = (function () {
case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' };
case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' };
case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' };
case 'stopped': return { word: 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' };
}
return { word: n.state || 'starting', line: n.message || '', tone: '' };
}
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks"
// "12 s" / "4 min" / "53 min" / "2 h" for a tip age
function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; }
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago"
function nodeLine(n, clock, eta) {
var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word];
if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's'));
if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks');
if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago');
return { text: parts.join(' · '), sub: w.line, tone: w.tone };
}
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
@ -472,25 +499,25 @@ var View = (function () {
return null;
}
function switchLine(sw, daa) {
if (!sw) return (daa > 0 ? 'Every planned switch is behind this node. ' : '') + 'A switch is a planned rule change. The node applies it by itself when the chain reaches that height.';
if (!sw) return (daa > 0 ? 'Every planned rule change is behind this node. ' : '') + 'A rule change is planned ahead. The node applies it by itself when the chain reaches that height.';
var blocks = daa > 0 ? withCommas(sw.away) + ' blocks away, about ' + (sw.away < 3600 ? Math.max(1, Math.round(sw.away / 60)) + ' min' : sw.away < 86400 ? Math.floor(sw.away / 3600) + ' h ' + Math.round(sw.away % 3600 / 60) + ' min' : Math.floor(sw.away / 86400) + ' d') + ' at one block a second.' : 'the node applies it by itself at that height.';
return sw.name + ' at DAA ' + withCommas(sw.daa) + ': ' + blocks;
return sw.name + ' at block ' + withCommas(sw.daa) + ': ' + blocks;
}
// the Prove page: pv = state.proving, enabled = state.settings.prove, synced = state.node.synced
function proveWords(pv, enabled, synced) {
pv = pv || {};
if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the pieces the chain assigns to its keys.' };
if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the shards it is given.' };
if (pv.status === 'setup' || (pv.enabled && !pv.available && pv.setup_hint)) return { word: 'needs setup', sub: 'one-time install', tone: 'bad', note: pv.setup_hint || pv.message || 'The prover is not installed yet.' };
if (!synced || pv.status === 'waiting') return { word: 'waiting', sub: 'for the node to sync', tone: '', note: pv.message || 'Proving starts once the node has every block.' };
if (pv.status === 'proving') return { word: 'proving', sub: pv.current || 'a shard', tone: 'on', note: pv.message || 'A proof takes minutes on a CPU and seconds on a CUDA card.' };
if (pv.status === 'proving') return { word: 'proving', sub: pv.current || 'a shard', tone: 'on', note: pv.message || 'A proof takes minutes on a CPU and seconds on an NVIDIA card.' };
if (pv.status === 'submitted') return { word: 'submitted', sub: 'waiting to be paid', tone: 'on', note: pv.message || 'The node checks the proof, then the chain pays it.' };
if (pv.status === 'idle') return { word: 'idle', sub: 'nothing assigned', tone: 'ok', note: pv.message || 'On. Nothing is assigned to your keys right now; the next shard arrives within a minute.' };
if (pv.status === 'idle') return { word: 'idle', sub: 'nothing assigned', tone: 'ok', note: pv.message || 'On. Nothing to prove right now. The next shard arrives within a minute.' };
return { word: pv.status || 'on', sub: pv.backend ? pv.backend.toUpperCase() : '', tone: '', note: pv.message || '' };
}
function verifierWords(pv) {
var vm = (pv && pv.verifier_mode) || 'unknown';
var word = { command: 'verifying', trust: 'trusting (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm;
var word = { command: 'verifying', trust: 'trusting without verifying (devnet only)', off: 'off, passing proofs along', unknown: 'not read yet' }[vm] || vm;
if (vm === 'command' && pv.pool_entries) word += ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '');
return { word: word, tone: vm === 'command' ? 'ok' : (vm === 'off' || vm === 'trust') ? 'warn' : 'dim', needsSetup: vm === 'off' && /WSL2 prover is not installed/.test((pv && pv.verifier_reason) || '') };
}
@ -500,7 +527,7 @@ var View = (function () {
var gb = cd.vram_mb ? Math.round(cd.vram_mb / 1024) : 0;
if (cd.vendor === 'apple') return cd.name + ': proves on the CPU, slowly.';
if (cd.vendor === 'nvidia') return gb >= PROVE_GB ? cd.name + ': proves while it mines (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' : cd.name + ': cannot prove a full shard (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).';
return cd.name + ': cannot prove yet (the prover is CUDA only).';
return cd.name + ': cannot prove yet (proving needs an NVIDIA card).';
}
// the counts in one sentence: "idle · nothing assigned · 0 assigned · 0 proven · 0 paid · 0.0000 IGN"
function proveLine(pv, enabled, synced) {
@ -525,10 +552,10 @@ var View = (function () {
function jobsNote(j, now, jobTitle) {
var parts = [];
if (j.account) parts.push(j.account);
if (!j.allowed) parts.push('Off: nothing runs here until the switch above allows remote jobs.');
else if (!j.url_set) parts.push('No jobs address in this build.');
if (!j.allowed) parts.push('Off. Nothing runs here until the switch above allows jobs from the team.');
else if (!j.url_set) parts.push('This build has no jobs address.');
else if (j.error) parts.push(j.error + '.');
else if (j.active) parts.push('Running ' + jobTitle(j) + ' (' + j.id + ').');
else if (j.active) parts.push('Running: ' + jobTitle(j) + '.');
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + ((j.history || []).length ? '; ' + j.history.length + ' job' + (j.history.length === 1 ? '' : 's') + ' ran here' : '') + '.');
return parts.join(' ');
}
@ -539,10 +566,213 @@ var View = (function () {
if (text.length > max) { var cut = text.lastIndexOf(' ', max - 2); text = text.slice(0, cut > 40 ? cut : max - 2) + '…'; }
return text;
}
// the engine's event line in a user's voice (src/engine.rs event strings, 6 October 2026): {text, tech}; tech is
// the engine's own line when it differs, for the tooltip. Unknown lines keep their words with a JSON tail cut.
var EVENT_MAP = [
[/^updated to Igneum Miner (\S+) from (\S+)/, function (m) { return 'Updated to ' + m[1]; }],
[/^Igneum Miner (\S+) is available: downloading/, function (m) { return 'Downloading ' + m[1]; }],
[/^Igneum Miner (\S+) is ready/, function (m) { return m[1] + ' is ready to install'; }],
[/^installing Igneum Miner (\S+)/, function (m) { return 'Installing ' + m[1]; }],
[/^update to (\S+) failed/, function (m) { return 'The update to ' + m[1] + ' failed'; }],
[/^the update to (\S+) did not take/, function (m) { return 'The update to ' + m[1] + ' did not take'; }],
[/^Igneum Miner (\S+) did not stay up twice; restoring (\S+)/, function (m) { return m[1] + ' did not stay up; going back to ' + m[2]; }],
[/^update downloaded/, function () { return 'Update downloaded'; }],
[/^OTA: administrator approval not given/, function () { return 'The update waits for the next time someone is at this PC'; }],
[/^job (\S+) \((.+)\) starts: /, function (m) { return 'A job from the team started: ' + Notices.jobWord(m[2], m[1]); }],
[/^job (\S+) \((.+)\) (done|failed|timeout|aborted) after (\d+) min/, function (m) { return 'A job from the team ' + (m[3] === 'done' ? 'ran' : m[3] === 'timeout' ? 'ran out of time' : m[3] === 'aborted' ? 'was stopped' : 'failed') + ': ' + Notices.jobWord('', m[1]) + ', ' + m[4] + ' min'; }],
[/^(\d+) remote jobs? received from Igneum/, function (m) { return m[1] + ' job' + (m[1] === '1' ? '' : 's') + ' from the team received'; }],
[/^remote job: update check now/, function () { return 'The team asked for an update check'; }],
[/^remote job: the app restarts/, function () { return 'The team asked the app to restart'; }],
[/^remote job asked for a node restart, but the node is external/, function () { return 'The team asked for a node restart. This node is not the app’s, so nothing was done'; }],
[/^remote jobs allowed/, function () { return 'Jobs from the team can run here'; }],
[/^remote jobs switched off/, function () { return 'Jobs from the team are off'; }],
[/^job finished; the miners restart/, function () { return 'The job finished; mining restarts'; }],
[/^node synced: (\d+) blocks, (\d+) peer/, function (m) { return 'Node synced: ' + withCommas(+m[1]) + ' blocks, ' + m[2] + ' peer' + (m[2] === '1' ? '' : 's'); }],
[/^a node already answers on/, function () { return 'Using the node already running on this machine'; }],
[/^consensus parameters from the signed manifest/, function () { return 'Rule settings read from the team'; }],
[/^this node build does not understand the consensus parameters/, function () { return 'This node is too old for the team’s rule settings. An app update carries the newer node'; }],
[/^restarting the node with the new consensus parameters/, function () { return 'Restarting the node with the new rule settings'; }],
[/^kernel tuning from the signed manifest/, function () { return 'Tuning starting points read from the team'; }],
[/^block accepted by the node \((.+)\)/, function (m) { return 'Block found by ' + m[1]; }],
[/^igneumd exited with code (\d+) after (\d+) s; restarting/, function (m) { return 'The node stopped (code ' + m[1] + '). Restarting'; }],
[/^igneumd exited at once \(code (\d+)\)/, function (m) { return 'The node stopped at once (code ' + m[1] + ')'; }],
[/^igneumd could not start/, function () { return 'The node could not start'; }],
[/^miner (\S+) exited with code (\d+); restarting in (\d+) s/, function (m) { return 'A card stopped (code ' + m[2] + '). Restarting in ' + m[3] + ' s'; }],
[/^no reading from the node for (\d+) s/, function (m) { return 'No reading from the node for ' + m[1] + ' s. Restarting it'; }],
[/^no peer after 90 s/, function () { return 'No other node found after 90 s. Still looking'; }],
[/^no payout address/, function () { return 'No rewards address yet: finish the setup'; }],
[/^mining paused; the node keeps running/, function () { return 'Mining paused. The node keeps running'; }],
[/^mining resumed/, function () { return 'Mining resumed'; }],
[/^GPU power cap in force: (.+)/, function (m) { return 'Power cap in force: ' + m[1]; }],
[/^GPU power cap NOT applied/, function () { return 'Power cap not set. The administrator prompt was refused (Retry on the card)'; }],
[/^power control on:/, function () { return 'Power control on'; }],
[/^rewards go to (\S+)/, function (m) { return 'Rewards go to ' + shortHex(m[1], 6, 4); }],
[/^settings changed \((.+)\); the miners restart/, function (m) { return 'Settings changed (' + m[1] + '). Mining restarts'; }],
[/^(.+): tune queued; it starts once/, function (m) { return m[1] + ': tune queued'; }],
[/^(.+): tuning started: /, function (m) { return m[1] + ': tuning started'; }],
[/^(.+): best point (.+?) \((.+?)\); your setting stays pinned/, function (m) { return m[1] + ': best point ' + m[2] + ' (' + m[3] + '). Your cap stays pinned'; }],
[/^(.+): measured as it runs: (.+)/, function (m) { return m[1] + ': measured ' + m[2]; }],
[/^(.+): no tune: (.+)/, function (m) { return m[1] + ': no tune (' + m[2] + ')'; }],
[/^(.+): worker fault: (.+); the miner restarts the worker/, function (m) { return m[1] + ': an error. Restarting it'; }],
[/^(.+): the worker could not compile the next program/, function (m) { return m[1] + ': the next program is built at the top of the hour instead'; }],
[/^(.+) worker ready/, function (m) { return m[1] + ': ready'; }],
[/^(.+) kernel race: (.+?) at ([\d.]+) MH\/s/, function (m) { return m[1] + ': fastest program chosen, ' + m[3] + ' MH/s'; }],
[/^(.+): switched on, (\d+) identit/, function (m) { return m[1] + ': on'; }],
[/^(.+): switched off/, function (m) { return m[1] + ': off'; }],
[/^(.+): (\d+) identit(y|ies); its worker restarts/, function (m) { return m[1] + ': ' + m[2] + ' miner' + (m[2] === '1' ? '' : 's') + ' on it. The card restarts'; }],
[/^next hourly program compiled and ready/, function () { return 'Next hourly program ready'; }],
[/^the hourly program changed; the worker restarts/, function () { return 'New hourly program. The card restarts on it'; }],
[/^hourly program changed: /, function () { return 'New hourly program'; }],
[/^program pack out of date, rebuilding/, function () { return 'Rebuilding the mining programs'; }],
[/^GPU worker built from source/, function () { return 'Mining program built'; }],
[/^block (\d+) shard (\d+) paid (.+) IGN/, function (m) { return 'Proof paid: ' + m[3] + ' IGN'; }],
[/^block (\d+) shard (\d+) proven and submitted in (\d+) s/, function (m) { return 'Proof sent in ' + m[3] + ' s'; }],
[/^segment .+ paid (.+) IGN to the aggregator/, function (m) { return 'Segment paid: ' + m[1] + ' IGN'; }],
[/^segment (\S+)\.\.(\S+) aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven in ' + m[3] + ' s'; }],
[/^segment (\S+)\.\.(\S+): (\d+) shards proven, aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven: ' + m[3] + ' shards in ' + m[4] + ' s'; }],
[/^segment (\S+)\.\.(\S+): (\d+) shards proven and submitted in (\d+) s; the segment record is held/, function (m) { return 'Segment proven: ' + m[3] + ' shards in ' + m[4] + ' s. Its record waits for the chain'; }],
[/^segment (\S+)\.\.(\S+) record accepted on retry/, function () { return 'Segment record accepted'; }],
[/^WSL2 prover setup started/, function () { return 'Prover setup started in its own window'; }],
[/^Card removed: (.+); its worker stopped/, function (m) { return 'Card removed: ' + m[1]; }],
[/^(.+): not usable \((.+?)\); its worker stopped/, function (m) { return m[1] + ': not usable (' + m[2] + ')'; }],
[/^clock agrees with the network again/, function () { return 'Clock agrees with the network again'; }],
[/^asking the system to set its clock/, function () { return 'Setting the clock from a time server'; }],
[/^clock: (.+)/, function (m) { return 'Clock: ' + m[1]; }],
[/^devnet only: the node trusts proof records/, function () { return 'Devnet trust mode: proofs are not checked (Settings)'; }],
[/^a sweep is setting the caps right now/, function () { return 'A tune is setting the caps now. Retry when it is done'; }],
[/^updates are off for this engine/, function () { return 'Updates are off for this copy (a measurement run)'; }],
[/^(.+); the node restarts$/, function (m) { return cap(m[1]) + '; the node restarts'; }],
[/^(.+); the node is external, so the app cannot restart it/, function (m) { return cap(m[1]) + '; the node is not the app’s, so it is not restarted'; }]
];
function plainEvent(text) {
text = String(text || '');
for (var i = 0; i < EVENT_MAP.length; i++) { var m = EVENT_MAP[i][0].exec(text); if (m) { var out = EVENT_MAP[i][1](m); return { text: out, tech: out === text ? '' : text }; } }
var cut = eventLine(text);
return { text: cap(cut), tech: cut === text ? '' : text };
}
// the pill in the top bar: a verb a user understands, and its tone (on = molten, bad = ember, '' = grey)
function pill(s) {
if (s.quitting) return { text: 'Stopping', tone: '' };
if (!s.setup_done) return { text: s.detecting ? 'Detecting cards' : 'Setting up', tone: '' };
var m = s.mining || {}, n = s.node || {};
if (tuningCard(m.cards)) return { text: 'Tuning', tone: 'on' };
if (m.state === 'mining') return { text: 'Mining · ' + (m.hash_total >= 100 ? m.hash_total.toFixed(0) : (m.hash_total || 0).toFixed(1)) + ' MH/s', tone: 'on' };
if (m.state === 'paused') return { text: 'Paused', tone: '' };
if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' };
if (n.state === 'starting') return { text: 'Starting the node', tone: '' };
if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' };
if (n.state === 'behind') return { text: 'Node behind', tone: 'bad' };
if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' };
if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' };
if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' };
if (n.state === 'stub') return { text: 'Node fault', tone: 'bad' };
if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' };
if (m.state === 'idle') return { text: 'Not mining', tone: '' };
return { text: cap(m.state || 'starting'), tone: '' };
}
// ---- Ember on the card row (miner-ui-4, 6 October 2026): the flame mark's fill, the before -> after watts, the saving,
// the sparkline points and the plain label. cd = the card, s = state, now = unix s ----
// the "before" point (Ember, 6 October 2026): card.tune_before_watts / tune_before_mhs, step 0 of the last FULL plan,
// kept across confirm plans; until a build carries them, the full plan's step 0 in tune_curve (unlocked, 100%); a
// confirm or baseline plan without the fields has no before, so no saving is claimed
function beforeOf(cd) {
if (cd.tune_before_watts > 0) return { watts: cd.tune_before_watts, mhs: cd.tune_before_mhs || 0 };
if (cd.tune_before_watts !== undefined) return null;
var r = (cd.tune_curve || [])[0];
if (cd.tune_source === 'full' && r && r.watts > 0 && (r.power_pct || 0) >= 100 && !(r.clock_mhz > 0)) return { watts: r.watts, mhs: r.mhs || 0 };
return null;
}
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points}
function ember(cd, s, now) {
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [] };
if (tw.kind === 'none') return out;
var st = (s && s.settings) || {};
out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, '');
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; });
if (cd.tune_floor) out.floor = true;
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; }
if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; }
if (tw.kind === 'stopped') { out.label = tw.text; return out; }
if (tw.kind === 'measured') { out.fill = 0.5; out.label = 'Measured, not tuned'; out.sub = /Power control/i.test(cd.sweep_note || '') ? 'Power control is off' : cd.vendor === 'apple' ? 'Apple silicon sets its own power' : (tw.note || '').replace(/^Measured only /, ''); return out; }
if (tw.kind === 'tuned') {
out.fill = 1; var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
out.label = 'Tuned' + (when ? ' ' + when : '') + (cd.tune_source === 'confirm' ? ' from what other miners found' : '');
out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.next ? ' · next check ' + out.next : '');
var b = beforeOf(cd); out.after = cd.sweep_watts > 0 ? cd.sweep_watts : 0; out.before = b ? b.watts : 0;
if (out.before > 0 && out.after > 0 && out.before > out.after) {
out.savingW = Math.round(out.before - out.after);
out.savingPct = b.mhs > 0 && cd.sweep_mhs > 0 ? Math.max(0, (b.mhs - cd.sweep_mhs) / b.mhs * 100) : 0;
out.savingText = 'saves ' + out.savingW + ' W' + (b.mhs > 0 ? ', ' + (out.savingPct < 0.05 ? 'no' : out.savingPct.toFixed(2) + '% of') + ' rate' : '');
}
return out;
}
out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = st.sweep === false ? 'Tune starts one by hand' : 'starts after 2 min of steady mining'; return out;
}
// the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards
function fleetSaving(cards, s, now, pence) {
var w = 0, n = 0;
(cards || []).forEach(function (c) { if (!present(c)) return; var e = ember(c, s, now); if (e.kind === 'tuned' && e.savingW > 0) { w += e.savingW; n++; } });
if (!n) return '';
var ppd = poundsPerDay(w, pence);
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's');
}
// ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei
// (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign
// (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ----
function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; }
// the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read
function balanceWords(a, now) {
a = a || {};
if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null };
if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null };
var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null };
var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : '';
var price = a.price_gbp_per_ign;
return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null };
}
// the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums
function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); }
function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); }
// is a lane key this machine's? The cards' ids are the first 8 hex of each vote key hash; a source may shorten the
// hash to another length (the 0.3.16 engine keeps 12), so either prefix of the other counts
function isMine(miner, cards) {
var m = String(miner || '').replace(/^0x/, '').toLowerCase(); if (!m) return false;
var head = m.split('..')[0]; // the 0.3.16 engine shortens a long hash to "head6..tail6"; the site keeps 8 hex
return (cards || []).some(function (c) { return (c.ids || []).some(function (id) {
id = String(id).replace(/^0x/, '').toLowerCase(); if (!id) return false;
var n = Math.min(head.length, id.length); return n >= 6 && head.slice(0, n) === id.slice(0, n);
}); });
}
// the chain scene's note from the reply's source
function liveNote(reply) {
if (!reply) return '';
if (reply.pending) return 'connecting';
var src = reply.source || ((reply.state || {}).source);
if (src === 'node') return 'From your node. Your blocks are ringed.';
if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.';
return '';
}
// finality (ember-tune 1357d280, folded in for 0.3.17): f = state.finality. While the network's finality is paused
// (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a
// lock "final" (main, Q83/Q84). {paused, age, note}
function finalityWords(f) {
f = f || {};
if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' };
if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' };
return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' };
}
// whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | ""
function nodeSource(n) {
n = n || {};
if (n.source === 'app') return { text: 'this app’s own node', sub: n.mode_reason || n.port_note || '' };
if (n.source === 'external') return { text: 'another node on this machine', sub: n.rules_check === 'match' ? 'same network and rules, checked' : n.rules_check === 'unknown' ? 'its rules could not be checked (it predates 0.3.17)' : (n.port_note || '') };
if (n.source === 'none') return { text: 'no node', sub: n.port_note || n.mode_reason || 'the ports are taken by a node on other rules' };
return { text: '', sub: '' };
}
// the confirmations' words (in place, never a dialog)
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? The miner restarts.'; }
function trustAsk(on) { return on ? 'Include proof records the node never checked? Devnet only; the node restarts.' : 'Verify proof records again? The node restarts.'; }
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, goalWords: goalWords, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, addressAsk: addressAsk, trustAsk: trustAsk };
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -585,7 +815,7 @@ if (typeof document !== 'undefined') (function () {
var state = null, phase = 'welcome', page = 'mine', forced = null, cardsSeen = false, keyShownThisRun = false;
var params = new URLSearchParams(location.search);
if (params.get('screen')) forced = params.get('screen');
if (params.get('page')) page = params.get('page'); // ?page=mine|earnings|prove|settings (screenshots)
if (params.get('page')) page = params.get('page'); // ?page=overview|cards|earnings|prove|settings (screenshots)
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
@ -659,7 +889,7 @@ if (typeof document !== 'undefined') (function () {
$('page-title').hidden = !dash;
if (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
if (dash) showPage(page);
if (dash) { showPage(page); mountDag(); }
api('api/phase', { phase: p });
}
function showPage(id) {
@ -674,7 +904,7 @@ if (typeof document !== 'undefined') (function () {
});
setText('page-title-text', meta.title); setText('page-sub', meta.sub);
$('main').scrollTop = 0;
if (state) { renderPage(state); if (page === 'mine') size(); }
if (state) { renderPage(state); if (page === 'overview') size(); }
}
$('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); });
// the rail answers the arrow keys and Home/End, so the sections are reachable without a mouse
@ -713,7 +943,7 @@ if (typeof document !== 'undefined') (function () {
if (!r.ok) { $('addr-err').textContent = r.error || 'could not save'; $('addr-err').hidden = false; return; }
if (mode === 'generate' && r.private_key) { showKeySheet(r); }
else { api('api/start', {}).then(function () { show('dashboard'); }); }
}).catch(function () { $('btn-start').disabled = false; toast('The engine did not answer'); });
}).catch(function () { $('btn-start').disabled = false; toast('The app did not answer'); });
});
function showKeySheet(r) {
$('key-address').textContent = r.display || r.address;
@ -743,7 +973,7 @@ if (typeof document !== 'undefined') (function () {
if (!state) return;
var t = View.toggle(state.mining, state.node, state);
if (!t.act) return;
api('api/' + t.act, {}).then(function () { toast(t.act === 'pause' ? 'Mining stops; the node keeps running' : 'Mining starts'); });
api('api/' + t.act, {}).then(function () { toast(t.act === 'pause' ? 'Mining stops. The node keeps running' : 'Mining starts'); });
});
// a row's switch, slider or stepper sends the whole list to the engine (pending[key] holds a flipped switch until
// the engine agrees, so the row does not flicker back)
@ -752,7 +982,7 @@ if (typeof document !== 'undefined') (function () {
clearTimeout(cardsT);
cardsT = setTimeout(function () {
var choices = readCardRows($('d-cards'));
if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied; only the changed worker restarts'); else toast(r.error || 'could not change'); });
if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied. Only that card restarts'); else toast(r.error || 'could not change'); });
}, 400);
}
$('d-cards').addEventListener('change', function (e) {
@ -761,14 +991,14 @@ if (typeof document !== 'undefined') (function () {
var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked;
pending[key] = { on: on, at: Date.now() };
var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; });
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } });
} else if (e.target.matches('.ids input')) sendCards('Identities set; that card’s worker restarts');
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Starting that card' : 'That card is off'); else { delete pending[key]; toast(r.error || 'could not change'); } });
} else if (e.target.matches('.ids input')) sendCards('Set. The card restarts');
});
$('d-cards').addEventListener('input', function (e) {
if (e.target.matches('input[type=range]')) {
var row = e.target.closest('.gpu-line'), cd = cardOf(row.dataset.key), pct = parseInt(e.target.value, 10);
row.querySelector('.pv').textContent = pct + '% · ' + (cd && cd.power_default_w ? Math.round(cd.power_default_w * pct / 100) + ' W' : '');
sendCards('Power cap set; pinned until you unpin it');
sendCards('Power cap set. It stays until you choose Default again');
}
});
$('d-cards').addEventListener('click', function (e) {
@ -776,11 +1006,14 @@ if (typeof document !== 'undefined') (function () {
var row = b.closest('.gpu-line'), key = row ? row.dataset.key : '';
if (b.dataset.open !== undefined) { openCards[key] = !openCards[key]; applyOpen(row); }
else if (b.dataset.tune !== undefined) api('api/sweep/start', { key: key }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the tune') : 'Tune queued for ' + (cardOf(key) || {}).name); });
else if (b.dataset.stop !== undefined) api('api/sweep/stop', {}).then(function () { toast('Tune stopped; the card is back where it was'); });
else if (b.dataset.stop !== undefined) api('api/sweep/stop', {}).then(function () { toast('Tune stopped. The card is back where it was'); });
else if (b.dataset.d) { var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; inp.value = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); sendCards('Identities set; that card’s worker restarts'); }
else if (b.dataset.pin !== undefined) api('api/sweep/pin', { key: key, pinned: b.dataset.pin === '1' }).then(function () { toast(b.dataset.pin === '1' ? 'Cap pinned' : 'The tune chooses the cap again'); });
else if (b.dataset.powerRetry !== undefined) api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
else if (b.dataset.pin !== undefined) api('api/sweep/pin', { key: key, pinned: b.dataset.pin === '1' }).then(function () { toast(b.dataset.pin === '1' ? 'Cap pinned' : 'Default: Ember chooses the cap again'); });
else if (b.dataset.powerRetry !== undefined) api('api/power/apply', {}).then(function () { toast('Allow the administrator prompt to set the cap'); });
else if (b.dataset.price !== undefined) { showPage('settings'); $('s-price').focus(); }
else if (b.closest('[data-card-goal]') && b.dataset.goal !== undefined) {
api('api/tune/goal', { key: key, goal: b.dataset.goal || '' }).then(function (r) { if (r && r.ok) toast(b.dataset.goal ? 'Goal for this card: ' + View.GOALS[b.dataset.goal] : 'Default: this card follows the goal set for all cards'); else toast('A goal for one card needs the next Ember Tune update'); }).catch(function () { toast('A per-card goal needs the Ember Tune update'); });
}
});
function applyOpen(row) { var open = !!openCards[row.dataset.key], det = row.querySelector('.gl-details'), chev = row.querySelector('[data-open]'); if (det) det.hidden = !open; if (chev) { chev.setAttribute('aria-expanded', open ? 'true' : 'false'); chev.classList.toggle('open', open); } row.classList.toggle('open', open); }
function cardOf(key) { return state ? state.mining.cards.filter(function (c) { return c.key === key; })[0] : null; }
@ -789,21 +1022,21 @@ if (typeof document !== 'undefined') (function () {
var list = shownCards(state.mining.cards).filter(function (c) { return View.tunable(c) && c.enabled; });
if (!list.length) { toast('No card to tune'); return; }
var n = 0;
list.reduce(function (p, c) { return p.then(function () { return api('api/sweep/start', { key: c.key }).then(function (r) { if (!r || r.ok !== false) n++; }); }); }, Promise.resolve()).then(function () { toast(n === 1 ? 'Tune queued for 1 card' : 'Tune queued for ' + n + ' cards, one at a time'); });
list.reduce(function (p, c) { return p.then(function () { return api('api/sweep/start', { key: c.key }).then(function (r) { if (!r || r.ok !== false) n++; }); }); }, Promise.resolve()).then(function () { toast(n === 1 ? 'Tune queued for 1 card' : 'Tune queued for ' + n + ' cards, one after another'); });
});
// the goal (ember-tune: POST api/tune/goal); a build without the route says so and keeps the old value
function setGoal(goal) {
api('api/tune/goal', { goal: goal }).then(function (r) { if (r && r.ok) { store('igneum.goal', goal); toast('Goal: ' + View.GOALS[goal]); } else toast('The goal needs the Ember Tune update'); if (state) renderTuneCard(state); }).catch(function () { toast('The goal needs the Ember Tune update'); });
api('api/tune/goal', { goal: goal }).then(function (r) { if (r && r.ok) { store('igneum.goal', goal); toast('Goal: ' + View.GOALS[goal]); } else toast('The goal needs the next Ember Tune update'); if (state) renderTuneCard(state); }).catch(function () { toast('The goal needs the Ember Tune update'); });
}
['goal-seg', 'goal-seg-2'].forEach(function (id) { $(id).addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) setGoal(b.dataset.goal); }); });
function powerControl(on) { api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off; nothing asks'); }); }
function powerControl(on) { api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on. Windows asks for administrator rights once' : 'Power control off. Nothing asks'); }); }
$('m-power-control').addEventListener('change', function () { powerControl(this.checked); });
$('s-power-control').addEventListener('change', function () { powerControl(this.checked); });
$('m-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
$('btn-open-log').addEventListener('click', function () { setDrawer(true); });
// ---------- Earnings ----------
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied. Mining restarts' : 'Applied'); }); });
$('e-price-btn').addEventListener('click', function () { showPage('settings'); $('s-price').focus(); });
$('t-money-cell').addEventListener('click', function () { if ($('t-money').classList.contains('ask-price')) { showPage('settings'); $('s-price').focus(); } });
$('s-address-save').addEventListener('click', function () {
@ -826,7 +1059,7 @@ if (typeof document !== 'undefined') (function () {
$('r-wallet').addEventListener('click', function () { api('api/open', { url: 'https://igneum.network/wallet' }); });
// ---------- Prove ----------
$('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on; the first piece arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); });
$('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on. The first shard arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
// ---------- Settings ----------
@ -838,11 +1071,11 @@ if (typeof document !== 'undefined') (function () {
$('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on: once after install, then every 7 days' : 'Ember Tune off'); }); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on. Once after install, then every 7 days' : 'Ember Tune off'); }); });
$('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); });
function savePrice() {
var p = parseFloat($('s-price').value);
if (!(p >= 0)) { toast('A price in pence per kWh, for example 28'); return; }
if (!(p >= 0)) { toast('Type a price in pence per kWh, for example 28'); return; }
store('igneum.price', p > 0 ? p : null);
api('api/tune/goal', { price_pence: p }).then(function () { toast(p > 0 ? 'Price saved: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); });
}
@ -852,7 +1085,7 @@ if (typeof document !== 'undefined') (function () {
$('ask-trust-no').addEventListener('click', function () { $('ask-trust').hidden = true; });
$('ask-trust-yes').addEventListener('click', function () {
var on = $('ask-trust').dataset.on === '1'; $('ask-trust').hidden = true;
api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) { $('s-trust').checked = on; toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); } else toast(r.error || 'could not change'); });
api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) { $('s-trust').checked = on; toast(on ? 'Trust mode on (devnet only). The node restarts' : 'Trust mode off. The node restarts'); } else toast(r.error || 'could not change'); });
});
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('s-log-open').addEventListener('click', function () { setDrawer(true); });
@ -1121,7 +1354,7 @@ if (typeof document !== 'undefined') (function () {
if (found.length && Object.keys(born).length > found.length + 50) { var keep = {}; for (var k = 0; k < found.length; k++) if (born[String(found[k])]) keep[String(found[k])] = born[String(found[k])]; born = keep; }
if (!shown) {
x.fillStyle = css('--ash') || '#9A9A9E'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center';
var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: a block this machine finds appears here' : state.mining.state === 'paused' ? 'paused: no blocks while mining is stopped' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync';
var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'Mining. A block this machine finds appears here' : state.mining.state === 'paused' ? 'Paused. No blocks while mining is stopped' : state.node.synced ? 'waiting for the card' : 'waiting for the node to sync';
x.fillText(msg, W / 2, rail - 10);
}
return animating && !reduce;
@ -1131,7 +1364,7 @@ if (typeof document !== 'undefined') (function () {
drawRaf = 0; clearTimeout(drawTimer); drawTimer = null;
if (hiddenNow()) return;
var fast = false;
if (phase === 'dashboard' && page === 'mine') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
if (phase === 'dashboard' && page === 'overview' && !$('dag').hidden) { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
if (fast) drawRaf = requestAnimationFrame(frame);
else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500);
}
@ -1143,6 +1376,21 @@ if (typeof document !== 'undefined') (function () {
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); });
requestDraw();
// ---------- the chain scene (live-dag.js, the site's module, copied in): the engine's own api/live, this machine's
// lane as "you"; until the engine serves it (owed) the module reports failed and the blocks strip draws instead ----------
var dag = null, dagMode = '';
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
try {
var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); };
dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) {
var live = st === 'live' || st === 'stale';
if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); }
$('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live;
if (!live) { size(); requestDraw(); }
} });
} catch (e) { dag = null; $('dag-live').hidden = true; $('dag').hidden = false; size(); }
}
// ---------- render ----------
function badgeHtml(cd) {
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
@ -1192,7 +1440,7 @@ if (typeof document !== 'undefined') (function () {
var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|');
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
if (!cards.length) {
$('cards-sub').textContent = 'No graphics card this app can drive was found.';
$('cards-sub').textContent = 'No graphics card this app can use was found.';
$('cards-note').textContent = (s.detect_message || '') + ' The node can still run on its own; mining needs a supported card.';
$('cards-note').hidden = false; $('cards-help').hidden = true; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining';
return;
@ -1229,13 +1477,16 @@ if (typeof document !== 'undefined') (function () {
if (cells.money) nums += numCell('money', cells.money, '', 'a day', '');
else if (cells.needsPrice) nums += '<div class="num money"><button class="btn tiny ghost" data-price="1">Set a price</button><span class="k">£ a day</span></div>';
if (r.temp) nums += numCell('temp', r.temp.replace(' °C', ''), '°C', 'GPU', r.tempTone);
var tuneBtn = tw.button === 'stop' ? '<button class="btn small" data-stop="1">Stop</button>' : tw.button === 'tune' ? '<button class="btn small ghost" data-tune="1">Tune</button>' : '';
var em = View.ember(cd, s, s.now);
if (em.kind === 'tuned' && em.before > 0 && em.after > 0 && cells.power) nums = nums.replace(numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, ''), numCell('power', Math.round(em.before) + ' → ' + cells.power.replace(' W', ''), 'W', cells.eff, ''));
if (em.points.length > 1 && cells.power) nums = nums.replace('<span class="k">' + esc(cells.eff) + '</span>', '<span class="k spark">' + esc(cells.eff) + sparkHtml(em.points) + '</span>');
var tuneBtn = tw.button === 'stop' ? '<button class="btn small" data-stop="1">Stop</button>' : tw.button === 'tune' ? '<button class="btn small ghost" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune') + '</button>' : '';
var sw = '<label class="switch" title="' + (r.on ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(r.name) + '"' + (r.on ? ' checked' : '') + (r.canToggle ? '' : ' disabled') + '><span class="track"></span></label>';
var chev = '<button class="chev-btn" data-open="1" aria-expanded="false" aria-label="Details for ' + esc(r.name) + '"><span class="chev"></span></button>';
var subs = [];
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>');
var tune = tw.kind === 'none' ? '' : '<div class="gl-tune ' + tw.kind + '"><span class="t">' + esc(tw.text) + '</span>' + (tw.note ? '<span class="n dim">' + esc(tw.note) + '</span>' : '') + (tw.progress >= 0 ? '<i class="bar"><b style="width:' + Math.round(tw.progress * 100) + '%"></b></i>' : '') + '</div>';
var tune = em.kind === 'none' ? '' : '<div class="gl-tune ' + em.kind + '">' + flameHtml(em.fill, em.kind) + '<span class="t">' + esc(em.label) + '</span>' + (em.sub ? '<span class="n dim">' + esc(em.sub) + '</span>' : '') + (em.savingText ? '<span class="n save">' + esc(em.savingText) + '</span>' : '') + (em.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round(em.fill * 100) + '%"></b></i>' : '') + '</div>';
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
'<div class="gl-main"><div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' +
'<div class="who"><div class="name" title="' + esc(r.title) + '">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
@ -1244,17 +1495,46 @@ if (typeof document !== 'undefined') (function () {
'<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + tune +
'<div class="gl-details" hidden>' + detailsHtml(cd, s) + '</div></div>';
}
// the flame mark (the brand mark's two polygons) filled to a fraction: outline when not tuned, filling while tuning, solid when tuned
var flameN = 0;
function flameHtml(fill, kind) {
var id = 'fl' + (++flameN), h = Math.round(100 * (1 - Math.max(0, Math.min(1, fill))));
return '<svg class="flame ' + kind + '" viewBox="0 0 100 100" width="16" height="16" aria-hidden="true"><defs><clipPath id="' + id + '"><rect x="0" y="' + h + '" width="100" height="' + (100 - h) + '"/></clipPath></defs>' +
'<path class="fo" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/>' +
'<path class="ff" clip-path="url(#' + id + ')" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/></svg>';
}
// a tiny sparkline of the tune's ladder: MH/W per step, the chosen step as a dot
function sparkHtml(points) {
var effs = points.map(function (p) { return p.eff; }), lo = Math.min.apply(null, effs), hi = Math.max.apply(null, effs), n = points.length, W = 44, H = 12;
var pts = points.map(function (p, i) { var x = n > 1 ? i / (n - 1) * (W - 2) + 1 : W / 2, y = hi > lo ? H - 1 - (p.eff - lo) / (hi - lo) * (H - 2) : H / 2; return { x: x, y: y, c: p.chosen, m: p.marked }; });
var d = pts.map(function (p, i) { return (i ? 'L' : 'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1); }).join(' ');
var dot = pts.filter(function (p) { return p.c; })[0];
return '<svg class="spark" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '" aria-hidden="true"><path d="' + d + '"/>' + (dot ? '<circle cx="' + dot.x.toFixed(1) + '" cy="' + dot.y.toFixed(1) + '" r="2"/>' : '') + '</svg>';
}
// the tune's curve behind the chevron: W across, MH/s up, every step a dot, the chosen one ringed, a marked one hollow
function curveHtml(points) {
if (points.length < 2) return '';
var ws = points.map(function (p) { return p.w; }), ms = points.map(function (p) { return p.mhs; });
var w0 = Math.min.apply(null, ws), w1 = Math.max.apply(null, ws), m0 = Math.min.apply(null, ms), m1 = Math.max.apply(null, ms), W = 320, H = 110, L = 34, B = 18;
var X = function (w) { return L + (w1 > w0 ? (w - w0) / (w1 - w0) : 0.5) * (W - L - 8); }, Y = function (m) { return 6 + (m1 > m0 ? 1 - (m - m0) / (m1 - m0) : 0.5) * (H - B - 6); };
var sorted = points.slice().sort(function (a, b) { return a.w - b.w; });
var path = sorted.map(function (p, i) { return (i ? 'L' : 'M') + X(p.w).toFixed(1) + ' ' + Y(p.mhs).toFixed(1); }).join(' ');
var dots = sorted.map(function (p) { return '<circle class="' + (p.chosen ? 'chosen' : p.marked ? 'marked' : '') + '" cx="' + X(p.w).toFixed(1) + '" cy="' + Y(p.mhs).toFixed(1) + '" r="' + (p.chosen ? 5 : 3) + '"/>'; }).join('');
return '<div class="curve-wrap"><svg class="curve-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="The tune\'s curve: watts across, hash rate up"><path class="ln" d="' + path + '"/>' + dots +
'<text x="' + L + '" y="' + (H - 4) + '">' + Math.round(w0) + ' W</text><text x="' + (W - 8) + '" y="' + (H - 4) + '" text-anchor="end">' + Math.round(w1) + ' W</text>' +
'<text x="2" y="' + (H - B - 2) + '">' + m0.toFixed(0) + '</text><text x="2" y="12">' + m1.toFixed(0) + '</text></svg><span class="note">Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.</span></div>';
}
// the card's details (one chevron): the cap slider, identities, pin, the telemetry, the facts, the last tune's table
function detailsHtml(cd, s) {
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, h = '';
if (cd.removed_at > 0) return '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p>';
if (cd.problem) return '<p class="help">' + esc(cd.reason || 'No worker can drive this card.') + ' Reboot with the card attached; if it persists, reinstall the driver with the card attached.</p>';
if (cd.removed_at > 0) return '<p class="help">The card was unplugged. Its row goes in five minutes.</p>';
if (cd.problem) return '<p class="help">' + esc(cd.reason || 'This app cannot use this card.') + ' Reboot with the card attached; if it persists, reinstall the driver with the card attached.</p>';
if (nv) {
var want = Math.round(cd.power_default_w * pct / 100);
h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv mono">' + pct + '% · ' + want + ' W</span>' +
'<p class="help">Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the tune leaves it. <button class="btn tiny ghost" data-pin="0">Unpin</button>' : 'Ember Tune may move it.') + '</p></div>';
h += cd.power_applied ? '<div class="line ok">cap applied: <b>' + want + ' W</b>' + (cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">chosen by the tune</span>' : '') + '</div>' : '<div class="line hot">cap not applied: Windows needs the administrator prompt <button class="btn tiny" data-power-retry="1">Retry</button></div>';
} else if (cd.vendor === 'apple') h += '<p class="help">Apple silicon manages its own power; there is no cap to set.</p>';
'<p class="help">Lower is cooler and quieter. Most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so Ember leaves it. <button class="btn tiny ghost" data-pin="0">Back to Default</button>' : 'Default: Ember may move it.') + '</p></div>';
h += cd.power_applied ? '<div class="line ok">Cap in force: <b>' + want + ' W</b>' + (cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">Ember’s choice</span>' : '') + '</div>' : '<div class="line hot">Cap not set yet. Windows needs the administrator prompt. <button class="btn tiny" data-power-retry="1">Retry</button></div>';
} else if (cd.vendor === 'apple') h += '<p class="help">Apple silicon manages its own power. There is no cap to set.</p>';
var tl = [];
if (cd.temp_mem > 0) tl.push('memory <b>' + Math.round(cd.temp_mem) + ' °C</b>');
if (cd.gclk_mhz > 0) tl.push('core clock <b>' + Math.round(cd.gclk_mhz) + ' MHz</b>' + (cd.clock_cap_mhz > 0 ? ' (cap ' + cd.clock_cap_mhz + ')' : ''));
@ -1262,13 +1542,15 @@ if (typeof document !== 'undefined') (function () {
if (cd.fan_pct > 0) tl.push('fan <b>' + Math.round(cd.fan_pct) + '%</b>');
if (cd.util_pct > 0) tl.push('busy <b>' + Math.round(cd.util_pct) + '%</b>');
if (tl.length) h += '<div class="line">' + tl.join('<span class="sep"></span>') + '</div>';
var tm = TuneLine.model(cd, s.now);
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + '</div>';
if (cd.tune_curve && cd.tune_curve.length) {
h += '<table class="curve"><thead><tr><th>step</th><th>clock</th><th>power</th><th>MH/s</th><th>W</th><th>MH/W</th><th>note</th></tr></thead><tbody>' + cd.tune_curve.map(function (r, i) { return '<tr' + (r.mark ? ' class="marked"' : '') + '><td>' + (i + 1) + '</td><td>' + (r.clock_mhz ? r.clock_mhz + ' MHz' : 'unlocked') + '</td><td>' + (r.power_pct || 0) + '%</td><td>' + (r.mhs || 0).toFixed(1) + '</td><td>' + Math.round(r.watts || 0) + '</td><td>' + (r.eff || 0).toFixed(3) + '</td><td>' + esc(r.mark || '') + '</td></tr>'; }).join('') + '</tbody></table>';
var tm = TuneLine.model(cd, s.now), em = View.ember(cd, s, s.now);
if (View.tunable(cd)) {
var g = cd.tune_goal || '', goalSeg = '<div class="seg small" data-card-goal="1" role="radiogroup" aria-label="Goal for ' + esc(cd.name) + '">' + [['', 'Default', 'the goal set for all cards'], ['efficiency', 'Efficiency', 'most hashes per watt, a little rate traded'], ['balanced', 'Balanced', 'a little rate for most of the saving'], ['rate', 'Maximum', 'most hashes, watts second']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' : '') + '" data-goal="' + o[0] + '" role="radio" aria-checked="' + (g === o[0] ? 'true' : 'false') + '" title="' + o[2] + '">' + o[1] + (o[0] === '' ? ' <span class="dim">· ' + esc(View.GOAL_NAMES[goalNow()]) + '</span>' : '') + '</button>'; }).join('') + '</div>';
h += '<div class="ctl"><span class="k">Goal</span>' + goalSeg + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">' + (cd.tune_goal === undefined ? 'A goal for one card needs the next Ember Tune update. Until then every card follows the goal above.' : 'This card’s own goal. Default follows the goal set for all cards. A new goal applies from the next tune.') + '</p></div>';
}
h += '<div class="ctl"><span class="k">Identities</span><p class="help">Each identity votes and is paid on its own. 8 suits a big card, 2 a small one, 1 an integrated GPU. Changing it restarts that card’s worker.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">&minus;</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
var facts = [cd.worker ? 'worker ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean);
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + (em.savingText ? ' <span class="save">' + esc(em.savingText) + '</span>' : '') + '</div>';
h += curveHtml(em.points);
h += '<div class="ctl"><span class="k">Miners on this card</span><p class="help">How many miners this card runs as. Each one votes and is paid on its own. 8 suits a big card, 2 a small one, 1 a built-in GPU. Changing it restarts the card.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">&minus;</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
var facts = [cd.worker ? 'runs on ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean);
if (facts.length) h += '<p class="note mono small">' + esc(facts.join(' · ')) + '</p>';
return h;
}
@ -1281,10 +1563,10 @@ if (typeof document !== 'undefined') (function () {
var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length;
// the live total: rate, watts, £ a day, blocks
setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state);
var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
var ppd = View.poundsPerDay(watts, price);
var ppd = View.fleetPounds(m, m.cards, price);
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh');
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
@ -1293,12 +1575,12 @@ if (typeof document !== 'undefined') (function () {
var on = rows.filter(function (r) { return r.on; }).length;
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on');
var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; });
setHidden('btn-tune-all', !(tunables.length > 1) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT');
var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length]; })]);
var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs]; }), goalNow()]);
if (sig !== mineRowsSig && !busy) {
mineRowsSig = sig;
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can drive was found. The node runs on its own.') + '</div>';
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '</div>';
box.querySelectorAll('.gpu-line').forEach(applyOpen);
}
var note = !shown.length && s.detect_message ? s.detect_message : '';
@ -1311,7 +1593,7 @@ if (typeof document !== 'undefined') (function () {
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
if (key !== lastEventsKey) {
lastEventsKey = key;
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 8).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(View.eventLine(e.text)) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 8).map(function (e) { var pe = View.plainEvent(e.text); return '<div' + (pe.tech ? ' title="' + esc(pe.tech) + '"' : '') + '><span><i class="k ' + esc(e.kind) + '"></i>' + esc(pe.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
}
}
// the Tuning card on Mine and the Tuning rows on Settings share the goal, the schedule and the Power control switch
@ -1321,7 +1603,7 @@ if (typeof document !== 'undefined') (function () {
setText('goal-line', gw.consequence); setText('goal-line-2', gw.consequence);
var sched = View.schedule(s.mining.cards, s, s.now);
setText('tune-schedule', sched); setText('s-tune-schedule', sched);
setText('tune-eyebrow', st.tuning_off ? 'paused by Igneum' : st.sweep === false ? 'Ember Tune off' : 'Ember Tune');
setText('fleet-saving', View.fleetSaving(s.mining.cards, s, s.now, priceNow()));
var nvOff = cards.some(function (c) { return c.vendor === 'nvidia' && View.present(c); }) && !st.power_control;
setHidden('m-power-row', !nvOff);
if (document.activeElement !== $('m-power-control')) $('m-power-control').checked = !!st.power_control;
@ -1331,29 +1613,31 @@ if (typeof document !== 'undefined') (function () {
var switchesSig = '';
function renderNode(s) {
var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now);
var nl = View.nodeLine(n, s.clock, eta);
setText('node-line-text', nl.text); setText('node-sub', nl.sub);
var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f);
setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub);
$('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : '');
$('node-card').classList.toggle('bad', nl.tone === 'bad');
setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : ''));
var ns = View.nodeSource(n); setText('n-source', ns.text); setText('n-source-m', ns.sub); setHidden('n-source', !ns.text);
setText('n-blocks', withCommas(n.blocks)); setText('n-blocks-sub', View.heightLine(n));
setText('n-peers', String(n.peers)); setText('n-peers-sub', View.peersLine(n));
setText('n-version', n.version ? n.version.replace(/^igneumd\s*/, '') : '--');
setText('d-node-net', s.chain || s.network || '');
setText('n-headers', withCommas(n.headers)); setText('n-daa', withCommas(n.daa)); setText('n-diff', compact(n.difficulty)); setText('n-tips', String(n.tips)); setText('n-blue', withCommas(n.blue));
if (n.daa > 0 && p && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', 'until the next hourly mining program' + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '') + (p.message ? ' · ' + p.message : '')); }
else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'known after the node is synced' : 'known once the node runs'); }
setText('n-digest', n.consensus_digest || (n.state === 'stopped' || n.state === 'failed' ? 'the node is not running' : 'not printed yet'));
if (n.daa > 0 && p && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', 'until the next hourly program' + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '') + (p.message ? ' · ' + p.message : '')); }
else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'known after the node is synced' : 'known once the node is running'); }
setText('n-digest', n.consensus_digest || (n.state === 'stopped' || n.state === 'failed' ? 'the node is not running' : 'not read yet'));
var sw = View.nextSwitch(n.consensus_switches, n.daa);
setText('n-switch', sw ? sw.name + ' at ' + withCommas(sw.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'DAA ' + withCommas(n.consensus_switch_daa) : (n.consensus_switches && n.consensus_switches.length ? 'none ahead' : 'none planned')));
setText('n-switch-help', sw ? View.switchLine(sw, n.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'The update manifest announces a switch at DAA ' + withCommas(n.consensus_switch_daa) + ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : View.switchLine(null, n.daa)));
setText('n-switch-help', sw ? View.switchLine(sw, n.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'A rule change is announced at block ' + withCommas(n.consensus_switch_daa) + ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : View.switchLine(null, n.daa)));
var sig = JSON.stringify([n.consensus_switches, sw && sw.key]);
if (sig !== switchesSig) {
switchesSig = sig;
$('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).join('');
}
if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone; locks at 2/3 of the 30-day weight'); }
else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'locks appear once the miner votes on checkpoints'); }
setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age);
setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote'));
$('f-lock').classList.toggle('warn', !!fw.paused);
setText('f-votes', withCommas(f.votes));
}
@ -1363,19 +1647,21 @@ if (typeof document !== 'undefined') (function () {
var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000';
setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : ''));
setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s));
var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price);
var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price);
var bw = View.balanceWords(a, s.now);
if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); }
setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day');
setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W');
setHidden('e-price-btn', !(watts > 0 && ppd === null));
if (document.activeElement !== $('s-devfee')) $('s-devfee').checked = !!(s.settings && s.settings.dev_fee);
setText('s-devfee-text', s.settings.dev_fee ? 'Dev fee: 1 block in 100 pays the miner software’s author' : 'Dev fee off: every block pays your address');
setText('r-devfee-line', s.settings.dev_fee ? 'The protocol takes nothing; this is the miner software’s fee, the same way every GPU miner takes one' + (m.fee_total ? ' (' + withCommas(m.fee_total) + ' block' + (m.fee_total === 1 ? '' : 's') + ' so far)' : '') + '.' : 'The protocol takes nothing either.');
setText('s-devfee-text', s.settings.dev_fee ? 'Dev fee: 1 block in 100 pays the people who make this app' : 'Dev fee off. Every block pays your address');
setText('r-devfee-line', s.settings.dev_fee ? 'The network itself takes nothing. This is the app’s fee, the same way every mining app takes one' + (m.fee_total ? ' (' + withCommas(m.fee_total) + ' block' + (m.fee_total === 1 ? '' : 's') + ' so far)' : '') + '.' : 'The network itself takes nothing either.');
setText('r-address', a.display || 'not set');
setText('r-source', a.source === 'generated' ? 'made on this machine' : a.source === 'pasted' ? 'your own address' : '');
setText('r-address-help', a.display ? 'Every block this machine finds pays this address.' : 'No address yet. Finish the setup to choose one.');
var gen = a.source === 'generated';
setHidden('key-toggle', !gen); if (!gen) { $('key-details').hidden = true; hideKey(); }
setText('r-key-help', 'The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe.');
setText('r-key-help', 'The key for this address was made on this machine and is stored in the app folder. Only your user can read it. Keep a copy somewhere safe.');
setText('r-key-file', gen && a.wallet_file ? a.wallet_file : '');
}
@ -1393,10 +1679,10 @@ if (typeof document !== 'undefined') (function () {
setText('pv-note', (!enabled && pv.default_note) ? pv.default_note + '.' : w.note);
var v = View.verifierWords(pv);
setText('pv-verifier', v.word); $('pv-verifier').className = 'v ' + v.tone;
setText('pv-verifier-help', pv.verifier_note || 'the node checks a proof before it counts');
setText('pv-verifier-help', pv.verifier_note || 'the verifier: the node checks a proof before it counts');
$('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || v.needsSetup);
var seg = pv.segments_submitted ? pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN)' : '') + (pv.segment_last_s ? ', the last in ' + Math.round(pv.segment_last_s) + ' s' : '') + (pv.segments_held ? ', ' + pv.segments_held + ' held for the chain rule' : '') : 'none yet';
setText('pv-seg', seg); setText('pv-seg-note', pv.segment_note && enabled ? pv.segment_note : 'whole segments this machine proved');
var seg = pv.segments_submitted ? pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN)' : '') + (pv.segment_last_s ? ', the last took ' + Math.round(pv.segment_last_s) + ' s' : '') + (pv.segments_held ? ', ' + pv.segments_held + ' waiting for the chain' : '') : 'none yet';
setText('pv-seg', seg); setText('pv-seg-note', pv.segment_note && enabled ? pv.segment_note : 'whole segments (runs of 8 blocks) this machine proved');
setText('pv-program', pv.program_id || 'not read yet');
setText('pv-aggregator', pv.aggregator_id || 'not read yet');
}
@ -1436,24 +1722,16 @@ if (typeof document !== 'undefined') (function () {
}
function renderPage(s) {
if (page === 'mine') renderMine(s);
if (page === 'overview' || page === 'cards') renderMine(s);
else if (page === 'earnings') renderEarnings(s);
else if (page === 'prove') renderProve(s);
else if (page === 'settings') renderSettings(s);
}
function renderPill(s) {
var pill = $('pill'), text;
var pill = $('pill'), pw = View.pill(s);
pill.classList.remove('on', 'warn');
if (s.quitting) text = 'stopping';
else if (!s.setup_done) text = s.detecting ? 'detecting' : 'set up';
else if (View.tuningCard(s.mining.cards)) { text = 'tuning'; pill.classList.add('on'); }
else if (s.mining.state === 'mining') { text = s.mining.hash_total.toFixed(1) + ' MH/s'; pill.classList.add('on'); }
else if (s.mining.state === 'paused') text = 'paused';
else if (s.node.state === 'syncing' || s.node.state === 'starting') text = 'node ' + s.node.state;
else if (s.node.state === 'failed' || s.node.state === 'restarting') { text = 'node ' + s.node.state; pill.classList.add('warn'); }
else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); }
else text = s.mining.state;
$('pill-text').textContent = text;
if (pw.tone === 'on') pill.classList.add('on'); else if (pw.tone === 'bad') pill.classList.add('warn');
$('pill-text').textContent = pw.text;
var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'pays ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet') + '<br>up ' + uptime(s.uptime_s);
if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; }
var fv = 'v' + esc(s.version) + '<span class="chain"> · ' + esc(s.chain || s.network || '') + '</span>';
@ -1492,7 +1770,8 @@ if (typeof document !== 'undefined') (function () {
el.className = 'notice ' + n.kind + (n.tone ? ' ' + n.tone : '');
el.dataset.key = n.key;
$('notice-text').textContent = n.text; $('notice-text').title = n.title || '';
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = !n.detail; $('notice-detail').title = n.detail;
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = true; $('notice-detail').title = n.detail;
$('notice-more').hidden = !n.detail; $('notice-more').setAttribute('aria-expanded', 'false'); $('notice-more').classList.remove('open');
$('notice-actions').innerHTML = n.actions.map(function (a) { return '<button class="btn small ' + (a.primary ? 'primary' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('notice-prog').hidden = !(n.progress >= 0);
strip.hidden = false;
@ -1514,6 +1793,7 @@ if (typeof document !== 'undefined') (function () {
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, key = $('notice').dataset.key;
if (act === 'close') { dismissed[key] = true; renderNotices(state || {}); }
else if (act === 'more') { var open = $('notice-detail').hidden; $('notice-detail').hidden = !open; b.setAttribute('aria-expanded', open ? 'true' : 'false'); b.classList.toggle('open', open); layoutStrip(); }
else if (act === 'install') { api('api/update/install', {}); toast('Installing at once'); }
else if (act === 'retry') { api('api/update/install', {}); toast('Trying the update again'); }
else if (act === 'open') api('api/update/open', {});
@ -1584,11 +1864,11 @@ if (typeof document !== 'undefined') (function () {
if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.');
if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : ''));
else if (u.status === 'checking') parts.push('Checking.');
else if (u.status === 'current') parts.push('Up to date' + (u.checked_at ? ', checked ' + rel(state.now - u.checked_at) : '') + '.');
else if (u.status === 'current') parts.push('Up to date' + (u.checked_at ? '. Checked ' + rel(state.now - u.checked_at) : '') + '.');
else if (/no update manifest configured/.test(u.error || '')) parts.push('This build has no update address; updates come as a new download.');
else if (u.error) parts.push(u.error);
else if (!u.status || u.status === 'unknown') parts.push('Not checked yet. The engine checks by itself every hour.');
if (u.activation_height && !u.urgent && state && state.node && u.activation_height > state.node.daa) parts.push('A rule switch comes at height ' + withCommas(u.activation_height) + '.');
else if (!u.status || u.status === 'unknown') parts.push('Not checked yet. The app checks by itself every hour.');
if (u.activation_height && !u.urgent && state && state.node && u.activation_height > state.node.daa) parts.push('A rule change comes at block ' + withCommas(u.activation_height) + '.');
return parts.join(' ');
}
function sampleUpdate(kind) {
@ -1620,7 +1900,9 @@ if (typeof document !== 'undefined') (function () {
function sampleTune(s, kind) {
var c = s.mining.cards[0]; if (!c) return;
if (kind === 'running') { c.state = 'tuning'; c.sweep_state = 'running'; c.tune_step = 7; c.tune_steps = 9; c.tune_eta_s = 230; c.tune_plan = 'full'; c.sweep_note = 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)'; if (!(c.power_w > 0)) c.power_w = 219.6; if (!(c.hash_now > 0)) c.hash_now = 123.4; }
if (kind === 'tuned') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 100; c.sweep_at = s.now - 7200; c.sweep_watts = 290; }
var curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2780, power_pct: 80, watts: 298, mhs: 122.4, eff: 0.411 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 2160, power_pct: 80, watts: 281, mhs: 117.0, eff: 0.416 }, { clock_mhz: 1850, power_pct: 80, watts: 270, mhs: 109.2, eff: 0.404 }];
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; }
if (kind === 'mix' && s.mining.cards[1]) { var d = s.mining.cards[1]; d.state = 'tuning'; d.sweep_state = 'running'; d.tune_step = 4; d.tune_steps = 9; d.tune_eta_s = 410; d.tune_plan = 'full'; d.tune_control = true; d.hash_now = d.hash_now || 98.1; }
if (kind === 'measured') { c.tune_line = 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)'; c.tune_source = 'baseline'; c.tune_control = false; c.sweep_at = s.now - 600; c.sweep_note = 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed'; }
}
function render(s) {
@ -1659,11 +1941,11 @@ if (typeof document !== 'undefined') (function () {
}).catch(function () {
failures++;
if (failures >= 4) {
$('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away';
$('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'Stopped' : 'App not answering';
if (phase === 'dashboard') {
setText('node-line-text', 'Node: no answer'); setText('node-sub', 'the engine is not answering; the window reconnects by itself'); $('node-card').classList.add('bad');
setText('btn-toggle-sub', 'the engine is not answering'); $('btn-toggle').disabled = true;
setText('t-hash-sub', 'no answer from the engine');
setText('node-line-text', 'Node: no answer'); setText('node-sub', 'the app is not answering. The window reconnects by itself'); $('node-card').classList.add('bad');
setText('btn-toggle-sub', 'the app is not answering'); $('btn-toggle').disabled = true;
setText('t-hash-sub', 'no answer from the app');
}
}
});

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="mine">
<body class="phase-welcome" data-phase="welcome" data-page="overview">
<!-- 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,7 +18,8 @@
<span class="word">IGNEUM</span>
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<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 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" 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>
@ -37,7 +38,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">Mine</h1>
<h1 id="page-title-text">Overview</h1>
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
@ -48,8 +49,10 @@
<!-- 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>
@ -115,7 +118,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>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
<p class="note" id="cards-help" hidden>A built-in GPU starts off. 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>
@ -143,7 +146,7 @@
<span class="radio"></span>
<span class="body">
<span class="t">Use my own address</span>
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
<span class="s">Paste an address you control. It starts with 0x and has 40 characters after it.</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>
@ -160,8 +163,8 @@
<!-- 4. the dashboard: four pages behind the rail -->
<section class="screen" id="screen-dashboard">
<!-- Mine -->
<section class="page" id="page-mine" data-page="mine">
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
<section class="page" id="page-overview" data-page="overview">
<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>
@ -182,24 +185,15 @@
</div>
</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 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-pause" aria-pressed="false">Pause</button></div></div>
<div class="dag-wrap">
<canvas id="dag-live" aria-label="The last two minutes of blocks: your row and the other miners, the selected chain as a lit path, checkpoints as bands"></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="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 class="legend"><span><i class="lg you"></i>yours</span><span><i class="lg pending"></i>pending</span><span><i class="lg included"></i>included</span><span><i class="lg excluded"></i>excluded</span><span><i class="lg proven"></i>proven</span><span><i class="lg locked"></i>locked checkpoint</span><span class="dim" id="dag-note"></span></div>
</div>
<div class="card node-card" id="node-card">
@ -210,25 +204,26 @@
<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">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">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">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 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">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">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 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><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>
<div class="field">
<div class="k">rules digest</div>
<div class="k">rules fingerprint</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 switch</div>
<div class="k">next rule change</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>
@ -238,11 +233,35 @@
<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>
<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">
@ -252,7 +271,7 @@
<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>
<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>
<p class="help" id="r-devfee-line"></p>
</div>
@ -297,19 +316,19 @@
<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. The chain hands pieces to your cards; each piece proven pays IGN.</p>
<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>
</div>
<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">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">Takes 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 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 (runs of 8 blocks) this machine proved</span></div>
</div>
<div class="field">
<div class="k">shard program id</div>
@ -318,7 +337,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. Other nodes accept a proof only from these two ids.</p>
<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>
</div>
</div>
</div>
@ -330,19 +349,19 @@
<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">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>
<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-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 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>
<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>
<div class="field">
<div class="k">electricity price</div>
<div class="row">
<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>
<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 this.</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
</div>
@ -352,7 +371,7 @@
<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-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>
<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 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>
@ -362,7 +381,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 label for you only.</p>
<p class="help">A name for you only.</p>
</div>
<div class="field">
<div class="k">appearance</div>
@ -401,8 +420,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 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>
<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>
<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>
@ -494,6 +513,7 @@
</div>
<div class="toast" id="toast" hidden></div>
<script src="live-dag.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,236 @@
/* Igneum live DAG view (docs/plans/site-ui-3.md section 6, 6 October 2026).
One module for /live and the home page. Reads /api/live only; every pixel is a block, edge or checkpoint the observer
stored. Blocks are placed by header time on x (the axis is labelled in DAA score) and by miner lane on y. Parent edges
are drawn; the edge between two selected-chain blocks is heavier and the chain glows as one path. Colour words are the
ledger's: pending, included (blue), excluded (red), proven (every shard verified or paid), locked (a checkpoint block).
Checkpoints are vertical bands at their blue score with the lock weight; a locked band is solid, a pending one dashed.
The view scrolls at chain speed, new blocks arrive from the right with a brief glow. The page's scroll passes through the
scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene (engaged, released by Escape or a click
outside; opts.chip is the "scroll to zoom, Esc to release" element); window 30 to 300 s (the API's ?window=); a pause button;
hover and tap show a block's details.
Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, onEngage, pauseButton, tooltip, chip })
- url: the /api/live endpoint (default '/api/live'); poll: false stops the module's own 2 s fetch, the host then calls
dag.push(reply) with a reply of the same shape ({ok, state:{stale, age_s, height, ...}, blocks:[...], finality:{checkpoints:[...]}, proving})
whenever it has new data (every poll or every new block, either is fine; blocks already on screen are updated in place).
- block record (the /api/live shape): hash (string, 16 hex), ts (ms, header time), blue_score, daa (numbers), parents ([hash]),
chain (bool, selected chain), color ('blue' | 'red' | 'pending'), miner (string lane key, 8 hex of the vote key hash),
locked (bool, a locked checkpoint block), final (bool, at or before the newest lock), shards ([{state: 'planned' | 'proving' |
'verified' | 'paid'}]), proven (bool, every shard verified or paid). Checkpoints: {index, blue_score, state: 'locked' | 'pending', fraction_total}.
- mine: a miner lane key (string) or function(block) -> bool; the user's own blocks take a molten ring and the lane is labelled "you".
- The legend is the host's; the module draws no HTML. Colours come from the CSS custom properties on :root (--ember, --ember-hi,
--molten, --bone, --ash, --line, --line-2, --row or --obsidian), re-read on data-theme and prefers-color-scheme changes.
- Sizing: the canvas fills its CSS box (width and height from the stylesheet); 280 px wide works, 600 or more shows lane labels well.
onState(state, reason): 'connecting' | 'live' | 'stale' | 'failed'; onData(d) with every fresh reply. */
(function (root) {
'use strict';
var COL = {
ember: '#F2541B', emberHi: '#FF6A2B', molten: '#FFB35C', bone: '#F4F1EC', ash: '#9A9A9E', line: '#2A2A30', line2: '#3A3A42', obsidian: '#0C0C0E',
red: '#B0362B'
};
function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } }
function readTheme() {
COL.ember = cssVar('--ember', COL.ember); COL.emberHi = cssVar('--ember-hi', COL.emberHi); COL.molten = cssVar('--molten', COL.molten);
COL.bone = cssVar('--bone', COL.bone); COL.ash = cssVar('--ash', COL.ash); COL.line = cssVar('--line', COL.line); COL.line2 = cssVar('--line-2', COL.line2);
COL.obsidian = cssVar('--row', cssVar('--obsidian', COL.obsidian));
}
function rgba(hex, a) { var h = hex.replace('#', ''); if (h.length !== 6) return hex; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ',' + parseInt(h.slice(2, 4), 16) + ',' + parseInt(h.slice(4, 6), 16) + ',' + a + ')'; }
function fmt(n) { return n === null || n === undefined ? 'pending' : Number(n).toLocaleString('en-GB'); }
function fmtAge(sec) { sec = Math.round(sec); return sec < 90 ? sec + ' s' : sec < 5400 ? Math.round(sec / 60) + ' min' : Math.round(sec / 3600) + ' h'; }
function mount(canvas, opts) {
opts = opts || {};
var compact = !!opts.compact, windowS = Math.max(30, Math.min(300, opts.window || (compact ? 90 : 120)));
var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isMine = typeof opts.mine === 'function' ? opts.mine : opts.mine ? function (b) { return b.miner === opts.mine; } : null;
var ctx = canvas.getContext('2d'), dpr = Math.min(root.devicePixelRatio || 1, 2), W = 0, H = 0;
var blocks = [], byHash = {}, lanes = [], laneOf = {}, cps = [], data = null, state = 'connecting', reason = 'connecting';
var paused = false, offsetMs = 0, hover = null, pinned = null, lastPoll = 0, failures = 0, running = false, inView = true, lastT = 0;
var vnow = function () { return Date.now() - 2500 - offsetMs; }; // the right edge sits 2.5 s behind the clock so a block has arrived before it is drawn
var padL = compact ? 10 : 56, padR = 14, padT = compact ? 12 : 22, padB = compact ? 10 : 26;
function size() { W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout(); }
root.addEventListener('resize', size);
// ---- data ----
function apply(d) {
data = d;
var seen = {}, now = performance.now();
d.blocks.forEach(function (b) {
seen[b.hash] = 1;
var e = byHash[b.hash];
if (e) { e.chain = !!b.chain; e.color = b.color || 'pending'; e.locked = !!b.locked; e.final = !!b.final; e.shards = b.shards || []; e.proven = !!b.proven; e.blue = b.blue_score; e.daa = b.daa; return; }
e = { hash: b.hash, ts: b.ts, blue: b.blue_score, daa: b.daa, parents: b.parents || [], chain: !!b.chain, color: b.color || 'pending', miner: b.miner || '?', locked: !!b.locked, final: !!b.final, shards: b.shards || [], proven: !!b.proven, born: now, x: 0, y: 0 };
byHash[b.hash] = e; blocks.push(e);
});
var cut = vnow() - windowS * 1000 - 20000;
blocks = blocks.filter(function (b) { if (b.ts < cut && !seen[b.hash]) { delete byHash[b.hash]; return false; } return true; });
blocks.sort(function (a, b) { return a.ts - b.ts; });
cps = (d.finality && d.finality.checkpoints) || [];
layout();
}
function layout() {
// lanes: miners of the visible window ordered by blocks, as many as fit at 22 px, the rest in one "others" lane
var vis = vnow() - windowS * 1000, count = {};
blocks.forEach(function (b) { if (b.ts >= vis) count[b.miner] = (count[b.miner] || 0) + 1; });
var ids = Object.keys(count).sort(function (a, b) { return count[b] - count[a] || (a < b ? -1 : 1); });
var usable = H - padT - padB, laneH = compact ? 26 : 22, maxLanes = Math.max(2, Math.floor(usable / laneH));
var shown = ids.length > maxLanes ? ids.slice(0, maxLanes - 1) : ids;
lanes = shown.slice(); if (ids.length > shown.length) lanes.push('others');
laneOf = {}; shown.forEach(function (id, i) { laneOf[id] = i; });
}
function laneY(b) { var i = b.miner in laneOf ? laneOf[b.miner] : lanes.length - 1; var n = Math.max(1, lanes.length); return padT + (i + 0.5) * ((H - padT - padB) / n); }
function xOf(ts) { return padL + (W - padL - padR) * (1 - (vnow() - ts) / (windowS * 1000)); }
// the host can feed replies itself (poll: false); the same path the fetch takes
function push(d) {
if (!d || !d.ok || !d.state || !d.blocks) { setState('failed', 'live feed unavailable'); return; }
failures = 0;
if (d.state.stale) setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown'));
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d); kick();
}
function poll() {
if (opts.poll === false || document.hidden) return;
var u = (opts.url || '/api/live') + '?window=' + windowS;
fetch(u, { cache: 'no-store' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
if (!d || !d.ok || !d.state || !d.blocks) throw new Error('bad reply');
failures = 0;
if (d.state.stale) { setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); }
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d);
kick();
}).catch(function () { failures++; if (failures >= 2) setState('failed', 'live feed unavailable'); });
}
function setState(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); }
// ---- drawing ----
function draw(t) {
if (!W || !H) return;
var dt = lastT ? Math.min(50, t - lastT) : 16; lastT = t;
ctx.clearRect(0, 0, W, H);
var vis = vnow() - windowS * 1000, n = Math.max(1, lanes.length), lh = (H - padT - padB) / n;
// lane rails and labels
ctx.lineWidth = 1;
for (var i = 0; i < n; i++) {
var y = padT + (i + 0.5) * lh;
ctx.strokeStyle = rgba(COL.line, 0.9); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W - padR, y); ctx.stroke();
if (!compact) { var mineLane = isMine && typeof opts.mine === 'string' && lanes[i] === opts.mine; ctx.fillStyle = mineLane ? COL.molten : COL.ash; ctx.font = (mineLane ? '500' : '400') + ' 10px IBM Plex Mono, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(mineLane ? 'you' : lanes[i] === 'others' ? 'others' : String(lanes[i]).slice(0, 8), 6, y); }
}
// checkpoint bands at their blue score (x from the chain block carrying that score, else the nearest)
cps.forEach(function (c) {
var ref = null, best = Infinity;
for (var k = blocks.length - 1; k >= 0; k--) { var b = blocks[k]; if (b.blue === null || b.blue === undefined) continue; var dd = Math.abs(b.blue - c.blue_score); if (dd < best) { best = dd; ref = b; } }
if (!ref || best > 3 || ref.ts < vis) return;
var x = xOf(ref.ts), locked = c.state === 'locked';
ctx.strokeStyle = rgba(COL.ember, locked ? 0.55 : 0.3); ctx.lineWidth = locked ? 1.5 : 1; ctx.setLineDash(locked ? [] : [4, 6]);
ctx.beginPath(); ctx.moveTo(x, padT - 6); ctx.lineTo(x, H - padB + 6); ctx.stroke(); ctx.setLineDash([]);
if (!compact) { ctx.fillStyle = rgba(COL.ember, 0.9); ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText((locked ? 'locked ' : 'pending ') + (c.fraction_total !== null && c.fraction_total !== undefined ? Math.round(c.fraction_total * 100) + '%' : ''), x, H - 8); }
});
// positions
blocks.forEach(function (b) { b.x = xOf(b.ts); b.y = laneY(b); b.on = b.ts >= vis && b.x >= padL - 10; });
// edges: merge edges thin, chain-to-chain heavier; the chain as one glowing path
blocks.forEach(function (b) {
if (!b.on) return;
b.parents.forEach(function (h) {
var p = byHash[h]; if (!p || !p.on) return;
var chainEdge = b.chain && p.chain;
ctx.strokeStyle = chainEdge ? rgba(COL.ember, 0.75) : rgba(COL.ash, b.color === 'red' ? 0.12 : 0.22);
ctx.lineWidth = chainEdge ? 2 : 1;
ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + p.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke();
if (chainEdge) { ctx.strokeStyle = rgba(COL.ember, 0.18); ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke(); }
});
});
// blocks
var S = compact ? 9 : 8, now = performance.now();
blocks.forEach(function (b) {
if (!b.on) return;
var age = now - b.born, glow = age < 1600 ? 1 - age / 1600 : 0, ex = b.color === 'red';
var r = b.chain ? S + 1 : S;
if (glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, r * 3); g.addColorStop(0, rgba(COL.ember, 0.4 * glow)); g.addColorStop(1, rgba(COL.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r * 3, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = ex ? 0.45 : 1;
// fill: proven = ember, otherwise the row surface; outline: included (blue or chain) = ember, pending = grey, excluded = dim
var proving = !b.proven && b.shards.some(function (s) { return s.state === 'proving' || s.state === 'verified' || s.state === 'paid'; });
ctx.beginPath(); rr(b.x - r, b.y - r, 2 * r, 2 * r, 3);
ctx.fillStyle = b.proven ? COL.ember : COL.obsidian; ctx.fill();
ctx.lineWidth = b.chain ? 2 : 1.5;
// chain blocks in ember (the path), included blocks lit in bone, pending in ash, excluded dim
ctx.strokeStyle = b.proven ? COL.ember : proving ? COL.molten : b.chain ? rgba(COL.ember, 0.9) : b.color === 'blue' ? rgba(COL.bone, 0.8) : ex ? rgba(COL.ash, 0.5) : rgba(COL.ash, 0.75);
ctx.stroke();
if (b.locked) { ctx.strokeStyle = rgba(COL.ember, 0.8 + 0.2 * Math.sin(t / 350)); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.9, 0, Math.PI * 2); ctx.stroke(); }
if (isMine && isMine(b)) { ctx.strokeStyle = rgba(COL.molten, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.5, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
});
// hover or pinned ring
var f = pinned || hover; if (f && f.on) { ctx.strokeStyle = COL.bone; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.arc(f.x, f.y, S * 2.4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }
// axis in DAA at the left and right edges, and the lock line label
if (!compact) {
ctx.fillStyle = COL.ash; ctx.font = '400 10px IBM Plex Mono, monospace'; ctx.textBaseline = 'alphabetic';
var first = null, last = null; blocks.forEach(function (b) { if (!b.on) return; if (!first || b.ts < first.ts) first = b; if (!last || b.ts > last.ts) last = b; });
if (first && last) { ctx.textAlign = 'left'; ctx.fillText('daa ' + fmt(first.daa), padL, padT - 8); ctx.textAlign = 'right'; ctx.fillText('daa ' + fmt(last.daa) + (paused ? ' paused' : ''), W - padR, padT - 8); }
}
if (paused) { ctx.fillStyle = rgba(COL.obsidian, 0.0); }
}
function rr(x, y, w, h, r) { 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 frame(t) { if (!running) return; if (!paused) draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); }
function start() { if (running) return; running = true; requestAnimationFrame(frame); }
function kick() { if (!running) requestAnimationFrame(function (t) { draw(t); if (!reduce && !document.hidden && inView) start(); }); }
// ---- interaction ----
function hit(ev) {
var rect = canvas.getBoundingClientRect(), px = ev.clientX - rect.left, py = ev.clientY - rect.top, best = null, bd = 14 * 14;
blocks.forEach(function (b) { if (!b.on) return; var dx = b.x - px, dy = b.y - py, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = b; } });
return best;
}
function words(b) {
var state = b.locked ? 'locked checkpoint' : b.proven ? 'proven' : b.chain ? 'included, selected chain' : b.color === 'blue' ? 'included' : b.color === 'red' ? 'excluded' : 'pending';
var sh = b.shards.length ? b.shards.map(function (s) { return s.state; }).join(', ') : 'no shard plan yet';
return { title: b.hash + ' ' + state, lines: [(isMine && isMine(b) ? 'your block, miner ' : 'miner ') + b.miner + (b.miner in laneOf ? '' : ' (others lane)'), 'blue score ' + fmt(b.blue) + ' daa ' + fmt(b.daa), b.parents.length + (b.parents.length === 1 ? ' parent' : ' parents'), 'proof: ' + sh + (b.final ? ' final' : '')] };
}
var tip = opts.tooltip || null;
function showTip(b, ev) {
if (!tip) return;
if (!b) { tip.hidden = true; return; }
var w = words(b); tip.hidden = false; tip.innerHTML = '<b>' + w.title + '</b>' + w.lines.map(function (l) { return '<span>' + l + '</span>'; }).join('');
var rect = canvas.getBoundingClientRect(); var x = Math.min(rect.width - tip.offsetWidth - 8, Math.max(8, b.x + 12)), y = Math.max(8, b.y - tip.offsetHeight - 10); if (y < 8) y = b.y + 14;
tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
}
canvas.addEventListener('mousemove', function (ev) { hover = hit(ev); canvas.style.cursor = hover ? 'pointer' : 'default'; if (!pinned) showTip(hover, ev); kick(); });
canvas.addEventListener('mouseleave', function () { hover = null; if (!pinned) showTip(null); kick(); });
canvas.addEventListener('click', function (ev) { engage(true); var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
// the wheel is never captured by default (6 Oct 2026, the owner: scrolling past the scene zoomed it): the page scrolls through the
// scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene, which engages it (opts.chip shows
// "scroll to zoom, Esc to release"); Escape, a click outside or leaving the scene releases it
var engaged = false, chip = opts.chip || null;
function engage(on) { if (on === engaged) return; engaged = on; canvas.classList.toggle('engaged', on); if (chip) chip.hidden = !on; if (opts.onEngage) opts.onEngage(on); }
canvas.addEventListener('wheel', function (ev) {
var zoomKey = ev.ctrlKey || ev.metaKey;
if (!zoomKey && !engaged) return; // a plain wheel or a two-finger scroll passes through to the page
if (Math.abs(ev.deltaY) < 1) return;
ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15);
}, { passive: false });
document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && engaged) engage(false); });
document.addEventListener('pointerdown', function (ev) { if (engaged && !canvas.contains(ev.target) && !(chip && chip.contains(ev.target))) engage(false); });
var pinch = null;
canvas.addEventListener('touchstart', function (ev) { if (ev.touches.length === 2) pinch = dist(ev.touches); }, { passive: true });
canvas.addEventListener('touchmove', function (ev) { if (ev.touches.length === 2 && pinch) { var d = dist(ev.touches); if (Math.abs(d - pinch) > 12) { zoom(d > pinch ? 1 / 1.1 : 1.1); pinch = d; } } }, { passive: true });
canvas.addEventListener('touchend', function () { pinch = null; }, { passive: true });
function dist(t) { var dx = t[0].clientX - t[1].clientX, dy = t[0].clientY - t[1].clientY; return Math.sqrt(dx * dx + dy * dy); }
function zoom(f) { var w = Math.round(Math.max(30, Math.min(300, windowS * f))); if (w === windowS) return; windowS = w; layout(); if (opts.onWindow) opts.onWindow(w); poll(); kick(); }
function setPaused(p) { paused = !!p; if (paused) { offsetMs = 0; pausedAt = Date.now(); } else { offsetMs = 0; } if (opts.pauseButton) { opts.pauseButton.setAttribute('aria-pressed', paused ? 'true' : 'false'); opts.pauseButton.textContent = paused ? 'Resume' : 'Pause'; } kick(); }
var pausedAt = 0;
if (opts.pauseButton) opts.pauseButton.addEventListener('click', function () { setPaused(!paused); });
// while paused the clock stops: offsetMs grows so the scene holds still but keeps its blocks
setInterval(function () { if (paused) offsetMs = Date.now() - pausedAt; }, 250);
if ('IntersectionObserver' in root) { new IntersectionObserver(function (es) { es.forEach(function (e) { inView = e.isIntersecting; if (inView) kick(); }); }, { threshold: 0.05 }).observe(canvas); }
document.addEventListener('visibilitychange', function () { if (!document.hidden) { poll(); kick(); } });
if (root.matchMedia) { try { root.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { readTheme(); kick(); }); } catch (e) { } }
new MutationObserver(function () { readTheme(); kick(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
readTheme(); size(); if (opts.poll !== false) { poll(); setInterval(poll, 2000); } kick();
return { push: push, setPaused: setPaused, zoom: zoom, engage: engage, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
}
root.IgneumDag = { mount: mount };
})(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.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 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: 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,22 +106,26 @@ 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, '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.');
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.');
const d = jobNotice(fin('done'), NOW);
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
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, '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.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.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.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.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({ active: false, last: {} }, NOW), null);
assert.equal(jobNotice(null, NOW), null);
});

View file

@ -14,10 +14,11 @@ 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 four sections and their order (miner-ui-3)', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
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');
assert.equal(V.page('earnings').title, 'Earnings');
assert.equal(V.page('nonsense').id, 'mine');
assert.equal(V.page('nonsense').id, 'overview');
});
test('a GPU row: name, kind, the numbers where known, the switch', () => {
@ -72,8 +73,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 GPU on below first');
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
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(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);
});
@ -106,11 +107,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 DAA 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 block 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 switch is behind this node/);
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
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.equal(V.nextSwitch([], 5), null);
});
@ -139,9 +140,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 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: 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: 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');
@ -159,7 +160,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 worker stopped/);
assert.match(r.sub, /^unplugged\. Its row goes/);
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);
@ -194,7 +195,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 GPU this app can drive');
assert.equal(t.sub, 'no card this app can use');
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
assert.equal(t2.sub, 'mining on 1 of 1 card');
});
@ -232,6 +233,7 @@ 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 }))), '');
});
@ -249,7 +251,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 the fleet prior, confirmed · 2470 MHz at 100%/);
assert.match(prior.text, /^Tuned 2 min ago from what other miners found, 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');
@ -279,7 +281,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 by hand.');
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({ 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.');
@ -296,7 +298,7 @@ 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).');
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 (the prover is CUDA only).');
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.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');
});
@ -310,8 +312,151 @@ 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? 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.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.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('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 (it predates 0.3.17)' });
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' });
});

View file

@ -209,6 +209,9 @@ 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" {
@ -322,6 +325,19 @@ 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

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
@ -39,6 +40,13 @@ 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 = {};
@ -308,6 +316,9 @@ 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();

View file

@ -3,6 +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.14"
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
#define IGNEUM_HOST_VERSION_STR "0.3.17"
#define IGNEUM_HOST_VERSION_RC 0,3,17,0
#endif

View file

@ -2544,6 +2544,35 @@ task now runs ...Programs\Igneum Miner\igneum-app.exe --power-helper"), then the
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
through the installed app. Ember closed 16:53Z.
### 6 October 2026, 17:55Z to 17:57Z, the first segment above the restart cut from a snapshot-restored node on the new export (Mac)
A copy of node 1's data dir started on the shipper's 0.3.14 Mac binary (target-0314, release-0.3.14-node 4c6b129d) with
`--igneum-exec-snapshot=node1-copy-snapshot.bin,0xac101f13...` (the hands' recovery file, tip 130,272) and node 1 as its
peer: IBD of 8,049 blocks in 29 s, the executor resumed from the file and reached the tip 140,023 with no restart replay.
`igneum_exportSegments [139951, 139959]` answered in under 0.1 s with 9 segments, `preState` 79 accounts, `execRestart`
27,276. The branch exporter (exec-app-0314 bfebff1) cut all 8 blocks of segment 139,952..139,959 in under 0.1 s each:
"replayed 8 segments from the account dump; every state root equals the node's". The first block's fixture carries
pre-state root 0x64304441... and post-state root 0x60d60bba..., equal to node 1's eth_getBlockByNumber roots at 139,951
and 139,952 (an independent node). Consequence: a prover on a snapshot-restored node (every hand, nine fleet boxes) cuts a
provable fixture again with the 0.3.14 app and exporter; what is left for a paid record is the proof itself (the PCs or
the fleet, not this Mac).
### 6 October 2026, 18:11Z to 18:13Z, the PC shape over p2p (Mac, `tools/lock/with-lock.sh run`)
Fork 1255c0f9. `node tools/exec-sync/net.mjs`: 18/18 in 117.5 s. Case 7: a node whose data dir holds the tip-0 pair the 0.3.13
genesis replay left behind, with `--igneum-exec-snapshot=peer`, sets the pair aside (.bin.tip0), asks A over p2p and loads
A's state with no hand action; its state root at 60 equals A's. `reorg.mjs` 18/18 in 262.1 s on the same binary; the exec
suite 20. Consequence: a PC or a box left with the tip-0 pair recovers on its own once a hand serves (the 5-minute
no-progress rule fires the ask even when the error text is new); until 0.3.14.1 the recovery file by hand is the way.
## 6 October 2026, 18:18Z: the shipped 0.3.14 app asked to tune itself on PC 1 (ember-installed-pc1-1): FAILED, no tune ran
Gate ok (0.3.14, Power control on, the task on the installed exe). Every card's "after" was its 17:55Z failure from
the app's own first tune: 5090 and 4070 "did not take within 30 s (card reports 460 / 160 W, acknowledged true)",
9070 XT "the card refused the setting"; the three "Tune now" requests sat queued behind the hour's back-off. Five
causes (A to E) and their fixes in the plan's 0.3.14 window section; the window repeats on 0.3.15. The cards kept
mining at the driver's held locks: 5090 222.9 W at 1845 MHz, 4070 75.9 W at 1860 MHz, 9070 XT 201 W. Lever-2 TUNING
records during the window: 5090 135.6 MH/s at 236 W (0.574 MH/W), 4070 31.1 MH/s at 79 W (0.393).
## Prover tiers on real cards: the rented fleet, 6 October 2026 (branch gpu-fleet)
@ -2597,3 +2626,46 @@ USD 20 an hour on community pods, against a devnet of 1.16 GH/s.
Consequence: the devnet's hash is rentable for the price of a dinner, so nothing on it is a security result; the counter-ASIC and
finality work is tested there for correctness, not for cost. The cost argument only starts at the TH/s scale, where the rental
market's supply (not its price) is the limit, and that number belongs in the litepaper with this caveat.
### 6 October 2026, 20:38Z to 21:03Z, proof verification on the consensus path (fork exec-sync-0313 da2d17ec)
The in-process SP1 verifier (sp1-sdk LightProver, the embedded keys) on a compressed shard proof of
block-58927-empty-reward (1,272,897 B), one core, including the LightProver setup each call:
| Machine | Profile | Verify | Command |
|---|---|---|---|
| M5 Max (this Mac) | release | 0.204 s, 0.232 s (two runs) | `cargo test --release -p igneum-exec nativeverify -- --nocapture` with the fixture |
| M5 Max | debug | 1.226 s | the same without `--release` |
| igneum-build-1 (Linux, the build box) | release | 0.400 s | `tools/build-remote.sh --no-fetch -- test --release -p igneum-exec nativeverify -- --nocapture` |
| EPYC 7K62, 2019 Zen 2 (rz-4090, earlier today) | release, through the host | 0.53 s | bench/proof-systems rows |
`node tools/exec-sync/forged.mjs` (fast-time simnet, `proving_consensus_verify_daa` 0 under the embedded ids): 8/8 in
23.6 s. The attacker A (`IGNEUM_TEST_SKIP_PROOF_RULE=1`, Trust pool) carried a forged shard record at block 27; B asked
A for the proof (22:03:31.270 local), held it 1 ms later, refused the block at 31.522 ("the proof bytes are not a bincode
SP1 proof"; the verify itself 0.000 s), never included it and paid nothing; before the forgery B followed A block for
block. An unmodified A refused its own carrying block ("block is known to be invalid", 20:45Z run).
Consequence per tier: the rule costs an honest node nothing on the hot path (a proof verified at relay time is a
cache hit); a cold proof costs a 2019 core 0.4 to 0.5 s and a current one 0.2 s, in parallel per record; a forger
loses its block and its peer. Until 0.3.17.1's finality-horizon skip, a fresh joiner fetches every carried proof in
its IBD window from its syncer (1.27 MB a record), so a syncer must still hold them.
### 6 October 2026, 22:10Z to 22:28Z, Devnet 2: the zero program-id class and its close (the fleet's reads)
Devnet 2 (igneum-devnet-2, five nodes on 4c6b129d, 1 block/s, every switch at DAA 0): the nodes ran without a verifier
host or IGNEUM_PROOF_PROGRAM_IDS, so their expected segment statements carried zero program ids at hex offset 472 and
every segment record from the shipped host was refused while 8 of 8 shards passed (seg 2868: chain proof 1.27 MB in
281 s on a 4090). Fix on 4c6b129d: IGNEUM_PROOF_PROGRAM_IDS=0x2b1a81cb...,0x474678f3... on each node process, restart
22:10Z to 22:16Z, same genesis. Result: paidSegments 2 on every node at 22:22:28Z and 4 at 22:28:10Z (two provers,
8-block segments), 0 PoW rejected, one exec state root on all five at height 3,792 (0xab19a0be...), one version
(2.1.0-1279a1d6, digest 4a0b8726). On the 0.3.17 node (fork a344fea3) the override object's proving_shard_program_id
and proving_aggregator_id are the statement's ids when set, so the env is not needed. Consequence per tier: a node
without a host (a pool hand, a seed, a home node that does not prove) must carry the ids in its object or its env, or
it refuses every segment record and pays no aggregator; the 0.3.17 object carries them.
## 6 October 2026, 22:16Z: the shipped 0.3.16 app asked to tune itself on PC 1 (ember-installed-pc1-2): A to E held, no climb (cause F)
Gate ok (0.3.16, Power control on, task on the installed exe, Running at the end), prompts 0, the helper up once per
NVIDIA card. RX 9070 XT measured as it runs: 19.2 MH/s at 202 W (0.095 MH/W), no set sent (measure only: the probe
gave no tune line; open). RTX 5090 and 4070 climbs stopped at request 1: "the helper did not run sequence 1 within
15 s"; cmd.txt held "00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc", two-digit wire numbers below the cap path's unix-based
ones, so the helper skipped them as stale (cause F, fixed cbd4d51 for 0.3.17). Draws unchanged: 5090 208 W at
1845 MHz, 4070 75.5 W at 1860 MHz, 9070 XT 203 W.

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 362.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.3 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.445 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 7.106 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
CPU verify: 7.004 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.7 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.905 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 8.731 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 8.723 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
CPU verify: 8.714 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 368.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.5 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.089 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 4.935 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 4.934 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.1 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 5.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 5.059 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
CPU verify: 5.067 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 363.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 366.6 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.512 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 5.350 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 5.342 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
CPU verify: 5.342 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.0 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 6.044 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 5.887 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 5.887 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
CPU verify: 5.900 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 486.8 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.386 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 7.244 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 7.254 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
CPU verify: 7.231 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 541.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 525.0 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.834 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 8.701 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 8.717 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
CPU verify: 8.712 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 540.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 426.2 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.078 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 4.936 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 4.928 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 4.931 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 542.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 429.9 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 5.070 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 5.062 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
CPU verify: 5.055 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 542.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 436.9 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.497 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 5.360 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 5.359 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
CPU verify: 5.350 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 539.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 450.4 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 6.066 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 5.881 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 5.884 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
CPU verify: 5.829 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)

View file

@ -0,0 +1,2 @@
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
load after 19.77 12.55 8.74

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.4 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 9.918 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 9.703 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 9.691 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
CPU verify: 10.870 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.8 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 11.728 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 11.491 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 11.493 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
CPU verify: 13.125 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 365.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.3 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.146 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 6.915 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 6.897 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 7.421 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.5 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.291 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 7.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 7.070 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
CPU verify: 7.587 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.1 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.627 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 7.404 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 7.380 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
CPU verify: 8.023 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 544.8 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.290 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 8.085 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 8.051 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
CPU verify: 8.846 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)

View file

@ -0,0 +1,10 @@
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
load after 19.77 12.55 8.74
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |
|---|---|---|---|---|---|---|---|---|
| 0 | 27 | 55296 | 102,122 | 5.09 | 4.93 | 5.08 | 4.93 | yes |
| 1 | 35 | 71680 | 132,105 | 5.20 | 5.07 | 5.20 | 5.05 | yes |
| 2 | 53 | 108544 | 199,566 | 5.51 | 5.34 | 5.50 | 5.35 | yes |
| 3 | 88 | 180224 | 330,740 | 6.04 | 5.90 | 6.07 | 5.83 | yes |
| 4 | 173 | 354304 | 649,307 | 7.45 | 7.00 | 7.39 | 7.23 | yes |
| 5 | 267 | 546816 | 1,001,604 | 8.90 | 8.71 | 8.83 | 8.71 | yes |

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.6 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.380 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 7.232 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 7.230 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
CPU verify: 7.233 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 364.0 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 367.0 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.881 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 8.723 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 8.707 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
CPU verify: 8.499 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 460.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.6 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.141 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 4.954 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 4.969 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 4.946 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 456.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 437.3 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.571 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 5.327 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 5.203 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
CPU verify: 5.521 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 368.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.8 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.569 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 5.352 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 5.366 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
CPU verify: 5.351 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 360.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 361.8 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 6.073 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 5.900 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 5.883 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
CPU verify: 5.892 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 548.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 551.8 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 12.383 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 11.970 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 12.177 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
CPU verify: 12.091 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 548.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.3 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 14.959 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 14.619 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 14.437 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
CPU verify: 14.580 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 551.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 561.9 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.769 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 8.503 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 8.469 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
CPU verify: 8.674 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 549.4 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 554.8 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.867 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 8.505 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 8.517 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
CPU verify: 8.510 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 541.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 549.4 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 9.233 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 8.869 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 8.943 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
CPU verify: 8.908 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)

View file

@ -0,0 +1,8 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.0 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 10.081 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 9.845 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 9.944 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
CPU verify: 9.842 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)

View file

@ -0,0 +1,2 @@
host igneum-build-1 load 25.39 17.85 11.63 freq40 1500000 kHz siblings 40,88 pow 3f7623f208335f5b
load after 23.60 18.46 12.05

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.9 ms
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 7.394 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
warp base 1000000: single cold run 7.269 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 365.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.0 ms
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 8.930 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
warp base 4096: single cold run 8.716 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
warp base 1000000: single cold run 8.762 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 369.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 370.0 ms
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.129 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
warp base 1000000: single cold run 4.947 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 366.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.5 ms
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.247 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
warp base 4096: single cold run 5.071 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
warp base 1000000: single cold run 5.063 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 362.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.4 ms
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 5.481 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
warp base 4096: single cold run 5.346 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
warp base 1000000: single cold run 5.349 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a

View file

@ -0,0 +1,7 @@
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
cache: fill 367.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.7 ms
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
warp base 0: single cold run 6.058 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
warp base 4096: single cold run 5.909 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
warp base 1000000: single cold run 5.896 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f

View file

@ -0,0 +1,10 @@
host igneum-build-1 load 25.39 17.85 11.63 freq40 1500000 kHz siblings 40,88 pow 3f7623f208335f5b
load after 23.60 18.46 12.05
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |
|---|---|---|---|---|---|---|---|---|
| 0 | 27 | 55296 | 102,122 | 5.14 | 4.95 | 8.77 | 8.67 | yes |
| 1 | 35 | 71680 | 132,105 | 5.57 | 5.52 | 8.87 | 8.51 | yes |
| 2 | 53 | 108544 | 199,566 | 5.57 | 5.35 | 9.23 | 8.91 | yes |
| 3 | 88 | 180224 | 330,740 | 6.07 | 5.89 | 10.08 | 9.84 | NO |
| 4 | 173 | 354304 | 649,307 | 7.38 | 7.23 | 12.38 | 12.09 | NO |
| 5 | 267 | 546816 | 1,001,604 | 8.88 | 8.50 | 14.96 | 14.58 | NO |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,86 @@
22:15:30.265 signals up/up/none, expect step; class v4 from genesis, the ladder active from DAA 0, window 60 DAA x 7 (the first epoch that can step is 8, DAA 480); 60 DAA per epoch, lead 10; run 900 s or 10 epochs
22:15:31.502 n0 up pid 1524201 json 29722 p2p 29721, signals up
22:15:32.712 n1 up pid 1524767 json 29732 p2p 29731, signals up
22:15:33.921 n2 up pid 1525234 json 29742 p2p 29741, signals none
22:15:33.922 n0: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:15:33.922 n1: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:15:33.923 n2: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals none
22:15:33.923 n0 digest: b1f606bede788b70
22:15:34.935 epoch -1 -> 0 at daa 0, 4.7 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 0 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:15:34.936 t=4.7 s daa 0 epoch 0 rung 0 (27) up 0 bps weakest 0 blocks/sink per node 0/234e082d 0/234e082d 0/234e082d
22:15:49.976 t=19.7 s daa 5 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 5/40fa3a38 5/40fa3a38 5/40fa3a38
22:16:04.996 t=34.7 s daa 10 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 10/6c4045c3 10/6c4045c3 10/6c4045c3
22:16:20.016 t=49.8 s daa 18 epoch 0 rung 0 (27) up 8235 bps weakest 0 blocks/sink per node 18/c1c333f0 18/c1c333f0 18/c1c333f0
22:16:35.038 t=64.8 s daa 35 epoch 0 rung 0 (27) up 8529 bps weakest 0 blocks/sink per node 35/7b225e5b 35/7b225e5b 35/7b225e5b
22:16:50.055 t=79.8 s daa 50 epoch 0 rung 0 (27) up 7755 bps weakest 0 blocks/sink per node 50/47cc7477 50/47cc7477 50/47cc7477
22:17:01.070 epoch 0 -> 1 at daa 60, 90.8 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7627 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:17:05.076 t=94.8 s daa 66 epoch 1 rung 0 (27) up 7333 bps weakest 0 blocks/sink per node 66/3e771191 66/3e771191 66/3e771191
22:17:20.093 t=109.8 s daa 83 epoch 1 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 83/6c5cdee4 83/6c5cdee4 83/6c5cdee4
22:17:35.109 t=124.8 s daa 95 epoch 1 rung 0 (27) up 6500 bps weakest 0 blocks/sink per node 95/7764cba7 95/7764cba7 95/7764cba7
22:17:50.123 t=139.9 s daa 107 epoch 1 rung 0 (27) up 6500 bps weakest 0 blocks/sink per node 107/2cb36d19 107/2cb36d19 107/2cb36d19
22:18:05.138 t=154.9 s daa 119 epoch 1 rung 0 (27) up 6166 bps weakest 0 blocks/sink per node 119/352afb28 119/352afb28 119/352afb28
22:18:06.139 epoch 1 -> 2 at daa 121, 155.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6166 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:18:20.155 t=169.9 s daa 136 epoch 2 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 136/e0952e01 136/e0952e01 136/e0952e01
22:18:35.174 t=184.9 s daa 152 epoch 2 rung 0 (27) up 6166 bps weakest 0 blocks/sink per node 152/ed8e34c7 152/ed8e34c7 152/ed8e34c7
22:18:50.193 t=199.9 s daa 162 epoch 2 rung 0 (27) up 6500 bps weakest 0 blocks/sink per node 162/6065aef4 162/6065aef4 162/6065aef4
22:19:05.211 t=214.9 s daa 176 epoch 2 rung 0 (27) up 6333 bps weakest 0 blocks/sink per node 176/317cae30 176/317cae30 176/317cae30
22:19:09.216 epoch 2 -> 3 at daa 181, 218.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6500 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:19:20.231 t=230.0 s daa 189 epoch 3 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 189/21d90cfa 189/21d90cfa 189/21d90cfa
22:19:35.249 t=245.0 s daa 202 epoch 3 rung 0 (27) up 6333 bps weakest 0 blocks/sink per node 202/f1e7ec5c 202/f1e7ec5c 202/f1e7ec5c
22:19:50.265 t=260.0 s daa 218 epoch 3 rung 0 (27) up 6333 bps weakest 0 blocks/sink per node 218/e274c4f7 218/e274c4f7 218/e274c4f7
22:20:05.285 t=275.0 s daa 235 epoch 3 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 235/8e665e8d 235/8e665e8d 235/8e665e8d
22:20:08.289 epoch 3 -> 4 at daa 240, 278.0 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:20:20.303 t=290.0 s daa 254 epoch 4 rung 0 (27) up 7500 bps weakest 0 blocks/sink per node 254/ce04cd38 254/ce04cd38 254/ce04cd38
22:20:35.315 t=305.0 s daa 267 epoch 4 rung 0 (27) up 7500 bps weakest 0 blocks/sink per node 267/bf108545 267/bf108545 267/bf108545
22:20:50.328 t=320.1 s daa 283 epoch 4 rung 0 (27) up 7500 bps weakest 0 blocks/sink per node 283/5a897c6f 283/5a897c6f 283/5a897c6f
22:21:05.344 t=335.1 s daa 294 epoch 4 rung 0 (27) up 8000 bps weakest 0 blocks/sink per node 294/2b000b87 294/2b000b87 294/2b000b87
22:21:09.348 epoch 4 -> 5 at daa 300, 339.1 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7833 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:21:20.359 t=350.1 s daa 319 epoch 5 rung 0 (27) up 7333 bps weakest 0 blocks/sink per node 319/ecef2a5f 319/ecef2a5f 319/ecef2a5f
22:21:35.378 t=365.1 s daa 335 epoch 5 rung 0 (27) up 7166 bps weakest 0 blocks/sink per node 335/a8fbe817 335/a8fbe817 335/a8fbe817
22:21:50.399 t=380.1 s daa 343 epoch 5 rung 0 (27) up 7333 bps weakest 0 blocks/sink per node 343/b1967aab 343/b1967aab 343/b1967aab
22:22:05.416 t=395.1 s daa 354 epoch 5 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 354/2a4ccf8a 354/2a4ccf8a 354/2a4ccf8a
22:22:13.424 epoch 5 -> 6 at daa 360, 403.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6333 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:22:20.432 t=410.2 s daa 366 epoch 6 rung 0 (27) up 6166 bps weakest 6166 blocks/sink per node 366/a16b8185 366/a16b8185 366/a16b8185
22:22:35.449 t=425.2 s daa 377 epoch 6 rung 0 (27) up 6000 bps weakest 6000 blocks/sink per node 377/50316214 377/50316214 377/50316214
22:22:50.468 t=440.2 s daa 395 epoch 6 rung 0 (27) up 6666 bps weakest 6166 blocks/sink per node 395/5be9e979 395/5be9e979 395/5be9e979
22:23:05.487 t=455.2 s daa 404 epoch 6 rung 0 (27) up 6000 bps weakest 6000 blocks/sink per node 404/3a965599 404/3a965599 404/3a965599
22:23:15.500 epoch 6 -> 7 at daa 421, 465.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6333 bps (weakest of 7: 6166), down 0 bps, this node signals 1, step took effect at epoch 0
22:23:20.507 t=470.2 s daa 424 epoch 7 rung 0 (27) up 6333 bps weakest 6065 blocks/sink per node 424/097eaa14 424/097eaa14 424/097eaa14
22:23:35.529 t=485.3 s daa 439 epoch 7 rung 0 (27) up 6166 bps weakest 6000 blocks/sink per node 439/7d27db8c 439/7d27db8c 439/7d27db8c
22:23:50.550 t=500.3 s daa 456 epoch 7 rung 0 (27) up 5833 bps weakest 5833 blocks/sink per node 456/f4675851 456/f4675851 456/f4675851
22:24:05.570 t=515.3 s daa 467 epoch 7 rung 0 (27) up 6333 bps weakest 6333 blocks/sink per node 467/a7575707 467/a7575707 467/a7575707
22:24:20.593 epoch 7 -> 8 at daa 480, 530.3 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6500 bps (weakest of 7: 6333), down 0 bps, this node signals 1, step took effect at epoch 0
22:24:20.593 t=530.3 s daa 480 epoch 8 rung 0 (27) up 6500 bps weakest 6333 blocks/sink per node 480/f8f68e4b 480/f8f68e4b 480/f8f68e4b
22:24:35.616 t=545.4 s daa 499 epoch 8 rung 0 (27) up 7000 bps weakest 6000 blocks/sink per node 499/dba81f65 499/dba81f65 499/dba81f65
22:24:50.638 t=560.4 s daa 508 epoch 8 rung 0 (27) up 7000 bps weakest 5833 blocks/sink per node 508/e6208807 508/e6208807 508/e6208807
22:25:05.661 t=575.4 s daa 521 epoch 8 rung 0 (27) up 6833 bps weakest 6166 blocks/sink per node 521/faf3f2b3 521/faf3f2b3 521/faf3f2b3
22:25:20.684 t=590.4 s daa 537 epoch 8 rung 0 (27) up 7166 bps weakest 6166 blocks/sink per node 537/0cec2d26 537/0cec2d26 537/0cec2d26
22:25:24.690 epoch 8 -> 9 at daa 543, 594.4 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6666 bps (weakest of 7: 6333), down 0 bps, this node signals 1, step took effect at epoch 0
22:25:35.706 t=605.4 s daa 546 epoch 9 rung 0 (27) up 6666 bps weakest 6166 blocks/sink per node 546/75a259c6 546/75a259c6 546/75a259c6
22:25:50.729 t=620.5 s daa 573 epoch 9 rung 0 (27) up 6166 bps weakest 6000 blocks/sink per node 573/31d72980 573/31d72980 573/31d72980
22:26:05.750 t=635.5 s daa 581 epoch 9 rung 0 (27) up 6333 bps weakest 6166 blocks/sink per node 581/3cfe5b93 581/3cfe5b93 581/3cfe5b93
22:26:20.771 t=650.5 s daa 595 epoch 9 rung 0 (27) up 6333 bps weakest 6333 blocks/sink per node 595/84601051 595/84601051 595/84601051
22:26:25.777 epoch 9 -> 10 at daa 600, 655.5 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6166 bps (weakest of 7: 6166), down 0 bps, this node signals 1, step took effect at epoch 0
22:26:29.069 SUMMARY FAIL (expect step, signals up/up/none): no step; epochs seen e0:r0:0bps e1:r0:0bps e2:r0:0bps e3:r0:0bps e4:r0:0bps e5:r0:0bps e6:r0:0bps e7:r0:6166bps e8:r0:6333bps e9:r0:6333bps e10:r0:6166bps; chain bits {"none":198,"up":406} (6722 bps up); blocks 604 / 0; rejected miners 0/0/0 nodes 0/0/0; sinks 783087038f7096b1 783087038f7096b1 783087038f7096b1 at 603/603/603
22:26:29.069 PROGRAM ID epoch 0 seed 234e082d653dc69d reps 27: miners 26fc8f3decca98ed (3 of 3) cli at reps 26fc8f3decca98ed cli rung 0 26fc8f3decca98ed
22:26:29.069 PROGRAM ID epoch 1 seed 47cc74777f2067e9 reps 27: miners 4eca8ebd9601cb06 (3 of 3) cli at reps 4eca8ebd9601cb06 cli rung 0 4eca8ebd9601cb06
22:26:29.069 PROGRAM ID epoch 2 seed 885cd519d10a63af reps 27: miners d8692bc62ac8b7c1 (3 of 3) cli at reps d8692bc62ac8b7c1 cli rung 0 d8692bc62ac8b7c1
22:26:29.069 PROGRAM ID epoch 3 seed 422f6e379baea2d0 reps 27: miners 379b63489b302ff1 (3 of 3) cli at reps 379b63489b302ff1 cli rung 0 379b63489b302ff1
22:26:29.069 PROGRAM ID epoch 4 seed 9a339c3a0da7a629 reps 27: miners 5297fd1c191bef91 (3 of 3) cli at reps 5297fd1c191bef91 cli rung 0 5297fd1c191bef91
22:26:29.069 PROGRAM ID epoch 5 seed 630d437238ecfbd0 reps 27: miners 188721c15b83df97 (3 of 3) cli at reps 188721c15b83df97 cli rung 0 188721c15b83df97
22:26:29.069 PROGRAM ID epoch 6 seed 46d593aedc1dfd37 reps 27: miners 962b4d74afdf7f3a (3 of 3) cli at reps 962b4d74afdf7f3a cli rung 0 962b4d74afdf7f3a
22:26:29.069 PROGRAM ID epoch 7 seed 842561a101da3cc8 reps 27: miners 3fc4c1e7704bb8a5 (3 of 3) cli at reps 3fc4c1e7704bb8a5 cli rung 0 3fc4c1e7704bb8a5
22:26:29.069 PROGRAM ID epoch 8 seed c0cb56ba970dd961 reps 27: miners 0fa5001f8bc6ffc0 (3 of 3) cli at reps 0fa5001f8bc6ffc0 cli rung 0 0fa5001f8bc6ffc0
22:26:29.069 PROGRAM ID epoch 9 seed 3a647e8feb58ab1b reps 27: miners b0a067d5bf44497e (3 of 3) cli at reps b0a067d5bf44497e cli rung 0 b0a067d5bf44497e
22:26:29.069 PROGRAM ID epoch 10 seed 5d7277e6f83f9e4d reps 27: miners 84e6cfe07a3316f4 (3 of 3) cli at reps 84e6cfe07a3316f4 cli rung 0 84e6cfe07a3316f4
22:26:29.069 FAILED CHECK chain_carries_the_bits
22:26:29.069 FAILED CHECK template_stepped_to_rung_1
22:26:29.069 FAILED CHECK stepped_at_the_first_full_window_epoch
22:26:29.069 FAILED CHECK step_line_on_every_node_same_epoch
22:26:29.069 FAILED CHECK weakest_up_at_or_above_threshold
22:26:29.069 FAILED CHECK no_second_step_inside_seven_windows
22:26:29.069 FAILED CHECK blocks_on_both_sides
22:26:29.069 FAILED CHECK rung1_ids_equal_the_cli_rung1_id
22:26:29.069 FAILED CHECK rung1_ids_differ_from_the_same_seed_rung0_id
22:26:29.069 summary: /tmp/igneum-fast-time-ladder-failed-case/summary.json
CASE failed-case rc=1

View file

@ -0,0 +1,969 @@
{
"pass": true,
"expect": "no-step",
"signals": [
"up",
"up",
"none"
],
"checks": {
"zero_rejected_by_miners": true,
"zero_rejected_by_nodes": true,
"sinks_agree": true,
"block_counts_agree": true,
"miners_agree_on_every_program": true,
"ladder_line_on_every_node": true,
"every_node_signals_its_bits": true,
"every_epoch_class_v4": true,
"rung0_ids_equal_the_cli_rung0_id": true,
"chain_carries_the_bits": true,
"template_never_above_rung_0": true,
"no_step_line_on_any_node": true,
"ran_the_epochs": true,
"passed_the_first_full_window_epoch": true,
"up_share_under_threshold_on_chain": true
},
"window": 60,
"windows": 7,
"threshold_bps": 9000,
"epoch_blocks": 60,
"lead": 10,
"first_step_epoch": 8,
"node": "/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/target/release/igneumd",
"miner": "/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/target/release/igneum-miner",
"pow": "/srv/builds/igneum-wt-ladder/igneum-pow/target/release/igneum-pow",
"template_step": null,
"run_ended_at_s": 592.5,
"final_daa": 600,
"max_epoch_seen": 10,
"epochs": {
"0": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 0,
"at": 4.7,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 0,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"1": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 62,
"at": 96.8,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 7000,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"2": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 120,
"at": 156.9,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 7166,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"3": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 180,
"at": 213.9,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 7166,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"4": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 240,
"at": 273,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6833,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"5": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 300,
"at": 322.1,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6666,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"6": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 360,
"at": 378.2,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6333,
"up_weakest_bps": 0,
"down_bps": 0,
"step_epoch": 0
},
"7": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 420,
"at": 436.2,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 5833,
"up_weakest_bps": 5833,
"down_bps": 0,
"step_epoch": 0
},
"8": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 480,
"at": 490.3,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6166,
"up_weakest_bps": 5833,
"down_bps": 0,
"step_epoch": 0
},
"9": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 540,
"at": 541.4,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6666,
"up_weakest_bps": 5833,
"down_bps": 0,
"step_epoch": 0
},
"10": {
"class": 4,
"reps": 27,
"step": 0,
"firstSeenDaa": 600,
"at": 592.5,
"eraSeed": "234e082d653dc69db36d875b256d0f5f4a123b353d6aa069c3c7aa9aa6c46062",
"up_bps": 6833,
"up_weakest_bps": 5833,
"down_bps": 0,
"step_epoch": 0
}
},
"blocks": {
"total": 602,
"before_boundary": 602,
"after_boundary": 0,
"ladder_bits": {
"none": 200,
"up": 402
},
"object_bytes": {
"0": 602
},
"up_share_bps_on_chain": 6678
},
"programs": [
{
"epoch": 0,
"class": "v4",
"program_id": "26fc8f3decca98ed",
"seed": "234e082d653dc69d",
"miners": 3,
"disagree": false
},
{
"epoch": 1,
"class": "v4",
"program_id": "e0910c8d87135035",
"seed": "894676c6f3ebb385",
"miners": 3,
"disagree": false
},
{
"epoch": 2,
"class": "v4",
"program_id": "2eb05375a9d9077d",
"seed": "405d6592622d1af9",
"miners": 3,
"disagree": false
},
{
"epoch": 3,
"class": "v4",
"program_id": "0acdab9caca48471",
"seed": "9598288f0a520b69",
"miners": 3,
"disagree": false
},
{
"epoch": 4,
"class": "v4",
"program_id": "1df476827c33e263",
"seed": "9b6e5a23005f0998",
"miners": 3,
"disagree": false
},
{
"epoch": 5,
"class": "v4",
"program_id": "20003329ca5050d9",
"seed": "a0a54f6a687f94f4",
"miners": 3,
"disagree": false
},
{
"epoch": 6,
"class": "v4",
"program_id": "2e5cee148e6b82b1",
"seed": "1b20ae99061e034f",
"miners": 3,
"disagree": false
},
{
"epoch": 7,
"class": "v4",
"program_id": "d9babb74721f6cd0",
"seed": "5545b276021f1ad9",
"miners": 3,
"disagree": false
},
{
"epoch": 8,
"class": "v4",
"program_id": "94b5809680b13285",
"seed": "f959d016b2d47167",
"miners": 3,
"disagree": false
},
{
"epoch": 9,
"class": "v4",
"program_id": "e1cae64a2a12de45",
"seed": "24b28bef5cba00a9",
"miners": 3,
"disagree": false
},
{
"epoch": 10,
"class": "v4",
"program_id": "c7ce4aa0e96ba6b9",
"seed": "ccfc16e9107ae720",
"miners": 3,
"disagree": false
}
],
"program_id_rows": [
{
"epoch": 0,
"seed": "234e082d653dc69d",
"reps": 27,
"miners_id": "26fc8f3decca98ed",
"miners": 3,
"cli_rung0": "26fc8f3decca98ed",
"cli_at_reps": "26fc8f3decca98ed"
},
{
"epoch": 1,
"seed": "894676c6f3ebb385",
"reps": 27,
"miners_id": "e0910c8d87135035",
"miners": 3,
"cli_rung0": "e0910c8d87135035",
"cli_at_reps": "e0910c8d87135035"
},
{
"epoch": 2,
"seed": "405d6592622d1af9",
"reps": 27,
"miners_id": "2eb05375a9d9077d",
"miners": 3,
"cli_rung0": "2eb05375a9d9077d",
"cli_at_reps": "2eb05375a9d9077d"
},
{
"epoch": 3,
"seed": "9598288f0a520b69",
"reps": 27,
"miners_id": "0acdab9caca48471",
"miners": 3,
"cli_rung0": "0acdab9caca48471",
"cli_at_reps": "0acdab9caca48471"
},
{
"epoch": 4,
"seed": "9b6e5a23005f0998",
"reps": 27,
"miners_id": "1df476827c33e263",
"miners": 3,
"cli_rung0": "1df476827c33e263",
"cli_at_reps": "1df476827c33e263"
},
{
"epoch": 5,
"seed": "a0a54f6a687f94f4",
"reps": 27,
"miners_id": "20003329ca5050d9",
"miners": 3,
"cli_rung0": "20003329ca5050d9",
"cli_at_reps": "20003329ca5050d9"
},
{
"epoch": 6,
"seed": "1b20ae99061e034f",
"reps": 27,
"miners_id": "2e5cee148e6b82b1",
"miners": 3,
"cli_rung0": "2e5cee148e6b82b1",
"cli_at_reps": "2e5cee148e6b82b1"
},
{
"epoch": 7,
"seed": "5545b276021f1ad9",
"reps": 27,
"miners_id": "d9babb74721f6cd0",
"miners": 3,
"cli_rung0": "d9babb74721f6cd0",
"cli_at_reps": "d9babb74721f6cd0"
},
{
"epoch": 8,
"seed": "f959d016b2d47167",
"reps": 27,
"miners_id": "94b5809680b13285",
"miners": 3,
"cli_rung0": "94b5809680b13285",
"cli_at_reps": "94b5809680b13285"
},
{
"epoch": 9,
"seed": "24b28bef5cba00a9",
"reps": 27,
"miners_id": "e1cae64a2a12de45",
"miners": 3,
"cli_rung0": "e1cae64a2a12de45",
"cli_at_reps": "e1cae64a2a12de45"
},
{
"epoch": 10,
"seed": "ccfc16e9107ae720",
"reps": 27,
"miners_id": "c7ce4aa0e96ba6b9",
"miners": 3,
"cli_rung0": "c7ce4aa0e96ba6b9",
"cli_at_reps": "c7ce4aa0e96ba6b9"
}
],
"accepted_per_miner": [
202,
200,
199
],
"rejected_by_miners": [
0,
0,
0
],
"rejected_by_nodes": [
0,
0,
0
],
"sinks": [
"bfc435d62d7c659c",
"bfc435d62d7c659c",
"bfc435d62d7c659c"
],
"block_counts": [
601,
601,
601
],
"step_lines": [
[],
[],
[]
],
"samples": [
{
"t": 4.7,
"daa": 0,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 0,
"up_weakest_bps": 0,
"nodes": [
"0/234e082d",
"0/234e082d",
"0/234e082d"
]
},
{
"t": 19.7,
"daa": 1,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 0,
"up_weakest_bps": 0,
"nodes": [
"1/350fed25",
"1/350fed25",
"1/350fed25"
]
},
{
"t": 34.7,
"daa": 7,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 6666,
"up_weakest_bps": 0,
"nodes": [
"7/33ca6cd0",
"7/33ca6cd0",
"7/33ca6cd0"
]
},
{
"t": 49.8,
"daa": 12,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 6363,
"up_weakest_bps": 0,
"nodes": [
"12/70a47fc6",
"12/70a47fc6",
"12/70a47fc6"
]
},
{
"t": 64.8,
"daa": 28,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 6296,
"up_weakest_bps": 0,
"nodes": [
"28/1c7719a6",
"28/1c7719a6",
"28/1c7719a6"
]
},
{
"t": 79.8,
"daa": 36,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 6571,
"up_weakest_bps": 0,
"nodes": [
"36/72330841",
"36/72330841",
"36/72330841"
]
},
{
"t": 94.8,
"daa": 59,
"epoch": 0,
"step": 0,
"reps": 27,
"up_bps": 7068,
"up_weakest_bps": 0,
"nodes": [
"59/cf02d592",
"59/cf02d592",
"59/cf02d592"
]
},
{
"t": 109.8,
"daa": 74,
"epoch": 1,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 0,
"nodes": [
"74/c65d7a59",
"74/c65d7a59",
"74/c65d7a59"
]
},
{
"t": 124.8,
"daa": 85,
"epoch": 1,
"step": 0,
"reps": 27,
"up_bps": 7333,
"up_weakest_bps": 0,
"nodes": [
"85/e53510f4",
"85/e53510f4",
"85/e53510f4"
]
},
{
"t": 139.9,
"daa": 105,
"epoch": 1,
"step": 0,
"reps": 27,
"up_bps": 7166,
"up_weakest_bps": 0,
"nodes": [
"105/37769ced",
"105/37769ced",
"105/37769ced"
]
},
{
"t": 154.9,
"daa": 119,
"epoch": 1,
"step": 0,
"reps": 27,
"up_bps": 7166,
"up_weakest_bps": 0,
"nodes": [
"119/aa8e5664",
"119/aa8e5664",
"119/aa8e5664"
]
},
{
"t": 169.9,
"daa": 131,
"epoch": 2,
"step": 0,
"reps": 27,
"up_bps": 7500,
"up_weakest_bps": 0,
"nodes": [
"131/bf6ed8b2",
"131/bf6ed8b2",
"131/bf6ed8b2"
]
},
{
"t": 184.9,
"daa": 150,
"epoch": 2,
"step": 0,
"reps": 27,
"up_bps": 8166,
"up_weakest_bps": 0,
"nodes": [
"150/6a31bf2e",
"150/6a31bf2e",
"150/6a31bf2e"
]
},
{
"t": 199.9,
"daa": 165,
"epoch": 2,
"step": 0,
"reps": 27,
"up_bps": 7500,
"up_weakest_bps": 0,
"nodes": [
"165/83f8d3f6",
"165/83f8d3f6",
"165/83f8d3f6"
]
},
{
"t": 215,
"daa": 181,
"epoch": 3,
"step": 0,
"reps": 27,
"up_bps": 7000,
"up_weakest_bps": 0,
"nodes": [
"181/5be6a01e",
"181/5be6a01e",
"181/5be6a01e"
]
},
{
"t": 230,
"daa": 202,
"epoch": 3,
"step": 0,
"reps": 27,
"up_bps": 6666,
"up_weakest_bps": 0,
"nodes": [
"202/5663af4a",
"202/5663af4a",
"202/5663af4a"
]
},
{
"t": 245,
"daa": 216,
"epoch": 3,
"step": 0,
"reps": 27,
"up_bps": 5833,
"up_weakest_bps": 0,
"nodes": [
"216/3d231639",
"216/3d231639",
"216/3d231639"
]
},
{
"t": 260,
"daa": 228,
"epoch": 3,
"step": 0,
"reps": 27,
"up_bps": 6500,
"up_weakest_bps": 0,
"nodes": [
"228/88370ed6",
"228/88370ed6",
"228/88370ed6"
]
},
{
"t": 275,
"daa": 241,
"epoch": 4,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 0,
"nodes": [
"241/a2dbefc3",
"241/a2dbefc3",
"241/a2dbefc3"
]
},
{
"t": 290,
"daa": 248,
"epoch": 4,
"step": 0,
"reps": 27,
"up_bps": 6500,
"up_weakest_bps": 0,
"nodes": [
"248/0815d37e",
"248/0815d37e",
"248/0815d37e"
]
},
{
"t": 305.1,
"daa": 267,
"epoch": 4,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 0,
"nodes": [
"267/09bb3297",
"267/09bb3297",
"267/09bb3297"
]
},
{
"t": 320.1,
"daa": 297,
"epoch": 4,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 0,
"nodes": [
"297/af91ef29",
"297/af91ef29",
"297/af91ef29"
]
},
{
"t": 335.1,
"daa": 312,
"epoch": 5,
"step": 0,
"reps": 27,
"up_bps": 6666,
"up_weakest_bps": 0,
"nodes": [
"312/a015f07f",
"312/a015f07f",
"312/a015f07f"
]
},
{
"t": 350.1,
"daa": 330,
"epoch": 5,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 0,
"nodes": [
"330/c9f57620",
"330/c9f57620",
"330/c9f57620"
]
},
{
"t": 365.1,
"daa": 345,
"epoch": 5,
"step": 0,
"reps": 27,
"up_bps": 6949,
"up_weakest_bps": 0,
"nodes": [
"345/fe4111b9",
"345/fe4111b9",
"345/fe4111b9"
]
},
{
"t": 380.2,
"daa": 360,
"epoch": 6,
"step": 0,
"reps": 27,
"up_bps": 6333,
"up_weakest_bps": 0,
"nodes": [
"360/2acb1c9a",
"360/2acb1c9a",
"360/2acb1c9a"
]
},
{
"t": 395.2,
"daa": 368,
"epoch": 6,
"step": 0,
"reps": 27,
"up_bps": 6779,
"up_weakest_bps": 5714,
"nodes": [
"368/853bece4",
"368/853bece4",
"368/853bece4"
]
},
{
"t": 410.2,
"daa": 390,
"epoch": 6,
"step": 0,
"reps": 27,
"up_bps": 5666,
"up_weakest_bps": 5666,
"nodes": [
"390/9101251e",
"389/6d70c5a8",
"390/9101251e"
]
},
{
"t": 425.2,
"daa": 404,
"epoch": 6,
"step": 0,
"reps": 27,
"up_bps": 5500,
"up_weakest_bps": 5500,
"nodes": [
"404/89a08ca7",
"404/89a08ca7",
"404/89a08ca7"
]
},
{
"t": 440.2,
"daa": 421,
"epoch": 7,
"step": 0,
"reps": 27,
"up_bps": 6000,
"up_weakest_bps": 6000,
"nodes": [
"421/a39e260b",
"421/a39e260b",
"421/a39e260b"
]
},
{
"t": 455.3,
"daa": 437,
"epoch": 7,
"step": 0,
"reps": 27,
"up_bps": 5666,
"up_weakest_bps": 5666,
"nodes": [
"437/9782bb5e",
"437/9782bb5e",
"437/9782bb5e"
]
},
{
"t": 470.3,
"daa": 456,
"epoch": 7,
"step": 0,
"reps": 27,
"up_bps": 5932,
"up_weakest_bps": 5833,
"nodes": [
"456/2a6dea3c",
"456/2a6dea3c",
"456/2a6dea3c"
]
},
{
"t": 485.3,
"daa": 473,
"epoch": 7,
"step": 0,
"reps": 27,
"up_bps": 6500,
"up_weakest_bps": 5500,
"nodes": [
"473/ab842458",
"473/ab842458",
"473/ab842458"
]
},
{
"t": 500.3,
"daa": 488,
"epoch": 8,
"step": 0,
"reps": 27,
"up_bps": 6000,
"up_weakest_bps": 5666,
"nodes": [
"488/a16f988f",
"488/a16f988f",
"488/a16f988f"
]
},
{
"t": 515.4,
"daa": 505,
"epoch": 8,
"step": 0,
"reps": 27,
"up_bps": 6333,
"up_weakest_bps": 5737,
"nodes": [
"505/915b20b6",
"505/915b20b6",
"505/915b20b6"
]
},
{
"t": 530.4,
"daa": 527,
"epoch": 8,
"step": 0,
"reps": 27,
"up_bps": 6666,
"up_weakest_bps": 5333,
"nodes": [
"527/7161dfa6",
"527/7161dfa6",
"527/7161dfa6"
]
},
{
"t": 545.4,
"daa": 541,
"epoch": 9,
"step": 0,
"reps": 27,
"up_bps": 6666,
"up_weakest_bps": 6000,
"nodes": [
"541/5fcc65e3",
"541/5fcc65e3",
"541/5fcc65e3"
]
},
{
"t": 560.4,
"daa": 562,
"epoch": 9,
"step": 0,
"reps": 27,
"up_bps": 6833,
"up_weakest_bps": 5666,
"nodes": [
"562/673f1e20",
"562/673f1e20",
"562/673f1e20"
]
},
{
"t": 575.4,
"daa": 577,
"epoch": 9,
"step": 0,
"reps": 27,
"up_bps": 7166,
"up_weakest_bps": 5833,
"nodes": [
"577/67af1166",
"577/67af1166",
"577/67af1166"
]
},
{
"t": 590.5,
"daa": 596,
"epoch": 9,
"step": 0,
"reps": 27,
"up_bps": 7000,
"up_weakest_bps": 5666,
"nodes": [
"596/2dd560ca",
"596/2dd560ca",
"596/2dd560ca"
]
}
]
}

View file

@ -0,0 +1,73 @@
22:37:08.927 signals up/up/none, expect no-step; class v4 from genesis, the ladder active from DAA 0, window 60 DAA x 7 (the first epoch that can step is 8, DAA 480); 60 DAA per epoch, lead 10; run 900 s or 10 epochs
22:37:10.165 n0 up pid 1658675 json 29722 p2p 29721, signals up
22:37:11.374 n1 up pid 1659188 json 29732 p2p 29731, signals up
22:37:12.584 n2 up pid 1659706 json 29742 p2p 29741, signals none
22:37:12.584 n0: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:37:12.585 n1: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:37:12.585 n2: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals none
22:37:12.585 n0 digest: b1f606bede788b70
22:37:13.597 epoch -1 -> 0 at daa 0, 4.7 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 0 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:37:13.598 t=4.7 s daa 0 epoch 0 rung 0 (27) up 0 bps weakest 0 blocks/sink per node 0/234e082d 0/234e082d 0/234e082d
22:37:28.640 t=19.7 s daa 1 epoch 0 rung 0 (27) up 0 bps weakest 0 blocks/sink per node 1/350fed25 1/350fed25 1/350fed25
22:37:43.659 t=34.7 s daa 7 epoch 0 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 7/33ca6cd0 7/33ca6cd0 7/33ca6cd0
22:37:58.679 t=49.8 s daa 12 epoch 0 rung 0 (27) up 6363 bps weakest 0 blocks/sink per node 12/70a47fc6 12/70a47fc6 12/70a47fc6
22:38:13.696 t=64.8 s daa 28 epoch 0 rung 0 (27) up 6296 bps weakest 0 blocks/sink per node 28/1c7719a6 28/1c7719a6 28/1c7719a6
22:38:28.714 t=79.8 s daa 36 epoch 0 rung 0 (27) up 6571 bps weakest 0 blocks/sink per node 36/72330841 36/72330841 36/72330841
22:38:43.731 t=94.8 s daa 59 epoch 0 rung 0 (27) up 7068 bps weakest 0 blocks/sink per node 59/cf02d592 59/cf02d592 59/cf02d592
22:38:45.733 epoch 0 -> 1 at daa 62, 96.8 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:38:58.747 t=109.8 s daa 74 epoch 1 rung 0 (27) up 6833 bps weakest 0 blocks/sink per node 74/c65d7a59 74/c65d7a59 74/c65d7a59
22:39:13.765 t=124.8 s daa 85 epoch 1 rung 0 (27) up 7333 bps weakest 0 blocks/sink per node 85/e53510f4 85/e53510f4 85/e53510f4
22:39:28.785 t=139.9 s daa 105 epoch 1 rung 0 (27) up 7166 bps weakest 0 blocks/sink per node 105/37769ced 105/37769ced 105/37769ced
22:39:43.805 t=154.9 s daa 119 epoch 1 rung 0 (27) up 7166 bps weakest 0 blocks/sink per node 119/aa8e5664 119/aa8e5664 119/aa8e5664
22:39:45.808 epoch 1 -> 2 at daa 120, 156.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7166 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:39:58.824 t=169.9 s daa 131 epoch 2 rung 0 (27) up 7500 bps weakest 0 blocks/sink per node 131/bf6ed8b2 131/bf6ed8b2 131/bf6ed8b2
22:40:13.843 t=184.9 s daa 150 epoch 2 rung 0 (27) up 8166 bps weakest 0 blocks/sink per node 150/6a31bf2e 150/6a31bf2e 150/6a31bf2e
22:40:28.862 t=199.9 s daa 165 epoch 2 rung 0 (27) up 7500 bps weakest 0 blocks/sink per node 165/83f8d3f6 165/83f8d3f6 165/83f8d3f6
22:40:42.878 epoch 2 -> 3 at daa 180, 213.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 7166 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:40:43.880 t=215.0 s daa 181 epoch 3 rung 0 (27) up 7000 bps weakest 0 blocks/sink per node 181/5be6a01e 181/5be6a01e 181/5be6a01e
22:40:58.901 t=230.0 s daa 202 epoch 3 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 202/5663af4a 202/5663af4a 202/5663af4a
22:41:13.919 t=245.0 s daa 216 epoch 3 rung 0 (27) up 5833 bps weakest 0 blocks/sink per node 216/3d231639 216/3d231639 216/3d231639
22:41:28.939 t=260.0 s daa 228 epoch 3 rung 0 (27) up 6500 bps weakest 0 blocks/sink per node 228/88370ed6 228/88370ed6 228/88370ed6
22:41:41.955 epoch 3 -> 4 at daa 240, 273.0 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6833 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:41:43.958 t=275.0 s daa 241 epoch 4 rung 0 (27) up 6833 bps weakest 0 blocks/sink per node 241/a2dbefc3 241/a2dbefc3 241/a2dbefc3
22:41:58.976 t=290.0 s daa 248 epoch 4 rung 0 (27) up 6500 bps weakest 0 blocks/sink per node 248/0815d37e 248/0815d37e 248/0815d37e
22:42:13.998 t=305.1 s daa 267 epoch 4 rung 0 (27) up 6833 bps weakest 0 blocks/sink per node 267/09bb3297 267/09bb3297 267/09bb3297
22:42:29.017 t=320.1 s daa 297 epoch 4 rung 0 (27) up 6833 bps weakest 0 blocks/sink per node 297/af91ef29 297/af91ef29 297/af91ef29
22:42:31.020 epoch 4 -> 5 at daa 300, 322.1 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6666 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:42:44.036 t=335.1 s daa 312 epoch 5 rung 0 (27) up 6666 bps weakest 0 blocks/sink per node 312/a015f07f 312/a015f07f 312/a015f07f
22:42:59.055 t=350.1 s daa 330 epoch 5 rung 0 (27) up 6833 bps weakest 0 blocks/sink per node 330/c9f57620 330/c9f57620 330/c9f57620
22:43:14.072 t=365.1 s daa 345 epoch 5 rung 0 (27) up 6949 bps weakest 0 blocks/sink per node 345/fe4111b9 345/fe4111b9 345/fe4111b9
22:43:27.092 epoch 5 -> 6 at daa 360, 378.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6333 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:43:29.095 t=380.2 s daa 360 epoch 6 rung 0 (27) up 6333 bps weakest 0 blocks/sink per node 360/2acb1c9a 360/2acb1c9a 360/2acb1c9a
22:43:44.112 t=395.2 s daa 368 epoch 6 rung 0 (27) up 6779 bps weakest 5714 blocks/sink per node 368/853bece4 368/853bece4 368/853bece4
22:43:59.132 t=410.2 s daa 390 epoch 6 rung 0 (27) up 5666 bps weakest 5666 blocks/sink per node 390/9101251e 389/6d70c5a8 390/9101251e
22:44:14.153 t=425.2 s daa 404 epoch 6 rung 0 (27) up 5500 bps weakest 5500 blocks/sink per node 404/89a08ca7 404/89a08ca7 404/89a08ca7
22:44:25.167 epoch 6 -> 7 at daa 420, 436.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 5833 bps (weakest of 7: 5833), down 0 bps, this node signals 1, step took effect at epoch 0
22:44:29.172 t=440.2 s daa 421 epoch 7 rung 0 (27) up 6000 bps weakest 6000 blocks/sink per node 421/a39e260b 421/a39e260b 421/a39e260b
22:44:44.192 t=455.3 s daa 437 epoch 7 rung 0 (27) up 5666 bps weakest 5666 blocks/sink per node 437/9782bb5e 437/9782bb5e 437/9782bb5e
22:44:59.214 t=470.3 s daa 456 epoch 7 rung 0 (27) up 5932 bps weakest 5833 blocks/sink per node 456/2a6dea3c 456/2a6dea3c 456/2a6dea3c
22:45:14.235 t=485.3 s daa 473 epoch 7 rung 0 (27) up 6500 bps weakest 5500 blocks/sink per node 473/ab842458 473/ab842458 473/ab842458
22:45:19.242 epoch 7 -> 8 at daa 480, 490.3 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6166 bps (weakest of 7: 5833), down 0 bps, this node signals 1, step took effect at epoch 0
22:45:29.257 t=500.3 s daa 488 epoch 8 rung 0 (27) up 6000 bps weakest 5666 blocks/sink per node 488/a16f988f 488/a16f988f 488/a16f988f
22:45:44.278 t=515.4 s daa 505 epoch 8 rung 0 (27) up 6333 bps weakest 5737 blocks/sink per node 505/915b20b6 505/915b20b6 505/915b20b6
22:45:59.304 t=530.4 s daa 527 epoch 8 rung 0 (27) up 6666 bps weakest 5333 blocks/sink per node 527/7161dfa6 527/7161dfa6 527/7161dfa6
22:46:10.317 epoch 8 -> 9 at daa 540, 541.4 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6666 bps (weakest of 7: 5833), down 0 bps, this node signals 1, step took effect at epoch 0
22:46:14.324 t=545.4 s daa 541 epoch 9 rung 0 (27) up 6666 bps weakest 6000 blocks/sink per node 541/5fcc65e3 541/5fcc65e3 541/5fcc65e3
22:46:29.343 t=560.4 s daa 562 epoch 9 rung 0 (27) up 6833 bps weakest 5666 blocks/sink per node 562/673f1e20 562/673f1e20 562/673f1e20
22:46:44.364 t=575.4 s daa 577 epoch 9 rung 0 (27) up 7166 bps weakest 5833 blocks/sink per node 577/67af1166 577/67af1166 577/67af1166
22:46:59.385 t=590.5 s daa 596 epoch 9 rung 0 (27) up 7000 bps weakest 5666 blocks/sink per node 596/2dd560ca 596/2dd560ca 596/2dd560ca
22:47:01.388 epoch 9 -> 10 at daa 600, 592.5 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 6833 bps (weakest of 7: 5833), down 0 bps, this node signals 1, step took effect at epoch 0
22:47:04.711 SUMMARY PASS (expect no-step, signals up/up/none): no step; epochs seen e0:r0:0bps e1:r0:0bps e2:r0:0bps e3:r0:0bps e4:r0:0bps e5:r0:0bps e6:r0:0bps e7:r0:5833bps e8:r0:5833bps e9:r0:5833bps e10:r0:5833bps; chain bits {"none":200,"up":402} (6678 bps up); blocks 602 / 0; rejected miners 0/0/0 nodes 0/0/0; sinks bfc435d62d7c659c bfc435d62d7c659c bfc435d62d7c659c at 601/601/601
22:47:04.711 PROGRAM ID epoch 0 seed 234e082d653dc69d reps 27: miners 26fc8f3decca98ed (3 of 3) cli at reps 26fc8f3decca98ed cli rung 0 26fc8f3decca98ed
22:47:04.711 PROGRAM ID epoch 1 seed 894676c6f3ebb385 reps 27: miners e0910c8d87135035 (3 of 3) cli at reps e0910c8d87135035 cli rung 0 e0910c8d87135035
22:47:04.711 PROGRAM ID epoch 2 seed 405d6592622d1af9 reps 27: miners 2eb05375a9d9077d (3 of 3) cli at reps 2eb05375a9d9077d cli rung 0 2eb05375a9d9077d
22:47:04.711 PROGRAM ID epoch 3 seed 9598288f0a520b69 reps 27: miners 0acdab9caca48471 (3 of 3) cli at reps 0acdab9caca48471 cli rung 0 0acdab9caca48471
22:47:04.711 PROGRAM ID epoch 4 seed 9b6e5a23005f0998 reps 27: miners 1df476827c33e263 (3 of 3) cli at reps 1df476827c33e263 cli rung 0 1df476827c33e263
22:47:04.711 PROGRAM ID epoch 5 seed a0a54f6a687f94f4 reps 27: miners 20003329ca5050d9 (3 of 3) cli at reps 20003329ca5050d9 cli rung 0 20003329ca5050d9
22:47:04.711 PROGRAM ID epoch 6 seed 1b20ae99061e034f reps 27: miners 2e5cee148e6b82b1 (3 of 3) cli at reps 2e5cee148e6b82b1 cli rung 0 2e5cee148e6b82b1
22:47:04.711 PROGRAM ID epoch 7 seed 5545b276021f1ad9 reps 27: miners d9babb74721f6cd0 (3 of 3) cli at reps d9babb74721f6cd0 cli rung 0 d9babb74721f6cd0
22:47:04.711 PROGRAM ID epoch 8 seed f959d016b2d47167 reps 27: miners 94b5809680b13285 (3 of 3) cli at reps 94b5809680b13285 cli rung 0 94b5809680b13285
22:47:04.711 PROGRAM ID epoch 9 seed 24b28bef5cba00a9 reps 27: miners e1cae64a2a12de45 (3 of 3) cli at reps e1cae64a2a12de45 cli rung 0 e1cae64a2a12de45
22:47:04.711 PROGRAM ID epoch 10 seed ccfc16e9107ae720 reps 27: miners c7ce4aa0e96ba6b9 (3 of 3) cli at reps c7ce4aa0e96ba6b9 cli rung 0 c7ce4aa0e96ba6b9
22:47:04.711 summary: /tmp/igneum-fast-time-ladder-no-step/summary.json
CASE no-step rc=0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
22:26:30.617 signals up/up/up, expect step; class v4 from genesis, the ladder active from DAA 0, window 60 DAA x 7 (the first epoch that can step is 8, DAA 480); 60 DAA per epoch, lead 10; run 900 s or 10 epochs
22:26:31.854 n0 up pid 1601845 json 29722 p2p 29721, signals up
22:26:33.062 n1 up pid 1602372 json 29732 p2p 29731, signals up
22:26:34.272 n2 up pid 1602849 json 29742 p2p 29741, signals up
22:26:34.273 n0: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:26:34.273 n1: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:26:34.273 n2: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:26:34.274 n0 digest: b1f606bede788b70
22:26:35.285 epoch -1 -> 0 at daa 0, 4.7 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 0 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:26:35.286 t=4.7 s daa 0 epoch 0 rung 0 (27) up 0 bps weakest 0 blocks/sink per node 0/234e082d 0/234e082d 0/234e082d
22:26:50.326 t=19.7 s daa 3 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 3/249577e0 3/249577e0 3/249577e0
22:27:05.344 t=34.7 s daa 9 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 9/f5f30338 9/f5f30338 9/f5f30338
22:27:20.362 t=49.7 s daa 13 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 13/9a570e80 13/9a570e80 13/9a570e80
22:27:35.380 t=64.8 s daa 21 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 21/e9eb6357 21/e9eb6357 21/e9eb6357
22:27:50.394 t=79.8 s daa 43 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 43/231339b1 43/231339b1 43/231339b1
22:28:05.413 t=94.8 s daa 59 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 59/b7fc02c8 59/b7fc02c8 59/b7fc02c8
22:28:06.415 epoch 0 -> 1 at daa 60, 95.8 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:28:20.430 t=109.8 s daa 72 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 72/b2806522 72/b2806522 72/b2806522
22:28:35.450 t=124.8 s daa 94 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 94/79f760e8 94/79f760e8 94/79f760e8
22:28:50.469 t=139.9 s daa 113 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 113/5561b157 113/5561b157 113/5561b157
22:28:59.481 epoch 1 -> 2 at daa 120, 148.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:29:05.490 t=154.9 s daa 126 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 126/2349621f 126/2349621f 126/2349621f
22:29:20.507 t=169.9 s daa 140 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 140/bc35ba71 140/bc35ba71 140/bc35ba71
22:29:35.525 t=184.9 s daa 156 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 156/63d8089e 156/63d8089e 156/63d8089e
22:29:50.544 t=199.9 s daa 171 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 171/18cedc1e 171/18cedc1e 171/18cedc1e
22:30:01.556 epoch 2 -> 3 at daa 180, 210.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:30:05.560 t=214.9 s daa 183 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 183/72878aa2 183/72878aa2 183/72878aa2
22:30:20.580 t=230.0 s daa 194 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 194/d912a5a6 194/d912a5a6 194/d912a5a6
22:30:35.597 t=245.0 s daa 213 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 213/c32e5855 213/c32e5855 213/c32e5855
22:30:50.614 t=260.0 s daa 230 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 230/6e0fe9f3 230/6e0fe9f3 230/6e0fe9f3
22:30:55.619 epoch 3 -> 4 at daa 240, 265.0 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:31:05.626 t=275.0 s daa 254 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 254/5374343f 254/5374343f 254/5374343f
22:31:20.640 t=290.0 s daa 270 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 270/2102c92b 270/2102c92b 270/2102c92b
22:31:35.653 t=305.0 s daa 283 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 283/96ca2f27 283/96ca2f27 283/96ca2f27
22:31:50.672 t=320.1 s daa 295 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 295/d8305233 295/d8305233 295/d8305233
22:31:54.677 epoch 4 -> 5 at daa 300, 324.1 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:32:05.692 t=335.1 s daa 308 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 308/a6a3dd91 308/a6a3dd91 308/a6a3dd91
22:32:20.714 t=350.1 s daa 322 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 322/6819393b 322/6819393b 322/6819393b
22:32:35.737 t=365.1 s daa 346 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 346/354f160d 346/354f160d 346/354f160d
22:32:48.751 epoch 5 -> 6 at daa 360, 378.1 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:32:50.754 t=380.1 s daa 361 epoch 6 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 361/af58d2f8 361/af58d2f8 361/af58d2f8
22:33:05.772 t=395.2 s daa 382 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 382/71d14149 382/71d14149 382/71d14149
22:33:20.794 t=410.2 s daa 399 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 399/9fb39463 399/9fb39463 399/9fb39463
22:33:35.817 t=425.2 s daa 407 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 407/c60fa280 407/c60fa280 407/c60fa280
22:33:48.836 epoch 6 -> 7 at daa 420, 438.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 0
22:33:50.840 t=440.2 s daa 421 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 421/237fb92b 421/237fb92b 421/237fb92b
22:34:05.864 t=455.2 s daa 435 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 435/4b441312 435/4b441312 435/4b441312
22:34:20.885 t=470.3 s daa 453 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 453/cd6b10e3 453/cd6b10e3 453/cd6b10e3
22:34:35.908 t=485.3 s daa 468 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 468/07607c5a 468/07607c5a 468/07607c5a
22:34:50.929 epoch 7 -> 8 at daa 480, 500.3 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:34:50.930 LADDER STEP: the template is rung 1 (35 shadow passes) from epoch 8 (daa 480) at 500.3 s wall
22:34:50.930 t=500.3 s daa 480 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 480/446f5cad 480/446f5cad 480/446f5cad
22:35:05.952 t=515.3 s daa 494 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 494/cb995010 494/cb995010 494/cb995010
22:35:20.972 t=530.4 s daa 507 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 507/6fdd3fa6 507/6fdd3fa6 507/6fdd3fa6
22:35:35.991 t=545.4 s daa 523 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 523/9c372f3f 523/9c372f3f 523/9c372f3f
22:35:51.015 t=560.4 s daa 539 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 539/89195e3c 539/89195e3c 539/89195e3c
22:35:52.017 epoch 8 -> 9 at daa 540, 561.4 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:36:06.037 t=575.4 s daa 555 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 555/e9ac1c74 555/e9ac1c74 555/e9ac1c74
22:36:21.059 t=590.4 s daa 568 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 568/f092f0fb 568/f092f0fb 568/f092f0fb
22:36:36.084 t=605.5 s daa 583 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 583/e3ffdab3 583/e3ffdab3 583/e3ffdab3
22:36:51.107 t=620.5 s daa 592 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 592/27682e21 592/27682e21 592/27682e21
22:36:57.114 epoch 9 -> 10 at daa 601, 626.5 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:37:07.379 SUMMARY FAIL (expect step, signals up/up/up): rung 1 (35 passes) from epoch 8 at DAA 480; epochs seen e0:r0:0bps e1:r0:0bps e2:r0:0bps e3:r0:0bps e4:r0:0bps e5:r0:0bps e6:r0:0bps e7:r0:10000bps e8:r1:10000bps e9:r1:10000bps e10:r1:10000bps; chain bits {"none":1,"up":612} (9984 bps up); blocks 481 / 132; rejected miners 0/0/0 nodes 0/0/0; sinks c4fb461282cf9321 c4fb461282cf9321 c4fb461282cf9321 at 612/612/612
22:37:07.379 PROGRAM ID epoch 0 seed 234e082d653dc69d reps 27: miners 26fc8f3decca98ed (3 of 3) cli at reps 26fc8f3decca98ed cli rung 0 26fc8f3decca98ed
22:37:07.379 PROGRAM ID epoch 1 seed f3f15962195b3ed5 reps 27: miners 8a3027b727010ebb (3 of 3) cli at reps 8a3027b727010ebb cli rung 0 8a3027b727010ebb
22:37:07.379 PROGRAM ID epoch 2 seed 08861cd8892dd5bf reps 27: miners 5ce393c9689621a3 (3 of 3) cli at reps 5ce393c9689621a3 cli rung 0 5ce393c9689621a3
22:37:07.379 PROGRAM ID epoch 3 seed 1ea70b68ae751655 reps 27: miners 1e113fa683988a88 (3 of 3) cli at reps 1e113fa683988a88 cli rung 0 1e113fa683988a88
22:37:07.379 PROGRAM ID epoch 4 seed 6e0fe9f3461ed4b7 reps 27: miners f78fdc10877a6a53 (3 of 3) cli at reps f78fdc10877a6a53 cli rung 0 f78fdc10877a6a53
22:37:07.379 PROGRAM ID epoch 5 seed 756db10abdc2caa1 reps 27: miners c54ce8f09a0197fd (3 of 3) cli at reps c54ce8f09a0197fd cli rung 0 c54ce8f09a0197fd
22:37:07.379 PROGRAM ID epoch 6 seed 71066f551557cca4 reps 27: miners 4ee83f583fd0d3a6 (3 of 3) cli at reps 4ee83f583fd0d3a6 cli rung 0 4ee83f583fd0d3a6
22:37:07.379 PROGRAM ID epoch 7 seed f2f76ce991184adb reps 27: miners 32c35a3b0dfb22e8 (3 of 3) cli at reps 32c35a3b0dfb22e8 cli rung 0 32c35a3b0dfb22e8
22:37:07.379 PROGRAM ID epoch 8 seed beb8b40fec4bf2c8 reps 35: miners d4cfc28a1c525077 (3 of 3) cli at reps ea49bc6e870eb34c cli rung 0 ea49bc6e870eb34c
22:37:07.379 PROGRAM ID epoch 9 seed 04d782fc0d5def41 reps 35: miners 35c3a81b81cc2b02 (3 of 3) cli at reps 1fe5e84ead8e71e3 cli rung 0 1fe5e84ead8e71e3
22:37:07.379 PROGRAM ID epoch 10 seed 3a51ee194be8d47b reps 35: miners 804c18c3a462f05c (3 of 3) cli at reps 4e6c5bbf6463131d cli rung 0 4e6c5bbf6463131d
22:37:07.379 FAILED CHECK chain_carries_the_bits
22:37:07.379 FAILED CHECK rung1_ids_equal_the_cli_rung1_id
22:37:07.379 summary: /tmp/igneum-fast-time-ladder-step/summary.json
CASE step rc=1

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
22:47:26.794 signals up/up/up, expect step; class v4 from genesis, the ladder active from DAA 0, window 60 DAA x 7 (the first epoch that can step is 8, DAA 480); 60 DAA per epoch, lead 10; run 900 s or 10 epochs
22:47:28.032 n0 up pid 1734618 json 29722 p2p 29721, signals up
22:47:29.240 n1 up pid 1735138 json 29732 p2p 29731, signals up
22:47:30.451 n2 up pid 1735605 json 29742 p2p 29741, signals up
22:47:30.452 n0: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:47:30.452 n1: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:47:30.453 n2: Latency ladder from the override file: rungs 27, 35, 53, [88], [173], [267] shadow passes (brackets: inadmissible, never entered), active from epoch 0 (DAA score 0 rounded up to the epoch boundary at 0), one rung per decision at 90 percent of blue blocks in each of 7 consecutive windows of 60 DAA ending at an epoch's seed block | this node signals up
22:47:30.453 n0 digest: b1f606bede788b70
22:47:31.464 epoch -1 -> 0 at daa 0, 4.7 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 0 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:47:31.468 t=4.7 s daa 0 epoch 0 rung 0 (27) up 0 bps weakest 0 blocks/sink per node 0/234e082d 0/234e082d 0/234e082d
22:47:46.507 t=19.7 s daa 2 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 2/93be64d1 2/93be64d1 2/93be64d1
22:48:01.523 t=34.7 s daa 12 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 12/702814dd 12/702814dd 12/702814dd
22:48:16.542 t=49.7 s daa 25 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 25/d0d9d954 25/d0d9d954 25/d0d9d954
22:48:31.562 t=64.8 s daa 44 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 44/59a39e34 44/59a39e34 43/fbe3301e
22:48:46.581 t=79.8 s daa 58 epoch 0 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 58/5d32aa5d 58/5d32aa5d 58/5d32aa5d
22:48:49.585 epoch 0 -> 1 at daa 60, 82.8 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:49:01.599 t=94.8 s daa 72 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 72/d6ae0009 72/d6ae0009 72/d6ae0009
22:49:16.617 t=109.8 s daa 89 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 89/37c27f2c 89/37c27f2c 89/37c27f2c
22:49:31.633 t=124.8 s daa 102 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 102/cca31beb 102/cca31beb 102/cca31beb
22:49:46.650 t=139.9 s daa 115 epoch 1 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 115/22238404 115/22238404 115/22238404
22:49:49.654 epoch 1 -> 2 at daa 122, 142.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:50:01.669 t=154.9 s daa 128 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 128/3b025ca2 128/3b025ca2 128/3b025ca2
22:50:16.687 t=169.9 s daa 148 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 148/eb6fd081 148/eb6fd081 148/eb6fd081
22:50:31.708 t=184.9 s daa 162 epoch 2 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 162/f7591e43 162/f7591e43 162/f7591e43
22:50:45.727 epoch 2 -> 3 at daa 180, 198.9 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:50:46.729 t=199.9 s daa 180 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 180/8508ae27 180/8508ae27 180/8508ae27
22:51:01.749 t=215.0 s daa 192 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 192/d3cae989 192/d3cae989 192/d3cae989
22:51:16.769 t=230.0 s daa 205 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 205/b7e9b38a 205/b7e9b38a 205/b7e9b38a
22:51:31.789 t=245.0 s daa 224 epoch 3 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 224/4b576b96 224/4b576b96 224/4b576b96
22:51:44.803 epoch 3 -> 4 at daa 240, 258.0 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:51:46.806 t=260.0 s daa 242 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 242/21eac0e7 242/21eac0e7 242/21eac0e7
22:52:01.826 t=275.0 s daa 263 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 263/3a7f5eb1 263/3a7f5eb1 263/3a7f5eb1
22:52:16.845 t=290.1 s daa 276 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 276/9fa09972 276/9fa09972 276/9fa09972
22:52:31.861 t=305.1 s daa 292 epoch 4 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 292/c5cfe43d 292/c5cfe43d 292/c5cfe43d
22:52:41.874 epoch 4 -> 5 at daa 301, 315.1 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:52:46.877 t=320.1 s daa 306 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 306/8a223b88 306/8a223b88 306/8a223b88
22:53:01.894 t=335.1 s daa 319 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 319/3818e422 319/3818e422 319/3818e422
22:53:16.914 t=350.1 s daa 333 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 333/5d9f5abe 333/5d9f5abe 333/5d9f5abe
22:53:31.934 t=365.1 s daa 345 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 345/c186686a 345/c186686a 345/c186686a
22:53:46.950 t=380.2 s daa 358 epoch 5 rung 0 (27) up 10000 bps weakest 0 blocks/sink per node 358/71308cca 358/71308cca 358/71308cca
22:53:49.955 epoch 5 -> 6 at daa 360, 383.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 0), down 0 bps, this node signals 1, step took effect at epoch 0
22:54:01.971 t=395.2 s daa 373 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 373/dc7f7051 373/dc7f7051 373/dc7f7051
22:54:16.990 t=410.2 s daa 392 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 392/932e6a12 392/932e6a12 392/932e6a12
22:54:32.010 t=425.2 s daa 401 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 401/c76de149 401/c76de149 401/c76de149
22:54:47.031 t=440.2 s daa 418 epoch 6 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 418/3bfd34a2 418/3bfd34a2 418/3bfd34a2
22:54:49.033 epoch 6 -> 7 at daa 420, 442.2 s: template class 4 rung 0 (27 passes), next rung 0 (27), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 0
22:55:02.050 t=455.3 s daa 437 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 437/1eb8bef0 437/1eb8bef0 437/1eb8bef0
22:55:17.071 t=470.3 s daa 450 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 450/8b056200 450/8b056200 450/8b056200
22:55:32.093 t=485.3 s daa 459 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 459/75ec3917 459/75ec3917 459/75ec3917
22:55:47.114 t=500.3 s daa 473 epoch 7 rung 0 (27) up 10000 bps weakest 10000 blocks/sink per node 473/022ba23c 473/022ba23c 473/022ba23c
22:55:52.120 epoch 7 -> 8 at daa 481, 505.3 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:55:52.120 LADDER STEP: the template is rung 1 (35 shadow passes) from epoch 8 (daa 481) at 505.3 s wall
22:56:02.135 t=515.3 s daa 486 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 486/3e3ac762 486/3e3ac762 486/3e3ac762
22:56:17.156 t=530.4 s daa 501 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 501/4de14a5f 501/4de14a5f 501/4de14a5f
22:56:32.177 t=545.4 s daa 515 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 515/c64de9c2 515/c64de9c2 515/c64de9c2
22:56:47.198 t=560.4 s daa 538 epoch 8 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 538/d24d285a 538/d24d285a 538/d24d285a
22:56:50.203 epoch 8 -> 9 at daa 540, 563.4 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:57:02.221 t=575.4 s daa 555 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 555/d8617bdb 555/d8617bdb 555/d8617bdb
22:57:17.243 t=590.4 s daa 570 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 570/c16b6327 570/c16b6327 570/c16b6327
22:57:32.263 t=605.5 s daa 586 epoch 9 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 586/f1afc7fb 586/f1afc7fb 586/f1afc7fb
22:57:42.280 epoch 9 -> 10 at daa 600, 615.5 s: template class 4 rung 1 (35 passes), next rung 1 (35), up 10000 bps (weakest of 7: 10000), down 0 bps, this node signals 1, step took effect at epoch 8
22:57:47.288 t=620.5 s daa 605 epoch 10 rung 1 (35) up 10000 bps weakest 10000 blocks/sink per node 605/407dd224 605/407dd224 605/407dd224
22:57:58.524 SUMMARY PASS (expect step, signals up/up/up): rung 1 (35 passes) from epoch 8 at DAA 481; epochs seen e0:r0:0bps e1:r0:0bps e2:r0:0bps e3:r0:0bps e4:r0:0bps e5:r0:0bps e6:r0:0bps e7:r0:10000bps e8:r1:10000bps e9:r1:10000bps e10:r1:10000bps; chain bits {"none":1,"up":617} (9984 bps up); blocks 481 / 137; rejected miners 0/0/0 nodes 0/0/0; sinks f1cbdb99f48e0a19 f1cbdb99f48e0a19 f1cbdb99f48e0a19 at 617/617/617
22:57:58.524 PROGRAM ID epoch 0 seed 234e082d653dc69d reps 27: miners 26fc8f3decca98ed (3 of 3) cli at reps 26fc8f3decca98ed cli rung 0 26fc8f3decca98ed
22:57:58.524 PROGRAM ID epoch 1 seed 5fce000863234999 reps 27: miners 22db8cc739098c5f (3 of 3) cli at reps 22db8cc739098c5f cli rung 0 22db8cc739098c5f
22:57:58.524 PROGRAM ID epoch 2 seed 8217681d05165ddb reps 27: miners 0ec4930eb73fccbd (3 of 3) cli at reps 0ec4930eb73fccbd cli rung 0 0ec4930eb73fccbd
22:57:58.524 PROGRAM ID epoch 3 seed 4870884b48c88957 reps 27: miners 436c842444e67520 (3 of 3) cli at reps 436c842444e67520 cli rung 0 436c842444e67520
22:57:58.524 PROGRAM ID epoch 4 seed 2505cecc606d69e8 reps 27: miners 4dd9f1e921cb066e (3 of 3) cli at reps 4dd9f1e921cb066e cli rung 0 4dd9f1e921cb066e
22:57:58.524 PROGRAM ID epoch 5 seed e31a3a8e73690d75 reps 27: miners fead43c4c01d42da (3 of 3) cli at reps fead43c4c01d42da cli rung 0 fead43c4c01d42da
22:57:58.524 PROGRAM ID epoch 6 seed 2674be5e8dbaba18 reps 27: miners 2c6402c1dcc39dae (3 of 3) cli at reps 2c6402c1dcc39dae cli rung 0 2c6402c1dcc39dae
22:57:58.524 PROGRAM ID epoch 7 seed de28d9d30dad45b8 reps 27: miners 120eb6f07fdd1e2b (3 of 3) cli at reps 120eb6f07fdd1e2b cli rung 0 120eb6f07fdd1e2b
22:57:58.524 PROGRAM ID epoch 8 seed 5fbfb48f6e4c66ba reps 35: miners 65ffdc96f1921062 (3 of 3) cli at reps 65ffdc96f1921062 cli rung 0 3d40916b72ae9b97
22:57:58.524 PROGRAM ID epoch 9 seed 367f8d4b525fd620 reps 35: miners 62c19c11cb2909cf (3 of 3) cli at reps 62c19c11cb2909cf cli rung 0 9c6ad9fe883c3330
22:57:58.524 PROGRAM ID epoch 10 seed 518f98485a18e924 reps 35: miners 2f5237323ef1a4bb (3 of 3) cli at reps 2f5237323ef1a4bb cli rung 0 2a003e3289fe04dc
22:57:58.524 summary: /tmp/igneum-fast-time-ladder-step2/summary.json
CASE step2 rc=0

View file

@ -0,0 +1,127 @@
# The latency ladder: N as a verifier-bounded genesis ladder stepped by miner signal
6 October 2026, night UK. Horizon finding 4 (`docs/analysis/horizon/algorithm.md` 5.3a and proposal 5; `frontier.md` 2.3). Branch `ladder` (repo) and `ladder-node` (fork, from `ca3-v4-0316` 8dbb7a23). Status: implemented behind `latency_ladder_activation_daa` (never until set; 0 on the testnet when the project lead says), ships in the 0.3.17 feature tree (0.3.16 went out on 6 October 2026 as a corrective cut of 0.3.15, node f1ea7a38, and carries none of this); not in `docs/spec` until adopted.
## 1. The finding in one line
The chip that matters stores the dataset; the reserve and the era draw buy nothing against it; the one lever is the latency-shadow size N, and an unconditional doubling per era retires the M5 Max at era 1 (-10.5 percent at 200,000 ops). So N moves inside a ladder fixed at genesis, one rung at a time, only when 90 percent of mining weight asks for it, never past the rung the reference verifier can still check under 10 ms.
## 2. Where N lives
| Item | Place | Value |
|---|---|---|
| The unit | `igneum_pow::ShadowClass { instrs: 256, reps }`: class v4 is `mx8` plus a 256-instruction shadow block run `reps` times per iteration; N in counted ops = 930 + 2,048 x reps x 1.83 (the convention of `latency-shadow-2026-10-06.md` s1) | reps is what the generator consumes; the ops label is derived (`igneum::latency_ladder_counted_ops`) |
| The ladder | `Params::latency_ladder` (`consensus/core/src/config/params.rs`), a genesis list of six rungs `{ reps, admissible }`, in the override file as `latency_ladder: [{"reps": 27, "admissible": true}, ...]`, in the digest once the activation is set | reps 27, 35, 53, 88, 173, 267 = about 102,100, 132,100, 199,600, 330,700, 649,400, 1,001,600 counted ops: the measured rungs of 5.3a (35 passes is the integer rung nearest the 130,000 interpolation), then the two doublings; admissible tonight: rungs 0, 1, 2 (section 5) |
| The switch | `Params::latency_ladder_activation_daa` (`u64::MAX` never; in the digest once set, the 0.3.15 rule, so a binary that carries the field peers with one that does not until a file sets it) | never on every network; 0 on the testnet when the project lead says |
| The window | `Params::latency_ladder_window_daa` (one window of the seven; in the digest once the activation is set) | 86,400 DAA (one day); 120 on the fast-time profile, the class window's rounding |
| The step | consensus state derived from signals, never stored: `processes::latency_ladder::step_of_epoch` walks the epochs' seed blocks down the selected chain exactly as `class_signal::program_class_of_epoch` does, memoised per seed block | rung 0 before the activation and at it |
| The era draw | draws 8 (`epoch_len`) and 9 (the ladder) of the era stream are consumed and not used (`igneum_pow::era_draw`), so a later use of either slot changes no other parameter; the first seven draws and every pinned pack are unchanged | the value is set by signal, not by the draw, as `epoch_len` is (spec 01 1.13.1) |
| The program | `EpochSeeds.shadow_reps` (0 = the class's own) → `Epoch::chain_program_shadow` → `generate_from_seed_bytes_program_class_shadow`: class v4 at rung r is `LoadClass::era(v4_class_at(reps_r), era, &V3_ALLOWED)`, generator 4; rung 0 is `V4_CLASS` byte for byte | the base program, the 16 loads, the day cache and the era draw are class v3's; only the block count moves |
## 3. The step rule (`igneum::latency_ladder_step_signalled`, pure; the tally shared with the class signal)
1. The carrier: two bits of the header `version` high byte. Bit 15 = up, bit 14 = down, both = no signal; the object version (the class signal) keeps bits 8 to 13 (`class_signal_of` masks with 0x3f; every value written so far is 4, so nothing changes). Stamped by the node from `Params::latency_ladder_signal` (`IGNEUM_LADDER_SIGNAL=up|down|none` on devnet and simnet; the app's toggle later) only while the ladder is active; the header version rule accepts the high byte while the class signal or the ladder is active.
2. The weight and the windows: blue blocks, the finality rule's convention, `CLASS_SIGNAL_WINDOWS = 7` consecutive windows of `latency_ladder_window_daa` ending at the epoch's seed block `S_e`, one walk bucketed per window (`class_signal::tally_window` with the ladder predicate). The number the decision rests on is the weakest of the seven.
3. The decision for epoch e, from the step of epoch e - 1: up one rung when every one of the seven windows is full, each has at least `LADDER_SIGNAL_THRESHOLD_BPS = 9,000` of its blue blocks signalling up, the rung above exists and is admissible, and the oldest window begins at or after the DAA score where the current step took effect; down one rung symmetrically (never below rung 0). Otherwise the step stands. 90 percent, not 95: the ladder is a parameter the genesis rules leave to miners within fixed bounds (the `epoch_len` precedent, spec 01 1.13.1), not a class change; 60 percent (a parameter the rules leave open) is too low for a move that retires cards.
4. Where it lands: at the epoch boundary, decided at the seed block, so the miner learns the next epoch's rung one lead (600 DAA) before it, with `next_latency_ladder_reps` in every template; a class never changes inside an epoch, nor does a rung.
5. The cool-down (the two-step guard): because the seven windows must lie wholly after the last step, the earliest next decision is 7 x W (seven days) after a step, and every counted signal was cast under the rung it moves from. A miner majority cannot jump two rungs in one decision and cannot take two decisions inside seven days.
## 4. The verifier bound
A rung is admissible only if the cold verify of one 32-lane warp on the reference core stays under 10 ms with the SMT sibling loaded. Measured as lane 2 measured class v4: `igneum-pow bench --seed igneum-genesis --day 2026-10-03 --class mx8+sh256x<reps> --warps 50` on igneum-build-1 (EPYC 9454P, core 40 at 3.8 GHz under schedutil, `nice -n 19 taskset -c 40`), the figure is `warp base 0: single cold run` with the same bench on core 88 (the sibling, `thread_siblings_list` 40,88) at the same time; the average of 50 warps and the quiet-core cold run are recorded beside it. The measurement runs under the box's `measure` hold (`remote-run.sh BR_MEASURE=1`: every build slot waits, the capacity layer yields), `tools/ladder/verify-bench-remote.sh`. The table is section 5; a rung over 10 ms is `admissible: false` in the genesis list itself, and the step rule never enters it. Changing a flag after genesis is a code change under the 90 percent upgrade path (spec 05 5.7); the owed O-1.14 laptop run can only tighten the list before genesis.
## 5. The measured table (igneum-build-1, 6 to 7 October 2026)
Run 2 (22:10Z, `docs/design/latency-ladder-bench/20261006T221034Z`, `tools/ladder/verify-bench-remote.sh` under the measure hold; core 40 read 1,500,000 kHz at the start under schedutil; box load average 25.4 before and 23.6 after from other agents' processes the hold does not exclude; igneum-pow sha256 3f7623f2...; every rung's lane-0 hash equal across the three runs of a rung):
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Admissible |
|---|---|---|---|---|---|---|---|---|
| 0 | 27 | 55,296 | 102,100 | 5.14 | 4.95 | 8.77 | 8.67 | yes (class v4 today) |
| 1 | 35 | 71,680 | 132,100 | 5.57 | 5.52 | 8.87 | 8.51 | yes |
| 2 | 53 | 108,544 | 199,600 | 5.57 | 5.35 | 9.23 | 8.91 | yes |
| 3 | 88 | 180,224 | 330,700 | 6.07 | 5.89 | 10.08 | 9.84 | NO (by 0.08 ms under a load of 25; a quiet re-run before genesis may admit it) |
| 4 | 173 | 354,304 | 649,400 | 7.38 | 7.23 | 12.38 | 12.09 | NO |
| 5 | 267 | 546,816 | 1,001,600 | 8.88 | 8.50 | 14.96 | 14.58 | NO |
Reading: the loaded slope is about 12.6 us per 1,000 extra shadow instructions (8.77 to 14.96 over 491,520), lane 2's 12.1; rung 0 reads 8.77 against lane 2's 8.23 (its load average was 2.3), so the box's other work costs about 0.5 ms and rung 3 would read about 9.6 ms on a quiet box. The rule is the measurement, not the estimate: the genesis list carries rung 3 as inadmissible tonight, and a re-measurement on a quiet box (or the O-1.14 laptop) before the testnet genesis file is written can admit it. Per tier what the flags mean: the ladder as it ships can take N from 102,100 to 199,600 ops (two steps, each at least seven windows apart); at the top rung the Apple tier has given up about 10 percent of rate, the 5090 2.7 percent at its cap, the 4070 pays 21 W more, and a node on a 2019-class core still verifies a warp inside 10 ms with its sibling busy.
Lane 2's slopes predicted 8.23 + 12.1 us per 1,000 extra shadow instructions on the loaded core: 8.4, 8.9, 9.7, 11.9, 14.2 ms for rungs 1 to 5, so rungs 4 and 5 were expected out before the run. The genesis list in `params.rs` carries the flags this table gives. First run (22:06Z, `docs/design/latency-ladder-bench/20261006T220642Z`): the "loaded" column was NOT loaded (the known-failed case of the script: a 50-warp sibling finished during the measured run's own cache fill, which the fill time showed, 540 against 366 ms, while the warps then ran alone: 5.08, 5.20, 5.50, 6.07, 7.39, 8.83 ms); the script now keeps the sibling busy for the whole run and the table above is the second run's.
Also recorded in the first run: the quiet-core cold figures 5.09, 5.20, 5.51, 6.04, 7.45, 8.90 ms for rungs 0 to 5 (lane 2's class v4 cold figure was 5.06), so on the quiet reference core every rung including 1.0 M ops passes 10 ms; the loaded column decides.
## 5a. The chip anchor: what a determined vendor did to RandomX in seven years (coordinator's data point, 6 October 2026, night)
Bitmain's Antminer X9 is a shipping RandomX ASIC: about 1 MH/s at 2,472 W (2.47 J per KH), custom RISC-V cores, about USD 5,600, shipping July 2026 (github.com/monero-project/monero/issues/10270; every figure approximate, read from the issue by the coordinator). A Zen 4 desktop CPU at roughly 20 KH/s and 150 W is 7.5 J per KH (approximate), so the chip's per-joule edge over the honest device RandomX was built for is about 3x, seven years after launch. Two consequences for this project's own text and model:
| Item | Before | After |
|---|---|---|
| The precedent sentence | `latency-shadow-2026-10-06.md` s5 and `site/litepaper.html`: "no RandomX chip has beaten a CPU per joule", "no chip publicly shipped" | false since July 2026; corrected in the litepaper tonight (section 10 below) and owed in `latency-shadow-2026-10-06.md` and `chip-model-v3.md` |
| The k column | k = 0.3 was "the attacker's claim" (a wide-SIMD fixed-datapath array at N5); k = 1 was "ours" and the headline (2.1x over the 5090 at class v4) | a shipped product reached about 1/3 of its honest device's energy on a latency-bound random program, so k about 0.33 is a measured class, not a claim; the honest headline is the range, 2.1x (k = 1) to 3.9x (k = 0.33) over the 5090 at rung 0, with the X9 bracket named. Approximate in both directions: the 5090's ALU is already a wide-SIMD part at 11 pJ per op, so a chip's gain over it on random ALU work should be under its gain over a CPU core, and the memory side (the f = 1 chip) is priced separately and is not moved by k |
| The 5.7x | the f = 1 GDDR7 chip's per-joule edge at class v3 (memory only) | unchanged: no core work in it, k does not enter |
What N buys against a vendor at the X9's scale (energy per hash = memory + N x 11 pJ x k, `latency-shadow-2026-10-06.md` s5; the 5090 and M5 Max rows measured, algorithm.md 5.3a; k = 0.33; approximate):
| Rung | N (approx) | f = 1 GDDR7 chip, uJ | Edge over the 5090 at its 431 W cap | Edge over the M5 Max |
|---|---|---|---|---|
| 0 | 102,100 | 0.84 | 3.9x | 1.7x |
| 1 | 132,100 | 0.95 | 3.4x | 1.5x |
| 2 | 199,600 | 1.19 | 2.8x | 1.3x |
| 3 | 330,700 | 1.67 | 3.0x (the 5090 itself is compute-bound at this rung, 86 MH/s) | 1.1x |
So against a vendor at the X9's scale the ladder narrows the chip's edge over the 5090 from about 3.9x to about 2.8x by rung 2 (the top admissible rung tonight) and does not close it; against the Apple tier it takes the edge to about 1.3x at rung 2 and would take it to about 1.1x at rung 3, where the Mac has given up 21 percent of its rate and which the verifier measurement keeps out until a quiet re-run. The lever that moves every row by the same factor is the honest card's own watts (algorithm.md proposal 7), and the public text says so. The ladder is the chain's only automatic answer; it is not the whole answer.
## 6. What moves the digest and the wire
| Change | Digest | Wire |
|---|---|---|
| The binary carrying the fields, nothing set | no (the 0.3.15 rule: the activation and the window enter only once set; the ladder list enters only once the activation is set) | header version unchanged; `PowEpochInfo` gains fields 25 to 36 with defaults, so an old miner reads reps 0 (the class's own) |
| The file setting `latency_ladder_activation_daa` (and the window, and the list) | yes, once, at publish | the high byte may carry bits 14 and 15; a node without the ladder refuses the version when class signalling is inactive, which is the point of the rollout order (binary first, file second) |
| A step | no | the program of the epoch changes: `program_id_class` with the `shadow/` bytes (rung 0 keeps `program_id(4, seed, attempt)`), the pack's `IGNEUM_SHADOW_REPS` and `seeds.txt`'s `shadow_reps` line (absent at rung 0, so every pack and job line written today is unchanged; the job line gains no token, because the three hosts strip only `class=` and `era=` from its tail) |
## 7. Hostile review
| Question | Answer |
|---|---|
| Can a chip-holder vote the ladder down? | Down needs 90 percent of blue blocks in each of seven windows. A chip at 10 percent of hash cannot; at 90 percent the chain has a bigger problem than N, and the honest 10 percent still blocks the move (a 10 percent minority stops any move either way). |
| Can a chip-holder stall it? | Yes, with over 10 percent of weight for the whole week: the status quo is kept. The status quo is an admissible rung the cards already run, so a stall costs the chain nothing it does not have today; the ladder is a lever miners pull, not a schedule a chip can be late for. The one defence it does not give is a forced move against a chip that holds 10 percent: that is the honest-card watts and the price per joule (algorithm.md 5.1). |
| Can a miner majority jump two steps? | No: one rung per decision, and the next decision's seven windows must begin after the step took effect, so two moves are at least 7 x W apart (`latency_ladder_step_signalled`, tested). |
| Can a week be bought? | 90 percent of blue blocks for seven consecutive days is 90 percent of hash for a week, in public, with the pending count on every console (`latency_ladder_up_weakest_bps`); the class signal's argument (one day can be bought, seven cannot unnoticed). |
| What happens to a miner mid-step? | The rung is decided at the seed block, 600 DAA before the boundary; the template carries `next_latency_ladder_reps` and the miner prepares the next pack with it (the 2.0 era-boundary shape: a boundary that decides otherwise costs one refused pair and one prepare). A miner on a binary without the ladder keeps mining rung 0 after the step and loses every block until it updates; it can be at most 10 percent of hash by construction, and it had seven days of a rising count to see it. |
| An inadmissible rung by a 90 percent majority? | Impossible: the rule reads the flag; the flag is a genesis constant in the digest. |
| A proof-synced node | holds no headers below its pruning point, so an epoch whose windows reach below it cannot be tallied; it takes rung 0 (the floor) and logs that a ladder witness is owed, the class signal's shape (counter-asic-3-node.md 6.3). The witness in the proof format is the next node item, shared with the class witness. |
| The program id | rung 0 keeps today's id; a rung above goes through `program_id_class`, which carries the shadow size, so two rungs of one seed never share an id and a pack of another rung is refused by the id check as a pack of another class is. |
## 8. Consequences per tier (measured rungs from algorithm.md 5.3a; the step is the miners' to take)
| Step | M5 Max | RTX 5090 at 431 W | RTX 4070 at 160 W | RX 9070 XT | Pool user | Verifier (half-core) |
|---|---|---|---|---|---|---|
| 0 → 1 (102,100 → 132,100) | -3.3 points of rate, 0 W more | 0 | 0 | 0 | 0 | +0.2 ms |
| 1 → 2 (→ 199,600) | -6 more points | -2.7 percent | +21 W | 0 | 0 | +0.5 ms |
| 2 → 3 (→ 330,700) | -21 percent | -35 percent (compute-bound at the cap) | -12 percent | +3.6 percent | 0 | +0.9 ms |
| 3 → 4, 4 → 5 | unmeasured on cards; the verifier decides admissibility (section 5) | | | | | |
A chip's shadow core grows with N at k x 11 pJ per op; at rung 1 the f = 1 GDDR7 chip's edge over the 5090 at k = 1 falls 2.1x to 1.7x, at rung 2 to 1.3x. Which is why the step is the miners' who pay for it: rung 3 is a step the signal would refuse until cards change.
## 9. What is implemented, tested, owed
Public text: section 10.
Test and build lines (igneum-build-1 through `tools/build-remote.sh`, `IGNEUM_AGENT=ladder`, 6 October 2026, 22:00 to 22:20Z): igneum-pow `test --release`: 61 passed (the first run failed on the rung-1 label 130,000 against the arithmetic's 132,105, corrected to the arithmetic); the fork at 1591ee1d: kaspa-consensus-core 114 passed, 2 ignored (`latency_ladder_rule`, `override_params_carry_the_latency_ladder_and_the_digest_moves_only_when_set`, the 60x file test after the file gained the two 0.3.16-lane fields and `exec_restart_state_root` it lacked); kaspa-pow with the feature 16 passed (`latency_ladder_rungs_are_programs_of_their_own_over_one_day_cache`: one cache build for three rungs, rung 1 another pow, 27 passes equal to rung 0); kaspa-consensus lib 131 passed (the template carries bit 15 only while the ladder is active); igneum-miner, kaspa-rpc-core, kaspa-grpc-core green; `igneumd` and `igneum-miner` built, the commit string carried. Harness results: section 11.
Implemented: the ladder, activation, window and signal in `Params`, `OverrideParams`, the digest and the 60x file; the pure rule and the bits in `igneum.rs`; `processes::latency_ladder` (the walk, the memo, the step); `EpochSeeds.shadow_reps` through the engine, the header path, the miner and the pack; `PowEpochInfo` and the gRPC fields; the daemon lines; `igneum-pow`: `v4_class_at`, the shadow program path, the id rule, the consumed draws, `--shadow-reps` on the CLI. Tests, the known-failed case first: a changed N today is a hard fork (the same seeds at reps 35 hash another program with the same class and, before the ladder, the same id); the step moves only by signal; 8,999 bps does not move it; a two-step jump is impossible; down never below 0; an inadmissible rung is never entered; rung 0 is `V4_CLASS` byte for byte. Fast-time harness `infra/fast-time/latency-ladder.mjs` (three cases and the failed case, the class harness's shape). Owed: the ladder witness in the pruning proof; the app's signal toggle and its consequences line; a rung check in the three hosts (today a host compiles whatever `IGNEUM_SHADOW_REPS` the pack carries and the Rust pack check binds the pack to its seeds, so a stale pack of another rung fails the miner's own check, not the host's); the O-1.14 laptop run.
## 10. Public text (the litepaper's hash-class paragraph, `site/litepaper.html`, Mining section) and the ledger row
The paragraph as placed in the litepaper: "**The work that waits can grow.** Class v4 adds a block of latency-shadow arithmetic to every hash, about 100,000 integer operations that run while the memory reads are in flight, so a chip that stores the whole dataset still has to pay for a core. That size sits on a ladder fixed at genesis, six rungs from about 100,000 to about 1,000,000 operations, and it moves one rung at a time only when 90 percent of blue blocks in each of seven consecutive days ask for it; it can never move two rungs inside a week and never past a rung the reference verifier cannot check under 10 ms with its sibling thread busy (measured on the build server: every rung's cold verify stays under that gate on the quiet core, 5.1 to 8.9 ms). What it buys, on the measured cards: against a dataset-storing chip whose core costs what an RTX 5090's does per operation, the chip's per-joule edge falls from 2.1x at the first rung to 1.3x at the third; against a core as good as the shipping RandomX chip's (Bitmain's Antminer X9, about 3x per joule over a desktop CPU after seven years, approximate), from 3.9x to 2.8x. What it costs, per rung, is measured too: the Apple tier gives up 3 points of rate at the first step and 6 more at the second, the RTX 5090 nothing until the second; so the miners who pay for a step are the ones who take it." The sentence "Monero has run on RandomX since 2019 with no chip publicly shipped" is replaced by the X9 fact.
The ledger row is M34 in `docs/fud-ledger.md`.
## 11. The fast-time gate (igneum-build-1, 6 October 2026, 22:15 to 22:58Z; summaries and logs copied to `docs/design/latency-ladder-harness/`; `infra/fast-time/latency-ladder.mjs`, the ladder fork's Linux binaries built on the box, three nodes and three CPU miners on `override-60x.json` with class v4 from genesis, class signalling off, the ladder active from DAA 0, windows of 60 DAA)
| Case | Run | SUMMARY | Summary file |
|---|---|---|---|
| The known-failed case first (`--signal up,up,none --expect step`) | 22:15 to 22:26Z | FAIL as it must: no step over epochs 0 to 10, the weakest-of-seven up share 6,166 to 6,333 bps at the sink from epoch 7 (two CPU miners of three), on the chain 406 blocks up and 198 none (6,722 bps up), no step line on any node, 0 rejected, one sink at 603/603/603; `FAILED CHECK` on eight step checks plus `chain_carries_the_bits`, which was the harness's own fault (genesis carries header version 0 and the low-byte check read it; fixed in the file and recorded there), exit 1 | `/srv/builds/_log/ladder-harness/failed-case.json` |
| All three signal up (`--signal up,up,up --expect step`) | 22:26 to 22:37Z | the chain did what the rule says: `Latency ladder step by miner signal: epoch 8 moves to rung 1 (35 shadow passes, from rung 0): up in each of 7 consecutive windows of 60 DAA ending at seed block beb8b40f..., threshold 9000 bps, weakest up 10000 bps, ... 420 of 420 blue blocks up` on 3 of 3 nodes with the same epoch; the template at rung 1 from epoch 8 (DAA 480, the first epoch whose seed block has seven full windows below it) at 500 s wall; epochs 9 and 10 stayed at rung 1 (no second step inside seven windows); 481 / 132 blocks across the boundary; 0 rejected; one sink at 612/612/612; 612 blocks up and genesis none (9,984 bps up), every object byte 0; the three miners' rung-1 ids equal each other and differ from the same seed's rung-0 id on epochs 8, 9 and 10 (e8 `d4cfc28a1c525077` against `ea49bc6e870eb34c`). The harness reported FAIL on two checks of its own: the genesis low-byte fault above and `rung1_ids_equal_the_cli_rung1_id`, because `igneum-pow show` built its program without `--shadow-reps` (fixed at 6b30e85; the miners' ids were the chain's). Re-run as step2 below with both fixes | `step.json` |
| Two of three signal up (`--signal up,up,none --expect no-step --epochs 10`) | 22:37 to 22:47Z | PASS: no step over epochs 0 to 10; the weakest-of-seven up share at the sink 5,833 bps from epoch 7 (the third miner's blocks carry no bits); on the chain 402 blocks up and 200 none (6,678 bps up); no step line on any node; 0 rejected; one sink `bfc435d62d7c659c` at 601/601/601; every rung-0 id equal to the CLI's | `no-step.json` |
| All three signal up, the fixed harness and CLI (`--signal up,up,up --expect step`, step2) | 22:47 to 22:58Z | PASS on all 18 checks: the step line on 3 of 3 nodes at epoch 8 (weakest up 10,000 bps, 420 of 420 blue blocks up); the template at rung 1 (35 passes) from epoch 8 at 505 s wall; epochs 9 and 10 at rung 1, one step line per node (no second step inside seven windows); 481 / 137 blocks across the boundary; 0 rejected; one sink `f1cbdb99f48e0a19` at 617/617/617; 617 blocks up and genesis none (9,984 bps), every object byte 0, every mined block's low byte 2; the three miners' rung-1 ids on epochs 8, 9 and 10 equal the CLI's `--shadow-reps 35` id and differ from the same seed's rung-0 id | `step2.json` |

View file

@ -182,6 +182,15 @@ Answer: Correct. The measured 2.4 G/s had been read on 6 October as a mapping ar
Evidence: `docs/analysis/horizon/algorithm.md` section 5.1 (the ceiling table: measured 2.4, tFAW-bound 2.3, tRRD-bound 2.8, bank-bound 11.4, O'Connor 10.7 G reads/s, and the F2 measurement plan), 6 October 2026; JEDEC HBM2 timings as carried by ICCAD 2021 Table I; Shuhai, FCCM 2020, Fig 7.
### M34. The shadow size N is a constant of the binary, so the one lever against the dataset-storing chip needs a fork to move
"Your own Horizon lane says the reserve and the era draw buy nothing against a chip that stores the dataset, and that the only lever is the latency-shadow size N. N is 27 passes of a 256-instruction block, hard-coded in `V4_CLASS`. So when HBM4 doubles a chip's rate per stack in 2028, your answer is a hard fork, and a fork that retires the M5 Max at the first doubling. And now there is a shipping RandomX ASIC."
Status: Conceded, implemented (6 October 2026, night; `docs/design/latency-ladder.md`, branch `ladder`, fork branch `ladder-node`, the 0.3.17 feature tree, behind `latency_ladder_activation_daa`, never until set, 0 on the testnet when the project lead says): N is a genesis ladder of six rungs (27, 35, 53, 88, 173, 267 passes; about 102,100 to 1,001,600 counted ops) with a measured admissibility flag per rung (cold verify under 10 ms on the reference core with its SMT sibling loaded, igneum-build-1, 6 October 2026: rungs 0 to 2 pass at 8.77, 8.87 and 9.23 ms, rung 3 misses by 0.08 ms under a box load of 25 and is out until a quiet re-run, rungs 4 and 5 are out at 12.38 and 14.96), and the step is consensus state derived from two bits of the header version: up one rung when 90 percent of blue blocks in each of seven consecutive windows ask for it and the rung above is admissible, down one rung symmetrically, never two rungs inside seven windows (the oldest window must begin after the last step took effect), never unconditionally. Tests, the known-failed case first: a changed N today hashes another program under the same program id (a hard fork no pack line told apart); after, rung 0 is class v4 byte for byte, a rung above carries its pass count in the id, 8,999 bps in one window of seven does not move the step, a two-step jump is impossible, down never passes rung 0, an inadmissible rung is never entered. Stated in `site/litepaper.html`, Mining section ("The work that waits can grow").
Answer: Correct on both counts, and the second was the sharper one. The X9 (Bitmain, about 1 MH/s at 2,472 W, approximate, github.com/monero-project/monero/issues/10270) is a shipped 3x per-joule edge over a desktop CPU on the best-known latency-bound random-program design, seven years after launch; it makes the k = 0.3 column of the chip model a product class rather than an attacker's claim, and the public headline is now the range 2.1x (k = 1) to 3.9x (k = 0.33) over the RTX 5090 at class v4, with the ladder taking the X9 bracket to about 2.8x by rung 2 and the Apple tier's to about 1.1x by rung 3. The ladder does not close the gap; it is the chain's only automatic answer, it moves at the pace of the cards that pay for it, and the honest card's watts remain the lever that moves every row (algorithm lane proposal 7). What the ladder gives up by design: a chip holding over 10 percent of weight can stall it, and the status quo it stalls is a rung the cards already run.
Evidence: `docs/design/latency-ladder.md` (the rule, the hostile review, the measured verifier table, the X9 arithmetic); `igneum-pow/src/generator.rs` test `latency_ladder_known_failed_a_changed_n_was_a_hard_fork_and_rungs_are_class_v4`; the fork's `consensus/core/src/igneum.rs` test `latency_ladder_rule`, `consensus/pow/src/igneum.rs` test `latency_ladder_rungs_are_programs_of_their_own_over_one_day_cache`; `infra/fast-time/latency-ladder.mjs` (the step, no-step and known-failed cases).
## 2. Finality and attacks
### F1. Finality is attackable for the first month
@ -1772,6 +1781,8 @@ Answer: Correct for v0, and by design for now. The consensus check on a carried
Round 2 (5 October 2026, night): the public text now carries the v0 fact, labelled Open: `site/litepaper.html`, Proving, "How a block gets proven": "Open: in proving v0 the SP1 proof itself is checked off the consensus path, by every block producer before it carries a record, and the native-execution check is what consensus enforces; whether the SP1 verifier moves inside consensus is a decision before the public testnet." Status unchanged: decision owner the project lead, decisions item 11.
Round 4 (6 October 2026, 21:0x UTC, the 0.3.17 exec work): Fixed on a branch, pending merge. Fork `exec-sync-0313` da2d17ec puts the proof check on the consensus path: from `proving_consensus_verify_daa` (never by default; with `proving_shard_program_id` and `proving_aggregator_id` in the digest once set) body validation in context verifies every carried record's proof in-process (sp1-sdk's LightProver, the keys embedded from `proving/igneum-prove/elf`) against the pinned program id and the record's statement; a failing proof invalidates the block (`RuleError::IgneumInvalidProofRecord`, the relaying peer disconnected); a proof not held is fetched from the relaying peer (`IgneumRequestProofRecords`, payload 78) and the block retried once, never marked. Measured: 0.204 to 0.232 s a proof on one M5 Max core, 0.400 s on igneum-build-1, release, in-process; the harness `tools/exec-sync/forged.mjs` 8/8 in 23.6 s (an attacker with the rule off carries a forged record; the honest peer asks for the proof, refuses the block in 0.000 s of verify, pays nothing). A node whose embedded keys are not the pinned ids refuses to start. Open for 0.3.17.1: the finality-horizon skip (a block a certified checkpoint covers needs no proof check), so a fresh joiner's syncer serves proofs only for the unfinalised window.
### P22. The rewards and payouts are inputs to the shard proof, not outputs
"The shard guest takes the segment's rewards and the prover payouts as data and commits the post-root after them. A host can feed any list and the proof still verifies."
@ -2316,6 +2327,13 @@ Same rule as the count above. 167 entries (P23 added by round 2).
| Open, found tonight, fix in round 3 | 1 | P23 |
| Another agent's tonight | 1 | C4 |
## Status updates, 6 October 2026 (21:0x UTC, the 0.3.17 exec work)
### X34. The prover's segment exports filled the disk under the node
The fleet's provers (the kit's box-prover.py) exported the chain with `igneum_exportSegments [0, last]` for every segment, 50 to 500 MB a file, and nothing pruned them: between about 13:00Z and 20:44Z on 6 October 2026 the exports reached 42 GB on the hub (586 segment dirs on a 60 GB disk, 100 percent) and 3 to 46 GB on every standing box (p1-4090 and p2-4070-1 at 100 percent with 45 and 46 GB; p1-4070 97 percent, p2-3090-4 94 percent, p1-3080 87 percent, p1-a5000 81 percent, p2-4090-3 79 percent, p2-3090-3 75 percent, p1-5090 74 percent, p2-3090-2 55 percent, p2-3090-1 38 percent, p2-4090-1b 30 percent, the 8x rig 28 GB), 4 to 6 GB an hour a box. The hub's node died three times: 19:58:08Z and 20:01:55Z on the sync KeyNotFound (a peer's gap sync below retention, another class), 20:42:31Z on "No space left on device"; the supervisor restarted it at 19:59:11Z, 20:02:36Z and 20:44:38Z.
Status: Fixed (6 October 2026, 21:0x UTC). At the source: the 0.3.14 export is `[first-1, last]` with the account dump, a few hundred KB a segment, not 50 to 500 MB. In the kit: gpu-fleet f9ad70e (every standing box): a 20 GB and 168 h cap on the export dir enforced before every export, the export deleted the moment its record is accepted or paid, no export under 10 percent free disk (logged); the supervisor's 20-minute prune stays as the belt. In the app: exec-app-0314 4159818 and proving-v2 717148d (`app/igneum-app/src/provingdir.rs`): the same caps on `<app dir>/proving` (IGNEUM_PROVING_DIR_MAX_GB 20, IGNEUM_PROVING_DIR_MAX_DAYS 7, which leave 80 GB of a 100 GB box to the node and the system), a segment's directory deleted on submission, the free-disk floor before each export with the skip on the Prove page; the cap is a pure function with a unit test. Consequence per tier: a home miner's app never grows its proving dir past 20 GB or a week, and stops exporting (not mining) when the disk is under 10 percent free; a box or a rig the same through the kit.
## Count by status, 6 October 2026 (17:30 UTC, round 3 merged and the owner's decisions applied)
Same rule as the counts above. 167 entries.

View file

@ -296,8 +296,76 @@ record. Known in 0.3.13 on PC 1 until 0.3.14: the rows show the app's own earlie
to receive the run's result), and the 80% caps are the app's setting; the draw is the tuned one regardless because
the driver holds the clock locks and the caps do not bind.
### The 0.3.14 window (6 October 2026, 18:18 to 18:27Z): no tune ran; five causes
The shipped 0.3.14 app, Power control on, the task registered, asked over its own API to tune each card
(`relay/playbooks/ember-installed-tune-pc1.ps1`). From its log and helper.log:
| | When (UTC) | What | Cause | Fix (0.3.15) |
|---|---|---|---|---|
| C | 17:55:25 | the app's own first 5090 tune wrote its first request in the second the helper started; the helper skipped it as "present at my start"; 30 s later "did not take (card reports 460 W, acknowledged true)" | the engine wrote before the helper was up | the helper writes a heartbeat (`helper.alive`) every poll; the engine starts the task and waits for a fresh heartbeat BEFORE writing (`powertask::ensure_running`) |
| A | 17:55:55 | the 4070's helper start read the stale `quit` from the 5090's abort and exited in the same second | quit/remove matched before the sequence check | the helper acts only on lines added after its start (`commands_after`) |
| D | 17:57:27 | the 9070 XT, measure only ("igneum-gpu-telemetry gave no tune line": the 3.0 coordinator's job was on the ADLX helper), was still sent a set; refused; "tuning stopped" | a measure-only card was set | `ember::may_set`: measure only sets nothing |
| E | 18:18 to 18:27 | three "Tune now" requests sat "queued" behind the hour's back-off from the 17:55 failures; none started | `sweep_retry` blocked a forced request silently | a forced request clears the back-off; a held row says "tuning: retry in N min (why)" (`ember::retry_note`) |
| B | (latent) | the engine's helper flag stayed true after the helper's idle exit; a later step would have been acknowledged after a blind 4 s sleep | a flag instead of a fact | the heartbeat decides; the acknowledgement is the helper's own log line for the sequence (up to 15 s), a missing line fails the step with that reason |
What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the rows keep the baseline or
"tuning stopped"); the lever-2 TUNING records still flow. 0.3.15 closes it; the window repeats there.
### The 0.3.16 window (6 October 2026, 22:16 to 22:25Z): A to E held; a sixth cause (F) stopped the climbs
`ember-installed-pc1-2`, unelevated, the installed 0.3.16 app (the branch at 5429e82) tuning itself through the task.
Gate ok; prompts 0; the helper started once per NVIDIA card and stayed up (task state Running at the end).
| Card | Plan | Result |
|---|---|---|
| RX 9070 XT | baseline, measure (the probe gave no tune line again) | "Measured: 19.2 MH/s at 202 W (0.095 MH/W)", gclk 3298, mclk 2505, 62 C, ok; no set sent (D held). Open: why the installed app's probe gets no tune line when run 6's kit engine did |
| RTX 5090 | climb, 5 probes, floor 1390 MHz, mode helper | request 1 "the helper did not run sequence 1 within 15 s (no line in helper.log)"; 211.5 W before, 208 W after, unchanged |
| RTX 4070 | climb | the same; 74.8 W before, 75.5 W after |
Cause F (cmd.txt read back: `00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc`): the tune path wrote its request index as the
wire sequence while the cap path had written unix-based numbers earlier in the same helper session; the helper runs
only numbers above every one it has seen, so every tune command read as stale. The honest acknowledgement (B) said
so within 15 s instead of a blind yes. Fix (cbd4d51, 0.3.17): one monotonic wire space for every writer
(`powertask::wire_seq`, the unix time modulo 999,990), the acknowledgement keyed to the wire number; test. The
elevated runs never crossed F (they set limits directly), which is why run 6 tuned and the self-tune did not.
## 8a. Next-cut notes (for the 0.3.12 shipper)
Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 (merge 886c075), because Miner UI 4's
Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except
`card.tune_floor` (995530b), which rides 0.3.17.
Numbering (the shipper, 6 October 2026, 21:xx UK): 0.3.16 is a corrective cut of 0.3.15 under a new number (the Mac and PC 1 had taken earlier 0.3.15 builds), so everything below written as "0.3.16" ships as 0.3.17; the 0.3.15/0.3.16 content is the branch at 5429e82.
For 0.3.17 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82:
| Field | What | Where |
|---|---|---|
| `state.mining.watts_total` | the sum of the mining cards' draw (a reading under 60 s old) | `ember::fleet_watts`, engine `derive` + test |
| `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` |
| `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test |
| `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists |
| (F) the helper's wire sequence | one monotonic space for every writer of cmd.txt (`powertask::wire_seq`); the tune path's two-digit request index read as stale beside the cap path's unix-based numbers, so the 0.3.16 self-tune's climbs stopped at their first request | powertask.rs, engine.rs + test |
| Horizon polish Q4 (updater) | `fork_is_close`: an activation height at or below the DAA has passed, nothing is pending (the old rule read it as close: every update since the 0.3.14 manifest said "0 blocks away, installing now", stripped Later and skipped every guard; PC 1's 17:52:54Z install under a job came through it); `publish-manifest.sh` refuses an activation height at or below the live DAA (/api/live state.daa) unless `--allow-passed-activation` | manifest.rs + test, packaging/ota/publish-manifest.sh |
| Horizon polish Q83/Q84/Q2 (the pause shown) | `state.finality.paused`, `paused_since` (the last lock's time, else the engine's start), `reason`, `held_by`, `line` = "Finality paused since 18:39 UTC: under two thirds of the weight is signing" (the node's cause when it carries one: the engine parses a node log line carrying `finality_reason=<words or "quoted words"> held_by=<id>`, the node lane's to emit); the node line, the Overview's state and the Finality card show the one sentence while paused, the Finality card's age reads "paused", and no surface calls a lock final; `finality.message` carries the sentence too so older UIs show it | ember.rs `finality_paused_line`, `parse_finality_line`, `hhmm_utc` + tests; engine.rs derive and the node-line parse; ui/app.js `View.finalityWords` + test |
| the finality rule (updater) | Horizon frontier lane: the updater installs nothing while the network's finality is paused (a synced node with no checkpoint lock for `manifest::FINALITY_PAUSE_S` = 15 min; the last LOCK line's age, else the engine's uptime); the update card reads "waiting for finality: ..."; slot, catch-up and patience rules unchanged otherwise; only the signed manifest's own `urgent` flag installs through a pause (a fork-close or unsupported urgency does not); the known-failed case is the test | manifest.rs `Moment.finality_paused`, `manifest_urgent`, `Manifest.urgent`, `safe_to_apply` + test; ota.rs `Ctx`; engine.rs |
| `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs |
For Miner UI 4's Cards tab (Ember as its second layer), 0.3.17, on ember-tune:
| Commit | What | Where |
|---|---|---|
| 5429e82 (in 0.3.15), 995530b (`tune_floor`, next cut) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); `card.tune_floor` (the chosen clock is the ladder's floor; `CardPref.sweep_floor`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
For the 0.3.15 cut (6 October 2026 evening), on ember-tune (taken at 5429e82, see the correction above):
| Commit | What | Where |
|---|---|---|
| a8cab23 | (A) the helper acts only on lines added after its start; the acknowledgement is the helper's log line for the sequence | powertask.rs `commands_after` + test, engine.rs |
| (tip) | (B, C) the heartbeat, `ensure_running` before every task-path write; (D) `may_set`; (E) the back-off yields to a forced request, `retry_note`; the window playbook's gate at 0.3.15; `ember-tune-pc1.ps1` deleted (superseded by the installed-tune playbook) | powertask.rs, engine.rs, ember.rs + tests, relay/playbooks |
| e70ac5e | every task command goes to the helper's fixed folder | platform.rs, powertask.rs, engine.rs, main.rs + test |
For the 0.3.14 cut (6 October 2026, after run 6), on ember-tune:
| Commit | What | Where |

View file

@ -16,6 +16,7 @@ name" and "the login" stand for the values the script reads from the history its
| The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` |
| The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` |
| The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree |
| After the 5 October rotation (confirmed 5 October 2026, night, ledger G14) | the values IN THE HISTORY are the OLD ones | `~/.config/igneum/log-intake-key.old-2026-10-05` and `dl-token.old-2026-10-05` hold them; the live files hold the new values, which are in 0 tracked files. `replace.txt` must be written from the `.old-2026-10-05` files, not from the live ones, or the pass replaces nothing. The relay key, the relay token, the relay run key and the machine secrets (`relay-machines/`) are in 0 files and 0 commits |
| The relay key and token (current and old) | 0 files, 0 commits | |
| The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored |
| Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule |

View file

@ -275,3 +275,35 @@ the quit ask, paused, syncing, the clock, the update card, the job strip, the lo
| A Windows and a PC screenshot of the row with a real NVIDIA card (the telemetry cells, the cap slider, Retry) | the 0.3.14 cut, first thing to look at |
| The row's state word while tuning carries the meta after it ("tuning: step 7 of 9 · measuring 441 W · 32 GB · ...") and is cut on a narrow row | a later pass: drop the meta while tuning |
| The merge with `ember-tune`: `app.js` takes this branch's file (it already carries ember-tune's UI words and test); the engine files merge by themselves | the 0.3.14 shipper |
## 14. Polish round (6 October 2026, after the 0.3.14 cut)
the project lead, on the live Mac app: "I do think this could look better", at the job strip reading "Job: 0.3.14 is published:
re-read the manifest and install now (...) done after 0 min. Report uploaded." Branch rebased on master 630b537.
| What | Where |
|---|---|
| No raw job, manifest or relay text on a user surface. The strip reads "A job from the team ran: update check, 0 min." / "A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8." / "A job from the team failed: build, after 32 min." / "Updated to 0.3.14 at 18:52."; the technical line (the job's title, summary, RESULT line, exit code) sits behind a chevron; the × stays. `Notices.jobWord` maps a kind or an id prefix to a user's word (update check, restart, log collection, shard benchmark, build, tuning check, a script). | app.js Notices |
| The Activity feed speaks in the app's voice: `View.plainEvent` maps every engine event string (`src/engine.rs` et al., 80 patterns) to a sentence ("Block found by Apple M5 Max", "Rule settings read from the team", "The node stopped (code 101); restarting", "A job from the team ran: update check, 0 min"); the engine's own line is the row's tooltip; the kind word becomes a coloured dot. Unknown lines keep their words with a JSON tail cut. | app.js View, renderMine |
| The header: title, grey subtitle and the pill share one baseline; the pill is a sentence with a coloured dot: "Mining · 511 MH/s" (molten), "Tuning" (molten), "Paused", "Syncing the node", "Starting the worker", "Not mining" (grey), "Node restarting", "Node failed", "Engine not answering" (ember), "Stopping", "Detecting cards", "Setting up". `View.pill`. | app.js, app.css |
| Three Mine layouts were offered behind `?variant=a|b|c` (quieter, denser, hero number); the project lead picked C, then ruled the page stays as shipped (section 15). The switch is gone. | app.css |
| 390 px: the strip's text, chevron and × on one line. Light and dark checked. | app.css |
Screenshots: `p04`/`p05` the plain job strip closed and open, `p06` the updated strip,
`p07` the header paused, `p08` the activity feed, `p09`/`p12` light, `p10`/`p11` 390 px. Tests: 36 pass.
## 15. the project lead's ruling: the banner only (6 October 2026, evening)
the project lead: "lets not actually change the miner page just sort the banner". Variant C and the Earnings reshaping are
reverted; the Mine and Earnings screens are exactly as shipped in 0.3.14. The three variant screenshots are gone with
them. What stays from the polish round:
| Kept | Where |
|---|---|
| The strip's look: the row surface (one step lighter than the page), a 1 px ember hairline along the top, ash text, ember only on the state dot and the chevron; the update strip and the job strip share the one `.notice`; the asks and the clock card carry the same hairline. No filled brown or mustard field anywhere (the rail's active item, the pill, the ghost-on button, the option, the stop ring, the log hit and `mark` lost their tints; only `::selection` keeps a colour). | app.css |
| The strip's words: "A job from the team ran: update check, 0 min." / "Updated to 0.3.14 at 18:52.", the technical line behind the chevron, the × kept | app.js Notices |
| The Activity feed in the app's voice (`View.plainEvent`, the engine line as the tooltip, a kind dot) | app.js View, renderMine |
| The header: title, subtitle and pill on one baseline; the pill a sentence with a coloured dot (`View.pill`) | app.js, app.css |
Screenshots: `r01-mine-strip-dark.png`, `r02-mine-strip-light.png` (Mine as shipped, the job strip showing),
`r03-earnings-dark.png`. Tests: 36 pass.

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View file

@ -0,0 +1,190 @@
# Igneum Miner UI 4: the copy sweep (6 October 2026, 20:0x UK)
the project lead, on the Cards screenshot: "'As the fleet' might need some work, check all UI copy for simplicity." Every string in
`index.html` and `app.js` was read against the rules: no word a home miner would not use (fleet, lane, identities,
prior, hill climb, sweep, DAA, epoch, vote key, digest, manifest, shard, segment, aggregator, verifier, worker, engine
become plain words or carry one grey explanation), every state line one short sentence with the number first, labels
are nouns and buttons are verbs, no colon where a sentence will do, no two-beat lines, one word for one thing (tune,
card). Main's correction (20:0x UK): the chain's own objects keep the words of the site, the litepaper and the ledger,
with a grey explanation where they first appear: shard (not piece), segment (not run of blocks), checkpoint and
"locked checkpoint" (not lock point), aggregator (not combiner), verifying (not checking proofs); "network blocks (the
DAA score)" stays. The rows below that renamed those words are superseded by this correction. the project lead's rulings folded in: the per-card inherit option is "Default · <shared goal>" in grey, updating when the
shared goal changes; "Default" wherever a per-card setting follows the shared one (the cap's "Back to Default"); the
Cards strip is titled "Ember Tune"; every goal option carries its meaning as a tooltip: Efficiency "most hashes per
watt, a little rate traded", Balanced "a little rate for most of the saving", Maximum "most hashes, watts second",
Default "the goal set for all cards". 150 strings changed.
## The ten most visible
| Where | Old | New |
|---|---|---|
| Cards, the per-card goal | As the fleet | Default · Balanced (grey, follows the shared goal) |
| Cards, the strip | (no title) | Ember Tune, with each goal's meaning on hover |
| Cards, the Ember line from a prior | Tuned 2 min ago from the fleet prior | Tuned 2 min ago from what other miners found |
| Cards, the details label | Identities | Miners on this card |
| Cards, the cap lines | cap applied: 460 W / cap not applied: Windows needs the administrator prompt / Unpin | Cap in force: 460 W / Cap not set yet. Windows needs the administrator prompt. / Back to Default |
| Cards, the row's state word | held for a remote job / stopped after repeated faults | paused for a job from the team / stopped after repeated errors |
| Overview, the big button | switch a GPU on below first / no GPU this app can drive / worker starting | switch a card on in Cards first / no card this app can use / card starting |
| Prove | off: relay only / Every block is turned into a short proof. The chain hands pieces to your cards | off, passing proofs along / Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one (shard, segment and aggregator keep their names, each explained once) |
| Node details | DAA score / rules digest / next rule switch / last lock | network blocks (the DAA score) / rules fingerprint / next rule change / last checkpoint (a point the miners agree can never be undone) |
| Settings | Vote on finality checkpoints / Trust proof records without verifying them / Hill climb | Vote on checkpoints / Trust proofs without checking them / Fine tuning |
Every toast and event sentence lost its semicolon or colon joint for a full stop ("Mining stops. The node keeps
running"). "Worker" (the engine's word for a card's mining process) became "card" wherever a user reads it; "engine"
became "app"; "fleet prior" became "what other miners found". Shard, segment, checkpoint and aggregator stay (the
chain's own words), each with a grey explanation where it first appears. The engine's own log strings are untouched: `View.plainEvent` maps them.
## Every string changed
| File | Where | Old | New |
|---|---|---|---|
| index.html | The chain scene canvas label | The last two minutes of blocks: this machine's lane and the others, the selected chain as a lit path, checkpoints as bands | The last two minutes of blocks: your row and the other miners, the main chain as a lit path, locks as bands |
| index.html | Node details: DAA score row | <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> | <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> |
| index.html | Node details: votes row | this machine signs a checkpoint every 30 s | this machine signs a lock point every 30 s |
| index.html | Node details: digest label | <div class="k">rules digest</div> | <div class="k">rules fingerprint</div> |
| index.html | Node details: tips row | open ends of the block DAG right now | open ends of the chain right now |
| index.html | Node details: blue score row | blocks on the agreed main chain</span> | blocks on the agreed main chain (the blue score)</span> |
| index.html | Node details: next switch label | <div class="k">next rule switch</div> | <div class="k">next rule change</div> |
| index.html | Prove: segments row | whole segments this machine proved | whole runs of blocks this machine proved |
| index.html | Prove: shard program id label | <div class="k">shard program id</div> | <div class="k">proof program id</div> |
| index.html | Prove: aggregator id label | <div class="k">aggregator id</div> | <div class="k">combiner program id</div> |
| index.html | Prove: lead sentence | Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN. | Every block is turned into a short proof. Your cards prove pieces of it and earn IGN for each one. |
| index.html | Settings: Ember Tune switch | Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change. | Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change. |
| index.html | Settings: Hill climb switch | <b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span> | <b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span> |
| index.html | Settings: vote switch | <b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span> | <b>Vote on lock points</b> <span class="dim">Signs a lock point every 30 s. Votes are what lock the chain. Leave it on.</span> |
| index.html | Settings: trust switch | <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 | <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> |
| index.html | Settings: remote jobs switch | Signed jobs (a benchmark, a script, logs to collect) run here once and report back. | Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back. |
| index.html | Settings: Power control | Off, NVIDIA cards are measured only. | Off, NVIDIA cards are only measured. |
| index.html | Earnings: dev fee switch | Dev fee: 1 block in 100 pays the miner software's author | Dev fee: 1 block in 100 pays the people who make this app |
| index.html | Setup: address lead | Paste an Ethereum-style address you control: 0x and 40 characters. | Paste an address you control. It starts with 0x and has 40 characters after it. |
| index.html | Setup: GPU note | An integrated GPU is off by default: it is slow and shares the machine's memory. | A built-in GPU starts off. It is slow and shares the machine's memory. |
| index.html | Settings: price | pence per kWh. Every £ figure uses it. | pence per kWh. Every £ figure uses this. |
| index.html | Settings: name help | A label for you only. | A name for you only. |
| index.html | Prove: setup note | About 20 minutes, once. | Takes about 20 minutes, once. |
| index.html | Cards: the strip title | <div class="card tune-strip" id="tune-card"> <div class="ts-row"> | <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></di |
| index.html | Cards: the shared goal options with their meanings | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button> <button class="seg-b" data-goal="balanced" role | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button> |
| index.html | Settings: the shared goal options with their meanings | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button> <button class="seg-b" data-goal="balanced" role | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button> |
| app.js | Cards: the per-card goal options | [['', 'As the fleet'], ['efficiency', 'Efficiency'], ['balanced', 'Balanced'], ['rate', 'Maximum']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' | [['', 'Default', 'the goal set for all cards'], ['efficiency', 'Efficiency', 'most hashes per watt, a little rate traded'], ['balanced', 'Balanced', 'a little rate for mo |
| app.js | Cards: the fleet saving line | ' over ' + n + ' tuned card' | ' across ' + n + ' tuned card' |
| app.js | Cards: the Ember line from a prior | ' from the fleet prior' | ' from what other miners found' |
| app.js | Cards: the Ember line, measured sub | 'Apple silicon sets its own clocks' | 'Apple silicon sets its own power' |
| app.js | Cards: the tune line, prior | ' from the fleet prior, confirmed' | ' from what other miners found, confirmed' |
| app.js | Cards: the schedule, off | 'Ember Tune is off. Tune and Tune all still work by hand.' | 'Ember Tune is off. Tune and Tune all still work when you press them.' |
| app.js | Cards: the floor note | ' (the ladder’s floor)' | ' (the lowest clock Ember will set)' |
| app.js | Cards: identities label | <span class="k">Identities</span><p class="help"> | <span class="k">Miners on this card</span><p class="help"> |
| app.js | Cards: identities toast | 'Identities set; that card’s worker restarts' | 'Set. The card restarts' |
| app.js | Cards: cap help, Ember may move it | 'Ember Tune may move it.' | 'Default: Ember may move it.' |
| app.js | Cards: cap help, pinned | 'Set by hand, so the tune leaves it. <button class="btn tiny ghost" data-pin="0">Unpin</button>' | 'Set by hand, so Ember leaves it. <button class="btn tiny ghost" data-pin="0">Back to Default</button>' |
| app.js | Cards: cap applied | '<div class="line ok">cap applied: <b>' + want + ' W</b>' | '<div class="line ok">Cap in force: <b>' + want + ' W</b>' |
| app.js | Cards: cap not applied | '<div class="line hot">cap not applied: Windows needs the administrator prompt <button class="btn tiny" data-power-retry="1">Retry</button></div>' | '<div class="line hot">Cap not set yet. Windows needs the administrator prompt. <button class="btn tiny" data-power-retry="1">Retry</button></div>' |
| app.js | Cards: chosen by the tune pin | ' <span class="pin">chosen by the tune</span>' | ' <span class="pin">Ember’s choice</span>' |
| app.js | Cards: Apple cap help | '<p class="help">Apple silicon manages its own power; there is no cap to set.</p>' | '<p class="help">Apple silicon manages its own power. There is no cap to set.</p>' |
| app.js | Cards: facts line worker | cd.worker ? 'worker ' + cd.worker : '' | cd.worker ? 'runs on ' + cd.worker : '' |
| app.js | Row word: held | 'held for a remote job' | 'paused for a job from the team' |
| app.js | Row word: faulted | 'stopped after repeated faults' | 'stopped after repeated errors' |
| app.js | Big button: held | 'a remote job holds the cards; mining restarts when it ends' | 'a job from the team is using the cards. Mining restarts when it ends' |
| app.js | Hero sub: no GPU worker | m.state === 'idle' ? 'no GPU worker' | m.state === 'idle' ? 'no card mining' |
| app.js | Hero sub: worker starting | (n.synced ? 'worker starting' : 'waiting for the node') | (n.synced ? 'card starting' : 'waiting for the node') |
| app.js | Big button: worker starting | sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync' | sub: n && n.synced ? 'card starting' : 'waiting for the node to sync' |
| app.js | Big button: switch a GPU on | 'switch a GPU on below first' | 'switch a card on in Cards first' |
| app.js | Big button: no GPU | 'no GPU this app can drive' | 'no card this app can use' |
| app.js | Strip fallback: mining | 'mining: a block this machine finds appears here' | 'Mining. A block this machine finds appears here' |
| app.js | Strip fallback: paused | 'paused: no blocks while mining is stopped' | 'Paused. No blocks while mining is stopped' |
| app.js | Strip fallback: worker | state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync' | state.node.synced ? 'waiting for the card' : 'waiting for the node to sync' |
| app.js | Chain scene: the note | 'The full chain view needs the next engine update; this is the last 10 minutes of your own blocks.' | 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' |
| app.js | Chain scene: the state word | st === 'failed' ? 'your blocks' | st === 'failed' ? 'your blocks only' |
| app.js | Node: next program sub | 'until the next hourly mining program' | 'until the next hourly program' |
| app.js | Node: next program unknown | 'known once the node runs' | 'known once the node is running' |
| app.js | Node: finality note | 'a point the miners agreed can never be undone; locks at 2/3 of the 30-day weight' | 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' |
| app.js | Node: finality none | 'locks appear once the miner votes on checkpoints' | 'locks appear once this machine votes' |
| app.js | Node: switch line | 'A switch is a planned rule change. The node applies it by itself when the chain reaches that height.' | 'A rule change is planned ahead. The node applies it by itself when the chain reaches that height.' |
| app.js | Node: switch line, all behind | 'Every planned switch is behind this node. ' | 'Every planned rule change is behind this node. ' |
| app.js | Node: switch line, at DAA | sw.name + ' at DAA ' + withCommas(sw.daa) + ': ' + blocks | sw.name + ' at block ' + withCommas(sw.daa) + ': ' + blocks |
| app.js | Node: manifest switch line | 'The update manifest announces a switch at DAA ' + withCommas(n.consensus_switch_daa) | 'A rule change is announced at block ' + withCommas(n.consensus_switch_daa) |
| app.js | Node: digest not printed | 'not printed yet' | 'not read yet' |
| app.js | Prove: the words off | 'Off. Switch it on and this machine proves the pieces the chain assigns to its keys.' | 'Off. Switch it on and this machine proves the pieces it is given.' |
| app.js | Prove: proving sub | sub: pv.current \|\| 'a shard' | sub: pv.current \|\| 'a piece' |
| app.js | Prove: idle note | 'On. Nothing is assigned to your keys right now; the next shard arrives within a minute.' | 'On. Nothing to prove right now. The next piece arrives within a minute.' |
| app.js | Prove: proving note | 'A proof takes minutes on a CPU and seconds on a CUDA card.' | 'A proof takes minutes on a CPU and seconds on an NVIDIA card.' |
| app.js | Prove: tier, cannot prove yet | ': cannot prove yet (the prover is CUDA only).' | ': cannot prove yet (proving needs an NVIDIA card).' |
| app.js | Prove: tier, proves while it mines | ': proves while it mines (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' | ': proves while it mines (' + gb + ' GB; a full piece needs ' + SHARD_GB + ' GB).' |
| app.js | Prove: tier, cannot prove a full shard | ': cannot prove a full shard (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' | ': cannot prove a full piece (' + gb + ' GB; a full piece needs ' + SHARD_GB + ' GB).' |
| app.js | Prove: toast on | 'Proving on; the first piece arrives within a minute' | 'Proving on. The first piece arrives within a minute' |
| app.js | Prove: verifier words | { command: 'verifying', trust: 'trusting (devnet only)', off: 'off: relay only', unknown: 'not read yet' } | { command: 'checking proofs', trust: 'trusting without checking (devnet only)', off: 'off, passing proofs along', unknown: 'not read yet' } |
| app.js | Prove: segments line | 'whole segments this machine proved' | 'whole runs of blocks this machine proved' |
| app.js | Prove: segments held | ', ' + pv.segments_held + ' held for the chain rule' | ', ' + pv.segments_held + ' waiting for the chain' |
| app.js | Prove: segments last | (pv.segment_last_s ? ', the last in ' | (pv.segment_last_s ? ', the last took ' |
| app.js | Earnings: dev fee off | 'Dev fee off: every block pays your address' | 'Dev fee off. Every block pays your address' |
| app.js | Earnings: dev fee on | 'Dev fee: 1 block in 100 pays the miner software’s author' | 'Dev fee: 1 block in 100 pays the people who make this app' |
| app.js | Earnings: dev fee line | 'The protocol takes nothing; this is the miner software’s fee, the same way every GPU miner takes one' | 'The network itself takes nothing. This is the app’s fee, the same way every mining app takes one' |
| app.js | Earnings: dev fee line off | 'The protocol takes nothing either.' | 'The network itself takes nothing either.' |
| app.js | Earnings: key help | 'The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe.' | 'The key for this address was made on this machine and is stored in the app folder. Only your user can read it. Keep a copy somewhere safe.' |
| app.js | Settings: jobs note off | 'Off: nothing runs here until the switch above allows remote jobs.' | 'Off. Nothing runs here until the switch above allows jobs from the team.' |
| app.js | Settings: jobs note no address | 'No jobs address in this build.' | 'This build has no jobs address.' |
| app.js | Settings: jobs note running | 'Running ' + jobTitle(j) + ' (' + j.id + ').' | 'Running: ' + jobTitle(j) + '.' |
| app.js | Settings: update note current | 'Up to date' + (u.checked_at ? ', checked ' + rel(state.now - u.checked_at) : '') + '.' | 'Up to date' + (u.checked_at ? '. Checked ' + rel(state.now - u.checked_at) : '') + '.' |
| app.js | Settings: update note never | 'Not checked yet. The engine checks by itself every hour.' | 'Not checked yet. The app checks by itself every hour.' |
| app.js | Settings: update note rule change | 'A rule switch comes at height ' + withCommas(u.activation_height) + '.' | 'A rule change comes at block ' + withCommas(u.activation_height) + '.' |
| app.js | Settings: price toast | 'A price in pence per kWh, for example 28' | 'Type a price in pence per kWh, for example 28' |
| app.js | Settings: Ember toast | 'Ember Tune on: once after install, then every 7 days' | 'Ember Tune on. Once after install, then every 7 days' |
| app.js | Settings: Power control on toast | 'Power control on: Windows asks for administrator rights once' | 'Power control on. Windows asks for administrator rights once' |
| app.js | Settings: Power control off toast | 'Power control off; nothing asks' | 'Power control off. Nothing asks' |
| app.js | Settings: trust on toast | 'Trust mode on (devnet only); the node restarts' | 'Trust mode on (devnet only). The node restarts' |
| app.js | Settings: trust off toast | 'Trust mode off; the node restarts' | 'Trust mode off. The node restarts' |
| app.js | Settings: applied toasts | 'Applied; the miner restarts' | 'Applied. Mining restarts' |
| app.js | Cards: switch on toast | 'Worker starting on that card' | 'Starting that card' |
| app.js | Cards: switch off toast | 'Worker stopped on that card' | 'That card is off' |
| app.js | Cards: cap toast | 'Power cap set; pinned until you unpin it' | 'Power cap set. It stays until you choose Default again' |
| app.js | Cards: sendCards default toast | 'Applied; only the changed worker restarts' | 'Applied. Only that card restarts' |
| app.js | Cards: stop toast | 'Tune stopped; the card is back where it was' | 'Tune stopped. The card is back where it was' |
| app.js | Cards: pin toast | 'The tune chooses the cap again' | 'Default: Ember chooses the cap again' |
| app.js | Cards: retry toast | 'Administrator prompt: allow it to set the cap' | 'Allow the administrator prompt to set the cap' |
| app.js | Cards: Tune all toast | 'Tune queued for ' + n + ' cards, one at a time' | 'Tune queued for ' + n + ' cards, one after another' |
| app.js | Overview: toggle toast | 'Mining stops; the node keeps running' | 'Mining stops. The node keeps running' |
| app.js | Address ask | ' from the next block? The miner restarts.' | ' from the next block? Mining restarts.' |
| app.js | Trust ask on | 'Include proof records the node never checked? Devnet only; the node restarts.' | 'Include proofs the node never checked? Devnet only. The node restarts.' |
| app.js | Trust ask off | 'Verify proof records again? The node restarts.' | 'Check proofs again? The node restarts.' |
| app.js | Pill: engine away | 'Engine not answering' | 'App not answering' |
| app.js | Node line: engine away | 'the engine is not answering; the window reconnects by itself' | 'the app is not answering. The window reconnects by itself' |
| app.js | Button: engine away | 'the engine is not answering' | 'the app is not answering' |
| app.js | Hero: engine away | 'no answer from the engine' | 'no answer from the app' |
| app.js | Toast: engine did not answer | 'The engine did not answer' | 'The app did not answer' |
| app.js | Settings: goal toast | 'The goal needs the Ember Tune update' | 'The goal needs the next Ember Tune update' |
| app.js | Cards: card goal toast | 'A per-card goal needs the Ember Tune update' | 'A goal for one card needs the next Ember Tune update' |
| app.js | Cards: card goal set toast | 'This card follows the fleet goal' | 'Default: this card follows the goal set for all cards' |
| app.js | Activity: identity events | m[1] + ': ' + m[2] + ' identit' + m[3] + '; its worker restarts' | m[1] + ': ' + m[2] + ' miner' + (m[2] === '1' ? '' : 's') + ' on it. The card restarts' |
| app.js | Activity: worker ready | m[1] + ': worker ready' | m[1] + ': ready' |
| app.js | Activity: worker fault | m[1] + ': worker fault; restarting it' | m[1] + ': an error. Restarting it' |
| app.js | Activity: worker stopped | 'A worker stopped (code ' + m[2] + '); restarting in ' + m[3] + ' s' | 'A card stopped (code ' + m[2] + '). Restarting in ' + m[3] + ' s' |
| app.js | Activity: worker built | 'Worker built' | 'Mining program built' |
| app.js | Activity: program variant | m[1] + ': program variant chosen, ' + m[3] + ' MH/s' | m[1] + ': fastest program chosen, ' + m[3] + ' MH/s' |
| app.js | Activity: segment proven pieces | 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s' | 'Run of blocks proven: ' + m[3] + ' pieces in ' + m[4] + ' s' |
| app.js | Activity: segment proven | 'Segment proven in ' + m[3] + ' s' | 'Run of blocks proven in ' + m[3] + ' s' |
| app.js | Activity: segment paid | 'Segment paid: ' + m[1] + ' IGN' | 'Run of blocks paid: ' + m[1] + ' IGN' |
| app.js | Activity: segment held | 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s; its record waits for the chain rule' | 'Run of blocks proven: ' + m[3] + ' pieces in ' + m[4] + ' s. Its record waits for the chain' |
| app.js | Activity: segment accepted | 'Segment record accepted' | 'Run of blocks accepted' |
| app.js | Activity: node external | 'The team asked for a node restart; this node is not the app’s, so nothing was done' | 'The team asked for a node restart. This node is not the app’s, so nothing was done' |
| app.js | Activity: no peer | 'No other node found after 90 s; still looking' | 'No other node found after 90 s. Still looking' |
| app.js | Activity: node stopped restarting | 'The node stopped (code ' + m[1] + '); restarting' | 'The node stopped (code ' + m[1] + '). Restarting' |
| app.js | Activity: no reading | 'No reading from the node for ' + m[1] + ' s; restarting it' | 'No reading from the node for ' + m[1] + ' s. Restarting it' |
| app.js | Activity: mining paused | 'Mining paused; the node keeps running' | 'Mining paused. The node keeps running' |
| app.js | Activity: settings changed | 'Settings changed (' + m[1] + '); mining restarts' | 'Settings changed (' + m[1] + '). Mining restarts' |
| app.js | Activity: tune best point | m[1] + ': best point ' + m[2] + ' (' + m[3] + '); your cap stays pinned' | m[1] + ': best point ' + m[2] + ' (' + m[3] + '). Your cap stays pinned' |
| app.js | Activity: next program compiles | m[1] + ': the next program compiles at the hour boundary instead' | m[1] + ': the next program is built at the top of the hour instead' |
| app.js | Activity: new program worker | 'New hourly program; the worker restarts on it' | 'New hourly program. The card restarts on it' |
| app.js | Activity: pack | 'Rebuilding the program pack' | 'Rebuilding the mining programs' |
| app.js | Activity: trust | 'Devnet trust mode: proof records are not verified (Settings)' | 'Devnet trust mode: proofs are not checked (Settings)' |
| app.js | Activity: sweep caps | 'A tune is setting the caps now; retry when it is done' | 'A tune is setting the caps now. Retry when it is done' |
| app.js | Activity: updates off engine | 'Updates are off for this engine (a measurement run)' | 'Updates are off for this copy (a measurement run)' |
| app.js | Activity: power cap not applied | 'Power cap not applied: the administrator prompt was refused (Retry on the card)' | 'Power cap not set. The administrator prompt was refused (Retry on the card)' |
| app.js | Activity: node too old | 'This node is too old for the team’s rule settings; an app update carries the newer node' | 'This node is too old for the team’s rule settings. An app update carries the newer node' |
| app.js | Row word: unplugged | 'unplugged; its worker stopped. The row goes in five minutes.' | 'unplugged. Its row goes in five minutes.' |
| app.js | Details: removed | '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p>' | '<p class="help">The card was unplugged. Its row goes in five minutes.</p>' |
| app.js | Details: problem | 'No worker can drive this card.' | 'This app cannot use this card.' |
| app.js | Setup: no cards | 'No graphics card this app can drive was found.' | 'No graphics card this app can use was found.' |
| app.js | Overview: no cards | 'No graphics card this app can drive was found. The node runs on its own.' | 'No graphics card this app can use was found. The node runs on its own.' |
| app.js | Pages: overview sub | sub: 'the rate, what it costs, the chain as it grows' | sub: 'your rate, what it costs, the chain as it grows' |
| app.js | Pages: prove sub | sub: 'turn blocks into proofs, paid in IGN' | sub: 'prove blocks, earn IGN' |
| app.js | Pages: earnings sub | sub: 'what it made, where it goes' | sub: 'what you made, where it goes' |
| app.js | Goal names for the Default option | var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' }; | var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' }; var GOAL_NAME |
| app.js | Export GOAL_NAMES | GOALS: GOALS, goalWords: goalWords, | GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, |

101
docs/plans/miner-ui-4.md Normal file
View file

@ -0,0 +1,101 @@
# Igneum Miner UI 4: Overview, Cards, Ember on the row
6 October 2026, evening. the project lead: "add Cards under its own tab, and have Overview and the cool mining demo on the main
page with the revamped C option", then "incorporate Ember into the Cards tab beautifully", then "I would like the new
miner UI to go into .15". Branch `miner-ui-4` from the 0.3.15 tip `6ea9497` (untouched), worktree
`../igneum-wt-miner-ui-4`. Rust: one route (`GET /live-dag.js`, `server.rs`), nothing else.
## 1. The rail
Overview, Cards, Earnings, Prove, Settings; Logs and Quit in the foot; a bottom tab bar under 720 px.
## 2. Overview
| Block | What |
|---|---|
| The hero (the project lead's pick, C) | the fleet rate at 84 px on a graphite-to-obsidian fade, W with MH/W, £ a day at the user's price, blocks (this run, lifetime) beside it; Start/Stop as the full-width bar under it with its state sentence at the right |
| The chain, live | the site's `live-dag.js` (a byte copy at `app/igneum-app/ui/live-dag.js`, served by the engine at `/live-dag.js`), mounted on the engine's own `api/live` with `mine: 'you'` so this machine's lane reads "you" and its blocks take a molten ring; the legend words in HTML under the canvas: yours, pending, included, excluded, proven, locked; Pause; a tooltip per block. The engine's `/api/live` is OWED (it maps the local node's RPC into the site's reply shape: `{ok, state, blocks[{hash, ts, blue_score, daa, parents, chain, color, miner, locked, final, shards, proven}], finality.checkpoints}`); until it exists the module reports `failed` and the app's own 10-minute blocks strip draws in its place with the note "The full chain view needs the next engine update". |
| The node line | as 0.3.15, Details under it |
| Activity | the event feed in the app's voice (the strip canvas moved into the chain card as the fallback) |
## 3. Cards
| Block | What |
|---|---|
| The Tuning strip | the goal control (Efficiency, Balanced, Maximum) with "about £X a day at Y W"; the fleet saving ("Ember saves 84 W, £0.56 a day over 1 tuned card", `View.fleetSaving`); Tune all; the schedule line; Power control only when an NVIDIA card has it off |
| The rows | as 0.3.15 (state word with its reason, MH/s, W with MH/W, £ a day, °C, the switch, the chevron) with Ember as the second layer |
| The flame mark | the brand mark's two polygons, 16 px, outline when not tuned, filling bottom-up with step k of n while tuning (molten), solid ember when tuned, half grey when measured only |
| The Ember line | "Tuned 2 h ago · 2470 MHz at 80% · next check Sunday · saves 84 W, 0.16% of rate"; "Tuning: step 4 of 9 · about 7 min left" with the bar; "Measured, not tuned · Power control is off"; "Not tuned yet · starts after 2 min of steady mining"; the fleet pause and a stop in the same place |
| The W cell | before → after when tuned (`tune_before_watts` → the live draw) |
| The MH/W cell | a 44 px sparkline of the ladder's MH/W per step, the chosen step as a dot |
| Tune / Retune / Stop | the row's button; Retune once tuned |
| The chevron | the cap slider, the applied or Retry line, memory and clocks, the card's own goal (As the fleet, Efficiency, Balanced, Maximum: `POST api/tune/goal {key, goal}`, "" clears; a build without it gets a toast), Retune, the Tuned line, the curve (W across, MH/s up, every step a dot, the chosen ringed, a rejected one hollow), identities, the facts |
Fields read (Ember Tune 5429e82, on the 0.3.16 lane): `tune_before_watts`, `tune_before_mhs` (step 0 of the last full
plan; 0 = never; the UI falls back to `tune_curve[0]` only for a full plan on an older build and claims no saving for a
confirm or baseline plan), `sweep_watts`, `sweep_mhs`, `sweep_eff`, `tune_curve`, `tune_goal`, `tune_step`,
`tune_steps`, `tune_eta_s`, `sweep_state`, `sweep_note`, `tune_floor` (asked for). `View.ember` and `View.beforeOf`
carry the rules; `view.test.mjs` covers every state.
## 4. Screenshots (the mock on port 4318 with the rig state, the 5090 tuned, the 4070 at step 4 of 9)
`docs/plans/miner-ui-4/u01-overview-dark-1440.png`, `u02-overview-light-1440.png`, `u03-cards-dark-1440.png` (the
5090's details open: cap, goal, Retune, the curve), `u04-cards-light-1440.png`, `u05-overview-dark-390.png`,
`u06-cards-dark-390.png`, `u07-overview-light-390.png`, `u08-cards-light-390.png`, `u09-overview-mac-dark.png`.
The chain scene in the shots is the mock's `/api/live` (four lanes, one block a second, "you" ringed); on a real
0.3.15 engine the fallback strip shows until `/api/live` lands.
## 5. Owed
| What | Who |
|---|---|
| `GET /api/live` on the engine: the local node's blocks over the window, mapped into the site's shape, this machine's vote keys as the lane "you" | engine |
| `card.tune_floor` | Ember (said yes) |
| `watts_total`, `pounds_per_day` for the tray tip; `address.balance_wei`; `price_gbp_per_ign` | engine (from miner-ui-3) |
| Re-copy `site/live-dag.js` over `app/igneum-app/ui/live-dag.js` when the site agent's audit commit lands, so the two stay identical | the shipper at merge |
Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
## 6. The second tip (20:0x UK): /api/live on the engine, the copy sweep, the project lead's goal rulings
| What | Where |
|---|---|
| `GET /api/live` on the engine: the observer's `/api/live` (the URL `ota.rs` already polls for the identity count) through curl, cached 2 s per window, this machine's blocks marked as the lane "you" by matching the block's miner id (the first 8 hex of its vote key hash) against every card's `ids`; `state.you_blocks` and `state.source: "observer"` added; `{ok:false, error}` when the observer is unreachable or the build has no observer address, which the UI meets with its own blocks strip. Four unit tests on the shaping from a fixture in the observer's shape (`live::tests`). The local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so a local-node source is OWED (a JSON endpoint on igneumd, or a wRPC client here); the reply shape stays the same when it comes. | `src/live.rs`, `src/server.rs`, `src/main.rs`, `ota::live_api_from` made `pub` |
| The copy sweep: 150 strings, every one listed in `docs/plans/miner-ui-4-copy.md` | `ui/index.html`, `ui/app.js`, the tests |
| the project lead's rulings: the per-card inherit option is "Default · Balanced" (grey, following the shared goal); "Default" wherever a per-card setting follows the shared one (the cap's "Back to Default"); the Cards strip is titled "Ember Tune"; every goal option carries its meaning as a tooltip (Efficiency "most hashes per watt, a little rate traded", Balanced "a little rate for most of the saving", Maximum "most hashes, watts second", Default "the goal set for all cards") | `ui/index.html`, `ui/app.js` |
The Rust tests did not run on this Mac (its cargo predates the lock file's version 4); the box runs them at the
merge, as the rule says. UI tests: 37 pass. Screenshots `u01`, `u03`, `u04`, `u06` reshot after the sweep.
## 7. Pinned to the next engine (ember-tune fd03f35, then b55bc1f), 20:5x UK
Numbering (the shipper, 21:3x UK): 0.3.16 is tonight's corrective cut, the same release as 0.3.15 (this branch's ui/
at 872f1c9) under a new number; the engine fields and the pinning below ship as 0.3.17 with miner-ui-4's tip e31acab.
| Field | Where the UI reads it |
|---|---|
| `mining.watts_total`, `mining.pounds_per_day` | the Overview hero and Earnings use the engine's figures when present (`View.fleetWatts`, `View.fleetPounds`), else the UI's own sums as before |
| `address.balance_wei` (string or null), `address.balance_age_s` (-1 until read), `address.balance_note`, `address.price_gbp_per_ign` (null until a signed manifest carries a price) | Earnings' first row: "12.3456 IGN · in the wallet · read 20 s ago" (`View.balanceWords`); a priced balance shows £ with "at the signed price"; an older engine without the field keeps the row as it was |
| `card.tune_floor` | the Ember line's "(the lowest clock Ember will set)" |
| `/api/live` reply `source` ("node" when igneumd answers `igneum_getRecentBlocks`, "site" until then; `pending: true` before the first body) | the chain scene's note (`View.liveNote`): "From your node. Your blocks are ringed." or "From Igneum’s observer. Your blocks are ringed."; "you" is matched in the UI from every card's vote-key ids whatever the source names the lane |
At the 0.3.17 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15
file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two.
## 8. N4 (the frozen tip), the capture route, and PC 2's light render (6 October 2026, 23:0x to 23:4x UK)
| What | Where |
|---|---|
| N4, the app half: `sync_decision_v2` (the old rule AND the watch line's `tip_age_s` at or under 120 s AND peers >= 1; the cause word frozen / no peers / syncing / behind on `node.sync_cause`; the state word "behind" or "no peers", never "synced" on a frozen tip); `is_stall_exit` (code 45 or a "STALLED '" tail line; the first restarts the miner, the second since the node started restarts the node and re-dials its peers); `node.tip_age_s`, `node.stall_exits`. The known-failed case is the first test. UI: "Node behind · 1 peer · 133,000 blocks · last block 53 min ago" with the cause line, the pill "Node behind", the big button's "waiting: the node is behind the chain". The node lane's miner half is ca3-v4-0316. | `src/engine.rs`, `src/state.rs`, `ui/app.js`, `view.test.mjs` |
| (b) a screenshot from the machine itself: `POST /api/shot {name}` writes `SHOT <path>` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `<data root>/shots/igneum-<name>-<unix>.png` (`live::shot_path`, the name reduced to [a-z0-9-]); `relay/playbooks/app-shot.ps1` asks and waits, a collect job's glob `shots/*.png` brings the file back. Read-only. The Swift parses (`swiftc -parse`); the C++ builds on the GitHub runner at the cut (`BUILD-APP.bat` already links shlwapi.lib). | `src/server.rs`, `src/live.rs`, `app/mac/IgneumMiner.swift`, `app/windows/host.cpp`, `relay/playbooks/app-shot.ps1` |
| The light-theme showcase capture: Edge headless on PC 2 wrote nothing in three takes (154.0.4258.53, exit 0, no stderr, in the app's elevated session); PC 2's `api/state` and `api/live` were fetched by `relay/playbooks/app-state-grab.ps1` into the data root and collected, and the ui-mock replayed them (`IGNEUM_MOCK_STATE`, `IGNEUM_MOCK_LIVE`) through the real 0.3.16 UI on the Mac: `docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png`, `pc2-cards-light-1440.png`, `pc2-overview-dark-1440.png`, captioned "rendered from PC 2's live state at 23:33 UK" (DESKTOP-KMCV30N, RTX 5090 at 103 MH/s and 317 W, node synced, 5 peers). Handed to the site lane. | `tools/ui-mock/server.mjs`, `relay/playbooks/app-state-grab.ps1` |
| The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` |
Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them).
## 9. 0.3.17 fold (7 October 2026, 23:20 UK)
- Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests).
- src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests.
- src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more).
- External node re-decided (the Mac, 7 October 2026 00:18 UK: the hand node left and the app sat on "node stopped" all night). While the app reads another node, or refused one, it checks the port every 5 s; gone for 60 s (`extnode::TAKEOVER_WAIT_S`) it starts its own node on the freed ports and says so in Activity; a node back inside the wait is kept. api/state node.mode own | external | none with node.mode_reason, shown under the Node details. Test `an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait` (169 box tests).

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

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