Compare commits

...

612 commits

Author SHA1 Message Date
igneum-labs
04fcb275f8 igneum-pool recheck: the pack's genesis day index installed with its dataset size before the first epoch (--genesis-day overrides). The same-work lane's class, 8 October 2026 22:10 UK: a standalone process holds the genesis day at 0, the cache growth rule doubled the cache fourteen times by day 20730, and every hash of the D1-pairing row disagreed with the reference while igneum-pow's own class v6 seam reproduced it exactly; the live miner installs both from the template, the reader now from the pack
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 21:18:53 +00:00
igneum-labs
04cf78f26b igneum-pool recheck: the pack's dataset size (program.json dataset.log2_words) installed before its epoch is built, the flag overriding; the same-work context's packs say 2^28 words and a reader at the genesis default hashes every nonce wrong
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:52:28 +00:00
igneum-labs
6961a9792b igneum-pool recheck: the same-work test's pool reader (review B F03, R02/R03; docs/plans/igneum-2.0-same-work-test.md). The pool's own share verifier (IgneumEngine::epoch_for + hash_bound, the path every live share takes; verify::check for a 64-nonce accepted-share sample a phase) over the P01 driver's job context (--job-context --phase, the driver's segments_for: phase 1 on day D, phase 3 on D+1, phase 2 split at the boundary nonce), the evidence in p01-vectors.py's shape (case, profile, pack, program_id, generator, class, device_line, manifest_sha, prehash, nonces, answered, agree, disagree, missing, the first ten disagreements with the pool and cpu hashes, first_missing, segments, boundary with switched, verdict) plus accepted_share_sample; an engine refusal (the class v5 or v6 state check, a class the vendored node cannot name) is the file's own verdict BLOCKED with the text, exit 2, never a crash; a file-backed day-state provider (--state <igsd1>) for the node1 stream. Self-test: known-pass 256 of 256, known-failed a flipped hash at nonce 1100 and a missing nonce at 1200 counted and named, the job-context form over three phases with phase 2 switched at nonce 24 with the boundary block, the BLOCKED verdict on a refusal; green on build-6 from the release-2.0.2 tree against node 7cfa422a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:50:01 +00:00
igneum-labs
08ec79eee9 Merge reviewb-202 d488b76c into release-2.0.2 (the quit guard: api/quit completes within 45 s whatever the node does; PC 2's 21:24 class)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:39:53 +00:00
igneum-labs
d488b76c34 quit guard test: the unix-only child carries the console note the spawn check reads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:37:36 +00:00
igneum-labs
c00e5dc42e 2.0.2: a quit that quits, whatever the node's state (the quit guard)
PC 2, 8 October 2026, 21:24 UK: api/quit on 2.0.1 left all four processes up 90 s later while the node sat in initial
sync. Now: a quit guard armed at the ask (the server's /api/quit and the engine's Cmd::Quit alike) ends the node and
the workers by the pids the state records, never by name, 45 s after the ask when the engine has not left by itself,
prints the exit line and leaves; the node's grace is 10 s then the kill; the last log upload waits 10 s at most; the
window reads the stage ("quitting: the node is being stopped") on the pill and the big button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:35:10 +00:00
igneum-labs
b5531f46b5 Pool, the devnet-4 pair's class (8 October 2026): no job on an unsynced node's state. state_provider.rs reads igneum_getExecStatus beside the class v5 leaves and refuses a stream while the node reads synced false, blocked or re-executing (ExecStatus; a missing field is not synced, never a job on a guess), so the engine builds no epoch and the template feed issues no job on catch-up leaves (the pair: identical seeds, era and the freeze's generator on node, pool and member, every share wrong_hash because the node in class v5 catch-up answered igneum_getPowStateLeaves from its still-settling state); the STATUS line says 'node not synced (class v5 catch-up): no jobs' with the exec RPC named while it holds. Known-failed first: an_unsynced_node_hands_the_pool_no_state_and_no_job read red against the 8ff7a0f4 provider on build-6 (it served the leaves), green with the guard; the mock RPC answers igneum_getExecStatus and igneum_getPowStateLeaves. Gate from this tree against node 7cfa422a: 52 of 52 on build-6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:23:07 +00:00
igneum-labs
1c9134617c Merge reviewb-202 ec35d31c into release-2.0.2 (the power-helper-task@protected right: an installed 2.0.1 PC takes the protected helper copy once, promptless with --win-engine)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:16:32 +00:00
igneum-labs
ec35d31c50 V6-06: the protected helper copy is a new right, so an installed PC takes it once
Rights are by id and a changed script re-asks nothing, so without this a 2.0.1 install would have kept its task on the
LOCALAPPDATA exe for ever. power-helper-task@protected is helper-takeable: an update by job takes it through the
running helper's reregister (the copy under the signed manifest's engine hash, no click); otherwise the next
interactive start asks once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:14:25 +00:00
igneum-labs
b1eee29f6a Merge reviewb-202 295711ba into release-2.0.2 (V6-06: the elevated script and helper hardening; publish-manifest --win-engine writes platforms.windows.engine_sha256 for the Power Helper's protected copy)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:12:37 +00:00
igneum-labs
295711ba29 V6-06: the Power Helper runs a protected copy, refreshed only on the signed manifest's engine hash; elevated scripts inline; quit and remove through the sequence guard; the host asInvoker
The one elevated step copies igneum-app.exe and its runtime DLLs into %ProgramData%\Igneum\helper with inheritance cut
(Administrators and SYSTEM full, Users read and execute, owner Administrators) and registers the task against that copy;
the per-user install under LOCALAPPDATA is never what the scheduler runs elevated. The helper's reregister refreshes
the copy only when the signed update manifest names the installed exe's sha256 (platforms.windows.engine_sha256,
publish-manifest.sh --win-engine); anything else is refused with its reason in helper.log. rights.ps1 and
register-power-task.ps1 are no longer read from user-writable folders: every elevated script travels inline through
-EncodedCommand. quit and remove carry a sequence like every verb and pass the rising-sequence guard; a stale line
re-added to the file is skipped. The window host declares asInvoker. The prove host's lease is the lesser of the
grant and the free reading (the V6-07 sub-lane's 12 GB row).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:08:18 +00:00
igneum-labs
0dfb3b05d3 Pool on the 2.0.2 release branch: pool/ and docs/plans/pool.md from the pool line's tip pool-2.0 e063fdcd (pool-review-b 786e09cc: review B F12 durable payment intents, F13 admission bounds, INT-04 the verified ledger, INT-15 the challenge-bound authorize; merged by the pool seat on 8ff7a0f4 the class v5 day-state source via --exec-rpc and 8106ba27 the base unit from the node's network, the two fixes the devnet-4 pair needs; suite 51 of 51 on build-2 against successor-2.0.1 2b1a247a). The spec stays the hand-merged copy this branch carries. Rule 24's crate gate from this tree on build-6 against the pinned node 7cfa422a is the read-back below
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:03:01 +00:00
igneum-labs
552683e034 spec 09: the hand-merged copy, carried from release-2.0.1 ba50fff1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 20:01:11 +00:00
igneum-labs
5a6126a4c2 Merge reviewb-202 af5ade43 into release-2.0.2 (F14 public and lab products, the install-time update choice honoured, the lab channel and root, F04 words, F07 device modes and the prove host env, the no-peers watchdog words, the ten-minute check, the safe-moment fix, the job runner refusing kill-by-name)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:59:27 +00:00
igneum-labs
a70ea2d6c9 2.0.2: the version bump (rule 15, the release branch's first commit, six places and the README line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:59:27 +00:00
igneum-labs
a93ba3dea0 Merge f03-manifest-201 7437a31a into release-2.0.1 (review B F03: the one release manifest packaging/release-manifest.json, release-manifest-check.sh, build-from-manifest.sh)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:57:24 +00:00
igneum-labs
7167f59a0e Pool on the release tree (the coordinator's ruling, 8 October 2026 20:5x UK: the pool crate's home): pool/ and docs/plans/pool.md, docs/spec/09-pool-protocol.md from pool-review-b 786e09cc. Review B F12 (payments as durable intents, never a duplicate or a lost obligation, finalised only under the chain's final lock), F13 (the frame bound while reading, the bounded outgoing queue, one membership per session, nonce and in-flight bounds, share and connection budgets), INT-04 (the verified ledger, never empty over a corrupt file), INT-15 (the challenge-bound authorize binding v2). Suite 51 of 51 on build-6 against successor-2.0.1 515ba674 and cargo check green against this tree's node pin 7cfa422a on pool-review-b; the same suite from this tree against 7cfa422a is the read-back below
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:54:57 +00:00
igneum-labs
af5ade4339 2.0.2: PRODUCT=public|lab packaging switch, the kill-by-name refusal, the prove host's lease, the ten-minute check, the synced apply bound
PRODUCT=public|lab (the founder's word at 20:21 UK): one variable in packaging/mac/packaged-config.sh that every packager
reads: Igneum-Miner.iss AppId, AppName, folder and file name (/DProduct from build-installer.ps1 -Product), make-payload.sh
and make-hive-package.sh names (payload folder, Hive CUSTOM_NAME and archive), build-dmg.sh bundle id, app and dmg names,
the channel (igneum-2.0-devnet stays the public string), the manifest name and the signing root; the packager writes
edition, channel and ota_root_hex into igneum-app.json and the engine names a mismatch on its update card.

The job runner refuses a run-script body that ends a process by name (Stop-Process -Name, taskkill /IM, Get-Process |
Stop-Process, pkill, killall): exit 77 with the matched line, in the signer and in the runner; a pid is the only way.

Every igneum-prove-host spawn carries IGNEUM_PROVE_DEVICE, IGNEUM_PROVE_WORKLOAD, IGNEUM_PROVE_MEM_FREE_MB,
IGNEUM_PROVE_MEM_BUDGET_MB and IGNEUM_PROVE_DEADLINE_S (the V6-07 contract); exit 78 reads "proving needs N GB free".

The manifest check runs every ten minutes. A staged update applies at once on a synced node with an idle miner and
within two minutes of the sync otherwise; Install now passes the finality guard; publish-manifest.sh --urgent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:51:15 +00:00
igneum-labs
862e2e7586 F03 (Review B): the 2.0.1 release manifest, pinning node 7cfa422a, the class v5 freeze, the dataset policy, the acceptance rule, the host ABI, the miner app, the pool, the proof guests and vks, and the activation settings
packaging/release-manifest.json for release 2.0.1 on igneum-devnet-4, with tools/ci/release-manifest-check.sh (every component's own pin must equal it) and tools/ci/build-from-manifest.sh (every component built from it on a box). The shipper lands it on release-2.0.1 at the next cut; the manifest's miner_app sha then moves to that cut and the check reads it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:32:46 +00:00
igneum-labs
af4302002f The lab key travels with the build environment; the device coordinator's record line
infra/build-server/lib.sh: bs_repro_env forwards IGNEUM_LAB_PUBLIC_KEY to the box when the caller set it, so the lab
build (`--features lab`) compiles its root through tools/build-remote.sh without the key in the tree. The prover
logs `DEVICE event=free|not-free used_mib=<n> waited_s=<s> holder=prover` around the time-share gate: the line the
fleet lane's INT-11 rows read (the device free memory confirmed, not dispatch paused).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:20:16 +00:00
igneum-labs
414fafd57b v2.0.2: review B F14 (two builds), F07 (the per-device proving mode and its coordinator), F04 (recovery locks named) in the app
F14, the founder's ruling of 19:57 BST (docs/analysis/review-2026-10-08-b, the shipper's and the relay lane's names):
one tree, two builds, by the cargo feature `lab` (src/edition.rs). The public miner (the default) runs no remote
jobs: no signing root for them, no jobs url, the routes /api/jobs/* compiled out, the wake listener compiled out, the
Settings switch hidden, POST /api/jobs/allow refused with a line; its update choice is honoured: with automatic
updates off nothing installs until Install now, an urgent manifest included (manifest::safe_to_apply, the first
rule), and an unsupported version pauses mining with the reason on every card (update.hold_mining, the engine's
unsupported_hold); a manifest's consensus override is ignored (the node's rules come from the node; a compromised
update key activates nothing). The lab build (our fleet, `IGNEUM_LAB_PUBLIC_KEY=<hex> cargo build --features lab`)
verifies its OTA manifest, its interface bundles and its jobs feed against the lab root the relay lane generated
(never in the repo; the build fails without the variable), reads channel igneum-2.0-devnet-lab, names itself
"Igneum Miner Lab"; the public build reads igneum-2.0-devnet (the 2.0.0 and 2.0.1 manifests' "devnet" accepted
until the publisher renames it) and refuses a manifest of the other channel before staging. api/state carries
edition, product and channel; the IGNEUM-APP intake line carries channel= and edition=. The update choice is asked at
install: the welcome screen's "Update automatically" switch (on by default) and the Windows installer's task, which
writes install-choices.json for the engine's first start (config.rs).

F07: src/device.rs, the per-device coordinator. The mode per NVIDIA card from the measured rows (coexist-rows.md,
8 October 2026): simultaneous only on a tested configuration (24 GB and up, with 10% headroom over the measured
peaks), time-share where the compressed shard proof (7,532 MiB) fits alone, mining-only where no complete paid proof
fits; a lease table across the miner, the prover, an aggregation, a benchmark and the next-epoch preparation, where
pausing dispatch is not releasing memory (a lease ends only after the holder's process exits and nvidia-smi reads the
device under 1,024 MiB), and admission counts transfer, startup, proof, aggregation, rebuild and the payment deadline.
Wired: provedefault reads the modes (16 to 24 GB alternates, no longer "together"), state.proving.modes carries one
line per card for the Proving section, and the time-share prover waits up to 60 s for the card to read free after the
miner steps off before a shard, leaving the shard for another prover with the reading when it does not.

F04: the window's block inspector reads "finalised by a recovery lock" and the strip "recovery lock" with its why,
from igneum_getProvingStatus's lockKind and lockWhy once the node lane's field lands; the pause word stands until then.

Tests: edition.rs (the roots and channels per build), manifest.rs (off stays off, known-failed first), config.rs (the
installer's choice taken once), device.rs (the modes per row, the full cycle with no leaked reservation, the
simultaneous and mining-only rules, the deadline), provedefault.rs (the modes and the refusal), detect.rs parse; the
public crate 327 green on build-1; the once-tests for the window (F14, F07, F04); 133 UI tests green on build-2.
Captures in ~/Desktop/igneum-previews-2026-10-08/reviewb-202. The lab build's test run needs the key in the box's
build environment (tools/build-remote.sh does not forward it yet); the public build is the gate's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 19:17:53 +00:00
igneum-labs
38351afda5 2.0.1: the node pin moves to release-2.0.0-node 7cfa422a (the miner base-unit fix, compiling; 777214af amended away)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:49:11 +00:00
igneum-labs
6c10f232b5 2.0.1: the node pin moves to release-2.0.0-node 777214af (every miner command installs the network's base unit before reading an amount; the over-u64 coinbase refusal that stalled the fleet; digest-preserving)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:45:31 +00:00
igneum-labs
1c22fc5de6 2.0.1: six dial targets across five boxes in the packaged peer list (build-1's seed and hand, the two fleet seeds, the two held hubs lp-4090-07 and lp-4090-02); 8 October 2026, 19:2x BST: every packaged target was down at once and every home miner lost its peers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:40:28 +00:00
igneum-labs
972b1c1e4a v2.0.2: a node with no peers holds the worker; the watchdog names the node, never a worker fault
The founder's call at 19:25 BST on the mini (8 October 2026): with the node at 0 peers the worker idled for lack of
templates and the watchdog blamed the worker ("did not come back within 180 s of the miner restarting it (seed
mismatch...)"): the restart grace ran before the node's readiness was read. Now CardWatch::tick takes `no_peers`
(the engine reads state.node.peers == 0, once the node is past starting) and, whenever the node has no peers or the
miner's last word was a template timeout (templates_blocked), every clock holds, the restart grace included, the
verdict is None, and `held_by_node()` names the cause: "no block templates: the node has no peers" or "no block
templates: the node answers none for the window". The engine writes that cause onto the card's row while it holds,
and the waiting card of a node with no peers reads it too. The node line on Mine reads "Node: no peers, dialling the
seeds" and the words under it end "mining waits for block templates". F6 in the window audit doc (master).

Tests known-failed first: watchdog.rs (the founder's case: a worker restart on a node with no peers, the grace long
past, Action::None and the cause; the template-timeout window; the old grace rule with peers and templates), the
once-test (the node line, the cause in the watchdog, the engine reading it). The crate: 320 tests green on build-1;
130 UI tests green on build-2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:31:19 +00:00
igneum-labs
885f5f9cca 2.0.1: proving/igneum-prove/elf/prior from key-succession-pin 4c6d6d64 (node ef0f2ed8 embeds the prior pair's verifying keys by include_bytes from this tree; the release branch carries what builds it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:23:33 +00:00
igneum-labs
c68bf66ceb 2.0.1: the node pin moves to release-2.0.0-node ef0f2ed8 (291ee6ae key-succession capability + the node's 2.0.1 version bump; digest-preserving)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:12:19 +00:00
igneum-labs
048b9fc82f 2.0.1: the DMG README's version line (the copy beside the six places)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:50:45 +00:00
igneum-labs
3c13a71cef Merge cards-201 d6337a86 into release-2.0.1 (the Cards tab back as its own page, the first-block card once per payout address, the four finality words on the window, one positioning line, the watchdog's real cause; the founder's calls of 8 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:35:21 +00:00
igneum-labs
d6337a867b v2.0.1 window: the Cards tab back as its own page, the first-block card once per payout address, the four interface words, one positioning line, the watchdog's waiting words
The founder's calls of 18:1x BST, 8 October 2026 (through the shipper), recorded as F1 to F5 in the window audit doc.

F1. The Cards tab is back in the rail as its own page (View.PAGES: mine, cards, tune, earnings, settings); every card
row moved out of Mine into it: the state, the hash rate, the power, the temperature, the tune state (one line, the Tune
page's words, View.cardsRowWords), the enable switch, the 16 GB proving note on an NVIDIA card under 16 GB. Mine keeps
the headline rate and the chain status (the hero tiles, the chain scene, the node line, Activity).

F2. The first-block celebration shows once per payout address, ever: settings.json keeps first_block_shown[address]
= {hash, at} (config.rs FirstBlockMark), written when a window reports the card seen (POST /api/card/seen), so it
survives runs, updates and a reinstall that keeps the wallet; a new address shows it once again (the engine passes
the address's mark to Ladder::on_block_for, which raises the card whenever the address has none); an install that
showed it under the machine rule takes the mark for its current address once at start. Never on a dev-fee block: the
miner's "dev-fee block <hash>" line follows the fee block's ACCEPTED line, and Ladder::on_fee_block withdraws a first
card that block raised; the engine now counts fee blocks from that line (fee_session, fee_total) as well.

F3. The four interface words (docs/spec/finality-guarantees.md section 9) on the window: the block inspector reads
included or excluded or pending, executed as chain block N, proven, finalised (View.blockState); a block under the lock
is finalised even while the network's finality is paused (the pause is said after the block's own words and on the
status strip), and no block reads "not active" or "(reported)"; the scene's hover words say finalised and executed
(scene/live-dag.js 2.0.8, the copies synced).

F4. One positioning line: the hero keeps it; #s-positioning under Updates is gone.

F5. The watchdog's words when the node serves no epoch state stream: "waiting for the node's execution state for this
epoch: no program to load 300 s after starting", never "the worker did not load its program".

Tests known-failed first: once.test.mjs (the Cards page with the home page's contents, the inspector's words), the
view tests' page list, ladder.rs (the first card follows the address mark; a dev-fee line withdraws it; the 0.3.21
wrapper keeps the machine rule), watchdog.rs (the waiting words). The crate: 319 tests green on build-1; 129 UI tests
green on build-2. Captures: ~/Desktop/igneum-previews-2026-10-08/cards-201.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:27:36 +00:00
igneum-labs
40fb814060 2.0.1: the node pin moves to release-2.0.0-node 417c4a57 (9fc9f42a + the genesis-stream fix 5713d547 + the follower's tip lag; digest-preserving)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:21:19 +00:00
igneum-labs
8029b5b842 2.0.1: the node pin moves to release-2.0.0-node 9fc9f42a (the manifest block, finality rule v4 behind its switch, the four-state transaction RPC; node-only, no digest move; the paired cut is tomorrow morning's, the guest elf re-pin rides it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:04:59 +00:00
igneum-labs
0dd5a8d7ee Merge resume-on-update-20 853eab97 into release-2.0.1 (the first start after an update clears a pause the previous version saved; the founder's rule of 8 October 2026: an update never leaves mining off)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:04:49 +00:00
igneum-labs
2b80e31861 2.0.1: the version bump (rule 15, the release branch's first commit, six places)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:04:49 +00:00
igneum-labs
853eab974a v2.0: an update never leaves mining off (the founder's rule, 8 October 2026, 18:0x BST: the Devnet 3 off jobs left both PCs paused through api/pause, and the 2.0.0 update would have started paused with a click owed after the install). On the first start after an update (ota::updated_from, right after the "up after the update from" read-back line) a saved pause is cleared, saved and said: "mining was paused under <from>; the update to <version> resumes it (an update never leaves mining off)"; an ordinary start keeps the pause the user or a job set. engine::resume_after_update is the rule; test known-failed first: engine::tests::the_first_start_after_an_update_never_leaves_mining_paused
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:00:42 +00:00
igneum-labs
245329c051 Rule 25 in the window: wei never a JavaScript Number; the Mac first-run copy says the build is unsigned tonight
The engine already sends every wei value as a decimal string (u128 on the Rust side), but the window turned three of them into a Number for the IGN figure (the proving paid line, the segment paid line, the wallet balance). A 2.0 block subsidy is 9.5e18 wei, past 2^53, so the IGN figure is now cut from the string (weiParts, ignText): whole units and the first four decimals, no wei value in a Number anywhere; only the IGN figure is a Number, for the pounds line and the thousands check. The 61 view tests hold.

The DMG README's step 2 says the build is unsigned tonight and gives the two-step (open once, then System Settings, Privacy and Security, Open Anyway); Developer ID signing and notarization follow with the Apple enrolment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:30:29 +00:00
igneum-labs
410c2d2d84 Merge restart-kind-20 b86fcbe6 into release-2.0.0 (a remote app restart under the window host exits plainly and lets the host's ladder restart it, no helper race; headless starts the helper itself; PC 1 down for hours twice on the race; known-failed first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:14:33 +00:00
igneum-labs
b86fcbe6b5 v2.0.0: a remote app restart under the window host is the host's ladder, never a helper racing it (PC 1, 6 October 2026 19:10Z and 8 October 2026 14:50Z: the restart kind quit the engine and left a hidden PowerShell to Start-Process igneum-app.exe --launch 8 s later, racing the host's own restart ladder for the single instance; it lost both times, "the relaunch helper did not bring it back", the app down for hours with the whole job queue behind it). Now the engine decides (engine::restart_plan): under the host it sets the quit source "remote job: app restart" (a plain EXIT, so the host restarts it in about 10 s; an update exit would end the host) and starts nothing; with no host (--boot, a headless start) it starts the relaunch helper itself and keeps running when the helper cannot start. The job runner's restart arm no longer spawns the helper. Test known-failed first: engine::tests::a_remote_app_restart_under_the_host_uses_the_hosts_ladder_and_no_helper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:11:14 +00:00
igneum-labs
30823385dc 2.0.0: the Windows node-source pin moves to 4cdcc488 (node 2.0.0 on the Igneum 2.0 devnet: c04674fe plus the emission literal widened to 18 decimals; digest be5f4068; the 2.0.0 pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:10:00 +00:00
igneum-labs
1602298d08 2.0.0: the prove-instead line's test reads 16 GB with the rule (the one red on 1e76d2a4's crate gate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:54:03 +00:00
igneum-labs
1e76d2a413 Merge net-20 e30d58c4 into release-2.0.0 (the network step renders: app.css's phase rule lists #screen-network, blank since 0.3.23 on every fresh install; the once-test reads every screen id against it; the step renders on every state)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:50:12 +00:00
igneum-labs
e30d58c455 The network step is shown: app.css never listed #screen-network, so step 3 of 4 was a blank page on a fresh install
Found by the net-20 capture: .screen is display:none and only the ids in app.css's phase rule display; screen-network
(the network step, 0.3.23) was never in that rule, so a fresh install reached step 3 of 4 as a blank page with no
Continue button, on every build from 0.3.23 to 0.3.26 (the forced ?screen=network capture read the same). The rule
lists it now. The once-test reads every screen id in index.html against the rule, known-failed first (five of six).
127 UI tests green on build-2; the step captured with its one card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:47:41 +00:00
igneum-labs
0400205bf7 Merge prove-host-20 7e81180b into release-2.0.0 (a Windows node under the proof rule waits for its verifier host with the reason shown and sets it up itself: the WSL2 feature as the installer's one elevated right, the distro unelevated in the background, the probe on the engine's restart clock; known-failed first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:47:25 +00:00
igneum-labs
93d8310384 2.0.0: the window's proving line moves to 16 GB with the engine (PROVE_MIN_GB 16, the one-at-a-time sentence; the view test known-failed first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:45:25 +00:00
igneum-labs
b03eed628b Merge net-20 942e82bb into release-2.0.0 (one network card, the Igneum 2.0 devnet; the testnet card, the Devnet 3 and devnet v4 text gone from the window; the once-test refuses them)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:45:03 +00:00
igneum-labs
4c5c1bb562 2.0.0: mine and prove together need a 16 GB card (the rented-card rows of 8 October 2026: the miner 6.1 GiB resident, a compressed shard proof 7.5 GiB; a 12 GB card running both kills the prover), so under 16 GB the card alternates (prove instead of mining), the line says why, the test known-failed first; a stored Devnet 3 choice reads as the one devnet (the chain moved under every 0.3.26 install)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:45:03 +00:00
igneum-labs
e10f6b8e9f The network step renders on every state, not only on the click that opens it
The net-20 capture of the network step (?screen=network, the forced start the screenshots use) read empty: show('network')
renders the step only when a state is already held, and the first poll shows the forced screen before render() stores
the state; later polls render the dashboard pages only. render() now renders the step whenever the phase is network,
the same way it renders the cards step. 126 UI tests green on build-2; the step captured with its one card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:44:16 +00:00
igneum-labs
7e81180b38 2.0.0: the node's proof verifier is set up with no hand on Windows (main's order, 8 October 2026: on the 2.0 devnet the proof rule is set from block zero and the daemon refuses to start on Windows when igneum-prove-host is absent; under plug, tune, play that refusal never reaches a user). The host is the payload's WSL2 Linux binary (wsl2\bin\igneum-prove-host with igneum-prove-export, run through the native igneum-prove-verify.exe wrapper); no native Windows host exists (SP1's prover is Linux-only), so the one-click path is the WSL feature, the distro and the file. Start order (src/engine.rs start_node, src/verifier.rs node_start_hold): on Windows the node is NOT spawned while the verifier resolves "off"; the reason goes to the node tile and /api/state (node.state "waiting", node.message "waiting for the proving host: <why> ... no hand needed ... the node starts by itself when it answers"), one event, then host_setup_step takes one step per held start from host_setup_plan: NeedsFeature (names the installer's rights step, never a prompt from the engine), InstallDistro (wsl --install -d Ubuntu-24.04 --no-launch in the background, unelevated, no first-run window), HostMissing (names the file), Probe; the start is retried by the engine's own restart clock every minute with a fresh probe, and trust (devnet only) still starts the node. Rights: a new id wsl2-feature taken in the installer's one elevated step (wsl --install --no-distribution --no-launch, idempotent through wsl --status, a reboot said in rights.log and never forced), so a 0.3.26 install asks once at the update. Tests known-failed first: verifier::tests::a_windows_node_without_its_verifier_is_held_with_the_reason_and_the_host_is_set_up (the hold, the plan, the command, the start order read from engine.rs), rights::tests::the_wsl2_feature_is_a_right_the_installer_takes_once. Owed to the gate box (PC 1, the detached job shape after the host-200 build): a clean Windows machine starts the node with the rule set and no hand step; the reboot after the feature is the one wait the gate must include
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:43:40 +00:00
igneum-labs
1e0aa9c1d7 2.0.0: the copy pass against docs/plans/igneum-2.0-reference.txt inside the window and the DMG README: the first-run eyebrow and the About line carry the positioning line (A GPU-secured network for Ethereum-compatible applications and verifiable computation), no devnet v4 or 0.x text, the demo samples read 2.0.0; the network cards stay the UI lane's
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:09 +00:00
igneum-labs
6112a969dd 2.0.0: the network move to the Igneum 2.0 devnet (igneum-devnet-4): the package's suffix 4 and its seeds (build-1's seed and hand, dn4-seed 64.119.209.250:21703, lp-4090-01 69.145.85.93:17873), the hive's node on --devnet-suffix (DEVNET_SUFFIX, default 4) with the same seeds, network_name reads igneum-devnet-4, the test network no longer offered, the chain label Igneum 2.0 devnet; the tests rewritten known-failed first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:09 +00:00
igneum-labs
942e82bb9e Igneum 2.0: one network in the window, the Igneum 2.0 devnet; the testnet card and its note are gone
The founder's word of 8 October 2026 (16:2x BST): the testnet is scrapped and the devnet is one network, served as
"the Igneum 2.0 devnet" (the engine runs igneum-devnet-4 and serves the label as state.chain, the shipper's engine
commit on the same line). The network step (step 3 of 4) and the Settings Node card show one card, "Igneum 2.0 devnet
· igneum-devnet-4 · the chain may reset, coins have no value", the fresh-install default and the only choice; an
existing install keeps what it runs ("This machine runs the Igneum 2.0 devnet (igneum-devnet-4)."); an older network
name reads as the one network. The "igneum-testnet-1 opens when…" note, the testnet card, "Devnet 3" and "devnet v4"
leave index.html and app.js (the welcome eyebrow and the node facts default read "Igneum 2.0 devnet"). The mock's
recorded state carries the new chain label and network name.

Tests known-failed first on build-2 (once.test.mjs: one NETWORKS entry, one card, the words, no testnet or old label
in the window), then green; the 0.3.23 network tests follow (the Rust-source mirror keeps the network_switch name
and its test, and no longer pins the testnet_open signature or the manifest's testnet rule, which the engine commit
removes). 126 UI tests green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:40:24 +00:00
igneum-labs
c4f7bbfd22 2.0.0: the version bump (rule 15; Igneum 2.0 on the founder's word of 8 October 2026: the version line restarts at v2.0.0 for the miner, the testnet scrapped, Devnet 3 the network; opened from release-0.3.26's final tip 5054d0ee; release-0.3.27 superseded)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:29:07 +00:00
igneum-labs
5054d0ee27 Merge app-ia-26 bf28cd23 into release-0.3.26 (the denominator lane's final 20-row tiers table; the public 0.3.26 app cut tip on main's word)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:48:35 +00:00
igneum-labs
bf28cd2357 tiers-table retake: the denominator lane's closed sweep (class-v6-floor-denominator 2a8572e4) replaces the 1a0a0e95 table
The 15:15 BST retake owed on tiers-table-26, taken on app-ia-26 where that branch now lives: app/igneum-app/tiers/
class-v5-tiers.json from the lane's closed sweep (20 rows, the 4060 Ti's class v5 stock row, the 3060 and 4070 rows
measured). The lane's .mjs and test are unchanged. The app crate (src/tiertable.rs embeds the file): 314 tests green on
build-1, the 5090's three rows and the knee at 1,300 MHz as before; the lane's table test and the UI tests green on
build-2; the Tune page's table scenario recaptured (~/Desktop/igneum-previews-2026-10-08/app-ia-26/tiers-table-retake).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:38:14 +00:00
igneum-labs
28b2f731ed Merge preview-26-2 ffe0b38b into release-0.3.26 (the public 0.3.26 app cut on main's word: app-ia-26 0ab19a4e whole, the four pages, tiers-table-26, earnings-26 and the earned windows, the preview mark engine-side and empty on this cut; the audit lane passed every item)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:37:30 +00:00
igneum-labs
ffe0b38b61 Merge app-ia-26 0ab19a4e into preview-26-2 (the four pages whole: Tune, Earnings on the engine's earned windows, Mine, Settings; the founder's preview 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:36:36 +00:00
igneum-labs
0ab19a4ebc app-ia-26 item 8: the audit-26 screens in docs/design/app-screens/0.3.26; the Settings page renders again; a render that throws says so
Item 8 (docs/design/app-audit-2026-10-08.md): tools/ui-mock/shots-audit-26.txt is the shot list (the four pages, dark
and light, the first minute, the table, the syncing node, a job holding the card, a still chain) and the fourteen
screens are in docs/design/app-screens/0.3.26, taken by tools/ui-mock/capture.sh on build-2 against the mock with
the engine's earned windows on every scenario.

Two faults found by the screens. (1) Since item 4 the Settings page had not rendered on the mock: renderProvingSection
called proveInsteadWords bare after the page-side alias left with the old card details, the poll's catch swallowed
the throw as an engine silence, and the Settings captures of items 4 and 6 showed the markup's defaults ("not set",
"Node starting"). It reads View.proveInsteadWords now; renderSettings also renders the Node card's line and facts
(they live on Settings since item 3). (2) The poll's catch now prints "render failed: <stack>" to the console, so a
throwing render is a visible bug and the capture scripts report it, never a silent default page. The not-answering
branch addresses the node line and the toggle by their present ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:33:55 +00:00
igneum-labs
86d4fd1fef app-ia-26 items 6 and 7: Settings in 4.4's order, Updates holds the Interface row, the copy and the units
The founder's audit (docs/design/app-audit-2026-10-08.md, 4.4, items 6 and 7). Settings reads, in this order: Wallet
(the address, Change address, Show my key, Open the wallet), Node (the network cards, one line of state, the facts
behind Details), Updates (the version and its update, "Install updates by itself", the Interface row "Interface 1.0.0,
built in" with one sentence of help and the built-in switch; the Interface card is gone, T-R21 and A19), Power
control, Electricity, Heat mode (the switch label is five words; the paragraph is the help, A20), Proving (the shard
card and the switches from the old Prove page), This machine, Logs, Advanced, The dev fee last. Item 7: no
text-transform on a unit label (.tot .k, A8); the key sheet reads "Settings can show it again"; the setup's card note
drops "The card row has a slider." (the slider is on Tune); no em dash anywhere in the window. The mock's ladder
carries the engine's earned windows on every scenario (the audit lane's ask: figures, not "reading", when the founder
looks), and tools/ui-mock/shots-audit-26.txt is the audit's shot list for item 8.

Tests known-failed first on build-2 (once.test.mjs: the eleven sections in order with T-R21, A19 and A20; T-A8 with the
em-dash grep), then green; 125 UI tests green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:28:54 +00:00
igneum-labs
17dbebfa96 app-ia-26: the earned windows join the branch; the fallback never reads zero; the 24 h figure once, as the headline
earned-windows-26 65828fb2 (item 2) is merged in, so the Earnings page and the Mine IGN tile read the engine's sums
(state.ladder.earned) on the same build. On an engine before the windows the counts come from the ladder's own record
and never from an absent field read as zero: blocks_24h, else the block list filtered to the day; the day ring for
7 days; the lifetime count for all time (the audit lane read "no block yet" beside 8,054 lifetime blocks). The 24 h
column under the 24 h headline was a repeat of the audit's own making, amended at 15:2x BST: the row is 7 days and
All time, the headline is the 24 h figure with its blocks and shards. T-A1 asserts the fallback counts and the one
24 h figure on the page; 123 UI tests green on build-2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:25:11 +00:00
igneum-labs
b54c7389b8 Merge branch 'earned-windows-26' into app-ia-26 2026-10-08 14:23:54 +00:00
igneum-labs
b861ba0aea app-ia-26 item 3: Mine is what is happening now; four tiles, one node line, five activity lines
The founder's audit (docs/design/app-audit-2026-10-08.md, 4.1 and item 3). The hero is four numbers with a unit and
one sub-line each: MH/s ("2 of 2 cards mining", "paused", "waiting for the node", "resting · heat mode"), W ("drawn
now", hidden with no power reading), MH/W ("hashes per watt", its own tile, hidden with the watts), IGN ("last 24
hours" from the engine's earned window, "no block yet" before the first, "reading" on an engine before item 2). The
toggle reads Start mining or Stop mining with no sub-line (its reason sits in the title). The card rows stay as item 4
left them. The ladder strip, the money tile, the blocks tile, the stats line and the Open the log button are gone;
Activity is the last five events. The node is one line with a dot ("Node synced · 4 peers"; "Node syncing · 41,000 of
52,000 blocks · about 12 min left"; "Node restarting · <reason>"; "Node behind · last block 53 min ago"); a click
opens Settings > Node with the facts open. The node facts (the 13 rows, the rules fingerprint, the next rule change)
moved to Settings as a closed Details under the Node card (the Network card renamed), the same ids, one line of state
above them. The toggle's "switch a card on in Cards first" reads "switch a card on first".

Tests known-failed first on build-2 (once.test.mjs: T-R8's toggle half with T-A5, T-R9, T-R18 and T-R10; T-A14 with
T-R14; T-R13 with T-A15 and the stats half of T-R12), then green with the node-line fixtures in view.test.mjs
following; 123 UI tests green. Captures from the mock on build-2 in ~/Desktop/igneum-previews-2026-10-08/app-ia-26/
item3 (the live record, two cards, the first block, light, syncing, the Settings Node card).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:22:50 +00:00
igneum-labs
7d8b61a33e app-ia-26 item 5: Earnings reads the measured windows; one row each for Electricity, Payouts and Standing; the projection as one dim foot line
The founder's audit (docs/design/app-audit-2026-10-08.md, 4.3 and item 5): the headline is the last 24 hours from the
engine's earned sums (state.ladder.earned, earned-windows-26: what the chain paid at the time), then the three windows
(Last 24 hours, 7 days, All time; each IGN with blocks and shards, "estimated" on a back-filled all-time figure, "one
block not priced yet" while a reward is unread); an engine before the windows reads "reading" with the block counts.
Electricity is one row ("£4.18 a day" / "at 28p/kWh for 622 W"; "622 W" / "Set a price in Settings for the cost.";
"No power reading on Apple silicon."). Payouts is one line ("Pays 0xdd44…86E8", Copy, Open the wallet, "Your balance
is in the wallet. Change the address in Settings."). Standing is one row ("Rank 3 of 4 keys · signing 8 of 80 points",
the window in the sub) with the rungs behind a closed Details and no intro. The projection is one dim foot line
("About 71.68 IGN a day at 17.0 MH/s, 0.017% of the network.") that hides under a remote job, a pause, a still chain,
no card mining or an unread rate; under a job or a still chain the line carries the reason instead ("Projection
paused: <job> running since HH:MM.", "Network paused: no block on the chain for 15 min."), with no IGN figure, as the
coordinator ordered on earnings-26. The address card is Settings > Wallet now (first card, the same controls; Open
the wallet on both pages); the rail foot reads the machine's name and its uptime (View.railFoot), no address. Gone
from Earnings: the IGN a day headline, the run line, the Weight and Lifetime cells, the ladder intro, the first-hour
timeline, the address card.

Also in this commit, the audit lane's two readings of item 4: the Tune lock line names the cap from the slider's want
(power_default_w × pct), never the draw ("Locked at 1,950 MHz, cap 405 W."; the test asserts the same cap figure as
the cap sentence); the miners help glues its last two words (copy law 18) and .help wraps pretty.

Tests known-failed first on build-2 (once.test.mjs: T-A1 with T-A2, T-A3 with A17 and A18, T-R9 with T-R11, T-R18 and
A4, T-R19), then green with the superseded view tests removed (their truths live in the once-tests); 120 UI tests
green. Captures from the mock on build-2 in ~/Desktop/igneum-previews-2026-10-08/app-ia-26/item5 (a job holding the
card, a still chain, the live record, light, the Settings Wallet card).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:17:28 +00:00
igneum-labs
cf997e766e app-ia-26: the pill's rest word names heat mode; the three UI test files outside the gate line join it
The shipper read ui/heat-region.test.mjs red on 7e60442e: the item-1 pill said "Resting" where the heat lane's rule
wants "Resting · heat mode" (never a bare rest word on a resting card). The pill reads the rule's words again; the
once-test says why. heat-region.test.mjs, fold.test.mjs and ui-ota.test.mjs were not in the gate's UI line, which is
how the red passed the gate; they are in it now (both lines of tools/ci/pre-push.sh).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:13:26 +00:00
igneum-labs
468c0339fb The preview mark: state.preview from IGNEUM_PREVIEW at build time (the founder's internal builds, 8 October 2026), shown after the version on the About line and the footer; empty on a public cut, the version itself untouched (the update check and the DMG's engine check compare that)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:11:42 +00:00
igneum-labs
bd96e9198d Merge release-0.3.26 0fd93532 into preview-26-1 (the installer detach fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:11:12 +00:00
igneum-labs
3daf7304e7 Merge app-ia-26 7e60442e into preview-26-1 (item 4, the Tune page; the founder's preview 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:11:12 +00:00
igneum-labs
65828fb20e earned-windows-26: the engine's earned windows (24 hours, 7 days, all time) from what the chain paid at the time
The founder's audit (docs/design/app-audit-2026-10-08.md, item 2): the Earnings page reads measured sums, never
today's reward applied to old blocks. The ladder keeps one row per coinbase the node credited and per shard paid
(Ladder.earned_log: at, shard, wei as a decimal string), trimmed to 7 days plus an hour, and the all-time running
totals (earned_all_wei, earned_all_blocks, earned_all_shards). A block row is priced at the miner reward the chain
facts carry when the block lands (chainfacts::cached_reward_wei, the dashboard's 10 s cache) or on the first state read
that has the figure (price_blocks); a priced row never changes. A shard row carries the wei its record paid. The
dashboard reads state.ladder.earned = {h24, d7, all} each {wei, ign, blocks, shards, priced} plus `estimated`:
a record from before the rows is back-filled once (backfill_earned) from the lifetime count the engine persists at
the reward then read, with the kept shard records, and says estimated for all time; the windows never invent rows.

Tests (ladder.rs): three windows over a fixed record at two rewards (the ramp moved between them); a block at the
24 h edge counts once, on the far side; an unpriced block reads priced:false until the reward arrives and then takes
that reward and no later one; the log trims past 7 days while all time keeps the block; the back-fill runs once,
says estimated, and a fresh record with rows for every block is not estimated. The crate: 308 tests green on build-1.
Builds on earnings-26 66cc2041 (blocks_24h); the view reads the windows in app-ia-26 item 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:09:53 +00:00
igneum-labs
0fd93532b3 Merge install-detach-26 4d3aa6e4 into release-0.3.26 (the installer's detach step logs the OS error, copies through a .part name, runs the source in place when no copy can be made, and under a job refuses rather than installing in place; take 3b's class on PC 2; known-failed first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:09:27 +00:00
igneum-labs
2b0380fe5d Merge remote-tracking branch 'box/release-0.3.26' into preview-26-1 2026-10-08 14:06:08 +00:00
igneum-labs
7e60442e22 app-ia-26 item 4: the Tune page; nothing about tuning anywhere else
The founder's audit (docs/design/app-audit-2026-10-08.md, 4.2 and item 4): Tune is the one place a card's driving is read
and set. The strip: the fleet's three tier buttons with the measured rate and watts and the saving against stock in
watts ("358 W under stock", never money, never "saves"), Tune all, ONE sentence ("Balanced on 2 cards. Measured 1 h ago,
next check 15 Oct." and its measuring, table, off, paused and class-flip forms), the switch "Measure cards by itself"
(today's s-sweep, moved from Settings), Fine tuning when the engine offers it, the heat line only while heat mode is on,
and one sentence with a button when NVIDIA cards are measure-only. Then one card per graphics card: the head (mark, name,
the live "127 MH/s · 372 W · 0.34 MH/W", one Tune, Retune or Stop button), the card's three tiers, one line
(View.tuneLine: "Locked at 1,950 MHz, cap 372 W. Knee at 1,100 MHz." or its unlocked, measuring, no-lever, re-measuring,
table and not-measured forms; MHz and watts only, one baseline), and a closed Details disclosure: the curve with a
two-sentence caption, the cap as one sentence beside the slider (View.capSentence: "Cap 405 W (90%). The card draws
372 W at the lock."), the miners stepper with one sentence, the telemetry line, the facts line, the driver note. The
driver offer stays on the card. The foot carries the knee rule once, in the markup. The card html is built in the View
block (View.tuneCardHtml) so the once-tests read it with no DOM.

Gone: the Tuned, Locked and Ember lines under a row, the fleet saving, the price figure, the rule constants, the goal
segments, the per-card IGN a day, the Settings Tuning card (its Power control switch is its own Settings card now, the
sentence with "at install"), the second power switch on the strip. The Mine row is the plain row of 4.1 item 5 (mark,
name, kind, state word, MH/s, W, °C, the switch; no chevron, no buttons, no money) and the card list is back on Mine.
The engine's choices (api/cards) read the switch from Mine and the cap and miners count from Tune.

Tests known-failed first on build-2 (11 red in once.test.mjs: T-R1, R2, R3, R4, R5, R6 with A10, R7, A7, A9, A11, R16
with R17), then green with the view-test fixtures following (the saving wording, the strip sentence, no fleetSaving,
priceFigure, TIER_RULE, LOCK_RULE or cardIgnDay exports). Captures from the mock on build-2 in
~/Desktop/igneum-previews-2026-10-08/app-ia-26/item4 (tiers, first minute, table, class flip, knob, details open; Mine
and Settings after the move).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:04:46 +00:00
igneum-labs
4d3aa6e497 0.3.26: the installer's detach step names its error and never installs in place under a job (PC 2, 0.3.25 take 3b, 8 October 2026, 14:54:54 BST: "could not copy <dist exe> to app\updates\Igneum-Miner-Setup-0.3.25.exe" with no reason, then the fallback installed in place under the job, the stop step quit the engine and the job's tree ended the installer 3 s in, nothing installed, the app down until a start-app). Igneum-Miner.iss: LastErrorWords (DLLGetLastError + SysErrorMessage) on every failed step; DetachSource makes the updates folder (logging a failure), copies through a .part name then renames (a half-written copy never carries the final name), and when no copy can be made schedules the SOURCE in place (a job's folder persists after the job); InitializeSetup under a job either starts the detached task or stops with "REFUSED: ... no detached start could be made (the lines above say why); nothing is installed and the app is left running", never an in-place install. installer-stop-check rule 6b: the OS error logged, no in-place fallback, the REFUSED line; self-test known-failed on take 3b's shape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:03:46 +00:00
igneum-labs
2b320c3a27 app-ia-26 item 1: four pages (Mine, Tune, Earnings, Settings), the pill as a state word, Prove folded under Settings
The founder's audit of 8 October 2026 (docs/design/app-audit-2026-10-08.md, section 4.5 and item 1 of section 5): the
window has four pages, one job each. View.PAGES reads mine, tune, earnings, settings in that order with the titles and
subs of 4.5 ("what this machine is doing now", "how Ember drives each card", "what this machine earned", "set once,
left alone"); page('nonsense') falls to mine. The rail carries the four entries; the Prove entry is gone and its markup
(the switches, the tiers, the line, the details and the shard card) is the Proving section of Settings, rendered by
renderProvingSection from renderSettings (D1, confirmed by main). The pill is the state word only: Mining, Paused,
Resting, Syncing, Node down, Waiting, Job running, Tuning, Not mining; the rate lives on Mine. The page ids in the
markup, the css (#page-mine) and the mock's ?page= follow.

Tests known-failed first on build-2: ui/once.test.mjs (new, in the gate line) counts the static markup; T-R15 (four
pages, no page-prove, s-prove once and inside Settings, four rail entries, the subs, no renderProve, no overview id
left in the page code) and T-R8 (the pill words); the PAGES and pill fixtures in view.test.mjs follow. Captures from
the mock on build-2 (~/Desktop/igneum-previews-2026-10-08/app-ia-26/item1). Items 3 to 6 reshape each page in turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:17 +00:00
igneum-labs
670d3a4de2 Merge branch 'earnings-26' into app-ia-26
# Conflicts:
#	app/igneum-app/ui/view.test.mjs
#	tools/ui-mock/server.mjs
2026-10-08 13:45:10 +00:00
igneum-labs
17b30fe8dc 0.3.26: the Windows node-source pin moves to f8da7515 (c9ad753a plus the executor's cold-restart rule: a node holding the chain from genesis replays whatever the sink's age; nothing consensus, digest 2066aa57 unchanged; the 0.3.26 pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:43:05 +00:00
igneum-labs
66cc2041e8 earnings-26: the projection pauses with its reason, the earned line is the measured 24 hours, the dev fee moves to the bottom of Settings
The founder read PC 1's Earnings tab: "100k+ IGN/day" on one line and "0 blocks in 2 hours" on the next, both shown as
earnings while a job held the card and the chain stood still. Three truths on the tab now, each with a test:

1. When a job holds the card (jobs.active, or a card in state held) the projection line reads "Projection paused:
   <job name> running since <time>; the card is not mining" and carries no IGN a day figure.
2. When the chain has no block for ten minutes (node.tip_age_s at or over 600) the line reads "Network paused" with the
   last block's age; the projection resumes with the next block. A job's word wins when both hold.
3. The earned line is always the measured last 24 hours from the node's coinbase reads, labelled "earned", never the
   projection. The engine's ladder keeps the time of every credited block for a day (Ladder.block_times, trimmed on each
   read) and reports ladder.blocks_24h, since ladder.blocks keeps only the last 30 and a fast card finds more in a day;
   a record from before the field reads its block list once. The run count and the shards paid sit under it.

The dev fee leaves the Earnings tab entirely: no switch, no line, no words. It sits at the bottom of Settings as its own
small section (The dev fee, 1%): the site's wording from site/miner.html, what it funds in one line, no control. The
command-line arm (--dev-fee 0) is unchanged and the off state reads as off. Tests: Earnings renders no fee words; the
fee section is the last card on Settings and holds no control; the page code has no fee switch.

Mock scenarios earn-held (PC 1's shape, a floor-lane job holding the 5090 for two hours) and earn-still (no block for
15 min), both with 3 blocks earned in the last 24 hours. Known-failed first on release-0.3.25 (4758c914): the projection
read as earnings under a job, "blocks this run" stood where earned belongs, the fee switch sat on Earnings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:40:33 +00:00
igneum-labs
9edfbe4bdb 0.3.26: the version bump (rule 15; a node-only release: the sink-age gate hotfix on the node line, the app crate unchanged from 0.3.25)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:33:51 +00:00
igneum-labs
4758c914a5 0.3.25: the Windows node-source pin moves to c9ad753a (6ccaf9e9 plus the miner's full fingerprint line; the node code, the object and the digest 2066aa57 unchanged; the 0.3.25 pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:47:42 +00:00
igneum-labs
9d6b2e3997 0.3.25: the Windows node-source pin moves to 6ccaf9e9 (d5b68fae plus rule 19's build-time fingerprint against the freeze and the ceiling re-cut to 90,000; the 0.3.25 pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:42:41 +00:00
igneum-labs
83491824e0 Ember Tune tiers from the team's table (tiers-table-26, 8 October 2026, main's order: the denominator lane's measured table wires the three tiers before a card's own search runs; the founder's definition unchanged: Ember tunes the card once, three tabs, a tap flips the card to the measured point at once, a class flip re-runs the sweep and re-keys the tabs). app/igneum-app/tiers/class-v5-tiers.json, .mjs and .test.mjs are the lane's files at 7ab204b6 (30 card classes, 5 measured; the lane fills every rentable class by 15:15 BST and the file is taken again then). src/tiertable.rs embeds the table, matches a card by the lane's rule (the longest match string in the name wins, "5070 Ti" over "5070") and gives the three rows in the apply shape with table: true and the class's label (measured or estimated); a class with no lever gives its stock row only; an AMD row's core offset becomes the absolute clock the knob sends once the card's stock clock is known (clock_from_offset). hotplug.rs: apply_pref restores the card's own measured rows when the pref kept them (CardPref.tiers, new, written at the search's end), else the table seeds the tabs; settle_new seeds a card with no pref. state.rs: tiers_table on the card. engine.rs: the search's end clears the table flag and keeps the measured rows in the pref; /api/tune/tier applies a table row as it applies a measured one (an AMD table row through its offset). The window (ui/app.js View.tierSet kind "table"): the buttons carry the table's rate, watts, MH/W and the saving with the word table, the panel says "From the team's table, measured on this card class" or "estimated for this card class" and that Ember measures this card 2 min into steady mining, the lock words read the row in force, the strip's count reads "0 of 2 cards measured, 2 from the team's table", the sentence names the table before the first tune; a row's note is the button's title. Tests known-failed first on build-2 (the UI read a table row as a measured one with no word: expected 'table', actual 'ready'), then green; tiertable.rs tests: the shipped table parses and covers the brief's cards, the longest match, the 5090's three rows with the knee at 1,300 MHz in the apply shape, a no-lever card's one row, an AMD offset converting only once the stock clock is known; the lane's table test joins the gate's app test line. The H100 tier's --sm-sparse auto is the worker's own self-tune from the tuning file the manifest carries (class-v6-floor-sm, proto-cuda/nvrtc/worker.cpp reads sm_sparse per card); the app passes that file through unchanged, so the tier applies the lock and cap and the worker takes the SM shape on its own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:30:52 +00:00
igneum-labs
56593787c3 Merge ember-tiers-25 0a4cc603 into tiers-table-26 (the hash lane's priors by architecture)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:22:03 +00:00
igneum-labs
3d4f9bd2e6 0.3.25: the Windows node-source pin moves to d5b68fae (5f316c21 plus the transaction admission height during a re-walk; nothing consensus, digest 1b37cb9d unchanged; the 0.3.25 pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:14:49 +00:00
igneum-labs
0da8e65fce 0.3.25: the Windows node-source pin moves to 5f316c21 (42ce0f07 plus the chain-id answer: eth_chainId and net_version return the id the pool admits at; nothing consensus, digest 1b37cb9d unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:59:14 +00:00
igneum-labs
3d080d75e1 0.3.25: the Metal worker from class-v5 79799452 (proto-metal/main.swift and packbench.swift: the generator-5 path, packClassOf v5, servePackDataset reading leaves.bin as buffer 3); the DMG's igneum-bench built from f2267bb6's tree refused epoch 19 at prepare (generator 5 not run), the cause of the Mac side of the Devnet 3 stall at the class v5 floor; the igneum-pow pairing f2267bb6 unchanged (the worker host is the kit's, the hash object the freeze's)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:54:52 +00:00
igneum-labs
f055fba78f 0.3.25: the Windows node-source pin moves to 42ce0f07 (e0644958 plus the ring self-check, the snapshot digest stamp, the vetoed-node status and the proof map's window; nothing consensus, digest 1b37cb9d unchanged; the 0.3.25 pin candidate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:40:26 +00:00
igneum-labs
8604e89b13 Merge amd-clock-25 aa2b8652 into release-0.3.25 (the RX 9070 XT's measured efficient point in the tuner's ceiling table: 149 W at 18.96 MH/s, clock offset -500 and power limit -30, 24 percent under stock; the grid playbook finds the rebuilt exe by itself; the kit input unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:26:23 +00:00
igneum-labs
aa2b865295 0.3.25 AMD knob: the RX 9070 XT's measured efficient point, 149 W, in the tuner's ceiling table (the first grid on the knob, PC 1, 8 October 2026, 12:10 BST, run-ca3-pc1-amd-grid-9070-20261008-b, 24 of 24 rows ok, the app's own hash_now, the card reset at the end). The rate is flat at 18.93 to 18.98 MH/s over the whole ladder: the knob moves watts only, so the knee never comes and the stop rule reaches the floor; the efficient point is both floors (clock offset -500, about 2,920 MHz where ADLX clamps; power limit -30): 149.3 W at 18.96 MH/s, 0.127 MH/W, 24 percent under stock's 195.8 W at the same rate. The clock offset alone takes 196 to 159 W; the power limit alone does nothing until -30 (184 W). Test: efficient_watts reads 149
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:23:54 +00:00
igneum-labs
0a4cc603f4 Ember search priors by architecture (the sweep lane's nine capped-against-uncapped pairs, 8 October 2026): a power cap holds the rate until the SM clock falls under about 1,800 MHz on Ampere and 2,400 on Ada, the measured lock knees on Blackwell; the power ladder starts one rung above the prior's cubed-clock rung and the clock ladders start near the prior, the stock row and the measured knee unchanged; known-failed first (no prior keeps the full ladder)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:45:32 +00:00
igneum-labs
3a15e3b0d0 ca3-pc1-amd-grid.ps1 finds the rebuilt telemetry exe by itself: the newest igneum-gpu-telemetry*.exe under the data root's jobs folders (a fetch job's landing place) whose --tune prints a tune line, before the installed exe (the kit's old tool has no --tune, so the first grid stopped at no_tune_line); IGNEUM_GRID_TOOL still overrides
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:27:53 +00:00
igneum-labs
b36b7200be fast-time: override-60x.json taken whole from ca3-v4-node (81 keys: master's 75 plus base_unit_decimals, pool_split_activation_daa, program_class_v5_activation_daa, proving_base_fee_ceiling_multiple, proving_fee_ceiling_activation_daa, subsidy_per_block_activation_daa; no shared value differs), the file the 0.3.25 node line's test fast_time_60x_file_is_the_devnet_at_60x reads key by key; a single added field moved the red to the next key (8 October 2026, 11:2x UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:24:26 +00:00
igneum-labs
d9b1c22d54 fast-time: base_unit_decimals 8 in override-60x.json, the field the node line's test fast_time_60x_file_is_the_devnet_at_60x demands (0e4ec18a's change, added by hand after its cherry-pick conflicted; the 0.3.25 pre-pin matrix read core RED on every box without it, 8 October 2026, 11:1x UK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:16:33 +00:00
igneum-labs
9c77b36be8 Merge tiers-class-25 dc7df7bd into release-0.3.25 (the tiers at the class flip, both halves: the engine's class key ember-tiers-25 2d188892 and the display; tiers measured under one program class never apply under another, the search re-arms within ten minutes, the knee note over 2 percent)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:13:51 +00:00
igneum-labs
30b5c85564 ota::return_tests: the installer's clear step is read in either shape (the one-line form, or install-detach-25's block within the next lines); the literal one-line assert went red on 8779c80e
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:13:50 +00:00
igneum-labs
dc7df7bd9a Merge ember-tiers-25 2d188892 into tiers-class-25 (the class-flip engine half: tiers_class, tiers_remeasure_at, knee_loss_pct, the stale refusal, the re-armed search; on release-0.3.25 87d8862d, before the install-detach merge whose .iss reshaping reds ota::return_tests on 8779c80e)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:10:30 +00:00
igneum-labs
2d18889284 tiers tests: the json test's fn line, dropped by the stale-test insertion
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:06:15 +00:00
igneum-labs
7e08d5663a Ember Tune tiers: the class flip (tiers_class, tiers_remeasure_at, knee_loss_pct): tiers measured under one program class read stale under another, the apply and /api/tune/tier refuse with the re-measuring line, the scheduler re-arms the search on the class change and says why once, the search's end stores the set under the new class with the knee loss against the old; the tier and the class survive a restart; known-failed first (v4 tiers under v5 read stale)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:04:58 +00:00
igneum-labs
8779c80eb2 Merge install-detach-25 05aa562d into release-0.3.25 (an installer started under the app's job runner re-launches itself as a one-shot scheduled task outside the job's process tree, so the engine's kill_tree on quit cannot end it between PrepareToInstall and the copy; the 0.3.24 take 2 class on PC 2; known-failed first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:04:21 +00:00
igneum-labs
05aa562da9 installer-stop-check rule 6: the /Create pattern matches the installer's own line (the schtasks command is built in a variable); self-test green on the tree, known-failed on the shape without the detach
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:01:25 +00:00
igneum-labs
bdc9a49425 0.3.25: an installer started by the app's job runner detaches itself from the job's process tree (PC 2, 0.3.24 take 2, 8 October 2026, 09:24Z: the stop step ended the host and quit the engine, the engine's job runner ended the job's whole process tree on its way out (src/jobrun.rs kill_tree, taskkill /T) and the installer, the job script's child, died between PrepareToInstall and the copy: every exe still 0.3.21, install-running.flag left behind, no ssDone, no DeinitializeSetup). Igneum-Miner.iss: under a job (IGNEUM_JOB_ID set, no /IGDETACHED=1) InitializeSetup copies the installer to <localappdata>\igneum\app\updates\, registers and runs a one-shot scheduled task (a child of the Task Scheduler, outside the tree) with the same switches plus /IGDETACHED=1 and /LOG=install-<v>-detached.log, and exits before any marker or stop step; the detached run installs and deletes its task at ssDone; if the detached start fails it installs in place and says so in the log. The job's proof is the --version wait and the detached log, not this process's exit code (1). installer-stop-check rule 6 reads the path, the order (InitializeSetup first) and Inno's declare-before-use (RemoveDetachTask above CurStepChanged); self-test known-failed on 87d8862d's installer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:00:44 +00:00
igneum-labs
87d8862d53 0.3.25: host.sha256 in the gate's line shape (the sha alone, the note as a comment line above it; the one-line form was not read by host-gate.py)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:53:33 +00:00
igneum-labs
6f444d14cd Ember Tune tiers: the class flip on the table (tiers-class-25, 0.3.25; the Counter ASIC coordinator's order of 10:4x BST, 8 October 2026, after the sweep found a power-capped A4000 reading class v5 17 percent under v4). The card's stored tiers carry the class they were measured under (tiers_class) and the card runs program_class; when they differ the engine re-runs the search within ten minutes (tiers_remeasure_at). The table then reads "re-measuring for class v5" on every button with the start minute ("Re-measuring for class v5, started 12:54; the class v4 points are out of date", or "queued (within ten minutes of the crossing)") and never the v4 watts as current; the tier in force stays marked; the strip's sentence names the crossing and the cards re-measuring; the fleet buttons read the re-measure when nothing else is measured; a flipped card counts as not measured. The knee note under the table when the measured loss at the same lock (knee_loss_pct) is over 2 percent: "The knee is being re-found under class v5: 5.2% less rate at the same lock than under class v4."; 2 or under, nothing. View.classFlip, minuteOf, kneeNote; the rows re-render on the four fields. Mock scenario tiers-flip. Test known-failed first on build-2 (the v4 tiers stayed on the buttons after the flip on 290d4e1d), then 74 green. Reads the hash lane's class-flip fields in the proposed shape (tiers_class, program_class, tiers_remeasure_at, knee_loss_pct).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:50:43 +00:00
igneum-labs
69d15f3f5f 0.3.25: the Windows node-source pin moves to e0644958 (7bd2940f plus proving_fee_ceiling_activation_daa 82,800 in the Devnet 3 object; digest 1b37cb9d; the 0.3.25 pin candidate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:49:18 +00:00
igneum-labs
b5b69ad5e4 0.3.25: the Windows host sha256 bc8d4f79 (Igneum Miner.exe from kit c68aea33 on PC 1, MSVC, 10:37 BST)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:40:27 +00:00
igneum-labs
c68aea339c 0.3.25: the Windows node-source pin moves to 7bd2940f (c6629572 plus the proof-pool reannounce, nothing consensus; digest cc902690 unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:31:06 +00:00
igneum-labs
58c80eb751 0.3.25: the Windows node-source pin moves to c6629572 (the 0.3.25 node line: keygen and the fee ceiling switch coded; Devnet 3 digest cc902690 unchanged)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:26:06 +00:00
igneum-labs
0e99664283 Merge tiers-25 290d4e1d into release-0.3.25 (the three-tier Ember Tune, both halves: the tier buttons with rate, watts and the daily saving on the Cards strip, the Settings card and each panel; sweep on at Balanced from install)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:22:50 +00:00
igneum-labs
bf3eefb3c3 Merge amd-clock-25 2ab55388 into release-0.3.25 (the integrated Radeon's dash-only tune line reads not available; the 9070 XT's real line gives the knob)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:20:30 +00:00
igneum-labs
62a666853f 0.3.24 installer: drop the unused Age: TDateTime var that stops ISCC (the install-close-23 9b4a4324 fix, which release-0.3.24 had merged before)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 857925b9e9)
2026-10-08 09:18:37 +00:00
igneum-labs
290d4e1d03 Merge ember-tiers-25 04e5b25a into tiers-25 (the engine half: tiers[], tier, tier_note, tier_at, settings.tune_tier, POST /api/tune/tier, sweep on at balanced from install)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:14:20 +00:00
igneum-labs
596c4f3827 Ember Tune: the three tiers, the UI half (tiers-25, 0.3.25; main's order of 8 October 2026 after the project lead's word that the settings must give a miner everything they need; the project lead's definition: Ember tunes a card once and the tiers are rows of that search, efficiency = the best MH/W row, balanced = the best MH/W row within 1% of the top rate and the default from install, max = the top-rate row). The Cards strip and the Settings Tuning card carry three tier buttons from View.fleetTiers: the name, the fleet's rate and watts at that tier, the saving against stock in watts and money a day ("Balanced / 202.9 MH/s · 622 W / saves 188 W, £1.26 a day"), the one in force marked; under them the one sentence (View.tierSentence: on at Balanced before the first tune, "Balanced on 2 cards, tuned 1 h ago · next check 15 Oct", off with the tiers still one tap, measuring, no lever), the electricity figure (View.priceFigure: the set price, else 0.25 in the currency in force named as the default) with the measured count, and the rule ("Ember never locks below the knee by itself. Going lower is your choice."). Each card's panel carries its own three tiers with rate, watts, MH/W and saving (View.tierSet), the lock in words ("locked at 1950 MHz, 372 W; stock 450 W"), the note for a card not yet measured or measuring, and a card with no lever shows one tier, "Stock", with its numbers and the reason; a tap applies the known row at once through POST api/tune/tier (fleet or one card; an engine before the route falls back to the goal route and says the tiers need the 0.3.25 engine). The per-card tier is wired, the "needs the next update" text is gone. Reads the hash lane's fields (ember-tiers-25: tiers[], tier, tier_note, tier_at, settings.tune_tier). Tests known-failed first on build-2 (V.tierSet, fleetTiers, tierSentence not functions), then 73 green; mock scenarios tiers, tiers-first, tiers-mac; ?tune=tuned carries the tiers; captures on build-2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:14:19 +00:00
igneum-labs
ddb13210f0 Merge ember-tiers-25 04e5b25a into release-0.3.25 (the three-tier Ember Tune, the engine half: the tier fields and the apply Cmd)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:14:10 +00:00
igneum-labs
ef45ab926d Merge amd-clock-25 922da9b7 into release-0.3.25 (the AMD core-clock and power-cap knob through ADLX manual tuning in the telemetry tool; the 9070 XT grid playbook)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:13:59 +00:00
igneum-labs
533944897a 0.3.25: version strings first (rule 15; the six places), the tree after 0.3.24's cut: the AMD core-clock and power-cap knob, the hash text fixes, the Intel rotate-fold kit, the three-tier Ember Tune, keygen and the fee ceiling on the node line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:13:58 +00:00
igneum-labs
2ab55388e7 0.3.25 AMD knob: a tune line with no ranges is "not available (no tuning interface)", from PC 1's read-back of igneum-gpu-telemetry 1d8e055d (8 October 2026, 09:07Z): the integrated Radeon answers gmax - gmax_range - - plimit - plimit_range - - factory 0 ok, every field a dash (-1 after the parser), which amd_knob read as an offset knob with a one-MHz ladder and a 99 percent power range (known-failed first). The RX 9070 XT's line (gmax 0 gmax_range -500 1000 plimit 0 plimit_range -30 10 factory 1 ok) and its sample (gclk_mhz 3292 under load, plimit_pct 100, gmax_mhz 0) are the test's second half: the knob reads stock 3,292 MHz, the ladder 3192 down to 2792, the power range 70 to 110 percent, the apply sending offsets. Tests: ember::tests::the_amd_knob_reports_not_available_with_its_reason_and_sets_nothing (the integrated line added), engine::tests::pc1s_two_tune_lines_give_one_refusal_and_one_knob
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:13:22 +00:00
igneum-labs
04e5b25a5d tiers test: 123.05 formats as 123.0 (the binary value sits under the half)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:12:20 +00:00
igneum-labs
087eb65d58 Ember Tune tiers (engine half, 0.3.25): the three measured rows of the card's own search as card-state fields (tiers, tier, tier_note, tier_at; efficiency = best MH/W outright, balanced = best MH/W within 1 percent of the top rate, max = the top-rate row, source measured or stock, nothing interpolated), the fleet tier setting (balanced from install, sweep on), Cmd::TuneTier and POST /api/tune/tier through the cap and lock path with no re-search, an automatic tune lands on the tier's row, the first-run strip line, a no-lever card's one stock tier with its reason; tests on the 5090 and 5080 curves of 7 to 8 October
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 09:11:20 +00:00
igneum-labs
922da9b789 0.3.25: relay/playbooks/ca3-pc1-amd-grid.ps1, the RX 9070 XT's first measured grid on the AMD knob by job, under the installed app (0.3.20 to 0.3.24 have no AMD knob in the engine), driving the rebuilt igneum-gpu-telemetry.exe directly: the tune line names the card's ordinal and ranges, each point sets the max-clock offset then the power-limit offset (refusals are rows marked refused), holds 75 s with the first half settling, reads the app's own hash_now for the card and the tool's watts and clock in force, prints RESULT GRID row lines, resets the card at the end and names the best point against stock. One card at a time, the NVIDIA cards untouched, never quits, pauses or resumes the app and never starts its tune
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:53:27 +00:00
igneum-labs
f0c12e22a6 0.3.24: packaging/windows/host.sha256 takes PC 1's MSVC 0.3.24 window host 0e241c94 (the kit at 736d8e98)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:53:20 +00:00
igneum-labs
00960323b2 0.3.25: the AMD core-clock and power-cap knob for Ember Tune (main's order of 8 October 2026: PC 1's RX 9070 XT measured 18.9 MH/s at 202 W and stopped, "no knob on AMD"; the card is latency-bound on the whole class v4 ladder, so a core-clock cap is the lever as on NVIDIA). Two findings behind the stop: the app's AMD lever (--tune, --set-gmax, --set-plimit, --reset in igneum-gpu-telemetry: ADLX IADLXManualGraphicsTuning2 and IADLXManualPowerTuning on Windows, pp_od_clk_voltage and hwmon power1_cap on Linux) was built on 5 October (8c27350f) and never left branch opencl-rdna4-telemetry, so the kit's exe answers no tune line and every AMD tune fell to "measure only"; and the 9070 XT's max clock is an OFFSET range (gmax 0, gmax_range -500 1000), which the engine read as "no clock knob". Now: proto-opencl/gpu-telemetry.c takes 8c27350f's tool whole (a superset of the shipped one); src/ember.rs amd_knob turns the tune line and the card's stock clock under load into the knob (clock ladder stock down to stock + gmax_min, power ladder on the percent scale 100 + plimit range; absolute-MHz drivers pass through), amd_limits, amd_gmax_arg (the apply sends the offset), and "not available (<reason>)" with nothing set for no AMD device, a tune line that read an error, Linux (root under /sys, a later cut), or a stock clock not yet known; Plan::full takes a narrow range (the floor above the 45 percent rung) as the fine ladder alone in 100 MHz steps, so the 9070 XT runs 2870, 2770, 2670, 2570, 2470 under a 2,970 MHz stock, the stop rule at the knee or a faulted row, lock_result and the lock_* fields as on NVIDIA. ADLX manual tuning needs no elevation (PC 1, 5 October 2026, an unelevated job), so the no-prompt rule holds with no Power Helper verb; nothing of the engine runs elevated. engine.rs carries the whole tune line in TuneProbe.amd_tune and the card's amd_stock_mhz and amd_gmax_offset. Tests known-failed first: the_amd_knob_reports_not_available_with_its_reason_and_sets_nothing, the_9070_xt_gets_a_power_ladder_and_a_clock_ladder_from_its_stock_clock, the_9070_xt_ladder_locks_at_the_knee (a declared ladder in the card's shape, not a measurement). Owed: the kit's igneum-gpu-telemetry.exe rebuilt from this source (MSVC on a PC or build-1, the ADLX SDK at vendor/adlx beside the tree), then the first measured grid on PC 1 by job when the hash lane's queue is clear
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:42:15 +00:00
igneum-labs
736d8e98d2 Merge knob-24 b9f66eff into release-0.3.24 (the core-clock knob's display: the lock against the unlocked row on the card row, the Ember panel's lock line and the knee, the rule sentence)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:38:14 +00:00
igneum-labs
674c4023a9 0.3.24: the Windows node-source pin moves to 5b673577 (the v5 floor cut from the 10:45 BST minute: DAA 68,400, epoch 19)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:35:17 +00:00
igneum-labs
b9f66eff48 Ember Tune: the core-clock knob's display (knob-24, 0.3.24; the hash lane's engine tip 8bb0e288 on release-0.3.24, the shipper's line of 7 October 2026): the card row and the Ember panel read the card's lock_* fields. The row's tune line carries the lock against the unlocked row at the same cap ("the lock: 112 W less for 1.3% of rate, knee at 1200 MHz"; "no rate lost" when nothing was lost; "clock unlocked, the knee came at 2472 MHz" when the first lock step was already past it) and, while the clock ladder runs, the search step inside the tune's ("locking clocks: step 4 of 9 · about 7 min left"). The panel adds a lock line (the measured point, the unlocked row, the knee in words: the rate fell, a hash mismatch with the clocks reset, or the floor with no knee), the rule the knob keeps (Ember never locks the clock below the knee by itself; going lower is the user's choice) and the knee as a dashed point on the curve with its sentence in the note; "no lever" names a card without a clock lock; a build without the fields shows nothing new. View.lockWords, kneeWords and LOCK_RULE in the pure block; the rows re-render when the lock fields change (sig). The mock gains scenario=knob (the 5090 locked at 1,300 MHz with the knee at 1,200, the 4070 in the clock search) and knob-search; ?tune=tuned and ?tune=running carry the fields. view.test.mjs: the lock, the knee words, the search step, no lever, the untouched older shape (known-failed first on build-2: "V.lockWords is not a function"; then 69 green). Display only: the knob and its fields stay the engine's.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:32:19 +00:00
igneum-labs
e3ecd6775e ember tests: the efficient-point plan counts the knob's seven fine steps on the 5090 (1 + 6 + 7), the two features meeting on release-0.3.24
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 06:17:13 +00:00
igneum-labs
8bb0e2884e Ember Tune: the core-clock knob (0.3.24, main's order of 7 October 2026): the clock ladder continues below 45 percent in 100 MHz steps to a 20 percent floor; the search stops at the knee (the first row more than the tolerance under the cap point's rate) or on a faulted row (a rejected or mismatched hash: the fingerprint check), the best MH per watt within tolerance is the point; lock_result and the card's lock_* fields for the UI; tests known-failed first on a fake helper
ember.rs: CLOCK_FLOOR_PCT 45 -> 20 (the PC 1 passes of 7 October: the 5090's best MH/W at 1,200 to 1,300 MHz, 39 to 42 percent of 3,090, under the old floor), CLOCK_FINE_STEP_MHZ 100, Plan.clock_fine, Plan::cap_row, Plan::clock_stop_reason ("rate fell 5.2 percent at 1200 MHz", "fingerprint mismatch at 2163 MHz, clocks reset", "the floor at 700 MHz"), the stop rule in Plan::next, LockResult + lock_result (lock_mhz/mhs/w/mhw, unlocked_mhs/w, lock_note; "no lever" without a clock maximum). state.rs: CardState lock_mhz, lock_mhs, lock_w, lock_mhw, unlocked_mhs, unlocked_w, lock_step, lock_steps, lock_at, lock_note. engine.rs: sweep_finish fills them (Baseline: "no lever" on Apple or without a maximum clock), the progress tick fills lock_step/lock_steps while a clock step runs. Tests: the ladder to the floor (2,781 .. 1,390, 1,300 .. 700), the knee at 1 and 1.5 percent on the measured 5090 rows (1,854 and 1,300 as the points), a Faulted clock row ends the search and the note says so, and the Run-level fake helper: a mismatch during 2,163's hold -> the row Faulted, no next step, the final Apply is the chosen 2,472 (the reset), Finished; the existing full-plan test updated to the stop rule. The 18 Ember tests green on box 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a0751feb43)
2026-10-08 06:14:47 +00:00
igneum-labs
7656e22080 0.3.24: the Windows node-source pin moves to dfbd1e10 (the v5 floor cut from the named move minute 02:00 BST: DAA 39,600, epoch 11)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:55:29 +00:00
igneum-labs
95c0e9d34f 0.3.24: the Windows node-source pin moves to c9e385eb (the v5 floor re-cut to DAA 32,400, epoch 9; the 28,800 floor lost to the clock)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:33:14 +00:00
igneum-labs
6c8df8fcf2 Merge power-helper-24 5a8bad66 into release-0.3.24 (a deferred install takes the task's unattended form through the registered helper itself, no click; the S4U proof and reprobe playbooks)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:08:49 +00:00
igneum-labs
5a8bad665c 0.3.24: a job's install takes the Power Helper's unattended form through the registered helper itself (PC 2, 7 October 2026: the deferred rule left every new right to "the next interactive start", which on a PC with nobody at the desk never comes, so an update by job could never take the S4U form without a click). rights::install, in the deferred branch: when every missing right is one the registered helper can take (HELPER_TAKEABLE = power-helper-task@unattended) it starts the task, waits for the heartbeat, writes "<seq> reregister" and waits 20 s for the helper's own "reregister ok" line (the helper re-registers from the running build's script, now S4U + Highest, elevated, no prompt), then records the right in the manifest; anything else stays deferred as before. Playbooks: pc2-s4u-proof.ps1 (unelevated: an S4U probe task started from the job's session, read by stamp file, LastTaskResult and LastRunTime, then unregistered; the real task read only) and pc1-helper-reprobe.ps1 (installed version, heartbeat first, one dev line, the log line within 15 s: answers or not). Test known-failed first: a_job_install_takes_the_task_form_through_the_registered_helper_and_defers_the_rest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:06:10 +00:00
igneum-labs
38b28841d2 0.3.24: the Windows node-source pin moves to 47b9b229 (the 0.3.24 node pin: the class v5 object by height on Devnet 3, the chain id at the v5 floor, the testnet re-cut at 18 decimals with the final message)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:04:35 +00:00
igneum-labs
51401e52b1 Merge power-helper-24 f42ebbd7 into release-0.3.24 (the engine never runs an elevated helper of its own on Windows; the task registered S4U with the right power-helper-task@unattended)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:02:08 +00:00
igneum-labs
f42ebbd762 0.3.24 Power Helper (main's two orders, 7 October 2026, PC 1): (1) the engine never runs an elevated helper of its own on Windows. The tune's legacy branch (helper.ps1 written into the sweep folder and run through the administrator prompt) and the cap's elevated step (register-power-task.ps1 plus ELEVATE through the window host) are deleted; helper_route decides Task, Unix or Unavailable, and a missing task is the one event "power control: helper not available (the Igneum Power Helper task is not registered, or its exe is gone): run the installer's rights step" with no script, no process and no prompt; the tune's task path waits for the heartbeat before its dev line. The Windows helper script in sweep.rs goes with the branch. (2) The task is registered to run whether or not a user is logged on (-LogonType S4U, RunLevel Highest, the user's own token, no stored password), so a start from a job's session or the engine's own tune is accepted; a new right id power-helper-task@unattended makes a 0.3.23 install take the form once at the update. (3) The helper logs "cmd.txt changed (N lines) but nothing to run: ..." when a changed file yields nothing (PC 1's 0.3.20 silence read in its own log). Tests known-failed first: the_engine_never_runs_an_elevated_helper_of_its_own_on_windows (the literals grepped gone, the route, event before return before any file), the_registration_is_per_user_highest_no_trigger_fixed_action (S4U, Interactive gone), the_unattended_task_form_is_one_new_right_for_a_0_3_23_install, a_two_line_rewrite_over_a_two_line_start_runs_and_a_silent_change_is_logged. Owed to the Windows gate: the S4U registration taken through the scheduler on a box and PC 2 by job (PC 2 is under stand-down tonight)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:59:49 +00:00
igneum-labs
6789b4192e Merge power-helper-24 2a9f4876 into release-0.3.24 (the Power Helper logs its facts at start and its exit reason; the engine names a helper that is not running; the cap and remove lines written after the heartbeat)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:53:15 +00:00
igneum-labs
1cdc5dd8ab publish-public.sh: the public manifest leaves the signed interface entry out (the apps read it from the token manifest; the --public arm had refused every publish since interface 1.0.1 because the entry carried the token)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:49:03 +00:00
igneum-labs
ea983fc195 0.3.24: igneum-pow and the class v5 packs from the frozen class-v5 f2267bb6 (rule 7: the node, the pool daemon and the app's CPU re-check pair against the pinned igneum-pow; the packs pin travels with the generator); the sub-version 3 packs unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:44:18 +00:00
igneum-labs
2a9f48760f Power Helper: a helper that dies says why, and the engine shows it (PC 1, 7 October 2026, 22:08 BST: six "helper started" lines, no command run after any, no exit line, the task reading Running with no process, the engine toggling nothing and saying nothing). The helper logs its facts after the start line (exe, version, pid, folder, whether cmd.txt is readable and how many lines) and writes every end to helper.exit as "<unix> <code> <reason>": quit, remove, idle, an unwritable folder (exit 2), and a panic caught by run_guarded (FAULT helper: panicked: <msg>, exit 101). ensure_running returns the helper's own exit reason from this start when the heartbeat does not come, else what no reason means (ended from outside or crashed before it ran; Event Viewer Application 1000, TaskScheduler/Operational 201/202), as "power control: helper not running (<reason>)"; the tune and cap paths show that line as an error event, never a silent toggle. Two ordering fixes of the same class as 6 October's (C): the cap path writes its command lines only after the heartbeat (it wrote them right after Start-ScheduledTask, which a fresh helper skips as present at start), and Power control off starts the helper and waits for its heartbeat BEFORE writing remove (a remove present at the start was skipped, so the task never unregistered itself). Tests known-failed first: a_helper_that_dies_at_start_leaves_its_reason_and_the_engine_names_it, remove_is_written_after_the_heartbeat_not_before_the_start
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:40:42 +00:00
igneum-labs
729a1e99aa Merge pool-finish-22 b3872b77 into release-0.3.24 (the pool daemon reconnects its node connections after a node restart; rejections by code in STATUS and /api/stats; dn3-split-read.mjs as the pool lane's)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:39:35 +00:00
igneum-labs
5eee683479 Merge dash-24 479c334a into release-0.3.24 (the .screen cap 1280 px at a 1440p window, 1440 px at 4K; Settings in two columns at the wide cap)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:37:45 +00:00
igneum-labs
fe0469a051 0.3.24: version strings first (rule 15; the six places), the tree after 0.3.23's cut: class v5 by height on Devnet 3, the chain id at the v5 floor, the dashboard width caps, the pool daemon's reconnect and counters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:37:44 +00:00
igneum-labs
7e02ff8daa Merge install-close-23 4412d3cf into release-0.3.23 (the installer runs the payload's stop step with -Install, refuses a misnamed installer, clears a stale flag; packaging, tools/ci and docs only; the crate stays 20ac7a71's)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:34:45 +00:00
igneum-labs
4412d3cf69 Merge remote-tracking branch 'build/release-0.3.23' into install-close-23 2026-10-07 21:31:34 +00:00
igneum-labs
526db5f78e 0.3.23: the installer runs ITS OWN stop step, with the install dir (PC 2, 0.3.23 take 1, 22:07 BST: PrepareToInstall preferred the installed 0.3.21 stop-igneum.ps1, which only asked the engine to quit; the window host lived on, Inno could not replace it, the host's restart ladder started the old engine 45 s later and every file stayed 0.3.21, while the crate's text tests passed). Igneum-Miner.iss: always ExtractTemporaryFile and run the payload's script with -Install "{app}" (and once more for an old admin dir), Log lines for the step and its exit (3 = a file stayed locked, CloseApplications is the fallback); InitializeSetup refuses an installer whose file name carries another version than it installs, and clears a stale install-running.flag with a line. stop-igneum.ps1: param Install (its own folder only the default, since the installer runs it from {tmp}), exit 3 when a file stays locked. tools/ci/installer-stop-check.sh (pre-push, self-test on tonight's shape, known-failed first on the tree): payload script, -Install, host by path before api/quit, unlock wait. Release rule 14 and the CI README row: an installer is tested by running it end to end on a Windows box over a running older app (installed versions, process set, first upload); a text test is a lint
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:31:34 +00:00
igneum-labs
b3872b773d Pool daemon: every node connection reconnects by itself after the node restarts (connect_reconnecting: the gRPC client reconnect flag, off in GrpcClient::connect; 7 October 2026, the Devnet 3 sweep minute with daemons running on across their nodes restart); pool.md 10.5d row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:17:57 +00:00
igneum-labs
4180c2fcc2 docs/plans/pool.md 10.5c: the members exec roots read, AGREE with the hub at executed block 5512 (21:10:34Z, through the fleet tunnels); the hour condition reads complete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:11:32 +00:00
igneum-labs
9f59da3967 docs/plans/pool.md 10.5c and 10.5d: the members part of the hour from their logs (pool-b twenty minutes, no rejected line), the prepare stall and its two halves with the fork commit, the two rejected counters and what each counts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:07:07 +00:00
igneum-labs
96be28bc71 Pool daemon: rejections by code in STATUS and /api/stats, the ledger's life and this run apart (7 October 2026, Devnet 3 pool-b: STATUS rejected=235,016 was the ledger's lifetime across the daemon's eras while the member had no rejected line in the hour; the two counters now say what they count)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:04:25 +00:00
igneum-labs
e2e2ff929a 0.3.23: packaging/windows/host.sha256 takes PC 1's MSVC 0.3.23 window host 365d3210 (the kit at 20ac7a71)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:57:06 +00:00
igneum-labs
03dd76c40f docs/plans/pool.md 10.5c and 10.5d: the Devnet 3 hour's read (1,278 chain blocks, 99 pool blocks under two keys, the hub's roots, the members' roots pending the tunnels, the implied height 20,409 and the shipper's ruling), the tag collision and its rename, the pairing rule, the sweep
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:47:02 +00:00
igneum-labs
479c334a32 The dashboard's width cap scales with the window (dash-24, 7 October 2026, main's order after the screenshots showed gutters inside the app at its 1440p and 4K opening sizes): .screen stays 1080 px to 1699, 1280 px at a 1440p-class window from 1700, 1440 px at a 4K-class window from 2500; Settings gains a second column at the wide cap with its cards never past 640 px (the Interface card spans), the list pages read at the full cap; fold.test.mjs measures #screen-dashboard at 1920 by 1080 and 1920 by 1200 on build-2 (known-failed first: 1080 against 1280) and view.test.mjs checks the rules (known-failed first); prepared off release-0.3.23's 20ac7a71 for release-0.3.24
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:38:51 +00:00
igneum-labs
a7e2796240 dn3-split-read: --tags names the two tags to look for (IGNH,IGNW by default; IGNS,IGNP for the Devnet 3 pair's hour on the earlier daemons)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:36:20 +00:00
igneum-labs
20ac7a71a7 0.3.23: the three version places the bump missed (igneum-app.rc FILEVERSION, PRODUCTVERSION, FileVersion, ProductVersion; Info.plist; the installer's AppVersion); build.rs caught the .rc on the box cross
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:32:49 +00:00
igneum-labs
65759ceaa8 0.3.23: version strings (the tree after 0.3.22's cut: the rights step at install, the unattended driver install, the check labels, the .next token read, per-monitor DPI, the network step, the installer closing the host; node pin 2720d8d2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:23:57 +00:00
igneum-labs
47d55092ff Merge install-close-23 67d53ca5 into release-0.3.23 (main's order: an installer over a running app ends the window host first and waits for the unlock; the engine and host refuse a relaunch while an installer runs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:19:19 +00:00
igneum-labs
a7a8179115 Merge network-23 4f2e359b into release-0.3.23 (the first-run network step: the manifest's default_network, the testnet card present and refused until testnet_open; igneum-testnet-1 by --testnet --netsuffix=1 with the seeds as --addpeer; view.test.mjs as the union of the check-labels and network tests)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:14:37 +00:00
igneum-labs
67d53ca5a4 0.3.23: an install over the running app returns the new version (PC 2, 7 October 2026, 20:54 BST: a job's silent 0.3.22 install quit the engine through api/quit, the 0.3.21 window host's restart ladder started the old engine 10 s later, Inno could not replace the locked host and every file stayed 0.3.21). (1) The installer's stop step ends the window host FIRST by its path, then asks the engine to quit, then ends what is left by path, and waits up to 30 s for every exe to open for write, naming the one that stays locked; CloseApplicationsFilter and SetupMutex set. (2) The engine prints "EXIT update" when an installer or its own OTA stopped it and the host then ends itself instead of restarting; the installer writes install-running.flag beside app.url for its whole run and the update helper's every launch and the host's restart ladder hold while it is there (a marker older than 15 min is a dead installer, ignored with a FAULT line). Tests known-failed first: exit_line, relaunch_allowed with tonight's sequence, the stop step's order and unlock wait, the host's and the installer's text
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:14:08 +00:00
igneum-labs
7d38824cd5 Open pool on the renamed coinbase tags IGNH and IGNW (the fork's pool-tags-node 7455b8d5): the read tool, the chain's tests, README, pool.md and spec 09 follow
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:48:24 +00:00
igneum-labs
1a1d61204f dn3-split-read: the window by header time (--from-ms/--to-ms), the hour as the fleet lane names it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:43:14 +00:00
igneum-labs
4f2e359bdd The network step (0.3.23, main's design with two corrections, 7 October 2026): a fourth first-run screen before the address step and a Settings card, two cards, Devnet 3 (igneum-devnet-3, chain id 4463; staging: the chain may reset, coins have no value) and igneum-testnet-1 (the public test chain: coins have no value, resets are announced); a fresh install selects the manifest's default_network (Devnet 3 until the go), the testnet card reads not yet open and is refused until the manifest's testnet_open names it open, a manifest naming the testnet default before the open is refused by the parser; an existing install keeps the network it runs and sees the step as the Settings card; nothing switches a running miner but the user's click and the confirm that names what resets (the node's data and the mining state; the key and the address stay); the choice lives in settings.network, read before the runtime so igneum-testnet-1 starts the node with --testnet --netsuffix=1 and the three seeds as --addpeer (no DNS seeders compiled; no override file on the testnet), taking effect at the next start; api/network, Cmd::Network, config::network_switch with its tests, manifest default_network_rules, the Rust round trips, two view tests known-failed first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:36:38 +00:00
igneum-labs
2e95c87adf proto-cuda packs from the 0.3.22 re-pin a5d735b2 beside its igneum-pow: the packs the recheck tests pin were the pre-amend export in the release-0.3.22 tree (program id 12071054473004869770 against the sub-version 3 generator's 1892128023569472608 on packs-ca3-v4/v4-devnet-epoch0); generator and packs travel as one tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:36:05 +00:00
igneum-labs
c7e5d0ac04 Merge dpi-23 5bb958f5 into release-0.3.23 (per-monitor DPI awareness for the Windows host: the manifest, WM_DPICHANGED, the opening size in logical pixels)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:34:08 +00:00
igneum-labs
3b94cfba4a 0.3.23: the Windows node-source pin moves to 2720d8d2 (the 0.3.23 node pin: the cache-rung digest fix and the three Devnet 3 heights)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:31:03 +00:00
igneum-labs
699abe1e25 Merge ota-token-23 58752f11 into release-0.3.23 (publish.mjs reads the dl token by the .next rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:31:01 +00:00
igneum-labs
3bd70c97f8 igneum-pow from the 0.3.22 re-pin a5d735b2 (class v4 sub-version 3, object byte 7, the tree Devnet 3's node 69d1b56e and the paired miner run): the pool daemon's share check draws the chain's program, not the release-0.3.22 repo tree's older generator (the Devnet 3 pair, 19:27Z: identical seeds, WRONG HASH on every share, the miner's pack at attempt 4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:29:03 +00:00
igneum-labs
5bb958f55f Per-monitor DPI awareness for the Windows host (dpi-23, 7 October 2026, PC 2's 5120 by 2160 at 200 percent rendered stretched and blurry): host.manifest declares PerMonitorV2 (and true/pm), embedded by host.rc as RT_MANIFEST with the link at /MANIFEST:NO; SetProcessDpiAwarenessContext at start as the runtime path for a Windows that ignores the manifest, SetProcessDPIAware as the last fallback; WM_DPICHANGED takes the suggested rectangle so WebView2 re-renders at the new monitor's scale; the opening-size rule now runs in logical pixels (opening_size.h igneum_opening_size_scaled: the primary monitor's physical work area and its DPI from GetDpiForMonitor, looked up at run time, LOGPIXELSX as the fallback), so the 1440 by 900 and 1920 caps mean CSS pixels as on the Mac (PC 2 opens 1625 by 1016 logical, 3250 by 2032 physical, sharp); the remembered frame is logical on both hosts (the unaware host read virtualised coordinates), kept as it is, with a WindowUnits marker from here; six scaled cases in opening_size_test.c (the test could not compile before the function existed); tools/windows/dpi-check.ps1 is the read-only PC-job check (awareness, rectangle, monitor DPI, logical size; -Expect PerMonitorV2 fails on the 0.3.22 host, which reads Unaware); host.cpp touched
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:28:34 +00:00
igneum-labs
e41cb2c75b Merge check-labels-23 b6b436a6 into release-0.3.23 (Settings: Check for an update, Check for jobs; each handler's route asserted)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:24:10 +00:00
igneum-labs
58752f1189 ui-ota publish: the dl token follows packaged-config.sh's .next rule (IGNEUM_DL_TOKEN_FILE, else dl-token.next while a rotation runs, else dl-token), so a bundle staged during the rotation lands in the new folder and --verify reads the new URL; tokenFile and readToken exported, token.test.mjs on the gate, known-failed first on the direct read (7 October 2026, the dl token rotates with 0.3.22)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:20:28 +00:00
igneum-labs
b6b436a60d Settings: "Check for an update" and "Check for jobs" (7 October 2026, main's ruling after a press of the jobs button for the update check: the two sat as "Check" and "Check now"); the view test asserts each label and that each handler posts its own route (api/update/check, api/jobs/check), known-failed first on the old labels; for the 0.3.23 tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:19:39 +00:00
igneum-labs
e2d355ca29 Merge driver-hold-22 58d4b1f5 into release-0.3.23 (the unattended driver install through the Power Helper task; the driver-install-task right)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:15:45 +00:00
igneum-labs
d989f59f40 Merge boot-start-22 ffb8e270 into release-0.3.23 (the rights step at install, deferred in a job or session-less install; the WebView2 right webview2-runtime@109.0.1518.78)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:15:45 +00:00
igneum-labs
71f578b768 Driver check plan: the driver-install-task right taken into rights::RIGHTS on boot-start-22 332b82eb; the merge point and the prompt rule recorded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:12:57 +00:00
igneum-labs
ffb8e270b8 Rights step: asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment), else "rights: deferred" and the next interactive start asks once (the project lead, 7 October 2026: no PC job may need a click); the WebView2 runtime as the right webview2-runtime@<min> with the host loader's minimum in app/windows/version.h (IGNEUM_WEBVIEW2_MIN 109.0.1518.78, read at build time), the elevated step reading the Edge WebView2 client key (HKLM WOW6432Node and HKCU) and running the bundled evergreen bootstrapper silently when absent or below it (make-payload.sh carries the bootstrapper only when it matches packaging/windows/webview2.sha256); the driver lane's right driver-install-task in the list; tests known-failed first (a job's install defers, a session-less one defers, the person at the PC asks once; absent or older runtime installs, equal or newer does not, a raised minimum is exactly one new right)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:09:55 +00:00
igneum-labs
f69327971f pool/tools/dn3-split-read.mjs from pool-finish-22 c15b39b0 (the Devnet 3 read for the split's activation; the merge did not apply, the one file taken as is)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:05:54 +00:00
igneum-labs
58d4b1f505 Driver install unattended through the Power Helper task: the rights-at-install step driver-install-task (7 October 2026, 19:5x BST; the project lead's rule that evening: no PC job needs a click or a UAC prompt)
src/driverinstall.rs: RIGHT = ("driver-install-task", ...) for boot-start-22's rights::RIGHTS; register_script = the Power Helper task with a two-hour run limit (no second task, no new firewall rule); the helper's command file takes "<seq> driver <vendor>" with a vendor WORD only, and the elevated helper resolves the file, size, sha256 and Authenticode signer from the signed table itself (Intel Corporation, NVIDIA Corporation or Advanced Micro Devices, and the row's own signer), runs the row's silent arguments with the heartbeat kept (cap 45 min), and writes "<seq> <vendor> exit <code> reboot <0|1>" to driver-result.txt; a restart-required exit is the Restart now button, never a restart by the app. drivers::start_install takes the helper route when the task is registered and keeps the one-prompt path otherwise. Tests known-failed first (the helper knew no driver verb: red on build-2, then green; the refusal of a file that is not the table's or not a vendor's; the right's id and the protocol round trip). Box gate 263 + 34 + 8. Plan 3d is the step as a table for the rights lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:03:01 +00:00
igneum-labs
19bd38a746 docs/plans/pool.md 10.5b: the Devnet 3 pair's two daemon faults and their rules (the job window; a daemon built from the chain's own tree), the fleet's swap lesson
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:01:39 +00:00
igneum-labs
d27092d66e 0.3.22: the Windows node-source pin moves to 34a2dbaa (the 0.3.22 node pin; the Windows node pair 2040cf65 / 680dacfe is its cross)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:56:21 +00:00
igneum-labs
af0978ea92 pool/tools/dn3-split-read.mjs: the Devnet 3 read for the pool split's activation (the hour's first and last chain block, blocks carrying IGNS and IGNP, exec roots across the nodes at one executed height, the implied height)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 283118f8f4)
2026-10-07 18:55:22 +00:00
igneum-labs
3063bf1ed7 0.3.22: every right taken once at install, never at runtime (the project lead, 7 October 2026: "all the rights need to be done on install, and then on update if anything new"): src/rights.rs with the rights list (the Power Helper task, the boot task, the firewall rules for the node and the pool miner), the installed manifest rights.json, the one elevated script, and the prompt model; the installer's [Run] step igneum-app.exe --rights on every install (silent ones too) asks for administrator rights only when a right is missing; at runtime Power control is a plain toggle (the Power Helper task does the work) and a missing right is a notice ("run the installer again"), the firewall first-run prompt gone; tests known-failed first: a fresh install then Power control on shows one prompt at install and none after (the old way: two), an update with no new right shows none, an update with a new right shows exactly one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:54:53 +00:00
igneum-labs
232fd49f40 Merge driver-hold-22 180ed2cb into release-0.3.22 (the Intel row takes min_version 32.0.101.6733: an Arc on the inbox driver reads fine, no button)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:46:31 +00:00
igneum-labs
deb132f7ef Merge ota-cut 3f50b666 into release-0.3.22 (the interface version pair: ui/VERSION 1.0.2, pair-check.mjs, publish-manifest.sh stamps ui_version on new entries)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:46:31 +00:00
igneum-labs
6c6d19234f Merge boot-start-22 d6296308 into release-0.3.22 (the window host gate: a payload's Igneum Miner.exe must carry the cut's version, no mingw signature, and a sha in packaging/windows/host.sha256)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:42:21 +00:00
igneum-labs
180ed2cb5c Driver table: 6733 is acceptable for the Arc B580 until an unattended install path exists (the project lead's rule, 7 October 2026 evening: no PC job needs a click or a UAC prompt; the 9034 retry on PC 2 cancelled)
The Intel row's min_version is 32.0.101.6733 (Windows' inbox driver, on which the worker mines at 11.0 MH/s with the Intel rotate rewrite); 9034 stays the version on offer for a card with no driver. Test known-failed first (the shipped table demanded 9034 of an Arc on 6733; red on build-2, then green). The mock's offer scenario shows an older 6600 so the Install row still renders. Plan 3c: the install path moves onto the app's Power Helper task next; the minidump waits for the same path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:42:12 +00:00
igneum-labs
3f50b66670 publish-manifest.sh: a carried app entry keeps its own ui_version or none; only a new entry is stamped from the tree (a carried 0.3.21 entry had taken the tree's 1.0.2 and passed the pair check falsely)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:42:05 +00:00
igneum-labs
6e07c5131c ui-ota publish: --ui-pair-override passes through to the manifest writer (tonight's 1.0.2 to the 0.3.21 manifest)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:41:20 +00:00
igneum-labs
e89922eaa5 ota-102: the ui-ota test pins ui/VERSION 1.0.2; publish-manifest.sh --ui-pair-override <reason> ships an interface from another UI tree knowingly with the reason logged (tonight: interface 1.0.2 from the 0.3.22 tree to the 0.3.21 manifest, so every 0.3.21 app takes the Prove switch fix), the pair rule binding without it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:40:38 +00:00
igneum-labs
db02ac511f The version pair (7 October 2026, the project lead's Mac on 0.3.21: interface 1.0.1, cut from 0.3.20 and carried into the 0.3.21 manifest, served over the packaged UI and brought the Prove switch defect back): tools/ui-ota/pair-check.mjs refuses a manifest whose ui.version differs from the ui_version of any app entry, or whose entries carry none (today's shape fails by design; self-test known-failed first); publish-manifest.sh stamps the tree's app/igneum-app/ui/VERSION on every new entry, carries it on carried entries and runs the check before signing, so a carried-over interface against a newer tree refuses the publish; ui/VERSION 1.0.2 for the 0.3.22 tree and the interface cut from it; the gate runs the self-test and the check on IGNEUM_MANIFEST when one is given
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:40:38 +00:00
igneum-labs
f1d3333474 Merge window-22 dbc45a33 into release-0.3.22 (the opening size from the primary monitor's work area, a frame never wider than 1.6 times its height; the project lead's 5120 by 2160 panel)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:40:14 +00:00
igneum-labs
d629630869 The window host gate (0.3.22): packaging/windows/host-gate.py refuses a payload whose Igneum Miner.exe version resource is not the cut's version, carries a mingw-w64 signature, or is not the pinned MSVC host (packaging/windows/host.sha256, e223db18 for 0.3.21); wired into make-payload.sh before the host is copied, its self-test in the pre-push gate (PC 2, 7 October 2026: a 0.3.22.0 mingw host inside a 0.3.21 installer crashed in ntdll seconds after starting its engine)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:39:23 +00:00
igneum-labs
dbc45a334f The opening window size, one rule for both hosts (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super wide?"): scaling-21's 80 percent of the work area scaled above 1920 wide opened 2752 by 1152 on a 3440-wide area and 3072 by 864 on a two-monitor span, and a remembered WindowW/WindowH could carry that into later starts; now app/windows/opening_size.h: the PRIMARY monitor's work area (MonitorFromPoint primary, never the virtual desktop), width 80 percent capped at 1440 up to a 1920-wide display and 1920 beyond, height from the width at 16:10 bounded by the work area, never wider than 1.6 times the height, never under 900 by 600, never over the work area; a remembered frame kept only when it fits the work area, the minimum and the aspect cap, and never saved when it would not; the Mac window mirrors the rule on NSScreen.screens.first and discards an autosave frame that fails it; opening_size_test.c compiles on the gate with cc (known-failed first: it could not compile before the header, and 0.3.21's rule fails the 3440 by 1440 and the 3840 by 1080 cases); host.cpp touched (the shipper's named line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:34:47 +00:00
igneum-labs
146c80723b Merge pool-finish-21 2b8e5580 into release-0.3.22 (the pool daemon keeps jobs 60 s or 256 per member, stale grace 30 s: the Devnet 3 read of 25,477 unknown_job shares at one template a second)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:34:37 +00:00
igneum-labs
d7ed73b773 Merge shards-22 into release-0.3.22 (the inspector's shard words from the block's facts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:34:37 +00:00
igneum-labs
aafaa3387b Merge boot-start-22 fe182a55 into release-0.3.22 (the engine starts at boot without a logon on Windows: the per-user boot task, headless --launch, the host bridge; a headless engine never reads a closed stdin as the host leaving)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:30:32 +00:00
igneum-labs
fe182a5530 0.3.22: the engine starts at boot without a logon on Windows (MF-11's second half; PC 2 idle 67 minutes after its 17:28 BST self-reboot, 7 October 2026): the per-user task "Igneum Miner (boot)" at system start under the user's S4U logon running igneum-app.exe --launch --data-root <root>, registered by the engine at start and in the Power Helper's one approved step; --launch with no interactive session runs the engine headless (--boot); a window host's --wrapper engine that finds the headless engine's app.url answering becomes a bridge (URL and STATE lines to the host, quit, pause, resume and detect relayed to the API; the window closing leaves the engine mining; an engine gone ends the bridge with EXIT); a headless engine never reads a closed stdin as the host leaving (PC 2, 19:09 to 19:11 BST: four engines quit 3 s after the node started); the known-failed forms first in every test, the no-logon return case beside the helper's sequence; install-repair.ps1 on the record
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:28:06 +00:00
igneum-labs
f1016c2611 Merge driver-hold-22 6aebb34c into release-0.3.22 (a driver install holds every enabled card of that vendor, the other vendors keep mining, each held card returns when the installer ends)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:26:04 +00:00
igneum-labs
46bf3d928d Merge miner-reliability-22 18c10d9e into release-0.3.22 (MF-14: a worker never waits on the export past one retry interval; the row names the blocker, the slot leaves building at two)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:22:31 +00:00
igneum-labs
6aebb34cfa Driver check: every card of the vendor stops for the install (0.3.22; PC 2's Arc in the Razer Core X V2 read kind=discrete on 0.3.21, so the external-kind hold alone missed the card that crashed the box at 16:27Z)
drivertable::install_hold_keys holds every enabled card of the vendor being installed (the other vendors' cards keep mining; a card already off has nothing to stop); the row says so before the click and while it runs, with the enclosure warning kept on an external row; the toast and the engine's event name the vendor. The x1 gen1 link signature is not on the card state and was not added. Test known-failed first (the 5090 inside the case was not held for an NVIDIA install; red on build-2, then green); box gate 259 + 33 + 8; UI 51.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:20:14 +00:00
igneum-labs
2b8e55806b Pool daemon: jobs kept 60 s or 256 per member (never fewer than 12), the open pool's stale grace 30 s unless set (the Devnet 3 pair, 7 October 2026, 18:16Z: a template every second and a GPU batch outliving the 12-job window, 25,477 unknown_job and 19,119 stale shares, 0 accepted)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:19:52 +00:00
igneum-labs
18c10d9e1b MF-14: a worker never waits on the program export longer than one retry interval without the reason shown
PC 2, 7 October 2026 18:34 BST: two rows sat on "exporting this hour's program" for 18 minutes while a third card
mined. The export ran on a thread per card under one lock with no bound on the wait, and a result that never came
left the slot in the building state for ever.
- watchdog::export_wait: inside one interval (the ladder rung, at least 30 s) the row keeps its word; past it the row
  names what blocks the export; past two intervals the wait ends and the worker retries on its own interval.
- engine: EXPORT_HOLDER names the card whose export holds the pack lock and for how long, EXPORT_LAST_ERROR the last
  failure (the node not at the epoch, no seeds.txt); export_blocker() reads them for the row; build_seq ignores a
  late result after the wait ended; a failed export retries on the ladder, never a flat five minutes.
- docs/plans/miner-faults.md: MF-14 with rule, test and gate line.
Test known-failed first: an export that never returns is named at one interval and given up at two.
Gates: app tests 260 + 33 + 8 green on igneum-build-2; the tree gate GREEN, 56 checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:18:43 +00:00
igneum-labs
bed8d84af6 The shard sentence from the block's facts (7 October 2026, the site's animation said "no shard plan yet" on every planless block): IgneumDag.shardWords(block, nowMs) in scene/live-dag.js (2.0.6) is the one source for the tooltip, the /live inspector and the app's inspector: no shards reads shard plan loading (a chain block under 10 s), excluded, nothing to prove, not yet ordered (pending), off the selected chain, no shards planned, or no shard plan after N s; a planned shard with no prover reads awaiting a prover with the block's age, assigned to <prover> when one holds it, proving, verified, paid as before; the app's inspector prints these per shard and as the count line; the copies synced; tools/scene/shard-words.test.mjs on the gate's scene line, known-failed first on 2.0.5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:13:23 +00:00
igneum-labs
1e0454161c 0.3.22 packaged peers: the Devnet 3 hubs dn3-g1 64.119.209.250:21703 and dn3-g2 154.64.230.67:27017 beside build-1's seed and node1, so a home app dials three hosts (tree item f); the self-test reads four
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:10:15 +00:00
igneum-labs
40494b2c57 Merge key-22 8a690ab2 into release-0.3.22 (the amend: the legend test takes the /live key's ruled exceptions, the site untouched; crate and UI byte-identical to afcc9662)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:06:07 +00:00
igneum-labs
8a690ab244 The miner's chain key from the shared legend export (key-22, 7 October 2026): IgneumDag.legend({mine}) and renderLegend(el, {mine}) in scene/live-dag.js (2.0.5) carry the one list of entries with each state's token and shape (Pending, Included, Excluded, Selected chain, Proven, Locked checkpoint; the app adds Your blocks, ringed, first); the app's chain card renders its key from it at mount with the source line kept (the Included swatch had been ember while the scene draws included blocks in --included, and "pending" was lower-case), its CSS colours a swatch only from the entry's token; the copies synced; tools/scene/legend.test.mjs on the gate's scene line: the export's entries, order, tokens and shapes, every colour the scene draws has an entry, the app key is the export, and the /live key equals the export minus the ruled exceptions (no Pending, no Your blocks) with the same wording, token and shape on every shared entry, binding on master where the site deploys from and a diagnostic on a release branch whose site tree is the frozen cut; the site is not edited (main's correction: the project lead's ask was the miner's key)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:02:56 +00:00
igneum-labs
c60269def8 Merge key-22 afcc9662 into release-0.3.22 (the one chain key: scene/live-dag.js 2.0.5 legend, the app's chain card renders it, Your blocks first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:01:11 +00:00
igneum-labs
afcc966286 The chain key, once (key-22, the project lead's screenshot of the app's key against /live, 7 October 2026): IgneumDag.legend({mine}) and renderLegend(el, {mine}) in scene/live-dag.js (2.0.5) are the one key both pages render: Pending, Included, Excluded, Selected chain, Proven, Locked checkpoint, each with the token the scene draws that state in and the swatch's shape, the app adding Your blocks (ringed, molten) first; the app's key renders from it at mount with the source line kept after the entries (the Included swatch had been ember while the scene draws included blocks in --included, and "pending" was lower-case), its CSS now colours a swatch only from the entry's token; the copies synced; tools/scene/legend.test.mjs on the gate: the export's entries, order, tokens and shapes, every colour the scene draws has an entry, the app key is the export, and the site key must equal it (binding on master, a diagnostic on a release branch whose site tree is frozen; known-failed under SCENE_SYNC_SCOPE=site today: the site's hand-written key lacks Pending and carries Your block)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:57:52 +00:00
igneum-labs
390cf17ae0 Merge signing-22 22c5ce4c into release-0.3.22 (the project lead's signing-bonus plan, the app half: vote on by default pinned by test, the Overview and Cards rows read signing or silent with the reason)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:55:30 +00:00
igneum-labs
22c5ce4c01 Signing state on every card row and the node line (signing-22, the project lead's signing-bonus ruling, 7 October 2026): the miner signs the finality section by default (Settings.vote true, an old settings file without the field reads true, the one --no-vote flag comes from config::vote_flag and exists only when the user turned voting off; the Rust test vote_defaults_on pins all three); View.signingWords says "signing" with the checkpoint signed and when, or "silent" with the reason (voting off in Settings, no vote key, the clock behind or ahead of the network, no template while the node syncs, or a key that stopped for N min while the chain kept locking), "first checkpoint pending" in the first two minutes, "paused" when the network itself has not locked; the Overview's node line ends in the word and turns bad with the reason as its sub line; each Cards row shows its own keys' state under the state line; two view tests known-failed first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:52:08 +00:00
igneum-labs
3f013f7433 Merge pool-finish-21 283118f8 into release-0.3.22 (pool/tools/dn3-split-read.mjs, the Devnet 3 read tool for the split's activation; docs and tools only)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:49:20 +00:00
igneum-labs
283118f8f4 pool/tools/dn3-split-read.mjs: the Devnet 3 read for the pool split's activation (the hour's first and last chain block, blocks carrying IGNS and IGNP, exec roots across the nodes at one executed height, the implied height)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:42:45 +00:00
igneum-labs
795b372fdd Merge driver-check 136889b7 into release-0.3.22 (main: a 0.3.22 app item; the eGPU driver-install hold and warning)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:32:40 +00:00
igneum-labs
273339877a Merge release-0.3.21 97f9648f into release-0.3.22 (the 0.3.21 app tree as shipped over c4459193)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:32:40 +00:00
igneum-labs
136889b748 Driver check: a card in an external enclosure has its worker stopped for the install (PC 2, 7 October 2026, 16:26Z: the Intel installer's display reset took the machine down with the app's worker mining on the Arc in the Razer Core X V2)
The rule (drivertable::install_hold_keys): a driver install on a card the app reads as external (the eGPU kind from update-return-21b) stops that card's worker before the installer starts, through the cards path with the restore choice kept (the --cards-off shape); the vendor's cards inside the case and every other vendor's card keep mining. The row says so before the click and while it runs (the display reset can take the machine for a minute and may need a restart; save your work first). When the installer ends the held card goes back as it was; a card the install took away comes back when the card does (driver_release_held on the detection that lists it again). The app never restarts the machine.

Tests: the known-failed rule test first (an install on an eGPU card with the worker still running held nothing: red on build-2, then green), the view test for the two sentences; box gate 258 + 33 + 8 (test --release on build-2). Mock scenarios drivers-egpu and drivers-egpu-running; captures 13 to 16 (light and dark). Branch rebased onto release-0.3.21, which already carries the driver-check commits; the earlier tip is kept as driver-check-0320.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:28:45 +00:00
igneum-labs
97f9648f07 0.3.21 over the field node: the Windows node-source pin back to c4459193 (main's shape: the 0.3.21 app over c4459193, no node gate; the 96161037 line folds into 0.3.22)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:04:28 +00:00
igneum-labs
ec9bb227b4 windows.yml smoke: a direct call with the exit code read, in place of Start-Process -Wait -PassThru that raced a program exiting at once (the 0.3.21 run's failure at the version read-back)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:02:58 +00:00
igneum-labs
6bef0a41cb Merge remote-tracking branch 'origin/scaling-21' into release-0.3.21 2026-10-07 17:01:49 +00:00
igneum-labs
54f2599a36 Scaling (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the window opens at about 80 percent of the screen's work area, capped at 1440 by 900 on a display up to 1920 wide and scaled up with the display beyond that, remembered per machine once the user resizes (the Mac window's frame autosave name; the Windows host's HKCU Software/Igneum/Miner WindowW and WindowH, written on WM_EXITSIZEMOVE and used while they fit the work area); the Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node card's first row sit above the fold at 1280 by 800 (gaps, paddings and the scene's height 220 to 184 px; no type below 13 px; the live scene and the GPU marks keep their look); fold.test.mjs measures the Overview's key elements inside the viewport at 1280 by 800, 1440 by 900 and 1920 by 1080 and the first object of Cards, Earnings and Prove, with Playwright on build-2 (known-failed on 0.3.21: the chain card ended at 836 and the node row at 900 of 800), skipping where there is no Playwright env; tools/ui-mock/fold-measure.cjs prints the numbers; captures at 1280 by 800 and 1920 by 1080
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:58:40 +00:00
igneum-labs
182e67f316 release rules 6a (the engine link check mechanical on every fetch, start and placement); the 0.3.22 plan's start incident and ruling
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:56:05 +00:00
igneum-labs
747c625f8f packaged-config: the Devnet 3 package names its network (node_devnet_suffix 3, node_peers = build-1's seed 26631 and node1-dn3 26671), NODE_OVERRIDE_PARAMS empty on the new chain (the node refuses the file), the shared devnet's object kept for the record; self-test rows for the fields
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:53:23 +00:00
igneum-labs
6da9596c75 release-0.3.22 plan: section 3, Devnet 3's first node up on dn3-g1 at 16:50:21Z
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:51:51 +00:00
igneum-labs
eff88bdc96 release-0.3.22 plan: section 2, 21d8f454 green on every box gate and the canary set; the app side's first commit and the packaged peers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:51:08 +00:00
igneum-labs
b776412ec4 0.3.22, Devnet 3 (7 October 2026): the package names the network its node joins (igneum-app.json node_devnet_suffix and node_peers; Runtime::from_env_with reads them when the environment is silent, node_dir devnet-N beside devnet-v4), no override file on a suffixed devnet from 3 (the node refuses it; the manifest's consensus override ignored with one log line), the version strings; app gate on build-2 258 + 32 + 8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:49:21 +00:00
igneum-labs
ea3e7193a6 release-0.3.22 plan: main's yes with two conditions; the app side's shape (the packaged devnet-3 suffix, no override file, a fresh node datadir)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:44:07 +00:00
igneum-labs
ee60c8eba6 release-0.3.22 plan: main's ruling on the nine switches; the fleet's Devnet 3 set standing, the cutover and gate scripts, the capacity plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:42:34 +00:00
igneum-labs
c6f9e6e9f7 release-0.3.22 plan: section 1, the candidate fa7f854f and the Devnet 3 object; build-1's Devnet 3 processes staged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:41:52 +00:00
igneum-labs
eda005504c 0.3.21: the Windows node-source pin at 96161037 (written by push-inputs with the payload)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:39:10 +00:00
igneum-labs
67ff8b97e1 release-0.3.22 plan: the frozen sub-version 3 object (017e7037, byte 7, id a785001687d8688a, the fingerprints, both gates green)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:31:39 +00:00
igneum-labs
1851b0d410 powertask: the registration test asserts the Power Helper's action is the app's own windowless exe (the Rust part of update-return dbbf0483; the relay/clients half lands through master on the relay line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:31:07 +00:00
igneum-labs
7686f08a88 Merge remote-tracking branch 'origin/currency-21' into release-0.3.21 2026-10-07 16:29:12 +00:00
igneum-labs
b1165dbcbe release-0.3.22 plan: Devnet 3 by 18:30 BST (main's order), the node line, the gates, the staged seed and hands, era VDF clean on 96161037
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:28:31 +00:00
igneum-labs
e8112f4e51 Site currency (currency-21): site/money.js is the app's currency rules for the site (tables generated from app.js, site/lib/money-regen.mjs rewrites them, site/lib/money.test.mjs fails on drift and runs in the pre-push gate); the card picker (yourcard.js, on /miner and /app) follows navigator.language, shows the card's electricity a day at the region's typical tariff through the one Intl formatter, with a currency switch saved in localStorage; watts per card from the Ember table and the bench log, Apple silicon says no power reading; the review shots from build-2 (Earnings in euro dark and light with de-DE placement, Settings with the picker, the first-run step on a fr-CA machine, the Overview in euro)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:27:27 +00:00
igneum-labs
d73cba9a68 Currency by locale (the project lead, 7 October 2026: "£/day is for UK; we need other currencies"): no IP lookup; the web view's language (the OS locale) picks the region, its currency and its default tariff at first run (GBP, EUR, USD, CAD, AUD and the long tail by ISO region through ISO_CURRENCY, names from Intl.DisplayNames, an EU member on the euro takes the EU average), a currency picker in Settings and on the first-run step whose override lives in the engine's settings file (config.rs currency, state.rs, Cmd::Region's third field, api/region currency; the Rust round-trip test) and wins over the locale; a region with no currency row falls back to USD with the prompt visible; every money line goes through one formatter (fmtMoney: Intl.NumberFormat with the code, narrowSymbol, the symbol placed as the locale places it); the user's own tariff is the number, so no exchange rate anywhere (the rent line says so for a currency without a USD rate); zero-decimal currencies typed in the unit (priceFactor), the price field's ceiling 100,000; three view tests known-failed first (de-DE reads EUR with the Eurostat tariff; the override wins and survives restart; a locale with no row asks); the mock's euro scenario
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:22:37 +00:00
igneum-labs
2534849578 release-0.3.21 plan: 96161037's hands and seed pairs, the Mac's binaries and DMG
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:20:15 +00:00
igneum-labs
dff16f3745 release-0.3.21 plan: the injector's nine steps PASS on 0.3.21's binaries; the register merged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:14:50 +00:00
igneum-labs
91db40be36 Merge remote-tracking branch 'origin/miner-reliability-21' into release-0.3.21 2026-10-07 16:14:50 +00:00
igneum-labs
4bc5e050c1 miner-faults register: the 0.3.21 injector run (nine steps green on app 786c3d37 and node c4459193, kept-datadir included)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:14:03 +00:00
igneum-labs
3a4419e561 release-0.3.21 plan: 96161037's two node gates PASS, the prover pair stands, the builds running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:12:53 +00:00
igneum-labs
8a2cbcc927 release-0.3.21 plan: section 6, the node line staged at 96161037 with its suite lines and the two box-input rules
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:56:12 +00:00
igneum-labs
0fcebf1f44 reliability injector: catch-up after a kept datadir reads the readiness line before the first worker start (the record exists from the first second there)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:48:32 +00:00
igneum-labs
64ccb16e5b release-0.3.21 plan: the final node order with c631c64b first, the whole-crate suite rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:35:25 +00:00
igneum-labs
cf85c9f80f release-0.3.21 plan: update-return-21b, first-block-21 (seen once), the reliability register in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:21:50 +00:00
igneum-labs
8a77336545 Merge remote-tracking branch 'origin/miner-reliability-21' into release-0.3.21 2026-10-07 15:21:29 +00:00
igneum-labs
dc3d763822 Merge remote-tracking branch 'origin/first-block-21' into release-0.3.21 2026-10-07 15:21:29 +00:00
igneum-labs
815d49f503 miner-faults: MF-11 in the update-return lane's words (the helper owns the return, the relay service, the ping, the tuner's ceiling) and MF-13 (the Power Helper's stale-command count); the external card kind noted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:09:50 +00:00
igneum-labs
7c065d100c first-block-21, main's reading "seen once": the first-block card waits until a window has shown it. The flag first_block_shown is set when the page reports the card displayed or dismissed (POST api/card/seen with the milestone's count and at, ladder.rs card_seen; the window hiding to the tray with the card up counts as the dismiss), never when the card is raised; an unseen card survives restarts and auto-updates (start_run drops a card only once milestone_seen), so a first block found overnight greets the miner the first time they open the app and never again. Every other rule stands: the lifetime count 0 to 1 raises it, identities and card swaps leave it, a kept directory at 0 shows it on the first block, a count that starts over raises nothing, a 0.3.20 record (schema 0, no flag) whose first block already came takes the flag and loses its card. The view trusts a 0.3.21 engine (the ladder carries first_block_shown) and keeps the uptime refusal only against a 0.3.20 engine. Tests: ladder.rs eight (a_first_block_found_with_no_window_greets_the_miner_at_the_first_open_and_never_again added: no window, a restart, the first open shows it once, a second open does not; the round-trip helper gets one temp dir per call, the shared path let parallel tests wipe each other's file), the view test re-cut, the mock's firstblock card unseen (the capture's mock log shows the page's api/card/seen). Lines: build-2 app gate 255 + 32 + 8; UI 67; pre-push 56 green; captures re-taken to ~/Desktop/igneum-previews-2026-10-07/first-block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:09:20 +00:00
igneum-labs
da38a6aca5 MF-11 follow-ups on release-0.3.21 (the app half of update-return 2d3d211a and 0b423697): the external card kind from a USB4 or Thunderbolt router in the device's parent chain (PC 2's RTX 5060 Ti in a Razer Core X V2 reads eGPU, not discrete); the Power Helper's registered probe wants the task enabled and its action exe on disk; a helper that gives no heartbeat or no line inside its window is a FAULT power-helper line with the registration re-read; a same-length rewrite of the command file is a new command (effective_skip; PC 2, 7 October 2026: every tune refused since the 14:35Z boot)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 15:07:17 +00:00
igneum-labs
6ffb825cf0 release-0.3.21 plan: first-block-21 in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:59:10 +00:00
igneum-labs
371d7dfcf0 first-block-21 (the project lead, 7 October 2026: "the 'you got your first block' situation only shows for the miner's first ever block"): the first-block card once in the app's life. Engine: ladder.rs carries a persisted first_block_shown flag set when the lifetime count settings.json holds crosses from 0 to 1 (survives restarts, updates and a kept data directory; never touched by a key changing identities or a card swap; a count that starts over on a shown record raises nothing); Ladder::start_run at engine start drops a milestone an earlier run persisted (a card is for the run that raised it) and takes the flag on a 0.3.20 record whose first block already came; the new-card card never fires at a count of 1; the state carries started_at (the run's wall-clock start). View: blockCard refuses a milestone raised before this run (staleCard, a minute of slack, uptime_s when the engine has no started_at), firstWait keeps the count-up off once the ladder records a first block, the first rung reads the flag. Tests: seven in ladder.rs (the known-failed second run first: a 0.3.20 record with the card persisted and the count at 1 showed it again; the first ever block; a restart at 1; an update at 1 over the older shape; a kept directory at 0 with the flag unset; a count that starts over; block 2 ordinary) and one view test in the same order. Mock: the secondblock scenario and started_at. Lines: build-2 app gate 254 + 32 + 8 (test --release); UI 67; pre-push 56 green; captures in ~/Desktop/igneum-previews-2026-10-07/first-block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:57:34 +00:00
igneum-labs
24ef8d6545 release-0.3.21 plan: update-return-21 in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:32:15 +00:00
igneum-labs
49f9aa773c Merge remote-tracking branch 'origin/update-return-21' into release-0.3.21 2026-10-07 14:32:13 +00:00
igneum-labs
3989a0cec9 release-0.3.21 plan: update-return's app half rides (main's word), the host.cpp compile as a named gate line, the relay half through master
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:30:38 +00:00
igneum-labs
d834055eac MF-11 update-return (0.3.21), the app half on release-0.3.21: the Windows update helper owns the return (the exe set kept beside the app, the app launched by the helper, api/state polled 120 s, the kept set restored when nothing answers, one intake line either way; the installer stays silent under /IGNOTA=2), the window host restarts a dead engine and answers the Restart Manager, the first act after an update is the read-back line and a FAULT pc-restart line when the PC came up from a power loss; the tuner never asks above a card's measured efficient point (the 5090 at 308 W, floored at the vendor's 400 W) unless Power control is on and the user raised the cap, and a refused cap is asked again at 2 and 10 min then reported as FAULT power-cap; the job-channel ping on every wake request (7 October 2026, PC 2 lost power at 10:46Z; the relay half stays on update-return)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:29:41 +00:00
igneum-labs
687e6d02a2 release-0.3.21 plan: the app reports its vote key hashes to the intake (main's item)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:21:41 +00:00
igneum-labs
9e2926047b release-0.3.21 plan: the prove-instead switch in, N15 on the node line, the final node order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:16:23 +00:00
igneum-labs
571368caf6 Prove instead of mining (main's routing, 7 October 2026): on a machine whose every NVIDIA card is under 12 GB the Settings switch prove_instead lets the prover run with those cards' miners held off (Cmd::ProveHold through the cards path, the restore choices kept, given back when proving or the switch goes off); the Prove page's row shows only on such a machine with the sentence naming the choice; provedefault::prove_instead_cards and prove_instead_line with their test, proving.under_12gb on the state, api/prove/instead, the view test; the fleet's 3080 hour: a 10 GB card completes the compressed step alone at 8.6 GB and never beside its miner
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:16:23 +00:00
igneum-labs
53919ce85b release-0.3.21 plan: pool-finish-21 in; the app side's state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:06:21 +00:00
igneum-labs
682d9bc3a2 Merge remote-tracking branch 'origin/pool-finish-21' into release-0.3.21 2026-10-07 14:05:59 +00:00
igneum-labs
48085fc628 release-0.3.21 plan: scene-parity-21-sizing in (live-dag.js 2.0.4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:04:32 +00:00
igneum-labs
a586b94985 Merge remote-tracking branch 'origin/scene-parity-21-sizing' into release-0.3.21 2026-10-07 14:04:32 +00:00
igneum-labs
7bf8717d6d release-0.3.21 plan: f95178a1 last in the node order, the overlap lane closed, the UI row updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:04:11 +00:00
igneum-labs
7fa81e8b28 Pool daemon: --template-parallel (default 2) bounds the template fetches in flight against the node (the ten-member window on pool-1, 7 October 2026: ten parallel getBlockTemplate calls on one gRPC connection queued past the client's request timeout from 12:54Z, no job for 36 minutes); pool.md 10.5a records the window's two stacked faults
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:03:05 +00:00
igneum-labs
46dd0b4214 docs/plans/pool.md 10.5: the open-pool gate on igneum-build-1 (100 members, 10 daemons, 4 nodes, PASS: 90 of 90 honest members paid by the coinbase rule, first payout p50 3.6 s p90 7.0 s after the first share, 0 withheld shares seen, 0 honest blocks paying a withheld address, stale 9.6 percent), the two failed runs and their lessons, consequences by tier
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit cb2c3ee0c7)
2026-10-07 14:03:05 +00:00
igneum-labs
271c89d31c Open pool: the seeds check accepts another node's view of an epoch when its seed is a block the member's node holds at the seed depth (a DAG settling below the lead), the node's epochs kept by span; the gate harness starts the nodes in sequence with --addpeer and reads their peers and DAA scores by RPC before and during the run (the first box run was three DAG partitions)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0a4437195c)
2026-10-07 14:03:05 +00:00
igneum-labs
6d21a512a1 Open pool: the retarget clamps against the window's mean (never compounded share by share), the ceiling in u128 (a 2^49 first target shifted by 20 wrapped to 0 on the box), a deeper reorg depth and a progressive sync when parents are missing; the fast-time file carries pool_split_activation_daa (never); the gate runs on build-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit af9849a618)
2026-10-07 14:03:05 +00:00
igneum-labs
77f1076d3e Open pool: the share chain's first target as a chain constant, the retarget on the window's mean target with a ceiling, withheld shares kept out of sync replies, the share line carries the cache rule for verify-share, the gate harness cleans up after itself and reads the first payout to 5 s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 5f5be5676a)
2026-10-07 14:03:05 +00:00
igneum-labs
cadaed7fcd Pool finished (mission item 11): pool-0's fee published beside the dev fee, TLS 1.3 on the member port with the authorize binding (spec 9.3, O-9.7 closed), the page rows Q68 to Q73, and the open pool: a share sidechain with no operator (spec 9.12)
Pool-0: welcome carries software_dev_fee_percent beside the pool fee, the page's Fees row and site/miner.html say pool-0 charges the same 1 percent as the solo dev fee; jobs and seeds carry the latency ladder's rung for 0.3.19.

TLS (pool/src/tls.rs): --tls-cert/--tls-key or --tls-self-signed (a P-256 pair under the data dir, the certificate pin printed at start); the binding is the member's BLS signature over this connection's exporter (label EXPORTER-igneum-pool-binding, context the chain id), refused on any other connection; testnet and mainnet refuse the clear without --allow-plain. HiveOS: pools:// and POOL_PIN.

Page rows: node state in words (Q68), one formatter set and luck in MiningPoolStats' convention (Q69), samples and check costs persisted (Q70), payments under the lookup (Q71), hourly history with a sparkline, --alert-webhook, /metrics, /health on the node (Q72), the network's finality state beside "payouts follow blue confirmation, not finality" (Q73).

The open pool (pool/src/sidechain.rs, open.rs, p2p.rs; igneum-pool --open): the member's own node and daemon, no payout key, no balance; every coinbase carries the member's own address, the share chain's parent (IGNS) and the window's split (IGNP); templates re-stamped on a new chain tip without a node call; shares gossiped and checked by every member (structure, the node's seeds, the PoW); heaviest work wins, a fork's loser is stale; the dev fee as one split entry; shares.log and verify-share for the drop proof; the gate harness pool/tools/open-gate.mjs. The node side (the executor's split from pool_split_activation_daa) is on the fork branch pool-finish-node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1cbafd7e74)
2026-10-07 14:03:05 +00:00
igneum-labs
5a50a7f0fd Pool vardiff: the idle easing no longer consumes the sized first correction; docs/plans/pool.md 9.3: the re-run's close and the member rows
pm-1, 7 October 2026: the member was idle for four intervals while its worker compiled, the first-phase idle easing
set first_done, and the measured 190 shares a second then walked down one step per 30 s for 5.5 minutes (the share
check at 10 to 11 ms on pool-1's cores). The sized correction now stays owed through idle easings (test
an_idle_easing_does_not_consume_the_sized_first_correction). Section 9.3: 207 found / 144 confirmed / 56 own orphans
under 2f6c0358, the residual as the node's 1 to 1.6 s template latency (a node-lane row), the member findings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 4f875dd868)
2026-10-07 14:03:05 +00:00
igneum-labs
8913282f4c Pool daemon: the confirmation walk on its own gRPC connection, never restarted from the pruning point, bounded per tick
7 October 2026, 06:01Z and 06:12:55Z on pool-1: confirm_loop restarted its chain walk from the pruning point on any
failed getVirtualChainFromBlock and then fetched every chain block since (about 120,000) over the one connection the
templates used; one timed-out request under four parallel template fetches started it, every template request after it
timed out, no job was issued, 105 blocks on stale templates were orphans. Now: a second GrpcClient (pool.walker) for the
walk and the network numbers; a failed chain call keeps its cursor (the sink only when the node no longer knows it;
cursor_after_failure, node::walk_tests); at most 600 chain blocks per tick with a line saying so; a start with pending
blocks walks from the sink and says that older ones resolve by the orphan rule. docs/plans/pool.md section 9.2: the
re-run's record (the class question closed on pm-1 and pm-2, 71,240 shares, 0 mismatches), the cause, what stays open.
Suite 24 of 24 on igneum-build-1. Protocol and API unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c686d8fc2e)
2026-10-07 14:03:05 +00:00
igneum-labs
19d0588aac Pool daemon: --template-timeout-s (default 20, was a fixed 5 s): pool-1's node builds a template in 1.6 to 1.8 s under load, four parallel member fetches overran 5 s and no job was issued for 7 minutes (7 October 2026, 06:08Z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit d1f146af12)
2026-10-07 14:03:05 +00:00
igneum-labs
64be384d57 Pool daemon: refuses to start half-alive (listeners bound first, exit 2; a node that answers no template, exit 3; a STATUS line every 30 s)
The fleet agent's row from the 6 October night: the daemon ran 20 minutes as a process serving nothing while its node
answered no template and its member port had been held. Now main binds the member and API listeners before anything
else (server::bind_listener; a failure is "POOL NOT STARTED: cannot bind the members listener on <addr>: <os error>",
exit 2), probes the node for a template with pow_epoch, retried for --node-wait-secs (default 60) with a line per
attempt, else exit 3, and prints a STATUS line every 30 s (members, jobs issued, shares, blocks, template failures, the
node's state: ok, NODE NOT ANSWERING, NO TEMPLATE YET). fetch_job counts template failures and the last success.
Test server::bind_tests: a held port is refused with the address in the message, the freed port binds. Suite 23 of 23
on igneum-build-1. Protocol and API unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9473e01320)
2026-10-07 14:03:05 +00:00
igneum-labs
639302e3a1 Chain scene 2.0.4: the box's height follows the lanes (onSize, autoHeight, the lane geometry in stats) (7 October 2026, 16:3x UK)
the project lead's /live screenshot: a fixed tall box with the lanes in its top third and dead space under them. The renderer now knows
the height it wants: top padding + axis padding + lanes shown (at most maxLanes 7, narrowLanes 4 on a phone, never under 2)
times laneHeight (46 px, 40 on a phone, 26 compact; the mount option laneHeight overrides). It reports it through
onSize(heightPx, {lanes, laneHeight, narrow, compact}) whenever it changes and through getWantedHeight(); stats() carries
laneHeight, padT, padB, capacity, wantedHeight and autoHeight, so a page that sizes its own box reads no constants. With
autoHeight (on by default when the host set no CSS height on the canvas, which is read before the first size(); forced either
way by the option; never in compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself
and lets every lane through instead of fitting the lanes to the box. Every current host sets a height (/live 420, the hero
500, the app's card 220 and its Inspect view 420), so the frames are unchanged: the parity test on build-2 stayed equal on
every comparison. The site lane switches /live and the home fold to the hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1546b61fa1)
2026-10-07 14:02:19 +00:00
igneum-labs
c3fb95e3be release-0.3.21 plan: scene-parity-21 in (live-dag.js 2.0.3, the parity gate)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:54:49 +00:00
igneum-labs
9a56f1ae2e Merge remote-tracking branch 'origin/scene-parity-21' into release-0.3.21 2026-10-07 13:54:45 +00:00
igneum-labs
719fb1edd4 Chain scene parity test: one recorded feed through the home fold, /live and the app's Inspect view, three frames each pixel-equal, in the gate (7 October 2026, 15:5x UK)
tools/scene/parity.mjs serves site/ (tools/site-serve.mjs) and the app's UI (tools/ui-mock/server.mjs) on a build box, answers
every page's /api/live from scene/fixtures/live-2026-10-07.json, freezes the clock at the fixture's instant (Date, timers,
requestAnimationFrame) and reads each scene canvas at 900 by 420 px at the first push and the next two polls: the read is the
push's own synchronous paint, so a frame depends on the fake time alone, never on how long a fetch took. Known-failed first: the
app with --included moved by one unit must differ from /live at every instant (it does: 1,549 / 1,282 / 1,157 px). Then home
fold = /live, app Inspect = /live, app with this machine's key = /live with the same key (the overlay is the same picture), and
the app's overlay frame differs from its base frame (the overlay is drawn). The compact card is rendered and reported, not
compared. A RED line names the differing pixels and their box. tools/scene/parity-remote.sh carries the files to the box
(~/.config/igneum/build-server-2 by default) under this lane's prefix and runs it there; a plain CI runner with no box and no
Playwright prints a skip line. One new line in tools/ci/pre-push.sh.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 4102c935e518e83eba39d880daad1a57b77c3bf8)
2026-10-07 13:51:47 +00:00
igneum-labs
429fcdf000 Miner app: the chain scene is the site's scene (scene/ 2.0.3), one feed contract, the viewpoint as an overlay (scene-parity for 0.3.21, 7 October 2026, 15:5x UK)
The drift the app carried against the site's home fold and /live, and what moved:
- renderer: both were 2.0.2 byte for byte; the app now takes the shared scene/live-dag.js 2.0.3 (paint on push whatever the
  document's visibility says: the blank /live; the phone rule on the viewport width) and proof-core.js through tools/scene/sync.mjs,
  and the gate refuses a drifted copy.
- palette: the dark tokens were equal; the light theme's --ember was #E04A14 and --ember-hi #F2541B against the brand package's
  #D0420D / #E04A14. The fourteen scene tokens now sit in the scene-tokens block app.css takes from scene/tokens.css (dark,
  [data-theme="light"], prefers-color-scheme light) and are defined nowhere else in the file.
- phone rule: the app passed narrow: window.innerWidth < 720 at mount time and never again; the site keyed it on the canvas
  width (a 640 px hero on a laptop rendered as a phone). Both now leave it to the renderer: the viewport, live on resize.
- feed window: the app asked the engine for 120 s, the site 300 s; both 300 now, so the viewer can pan the same range.
- Inspect view: 360 px tall against /live's 420 (five lanes against seven); 420 now.
- feed shape: the engine rewrote this machine's blocks to miner: "you" (a word the observer never emits) and its node-only
  fallback carried now as a float of seconds, rows with timestamp_ms / is_chain_block / vote_key_hash / timestamp_source and no
  number or rx, miners as {id, vote_key_hash, blocks_10m}, no proving, a finality with checkpoints alone. live.rs now passes the
  observer's rows through untouched (state.you_blocks counts them; the UI's mine function marks the lane from the card ids, which
  is the overlay: own blocks glow, the lane reads YOUR KEY) and node_only_reply builds the fallback in the contract's shape
  (every key of scene/feed-contract.json, null where the node cannot know, partial: true, state.source "node", ISO now). The
  test the_node_only_reply_has_the_contract_shape reads the contract file itself (include_str!), so the Rust side and
  tools/scene/feed-contract.mjs cannot drift.
App gate on build-2 (test --release, --priority gate): 228 + 32 + 8 passed. Parity on build-2: app Inspect = /live = home fold
at T+0, T+2, T+4 s, the overlay identical when both surfaces know the key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:51:47 +00:00
igneum-labs
ea669b9c69 Chain scene 2.0.3: /live and the home fold paint on every push whatever the document's visibility says; the renderer, its palette and its feed contract move to one shared folder scene/ with byte-equal copies checked by the gate (7 October 2026, 15:2x UK)
The blank /live (the project lead, 14:3x UK): IgneumDag 2.0.2 painted only from a requestAnimationFrame loop gated on document.hidden and
the IntersectionObserver, so a page that loaded with document.hidden true (the desktop pane, a background tab) and whose embedder
never fired visibilitychange took every push, reported live and never drew a frame. 2.0.3 paints the current picture synchronously
on push, size and theme change; the motion loop alone waits for a visible document and an intersecting canvas. Confirmed headless
on build-2 against the live feed: hidden document 0 lit pixels before, 110,007 after; the never-intersecting observer repaints
on every push. Known-failed test tools/scene/paint-test.cjs (the 2.0.2 renderer draws nothing in the same world).

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f774353461)
2026-10-07 13:51:47 +00:00
igneum-labs
f63e6b67e7 release-0.3.21 plan: section 5, the first node candidate 55768f88 with both gates green
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:48:53 +00:00
igneum-labs
a8e9dcd187 release-0.3.21 plan: miner-reliability-21 7e1b7060 in (MF-10's capability check, MF-8's injector step, the register rows)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:46:39 +00:00
igneum-labs
773e1e063b Merge remote-tracking branch 'origin/miner-reliability-21' into release-0.3.21 2026-10-07 13:46:25 +00:00
igneum-labs
7e1b706016 miner-faults: MF-8's injector step and MF-10's capability check are code now, not owed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:45:41 +00:00
igneum-labs
ee3240c9d5 miner-faults MF-11 corrected: the machine goes silent and nothing tells anyone (PC 2's power loss, not the update-now); the silence watch per machine and the relay agent as a service
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:44:51 +00:00
igneum-labs
326cde2a3b release-0.3.21 plan: earnings-tidy-21 in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:42:56 +00:00
igneum-labs
f2ec05f376 Merge remote-tracking branch 'origin/earnings-tidy-21' into release-0.3.21 2026-10-07 13:42:53 +00:00
igneum-labs
c8e6322a5d release-0.3.21 plan: pool-finish-21 at 5e557171, ready to merge after the UI branches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:42:11 +00:00
igneum-labs
434dea281e earnings-tidy captures from build-2: the Earnings page dark, light and a fresh miner (ladder and firstwait scenarios), 1280 wide at 2x
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:41:30 +00:00
igneum-labs
f59391c5ed Earnings card tidied (the project lead, 7 October 2026: "remove all the type a price stuff"): the £ per IGN input, Use it, the remembered igneum.ign_price, the price line, the balance-in-pounds line, the share line, the IGN per kWh line and the cost-of-hash row are gone from the card (rentLine stays in View for the site's hash-cost model); the card is now the day rate in ember with its reason line, blocks and IGN this run, a quiet row of three (weight: rank, window, days, share; electricity: £ a day at the Settings price or the draw, IGN per kWh; lifetime: blocks and their IGN), the dev-fee switch last with one sentence; View.earningsLines(s, chain, pence, now, minor) carries every line and names its field; three view tests known-failed first (no price input anywhere, the headline and run line, the row of three), the old price test retired
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:40:19 +00:00
igneum-labs
11f05ed93e release-0.3.21 plan: the ids commit first on the node line, the node order with horizon-node and peer-directory-node, pool-finish-21 and the template-parallel fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:39:18 +00:00
igneum-labs
4242ae849b release-0.3.21 plan: gpu-logos-21 in (the marks, the Prove switch fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:29:29 +00:00
igneum-labs
16a45243b4 Merge remote-tracking branch 'origin/gpu-logos-21' into release-0.3.21 2026-10-07 13:29:27 +00:00
igneum-labs
de52b2e061 brand/marks/vendor-marks.mjs: the app's vendor marks and tokens exported verbatim for the site (site-ui-5), with the Windows mark in the same treatment and macOS as the Apple glyph, regen.mjs to refresh it from app.js, a view test that fails on drift; the review shots re-taken on build-2 against gpu-logos-21 (the Prove page on, off and light added: the switch fix in the picture)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:28:36 +00:00
igneum-labs
b986804310 Prove page switch fix (the project lead's 0.3.19 Mac screenshot, 7 October 2026): the thin white rectangle above-left of "Prove on this machine" was the DAG legend's bare .lg rule hitting label.switch.lg, the knob outside the track at off was the inspector's bare .track rule (flex, 10 px margin) hitting every switch track; both scoped (.legend .lg, #i-track), the switch's size class is its own (big), the native input is hidden the accessible way (1 px clip, focusable, label kept) under a positioned label; three view tests, known-failed first on the old CSS (bare rules, input hiding, knob inside the track at off and on in both sizes with the theme blocks untouched)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:26:45 +00:00
igneum-labs
210f0b92e8 gpu-logos captures from build-2 (tools/ui-mock/capture.sh with --force-prefers-reduced-motion so the page is drawn, not mid-fade): the Overview dark and light, the first-block card with the mini mark, the Mac row, the mixed rig on the Cards page and the first-run list, dark and light, 1280 wide at 2x
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:25:52 +00:00
igneum-labs
ca71410971 GPU vendor marks on every card row (gpu-logos, 7 October 2026): NVIDIA, AMD Radeon, Intel Arc and Apple as hand-drawn monochrome inline SVG glyphs (under 460 B each, currentColor), each in a soft rounded well tinted by its own --mark-* token (dark and light sets, ember never tints a brand), the series line under the name in mono (RTX 50 series · Blackwell, RX 9000 series · RDNA 4, Arc B series · Battlemage, M5 series · integrated), an integrated row keeps the same glyph with the integrated line, a neutral chip glyph for an unknown vendor, the block card's mini mark; one contiguous View block and one CSS block for the apps-harmony rebase; five view tests (every vendor, the fallback, never twice on a row, the series line, light contrast at 3:1 or better with app.css checked); the mock's rig scenario and tools/ui-mock/capture.sh for the box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:25:52 +00:00
igneum-labs
545778543b MF-10: the prover reads the card's compute capability and the GPU server's sm_ words and refuses a mismatch with the reason (and reads a named-symbol proof failure as the same class); MF-8: the injector's kept-datadir step (a previous release's node writes the datadir, the new node must open it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:25:08 +00:00
igneum-labs
3549a16e15 miner-faults MF-11 (the app not back after update-now: read-back line, FAULT home, the relay agent as a service) and MF-12 (the pool lane's epoch-boundary stall, renumbered from its MF-11)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:23:21 +00:00
igneum-labs
7ad98bdf87 release-0.3.21 plan: pool-finish rides it (main's word, the split switch at never), the four UI branches in order, MF-11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:21:10 +00:00
igneum-labs
d8e87ec985 platform: kill_pid spawns taskkill and kill with the hidden console (master's windows-spawn check on the reliability merge)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:18:20 +00:00
igneum-labs
3c54d0cfc5 release-0.3.21 plan: what rides it, the under-12 GB prove-instead switch and the per-architecture prover server as designs, the gate clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:16:59 +00:00
igneum-labs
5e8df956f3 Merge remote-tracking branch 'origin/miner-reliability-21' into release-0.3.21 2026-10-07 13:16:12 +00:00
igneum-labs
9ede727372 engine (0.3.21): the heat-mode release restarts every slot; there is no faulted state to skip
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:14:13 +00:00
igneum-labs
bed618b2cf restart-hand-nodes: the bracket form for the pgrep (master's kill-by-name check), the launchd form kept
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:59 +00:00
igneum-labs
da4a6dfe3a execrpc: 0.3.21's two callers classified (igneum_getRecentBlocks gated, eth_getBalance safe)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:26 +00:00
igneum-labs
d366f99ace miner-faults register: run 5 (card-appears green with the fixed listing); every class's injector step passes on engine 4daaa2cc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ee3d93386b7aabe8188fd2c8bd00c63a818ac2bf)
2026-10-07 13:12:08 +00:00
igneum-labs
93a1082f58 miner-faults register: the pod injector's run 4 lines (seven of eight steps pass on engine 4daaa2cc)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9a91ff57e1db5896f9f9016b7aadcc2ba27e5bff)
2026-10-07 13:12:08 +00:00
igneum-labs
20ff77922d miner-faults MF-10: a prover server built for another card's architecture (named symbol not found); the capability check at start and the per-architecture kit
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9d2c267af7a7b097199e6232b221b91081f52bca)
2026-10-07 13:12:08 +00:00
igneum-labs
8ed08e521a miner-faults MF-9: a node that stops slower than its restart (the listener watchdog's sleep-then-check); every poll loop returns on shutdown, a sub-second shutdown is a named release gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7a0115617b9fa7b7e8e9930c8cfb530af71d4d55)
2026-10-07 13:12:08 +00:00
igneum-labs
93ce4236b8 fake worker: the OpenCL listing carries the real worker's header line, so the engine reads an answered enumeration and removes a card that left
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ecd798747a26d339ff514da04773be858780636c)
2026-10-07 13:12:08 +00:00
igneum-labs
782559162e execrpc: an empty or unparsable reply is no answer, so a stopped node reads as silent to the watchdog's probe
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 4daaa2cc33)
2026-10-07 13:12:08 +00:00
igneum-labs
58c9d84dad miner-faults MF-8: the class in the shipper's words (node refuses its own kept datadir after an update), N13 b7cc37e7, the gate on both datadir shapes, the injector step owed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 612d524899)
2026-10-07 13:12:08 +00:00
igneum-labs
13911191d1 miner-faults MF-8: a node dying at start on a kept datadir (the serde(default) row, N13); the LG-4 job's tenth install keeps the datadir and a node restart loop fails the row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 625fc4e06d)
2026-10-07 13:12:08 +00:00
igneum-labs
f6848afb4b reliability injector: the stale duplicate step blocks removed (the card-appears rewrite had sliced before an earlier definition); the rung is read over two seconds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fa76c99d8c)
2026-10-07 13:12:08 +00:00
igneum-labs
ea9d23ed94 engine: the row's countdown is set the moment a watchdog restart is scheduled; injector reads the rung over two seconds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 887006768d)
2026-10-07 13:12:08 +00:00
igneum-labs
7ad2fd3194 reliability injector: card-appears uses a second fake device (added, removed, revived); an empty device list is read by the engine as no answer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1cc8580368)
2026-10-07 13:12:08 +00:00
igneum-labs
4ebb3fe5c8 reliability injector: reads the fake cards by name and switches a box's real GPU off through the app's own card path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7674086af2)
2026-10-07 13:12:08 +00:00
igneum-labs
ca47ed1e42 miner-faults register: the commits table and which side each class is on
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ce971e1b08)
2026-10-07 13:12:08 +00:00
igneum-labs
ffb644fe2b reliability injector: the catch-up step's flag no longer shadows the process list
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit bfab69e9db)
2026-10-07 13:12:08 +00:00
igneum-labs
71fa3e5b82 execrpc: one gate for every execution-layer RPC call the app makes (ledger N7, main's rule for 0.3.19)
A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes the record vector is asked while its exec follower holds no record; PC 1 crash-looped on two callers in one night (eth_getBlockByNumber from the clock sample, then igneum_getAssignedShards from the prover loop: 'panicked at igneum/exec/src/rpc.rs:808:35: range start index 1 out of range for slice of length 0'). Every caller (prover.rs's evm_rpc, update.rs's clock sample, extnode's rpc for chainfacts and the external-node probe) now goes through execrpc::call: SAFE_ON_EMPTY methods go out, GATED ones wait for igneum_getExecStatus's executedTipHash, an unclassified method is refused. The test every_caller_goes_through_the_gate scans src/ for JSON-RPC requests built elsewhere and for unclassified exec method names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:08 +00:00
igneum-labs
f3238c9b3e Miner app: plug, tune, play (the project lead, 7 October 2026): node readiness gate, the retry ladder, no permanent fault, fault lines to the intake, the signed cards job, the fault-class register
docs/plans/miner-faults.md: MF-1 to MF-7, each with its rule, test and gate line.
- MF-1/MF-2: a worker starts and is judged only when the node is READY (synced and igneum_getExecStatus reports an
  executed tip; execrpc::probe every 5 s off the engine thread); the node watchdog never counts the catch-up (settled
  once read synced; 30 min cap before that; any RPC answer is a sign of life); the watchdog restarts on a ladder 10 s,
  30 s, 2 min, 5 min, then every 5 min for ever (watchdog::RETRY_LADDER_S); the faulted state and the one-restart
  budget are gone (tools/ci/permanent-fault-check.sh in the gate); a node-caused restart resets the ladder at sync.
- MF-3: the hot-plug pass starts a recovered or revived card's worker (unchanged rule, now in the register).
- MF-4: the status clock starts at ready (program loaded), loading bounded by 300 s; a self-test failure holds the
  card 30 min with the reason on its row, released on a driver change; a crash loop climbs the ladder; the pack is
  exported once a minute for every card (a refused pack forces one).
- MF-5: the app reads template_wait=, template_ms=, identities_active= from the 0.3.20 miner's STATUS; waiting on
  the node is never the card's fault; the row says node slow; every node-wait label clears on the first rate.
- MF-6: a miners hold belongs to the job that took it and releases when that job is gone or at its own cap.
- MF-7: the engine owns every igneum-miner it started: an untracked one on this engine's node RPC is killed at start,
  after every stop and every minute, one line and one fault report per kill; a restart kills the old process first.
- Every fault line posts one FAULT line to the log intake (label fault-<id8>, app and node version, 60/h cap).
- The signed cards job kind (per card enabled, identities, power_pct; refused for a card the machine lacks; applied
  through the app's own card path, persisted, read back): packaging/ota/publish-jobs.sh add --kind cards.
- LG-4 as a job: relay/playbooks/first-share.ps1 and tools/fleet/first-share-gate.mjs (no Windows box yet).
- tools/reliability: the fault injector with one step per class (catch-up, card-appears, own-restart, zero-ladder,
  no-status, node-silent, one-card-fails, orphan-miner); fake-worker.mjs lists devices and fails self-tests on command.
- master's build tooling (d4bc5a94) and release-0.3.20's igneum-pow taken into the worktree for the box routes.
Box: app 198 + 27 + 8 tests green on igneum-build-2; the tree gate green (33 checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:11:48 +00:00
igneum-labs
d18e7c4d36 Merge remote-tracking branch 'origin/master' into release-0.3.21 2026-10-07 13:11:18 +00:00
igneum-labs
0e6ae34ad8 0.3.21: version strings (the tree after 0.3.20's cut: driver-check f43c9d4d first; miner-reliability's app half, the under-12 GB prove-instead routing and the per-architecture prover server follow)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:10:44 +00:00
igneum-labs
a4e6ab7b8a Driver table: a referer per row, sent as curl -e (drivers.amd.com answers 403 without an amd.com one; the AMD row carries it); a plain user agent on the download
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f43c9d4d7c)
2026-10-07 13:09:47 +00:00
igneum-labs
f5d682e98e Driver table: the NVIDIA 617.42 and AMD 26.9.2 rows measured on igneum-build-2 (sha256, size, Authenticode subject from the vendors' own files); the stray #[test] above the Intel test's doc comment removed
NVIDIA GeForce Game Ready 617.42 WHQL (6 October 2026): us.download.nvidia.com/Windows/617.42/..., 990,853,168
bytes, sha256 f115c927..., subject CN=NVIDIA Corporation (DigiCert G4). AMD Software Adrenalin 26.9.2 WHQL
(29 September 2026, the win11-b build): drivers.amd.com/drivers/whql-amd-software-adrenalin-edition-26.9.2-win11-b.exe
(an amd.com Referer required), 1,000,800,840 bytes, sha256 593c1d73..., subject CN=Advanced Micro Devices (Sectigo).
Read on the box: curl, sha256sum, the PKCS7 out of the PE security directory through openssl pkcs7 -print_certs.
The table's placeholders are gone: every row installs. The drivertable test sample, the mock and the view test name
the real NVIDIA release. detect.rs:990 carried a #[test] above the doc comment of the Intel test from the cherry-pick,
the test build's one warning ("duplicated attribute"): removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 4f8b37b6be)
2026-10-07 13:09:47 +00:00
igneum-labs
108624cb19 Driver check: the app detects a missing or old driver per card and installs it on one click from the vendor's own server (branch driver-check, 7 October 2026)
the project lead: can the drivers be packaged with the miner, for all cards, with the system knowing which to install if not
present. Not bundled: detected and installed on one click. A per-vendor table rides the signed manifest (drivers.json:
min_version, the version on offer, the vendor's URL, size, sha256 and its source page, the silent arguments, the
restart exit codes, the Authenticode signer, the Linux and HiveOS package), validated by the signer and the app alike
(src/drivertable.rs, shared), written to <app data>/drivers.json by ota.rs. Each card's driver version is read at
every detection (nvidia-smi's driver_version; Windows' DriverVersion for AMD and Intel) and compared; a missing or
old driver puts the offer on the card's row, on the dashboard and on the first-run list. The click downloads with
curl (resume), checks size, sha256 and the Authenticode subject, runs the installer through one elevated prompt
(platform::elevated_command, the PC 1 driver job's shape), reports restart required with a Restart now button, and
never restarts by itself; the miners keep mining. macOS: no step; Linux and HiveOS: the package line. Dry run through
IGNEUM_DRIVER_DRY_RUN or the table. Tests: the table, the versions, the offers per tier, the exit codes, the
Authenticode verdicts, the download against a mocked vendor server on 127.0.0.1, the UI's strip per state; the mock's
drivers scenarios; captures light and dark in docs/plans/driver-check-shots. publish-manifest.sh --drivers carries the
table. Also the doubled #[test] in detect.rs from the cherry-pick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a0b3982998)
2026-10-07 13:09:46 +00:00
igneum-labs
3d42a22e71 release-0.3.20 plan: section 35, the project lead's orders (the floor file publishes, the sweep in waves, the Arc-tested worker exe, 0.3.21 tonight)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:09:09 +00:00
igneum-labs
6d24730df5 release rules: 4a every gate starts on every candidate as it builds, 4b warm pods per gate class, 5 the sweep in waves (the project lead, 7 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:07:49 +00:00
igneum-labs
5a0b84b932 release-0.3.20 plan: section 34, every artefact on c4459193 with shas, the workers with the Arc fix, the hive package and its smoke, the Windows inputs and run, the staging plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:00:53 +00:00
igneum-labs
a4b2293b1a release-0.3.20 plan: the cases rerun's slip to about 16:10 BST, the choice put to main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:53:10 +00:00
igneum-labs
7c591e3619 release-0.3.20 plan: the c4459193 seed pair; the pair set complete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:48:51 +00:00
igneum-labs
c1b60cc9d3 release-0.3.20 plan: c20-1's kept start on the pin, the Mac build started
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:47:20 +00:00
igneum-labs
34ee819a70 release-0.3.20 plan: c4459193's two node gates pass; the Mac binaries start
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:46:47 +00:00
igneum-labs
04eb4b4eb7 release-0.3.20 plan: the c4459193 hands pair
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:41:51 +00:00
igneum-labs
d87a511b2b release-0.3.20 plan: the cases rerun started on c20-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:41:44 +00:00
igneum-labs
d329bd7743 release-0.3.20 plan: main accepts the three lines; the cut set stands
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:41:24 +00:00
igneum-labs
a213628fcd release-0.3.20 plan: the cases rerun on c4459193 (the class byte touch), the 610 read (the driver proves; the server per architecture is the rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:40:45 +00:00
igneum-labs
89ebce25d2 release-0.3.20 plan: the wipe canary's start stamp on c19-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:40:00 +00:00
igneum-labs
3aa518138a release-0.3.20 plan: CASES END pass on dc141409, the carry question to c4459193
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:39:47 +00:00
igneum-labs
1d3cab98ed release-0.3.20 plan: the wipe canary pod c19-1 on c4459193, the 10 GB tier closed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:38:30 +00:00
igneum-labs
b39864c2f6 release-0.3.20 plan: main accepts the wipe canary's clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:36:56 +00:00
igneum-labs
4d7068e0e1 release-0.3.20 plan: the wipe canary moves to a second pod so its line lands inside the checkpoint
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:36:32 +00:00
igneum-labs
af3e72df00 release-0.3.20 plan: the 3080 hour's number (a 10 GB card proves alone, not beside its miner), the build-server sequence on c4459193
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:34:34 +00:00
igneum-labs
75b1646931 release-0.3.20 plan: section 33, b7cc37e7 struck (the 10 s stop), the pin c4459193, the carry ruling, main's F8 and slot rulings, the 12 GB rule in and green, the roll complete, the server-architecture fact, the 0.3.21 driver-check tip
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:33:26 +00:00
igneum-labs
196ee0101d Proving refuses a card under 12 GB and says why (main's rule, 7 October 2026): MIN_VRAM_MB_PROVE_ANY 11,800 and the sentence "proving needs a 12 GB card; mining continues" in provedefault.rs with its test, the prover loop refusing before the sync wait when every present NVIDIA card is under it, the tile sentence in the UI with its view test; the fleet's p1-3080 died at the compressed step 29 of 29 with 0 paid; the measured SP1 threshold moves the line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:13:33 +00:00
igneum-labs
677a08a7cf release-0.3.20 plan: the driver check to 0.3.21, the clock rule, the 10 GB prover tier and the rule put to main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:11:42 +00:00
igneum-labs
c78c407c5e release-0.3.20 plan: 6b94c823's two gate lines (clean on the checks that bear on the binary), the harness sequencing fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:10:20 +00:00
igneum-labs
e3b2f8a667 release rules file (the standing rules as main set them, the kept-datadir gate as rule 4); the 0.3.20 plan's row for main's three additions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:07:42 +00:00
igneum-labs
6bd5741c5e release-0.3.20 plan: section 32, N12 and N13 on the line, the pin b7cc37e7 with no fallback commit, the kept-datadir gate, the stale packs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 12:06:22 +00:00
igneum-labs
efbad7b158 proto-cuda/packs-ca3-v4 at the hash lane's 8c728ca3: the amended class v4 packs re-exported under the sub-version-1 stamp (program id 1a4230699a6b9c60 on every v4 pack; the pre-amendment c120d7963abdcd96 is the must-differ vector); igneum-pow's recheck tests read these
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:55:16 +00:00
igneum-labs
3af54b17e3 release-0.3.20 plan: the build-server lane's four pairs in flight, the node line's missing rust-toolchain.toml owed for 0.3.21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:54:38 +00:00
igneum-labs
dc63c587b7 release-0.3.20 plan: clock correction (the UK stamps since 12:40 BST ran ahead; true times from the commit times)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:54:22 +00:00
igneum-labs
657ca5ad14 release-0.3.20 plan: the sixteen-field interop fact (0.3.17 relays byte-5 headers), the void run's failed checks explained
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:54:01 +00:00
igneum-labs
21c2d0ade6 release-0.3.20 plan: the amended v4 packs' ids and fingerprints (the hash lane's table), G1, the pairing line in flight
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:53:51 +00:00
igneum-labs
908c06f1de release-0.3.20 plan: section 31, the candidate pin, igneum-pow pairing, the speculative builds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:52:21 +00:00
igneum-labs
7221da2328 igneum-pow at the hash lane's 8c728ca3 (the amended class v4 for AP-F8-1: the source rule keyed on the class with the shadow's pass count, the v4 unit test following the amendment; the pair for release-0.3.20-node 6a3432a3 and its fallback 6b94c823)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:51:28 +00:00
igneum-labs
b0afb70463 release-0.3.20 plan: the fallback pin 6b94c823 on the mirror
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:47:57 +00:00
igneum-labs
0f38c0e8c4 release-0.3.20 plan: the stale finality test on 8097d600, the fallback pin is a test-only commit on top
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:47:10 +00:00
igneum-labs
976820b9a2 release-0.3.20 plan: epoch 231 on the old file confirmed with the reference, the staging recipe for the floor-moved file
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:43:30 +00:00
igneum-labs
a38f7bf2ed release-0.3.20 plan: main's rulings (watchdog test meets the condition, the floor-moved file staged not published, the 13 October sweep date in the lock lines, the epoch-231 question for the report)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:41:45 +00:00
igneum-labs
c75a9fc8c0 release-0.3.20 plan: the node lane's clock estimate, how the two conditions are met, the kit's wait rule, the 13 October floor deadline
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:41:15 +00:00
igneum-labs
c0af072cbf release-0.3.20 plan: main's two conditions on the second node commit, the publish order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:40:07 +00:00
igneum-labs
8c2032265d release-0.3.20 plan: the node line at 8097d600 (what it carries), the second commit, the 15:30 UK cut rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:39:21 +00:00
igneum-labs
3b5525773f release-0.3.20 plan: the fleet's prover-roll read (12 GB provers paired and unpaid, the claim race, the fix on the node line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:36:26 +00:00
igneum-labs
dfa404b304 release-0.3.20 plan: cards_leave_off in (918cd15a), the third app gate, the reliability lane's hashes named
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:32:39 +00:00
igneum-labs
918cd15a1d Remote jobs: cards_leave_off, a run job's --cards-off cards restored as OFF on any exit (persisted by the app's own card path)
The way to hold a card out of mining past a job without a script on api/cards (the 6 October rule): the runner's hold
is built with enabled=false (identities and cap kept), the report line says LEFT OFF, publish-jobs.sh carries
--cards-leave-off. For the Arc B580 on PC 1 while its worker fix rides to the shipped app. Test:
cards_leave_off_restores_the_card_as_off_with_its_settings_kept (igneum-app 157 of 157 on the box).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9e794503d2e9689ae3a0996e703cb97ea6d95cef)
2026-10-07 11:31:55 +00:00
igneum-labs
6054cf7a5c release-0.3.20 plan: main's hold for the isSynced fix (16:00 UK checkpoint)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:29:39 +00:00
igneum-labs
85729b4f2c release-0.3.20 plan: the app gate green twice on the box, the push
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:28:48 +00:00
igneum-labs
509b838631 release-0.3.20 plan: the Arc rotr fix and the Intel app files are in (bench d numbers, the two hashes, the box tests)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:25:31 +00:00
igneum-labs
99a1268b83 Intel Arc in the app (0.3.20): vendor intel from the OpenCL vendor string and the Windows name, the INTEL badge and its token, the no-cap row naming IGCL, the measure-only tune words, the view test and the ui-mock scenario (the six app files of intel-arc 18453bb1, applied as its own diff; the test's prove sentence in this tree's wording)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:25:05 +00:00
igneum-labs
6f011e2aa3 OpenCL worker: on an Intel platform rotr_var is rewritten to the shift form before the build (the Intel rotate fold, the Arc B580 bisect of 7 October 2026)
Intel's compiler turns rotate(x, (0u - n) & 31u) into a rotate LEFT by n: lane 0's register trace on the B580 diverged
at instruction 6 of iteration 0 (rotr) and nowhere before, in both exchange modes, with every other family and the
dataset kernels bit-exact. proto-opencl/intel_rotr.h rewrites the one helper line when the device's vendor or
platform string holds Intel (host.c's buildProgram and the prepare path), no other vendor sees a change, no pack or
consensus text moves. proto-opencl/test_intel_rotr.c (the pre-push gate runs it) feeds the line through the rewrite
under Intel, AMD and NVIDIA strings and asserts the outputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 26e135a362718a68a842da59080b43e93afd9dc2)
2026-10-07 11:23:55 +00:00
igneum-labs
9847bac5d9 release-0.3.20 plan: the app side assembled (the picks, the five unions, the rebuilt signer, the box UI tests), the isSynced hold, main's Arc rotr_var order, the fleet's case timing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:19:45 +00:00
igneum-labs
1e933dccdb ember-heat: the gate reader, the PC 1 four-hour runbook, the plan and the light and dark captures
tools/heat-gate.mjs reads the HEAT lines of an app log and passes a hold within 1 degree for 4 hours with the hash
following the slice; its self-test fires on a known hold, a drift, a hash through the rest, a short log, an empty log
and a log without readings, and sits on the one gate beside heat-region.test.mjs. docs/plans/ember-heat.md carries
the words, the loop, the sources, the per-tier table and the runbook for the project lead's desk (this lane never touches PC 1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0d5fc6d258dee4774ebe7ea760736c9f05026d06)
2026-10-07 10:50:23 +00:00
igneum-labs
2bd069eb53 ember-heat (0.3.19): the region and price prompt, the heat cards and lines, the cost-against-rent row
First run gains step 2 of 3 (the region list, the price per kWh prefilled from the public table and typed, never
fetched, the restricted line for Russia's regions and China from future.md 4.4 and 8.3 with the standing sentence);
Settings gains Electricity and Heat mode; the Cards strip and every row's Ember line show the duty and the heat in
watts; a resting card, the pill and the big button say heat mode; Earnings gains the miner's cost per MH/s-hour at
their price beside the bench log's measured rental rate with the verdict. The money symbol follows the region.
heat-region.test.mjs checks the Russian entry, the bench-log rate and the words; the mock gains heat and heat-rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e529b93249)
2026-10-07 10:50:05 +00:00
igneum-labs
ac5863d49b ember-heat (0.3.19): heat mode in the engine, the loop, the rest and the release (mission item 7)
Ember holds a room temperature or a schedule and the hash follows the duty cycle: the miners run for a share of
every 10-minute period and stop for the rest (src/heat.rs, the PI loop decided once per period; engine.rs tick_heat,
heat_rest and heat_release the way a remote job holds the cards). The temperature source is a typed reading (fresh
two hours) or the coolest card's sensor after 3 minutes of rest with the cooling tail taken off by its slope; no
hardware the app does not have. Settings carry the region, the switch, the set point, the schedule with the window's
clock offset, the typed reading and the learned idle offset; state.heat carries the phase, the duty, the watts and
the one line; POST /api/heat and /api/region. One HEAT line in the log every 30 s for the gate reader. 8 tests with
a model room: a typed reading and the card sensor alone each hold within a degree for four hours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 345ab910d0)
2026-10-07 10:49:50 +00:00
igneum-labs
503cc1004a ui-ota: the publish order rule in the plan (1.0.1 with min_engine 0.3.20 only after 0.3.20 is on the manifest)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ee09ae8b633206c8a7ab438eb4b0ee57362de000)
2026-10-07 10:44:51 +00:00
igneum-labs
f80359635b ui-ota: the publisher (tools/ui-ota/publish.mjs), publish-manifest.sh --ui and --no-ui, the plan with the security notes
publish.mjs packs the fifteen served files with one fixed mtime (reproducible; the self-test checks it), hashes, signs
the entry through igneum-ota-sign sign-ui with the key in ~/.config/igneum (never read or printed here), copies the
bundle into the folder's ui/ and hands ui.json to publish-manifest.sh --ui, the one writer of the signed manifest,
which verifies the entry and the bundle's hash before signing; --dry-run writes nothing, --verify reads the live
manifest back against dl/<token>/ui and dl/public/ui; --no-ui withdraws the channel. Both self-tests sit on the one
gate. docs/plans/ui-ota.md: the shape, the engine, the security notes, the operator recipe, the tests, per tier.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a1d6eecb33)
2026-10-07 10:44:51 +00:00
igneum-labs
f44698ced8 ui-ota (0.3.20): Settings > Interface, the health ping, the first-paint error and the gentle reload
"Interface <version>, over the air, <date>" or "built in", with the help line for pending, refused, held back and
skipped; the "Use the built-in interface" switch (POST /api/ui/builtin). The page posts /api/ui/health once after its
first paint, or the first script error it catches before that; when the served interface changes under it, the page
reloads itself only when idle (no input focused, no sheet or update card open, on the dashboard). ui-ota.test.mjs
covers the words, the reload rule, the ping and that the page loads no remote script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 36306fe901)
2026-10-07 10:43:52 +00:00
igneum-labs
35342ad654 ui-ota (0.3.20): the manifest's interface channel and the engine that swaps a signed dashboard bundle in
The signed manifest gains a "ui" object {version, sha256, size, url, min_engine, signature}; the entry's own Ed25519
signature (the release key, manifest::ui_sign_bytes) and the manifest's cover it. src/uiota.rs: the hourly check hands
the entry over; a bundle for this engine is downloaded, checked (size, sha256, signature), unpacked next to the current
one and swapped by an atomic pointer; the server serves the bundle's fixed file names in place of the embedded ones;
the first page load starts a 10 s wait for the page's health ping, and silence, a first-paint error, a missing
index.html or a renamed bundle rolls back to the embedded interface and marks the version bad for good. No "ui"
object retires the bundle (the kill switch). settings.ui_builtin and state.ui carry Settings > Interface.
igneum-ota-sign gains sign-ui and verify-ui. Tests: a good bundle applies, a bad signature is refused, a too-new
min_engine is ignored, a broken bundle rolls back, every bad manifest field fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fb029bcb64)
2026-10-07 10:43:51 +00:00
igneum-labs
dc7d24f448 miner-ui-5: u15 and u16, the chain card at 390 on live-dag.js 2.0.2 (narrow), dark and light
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a2292f8c99)
2026-10-07 10:43:51 +00:00
igneum-labs
757d4693d5 miner-ui-5: live-dag.js 2.0.2 (master 86113b40, byte-identical), fps 60 and narrow on the phone-width card, four captures reshot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 6ba028f725)
2026-10-07 10:43:51 +00:00
igneum-labs
08ec90d96d release 0.3.20 plan: the one-off cards exception on PC 1; the signed cards job kind for 0.3.20
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:42:43 +00:00
igneum-labs
348aeac3fc release 0.3.20 plan: PC 1's template path, the two app faults, the identity finding withdrawn, the weight-table cache
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:42:30 +00:00
igneum-labs
5c3499d395 release 0.3.20 plan: main's call on miner-reliability
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:37:27 +00:00
igneum-labs
86ec04d5b2 release 0.3.20 plan: the canary synced with the IBD-end line; ui-ota and ember-heat taken; the prover identity rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:36:19 +00:00
igneum-labs
0919e40aca release 0.3.20 plan: the listener watchdog, igneum-pow 8c728ca3, the observer methods
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:18:43 +00:00
igneum-labs
2e46c737de release 0.3.20 plan: the tree as of 10:2xZ (node side to come, ember-heat and ui-ota, N10, the Mac rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:17:12 +00:00
igneum-labs
161a68a748 ledger N7: the second caller (the prover loop, rpc.rs:808) and the whole-class gate; the macOS listener shape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 10:16:16 +00:00
igneum-labs
43aa5fa651 0.3.20: this feature tree renumbered (0.3.19 is the app-only miner-ui-5 cut); plan moved to release-0.3.20.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:35:42 +00:00
igneum-labs
39d9edd67c prover loop: no records-indexing poll until the node's exec follower holds a record (ledger N7, PC 1 on 0.3.18)
The 0.3.18 clock-sample gate stopped one caller; the prover's first igneum_getAssignedShards after the node reads synced killed PC 1's 0.3.17 node the same way (rpc.rs:808, records[1..=0] on an empty vector) because its follower loads after the sync flag. The loop now waits on igneum_getExecStatus's executedTipHash, the same gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:34:08 +00:00
igneum-labs
3d43618098 release 0.3.19 plan: miner-ui-5 is the app (gate green); the prover pair handed over and verified, the CI check
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:26:56 +00:00
igneum-labs
046be60406 release 0.3.19 plan: the M20 witness test red under the igneum-pow feature (coverage note)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:12:49 +00:00
igneum-labs
257922f5de miner-ui-5: u04 and u14 reshot on live-dag.js 2.0.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 768694d22c)
2026-10-07 09:12:43 +00:00
igneum-labs
d450629b51 miner-ui-5: live-dag.js 2.0.1 (the site lane's real-data options, byte-identical), window 60 on both scenes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 99e474ea27)
2026-10-07 09:12:43 +00:00
igneum-labs
2e3575f0d1 miner-ui-5: the plan, the captures, the first-share runbook for the shipper, the Discord rung shapes (not posted)
docs/plans/miner-ui-5.md (what is built, gate results, owed, the RPC fields the node does not expose yet, what the
site lane takes); docs/plans/miner-ui-5-shots/ (light and dark at 1440 and 390, the saved block card PNG);
docs/plans/miner-ui-5-first-share-runbook.md; docs/community/discord/ladder.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit edb5b440d7)
2026-10-07 09:12:43 +00:00
igneum-labs
4c20047632 miner-ui-5 (0.3.19): the ladder on every page, the count-up, the block card, Earnings in IGN first, EMBER 02 scene
View: poisson, ignPerDay, ladderRungs (every threshold from the node's params: dust, weightWindow, presenceWindow),
blockCard, earningsLines (IGN first, the typed price never fetched, the share and rank, IGN per kWh), cardIgnDay,
timeline, profileWords; FIELDS names every number's RPC field for the title on hover. Overview: the Poisson
count-up until the first block, the block card in place with Save the card (a canvas PNG, no network call), Copy the
link and the explorer, the ladder strip. Earnings: the six lines, the ladder card, Your first hour. Prove: the shard
card. Cards: IGN a day per card. Settings: Make my page public. The chain scene is the site lane's EMBER 02 pack
(live-dag.js and proof-core.js byte-identical): the compact card fed by the page's own api/live read, Inspect opens
the full scene with the block inspector in the site's words. ui-mock: scenarios firstwait, firstblock, ladder,
api/ladder, api/card. view.test.mjs: a Devnet 2 key walks every rung; 47 UI tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 552c35ed66)
2026-10-07 09:12:43 +00:00
igneum-labs
7af60bb8ee miner-ui-5 (0.3.19): the ladder's engine half: ladder.json, api/ladder chain facts, the block card's PNG
src/ladder.rs: this machine's record (first block by card, hash joined to the miner's ACCEPTED line by nonce from the
node's PoW accepted line, blocks per UTC day, VOTE and LOCK lines as the signing record and streak, paid shards, the
milestone at 1, 100, 1,000, every 10,000th and the first block of a new card, the first-hour marks); persisted, in
api/state.ladder. src/chainfacts.rs: GET /api/ladder, getFinalityWeights through the node's EVM port when it answers
igneum_getFinalityWeights (owed), else the observer's relay plus /api/stats; this machine's keys ranked; the source
named. src/card.rs and POST /api/card: the page's 1200x630 PNG written under <data root>/cards/ and revealed.
settings.profile_public behind api/settings. Hooks in engine.rs (block, node line, vote, lock, synced, mining) and
prover.rs (paid shard). Box: 190 + 27 + 8 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit aed171cdd1)
2026-10-07 09:12:43 +00:00
igneum-labs
b94dc0833b CI: the prover pair is built from the pinned node's exec types (prover-pair-check, in pre-push)
The fleet's 14 standing provers cut a different state root from the 0.3.17 node on every segment because their pair came from another tree (7 October 2026). The prover depends on vendor/igneum-node-exec's evm-types; this check compares that tree with the evm-types tree of the commit in packaging/windows/node-source.pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:10:31 +00:00
igneum-labs
2095164df9 release 0.3.19 plan: aea0ca5c (the proof archive) past the pin; the pin stays at dc141409
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 09:05:17 +00:00
igneum-labs
2f7ae702fb release 0.3.19 plan: the signing step's names
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:57:32 +00:00
igneum-labs
bc719076f4 release 0.3.19 plan: owed to 0.4.0 (the signing step, income tiers)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:57:10 +00:00
igneum-labs
e86fd6e00a release 0.3.19 plan: the canary on dc141409 started, its clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:52:23 +00:00
igneum-labs
ea3d1004f3 release 0.3.19 plan: the native build on dc141409, canary GO
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:51:37 +00:00
igneum-labs
a16bab2ad8 release 0.3.19 plan: the dc141409 pairs held
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:50:51 +00:00
igneum-labs
4a92e3350e release 0.3.19 plan: the pin dc141409 (the oracle switch), chains restarted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:38:00 +00:00
igneum-labs
3f18dcfc68 release 0.3.19 plan: the exec lane's agreement on the oracle switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:37:21 +00:00
igneum-labs
93d1de3801 release 0.3.19 plan: miner-ui-4 733f5124 taken (the N7 gate carried)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:35:40 +00:00
igneum-labs
e8ea778df4 miner-ui-4: carry the clock sample's exec-record gate (d3453190, ledger N7); version words follow the 0.3.19 renumbering
Only update.rs from d3453190; its version bumps stay with the shipper's cut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 733f51241b)
2026-10-07 08:33:27 +00:00
igneum-labs
d3b4a2d843 miner-ui-4: light 390 px Overview and Cards rendered from PC 2's live state for the site
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 823b60ba5b)
2026-10-07 08:33:27 +00:00
igneum-labs
b92c3e3b7e release 0.3.19 plan: the block-stage hold and the oracle-switch fix; ledger N8 for the devnet height
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:32:13 +00:00
igneum-labs
8d6a33f293 0.3.19: this tree renumbered again (0.3.18 is the app-only N7 cut); plan moved to release-0.3.19.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:28:05 +00:00
igneum-labs
03e7cef20e ledger N7, 0.3.18 plan: main's publish rule (synced line plus the poller summary; PC 1 first)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:54:12 +00:00
igneum-labs
ba2625b163 ledger N7, 0.3.18 plan: the real sender is the engine's 9-second clock sample (update.rs latest_block_time); a fresh 0.3.17 install with the app crash-loops in IBD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:53:02 +00:00
igneum-labs
035f228033 release 0.3.18 plan, ledger N7: PC 1's loop is the N7 class through igneum-miner export-pack
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:51:16 +00:00
igneum-labs
ed68c80953 release 0.3.18 plan: rung 3 re-measured (inadmissible, 10.85 ms loaded)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:46:36 +00:00
igneum-labs
cd2101beba release 0.3.18 plan: PC 1 and PC 2 after the reopen (PC 2 mining on 0.3.17; PC 1's node in a restart loop)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:38:13 +00:00
igneum-labs
e34d3fea4e release 0.3.18 plan: the pin's canary past the guard mark, the poller alive
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:21:24 +00:00
igneum-labs
edad28b7b9 release 0.3.18 plan: fd7de1b4 is 0.3.19 (main's word)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:12:48 +00:00
igneum-labs
6be913016f release 0.3.18 plan: fd7de1b4 recorded for 0.3.19 (not the pin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:12:03 +00:00
igneum-labs
c3f8b68abc release 0.3.18 plan: staged at the line on e69e8a39
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:11:53 +00:00
igneum-labs
4828c19fe7 release 0.3.18 plan: the suite line on the pin
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:09:21 +00:00
igneum-labs
c36b49c4ed release 0.3.18 plan: builds on the pin e69e8a39, digests, the N7 read, the scratch loss
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:08:31 +00:00
igneum-labs
29a7dca9ff 0.3.18: the node pin at e69e8a39 (the exec RPC bounds-check and template timers on ae17ad00)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:04:04 +00:00
igneum-labs
a4a4d509f8 release 0.3.18 plan: the cut at e69e8a39; ledger N7 carries the fix hash c6a62e00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:56:04 +00:00
igneum-labs
85acbe0d57 ledger N7: the method map (the shipped app never sends the panic class before synced); 0.3.18 plan row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:54:45 +00:00
igneum-labs
5865d46e74 public RPC filter: the five igneum_* records-indexing reads join the block until the fixed node
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:54:20 +00:00
igneum-labs
f9f7206621 public RPC filter: the records-indexing methods refused until the fixed node (ledger N7); testnet go note
One block-tagged request kills a node whose exec follower has no record yet (rpc.rs indexes records[0] on an empty vector; the panic hook exits). Live on seed1's filter from 7 October 2026 06:5xZ.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:48:31 +00:00
igneum-labs
f115b8f249 release 0.3.18 plan: HOLD for the exec RPC panic fix (third node tip)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:43:57 +00:00
igneum-labs
288f600306 release 0.3.18 plan: canary past the guard mark, the warning flood gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:36:05 +00:00
igneum-labs
7950250db9 release 0.3.18 plan: the hands' and seed's pairs held
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:31:29 +00:00
igneum-labs
3f3e6f66bb release 0.3.18 plan: staged at the line (installer, scratch manifest, runbook, card)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:29:07 +00:00
igneum-labs
e448c30c19 release 0.3.18 plan: seed and hive pairs, the hive package smoke, the integration check
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:23:21 +00:00
igneum-labs
2565c1b6bd release 0.3.18 plan: builds on ae17ad00, inputs, the Windows run
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:20:55 +00:00
igneum-labs
1d7f5d89fc 0.3.18: the node pin at ae17ad00 (12153428 plus ca3-v4-0318, two merges)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:19:56 +00:00
igneum-labs
048b50097b release 0.3.18 plan: the canary on ae17ad00 started, its clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:17:04 +00:00
igneum-labs
f01a68218b release 0.3.18 plan: the cut at ae17ad00, chains restarted, the two canary reads for PC 1's rules
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:15:13 +00:00
igneum-labs
871e2f9107 release 0.3.18 plan: the no-file digest read
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:14:28 +00:00
igneum-labs
75fa575636 release 0.3.18 plan: builds on 1cf43254, digests, the blockrate read
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:14:21 +00:00
igneum-labs
0be664bca9 release 0.3.18 plan: main's word on the chain start and the PC 1 rules' cut-off
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:10:38 +00:00
igneum-labs
33a2ca12ee release 0.3.18 plan: the node tree 1cf43254; chains started
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:10:22 +00:00
igneum-labs
f7ad8c994c release 0.3.18 plan: the installing tests move to their own target; the suite runs kaspa-consensus without --lib
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:04:37 +00:00
igneum-labs
25e210dd8d release 0.3.18 plan: the node merge resolved (node lane), the test-order race and its lock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 06:03:26 +00:00
igneum-labs
88fcfebc2f release 0.3.18 plan: the UI lane's note on the merge view's window clamp
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:49:08 +00:00
igneum-labs
334670185c release 0.3.18 plan: the app tree assembled (24f45458, tests green)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:48:13 +00:00
igneum-labs
24f45458ef miner-ui-4 on release-0.3.18: the merge view's live::fetch call takes the engine's Shared (the four-argument form 66bf1ee3 gave it; 044d2ac8 was written against the three-argument one)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:48:00 +00:00
igneum-labs
4f9798283d miner-ui-4 (0.3.18): a miner whose template fetches time out is not faulted on silence
PC 1's read-back: the node reported synced while its finality replay blocked the template RPC for three minutes; the miners printed only timeouts and the watchdog faulted every card. The timeout line is the miner's heartbeat: silence clocks start over from it, the card says it waits for templates, one Activity line per episode. Recorded sequence as the test; 173 box tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 785d8be6dd)
2026-10-07 05:47:28 +00:00
igneum-labs
dce549c805 Ember shipped-app window playbook: the gate at 0.3.18 (0.3.17 is a node-only hotfix on the 0.3.16 app tree; the helper fixes A to F ship in 0.3.18)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c9b31edc71d7f92628c83022ddf07aa288c55279)
2026-10-07 05:46:50 +00:00
igneum-labs
f2eefc4805 miner-ui-4 (0.3.18): workers come back after an OTA relaunch
The card watchdog judges a miner only while the node is synced; a silence fault from the node's sync is released at the synced transition and the card starts again with an Activity line; a worker silent from its start is faulted at 60 s; one Activity line per card at its first start. Known-failed test from PC 1's 04:51Z relaunch; 173 box tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7ece07ffd9)
2026-10-07 05:46:50 +00:00
igneum-labs
617b053739 miner-ui-4: note the node's blockrate object (6e4ace3f) behind the merge depth read
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ea533541c8)
2026-10-07 05:46:50 +00:00
igneum-labs
4e2465b730 miner-ui-4: the 0.3.18 Mac check as decided (own node after the apply, mode own, digest from RPC, synced; miner paused)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7065c4cb8c)
2026-10-07 05:46:50 +00:00
igneum-labs
0be82b5977 miner-ui-4 (0.3.18): the merge depth reads blockrate.mergeDepth, not params; 3,600 stands until the node carries it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 3bc0450b3a)
2026-10-07 05:46:50 +00:00
igneum-labs
b64f6fdf4b miner-ui-4: version words follow the renumbering (0.3.17 is a node-only hotfix; this branch ships as 0.3.18)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7c0c1f4216)
2026-10-07 05:46:50 +00:00
igneum-labs
796aea66a6 miner-ui-4 (0.3.18): a node whose blocks never merge reads behind and holds the miner
src/merge.rs, pure: every network sink more than the merge depth ahead and none of our blocks in the network's view for 120 s. Engine polls the observer's view every 30 s while mining and runs the check after sync_decision_v2; state behind, sync_cause not merging, one error event. Known-failed test first; 172 box tests, 42 UI tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 044d2ac816)
2026-10-07 05:46:50 +00:00
igneum-labs
47becba051 release 0.3.18 plan: the node tree is a merge (six conflicts, the node lane's); PC 1's template-timeout row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:45:24 +00:00
igneum-labs
42c559d804 release 0.3.18 plan: the inputs as of 05:2xZ (node 8220c944 with suites, app list with 7ece07ff)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:24:48 +00:00
igneum-labs
7d840da749 release 0.3.18 plan: ca3-v4-0318 aa49613f (the IBD-end timing line)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:06:24 +00:00
igneum-labs
022d4dd19a release 0.3.18 plan: deadline withdrawn, ca3-v4-0318 e3798a17, the cost fix's before figure by route (b)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:05:39 +00:00
igneum-labs
8c0511b3ee release 0.3.18 plan: the young-window fault does not touch the devnet (correction); testnet go note softened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:04:22 +00:00
igneum-labs
24ffd43fd1 release 0.3.18 plan: main's rule (live before 13:30 UK, the pruning fix aboard); the testnet go line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:00:49 +00:00
igneum-labs
178da95678 release 0.3.18 plan: the dated pruning-point fresh-join fault and its canary timing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 05:00:28 +00:00
igneum-labs
237fd6259f release 0.3.18 plan: the node tree is ca3-v4-0318 6e4ace3f (main's order)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:54:17 +00:00
igneum-labs
31bbe6034b release 0.3.18 plan: the fix tips on both trees and the follow-up rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:49:53 +00:00
igneum-labs
63a1be803a release 0.3.18 plan: the cut after the hotfix (4f4bb9c9, the UI list, the canary form)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:43:54 +00:00
igneum-labs
aa350fea30 0.3.18: this tree renumbered (tonight's 0.3.17 is the node-only hotfix); plan moved to release-0.3.18.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:36:46 +00:00
igneum-labs
dbe641d1e5 release 0.3.17 plan: the 0.3.18 list (miner-ui-4 044d2ac8, the window test, the pairing log)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:16:47 +00:00
igneum-labs
7abdcfd490 release 0.3.17 plan: two-daemon test green on the pin, the igneum-pow pairing rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:02:13 +00:00
igneum-labs
460f2cef24 release 0.3.17 plan: 2f, ready at the line (staged manifest, runbook, fallback tree, card dry run)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:53:38 +00:00
igneum-labs
dbafc5d005 release 0.3.17 plan: 2e, the rebuild on the IBD-guard fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:44:05 +00:00
igneum-labs
4e0ef8a590 hive package: no AppleDouble entries in the tar (COPYFILE_DISABLE, no mac metadata)
GNU tar on HiveOS materialised the Mac's extended headers as ._ files next to every binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:43:07 +00:00
igneum-labs
214df6a044 0.3.17: the node pin at 12153428 (the IBD-guard fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:38:02 +00:00
igneum-labs
5ebf7153dd Ledger N6: a fresh node could not join the devnet while the signal window was open (the IBD guard; live since publish 2; the fix rides 0.3.17)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:55:31 +00:00
igneum-labs
5912b7dbff Plan 0.3.17: the canary's FAIL (the IBD guard refuses signal-version relay blocks; live since publish 2), the fix and the checklist line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:55:00 +00:00
igneum-labs
9c37e201a1 rust-toolchain.toml: the one pin (1.99.0) from master, so master's build tools run against this tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:36:28 +00:00
igneum-labs
877d85eff5 Plan 0.3.17: the kill-by-name row (my pkill of build-remote.sh killed another lane's run)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:35:28 +00:00
igneum-labs
a349ebad11 Plan 0.3.17: the testing-integration check as a checklist line for every cut; 0.3.18 node notes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:01:15 +00:00
igneum-labs
a0bf6c92c9 packaged-config: the packaged object is the live sixteen-field one (a fresh 0.3.17 install peers at once; the thirteen-field object would be refused until the first manifest read)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:33:41 +00:00
igneum-labs
a12f7ce827 0.3.17: the node pin at b49c58c1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:32:39 +00:00
igneum-labs
84b8e3c806 Plan 0.3.17: 812c3ac2 (the silence read fixed, replay gate green) rides 0.3.18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:31:31 +00:00
igneum-labs
7fe0245e0f 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
cf4894b0f9 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
281ddc3293 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
82a80c8204 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
885242af90 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
5cae1f5492 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
77752dedf4 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
0d4415d8d6 Merge miner-ui-4 bb6f06e2 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
bb6f06e2d1 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
f6280819f7 Merge miner-ui-4 64aae046 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
64aae04618 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
cf07c9280e Merge commit 'fef1a9db' into release-0.3.17
# Conflicts:
#	docs/bench-log.md
2026-10-06 23:20:19 +00:00
igneum-labs
fef1a9dbef 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 6842ae8b39)
2026-10-06 23:19:24 +00:00
igneum-labs
2c8376c288 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 6831a179ed)
2026-10-06 23:19:24 +00:00
igneum-labs
8586e53dbb 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 f00ccff34a)
2026-10-06 23:19:08 +00:00
igneum-labs
d902b64031 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 d495976610)
2026-10-06 23:19:08 +00:00
igneum-labs
33c395d115 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 35c75bfba8)
2026-10-06 23:19:08 +00:00
igneum-labs
007e8a22a7 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 f2ccb6ab73)
2026-10-06 23:19:08 +00:00
igneum-labs
7248b35f26 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 b85c0359b1)
2026-10-06 23:19:08 +00:00
igneum-labs
92bb9f1623 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 aee92a186e)
2026-10-06 23:18:34 +00:00
igneum-labs
c15c5c19a5 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 10d42805d7)
2026-10-06 23:18:16 +00:00
igneum-labs
66bf1ee3f9 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 9a7ce12078)
2026-10-06 23:16:29 +00:00
igneum-labs
0bc16fd952 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
a3062dce61 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
0e98ac18e7 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
d4269a1f4c 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
822a6613f5 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
ef9ee0ea78 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
2c0b9e8be7 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
fbf009c53f 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
4b592c4b72 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
6ee27d93d0 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
3206420205 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
6eafe9c739 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
ab0da29c12 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
80b1ed7de7 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
69d8e14fb0 miner-ui-4: pinned to the 0.3.16 engine fields (ember-tune 10d4280): 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
ccd53b5a61 Merge commit '39f51fa' into release-0.3.17 2026-10-06 23:13:57 +00:00
igneum-labs
f6e4e2cfd8 Ember plan: 0.3.15 took ember-tune at 5b66a49 (merge 7b0dc0b); card.tune_floor (2dbb3df) rides the next cut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 6649a23750)
2026-10-06 23:13:48 +00:00
igneum-labs
e418fe189d 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 2dbb3df3c2)
2026-10-06 23:13:47 +00:00
igneum-labs
d12150f8e9 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
82c0fc5028 Merge commit 'b982f41' into release-0.3.17 2026-10-06 23:12:13 +00:00
igneum-labs
0d4c6a0d1b Merge commit '6478040' into release-0.3.17
# Conflicts:
#	.github/workflows/ci.yml
2026-10-06 23:10:56 +00:00
igneum-labs
e227d2c5d9 Merge commit '3baeadc' into release-0.3.17
# Conflicts:
#	docs/bench-log.md
2026-10-06 23:10:27 +00:00
igneum-labs
10eb26a389 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
40d150c444 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
b982f41479 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
67d279baf1 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
d525a94d6e 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
d187f128f7 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
39f51faa2d 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
2370d57c65 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
0406b457d0 Plan: the 0.3.17 merge rule names ember-tune 6831a17 (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
2481dd2c44 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
b9c6392a4f 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
a3a4d27661 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
67f56a299f 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
fec51e6218 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
40fb68a7a8 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
2b1f67c626 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
41675e4585 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
3c118196e3 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
cd54712255 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
88af035f88 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
7b1dc96548 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
ab7c301df5 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
d87a41fa00 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
476ce8c907 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
2149fcf753 Plan 0.3.15: the 0.3.16 merge rule names ember-tune 35c75bf 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
371c685298 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
744a368bfe 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
87d99cd441 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
3baeadc51c 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
751cac52da 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
b03ea852d5 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 c649036) 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
8bdd0ea015 Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 20:57:42 +00:00
igneum-labs
3d0a6d0f83 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
dc51191da6 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
1a60e9f73f 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
222a928226 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
ea5bd0a50b 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
f01d0f21e2 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
87b330e873 Plan 0.3.15: the 0.3.16 merge rule names ember-tune f2ccb6a (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
c6b02fd06d 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
43a1af87a8 Plan 0.3.15: owed rows (6478040 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
196e296fb2 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
3d2c39e4ad 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
af1d51ca0a 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
8e740b40a8 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
5581d2ac83 Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 20:14:55 +00:00
igneum-labs
15cef4c25e 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
c0582f0c30 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
3f6f5034a1 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
6fbfc4ea1c 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
042a9c78e6 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
52aec4ff6f 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
22ca08a554 miner-ui-4 872f1c9: ui/live-dag.js as site-ui-3 2a12fb5 (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
19b756f9a2 Plan 0.3.15: the DMG row (3206edb build)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:46:55 +00:00
igneum-labs
7dc14ff48d 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
1732548d1d Merge miner-ui-4 3206edb 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
3206edb5a5 miner-ui-4: live-dag.js from site-ui-3 582f1af (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
c57de0680b 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
e7145e42ba 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
9f031c18ab 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
4d738bb36b 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
b17e2802a5 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
455cd1f66a Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 19:20:52 +00:00
igneum-labs
e3e267c6c0 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
d6964895ec 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
a8604e53d3 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
90177550fb 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
a3f31ea072 Merge remote-tracking branch 'origin/master' into release-0.3.15
# Conflicts:
#	.gitignore
2026-10-06 19:15:08 +00:00
igneum-labs
09524a4071 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
30c124c8cb Merge miner-ui-4 6792bc0 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 4710d7b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:02:17 +00:00
igneum-labs
6792bc0192 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
0c2e2ae211 Merge remote-tracking branch 'origin/master' into release-0.3.15 2026-10-06 18:59:50 +00:00
igneum-labs
600fc51874 Merge miner-ui-4 4710d7b 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
4710d7b4bd 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
4a741c79ac 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
85b5847126 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
354ba3f6f4 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
3b54f24f18 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
7b0dc0b9f9 Merge commit '5b66a49' into release-0.3.15 2026-10-06 18:45:15 +00:00
igneum-labs
5b66a497dd 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
fb85155386 Merge ember-tune d3c995f 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
3fe004342f 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
d3c995fe30 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
843a221f93 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
96e768699d Merge exec-app-0314 5ffd3e7 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
510bd10528 Merge miner-ui-3 1162680 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
151da01e57 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
8af1d9a3d4 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
5ffd3e75ae 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
11626807de 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
ec2501f6a4 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
f8322bcbaa 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
40528b4cb5 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
134a638d33 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
b603e0ef0f 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 (fb07429) 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
26cf93f6cd 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
31084f998b 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
64780403e1 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
552 changed files with 72620 additions and 20853 deletions

View file

@ -216,11 +216,13 @@ jobs:
run: |
$payload = Resolve-Path 'packaging\windows\igneum-windows-app'
function Run-Capture([string]$exe, [string]$flag) {
$out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt')
$p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out
$text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' }
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim())
if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" }
# a direct call, not Start-Process -Wait -PassThru: a program that prints and exits at once raced the cmdlet
# ("Cannot process request because the process has exited", 7 October 2026, the 0.3.21 run) and the read-back
# was lost; the host program is a windows-subsystem exe, so its text comes through the same pipe
$text = (& $exe $flag 2>&1 | Out-String)
$code = $LASTEXITCODE
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $code, $text.Trim())
if ($code -ne 0) { throw "$exe $flag exited $code" }
return $text
}
$v = $env:APP_VERSION

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 = "2.0.2"
dependencies = [
"ed25519-dalek",
"getrandom",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-app"
version = "0.3.14"
version = "2.0.2"
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"
@ -22,6 +22,12 @@ path = "src/bin/ota-sign.rs"
name = "igneum-prove-verify"
path = "src/bin/prove-verify.rs"
[features]
# review B F14 (8 October 2026): the lab build runs our fleet's remote jobs under a separate signing root and takes the
# manifest's consensus override; the public miner (the default) has no code path that runs a job. Built with
# IGNEUM_LAB_PUBLIC_KEY=<hex of ~/.config/igneum/lab-signing/key.pub> cargo build --features lab
lab = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

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

View file

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

349
app/igneum-app/src/boot.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -39,11 +39,43 @@ 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,
/// Ember Tune tiers (8 October 2026): this card's tier in force and when it was applied
#[serde(default)]
pub tier: String,
#[serde(default)]
pub tier_at: u64,
/// tiers-table-26: the card's measured tiers, kept across restarts (the table seeds a card with none)
#[serde(default)]
pub tiers: Vec<serde_json::Value>,
/// the program class the stored tiers were measured under ("" = none)
#[serde(default)]
pub tiers_class: 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,
}
/// One first-block card shown: the block's hash (empty until the node's line names it) and the unix time.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct FirstBlockMark {
#[serde(default)]
pub hash: String,
#[serde(default)]
pub at: f64,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default)]
@ -98,10 +130,42 @@ pub struct Settings {
/// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders).
#[serde(default = "balanced")]
pub tune_goal: String,
/// Ember Tune tiers: the fleet tier (efficiency | balanced | max); balanced from install
#[serde(default = "default_tier")]
pub tune_tier: String,
#[serde(default)]
pub power_price_pence: f64,
#[serde(default)]
pub tune_climb: bool,
/// Ember Heat (mission item 7, 7 October 2026; src/heat.rs): the region code the miner chose at first run or in
/// Settings ("" = not chosen; the price above is in that region's minor unit per kWh, typed, never fetched), the
/// heat-mode switch, the set point in degrees, the schedule (slots by minute of the day in the window's clock,
/// `heat_tz_min` minutes east of UTC), the typed room reading and when it was typed (0 = none), and the learned
/// idle offset of the card sensor above the room (0 = the default).
#[serde(default)]
pub region: String,
/// the network step (0.3.23): the chain this machine runs, chosen at first run or in Settings ("devnet-3" |
/// "testnet-1"; "" = not chosen, the package's own network). Read at start; a change takes effect at the next start.
#[serde(default)]
pub network: String,
/// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which
/// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
#[serde(default)]
pub currency: String,
#[serde(default)]
pub heat_on: bool,
#[serde(default = "nineteen")]
pub heat_set_c: f64,
#[serde(default)]
pub heat_schedule: Vec<crate::heat::Slot>,
#[serde(default)]
pub heat_tz_min: i32,
#[serde(default)]
pub heat_room_c: f64,
#[serde(default)]
pub heat_room_at: f64,
#[serde(default)]
pub heat_offset_c: f64,
/// When this install first ran (unix s), for the "first hour after install" sweep.
#[serde(default)]
pub installed_at: u64,
@ -112,6 +176,11 @@ pub struct Settings {
/// Lifetime dev-fee blocks this machine found (the miner's `dev-fee block` lines), carried across runs.
#[serde(default)]
pub fee_total: u64,
/// The first-block celebration, once per payout address, ever (the founder, 8 October 2026, v2.0.1): the lower-case
/// address a window showed the card for, with the block's hash and time. Kept in settings.json so it survives runs,
/// updates and a reinstall that keeps the wallet; a new address shows it once again; a dev-fee block never raises it.
#[serde(default)]
pub first_block_shown: HashMap<String, FirstBlockMark>,
/// Devnet only: when no verifier is found next to the engine, start the node with `IGNEUM_PROOF_VERIFY=trust`
/// so it includes proof records it never verified (src/verifier.rs). Default off; a found verifier always wins.
#[serde(default)]
@ -120,6 +189,18 @@ pub struct Settings {
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
#[serde(default)]
pub prove_default_applied: bool,
/// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
#[serde(default)]
pub profile_public: bool,
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
/// over-the-air interface bundle is active. Default off.
#[serde(default)]
pub ui_builtin: bool,
/// Prove instead of mining on a machine whose only NVIDIA card is under 12 GB (main's routing, 7 October 2026: a
/// 10 GB card holds the prover or the miner, never both). Off by default; the 12 GB refusal stays while it is off.
#[serde(default)]
pub prove_instead: bool,
}
fn one() -> u32 {
@ -128,13 +209,46 @@ fn one() -> u32 {
fn balanced() -> String {
"balanced".into()
}
fn nineteen() -> f64 {
19.0
}
/// The network step's rule (0.3.23): a switch is refused when the network is unknown, when it names the testnet while the
/// manifest has not opened it, and when the engine runs another network and the user has not confirmed what resets.
pub fn network_switch(want: &str, running: &str, testnet_open: bool, confirmed: bool) -> Result<(), String> {
if !["devnet-4", "devnet-3", "testnet-1"].contains(&want) {
return Err(format!("'{want}' is not a network this app knows"));
}
if want == "testnet-1" {
let _ = testnet_open;
return Err("the test network is no longer offered: Igneum 2.0 runs one devnet".into());
}
if network_name(want) != running && !confirmed {
return Err("switching the network needs the confirm: the node's data and the mining state reset at the next start".into());
}
Ok(())
}
/// The chain's name for a choice ("" = the package's own, read as Devnet 3 from 0.3.22).
pub fn network_name(choice: &str) -> &'static str {
match choice {
"testnet-1" => "igneum-testnet-1",
_ => "igneum-devnet-4",
}
}
/// The seeds of igneum-testnet-1 (docs/plans/testnet-go.md: p2p 26811, chain id 4462). The node compiles no DNS seeders for
/// the testnet (the node lane, 7 October 2026), so the engine passes these as --addpeer.
pub const TESTNET_SEEDS: [&str; 3] = ["seed1.testnet.igneum.network:26811", "seed2.testnet.igneum.network:26811", "seed3.testnet.igneum.network:26811"];
fn yes() -> bool {
true
}
fn default_tier() -> String {
"balanced".into()
}
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), tune_tier: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, first_block_shown: HashMap::new(), proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
}
}
@ -162,8 +276,21 @@ impl Settings {
}
pub fn load(path: &Path) -> Settings {
let fresh = !path.exists();
let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
let mut dirty = false;
// F14 (the founder, 8 October 2026): automatic updates are a choice at install. The Windows installer's task and
// the Mac setup's first step write install-choices.json next to this file; a fresh install takes it once.
if fresh {
if let Some(dir) = path.parent() {
if let Some(choices) = install_choices(&dir.join("install-choices.json")) {
if let Some(v) = choices.get("auto_update").and_then(|v| v.as_bool()) {
s.auto_update = v;
dirty = true;
}
}
}
}
if s.installed_at == 0 {
// an install from before the sweep existed counts as installed now: it gets its first-hour sweep
s.installed_at = crate::platform::unix_now();
@ -190,6 +317,12 @@ impl Settings {
}
}
/// The installer's choices (F14): a small JSON object the Windows installer writes when a task is unchecked and the
/// Mac setup writes from its first step; `{"auto_update": false}` today. None when absent or unreadable.
pub fn install_choices(path: &Path) -> Option<serde_json::Value> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
/// Reads the machine id, or makes one on the first run (16 hex from the OS) and locks the file to the user.
fn machine_id(app_dir: &Path) -> String {
let path = app_dir.join("machine-id");
@ -211,6 +344,14 @@ fn machine_id(app_dir: &Path) -> String {
/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature.
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct Packaged {
/// PRODUCT=public|lab (packaging/mac/packaged-config.sh, 8 October 2026): the edition the package was made for, its
/// channel and the signing root its engine must carry; empty in a package from before the switch.
#[serde(default)]
pub edition: String,
#[serde(default)]
pub channel: String,
#[serde(default)]
pub ota_root_hex: String,
#[serde(default)]
pub update_manifest: String,
#[serde(default)]
@ -226,6 +367,14 @@ pub struct Packaged {
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
#[serde(default)]
pub node_override_params: Option<serde_json::Value>,
/// The network the package's node joins (7 October 2026, Devnet 3): a suffixed devnet (`--devnet-suffix=N`), its own
/// genesis and p2p port, its own node datadir devnet-N beside the shared devnet's devnet-v4 (kept for the way back).
/// Absent = the shared devnet. IGNEUM_APP_DEVNET_SUFFIX in the environment wins over it.
#[serde(default)]
pub node_devnet_suffix: Option<u32>,
/// The package's default peers for that network (host:port); absent = the shared devnet's list.
#[serde(default)]
pub node_peers: Option<Vec<String>>,
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" or "none". Never
/// serialised (the packaged file does not carry them; nothing sends this struct to the UI).
#[serde(skip)]
@ -309,6 +458,22 @@ impl Packaged {
self
}
/// The words when the package was made for the other edition or another signing root (a public engine in a lab
/// package would never verify a lab manifest, and the other way round); None when the fields match or are absent.
pub fn edition_mismatch(&self) -> Option<String> {
let mut faults = Vec::new();
if !self.edition.is_empty() && self.edition != crate::edition::name() {
faults.push(format!("the package is the {} edition and this engine is the {} build", self.edition, crate::edition::name()));
}
if !self.channel.is_empty() && !crate::edition::channel_accepted(&self.channel) {
faults.push(format!("the package's channel {} is not this build's ({})", self.channel, crate::edition::channel()));
}
if !self.ota_root_hex.is_empty() && self.ota_root_hex != crate::edition::ota_key() {
faults.push(format!("the package's signing root {} is not this build's root {}", fingerprint8(&self.ota_root_hex), fingerprint8(crate::edition::ota_key())));
}
if faults.is_empty() { None } else { Some(format!("package mismatch: {}; updates will not verify until the matching build is installed", faults.join("; "))) }
}
/// The log header line: the intake URL with the key's fingerprint and the manifest URL with the folder's
/// fingerprint, each with its source; the values themselves never appear (the log is uploaded).
pub fn describe(&self) -> String {
@ -351,18 +516,34 @@ pub struct Runtime {
impl Runtime {
pub fn from_env() -> Runtime {
Self::from_env_with(None, None)
}
/// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins.
pub fn from_env_with(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>) -> Runtime {
Self::from_env_with_choice(packaged_suffix, packaged_peers, "")
}
/// The network step: `choice` is settings.network ("testnet-1" turns the runtime to the testnet object, `--testnet
/// --netsuffix=1` with the three seeds as peers; "devnet-3" or "" keeps the package's network); the environment wins inside.
pub fn from_env_with_choice(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>, choice: &str) -> Runtime {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| "devnet".into());
let testnet = choice == "testnet-1";
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| if testnet { "testnet".into() } else { "devnet".into() });
let packaged_peers: Option<&[String]> = if testnet { None } else { packaged_peers };
let packaged_suffix = if testnet { None } else { packaged_suffix };
let rpc_port = env("IGNEUM_APP_RPC_PORT").and_then(|v| v.parse().ok()).unwrap_or(26610);
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
let peers = match std::env::var("IGNEUM_APP_PEERS") {
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
// the public seed node first, then the 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 packaged_peers.map(|p| !p.is_empty()).unwrap_or(false) => packaged_peers.unwrap().to_vec(),
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()],
Err(_) if network == "testnet" => TESTNET_SEEDS.iter().map(|s| s.to_string()).collect(),
Err(_) => vec![],
};
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok()).or(packaged_suffix);
let root = crate::platform::data_root();
let node_dir = match env("IGNEUM_APP_NODE_DIR") {
Some(d) => PathBuf::from(d),
@ -392,6 +573,50 @@ impl Runtime {
#[cfg(test)]
mod tests {
#[test]
fn a_fresh_install_takes_the_installers_auto_update_choice_once() {
let dir = std::env::temp_dir().join(format!("igneum-choices-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let settings = dir.join("settings.json");
let _ = std::fs::remove_file(&settings);
std::fs::write(dir.join("install-choices.json"), r#"{"auto_update": false}"#).unwrap();
let s = Settings::load(&settings);
assert!(!s.auto_update, "the installer's choice is honoured on a fresh install");
assert!(settings.exists(), "the choice is written into settings.json");
// the choice file no longer speaks once settings.json exists: the user's later change in Settings wins
let mut t = Settings::load(&settings);
t.auto_update = true;
t.save(&settings);
assert!(Settings::load(&settings).auto_update);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn network_switch_rules() {
// the network step: unknown refused; the testnet refused until open; another network needs the confirm; the same network is fine
// Igneum 2.0 (8 October 2026): one devnet; the test network is no longer offered whatever the manifest says
assert!(super::network_switch("mainnet", "igneum-devnet-4", true, true).is_err());
assert!(super::network_switch("testnet-1", "igneum-devnet-4", true, true).unwrap_err().contains("no longer offered"));
assert!(super::network_switch("devnet-4", "igneum-devnet-4", false, false).is_ok(), "the network already running needs no confirm");
assert!(super::network_switch("devnet-3", "igneum-devnet-4", false, false).is_ok(), "a stored Devnet 3 choice reads as the one devnet: the chain moved under every 0.3.26 install");
assert_eq!(super::network_name(""), "igneum-devnet-4");
assert_eq!(super::network_name("devnet-4"), "igneum-devnet-4");
}
#[test]
fn currency_round_trip() {
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
let mut s = super::Settings::default();
s.currency = "EUR".into();
s.region = "de".into();
let text = serde_json::to_string(&s).unwrap();
let back: super::Settings = serde_json::from_str(&text).unwrap();
assert_eq!(back.currency, "EUR");
assert_eq!(back.region, "de");
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
assert_eq!(old.currency, "");
}
use super::*;
fn tmp(name: &str, content: &str) -> PathBuf {
@ -411,6 +636,21 @@ mod tests {
out
}
/// Known failed first (PRODUCT=public|lab, 8 October 2026): the packager writes the edition, the channel and the signing
/// root into igneum-app.json; an engine of the other build reports the mismatch in words (a public engine in a lab
/// package would never verify a lab manifest, and the other way round), and an older package without the fields says nothing.
#[test]
fn a_package_of_the_other_edition_is_named() {
let mine = Packaged { edition: crate::edition::name().into(), channel: crate::edition::channel().into(), ota_root_hex: crate::edition::ota_key().into(), ..Default::default() };
assert_eq!(mine.edition_mismatch(), None);
assert_eq!(Packaged::default().edition_mismatch(), None, "a package without the fields says nothing");
let other = Packaged { edition: "beta".into(), channel: "igneum-2.0-devnet-beta".into(), ota_root_hex: "ab".repeat(32), ..Default::default() };
let w = other.edition_mismatch().expect("named");
assert!(w.contains("beta") && w.contains(crate::edition::name()) && w.contains("root"), "{w}");
let root_only = Packaged { edition: crate::edition::name().into(), ota_root_hex: "cd".repeat(32), ..Default::default() };
assert!(root_only.edition_mismatch().unwrap().contains("signing root"));
}
#[test]
fn a_measurement_engine_overrides_the_copied_settings_in_memory() {
let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() };
@ -529,4 +769,19 @@ mod fixture_tests {
Err(e) => panic!("the crate refuses the file: {e}"),
}
}
#[test]
fn the_package_names_the_network_when_the_environment_is_silent() {
use super::Runtime;
// the test never sets IGNEUM_APP_DEVNET_SUFFIX or IGNEUM_APP_PEERS, so the package's values win
let peers = vec!["188.40.146.49:26631".to_string(), "188.40.146.49:26671".to_string()];
let r = Runtime::from_env_with(Some(3), Some(&peers));
assert_eq!(r.devnet_suffix, Some(3));
assert_eq!(r.peers, peers);
assert!(r.node_dir.ends_with("devnet-3"), "{}", r.node_dir.display());
// no package network: the shared devnet as before
let r = Runtime::from_env_with(None, None);
assert_eq!(r.devnet_suffix, None);
assert!(r.node_dir.ends_with("devnet-v4"));
}
}

View file

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

View file

@ -0,0 +1,423 @@
//! Per-device resource coordination (review B F07, 8 October 2026): one place that says what a card does with its
//! memory, and who holds it. The modes, decided per device from measured figures:
//!
//! | Mode | When | Measured basis (docs/analysis/class-v6/coexist-rows.md, 8 October 2026; prover-tiers-real-cards.md) |
//! |---|---|---|
//! | simultaneous | a TESTED configuration with headroom: 24 GB or more (`provedefault::MIN_VRAM_MB_MINING`) | the shard prover peaked at 20,434 MiB alone and 22,210 beside the miner on the adopted shard; a chained aggregation 16,751 MiB with the miner resident; measured on 24 and 32 GB cards |
//! | time-share | under the tested line but the compressed shard proof fits ALONE | the 5.5 GiB miner holds 6,129 MiB resident; a compressed shard proof peaks at 7,532 MiB alone (8.2 s on a 4060 8 GB, 13.2 s on a 3060 12 GB) and dies in a device allocation beside the miner on both |
//! | mining-only | a complete paid job cannot fit even alone | under `PROOF_PEAK_MIB` plus the driver's own use |
//!
//! A 16 to 24 GB card runs time-share, not simultaneous: the sum (6,129 + 7,532 = 13,661 MiB) fits on paper and no
//! row has measured the two together there; a measured row moves the line (`TESTED_SIMULTANEOUS_MIB`).
//!
//! The lease table: a holder (the miner, the prover, an aggregation, a benchmark, the next-epoch preparation) takes a
//! reservation on a device; a second holder is admitted only when the mode allows it beside the first, or the first
//! has released. Pausing dispatch is not releasing memory: a lease ends only on `release`, which the engine calls
//! after the holder's PROCESS has exited and `nvidia-smi` reports the device's memory used under `FREE_MIB`.
//! Admission counts the clock: the transfer, the WSL start, the proof, the aggregation, the dataset rebuild and the
//! payment deadline (`Budget`), so a job that cannot pay before its deadline is refused rather than started.
use std::collections::HashMap;
/// The miner's resident set with the 5.5 GiB dataset (coexist-rows, both cards: 6,129 and 6,116 MiB).
pub const MINER_RESIDENT_MIB: u64 = 6_129;
/// A compressed shard proof's peak alone (coexist-rows: 7,525 on the 3060, 7,532 on the 4060).
pub const PROOF_PEAK_MIB: u64 = 7_532;
/// The driver's own use on an idle device (coexist-rows: 1 to 2 MiB idle) plus the display's: under this, the device
/// reads as free after a holder has exited.
pub const FREE_MIB: u64 = 1_024;
/// Headroom a simultaneous configuration keeps over its measured peak for the next epoch's preparation (the next
/// hour's program pack is exported beside the running one; the pack is small, the margin is the allocator's): 10%.
pub const HEADROOM_PCT: u64 = 10;
/// The line above which mining and proving together have been MEASURED (prover-tiers-real-cards.md: the adopted shard
/// beside the miner at 22,210 MiB on a 24 GB card; 30,039 on the 32 GB prototype shard): `provedefault::MIN_VRAM_MB_MINING`.
pub const TESTED_SIMULTANEOUS_MIB: u64 = crate::provedefault::MIN_VRAM_MB_MINING;
/// The engine waits this long for a device to read free after a holder exits before it refuses the next admission.
pub const FREE_WAIT_S: u64 = 60;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// the miner and the prover share the card: a tested configuration with headroom
Simultaneous,
/// one at a time: the miner's dataset is evicted (its process exits), the memory is confirmed free, the prover
/// runs, the dataset is rebuilt, mining resumes
TimeShare,
/// the card mines; no complete paid proof job fits on it even alone
MiningOnly,
/// a card that is not mining proves alone (the switch is off on it)
ProveOnly,
}
impl Mode {
pub fn word(self) -> &'static str {
match self {
Mode::Simultaneous => "simultaneous",
Mode::TimeShare => "time-share",
Mode::MiningOnly => "mining-only",
Mode::ProveOnly => "prove-only",
}
}
}
/// The mode of one NVIDIA device from its memory and whether it mines. `vram_mib` as nvidia-smi reports it.
pub fn mode(vram_mib: u64, mining: bool) -> Mode {
let proof_alone_fits = vram_mib >= PROOF_PEAK_MIB + FREE_MIB;
if !mining {
return if proof_alone_fits { Mode::ProveOnly } else { Mode::MiningOnly };
}
if vram_mib >= TESTED_SIMULTANEOUS_MIB && vram_mib * 100 >= (MINER_RESIDENT_MIB + PROOF_PEAK_MIB) * (100 + HEADROOM_PCT) {
return Mode::Simultaneous;
}
if proof_alone_fits { Mode::TimeShare } else { Mode::MiningOnly }
}
/// One sentence for the window and the log.
pub fn line(name: &str, vram_mib: u64, m: Mode) -> String {
let gb = (vram_mib + 512) / 1024;
match m {
Mode::Simultaneous => format!("{name}: proves while it mines ({gb} GB; a tested configuration with headroom)."),
Mode::TimeShare => format!("{name}: one at a time ({gb} GB): the miner steps off the card, the memory is confirmed free, the proof runs, the miner rebuilds its dataset and resumes."),
Mode::MiningOnly => format!("{name}: mines only ({gb} GB): a complete paid proof does not fit even alone."),
Mode::ProveOnly => format!("{name}: proves alone ({gb} GB; not mining)."),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Holder {
Miner,
Prover,
Aggregation,
Benchmark,
/// the next epoch's program preparation
Prepare,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Lease {
pub holder: Holder,
pub mib: u64,
pub since: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Refusal {
/// another holder has the device and the mode does not allow them side by side
Held { by: Holder, mode: Mode },
/// the device has not read free since the last holder exited (pausing dispatch is not releasing memory)
NotFree { used_mib: u64 },
/// the job cannot pay before its deadline
Deadline { needs_s: f64, has_s: f64 },
/// a complete paid job never fits on this device
NoFit,
}
/// The clock a proof job needs, in seconds, against the payment deadline. The figures are the engine's measured or
/// configured ones; the defaults are coexist-rows and the prover log of 8 October 2026 (approximate where noted).
#[derive(Clone, Debug)]
pub struct Budget {
/// moving the inputs to the prover (the shard's bodies and witnesses): the 3060 row's wall minus prove, 18 s
pub transfer_s: f64,
/// the host process start (a WSL2 process on Windows; 0 on Linux)
pub startup_s: f64,
/// the proof itself
pub prove_s: f64,
/// the aggregation step when the job is a segment (0 for a shard)
pub aggregate_s: f64,
/// the miner's dataset rebuild after a time-share (the worker's export and self-test; approximate, from the worker's
/// own ready line on PC 1)
pub rebuild_s: f64,
/// seconds left to the payment deadline
pub deadline_s: f64,
}
impl Budget {
pub fn needs_s(&self) -> f64 {
self.transfer_s + self.startup_s + self.prove_s + self.aggregate_s + self.rebuild_s
}
}
/// The job the host is admitted for (one per spawn): a shard proof, a segment aggregation, a whole chain run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Workload {
Shard,
Aggregate,
Chain,
}
impl Workload {
pub fn word(self) -> &'static str {
match self {
Workload::Shard => "shard",
Workload::Aggregate => "aggregate",
Workload::Chain => "chain",
}
}
}
/// The exit code the host answers with when the budget is under its profile's floor (the V6-07 sub-lane's contract,
/// 8 October 2026): the engine records it on the lease and the card reads "proving needs N GB free".
pub const HOST_FLOOR_EXIT: i32 = 78;
/// The five variables every igneum-prove-host spawn carries, exactly as the host reads them (the shipper's contract with
/// the V6-07 sub-lane, 8 October 2026): the card's ordinal as the host enumerates it, the one workload admitted, the free
/// memory the engine read at admission, the lease's grant (the host's hard ceiling) and the lease's deadline.
pub fn host_env(device: u32, workload: Workload, free_mib: u64, budget_mib: u64, deadline_s: u64) -> Vec<(&'static str, String)> {
vec![
("IGNEUM_PROVE_DEVICE", device.to_string()),
("IGNEUM_PROVE_WORKLOAD", workload.word().to_string()),
("IGNEUM_PROVE_MEM_FREE_MB", free_mib.to_string()),
("IGNEUM_PROVE_MEM_BUDGET_MB", budget_mib.to_string()),
("IGNEUM_PROVE_DEADLINE_S", deadline_s.to_string()),
]
}
/// The lease's grant for a proof on a card of `vram_mib` under `mode`: beside the miner the measured peak plus the
/// headroom; alone on the card everything above the free floor; nothing on a card that only mines.
pub fn proof_budget_mib(vram_mib: u64, mode: Mode) -> u64 {
match mode {
Mode::Simultaneous => PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100,
Mode::TimeShare | Mode::ProveOnly => vram_mib.saturating_sub(FREE_MIB),
Mode::MiningOnly => 0,
}
}
/// The lease's figure for a spawn: the grant the mode allows, never more than the card reads free (the V6-07 sub-lane's
/// 12 GB row: 6,159 MiB free beside the miner, under the 8,285 MiB grant).
pub fn lease_mib(vram_mib: u64, mode: Mode, free_mib: u64) -> u64 {
proof_budget_mib(vram_mib, mode).min(free_mib)
}
/// The card's words after exit 78: the figure the host printed ("needs N GB" or "needs N MB"/"MiB"; the host's own line
/// is "RESULT memory_profile refused: proving needs N GB free on the card for the <workload> workload (<floor> MiB
/// floor); <free> MiB free") when it did, else the budget it was offered, in whole GB rounded up.
pub fn floor_refusal_words(out: &str, budget_mib: u64) -> String {
let printed = out.lines().rev().find_map(|l| {
let i = l.find("needs ")?;
let rest = &l[i + 6..];
let n: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let n = n.parse::<u64>().ok().filter(|n| *n > 0)?;
let unit = rest[n.to_string().len()..].trim_start();
Some(if unit.starts_with("GB") || unit.starts_with("GiB") { n } else { n.div_ceil(1024) })
});
let gb = printed.unwrap_or(budget_mib.div_ceil(1024));
format!("proving needs {gb} GB free")
}
#[derive(Default, Debug)]
pub struct Coordinator {
leases: HashMap<String, Vec<Lease>>,
/// a device whose last holder exited and whose memory has not read free yet: the last reading
pending_free: HashMap<String, u64>,
}
impl Coordinator {
pub fn new() -> Self {
Self::default()
}
pub fn leases(&self, device: &str) -> &[Lease] {
self.leases.get(device).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Whether `holder` may take `mib` on `device` under `mode` now. The miner's lease and the prover's coexist only in
/// the simultaneous mode; an aggregation, a benchmark or the next-epoch preparation never shares a time-shared
/// device with a prover; the preparation shares with the miner (it is the miner's own next pack).
pub fn admit(&mut self, device: &str, holder: Holder, mib: u64, mode: Mode, now: f64, budget: Option<&Budget>) -> Result<Lease, Refusal> {
if mode == Mode::MiningOnly && matches!(holder, Holder::Prover | Holder::Aggregation) {
return Err(Refusal::NoFit);
}
if let Some(used) = self.pending_free.get(device).copied() {
if used >= FREE_MIB {
return Err(Refusal::NotFree { used_mib: used });
}
}
if let Some(b) = budget {
if b.needs_s() > b.deadline_s {
return Err(Refusal::Deadline { needs_s: b.needs_s(), has_s: b.deadline_s });
}
}
let held = self.leases(device).to_vec();
for l in &held {
if l.holder == holder {
return Ok(l.clone()); // idempotent: the same holder keeps its lease
}
let beside_ok = match (l.holder, holder) {
(Holder::Miner, Holder::Prepare) | (Holder::Prepare, Holder::Miner) => true,
// the next epoch's pack is small; it rides beside a prover only where the prover rides beside the miner
(Holder::Prepare, Holder::Prover) | (Holder::Prover, Holder::Prepare) => mode == Mode::Simultaneous,
(Holder::Miner, Holder::Prover) | (Holder::Prover, Holder::Miner) => mode == Mode::Simultaneous,
(Holder::Miner, Holder::Aggregation) | (Holder::Aggregation, Holder::Miner) => mode == Mode::Simultaneous,
_ => false,
};
if !beside_ok {
return Err(Refusal::Held { by: l.holder, mode });
}
}
let lease = Lease { holder, mib, since: now };
self.leases.entry(device.to_string()).or_default().push(lease.clone());
self.pending_free.remove(device);
Ok(lease)
}
/// The holder's process has exited; its lease ends only once the device reads free (`confirm_free`). Until then
/// the device admits nobody new: pausing dispatch is not releasing memory.
pub fn exited(&mut self, device: &str, holder: Holder) {
if let Some(v) = self.leases.get_mut(device) {
v.retain(|l| l.holder != holder);
}
self.pending_free.entry(device.to_string()).or_insert(u64::MAX);
}
/// A memory reading after an exit: under FREE_MIB the device is free again (when no lease remains); the reading
/// is kept otherwise and the next admission is refused with it.
pub fn confirm_free(&mut self, device: &str, used_mib: u64) -> bool {
if self.leases(device).is_empty() && used_mib < FREE_MIB {
self.pending_free.remove(device);
true
} else {
if self.leases(device).is_empty() {
self.pending_free.insert(device.to_string(), used_mib);
} else {
self.pending_free.remove(device);
}
false
}
}
/// Every device with a lease, for the record.
pub fn held_devices(&self) -> Vec<String> {
let mut v: Vec<String> = self.leases.iter().filter(|(_, l)| !l.is_empty()).map(|(d, _)| d.clone()).collect();
v.sort();
v
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Known failed first (the shipper's contract with the V6-07 sub-lane, 8 October 2026): every igneum-prove-host spawn
/// carries the five names, exactly as written, with the lease's figures; a spawn without them is the old shape.
#[test]
fn the_host_reads_its_lease_from_five_named_variables() {
let env = host_env(1, Workload::Shard, 11_800, 7_532, 600);
let names: Vec<&str> = env.iter().map(|(k, _)| *k).collect();
assert_eq!(names, ["IGNEUM_PROVE_DEVICE", "IGNEUM_PROVE_WORKLOAD", "IGNEUM_PROVE_MEM_FREE_MB", "IGNEUM_PROVE_MEM_BUDGET_MB", "IGNEUM_PROVE_DEADLINE_S"]);
let values: Vec<&str> = env.iter().map(|(_, v)| v.as_str()).collect();
assert_eq!(values, ["1", "shard", "11800", "7532", "600"]);
assert_eq!(host_env(0, Workload::Aggregate, 1, 2, 3)[1].1, "aggregate");
assert_eq!(host_env(0, Workload::Chain, 1, 2, 3)[1].1, "chain");
}
/// The lease's grant per mode: the measured proof peak with headroom beside the miner; the card less the free floor
/// when the prover has the card to itself; nothing on a mining-only card.
#[test]
fn the_budget_is_the_grant_the_mode_allows() {
assert_eq!(proof_budget_mib(24_576, Mode::Simultaneous), PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100);
assert_eq!(proof_budget_mib(12_288, Mode::TimeShare), 12_288 - FREE_MIB);
assert_eq!(proof_budget_mib(8_192, Mode::ProveOnly), 8_192 - FREE_MIB);
assert_eq!(proof_budget_mib(8_192, Mode::MiningOnly), 0);
}
/// The V6-07 sub-lane's row (8 October 2026): a 12 GB card beside its miner has 6,159 MiB free, under the grant the
/// mode allows; the lease is the lesser of the grant and the free reading, never more than the card has.
#[test]
fn the_lease_is_the_lesser_of_the_grant_and_the_free_memory() {
assert_eq!(lease_mib(12_288, Mode::Simultaneous, 6_159), 6_159);
assert_eq!(lease_mib(24_576, Mode::Simultaneous, 18_000), PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100);
assert_eq!(lease_mib(8_192, Mode::MiningOnly, 8_000), 0);
assert_eq!(lease_mib(0, Mode::ProveOnly, 0), 0, "no card, no grant");
}
/// Exit 78 from the host is the floor refusal: the card's words name the floor the host printed, else the budget.
#[test]
fn the_floor_refusal_names_the_memory_proving_needs() {
assert_eq!(HOST_FLOOR_EXIT, 78);
assert_eq!(floor_refusal_words("RESULT refused: profile needs 9216 MB free, 7532 offered", 7_532), "proving needs 9 GB free");
// the host's own line (the V6-07 sub-lane, 8 October 2026): the GB figure is read as GB, not as MiB
assert_eq!(floor_refusal_words("RESULT memory_profile refused: proving needs 8 GB free on the card for the shard workload (7700 MiB floor); 6100 MiB free", 7_532), "proving needs 8 GB free");
assert_eq!(floor_refusal_words("nothing useful", 7_532), "proving needs 8 GB free");
assert_eq!(floor_refusal_words("", 12_000), "proving needs 12 GB free");
}
#[test]
fn the_modes_follow_the_measured_rows() {
// nvidia-smi's figures: 32 GB 32,607; 24 GB 24,564; 16 GB 16,376; 12 GB 12,208; 8 GB 8,188; 6 GB 6,144
assert_eq!(mode(32_607, true), Mode::Simultaneous);
assert_eq!(mode(24_564, true), Mode::Simultaneous);
assert_eq!(mode(16_376, true), Mode::TimeShare, "16 to 24 GB: the sum fits on paper, no row measured it together");
assert_eq!(mode(12_208, true), Mode::TimeShare, "the 3060 row: the proof verified alone, died beside the miner");
assert_eq!(mode(8_188, true), Mode::MiningOnly, "the 4060 row: 7,532 MiB alone of 8,188 leaves no driver headroom; a complete paid proof does not fit");
assert_eq!(mode(6_144, true), Mode::MiningOnly);
assert_eq!(mode(12_208, false), Mode::ProveOnly);
assert_eq!(mode(6_144, false), Mode::MiningOnly);
assert!(line("RTX 3060", 12_208, Mode::TimeShare).contains("one at a time (12 GB)"));
assert!(line("RTX 5090", 32_607, Mode::Simultaneous).contains("tested configuration"));
}
#[test]
fn the_cycle_mine_evict_prove_aggregate_submit_rebuild_resume_leaks_no_reservation() {
let mut c = Coordinator::new();
let d = "nvidia:0";
let m = Mode::TimeShare;
c.admit(d, Holder::Miner, MINER_RESIDENT_MIB, m, 0.0, None).unwrap();
// the prover asks while the miner holds the card: refused by the mode
assert_eq!(c.admit(d, Holder::Prover, PROOF_PEAK_MIB, m, 1.0, None), Err(Refusal::Held { by: Holder::Miner, mode: m }));
// the engine pauses dispatch: that releases nothing
assert_eq!(c.leases(d).len(), 1);
// the miner's process exits: the lease is gone, the device is not free until a reading says so
c.exited(d, Holder::Miner);
assert_eq!(c.admit(d, Holder::Prover, PROOF_PEAK_MIB, m, 2.0, None), Err(Refusal::NotFree { used_mib: u64::MAX }));
assert!(!c.confirm_free(d, 6_000), "the dataset is still resident");
assert_eq!(c.admit(d, Holder::Prover, PROOF_PEAK_MIB, m, 3.0, None), Err(Refusal::NotFree { used_mib: 6_000 }));
assert!(c.confirm_free(d, 2));
c.admit(d, Holder::Prover, PROOF_PEAK_MIB, m, 4.0, None).unwrap();
// the aggregation never shares a time-shared device with the prover
assert!(matches!(c.admit(d, Holder::Aggregation, 13_400, m, 5.0, None), Err(Refusal::Held { by: Holder::Prover, .. })));
c.exited(d, Holder::Prover);
assert!(c.confirm_free(d, 1));
c.admit(d, Holder::Aggregation, 13_400, m, 6.0, None).unwrap();
c.exited(d, Holder::Aggregation);
assert!(c.confirm_free(d, 1));
// the miner rebuilds and resumes; nothing is left over
c.admit(d, Holder::Miner, MINER_RESIDENT_MIB, m, 7.0, None).unwrap();
assert_eq!(c.leases(d).len(), 1);
assert_eq!(c.held_devices(), vec![d.to_string()]);
c.exited(d, Holder::Miner);
assert!(c.confirm_free(d, 1));
assert!(c.held_devices().is_empty());
assert!(c.admit(d, Holder::Miner, MINER_RESIDENT_MIB, m, 8.0, None).is_ok(), "no leaked reservation");
}
#[test]
fn simultaneous_admits_the_prover_beside_the_miner_and_mining_only_refuses_a_proof() {
let mut c = Coordinator::new();
let d = "nvidia:0";
c.admit(d, Holder::Miner, MINER_RESIDENT_MIB, Mode::Simultaneous, 0.0, None).unwrap();
c.admit(d, Holder::Prepare, 200, Mode::Simultaneous, 0.5, None).unwrap();
c.admit(d, Holder::Prover, 20_434, Mode::Simultaneous, 1.0, None).unwrap();
assert_eq!(c.leases(d).len(), 3);
// a benchmark never runs beside a prover
assert!(matches!(c.admit(d, Holder::Benchmark, 6_000, Mode::Simultaneous, 2.0, None), Err(Refusal::Held { .. })));
let mut o = Coordinator::new();
o.admit(d, Holder::Miner, MINER_RESIDENT_MIB, Mode::MiningOnly, 0.0, None).unwrap();
assert_eq!(o.admit(d, Holder::Prover, PROOF_PEAK_MIB, Mode::MiningOnly, 1.0, None), Err(Refusal::NoFit));
// the same holder asking twice keeps its one lease
o.admit(d, Holder::Miner, MINER_RESIDENT_MIB, Mode::MiningOnly, 2.0, None).unwrap();
assert_eq!(o.leases(d).len(), 1);
}
#[test]
fn admission_counts_the_whole_clock_against_the_payment_deadline() {
let mut c = Coordinator::new();
let d = "nvidia:0";
let shard = Budget { transfer_s: 18.0, startup_s: 4.0, prove_s: 13.2, aggregate_s: 0.0, rebuild_s: 90.0, deadline_s: 600.0 };
assert!((shard.needs_s() - 125.2).abs() < 1e-9);
c.admit(d, Holder::Prover, PROOF_PEAK_MIB, Mode::TimeShare, 0.0, Some(&shard)).unwrap();
c.exited(d, Holder::Prover);
c.confirm_free(d, 1);
let late = Budget { deadline_s: 100.0, ..shard.clone() };
assert_eq!(c.admit(d, Holder::Prover, PROOF_PEAK_MIB, Mode::TimeShare, 1.0, Some(&late)), Err(Refusal::Deadline { needs_s: 125.2, has_s: 100.0 }));
assert!(c.leases(d).is_empty(), "a refused job takes no lease");
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,115 @@
//! The two builds (review B F14, the founder's ruling of 19:57 UK, 8 October 2026): the PUBLIC miner and the LAB build.
//!
//! The public miner runs no remote-execution jobs at all: no jobs file is fetched, no signing root for jobs exists in
//! it, the Settings switch is not shown, and `POST /api/jobs/allow` is refused. Its update choice is honoured: with
//! automatic updates off, nothing installs until the user presses Install now, an urgent manifest included; an urgent
//! manifest that says this version is unsupported pauses mining and says so (src/ota.rs, src/engine.rs). A manifest's
//! `consensus.override` is never written to the node on the public build: a compromised update key cannot activate a
//! consensus change there; the node's rules come from the node. The lab build (our own fleet) runs the remote jobs
//! under a SEPARATE signing root, `JOBS_LAB_PUBLIC_KEY_HEX`, never the OTA release key, so the update key alone
//! signs no job even on the fleet.
//!
//! The build is chosen at compile time by the cargo feature `lab` (`cargo build --features lab`); the default is the
//! public miner. The lab build's signing root is read from the build environment, never from the repo: the relay
//! lane keeps the pair under ~/.config/igneum/lab-signing/ and the lab build is compiled with
//! `IGNEUM_LAB_PUBLIC_KEY=<hex of key.pub>`; the build fails without it. The lab build verifies BOTH its OTA manifest
//! and its jobs feed against that root and refuses a release-signed one; the public build carries the release root
//! only and refuses a lab-signed manifest. Each build reads its own channel (`channel()`) and refuses a manifest of
//! the other, so a lab build never reaches a public machine or the reverse. The engine reports the build as
//! `state.edition` ("public" | "lab") and the window shows it on the About line.
/// True on the lab build, false on the public miner.
pub const LAB: bool = cfg!(feature = "lab");
/// The lab signing root, compiled in from the build environment under the `lab` feature only.
#[cfg(feature = "lab")]
pub const LAB_PUBLIC_KEY_HEX: &str = env!("IGNEUM_LAB_PUBLIC_KEY", "the lab build needs IGNEUM_LAB_PUBLIC_KEY (the hex of ~/.config/igneum/lab-signing/key.pub)");
/// "public" or "lab", for state.edition and the log.
pub fn name() -> &'static str {
if LAB { "lab" } else { "public" }
}
/// The product name the window and the installers show: both builds can sit on one machine.
pub fn product_name() -> &'static str {
if LAB { "Igneum Miner Lab" } else { "Igneum Miner" }
}
/// The manifest channel this build reads; a manifest of another channel is refused (src/ota.rs).
pub fn channel() -> &'static str {
if LAB { "igneum-2.0-devnet-lab" } else { "igneum-2.0-devnet" }
}
/// The channel names this build accepts: its own, and for the public build the older plain name the 2.0.0 and 2.0.1
/// manifests carried, so a public machine on them keeps updating until the publisher renames the channel.
pub fn channel_accepted(manifest_channel: &str) -> bool {
manifest_channel == channel() || (!LAB && (manifest_channel.is_empty() || manifest_channel == "devnet"))
}
/// The signing root this build verifies its OTA manifest and its interface bundles against.
pub fn ota_key() -> &'static str {
#[cfg(feature = "lab")]
{
LAB_PUBLIC_KEY_HEX
}
#[cfg(not(feature = "lab"))]
{
crate::manifest::OTA_PUBLIC_KEY_HEX
}
}
/// The signing root remote jobs are checked against: the lab root on the lab build, none on the public miner (which
/// runs no jobs).
pub fn jobs_key() -> Option<&'static str> {
#[cfg(feature = "lab")]
{
Some(LAB_PUBLIC_KEY_HEX)
}
#[cfg(not(feature = "lab"))]
{
None
}
}
/// Whether remote jobs can run on this build at all.
pub fn remote_jobs_possible() -> bool {
jobs_key().is_some()
}
/// Whether a manifest's consensus override may reach the node: the lab build only.
pub fn manifest_override_allowed() -> bool {
LAB
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_public_build_has_no_jobs_root_and_the_lab_build_has_its_own() {
if LAB {
assert_eq!(name(), "lab");
assert_eq!(product_name(), "Igneum Miner Lab");
assert_eq!(channel(), "igneum-2.0-devnet-lab");
assert!(channel_accepted("igneum-2.0-devnet-lab"));
assert!(!channel_accepted("igneum-2.0-devnet"), "a lab build never takes the public channel");
assert!(!channel_accepted("devnet"));
assert_ne!(ota_key(), crate::manifest::OTA_PUBLIC_KEY_HEX, "the lab root is not the release root");
assert_eq!(jobs_key(), Some(ota_key()));
assert!(crate::manifest::public_key(ota_key()).is_ok());
assert!(manifest_override_allowed());
} else {
assert_eq!(name(), "public");
assert_eq!(product_name(), "Igneum Miner");
assert_eq!(channel(), "igneum-2.0-devnet");
assert!(channel_accepted("igneum-2.0-devnet"));
assert!(channel_accepted("devnet"), "the 2.0.0 and 2.0.1 manifests' name, until the publisher renames it");
assert!(channel_accepted(""));
assert!(!channel_accepted("igneum-2.0-devnet-lab"), "a public build never takes the lab channel");
assert_eq!(ota_key(), crate::manifest::OTA_PUBLIC_KEY_HEX);
assert_eq!(jobs_key(), None, "the public miner has no signing root for remote jobs");
assert!(!remote_jobs_possible());
assert!(!manifest_override_allowed(), "a manifest never activates a consensus change on the public miner");
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

487
app/igneum-app/src/heat.rs Normal file
View file

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

View file

@ -167,11 +167,47 @@ 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();
// Ember Tune tiers (8 October 2026): the tier in force and the class the set was measured under survive a restart
c.tier = p.tier.clone();
c.tier_at = p.tier_at as f64;
c.tiers_class = p.tiers_class.clone();
// tiers-table-26 (8 October 2026): the card's own measured rows when the pref kept them, else the team's table seeds
// the three tabs for this card class until the search runs (the rows carry table: true, the class the table is for)
if !p.tiers.is_empty() {
c.tiers = p.tiers.clone();
c.tiers_table = false;
} else {
seed_from_table(c);
}
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);
}
}
/// The team's table seeds a card with no measured set of its own (tiers-table-26): the rows in the apply shape, the
/// table's class as the set's class (a card on another program class reads them stale and re-measures), the no-lever
/// note for a class the table carries as stock only. A name the table does not know gets nothing.
pub fn seed_from_table(c: &mut CardState) {
if !c.tiers.is_empty() || c.name.is_empty() {
return;
}
let Some(seed) = crate::tiertable::seed(&c.name) else { return };
c.tiers = seed.rows;
c.tiers_table = true;
c.tiers_class = seed.class;
if c.tier.is_empty() {
c.tier = String::new(); // the fleet tier applies until a tap or the search picks one for this card
}
if c.tiers.len() == 1 && c.vendor != "nvidia" && c.vendor != "amd" {
c.tier_note = if c.vendor == "apple" { "no lever: Apple silicon sets its own clocks and power".into() } else { "no lever: no clock or power control for this card".into() };
c.tier = "max".into();
}
}
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
/// came with; its slot and the time it appeared.
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
@ -181,6 +217,8 @@ pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now:
c.gone = false;
if let Some(p) = pref {
apply_pref(c, p);
} else {
seed_from_table(c);
}
if c.problem.is_empty() {
c.state = if c.enabled { "waiting".into() } else { "off".into() };

View file

@ -87,6 +87,9 @@ pub enum Action {
StopMiners(String),
RestartMiners,
RestartNode,
/// The signed `cards` kind: apply these per-card choices through the app's own card path (persisted), then
/// call `cards_applied` with the read-back.
ApplyCards(Vec<crate::engine::CardChoice>),
/// The relaunch helper was started; the engine quits now.
RestartApp,
UpdateNow,
@ -137,6 +140,12 @@ pub fn key_without_index(k: &str) -> String {
}
}
/// The restore list of a `cards_leave_off` job: the same entries (identities and cap kept) with enabled=false, so the
/// runner's restore on any exit leaves the card off, persisted by the app's own card path.
pub fn leave_off(restore: Vec<crate::engine::CardChoice>) -> Vec<crate::engine::CardChoice> {
restore.into_iter().map(|mut c| { c.enabled = false; c }).collect()
}
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
pub type LiveCard = (String, bool, u32, u32, bool);
@ -224,7 +233,9 @@ impl Jobs {
let _ = ledger.save(&ledger_path);
let jitter = shared.runtime.machine_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64));
let first = env("IGNEUM_APP_JOBS_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(40 + jitter % 20);
let allowed = shared.settings.lock().unwrap().remote_jobs;
// F14: the public miner runs no remote jobs at all (no signing root for them, no url); the lab build does
let allowed = crate::edition::remote_jobs_possible() && shared.settings.lock().unwrap().remote_jobs;
let url = if crate::edition::remote_jobs_possible() { url } else { String::new() };
let data_root = crate::platform::data_root();
let j = Jobs {
url,
@ -238,12 +249,17 @@ impl Jobs {
queue: Vec::new(),
active: None,
needs_logged: std::collections::HashSet::new(),
fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX),
wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }),
fingerprint: crate::edition::jobs_key().map(manifest::fingerprint).unwrap_or_default(),
wake: Arc::new(WakeCtl::new(allowed, &shared.runtime.id8())),
wake_pending: false,
last_published: String::new(),
};
j.publish(shared);
// the ping names the last job this machine ran, across restarts (the newest record in jobs-state.json)
if let Some(last) = j.ledger.records.values().max_by_key(|r| r.started_at) {
j.wake.set_last_job(&last.id);
}
#[cfg(feature = "lab")]
if !j.url.is_empty() {
let wake_url = wake_url_of(std::env::var("IGNEUM_APP_JOBS_WAKE_URL").ok());
if !wake_url.is_empty() {
@ -311,6 +327,10 @@ impl Jobs {
}
pub fn set_allowed(&mut self, shared: &Arc<Shared>, on: bool) {
if on && !crate::edition::remote_jobs_possible() {
shared.event("info", "this is the public build: it runs no remote jobs (the lab build does)");
return;
}
self.allowed = on;
self.wake.on.store(on, Ordering::Relaxed);
{
@ -559,11 +579,29 @@ impl Jobs {
return None;
}
shared.event("info", &format!("job {} ({}) starts: {}", job.id, job.kind, job.label()));
self.wake.set_last_job(&job.id);
let ctl = Arc::new(Ctl::default());
let needs_miners_stopped = job.kind == "shard-benchmark" || (job.kind == "run" && job.bool_param("stop_miners_first"));
let cards_off: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, waiting_for_cards: !cards_off.is_empty(), cards: CardHold::default(), ctl: ctl.clone() });
match job.kind.as_str() {
"cards" => {
// engine-side: refused at once for a card this machine does not have; else applied through the
// app's own card path and reported with the read-back (cards_applied)
let live: Vec<(String, bool, u32)> = shared.state.lock().unwrap().mining.cards.iter().filter(|c| c.present()).map(|c| (c.key.clone(), c.enabled, c.identities)).collect();
let (choices, missing) = cards_job_choices(&job, &live);
let sink = Sink::new(shared, &job, &self.dir);
if !missing.is_empty() {
let summary = format!("refused: this machine has no card {}", missing.join(", "));
sink.line(&format!("cards: {summary}; present: {}", live.iter().map(|c| c.0.as_str()).collect::<Vec<_>>().join(", ")));
let uploaded = report(shared, &job, &sink, "failed", 2, now, crate::platform::unix_now(), &summary, json!({ "present": live.iter().map(|c| c.0.clone()).collect::<Vec<_>>() }));
let o = Outcome { status: "failed".into(), exit: 2, summary, results: vec![], uploaded };
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
return None;
}
sink.line(&format!("cards: applying {}", choices.iter().map(|c| format!("{} enabled={} identities={}{}", c.key, c.enabled, c.identities, c.power_pct.map(|p| format!(" power_pct={p}")).unwrap_or_default())).collect::<Vec<_>>().join("; ")));
return Some(Action::ApplyCards(choices));
}
"restart" | "update-now" => {
// engine-side; the report says what was asked and the ledger closes at once
let what = job.str_param("what");
@ -573,13 +611,10 @@ impl Jobs {
(_, "node") => ("node restarted (the miners follow)".to_string(), Action::RestartNode),
_ => ("app restarting".to_string(), Action::RestartApp),
};
if let Action::RestartApp = action {
if let Err(e) = spawn_relaunch_helper(shared) {
let o = Outcome { status: "failed".into(), exit: 1, summary: format!("could not start the relaunch helper: {e}"), ..Default::default() };
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
return None;
}
}
// the app restart's relaunch is the ENGINE's decision (Action::RestartApp, src/engine.rs restart_plan): under the
// window host the host's own ladder restarts the engine, and a helper here would race it for the single
// instance (PC 1, 6 and 8 October 2026: "the relaunch helper did not bring it back", twice); the helper is
// for an engine with no host
let sink = Sink::new(shared, &job, &self.dir);
sink.line(&format!("{}: {summary}", job.kind));
let uploaded = report(shared, &job, &sink, "done", 0, now, crate::platform::unix_now(), &summary, json!({}));
@ -597,6 +632,35 @@ impl Jobs {
}
}
/// The running job's id, for the hold rule (a hold belongs to the job that took it).
pub fn active_id(&self) -> Option<String> {
self.active.as_ref().map(|a| a.job.id.clone())
}
/// The running job's cap in minutes (the hold's hard cap).
pub fn active_cap_minutes(&self) -> u64 {
self.active.as_ref().map(|a| a.job.timeout_minutes()).unwrap_or(60)
}
/// Called by the engine once a `cards` job's choices are applied: the report carries the read-back of every
/// card named (what the engine holds now) and the job closes done.
pub fn cards_applied(&mut self, shared: &Arc<Shared>, readback: Vec<(String, bool, u32, u32)>) -> Option<Action> {
let Some(a) = self.active.as_ref() else { return None };
if a.job.kind != "cards" {
return None;
}
let (job, started) = (a.job.clone(), a.started_unix);
let sink = Sink::new(shared, &job, &self.dir);
let lines: Vec<String> = readback.iter().map(|(k, e, i, p)| format!("{k} enabled={e} identities={i} power_pct={p}")).collect();
for l in &lines {
sink.line(&format!("cards: read back {l}"));
}
let summary = format!("cards applied: {}", lines.join("; "));
let uploaded = report(shared, &job, &sink, "done", 0, started, crate::platform::unix_now(), &summary, json!({ "cards": readback.iter().map(|(k, e, i, p)| json!({ "key": k, "enabled": e, "identities": i, "power_pct": p })).collect::<Vec<_>>() }));
let o = Outcome { status: "done".into(), exit: 0, summary, results: lines, uploaded };
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o })
}
/// Called by the engine once a job's `--cards-off` cards are switched off; `restore` puts them back exactly.
/// Next: the miners, if the job asked for them too, else the script.
pub fn cards_off_done(&mut self, shared: &Arc<Shared>, restore: Vec<crate::engine::CardChoice>) -> Option<Action> {
@ -605,6 +669,10 @@ impl Jobs {
return None;
}
a.waiting_for_cards = false;
// cards_leave_off (7 October 2026, intel-arc): the hold still ends through the app's own card path on any
// exit, but with enabled=false, so the card stays off and persisted (settings.json) after the job: the way
// to hold a card out of mining past a job without a script touching api/cards (the 6 October rule).
let restore = if a.job.bool_param("cards_leave_off") { leave_off(restore) } else { restore };
a.cards = CardHold::hold(restore);
if a.waiting_for_miners {
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
@ -630,7 +698,11 @@ impl Jobs {
if held.is_empty() {
sink.line(&format!("cards-off: {} asked, no present card matched (nothing switched; the script's card may be loaded)", asked.join(",")));
} else {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
if job.bool_param("cards_leave_off") {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards LEFT OFF: {} by the runner on any exit (done, failed, timeout, aborted, app quit), enabled=false with their own identities and cap, persisted by the app (cards_leave_off)", held.keys(), held.keys()));
} else {
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
}
}
}
let ctx = account_context();
@ -764,9 +836,43 @@ fn upload_file(shared: &Arc<Shared>, job: &Job, path: &Path, label_prefix: &str)
// busy-loops: a reply that came back early is followed by the rest of a 10 s floor, a failing endpoint backs off
// 5, 15, then 60 s, and an empty stamp (nothing published yet) waits a full hold.
/// Shared with the waker thread: it polls only while remote jobs are allowed.
/// Shared with the waker thread: it polls only while remote jobs are allowed. The ping (MF-11, 0.3.21): every wake
/// request names this machine (its id8, no secret), the app version and the last job it ran, so the relay can show a
/// machine whose job channel has gone quiet ("silent since <time>, last job <name>") the moment 15 minutes pass with no
/// poll; a dead app polls nothing, and before this nothing on the console said so until the next upload gap was noticed.
pub struct WakeCtl {
on: AtomicBool,
machine: String,
last_job: Mutex<String>,
}
impl WakeCtl {
pub fn new(on: bool, machine: &str) -> WakeCtl {
WakeCtl { on: AtomicBool::new(on), machine: machine.to_string(), last_job: Mutex::new(String::new()) }
}
pub fn set_last_job(&self, id: &str) {
*self.last_job.lock().unwrap() = id.to_string();
}
/// The query fragment of the ping: machine=<id8>&v=<version>[&job=<id>]; values are the app's own, URL-safe by shape.
pub fn ping(&self) -> String {
ping_query(&self.machine, crate::engine::VERSION, &self.last_job.lock().unwrap())
}
}
/// machine and job ids as the relay reads them: id8 is 8 hex; a job id is the publisher's `[\w.-]` name; anything else
/// is dropped from the query rather than escaped (the relay clips and validates on its side too).
pub fn ping_query(machine: &str, version: &str, last_job: &str) -> String {
let safe = |s: &str, max: usize| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')).take(max).collect() };
let m = safe(machine, 16);
if m.is_empty() {
return String::new();
}
let mut q = format!("machine={m}&v={}", safe(version, 32));
let j = safe(last_job, 80);
if !j.is_empty() {
q.push_str(&format!("&job={j}"));
}
q
}
/// The stamp logic, free of I/O for the tests.
@ -829,17 +935,24 @@ fn wake_url_of(env: Option<String>) -> String {
}
}
fn wake_query(url: &str, since: &str) -> String {
if since.is_empty() {
url.to_string()
} else {
format!("{url}{}since={since}", if url.contains('?') { '&' } else { '?' })
fn wake_query(url: &str, since: &str, ping: &str) -> String {
let mut out = url.to_string();
let mut sep = if url.contains('?') { '&' } else { '?' };
if !since.is_empty() {
out.push(sep);
out.push_str(&format!("since={since}"));
sep = '&';
}
if !ping.is_empty() {
out.push(sep);
out.push_str(ping);
}
out
}
/// One long-poll. Ok carries the relay's stamp (empty when it holds none).
fn wake_request(url: &str, since: &str) -> Result<String, String> {
let full = wake_query(url, since);
fn wake_request(url: &str, since: &str, ping: &str) -> Result<String, String> {
let full = wake_query(url, since, ping);
let max_time = (WAKE_HOLD_S + 13).to_string();
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", &max_time, &full]), Duration::from_secs(WAKE_HOLD_S + 20));
if code != Some(0) {
@ -860,7 +973,7 @@ fn wake_loop(shared: Arc<Shared>, url: String, ctl: Arc<WakeCtl>) {
continue;
}
let t0 = Instant::now();
match wake_request(&url, &st.stamp) {
match wake_request(&url, &st.stamp, &ctl.ping()) {
Ok(stamp) => {
let (r, recovered) = st.reply(&stamp);
if recovered {
@ -916,7 +1029,8 @@ fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec<String>), St
let (f, skipped) = match curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &ef.display().to_string(), &signed_url], Duration::from_secs(25)) {
Ok(()) => {
let bytes = std::fs::read(&ef).map_err(|e| e.to_string())?;
let (f, skipped, inner) = jobs::verify_and_parse_signed_lenient(&bytes, manifest::OTA_PUBLIC_KEY_HEX)?;
let key = crate::edition::jobs_key().ok_or("this build runs no remote jobs")?;
let (f, skipped, inner) = jobs::verify_and_parse_signed_lenient(&bytes, key)?;
let _ = std::fs::write(&jf, &inner);
let _ = std::fs::rename(&ef, dir.join("jobs.signed.json"));
(f, skipped)
@ -927,7 +1041,8 @@ fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec<String>), St
curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("jobs signature: {e}"))?;
let bytes = std::fs::read(&jf).map_err(|e| e.to_string())?;
let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?;
let r = jobs::verify_and_parse_lenient(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
let key = crate::edition::jobs_key().ok_or("this build runs no remote jobs")?;
let r = jobs::verify_and_parse_lenient(&bytes, sig.trim(), key)?;
let _ = std::fs::rename(&sf, dir.join("jobs.json.sig"));
r
}
@ -1217,6 +1332,12 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
let dir = jobs_dir.join(&job.id);
let shell = shell_for(job);
let body = job.str_param("script").replace("\r\n", "\n");
// the founder's word at 20:21 UK (8 October 2026): a body that ends a process by name never runs (the relay agent's
// Check-Task carries the same refusal, exit 77 with the matched line)
if let Some(line) = jobs::kill_by_name_line(&body) {
sink.line(&format!("refused: the script ends a process by name ({line}); a pid is the only way"));
return Ok(Done { status: "failed".into(), exit: 77, summary: format!("refused: the script ends a process by name ({line}); a pid is the only way"), extra: json!({ "refused": "kill_by_name", "line": line }) });
}
let script = dir.join(if shell == "powershell" { "script.ps1" } else { "script.sh" });
let text = if shell == "powershell" { body.replace('\n', "\r\n") } else { body };
std::fs::write(&script, if shell == "powershell" { [b"\xEF\xBB\xBF".as_slice(), text.as_bytes()].concat() } else { text.into_bytes() }).map_err(|e| format!("cannot write the script: {e}"))?;
@ -1316,6 +1437,50 @@ fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
)
}
/// The choices a `cards` job asks for, matched to the machine's present cards (key exact, or the key with the device
/// index left out: `vendor::name`); missing keys are returned for the refusal. A field the job leaves out keeps the
/// card's current value.
pub fn cards_job_choices(job: &Job, live: &[(String, bool, u32)]) -> (Vec<crate::engine::CardChoice>, Vec<String>) {
let mut out = Vec::new();
let mut missing = Vec::new();
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
for c in list {
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
let found = live.iter().find(|(k, _, _)| *k == key).or_else(|| {
let parts: Vec<&str> = key.splitn(3, ':').collect();
live.iter().find(|(k, _, _)| { let lp: Vec<&str> = k.splitn(3, ':').collect(); parts.len() == 3 && lp.len() == 3 && lp[0] == parts[0] && lp[2] == parts[2] && (parts[1].is_empty() || parts[1] == lp[1]) })
});
match found {
Some((k, enabled, identities)) => out.push(crate::engine::CardChoice {
key: k.clone(),
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(*enabled),
identities: c.get("identities").and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(*identities),
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|n| n as u32),
}),
None => missing.push(key),
}
}
(out, missing)
}
/// The hold rule (MF-6, PC 1, 7 October 2026: a read-only job that followed a --stop-miners job kept the cards off
/// for its whole run): a hold belongs to the job that took it and releases the moment that job is no longer the
/// running one, whatever runs next, or when the owner's own cap has passed. Returns the reason to release, or None.
pub fn hold_release(owner: Option<&str>, active: Option<&str>, held_s: f64, cap_s: f64) -> Option<&'static str> {
match owner {
None => Some("no job owns the hold"),
Some(o) => {
if active != Some(o) {
Some("the job that took the hold is no longer running")
} else if held_s >= cap_s {
Some("the hold passed the job's own cap")
} else {
None
}
}
}
}
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
match ran.code {
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
@ -1434,6 +1599,23 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
mod tests {
use super::*;
/// cards_leave_off (7 October 2026): the restore entries keep their identities and cap and carry enabled=false,
/// so the job's exit leaves the card off through the same path; without the param they keep their own flag.
#[test]
fn cards_leave_off_restores_the_card_as_off_with_its_settings_kept() {
let live: Vec<LiveCard> = vec![("other:Intel(R) Arc(TM) B580 Graphics".into(), true, 8, 0, true), ("nvidia:NVIDIA GeForce RTX 5090".into(), true, 2, 80, true)];
let (off, restore) = cards_off_choices(&["other:Intel(R) Arc(TM) B580 Graphics".to_string()], &live);
assert_eq!(off.len(), 1);
assert!(restore[0].enabled, "the plain restore puts the card back on");
let left = leave_off(restore.clone());
assert_eq!(left.len(), 1);
assert_eq!((left[0].key.as_str(), left[0].enabled, left[0].identities, left[0].power_pct), ("other:Intel(R) Arc(TM) B580 Graphics", false, 8, restore[0].power_pct));
// the job's param decides, through the same hold
let j = jobs::parse(r#"{"jobs":[{"id":"x","kind":"run","expires_at":"2099-01-01T00:00:00Z","target":{"machine_ids":["ae432dc7"]},"params":{"script":"ls","cards_off":["other:Intel(R) Arc(TM) B580 Graphics"],"cards_leave_off":true}}]}"#).unwrap().jobs.remove(0);
assert!(j.bool_param("cards_leave_off"));
assert_eq!(j.list_param("cards_off"), vec!["other:Intel(R) Arc(TM) B580 Graphics".to_string()]);
}
#[test]
fn cards_off_matches_keys_with_and_without_the_device_index_and_restores_exactly() {
// PC 1, 6 October 2026: settings.json holds amd:1:gfx1201 and amd:3:gfx1201, the live state's key is amd:gfx1201
@ -1566,9 +1748,16 @@ mod tests {
assert_eq!(wake_url_of(None), WAKE_URL);
assert_eq!(wake_url_of(Some(String::new())), "");
assert_eq!(wake_url_of(Some(" http://127.0.0.1:4180/wake ".into())), "http://127.0.0.1:4180/wake");
assert_eq!(wake_query("https://r/wake", ""), "https://r/wake");
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5"), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
assert_eq!(wake_query("https://r/api/wake?x=1", "S"), "https://r/api/wake?x=1&since=S");
assert_eq!(wake_query("https://r/wake", "", ""), "https://r/wake");
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5", ""), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
assert_eq!(wake_query("https://r/api/wake?x=1", "S", ""), "https://r/api/wake?x=1&since=S");
// the ping (MF-11): the machine, the version and the last job ride on every wake request
assert_eq!(wake_query("https://r/wake", "", "machine=1ccfe586&v=0.3.21"), "https://r/wake?machine=1ccfe586&v=0.3.21");
assert_eq!(wake_query("https://r/wake", "S", "machine=1ccfe586&v=0.3.21&job=update-now-0319"), "https://r/wake?since=S&machine=1ccfe586&v=0.3.21&job=update-now-0319");
assert_eq!(ping_query("1ccfe586", "0.3.21", ""), "machine=1ccfe586&v=0.3.21");
assert_eq!(ping_query("1ccfe586", "0.3.21", "update-now-0319-1ccfe586"), "machine=1ccfe586&v=0.3.21&job=update-now-0319-1ccfe586");
assert_eq!(ping_query("", "0.3.21", "x"), "", "no machine, no ping");
assert_eq!(ping_query("1ccfe586", "0.3.21", "a b&c=d"), "machine=1ccfe586&v=0.3.21&job=abcd", "only the safe characters travel");
}
}
@ -1974,7 +2163,7 @@ fn run_build(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, ctl:
// ---- kind: restart app -----------------------------------------------------------------------------------------------
/// A detached helper that starts the app again a few seconds after this engine has gone.
fn spawn_relaunch_helper(shared: &Arc<Shared>) -> Result<(), String> {
pub fn spawn_relaunch_helper(shared: &Arc<Shared>) -> Result<(), String> {
let mut c;
#[cfg(target_os = "macos")]
{
@ -2058,3 +2247,23 @@ fn account_warning(ctx: &str) -> String {
fn short(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::<String>()) }
}
#[cfg(test)]
mod hold_tests {
use super::hold_release;
/// MF-6 (PC 1, 7 October 2026): a --stop-miners job's hold outlived it into a read-only watch job for three minutes.
#[test]
fn a_hold_belongs_to_the_job_that_took_it() {
// the owner is still running, under its cap: the hold stays
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 30.0, 3600.0), None);
// another job runs now: released at once, whatever that job is
assert_eq!(hold_release(Some("job-a"), Some("job-b"), 30.0, 3600.0), Some("the job that took the hold is no longer running"));
// no job runs: released
assert_eq!(hold_release(Some("job-a"), None, 30.0, 3600.0), Some("the job that took the hold is no longer running"));
// the owner's own cap passed: released and logged
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 3601.0, 3600.0), Some("the hold passed the job's own cap"));
// a hold with no owner (an older engine state) never sticks
assert_eq!(hold_release(None, Some("job-b"), 1.0, 3600.0), Some("no job owns the hold"));
}
}

View file

@ -22,7 +22,9 @@
//!
//! Kinds and their params:
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
//! timeout_minutes (default 60, at most 600)
//! timeout_minutes (default 60, at most 600), cards_off [keys] (the runner switches them off before
//! the script and restores them on any exit), cards_leave_off (with cards_off: restored as OFF, so
//! the card stays off and persisted after the job; 7 October 2026, the Arc on PC 1)
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command
@ -59,7 +61,7 @@ pub const JOBS_FILE: &str = "igneum-jobs.json";
/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms.
pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json";
pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1";
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"];
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build", "cards"];
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
/// Named folders a `fetch` may write into, all under the app data root.
@ -371,12 +373,36 @@ fn sha_ok(s: &str) -> bool {
}
/// Per-kind checks of the params, so a job that cannot run is refused at signing time.
/// The founder's word at 20:21 UK, 8 October 2026 (fifteen Mac processes killed by a grep that evening; by construction,
/// not by rule): a run-script body that ends a process by NAME is refused before it runs; a pid is the only way. The
/// shapes: `Stop-Process -Name`, `taskkill /IM`, `Get-Process -Name … | Stop-Process`, `pkill`, `killall`, as commands
/// (the first word of a line or of a pipeline stage), never as a word inside a string. Returns the matched line.
pub fn kill_by_name_line(script: &str) -> Option<&str> {
for raw in script.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let lower = line.to_ascii_lowercase();
let stage_starts = |word: &str| lower.split(['|', ';', '&']).any(|stage| { let st = stage.trim_start(); st == word || st.starts_with(&format!("{word} ")) || st.starts_with(&format!("{word}\t")) });
let taskkill_im = lower.contains("taskkill") && lower.split_whitespace().any(|w| w == "/im");
let stop_by_name = lower.contains("stop-process") && (lower.contains("-name") || lower.contains("get-process -name") || lower.contains("get-process "));
if stage_starts("pkill") || stage_starts("killall") || taskkill_im || stop_by_name {
return Some(raw.trim());
}
}
None
}
pub fn validate_params(job: &Job) -> Result<(), String> {
match job.kind.as_str() {
"run" => {
if job.str_param("script").trim().is_empty() {
return Err("run: params.script is empty".into());
}
if let Some(line) = kill_by_name_line(&job.str_param("script")) {
return Err(format!("run: the script ends a process by name ({line}); a pid is the only way (Stop-Process -Id, taskkill /PID, kill <pid>)"));
}
let sh = job.str_param("shell");
if !sh.is_empty() && !["powershell", "bash"].contains(&sh.as_str()) {
return Err(format!("run: shell '{sh}' is not powershell or bash"));
@ -418,6 +444,33 @@ pub fn validate_params(job: &Job) -> Result<(), String> {
}
}
"update-now" => {}
"cards" => {
// the signed `cards` kind (7 October 2026): per-card enabled and identities, applied by the app through
// its own card path and persisted; it closes the exception of a one-off script POSTing /api/cards
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
if list.is_empty() {
return Err("cards: params.cards is empty (a list of {key, enabled, identities})".into());
}
for c in &list {
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("");
if key.trim().is_empty() || !key.contains(':') {
return Err(format!("cards: key '{key}' is not a card key (vendor:device:name)"));
}
if c.get("enabled").map(|v| !v.is_boolean()).unwrap_or(false) {
return Err(format!("cards: {key}: enabled must be true or false"));
}
if let Some(n) = c.get("identities") {
if !n.as_u64().map(|n| (1..=64).contains(&n)).unwrap_or(false) {
return Err(format!("cards: {key}: identities must be 1 to 64"));
}
}
if let Some(n) = c.get("power_pct") {
if !n.as_u64().map(|n| (50..=100).contains(&n)).unwrap_or(false) {
return Err(format!("cards: {key}: power_pct must be 50 to 100"));
}
}
}
}
"shard-benchmark" => {
let url = job.str_param("zip_url");
if !url.is_empty() && !https_ok(&url) {
@ -824,9 +877,22 @@ mod tests {
assert!(j("restart", r#"{"what":"everything"}"#).unwrap_err().contains("restart"));
assert!(j("restart", r#"{"what":"miners"}"#).is_ok());
assert!(j("update-now", r#"{}"#).is_ok());
assert!(j("cards", r#"{}"#).unwrap_err().contains("cards"));
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:NVIDIA GeForce RTX 5090","enabled":true,"identities":8}]}"#).is_ok());
assert!(j("cards", r#"{"cards":[{"key":"5090","enabled":true}]}"#).unwrap_err().contains("card key"));
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:x","identities":65}]}"#).unwrap_err().contains("1 to 64"));
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
// the founder's word at 20:21 UK, 8 October 2026 (by construction, not by rule): a run-script body that ends a
// process by name is refused before it runs; a pid is the only way. Known-failed first: every shape ran.
for body in ["Stop-Process -Name igneum-app -Force", "taskkill /IM igneum-app.exe /F", "Get-Process -Name igneumd | Stop-Process", "pkill -f igneumd", "killall igneum-worker", "echo ok\ntaskkill /f /im node.exe\necho done"] {
let e = j("run", &format!(r#"{{"script":{}}}"#, serde_json::Value::String(body.into()))).unwrap_err();
assert!(e.contains("ends a process by name") && e.contains("pid"), "{body}: {e}");
}
assert!(j("run", r#"{"script":"Stop-Process -Id 4242; taskkill /PID 4242 /F; kill -TERM $(cat run.pid)"}"#).is_ok(), "a pid is the way");
assert_eq!(kill_by_name_line("echo one\nGet-Process -Name igneumd | Stop-Process\necho two"), Some("Get-Process -Name igneumd | Stop-Process"));
assert_eq!(kill_by_name_line("echo pkill is a word in a string, not a command: 'the pkill rule'"), None, "the shape, not the word");
}
#[test]

1041
app/igneum-app/src/ladder.rs Normal file

File diff suppressed because it is too large Load diff

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

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

View file

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

View file

@ -12,8 +12,13 @@
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
//! "min_supported_version": "0.3.0", "notes": "one line",
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
//! }
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
@ -40,6 +45,9 @@ pub struct PlatformEntry {
pub sha256: String,
pub size: u64,
pub kind: String, // dmg | zip | inno-setup
/// V6-06 (8 October 2026): the engine exe's own sha256 inside this installer (publish-manifest.sh --win-engine),
/// what the Power Helper checks before it refreshes its protected copy; empty when the publish did not name it.
pub engine_sha256: String,
}
#[derive(Clone, Debug, PartialEq, Default)]
@ -52,6 +60,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.
@ -59,6 +70,41 @@ pub struct Manifest {
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
pub tuning: Option<serde_json::Value>,
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
pub ui: Option<UiEntry>,
/// the network step (0.3.23): the network a FRESH install selects by default ("devnet-3" | "testnet-1"; "" = the
/// package's own), and whether igneum-testnet-1 is open; a manifest naming the testnet default before the open is refused
pub default_network: String,
pub testnet_open: bool,
/// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and
/// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers
pub drivers: Option<serde_json::Value>,
}
/// One published interface bundle (the manifest's `ui` object).
#[derive(Clone, Debug, PartialEq, Default)]
pub struct UiEntry {
pub version: String,
pub sha256: String,
pub size: u64,
pub url: String,
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
pub min_engine: String,
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
pub signature: String,
}
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
}
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
let key = public_key(pub_hex)?;
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
}
impl Manifest {
@ -132,7 +178,7 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
if p.is_null() {
return Ok(None);
}
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") };
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind"), engine_sha256: s(p, "engine_sha256").to_ascii_lowercase() };
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
return Err(format!("{name}: the url is not https"));
}
@ -148,7 +194,17 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Ok(Some(e))
};
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
let default_network = s(&v, "default_network");
let testnet_open = v.get("testnet_open").and_then(|b| b.as_bool()).unwrap_or(false);
if !default_network.is_empty() && !["devnet-3", "testnet-1"].contains(&default_network.as_str()) {
return Err(format!("default_network '{default_network}' is not a network this app knows"));
}
if default_network == "testnet-1" && !testnet_open {
return Err("default_network names the testnet before it is open (testnet_open is not true)".into());
}
Ok(Manifest {
default_network,
testnet_open,
version,
published_at: s(&v, "published_at"),
channel: s(&v, "channel"),
@ -157,6 +213,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()),
@ -168,6 +226,40 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
_ => None,
},
drivers: match v.get("drivers") {
Some(d) if d.is_object() => {
crate::drivertable::Table::parse(d)?;
Some(d.clone())
}
Some(d) if !d.is_null() => return Err("drivers must be an object".into()),
_ => None,
},
ui: match v.get("ui") {
Some(u) if u.is_object() => {
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
if parse_version(&e.version).is_none() {
return Err(format!("ui: version '{}' is not a version", e.version));
}
if parse_version(&e.min_engine).is_none() {
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
}
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
return Err("ui: the url is not https".into());
}
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: sha256 is not 64 hex characters".into());
}
if e.size == 0 {
return Err("ui: size is missing".into());
}
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: signature is not 128 hex characters".into());
}
Some(e)
}
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
_ => None,
},
})
}
@ -300,6 +392,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 +404,30 @@ 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,
/// review B F14 (the founder, 8 October 2026): automatic updates are off in Settings; nothing installs, urgent or
/// not, until the user presses Install now (`install_asked`)
pub auto_update_off: bool,
pub install_asked: bool,
/// 2.0.2 (the shipper, the founder's mini, 8 October 2026): no card is mining or starting; an idle or refused miner
/// is the strongest reason to apply, never a reason to wait.
pub miner_idle: bool,
/// How long the node has read synced, in seconds (0 when it does not).
pub synced_for_s: u64,
}
/// With automatic updates on, a staged update applies within this many seconds of the node reading synced (and of the
/// staging, whichever is later), whatever the miner is doing: the machine's slot minute, the boundary guard and a
/// starting worker hold it no longer than this (2.0.2; the mini held 2.0.1 for its slot minute with an idle miner).
pub const SYNCED_APPLY_BOUND_S: u64 = 120;
/// 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,12 +438,32 @@ 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> {
if m.urgent {
// the user's choice comes first (F14): with automatic updates off nothing installs until Install now, an urgent
// manifest included; an unsupported version pauses mining instead (the engine) and the card says install now
if m.auto_update_off && !m.install_asked {
return Err("automatic updates are off: waiting for Install now".into());
}
// the finality rule next: a fork-close or unsupported urgency does not pass it, only the manifest's flag or the
// operator's hand (Install now, 2.0.2: the held update can be the fix that restores the locks; the rule is for
// unattended machines)
if m.finality_paused && !m.manifest_urgent && !m.install_asked {
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());
}
// the operator's Install now is urgent in its own right (the engine folds it into `urgent` too)
if m.urgent || m.install_asked {
return Ok(());
}
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
}
// 2.0.2: a synced node with an idle miner applies at once; a mining one within SYNCED_APPLY_BOUND_S of the sync
// and the staging, slot or no slot, boundary or no boundary
if m.node_synced && (m.miner_idle || (m.synced_for_s >= SYNCED_APPLY_BOUND_S && m.ready_for_s >= SYNCED_APPLY_BOUND_S)) {
return Ok(());
}
if !m.slot_ok {
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
}
@ -349,7 +487,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,
}
}
@ -382,6 +523,36 @@ mod tests {
assert_eq!(ours, theirs);
}
#[test]
fn the_ui_entry_parses_and_every_bad_field_fails() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[3u8; 32]);
let pk = hex_encode(sk.verifying_key().as_bytes());
let sha = "ab".repeat(32);
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
let m = parse(&base.to_string()).unwrap();
let u = m.ui.clone().unwrap();
assert_eq!(u.version, "1.0.1");
assert_eq!(u.min_engine, "0.3.19");
assert!(verify_ui_entry(&u, &pk).is_ok());
let mut t = u.clone();
t.sha256 = "00".repeat(32);
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
let mut t = u.clone();
t.min_engine = "0.3.0".into();
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
let mut b = base.clone();
b["ui"][k] = v;
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
}
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
}
#[test]
fn tuning_parses() {
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
@ -408,6 +579,33 @@ mod tests {
(sk, pk)
}
/// V6-06: the windows entry may name the engine exe's own sha256 (publish-manifest.sh --win-engine), what the
/// Power Helper checks before it refreshes its protected copy; absent = empty, nothing refreshes.
#[test]
fn the_windows_entry_carries_the_engine_hash_when_published() {
let sha = "cd".repeat(32);
let m = parse(&format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{sha}","size":1,"kind":"inno-setup","engine_sha256":"ABCD"}}}}}}"#)).unwrap();
assert_eq!(m.windows.unwrap().engine_sha256, "abcd");
let n = parse(&format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{sha}","size":1,"kind":"inno-setup"}}}}}}"#)).unwrap();
assert_eq!(n.windows.unwrap().engine_sha256, "");
}
#[test]
fn default_network_rules() {
// the network step: absent = the package's own; devnet-3 fine; the testnet default only once the manifest opens it
let m = parse(SAMPLE).unwrap();
assert_eq!(m.default_network, "");
assert!(!m.testnet_open);
let d = parse(r#"{"version":"0.3.23","default_network":"devnet-3"}"#).unwrap();
assert_eq!(d.default_network, "devnet-3");
let e = parse(r#"{"version":"0.3.23","default_network":"testnet-1"}"#).unwrap_err();
assert!(e.contains("before it is open"), "{e}");
let t = parse(r#"{"version":"0.3.23","default_network":"testnet-1","testnet_open":true}"#).unwrap();
assert_eq!(t.default_network, "testnet-1");
assert!(t.testnet_open);
assert!(parse(r#"{"version":"0.3.23","default_network":"mainnet"}"#).unwrap_err().contains("not a network"));
}
#[test]
fn parses_manifest() {
let m = parse(SAMPLE).unwrap();
@ -490,10 +688,49 @@ mod tests {
assert!(!newer("0.3.1.2", "0.3.0"));
}
/// Known failed first (the shipper, the founder's mini, 8 October 2026 20:30 to 20:40 UK): 2.0.1 staged at 20:30:22,
/// the node synced from 20:35, the miner idle the whole time, and nothing installed by 20:40 because the machine's
/// own minute of the hour had not come. The rule: with automatic updates on, a staged update applies within
/// SYNCED_APPLY_BOUND_S of the node reading synced, whatever the miner is doing; an idle or refused miner is the
/// strongest reason to apply, never a reason to wait.
#[test]
fn a_staged_update_applies_within_two_minutes_of_sync_and_at_once_on_an_idle_miner() {
let staged = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 300, urgent: false, slot_ok: false, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 0 };
assert_eq!(SYNCED_APPLY_BOUND_S, 120);
// the mini's case: node synced, miner idle, outside the slot: applies at once
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 1, ..staged.clone() }).is_ok());
// a mining miner: within the bound of the sync, outside the slot and inside the boundary guard, it still applies
assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S, boundary_eta_s: Some(30), ..staged.clone() }).is_ok());
assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).unwrap_err().contains("own minute"));
// the bound counts from the later of the sync and the staging
assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).is_err());
assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S, ..staged.clone() }).is_ok());
// an unsynced node still waits, idle miner or not; a job, paused finality and updates-off still hold
assert!(safe_to_apply(&Moment { node_synced: false, miner_idle: true, synced_for_s: 0, ..staged.clone() }).is_err());
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, job_active: true, ..staged.clone() }).unwrap_err().contains("remote job"));
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, finality_paused: true, ..staged.clone() }).unwrap_err().contains("finality"));
assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, auto_update_off: true, ..staged.clone() }).unwrap_err().contains("Install now"));
// 20:45 on the mini: the finality guard held the update that was the fix for the locks, and Install now could not
// pass it; the operator's hand outranks a rule for unattended machines (a fork-close urgency alone still does not)
assert!(safe_to_apply(&Moment { finality_paused: true, install_asked: true, ..staged.clone() }).is_ok());
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, install_asked: false, ..staged.clone() }).unwrap_err().contains("finality"));
}
#[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, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 60 };
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());
// F14 (known-failed first on 2.0.1: urgent beat auto_update = false): off stays off until Install now
assert_eq!(safe_to_apply(&Moment { auto_update_off: true, urgent: true, manifest_urgent: true, ..base.clone() }).unwrap_err(), "automatic updates are off: waiting for Install now");
assert!(safe_to_apply(&Moment { auto_update_off: true, install_asked: true, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { auto_update_off: true, urgent: true, install_asked: 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,10 +739,16 @@ 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());
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
// the machine's slot: outside it nothing applies in the first SYNCED_APPLY_BOUND_S of a synced node (2.0.2; before
// that nothing applied outside it at all, not even with patience); urgent ignores it
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());
@ -531,8 +774,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

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

View file

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

View file

@ -13,7 +13,7 @@ pub fn tool(name: &str) -> PathBuf {
let sys = format!("{root}\\System32");
let p = match name {
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" | "wevtutil" | "robocopy" => format!("{sys}\\{name}.exe"),
"nvidia-smi" => {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
@ -206,6 +206,17 @@ pub fn open_url(url: &str) {
let _ = Command::new("xdg-open").arg(url).spawn();
}
/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with
/// the file selected, the folder on other systems.
pub fn reveal_file(path: &std::path::Path) {
#[cfg(target_os = "macos")]
let _ = Command::new(tool("open")).arg("-R").arg(path).spawn();
#[cfg(windows)]
let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn();
#[cfg(not(any(target_os = "macos", windows)))]
let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn();
}
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
/// which must be refreshed (call `keep_awake_tick` every minute).
pub struct KeepAwake {
@ -257,6 +268,49 @@ pub fn keep_awake_tick() {
}
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
/// Every `igneum-miner` process on this machine with its command line: (pid, command line). Windows reads
/// Win32_Process through PowerShell; unix reads `ps`. An empty list when the tool fails (the caller kills nothing).
pub fn miner_processes() -> Vec<(u32, String)> {
let out = if cfg!(windows) {
let mut c = std::process::Command::new(tool("powershell"));
c.args(["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='igneum-miner.exe'\" | ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"]);
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(20))
} else {
let mut c = std::process::Command::new("ps");
c.args(["-eo", "pid=,args="]);
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(10))
};
let Some(out) = out else { return vec![] };
let mut v = Vec::new();
for l in out.lines() {
let l = l.trim();
let (pid, cmd) = if cfg!(windows) {
let Some((p, c)) = l.split_once('|') else { continue };
(p.trim(), c.trim())
} else {
let Some((p, c)) = l.split_once(' ') else { continue };
(p.trim(), c.trim())
};
let Ok(pid) = pid.parse::<u32>() else { continue };
if !cfg!(windows) && !(cmd.contains("igneum-miner ") || cmd.ends_with("igneum-miner")) {
continue;
}
if cmd.contains(" mine ") {
v.push((pid, cmd.to_string()));
}
}
v
}
/// Ends one process by pid (Windows: taskkill /T /F; unix: SIGKILL).
pub fn kill_pid(pid: u32) {
if cfg!(windows) {
let _ = quiet(&mut std::process::Command::new(tool("taskkill"))).args(["/PID", &pid.to_string(), "/T", "/F"]).output();
} else {
let _ = quiet(&mut std::process::Command::new("kill")).args(["-9", &pid.to_string()]).output();
}
}
/// std (what today's launcher does with taskkill /F).
pub fn terminate(child: &mut std::process::Child) {
#[cfg(unix)]
@ -486,6 +540,29 @@ pub fn elevated_failure(code: Option<i32>, stderr: &str) -> String {
}
}
/// Standard base64 (RFC 4648, padded) of `bytes`: what PowerShell's -EncodedCommand reads.
pub fn base64_encode(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });
}
out
}
/// The PowerShell arguments that run `script` INLINE (V6-06, 8 October 2026): `-EncodedCommand` over the script's
/// UTF-16LE bytes, so an elevated step never reads a .ps1 from a folder the user can write between the write and the
/// run (rights.ps1 under the data root, register-power-task.ps1 under the sweep folder were that). The command line
/// cap is 32 K characters; the callers' scripts are a few K.
pub fn encoded_command_args(script: &str) -> Vec<String> {
let utf16: Vec<u8> = script.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
vec!["-NoProfile".into(), "-ExecutionPolicy".into(), "Bypass".into(), "-EncodedCommand".into(), base64_encode(&utf16)]
}
/// Doubles the single quotes of `s` for a single-quoted PowerShell literal.
pub fn ps_quote(s: &str) -> String {
s.replace('\'', "''")

View file

@ -4,7 +4,8 @@
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module
//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task,
//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very
//! `Igneum Power Helper`, principal = the signed-in user (S4U since 0.3.24: it runs whether or not that user is logged
//! on, so a start from a job's session is accepted), RunLevel Highest, no trigger, whose action is this very
//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a
//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it
//! through the command file `<app data>/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: `<seq> pl
@ -27,6 +28,23 @@
//! else: no file, no process, no registry, no other binary.
//! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise).
//! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set.
//!
//! V6-06 (the master's R1 review, 8 October 2026; the design the window lane built):
//! - The task's action is a PROTECTED copy of the exe and its runtime DLLs in %ProgramData%\Igneum\helper, made by the
//! one elevated step with inheritance cut and Administrators and SYSTEM full, Users read and execute, owner
//! Administrators (`register_script`). The per-user install under LOCALAPPDATA, which the user can overwrite, is
//! never what the scheduler runs elevated.
//! - The copy is refreshed only through `reregister`, and only when the signed update manifest names the installed
//! exe's sha256 (platforms.windows.engine_sha256, `refresh_allowed`); a swapped binary is refused with its reason in
//! helper.log. An update whose manifest names no engine hash leaves the old protected copy in place.
//! - Every elevated script travels inline (-EncodedCommand, platform::encoded_command_args): no .ps1 is read from a
//! folder the user can write between the write and the run.
//! - `quit` and `remove` carry a sequence like every other verb and pass the same rising-sequence guard; a stale
//! line re-added to the file is skipped. The command file stays the IPC: its boundary is the user account (the
//! file is in the user's profile, the task's principal is the user), the same boundary a per-user named pipe
//! would draw; what matters is that no verb lets a writer choose a path, a hash or a binary, which holds.
//! - The window host is asInvoker (app/windows/host.manifest); the one prompt it raises for the engine's step is
//! a child process, the host itself never holds rights.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@ -35,6 +53,102 @@ 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;
/// Every end of the helper is written here as `<unix> <code> <reason>`, the panic path included; the engine reads it
/// when a start gave no heartbeat and shows "power control: helper not running (<reason>)" (PC 1, 7 October 2026,
/// 22:08 BST: six "helper started" lines, no command run after any, no exit line, the task reading Running with no
/// process; the engine toggled nothing and said nothing).
pub const EXIT_FILE: &str = "helper.exit";
pub fn write_exit(dir: &Path, now: u64, code: i32, reason: &str) {
let _ = std::fs::write(dir.join(EXIT_FILE), format!("{now} {code} {}\n", reason.replace('\n', " ")));
}
/// The exit written at or after `since`, as (code, reason); None when there is none that recent (the process was ended
/// from outside, or crashed before any Rust ran: nothing of ours writes then).
pub fn exit_reason(text: &str, since: u64) -> Option<(i32, String)> {
let t = text.trim();
let mut it = t.splitn(3, ' ');
let at: u64 = it.next()?.parse().ok()?;
let code: i32 = it.next()?.parse().ok()?;
let reason = it.next().unwrap_or("").trim().to_string();
(at >= since).then_some((code, reason))
}
pub fn last_exit(dir: &Path, since: u64) -> Option<(i32, String)> {
std::fs::read_to_string(dir.join(EXIT_FILE)).ok().and_then(|t| exit_reason(&t, since))
}
/// The notice the engine shows when the task was started and no heartbeat came: the helper's own exit reason when it
/// wrote one, else what a missing reason means and where Windows keeps the rest.
pub fn not_running_notice(exit: Option<(i32, String)>, waited_s: u64) -> String {
match exit {
Some((code, reason)) => format!("power control: helper not running ({reason}; exit {code}; the task gave no heartbeat within {waited_s} s)"),
None => format!("power control: helper not running (the task gave no heartbeat within {waited_s} s and the helper wrote no exit reason: its process was ended from outside or crashed before it ran; Event Viewer, Application, id 1000 names the module, and TaskScheduler/Operational ids 201/202 carry the task's return code)"),
}
}
/// The text of a panic payload.
pub fn panic_text(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<String>().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "a panic with no message".to_string())
}
/// Runs the helper body under a panic catch: a panic becomes a FAULT line in the log, an exit reason and exit 101,
/// never a silent end (known-failed first, PC 1, 7 October 2026).
pub fn run_guarded(dir: &Path, log: &dyn Fn(&str), body: &mut dyn FnMut() -> i32) -> i32 {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body())) {
Ok(code) => code,
Err(p) => {
let reason = format!("panicked: {}", panic_text(&*p));
log(&format!("FAULT helper: {reason}"));
write_exit(dir, crate::platform::unix_now(), 101, &reason);
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
101
}
}
}
/// 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);
let since = crate::platform::unix_now();
start()?;
let until = Instant::now() + wait;
while Instant::now() < until {
std::thread::sleep(Duration::from_millis(250));
if alive(dir) {
return Ok(());
}
}
// the reason the helper itself wrote on its way out (helper.exit, this start or later), else what a missing one means
Err(not_running_notice(last_exit(dir, since), wait.as_secs()))
}
/// One parsed command from cmd.txt.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -51,18 +165,16 @@ pub enum HelperCmd {
/// re-point the task at the installed exe (no path argument: the helper finds the install folder itself, so a
/// writer of cmd.txt can never choose what runs elevated); 6 October 2026, run 6 registered a scratch copy
Reregister,
/// the unattended driver install (src/driverinstall.rs): a vendor WORD only; the helper resolves the file, hash,
/// signer and arguments from the signed table itself
Driver(String),
}
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
/// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None.
pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
let t = line.trim();
if t == "quit" {
return Some((0, HelperCmd::Quit));
}
if t == "remove" {
return Some((0, HelperCmd::Remove));
}
// V6-06: a bare quit or remove is no command; both carry a sequence and pass the guard like every verb
let p: Vec<&str> = t.split_whitespace().collect();
let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit());
let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?;
@ -74,11 +186,65 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
[_, "lmc", m] if digits(m) => Some((seq, HelperCmd::MemClock(m.parse().ok()?))),
[_, "rmc"] => Some((seq, HelperCmd::MemReset)),
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
[_, "quit"] => Some((seq, HelperCmd::Quit)),
[_, "remove"] => Some((seq, HelperCmd::Remove)),
[_, "reregister"] => Some((seq, HelperCmd::Reregister)),
[_, "driver", v] if crate::driverinstall::vendor_ok(v) => Some((seq, HelperCmd::Driver(v.to_string()))),
_ => None,
}
}
/// The sequence number a command line carries on the wire. The helper runs a line only when its number is above
/// every number it has seen (its `last_seq`, seeded from the file at its start), so every writer must draw from
/// ONE monotonic space: the unix time modulo a million (six digits, what `parse_line` accepts), plus a small
/// offset per line. (F) 6 October 2026, 22:19Z on PC 1: the tune path wrote its request index ("00 dev 1", "01 pl
/// 160", ...) while the cap path had written 305327 and up, so the helper skipped every tune command as stale and
/// both climbs stopped on "the helper did not run sequence 1 within 15 s". Wraps every 11.6 days; the helper's
/// idle exit (20 minutes) re-seeds it from the file, so a wrap costs at most one tune step.
pub fn wire_seq() -> u64 {
crate::platform::unix_now() % 999_990
}
/// How many leading lines of `text` are still the ones the helper saw at its start: `skip` while the file only grew
/// and its first `skip` lines read as before; 0 when the file shrank OR was rewritten (the same count, other text).
/// PC 2, 7 October 2026 (every tune refused since the 14:35Z boot, "helper started" three times with no command run):
/// the engine writes its four command lines with fs::write over a stale four-line file, so the count never dropped,
/// the skip never reset, and the helper read every new command as "present at start".
pub fn effective_skip(initial: &str, text: &str, skip: usize) -> usize {
if text.lines().count() < skip {
return 0;
}
let same_prefix = text.lines().take(skip).eq(initial.lines().take(skip));
if same_prefix { skip } else { 0 }
}
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
/// when the file shrank or was rewritten: effective_skip). A stale `quit` or `remove` from an earlier engine is never a command.
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
let skip = if text.lines().count() < skip { 0 } else { skip };
text.lines().skip(skip).filter_map(parse_line).collect()
}
/// The line the helper logs when the command file changed and nothing in it is a command for it: how many lines, how
/// many are skipped as present at its start, the highest sequence it has run, so a silent helper reads as a skip or a
/// stale sequence in its own log. None when the file is empty (a truncation is not a command) or something will run.
pub fn nothing_to_run_line(text: &str, skip: usize, last_seq: u64, cmds: &[(u64, HelperCmd)]) -> Option<String> {
let n = text.lines().count();
if n == 0 {
return None;
}
let runnable = cmds.iter().any(|(s, c)| matches!(c, HelperCmd::Quit | HelperCmd::Remove) || *s > last_seq);
if runnable {
return None;
}
let stale: Vec<u64> = cmds.iter().map(|(s, _)| *s).filter(|s| *s <= last_seq).collect();
Some(format!(
"cmd.txt changed ({n} line(s)) but nothing to run: {skip} skipped as present at start, {} parsed, {} at or below the last sequence run {last_seq}",
cmds.len(),
stale.len()
))
}
/// 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 {
@ -126,30 +292,68 @@ pub fn readback_command() -> String {
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t) {{ Write-Output ($t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments) }}; exit 0")
}
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this
/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no
/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs.
/// The protected folder's PowerShell expression: %ProgramData%\Igneum\helper.
pub fn protected_dir_expression() -> &'static str {
"Join-Path $env:ProgramData 'Igneum\\helper'"
}
/// The files the protected copy needs: the engine and the mingw runtime it links (packaging/windows/make-payload.sh).
pub const PROTECTED_FILES: [&str; 4] = ["igneum-app.exe", "libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll"];
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is the installed
/// executable; the script copies it and its runtime DLLs into the protected folder (V6-06: inheritance cut,
/// Administrators and SYSTEM full, Users read and execute, owner Administrators) and points the task at that copy.
/// Principal: the signed-in user, S4U, highest run level; no trigger; may start on battery; one hour limit per run;
/// multiple starts are ignored while one runs.
pub fn register_script(exe: &Path) -> String {
let exe = exe.display().to_string().replace('\'', "''");
let exe_s = exe.display().to_string();
let src = exe_s.rfind(['\\', '/']).map(|i| exe_s[..i].to_string()).unwrap_or_default().replace('\'', "''");
let copies: String = PROTECTED_FILES
.iter()
.map(|f| format!("if (Test-Path -LiteralPath (Join-Path $src '{f}')) {{ Copy-Item -LiteralPath (Join-Path $src '{f}') -Destination (Join-Path $helperDir '{f}') -Force }}\r\n"))
.collect();
format!(
"$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest\r\n\
"$src = '{src}'\r\n\
$helperDir = {pdir}\r\n\
New-Item -ItemType Directory -Force -Path $helperDir | Out-Null\r\n\
& icacls.exe $helperDir /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' /setowner '*S-1-5-32-544' | Out-Null\r\n\
{copies}\
$helperExe = Join-Path $helperDir 'igneum-app.exe'\r\n\
$a = New-ScheduledTaskAction -Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir\r\n\
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Highest\r\n\
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\
Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\
exit 0\r\n",
dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(),
pdir = protected_dir_expression(),
name = TASK_NAME
)
}
/// V6-06: whether the installed exe (its sha256 `candidate`) may replace the protected copy: only when the signed
/// manifest verifies under `pub_hex` and its windows entry names that very hash. Ok carries the manifest's version.
pub fn refresh_allowed(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str, candidate: &str) -> Result<String, String> {
let m = crate::manifest::verify_and_parse(manifest_bytes, sig_hex, pub_hex).map_err(|e| format!("the update manifest does not verify ({e}); the protected copy stays"))?;
let want = m.windows.as_ref().map(|w| w.engine_sha256.clone()).unwrap_or_default();
if want.is_empty() {
return Err("the signed manifest names no engine hash (publish-manifest.sh --win-engine); the protected copy stays".into());
}
if want != candidate.to_ascii_lowercase() {
return Err(format!("{candidate} is not the exe the signed manifest names ({want}); the protected copy stays"));
}
Ok(m.version)
}
/// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task).
pub fn start_command() -> String {
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
}
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
/// The PowerShell that says whether the task is registered AND its action's exe is still there (exit 0), or not (exit 1).
/// A task whose exe has gone (the stale-task class of 7 October 2026: a logon task pointing at a folder that was not
/// there any more) reads as not registered, so the next cap apply with Power control on registers it again through the
/// one approved step instead of starting a task that cannot run.
pub fn query_command() -> String {
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
}
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
@ -185,8 +389,7 @@ pub fn start() -> Result<(), String> {
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
/// task) or after 20 idle minutes. `dir` is `<app data>/app/sweep`.
pub fn run_helper(dir: &Path) -> i32 {
let _ = std::fs::create_dir_all(dir);
let cmd_file = dir.join("cmd.txt");
let made = std::fs::create_dir_all(dir);
let log_file = dir.join("helper.log");
let log = |line: &str| {
use std::io::Write;
@ -195,20 +398,63 @@ 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();
// the facts a silent end leaves nothing of (PC 1, 7 October 2026, 22:08 BST): which exe, which version, the folder,
// what the command file held; and every exit from here on writes its reason to helper.exit
let exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|e| format!("unknown ({e})"));
let cmd_state = match std::fs::read_to_string(dir.join("cmd.txt")) {
Ok(t) => format!("{} line(s)", t.lines().count()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => "absent".to_string(),
Err(e) => format!("unreadable ({e})"),
};
log(&format!("helper facts: exe {exe}, version {}, pid {}, dir {}{}, cmd.txt {cmd_state}", crate::engine::VERSION, std::process::id(), dir.display(), made.as_ref().map(|_| "").unwrap_or(" (NOT writable)"), ));
if let Err(e) = made {
write_exit(dir, crate::platform::unix_now(), 2, &format!("the helper folder could not be made: {e}"));
return 2;
}
let mut code = 0;
let mut body = || {
code = run_helper_loop(dir, &log);
code
};
run_guarded(dir, &log, &mut body)
}
fn run_helper_loop(dir: &Path, log: &dyn Fn(&str)) -> i32 {
let cmd_file = dir.join("cmd.txt");
beat(dir, crate::platform::unix_now());
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
// same second, and every later start did the same; so the lines present at the start are skipped whole
// (quit and remove included), and a file that shrinks starts the count again
let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default();
let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
let mut skip = initial.lines().count();
let initial_text = initial.clone();
let mut last_text = initial;
let mut dev = "0".to_string();
let mut idle = Instant::now();
let smi = crate::platform::tool("nvidia-smi");
loop {
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
if text != last_text {
skip = effective_skip(&initial_text, &text, skip);
last_text = text.clone();
for (seq, c) in text.lines().filter_map(parse_line) {
let cmds = commands_after(&text, skip);
// a changed file that yields nothing is said, never silent (PC 1, 7 October 2026, 21:08Z to 21:46Z on the
// 0.3.20 helper: six starts, every 2-line rewrite read as "present at start", no line after "helper started")
if let Some(line) = nothing_to_run_line(&text, skip, last_seq, &cmds) {
log(&line);
}
for (seq, c) in cmds {
// V6-06: the rising-sequence guard first, quit and remove included
if seq <= last_seq {
continue;
}
match c {
HelperCmd::Quit => {
log("quit");
write_exit(dir, crate::platform::unix_now(), 0, "quit (the engine asked)");
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return 0;
}
HelperCmd::Remove => {
@ -217,9 +463,10 @@ pub fn run_helper(dir: &Path) -> i32 {
crate::platform::quiet(&mut p);
let ok = p.status().map(|s| s.success()).unwrap_or(false);
log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" }));
write_exit(dir, crate::platform::unix_now(), if ok { 0 } else { 1 }, if ok { "remove (the task unregistered itself)" } else { "remove failed (Unregister-ScheduledTask returned an error)" });
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return if ok { 0 } else { 1 };
}
_ if seq <= last_seq => continue,
HelperCmd::Reregister => {
last_seq = seq;
idle = Instant::now();
@ -227,10 +474,23 @@ pub fn run_helper(dir: &Path) -> i32 {
log(&format!("{seq} reregister: no installed exe found"));
continue;
};
let script = dir.join("register-power-task.ps1");
let ok = std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), register_script(&target).as_bytes()].concat()).is_ok() && {
// V6-06: the protected copy is refreshed only when the signed manifest names this exe's hash
let updates = dir.parent().map(|p| p.join("updates")).unwrap_or_default();
let verdict = match (std::fs::read(updates.join("manifest.json")), std::fs::read_to_string(updates.join("manifest.json.sig")), crate::manifest::sha256_file(&target)) {
(Ok(b), Ok(sg), Ok(h)) => refresh_allowed(&b, sg.trim(), crate::edition::ota_key(), &h),
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(format!("no verified manifest or hash to check against ({e}); the protected copy stays")),
};
let version = match verdict {
Ok(v) => v,
Err(e) => {
log(&format!("{seq} reregister refused: {e}"));
continue;
}
};
log(&format!("{seq} reregister: {} is the exe manifest {version} names; refreshing the protected copy", target.display()));
let ok = {
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]);
p.args(crate::platform::encoded_command_args(&register_script(&target)));
crate::platform::quiet(&mut p);
p.status().map(|s| s.success()).unwrap_or(false)
};
@ -240,6 +500,11 @@ pub fn run_helper(dir: &Path) -> i32 {
let now = q.output().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
log(&format!("{seq} reregister {}: the task now runs {now}", if ok { "ok" } else { "failed" }));
}
HelperCmd::Driver(v) => {
last_seq = seq;
crate::driverinstall::run_in_helper(dir, seq, &v, &log);
idle = Instant::now();
}
HelperCmd::Dev(d) => {
last_seq = seq;
idle = Instant::now();
@ -261,8 +526,11 @@ 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)");
write_exit(dir, crate::platform::unix_now(), 0, "idle 20 min");
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
return 0;
}
beat(dir, crate::platform::unix_now());
std::thread::sleep(Duration::from_millis(500));
}
}
@ -282,6 +550,182 @@ pub fn helper_dir() -> PathBuf {
mod tests {
use super::*;
/// Known failed first (V6-06, the master's R1 review, 8 October 2026): a bare `quit` or `remove` carried no sequence
/// and so skipped the guard every other verb passes; a stale line re-added to the file ended the helper. Now every
/// verb carries a rising sequence, quit and remove included.
#[test]
fn quit_and_remove_carry_a_sequence_and_a_stale_one_is_ignored() {
assert_eq!(parse_line("quit"), None, "a bare quit is not a command");
assert_eq!(parse_line("remove"), None, "a bare remove is not a command");
assert_eq!(parse_line("12 quit"), Some((12, HelperCmd::Quit)));
assert_eq!(parse_line("12 remove"), Some((12, HelperCmd::Remove)));
assert_eq!(parse_line("quit 12"), None);
// the loop itself: a stale quit (sequence at or under the highest seen) is skipped; a rising one ends the helper
let dir = std::env::temp_dir().join(format!("igneum-helper-seq-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("cmd.txt"), "5 dev 0\n").unwrap();
let d2 = dir.clone();
let t = std::thread::spawn(move || run_helper_loop(&d2, &|_| {}));
std::thread::sleep(Duration::from_millis(900));
std::fs::write(dir.join("cmd.txt"), "5 dev 0\n3 quit\n").unwrap();
std::thread::sleep(Duration::from_millis(1500));
assert!(!t.is_finished(), "a quit with a stale sequence must not end the helper");
std::fs::write(dir.join("cmd.txt"), "5 dev 0\n3 quit\n6 quit\n").unwrap();
let started = Instant::now();
while !t.is_finished() && started.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(100));
}
assert!(t.is_finished(), "a quit with a rising sequence ends the helper");
assert_eq!(t.join().unwrap(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// Known failed first (V6-06): the task's action was the per-user install's exe under LOCALAPPDATA, which the user
/// (and anything running as the user) can overwrite, and the scheduler then ran it elevated with no prompt. The one
/// elevated step now copies the exe and its runtime DLLs into %ProgramData%\Igneum\helper, a folder only
/// administrators and SYSTEM may write (inheritance cut, Users read and execute), and the task runs that copy.
#[test]
fn the_task_runs_a_protected_copy_the_user_cannot_write() {
let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"));
assert!(s.contains("Join-Path $env:ProgramData 'Igneum\\helper'"), "{s}");
assert!(s.contains("icacls") && s.contains("/inheritance:r"), "{s}");
for grant in ["*S-1-5-32-544:(OI)(CI)F", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-545:(OI)(CI)RX"] {
assert!(s.contains(grant), "missing {grant}: {s}");
}
assert!(!s.contains("(OI)(CI)M") && !s.contains("S-1-5-32-545:(OI)(CI)F"), "users never get modify or full: {s}");
for f in ["igneum-app.exe", "libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll"] {
assert!(s.contains(&format!("Copy-Item -LiteralPath (Join-Path $src '{f}')")), "{f} is not copied: {s}");
}
assert!(s.contains("$src = 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
assert!(s.contains("-Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir"), "{s}");
assert!(!s.contains("-Execute 'C:\\Users"), "the action is never the user-writable exe: {s}");
assert_eq!(protected_dir_expression(), "Join-Path $env:ProgramData 'Igneum\\helper'");
}
/// Known failed first (V6-06): the helper's `reregister` copied whatever exe sat in the install folder into the
/// task's action. Now the protected copy is refreshed only when the signed manifest names the exe's sha256
/// (platforms.windows.engine_sha256), so a swapped binary never becomes the elevated one.
#[test]
fn a_refresh_of_the_protected_copy_needs_the_signed_manifest_to_name_the_exe() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = crate::manifest::hex_encode(sk.verifying_key().as_bytes());
let good = "ab".repeat(32);
let text = format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{}","size":1,"kind":"inno-setup","engine_sha256":"{good}"}}}}}}"#, "cd".repeat(32));
let sig = crate::manifest::hex_encode(&sk.sign(text.as_bytes()).to_bytes());
assert_eq!(refresh_allowed(text.as_bytes(), &sig, &pk, &good), Ok("2.0.2".to_string()));
assert!(refresh_allowed(text.as_bytes(), &sig, &pk, &"ef".repeat(32)).unwrap_err().contains("not the exe the signed manifest names"));
let other = crate::manifest::hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes());
assert!(refresh_allowed(text.as_bytes(), &sig, &other, &good).is_err(), "a signature under another key is refused");
let bare = format!(r#"{{"version":"2.0.2","platforms":{{"windows":{{"url":"https://x/s.exe","sha256":"{}","size":1,"kind":"inno-setup"}}}}}}"#, "cd".repeat(32));
let sig2 = crate::manifest::hex_encode(&sk.sign(bare.as_bytes()).to_bytes());
assert!(refresh_allowed(bare.as_bytes(), &sig2, &pk, &good).unwrap_err().contains("names no engine"), "a manifest without the engine hash refreshes nothing");
}
/// V6-06: the window host stays unprivileged (asInvoker; a prompt it raises for the engine's one step is a child, not
/// the host); every elevated script of the app travels inline in the command line, never as a file under a folder
/// the user can write between the write and the run.
#[test]
fn the_window_host_is_unprivileged_and_elevated_scripts_travel_inline() {
let manifest = include_str!("../../windows/host.manifest");
assert!(manifest.contains(r#"<requestedExecutionLevel level="asInvoker" uiAccess="false"/>"#), "{manifest}");
assert!(!manifest.contains("requireAdministrator") && !manifest.contains("highestAvailable"));
let args = crate::platform::encoded_command_args("exit 0");
assert_eq!(args, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", "ZQB4AGkAdAAgADAA"]);
assert!(!register_script(Path::new(r"C:\p\igneum-app.exe")).contains("-File "), "no script file is run");
}
/// Known-failed first (PC 1, 7 October 2026, 22:08 BST): the helper's process died at once after each of six starts
/// with no line after "helper started", and the engine toggled nothing and said nothing. Now a panic is a FAULT
/// line, an exit reason and exit 101, every planned exit writes its reason, and the engine's notice carries it.
#[test]
fn a_helper_that_dies_at_start_leaves_its_reason_and_the_engine_names_it() {
let dir = std::env::temp_dir().join(format!("igneum-helper-exit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let lines = std::sync::Mutex::new(Vec::new());
let log = |l: &str| lines.lock().unwrap().push(l.to_string());
let mut body = || -> i32 { panic!("the cmd file lock was held by pid 4242") };
let code = run_guarded(&dir, &log, &mut body);
assert_eq!(code, 101);
let logged = lines.lock().unwrap().join("\n");
assert!(logged.contains("FAULT helper: panicked: the cmd file lock was held by pid 4242"), "{logged}");
let since = crate::platform::unix_now() - 5;
let exit = last_exit(&dir, since).expect("the exit reason is written");
assert_eq!(exit.0, 101);
assert!(exit.1.starts_with("panicked: the cmd file lock"));
assert!(!dir.join(ALIVE_FILE).exists() || !alive(&dir), "no heartbeat survives the exit");
// the engine's notice: the reason when there is one, the meaning of none when there is none
let n = not_running_notice(Some(exit), 12);
assert!(n.starts_with("power control: helper not running (panicked: the cmd file lock was held by pid 4242; exit 101;"), "{n}");
assert!(n.contains("no heartbeat"), "helper_fault_line still reads it as the helper not answering: {n}");
let none = not_running_notice(None, 12);
assert!(none.starts_with("power control: helper not running (") && none.contains("ended from outside or crashed before it ran") && none.contains("1000"), "{none}");
// an older exit is not this start's
assert_eq!(exit_reason("1000 0 idle 20 min", 2000), None);
assert_eq!(exit_reason("3000 0 idle 20 min", 2000), Some((0, "idle 20 min".to_string())));
// a planned end writes its reason too
write_exit(&dir, 5000, 0, "quit (the engine asked)");
assert_eq!(last_exit(&dir, 4000), Some((0, "quit (the engine asked)".to_string())));
let _ = std::fs::remove_dir_all(&dir);
}
/// PC 1, 7 October 2026, 21:08Z to 21:46Z, known-failed first on the 0.3.20 rule: the helper started over the 2-line
/// restore ("423698 dev 0", "423699 rgc"), the next writer put 2 fresh lines in its place, and the old rule (skip the
/// first N lines whenever the file holds N or more) read both as present at start: six starts, one "helper started"
/// line each, nothing after. The 0.3.21 rule (effective_skip) runs them, and when a changed file truly yields nothing
/// the helper says so in its log instead of nothing.
#[test]
fn a_two_line_rewrite_over_a_two_line_start_runs_and_a_silent_change_is_logged() {
let initial = "423698 dev 0\n423699 rgc\n";
let rewritten = "427312 dev 1\n427313 rgc\n";
let old_rule_skip = initial.lines().count();
assert_eq!(commands_after(rewritten, old_rule_skip), vec![], "the 0.3.20 rule: both lines present at start, the helper silent");
let skip = effective_skip(initial, rewritten, old_rule_skip);
assert_eq!(skip, 0, "0.3.21: a rewritten prefix resets the skip");
let cmds = commands_after(rewritten, skip);
assert_eq!(cmds, vec![(427312, HelperCmd::Dev("1".into())), (427313, HelperCmd::ClockReset)]);
assert_eq!(nothing_to_run_line(rewritten, skip, 423699, &cmds), None, "something runs: no complaint");
// the cases that still yield nothing are said: stale sequences, and lines present at start
let stale = commands_after("423690 dev 1\n423691 rgc\n", 0);
let l = nothing_to_run_line("423690 dev 1\n423691 rgc\n", 0, 423699, &stale).unwrap();
assert_eq!(l, "cmd.txt changed (2 line(s)) but nothing to run: 0 skipped as present at start, 2 parsed, 2 at or below the last sequence run 423699");
let l = nothing_to_run_line(rewritten, 2, 423699, &commands_after(rewritten, 2)).unwrap();
assert!(l.contains("2 skipped as present at start, 0 parsed"), "{l}");
assert_eq!(nothing_to_run_line("", 0, 1, &[]), None, "a truncation is not a command and not a complaint");
assert_eq!(nothing_to_run_line("10 quit\n", 0, 9, &commands_after("10 quit\n", 0)), None, "quit runs");
assert!(include_str!("powertask.rs").matches("nothing_to_run_line(").count() >= 2, "the loop logs it");
}
/// A helper started over a file that holds `remove` skips it (a line present at the start is never a command, 6
/// October 2026), so the engine writes `remove` only once the helper's heartbeat is there: start first, then write.
#[test]
fn remove_is_written_after_the_heartbeat_not_before_the_start() {
assert_eq!(commands_after("5 remove\n", 1), vec![], "the remove present at the start is skipped");
assert_eq!(commands_after("5 remove\n", 0), vec![(5, HelperCmd::Remove)], "a remove added after the start runs");
let s = include_str!("engine.rs");
let off = s.find("fn power_control_off").unwrap();
let body = &s[off..off + 2500];
let ensure = body.find("ensure_running").expect("the off path waits for the heartbeat");
let write = body.find(" remove\\n\"").expect("then writes remove (with its sequence, V6-06)");
assert!(ensure < write, "the heartbeat comes before the remove line");
}
/// Known-failed first (7 October 2026, 19:5x BST): the helper knew no driver verb, so a driver install needed an
/// elevated prompt. The verb carries a vendor WORD only: the helper resolves the file, the hash, the signer and the
/// arguments from the signed table itself, so a writer of cmd.txt can never choose what runs elevated.
#[test]
fn the_helper_takes_a_driver_verb_for_a_vendor_word_and_nothing_else() {
assert_eq!(parse_line("305327 driver intel"), Some((305327, HelperCmd::Driver("intel".into()))));
assert_eq!(parse_line("305328 driver nvidia"), Some((305328, HelperCmd::Driver("nvidia".into()))));
assert_eq!(parse_line("305329 driver amd"), Some((305329, HelperCmd::Driver("amd".into()))));
assert_eq!(parse_line("305330 driver C:\\evil.exe"), None, "a path is not a vendor word");
assert_eq!(parse_line("305331 driver apple"), None, "no installer for that vendor");
assert_eq!(parse_line("driver intel"), None, "a sequence number is required");
assert_eq!(smi_args("0", &HelperCmd::Driver("intel".into())), None, "a driver verb is never an nvidia-smi call");
}
#[test]
fn only_fixed_verbs_with_digit_arguments_parse() {
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
@ -293,9 +737,36 @@ mod tests {
assert_eq!(smi_args("0", &HelperCmd::MemReset).unwrap(), vec!["-i", "0", "-rmc"]);
assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into()))));
assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form");
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
assert_eq!(parse_line("7 quit"), Some((7, HelperCmd::Quit)));
assert_eq!(parse_line("7 remove"), Some((7, 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![], "a bare quit is no command (V6-06)");
assert_eq!(commands_after("8 quit\n", 0), vec![(8, HelperCmd::Quit)]);
// PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's
// four-line rewrite: the count never dropped, so the old rule skipped every new command
let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n";
let fresh = "401888 dev 0\n401889 pl 575\n401890 rgc\n401891 rmc\n";
assert_eq!(commands_after(fresh, 4), vec![], "the old rule: a same-length rewrite is invisible");
assert_eq!(effective_skip(stale, fresh, 4), 0, "a rewrite resets the skip");
assert_eq!(commands_after(fresh, effective_skip(stale, fresh, 4)).len(), 4, "every new command runs");
// the file only grew: the stale prefix stays skipped (a stale quit at start is never a command)
let grown = format!("{stale}401888 dev 0\n");
assert_eq!(effective_skip(stale, &grown, 4), 4);
assert_eq!(commands_after(&grown, effective_skip(stale, &grown, 4)), vec![(401888, HelperCmd::Dev("0".into()))]);
assert_eq!(effective_skip("quit\n", "quit\n401888 dev 0\n", 1), 1, "a stale quit stays skipped while the file only grows");
assert_eq!(effective_skip(stale, "401888 dev 0\n", 4), 0, "a shorter file resets as before");
assert_eq!(effective_skip("", fresh, 0), 0);
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
// (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step
let w = wire_seq();
assert!(w + 3 <= 999_999 && parse_line(&format!("{} rmc", w + 3)).is_some());
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
assert!(!alive_at("", 1791309325));
assert!(!alive_at("soon", 1791309325));
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
assert!(readback_command().contains("Actions[0].Execute"));
@ -321,16 +792,29 @@ mod tests {
#[test]
fn the_registration_is_per_user_highest_no_trigger_fixed_action() {
let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"));
assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}");
assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType Interactive"), "{s}");
// V6-06: the action is the protected copy under %ProgramData%, taken from the install folder by the elevated step
assert!(s.contains("-Execute $helperExe -Argument '--power-helper' -WorkingDirectory $helperDir"), "{s}");
assert!(s.contains("$src = 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
// 0.3.24 (main, 7 October 2026, PC 1): the task runs whether or not a user is logged on (S4U, the user's own token,
// no stored password), so a start from a job's session or the engine's own tune is accepted; known-failed first
// (the Interactive-only form)
assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType S4U"), "{s}");
assert!(!s.contains("-LogonType Interactive"), "the interactive-only form is gone: {s}");
assert!(s.contains("[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"), "the signed-in user, never a literal");
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
// no window at logon or at a start (the project lead, 7 October 2026, PC 2's Terminal window): the action is the app's own exe,
// a windows-subsystem program with no console, never powershell.exe, cmd.exe or a `start` of a batch file
assert!(s.contains("-Argument '--power-helper'"), "{s}");
assert!(!s.contains("powershell.exe") && !s.contains("cmd.exe") && !s.contains("cmd /c start"), "the task's action must be the exe itself: {s}");
assert!(s.contains("-Hidden"), "the task is hidden in the scheduler too");
// the registered probe also wants the action's exe on disk and the task enabled (the stale-task class)
let q = query_command();
assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}");
// a quote in the path cannot break out of the literal
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
assert!(q.contains("$src = 'C:\\it''s'"), "{q}");
assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'"));
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false"));
assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'"));

View file

@ -26,6 +26,58 @@ pub const MIN_VRAM_MB_PROVE_ONLY: u64 = 23_552;
/// miner), the devnet's shard until its fee switch at DAA 210,000; `nvidia-smi` reports 32,607 for the RTX 5090.
pub const VRAM_MB_PROTOTYPE_SHARD: u64 = 31_000;
/// Main's rule (7 October 2026, the fleet's prover roll): a 10 GB card never completes the compressed step on the
/// 0.3.17 pair (p1-3080: 29 of 29 proofs died at the memory wall, device_used 9,859 of 9,885 MiB, 0 paid in 2,167
/// claims), so the prover REFUSES to start, Settings or not, unless an NVIDIA card of 12 GB or more is present, with
/// the reason shown; the lower-memory SP1 threshold is being measured on a rented 3080 and moves this line when
/// it lands. `nvidia-smi` reports a 12 GB card at about 12,208 MiB, a 10 GB card at about 10,240.
// Igneum 2.0 (8 October 2026, the rented-card rows): the miner holds 6.1 GiB resident and a compressed shard proof
// peaks at 7.5 GiB, so a 12 GB card running both kills the prover in a device allocation; mine and prove together
// needs 16 GB (nvidia-smi reports a 16 GB card at about 16,376 MiB); under it the card alternates (prove instead).
pub const MIN_VRAM_MB_PROVE_ANY: u64 = 15_800;
/// The sentence the Proving tile and the log carry when the rule refuses (verbatim from main; the kit's box-prover
/// says the same).
pub const PROVE_UNDER_12GB_LINE: &str = "mining and proving together need a 16 GB card; this card does one at a time, mining continues";
/// The rule above as one question: None when a present NVIDIA card of 12 GB or more exists (or when no NVIDIA card
/// is present at all, since then the CPU and Apple paths decide), Some(the sentence) when every present NVIDIA card is
/// under 12 GB.
/// The sentence the tile carries while the switch holds the miner off.
pub fn prove_instead_line(cards: &[CardState]) -> String {
let names: Vec<String> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| format!("{} ({} GB)", c.name, gb(c.vram_mb))).collect();
format!("proving instead of mining on {} (a card under 16 GB holds one, not both)", names.join(", "))
}
/// The keys of the cards the switch holds off: every present NVIDIA card under 12 GB, and only when the rule refuses
/// (no present NVIDIA card at or above 12 GB); with a bigger card present the small ones keep mining and nothing is held.
pub fn prove_instead_cards(cards: &[CardState]) -> Vec<String> {
if prove_refused_under_12gb(cards).is_none() {
return Vec::new();
}
// the cards the prover alternates with (time-share); a mining-only card is not held, no proof fits on it anyway
cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && crate::device::mode(c.vram_mb, c.enabled) == crate::device::Mode::TimeShare).map(|c| c.key.clone()).collect()
}
pub fn prove_refused_under_12gb(cards: &[CardState]) -> Option<&'static str> {
// review B F07 (8 October 2026): the mode per device decides; mine-and-prove together is a tested configuration only
// (src/device.rs), so a card under that line alternates (prove instead) whatever its memory says on paper
let nvidia: Vec<&CardState> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present()).collect();
let proves_beside_or_alone = |c: &&CardState| matches!(crate::device::mode(c.vram_mb, c.enabled), crate::device::Mode::Simultaneous | crate::device::Mode::ProveOnly);
if nvidia.is_empty() || nvidia.iter().any(proves_beside_or_alone) { None } else { Some(PROVE_UNDER_12GB_LINE) }
}
/// The per-device modes for the window and the log (review B F07): every present NVIDIA card with its mode and one line.
pub fn prove_modes(cards: &[CardState]) -> Vec<crate::state::ProveMode> {
cards
.iter()
.filter(|c| c.vendor == "nvidia" && c.present())
.map(|c| {
let m = crate::device::mode(c.vram_mb, c.enabled);
crate::state::ProveMode { key: c.key.clone(), name: c.name.clone(), mode: m.word().into(), line: crate::device::line(&c.name, c.vram_mb, m) }
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Decision {
pub on: bool,
@ -94,6 +146,18 @@ pub fn decide(cards: &[CardState], os: &str, wsl_answers: Option<bool>, ram_mb:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_modes_per_card_follow_the_device_rule_and_the_refusal_follows_the_modes() {
let cards = vec![card("nvidia", "NVIDIA GeForce RTX 5090", 32_607), card("nvidia", "NVIDIA GeForce RTX 3060", 12_208), card("nvidia", "NVIDIA GeForce RTX 4060", 8_188)];
let m = prove_modes(&cards);
assert_eq!(m.iter().map(|x| x.mode.as_str()).collect::<Vec<_>>(), vec!["simultaneous", "time-share", "mining-only"]);
assert!(m[1].line.contains("one at a time (12 GB)"));
assert!(prove_refused_under_12gb(&cards).is_none(), "the 5090 proves beside its miner");
let small = vec![card("nvidia", "NVIDIA GeForce RTX 4080", 16_376), card("nvidia", "NVIDIA GeForce RTX 3060", 12_208)];
assert_eq!(prove_refused_under_12gb(&small), Some(PROVE_UNDER_12GB_LINE), "16 GB is not a tested simultaneous configuration: the prover alternates");
assert_eq!(prove_modes(&small)[0].mode, "time-share");
}
fn card(vendor: &str, name: &str, vram_mb: u64) -> CardState {
CardState { vendor: vendor.into(), name: name.into(), vram_mb, enabled: true, ..Default::default() }
@ -166,4 +230,85 @@ mod tests {
let d = decide(&[idle("nvidia", "RTX 4090", 24_564), card("nvidia", "RTX 5090", 32_607)], "linux", None, None);
assert!(d.line.contains("RTX 5090 (32 GB, mining too)"), "{}", d.line);
}
#[test]
fn the_prover_refuses_every_nvidia_card_under_12gb_and_says_why() {
let c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
assert_eq!(prove_refused_under_12gb(&[c3080.clone()]), Some(PROVE_UNDER_12GB_LINE));
// Igneum 2.0: a 12 GB card beside it does NOT lift the refusal (6.1 GiB of miner plus a 7.5 GiB proof kill the
// prover on 12 GB, measured on rented 3060 and 4060 cards on 8 October 2026); a 16 GB card does (16,376 MiB)
let c3080_12 = card("nvidia", "NVIDIA GeForce RTX 3080 12GB", 12_208);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3080_12]), Some(PROVE_UNDER_12GB_LINE));
// review B F07 (8 October 2026): a 16 GB card beside it no longer lifts the refusal: mine-and-prove together is a
// TESTED configuration only (24 GB and up); 16 to 24 GB alternates (src/device.rs)
let c4080_16 = card("nvidia", "NVIDIA GeForce RTX 4080", 16_376);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c4080_16]), Some(PROVE_UNDER_12GB_LINE));
let c4090_24 = card("nvidia", "NVIDIA GeForce RTX 4090", 24_564);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c4090_24]), None);
// an idle 24 GB card lifts it too
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3090]), None);
// no NVIDIA card at all: not this rule's question (Apple and AMD have their own sentences)
assert_eq!(prove_refused_under_12gb(&[card("amd", "AMD Radeon RX 9070 XT", 16_368), card("apple", "Apple M5 Max", 0)]), None);
// a card that left the machine does not count either way
let mut gone = card("nvidia", "NVIDIA GeForce RTX 3090", 24_564); gone.removed_at = 1.0;
assert_eq!(prove_refused_under_12gb(&[c3080, gone]), Some(PROVE_UNDER_12GB_LINE));
assert_eq!(PROVE_UNDER_12GB_LINE, "mining and proving together need a 16 GB card; this card does one at a time, mining continues");
}
#[test]
fn prove_instead_holds_only_the_small_cards_and_only_when_the_rule_refuses() {
let mut c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
c3080.key = "nvidia:0:NVIDIA GeForce RTX 3080".into();
assert_eq!(prove_instead_cards(&[c3080.clone()]), vec!["nvidia:0:NVIDIA GeForce RTX 3080".to_string()]);
assert_eq!(prove_instead_line(&[c3080.clone()]), "proving instead of mining on NVIDIA GeForce RTX 3080 (10 GB) (a card under 16 GB holds one, not both)");
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
assert!(prove_instead_cards(&[c3080.clone(), c3090]).is_empty());
assert!(prove_instead_cards(&[card("amd", "AMD Radeon RX 9070 XT", 16_368)]).is_empty());
}
}
/// MF-10 (docs/plans/miner-faults.md, 7 October 2026): a `sp1-gpu-server` built for another card's architecture fails
/// every proof in 12 s with `CudaRustError: named symbol not found` and nothing notices. `card_cap` is the card's
/// compute capability as nvidia-smi prints it ("12.0", "8.9", "8.6"); `server_archs` are the `sm_NN` words found in
/// the server binary (empty = unknown, which passes: an SDK server may carry no arch string). Some(the sentence) when
/// the server names architectures and none is the card's.
pub fn server_mismatch(card_cap: &str, server_archs: &[String]) -> Option<String> {
let cap = card_cap.trim();
if cap.is_empty() || server_archs.is_empty() {
return None;
}
let want = format!("sm_{}", cap.replace('.', ""));
if server_archs.iter().any(|a| a.trim() == want) {
return None;
}
Some(format!("the proving server is built for {} and this card is {want} (compute capability {cap}); proving stays off here until a server for this card is installed", server_archs.join(", ")))
}
/// A proof failure line that names the architecture class (MF-10): the server's kernels do not load on this card.
pub fn is_arch_failure(text: &str) -> bool {
text.contains("named symbol not found") || text.contains("no kernel image is available")
}
#[cfg(test)]
mod arch_tests {
use super::*;
/// The prover roll, 7 October 2026: sm_86 servers on a 4070 (8.9) and a 5090 (12.0) failed every proof; the
/// 3080 and 3090 (8.6) proved.
#[test]
fn a_server_for_another_card_is_refused() {
let sm86 = vec!["sm_86".to_string()];
assert!(server_mismatch("8.9", &sm86).unwrap().contains("built for sm_86 and this card is sm_89"));
assert!(server_mismatch("12.0", &sm86).unwrap().contains("sm_120"));
assert_eq!(server_mismatch("8.6", &sm86), None);
// a fat binary names every card
let fat = vec!["sm_86".to_string(), "sm_89".to_string(), "sm_120".to_string()];
assert_eq!(server_mismatch("8.9", &fat), None);
// unknown on either side passes (the proof failure line is the second guard)
assert_eq!(server_mismatch("", &sm86), None);
assert_eq!(server_mismatch("8.9", &[]), None);
assert!(is_arch_failure("CudaRustError: named symbol not found"));
assert!(!is_arch_failure("PermissionDenied"));
}
}

View file

@ -31,6 +31,10 @@ use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The lease deadline a shard proof is given (the F07 budget row's clock: transfer, start, prove and rebuild inside
/// the exclusive window); a chain run and an aggregation carry their run limits.
const SHARD_DEADLINE_S: u64 = 600;
/// One shard the node lists for this machine's keys (`igneum_getAssignedShards`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Work {
@ -135,6 +139,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,23 +171,9 @@ 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> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
None,
timeout + Duration::from_secs(2),
);
let _ = std::fs::remove_file(&tmp);
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout)
}
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
@ -245,6 +251,42 @@ fn read_verifier(shared: &Shared) {
/// every second and killed when the app quits, the setting goes off or `limit` passes (a proof must never outlive
/// the app). Returns (exit ok, output).
fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (bool, String) {
let (code, out) = run_tool_code(shared, t, exe, args, env, limit, log);
(code == Some(0), out)
}
/// The lease every igneum-prove-host spawn carries (the V6-07 sub-lane's contract, src/device.rs host_env): the first
/// NVIDIA card as the host enumerates it, the free memory read now, the grant the card's mode allows, the deadline.
/// (free, budget, env) so the refusal words can name the budget; a machine without an NVIDIA card gets device 0 and
/// no grant, which the host reads as the CPU shape.
fn host_lease(shared: &Shared, workload: crate::device::Workload, deadline_s: u64) -> (u64, u64, Vec<(&'static str, String)>) {
let (index, vram, mining) = {
let st = shared.state.lock().unwrap();
st.mining.cards.iter().filter(|c| c.vendor == "nvidia").min_by_key(|c| c.index).map(|c| (c.index as u32, c.vram_mb, c.enabled && c.state == "mining")).unwrap_or((0, 0, false))
};
let used = crate::detect::nvidia_memory_used().get(&index.to_string()).copied().unwrap_or(0);
let free = vram.saturating_sub(used);
let budget = crate::device::lease_mib(vram, crate::device::mode(vram, mining), free);
let env = crate::device::host_env(index, workload, free, budget, deadline_s);
shared.log(&format!("LEASE holder=prover device=nvidia:{index} workload={} free_mib={free} budget_mib={budget} deadline_s={deadline_s}", workload.word()));
(free, budget, env)
}
/// The host's floor refusal (exit 78): the card's words, the LEASE line with the refusal, Some(words); None otherwise.
fn host_floor_refusal(shared: &Shared, code: Option<i32>, out: &str, budget: u64) -> Option<String> {
if code != Some(crate::device::HOST_FLOOR_EXIT) {
return None;
}
let words = crate::device::floor_refusal_words(out, budget);
shared.log(&format!("LEASE holder=prover event=refused exit={} budget_mib={budget} words=\"{words}\"", crate::device::HOST_FLOOR_EXIT));
set(shared, |p| {
p.status = "waiting".into();
p.message = words.clone();
});
Some(words)
}
fn run_tool_code(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (Option<i32>, String) {
// Windows: a script file exports the environment and execs the host; the arguments travel as $1, $2... (the
// single-quoted rule of src/wslhost.rs). The file lives until the run ends.
let (mut cmd, _script) = if t.wsl {
@ -252,7 +294,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
body.push_str(&format!("exec {} \"$@\"", crate::wslhost::sq(&exe.display().to_string())));
let file = match crate::wslhost::write_script("prove-run", &body) {
Ok(f) => f,
Err(e) => return (false, format!("cannot write the WSL run script: {e}")),
Err(e) => return (None, format!("cannot write the WSL run script: {e}")),
};
let argv: Vec<&str> = args.iter().map(|a| a.as_str()).collect();
let c = crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &argv);
@ -266,12 +308,12 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
(c, None)
};
crate::platform::quiet(&mut cmd);
let Ok(file) = std::fs::File::create(log) else { return (false, format!("cannot write {}", log.display())) };
let Ok(err) = file.try_clone() else { return (false, "cannot clone the log handle".into()) };
let Ok(file) = std::fs::File::create(log) else { return (None, format!("cannot write {}", log.display())) };
let Ok(err) = file.try_clone() else { return (None, "cannot clone the log handle".into()) };
cmd.stdin(std::process::Stdio::null()).stdout(file).stderr(err);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return (false, format!("{}: {e}", exe.display())),
Err(e) => return (None, format!("{}: {e}", exe.display())),
};
let started = Instant::now();
let status = loop {
@ -280,7 +322,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
Ok(None) => {}
Err(e) => {
let _ = child.kill();
return (false, format!("{}: {e}", exe.display()));
return (None, format!("{}: {e}", exe.display()));
}
}
let stop = {
@ -296,8 +338,8 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st
};
let out = std::fs::read_to_string(log).unwrap_or_default();
match status {
Some(st) => (st.success(), out),
None => (false, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())),
Some(st) => (st.code().or(Some(-1)), out),
None => (None, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())),
}
}
@ -359,6 +401,33 @@ pub fn start(shared: Arc<Shared>, bin_dir: PathBuf) {
.expect("prover thread");
}
/// Waits up to device::FREE_WAIT_S for every NVIDIA device's memory used to read under device::FREE_MIB (the miner's
/// dataset evicted with its process). None when free; Some(the highest reading) when a device still holds memory.
fn wait_for_free_memory(shared: &Arc<Shared>) -> Option<u64> {
let deadline = Instant::now() + Duration::from_secs(crate::device::FREE_WAIT_S);
let mut worst: u64;
loop {
let used = crate::detect::nvidia_memory_used();
worst = used.values().copied().max().unwrap_or(0);
if used.is_empty() || worst < crate::device::FREE_MIB {
// the record line the fleet lane's rows read (INT-11): the device read free before the proof, with the wait
let waited = crate::device::FREE_WAIT_S.saturating_sub(deadline.saturating_duration_since(Instant::now()).as_secs());
shared.log(&format!("DEVICE event=free used_mib={worst} waited_s={waited} holder=prover"));
return None;
}
if Instant::now() >= deadline {
shared.log(&format!("DEVICE event=not-free used_mib={worst} waited_s={} holder=prover", crate::device::FREE_WAIT_S));
shared.log(&format!("prover: a device still holds {worst} MiB after {} s; the miner's dataset is not evicted, the shard waits", crate::device::FREE_WAIT_S));
return Some(worst);
}
set(shared, |p| {
p.status = "waiting".into();
p.message = format!("waiting for the card's memory to read free ({worst} MiB still used)");
});
std::thread::sleep(Duration::from_secs(2));
}
}
fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let mut attempted: HashSet<(String, u32)> = HashSet::new();
let mut attempted_segments: HashSet<u64> = HashSet::new();
@ -382,6 +451,8 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
read_ids(&shared, &t);
}
}
// MF-10: the server architecture check, once per tools probe (None = not read yet; Some(None) = matches)
let mut arch_check: Option<Option<String>> = None;
loop {
std::thread::sleep(Duration::from_secs(10));
let enabled = shared.settings.lock().unwrap().prove;
@ -451,6 +522,57 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
}
}
}
// main's rule (7 October 2026, the fleet's prover roll): a 10 GB card dies at the compressed step every time
// (p1-3080, 29 of 29, 0 paid), so with every present NVIDIA card under 12 GB the prover refuses to start,
// Settings or not, and says why; the measured threshold moves the line (src/provedefault.rs)
if t.cuda {
let cards = shared.state.lock().unwrap().mining.cards.clone();
match crate::provedefault::prove_refused_under_12gb(&cards) {
Some(line) => {
// settings.prove_instead (main's routing): the owner chose the prover over the miner on this card; the
// engine holds the small cards' miners off while the prover runs and gives them back when it stops
let instead = shared.settings.lock().unwrap().prove_instead;
set(&shared, |p| p.under_12gb = true);
if instead {
shared.send(crate::engine::Cmd::ProveHold(true));
let msg = crate::provedefault::prove_instead_line(&cards);
set(&shared, |p| {
p.enabled = true;
p.available = true;
p.message = msg;
});
} else {
set(&shared, |p| {
p.enabled = true;
p.available = false;
p.status = "off".into();
p.message = line.into();
});
continue;
}
}
None => set(&shared, |p| p.under_12gb = false),
}
let modes = crate::provedefault::prove_modes(&cards);
set(&shared, |p| p.modes = modes);
}
// MF-10 (7 October 2026, the prover roll): a GPU server built for another card's architecture fails every
// proof; the card's compute capability and the server's sm_ words are read once per tools probe, a mismatch
// refuses the GPU path with the reason on the tile
if t.cuda {
if arch_check.is_none() {
arch_check = Some(server_arch_check(&shared, t));
}
if let Some(Some(line)) = arch_check.as_ref() {
set(&shared, |p| {
p.enabled = true;
p.available = false;
p.status = "off".into();
p.message = line.clone();
});
continue;
}
}
set(&shared, |p| {
p.enabled = true;
p.available = true;
@ -462,6 +584,16 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
});
continue;
}
// ledger N7 (7 October 2026, PC 1 on 0.3.18): a node before the exec RPC fix dies on igneum_getAssignedShards
// and igneum_getProofRecords when its exec follower holds no record (rpc.rs slices records[1..=0]), and the
// node reads synced seconds before a slow follower has one; nothing below asks until the follower reports a tip
if !crate::update::exec_has_record(shared.runtime.evm_port()) {
set(&shared, |p| {
p.status = "waiting".into();
p.message = "waiting for the node's execution layer".into();
});
continue;
}
// 1. the keys and the work list
let labels = labels(&shared);
let mut keys: Vec<(String, String)> = Vec::new();
@ -499,6 +631,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
if paid {
submitted.retain(|x| !(x.1 == h && x.2 == s));
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
shared.ladder_shard_paid(n, s as u64, wei);
set(&shared, |p| {
p.paid += 1;
p.paid_wei += wei;
@ -631,6 +764,18 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
});
continue;
};
// review B F07: in the time-share mode the miner has stepped off the card (ProveHold); the proof starts only once the
// device reads free, since pausing dispatch is not releasing memory. A card still holding its dataset after
// FREE_WAIT_S leaves the shard for another prover, with the reading on the tile.
if t.cuda && shared.state.lock().unwrap().proving.under_12gb {
if let Some(used) = wait_for_free_memory(&shared) {
set(&shared, |p| {
p.status = "waiting".into();
p.message = format!("the card did not release its memory ({used} MiB still used after {} s); the shard is left for another prover", crate::device::FREE_WAIT_S);
});
continue;
}
}
attempted.insert((w.hash.clone(), w.shard));
let label = keys.iter().find(|(_, h)| *h == w.key_hash).map(|(l, _)| l.clone()).unwrap_or_else(|| keys[0].0.clone());
let payout = shared.settings.lock().unwrap().address.clone();
@ -665,16 +810,26 @@ 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" };
let (ok, out) = run_tool(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &[("SP1_PROVER", prover_env), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard)));
let (_free, budget, lease) = host_lease(&shared, crate::device::Workload::Shard, SHARD_DEADLINE_S);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", prover_env), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &env, Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard)));
if let Some(words) = host_floor_refusal(&shared, code, &out, budget) {
return Err(format!("prover: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
// inside WSL2 left /tmp/sp1-cuda-0.sock owned by root, and this user's client cannot open it
let hint = if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
let hint = if crate::provedefault::is_arch_failure(&last) { " (MF-10: the proving server is built for another card's architecture; proving stays off here until a server for this card is installed)" } else if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
if crate::provedefault::is_arch_failure(&last) {
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, last.replace('"', "'")));
}
return Err(format!("prover: {last}{hint}"));
}
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
@ -854,7 +1009,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));
}
@ -867,7 +1022,14 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
args.push("--prev".into());
args.push(pf.to_string());
}
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log"));
let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Chain, 3 * 3600);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(3 * 3600), &dir.join("chain.log"));
if let Some(words) = host_floor_refusal(shared, code, &out, budget) {
return Err(format!("chain: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" };
@ -1031,7 +1193,14 @@ fn aggregate_once(shared: &Shared, t: &Tools, label: &str, payout: &str, attempt
args.push("--prev".into());
args.push(pf);
}
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(2 * 3600), &dir.join("aggregate.log"));
let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Aggregate, 2 * 3600);
let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")];
env.extend(lease.iter().map(|(k, v)| (*k, v.as_str())));
let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(2 * 3600), &dir.join("aggregate.log"));
if let Some(words) = host_floor_refusal(shared, code, &out, budget) {
return Err(format!("segment {first}..{last}: {words}"));
}
let ok = code == Some(0);
if !ok || !results.exists() {
return Err(format!("segment {first}..{last}: aggregator: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed")));
}
@ -1092,6 +1261,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";
@ -1132,3 +1316,23 @@ mod tests {
assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer"));
}
}
/// MF-10: the card's compute capability and the GPU server's architecture words, read through the same shell the
/// tools live in (WSL2 on Windows). None = no mismatch (or nothing readable); Some(line) = refuse with this reason.
fn server_arch_check(shared: &Shared, t: &Tools) -> Option<String> {
let script = "cap=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1); srv=\"$HOME/.sp1/bin/sp1-gpu-server\"; archs=$( [ -f \"$srv\" ] && strings \"$srv\" 2>/dev/null | grep -o 'sm_[0-9]*' | sort -u | tr '\\n' ' '); echo \"CAP=$cap\"; echo \"ARCHS=$archs\"";
let out = if t.wsl {
let file = crate::wslhost::write_script("prove-arch", script).ok()?;
crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string())?
} else {
crate::detect::run_timeout(std::process::Command::new("bash").args(["-c", script]), None, Duration::from_secs(20))?
};
let cap = out.lines().find_map(|l| l.strip_prefix("CAP=")).unwrap_or("").trim().to_string();
let archs: Vec<String> = out.lines().find_map(|l| l.strip_prefix("ARCHS=")).unwrap_or("").split_whitespace().map(|s| s.to_string()).collect();
shared.log(&format!("prover: card compute capability {}, server architectures {}", if cap.is_empty() { "unknown" } else { &cap }, if archs.is_empty() { "unknown".to_string() } else { archs.join(" ") }));
let line = crate::provedefault::server_mismatch(&cap, &archs);
if let Some(l) = &line {
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, l.replace('"', "'")));
}
line
}

View file

@ -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

@ -0,0 +1,181 @@
//! A quit that quits (PC 2, 8 October 2026, 21:24 UK: api/quit on 2.0.1 left all four processes up 90 s later while the
//! node sat in initial sync; the founder's plug-tune-play rule says no fault a user fixes by hand). The engine's own
//! quit path stops the miners, then the node, by their process handles with a short grace and a kill; this module is
//! the bound over the whole of it: a guard armed the moment a quit is asked, which, when the process is still here
//! after QUIT_BOUND_S whatever the engine is doing (a tick blocked on a syncing node's RPC, a stop that never
//! answers), ends the node and the workers by the pids the state records, never by name, prints the exit line and
//! leaves. The window reads the stage words from `quit_stage` meanwhile.
use std::sync::Arc;
/// The whole quit, whatever the node's state: miners, node, the last upload. Past this the guard ends what is left.
pub const QUIT_BOUND_S: u64 = 45;
/// The node's grace after its stop signal before the kill (30 before 2.0.2; a syncing node ignored it for longer).
pub const NODE_GRACE_S: u64 = 10;
/// How long the quit waits for the last log upload.
pub const UPLOAD_WAIT_S: u64 = 10;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
Miners,
Node,
Logs,
Forced,
}
impl Stage {
pub fn words(self) -> &'static str {
match self {
Stage::Miners => "quitting: the miners are being stopped",
Stage::Node => "quitting: the node is being stopped",
Stage::Logs => "quitting: the last log upload",
Stage::Forced => "quitting: the node did not stop in time; ending it by pid",
}
}
}
/// The clock over a quit (seconds since the engine started, the engine's own `secs`).
pub struct Guard {
armed_at: f64,
}
impl Guard {
pub fn new(now: f64) -> Guard {
Guard { armed_at: now }
}
pub fn overdue(&self, now: f64) -> bool {
now - self.armed_at >= QUIT_BOUND_S as f64
}
}
/// The pids the state records: the node's and every card's worker (0 = none).
pub fn recorded_pids(st: &crate::state::State) -> Vec<(&'static str, u32)> {
let mut v = Vec::new();
if st.node.pid > 0 {
v.push(("node", st.node.pid));
}
for c in &st.mining.cards {
if c.pid > 0 {
v.push(("worker", c.pid));
}
}
v
}
/// Ends each pid (platform::kill_pid: taskkill /PID /T /F on Windows, SIGKILL elsewhere); the record lines.
pub fn enforce_pids(pids: &[(&str, u32)]) -> Vec<String> {
pids.iter()
.map(|(what, pid)| {
crate::platform::kill_pid(*pid);
format!("QUIT event=forced what={what} pid={pid}")
})
.collect()
}
/// Whether a pid is still a live process (unix: kill -0; Windows: tasklist by pid).
pub fn pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(not(unix))]
{
let out = crate::platform::quiet(&mut std::process::Command::new(crate::platform::tool("tasklist"))).args(["/FI", &format!("PID eq {pid}"), "/NH"]).output();
out.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())).unwrap_or(false)
}
}
/// Sets the stage words the window shows.
pub fn set_stage(shared: &crate::engine::Shared, stage: Stage) {
let mut st = shared.state.lock().unwrap();
st.quitting = true;
st.quit_stage = stage.words().into();
}
/// Arms the guard once per process (the server's /api/quit and the engine's Cmd::Quit both call it): a thread that,
/// QUIT_BOUND_S after the ask, ends the recorded pids and leaves with the exit line, when the engine has not left
/// by itself. `exit_line` is what the window host reads ("EXIT" or "EXIT update").
pub fn arm(shared: Arc<crate::engine::Shared>, exit_line: &'static str) {
if shared.quit_armed.swap(true, std::sync::atomic::Ordering::SeqCst) {
return;
}
std::thread::Builder::new()
.name("quit-guard".into())
.spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(QUIT_BOUND_S));
let pids = recorded_pids(&shared.state.lock().unwrap());
shared.log(&format!("quit guard: still here {QUIT_BOUND_S} s after the ask; ending {} recorded process(es) by pid and leaving", pids.len()));
set_stage(&shared, Stage::Forced);
for l in enforce_pids(&pids) {
shared.log(&l);
}
shared.save_settings();
shared.log("stopped (by the quit guard)");
println!("{exit_line}");
use std::io::Write;
let _ = std::io::stdout().flush();
std::process::exit(0);
})
.expect("quit guard thread");
}
#[cfg(test)]
mod tests {
use super::*;
/// Known failed first: a node that never answers its stop (here a child that ignores SIGTERM, as a node stuck in
/// initial sync did on PC 2) is ended by its pid within the grace plus a second, never left running.
#[test]
fn a_node_that_never_answers_its_stop_is_ended_by_pid_within_the_bound() {
if cfg!(windows) {
return;
}
let (tx, _rx) = std::sync::mpsc::channel();
let log = std::env::temp_dir().join(format!("igneum-quit-node-{}.log", std::process::id()));
let mut p = crate::procs::spawn(crate::procs::Source::Node, std::path::Path::new("sh"), &["-c".into(), "trap '' TERM; while :; do sleep 1; done".into()], None, &log, &tx, &[]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let pid = p.pid();
let started = std::time::Instant::now();
let code = p.stop(NODE_GRACE_S.min(2));
assert!(started.elapsed() < std::time::Duration::from_secs(4), "the stop took {:?}", started.elapsed());
assert_eq!(code, Some(-9), "the grace ran out and the kill ended it");
assert!(!pid_alive(pid), "pid {pid} is still here");
let _ = std::fs::remove_file(log);
}
/// The guard: armed at the ask, overdue after the bound; it ends exactly the pids the state records (the node and
/// every card's worker), each by pid, and the words name the stage.
#[test]
fn the_guard_ends_the_recorded_pids_and_names_the_stage() {
assert_eq!(QUIT_BOUND_S, 45);
assert!(NODE_GRACE_S <= 10 && UPLOAD_WAIT_S <= 10, "the normal path stays well inside the bound");
let g = Guard::new(0.0);
assert!(!g.overdue(QUIT_BOUND_S as f64 - 1.0));
assert!(g.overdue(QUIT_BOUND_S as f64));
let mut st = crate::state::State::default();
st.node.pid = 4242;
st.mining.cards.push(crate::state::CardState { pid: 777, ..Default::default() });
st.mining.cards.push(crate::state::CardState { pid: 0, ..Default::default() });
assert_eq!(recorded_pids(&st), vec![("node", 4242), ("worker", 777)]);
assert_eq!(Stage::Miners.words(), "quitting: the miners are being stopped");
assert_eq!(Stage::Node.words(), "quitting: the node is being stopped");
assert_eq!(Stage::Logs.words(), "quitting: the last log upload");
assert_eq!(Stage::Forced.words(), "quitting: the node did not stop in time; ending it by pid");
if cfg!(windows) {
return;
}
let mut child = std::process::Command::new("sleep").arg("100").spawn().unwrap(); // console: a unix-only test child (the test returns above on Windows)
let pid = child.id();
let lines = enforce_pids(&[("node", pid)]);
assert_eq!(lines, vec![format!("QUIT event=forced what=node pid={pid}")]);
// the child is reaped here (a killed but unreaped child still answers kill -0 as a zombie)
let started = std::time::Instant::now();
let mut ended = None;
while ended.is_none() && started.elapsed() < std::time::Duration::from_secs(3) {
ended = child.try_wait().unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(ended.is_some(), "pid {pid} survived the guard");
assert!(!pid_alive(pid), "pid {pid} is still a process after the reap");
}
}

View file

@ -0,0 +1,553 @@
//! Every right the app will ever need is taken ONCE, at install, by one elevated step; the app never prompts at runtime;
//! an update asks again only when the list of rights grew (the project lead, 7 October 2026: "all the 'rights' need to be done on
//! install, and then on update if anything new").
//!
//! The step asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment):
//! a PC job may never need a click, so a silent install from a job logs "rights: deferred" and the next interactive start
//! of the app asks once. The WebView2 runtime is a right too, "webview2-runtime@<min>" with <min> the host loader's minimum
//! (app/windows/version.h IGNEUM_WEBVIEW2_MIN): the elevated step reads the Edge WebView2 client key (HKLM WOW6432Node and
//! HKCU) and runs the bundled evergreen bootstrapper silently when the runtime is absent or below <min>; a raised minimum is
//! a new id, so that update asks once.
//!
//! Windows: the installer's [Run] entry `igneum-app.exe --rights` (every install, silent ones included) compares the
//! installed rights manifest (`<app data>/app/rights.json`: the version and the list the elevated step completed) with
//! this build's RIGHTS; when a right is missing it writes rights.ps1 and runs it elevated once (the one UAC prompt: the
//! Power Helper task for clock and power control, the boot task, the inbound firewall rules for the node and the pool
//! miner), then writes the manifest; when nothing is missing it exits at once with no prompt. At runtime: Power control
//! is a plain toggle (the Power Helper task does the work with no prompt); a right the manifest lacks is a notice
//! ("run the installer again"), never a prompt. The prompt counts are a function here, tested with the known-failed
//! shapes first (before 0.3.22: a fresh install then Power control on prompted; the firewall rule prompted on the first
//! run; the boot task was registered at the engine's start).
use std::path::Path;
/// The rights this build needs, in the order the elevated step takes them. An id never changes meaning; a new need is
/// a new id (that is what makes an update ask once).
pub const RIGHTS: &[(&str, &str)] = &[
("power-helper-task", "the Igneum Power Helper task: the clock and power limits of NVIDIA cards with no prompt (src/powertask.rs)"),
// 0.3.24: the same task registered to run whether or not a user is logged on (S4U); a new id, so an install that holds the
// interactive-only form takes it again once at the update (main, 7 October 2026, PC 1)
("power-helper-task@unattended", "the Igneum Power Helper task in its unattended form: it runs whether or not a user is logged on, so a job's or the engine's own start is accepted (src/powertask.rs)"),
// V6-06 (8 October 2026): the same task pointed at the protected copy in %ProgramData%\Igneum\helper; a new id, so an
// install that holds the LOCALAPPDATA form takes it again once (by the running helper's own reregister where the
// signed manifest names the engine hash, else one prompt at the next interactive start)
("power-helper-task@protected", "the Igneum Power Helper task run from a protected copy of the engine that only administrators can write (src/powertask.rs)"),
("boot-task", "the Igneum Miner (boot) task: the engine starts at boot with nobody logged on (src/boot.rs)"),
("firewall-node", "the inbound firewall rule for igneumd.exe (other nodes can dial in)"),
("firewall-miner", "the inbound firewall rule for igneum-miner.exe (a pool's stratum port)"),
(WEBVIEW2_RIGHT, "the WebView2 runtime the window needs, at or above the host loader's minimum (installed silently from the bundled evergreen bootstrapper when absent or older)"),
// the driver lane's right (branch driver-hold-22, src/driverinstall.rs): the same Power Helper task with a two-hour run limit, so a
// vendor's driver installs unattended through it; at the merge the Power Helper line of script() becomes driverinstall::register_script
("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)"),
// Igneum 2.0 (8 October 2026): the node needs its proof verifier from block zero, and on Windows the host runs in WSL2;
// the feature is the one part that needs rights, taken here; the distro and the host follow with no prompt (src/verifier.rs)
(WSL2_RIGHT, "the Windows Subsystem for Linux (the feature and its kernel, no distribution): the node's proof verifier runs the payload's igneum-prove-host in WSL2 from block zero; the distribution installs with no prompt at the app's next start (src/verifier.rs)"),
];
/// The WSL2 feature right (Igneum 2.0): `wsl --install --no-distribution --no-launch` in the elevated step; a reboot
/// may follow before the feature answers. Idempotent: an installed feature returns at once.
pub const WSL2_RIGHT: &str = "wsl2-feature";
/// The host loader's minimum WebView2 runtime (app/windows/version.h IGNEUM_WEBVIEW2_MIN), read at build time so the two
/// never drift; the right's id carries it.
pub const WEBVIEW2_MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
pub const WEBVIEW2_RIGHT: &str = const_format_webview2_right();
/// The bundled bootstrapper in the install folder and its pinned sha file (packaging/windows/webview2.sha256).
pub const WEBVIEW2_BOOTSTRAPPER: &str = "MicrosoftEdgeWebview2Setup.exe";
pub const WEBVIEW2_KEY: &str = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
const fn webview2_min_from_header(h: &str) -> &str {
// the line `#define IGNEUM_WEBVIEW2_MIN "a.b.c.d"`: the text between the quotes
let b = h.as_bytes();
let key = b"IGNEUM_WEBVIEW2_MIN \"";
let mut i = 0;
while i + key.len() < b.len() {
let mut j = 0;
while j < key.len() && b[i + j] == key[j] {
j += 1;
}
if j == key.len() {
let start = i + key.len();
let mut end = start;
while end < b.len() && b[end] != b'"' {
end += 1;
}
// SAFETY of the slice: start and end sit on ASCII bytes of a str literal
match h.split_at(start).1.split_at(end - start).0 {
s => return s,
}
}
i += 1;
}
"0"
}
const fn const_format_webview2_right() -> &'static str {
// "webview2-runtime@" + WEBVIEW2_MIN, built once at compile time
const PREFIX: &str = "webview2-runtime@";
const MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
const LEN: usize = PREFIX.len() + MIN.len();
const BUF: [u8; LEN] = {
let mut out = [0u8; LEN];
let p = PREFIX.as_bytes();
let m = MIN.as_bytes();
let mut i = 0;
while i < p.len() {
out[i] = p[i];
i += 1;
}
let mut j = 0;
while j < m.len() {
out[p.len() + j] = m[j];
j += 1;
}
out
};
match std::str::from_utf8(&BUF) {
Ok(s) => s,
Err(_) => "webview2-runtime@0",
}
}
pub const MANIFEST_FILE: &str = "rights.json";
pub const SCRIPT_FILE: &str = "rights.ps1";
/// The manifest the elevated step leaves: which rights hold, from which version, when.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Manifest {
pub version: String,
pub rights: Vec<String>,
pub at: u64,
}
impl Manifest {
pub fn parse(text: &str) -> Option<Manifest> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Manifest {
version: v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(),
rights: v.get("rights").and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|r| r.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(),
at: v.get("at").and_then(|x| x.as_u64()).unwrap_or(0),
})
}
pub fn to_json(&self) -> String {
serde_json::json!({ "version": self.version, "rights": self.rights, "at": self.at, "format": "igneum-rights-1" }).to_string()
}
pub fn load(app_dir: &Path) -> Option<Manifest> {
std::fs::read_to_string(app_dir.join(MANIFEST_FILE)).ok().and_then(|t| Manifest::parse(&t))
}
pub fn save(&self, app_dir: &Path) -> std::io::Result<()> {
std::fs::write(app_dir.join(MANIFEST_FILE), self.to_json())
}
}
/// The ids this build wants that the installed manifest does not hold (every id when there is no manifest).
pub fn missing(installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> Vec<String> {
let have: Vec<&str> = installed.map(|m| m.rights.iter().map(|s| s.as_str()).collect()).unwrap_or_default();
wanted.iter().map(|(id, _)| *id).filter(|id| !have.contains(id)).map(|s| s.to_string()).collect()
}
/// Where the step runs: an interactive session that is not a job's may ask; anything else defers (the project lead, 7 October 2026:
/// no PC job may need a click).
pub fn may_ask(session_name: Option<&str>, job_env: bool) -> bool {
crate::boot::interactive_session(session_name) && !job_env
}
/// Is this process inside a remote job (the runner's IGNEUM_JOB_* environment)?
pub fn in_job_env() -> bool {
std::env::vars().any(|(k, _)| k.starts_with("IGNEUM_JOB_"))
}
/// The outcome of the install step.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
/// nothing missing: no prompt
Nothing,
/// a right is missing and this session may ask: one prompt
Asked,
/// a right is missing but this is a job's or a session-less run: no prompt, the next interactive start asks
Deferred,
}
pub fn step(installed: Option<&Manifest>, wanted: &[(&str, &str)], session_name: Option<&str>, job_env: bool) -> Step {
if missing(installed, wanted).is_empty() {
Step::Nothing
} else if may_ask(session_name, job_env) {
Step::Asked
} else {
Step::Deferred
}
}
/// The WebView2 version a registry read gave against the minimum: true when the runtime must be installed.
pub fn webview2_needs_install(installed: Option<&str>, min: &str) -> bool {
let parse = |v: &str| -> Vec<u64> { v.trim().split('.').map(|p| p.trim().parse::<u64>().unwrap_or(0)).collect() };
match installed.map(|v| v.trim()).filter(|v| !v.is_empty()) {
None => true,
Some(v) => parse(v) < parse(min),
}
}
/// What happens to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
/// the installer's --rights step (a fresh install or an update)
Install,
/// Power control switched on in Settings
PowerControlOn,
/// the engine's first run (the firewall rule, before 0.3.22)
FirstRun,
}
/// 0.3.22: how many administrator prompts an event raises. Only the install step asks, and only when a right is missing.
pub fn prompts(event: Event, installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> u32 {
match event {
Event::Install => if missing(installed, wanted).is_empty() { 0 } else { 1 },
Event::PowerControlOn | Event::FirstRun => 0,
}
}
/// Before 0.3.22, for the record: the installer asked nothing; the first run asked for the firewall rule; Power control on
/// asked when the Power Helper task was not registered yet.
pub fn legacy_prompts(event: Event, power_task_registered: bool) -> u32 {
match event {
Event::Install => 0,
Event::FirstRun => 1,
Event::PowerControlOn => if power_task_registered { 0 } else { 1 },
}
}
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
}
/// The one elevated script: every right in RIGHTS, idempotent (a rule is removed before it is added, a task is
/// registered with -Force). `exe` is the installed igneum-app.exe, `install_dir` its folder, `data_root` the user's
/// data root for the boot task.
pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String {
let node = ps_quote(&install_dir.join("igneumd.exe").display().to_string());
let miner = ps_quote(&install_dir.join("igneum-miner.exe").display().to_string());
let mut s = String::from("$ErrorActionPreference = 'Continue'\r\n# Igneum rights, one elevated step (src/rights.rs). Not for running by hand.\r\n");
s.push_str(&crate::powertask::register_script(exe).replace("exit 0\r\n", ""));
s.push_str(&crate::boot::register_script(exe, data_root).replace("exit 0\r\n", ""));
for (name, prog) in [("Igneum Miner node", node), ("Igneum Miner pool", miner)] {
s.push_str(&format!(
"& netsh.exe advfirewall firewall delete rule name='{name}' | Out-Null\r\n\
& netsh.exe advfirewall firewall add rule name='{name}' dir=in action=allow enable=yes profile=private,domain protocol=TCP program='{prog}' | Out-Null\r\n"
));
}
s.push_str(&webview2_script(install_dir));
s.push_str(&wsl2_script(install_dir));
s.push_str("exit 0\r\n");
s
}
/// The one elevated command line (V6-06): PowerShell with the rights script inline (-EncodedCommand), never a file.
pub fn elevated_line(powershell: &Path, exe: &Path, install_dir: &Path, data_root: &Path) -> String {
format!("\"{}\" {}", powershell.display(), crate::platform::encoded_command_args(&script(exe, install_dir, data_root)).join(" "))
}
/// The WSL2 part of the elevated script (Igneum 2.0): the feature and the kernel with no distribution and no window;
/// one log line either way; a reboot pending is said, never forced (the installer never restarts a PC by itself).
pub fn wsl2_script(install_dir: &Path) -> String {
let log = ps_quote(&install_dir.join("rights.log").display().to_string());
format!(
"$wslOn = $false; try {{ & wsl.exe --status *> $null; $wslOn = ($LASTEXITCODE -eq 0) }} catch {{}}\r\n\
if ($wslOn) {{ \"$(Get-Date -Format o) wsl2: on\" | Out-File -FilePath '{log}' -Append -Encoding utf8 }} else {{\r\n\
\x20 $wslOut = ''; try {{ $wslOut = (& wsl.exe --install --no-distribution --no-launch 2>&1 | ForEach-Object {{ \"$_\" }}) -join ' ' }} catch {{ $wslOut = \"$_\" }}\r\n\
\x20 \"$(Get-Date -Format o) wsl2: installed the feature and the kernel (exit $LASTEXITCODE; a restart of Windows may be needed before it answers): $wslOut\" | Out-File -FilePath '{log}' -Append -Encoding utf8\r\n\
}}\r\n"
)
}
/// The WebView2 part of the elevated script: the client key's pv (HKLM WOW6432Node, then HKCU), the bundled bootstrapper
/// /silent /install when absent or below the minimum (exit 0 required), the version read back, one log line either way.
pub fn webview2_script(install_dir: &Path) -> String {
let boot = ps_quote(&install_dir.join(WEBVIEW2_BOOTSTRAPPER).display().to_string());
let log = ps_quote(&install_dir.join("rights.log").display().to_string());
format!(
"function WV2 {{ foreach ($k in @('HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{key}', 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{key}')) {{ $v = (Get-ItemProperty $k -ErrorAction SilentlyContinue).pv; if ($v) {{ return \"$v\" }} }}; return '' }}\r\n\
$wvMin = '{min}'\r\n\
$wvHave = WV2\r\n\
$wvNeed = (-not $wvHave) -or ([version]$wvHave -lt [version]$wvMin)\r\n\
if ($wvNeed) {{\r\n\
\x20 if (Test-Path '{boot}') {{ $wp = Start-Process -FilePath '{boot}' -ArgumentList @('/silent', '/install') -Wait -PassThru -WindowStyle Hidden; Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', installed from the bundled bootstrapper, exit ' + $wp.ExitCode + ', now ' + (WV2)) }}\r\n\
\x20 else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', below ' + $wvMin + ', and no bootstrapper beside the app; the window opens the dashboard in the browser until the runtime is installed') }}\r\n\
}} else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: ' + $wvHave + ' ok (minimum ' + $wvMin + ')') }}\r\n",
key = WEBVIEW2_KEY,
min = WEBVIEW2_MIN
)
}
/// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted).
/// The rights the registered Igneum Power Helper can take by itself, elevated and with no prompt, through its `reregister`
/// verb (the helper re-registers its own task from the running build's script: src/powertask.rs). 0.3.24: the task's
/// unattended form is such a right, so an update by job on a PC with nobody at the desk (PC 2, 7 October 2026) takes it
/// without a click, where the deferred rule alone would have left it to "the next interactive start".
pub const HELPER_TAKEABLE: &[&str] = &["power-helper-task@unattended", "power-helper-task@protected"];
pub fn helper_can_take(missing: &[String], task_registered: bool) -> bool {
task_registered && !missing.is_empty() && missing.iter().all(|m| HELPER_TAKEABLE.contains(&m.as_str()))
}
/// The helper's answer to `<seq> reregister` in its log: Some(true) on "ok", Some(false) on "failed", None while no line.
pub fn reregister_ack(log: &str, seq: u64) -> Option<bool> {
let ok = format!(" {seq} reregister ok");
let failed = format!(" {seq} reregister failed");
log.lines().rev().find_map(|l| if l.contains(&ok) { Some(true) } else if l.contains(&failed) { Some(false) } else { None })
}
/// Takes the helper-takeable rights through the running Power Helper task: heartbeat first, then the reregister line, then
/// the helper's own ok line within 20 s. Windows only (elsewhere the task does not exist).
fn take_through_helper(missing: &[String]) -> Result<(), String> {
if !cfg!(windows) {
return Err("the Power Helper task is Windows only".into());
}
let dir = crate::powertask::helper_dir();
let _ = std::fs::create_dir_all(&dir);
crate::powertask::ensure_running(&dir, std::time::Duration::from_secs(12))?;
let seq = crate::powertask::wire_seq() + 1;
std::fs::write(dir.join("cmd.txt"), format!("{seq} reregister\n")).map_err(|e| e.to_string())?;
let until = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
let log = std::fs::read_to_string(dir.join("helper.log")).unwrap_or_default();
match reregister_ack(&log, seq) {
Some(true) => return Ok(()),
Some(false) => return Err(format!("the helper's reregister failed (its log names why); {} not taken", missing.join(", "))),
None if std::time::Instant::now() >= until => return Err(format!("the helper did not answer sequence {seq} reregister within 20 s; {} not taken", missing.join(", "))),
None => {}
}
}
}
pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result<bool, String> {
let installed = Manifest::load(app_dir);
match step(installed.as_ref(), RIGHTS, std::env::var("SESSIONNAME").ok().as_deref(), in_job_env()) {
Step::Nothing => return Ok(false),
Step::Deferred => {
let miss = missing(installed.as_ref(), RIGHTS);
// 0.3.24: what the registered helper can take itself, it takes now, elevated, with no prompt (PC 2 by job)
if helper_can_take(&miss, cfg!(windows) && crate::powertask::registered()) {
take_through_helper(&miss)?;
let mut rights: Vec<String> = installed.as_ref().map(|m| m.rights.clone()).unwrap_or_default();
rights.extend(miss.iter().cloned());
let _ = std::fs::create_dir_all(app_dir);
Manifest { version: version.to_string(), rights, at: crate::platform::unix_now() }.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?;
return Ok(true);
}
// a job's or a session-less install never raises a prompt (the project lead, 7 October 2026); the next interactive start asks once
return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", miss.len(), miss.join(", ")));
}
Step::Asked => {}
}
let _ = std::fs::create_dir_all(app_dir);
// V6-06: the script travels inline; no rights.ps1 under the data root is read by the elevated step (a copy is left
// for the record, after the run, never as its input)
#[cfg(windows)]
{
let line = elevated_line(&crate::platform::tool("powershell"), exe, install_dir, data_root);
crate::platform::run_elevated(&line)?;
let _ = std::fs::write(app_dir.join(SCRIPT_FILE), [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat());
}
#[cfg(not(windows))]
{
let _ = (exe, install_dir, data_root);
return Err("the rights step is Windows only".into());
}
#[allow(unreachable_code)]
{
let m = Manifest { version: version.to_string(), rights: RIGHTS.iter().map(|(id, _)| id.to_string()).collect(), at: crate::platform::unix_now() };
m.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?;
Ok(true)
}
}
/// Does the installed manifest hold this right? (The runtime's question before it would have prompted.)
pub fn held(app_dir: &Path, id: &str) -> bool {
Manifest::load(app_dir).map(|m| m.rights.iter().any(|r| r == id)).unwrap_or(false)
}
/// The sentence the dashboard shows for a right the manifest lacks.
pub fn missing_note(id: &str) -> String {
let what = RIGHTS.iter().find(|(i, _)| *i == id).map(|(_, d)| *d).unwrap_or(id);
format!("not set up on this PC ({what}); run the Igneum Miner installer again: it asks for administrator rights once and sets everything up")
}
#[cfg(test)]
mod tests {
use super::*;
/// Known failed first (V6-06): rights.ps1 was written to the user's data folder and run elevated with -File, so a
/// swap between the write and the run would have run as administrator. The script now travels inline.
#[test]
fn the_rights_step_runs_its_script_inline_under_the_command_line_cap() {
let (exe, dir, root) = (Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"), Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner"), Path::new(r"C:\Users\Admin\AppData\Local\igneum"));
let line = elevated_line(Path::new(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), exe, dir, root);
assert!(line.contains("-EncodedCommand ") && !line.contains("-File") && !line.contains(".ps1"), "{line}");
assert!(line.len() < 30_000, "the elevated line must fit the 32 K command line: {} chars", line.len());
}
fn m(rights: &[&str]) -> Manifest {
Manifest { version: "0.3.22".into(), rights: rights.iter().map(|s| s.to_string()).collect(), at: 1 }
}
/// Known-failed first: before 0.3.22 a fresh install asked nothing and then the first run and Power control on each
/// asked (two prompts on the way to a tuned card); 0.3.22 asks once at install and never again.
#[test]
fn a_fresh_install_then_power_control_on_shows_one_prompt_at_install_and_none_after() {
assert_eq!(legacy_prompts(Event::Install, false) + legacy_prompts(Event::FirstRun, false) + legacy_prompts(Event::PowerControlOn, false), 2, "the old way: two prompts");
assert_eq!(prompts(Event::Install, None, RIGHTS), 1, "the one prompt, at install");
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(prompts(Event::FirstRun, Some(&installed), RIGHTS), 0);
assert_eq!(prompts(Event::PowerControlOn, Some(&installed), RIGHTS), 0);
}
/// the project lead's rule of the same night: no PC job may need a click. Known-failed first: 3fbf4280's step asked on every install with
/// a missing right, a job's silent install included.
#[test]
fn the_rights_step_asks_only_in_an_interactive_session_that_is_not_a_jobs() {
assert_eq!(step(None, RIGHTS, Some("Console"), true), Step::Deferred, "a job's install (IGNEUM_JOB_* set): never a prompt");
assert_eq!(step(None, RIGHTS, None, false), Step::Deferred, "no interactive session: never a prompt");
assert_eq!(step(None, RIGHTS, Some("Console"), false), Step::Asked, "the person at the PC: one prompt");
let all = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(step(Some(&all), RIGHTS, Some("Console"), false), Step::Nothing);
assert_eq!(step(Some(&all), RIGHTS, None, true), Step::Nothing, "nothing missing: nothing, wherever it runs");
assert!(may_ask(Some("RDP-Tcp#2"), false) && !may_ask(Some("Console"), true) && !may_ask(None, false));
}
/// The WebView2 runtime as a right (packaging's step, named 7 October 2026). Known-failed first: before it the runtime
/// missing meant the window said "install the runtime from microsoft.com" and nothing installed it.
#[test]
fn the_webview2_runtime_is_a_right_with_the_minimum_in_its_id() {
assert_eq!(WEBVIEW2_MIN, "109.0.1518.78", "read from app/windows/version.h at build time");
assert_eq!(WEBVIEW2_RIGHT, "webview2-runtime@109.0.1518.78");
assert!(RIGHTS.iter().any(|(id, _)| *id == WEBVIEW2_RIGHT));
// absent runtime: one prompt at install; present at or above the minimum: the right still has to be taken once
// (the manifest records it), but the script installs nothing (its own check); a raised minimum is a new id
let old = m(&["power-helper-task", "power-helper-task@unattended", "power-helper-task@protected", "boot-task", "firewall-node", "firewall-miner", "webview2-runtime@100.0.0.0", "driver-install-task", WSL2_RIGHT]);
assert_eq!(missing(Some(&old), RIGHTS), vec![WEBVIEW2_RIGHT.to_string()], "a raised minimum is exactly one new right");
assert_eq!(prompts(Event::Install, Some(&old), RIGHTS), 1);
let now = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(prompts(Event::Install, Some(&now), RIGHTS), 0, "the same minimum: no prompt");
assert!(webview2_needs_install(None, WEBVIEW2_MIN), "absent: install");
assert!(webview2_needs_install(Some(""), WEBVIEW2_MIN));
assert!(webview2_needs_install(Some("108.0.1462.76"), WEBVIEW2_MIN), "below: install");
assert!(!webview2_needs_install(Some("109.0.1518.78"), WEBVIEW2_MIN), "equal: nothing");
assert!(!webview2_needs_install(Some("154.0.4258.62"), WEBVIEW2_MIN), "PC 2's runtime: nothing");
let s = webview2_script(Path::new("C:\\p\\Igneum Miner"));
assert!(s.contains("EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}") && s.contains("HKCU:"), "both registry forms");
assert!(s.contains("MicrosoftEdgeWebview2Setup.exe") && s.contains("'/silent', '/install'") && s.contains("-Wait -PassThru -WindowStyle Hidden"));
assert!(s.contains("$wvMin = '109.0.1518.78'") && s.contains("[version]$wvHave -lt [version]$wvMin"));
assert!(s.contains("webview2: ") && s.contains(" ok (minimum "), "one log line either way");
}
/// Known-failed first (PC 2 by job, 7 October 2026): the deferred rule left every new right to "the next interactive
/// start", which on a PC with nobody at the desk never comes. The task's unattended form is a right the registered
/// helper takes itself through `reregister`, elevated, no prompt; anything else stays deferred.
#[test]
fn a_job_install_takes_the_task_form_through_the_registered_helper_and_defers_the_rest() {
let one = vec!["power-helper-task@unattended".to_string()];
assert!(helper_can_take(&one, true));
assert!(!helper_can_take(&one, false), "no task registered: nothing can take it, deferred");
assert!(!helper_can_take(&[], true), "nothing missing: nothing to take");
assert!(!helper_can_take(&["power-helper-task@unattended".to_string(), "firewall-node".to_string()], true), "a firewall rule is not the helper's to take");
assert!(!helper_can_take(&["boot-task".to_string()], true));
assert_eq!(reregister_ack("1791409581 helper started\n1791409590 427400 reregister ok: the task now runs C:\\x\\igneum-app.exe --power-helper\n", 427400), Some(true));
assert_eq!(reregister_ack("1791409590 427400 reregister failed: the task now runs \n", 427400), Some(false));
assert_eq!(reregister_ack("1791409590 427399 reregister ok: ...\n", 427400), None, "another sequence's answer is not this one's");
assert_eq!(reregister_ack("", 427400), None);
// the install path: the deferred branch tries the helper before it defers, and only for what the helper can take
let s = include_str!("rights.rs");
let i = s.find("Step::Deferred => {").unwrap();
let body = &s[i..i + 1500];
let take = body.find("helper_can_take(&miss").expect("the helper is asked first");
let defer = body.find("rights: deferred").expect("then the deferral");
assert!(take < defer);
assert!(body.contains("take_through_helper(&miss)?") && body.contains("rights.extend(miss.iter().cloned())"), "the taken rights go into the manifest");
// the helper's reregister writes the running build's script: the S4U form (src/powertask.rs register_script)
assert!(crate::powertask::register_script(Path::new("C:\\p\\igneum-app.exe")).contains("-LogonType S4U -RunLevel Highest"));
}
/// 0.3.24: an install that holds every 0.3.23 right lacks exactly the unattended task form; the update asks once.
#[test]
fn the_unattended_task_form_is_one_new_right_for_a_0_3_23_install() {
let r0323 = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner", WEBVIEW2_RIGHT, "driver-install-task"]);
assert_eq!(missing(Some(&r0323), RIGHTS), vec!["power-helper-task@unattended".to_string(), "power-helper-task@protected".to_string(), WSL2_RIGHT.to_string()], "2.0: the WSL feature and the protected copy are the other rights a 0.3.23 install lacks");
assert_eq!(prompts(Event::Install, Some(&r0323), RIGHTS), 1);
let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\AppData\\Local\\igneum"));
assert!(s.contains("-TaskName 'Igneum Power Helper'") && s.contains("-LogonType S4U -RunLevel Highest"), "the rights step registers the unattended form: {s}");
}
/// V6-06, known-failed first (8 October 2026): a changed script re-asks nothing (rights are by id), so an installed
/// 2.0.1 PC would have kept its task on the LOCALAPPDATA exe for ever. The protected copy is a new id; a 2.0.1
/// install lacks exactly it; the running helper may take it itself (its reregister makes the copy, under the
/// signed manifest's engine hash) so an update by job asks no click, and an interactive start asks once otherwise.
#[test]
fn the_protected_helper_copy_is_one_new_right_for_a_2_0_1_install() {
assert!(RIGHTS.iter().any(|(id, _)| *id == "power-helper-task@protected"));
let r201 = m(&["power-helper-task", "power-helper-task@unattended", "boot-task", "firewall-node", "firewall-miner", WEBVIEW2_RIGHT, "driver-install-task", WSL2_RIGHT]);
assert_eq!(missing(Some(&r201), RIGHTS), vec!["power-helper-task@protected".to_string()]);
assert_eq!(prompts(Event::Install, Some(&r201), RIGHTS), 1);
assert!(helper_can_take(&["power-helper-task@protected".to_string()], true));
assert!(!helper_can_take(&["power-helper-task@protected".to_string()], false), "no task registered: the one prompt");
}
/// Igneum 2.0, known-failed first (main, 8 October 2026): the node refused to start on Windows without igneum-prove-host
/// and nothing installed it. The WSL feature is a right the installer's elevated step takes (no distribution, no
/// window); a 0.3.26 install lacks exactly it and asks once at the update.
#[test]
fn the_wsl2_feature_is_a_right_the_installer_takes_once() {
assert!(RIGHTS.iter().any(|(id, _)| *id == WSL2_RIGHT));
let r0326 = m(&["power-helper-task", "power-helper-task@unattended", "power-helper-task@protected", "boot-task", "firewall-node", "firewall-miner", WEBVIEW2_RIGHT, "driver-install-task"]);
assert_eq!(missing(Some(&r0326), RIGHTS), vec![WSL2_RIGHT.to_string()]);
assert_eq!(prompts(Event::Install, Some(&r0326), RIGHTS), 1);
let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\AppData\\Local\\igneum"));
assert!(s.contains("wsl.exe --install --no-distribution --no-launch"), "the feature and the kernel only: {s}");
assert!(s.contains("wsl.exe --status") && s.contains("wsl2: on"), "idempotent: an installed feature is a log line");
assert!(!s.contains("Restart-Computer") && !s.contains("shutdown"), "the installer never restarts the PC by itself");
}
#[test]
fn an_update_with_no_new_right_shows_no_prompt() {
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
assert_eq!(missing(Some(&installed), RIGHTS), Vec::<String>::new());
assert_eq!(prompts(Event::Install, Some(&installed), RIGHTS), 0);
}
#[test]
fn an_update_with_a_new_right_shows_exactly_one_prompt() {
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("example-new-right", "a right a later build needs")]).collect();
assert_eq!(missing(Some(&installed), &grown), vec!["example-new-right".to_string()]);
assert_eq!(prompts(Event::Install, Some(&installed), &grown), 1);
// and a manifest from an older build that lacks two rights still asks exactly once
let older = m(&["power-helper-task"]);
assert_eq!(missing(Some(&older), RIGHTS).len(), RIGHTS.len() - 1);
assert_eq!(prompts(Event::Install, Some(&older), RIGHTS), 1);
}
#[test]
fn the_manifest_round_trips_and_a_bad_file_reads_as_none() {
let a = m(&["power-helper-task", "boot-task"]);
assert_eq!(Manifest::parse(&a.to_json()), Some(a.clone()));
assert!(a.to_json().contains("\"format\":\"igneum-rights-1\""));
assert_eq!(Manifest::parse("not json"), None);
assert_eq!(Manifest::parse("{}"), Some(Manifest::default()));
}
#[test]
fn the_one_script_takes_every_right_and_is_idempotent() {
let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\igneum"));
assert!(s.contains("-TaskName 'Igneum Power Helper'"), "the Power Helper task");
assert!(s.contains("-TaskName 'Igneum Miner (boot)'"), "the boot task");
// the join's separator is the test host's (the box runs this on Linux), so the path is read by its file name
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner node'") && s.contains("Igneum Miner") && s.contains("igneumd.exe'"), "{s}");
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner pool'") && s.contains("igneum-miner.exe'"));
assert!(s.contains("firewall delete rule name='Igneum Miner node'"), "the rule is replaced, never doubled");
assert_eq!(s.matches("exit 0").count(), 1, "one exit at the end, the sub-scripts' own stripped");
assert!(!s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once (the bootstrapper's hidden run is a plain run)"); // console: a test string, not a spawn
for (id, _) in RIGHTS {
assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '@' || c == '.'), "ids are stable lowercase words, with an @version suffix where the minimum is part of the right: {id}");
}
assert!(missing_note("firewall-node").contains("run the Igneum Miner installer again"));
}
}

View file

@ -12,6 +12,9 @@ const INDEX: &str = include_str!("../ui/index.html");
const CSS: &str = include_str!("../ui/app.css");
const JS: &str = include_str!("../ui/app.js");
const MARK: &str = include_str!("../ui/mark.svg");
const DAG_JS: &str = include_str!("../ui/live-dag.js");
/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5)
const PROOF_JS: &str = include_str!("../ui/proof-core.js");
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
@ -138,6 +141,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('=')?;
@ -160,11 +172,31 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
return;
};
let rest = if rest.is_empty() { "/" } else { rest };
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
if req.method == "GET" {
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
if crate::uiota::SERVED.contains(&rel) {
if let Some(dir) = shared.ui_dir() {
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
if rel == "index.html" {
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
shared.send(Cmd::UiPageLoaded(v));
}
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
return;
}
}
}
}
match (req.method.as_str(), rest) {
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false),
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false),
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
@ -178,6 +210,20 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let v = shared.state_json();
json_resp(&mut stream, 200, v);
}
// the chain scene's feed (src/live.rs): the observer's /api/live with this machine's lane as "you", cached 2 s
("GET", "/api/live") => {
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
}
// miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local
// node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s
("GET", "/api/ladder") => {
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: Vec<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids));
}
("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
@ -251,14 +297,88 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ApplyCards(choices));
Ok(json!({ "ok": true }))
}
"/api/tune/tier" => {
// Ember Tune tiers (8 October 2026): apply a measured tier to one card or every card, no re-search
let tier = body.get("tier").and_then(|v| v.as_str()).unwrap_or("").to_string();
if !crate::ember::TIER_IDS.contains(&tier.as_str()) {
return Err("tier must be efficiency, balanced or max".into());
}
let key = body.get("key").and_then(|v| v.as_str()).filter(|k| !k.is_empty()).map(|k| k.to_string());
let applied: Vec<String> = {
let st = shared.state.lock().unwrap();
match &key {
Some(k) => {
let Some(c) = st.mining.cards.iter().find(|c| &c.key == k) else { return Err("no such card".into()) };
if crate::ember::tiers_stale(&c.tiers_class, &c.program_class) {
return Err(crate::ember::stale_text(&c.name, &c.tiers_class, &c.program_class));
}
if crate::ember::tier_from_json(&c.tiers, &tier).is_none() {
return Err("not measured yet: the search runs 2 min into steady mining".into());
}
vec![c.key.clone()]
}
None => st.mining.cards.iter().filter(|c| !crate::ember::tiers_stale(&c.tiers_class, &c.program_class) && crate::ember::tier_from_json(&c.tiers, &tier).is_some()).map(|c| c.key.clone()).collect(),
}
};
shared.send(Cmd::TuneTier(key, tier));
Ok(json!({ "ok": true, "applied": applied }))
}
"/api/tune/goal" => {
// Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
let climb = body.get("climb").and_then(|v| v.as_bool());
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
if let Some(k) = key {
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
shared.send(Cmd::TuneCardGoal(k, g));
return Ok(json!({ "ok": true }));
}
shared.send(Cmd::TuneGoal(goal, price, climb));
Ok(json!({ "ok": true }))
}
"/api/network" => {
// the network step: {network: "devnet-3" | "testnet-1", confirm: bool}; the engine applies config::network_switch
let want = body.get("network").and_then(|v| v.as_str()).unwrap_or("").to_string();
let confirm = body.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false);
shared.send(Cmd::Network(want, confirm));
Ok(json!({ "ok": true }))
}
"/api/region" => {
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
// hundredths of the chosen unit per kWh: up to 100,000 so a zero-decimal currency (yen, won) fits
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=100_000.0).contains(p));
let currency = body.get("currency").and_then(|v| v.as_str()).map(|c| c.to_string());
shared.send(Cmd::Region(region, price, currency));
Ok(json!({ "ok": true }))
}
"/api/heat" => {
// Ember Heat: the switch, the set point, the schedule with the window's clock offset, a typed room reading
let patch = crate::engine::HeatPatch {
on: body.get("on").and_then(|v| v.as_bool()),
set_c: body.get("set_c").and_then(|v| v.as_f64()),
schedule: body.get("schedule").and_then(|v| v.as_str()).map(|t| (t.to_string(), body.get("tz_min").and_then(|v| v.as_i64()).unwrap_or(0).clamp(-840, 840) as i32)),
room_c: body.get("room_c").and_then(|v| v.as_f64()),
};
if let Some(c) = patch.set_c {
if !(crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
return Err(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
}
}
if let Some((t, _)) = &patch.schedule {
crate::heat::parse_schedule(t)?;
}
if let Some(c) = patch.room_c {
if !(-20.0..=50.0).contains(&c) {
return Err("a room reading is -20 to 50 degrees".into());
}
}
shared.send(Cmd::Heat(patch));
Ok(json!({ "ok": true }))
}
"/api/settings" => {
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
let vote = body.get("vote").and_then(|v| v.as_bool());
@ -267,10 +387,24 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
let display_name = s("display_name");
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
}
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
"/api/prove/instead" => shared.set_prove_instead(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
"/api/prove/setup" => crate::prover::setup(shared),
"/api/drivers/install" => {
let vendor = body.get("vendor").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
return Err("vendor must be nvidia, amd or intel".into());
}
shared.send(Cmd::DriverInstall(vendor));
Ok(json!({ "ok": true }))
}
"/api/drivers/restart" => {
shared.send(Cmd::DriverRestart);
Ok(json!({ "ok": true }))
}
"/api/update/check" => {
shared.send(Cmd::CheckUpdate);
Ok(json!({ "ok": true }))
@ -279,6 +413,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::InstallUpdate);
Ok(json!({ "ok": true }))
}
"/api/ui/health" => {
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
shared.send(Cmd::UiHealth(err));
Ok(json!({ "ok": true }))
}
"/api/ui/builtin" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::UiBuiltin(on));
Ok(json!({ "ok": true }))
}
"/api/update/auto" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::AutoUpdate(on));
@ -288,11 +433,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::OpenUpdateFile);
Ok(json!({ "ok": true }))
}
// F14: the remote-jobs routes exist on the lab build only; the public miner has no route that runs a job
#[cfg(feature = "lab")]
"/api/jobs/allow" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::JobsAllow(on));
Ok(json!({ "ok": true }))
}
#[cfg(feature = "lab")]
"/api/jobs/check" => {
shared.send(Cmd::JobsCheck);
Ok(json!({ "ok": true }))
@ -351,11 +499,70 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
// miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under
// <data root>/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}.
// first-block-21: the page showed (or the user dismissed) the block card for the milestone at {count, at};
// the card is spent for later runs and a first-block card sets the lifetime flag (src/ladder.rs card_seen)
"/api/card/seen" => {
let count = body.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let at = body.get("at").and_then(|v| v.as_f64()).unwrap_or(0.0);
let (changed, mark) = { let mut ld = shared.ladder.lock().unwrap(); let mark = ld.first_card_mark(); (ld.card_seen(count, at), mark) };
if changed { shared.save_ladder(); }
// v2.0.1: a first card seen writes the per-address mark into settings.json (once per payout address, ever)
if changed {
if let Some((hash, when)) = mark {
let mut s = shared.settings.lock().unwrap();
let addr = s.address.to_ascii_lowercase();
if !addr.is_empty() && !s.first_block_shown.contains_key(&addr) {
s.first_block_shown.insert(addr, crate::config::FirstBlockMark { hash, at: when });
s.save(&shared.settings_path);
}
}
}
Ok(json!({ "ok": true, "changed": changed }))
}
"/api/card" => {
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block");
let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?;
let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?;
let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now());
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?;
if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); }
Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() }))
}
"/api/shot" => {
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
println!("SHOT {}", path.display());
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
}
"/api/quit" => {
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
// 2.0.2: armed here too, so a tick blocked on a syncing node (PC 2, 21:24 UK) cannot hold the quit
crate::quitguard::arm(shared.clone(), "EXIT update");
Ok(json!({ "ok": true }))
}
_ => 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,
@ -54,7 +77,7 @@ pub struct CardState {
pub detail: String, // memory, cores
pub device: String, // the worker's --device value (Windows)
pub enabled: bool,
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
pub state: String, // off | waiting | starting | ready | mining | restarting | failed (no "faulted": no fault is permanent, 7 October 2026) | unusable (the OS reports a problem) | removed (unplugged)
pub hash_now: f64, // MH/s, the last interval
pub hash_avg: f64, // MH/s since the start
pub accepted: u64,
@ -120,12 +143,23 @@ pub struct CardState {
pub clock_cap_mhz: u32, // the cap in force (0 = unlocked)
pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default)
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
// 0.3.25 (src/ember.rs amd_knob): the stock clock the driver's max-clock offsets apply to, and whether it speaks offsets
pub amd_stock_mhz: u32,
pub amd_gmax_offset: bool,
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
/// driver-check (7 October 2026): the comparable version the OS reports (nvidia-smi's for NVIDIA, Windows'
/// DriverVersion for AMD and Intel; empty = none found) and what the row says about it against the manifest's table
pub driver_os: String,
pub driver_offer: Option<crate::drivers::Offer>,
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
pub tune_control: bool, // both knobs reach the card (else measure only; sweep_note says why)
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
pub tune_source: String, // full | confirm | baseline
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
pub tune_before_mhs: f64,
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
pub tune_step: u32,
@ -135,6 +169,31 @@ pub struct CardState {
// Ember 2: the memory clock the last tune chose and the measured curve (every row of the last plan)
pub tune_mem_mhz: u32,
pub tune_curve: Vec<serde_json::Value>,
// the core-clock knob (7 October 2026, src/ember.rs lock_result; the UI lane's field shape): the chosen lock
// against the cap point's unlocked row, the step while the clock search runs, the moment and the stop reason
pub lock_mhz: u32, // the chosen core clock cap (0 = unlocked)
pub lock_mhs: f64,
pub lock_w: f64,
pub lock_mhw: f64,
pub unlocked_mhs: f64, // the cap point's row the lock is read against
pub unlocked_w: f64,
pub lock_step: u32, // the clock search's step while it runs (0 otherwise)
pub lock_steps: u32,
pub lock_at: f64, // unix s the lock point was taken (0 = never)
pub lock_note: String, // "rate fell 5.1 percent at 1200 MHz", "fingerprint mismatch at 1400 MHz, clocks reset", "the floor at 700 MHz", "no lever"
// Ember Tune tiers (8 October 2026, src/ember.rs tiers; the UI lane's field shape): the three rows of the card's own
// search (efficiency, balanced, max), the tier in force, why only stock exists, and when the tier was applied
pub tiers: Vec<serde_json::Value>,
pub tier: String, // "" | efficiency | balanced | max
pub tier_note: String, // "" or "no lever: ..."
pub tier_at: f64, // unix s (0 = never)
// the class flip (8 October 2026, main's order): the program class the stored tiers were measured under, when the
// class-flip re-run was queued (0 = not started), and the rate loss at the same lock under the new class (0 = unknown)
pub tiers_class: String,
pub tiers_remeasure_at: f64,
/// tiers-table-26: the rows came from the team's table (app/igneum-app/tiers), not this card's own search
pub tiers_table: bool,
pub knee_loss_pct: f64,
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
pub variant: String,
pub race_mhs: f64,
@ -163,6 +222,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...`).
@ -184,10 +247,23 @@ pub struct ProgramState {
pub message: String,
}
/// review B F07: one NVIDIA device's proving mode (src/device.rs) with its one line for the window.
#[derive(Clone, Serialize, Default, Debug, PartialEq)]
pub struct ProveMode {
pub key: String,
pub name: String,
pub mode: String,
pub line: String,
}
/// The prover service (src/prover.rs): assigned, proving, submitted, paid.
#[derive(Clone, Serialize, Default)]
pub struct ProvingState {
pub enabled: bool,
/// every present NVIDIA card is under 12 GB: the prover refuses unless settings.prove_instead holds the miner off
pub under_12gb: bool,
/// review B F07: the per-device mode (src/device.rs): simultaneous, time-share, mining-only, prove-only, with one line each
pub modes: Vec<ProveMode>,
/// the host runs here (macOS, Linux) or inside WSL2 (Windows); false = Set up needed
pub available: bool,
pub setup_hint: String,
@ -249,6 +325,27 @@ 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,
/// review B F04: the newest lock's kind from the node ("certified" | "recovery"; empty on a node without the field)
/// and its why; a recovery lock is labelled "recovery" on every surface, never "final"
pub lock_kind: String,
pub lock_why: String,
/// 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 +368,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)]
@ -297,6 +403,22 @@ pub struct SettingsState {
pub tune_goal: String,
pub power_price_pence: f64,
pub tune_climb: bool,
/// Ember Tune tiers: the fleet tier (efficiency | balanced | max), balanced from install
pub tune_tier: String,
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
pub region: String,
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
pub currency: String,
/// the network step: the chosen network ("" = the package's own)
pub network: String,
pub heat_on: bool,
pub heat_set_c: f64,
pub heat_schedule: String,
pub heat_tz_min: i32,
pub heat_room_c: f64,
pub heat_room_at: f64,
pub heat_offset_c: f64,
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
pub tune_period_s: u64,
@ -304,6 +426,67 @@ pub struct SettingsState {
pub dev_fee: bool,
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
pub proof_verify_trust: bool,
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
pub profile_public: bool,
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
pub ui_builtin: bool,
/// prove instead of mining on a card under 12 GB (settings.prove_instead)
pub prove_instead: bool,
}
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
#[derive(Clone, Serialize, Default)]
pub struct UiState {
/// the version compiled into this engine (ui/VERSION)
pub embedded_version: String,
/// the version the server serves now (the bundle's, or the embedded one)
pub active_version: String,
/// embedded | ota
pub source: String,
/// unix s the active bundle was swapped in (0 for the embedded interface)
pub installed_at: f64,
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
pub confirmed: bool,
/// the version the manifest publishes now ("" when the channel is withdrawn)
pub published_version: String,
pub busy: bool,
pub error: String,
/// versions refused here (never applied again)
pub bad: Vec<String>,
pub builtin: bool,
/// why the published version is not applied, when it is not
pub note: String,
}
/// Ember Heat (src/heat.rs): what the loop is doing, for the Cards strip, the Settings line and the Overview button.
#[derive(Clone, Serialize, Default)]
pub struct HeatState {
pub on: bool,
/// off | waiting | paused | heating | resting
pub phase: String,
/// the set point in force now (the schedule's slot, or the one set point)
pub set_c: f64,
/// the room estimate and where it came from: typed | card | none; the stated error; the reading's age
pub room_c: f64,
pub room_source: String,
pub room_error_c: f64,
pub room_age_s: f64,
/// this period's duty (0 to 1) and the share of the last hour the cards heated
pub duty: f64,
pub duty_hour: f64,
/// watts of heat now (the mining cards' draw; 0 while resting or with no draw reading), the cards' draw when
/// they heat (the last reading), and the period's average (duty times that)
pub heat_w: f64,
pub full_w: f64,
pub heat_avg_w: f64,
/// seconds until the slice changes
pub until_s: f64,
/// the one line under the switch
pub note: String,
pub period_s: f64,
/// the idle offset in use and whether a typed reading taught it
pub offset_c: f64,
pub offset_learned: bool,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
@ -367,9 +550,14 @@ pub struct UpdateState {
pub urgent_text: String,
pub activation_height: u64,
pub unsupported: bool,
/// F14: this version is unsupported and automatic updates are off: the engine pauses mining until Install now
pub hold_mining: bool,
pub min_supported: String,
pub channel: String,
pub published_at: String,
/// the network step: the manifest's default for a fresh install and whether the testnet is open
pub default_network: String,
pub testnet_open: bool,
pub file: String, // the downloaded installer or disk image (the manual path opens it)
pub updated_from: String, // set on the first run after an update
pub rolled_back: String, // set when the helper restored the previous version
@ -393,10 +581,24 @@ pub struct LogLine {
#[derive(Clone, Serialize, Default)]
pub struct State {
/// The founder's preview mark (8 October 2026): "preview 1" on an internal build, empty on a public cut. Read from
/// IGNEUM_PREVIEW at build time, never from the manifest, so the public aliases never carry it; the version itself
/// stays the crate's (the update check and the DMG's engine check compare that).
#[serde(default)]
pub preview: String,
/// "public" | "lab" (src/edition.rs, review B F14), the product name the build shows, the manifest channel it runs on
pub edition: String,
pub product: String,
pub channel: String,
pub version: String,
pub phase: String, // welcome | cards | address | dashboard
pub setup_done: bool,
pub network: String,
/// the network step: the chain this engine runs by name (igneum-devnet-3, igneum-testnet-1, igneum-devnet-v4) and
/// the one chosen for the next start when it differs ("" = none)
pub network_name: String,
pub network_pending: String,
pub network_error: String,
pub chain: String,
pub host: String, // the hostname, a friendly label only
pub display_name: String, // the user's name for this machine (settings), defaults to the hostname
@ -413,14 +615,24 @@ pub struct State {
pub clock: ClockState,
pub address: AddressState,
pub settings: SettingsState,
pub heat: HeatState,
pub dev_fee: DevFeeState,
pub update: UpdateState,
/// driver-check: the one install in flight (or the last), and the table's stamp
pub drivers: crate::drivers::DriverState,
pub ui: UiState,
pub jobs: JobsState,
pub events: Vec<Event>,
pub uptime_s: u64,
/// first-block-21: when this engine run began (unix s, the wall clock; uptime_s is monotonic and stops across a sleep)
pub started_at: f64,
pub now: f64,
pub quitting: bool,
/// 2.0.2: the quit's stage in words (src/quitguard.rs Stage), what the window shows while it waits
pub quit_stage: String,
pub live_page: String,
/// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now`
pub ladder: crate::ladder::LadderState,
}
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.

View file

@ -340,63 +340,8 @@ impl Run {
}
}
/// The elevated helper that sets limits for a tune (one administrator prompt per tune, not one per step). It polls
/// `<dir>/cmd.txt` twice a second; each line is `<seq> pl <watts>` (`nvidia-smi -i <device> -pl <watts>`; the
/// 0.3.9 form `<seq> <watts>` still works), `<seq> lgc <mhz>` (`-lgc 0,<mhz>`, the core clock cap; the memory clock
/// is never touched) or `<seq> rgc` (`-rgc`, unlocked); `quit` ends it. After 20 minutes without a new command it
/// restores `<restore watts>`, resets the clocks and exits by itself, so an engine that died mid-tune leaves the
/// card on its old limits. It writes what it ran to `<dir>/helper.log`.
pub fn helper_script_windows() -> &'static str {
r#"param([string]$Dir, [string]$Smi, [string]$Device, [string]$Restore)
$ErrorActionPreference = 'Continue'
$cmd = Join-Path $Dir 'cmd.txt'
$log = Join-Path $Dir 'helper.log'
$last = ''
$idle = Get-Date
"$(Get-Date -Format o) helper started: device $Device, restore $Restore W" | Out-File -FilePath $log -Append -Encoding utf8
while ($true) {
$c = ''
if (Test-Path -LiteralPath $cmd) { try { $c = (Get-Content -LiteralPath $cmd -Raw -ErrorAction Stop).Trim() } catch { $c = '' } }
if ($c -and $c -ne $last) {
$last = $c
$idle = Get-Date
if ($c -eq 'quit') { "$(Get-Date -Format o) quit" | Out-File -FilePath $log -Append -Encoding utf8; break }
foreach ($line in ($c -split "`n")) {
$p = ($line.Trim() -split ' ')
if ($p.Count -lt 2) { continue }
$op = $p[1]; $v = $p[-1]
if ($p.Count -eq 2 -and $v -match '^\d+$') { $op = 'pl' }
if ($op -eq 'pl' -and $v -match '^\d+$') {
$out = (& $Smi -i $Device -pl $v 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) $($p[0]) -pl $v : $out" | Out-File -FilePath $log -Append -Encoding utf8
} elseif ($op -eq 'lgc' -and $v -match '^\d+$') {
$out = (& $Smi -i $Device -lgc "0,$v" 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) $($p[0]) -lgc 0,$v : $out" | Out-File -FilePath $log -Append -Encoding utf8
} elseif ($op -eq 'rgc') {
$out = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) $($p[0]) -rgc : $out" | Out-File -FilePath $log -Append -Encoding utf8
} elseif ($op -eq 'lmc' -and $v -match '^\d+$') {
$out = (& $Smi -i $Device -lmc "$v,$v" 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) $($p[0]) -lmc $v,$v : $out" | Out-File -FilePath $log -Append -Encoding utf8
} elseif ($op -eq 'rmc') {
$out = (& $Smi -i $Device -rmc 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) $($p[0]) -rmc : $out" | Out-File -FilePath $log -Append -Encoding utf8
}
}
}
if (((Get-Date) - $idle).TotalMinutes -gt 20) {
$out = (& $Smi -i $Device -pl $Restore 2>&1 | Out-String).Trim()
$out2 = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) idle 20 min: restored $Restore W, clocks reset, and quit: $out / $out2" | Out-File -FilePath $log -Append -Encoding utf8
break
}
Start-Sleep -Milliseconds 500
}
exit 0
"#
}
/// The same helper for Linux (run through pkexec sh).
/// The unix helper for a tune (run through pkexec sh). The Windows script of the same protocol went with the engine's
/// legacy elevated branch (0.3.24): on Windows the Igneum Power Helper task is the only helper (src/powertask.rs).
pub fn helper_script_unix() -> &'static str {
r#"#!/bin/sh
# igneum sweep helper: $1 dir, $2 nvidia-smi, $3 device, $4 restore watts
@ -407,7 +352,7 @@ while true; do
c=""; [ -f "$dir/cmd.txt" ] && c=$(cat "$dir/cmd.txt" 2>/dev/null | tr -d '\r\n')
if [ -n "$c" ] && [ "$c" != "$last" ]; then
last="$c"; idle=$(date +%s)
if [ "$c" = "quit" ]; then echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break; fi
case "$c" in quit|[0-9]*" quit") echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break;; esac
printf '%s\n' "$c" | while IFS= read -r line; do
set -- $line
[ $# -ge 2 ] || continue
@ -439,6 +384,9 @@ pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> O
// Ember Tune (src/ember.rs, 5 October 2026): AMD is tuned through igneum-gpu-telemetry, Apple measures only;
// the tune itself says which at its start (the card row's note)
"apple" | "amd" => None,
// the first Intel card, the B580 on PC 1 (7 October 2026, docs/plans/intel-arc.md section 2.3): OpenCL
// exposes neither a cap nor a reading; Intel's path is IGCL (ControlLib.dll), the telemetry helper's next piece
"intel" => Some("not available: Intel Arc exposes no power cap or reading through OpenCL (the driver's IGCL counters come next)"),
_ => Some("not available: no power reading or cap for this card"),
}
}
@ -619,8 +567,10 @@ mod tests {
#[test]
fn helper_scripts_carry_the_protocol() {
for s in [helper_script_windows(), helper_script_unix()] {
for s in [helper_script_unix()] {
assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min"));
// V6-06: the engine writes "<seq> quit" (the Windows helper's guard); the unix helper takes that form too
assert!(s.contains(r#"case "$c" in quit|[0-9]*" quit")"#), "{s}");
assert!(s.contains("-lgc") && s.contains("-rgc"), "the clock cap and its reset");
assert!(s.contains("-lmc") && s.contains("-rmc"), "Ember 2: the memory clock and its reset");
}

View file

@ -0,0 +1,156 @@
//! The team's tiers table (tiers-table-26, 8 October 2026, main's order: the denominator lane's measured table,
//! app/igneum-app/tiers/class-v5-tiers.json, one entry per card class with the three tiers in the shape
//! src/ember.rs tier_from_json applies). A card whose own search has not run yet takes its class's rows from here, so
//! the three tabs show rate and watts from the first minute and a tap applies the table's point at once; the card's
//! own search (2 min into steady mining, weekly, on Tune, after a class flip) replaces the rows with its measured ones.
//! The rows carry `table: true` and the class's `label` ("measured" on a card the team measured, "estimated" for a
//! class read from the record) so the window says where they came from. A class with no lever (Apple, Intel) carries
//! the max row only. An AMD row carries the core offset and the power offset the lane measured; the apply path turns
//! the offset into the absolute clock the knob sends once the card's stock clock is known.
//! The match rule is the lane's (tiers/class-v5-tiers.mjs entryFor): the longest match string found in the card's
//! name wins, so "5070 Ti" beats "5070". The file is validated by tiers/class-v5-tiers.test.mjs in the gate.
use serde_json::{json, Value};
use std::sync::OnceLock;
const TABLE: &str = include_str!("../tiers/class-v5-tiers.json");
#[allow(dead_code)] // vendor and card name the entry in the log and the tests
pub struct Entry {
pub card: String,
pub matches: Vec<String>,
pub vendor: String,
pub label: String,
pub tiers: Vec<Value>,
}
pub struct Table {
pub class: String,
pub entries: Vec<Entry>,
}
fn parse(text: &str) -> Result<Table, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("the tiers table is not JSON: {e}"))?;
let class = v.get("class").and_then(|c| c.as_str()).unwrap_or("").to_string();
if class.is_empty() {
return Err("the tiers table names no class".into());
}
let mut entries = Vec::new();
for c in v.get("cards").and_then(|c| c.as_array()).ok_or("the tiers table has no cards")? {
let s = |k: &str| c.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let matches: Vec<String> = c.get("match").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_ascii_uppercase())).collect()).unwrap_or_default();
let tiers: Vec<Value> = c.get("tiers").and_then(|t| t.as_array()).cloned().unwrap_or_default();
if s("card").is_empty() || matches.is_empty() || tiers.is_empty() {
continue;
}
entries.push(Entry { card: s("card"), matches, vendor: s("vendor"), label: s("label"), tiers });
}
Ok(Table { class, entries })
}
pub fn table() -> &'static Table {
static T: OnceLock<Table> = OnceLock::new();
T.get_or_init(|| parse(TABLE).unwrap_or_else(|e| { eprintln!("tiers table: {e}"); Table { class: String::new(), entries: Vec::new() } }))
}
/// The entry a card name matches: the longest match string found in the upper-cased name wins.
pub fn entry_for<'a>(t: &'a Table, card_name: &str) -> Option<&'a Entry> {
let n = card_name.to_ascii_uppercase();
let mut best: Option<(&Entry, usize)> = None;
for e in &t.entries {
for m in &e.matches {
if n.contains(m.as_str()) && best.map(|(_, l)| m.len() > l).unwrap_or(true) {
best = Some((e, m.len()));
}
}
}
best.map(|(e, _)| e)
}
/// What a card takes from the table: its rows in the card-state shape (table: true, the class's label on every row),
/// the table's class, the class's label; None when the table has no entry for the name.
#[allow(dead_code)]
pub struct Seed {
pub rows: Vec<Value>,
pub class: String,
pub label: String,
pub card: String,
}
pub fn seed(card_name: &str) -> Option<Seed> {
let t = table();
let e = entry_for(t, card_name)?;
let rows: Vec<Value> = e.tiers.iter().map(|r| {
let n = |k: &str| r.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
let mut row = json!({
"id": r.get("id").cloned().unwrap_or(json!("")),
"clock_mhz": n("clock_mhz") as u64, "power_pct": (n("power_pct") as u64).clamp(50, 100), "mem_mhz": n("mem_mhz") as u64,
"limit_w": n("limit_w"), "w": n("w"), "mhs": n("mhs"), "mhw": if n("mhw") > 0.0 { n("mhw") } else if n("w") > 0.0 { n("mhs") / n("w") } else { 0.0 },
"source": r.get("source").cloned().unwrap_or(json!("measured")), "label": r.get("label").cloned().unwrap_or(json!(e.label.clone())),
"table": true, "note": r.get("note").cloned().unwrap_or(json!("")),
});
if let Some(o) = r.get("core_offset_mhz") { row["core_offset_mhz"] = o.clone(); }
if let Some(o) = r.get("power_offset_pct") { row["power_offset_pct"] = o.clone(); }
row
}).collect();
Some(Seed { rows, class: t.class.clone(), label: e.label.clone(), card: e.card.clone() })
}
/// A table row's core offset (AMD): the absolute clock the knob sends, from the card's stock clock; None when the row
/// carries no offset or the stock clock is not known yet.
pub fn clock_from_offset(row: &Value, stock_mhz: u32) -> Option<u32> {
let off = row.get("core_offset_mhz").and_then(|v| v.as_f64())?;
if stock_mhz == 0 || off >= 0.0 {
return None;
}
Some((stock_mhz as f64 + off).max(1.0) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_table_parses_and_covers_the_cards_the_brief_names() {
let t = table();
assert_eq!(t.class, "v5");
for want in ["5090", "5080", "5070 TI", "4090", "9070 XT", "H100", "M5 MAX", "B580"] {
assert!(t.entries.iter().any(|e| e.card.to_ascii_uppercase().contains(want)), "{want}");
}
}
#[test]
fn the_longest_match_wins_and_an_unknown_card_gets_nothing() {
let t = table();
assert_eq!(entry_for(t, "NVIDIA GeForce RTX 5070 Ti").unwrap().card, "NVIDIA GeForce RTX 5070 Ti");
assert_eq!(entry_for(t, "NVIDIA GeForce RTX 5070").unwrap().card, "NVIDIA GeForce RTX 5070");
assert_eq!(entry_for(t, "AMD Radeon RX 9070 XT (gfx1201)").unwrap().card, "AMD Radeon RX 9070 XT");
assert!(entry_for(t, "Some Other GPU").is_none());
assert!(seed("Some Other GPU").is_none());
}
#[test]
fn a_5090_takes_three_rows_in_the_apply_shape_with_the_table_flag_and_the_balanced_knee_at_1300() {
let s = seed("NVIDIA GeForce RTX 5090").unwrap();
assert_eq!((s.class.as_str(), s.label.as_str(), s.rows.len()), ("v5", "measured", 3));
let ids: Vec<&str> = s.rows.iter().map(|r| r["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["efficiency", "balanced", "max"]);
assert!(s.rows.iter().all(|r| r["table"] == json!(true) && r["label"] == json!("measured")));
let (p, limit, mhs, w) = crate::ember::tier_from_json(&s.rows, "balanced").unwrap();
assert_eq!((p.clock_mhz, p.power_pct, p.mem_mhz), (1300, 100, 0));
assert_eq!(limit, 575.0);
assert!((mhs - 134.76).abs() < 0.01 && (w - 318.8).abs() < 0.01);
assert_eq!(s.rows[2]["source"], json!("stock"));
}
#[test]
fn a_card_without_a_lever_takes_the_max_row_only_and_an_amd_row_converts_its_offset_once_the_stock_clock_is_known() {
let m = seed("Apple M5 Max").unwrap();
assert_eq!(m.rows.len(), 1);
assert_eq!(m.rows[0]["id"], json!("max"));
let a = seed("AMD Radeon RX 9070 XT").unwrap();
let eff = &a.rows[0];
assert_eq!(eff["core_offset_mhz"], json!(-500));
assert_eq!(clock_from_offset(eff, 0), None, "no stock clock yet: the offset waits");
assert_eq!(clock_from_offset(eff, 3292), Some(2792));
assert_eq!(clock_from_offset(&a.rows[2], 3292), None, "the stock row has no offset");
let (p, _, _, _) = crate::ember::tier_from_json(&a.rows, "efficiency").unwrap();
assert_eq!(p.power_pct, 70);
}
}

533
app/igneum-app/src/uiota.rs Normal file
View file

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

View file

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

View file

@ -80,6 +80,66 @@ fn find(bin_dir: &Path) -> Result<PathBuf, String> {
}
}
/// Igneum 2.0 (main's order, 8 October 2026): the proof rule is set from block zero on every 2.0 network, and the daemon
/// refuses to start on Windows without a verifier. Under plug, tune, play that refusal never reaches a user: the engine
/// holds the node start itself, shows the reason, and installs the host.
pub const PROOF_RULE_FROM_ZERO: bool = true;
/// Why the node start is held, or None when it may start. Windows only: elsewhere the host ships next to the engine.
/// `mode` is the resolved verifier's mode ("command", "trust", "off"). A held start is retried by the engine's own
/// restart clock once the host is in; the words go to the node tile and /api/state (node.message).
pub fn node_start_hold(windows: bool, mode: &str, detail: &str) -> Option<String> {
if !windows || !PROOF_RULE_FROM_ZERO || mode != "off" {
return None;
}
Some(format!("waiting for the proving host: {detail}. The node needs it from block zero on this network; it is being set up, no hand needed (the Windows Subsystem for Linux and {}, then the host from the install folder); the node starts by itself when it answers", crate::wslhost::DISTRO))
}
/// The unattended host setup on Windows, decided from what is there. The WSL feature is taken once by the installer's
/// elevated rights step (src/rights.rs WSL2_RIGHT: `wsl --install --no-distribution --no-launch`, a reboot pending);
/// the distro is installed by the engine with no prompt (`wsl --install -d Ubuntu-24.04 --no-launch`: no first-run
/// user prompt, root is the user), and the host itself is the payload's wsl2\bin\igneum-prove-host, run from /mnt.
/// No apt, no CUDA toolkit: the verifier needs none of that (the CUDA path is the prover's, Set up on the Proving tile).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostSetup {
/// the feature is not on: nothing the engine can do without rights; the reason names the installer's step
NeedsFeature,
/// the feature is on and the distro is missing: `wsl --install -d <distro> --no-launch`, unelevated
InstallDistro,
/// the distro answers and the host file is there: only the probe is owed (WSL may still be starting)
Probe,
/// the distro answers but the payload's host is missing: the install is incomplete; the reason names the file
HostMissing,
}
pub fn host_setup_plan(feature_on: bool, distro_answers: bool, host_file_present: bool) -> HostSetup {
if !feature_on {
HostSetup::NeedsFeature
} else if !distro_answers {
HostSetup::InstallDistro
} else if !host_file_present {
HostSetup::HostMissing
} else {
HostSetup::Probe
}
}
/// The PowerShell that installs the distro with no prompt and no first-run window.
pub fn distro_install_command() -> String {
format!("& wsl.exe --install -d {} --no-launch; exit 0", crate::wslhost::DISTRO)
}
/// Is the WSL feature on? `wsl.exe --status` answers 0 with the feature; the engine never raises a prompt for it.
pub fn feature_on() -> bool {
if !cfg!(windows) {
return false;
}
let mut c = Command::new(crate::platform::tool("wsl"));
c.arg("--status");
crate::platform::quiet(&mut c);
c.status().map(|s| s.success()).unwrap_or(false)
}
/// The node's `igneum_getProvingStatus().verifier` text (`Off`, `Trust`, `Command("...")`) as a word.
pub fn mode_of_report(report: &str) -> &'static str {
let r = report.trim();
@ -126,6 +186,32 @@ pub fn note(report_mode: &str, set: &str, reason: &str, external: bool) -> Strin
mod tests {
use super::*;
/// Igneum 2.0, known-failed first (main, 8 October 2026): the daemon refuses to start on Windows when igneum-prove-host
/// is absent and that refusal reached nobody. The engine holds the start with the reason, sets the host up with no
/// hand, and starts the node when the probe answers.
#[test]
fn a_windows_node_without_its_verifier_is_held_with_the_reason_and_the_host_is_set_up() {
let hold = node_start_hold(true, "off", "the WSL2 prover is not installed (looked at /mnt/c/x)").unwrap();
assert!(hold.starts_with("waiting for the proving host: the WSL2 prover is not installed"), "{hold}");
assert!(hold.contains("no hand needed") && hold.contains("Ubuntu-24.04") && hold.contains("starts by itself"), "{hold}");
assert_eq!(node_start_hold(true, "command", "C:/x/igneum-prove-verify.exe"), None, "a verifier: the node starts");
assert_eq!(node_start_hold(true, "trust", "devnet only"), None, "trust (devnet only) starts the node");
assert_eq!(node_start_hold(false, "off", "no host"), None, "macOS and Linux ship the host next to the engine");
assert_eq!(host_setup_plan(false, false, true), HostSetup::NeedsFeature, "no feature: the installer's step, never a prompt from the engine");
assert_eq!(host_setup_plan(true, false, true), HostSetup::InstallDistro);
assert_eq!(host_setup_plan(true, true, false), HostSetup::HostMissing);
assert_eq!(host_setup_plan(true, true, true), HostSetup::Probe);
assert_eq!(distro_install_command(), "& wsl.exe --install -d Ubuntu-24.04 --no-launch; exit 0", "no first-run window, no user prompt");
// the engine's start order: the hold before the spawn, the setup step, the retry clock
let e = include_str!("engine.rs");
let sn = e.find("fn start_node(&mut self)").unwrap();
let body = &e[sn..sn + 2600];
let hold_at = body.find("crate::verifier::node_start_hold(").expect("the hold is read in start_node");
let spawn_at = body.find("procs::spawn(Source::Node").expect("the spawn");
assert!(hold_at < spawn_at, "the hold comes before the spawn");
assert!(body[hold_at..spawn_at].contains("self.host_setup_step();") && body[hold_at..spawn_at].contains("self.node_restart_at = Some("), "the setup step and the retry clock inside the hold");
}
#[test]
fn resolve_never_trusts_by_default_and_names_the_missing_host() {
let dir = std::env::temp_dir().join(format!("igneum-verifier-test-{}", std::process::id()));

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,100 @@
// The class v5 tiers table (app/igneum-app/tiers/class-v5-tiers.json): the loader and the validator the test and the
// app's packaging read it through. The rows are in the shape src/ember.rs tier_from_json reads back from a card's state
// (id, clock_mhz, power_pct, mem_mhz, limit_w, mhs, w), so a card whose own search has not run can be set to a tier
// from this table by /api/tune/tier, and the search replaces the row when it runs. node --test class-v5-tiers.test.mjs
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
export const TIER_IDS = ['efficiency', 'balanced', 'max'];
export const LABELS = ['measured', 'estimated'];
export const SOURCES = ['measured', 'stock'];
export const VENDORS = ['nvidia', 'amd', 'apple', 'intel'];
// the fields tier_from_json reads, every one a number
export const ROW_FIELDS = ['clock_mhz', 'power_pct', 'mem_mhz', 'limit_w', 'mhs', 'w'];
// the brief's card classes (floor lane 4, 8 October 2026): every one must have an entry
export const REQUIRED_CARDS = ['5090', '5080', '5070 Ti', '5070', '5060 Ti', '5060', '4090', '4080', '4070', '3090', '3080', '3070', '3060',
'9070 XT', '7900 XTX', '7800 XT', '7600 XT', '9060 XT', 'H100', 'L40S', 'A100', 'B580', 'A750', 'M5 Max', 'M4 Max', 'M4 Pro', 'M3 Max'];
export function loadTable(path) {
const p = path || join(dirname(fileURLToPath(import.meta.url)), 'class-v5-tiers.json');
return JSON.parse(readFileSync(p, 'utf8'));
}
/** Every fault in the table as a list of strings; an empty list is a valid table. */
export function validate(t) {
const faults = [];
const f = (s) => faults.push(s);
if (t.class !== 'v5') f(`class is ${t.class}, not v5`);
if (!Array.isArray(t.tier_ids) || t.tier_ids.join() !== TIER_IDS.join()) f('tier_ids must be efficiency, balanced, max');
for (const k of ['stale_when', 'on_flip', 'measured_flip', 'period']) if (!t.remeasure_rule || typeof t.remeasure_rule[k] !== 'string' || !t.remeasure_rule[k]) f(`remeasure_rule.${k} missing`);
if (!t.v5_over_v4 || typeof t.v5_over_v4.watts_pct !== 'number') f('v5_over_v4.watts_pct missing');
if (!Array.isArray(t.cards) || t.cards.length === 0) { f('cards missing'); return faults; }
const seen = new Set();
for (const c of t.cards) {
const name = c.card || '(unnamed)';
if (seen.has(name)) f(`${name}: listed twice`);
seen.add(name);
if (!VENDORS.includes(c.vendor)) f(`${name}: vendor ${c.vendor}`);
if (!LABELS.includes(c.label)) f(`${name}: label ${c.label}`);
if (!Array.isArray(c.match) || c.match.length === 0) f(`${name}: no match list`);
if (typeof c.src !== 'string' || !c.src) f(`${name}: no src`);
if (!c.stock || typeof c.stock.uj !== 'number' || c.stock.uj <= 0) f(`${name}: stock.uj missing`);
if (!Array.isArray(c.tiers) || c.tiers.length === 0) { f(`${name}: no tiers`); continue; }
const ids = c.tiers.map((r) => r.id);
for (const id of ids) if (!TIER_IDS.includes(id)) f(`${name}: tier id ${id}`);
if (new Set(ids).size !== ids.length) f(`${name}: a tier id repeats`);
const lever = c.vendor === 'nvidia' || c.vendor === 'amd';
if (lever && ids.join() !== TIER_IDS.join()) f(`${name}: a card with a lever carries all three tiers in order`);
if (!lever && ids.join() !== 'max') f(`${name}: a card with no lever carries the max tier only`);
for (const r of c.tiers) {
for (const k of ROW_FIELDS) if (typeof r[k] !== 'number' || !Number.isFinite(r[k])) f(`${name}/${r.id}: ${k} is not a number`);
if (typeof r.power_pct === 'number' && (r.power_pct < 50 || r.power_pct > 100)) f(`${name}/${r.id}: power_pct ${r.power_pct} outside 50 to 100`);
if (typeof r.clock_mhz === 'number' && (r.clock_mhz < 0 || r.clock_mhz > 4000)) f(`${name}/${r.id}: clock_mhz ${r.clock_mhz}`);
if (!LABELS.includes(r.label)) f(`${name}/${r.id}: label ${r.label}`);
if (!SOURCES.includes(r.source)) f(`${name}/${r.id}: source ${r.source}`);
if (typeof r.uj !== 'number' || r.uj <= 0) f(`${name}/${r.id}: uj missing`);
if (typeof r.note !== 'string' || !r.note) f(`${name}/${r.id}: no note`);
if (r.mhs > 0 && r.w > 0 && r.uj > 0 && Math.abs(r.w / r.mhs - r.uj) / r.uj > 0.025) f(`${name}/${r.id}: uj ${r.uj} is not w over mhs (${(r.w / r.mhs).toFixed(2)})`);
if (r.mhs > 0 && r.w > 0 && typeof r.mhw === 'number' && Math.abs(r.mhs / r.w - r.mhw) / r.mhw > 0.025) f(`${name}/${r.id}: mhw ${r.mhw} is not mhs over w`);
if (r.id === 'max' && r.source !== 'stock') f(`${name}/max: the max tier is the stock row`);
if (r.id === 'max' && (r.clock_mhz !== 0 || r.power_pct !== 100)) f(`${name}/max: stock is unlocked at 100 percent`);
if (c.vendor === 'amd' && (typeof r.core_offset_mhz !== 'number' || typeof r.power_offset_pct !== 'number')) f(`${name}/${r.id}: an AMD row carries core_offset_mhz and power_offset_pct`);
if (c.vendor === 'amd' && r.core_offset_mhz > 0) f(`${name}/${r.id}: an AMD core offset never raises the clock`);
}
const by = Object.fromEntries(c.tiers.map((r) => [r.id, r]));
if (by.efficiency && by.max && by.efficiency.uj > by.max.uj) f(`${name}: efficiency costs more per hash than stock`);
if (by.efficiency && by.balanced && by.balanced.uj + 1e-9 < by.efficiency.uj) f(`${name}: balanced is cheaper per hash than efficiency`);
if (by.balanced && by.max && by.balanced.mhs > 0 && by.max.mhs > 0 && by.balanced.mhs < by.max.mhs * 0.98) f(`${name}: balanced gives up over 2 percent of the top rate`);
if (c.label === 'measured' && !c.tiers.some((r) => r.label === 'measured' && r.source === 'measured') && lever) f(`${name}: a measured card has no measured tuned row`);
}
for (const want of REQUIRED_CARDS) if (!t.cards.some((c) => c.card.includes(want))) f(`no entry for ${want}`);
return faults;
}
/** The entry a card name matches (the first whose match list hits; the longer match wins, so "5070 Ti" beats "5070"). */
export function entryFor(t, cardName) {
const n = cardName.toUpperCase();
let best = null, bestLen = 0;
for (const c of t.cards) for (const m of c.match) if (n.includes(m.toUpperCase()) && m.length > bestLen) { best = c; bestLen = m.length; }
return best;
}
/** The tier row a card starts from, in the shape tier_from_json reads; null when the table has none. */
export function tierRow(t, cardName, id) {
const c = entryFor(t, cardName);
if (!c) return null;
return c.tiers.find((r) => r.id === id) || null;
}
/** The re-measure verdict the app applies on a class flip (src/ember.rs tiers_stale): stale when both classes are known and differ. */
export function stale(tiersClass, programClass) {
return Boolean(tiersClass) && Boolean(programClass) && tiersClass !== programClass;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const t = loadTable();
const faults = validate(t);
if (faults.length) { console.error(faults.join('\n')); process.exit(1); }
console.log(`class ${t.class}: ${t.cards.length} card classes, ${t.cards.filter((c) => c.label === 'measured').length} measured`);
}

View file

@ -0,0 +1,109 @@
// node --test app/igneum-app/tiers/class-v5-tiers.test.mjs
// The class v5 tiers table: the shipped file validates; the measured rows carry the record's numbers; the match rule
// picks the longer name; a class flip reads stale; and the known-failed cases (a bad power rung, a missing field, a
// tuned row dearer than stock, a card class left out) are refused, so a stale or broken table cannot pass.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadTable, validate, entryFor, tierRow, stale, REQUIRED_CARDS, ROW_FIELDS } from './class-v5-tiers.mjs';
const clone = (t) => JSON.parse(JSON.stringify(t));
test('the shipped table validates with no faults', () => {
const t = loadTable();
assert.deepEqual(validate(t), []);
assert.equal(t.class, 'v5');
assert.ok(t.cards.length >= REQUIRED_CARDS.length);
});
test('the measured rows are the record\'s (the 5090 at its 1,300 MHz knee, the 5080 at 1,100, the 4070 at its tune, the 9070 XT grid, the M5 Max meter)', () => {
const t = loadTable();
const r5090 = tierRow(t, 'NVIDIA GeForce RTX 5090', 'balanced');
assert.equal(r5090.clock_mhz, 1300);
assert.equal(r5090.label, 'measured');
assert.ok(Math.abs(r5090.uj - 2.38) < 0.01, `the 5090 balanced row reads 2.38 microjoules under class v5: ${r5090.uj}`);
const e5090 = tierRow(t, 'NVIDIA GeForce RTX 5090', 'efficiency');
assert.equal(e5090.clock_mhz, 1200);
const r5080 = tierRow(t, 'NVIDIA GeForce RTX 5080', 'efficiency');
assert.equal(r5080.clock_mhz, 1100);
assert.ok(Math.abs(r5080.uj - 2.06) < 0.01);
const r4070 = tierRow(t, 'NVIDIA GeForce RTX 4070', 'efficiency');
assert.equal(r4070.clock_mhz, 1860);
assert.equal(r4070.power_pct, 50);
const amd = tierRow(t, 'AMD Radeon RX 9070 XT', 'efficiency');
assert.equal(amd.core_offset_mhz, -500);
assert.equal(amd.power_offset_pct, -30);
assert.ok(Math.abs(amd.w - 149.3) < 0.01);
const apple = entryFor(t, 'Apple M5 Max');
assert.equal(apple.tiers.length, 1);
assert.equal(apple.tiers[0].id, 'max');
assert.ok(Math.abs(apple.tiers[0].uj - 1.40) < 0.01);
});
test('every entry carries the three tiers where the card has a lever, and only max where it has none', () => {
const t = loadTable();
for (const c of t.cards) {
const ids = c.tiers.map((r) => r.id).join();
if (c.vendor === 'nvidia' || c.vendor === 'amd') assert.equal(ids, 'efficiency,balanced,max', c.card);
else assert.equal(ids, 'max', c.card);
for (const r of c.tiers) for (const k of ROW_FIELDS) assert.equal(typeof r[k], 'number', `${c.card}/${r.id}.${k}`);
}
});
test('the match rule takes the longer name: a 5070 Ti is not a 5070, a 4060 Ti is not a 4060, a 9060 XT is not a 9070 XT', () => {
const t = loadTable();
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 5070 Ti').card, 'NVIDIA GeForce RTX 5070 Ti');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 5070').card, 'NVIDIA GeForce RTX 5070');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 4060 Ti').card, 'NVIDIA GeForce RTX 4060 Ti');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 4060').card, 'NVIDIA GeForce RTX 4060');
assert.equal(entryFor(t, 'AMD Radeon RX 9060 XT').card, 'AMD Radeon RX 9060 XT');
assert.equal(entryFor(t, 'amd:gfx1201').card, 'AMD Radeon RX 9070 XT');
assert.equal(entryFor(t, 'NVIDIA GeForce GTX 1080 Ti'), null);
assert.equal(tierRow(t, 'NVIDIA GeForce GTX 1080 Ti', 'max'), null);
});
test('a class flip reads stale exactly as src/ember.rs tiers_stale does', () => {
assert.equal(stale('v4', 'v5'), true);
assert.equal(stale('v5', 'v5'), false);
assert.equal(stale('', 'v5'), false);
assert.equal(stale('v4', ''), false);
const t = loadTable();
assert.ok(t.remeasure_rule.measured_flip.includes('0.0 percent of rate'));
assert.equal(t.v5_over_v4.watts_pct, 2.0);
});
test('known-failed: a power rung under 50 is refused', () => {
const t = clone(loadTable());
t.cards[0].tiers[0].power_pct = 40;
assert.ok(validate(t).some((s) => s.includes('power_pct 40')));
});
test('known-failed: a row missing a field tier_from_json reads is refused', () => {
const t = clone(loadTable());
delete t.cards[1].tiers[1].limit_w;
assert.ok(validate(t).some((s) => s.includes('limit_w is not a number')));
});
test('known-failed: a tuned row dearer per hash than stock is refused, and a max tier that is not stock', () => {
const t = clone(loadTable());
const c = t.cards.find((x) => x.card.includes('4080'));
c.tiers[0].uj = c.tiers[2].uj + 1; c.tiers[0].w = c.tiers[0].uj * c.tiers[0].mhs; c.tiers[0].mhw = c.tiers[0].mhs / c.tiers[0].w;
assert.ok(validate(t).some((s) => s.includes('efficiency costs more per hash than stock')));
const u = clone(loadTable());
u.cards[0].tiers[2].clock_mhz = 1500;
assert.ok(validate(u).some((s) => s.includes('stock is unlocked at 100 percent')));
});
test('known-failed: a card class of the brief left out is refused, and a stale uj (not w over mhs) is refused', () => {
const t = clone(loadTable());
t.cards = t.cards.filter((c) => !c.card.includes('3060'));
assert.ok(validate(t).some((s) => s === 'no entry for 3060'));
const u = clone(loadTable());
u.cards[0].tiers[1].uj = 9.99;
assert.ok(validate(u).some((s) => s.includes('is not w over mhs')));
});
test('known-failed: a table under another class is refused', () => {
const t = clone(loadTable());
t.class = 'v4';
assert.ok(validate(t).some((s) => s.includes('not v5')));
});

View file

@ -0,0 +1 @@
1.0.2

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -19,8 +19,9 @@
</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" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="20" height="11" rx="2"/><path d="M6 17v3M10 17v3M14 17v3M18 17v3M6 11h4"/></svg><span>Cards</span></button>
<button class="nav" data-page="tune"><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>Tune</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>
</nav>
<div class="rail-foot">
@ -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>
@ -71,9 +74,10 @@
<section class="screen" id="screen-welcome">
<div class="hero">
<div class="mark-wrap"><img src="mark.svg" width="72" height="72" alt=""></div>
<div class="eyebrow ember">devnet v4 &middot; nothing is bought or sold</div>
<div class="eyebrow ember">Igneum 2.0 devnet &middot; nothing is bought or sold</div>
<h1>Igneum Miner</h1>
<p class="lead">Runs a node and mines Igneum with your graphics card. Set up in two steps.</p>
<p class="help" id="positioning">A GPU-secured network for Ethereum-compatible applications and verifiable computation</p>
<div class="three">
<div class="tile">
<div class="k">01</div>
@ -92,6 +96,8 @@
</div>
</div>
<div class="cta">
<!-- F14 (the founder, 8 October 2026): automatic updates are a choice at install, honoured from here on -->
<label class="switch" id="w-auto-update-row"><input type="checkbox" id="w-auto-update" checked><span class="track"></span><span class="sw-text"><b>Update automatically</b> <span class="dim">Installs new versions at a quiet moment. Off, the app shows you each one and waits for Install now.</span></span></label>
<button class="btn primary big" id="btn-begin">Get started</button>
</div>
<p class="seedline mono">Nobody from Igneum will ever ask for your key.</p>
@ -101,7 +107,7 @@
<!-- 2. cards -->
<section class="screen" id="screen-cards">
<div class="step">
<div class="eyebrow">step 1 of 2</div>
<div class="eyebrow">step 1 of 4</div>
<h2>Your graphics card</h2>
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
<div class="cards" id="cards-list">
@ -114,8 +120,8 @@
</div>
</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-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that.</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>
@ -123,10 +129,56 @@
</div>
</section>
<!-- 3. address -->
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
<section class="screen" id="screen-region">
<div class="step">
<div class="eyebrow">step 2 of 4</div>
<h2>Your electricity</h2>
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="region-select" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="region-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="region-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="region-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
</div>
<p class="help" id="region-note">Choose a region to see a typical price.</p>
</div>
<p class="restricted" id="region-restricted" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-region-next">Continue</button>
<button class="btn ghost" id="btn-region-back">Back</button>
<button class="btn ghost" id="btn-region-skip">Skip for now</button>
</div>
</div>
</section>
<!-- 4. the network (Igneum 2.0, 8 October 2026): one card, the fresh-install default -->
<section class="screen" id="screen-network">
<div class="step">
<div class="eyebrow">step 3 of 4</div>
<h2>Which network</h2>
<p class="sub">The chain this machine mines. You can change it later in Settings; a change takes effect at the next start.</p>
<div class="net-cards" id="network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="network-note" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-network-next">Continue</button>
<button class="btn ghost" id="btn-network-back">Back</button>
</div>
</div>
</section>
<!-- 5. address -->
<section class="screen" id="screen-address">
<div class="step">
<div class="eyebrow">step 2 of 2</div>
<div class="eyebrow">step 4 of 4</div>
<h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
<div class="options">
@ -143,7 +195,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>
@ -157,10 +209,10 @@
</div>
</section>
<!-- 4. the dashboard: four pages behind the rail -->
<!-- 5. the dashboard: four pages behind the rail -->
<section class="screen" id="screen-dashboard">
<!-- Mine -->
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
<section class="page" id="page-mine" data-page="mine">
<div class="clock-card" id="m-clock" hidden>
<p class="clock-msg" id="m-clock-msg"></p>
@ -172,92 +224,152 @@
<button class="toggle-big" id="btn-toggle" disabled>
<span class="ring"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg></span>
<span class="tl" id="btn-toggle-text">Start mining</span>
<span class="ts" id="btn-toggle-sub">waiting for the engine</span>
</button>
<!-- app-ia-26 (4.1 item 2): four numbers, each with its unit and one sub-line; W and MH/W hide when no card reports power -->
<div class="totals" id="totals">
<div class="tot"><span class="v ember" id="t-hash">0</span><span class="k">MH/s</span><span class="s" id="t-hash-sub">waiting</span></div>
<div class="tot" id="t-power-cell"><span class="v" id="t-power">0</span><span class="k">W</span><span class="s" id="t-power-sub">drawn now</span></div>
<div class="tot" id="t-money-cell"><span class="v" id="t-money">£0.00</span><span class="k">a day</span><span class="s" id="t-money-sub">electricity</span></div>
<div class="tot"><span class="v" id="t-blocks">0</span><span class="k">blocks</span><span class="s" id="t-blocks-sub">this run</span></div>
<div class="tot" id="t-power-cell"><span class="v" id="t-power">0</span><span class="k">W</span><span class="s">drawn now</span></div>
<div class="tot" id="t-eff-cell"><span class="v" id="t-eff">0</span><span class="k">MH/W</span><span class="s">hashes per watt</span></div>
<div class="tot"><span class="v" id="t-ign">reading</span><span class="k">IGN</span><span class="s" id="t-ign-sub">last 24 hours</span></div>
</div>
</div>
<!-- miner-ui-5: before the first block, the Poisson count-up; on a milestone, the block card (in place, never a dialog) -->
<div class="card wait-card" id="first-wait" hidden>
<div class="wait-row"><span class="wait-ring" aria-hidden="true"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15.5"></circle><circle class="arc" id="first-wait-arc" cx="18" cy="18" r="15.5"></circle></svg><span class="pct mono" id="first-wait-pct">0%</span></span>
<div class="wait-text"><div class="t" id="first-wait-line">Waiting for your first block.</div><div class="s" id="first-wait-sub"></div></div>
</div>
</div>
<div class="card block-card" id="block-card" hidden>
<div class="bc-head"><span class="eyebrow ember" id="bc-eyebrow">your first block</span><span class="bc-when mono" id="bc-when"></span><button class="notice-close bc-close" id="bc-close" title="Dismiss" aria-label="Dismiss the block card">&times;</button></div>
<h2 class="bc-title" id="bc-title">Block 0 is yours.</h2>
<p class="bc-line" id="bc-card"></p>
<p class="bc-line" id="bc-ign"></p>
<p class="bc-line dim" id="bc-rank"></p>
<p class="bc-hash mono" id="bc-hash"></p>
<div class="row wrap"><button class="btn small primary" id="bc-open">Open in the explorer</button><button class="btn small" id="bc-save">Save the card</button><button class="btn small ghost" id="bc-copy">Copy the link</button><span class="note mono small" id="bc-saved"></span></div>
<canvas id="bc-canvas" width="1200" height="630" hidden aria-hidden="true"></canvas>
</div>
<div class="card dag-card" id="dag-card">
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-inspect" aria-expanded="false" aria-controls="chain-full">Inspect</button><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
<!-- the compact scene (EMBER 02 live-dag.js, the site lane's module, byte-identical): the app feeds it its own api/live reply -->
<div class="dag-wrap">
<canvas id="dag-live" aria-label="The last minute of blocks: your lane and the other miner keys, the selected chain as one path, checkpoints as bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip" hidden></div>
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
<canvas id="dag" aria-hidden="true" hidden></canvas>
</div>
<div class="legend" data-legend="mine"><span class="dim" id="dag-note"></span></div>
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
<div class="chain-full" id="chain-full" hidden>
<div class="cf-bar">
<div class="seg small" id="dag-filter" role="radiogroup" aria-label="Which blocks"><button class="seg-b on" data-filter="all" role="radio" aria-checked="true">All blocks</button><button class="seg-b" data-filter="chain" role="radio" aria-checked="false">Selected chain</button><button class="seg-b" data-filter="mine" role="radio" aria-checked="false">Your blocks</button></div>
<span class="cf-right"><span class="zoom"><button type="button" id="z-out" aria-label="Wider window">&minus;</button><span id="z-pct" class="mono">60 s</span><button type="button" id="z-in" aria-label="Narrower window">+</button></span><button class="btn tiny ghost on" id="dag-follow">Follow</button><button class="btn tiny ghost" id="dag-pause-full" aria-pressed="false">Pause</button></span>
</div>
<div class="cf-graph">
<div class="cf-scene">
<canvas id="dag-full" aria-label="The devnet's block graph, live: time-aligned miner lanes, every parent connection, the selected chain as one path, checkpoint bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip-full" hidden></div>
<span class="dag-chip mono" id="dag-chip-full" hidden>scroll to zoom · Esc to release</span>
<div class="cf-foot mono">Click to inspect &nbsp;&middot;&nbsp; Drag to explore</div>
</div>
<aside class="inspector" id="inspector" aria-live="polite">
<div class="ih"><span class="eyebrow">Block inspector</span><span class="chip mono" id="i-kind">select a block</span></div>
<h3 class="ititle" id="i-title">Select a block</h3>
<div class="ihash mono" id="i-hash">Click the graph.</div>
<div class="proof-scene"><canvas id="proof" aria-label="Select a block to see its shards"></canvas></div>
<div class="proof-head"><span>Proof shards</span><span id="i-shard-count" class="mono">not set</span></div>
<div class="track" id="i-track"></div>
<ul class="shards mono" id="i-shards"><li class="muted">No block selected.</li></ul>
<dl class="insp mono">
<dt>Inclusion</dt><dd id="i-incl">not set</dd>
<dt>Miner key</dt><dd id="i-miner">not set</dd>
<dt>Blue score</dt><dd id="i-blue">not set</dd>
<dt>DAA score</dt><dd id="i-daa">not set</dd>
<dt>Parent links</dt><dd id="i-parents">not set</dd>
<dt>Header time</dt><dd id="i-time">not set</dd>
<dt>Checkpoint</dt><dd id="i-cp">not set</dd>
<dt>Finality</dt><dd id="i-lock">not set</dd>
</dl>
<p class="note">Proof, chain selection and finality are separate states. None is inferred from a block&rsquo;s age; a lock is drawn only when the observer reports one.</p>
<p class="top-gap"><button class="btn tiny ghost" id="insp-close" hidden>Clear</button></p>
</aside>
</div>
</div>
</div>
<!-- app-ia-26 (4.1 item 7): one node line; a click opens Settings > Node where the facts live -->
<button class="node-go" id="node-go" title=""><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span><span class="chev" aria-hidden="true"></span></button>
<div class="card">
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="note" id="d-cards-note" hidden></p>
</div>
<div class="card" id="tune-card">
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
</div>
<span class="goal-line" id="goal-line"></span>
</div>
<p class="line-text" id="tune-schedule"></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
<div class="card node-card" id="node-card">
<button class="disclose" id="node-toggle" aria-expanded="false" aria-controls="node-details">
<span class="node-line"><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span></span>
<span class="disclose-right"><span class="dim" id="node-sub"></span><span class="chev" aria-hidden="true"></span></span>
</button>
<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">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">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">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>
<div class="field">
<div class="k">rules digest</div>
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
</div>
<div class="field">
<div class="k">next rule switch</div>
<div class="line-text" id="n-switch">none planned</div>
<p class="help" id="n-switch-help"></p>
<div class="switch-list mono" id="n-switches"></div>
</div>
</div>
</div>
<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="card-head"><h3>Activity</h3></div>
<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>
<!-- v2.0.1 (the founder's call, 8 October 2026): every card row on its own page: the state, the rate, the watts, the
degrees, the tune state, the switch, the 16 GB proving note -->
<div class="card" id="cards-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>
<section class="page" id="page-tune" data-page="tune" hidden>
<!-- app-ia-26 (the founder's audit, section 4.2): the strip (the fleet's three tiers, Tune all, ONE sentence, the measure
switch, the heat and power lines when they matter), then one card per graphics card (rendered by renderTune from
View.tuneCardHtml), then the knee rule once at the foot. Nothing about tuning anywhere else. -->
<div class="card tune-strip" id="tune-card">
<div class="ts-row">
<div class="tiers" id="tier-seg" role="radiogroup" aria-label="Tuning tier"></div>
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
</div>
<p class="line-text" id="tune-schedule"></p>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Measure cards by itself</b> <span class="dim">Once, 2 min into steady mining, then every 7 days and after a driver or program change.</span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
<p class="line-text heat-line" id="heat-line" hidden></p>
<p class="line-text" id="power-note" hidden><span id="power-note-text"></span> <button class="btn tiny ghost" id="power-note-go">Open Settings</button></p>
</div>
<div class="gpu-list tune-list" id="t-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="line-text dim tune-foot" id="tier-rule">Ember never locks below the knee by itself. Going lower is your choice.</p>
</section>
<!-- Earnings -->
<section class="page" id="page-earnings" data-page="earnings" hidden>
<!-- app-ia-26 (the founder's audit, 4.3): the headline is the measured last 24 hours from the engine's earned sums;
the three windows; Electricity, Payouts and Standing as one row each; the rungs behind Details; the projection
as one dim foot line that hides with its reason. No fee, no address card, no run line, no lifetime cell, no
first-hour timeline. Every number names its source on hover (title). -->
<div class="card">
<div class="money">
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
<div class="earn">
<div class="earn-head"><span class="eyebrow">last 24 hours</span><span class="v" id="e-head">reading</span><span class="s" id="e-head-sub"></span></div>
<!-- the headline is the 24 h figure; the row is the two longer windows (the audit's amendment of 15:2x BST) -->
<div class="earn-three two">
<div class="earn-cell"><span class="k">7 days</span><span class="v" id="e-d7">reading</span><span class="s" id="e-d7-sub"></span></div>
<div class="earn-cell"><span class="k">All time</span><span class="v" id="e-all">reading</span><span class="s" id="e-all-sub"></span></div>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the miner software's author</span></label>
<p class="help" id="r-devfee-line"></p>
<div class="earn-rows">
<div class="earn-row"><span class="k">Electricity</span><span class="v" id="e-cost">reading</span><span class="s" id="e-cost-sub"></span></div>
<div class="earn-row"><span class="k">Payouts</span><span class="v mono" id="e-pays">not set</span><span class="s" id="e-pays-sub"></span><span class="acts"><button class="btn tiny" data-copy="e-pays-full">Copy</button><button class="btn tiny ghost" id="r-wallet">Open the wallet</button></span><span id="e-pays-full" hidden>not set</span></div>
<div class="earn-row"><span class="k">Standing</span><span class="v" id="e-standing">reading</span><span class="s" id="e-standing-sub"></span></div>
</div>
<details class="earn-details" id="standing-details"><summary>Details</summary><ol class="rungs" id="rungs"></ol></details>
<p class="line-text dim earn-foot" id="e-projection" hidden></p>
</div>
</section>
<div class="card">
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
<!-- Prove -->
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<!-- app-ia-26 (4.4 item 1): Wallet, the address card from the old Earnings page -->
<div class="card" id="s-wallet-card">
<div class="card-head"><h3>Wallet</h3><div class="eyebrow" id="r-source"></div></div>
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
@ -287,29 +399,153 @@
<p class="note mono small" id="r-key-file"></p>
</div>
<div class="row wrap top-gap"><button class="btn small" id="r-wallet">Open the wallet</button><span class="note">Your balance is in the wallet.</span></div>
<div class="row wrap top-gap"><button class="btn small" id="s-wallet">Open the wallet</button><span class="note">Your balance is in the wallet.</span></div>
</div>
</section>
<!-- Prove -->
<section class="page" id="page-prove" data-page="prove" hidden>
<div class="card">
<!-- app-ia-26 (4.4 item 2): Node: the network cards, one line, the facts behind Details (moved from Mine) -->
<div class="card-head"><h3>Node</h3><span class="eyebrow" id="s-network-eyebrow"></span></div>
<p class="help" id="s-network-line"></p>
<p class="line-text" id="s-node-line">Node starting</p>
<p class="note" id="s-node-sub"></p>
<div class="net-cards" id="s-network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="s-network-note" hidden></p>
<div class="ask inline" id="ask-network" hidden>
<span class="ask-text" id="ask-network-text"></span>
<button class="btn small primary" id="ask-network-yes">Switch</button>
<button class="btn small ghost" id="ask-network-no">Keep</button>
</div>
<details class="node-details" id="node-details"><summary>Details</summary>
<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">Igneum 2.0 devnet</span></div>
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
<div><span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the chain right now</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain (the blue score)</span></div>
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
<div><span class="k">last checkpoint</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s (a point the miners agree can never be undone)</span></div>
</div>
<div class="field">
<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 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>
</div>
</details>
</div>
<!-- app-ia-26 (4.4 item 3): Updates: the app version and its update, the Interface row -->
<div class="card" id="s-updates-card">
<div class="lead-row">
<div class="lead-text">
<h3>Prove on this machine</h3>
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
<h3 id="s-version">Igneum Miner</h3>
<p class="help" id="s-update-note">Not checked yet.</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 class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check for an update</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs at a quiet moment, never mid-program.</span></span></label>
<!-- app-ia-26 (4.4 item 3, A19): the Interface row under Updates -->
<p class="line-text" id="s-ui-line">Interface 1.0.0, built in</p>
<p class="help" id="s-ui-help-static">Small changes to this window arrive over the air, signed by Igneum.</p>
<p class="note" id="s-ui-help"></p>
<label class="switch"><input type="checkbox" id="s-ui-builtin"><span class="track"></span><span class="sw-text"><b>Use the built-in interface</b> <span class="dim">Serves the interface this version of the app was built with, even when a newer one has arrived over the air.</span></span></label>
</div>
<div class="card" id="s-power-card">
<div class="card-head"><h3>Power control</h3></div>
<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 at install. Off, NVIDIA cards are measured and never set.</span><span class="dim" id="s-power-note"></span></span></label>
</div>
<div class="card">
<div class="card-head"><h3>Electricity</h3><span class="eyebrow">typed, never fetched</span></div>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="s-region" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="s-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="s-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="s-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
<p class="help" id="s-price-note"></p>
</div>
<p class="restricted" id="s-region-restricted" hidden></p>
</div>
<div class="card">
<div class="card-head"><h3>Heat mode</h3><span class="eyebrow">Ember Heat</span></div>
<label class="switch"><input type="checkbox" id="s-heat"><span class="track"></span><span class="sw-text"><b>Hold a room temperature</b></span></label>
<p class="help">The cards heat for a share of every 10 minutes and rest for the rest. Your card is an electric heater that also earns.</p>
<p class="line-text" id="s-heat-line"></p>
<div class="row wrap">
<div class="field">
<div class="k">hold</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-set" min="5" max="30" step="0.5" placeholder="19" aria-label="Set point in degrees"><span class="note">°C</span></div>
</div>
<div class="field grow">
<div class="k">schedule</div>
<div class="row"><input type="text" class="addr-input mono" id="s-heat-schedule" placeholder="06:00 20, 22:00 16" spellcheck="false" autocomplete="off" aria-label="Schedule"><button class="btn small" id="s-heat-save">Save</button></div>
</div>
</div>
<p class="help">Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.</p>
<div class="field">
<div class="k">room now</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-room" min="-20" max="50" step="0.1" placeholder="19.5" aria-label="Room temperature now"><span class="note">°C from your own thermometer</span><button class="btn small" id="s-heat-room-save">Use it</button></div>
<p class="help" id="s-heat-room-note">A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.</p>
</div>
</div>
<!-- Proving (app-ia-26): the Prove page's switches, tiers, line and details, folded under Settings -->
<div class="card shard-card" id="shard-card" hidden>
<div class="bc-head"><span class="eyebrow ember">proof shards</span><span class="bc-when mono" id="sc-when"></span></div>
<h2 class="bc-title small" id="sc-title">Your card proved shard 0 of block 0.</h2>
<p class="bc-line" id="sc-line"></p>
<p class="bc-line dim" id="sc-sub"></p>
</div>
<div class="card" id="s-proving-card">
<div class="lead-row">
<div class="lead-text">
<h3>Proving</h3>
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one. 20 points of every block pay the provers.</p>
</div>
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<div class="row" id="pv-instead-row" hidden>
<div>
<h3>Prove instead of mining</h3>
<p class="help" id="pv-instead-help">A card under 16 GB holds the prover or the miner, never both (the miner holds 6 GB, a proof 8). On, the miner stops on that card while it proves and comes back when proving stops.</p>
</div>
<label class="switch big" title="Prove instead of mining"><input type="checkbox" id="s-prove-instead" aria-label="Prove instead of mining"><span class="track"></span></label>
</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,42 +554,17 @@
<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>
</section>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<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>
</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>
<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>
<button class="btn small" id="s-price-save">Save</button>
</div>
</div>
<p class="line-text" id="s-tune-schedule"></p>
</div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<label class="switch"><input type="checkbox" id="s-profile"><span class="track"></span><span class="sw-text"><b>Make my page public</b> <span class="dim" id="s-profile-text">Off: igneum.network/address keeps your page unlisted. Nothing about this machine is published.</span></span></label>
<label class="switch" id="s-jobs-row"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check for jobs</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<div class="details indent" id="s-jobs-history" hidden></div>
<p class="note mono small indent" id="s-jobs-key" hidden></p>
<div class="field">
@ -362,7 +573,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>
@ -374,20 +585,6 @@
</div>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3 id="s-version">Igneum Miner</h3>
<p class="help" id="s-update-note">Not checked yet.</p>
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs at a quiet moment, never mid-program.</span></span></label>
</div>
<div class="card">
<div class="card-head"><h3>Logs</h3></div>
<div class="row wrap">
@ -401,8 +598,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>
@ -410,6 +607,13 @@
</div>
<div class="row wrap"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button><span class="note mono small" id="s-mid"></span></div>
</details>
<!-- earnings-26 (the founder, 8 October 2026): the dev fee's one place in the app, last on Settings: the rate, what it
funds, the site's wording (site/miner.html), no control -->
<div class="card fee" id="s-fee-card">
<div class="card-head"><h3>The dev fee</h3><div class="head-right"><span class="eyebrow">1%</span></div></div>
<p class="help" id="s-fee-line">The Ember software takes a 1% dev fee. One block template in 100 is requested with the dev payout address instead of yours. The fee goes to Igneum Labs LTD, the company that ships the software.</p>
</div>
</section>
</section>
</main>
@ -448,7 +652,7 @@
<div class="k">rewards address</div>
<div class="box mono"><span id="key-address"></span><button class="btn tiny" data-copy="key-address">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Earnings can show it again.</p>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
<div class="cta">
<button class="btn primary big" id="btn-key-done" disabled>Start mining</button>
@ -494,6 +698,8 @@
</div>
<div class="toast" id="toast" hidden></div>
<script src="live-dag.js"></script>
<script src="proof-core.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

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

@ -0,0 +1,409 @@
// Nothing twice (app-ia-26, the founder's audit of 8 October 2026, docs/design/app-audit-2026-10-08.md section 5.4): the
// static markup of index.html, read as text and counted. `once(html, needle)` counts a string in the whole page;
// `inPage(html, pageId, needle)` counts inside one <section class="page" id="page-…">. One assertion per repeat in the
// audit's register (T-R1 to T-R21) and per readout that misled (T-A1 to T-A20), each written known-failed first.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const html = readFileSync(join(here, 'index.html'), 'utf8');
const src = readFileSync(join(here, 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
export function once(text, needle) { let n = 0, i = -1; while ((i = text.indexOf(needle, i + 1)) >= 0) n++; return n; }
export function section(text, pageId) {
const open = text.indexOf('id="page-' + pageId + '"'); if (open < 0) return '';
const start = text.lastIndexOf('<section', open);
let depth = 0, i = start;
const tag = /<\/?section\b/g; tag.lastIndex = start;
for (let m; (m = tag.exec(text));) { if (m[0] === '<section') depth++; else depth--; if (depth === 0) return text.slice(start, m.index + 10); }
return text.slice(start);
}
export function inPage(text, pageId, needle) { return once(section(text, pageId), needle); }
test('the counters count', () => {
assert.equal(once('a b a', 'a'), 2); assert.equal(once('', 'a'), 0);
assert.equal(inPage('<section class="page" id="page-x">one two one</section><section class="page" id="page-y">one</section>', 'x', 'one'), 2);
assert.equal(inPage(html, 'settings', 'id="page-settings"'), 1);
});
// item 1: the four pages
test('T-R15 (as the founder amended it for v2.0.1): five pages, mine, cards, tune, earnings, settings; Prove is a Settings section, not a page', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'cards', 'tune', 'earnings', 'settings']);
assert.equal(V.page('nonsense').id, 'mine');
assert.equal(once(html, 'id="page-prove"'), 0);
assert.equal(once(html, 'id="s-prove"'), 1); assert.equal(inPage(html, 'settings', 'id="s-prove"'), 1);
assert.equal(once(html, 'data-page="prove"'), 0);
for (const id of ['mine', 'cards', 'tune', 'earnings', 'settings']) { assert.equal(once(html, 'id="page-' + id + '"'), 1, id); assert.equal(once(html, '<button class="nav' + (id === 'mine' ? ' on' : '') + '" data-page="' + id + '"'), 1, 'rail ' + id); }
assert.equal(once(html, 'id="page-overview"'), 0);
assert.equal((html.match(/<button class="nav[^"]*" data-page="/g) || []).length, 5, 'five rail entries');
assert.equal(V.page('mine').sub, 'what this machine is doing now'); assert.equal(V.page('tune').sub, 'how Ember drives each card');
assert.equal(V.page('earnings').sub, 'what this machine earned'); assert.equal(V.page('settings').sub, 'set once, left alone');
assert.equal(/renderProve\b/.test(src), false, 'renderProve is renderProvingSection under Settings');
assert.equal(/'overview'|"overview"|page-overview/.test(src), false, 'no overview page id left in the page code');
});
test('T-R8: the pill is a state word, the rate lives on Mine', () => {
const base = { setup_done: true, mining: { state: 'mining', hash_total: 511, cards: [] }, node: { state: 'ok' } };
assert.deepEqual(V.pill(base), { text: 'Mining', tone: 'on' });
assert.equal(/MH\/s/.test(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }).text), false);
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', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'starting' } }), { text: 'Syncing', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'ok' } }), { text: 'Waiting', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'held', hash_total: 0, cards: [] }, jobs: { active: true } }), { text: 'Job running', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 100, cards: [] }, jobs: { active: true } }), { text: 'Job running', tone: '' });
for (const st of ['restarting', 'behind', 'no peers', 'failed', 'stopped', 'stub']) assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: st } }), { text: 'Node down', tone: 'bad' }, st);
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
assert.deepEqual(V.pill({ ...base, heat: { on: true, phase: 'resting' }, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Resting · heat mode', tone: '' }, 'a rest names heat mode, never a bare word (heat-region.test.mjs)');
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
// 2.0.2 (PC 2, 21:24 UK): the quit's stage stands on the pill and the big button while the node is being stopped
assert.deepEqual(V.pill({ ...base, quitting: true, quit_stage: 'quitting: the node is being stopped' }), { text: 'Stopping · the node is being stopped', tone: '' });
assert.equal(V.toggle({ state: 'mining', cards: [] }, { state: 'syncing' }, { quitting: true, quit_stage: 'quitting: the node is being stopped' }).sub, 'quitting: the node is being stopped');
assert.equal(V.toggle({ state: 'mining', cards: [] }, { state: 'syncing' }, { quitting: true }).sub, 'miners first, then the node');
});
// the toggle's sub-line (T-R8's second half) goes with item 3, Mine
// item 4: Tune. Fixtures: a measured 5090 with its three tiers and a lock, the fleet of two
const cardFx = (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: 126.8, hash_avg: 126.5, power_w: 372, power_default_w: 450, power_pct: 90, power_applied: true, temp_gpu: 59, temp_mem: 68, identities: 8, sweep_supported: true, sweep_state: 'idle', sweep_note: '', sweep_at: 1_800_000_000 - 3600, sweep_pct: 90, sweep_watts: 372, sweep_mhs: 126.8, tune_line: 'Tuned: 126.8 MH/s at 372 W (0.341 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 1950, lock_at: 1_800_000_000 - 3600, lock_mhz: 1950, lock_w: 372, lock_mhs: 126.8, lock_mhw: 0.341, lock_note: 'rate fell 2.3 percent at 1200 MHz', unlocked_w: 405, unlocked_mhs: 128.1, gclk_mhz: 1950, mclk_mhz: 13801, fan_pct: 62, detail: '21760 cores', driver_os: '617.42', tiers: [{ id: 'efficiency', clock_mhz: 1200, power_pct: 80, w: 305, mhs: 121.9, mhw: 0.4, source: 'measured' }, { id: 'balanced', clock_mhz: 1950, power_pct: 90, w: 372, mhs: 126.8, mhw: 0.341, source: 'measured' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 450, mhs: 128.4, mhw: 0.285, source: 'stock' }], tier: 'balanced', tier_note: '', tier_at: 1_800_000_000 - 3600, tune_curve: [{ watts: 450, mhs: 128.4, eff: 0.285, clock_mhz: 0 }, { watts: 405, mhs: 128.1, eff: 0.316, clock_mhz: 2400 }, { watts: 372, mhs: 126.8, eff: 0.341, clock_mhz: 1950 }, { watts: 305, mhs: 121.9, eff: 0.4, clock_mhz: 1200 }], ...over });
const NOW = 1_800_000_000;
const stateFx = (over) => ({ now: NOW, settings: { sweep: true, tuning_off: false, tune_period_s: 604800, power_control: true, tune_tier: 'balanced', power_price_pence: 28 }, mining: { state: 'mining', cards: [cardFx()] }, heat: { on: false }, ...over });
test('T-R1: the tier buttons live on Tune alone: the fleet strip and one row per card', () => {
assert.equal(once(html, 'aria-label="Tuning tier"'), 1); assert.equal(inPage(html, 'tune', 'aria-label="Tuning tier"'), 1);
assert.equal(inPage(html, 'settings', 'tier-seg'), 0); assert.equal(once(src, 'tier-seg-2'), 0); assert.equal(once(src, 'goal-seg'), 0);
assert.equal(inPage(html, 'mine', 'tier-seg'), 0);
});
test('T-R2: the Ember sentence once, on the Tune strip; the measure switch with it', () => {
assert.equal(once(html, 'id="tune-schedule"'), 1); assert.equal(inPage(html, 'tune', 'id="tune-schedule"'), 1); assert.equal(once(html, 's-tune-schedule'), 0);
assert.equal(once(html, 'id="s-sweep"'), 1); assert.equal(inPage(html, 'tune', 'id="s-sweep"'), 1);
assert.equal(inPage(html, 'tune', 'Measure cards by itself'), 1);
});
test('T-R3: one Power control switch, in Settings; Tune says one sentence when it matters', () => {
assert.equal(once(html, 'id="s-power-control"'), 1); assert.equal(inPage(html, 'settings', 'id="s-power-control"'), 1); assert.equal(once(html, 'm-power-control'), 0);
assert.equal(V.powerNote(stateFx({ settings: { ...stateFx().settings, power_control: false } })), 'Power control is off, so NVIDIA cards are measured and never set. Turn it on in Settings.');
assert.equal(V.powerNote(stateFx()), '');
assert.equal(V.powerNote(stateFx({ settings: { ...stateFx().settings, power_control: false }, mining: { state: 'mining', cards: [cardFx({ vendor: 'amd' })] } })), '', 'no NVIDIA card, no sentence');
});
test('T-R4: the electricity price is a figure in Settings > Electricity only', () => {
for (const p of ['mine', 'tune', 'earnings']) assert.equal(inPage(html, p, 'per kWh'), 0, p);
assert.equal(V.priceFigure, undefined); assert.equal(once(src, 'priceFigure'), 0);
});
test('T-R5: the knee rule once, at the foot of Tune, in the markup', () => {
assert.equal(once(html, 'Ember never locks below the knee by itself. Going lower is your choice.'), 1); assert.equal(inPage(html, 'tune', 'Ember never locks below the knee'), 1);
assert.equal(once(src, 'TIER_RULE'), 0); assert.equal(once(src, 'LOCK_RULE'), 0);
});
test('T-R6 and T-A10: one lock line per card, MHz and watts only, one baseline', () => {
const l = V.tuneLine(cardFx(), stateFx(), NOW);
assert.equal(l, 'Locked at 1,950 MHz, cap 405 W. Knee at 1,200 MHz.');
assert.equal(/cap (\d+) W/.exec(l)[1], /^Cap (\d+) W/.exec(V.capSentence(cardFx()))[1], 'the lock line and the cap sentence carry the same cap figure');
assert.equal(/Tuned:|MH\/s|of rate|saves/.test(l), false);
assert.equal(V.tuneLine(cardFx({ lock_mhz: 0, lock_note: 'rate fell 2.3 percent at 2400 MHz' }), stateFx(), NOW), 'Clock unlocked, cap 405 W. Knee at 2,400 MHz.');
assert.equal(V.tuneLine(cardFx({ vendor: 'amd', power_default_w: 0 }), stateFx(), NOW), 'Locked at 1,950 MHz. Knee at 1,200 MHz.', 'no cap slider, no cap figure');
assert.equal(V.tuneLine(cardFx({ state: 'tuning', sweep_state: 'running', tune_step: 3, tune_steps: 9, tune_eta_s: 230 }), stateFx(), NOW), 'Measuring: step 3 of 9, 4 min left.');
assert.equal(V.tuneLine(cardFx({ vendor: 'apple', kind: 'apple', tiers: [], tier: '', tier_note: 'no lever: Apple silicon sets its own clocks and power', lock_note: 'no lever', lock_at: 0, lock_mhz: 0 }), stateFx(), NOW), 'No lever on Apple silicon: the system sets the clocks. Ember measures and reports.');
assert.equal(V.tuneLine(cardFx({ tiers: cardFx().tiers.map((t) => ({ ...t, table: true, label: 'measured' })), tiers_table: true, lock_at: 0, lock_mhz: 0, tune_line: '', sweep_at: 0 }), stateFx(), NOW), 'From the team’s table, measured on this card class.');
assert.equal(V.tuneLine(cardFx({ tiers_class: 'v4', program_class: 'v5', tiers_remeasure_at: NOW - 120 }), stateFx(), NOW), 'Re-measuring for class v5, started ' + V.hhmm(NOW - 120) + '.');
assert.equal(V.tuneLine(cardFx({ tiers: [], lock_at: 0, lock_mhz: 0, tune_line: '', sweep_at: 0 }), stateFx(), NOW), 'Not measured yet: the search runs 2 min into steady mining.');
assert.equal(V.tuneLine(cardFx(), stateFx({ settings: { ...stateFx().settings, tuning_off: true, tuning_note: 'Tuning paused fleet-wide by the signed manifest' } }), NOW), 'Tuning paused fleet-wide by the signed manifest.');
assert.equal((l.match(/under stock/g) || []).length <= 1, true);
});
test('T-R7: no fleet saving line; the button carries the saving', () => {
assert.equal(V.fleetSaving, undefined); assert.equal(once(html, 'fleet-saving'), 0); assert.equal(once(src, 'fleetSaving'), 0);
});
test('T-A7: a tier button reads "N W under stock", no "saves", no currency', () => {
const t = V.tierSet(cardFx(), stateFx(), 28);
assert.equal(t.tiers[0].sub, '0.400 MH/W · 145 W under stock'); assert.equal(t.tiers[1].sub, '0.341 MH/W · 78 W under stock'); assert.equal(t.tiers[2].sub, '0.285 MH/W · stock');
for (const x of t.tiers) assert.equal(/saves|£|\$|€/.test(x.sub + x.line), false);
const f = V.fleetTiers([cardFx(), cardFx({ key: 'b' })], stateFx(), 28);
assert.equal(f.tiers[0].sub, '290 W under stock'); assert.equal(f.tiers[2].sub, 'stock');
});
test('T-A9: the strip sentence for two measured cards is one sentence with one "ago"', () => {
const s = V.tierSentence([cardFx(), cardFx({ key: 'b', sweep_at: NOW - 7200 })], stateFx(), NOW);
assert.match(s, /^Balanced on 2 cards\. Measured 1 h ago, next check \d+ \w+\.$/);
assert.equal((s.match(/ago/g) || []).length, 1);
});
test('T-A11: the cap sentence: one figure for the cap, one for the draw', () => {
assert.equal(V.capSentence(cardFx()), 'Cap 405 W (90%). The card draws 372 W at the lock.');
assert.equal(V.capSentence(cardFx({ lock_mhz: 0, lock_at: 0, power_w: 380 })), 'Cap 405 W (90%). The card draws 380 W.');
assert.equal(V.capSentence(cardFx({ power_applied: false })), 'Cap 405 W (90%), not set yet: Windows needs the administrator prompt.');
});
test('T-R16 and T-R17: the Tune card carries one Tune button and the MH/W once in its head; the row word stays on Mine', () => {
const h = V.tuneCardHtml(cardFx(), stateFx(), NOW);
assert.equal((h.match(/data-tune=|data-stop=/g) || []).length, 1);
const head = h.slice(0, h.indexOf('class="tc-tiers"'));
assert.equal((head.match(/MH\/W/g) || []).length, 1); assert.equal((head.match(/MH\/s/g) || []).length, 1);
assert.equal(once(h, 'Retune'), 1);
assert.equal(once(h, 'Locked at 1,950 MHz, cap 405 W. Knee at 1,200 MHz.'), 1);
assert.equal(once(h, '<details'), 1, 'one closed Details disclosure'); assert.equal(once(h, '<details open'), 0);
assert.equal(once(h, 'Cap 405 W (90%). The card draws 372 W at the lock. Lower is cooler and quieter.'), 1);
assert.equal(once(h, 'Each one votes and is paid on its own. 8 for a big card, 2 for a small one, 1 for a built-in\u00a0GPU.'), 1, 'the last two words glued');
assert.equal(once(h, 'memory 68 °C · core 1,950 MHz · memory clock 13,801 MHz · fan 62%'), 1);
assert.equal(once(h, 'runs on CUDA · 21,760 cores · driver 617.42'), 1);
assert.equal(once(h, 'The ringed point is the one Ember kept; a hollow point was rejected; the dashed point is the knee.'), 1);
assert.equal(/Ember never locks|saves \d|£|a day|IGN/.test(h), false, 'no rule, no money, no IGN on the Tune card');
const m = V.tuneCardHtml(cardFx({ state: 'tuning', sweep_state: 'running', tune_step: 3, tune_steps: 9, tune_eta_s: 230 }), stateFx(), NOW);
assert.equal(once(m, 'data-stop='), 1); assert.equal(once(m, 'data-tune='), 0);
});
test('T-R20: the heat line once, on Tune, only while heat mode is on', () => {
assert.equal(once(html, 'id="heat-line"'), 1); assert.equal(inPage(html, 'tune', 'id="heat-line"'), 1);
assert.equal(V.heatLine({ on: false }).text, '');
});
// item 5: Earnings. The windows from state.ladder.earned (item 2), the Electricity row, the Payouts line, the Standing
// row with the rungs behind Details, the projection as one dim foot line that hides with its reason
const earnedFx = { h24: { wei: '14640000000000000000', ign: 14.64, blocks: 3, shards: 0, priced: true }, d7: { wei: '89200000000000000000', ign: 89.2, blocks: 18, shards: 1, priced: true }, all: { wei: '393070000000000000000', ign: 393.07, blocks: 80, shards: 6, priced: true }, estimated: false };
const chainFx = (over) => ({ ok: true, source: 'node', params: { dust: 5, weight_window: 7200 }, network: { hashrate_hps: 100e9, miner_ign_per_block: 4.88, ign_per_block: 6.1, ramp_factor: 0.8, daa: 1, blocks_per_day_measured: 86400, bps: 1, stale: false }, keys_listed: 4, keys_cap: 0, voters: 14, mine: [], best: { id: '8fafda27', blocks: 251, voter: true, participation: 1, rank: 3 }, ...over });
const earnState = (over) => ({ now: NOW, uptime_s: 1800, setup_done: true, address: { display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8', source: 'generated' }, settings: { prove: false, dev_fee: true, power_price_pence: 28 }, node: { synced: true, state: 'synced', tip_age_s: 2 }, proving: { paid: 0, paid_wei: 0 }, jobs: { active: false }, mining: { state: 'mining', hash_total: 17, watts_total: 622, accepted_total: 80, accepted_session: 12, found: [], cards: [cardFx({ hash_now: 17, power_w: 622 })] }, ladder: { first_block_at: NOW - 86000, blocks_30d: 251, blocks_today: 2, blocks_24h: 3, days: [], blocks: [], streak_s: 3600, last_signed_locked: 8490, signed_locked_count: 40, shards: [], earned: earnedFx, first_synced_at: NOW - 86400, first_mining_at: NOW - 86400 }, ...over });
test('T-A1 and T-A2: the three windows read the engine\'s earned sums; no "day", no "this run", no reward applied to old blocks', () => {
const e = V.earningsLines(earnState(), chainFx(), 28, NOW);
assert.equal('day' in e, false); assert.equal('run' in e, false); assert.equal('lifetime' in e, false); assert.equal('weight' in e, false);
assert.deepEqual([e.h24.ign, e.h24.blocks, e.h24.shards], [14.64, 3, 0]);
assert.deepEqual([e.d7.ign, e.d7.blocks, e.d7.shards], [89.2, 18, 1]);
assert.deepEqual([e.all.ign, e.all.blocks, e.all.shards], [393.07, 80, 6]);
assert.equal(e.h24.text, '14.64 IGN'); assert.equal(e.h24.sub, '3 blocks');
assert.equal(e.d7.text, '89.20 IGN'); assert.equal(e.d7.sub, '18 blocks · 1 shard');
assert.equal(e.all.text, '393.1 IGN'); assert.equal(e.all.sub, '80 blocks · 6 shards');
assert.equal(e.headline.text, '14.64 IGN'); assert.equal(e.headline.eyebrow, 'last 24 hours'); assert.equal(e.headline.sub, '3 blocks');
assert.equal(/this run|at today|a day/.test(JSON.stringify([e.headline, e.h24, e.d7, e.all])), false);
// the sums come from the engine, never count × today's reward: a different reward changes nothing
assert.equal(V.earningsLines(earnState(), chainFx({ network: { ...chainFx().network, miner_ign_per_block: 99 } }), 28, NOW).h24.text, '14.64 IGN');
// before the first block
const z = V.earningsLines(earnState({ ladder: { ...earnState().ladder, earned: { ...earnedFx, h24: { wei: '0', ign: 0, blocks: 0, shards: 0, priced: true }, d7: { wei: '0', ign: 0, blocks: 0, shards: 0, priced: true }, all: { wei: '0', ign: 0, blocks: 0, shards: 0, priced: true } } } }), chainFx(), 28, NOW);
assert.equal(z.headline.text, '0 IGN'); assert.equal(z.headline.sub, 'no block yet'); assert.equal(z.h24.sub, 'no block yet');
// an engine before the windows: the IGN reads "reading", the windows show blocks only, counted from the ladder's own
// record and never zero (blocks_24h, else the block list filtered to the day; the day ring for 7 days; the lifetime count)
const old = V.earningsLines(earnState({ ladder: { ...earnState().ladder, earned: undefined } }), chainFx(), 28, NOW);
assert.equal(old.headline.text, 'reading'); assert.equal(old.h24.text, 'reading'); assert.equal(old.h24.sub, '3 blocks'); assert.equal(old.all.sub, '80 blocks');
const dayOf = Math.floor(NOW / 86400);
const older = V.earningsLines(earnState({ ladder: { ...earnState().ladder, earned: undefined, blocks_24h: undefined, blocks: [{ at: NOW - 100 }, { at: NOW - 5000 }, { at: NOW - 90000 }], days: [{ day: dayOf, blocks: 2 }, { day: dayOf - 1, blocks: 5 }, { day: dayOf - 6, blocks: 4 }, { day: dayOf - 9, blocks: 7 }] } }), chainFx(), 28, NOW);
assert.equal(older.headline.sub, '2 blocks', 'blocks_24h absent: the block list filtered to the day, never zero');
assert.equal(older.h24.sub, '2 blocks'); assert.equal(older.d7.sub, '11 blocks'); assert.equal(older.d7.text, 'reading'); assert.equal(older.all.sub, '80 blocks');
// the page shows the 24 h figure once, as the headline; the row is 7 days and all time
assert.equal(once(html, 'id="e-h24"'), 0); assert.equal(inPage(html, 'earnings', 'id="e-d7"'), 1); assert.equal(inPage(html, 'earnings', 'id="e-all"'), 1); assert.equal(inPage(html, 'earnings', 'id="e-head"'), 1);
assert.equal((section(html, 'earnings').match(/class="earn-cell"/g) || []).length, 2, 'two columns under the headline');
// an estimated all-time figure says so; an unpriced window says so
assert.equal(V.earningsLines(earnState({ ladder: { ...earnState().ladder, earned: { ...earnedFx, estimated: true } } }), chainFx(), 28, NOW).all.sub, '80 blocks · 6 shards · estimated');
assert.equal(V.earningsLines(earnState({ ladder: { ...earnState().ladder, earned: { ...earnedFx, h24: { ...earnedFx.h24, priced: false } } } }), chainFx(), 28, NOW).h24.sub, '3 blocks · one block not priced yet');
});
test('T-A3 and A17: one Standing row; the rungs behind Details with no intro', () => {
const e = V.earningsLines(earnState(), chainFx(), 28, NOW);
assert.equal(e.standing.text, 'Rank 3 of 4 keys · signing 8 of 80 points');
assert.equal(e.standing.sub, 'weight is your blocks over the last 2 hours, 2 of 2 hours mined');
assert.equal(V.earningsLines(earnState({ ladder: { ...earnState().ladder, streak_s: 0 } }), chainFx(), 28, NOW).standing.text, 'Rank 3 of 4 keys · no signing points yet');
assert.equal(V.earningsLines(earnState(), chainFx({ best: null, mine: [] }), 28, NOW).standing.text, 'Not in the weight table yet');
assert.equal(V.earningsLines(earnState(), { ok: false, error: 'neither answered' }, 28, NOW).standing.text, 'Standing: not read');
assert.equal(once(html, 'id="rungs"'), 1); assert.equal(inPage(html, 'earnings', 'id="rungs"'), 1);
const sec = section(html, 'earnings'), d = sec.indexOf('id="standing-details"');
assert.ok(d > 0 && sec.indexOf('id="rungs"') > d, 'the rungs sit behind the Standing details');
assert.equal(inPage(html, 'earnings', 'No points server'), 0, 'no ladder intro'); assert.equal(once(html, 'id="ladder-card"'), 0);
assert.equal(once(html, 'timeline-card'), 0, 'no first-hour timeline (A18)'); assert.equal(once(html, 'id="tl-toggle"'), 0);
});
test('T-R9, T-R11, T-R18 and A4: Electricity once, Payouts once, no lifetime cell, no run line, the address in Settings', () => {
const e = V.earningsLines(earnState(), chainFx(), 28, NOW);
assert.equal(e.cost.text, '£4.18 a day'); assert.equal(e.cost.sub, 'at 28p/kWh for 622 W');
const noPrice = V.earningsLines(earnState(), chainFx(), 0, NOW);
assert.equal(noPrice.cost.text, '622 W'); assert.equal(noPrice.cost.sub, 'Set a price in Settings for the cost.');
const mac = V.earningsLines(earnState({ mining: { ...earnState().mining, watts_total: 0, cards: [cardFx({ vendor: 'apple', kind: 'apple', power_w: 0, temp_gpu: 0 })] } }), chainFx(), 28, NOW);
assert.equal(mac.cost.text, 'No power reading on Apple silicon.'); assert.equal(mac.cost.sub, '');
assert.equal(e.payouts.text, 'Pays 0xdd44…86E8'); assert.equal(e.payouts.sub, 'Your balance is in the wallet. Change the address in Settings.');
assert.equal(V.earningsLines(earnState({ address: {} }), chainFx(), 28, NOW).payouts.text, 'No address yet');
assert.equal(once(html, 'e-run'), 0); assert.equal(once(html, 'id="e-blocks"'), 0); assert.equal(once(html, 'id="e-rung"'), 0);
assert.equal(once(html, 'id="r-address"'), 1); assert.equal(inPage(html, 'settings', 'id="r-address"'), 1);
assert.equal(inPage(html, 'earnings', 's-address-input'), 0); assert.equal(inPage(html, 'earnings', 'key-toggle'), 0);
assert.equal(inPage(html, 'earnings', 'id="e-cost"'), 1); assert.equal(inPage(html, 'earnings', 'id="e-pays"'), 1);
assert.equal(inPage(html, 'settings', '<h3>Wallet</h3>'), 1);
const f = V.railFoot(earnState({ display_name: 'study-pc' }));
assert.equal(f.name, 'study-pc'); assert.equal(f.up, 'up 30 min'); assert.equal('pays' in f, false); assert.equal(/pays|0x/.test(JSON.stringify(f)), false);
});
test('T-R19: one projection, a dim foot line, hidden with its reason under a job, a pause, a still chain, no card mining or an unread rate', () => {
const e = V.earningsLines(earnState(), chainFx(), 28, NOW);
assert.equal(e.projection, 'About 71.68 IGN a day at 17.0 MH/s, 0.017% of the network.');
assert.equal(V.cardIgnDay, undefined);
const held = earnState({ jobs: { active: true, title: 'class v6 floor lane 2 (the 5090 grid)', started_at: NOW - 7260 }, mining: { ...earnState().mining, state: 'held' } });
const h = V.earningsLines(held, chainFx(), 28, NOW);
assert.equal(h.projection, null); assert.equal(h.projectionNote, 'Projection paused: class v6 floor lane 2 (the 5090 grid) running since ' + V.hhmm(NOW - 7260) + '.');
assert.equal(/IGN/.test(h.projectionNote), false);
const still = V.earningsLines(earnState({ node: { ...earnState().node, tip_age_s: 900 } }), chainFx(), 28, NOW);
assert.equal(still.projection, null); assert.equal(still.projectionNote, 'Network paused: no block on the chain for 15 min.');
assert.equal(V.earningsLines(earnState({ mining: { ...earnState().mining, state: 'paused', paused: true } }), chainFx(), 28, NOW).projection, null);
assert.equal(V.earningsLines(earnState({ mining: { ...earnState().mining, state: 'waiting' } }), chainFx(), 28, NOW).projection, null);
assert.equal(V.earningsLines(earnState(), chainFx({ network: { ...chainFx().network, hashrate_hps: 0 } }), 28, NOW).projection, null);
assert.equal(V.earningsLines(earnState(), { ok: false, error: 'neither answered' }, 28, NOW).projection, null);
// the earned figures hold whatever the projection does
assert.equal(h.headline.text, '14.64 IGN'); assert.equal(still.h24.text, '14.64 IGN');
assert.equal(once(html, 'id="e-projection"'), 1); assert.equal(inPage(html, 'earnings', 'id="e-projection"'), 1);
const sec = section(html, 'earnings');
assert.ok(sec.indexOf('id="e-projection"') > sec.indexOf('id="standing-details"'), 'the projection is the last thing on the page');
});
// item 3: Mine. The toggle without its sub, four tiles, the card rows, the chain scene, one node line, five activity
// lines; the ladder strip, the money and blocks tiles, the node details, the stats line and the log button leave
test('T-R8 (the toggle), T-A5, T-R9, T-R18 and T-R10: the hero is four tiles with their units; no money, no blocks, no ladder strip on Mine', () => {
assert.equal(once(html, 'btn-toggle-sub'), 0);
const sec = section(html, 'mine'), tiles = sec.match(/<div class="tot"[^>]*>[\s\S]*?<span class="k">([^<]+)<\/span>/g) || [];
assert.deepEqual(tiles.map((t) => /<span class="k">([^<]+)<\/span>/.exec(t)[1]), ['MH/s', 'W', 'MH/W', 'IGN']);
assert.equal(inPage(html, 'mine', 'id="t-ign"'), 1); assert.equal(inPage(html, 'mine', 'id="t-eff"'), 1);
assert.equal(inPage(html, 'mine', 't-money'), 0); assert.equal(inPage(html, 'mine', 't-blocks'), 0);
assert.equal(once(html, 'ladder-strip'), 0); assert.equal(once(html, 'ls-more'), 0); assert.equal(once(src, "'ls-"), 0);
assert.equal(/£|\$|€/.test(sec.slice(sec.indexOf('class="totals"'), sec.indexOf('id="first-wait"'))), false, 'no currency in the hero');
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [{ key: 'a', enabled: false, state: 'off', vendor: 'nvidia', kind: 'discrete' }], found: [] }, { synced: true }, {}).sub, 'switch a card on first');
});
test('T-A14 and T-R14: one node line on Mine that opens Settings; the node facts live in Settings', () => {
const n = (over) => ({ state: 'synced', synced: true, blocks: 39587, headers: 39587, peers: 4, daa: 40000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
assert.equal(V.nodeLine(n(), { severity: 'none' }, '').text, 'Node synced · 4 peers');
assert.equal(V.nodeLine(n({ peers: 1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer');
assert.equal(V.nodeLine(n({ state: 'syncing', synced: false, blocks: 41000, headers: 52000 }), { severity: 'none' }, 'about 12 min left at 300 blocks/s').text, 'Node syncing · 41,000 of 52,000 blocks · about 12 min left');
assert.equal(V.nodeLine(n({ state: 'restarting', synced: false, message: 'igneumd exited; restart in 20 s' }), { severity: 'none' }, '').text, 'Node restarting · igneumd exited; restart in 20 s');
assert.equal(V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180 }), { severity: 'none' }, '').text, 'Node behind · last block 53 min ago');
assert.equal(once(html, 'id="node-details"'), 1); assert.equal(inPage(html, 'settings', 'id="node-details"'), 1);
assert.equal(inPage(html, 'mine', 'n-digest'), 0); assert.equal(inPage(html, 'mine', 'node-toggle'), 0);
assert.equal(inPage(html, 'mine', 'id="node-go"'), 1); assert.equal(inPage(html, 'settings', '<h3>Node</h3>'), 1);
});
test('T-R13, T-A15 and T-R12 (the stats): Activity is five lines, no stats line, no log button; the log opens from the rail and from Settings', () => {
assert.equal(once(html, 'btn-open-log'), 0); assert.equal(once(html, 'id="btn-logs"'), 1); assert.equal(once(html, 'id="s-log-open"'), 1);
assert.equal(once(html, 'd-stats'), 0); assert.equal(V.activityStats, undefined); assert.equal(once(src, 'to the dev fee'), 0);
assert.equal(once(src, 's.events.slice(0, 5)'), 1, 'five events');
});
// items 6 and 7: Settings in 4.4's order, Updates with the Interface row, the Heat mode label, the unit labels, the copy law
test('T-R21, T-A19, T-A20 and the order of 4.4: eleven Settings sections, Updates holds the Interface row, the fee last', () => {
const sec = section(html, 'settings'), heads = (sec.match(/<h3(?: [^>]*)?>([^<]+)<\/h3>/g) || []).map((h) => />([^<]+)</.exec(h)[1]).filter((t) => t !== 'Prove instead of mining');
assert.deepEqual(heads, ['Wallet', 'Node', 'Igneum Miner', 'Power control', 'Electricity', 'Heat mode', 'Proving', 'This machine', 'Logs', 'Advanced', 'The dev fee']);
assert.equal(once(html, 's-interface-card'), 0); assert.equal(once(html, 'id="s-ui-line"'), 1);
const u = sec.indexOf('id="s-updates-card"'), uEnd = sec.indexOf('\n </div>', u);
assert.ok(u > 0 && sec.indexOf('id="s-ui-line"') > u && sec.indexOf('id="s-ui-line"') < uEnd, 'the Interface row sits inside the Updates card');
assert.ok(sec.indexOf('id="s-ui-builtin"') > u && sec.indexOf('id="s-ui-builtin"') < uEnd);
const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true });
assert.equal(w.line, 'Interface 1.0.0, built in');
const helpStatic = /id="s-ui-help-static"[^>]*>([^<]+)</.exec(sec)[1];
assert.equal(helpStatic, 'Small changes to this window arrive over the air, signed by Igneum.');
const heat = /<label class="switch"><input type="checkbox" id="s-heat">[\s\S]*?<span class="sw-text">([\s\S]*?)<\/span><\/label>/.exec(sec)[1].replace(/<[^>]+>/g, '').trim();
assert.ok(heat.length < 30, 'the Heat mode label is five words: ' + heat);
assert.equal(inPage(html, 'settings', 'The cards heat for a share of every 10 minutes and rest for the rest. Your card is an electric heater that also earns.'), 1);
assert.equal(once(html, 'Settings can show it again.'), 1); assert.equal(once(html, 'Earnings can show it again.'), 0);
});
test('T-A8 and the copy law: no uppercase on a unit label; no em dash in the window', () => {
const css = readFileSync(join(here, 'app.css'), 'utf8');
for (const sel of ['.tot .k', '.num .v .unit', '.tier-b .l']) {
const rules = css.split('\n').filter((l) => l.startsWith(sel + '{') || l.startsWith(sel + ','));
for (const r of rules) assert.equal(/text-transform:\s*uppercase/.test(r), false, sel + ' is a unit label: ' + r);
}
assert.equal(once(html, '—'), 0, 'no em dash in index.html'); assert.equal(once(src, '—'), 0, 'no em dash in app.js');
});
// Igneum 2.0 (the founder, 8 October 2026, 16:2x BST): the testnet is scrapped and the devnet is one network, "the Igneum 2.0
// devnet" (the engine runs igneum-devnet-4 and serves the label as state.chain). The network step and the Settings Node card
// show one card; no testnet card, no "opens when" note, no "Devnet 3" or "devnet v4" text anywhere in the window. Known-failed
// first on release-2.0.0 b891444f: two cards, the testnet note, the old labels.
test('Igneum 2.0: one network, the Igneum 2.0 devnet; no testnet, no Devnet 3, no devnet v4 anywhere in the window', () => {
assert.equal(V.NETWORKS.length, 1); assert.equal(V.NETWORKS[0].id, 'devnet-4'); assert.equal(V.NETWORKS[0].name, 'igneum-devnet-4'); assert.equal(V.NETWORKS[0].label, 'Igneum 2.0 devnet');
const fresh = V.networkWords({ setup_done: false, network_name: '', network_pending: '', settings: { network: '' }, update: {} });
assert.equal(fresh.cards.length, 1); assert.equal(fresh.selected, 'devnet-4'); assert.equal(fresh.cards[0].open, true); assert.equal(fresh.cards[0].selected, true);
assert.equal(fresh.cards[0].line, 'igneum-devnet-4'); assert.equal(fresh.cards[0].sub, 'the chain may reset, coins have no value');
const running = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-4', network_pending: '', settings: { network: '' }, update: {} });
assert.equal(running.line, 'This machine runs the Igneum 2.0 devnet (igneum-devnet-4).'); assert.equal(running.cards[0].current, true);
assert.equal(V.networkIdOf('igneum-devnet-3'), 'devnet-4', 'an older name reads as the one network');
for (const text of [html, src]) { assert.equal(/testnet/i.test(text), false, 'no testnet'); assert.equal(/Devnet 3|devnet-3|devnet v4/.test(text), false, 'no old devnet label'); }
assert.equal(once(html, 'Igneum 2.0 devnet'), 2, 'the welcome eyebrow and the node facts default');
assert.equal(once(src, 'opens when the network does'), 0);
});
// every setup screen can be shown (8 October 2026, found by the net-20 capture): .screen is display:none and only the ids in
// app.css's phase rule display; screen-network was never in it, so the network step (step 3 of 4, since 0.3.23) was a blank
// page with no button on a fresh install. Known-failed first: the rule lists five screens and index.html has six.
test('every screen in index.html has its display rule in app.css', () => {
const css = readFileSync(join(here, 'app.css'), 'utf8');
const screens = [...html.matchAll(/<section class="screen" id="screen-([a-z-]+)"/g)].map((m) => m[1]);
assert.ok(screens.length >= 6, 'the setup screens and the dashboard: ' + screens.join(','));
for (const id of screens) assert.ok(css.includes('body[data-phase="' + id + '"] #screen-' + id), 'app.css shows #screen-' + id);
});
// v2.0.1 (the founder's call at 18:1x BST, 8 October 2026, reversing the audit's four-page layout on this point): the Cards
// tab is back in the rail as its own page with every card row (the state, the hash rate, the power, the temperature, the
// tune state, the enable switch, the 16 GB proving note); the home page keeps the headline rate and the chain status.
// Known-failed first on release-2.0.1 95f3068e: the rows on Mine, no Cards page.
test('v2.0.1: the Cards page holds every card row; the home page keeps the headline rate and the chain status', () => {
assert.equal(V.page('cards').title, 'Cards'); assert.equal(V.page('cards').sub, 'every card, its state and its switch');
assert.equal(inPage(html, 'cards', 'id="d-cards"'), 1); assert.equal(inPage(html, 'mine', 'id="d-cards"'), 0); assert.equal(inPage(html, 'mine', 'cards-card'), 0);
assert.equal(inPage(html, 'mine', 'id="totals"'), 1); assert.equal(inPage(html, 'mine', 'id="dag-card"'), 1); assert.equal(inPage(html, 'mine', 'id="node-go"'), 1);
assert.equal(once(src, "page === 'cards'"), 1, 'the Cards page has its own render');
// the row's words: the tune state and the proving note come from the View (the row builder is page-side)
const r = V.cardsRowWords(cardFx(), stateFx(), NOW);
assert.equal(r.tune, 'Locked at 1,950 MHz, cap 405 W. Knee at 1,200 MHz.');
assert.equal(r.proving, '', 'a 32 GB card carries no proving note');
const small = V.cardsRowWords(cardFx({ vram_mb: 12288 }), stateFx(), NOW);
assert.equal(small.proving, 'NVIDIA GeForce RTX 5090: mining and proving together need a 16 GB card; this card does one at a time; mining continues (12 GB).');
assert.equal(V.cardsRowWords(cardFx({ vendor: 'apple', kind: 'apple', tiers: [], tier_note: 'no lever: Apple silicon sets its own clocks and power', lock_note: 'no lever', lock_at: 0, lock_mhz: 0 }), stateFx(), NOW).proving, '');
});
// the four interface words (docs/spec/finality-guarantees.md section 9): the window's block inspector reads included,
// executed, proven, finalised; a block under the lock is finalised even while the network's finality is paused (the pause
// sits on the status strip), and no block ever reads "not active". Known-failed first: "Final (reported)" / "Not marked final".
test('the block inspector reads the four words; a locked block is finalised even in a pause', () => {
const b = { color: 'blue', chain: true, number: 28175, proven: true, final: true, locked: false };
assert.equal(V.blockState(b, false), 'Included / executed as chain block 28,175 / proven / finalised');
assert.equal(V.blockState(b, true), 'Included / executed as chain block 28,175 / proven / finalised', 'a lock holds in a pause');
assert.equal(V.blockState({ ...b, final: false }, true), 'Included / executed as chain block 28,175 / proven · finality paused on the network above the lock');
assert.equal(V.blockState({ ...b, final: false, proven: false }, false), 'Included / executed as chain block 28,175');
assert.equal(V.blockState({ color: 'red', chain: false, proven: false, final: false }, false), 'Excluded');
assert.equal(V.blockState({ color: '', chain: false }, false), 'Pending');
assert.equal(/not active|Not marked final|\(reported\)/.test(src), false);
assert.equal(once(html, 'id="s-positioning"'), 0, 'the positioning line once, on the hero'); assert.equal(once(html, 'id="positioning"'), 1);
});
// v2.0.2 (the founder, 8 October 2026, 19:25 on the mini): with the node at 0 peers the worker idles for lack of templates;
// the node line says so ("Node: no peers, dialling the seeds") and the engine's card message names the cause, never a worker
// fault. Known-failed first: "Node no peers · looking for the seed node" and no such message in the engine.
test('v2.0.2: no peers reads as the node dialling the seeds on the node line; the engine names the missing templates', () => {
const n = { state: 'no peers', synced: false, blocks: 39000, headers: 39000, peers: 0, daa: 40000, last_reading_age_s: 4, tip_age_s: 500, message: 'waiting for a peer on our chain', restart_in_s: 0 };
const l = V.nodeLine(n, { severity: 'none' }, '');
assert.equal(l.text, 'Node: no peers, dialling the seeds'); assert.equal(l.tone, 'bad');
assert.equal(V.nodeWords(n, { severity: 'none' }, '').line, 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits for block templates.');
const engine = readFileSync(join(here, '..', 'src', 'engine.rs'), 'utf8'), watchdog = readFileSync(join(here, '..', 'src', 'watchdog.rs'), 'utf8');
assert.ok(once(watchdog, 'no block templates: the node has no peers') >= 1, 'the watchdog carries the cause');
assert.ok(engine.includes('held_by_node()'), 'the engine reads the watchdog\'s hold reason onto the card');
});
// review B, 8 October 2026, for v2.0.2 (the founder's rulings through the shipper). F14: two builds from one tree; the
// window names the build it runs in, shows the remote-jobs switch on the lab build only, and asks the update choice at
// install. F07: the per-device proving mode from the engine (simultaneous, time-share, mining-only). F04: a recovery lock
// is labelled "recovery" on every surface, never "final". Written with the code (the read-back is the assertion).
test('F14: the window names the build, hides the jobs switch on the public miner, asks the update choice at install', () => {
assert.equal(V.productName('public'), 'Igneum Miner'); assert.equal(V.productName('lab'), 'Igneum Miner Lab'); assert.equal(V.productName(''), 'Igneum Miner');
assert.equal(once(src, "View.productName(s.edition)"), 1, 'the About line reads the build');
assert.equal(once(html, 'id="s-jobs-row"'), 1); assert.equal(once(src, "setHidden('s-jobs-row', s.edition !== 'lab')"), 1, 'the jobs switch is the lab build\'s');
const ws = html.indexOf('id="screen-welcome"'), w = html.slice(ws, html.indexOf('</section>', ws));
assert.ok(/<input type="checkbox" id="w-auto-update" checked>/.test(w), 'the choice at install, on by default');
assert.ok(w.indexOf('id="w-auto-update"') < w.indexOf('id="btn-begin"'), 'the choice sits before Get started');
assert.equal(once(src, "$('w-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); })"), 1, 'the welcome switch posts the choice by the Settings switch\'s route');
});
test('F07: the engine\'s per-device mode line stands on the Proving section; the window\'s own tier sentence stands for an older engine', () => {
const cd = cardFx();
assert.equal(V.proveModeLine(cd, { modes: [{ key: cd.key, name: cd.name, mode: 'time-share', line: 'NVIDIA GeForce RTX 5090: one at a time (32 GB): the miner steps off the card, the memory is confirmed free, the proof runs, the miner rebuilds its dataset and resumes.' }] }), 'NVIDIA GeForce RTX 5090: one at a time (32 GB): the miner steps off the card, the memory is confirmed free, the proof runs, the miner rebuilds its dataset and resumes.');
assert.equal(V.proveModeLine(cd, {}), V.proveTier(cd));
assert.equal(V.proveModeLine(cd, { modes: [{ key: 'other', line: 'x' }] }), V.proveTier(cd));
assert.equal(once(src, 'View.proveModeLine(cd, pv)'), 1);
});
test('F04: a recovery lock reads "recovery" on the strip and the inspector, never "final"', () => {
const f = { last_lock: 8495, age_s: 40, lock_kind: 'recovery', paused: false };
const fw = V.finalityWords(f);
assert.equal(fw.recovery, true); assert.equal(fw.paused, false); assert.match(fw.note, /^Recovery lock: /); assert.equal(/final\b/i.test(fw.note), false);
assert.equal(V.lockKindWords(f).word, 'recovery lock'); assert.equal(V.lockKindWords({ lock_kind: 'certified' }).recovery, false);
const b = { color: 'blue', chain: true, number: 28175, proven: true, final: true };
assert.equal(V.blockState(b, false, 'recovery'), 'Included / executed as chain block 28,175 / proven / finalised by a recovery lock');
assert.equal(V.blockState(b, false, ''), 'Included / executed as chain block 28,175 / proven / finalised');
assert.equal(once(src, "'Finalised by a recovery lock'"), 1); assert.equal(once(src, "' · recovery lock'"), 1);
// the pause word stands until the node carries lockKind
assert.equal(V.finalityWords({ paused: true, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' }).note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
});

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -109,7 +109,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
func buildWindow() {
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
// window-22 (7 October 2026, the one rule with app/windows/opening_size.h): the PRIMARY screen's work area, 80 percent
// wide capped at 1440 up to a 1920-wide display and 1920 beyond, height from the width at 16:10 bounded by the work
// area less 40, never wider than 1.6 times the height, never under 900 by 600, never over the work area
let work = (NSScreen.screens.first ?? NSScreen.main)?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
func openingSize(_ workW: CGFloat, _ workH: CGFloat) -> NSSize {
let minW = min(workW, 900), minH = min(workH, 600), capW: CGFloat = workW <= 1920 ? 1440 : 1920
var w = min(floor(workW * 0.8), capW); var h = min(floor(w / 1.6), workH - 40)
w = max(w, minW); h = max(h, minH); w = min(w, floor(h * 1.6)); w = min(w, workW); h = min(h, workH)
return NSSize(width: w, height: h)
}
func fits(_ f: NSRect) -> Bool { f.width >= 900 && f.height >= 600 && f.width <= work.width && f.height <= work.height && f.width <= f.height * 1.632 }
let opening = openingSize(work.width, work.height)
let size = snapshotPath == nil ? opening : snapshotSize
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.title = "Igneum Miner"
window.titlebarAppearsTransparent = true
@ -117,7 +129,11 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
window.isMovableByWindowBackground = true
window.backgroundColor = obsidian
window.minSize = NSSize(width: 900, height: 600)
window.center()
if snapshotPath == nil {
// the remembered frame is kept only when it fits the primary work area and the aspect cap, else discarded
if !window.setFrameUsingName("IgneumMinerMain") || !fits(window.frame) { window.setContentSize(opening); window.center() }
window.setFrameAutosaveName("IgneumMinerMain")
} else { window.center() }
window.delegate = self
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
@ -209,6 +225,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 +341,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

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

View file

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

28
app/windows/host.manifest Normal file
View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!-- Igneum Miner.exe (the window host): per-monitor DPI awareness v2 (dpi-23, 7 October 2026). Without it Windows
stretched the whole window on a scaled panel (PC 2's 5120 by 2160 at 200 percent rendered blurry); with it WebView2
renders at the panel's native scale and the host reads physical pixels. Embedded by host.rc as RT_MANIFEST 1;
BUILD-APP.bat links with /MANIFEST:NO so the linker's default manifest does not collide. -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity type="win32" name="Igneum.Miner" version="1.0.0.0"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<!-- V6-06 (8 October 2026): the window host never holds rights; the one prompt it raises for the engine's step is a
child process (runElevated in host.cpp), and the Power Helper task runs a protected copy of the engine. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>

View file

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

View file

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

View file

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

View file

@ -3,6 +3,10 @@
// 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 "2.0.2"
#define IGNEUM_HOST_VERSION_RC 2,0,2,0
// The WebView2 runtime the host's loader needs at least (the SDK 1.0.2903.40 loader's minimum, from its release note at the
// cut); the installer's rights step (src/rights.rs) installs the evergreen runtime when the PC's is absent or below it, and a
// raised minimum is a new right the next update asks once for.
#define IGNEUM_WEBVIEW2_MIN "109.0.1518.78"
#endif

14
brand/marks/regen.mjs Normal file
View file

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

View file

@ -0,0 +1,65 @@
// Igneum vendor and OS marks (gpu-logos, 7 October 2026): the strings the miner app ships in app/igneum-app/ui/app.js
// (View.MARKS and View.VENDORS), exported verbatim for the site and anything else that names the hardware.
// view.test.mjs fails when this file and app.js drift apart, so edit app.js first and regenerate this file with
// `node brand/marks/regen.mjs` (or copy the strings by hand; the test says which one moved).
//
// Each glyph: a hand-drawn simplified monochrome mark of the vendor's public geometry (never a copied logo file,
// never a raster), 24 x 24 viewBox at 22 px, under 460 bytes, fill or stroke through currentColor so the element's
// colour tints it. Nominative use that names the hardware; the ember accent is for state and never tints a brand.
//
// The treatment in the app (app.css, the block at the end): a 44 x 44 well, radius 12, background the vendor colour
// at .14 alpha (dark) or .10 (light), a 1 px ring in the vendor colour at .45 alpha, the glyph in the full colour;
// hover and focus-within add a 3 px halo of the well colour; nothing animates. The light hex of every vendor reads at
// 3:1 or better on its well over white (nvidia 3.87, amd 5.01, intel 4.29, apple 7.52, gpu 4.65).
//
// Class names in the app: .badge.<vendor> (nvidia | amd | intel | apple | gpu), .badge.mini for a 26 px inline mark,
// .gen for the series line under the name. Tokens: --mark-<vendor>, --mark-<vendor>-well, --mark-<vendor>-ring.
export const VENDORS = {
"nvidia": {
"label": "NVIDIA",
"dark": "#8BE37A",
"light": "#2F8A22"
},
"amd": {
"label": "AMD Radeon",
"dark": "#FF5A5A",
"light": "#C41E2A"
},
"intel": {
"label": "Intel",
"dark": "#7CC4FF",
"light": "#1C6FD6"
},
"apple": {
"label": "Apple",
"dark": "#E6E3DD",
"light": "#4A4A50"
},
"gpu": {
"label": "GPU",
"dark": "#9A9A9E",
"light": "#6B6B70"
}
};
export const WELL_ALPHA = { dark: 0.14, light: 0.1 };
export const MARKS = {
"nvidia": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z\"/><path d=\"M15.6 12A3.6 3.6 0 1 0 12 15.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"amd": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M8 3h13v13l-4-4V7h-5z\"/><path d=\"M3 8l4 4v5h5l4 4H3z\"/></svg>",
"intel": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3\"/><path d=\"M11.2 9.6v6.2\"/><circle cx=\"11.2\" cy=\"6.6\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"apple": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><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>",
"gpu": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><rect x=\"5.5\" y=\"5.5\" width=\"13\" height=\"13\" rx=\"2.2\"/><rect x=\"9.5\" y=\"9.5\" width=\"5\" height=\"5\" rx=\"1\"/><path d=\"M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3\"/></svg>"
};
// OS marks in the same treatment: Apple is the vendor glyph; Windows is the four slanted panes
export const OS_MARKS = {
macos: MARKS.apple,
windows: "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z\"/></svg>"
};
export const OS_COLOURS = { macos: VENDORS.apple, windows: { label: 'Windows', dark: '#7CC4FF', light: '#1C6FD6' } };
// the CSS tokens for both themes, as the app declares them
export function tokensCss() {
const line = (theme) => Object.entries(VENDORS).map(([v, c]) => { const h = c[theme], r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16), a = String(WELL_ALPHA[theme]).replace(/^0/, ''); return `--mark-${v}:${h};--mark-${v}-well:rgba(${r},${g},${b},${a});--mark-${v}-ring:rgba(${r},${g},${b},.45)`; }).join(';');
return `:root{${line('dark')}}\n@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){${line('light')}}}\n:root[data-theme="light"]{${line('light')}}`;
}
// the well: <div class="badge nvidia"><svg…></svg></div>
export function markHtml(vendor, size) { const v = MARKS[vendor] ? vendor : 'gpu'; return '<div class="badge ' + (size ? size + ' ' : '') + v + '" data-mark="' + v + '" title="' + VENDORS[v].label + '">' + MARKS[v] + '</div>'; }

View file

@ -2563,6 +2563,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)
@ -2616,6 +2645,49 @@ 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.
## Block rate on Devnet 2, 6 October 2026 (branch gpu-fleet): 10 blocks per second against 1 on 42 rented cards

View file

@ -0,0 +1,78 @@
# Discord: the ladder's rung announcements (message shapes, not posted)
7 October 2026, miner-ui-5 (mission item 6, docs/analysis/mission/mission.md 2.6). The shapes the bot would post for
each rung of the miner's ladder, so the words in Discord match the app and the site rung for rung. Nothing here is
posted by this lane; the Discord lane wires them into `tools/community/discord-hooks.mjs` behind its guard rails
(docs/community/discord-hooks.md: the forbidden-string guard, the limits, idempotency keys, never a mention). Every
number is a chain fact from the public API (`/api/live`, `/api/stats`), which is the observer's copy of the node's
`getFinalityWeights` and `getFinalityCheckpoints`; a post names nothing the chain does not say.
## The rules every rung post obeys
| Rule | How |
|---|---|
| The miner's object, never the project's | a post carries the key id, the block link, the rank and the day; never the network hashrate, the block count or a milestone of the project |
| A chain fact only | the trigger is a field of `getFinalityWeights` (`keys[].blocks`, `keys[].voter`, `keys[].participation`, `voters`) or `getFinalityCheckpoints` (`latestLockedIndex`), read through `/api/live finality.weights` |
| Opt-in for anything beyond the key id | the card model and the "(you)" link to the address page appear only when the miner switched "Make my page public" on in the app (settings.profile_public); without it the post is the key id and the block link |
| Short key id | the first 8 hex of the vote key hash, the same id the app, `/live` and the explorer show; never a payout address, never a machine name |
| One post per key per rung | idempotency key `rung:<rung>:<keyid>`; a rung is posted once for a key, ever (a key that drops and climbs back is not posted again) |
| Copy law | no em dashes, no two-beat antithesis, no aphorisms; the numbers in the line, nothing decorative |
| Volume | #first-blocks takes rungs 0 and 1 (one line each); #numbers takes the daily ladder summary; rungs 2 to 5 and P are never posted singly (one a minute at scale), they are counted in the summary |
## Channel: #first-blocks
### Rung 0, first block (trigger: a key's first blue block in the window; `keys[].blocks` goes 0 to 1 in `finality.weights`, and the block's `miner` is that key in `/api/live blocks[]`)
```
First block: key 6ad0e117
Block 1,284,117 · igneum.network/block/9f3a…c21e
RTX 4070 (the miner chose to show the card)
```
Without the opt-in the third line is absent. Embed: ember accent, title "First block", one field "Key" = `6ad0e117`, one field "Block" = the explorer link; footer the UK time with UTC in brackets (the hooks script's footer rule).
### Rung 1, the vote (trigger: `keys[].voter` goes false to true, that is `blocks` reaches `params.dust`)
```
Your key has a vote: 6ad0e117
100 blocks in the window (the line is 100) · the key now signs every checkpoint
```
On the devnet the line reads `5 blocks in the window (the line is 5)`: the number is `params.dust`, never a constant.
## Channel: #numbers, the daily ladder summary (09:00 UK, beside the daily digest)
```
The ladder, yesterday
First blocks: 14 keys found their first block
Votes: 9 keys crossed the dust line (now 1,213 voters)
Signatures: 1,201 keys signed a locked checkpoint (latest lock 184,220)
Full window: 406 keys at 30 of 30 days
Rank 1 by weight: 6e80f3ef with 2,592 blocks in the window
Signing streak, longest: 41 days unbroken (presence 100%)
Shards: 388 paid to 57 keys
```
Fields and sources, one per line:
| Line | Field |
|---|---|
| First blocks | keys whose `blocks` went from 0 to at least 1 between the two daily reads of `finality.weights.keys[]` |
| Votes | keys whose `voter` went false to true; `voters` for the total |
| Signatures | keys with `participation` above 0 at the day's last read; `latest_locked_index` for the lock |
| Full window | keys whose `blocks` span the whole `params.weightWindow` (owed from the node: `keys[].daysMined`; until then the line is left out, never estimated) |
| Rank 1 | `keys[]` sorted by `blocks`, the top row |
| Signing streak | owed from the node (`keys[].streakDays`); until then the line is left out |
| Shards | `/api/live proving.shards_paid_10m` summed over the day's reads, provers from `live_proofs.prover` |
A line whose field the node does not expose yet is left out of the post rather than estimated (the "no number a company server has to be trusted for" rule, reinvent.md section 6).
## Roles (the Discord lane, from the same fields)
| Role | Granted when | Revoked when |
|---|---|---|
| Voter | a message signed with the vote key names a key whose `voter` is true in `getFinalityWeights` | `voter` false at a daily read (a key under dust, or stripped: `strippedUntilDaa` above the DAA score) |
| Window | the key's blocks span the full `params.weightWindow` (`keys[].daysMined` owed; until then by hand from the ladder summary) | the key misses a day |
| Prover | a paid shard record names the key (`igneum_getProofRecords(block).paid[shard]`) | never (a paid record is a chain fact) |
The signed message is the proof of key ownership; the app does not sign it yet (owed: a "prove my key" button that signs a nonce with the vote key and copies it).

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

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

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