diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc4b45fc3..c104ba6bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,16 +73,24 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: override params files parse with no duplicate key (the duplicate-field class, 6 October 2026) run: bash tools/ci/override-json-check.sh + - name: second-engine playbooks log to a file and end their tree (C35) + run: bash tools/ci/second-engine-check.sh + - name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree) + run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh - name: the signer is never piped into head run: bash tools/ci/signer-pipe-check.sh - name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree) run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh - name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree) run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh + - name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree) + run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs - name: pinned guest programs match their manifest and are built only by pin-guests.sh run: bash tools/ci/pinned-guests-check.sh - name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026) run: bash tools/ci/prover-socket-check.sh + - name: commit-string gate self-test (the empty-commit class of 6 October 2026; the gate itself runs in build-remote.sh, cross-remote.sh and cross-build.sh on every node binary) + run: bash tools/ci/commit-string-check.sh --self-test - name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree) run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh - name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors) @@ -95,6 +103,6 @@ jobs: - name: ship tool self-test (version bump, the dl-both and public manifest helpers) run: node tools/ship-app.mjs --self-test - name: relay unit tests (parsers, secret compare, the wake endpoint) - run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs + run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs - name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows) - run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs + run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 636e1c833..21b95be1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,22 +51,36 @@ Applied 3 Oct 2026: all 15 Igneum domains at GoDaddy (also .art, .email, .pro, . ## Browser profile (standing rule, 3 October 2026) Every Igneum task in Claude in Chrome runs in the Chrome profile "josh (igneum.network)". Josh also has profiles for VIVANMN, QUANTUM, PEASEHILL, THRSTY, GEMVEN and JBM EXEC. Confirm the active profile before acting; if it is the wrong one, switch or stop and say so. Never carry Igneum sign-ins, posts or purchases through another brand's profile. -## Running agents on this Mac (standing rule, 4 October 2026) -- Code and documents in parallel; BUILDS and MEASUREMENTS one at a time through `tools/lock/with-lock.sh build|measure|run ` +## Running agents on this Mac (standing rule, 4 October 2026; builds moved to igneum-build-1 on 6 October 2026) +- Code and documents in parallel; the Mac's own BUILDS and MEASUREMENTS one at a time through `tools/lock/with-lock.sh build|measure|run ` (`measure` blocks builds too: hash rate, latency in ms, power; `run` is for functional runs such as test networks, attack harnesses and simulators whose outputs are counts, locks, forks or seconds, and lets builds continue; use the MAIN checkout's script, /Users/joshm/Projects/igneum/tools/lock/with-lock.sh, from any worktree). A number taken while - another build or simulation ran is not a number. -- Builds and test suites run on the PCs, not on this Mac (standing rule, 5 October 2026, Josh: "since building on the pc is so - much faster, lets fully utilise both of them"): Linux and Windows binaries and every cargo test suite go out as `build` jobs - (`node tools/build-job.mjs run --target ae432dc7|1ccfe586 ...`, PC 1 = ae432dc7 for builds, PC 2 = 1ccfe586 for test - suites; one job per machine at a time; results come back through the relay, every sha256 checked). Measured 5 October: - PC 1 built the Linux and Windows node and app and ran both test suites in 7 min 38 s cold, 5 min 09 s warm. The Mac - builds only the macOS binaries and the DMG, under the build lock, and `~/.config/igneum/build-slots` (1 to 3) caps how - many Mac builds run at once. + another build or simulation ran is not a number. `~/.config/igneum/build-slots` (1 to 3) caps how many Mac builds run at once. +- BUILDS GO TO THE BOX (standing rule, 6 October 2026, main's decision after the measurements in docs/plans/build-server.md): + every Linux and Windows cargo build and every Linux test suite from every agent runs on igneum-build-1 (Hetzner AX162, + 96 threads, 128 GB, `ssh -i ~/.ssh/igneum_ed25519 build@188.40.146.49`) through `tools/build-remote.sh [-- cargo args]` + and `tools/cross-remote.sh` from the crate directory of the agent's own worktree. Measured 6 October: clean node build + 1 min 27 s (Mac 12 to 18 min), incremental 7 s (Mac 2 to 15 min), Windows cross 1 min 44 s (Mac 4 min 49 s to 12 min 28 s). + The box has its own slot files (`/srv/builds/_locks/build-`, count in `/srv/builds/_locks/slots`, 1 today); a remote + build takes one of those, never a Mac slot. Sources travel as HEAD through the bare mirrors `/srv/igneum.git` and + `/srv/igneum-node.git` plus an rsync overlay of uncommitted changes (re-stamped); `/srv/builds/` mirrors the + worktree root; artefacts come back into `/target-remote/`, never `target/` (they are x86_64 Linux and Windows + binaries). Every run writes one line to `/srv/builds/_log/builds.jsonl` (the worker dashboard reads it); `IGNEUM_AGENT=` + tags it. The PCs keep only jobs that need their GPUs or the Windows runtime (measurements, Windows test suites, installer + smoke runs: `node tools/build-job.mjs run --target ae432dc7|1ccfe586 ...`, PC 1 = ae432dc7, PC 2 = 1ccfe586). The Mac + keeps macOS binaries, the DMG and Metal tests, under the build lock. The box never hosts a live-devnet node and never + holds a secret; its Devnet 2 seed, when it starts, runs its own unit on 26611 (ufw open). Setup and re-provision: + `infra/build-server/run-from-mac.sh ` (idempotent); the rustc pin is `RUST_TOOLCHAIN` in `infra/build-server/provision.sh` + (1.99.0; there is no rust-toolchain file, add one) and build-remote.sh refuses a version mismatch. Nobody deletes another + worktree's target dir on the box. Windows exes are reproducible (`-Wl,--no-insert-timestamp` in cross-remote.sh, the Mac's + cross-build.sh and the PC job alike); a node binary whose strings lack its commit fails `tools/ci/commit-string-check.sh` + (the empty-commit class, 6 October 2026: kaspa-build-info embeds the hash only from a `.git` directory on a branch and + never re-runs once empty, so every Mac worktree build and every PC job build had shipped without it; build-remote.sh, + cross-remote.sh, cross-build.sh and the PC job now carry the two-step: a branch or minimal `.git`, and + `cargo clean --release -p kaspa-build-info` on a new commit). - Every agent works in its own git worktree (`git worktree add ../igneum-wt-`), never the shared checkout, and - stages only its own files. Builds: `nice -n 19`, at most 4 cargo jobs. Windows builds go to the GitHub runner, Linux - binaries come from `infra/cross/build-linux.sh`; nothing is built on a server. + stages only its own files. Mac builds (macOS binaries only): `nice -n 19`, at most 4 cargo jobs. - Never launch /Applications/Google Chrome.app headless (it blocks the owner's Chrome); use the built-in browser pane. - Keep the Mac on mains with the 140 W charger; on 4 October it hibernated at 1% battery and took node 1 and the observer down. - A source tree copied to another machine (rsync, zip, tar, scp) is re-stamped with `touch` before anything builds it, @@ -84,4 +98,20 @@ Every Igneum task in Claude in Chrome runs in the Chrome profile "josh (igneum.n Josh: "this question and answer should have not needed to be asked ... so that suggestions like this get made without me" (the 12 GB mine-and-prove question, which followed from the 15.6 GB measurement and should have been raised by the agent that measured it). Rule: an agent that measures or reports a number also states, in the same report, what the number means for each user tier and what it will do about it, before anyone asks. The tiers: a home miner with one 8 GB card, one 12 GB card, one 16 GB card, one 24 or 32 GB card; a rig; a pool user; each on Windows, Linux and macOS; each on NVIDIA, AMD and Apple (Intel when it exists). A report that says "peak 15.6 GB" without "so 12 GB cards cannot do both; here is the profile that fits them, measuring now" is incomplete and goes back. The same for hash rates (per watt and per pound for the tiers), times (what deadline it fits), sizes (what disk or memory it needs), and prices. A standing reviewer agent reads every status file, bench-log entry and plan for missed consequences and opens the work; the coordinator does not wait for Josh to notice. ## A job never quits or restarts the installed app it did not start (standing rule, 5 October 2026, 23:05 UTC) -Ember Tune's PC 1 playbook read the installed app's `app.url` file and POSTed `/api/quit` to it when its own test budget was spent, taking PC 1 off the network from 22:31Z (141 MH/s gone, the 0.3.11 Windows build blocked) with nobody awake to relaunch. Rule: a test engine started by a job runs on its own port and data dir with its own URL file, and a job may quit, pause, resume or restart only an engine it started itself (the URL it created); the installed app is touched only through the signed `restart` and `update-now` job kinds. `tools/ci` carries a check (`playbook-quit-check`) that fails a playbook which reads `%LOCALAPPDATA%\igneum\app\app.url` or `~/Library/Application Support/Igneum/app/app.url` and sends quit, pause or resume to it; the reviewer's row C35 is the record. +Ember Tune's PC 1 playbook started a second engine, that engine's own updater saw itself as 0.3.9 and launched the per-user installer, and the installer's stop step POSTed `/api/quit` to the installed app, taking PC 1 off the network from 22:31Z (141 MH/s gone, the 0.3.11 Windows build blocked) with nobody awake to relaunch (corrected 6 October 2026 from the collected log; the playbook's own quit never fired and pointed at its scratch engine; fixed at e600e63: a second engine never runs the updater). Rule: a test engine started by a job runs on its own port and data dir with its own URL file, and a job may quit, pause, resume or restart only an engine it started itself (the URL it created); the installed app is touched only through the signed `restart` and `update-now` job kinds, and a job that needs the installed app's miners out of the way uses the runner's `--stop-miners` (the runner stops them before the script and restarts them on any exit), never `/api/pause` or `/api/resume` from the script, not even with a finally block (ruling 6 October 2026: a script that dies before its finally leaves the box paused unattended). `tools/ci` carries a check (`playbook-quit-check`) that fails a playbook which reads `%LOCALAPPDATA%\igneum\app\app.url` or `~/Library/Application Support/Igneum/app/app.url` and sends quit, pause or resume to it; the reviewer's row C35 is the record. + +## Devnet 2 gate and activation rules (standing rule, 6 October 2026, 16:4x UTC) + +Josh's ruling after the DAA 198,000 incident (a fixed-height activation crossed while the fleet was still updating: a two-sided chain, a 229-block reorg, execution reset to genesis on every node, proving at zero for an hour): releases stay hourly; what changes is where they land first. +- Devnet 2 is the rented fleet as its own staging chain (own genesis and network id, refused by live peers at the handshake). Every release, activation and tuning kit crosses Devnet 2 through `tools/fleet/devnet2-gate.sh` (PASS = zero rejected blocks across the activation, no reorg over depth 3, exec roots agreeing on every box, a segment record paid, every node on the new version) before the live devnet or any of Josh's machines sees it. The shipper does not build the live object before the PASS line. +- No fixed-height activation on the live devnet. A consensus change flips when 95 percent of mining weight over a window signals the new object, with a floor height as the backstop; the class v4 cut is the first to carry it (status file gates P1 and P2). +- A deep reorg never resets execution; a snapshot a node cannot read fails loudly; the p2p snapshot path refuses a snapshot below the node's tip or the restart; a hands script never pgreps its own command line. +- Main checks an agent's number against the log or the chain before relaying it to Josh, or labels it unverified. + +## A rule row closes only with its check (standing rule, 6 October 2026, 18:4x UK) + +Josh, after the pgrep self-match hit twice in one day (the shipper's hands script at lunchtime, the fleet's wave script at 17:1xZ: a `pgrep -f ""` whose literal sat in the calling shell's own command line, so the check always passed and no node ever started): "again wasted time". Rules: +- A bug class found today gets its `tools/ci` check merged on master the same hour, or the rule row stays OPEN and main says so. A rule row without a check is not closed. +- Box operations (ssh to a rented box, install a payload, start a node, wait for sync, read height, peers and exec tip, start a miner or prover) live in ONE shared, tested library (`tools/fleet/lib/`), exercised against a Devnet 2 box by a test; agents call it and never write their own copy in bash or PowerShell. +- A watcher or collector verifies the chain-side fact (height, peers, exec tip, paid records), never a reported rate or a process name. +- `pgrep -f` / `ps | grep` with a literal pattern is banned in scripts; use the bracket form `[i]gneumd` or `pgrep -x` on the binary name (CI check: pgrep-self-match-check). diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index f2265f7cf..557c75108 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "igneum-app" -version = "0.3.11" +version = "0.3.13" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index 55f3afc05..28d4ac4f8 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-app" -version = "0.3.11" +version = "0.3.13" 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" diff --git a/app/igneum-app/resources/igneum-app.rc b/app/igneum-app/resources/igneum-app.rc index a0ab6bc0e..f581c416d 100644 --- a/app/igneum-app/resources/igneum-app.rc +++ b/app/igneum-app/resources/igneum-app.rc @@ -6,8 +6,8 @@ 1 ICON "igneum.ico" 1 VERSIONINFO -FILEVERSION 0,3,11,0 -PRODUCTVERSION 0,3,11,0 +FILEVERSION 0,3,13,0 +PRODUCTVERSION 0,3,13,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.11" + VALUE "FileVersion", "0.3.13" VALUE "InternalName", "igneum-app" VALUE "LegalCopyright", "Igneum contributors" VALUE "OriginalFilename", "igneum-app.exe" VALUE "ProductName", "Igneum Miner" - VALUE "ProductVersion", "0.3.11" + VALUE "ProductVersion", "0.3.13" END END BLOCK "VarFileInfo" diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 23d633ed7..214ced9e5 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -29,6 +29,16 @@ pub struct CardPref { pub sweep_watts: f64, #[serde(default)] pub sweep_mhs: f64, + /// Ember Tune (src/ember.rs): the clock cap the last tune chose (0 = unlocked), the driver and program class it + /// ran under (a change makes the card due again), and the plan that produced it (full | confirm | baseline) + #[serde(default)] + pub sweep_clock_mhz: u32, + #[serde(default)] + pub sweep_driver: String, + #[serde(default)] + pub sweep_class: String, + #[serde(default)] + pub sweep_source: String, } #[derive(Clone, Serialize, Deserialize)] @@ -70,9 +80,16 @@ pub struct Settings { #[serde(default)] pub prove: bool, /// The efficiency sweep (src/sweep.rs): once after install, then weekly, each NVIDIA card's cap is stepped from - /// 100% to 50% on the live program and held at the best MH per watt. Default on. A pinned card is skipped. - #[serde(default = "yes")] + /// 100% to 50% on the live program and held at the best MH per watt. Default off; implied by `power_control` + /// (on when that is switched on, never effective while it is off). A pinned card is skipped. + #[serde(default)] pub sweep: bool, + /// Power control (Josh, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the + /// efficiency sweep need administrator rights (one UAC prompt on Windows). Default OFF on every machine; the app + /// never raises the prompt on its own. Switching it on asks once, at that moment; a refused, cancelled or + /// unanswered prompt switches it back off with a notice, no retries. + #[serde(default)] + pub power_control: bool, /// When this install first ran (unix s), for the "first hour after install" sweep. #[serde(default)] pub installed_at: u64, @@ -102,16 +119,48 @@ fn yes() -> bool { 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, 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, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } } } impl Settings { + /// What a measurement engine (`--sweep`, started by a job beside the installed app) runs with, whatever the copied + /// file says: no remote jobs (run 4, 6 October 2026: the second engine fetched the jobs file and ran 96 old jobs + /// inside its scratch root), no updates, no proving, not paused, the tune on, Power control off (only an engine + /// that is itself elevated controls NVIDIA, through the probe's `direct`), every card due and unpinned. The file + /// on disk is never changed: the playbook copies the installed app's settings verbatim (a PowerShell JSON round + /// trip rewrote big integers as doubles and the engine read the whole file as defaults: no payout address, every + /// card off). + pub fn for_measurement(mut self) -> Settings { + self.remote_jobs = false; + self.auto_update = false; + self.prove = false; + self.paused = false; + self.sweep = true; + self.power_control = false; + self.setup_done = true; + for p in self.cards.values_mut() { + p.sweep_at = 0; + p.pinned = false; + } + self + } + pub fn load(path: &Path) -> Settings { 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; 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(); + dirty = true; + } + if s.sweep && !s.power_control { + // the sweep is implied by power control (5 October 2026): an install from before that setting carried + // sweep = true by default; it no longer prompts on its own + s.sweep = false; + dirty = true; + } + if dirty { s.save(path); } s @@ -347,6 +396,18 @@ mod tests { out } + #[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() }; + s.cards.insert("nvidia:0:x".into(), CardPref { enabled: true, identities: 8, sweep_at: 1_791_000_000, pinned: true, power_pct: 70, ..Default::default() }); + let m = s.for_measurement(); + assert!(!m.remote_jobs && !m.auto_update && !m.prove && !m.paused && m.sweep && !m.power_control && m.setup_done); + assert_eq!(m.address, "0xabc", "the payout address is the installed app's"); + let c = &m.cards["nvidia:0:x"]; + assert!(c.enabled && c.identities == 8 && c.power_pct == 70, "the card's choices stay"); + assert!(c.sweep_at == 0 && !c.pinned, "every card is due and unpinned"); + } + #[test] fn manifest_url_round_trips_through_the_token() { assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); @@ -439,3 +500,18 @@ mod tests { assert!(p.node_override_params.is_none()); } } + +#[cfg(test)] +mod fixture_tests { + /// `IGNEUM_TEST_SETTINGS= cargo test settings_fixture`: parses a real settings.json with this crate's + /// struct and prints what it read (6 October 2026: PC 1's copied file read as defaults; this names the field). + #[test] + fn settings_fixture_parses_when_given() { + let Ok(p) = std::env::var("IGNEUM_TEST_SETTINGS") else { return }; + let t = std::fs::read_to_string(&p).unwrap(); + match serde_json::from_str::(&t) { + Ok(s) => println!("parsed: address {} cards {} remote_jobs {} setup_done {}", s.address, s.cards.len(), s.remote_jobs, s.setup_done), + Err(e) => panic!("the crate refuses the file: {e}"), + } + } +} diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 87d0b961b..9e69fa419 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -17,6 +17,8 @@ pub struct Bins { pub metal: Option, pub cuda: Option, pub opencl: Option, + /// igneum-gpu-telemetry: AMD power, heat, fans and clocks (proto-opencl/gpu-telemetry.c), 5 October 2026 + pub telemetry: Option, pub dir: std::path::PathBuf, } @@ -99,6 +101,7 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi device: device.into(), enabled: true, state: "off".into(), + amd_ordinal: -1, ..Default::default() } } @@ -725,7 +728,7 @@ pub fn find_bins() -> Result { // the prebuilt CUDA worker needs NVIDIA's nvrtc64_*_0.dll next to it (as igneum-common.ps1 checks) let nvrtc = std::fs::read_dir(dir).ok().map(|rd| rd.flatten().any(|e| { let n = e.file_name().to_string_lossy().to_ascii_lowercase(); n.starts_with("nvrtc64_") && n.ends_with("_0.dll") })).unwrap_or(false); let cuda = opt("igneum-worker-cuda").filter(|_| nvrtc || cfg!(not(windows))); - return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), dir: dir.clone() }); + return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), telemetry: opt("igneum-gpu-telemetry"), dir: dir.clone() }); } } Err(format!("igneumd and igneum-miner were not found next to the app (looked in {})", candidates.iter().map(|c| c.display().to_string()).collect::>().join(", "))) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs new file mode 100644 index 000000000..d839135e5 --- /dev/null +++ b/app/igneum-app/src/ember.rs @@ -0,0 +1,1033 @@ +//! Ember Tune: every card tuned for MH per watt out of the box, and the fleet's results folded into a prior that a +//! new card starts from (docs/plans/ember-tune.md). Two knobs per card: the power limit (percent of the card's +//! default) and the core clock cap (MHz; 0 = unlocked). The memory clock is never touched, and a step that drags it +//! down is marked and cannot win. This file is the logic, driven by an explicit clock so the tests run without a +//! card: the plans (full, confirm, baseline), the per-step rows with their marks, the choice rule, the fleet record, +//! the prior lookup and the state machine. The engine (src/engine.rs, `tick_tune` and the `Cmd::Tune*` commands) +//! owns the processes: NVIDIA through nvidia-smi (`-pl`, `-lgc 0,`, `-rgc`; administrator rights, so only with +//! Power control on), AMD through igneum-gpu-telemetry (`--set-plimit`, `--set-gmax`, `--reset`; no elevation on +//! Windows), Apple measure only. +//! +//! The lines in the app log (and on stdout under --sweep, which the PC job reads): +//! TUNE start card=