From a73be2c36dc3576c9fe6049afecaa515b9b2fbb9 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:05:37 +0000 Subject: [PATCH 01/18] playbook-quit-check: the ruling of 6 October 2026 15:30 UTC (a job script never pauses or resumes the installed app's miners, not even as a fallback; --stop-miners is the way): the two agg-cost allow entries dropped, so the gate names tools/proving-v1/pc2-agg-cost.ps1 and pc2-agg-cost-restore.ps1 until they move to --stop-miners Co-Authored-By: Claude Fable 5.1 --- tools/ci/playbook-quit-check.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/ci/playbook-quit-check.sh b/tools/ci/playbook-quit-check.sh index 20c7713d5..b0c178f85 100755 --- a/tools/ci/playbook-quit-check.sh +++ b/tools/ci/playbook-quit-check.sh @@ -25,11 +25,11 @@ if [ "${1:-}" = "--self-test" ]; then if ! check_file "$t/good.ps1"; then echo "self-test FAILED: the good playbook failed"; exit 1; fi rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file passes"; exit 0 fi -# allowed senders: the installer's own stop step (the update-now path the rule names), and, pending the rule owner's -# word (6 October 2026, 15:10 UTC): tools/proving-v1/pc2-agg-cost.ps1 and its restore step pc2-agg-cost-restore.ps1, which switch the 5090 off through /api/cards -# and falls back to /api/pause with /api/resume in its finally block (the aggregation-cost agent's measurement; the -# rule's letter forbids pause and resume of the installed app, its owner decides whether a card switch's fallback is one) -ALLOW='^(packaging/windows/stop-igneum\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$' +# the one allowed sender is the installer's own stop step (the update-now path the rule names). Ruled 6 October 2026, +# 15:30 UTC: a job script never pauses or resumes the installed app's miners through /api/pause or /api/resume, not even +# as a fallback with a finally block (a script that dies before its finally leaves the box paused unattended); the +# job runner's --stop-miners is the sanctioned way (it stops them around the job and restarts them on any exit). +ALLOW='^packaging/windows/stop-igneum\.ps1$' fail=0 while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/windows/**' 'tools/proving-v1/**' 'packaging/**' | grep -E '\.(ps1|sh)$') [ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses or resumes the installed app" From 2b894e3954e7745baaf2a0afb0ec5796eff551f3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:10:53 +0000 Subject: [PATCH 02/18] ember-tune-pc1.ps1: budget 55 min (three cards: 5090 9 steps, 4070 10 steps, 9070 XT 4 steps, 75 s each) Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 130b9c451..6baae9042 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -15,7 +15,7 @@ # therefore measure only tonight unless the engine finds itself elevated. $ErrorActionPreference = 'Continue' $resultTag = 'TUNE' -$budgetMinutes = 45 +$budgetMinutes = 55 if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35) $started = Get-Date $deadline = $started.AddMinutes($budgetMinutes) From 2c1305e95dc7c0bdf859e9cbb0c8b3eaaa9e0bff Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:11:37 +0000 Subject: [PATCH 03/18] ember: a registered Igneum Power Helper task counts as permission for the tune (an unelevated measurement engine, Power control forced off, still sets NVIDIA limits through the task with no prompt) Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 3f980a051..012778395 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2295,7 +2295,9 @@ impl Engine { }; // NVIDIA control: this process is elevated (direct), or Power control is on so the one-prompt helper may run. // The --sweep job alone never counts: it must not raise a prompt on a PC with nobody there (5 October 2026). - let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control; + // a registered Igneum Power Helper task (src/powertask.rs) is permission too: it sets limits with no prompt, + // so an unelevated measurement engine (Power control forced off) still controls NVIDIA through it + let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control || (cfg!(windows) && self.power_task_registered()); // AMD's power limit is a percent offset from the default (ADLX): the plan's watts scale becomes a percent // scale (default 100, floor 100 + plimit_min, ceiling 100 + plimit_max), tune_apply sends pct - 100 let limits = if c.vendor == "amd" && probe.direct && probe.plimit_max >= probe.plimit_min && probe.plimit_min > -100.0 { @@ -2388,7 +2390,8 @@ impl Engine { /// The elevated helper (src/sweep.rs helper_script_*): one administrator prompt; it polls /sweep/cmd.txt. fn sweep_helper_start(&mut self, c: &CardState) -> Result<(), String> { - if !self.shared.settings.lock().unwrap().power_control { + let task = cfg!(windows) && self.power_task_registered(); + if !task && !self.shared.settings.lock().unwrap().power_control { return Err("Power control is off in Settings".into()); } let dir = self.sweep_dir(); From 59b237ac92658cec7898da647f514a4bbaf96468 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:30:16 +0000 Subject: [PATCH 04/18] powertask: a kit run from a jobs folder registers the INSTALLED exe as the task's action (the 15:28Z run on PC 1 registered jobs\ember-kit-5\igneum-app-ember.exe); the playbook uses a kit only on an app that predates Ember Tune Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 3 ++- app/igneum-app/src/powertask.rs | 28 ++++++++++++++++++++++++++++ relay/playbooks/ember-tune-pc1.ps1 | 9 ++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 012778395..834631f31 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1818,7 +1818,8 @@ impl Engine { let dir = self.sweep_dir(); let _ = std::fs::create_dir_all(&dir); let script = dir.join("register-power-task.ps1"); - match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) { + let installed: Vec = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) { Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()), _ => cmds.join(" & "), } diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index 1a4ab9fe8..787142f38 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -80,6 +80,20 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option> { } } +/// The executable the task should point at: the running one, unless it runs out of a jobs folder (a measurement +/// kit a job fetched: `\app\jobs\\igneum-app-ember.exe`), in which case the installed app's own +/// exe beside it is the durable target (6 October 2026: the first registration on PC 1 came from such a kit). +pub fn task_exe(running: &Path, install_candidates: &[PathBuf]) -> PathBuf { + // either separator: the path comes from Windows, the tests run on the Mac too + let under_jobs = running.to_string_lossy().split(['\\', '/']).any(|c| c.eq_ignore_ascii_case("jobs")); + if under_jobs { + if let Some(p) = install_candidates.iter().find(|p| p.is_file()) { + return p.clone(); + } + } + running.to_path_buf() +} + /// 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. @@ -253,6 +267,20 @@ mod tests { assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'")); } + #[test] + fn a_kit_run_from_a_jobs_folder_registers_the_installed_exe() { + let kit = Path::new(r"C:\Users\Admin\AppData\Local\igneum\app\jobs\ember-kit-5\igneum-app-ember.exe"); + let own = Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"); + // no installed candidate exists on this machine: the running exe stands + assert_eq!(task_exe(kit, &[PathBuf::from(r"C:\nonexistent\igneum-app.exe")]), kit.to_path_buf()); + assert_eq!(task_exe(own, &[]), own.to_path_buf()); + // an installed candidate that exists (the test binary stands in for it) wins for a kit, not for the installed exe + let here = std::env::current_exe().unwrap(); + assert!(here.is_file()); + assert_eq!(task_exe(kit, &[here.clone()]), here); + assert_eq!(task_exe(own, &[here]), own.to_path_buf()); + } + #[test] fn a_stale_command_file_does_not_run_at_start() { // the helper's start reads the highest sequence already in the file and runs nothing below or at it diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 6baae9042..aa9bc361e 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -47,11 +47,10 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out $installedHasEmber = $false if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) } $ember = $null -# ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version; -# older kits only when the installed app predates Ember Tune -$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe' -if (Test-Path $k3) { $ember = $k3 } -elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } +# a kit is used only when the installed app predates Ember Tune (0.3.13); from 0.3.13 on the installed engine is the +# tune engine, so the Power Helper task it registers points at the install folder (the 15:28Z run on PC 1 registered +# the kit's path because kit-5 was still preferred) +if (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Say ("engine: the Ember build from " + $ember) From 7a6c9bce9a0aae5f2300cded4b98d5ed8cb5da51 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:07 +0000 Subject: [PATCH 05/18] =?UTF-8?q?A=20card=20never=20shows=200=20MH/s=20wit?= =?UTF-8?q?hout=20a=20reason=20word=20(the=20project=20lead,=206=20October?= =?UTF-8?q?=202026,=20watching=20PC=201=20during=20the=20table=20run):=20t?= =?UTF-8?q?he=20Mine=20row=20reads=20'tuning:=20step=20k=20of=20n=20=C2=B7?= =?UTF-8?q?=20measuring=20W=20W'=20with=20the=20live=20rate,=20'held=20for?= =?UTF-8?q?=20a=20remote=20job:=20'=20while=20a=20job=20holds=20the?= =?UTF-8?q?=20miners,=20and=20the=20Tuned=20line=20at=20the=20end;=20the?= =?UTF-8?q?=20big=20button=20reads=20'Tuning,=20mining=20again=20in=20abou?= =?UTF-8?q?t=20N=20min';=20the=20strip=20carries=20one=20tune=20line;=20a?= =?UTF-8?q?=20measurement=20engine=20prints=20a=20TUNE=20progress=20line?= =?UTF-8?q?=20every=2010=20s=20and=20the=20playbook=20forwards=20it=20(and?= =?UTF-8?q?=20TUNE=20chosen=20as=20done)=20to=20the=20installed=20app's=20?= =?UTF-8?q?POST=20/api/tune-progress,=20a=20post=20of=20state,=20never=20q?= =?UTF-8?q?uit,=20pause=20or=20resume;=20the=20rule=20in=20ember-tune.md?= =?UTF-8?q?=206a;=20UI=20tests=20for=20the=20three=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 13 +++++ app/igneum-app/src/engine.rs | 86 ++++++++++++++++++++++++++-- app/igneum-app/src/server.rs | 5 ++ app/igneum-app/src/state.rs | 6 ++ app/igneum-app/ui/app.js | 43 +++++++++++--- app/igneum-app/ui/tune-line.test.mjs | 33 +++++++++++ docs/plans/ember-tune.md | 15 +++++ relay/playbooks/ember-tune-pc1.ps1 | 21 ++++++- tools/ci/second-engine-check.sh | 2 +- 9 files changed, 211 insertions(+), 13 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index d839135e5..dcd96dafd 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -591,6 +591,19 @@ impl Run { self.rows.first().map(|r| r.mclk).unwrap_or(0.0) } + /// Seconds left in the plan from here: the rest of this step plus (settle + hold + a 10 s apply) per step to come. + pub fn eta_s(&self, now: Instant) -> i64 { + let per = (self.timing.settle + self.timing.hold).as_secs() as i64 + 10; + let this = match &self.phase { + Phase::Applying { .. } => per, + Phase::Settling { since } => per - 10 - now.duration_since(*since).as_secs().min(per as u64) as i64, + Phase::Holding { since } => (self.timing.hold.as_secs() as i64 - now.duration_since(*since).as_secs() as i64).max(0), + Phase::Finishing { .. } | Phase::Done => 0, + }; + let left = self.plan.len().saturating_sub(self.rows.len() + 1) as i64; + this.max(0) + left * per + } + /// The phase in words for the card row. pub fn words(&self, now: Instant) -> String { let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 834631f31..0ae565489 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -73,6 +73,8 @@ pub enum Cmd { SweepHelperDone(Result<(), String>), /// a tune request (by number) was carried out: what the vendor tool printed, or why it refused TuneSet(u64, Result<String, String>), + /// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state + TuneProgress(Value), /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// prover found a host that was not there when the node started RestartNode(String), @@ -1142,6 +1144,7 @@ impl Engine { self.shared.log("sweep helper: exited"); } } + Cmd::TuneProgress(v) => self.tune_progress(&v), Cmd::TuneSet(seq, r) => match r { Ok(text) => { self.tune_acked = Some(seq); @@ -2527,6 +2530,49 @@ impl Engine { self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct)); } + /// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the + /// engine's `TUNE progress` lines): the installed app shows the step, the live rate and draw and the time left + /// instead of a bare "off" while a job holds its miners. `done` ends it: the row reads the tuned line. + fn tune_progress(&mut self, v: &Value) { + let key = v.get("key").and_then(|k| k.as_str()).unwrap_or(""); + let mut st = self.st(); + let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == key || (!key.is_empty() && c.name.replace(' ', "_") == key)) else { return }; + let n = |k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0); + if v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) { + c.sweep_state = "idle".into(); + c.state = if self.job_hold { "held".into() } else { c.state.clone() }; + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; + if n("mhs") > 0.0 && n("watts") > 0.0 { + c.sweep_mhs = n("mhs"); + c.sweep_watts = n("watts"); + c.sweep_eff = n("mhs") / n("watts"); + c.sweep_pct = n("power_pct") as u32; + c.tune_clock_mhz = n("clock_mhz") as u32; + c.tune_source = v.get("plan").and_then(|p| p.as_str()).unwrap_or("full").into(); + c.tune_line = crate::ember::tuned_line(n("mhs"), n("watts"), n("mhs") / n("watts")); + c.sweep_at = crate::platform::unix_now_f(); + } + c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("").to_string(); + return; + } + c.state = "tuning".into(); + c.sweep_state = "running".into(); + c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("tuning").to_string(); + c.tune_step = n("step") as u32; + c.tune_steps = n("of") as u32; + c.tune_eta_s = n("eta_s") as i64; + c.tune_plan = v.get("plan").and_then(|p| p.as_str()).unwrap_or("").into(); + if n("mhs") > 0.0 { + c.hash_now = n("mhs"); + } + if n("watts") > 0.0 { + c.power_w = n("watts"); + } + c.message = String::new(); + } + /// Faults and conditions first, then the state machine. fn sweep_drive(&mut self, now: Instant) { let Some((idx, device, started, seq)) = self.sweep.as_ref().map(|r| (r.card, r.device.clone(), r.started, r.seq)) else { return }; @@ -2542,7 +2588,7 @@ impl Engine { Some("a remote job took the GPU".into()) } else if self.st().mining.paused { Some("mining paused".into()) - } else if c.state != "mining" { + } else if c.state != "mining" && c.state != "tuning" { Some(format!("the card left mining ({})", if c.state == "starting" || c.state == "restarting" { "worker restart, likely the hour boundary" } else { &c.state })) } else if slot_error { Some("the worker reported an error".into()) @@ -2591,9 +2637,20 @@ impl Engine { } } } - if let Some(words) = self.sweep.as_ref().map(|r| r.words(now)) { + if let Some((words, step, of, eta, plan)) = self.sweep.as_ref().map(|r| (r.words(now), r.rows.len() as u32 + 1, r.plan.len() as u32, r.eta_s(now), r.plan.kind.name())) { + let (key, mhs, watts) = self.st().mining.cards.get(idx).map(|c| (c.key.clone(), c.hash_now, c.power_w)).unwrap_or_default(); if let Some(cc) = self.st().mining.cards.get_mut(idx) { - cc.sweep_note = words; + cc.sweep_note = words.clone(); + cc.state = if cc.state == "mining" { "tuning".into() } else { cc.state.clone() }; + cc.tune_step = step; + cc.tune_steps = of; + cc.tune_eta_s = eta; + cc.tune_plan = plan.into(); + } + if self.shared.runtime.sweep_only { + // the job playbook forwards this to the installed app's /api/tune-progress + println!("TUNE progress key={} step={step} of={of} eta_s={eta} mhs={mhs:.2} watts={watts:.1} plan={plan} note={}", key.replace(' ', "_"), words.replace(' ', "_")); + let _ = std::io::stdout().flush(); } } } @@ -2616,6 +2673,12 @@ impl Engine { c.sweep_mhs = row.mhs; c.sweep_at = unix as f64; c.sweep_state = "idle".into(); + if c.state == "tuning" { + c.state = "mining".into(); + } + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; c.tune_clock_mhz = row.point.clock_mhz; c.tune_source = kind.name().into(); c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff); @@ -2701,6 +2764,12 @@ impl Engine { match st.mining.cards.get_mut(idx) { Some(c) => { c.sweep_state = "idle".into(); + if c.state == "tuning" { + c.state = "mining".into(); + } + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; c.sweep_note = format!("tuning stopped: {why}"); if touched { c.power_pct = before.power_pct; @@ -2874,6 +2943,13 @@ impl Engine { } if self.job_hold && !self.jobs.holds_miners() { self.job_hold = false; + for c in self.st().mining.cards.iter_mut().filter(|c| c.state == "held" || c.state == "tuning") { + c.state = "off".into(); + c.sweep_state = if c.sweep_state == "running" { "idle".into() } else { c.sweep_state.clone() }; + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; + } if self.running { self.shared.event("info", "job finished; the miners restart"); for m in self.miners.iter_mut() { @@ -2890,7 +2966,9 @@ impl Engine { self.job_hold = true; self.stop_miners(&why); for c in self.st().mining.cards.iter_mut().filter(|c| c.enabled) { - c.message = "stopped for a remote job".into(); + // never a bare "off" at 0 MH/s: the row says why (a job holds the card) + c.state = "held".into(); + c.message = format!("held for a remote job: {}", short(&why, 80)); } self.jobs.miners_stopped(&self.shared); } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index b2ddb9f31..e85d949b9 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -325,6 +325,11 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri shared.send(Cmd::PowerControl(on)); Ok(json!({ "ok": true })) } + // a measurement engine's live tune state for one card (forwarded by its job playbook): the row shows the step + "/api/tune-progress" => { + shared.send(Cmd::TuneProgress(body)); + Ok(json!({ "ok": true })) + } "/api/sweep/enable" => { let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; shared.send(Cmd::SweepEnable(on)); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 7ec9a944c..6e964ebfe 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -125,6 +125,12 @@ pub struct CardState { pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked) pub tune_source: String, // full | confirm | baseline pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned + // 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, + pub tune_steps: u32, + pub tune_eta_s: i64, + pub tune_plan: String, // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64, diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 57c14596b..5b8c301d4 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -13,7 +13,7 @@ 5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */ var Notices = (function () { 'use strict'; - var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; + var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300; function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; } @@ -120,8 +120,19 @@ var Notices = (function () { if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); } else { var c = clockNotice(s.clock); if (c) out.push(c); } if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0)); + var tn = tuneNotice(s.mining && s.mining.cards); + if (tn) out.push(tn); return out; } + // the one line while a card is being tuned: which card, which plan, the step, when mining is back + function tuneNotice(cards) { + var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); + if (!l.length) return null; + var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune'; + var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : ''; + var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : ''; + return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' }); + } // the lowest level wins; a dismissed key is skipped, so the next one shows in its place function pick(list, dismissed) { var best = null; @@ -132,7 +143,7 @@ var Notices = (function () { } return best; } - return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; + return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; })(); /* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- @@ -282,9 +293,11 @@ var View = (function () { function cardRow(cd) { var removed = cd.removed_at > 0, unusable = !!cd.problem; var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off'); - var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; - var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; - var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; + // the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word + var tuning = st === 'tuning', held = st === 'held'; + var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; + var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; + var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : ''; var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : ''; var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : ''; @@ -297,12 +310,28 @@ var View = (function () { return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) }; } + // "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet) + function tuneWord(cd) { + var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : ''; + var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : ''; + var rest = [step, w].filter(Boolean).join(' · '); + return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning'); + } + // "about 12 min" / "under a minute" from a tune's seconds left + function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; } + // the card a tune is running on, or null + function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); return l.length ? l[0] : null; } + // the big button on Mine: m = state.mining, n = state.node, s = { quitting } function toggle(m, n, s) { var cards = shownCards(m.cards).filter(present); var on = cards.filter(function (c) { return c.enabled; }).length; var mining = cards.filter(function (c) { return c.state === 'mining'; }).length; if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' }; + var tc = tuningCard(cards); + if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' }; + var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length; + if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' }; if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; @@ -384,7 +413,7 @@ var View = (function () { else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -414,7 +443,7 @@ var TuneLine = (function () { function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; } return { model: model, point: point, rel: rel }; })(); -if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; } +if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; } if (typeof document !== 'undefined') (function () { 'use strict'; diff --git a/app/igneum-app/ui/tune-line.test.mjs b/app/igneum-app/ui/tune-line.test.mjs index 17b6daed0..18fafce33 100644 --- a/app/igneum-app/ui/tune-line.test.mjs +++ b/app/igneum-app/ui/tune-line.test.mjs @@ -10,6 +10,8 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js') const mod = { exports: {} }; new Function('module', src)(mod); const { model, point } = mod.exports.TuneLine; +const View = mod.exports.View; +const tuneNotice = mod.exports.tuneNotice; const NOW = 1_800_000_000; const card = over => ({ vendor: 'nvidia', sweep_state: 'idle', sweep_note: '', sweep_pct: 0, power_pct: 80, sweep_at: 0, tune_line: '', tune_source: '', tune_clock_mhz: 0, tune_control: true, pinned: false, ...over }); @@ -42,3 +44,34 @@ test('running, stopped, idle and off', () => { assert.equal(model(card({ vendor: 'other' }), NOW).kind, 'off'); assert.equal(point({ tune_clock_mhz: 0, sweep_pct: 0, power_pct: 0 }), ''); }); + +test('a card never shows 0 MH/s without a reason word: tuning, held and tuned rows, the button, the strip', () => { + const base = { key: 'nvidia:0:x', name: 'RTX 5090', vendor: 'nvidia', kind: 'discrete', enabled: true, state: 'tuning', hash_now: 123.4, power_w: 219.6, tune_step: 7, tune_steps: 9, tune_eta_s: 230, tune_plan: 'full', sweep_note: 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)', vram_mb: 32768 }; + const r = View.cardRow(base); + assert.equal(r.word, 'tuning: step 7 of 9 · measuring 220 W'); + assert.equal(r.hash, '123', 'the live rate stays in the rate column'); + assert.equal(r.tone, 'on'); + const h = View.cardRow({ ...base, state: 'held', hash_now: 0, power_w: 0, message: 'held for a remote job: Ember Tune on PC 1', tune_steps: 0 }); + assert.equal(h.word, 'held for a remote job: Ember Tune on PC 1'); + assert.equal(h.hash, '', 'blank, never 0'); + const off = View.cardRow({ ...base, state: 'off', enabled: false, hash_now: 0 }); + assert.equal(off.word, 'off'); assert.equal(off.hash, ''); + // the big button during a tune + const t = View.toggle({ cards: [base], state: 'mining', paused: false }, { synced: true }, {}); + assert.equal(t.label, 'Tuning'); assert.equal(t.disabled, true); + assert.equal(t.sub, 'mining again in about 4 min · RTX 5090'); + const held = View.toggle({ cards: [{ ...base, state: 'held' }], state: 'waiting', paused: false }, { synced: true }, {}); + assert.equal(held.label, 'Held'); assert.equal(held.disabled, true); + // the strip + const n = tuneNotice([base]); + assert.equal(n.kind, 'tuning'); + assert.equal(n.text, 'Tuning RTX 5090: the full tune, step 7 of 9. Mining again in about 4 min.'); + assert.equal(tuneNotice([{ ...base, state: 'mining' }]), null); + // the finished row: TuneLine's tuned model (the table's "Tuned: ..." line with the point) + const done = model({ ...base, state: 'mining', sweep_state: 'idle', tune_line: 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)', tune_source: 'full', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 60, tune_control: true }, NOW); + assert.equal(done.kind, 'tuned'); + assert.equal(done.text, 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)'); + assert.match(done.note, /^2470 MHz at 100%, full tune, 1 min ago/); + assert.equal(View.tuneEta(30), 'under a minute'); + assert.equal(View.tuneEta(700), 'about 12 min'); +}); diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 470c06ff7..40bc4be90 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -161,6 +161,21 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r 10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout. +## 6a. A card never shows 0 MH/s without a reason word (the project lead, 6 October 2026, watching PC 1 during run 5) + +Rule: the Mine page's rate column is blank, never "0", when a card is not mining, and the row's word says why: +`mining`, `tuning: step k of n · measuring W W` (the live rate stays in the rate column), `held for a remote job: +<title>`, `paused`, `waiting for the node`, `restart in N s`, `starting`, `ready`, `stopped after repeated faults`, +`failed`, `off`, `removed`, `not usable (<problem>)`. Checked against `View.cardRow`: every state the engine can set +has a word; `held` and `tuning` were the two that read "off" before (a job's `--stop-miners` left `state = off`). +During a tune the big button reads "Tuning, mining again in about N min" (disabled) and the strip carries one line +("Tuning <card>: the full tune, step k of n. Mining again in about N min."). A tune run by a job beside the installed +app reaches the installed app's rows through `POST /api/tune-progress` (the playbook forwards the measurement +engine's `TUNE progress` lines every 10 s and its `TUNE chosen` line as `done`): the posted state is the step, the +live MH/s and W, the seconds left and the plan; a POST of state, never a quit, pause or resume. At the end the row +reads `Tuned: <MH/s> at <W> W (<MH/W>)` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held, +tuned rows; the button; the strip line). + ## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC) What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index aa9bc361e..f7e1483fe 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -126,6 +126,24 @@ function EndTree([int] $procId, [string] $why) { } $seen = 0 $rows = 0 +# the installed app shows the tune instead of a bare "off" (the project lead, 6 October 2026): every `TUNE progress` line and the +# `TUNE chosen` line go to its /api/tune-progress (a POST of state, never quit, pause or resume: the 5 October rule) +$installedUrl = '' +try { $installedUrl = (Get-Content -LiteralPath (Join-Path $appDir 'app.url') -Raw -ErrorAction Stop).Trim() } catch { } +function Forward([string] $line) { + if (-not $installedUrl) { return } + $kv = @{} + foreach ($tok in ($line -split ' ')) { if ($tok -match '^([a-z_]+)=(.*)$') { $kv[$Matches[1]] = $Matches[2] } } + $body = @{ key = ($kv['card'] -replace '_', ' '); done = ($line -match '^TUNE chosen ') } + if ($kv['key']) { $body.key = ($kv['key'] -replace '_', ' ') } + foreach ($n in @('step', 'of', 'eta_s', 'mhs', 'watts', 'clock', 'cap')) { if ($kv[$n] -ne $null) { $body[$n] = [double]$kv[$n] } } + if ($kv['cap'] -ne $null) { $body['power_pct'] = [double]$kv['cap'] } + if ($kv['clock'] -ne $null) { $body['clock_mhz'] = [double]$kv['clock'] } + if ($kv['plan']) { $body.plan = $kv['plan'] } + if ($kv['note']) { $body.note = ($kv['note'] -replace '_', ' ') } + if ($body.done) { $body.note = ('tuned: ' + $kv['mhs'] + ' MH/s at ' + $kv['watts'] + ' W') } + try { Invoke-WebRequest -Uri ($installedUrl + 'api/tune-progress') -Method POST -Body ($body | ConvertTo-Json -Compress) -ContentType 'application/json' -UseBasicParsing -TimeoutSec 3 | Out-Null } catch { } +} # the watchdog (coordinator, 6 October 2026): a tune engine that mines nothing for 120 s after its first status line # (every card "waiting" or 0.00 MH/s: no payout address, no worker, no node) fails the job at once with the engine's # last log line in the RESULT, its tree ended, mining restored by the job runner; a job that cannot mine never burns @@ -165,7 +183,8 @@ while (-not $p.HasExited) { $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ - if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } } + if ($l -match '^TUNE progress ') { Forward $l } + elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } } elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) } elseif ($l -match '^(URL|STATE) ') { } else { Say $l } diff --git a/tools/ci/second-engine-check.sh b/tools/ci/second-engine-check.sh index 8f378b082..ec8e8a647 100755 --- a/tools/ci/second-engine-check.sh +++ b/tools/ci/second-engine-check.sh @@ -23,7 +23,7 @@ while IFS= read -r f; do if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1 fi - if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then + if grep -vE '^\s*#' "$f" | grep -E 'ConvertTo-Json' | grep -qE 'settings\.json|Set-Content|WriteAllText|Out-File'; then echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1 fi if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then From db0c17b95e34f8e8f170a1e7cf397b8324c52560 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:43:53 +0000 Subject: [PATCH 06/18] Run 5 (15:28 to 15:39Z): the 5090's five power rows in the bench log (the cap does not bind: 0.41 MH/W flat at 310 W); the playbook's watchdog killed the live three-card tune at the first clock step, so: idle = three consecutive status lines reading 0.00 MH/s and 300 s, never a missing match; the after snapshot and the engine-log dump on every exit; an elevated tune engine registers the Igneum Power Helper itself before its first step (the --sweep engine skipped the cap path where the registration lived); Ember 2 groundwork: the memory-clock knob in Point and Limits, the goal, the hill-climb plan (not yet wired to the engine) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 223 ++++++++++++++++++++++++----- app/igneum-app/src/engine.rs | 43 ++++-- app/igneum-app/src/state.rs | 1 + docs/bench-log.md | 12 ++ relay/playbooks/ember-tune-pc1.ps1 | 39 ++--- 5 files changed, 258 insertions(+), 60 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index dcd96dafd..e5a4584b1 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -49,6 +49,10 @@ pub struct Limits { pub clock_max_mhz: u32, /// the lowest cap the vendor allows (the ADLX gmax_range floor); 0 = CLOCK_FLOOR_PCT of the maximum pub clock_min_mhz: u32, + /// Ember 2: the memory clock the card runs at by default and the vendor's maximum (nvidia-smi clocks.mem and + /// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none) + pub mem_default_mhz: u32, + pub mem_max_mhz: u32, } impl Limits { @@ -66,6 +70,13 @@ impl Limits { } mhz.clamp(self.clock_floor(), self.clock_max_mhz) } + /// A memory clock inside the vendor's range; 0 stays 0 (the default). + pub fn clamp_mem(&self, mhz: u32) -> u32 { + if mhz == 0 || self.mem_max_mhz == 0 || self.mem_default_mhz == 0 { + return 0; + } + mhz.clamp(self.mem_default_mhz, self.mem_max_mhz) + } /// The watts a power percent asks for, inside the card's min and max, rounded to a watt. pub fn watts_for(&self, pct: u32) -> f64 { let mut w = self.power_default_w * pct as f64 / 100.0; @@ -79,13 +90,15 @@ impl Limits { } } -/// One setting of the two knobs. +/// One setting of the knobs (Ember 2 adds the memory clock: 0 = the driver's default). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Point { /// the core clock cap in MHz; 0 = unlocked pub clock_mhz: u32, /// the power limit, percent of the default pub power_pct: u32, + /// the memory clock in MHz (NVIDIA `-lmc`, locked to one value); 0 = the driver's default + pub mem_mhz: u32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,6 +109,8 @@ pub enum Kind { Clock, /// the fleet prior and one neighbour Confirm, + /// Ember 2: a hill-climb probe + Climb, } #[derive(Clone, Debug, PartialEq)] @@ -106,11 +121,53 @@ pub struct Step { pub kind: Kind, } +/// What the tune is for (Settings > Ember Tune > goal). The rate floor is the share of the best rate seen a point +/// must keep to win on MH per watt: efficiency keeps 90%, balanced 99% (the 1% rule of lever 3), maximum rate +/// takes the fastest point and uses MH per watt only to break ties. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Efficiency, + Balanced, + MaxRate, +} + +impl Goal { + pub fn parse(s: &str) -> Goal { + match s { + "efficiency" | "eff" => Goal::Efficiency, + "rate" | "max_rate" | "maximum" => Goal::MaxRate, + _ => Goal::Balanced, + } + } + pub fn name(&self) -> &'static str { + match self { + Goal::Efficiency => "efficiency", + Goal::Balanced => "balanced", + Goal::MaxRate => "rate", + } + } + /// The rate tolerance the choice rule uses, percent under the best rate. + pub fn tolerance_pct(&self, manifest_default: f64) -> f64 { + match self { + Goal::Efficiency => 10.0, + Goal::Balanced => manifest_default, + Goal::MaxRate => 0.0, + } + } +} + +/// Pounds a day for a draw at a price in pence per kWh: watts × 24 h / 1000 × price / 100. +pub fn pounds_per_day(watts: f64, pence_per_kwh: f64) -> f64 { + watts * 24.0 / 1000.0 * pence_per_kwh / 100.0 +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlanKind { Full, Confirm, Baseline, + /// Ember 2: the hill-climb over memory up and core down from the start point + Climb, } impl PlanKind { @@ -119,6 +176,7 @@ impl PlanKind { PlanKind::Full => "full", PlanKind::Confirm => "confirm", PlanKind::Baseline => "baseline", + PlanKind::Climb => "climb", } } } @@ -134,9 +192,82 @@ pub struct Plan { power: Vec<Step>, clock_pcts: Vec<u32>, fixed: Vec<Step>, + /// Ember 2 (Climb): the start point, the step sizes and the step budget + climb: Option<Climb>, +} + +/// The hill-climb's shape: from `start`, each probe moves the memory clock up by `mem_step` or the core clock down +/// by `core_step` (or both), keeps the move when the goal's score improves, else turns to the other knob; a +/// refused step (a fault on it) backs that knob off for good. At most `budget` steps including the start. +#[derive(Clone, Debug, PartialEq)] +pub struct Climb { + pub start: Point, + pub mem_step: u32, + pub core_step: u32, + pub budget: usize, + pub goal: Goal, } impl Plan { + /// Ember 2: the hill-climb. The start is the fleet prior (or the card's current point); a probe step is 5% of + /// the memory range above the default (0 when the card has no memory knob) and 5% of the maximum core clock; + /// five steps of 60 s converge in under 10 minutes. + pub fn climb(limits: &Limits, start: Point, goal: Goal, tolerance_pct: f64) -> Plan { + let mem_step = if limits.mem_max_mhz > limits.mem_default_mhz { ((limits.mem_max_mhz - limits.mem_default_mhz) / 20).max(25) } else { 0 }; + let core_step = if limits.clock_max_mhz > 0 { (limits.clock_max_mhz / 20).max(25) } else { 0 }; + let start = Point { clock_mhz: limits.clamp_clock(start.clock_mhz), power_pct: start.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(start.mem_mhz) }; + Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) } + } + + /// The goal's score of a row: MH per watt for efficiency and balanced, the rate for maximum rate. + pub fn score(&self, r: &Row) -> f64 { + match self.climb.as_ref().map(|c| c.goal) { + Some(Goal::MaxRate) => r.mhs, + _ => r.eff, + } + } + + /// The climb's next probe from the rows so far: None when the budget is spent or no move is left. + fn climb_next(&self, rows: &[Row]) -> Option<Step> { + let c = self.climb.as_ref()?; + let step = |p: Point| Step { point: p, watts: self.limits.watts_for(p.power_pct), kind: Kind::Climb }; + if rows.is_empty() { + return Some(step(c.start)); + } + if rows.len() >= c.budget { + return None; + } + // the best usable row so far is the hill's top; a knob that produced a marked (refused) row is backed off + let best = rows.iter().filter(|r| r.usable()).max_by(|a, b| self.score(a).partial_cmp(&self.score(b)).unwrap_or(std::cmp::Ordering::Equal))?; + let refused_mem = rows.iter().any(|r| !r.usable() && r.point.mem_mhz > best.point.mem_mhz); + let refused_core = rows.iter().any(|r| !r.usable() && r.point.clock_mhz != 0 && (best.point.clock_mhz == 0 || r.point.clock_mhz < best.point.clock_mhz)); + let last = rows.last()?; + let mem_up = |p: Point| -> Option<Point> { + if c.mem_step == 0 || refused_mem { return None; } + let base = if p.mem_mhz == 0 { self.limits.mem_default_mhz } else { p.mem_mhz }; + let m = self.limits.clamp_mem(base + c.mem_step); + (m > 0 && m != p.mem_mhz && m != base).then_some(Point { mem_mhz: m, ..p }) + }; + let core_down = |p: Point| -> Option<Point> { + if c.core_step == 0 || refused_core { return None; } + let base = if p.clock_mhz == 0 { self.limits.clock_max_mhz } else { p.clock_mhz }; + let k = self.limits.clamp_clock(base.saturating_sub(c.core_step)); + (k > 0 && k != p.clock_mhz).then_some(Point { clock_mhz: k, ..p }) + }; + let tried = |p: Point| rows.iter().any(|r| r.point == p); + // the last move improved: keep going the same way from the top; else turn: memory first, then core, then both + let last_improved = last.usable() && last.point == best.point && rows.len() > 1; + let last_was_mem = rows.len() > 1 && last.point.mem_mhz != rows[rows.len() - 2].point.mem_mhz; + let candidates: Vec<Option<Point>> = if last_improved && last_was_mem { + vec![mem_up(best.point), core_down(best.point)] + } else if last_improved { + vec![core_down(best.point), mem_up(best.point)] + } else { + vec![mem_up(best.point), core_down(best.point), mem_up(best.point).and_then(core_down)] + }; + candidates.into_iter().flatten().find(|p| !tried(*p)).map(step) + } + /// Power 100% to 50% at the unlocked clock (duplicate watts dropped, as the card's floor clamps them), then the /// clock ladder 90% to the floor of the maximum core clock at the chosen power. A card without a readable /// maximum clock gets the power ladder only; a card without a default limit gets the clock ladder only. @@ -148,41 +279,44 @@ impl Plan { if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) { continue; } - power.push(Step { point: Point { clock_mhz: 0, power_pct: pct }, watts: w, kind: Kind::Power }); + power.push(Step { point: Point { clock_mhz: 0, power_pct: pct, mem_mhz: 0 }, watts: w, kind: Kind::Power }); } } let clock_pcts = if limits.clock_max_mhz > 0 { CLOCK_STEPS_PCT[1..].to_vec() } else { Vec::new() }; - Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new() } + Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new(), climb: None } } /// The prior's point, then one neighbour: the next clock step up when the prior caps the clock (is the cap /// costing rate?), else one power step down (is there efficiency left?). pub fn confirm(limits: &Limits, prior: Point, before: Point, tolerance_pct: f64) -> Plan { - let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100) }; + let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(prior.mem_mhz) }; let first = Step { point: p, watts: limits.watts_for(p.power_pct), kind: Kind::Confirm }; let neighbour = if p.clock_mhz > 0 && limits.clock_max_mhz > 0 { let up = p.clock_mhz + limits.clock_max_mhz / 10; let clock = if up >= limits.clock_max_mhz { 0 } else { limits.clamp_clock(up) }; - Point { clock_mhz: clock, power_pct: p.power_pct } + Point { clock_mhz: clock, power_pct: p.power_pct, mem_mhz: p.mem_mhz } } else { - Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50) } + Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50), mem_mhz: p.mem_mhz } }; let mut fixed = vec![first]; if neighbour != p { fixed.push(Step { point: neighbour, watts: limits.watts_for(neighbour.power_pct), kind: Kind::Confirm }); } - Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None } } /// One step at the card's current point: the before number, and all a measure-only card (Apple, or NVIDIA /// with Power control off) reports. pub fn baseline(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan { let fixed = vec![Step { point: before, watts: limits.watts_for(before.power_pct), kind: Kind::Baseline }]; - Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None } } /// How many steps the plan has at most (the clock ladder counts whether or not it runs). pub fn len(&self) -> usize { + if let Some(c) = &self.climb { + return c.budget; + } self.fixed.len() + self.power.len() + self.clock_pcts.len() } pub fn is_empty(&self) -> bool { @@ -191,6 +325,9 @@ impl Plan { /// The next step after `rows` (one row per step done so far), or None when the plan is complete. pub fn next(&self, rows: &[Row]) -> Option<Step> { + if self.climb.is_some() { + return self.climb_next(rows); + } let i = rows.len(); if !self.fixed.is_empty() { return self.fixed.get(i).cloned(); @@ -207,7 +344,7 @@ impl Plan { if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) { return None; } - Some(Step { point: Point { clock_mhz: clock, power_pct }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) + Some(Step { point: Point { clock_mhz: clock, power_pct, mem_mhz: 0 }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) } } @@ -305,9 +442,10 @@ impl Row { /// `TUNE card=<label> step=<i> clock=<MHz> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH/W> gclk=<MHz> mclk=<MHz> tmax=<C> mark=<m>` pub fn line(&self, card: &str, i: usize) -> String { format!( - "TUNE card={card} step={i} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}", + "TUNE card={card} step={i} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}", self.point.clock_mhz, self.point.power_pct, + self.point.mem_mhz, self.limit, self.watts, self.mhs, @@ -320,7 +458,7 @@ impl Row { ) } pub fn json(&self) -> serde_json::Value { - serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() }) + serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "mem_mhz": self.point.mem_mhz, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() }) } } @@ -454,7 +592,7 @@ pub fn prior_of(tuning: Option<&serde_json::Value>, key: &str, min_samples: u32) if samples < min_samples.max(1) { return None; } - let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100) }; + let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100), mem_mhz: n("mem_mhz") as u32 }; if point.power_pct == 0 && point.clock_mhz == 0 { return None; } @@ -606,7 +744,13 @@ impl Run { /// The phase in words for the card row. pub fn words(&self, now: Instant) -> String { - let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; + let what = |p: &Point| { + let mut w = if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; + if p.mem_mhz > 0 { + w.push_str(&format!(" · mem {}", p.mem_mhz)); + } + w + }; let cur = self.current.as_ref().map(|s| what(&s.point)).unwrap_or_default(); let n = self.rows.len() + 1; let of = self.plan.len(); @@ -624,7 +768,7 @@ impl Run { fn applied(step: &Step, rb: Readback, before_w: f64) -> bool { let power_ok = rb.limit_w <= 0.0 || (rb.limit_w - step.watts).abs() < 1.5; let power_changed = (step.watts - before_w).abs() >= 1.5; - if step.point.clock_mhz > 0 || !power_changed { + if step.point.clock_mhz > 0 || step.point.mem_mhz > 0 || !power_changed { rb.acked && power_ok } else { rb.limit_w > 0.0 && power_ok @@ -732,6 +876,13 @@ impl Run { Verdict::NoReadings => None, }, PlanKind::Full => choose(&self.rows, self.plan.tolerance_pct), + PlanKind::Climb => { + if self.plan.climb.as_ref().map(|c| c.goal) == Some(Goal::MaxRate) { + self.rows.iter().filter(|r| r.usable()).max_by(|a, b| a.mhs.partial_cmp(&b.mhs).unwrap_or(std::cmp::Ordering::Equal)).cloned() + } else { + choose(&self.rows, self.plan.tolerance_pct) + } + } } } @@ -767,7 +918,7 @@ mod tests { /// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is /// read at the first tune (3,090 MHz is the shape used here, not a measurement). fn l5090() -> Limits { - Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0 } + Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 } } fn row_at(p: Point, watts: f64, mhs: f64) -> Row { @@ -776,10 +927,10 @@ mod tests { #[test] fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() { - let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); assert_eq!(plan.len(), 5 + 4, "five power steps (60% and 50% clamp to 400 W; one kept) and four clock steps"); let first = plan.next(&[]).unwrap(); - assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100 }, 575.0, Kind::Power)); + assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power)); // the power ladder: 575, 518, 460, 403, 400 let mut rows = Vec::new(); let mut watts_seen = Vec::new(); @@ -794,7 +945,7 @@ mod tests { // the clock ladder rides the chosen power point: a flat ladder ties on efficiency and rate, the lowest draw wins (100%) let s = plan.next(&rows).unwrap(); assert_eq!(s.kind, Kind::Clock); - assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100 }); + assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 }); rows.push(row_at(s.point, 250.0, 123.8)); let s = plan.next(&rows).unwrap(); assert_eq!(s.point.clock_mhz, 2472); @@ -806,7 +957,7 @@ mod tests { assert_eq!(plan.next(&rows), None); // the choice: 2,472 MHz keeps 99.6% of the top rate at 220 W = 0.561 MH/W; 2,163 MHz (118 MH/s) is outside the 1% tolerance let best = choose(&rows, 1.0).unwrap(); - assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100 }); + assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }); // a wider tolerance lets the 2,163 MHz step (0.590 MH/W, 4.8% slower) win assert_eq!(choose(&rows, 5.0).unwrap().point.clock_mhz, 2163); // no power limits, clocks only; no clocks, power only; nothing, empty @@ -830,7 +981,7 @@ mod tests { #[test] fn the_choice_keeps_the_best_mh_per_watt_within_the_rate_tolerance() { - let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct }; + let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct, mem_mhz: 0 }; // a card that gets more efficient as the cap drops until it collapses: 70% wins (0.280 MH/W), 60% is 35% slower let rows = vec![row_at(p(0, 100), 560.0, 124.0), row_at(p(0, 90), 510.0, 123.5), row_at(p(0, 80), 455.0, 123.2), row_at(p(0, 70), 400.0, 122.9), row_at(p(0, 60), 345.0, 80.0)]; assert_eq!(choose(&rows, 1.0).unwrap().point, p(0, 70)); @@ -857,7 +1008,7 @@ mod tests { #[test] fn the_guards_mark_a_step_so_it_cannot_win() { - let step = Step { point: Point { clock_mhz: 2000, power_pct: 100 }, watts: 575.0, kind: Kind::Clock }; + let step = Step { point: Point { clock_mhz: 2000, power_pct: 100, mem_mhz: 0 }, watts: 575.0, kind: Kind::Clock }; let good = Samples { draws: vec![250.0, 251.0, 249.0], rates: vec![123.0], gclks: vec![1998.0], mclks: vec![2505.0], tmax: 70.0, faults: 0, unapplied: false }; assert_eq!(Row::from_samples(&step, &good, 2505.0).mark, Some(Mark::Ok)); // one rejected or mismatched hash: faulted @@ -875,15 +1026,15 @@ mod tests { assert!(!r.usable()); assert!(r.line("c", 3).contains("mark=no_readings"), "{}", r.line("c", 3)); let r = Row::from_samples(&step, &good, 0.0); - assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok"); + assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 mem=0 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok"); } #[test] fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() { let t0 = Instant::now(); let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) }; - let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0 }; - let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 }; + let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0); let mut t = t0; let mut limit = 240.0; @@ -932,13 +1083,13 @@ mod tests { #[test] fn the_confirm_plan_checks_the_prior_and_its_neighbour() { let l = l5090(); - let prior = Point { clock_mhz: 2472, power_pct: 100 }; - let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let prior = Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }; + let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); assert_eq!(plan.len(), 2); let a = plan.next(&[]).unwrap(); assert_eq!((a.point, a.kind), (prior, Kind::Confirm)); let b = plan.next(&[row_at(prior, 220.0, 123.5)]).unwrap(); - assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100 }, "one clock step up"); + assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 }, "one clock step up"); // the neighbour within 1%: the prior stands let rows = vec![row_at(prior, 220.0, 123.5), row_at(b.point, 250.0, 123.8)]; assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::Keep(r) if r.point == prior)); @@ -947,20 +1098,20 @@ mod tests { assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::FullDue { .. })); assert_eq!(confirm_verdict(&[], 1.0), Verdict::NoReadings); // an unlocked prior at 100%: the neighbour is one power step down; at the top clock the neighbour is unlocked - let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100 }, Point::default(), 1.0); - assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90 }); - let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100 }, Point::default(), 1.0); - assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0); + let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }); + let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0); // a prior outside the vendor's range is clamped, never applied as is - let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30 }, Point::default(), 1.0); - assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50 }); + let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50, mem_mhz: 0 }); } #[test] fn a_baseline_plan_measures_the_card_as_it_runs() { let t0 = Instant::now(); let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(2), apply: Duration::from_secs(3) }; - let before = Point { clock_mhz: 0, power_pct: 80 }; + let before = Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }; let mut run = Run::new(0, "0", "c", Plan::baseline(&l5090(), before, 1.0), 460.0, false, timing, t0); // nothing acknowledges the request (Power control is off): the apply window passes and the hold starts anyway let mut t = t0; @@ -992,7 +1143,7 @@ mod tests { #[test] fn the_record_and_the_prior_round_trip_through_the_manifest_shape() { - let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100 }, 220.0, 123.5); + let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }, 220.0, 123.5); let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None); assert_eq!(rec["card"], "NVIDIA_GeForce_RTX_5090"); assert_eq!(rec["key"], "NVIDIA_GeForce_RTX_5090|581|l128w16"); @@ -1012,7 +1163,7 @@ mod tests { let s = settings_of(Some(&tuning)); assert_eq!(s, Settings { enabled: true, min_samples: 5, tolerance_pct: 1.0, period_s: PERIOD_S }); let p = prior_of(Some(&tuning), "NVIDIA_GeForce_RTX_5090|581|l128w16", s.min_samples).unwrap(); - assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100 }); + assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }); assert_eq!(p.samples, 7); assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 5).is_none(), "3 samples are under the floor"); assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 3).is_some()); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 0ae565489..cc3ab85ac 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2239,16 +2239,19 @@ impl Engine { let current = if c.power_limit_w > 0.0 { c.power_limit_w } else { c.power_default_w }.round() as u64; let known_direct = self.sweep_direct; std::thread::spawn(move || { - let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default(); + let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version,clocks.mem,clocks.max.mem", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default(); let p: Vec<&str> = q.trim().split(',').map(|s| s.trim()).collect(); let clock_max = p.first().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; let driver = p.get(1).map(|s| s.to_string()).unwrap_or_default(); + // Ember 2: the memory clock now (the default under load) and the vendor's maximum + let mem_default = p.get(2).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; + let mem_max = p.get(3).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; let direct = match known_direct { Some(d) => Some(d), None if allowed || current > 0 => crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-pl", ¤t.to_string()]), None, Duration::from_secs(20)).map(|out| out.contains("All done")), None => Some(false), }; - shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, ..Default::default() }))); + shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, mem_default_mhz: mem_default, mem_max_mhz: mem_max, ..Default::default() }))); }); } "amd" => { @@ -2267,7 +2270,7 @@ impl Engine { // the stock clock, not MHz; a range with a negative floor is an offset range and the clock // knob stays closed until the stock clock is known (the power limit is the AMD lever), and // `plimit_range -30 10` bounds the power ladder (the percent scale rides power_* below) - Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max }, + Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max, mem_default_mhz: 0, mem_max_mhz: 0 }, Some(t) => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: t.ordinal as i64, ..Default::default() }, None => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: -1, ..Default::default() }, }))); @@ -2305,9 +2308,9 @@ impl Engine { // AMD's power limit is a percent offset from the default (ADLX): the plan's watts scale becomes a percent // scale (default 100, floor 100 + plimit_min, ceiling 100 + plimit_max), tune_apply sends pct - 100 let limits = if c.vendor == "amd" && probe.direct && probe.plimit_max >= probe.plimit_min && probe.plimit_min > -100.0 { - crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz } + crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: 0, mem_max_mhz: 0 } } else { - crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz } + crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: probe.mem_default_mhz, mem_max_mhz: probe.mem_max_mhz } }; let control = match c.vendor.as_str() { "nvidia" => crate::ember::control_reason("nvidia", &limits, &c.device, power_control, false), @@ -2316,6 +2319,26 @@ impl Engine { }; if c.vendor == "nvidia" { self.sweep_direct = Some(probe.direct); + // the approved step that made this engine elevated registers the Igneum Power Helper here too (run 5, + // 6 October 2026: a --sweep engine skipped the cap path where the registration lived, so the one + // approved click registered nothing); no prompt: this process already holds the rights + if probe.direct && cfg!(windows) && !self.power_task_registered() { + let dir = self.sweep_dir(); + let _ = std::fs::create_dir_all(&dir); + let script = dir.join("register-power-task.ps1"); + let installed: Vec<PathBuf> = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + if let Ok(exe) = std::env::current_exe() { + let target = crate::powertask::task_exe(&exe, &installed); + if std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&target).as_bytes()].concat()).is_ok() { + let mut p = std::process::Command::new(crate::platform::tool("powershell")); + p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); + crate::platform::quiet(&mut p); + let ok = p.status().map(|s| s.success()).unwrap_or(false); + self.power_task = None; + self.sweep_say(&format!("TUNE helper registered={} action={}", ok, target.display())); + } + } + } } { let mut st = self.st(); @@ -2333,7 +2356,7 @@ impl Engine { } let tuning = self.tuning_object(); let ember = crate::ember::settings_of(tuning.as_ref()); - let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct } }; + let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct }, mem_mhz: c.mem_cap_mhz }; let before_w = if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }; let key = crate::ember::prior_key(&c.name, &probe.driver, &c.program_class); let prior = crate::ember::prior_of(tuning.as_ref(), &key, ember.min_samples); @@ -2386,6 +2409,7 @@ impl Engine { crate::ember::PlanKind::Full => format!("{steps} steps over the power limit and the core clock, {} s each on the live program", (run.timing.settle + run.timing.hold).as_secs()), crate::ember::PlanKind::Confirm => format!("the fleet prior ({} samples) and one neighbour, {} s each", prior.as_ref().map(|p| p.samples).unwrap_or(0), (run.timing.settle + run.timing.hold).as_secs()), crate::ember::PlanKind::Baseline => format!("measuring the card as it runs ({})", control.clone().unwrap_or_default()), + crate::ember::PlanKind::Climb => format!("the hill-climb: memory up, core down, up to {steps} probes of {} s", (run.timing.settle + run.timing.hold).as_secs()), }; self.shared.event("info", &format!("{}: tuning started: {what}", c.name)); self.sweep = Some(run); @@ -2725,7 +2749,7 @@ impl Engine { _ => self.shared.event("ok", &format!("{name}: {line}, {} held", point_words(&row.point))), } if pinned && kind != crate::ember::PlanKind::Baseline { - let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct }).unwrap_or(run.before); + let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz }).unwrap_or(run.before); let w = self.st().mining.cards.get(idx).map(requested_watts).unwrap_or(run.before_w); self.tune_apply(idx, &run.device, &crate::ember::Step { point: p, watts: w, kind: crate::ember::Kind::Confirm }); } @@ -2753,7 +2777,7 @@ impl Engine { (Some(r), _) => (r.card, r.label.clone(), r.device.clone(), r.before, r.before_w, r.forced, r.seq > 0 && r.plan.kind != crate::ember::PlanKind::Baseline), (None, Some((idx, forced))) => { let c = self.st().mining.cards.get(idx).cloned(); - let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct })).unwrap_or_default(); + let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz })).unwrap_or_default(); (idx, label, device, p, w, forced, false) } _ => return, @@ -4030,6 +4054,9 @@ pub struct TuneProbe { /// AMD: the power offset range in percent from the `tune` line (PC 1's 9070 XT: -30 to 10) pub plimit_min: f64, pub plimit_max: f64, + /// Ember 2, NVIDIA: the memory clock under load and the vendor's maximum (nvidia-smi clocks.mem, clocks.max.mem) + pub mem_default_mhz: u32, + pub mem_max_mhz: u32, } /// One `tune` line of igneum-gpu-telemetry --tune: diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 6e964ebfe..81f90e263 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -118,6 +118,7 @@ pub struct CardState { pub clock_min_mhz: u32, // the vendor's floor for a cap (0 = 60% of the maximum) pub gclk_mhz: f64, // core clock now 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) pub driver: String, // the driver version (nvidia-smi, or the worker's race line) pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown diff --git a/docs/bench-log.md b/docs/bench-log.md index 27e1a3b54..cf16b9ed0 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1669,6 +1669,18 @@ Branch `ember-tune` (54ff1bc), docs/plans/ember-tune.md. Every card tuned for MH Nothing set; the installed app's miners back after 350 s. Why every earlier run (5 and 6 October, runs 1 to 4 and dry runs 1 and 2) read its copied settings as defaults, measured on PC 1 (collect ember-acl-2): the engine's own start locks its app folder with `icacls /inheritance:r /grant:r <user>:F`; cutting the folder's inheritance propagates down, the non-inheritable grant gives the children nothing, so a file COPIED in before the start (settings.json, machine-id, wallet.json) is left with no access entry and its owner cannot read it (`ReadAllText`: access denied), while the engine's own files written after the lock inherit fine, which hid it for a day. A first fix with `(OI)(CI)F /T` left the file empty too: `/T` re-applies `/inheritance:r` to each file after the propagation and an `(OI)(CI)` entry on a file is inherit-only. The right form is the inheritable grant without `/T` (07d5a72). Consequence for every tier on Windows: nothing changes for the installed app (its files were always its own); any tool that drops files into the app folder before the app starts (an installer's seed, a migration, a support script) was unreadable to the app until now and is readable from 0.3.13 on. +**Run 5, 6 October 2026, 15:28 to 15:39Z (job ember-tune-pc1-5, elevated on the project lead's click, PC 1 on 0.3.13, the tune engine = kit ember-kit-5 from 07d5a72, mode=direct):** the first run that set limits. The 5090's power ladder, 75 s a step, the clock unlocked (2,850 MHz core, 13,801 MHz memory), the rate = the worker's STATUS wall rate, the draw = nvidia-smi every 5 s: + +| Cap | Limit | MH/s | W | MH/W | GPU C | +|---|---|---|---|---|---| +| 100% | 575 W | 127.38 | 309.9 | 0.411 | 64 | +| 90% | 518 W | 99.32 | 313.6 | 0.317 | 65 | +| 80% | 460 W | 123.11 | 312.2 | 0.394 | 65 | +| 70% | 403 W | 127.38 | 310.9 | 0.410 | 65 | +| 60% (floor 400 W) | 400 W | 127.38 | 311.3 | 0.409 | 65 | + +Reading: the cap does not bind on this hash (310 to 314 W under every limit, as the 4 October stability line said), so the power knob is flat at 0.41 MH/W on the 5090 and the saving must come from the clocks; the 90% and 80% rows' rate dips at the same draw are stalls inside those holds (a worker restart or a template wait), not the cap. The clock ladder's first step (2,781 MHz at 575 W) was requested at 15:37:47Z and never measured: the playbook's own watchdog killed the live engine at 15:39:03Z (all three cards mining at 177 MH/s) because its idle clause sampled one log line and read "idle" from a missing match; the 4070's and the 9070 XT's plans never ran. Consequences: the 5090 is probably left with its core clock locked at 2,781 MHz (an `-lgc` lock persists until `-rgc` or a reboot) under the 575 W cap, which costs little rate; freeing it needs administrator rights; and the Power Helper was NOT registered by this run (the registration lived only in the installed app's cap path, which a `--sweep` engine skips). Fixed the same hour: the watchdog's idle rule (three consecutive status lines reading 0.00 MH/s and 300 s, never a missing match), the `after` snapshot and the engine-log dump on every exit, and an elevated tune engine registering the task itself before its first step. The decided way out: the project lead switches Power control ON in the 0.3.13 app (its one prompt registers the task from the install folder), a job frees the clock through the task (`rgc`), the tune runs unelevated through the task. + Consequence for the tiers: an AMD card is tuned on its power limit alone until its stock core clock is read (a 9070 XT at -30% is the floor the driver allows, 4 steps, 5 minutes); every NVIDIA card's two-knob plan waits on the user's one click on Power control; the re-run on PC 1 is held until the quit's source is named (the event-log collect) and follows the 0.3.11 rollout (the update clears the jobs folder, so the engine and the helper are fetched again), with the scheduler's slot. ## 5 October 2026 (night), read width of the lottery hash: 4, 16 and 64-byte loads, a per-load mix, a written scratch; three cards (gate 1 experiment, cryptographer) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index f7e1483fe..6a3b4afbc 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -150,6 +150,9 @@ function Forward([string] $line) { # its budget silently again $firstStatusAt = $null $lastMining = $null +$idleSince = $null +$idleSamples = 0 +$lastIdleStatus = '' $lastEngineLine = '' function EngineLogDump([string] $why) { Write-Output ('===== engine log tail (' + $why + ')') @@ -157,29 +160,32 @@ function EngineLogDump([string] $why) { if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' } if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } } } +# the newest `status:` line among the engine log's last 60 lines (run 5, 15:39Z: a single sampled tail line missed the +# status lines between TUNE and telemetry lines for 300 s and the watchdog killed a live three-card tune) +function EngineStatus() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $ls = @(Get-Content -LiteralPath $t.FullName -Tail 60 -ErrorAction SilentlyContinue); for ($i = $ls.Count - 1; $i -ge 0; $i--) { if ([string]$ls[$i] -match ' status: ') { return [string]$ls[$i] } } }; return '' } function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' } +function Exit3([string] $why, [string] $reason) { + Write-Output ('RESULT TUNE error=' + $reason + ' last_status_line=' + ((EngineStatus) -replace '\s+', '_') + ' last_log_line=' + ($lastEngineLine -replace '\s+', '_')) + EngineLogDump $why + EndTree $p.Id $why + Snapshot 'after' + Write-Output 'RESULT TUNE error=no_rows' + exit 3 +} while (-not $p.HasExited) { Start-Sleep -Seconds 5 $tailLine = EngineTail if ($tailLine) { $lastEngineLine = $tailLine } - if ($lastEngineLine -match ' status: ') { + $statusLine = EngineStatus + if ($statusLine) { if (-not $firstStatusAt) { $firstStatusAt = Get-Date } - if ($lastEngineLine -match ', mining \|' -or ($lastEngineLine -match '(\d+\.\d+) MH/s' -and [double]$Matches[1] -gt 0)) { $lastMining = Get-Date } - } - if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { - Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_')) - EngineLogDump 'watchdog: not mining' - EndTree $p.Id 'watchdog: not mining' - Write-Output 'RESULT TUNE error=no_rows' - exit 3 - } - if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) { - Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_')) - EngineLogDump 'watchdog: stopped mining' - EndTree $p.Id 'watchdog: stopped mining' - Write-Output 'RESULT TUNE error=no_rows' - exit 3 + $rate = 0.0; if ($statusLine -match '(\d+\.\d+) MH/s') { $rate = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) } + # idle = three consecutive 10-s samples whose newest status line reads 0.00 MH/s (never a missing match) + if ($rate -gt 0) { $lastMining = Get-Date; $idleSamples = 0; $idleSince = $null } + elseif ($statusLine -ne $lastIdleStatus) { $lastIdleStatus = $statusLine; $idleSamples++; if (-not $idleSince) { $idleSince = Get-Date } } } + if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { Exit3 'watchdog: not mining' 'not_mining reason=no_card_mined_within_120_s_of_the_first_status_line' } + if ($lastMining -and $idleSamples -ge 3 -and $idleSince -and ((Get-Date) - $idleSince).TotalSeconds -gt 300) { Exit3 'watchdog: stopped mining' 'stopped_mining reason=three_status_lines_read_0.00_MH/s_and_300_s_passed' } $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ @@ -203,6 +209,7 @@ while (-not $p.HasExited) { } else { Write-Output ('RESULT TUNE quit not sent: no URL file at ' + $u + '; killing pid ' + $p.Id) } Start-Sleep -Seconds 20 if (-not $p.HasExited) { EndTree $p.Id 'budget' } + EngineLogDump 'budget' Write-Output 'RESULT TUNE error=budget_exceeded' } } From c4a21d6afdcd2ad322c05a4d35bd5060877d66cc Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:48:14 +0000 Subject: [PATCH 07/18] The one approved Power control step on PC 1 failed with exit 1 (15:45:21Z, no cap applied, no helper registered): cmd.exe strips the first and last quote of a /c line that starts with one and holds more than two (two NVIDIA cards, or a cap plus the registration script); both launchers now wrap the line in one outer pair (platform::cmd_c_args, host.cpp runElevated), unit-tested; Ember 2: the memory clock set through nvidia-smi -lmc/-rmc (direct and through the helper's lmc/rmc verbs), the goal, the electricity price and the hill-climb switch in Settings and /api/tune/goal, the chosen point's memory clock and the measured curve on the card Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/config.rs | 17 ++++++++++- app/igneum-app/src/engine.rs | 54 ++++++++++++++++++++++++++++++--- app/igneum-app/src/hotplug.rs | 2 ++ app/igneum-app/src/platform.rs | 26 +++++++++++++++- app/igneum-app/src/powertask.rs | 11 +++++++ app/igneum-app/src/server.rs | 8 +++++ app/igneum-app/src/state.rs | 7 +++++ app/igneum-app/src/sweep.rs | 9 ++++++ app/windows/host.cpp | 4 ++- 9 files changed, 131 insertions(+), 7 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 1f481e1a3..9746985ac 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -39,6 +39,9 @@ pub struct CardPref { pub sweep_class: String, #[serde(default)] pub sweep_source: String, + /// Ember 2: the memory clock the last tune chose (0 = the driver's default) + #[serde(default)] + pub sweep_mem_mhz: u32, } #[derive(Clone, Serialize, Deserialize)] @@ -90,6 +93,15 @@ pub struct Settings { /// unanswered prompt switches it back off with a notice, no retries. #[serde(default)] pub power_control: bool, + /// Ember 2 (6 October 2026): the tune's goal ("efficiency" = most MH per watt within 10% of the top rate, + /// "balanced" = within 1%, "rate" = the fastest point), the electricity price in pence per kWh for the £/day + /// 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, + #[serde(default)] + pub power_price_pence: f64, + #[serde(default)] + pub tune_climb: bool, /// When this install first ran (unix s), for the "first hour after install" sweep. #[serde(default)] pub installed_at: u64, @@ -113,13 +125,16 @@ pub struct Settings { fn one() -> u32 { 1 } +fn balanced() -> String { + "balanced".into() +} fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, 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(), 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 } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index cc3ab85ac..096eec0c2 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -75,6 +75,8 @@ pub enum Cmd { TuneSet(u64, Result<String, String>), /// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state TuneProgress(Value), + /// Ember 2: Settings > goal, electricity price, the hill-climb switch + TuneGoal(Option<String>, Option<f64>, Option<bool>), /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// prover found a host that was not there when the node started RestartNode(String), @@ -121,7 +123,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -1145,6 +1147,28 @@ impl Engine { } } Cmd::TuneProgress(v) => self.tune_progress(&v), + Cmd::TuneGoal(goal, price, climb) => { + { + let mut s = self.shared.settings.lock().unwrap(); + if let Some(g) = &goal { + s.tune_goal = g.clone(); + } + if let Some(p) = price { + s.power_price_pence = p; + } + if let Some(c) = climb { + s.tune_climb = c; + } + } + self.shared.save_settings(); + let s = self.shared.settings.lock().unwrap().clone(); + let mut st = self.st(); + st.settings.tune_goal = s.tune_goal.clone(); + st.settings.power_price_pence = s.power_price_pence; + st.settings.tune_climb = s.tune_climb; + drop(st); + self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" })); + } Cmd::TuneSet(seq, r) => match r { Ok(text) => { self.tune_acked = Some(seq); @@ -2361,11 +2385,19 @@ impl Engine { let key = crate::ember::prior_key(&c.name, &probe.driver, &c.program_class); let prior = crate::ember::prior_of(tuning.as_ref(), &key, ember.min_samples); let full_due = self.tune_full_due.remove(&idx); + let (goal, climb_on) = { + let s = self.shared.settings.lock().unwrap(); + (crate::ember::Goal::parse(&s.tune_goal), s.tune_climb) + }; let plan = if let Some(why) = control.as_ref() { if let Some(cc) = self.st().mining.cards.get_mut(idx) { cc.sweep_note = why.clone(); } crate::ember::Plan::baseline(&limits, before, ember.tolerance_pct) + } else if climb_on || prior.as_ref().map(|p| p.point.mem_mhz > 0).unwrap_or(false) { + // Ember 2: the hill-climb from the fleet prior (or the card's point) toward the goal + let start = prior.as_ref().map(|p| p.point).unwrap_or(before); + crate::ember::Plan::climb(&limits, start, goal, ember.tolerance_pct) } else if let (Some(p), false, 0) = (prior.as_ref(), full_due, c.sweep_pct) { crate::ember::Plan::confirm(&limits, p.point, before, ember.tolerance_pct) } else { @@ -2477,6 +2509,7 @@ impl Engine { let Some(c) = card else { return }; let w = step.watts.round() as u64; let clock = step.point.clock_mhz; + let mem = step.point.mem_mhz; let shared = self.shared.clone(); self.tune_acked = None; match c.vendor.as_str() { @@ -2500,6 +2533,15 @@ impl Engine { ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset"); text.push(' '); text.push_str(out.trim()); + // Ember 2: the memory clock, locked to one value (`-lmc m,m`) or reset (`-rmc`) + let out = if mem > 0 { + crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-lmc", &format!("{mem},{mem}")]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into()) + } else { + crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-rmc"]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into()) + }; + ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset") || out.to_ascii_lowercase().contains("not supported"); + text.push(' '); + text.push_str(out.trim()); shared.send(Cmd::TuneSet(seq, if ok { Ok(text) } else { Err(text) })); }); } @@ -2509,7 +2551,7 @@ impl Engine { return; } let dev = device.to_string(); - let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }); + let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() }); let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd); // the helper polls twice a second and nvidia-smi answers within a second or two std::thread::spawn(move || { @@ -2551,7 +2593,7 @@ impl Engine { } _ => {} } - self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct)); + self.shared.log(&format!("tune: {} MHz, {}% ({w} W), memory {} requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct, if mem > 0 { format!("{mem} MHz") } else { "default".into() })); } /// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the @@ -2704,8 +2746,10 @@ impl Engine { c.tune_steps = 0; c.tune_eta_s = 0; c.tune_clock_mhz = row.point.clock_mhz; + c.tune_mem_mhz = row.point.mem_mhz; c.tune_source = kind.name().into(); c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff); + c.tune_curve = run.rows.iter().map(|r| r.json()).collect(); let control = c.tune_control; if kind == crate::ember::PlanKind::Baseline { c.sweep_note = if control { String::new() } else { c.sweep_note.clone() }; @@ -2714,6 +2758,7 @@ impl Engine { } else { c.power_pct = row.point.power_pct; c.clock_cap_mhz = row.point.clock_mhz; + c.mem_cap_mhz = row.point.mem_mhz; if c.vendor == "nvidia" { c.power_limit_w = row.limit; c.power_applied = true; @@ -2728,6 +2773,7 @@ impl Engine { e.sweep_watts = row.watts; e.sweep_mhs = row.mhs; e.sweep_clock_mhz = row.point.clock_mhz; + e.sweep_mem_mhz = row.point.mem_mhz; e.sweep_driver = c.driver.clone(); e.sweep_class = c.program_class.clone(); e.sweep_source = kind.name().into(); @@ -2738,7 +2784,7 @@ impl Engine { }; let _ = key; self.shared.save_settings(); - self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" })); + self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.point.mem_mhz, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" })); let before = run.rows.first().filter(|_| kind == crate::ember::PlanKind::Full).cloned(); let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref()); self.sweep_say(&format!("TUNE {record}")); diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs index f26779ba1..588e93e0a 100644 --- a/app/igneum-app/src/hotplug.rs +++ b/app/igneum-app/src/hotplug.rs @@ -163,6 +163,8 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) { c.sweep_at = p.sweep_at as f64; // Ember Tune (src/ember.rs): the clock cap the last tune chose, its plan, and the row's Tuned line c.tune_clock_mhz = p.sweep_clock_mhz; + c.tune_mem_mhz = p.sweep_mem_mhz; + 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(); if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 { diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index bb41df4ff..01f288c6d 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -430,11 +430,21 @@ pub fn sync_clock() -> Result<String, String> { /// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows. /// Blocking; call from a thread. +/// The cmd.exe argument for one elevated line. cmd's documented rule: when the text after /c starts with a quote +/// and holds more than two quotes (two cards, or a cap plus a script), it strips the FIRST and LAST quote and runs +/// the broken remainder (PC 1, 6 October 2026, 15:45Z: the one approved Power control step ran +/// `"...\nvidia-smi.exe" -i 0 -pl 460 & "...\nvidia-smi.exe" -i 1 -pl 160 & "...\powershell.exe" ... -File "...ps1"`, +/// exit 1, no cap applied, no task registered; a single-card PC, two quotes, was fine, which is why PC 2 never +/// showed it). Wrapping the whole line in one outer pair makes cmd strip exactly those. +pub fn cmd_c_args(cmdline: &str) -> String { + format!("/c \"{cmdline}\"") +} + pub fn run_elevated(cmdline: &str) -> Result<(), String> { #[cfg(windows)] { let cmd = tool("cmd").display().to_string(); - let mut c = elevated_command(&cmd, &format!("/c {cmdline}")); + let mut c = elevated_command(&cmd, &cmd_c_args(cmdline)); let out = c.output().map_err(|e| e.to_string())?; if out.status.success() { Ok(()) @@ -511,6 +521,20 @@ pub fn quiet(cmd: &mut Command) -> &mut Command { cmd } +#[cfg(test)] +mod cmd_tests { + #[test] + fn an_elevated_line_is_wrapped_so_cmd_keeps_every_inner_quote() { + let line = r#""C:\WINDOWS\System32\nvidia-smi.exe" -i 0 -pl 460 & "C:\WINDOWS\System32\nvidia-smi.exe" -i 1 -pl 160 & "C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "C:\x\register-power-task.ps1""#; + let a = super::cmd_c_args(line); + assert!(a.starts_with("/c \"\"C:\\WINDOWS"), "{a}"); + assert!(a.ends_with("register-power-task.ps1\"\""), "{a}"); + // the inner line is intact between the outer pair + assert_eq!(&a[4..a.len() - 1], line); + assert_eq!(super::cmd_c_args("echo hi"), "/c \"echo hi\""); + } +} + #[cfg(test)] mod lock_tests { #[test] diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index 787142f38..0cb86b083 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -43,6 +43,9 @@ pub enum HelperCmd { PowerLimit(u64), ClockCap(u64), ClockReset, + /// Ember 2: the memory clock locked to one value (`-lmc m,m`), or reset (`-rmc`) + MemClock(u64), + MemReset, Quit, Remove, } @@ -65,6 +68,8 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> { [_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))), [_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))), [_, "rgc"] => Some((seq, HelperCmd::ClockReset)), + [_, "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()))), _ => None, } @@ -76,6 +81,8 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> { HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]), HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]), HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]), + HelperCmd::MemClock(m) => Some(vec!["-i".into(), dev.into(), "-lmc".into(), format!("{m},{m}")]), + HelperCmd::MemReset => Some(vec!["-i".into(), dev.into(), "-rmc".into()]), _ => None, } } @@ -229,6 +236,10 @@ mod tests { assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460)))); assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472)))); assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset))); + assert_eq!(parse_line("10 lmc 14001"), Some((10, HelperCmd::MemClock(14001)))); + assert_eq!(parse_line("11 rmc"), Some((11, HelperCmd::MemReset))); + assert_eq!(smi_args("0", &HelperCmd::MemClock(14001)).unwrap(), vec!["-i", "0", "-lmc", "14001,14001"]); + 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))); diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index e85d949b9..95984e600 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -251,6 +251,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri shared.send(Cmd::ApplyCards(choices)); Ok(json!({ "ok": true })) } + "/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()); + shared.send(Cmd::TuneGoal(goal, price, climb)); + 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()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 81f90e263..cf0ccd395 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -132,6 +132,9 @@ pub struct CardState { pub tune_steps: u32, pub tune_eta_s: i64, pub tune_plan: String, + // 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 kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64, @@ -290,6 +293,10 @@ pub struct SettingsState { /// Ember Tune is paused fleet-wide by the signed manifest's kill switch (tuning.ember.enabled = false) pub tuning_off: bool, pub tuning_note: String, + /// Ember 2: the goal (efficiency | balanced | rate), the electricity price in pence per kWh, the hill-climb switch + pub tune_goal: String, + pub power_price_pence: f64, + pub tune_climb: bool, /// the miner software's dev fee switch (settings; `--dev-fee 0` when off) pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) diff --git a/app/igneum-app/src/sweep.rs b/app/igneum-app/src/sweep.rs index 5ae546f3d..92e402eee 100644 --- a/app/igneum-app/src/sweep.rs +++ b/app/igneum-app/src/sweep.rs @@ -375,6 +375,12 @@ while ($true) { } 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 } } } @@ -411,6 +417,8 @@ while true; do pl) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -pl $v : $("$smi" -i "$dev" -pl "$v" 2>&1)" >> "$dir/helper.log";; esac ;; lgc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lgc 0,$v : $("$smi" -i "$dev" -lgc "0,$v" 2>&1)" >> "$dir/helper.log";; esac ;; rgc) echo "$(date -u +%FT%TZ) $1 -rgc : $("$smi" -i "$dev" -rgc 2>&1)" >> "$dir/helper.log" ;; + lmc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lmc $v,$v : $("$smi" -i "$dev" -lmc "$v,$v" 2>&1)" >> "$dir/helper.log";; esac ;; + rmc) echo "$(date -u +%FT%TZ) $1 -rmc : $("$smi" -i "$dev" -rmc 2>&1)" >> "$dir/helper.log" ;; esac done fi @@ -614,6 +622,7 @@ mod tests { for s in [helper_script_windows(), helper_script_unix()] { assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min")); 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"); } } } diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 84bf99b5e..88328f388 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -112,7 +112,9 @@ static void applyState(const std::string& j) { // Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin. static void runElevated(std::wstring line) { std::thread([line] { - std::wstring params = L"/c " + line; + // cmd strips the first and last quote of a line that starts with one and holds more than two (two cards, or a + // cap plus a script): one outer pair around the whole line is what it may strip (PC 1, 6 October 2026) + std::wstring params = L"/c \"" + line + L"\""; wchar_t sysdir[MAX_PATH]; GetSystemDirectoryW(sysdir, MAX_PATH); std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3) From fb074297198a7f84fae9e301dcbbab60b84b3ee5 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:48:44 +0000 Subject: [PATCH 08/18] state: tune_period_s on the settings state (the manifest's period), so the UI derives the next tune from sweep_at Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/engine.rs | 3 ++- app/igneum-app/src/state.rs | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 096eec0c2..6a8e28bec 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -123,7 +123,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -2176,6 +2176,7 @@ impl Engine { let mut st = self.st(); st.settings.tuning_off = !ember.enabled; st.settings.tuning_note = if ember.enabled { String::new() } else { "tuning paused fleet-wide by the signed manifest".into() }; + st.settings.tune_period_s = ember.period_s; } if paused || !ember.enabled { return; diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index cf0ccd395..90ef51fd3 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -297,6 +297,9 @@ pub struct SettingsState { pub tune_goal: String, pub power_price_pence: f64, pub tune_climb: bool, + /// 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, /// the miner software's dev fee switch (settings; `--dev-fee 0` when off) pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) From d4208f9c40abbea94960a70a5ae424703b54505b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:34 +0000 Subject: [PATCH 09/18] =?UTF-8?q?Ember=202:=20the=20climb's=20tests=20(a?= =?UTF-8?q?=20synthetic=20memory-bound=20card=20converges=20in=20five=20pr?= =?UTF-8?q?obes;=20a=20refused=20probe=20backs=20the=20knob=20off=20for=20?= =?UTF-8?q?good;=20each=20goal=20picks=20its=20point;=20the=20=C2=A3/day?= =?UTF-8?q?=20formula)=20and=20the=20fleet=20prior=20carrying=20the=20memo?= =?UTF-8?q?ry=20clock=20(climb=20records=20aggregate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 78 +++++++++++++++++++++++++++++++++++++ relay/lib/ember.mjs | 6 ++- relay/test/ember.test.mjs | 11 +++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index e5a4584b1..9e9bf54cc 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -1180,6 +1180,84 @@ mod tests { assert_eq!(program_class(0, 0), "v2"); } + /// Ember 2: a synthetic memory-latency-bound card. Rate rises 1% per 100 MHz of memory above the default and + /// falls only 0.3% per 100 MHz of core below the maximum; draw falls 8 W per 100 MHz of core and rises 2 W per + /// 100 MHz of memory. The climb must walk memory up and core down and converge in under five probes. + fn synthetic(p: Point) -> Row { + let mem = if p.mem_mhz == 0 { 13801.0 } else { p.mem_mhz as f64 }; + let core = if p.clock_mhz == 0 { 3090.0 } else { p.clock_mhz as f64 }; + let mhs = 127.0 * (1.0 + 0.015 * (mem - 13801.0) / 100.0) * (1.0 - 0.003 * (3090.0 - core) / 100.0); + let watts = 310.0 - 8.0 * (3090.0 - core) / 100.0 + 2.0 * (mem - 13801.0) / 100.0; + Row { point: p, limit: 575.0, watts, mhs, eff: mhs / watts, draws: 12, rates: 6, gclk: core, mclk: mem, tmax: 65.0, faults: 0, mark: Some(Mark::Ok) } + } + + #[test] + fn the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes() { + // a 2 GHz memory range above the default (the headroom a 5090 has in practice; PC 1's driver reports 14,001) + let l = Limits { mem_max_mhz: 15801, ..l5090() }; + let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0); + assert_eq!(plan.len(), 5); + let mut rows = Vec::new(); + while let Some(step) = plan.next(&rows) { + assert_eq!(step.kind, Kind::Climb); + rows.push(synthetic(step.point)); + } + assert_eq!(rows.len(), 5, "the budget"); + assert_eq!(rows[0].point, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, "it starts at the start point"); + // every probe moved one knob the right way: memory never down, core never up, and both inside the vendor's range + for w in rows.windows(2) { + let (a, b) = (w[0].point, w[1].point); + assert!(b.mem_mhz >= a.mem_mhz || b.mem_mhz == 0, "{a:?} -> {b:?}"); + assert!(b.mem_mhz <= 15801 && (b.clock_mhz == 0 || b.clock_mhz >= 1854), "{b:?}"); + } + let best = choose(&rows, plan.tolerance_pct).unwrap(); + assert!(best.eff > rows[0].eff, "the chosen point beats the start: {:.4} > {:.4}", best.eff, rows[0].eff); + assert!(best.point.mem_mhz > 13801 || (best.point.clock_mhz > 0 && best.point.clock_mhz < 3090), "it moved a knob"); + } + + #[test] + fn a_refused_probe_backs_that_knob_off_for_good() { + let l = Limits { mem_max_mhz: 15801, ..l5090() }; + let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0); + let mut rows = vec![synthetic(plan.next(&[]).unwrap().point)]; + // the first probe (memory up) draws a mismatched hash: refused + let probe = plan.next(&rows).unwrap(); + assert!(probe.point.mem_mhz > 13801, "memory first: {:?}", probe.point); + let mut bad = synthetic(probe.point); + bad.faults = 1; + bad.mark = Some(Mark::Faulted); + rows.push(bad); + // from here every probe leaves the memory clock alone + while let Some(step) = plan.next(&rows) { + assert_eq!(step.point.mem_mhz, 0, "memory backed off: {:?}", step.point); + rows.push(synthetic(step.point)); + } + assert!(rows.len() >= 3 && rows.len() <= 5); + assert!(choose(&rows, 1.0).unwrap().point.mem_mhz == 0); + } + + #[test] + fn each_goal_picks_its_point() { + let l = l5090(); + let p = |c: u32, m: u32| Point { clock_mhz: c, power_pct: 100, mem_mhz: m }; + // three points: the fastest (slightly less efficient), the most efficient (4% slower), and the start + let rows = vec![synthetic(p(0, 0)), synthetic(p(0, 14001)), synthetic(p(1854, 0))]; + let eff_plan = Plan::climb(&l, p(0, 0), Goal::Efficiency, 1.0); + let bal_plan = Plan::climb(&l, p(0, 0), Goal::Balanced, 1.0); + let rate_plan = Plan::climb(&l, p(0, 0), Goal::MaxRate, 1.0); + assert_eq!(eff_plan.tolerance_pct, 10.0); + assert_eq!(bal_plan.tolerance_pct, 1.0); + assert_eq!(rate_plan.tolerance_pct, 0.0); + let by = |plan: &Plan| rows.iter().filter(|r| r.usable() && r.mhs >= rows.iter().map(|x| x.mhs).fold(0.0, f64::max) * (1.0 - plan.tolerance_pct / 100.0)).max_by(|a, b| plan.score(a).partial_cmp(&plan.score(b)).unwrap()).unwrap().point; + assert_eq!(by(&rate_plan), p(0, 14001), "maximum rate takes the fastest point"); + assert_eq!(by(&eff_plan), p(1854, 0), "efficiency takes the most MH/W inside 10%"); + assert_eq!(by(&bal_plan), p(0, 14001), "balanced keeps within 1% of the top rate"); + assert_eq!(Goal::parse("efficiency"), Goal::Efficiency); + assert_eq!(Goal::parse("rate"), Goal::MaxRate); + assert_eq!(Goal::parse("anything"), Goal::Balanced); + assert!((pounds_per_day(310.0, 28.5) - 2.1204).abs() < 1e-3, "310 W a day at 28.5 p/kWh = £2.12"); + } + #[test] fn control_reasons_per_vendor() { let l = l5090(); diff --git a/relay/lib/ember.mjs b/relay/lib/ember.mjs index 166b70274..5e4604352 100644 --- a/relay/lib/ember.mjs +++ b/relay/lib/ember.mjs @@ -46,7 +46,7 @@ export function spreadPct(xs) { return Number((median(xs.map(x => Math.abs(x - m))) / m * 100).toFixed(2)); } -const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm'); +const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm' || r.plan === 'climb'); /// Folds records into priors: one per key, from the full and confirm records with a usable chosen point. The point /// is the median clock cap and the median power percent (each rounded to the step the apps use: 10 MHz, 1%), the @@ -80,6 +80,8 @@ export function aggregate(records, { minSamples = 1 } = {}) { const prior = { clock_mhz: Math.round(median(t.map(r => r.chosen.clock_mhz)) / 10) * 10, power_pct: Math.round(median(t.map(r => r.chosen.power_pct))), + // Ember 2: the memory clock (0 = the driver's default); older records carry none + mem_mhz: Math.round(median(t.map(r => r.chosen.mem_mhz || 0)) / 10) * 10, eff: Number(median(effs).toFixed(4)), mhs: Number(median(t.map(r => r.chosen.mhs)).toFixed(2)), watts: Number(median(t.map(r => r.chosen.watts)).toFixed(1)), @@ -120,7 +122,7 @@ export function priorFor(tuning, key, minSamples) { const p = tuning && tuning.priors && tuning.priors[key]; const floor = Number.isFinite(minSamples) ? minSamples : (tuning && tuning.ember && tuning.ember.min_samples) || 5; if (!p || !(p.samples >= floor)) return null; - return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), eff: p.eff, samples: p.samples }; + return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), mem_mhz: p.mem_mhz || 0, eff: p.eff, samples: p.samples }; } /// One text line per prior for the console and the CLI. diff --git a/relay/test/ember.test.mjs b/relay/test/ember.test.mjs index a5d39e96e..c8ed38170 100644 --- a/relay/test/ember.test.mjs +++ b/relay/test/ember.test.mjs @@ -82,7 +82,7 @@ test('the manifest merge keeps the kernel-variant cards and carries the settings assert.deepEqual(mergeTuning(null, {}).cards, {}); // the round trip: canonical JSON (what publish-manifest.sh signs) parses back to the same prior const back = JSON.parse(JSON.stringify(t)); - assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 }); + assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, mem_mhz: 0, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 }); assert.equal(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16', 6), null, 'six wanted, five there'); assert.equal(priorFor(back, 'nothing|0|v2'), null); assert.equal(priorFor(null, 'x'), null); @@ -100,6 +100,15 @@ test('AMD confirm records aggregate by their own key, and the line reads', () => assert.equal(table.length, 1); }); +test('Ember 2: climb records carry the memory clock into the prior', () => { + const climb = (m, ts, mem) => ({ ...rec(m, ts, { ...step(2472, 100, 220, 123.5), mem_mhz: mem }), plan: 'climb' }); + const { priors } = aggregate([climb('a1', 1, 14001), climb('b2', 2, 14001), climb('c3', 3, 13901), climb('d4', 4, 14001), climb('e5', 5, 14001)], { minSamples: 5 }); + const p = priors['NVIDIA_GeForce_RTX_5090|581|l128w16']; + assert.equal(p.mem_mhz, 14000, 'the median memory clock, rounded to 10 MHz'); + assert.equal(p.clock_mhz, 2470); + assert.equal(priorFor({ priors, ember: { min_samples: 5 } }, 'NVIDIA_GeForce_RTX_5090|581|l128w16').mem_mhz, 14000); +}); + test('median and spread', () => { assert.equal(median([3, 1, 2]), 2); assert.equal(median([4, 1, 2, 3]), 2.5); From 2e6600ebeaf283b545d4b1cce34bba1355e345a3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:52:37 +0000 Subject: [PATCH 10/18] ember-tune-pc1.ps1: ember-kit-6 (the quote fix, the self-registering elevated engine) preferred when present Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-tune-pc1.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 6a3b4afbc..687219775 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -47,10 +47,12 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out $installedHasEmber = $false if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) } $ember = $null -# a kit is used only when the installed app predates Ember Tune (0.3.13); from 0.3.13 on the installed engine is the -# tune engine, so the Power Helper task it registers points at the install folder (the 15:28Z run on PC 1 registered -# the kit's path because kit-5 was still preferred) -if (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } +# a kit is used when the installed app predates Ember Tune (0.3.13), or when a run carries fixes the installed app +# lacks (ember-kit-6: the cmd quote fix and the self-registering elevated engine of 6 October 2026); a kit engine +# registers the Power Helper on the INSTALLED exe's path (powertask::task_exe), never its own +$k6 = Join-Path $appDir 'jobs\ember-kit-6\igneum-app-ember.exe' +if (Test-Path $k6) { $ember = $k6 } +elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Say ("engine: the Ember build from " + $ember) From 2c254d41d9d580b13665aeb9b82420526f0ada68 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:53:43 +0000 Subject: [PATCH 11/18] =?UTF-8?q?Ember=202:=20the=20plan's=20section=206b?= =?UTF-8?q?=20(the=20clocks,=20the=20hill-climb,=20the=20goal,=20the=20pri?= =?UTF-8?q?ce,=20the=20curve,=20the=20fleet=20prior,=20what=20is=20owed);?= =?UTF-8?q?=20Settings:=20the=20goal,=20the=20electricity=20price,=20the?= =?UTF-8?q?=20climb=20switch;=20the=20card=20detail:=20=C2=A3=20a=20day=20?= =?UTF-8?q?for=20the=20tuned=20point=20and=20the=20measured=20curve;=20UI?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/ui/app.js | 20 +++++++++++++++++++- app/igneum-app/ui/index.html | 3 +++ app/igneum-app/ui/tune-line.test.mjs | 5 +++++ docs/plans/ember-tune.md | 26 ++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 5b8c301d4..1df3cc395 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -441,7 +441,9 @@ var TuneLine = (function () { return { kind: 'idle', text: cd.sweep_note || 'tuning: not run yet (starts after 120 s of steady mining)' }; } function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; } - return { model: model, point: point, rel: rel }; + // Ember 2: £ a day for a draw at pence per kWh (watts × 24 / 1000 × price / 100) + function pounds(watts, pence) { var v = watts * 24 / 1000 * pence / 100; return '£' + v.toFixed(2); } + return { model: model, point: point, rel: rel, pounds: pounds }; })(); if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; } @@ -630,6 +632,9 @@ if (typeof document !== 'undefined') (function () { $('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); }); $('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); }); $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on: once after install, then weekly' : 'Ember Tune off'); }); }); + $('s-tune-goal').addEventListener('change', function () { var g = this.value; api('api/tune/goal', { goal: g }).then(function (r) { if (r.ok) toast('Goal: ' + g); }); }); + $('s-power-price').addEventListener('change', function () { var p = parseFloat(this.value); if (isNaN(p)) return; api('api/tune/goal', { price_pence: p }).then(function (r) { if (r.ok) toast('Electricity: ' + p + ' p/kWh'); }); }); + $('s-tune-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (r.ok) toast(on ? 'Ember 2: the clocks climb from the next tune' : 'The ladders run from the next tune'); }); }); $('s-power-control').addEventListener('change', function () { var on = this.checked; api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off; nothing asks'); }); }); $('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); }); $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); @@ -1177,6 +1182,10 @@ if (typeof document !== 'undefined') (function () { sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', s.settings.sweep); sw('s-trust', s.settings.proof_verify_trust); sw('s-power-control', s.settings.power_control); setText('s-power-note', s.settings.tuning_off ? s.settings.tuning_note : (s.settings.power_note || (s.settings.power_control ? '' : 'Off: NVIDIA cards measure only; AMD cards need no rights.'))); setText('s-devfee-text', View.devFeeText(s)); + if (document.activeElement !== $('s-tune-goal')) $('s-tune-goal').value = (s.settings.tune_goal || 'balanced'); + if (document.activeElement !== $('s-power-price')) $('s-power-price').value = s.settings.power_price_pence > 0 ? s.settings.power_price_pence : ''; + setText('s-power-price-note', s.settings.power_price_pence > 0 ? 'the card rows read in £ a day' : 'set it to see £ a day per card'); + sw('s-tune-climb', s.settings.tune_climb); if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || ''; setText('s-mid', (s.machine_id || '').slice(0, 8)); setText('s-node-dir', 'chain data: ' + s.node_dir); @@ -1200,6 +1209,7 @@ if (typeof document !== 'undefined') (function () { } // Ember Tune's line (TuneLine.model): running, tuned, measured, stopped or not run yet, for every card h += tuneLineHtml(cd); + h += tuneCurveHtml(cd, state && state.settings ? state.settings.power_price_pence : 0); h += '<div class="ctl"><span class="k">Identities</span><p class="help" style="grid-column:2">Each identity votes and is paid on its own. 8 suits a big card, 2 a small one, 1 an integrated GPU. Changing it restarts that card’s worker.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">−</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>'; return h + '</div>'; } @@ -1211,6 +1221,14 @@ if (typeof document !== 'undefined') (function () { var btn = running ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Tune now</button>' + (cd.pinned ? ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0">Unpin</button>' : ''); return '<div class="line' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>'; } + // Ember 2: £ a day for the tuned point and the measured curve (every row of the last plan) + function tuneCurveHtml(cd, price) { + var h = ''; + if (price > 0 && cd.sweep_watts > 0) h += '<div class="line dim">' + esc(TuneLine.pounds(cd.sweep_watts, price)) + ' a day at this point' + (cd.power_w > 0 && Math.abs(cd.power_w - cd.sweep_watts) > 5 ? ' · ' + esc(TuneLine.pounds(cd.power_w, price)) + ' at the draw now' : '') + '</div>'; + var c = cd.tune_curve || []; + if (c.length > 1) h += '<div class="line dim">curve: ' + c.map(function (r) { return esc((r.clock_mhz ? r.clock_mhz + ' MHz' : 'unlocked') + (r.mem_mhz ? ' mem ' + r.mem_mhz : '') + ' ' + r.power_pct + '%: ' + Number(r.mhs).toFixed(1) + ' MH/s ' + Math.round(r.watts) + ' W ' + Number(r.eff).toFixed(3) + (r.mark && r.mark !== 'ok' ? ' (' + r.mark + ')' : '')); }).join(' · ') + '</div>'; + return h; + } function renderSetCards(s) { var cards = shownCards(s.mining.cards), box = $('s-cards'); if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return; diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 7b423fdf9..4695883e8 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -369,6 +369,9 @@ <div class="set-cards" id="s-cards"><div class="empty">No card yet.</div></div> <label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Ember Tune: tune every card for hashes per watt</span></label> <p class="help">Once after install, then weekly and after a driver or program change: the power limit steps from 100% down to 50%, then the core clock from its maximum down to 60%, 75 s a step on the live program; the memory clock is never touched. The card keeps the point with the most hashes per watt within 1% of its top rate. A step with a rejected hash, a hot GPU or a dragged memory clock is reverted. A card whose model the fleet already knows starts at that point and confirms it in two steps. Every result goes back to the fleet without anything that identifies you. A cap you set by hand is left alone.</p> + <div class="ctl"><span class="k">Goal</span><select id="s-tune-goal" aria-label="tuning goal"><option value="efficiency">Efficiency: the most hashes per watt, within 10% of the top rate</option><option value="balanced">Balanced: the most hashes per watt, within 1% of the top rate</option><option value="rate">Maximum rate</option></select></div> + <div class="ctl"><span class="k">Electricity</span><input type="number" id="s-power-price" min="0" max="500" step="0.1" placeholder="pence per kWh" aria-label="electricity price, pence per kWh"><span class="pv" id="s-power-price-note"></span></div> + <label class="switch"><input type="checkbox" id="s-tune-climb"><span class="track"></span><span>Ember 2: climb the clocks (memory up, core down) instead of the ladders</span></label> <label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span>Power control: let the app set NVIDIA limits</span></label> <p class="help">Windows asks for administrator rights once; the NVIDIA cap and the tune need them. Off, the app never asks and NVIDIA cards measure only. AMD cards need no rights. <span id="s-power-note"></span></p> </div> diff --git a/app/igneum-app/ui/tune-line.test.mjs b/app/igneum-app/ui/tune-line.test.mjs index 18fafce33..ff684f4fd 100644 --- a/app/igneum-app/ui/tune-line.test.mjs +++ b/app/igneum-app/ui/tune-line.test.mjs @@ -75,3 +75,8 @@ test('a card never shows 0 MH/s without a reason word: tuning, held and tuned ro assert.equal(View.tuneEta(30), 'under a minute'); assert.equal(View.tuneEta(700), 'about 12 min'); }); + +test('Ember 2: £ a day from watts and pence per kWh', () => { + assert.equal(mod.exports.TuneLine.pounds(310, 28.5), '£2.12'); + assert.equal(mod.exports.TuneLine.pounds(102.7, 28.5), '£0.70'); +}); diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 40bc4be90..6d771c17c 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -176,6 +176,32 @@ live MH/s and W, the seconds left and the plan; a POST of state, never a quit, p reads `Tuned: <MH/s> at <W> W (<MH/W>)` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held, tuned rows; the button; the strip line). +## 6b. Ember 2: the clocks, the hill-climb, the goal (6 October 2026) + +Why: run 5 measured the 5090's power ladder flat (310 to 314 W under every cap from 575 to 400 W, 0.41 MH/W), so +on this memory-latency-bound hash the power cap is a safety, not a lever; the pair that matters is the memory +clock UP and the core clock DOWN. Ember 2 adds both and replaces the fixed ladder with a hill-climb. + +| Piece | What | Where | +|---|---|---| +| The memory knob | NVIDIA: `nvidia-smi -lmc <m>,<m>` locks the memory clock to one value, `-rmc` resets; the range is the driver's own (`clocks.mem` under load = the floor, `clocks.max.mem` = the ceiling; PC 1's 5090: 13,801 to 14,001 MHz); directly from an elevated engine or through the Power Helper's `lmc`/`rmc` verbs. AMD: ADLX on RDNA 4 exposes no memory setter through the helper's path, so an AMD card climbs on core and power only (the row says so). Linux NVIDIA: `-lmc` through pkexec as the cap is | `Point.mem_mhz`, `Limits.mem_default_mhz`, `Limits.mem_max_mhz`, `tune_apply`, `powertask::HelperCmd::MemClock`, `sweep::helper_script_*` | +| The hill-climb | from the start point (the fleet prior for the model, else the card's point): each probe moves memory up by 5% of the range above the default (at least 25 MHz) or core down by 5% of the maximum (at least 25 MHz); a probe that improves the goal's score keeps that direction, else the climb turns to the other knob, then to both; 5 probes of 60 s (10 s settle) converge in under 10 minutes | `Plan::climb`, `Climb`, `Plan::climb_next` | +| The stability guard | a rejected or mismatched hash during a probe marks it `faulted`; a faulted probe backs that knob off for good in this climb (memory never goes above, core never below, the last good point); the hash fingerprint is the worker's own self-test at every pack (a variant that is not bit-exact never serves), so a step that keeps mining with 0 mismatches is stable by the only test that matters | `Row::from_samples`, `climb_next`'s `refused_mem` / `refused_core`, docs/design/miner-tuning.md | +| The goal | Settings > goal: `efficiency` (the most MH/W within 10% of the top rate), `balanced` (within 1%, lever 3's rule), `rate` (the fastest point; MH/W breaks ties); one goal for every card | `Goal`, `Settings.tune_goal`, `POST /api/tune/goal {goal, price_pence, climb}` | +| The price | Settings > electricity, pence per kWh: the card's row reads the chosen point as £ a day (`watts × 24 / 1000 × price / 100`: 310 W at 28.5 p = £2.12) | `pounds_per_day`, `Settings.power_price_pence`, the UI | +| The curve | every row of the last plan on the card (`tune_curve`: point, MH/s, W, MH/W, clocks, hottest reading, mark) so a manual tuner sees the points the climb measured | `CardState.tune_curve`, the card detail | +| The fleet | the record's `steps` already carry the curve; the prior gains `mem_mhz` (the median memory clock, 10 MHz steps); a stranger's card of a known model starts its climb at the prior's point | `relay/lib/ember.mjs`, `ember::prior_of` | +| Re-check | weekly (the manifest's period), after a driver major or program-class change, and when the card's temperature band changes (owed: the band trigger) | `tick_sweep` | + +Tests: `ember::tests::the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes` (a synthetic +memory-bound card: every probe moves memory up or core down inside the range, the chosen point beats the start in +five probes), `a_refused_probe_backs_that_knob_off_for_good`, `each_goal_picks_its_point` (and the £/day formula), +`powertask::tests` (the `lmc`/`rmc` verbs reach nvidia-smi as `-lmc m,m` and `-rmc`), `sweep::tests` (the helper +scripts carry them), `relay/test/ember.test.mjs` (climb records aggregate into a prior with the memory clock). + +Owed: the measured comparison on PC 1 (one climb per card against run 5's ladder rows, no prompt through the +Power Helper), the temperature-band trigger, NVAPI/nvidia-settings for cards whose driver refuses `-lmc`. + ## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC) What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a From 62d2d4b3e439f2909f577aeb89fc6dc4810d616c Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:00:09 +0000 Subject: [PATCH 12/18] Ember playbook: the AMD watts source. The script samples the installed app's /api/state power_w for the AMD card every 10 s (a GET), counts the engine's own AMD watts, prints a fallback ladder (median per step, with the step's MH/s and MH/W) when the engine's AMD progress carries no watts, and names the source it used Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-tune-pc1.ps1 | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 687219775..d11183365 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -174,8 +174,21 @@ function Exit3([string] $why, [string] $reason) { Write-Output 'RESULT TUNE error=no_rows' exit 3 } +# the AMD watts source (coordinator, 6 October 2026 16:00Z): the 3.0 coordinator's job read ZERO watts for the 9070 XT +# from igneum-gpu-telemetry.exe while the installed app had read 198.9 W the day before. The tune engine samples its +# own helper; this script also samples the installed app's /api/state power_w for the AMD card every 10 s (a GET, +# never a write) and, when the engine's AMD progress lines carry no watts, prints a fallback ladder from those +# samples (median per step) and names the source it used +$amdApp = @(); $amdStep = 0; $amdMhs = 0.0; $amdEngNZ = 0; $amdEngZ = 0; $tick = 0; $amdMhsByStep = @{} while (-not $p.HasExited) { Start-Sleep -Seconds 5 + $tick++ + if ($installedUrl -and ($tick % 2 -eq 0)) { + try { + $js = (Invoke-WebRequest -Uri ($installedUrl + 'api/state') -UseBasicParsing -TimeoutSec 3).Content | ConvertFrom-Json + foreach ($c in @($js.mining.cards)) { if ($c.vendor -eq 'amd') { $amdApp += ,@{ step = $amdStep; w = [double]$c.power_w } } } + } catch { } + } $tailLine = EngineTail if ($tailLine) { $lastEngineLine = $tailLine } $statusLine = EngineStatus @@ -191,7 +204,12 @@ while (-not $p.HasExited) { $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ - if ($l -match '^TUNE progress ') { Forward $l } + if ($l -match '^TUNE progress ') { + Forward $l + if ($l -match ' key=amd' -and $l -match ' step=(\d+)') { $amdStep = [int]$Matches[1] + if ($l -match ' mhs=([\d.]+)') { $amdMhsByStep[$amdStep] = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) } + if ($l -match ' watts=([\d.]+)' -and [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) -gt 0) { $amdEngNZ++ } else { $amdEngZ++ } } + } elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } } elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) } elseif ($l -match '^(URL|STATE) ') { } @@ -217,6 +235,20 @@ while (-not $p.HasExited) { } $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++; if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } } } +$amdNZ = @($amdApp | Where-Object { $_.w -gt 0 }) +Write-Output ('RESULT TUNE amd_watts engine_progress nonzero=' + $amdEngNZ + ' zero=' + $amdEngZ + ' app_state samples=' + $amdApp.Count + ' nonzero=' + $amdNZ.Count) +$amdSource = 'none' +if ($amdEngNZ -gt 0) { $amdSource = 'engine_telemetry' } +elseif ($amdNZ.Count -gt 0) { + $amdSource = 'app_state_fallback' + foreach ($g in ($amdNZ | Group-Object { $_.step } | Sort-Object { [int]$_.Name })) { + $ws = @($g.Group | ForEach-Object { $_.w } | Sort-Object); $med = $ws[[int][math]::Floor($ws.Count / 2)] + $mh = 0.0; if ($amdMhsByStep.ContainsKey([int]$g.Name)) { $mh = $amdMhsByStep[[int]$g.Name] } + $eff = 0.0; if ($med -gt 0) { $eff = $mh / $med } + Write-Output ('RESULT TUNE amd_fallback source=app_state step=' + $g.Name + ' watts=' + ('{0:F1}' -f $med) + ' mhs=' + ('{0:F2}' -f $mh) + ' eff=' + ('{0:F4}' -f $eff) + ' samples=' + $ws.Count) + } +} +Write-Output ('RESULT TUNE amd_watts_source=' + $amdSource) Say ("tune engine exited " + $p.ExitCode + " after " + [int]((Get-Date) - $started).TotalSeconds + " s, " + $rows + " table rows") EndTree $p.Id 'end of run' # the project lead, 6 October 2026, 07:25Z: both cards stay on their best MH/W points (the tune pins them); no factory reset here. From 0aea6e8fce0922b7c3d78127a68469da4d7e09b7 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:35:03 +0000 Subject: [PATCH 13/18] Ember run 6: the helper registered on the scratch copy, so task_exe prefers the installed exe for every path that is not itself an install candidate, and the helper gains a reregister verb (no path argument; re-points the task at the install folder it finds itself, logs the action read back); the clock ladder and floor go to 45% with the 1% rate tolerance as the guard; a chosen point on the floor says floor, not optimum; the 5090 row (84 W saved for 0.15% of rate) on the fleet page; the re-point and no-prompt proof playbook; run 6 notes, threat note, bench log Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 31 ++++++--- app/igneum-app/src/engine.rs | 11 ++-- app/igneum-app/src/powertask.rs | 67 +++++++++++++++++-- docs/bench-log.md | 10 +++ docs/plans/ember-tune.md | 37 ++++++++++- relay/playbooks/ember-helper-repoint-pc1.ps1 | 69 ++++++++++++++++++++ site/build.mjs | 2 + site/miner-priors.json | 21 +++++- site/miners.html | 2 + 9 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 relay/playbooks/ember-helper-repoint-pc1.ps1 diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 9e9bf54cc..c1cb4db89 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -20,9 +20,11 @@ use std::time::{Duration, Instant}; /// The power steps, percent of the card's default limit, highest first (the same ladder as src/sweep.rs). pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50]; /// The clock steps, percent of the card's maximum core clock, highest first; 100 = unlocked (the card's own boost). -pub const CLOCK_STEPS_PCT: [u32; 5] = [100, 90, 80, 70, 60]; +/// 6 October 2026, run 6: the 5090's best MH/W sat on the 60% floor (1,854 MHz: 0.563 MH/W, the rate within 0.15%), +/// so the ladder and the floor go to 45% of the maximum; the 1% rate tolerance is the guard below that +pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45]; /// A card's clock floor when the vendor reports none: this share of its maximum core clock. -pub const CLOCK_FLOOR_PCT: u32 = 60; +pub const CLOCK_FLOOR_PCT: u32 = 45; /// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it). pub const RATE_TOLERANCE_PCT: f64 = 1.0; /// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90). @@ -601,7 +603,7 @@ pub fn prior_of(tuning: Option<&serde_json::Value>, key: &str, min_samples: u32) /// The fleet record: one `TUNE {json}` line. `machine` is a hash of the install id (never the id, never an address). #[allow(clippy::too_many_arguments)] -pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str, vendor: &str, driver: &str, class: &str, plan: PlanKind, rows: &[Row], chosen: Option<&Row>, before: Option<&Row>) -> serde_json::Value { +pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str, vendor: &str, driver: &str, class: &str, plan: PlanKind, rows: &[Row], chosen: Option<&Row>, before: Option<&Row>, floor: bool) -> serde_json::Value { serde_json::json!({ "ts": ts.round(), "machine": machine_hash, @@ -620,6 +622,8 @@ pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str, "eff": chosen.map(|r| r4(r.eff)).unwrap_or(0.0), "mhs": chosen.map(|r| r2(r.mhs)).unwrap_or(0.0), "watts": chosen.map(|r| r1(r.watts)).unwrap_or(0.0), + "floor": floor, + "note": if floor { "floor, not optimum" } else { "" }, }) } @@ -928,7 +932,7 @@ mod tests { #[test] fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() { let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); - assert_eq!(plan.len(), 5 + 4, "five power steps (60% and 50% clamp to 400 W; one kept) and four clock steps"); + assert_eq!(plan.len(), 5 + 6, "five power steps (60% and 50% clamp to 400 W; one kept) and six clock steps (90% down to 45%)"); let first = plan.next(&[]).unwrap(); assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power)); // the power ladder: 575, 518, 460, 403, 400 @@ -952,8 +956,14 @@ mod tests { rows.push(row_at(s.point, 220.0, 123.5)); rows.push(row_at(plan.next(&rows).unwrap().point, 200.0, 118.0)); let s = plan.next(&rows).unwrap(); - assert_eq!(s.point.clock_mhz, 1854, "60% of 3,090 is the floor"); + assert_eq!(s.point.clock_mhz, 1854, "60% of 3,090"); rows.push(row_at(s.point, 180.0, 100.0)); + let s = plan.next(&rows).unwrap(); + assert_eq!(s.point.clock_mhz, 1545, "50%"); + rows.push(row_at(s.point, 170.0, 90.0)); + let s = plan.next(&rows).unwrap(); + assert_eq!(s.point.clock_mhz, 1390, "45% of 3,090 is the floor (6 October 2026)"); + rows.push(row_at(s.point, 160.0, 80.0)); assert_eq!(plan.next(&rows), None); // the choice: 2,472 MHz keeps 99.6% of the top rate at 220 W = 0.561 MH/W; 2,163 MHz (118 MH/s) is outside the 1% tolerance let best = choose(&rows, 1.0).unwrap(); @@ -961,7 +971,7 @@ mod tests { // a wider tolerance lets the 2,163 MHz step (0.590 MH/W, 4.8% slower) win assert_eq!(choose(&rows, 5.0).unwrap().point.clock_mhz, 2163); // no power limits, clocks only; no clocks, power only; nothing, empty - assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 4); + assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 6); assert_eq!(Plan::full(&Limits { power_default_w: 300.0, ..Default::default() }, Point::default(), 1.0).len(), 6); assert!(Plan::full(&Limits::default(), Point::default(), 1.0).is_empty()); } @@ -969,11 +979,11 @@ mod tests { #[test] fn limits_never_exceed_the_vendor_or_undercut_the_floor() { let l = l5090(); - assert_eq!(l.clock_floor(), 1854); - assert_eq!(l.clamp_clock(1000), 1854); + assert_eq!(l.clock_floor(), 1390); + assert_eq!(l.clamp_clock(1000), 1390); assert_eq!(l.clamp_clock(5000), 3090); assert_eq!(l.clamp_clock(0), 0, "unlocked stays unlocked"); - assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 60% rule"); + assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 45% rule"); assert_eq!(l.watts_for(100), 575.0); assert_eq!(l.watts_for(50), 400.0); assert_eq!(Limits { power_default_w: 300.0, power_max_w: 250.0, ..Default::default() }.watts_for(100), 250.0); @@ -1144,7 +1154,8 @@ mod tests { #[test] fn the_record_and_the_prior_round_trip_through_the_manifest_shape() { let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }, 220.0, 123.5); - let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None); + let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None, false); + assert_eq!(rec["floor"], false); assert_eq!(rec["card"], "NVIDIA_GeForce_RTX_5090"); assert_eq!(rec["key"], "NVIDIA_GeForce_RTX_5090|581|l128w16"); assert_eq!(rec["driver_major"], "581"); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 6a8e28bec..0d391cbac 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1845,7 +1845,7 @@ impl Engine { let dir = self.sweep_dir(); let _ = std::fs::create_dir_all(&dir); let script = dir.join("register-power-task.ps1"); - let installed: Vec<PathBuf> = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + let installed: Vec<PathBuf> = crate::powertask::install_candidates(); match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) { Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()), _ => cmds.join(" & "), @@ -2351,7 +2351,7 @@ impl Engine { let dir = self.sweep_dir(); let _ = std::fs::create_dir_all(&dir); let script = dir.join("register-power-task.ps1"); - let installed: Vec<PathBuf> = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + let installed: Vec<PathBuf> = crate::powertask::install_candidates(); if let Ok(exe) = std::env::current_exe() { let target = crate::powertask::task_exe(&exe, &installed); if std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&target).as_bytes()].concat()).is_ok() { @@ -2785,9 +2785,12 @@ impl Engine { }; let _ = key; self.shared.save_settings(); - self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.point.mem_mhz, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" })); + // 6 October 2026, run 6: the 5090's chosen clock sat on the ladder's floor (1,854 MHz = the old 60%), so a + // chosen point at the floor says so: it is the lowest step measured, not the optimum (Ember 2's climb walks on) + let floor = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor(); + self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.point.mem_mhz, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" }, if floor { " floor=1 note=floor,_not_optimum" } else { "" })); let before = run.rows.first().filter(|_| kind == crate::ember::PlanKind::Full).cloned(); - let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref()); + let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref(), floor); self.sweep_say(&format!("TUNE {record}")); let line = crate::ember::tuned_line(row.mhs, row.watts, row.eff); match kind { diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index 0cb86b083..f674b31f7 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -48,6 +48,9 @@ pub enum HelperCmd { MemReset, Quit, Remove, + /// 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, } /// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and @@ -71,6 +74,7 @@ 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()))), + [_, "reregister"] => Some((seq, HelperCmd::Reregister)), _ => None, } } @@ -91,16 +95,37 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> { /// kit a job fetched: `<app data>\app\jobs\<id>\igneum-app-ember.exe`), in which case the installed app's own /// exe beside it is the durable target (6 October 2026: the first registration on PC 1 came from such a kit). pub fn task_exe(running: &Path, install_candidates: &[PathBuf]) -> PathBuf { - // either separator: the path comes from Windows, the tests run on the Mac too - let under_jobs = running.to_string_lossy().split(['\\', '/']).any(|c| c.eq_ignore_ascii_case("jobs")); - if under_jobs { - if let Some(p) = install_candidates.iter().find(|p| p.is_file()) { - return p.clone(); - } + // the running exe stands only when it is itself an install candidate (either separator, any case: the path + // comes from Windows, the tests run on the Mac too); a kit under jobs\ or a measurement run's scratch copy + // (run 6, 6 October 2026: igneum-tune-<stamp>\bin\igneum-app.exe) never becomes the task's action when an + // installed exe exists + let norm = |p: &Path| p.to_string_lossy().replace('/', "\\").to_ascii_lowercase(); + let me = norm(running); + if install_candidates.iter().any(|p| norm(p) == me) { + return running.to_path_buf(); + } + if let Some(p) = install_candidates.iter().find(|p| p.is_file()) { + return p.clone(); } running.to_path_buf() } +/// Where the installed exe lives: the per-user install, then the old administrator install. +pub fn install_candidates() -> Vec<PathBuf> { + [ + std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), + std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe")), + ] + .into_iter() + .flatten() + .collect() +} + +/// The PowerShell that prints the task's action (what `reregister` is proven by). +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. @@ -195,6 +220,26 @@ pub fn run_helper(dir: &Path) -> i32 { return if ok { 0 } else { 1 }; } _ if seq <= last_seq => continue, + HelperCmd::Reregister => { + last_seq = seq; + idle = Instant::now(); + let Some(target) = install_candidates().into_iter().find(|p| p.is_file()) else { + 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() && { + let mut p = std::process::Command::new(crate::platform::tool("powershell")); + p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); + crate::platform::quiet(&mut p); + p.status().map(|s| s.success()).unwrap_or(false) + }; + let mut q = std::process::Command::new(crate::platform::tool("powershell")); + q.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &readback_command()]); + crate::platform::quiet(&mut q); + 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::Dev(d) => { last_seq = seq; idle = Instant::now(); @@ -244,6 +289,10 @@ mod tests { 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("12 reregister"), Some((12, HelperCmd::Reregister))); + 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")); // nothing else: no shell, no path, no string argument, no oversized number for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] { assert_eq!(parse_line(bad), None, "{bad:?}"); @@ -289,7 +338,11 @@ mod tests { let here = std::env::current_exe().unwrap(); assert!(here.is_file()); assert_eq!(task_exe(kit, &[here.clone()]), here); - assert_eq!(task_exe(own, &[here]), own.to_path_buf()); + // a measurement run's scratch copy (run 6, 6 October 2026) is not under jobs\ and still yields + let scratch = Path::new(r"C:\Users\Admin\AppData\Local\igneum-tune-20261006-170105\bin\igneum-app.exe"); + assert_eq!(task_exe(scratch, &[here.clone()]), here); + // the installed exe itself stands (matched by path, any case or separator) + assert_eq!(task_exe(own, &[PathBuf::from(r"c:/users/admin/appdata/local/programs/igneum miner/IGNEUM-APP.EXE"), here]), own.to_path_buf()); } #[test] diff --git a/docs/bench-log.md b/docs/bench-log.md index cf16b9ed0..52b65be75 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -2244,3 +2244,13 @@ Run b (`segments-pc2-pv1b`, 07:20Z to 07:51Z) claimed nothing in 88 passes: the | The rule as shipped (no switch): fresh refused while the previous segment is pending (known-failed), accepted after it is unproven | 22 passed | 166.2 s | | `--fresh-rule 0`: fresh accepted while the previous segment is pending, `freshAdmissible` true, still refused after a proven one, the second offer a duplicate ("segment already paid") | 23 passed | 139.9 s | + +## 6 October 2026, 16:01Z: Ember run 6 on PC 1 (ember-tune-pc1-6, 0.3.13 + kit-6 = 564bdea, elevated, one click) + +The helper registered inside the run but on the scratch copy (fixed: task_exe, the `reregister` verb; see the plan's +run 6 notes). RTX 5090: chosen 1854 MHz at 100% = 127.71 MH/s at 226.8 W, 0.563 MH/W, against 127.9 at 311.0 W +(0.411) untuned: 84 W saved for 0.15% of rate. Every cap step 60 to 100% read 311 to 313 W (the cap never binds). +The clock ladder: 2781 MHz 298.8 W 0.428; 2472 MHz 262.0 W 0.488; 2163 MHz 239.9 W 0.533; 1854 MHz 226.8 W 0.563 +(the floor, not the optimum: the next cut's ladder goes to 45%). RTX 4070: caps 100 to 60% all 106.0 W 28.71 MH/s +(0.271); 50% 99.1 W 28.70 (0.290); clock 2794 MHz at 50% 99.1 W (0.290); 2484 MHz 81.1 W 28.73 (0.354); further rows +and the 9070 XT ladder below once the run closes. diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 6d771c17c..2531341be 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -202,7 +202,13 @@ scripts carry them), `relay/test/ember.test.mjs` (climb records aggregate into a Owed: the measured comparison on PC 1 (one climb per card against run 5's ladder rows, no prompt through the Power Helper), the temperature-band trigger, NVAPI/nvidia-settings for cards whose driver refuses `-lmc`. -## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC) +## 7a. One administrator approval, ever + +Threat note, `reregister` (6 October 2026): the verb takes no path. The helper, already elevated, picks the installed +exe from the two install folders itself (`powertask::install_candidates`), so the most a writer of `cmd.txt` can do +is point the task back at the installed app. The exposure that remains is the one every per-user install with a +highest-run task has: the install folder is the user's own, so a process running as the user can replace the exe the +task runs. The installer's code signature and the OTA's hash check are the answer to that, not the task. (0.3.13; the project lead, 6 October 2026, 11:50 UTC) What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever". @@ -225,6 +231,35 @@ process, registry key or other binary is reachable through it. Tests: `powertask non-digit or extra argument, the arguments reach nvidia-smi as a list, the registration is per-user, highest, trigger-less and quote-safe, a stale command file runs nothing). +### Run 6 (6 October 2026, 16:01Z, PC 1 on 0.3.13 with kit-6 = 564bdea, elevated, the project lead's one click) + +The scheduled task registered inside the run ("helper registered=true"), but its action was the run's scratch copy +(`igneum-tune-20261006-170105\bin\igneum-app.exe`): `task_exe` preferred the installed exe only for a path under +`jobs\`. Fixed the same hour: the running exe stands only when it is itself an install candidate; and the helper +gained a `reregister` verb (no path argument, so a writer of cmd.txt can never choose what runs elevated: the helper +finds the install folder itself, re-registers, and logs the task's action read back). The scratch root stays until +that read-back shows the installed path. + +RTX 5090 (driver 617.14, class l128w0, no prior), full plan, every row ok, 65 C at most: + +| Step | Clock cap | Power | Draw | Rate | MH/W | +|---|---|---|---|---|---| +| 0 (before) | unlocked | 100% 575 W | 311.0 W | 127.90 | 0.411 | +| 1 to 4 | unlocked | 90, 80, 70, 60% | 313 W | 127.9 | 0.408 to 0.409 (the cap never binds) | +| 5 | 2781 MHz | 100% | 298.8 W | 127.89 | 0.428 | +| 6 | 2472 MHz | 100% | 262.0 W | 127.86 | 0.488 | +| 7 | 2163 MHz | 100% | 239.9 W | 127.82 | 0.533 | +| 8 (chosen) | 1854 MHz | 100% | 226.8 W | 127.71 | 0.563 | + +The per-user number: 84 W saved for 0.15% of rate, 37% more hashes per watt. The chosen clock is the ladder's floor +(60% of 3,090), not the optimum: `CLOCK_STEPS_PCT` now ends 60, 50, 45 and `CLOCK_FLOOR_PCT` is 45, with the 1% rate +tolerance as the guard; a chosen point on the floor carries `floor=1 note=floor, not optimum` in the chosen line and +the record. Ember 2's climb starts from the chosen point. + +RTX 4070 (same driver and class): caps 100 to 60% all 106 W at 28.71 MH/s (0.271); 50% (100 W) 99.1 W 28.70 (0.290); +clock 2794 at 50% 99.1 W (0.290); 2484 MHz 81.1 W 28.73 MH/s (0.354); the rest of the ladder and the 9070 XT rows in +the bench log entry for the run. + ## 8a. Next-cut notes (for the 0.3.12 shipper) | Commit | What | Where | diff --git a/relay/playbooks/ember-helper-repoint-pc1.ps1 b/relay/playbooks/ember-helper-repoint-pc1.ps1 new file mode 100644 index 000000000..0b20f1929 --- /dev/null +++ b/relay/playbooks/ember-helper-repoint-pc1.ps1 @@ -0,0 +1,69 @@ +# Ember: re-point the Igneum Power Helper at the installed exe and prove the no-prompt cap (6 October 2026) +# Unelevated. Run 6 registered the task on the run's scratch copy of the engine (igneum-tune-<stamp>\bin\igneum-app.exe). +# This script: (1) reads the task's action back; (2) puts the kit-7 engine (carries the `reregister` verb) in place of +# that scratch copy, which is the user's own file; (3) starts the task (no prompt: it is registered) and writes +# `<seq> reregister` to the installed app's sweep\cmd.txt; the elevated helper picks the install folder itself and +# re-registers; (4) reads the action back again and prints it; (5) the proof: asks the installed app to apply its caps +# through the task and prints the "power cap: through the Igneum Power Helper task (no prompt)" line from its log. +# Never quits, pauses or resumes the installed app (the 5 October rule). The scratch root is left in place. +$ErrorActionPreference = 'Continue' +function Say([string] $s) { Write-Output ('[' + (Get-Date -Format 'HH:mm:ss') + '] ' + $s) } +$appDir = Join-Path $env:LOCALAPPDATA 'igneum\app' +$sweep = Join-Path $appDir 'sweep' +$cmd = Join-Path $sweep 'cmd.txt' +$hlog = Join-Path $sweep 'helper.log' +$installed = Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner\igneum-app.exe' +$kit = Join-Path $appDir 'jobs\ember-kit-7\igneum-app-ember.exe' +function Action() { $t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue; if ($t) { return ($t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments) } else { return '' } } +$before = Action +Write-Output ('RESULT TUNE task_action_before=' + $(if ($before) { $before } else { 'none' })) +if (-not $before) { Write-Output 'RESULT TUNE error=task_not_registered'; exit 1 } +$exe = ($before -split ' --power-helper')[0].Trim() +if ($exe -like '*Programs\Igneum Miner\igneum-app.exe') { Say 'the task already runs the installed exe' } +else { + if (-not (Test-Path -LiteralPath $kit)) { Write-Output ('RESULT TUNE error=no_kit_7 at ' + $kit); exit 1 } + if (-not (Test-Path -LiteralPath $exe)) { Write-Output ('RESULT TUNE error=task_exe_missing ' + $exe); exit 1 } + # a helper still running from an earlier start would hold the exe: ask it to quit through its own file, then wait + Add-Content -LiteralPath $cmd -Value 'quit' -Encoding ascii + Start-Sleep -Seconds 6 + Set-Content -LiteralPath $cmd -Value '' -Encoding ascii + $sha0 = (Get-FileHash -LiteralPath $kit -Algorithm SHA256).Hash.ToLower() + Copy-Item -LiteralPath $kit -Destination $exe -Force + $sha1 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower() + Write-Output ('RESULT TUNE task_exe_replaced=' + ($sha0 -eq $sha1) + ' sha256=' + $sha1 + ' path=' + $exe) + if ($sha0 -ne $sha1) { Write-Output 'RESULT TUNE error=copy_mismatch'; exit 1 } + $seq = 1 + if (Test-Path -LiteralPath $cmd) { foreach ($l in (Get-Content -LiteralPath $cmd)) { if ($l -match '^(\d+) ') { $n = [int]$Matches[1]; if ($n -ge $seq) { $seq = $n + 1 } } } } + $mark = 0; if (Test-Path -LiteralPath $hlog) { $mark = (Get-Content -LiteralPath $hlog).Count } + Start-ScheduledTask -TaskName 'Igneum Power Helper' + Start-Sleep -Seconds 3 + Add-Content -LiteralPath $cmd -Value ($seq.ToString() + ' reregister') -Encoding ascii + $got = '' + for ($i = 0; $i -lt 30; $i++) { + Start-Sleep -Seconds 2 + if (Test-Path -LiteralPath $hlog) { $lines = @(Get-Content -LiteralPath $hlog); if ($lines.Count -gt $mark) { $new = $lines[$mark..($lines.Count - 1)] | Where-Object { $_ -match 'reregister' }; if ($new) { $got = [string]$new[-1]; break } } } + } + Write-Output ('RESULT TUNE helper_log=' + $(if ($got) { $got } else { 'no reregister line within 60 s' })) +} +$after = Action +Write-Output ('RESULT TUNE task_action_after=' + $(if ($after) { $after } else { 'none' })) +if ($after -notlike '*Programs\Igneum Miner\igneum-app.exe --power-helper*') { Write-Output 'RESULT TUNE error=task_not_on_installed_exe'; exit 1 } +# the proof: the installed app applies its caps through the task; its log says so without a prompt +$u = Join-Path $appDir 'app.url' +if (Test-Path -LiteralPath $u) { + $base = (Get-Content -LiteralPath $u -Raw).Trim() + $logs = Get-ChildItem -Path (Join-Path $appDir 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + $mark2 = 0; if ($logs) { $mark2 = (Get-Content -LiteralPath $logs.FullName).Count } + try { Invoke-WebRequest -Uri ($base + 'api/power/control') -Method POST -Body '{"on":true}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { Say ('power/control: ' + $_.Exception.Message) } + $line = '' + for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Seconds 3 + $logs = Get-ChildItem -Path (Join-Path $appDir 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($logs) { $all = @(Get-Content -LiteralPath $logs.FullName); if ($all.Count -gt $mark2) { $hit = $all[$mark2..($all.Count - 1)] | Where-Object { $_ -match 'power cap' }; if ($hit) { $line = ($hit | ForEach-Object { [string]$_ }) -join ' | '; if ($line -match 'Power Helper task') { break } } } } + } + Write-Output ('RESULT TUNE cap_proof=' + $(if ($line) { $line } else { 'no power cap line within 60 s' })) +} else { Write-Output 'RESULT TUNE cap_proof=no app.url (installed app not running)' } +$smi = Join-Path $env:ProgramFiles 'NVIDIA Corporation\NVSMI\nvidia-smi.exe' +if (-not (Test-Path $smi)) { $smi = Join-Path $env:SystemRoot 'System32\nvidia-smi.exe' } +if (Test-Path $smi) { Write-Output ('RESULT TUNE nvidia_now ' + ((& $smi --query-gpu=index,name,power.draw,power.limit,clocks.gr --format=csv,noheader 2>&1 | Out-String) -replace "`r?`n", ' | ')) } +exit 0 diff --git a/site/build.mjs b/site/build.mjs index 7760c3fb7..736c1dc43 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -367,6 +367,8 @@ for (const [file, active] of PAGES) { '<h2 id="priors">Fleet tuning priors</h2>', '<p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet\'s median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs ' + pj.min_samples + ' reports before its prior is used.</p>', ptable, + // the team's own measured rows (site/miner-priors.json "team"): the per-user sentence per card, with its job + ...((pj.team || []).length ? ['<h3 id="team">Measured by the team</h3>', '<ul>' + pj.team.map(t => `<li><b>${esc(t.card)}</b> (driver ${esc(t.driver_major)}, class ${esc(t.class)}, ${esc(t.date)}, job <code>${esc(t.job)}</code>): ${esc(t.point)}. ${esc(t.sentence)}${t.note ? ' ' + esc(t.note) : ''}</li>`).join('') + '</ul>'] : []), `<p>Rows: ${prows.length}${pj.generated ? ', generated ' + pj.generated : ''}. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p>`, ].join('\n')); const toc = [{ lvl: 2, t: 'The table', id: 'table' }, { lvl: 2, t: 'How a row gets here', id: 'how' }, { lvl: 2, t: 'Fleet tuning priors', id: 'priors' }]; diff --git a/site/miner-priors.json b/site/miner-priors.json index cdb7b4fa5..c34d8e4a7 100644 --- a/site/miner-priors.json +++ b/site/miner-priors.json @@ -2,5 +2,24 @@ "_about": "Rows of the fleet priors table at /miners (site/build.mjs), written by tools/tuning.mjs --priors --site from the TUNE records every Igneum Miner uploads. One row per card model, driver major and program class: the median tuned point, MH per watt, the spread and the sample count. No machine names, no addresses.", "generated": null, "min_samples": 5, - "rows": [] + "rows": [], + "_team": "Rows measured on the team's own hardware, from the job log named in each row; they stand until the fleet's prior for the model reaches five samples.", + "team": [ + { + "card": "NVIDIA GeForce RTX 5090", + "driver_major": "617", + "class": "l128w0", + "date": "2026-10-06", + "job": "ember-tune-pc1-6", + "point": "1854 MHz core cap, power limit 100% (575 W)", + "mh_s": 127.71, + "watts": 226.8, + "mh_per_w": 0.563, + "untuned_mh_s": 127.9, + "untuned_watts": 311.0, + "untuned_mh_per_w": 0.411, + "sentence": "84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt.", + "note": "The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here." + } + ] } diff --git a/site/miners.html b/site/miners.html index 5b1e16428..eab8aecec 100644 --- a/site/miners.html +++ b/site/miners.html @@ -189,6 +189,8 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform: <h2 id="priors">Fleet tuning priors</h2> <p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet's median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs 5 reports before its prior is used.</p> <p>No tune reports yet. The first rows appear once five machines with the same card model have reported.</p> +<h3 id="team">Measured by the team</h3> +<ul><li><b>NVIDIA GeForce RTX 5090</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1854 MHz core cap, power limit 100% (575 W). 84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here.</li></ul> <p>Rows: 0. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p></article> </div> <p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p> From c896a44939a833c45ac52768bf19c87df67b22c9 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:43:26 +0000 Subject: [PATCH 14/18] Ember: a power step on a card with no limit readback (AMD through ADLX reports an offset, never watts) is applied on the acknowledgement; run 6 aborted the 9070 XT's ladder at its first step on "card reports 0 W"; test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index c1cb4db89..0dfbd87a8 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -774,8 +774,13 @@ impl Run { let power_changed = (step.watts - before_w).abs() >= 1.5; if step.point.clock_mhz > 0 || step.point.mem_mhz > 0 || !power_changed { rb.acked && power_ok + } else if rb.limit_w <= 0.0 { + // a card with no limit readback at all (AMD through ADLX reports an offset, never watts; run 6 on + // 6 October 2026 aborted the 9070 XT's ladder at its first step on "card reports 0 W"): the + // acknowledgement is the proof + rb.acked } else { - rb.limit_w > 0.0 && power_ok + power_ok } } @@ -976,6 +981,17 @@ mod tests { assert!(Plan::full(&Limits::default(), Point::default(), 1.0).is_empty()); } + #[test] + fn a_power_step_on_a_card_without_a_limit_readback_is_applied_on_the_acknowledgement() { + // AMD through ADLX: the limit reads back as an offset, never watts (run 6, 6 October 2026) + let step = Step { point: Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, watts: 90.0, kind: Kind::Power }; + assert!(Run::applied(&step, Readback { limit_w: 0.0, acked: true }, 100.0)); + assert!(!Run::applied(&step, Readback { limit_w: 0.0, acked: false }, 100.0)); + // NVIDIA: the watts must read back + assert!(!Run::applied(&step, Readback { limit_w: 100.0, acked: true }, 100.0)); + assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0)); + } + #[test] fn limits_never_exceed_the_vendor_or_undercut_the_floor() { let l = l5090(); From 3605b9a439d303698f83073c672128897691d8f8 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:44:36 +0000 Subject: [PATCH 15/18] Ember run 6 closed: the 4070 table and the 9070 XT abort in the plan and the bench log, the 4070 row on the fleet page, the playbook prefers ember-kit-7 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/bench-log.md | 8 ++++++++ docs/plans/ember-tune.md | 28 +++++++++++++++++++++++++--- relay/playbooks/ember-tune-pc1.ps1 | 4 +++- site/miner-priors.json | 16 ++++++++++++++++ site/miners.html | 2 +- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/bench-log.md b/docs/bench-log.md index 52b65be75..d3670a441 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -2254,3 +2254,11 @@ The clock ladder: 2781 MHz 298.8 W 0.428; 2472 MHz 262.0 W 0.488; 2163 MHz 239.9 (the floor, not the optimum: the next cut's ladder goes to 45%). RTX 4070: caps 100 to 60% all 106.0 W 28.71 MH/s (0.271); 50% 99.1 W 28.70 (0.290); clock 2794 MHz at 50% 99.1 W (0.290); 2484 MHz 81.1 W 28.73 (0.354); further rows and the 9070 XT ladder below once the run closes. + +Run 6 closed 16:39:37Z, exit 0, 2317 s, 19 rows. RTX 4070 chosen 1863 MHz at 50% = 28.78 MH/s at 75.6 W (0.381) against +28.72 at 106.0 W (0.271): 30 W saved for no rate lost; its clock ladder at 50%: 2794 MHz 99.1 W 0.290; 2484 MHz 81.1 W +0.354; 2173 MHz 77.7 W 0.370; 1863 MHz 75.6 W 0.381 (the floor). RX 9070 XT: aborted at step 1, "card reports 0 W, +acknowledged true" = the applied rule demanded watts from a card that reports offsets (fixed bd7fcf4); the draw itself +was read on every tick (amd_watts_source=engine_telemetry, 363 samples). The helper registered on the scratch copy +(fixed 200362a: task_exe, the reregister verb). PC 1 mined through the installed app again by 16:44Z: 170.6 MH/s over +the three cards, 0 faults. diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 2531341be..037871b70 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -256,9 +256,31 @@ The per-user number: 84 W saved for 0.15% of rate, 37% more hashes per watt. The tolerance as the guard; a chosen point on the floor carries `floor=1 note=floor, not optimum` in the chosen line and the record. Ember 2's climb starts from the chosen point. -RTX 4070 (same driver and class): caps 100 to 60% all 106 W at 28.71 MH/s (0.271); 50% (100 W) 99.1 W 28.70 (0.290); -clock 2794 at 50% 99.1 W (0.290); 2484 MHz 81.1 W 28.73 MH/s (0.354); the rest of the ladder and the 9070 XT rows in -the bench log entry for the run. +RTX 4070 (same driver and class), 10 rows, every one ok, 55 C at most: + +| Step | Clock cap | Power | Draw | Rate | MH/W | +|---|---|---|---|---|---| +| 0 (before) | unlocked | 100% 200 W | 106.0 W | 28.72 | 0.271 | +| 1 to 4 | unlocked | 90, 80, 70, 60% | 106.0 W | 28.71 | 0.271 (the cap never binds) | +| 5 | unlocked | 50% 100 W | 99.1 W | 28.70 | 0.290 | +| 6 | 2794 MHz | 50% | 99.1 W | 28.70 | 0.290 | +| 7 | 2484 MHz | 50% | 81.1 W | 28.73 | 0.354 | +| 8 | 2173 MHz | 50% | 77.7 W | 28.74 | 0.370 | +| 9 (chosen) | 1863 MHz | 50% | 75.6 W | 28.78 | 0.381 | + +Per user: 30 W saved for no rate lost (+0.2%), 41% more hashes per watt; the floor again. + +RX 9070 XT: no row. "TUNE aborted: the setting (0 MHz, 100% = 100 W) did not take within 30 s (card reports 0 W, +acknowledged true)", twice. The cause was `Run::applied`: a power step demanded the limit read back in watts, and +ADLX reports an offset, never watts, so no AMD power step could ever confirm. Fixed (bd7fcf4): a card with no limit +readback is applied on the acknowledgement. The watts themselves were read on every tick (`amd_watts_source= +engine_telemetry`, 363 nonzero engine samples, 456 nonzero app-state samples). The ladder reruns with kit-7. The +"100 W" in that line is the percent scale printed as watts (owed: a unit word for AMD). + +After the run (nvidia-smi, the engine gone, the installed miners not yet back): 5090 1845 MHz locked at 575 W; +4070 at the 100 W limit. The installed app mined again within a minute: 5090 122.9 MH/s, 4070 28.8, 9070 XT 18.9, +170.6 MH/s, 0 faults (console, 16:44Z). The 9070 XT's ADLX state read "gmax 0 plimit 0 factory 0" (before: factory +1): zero offsets, the factory flag cleared by the engine's set of 0; the 3.0 coordinator's reset job puts it back. ## 8a. Next-cut notes (for the 0.3.12 shipper) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index d11183365..beab8e1ea 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -50,8 +50,10 @@ $ember = $null # a kit is used when the installed app predates Ember Tune (0.3.13), or when a run carries fixes the installed app # lacks (ember-kit-6: the cmd quote fix and the self-registering elevated engine of 6 October 2026); a kit engine # registers the Power Helper on the INSTALLED exe's path (powertask::task_exe), never its own +$k7 = Join-Path $appDir 'jobs\ember-kit-7\igneum-app-ember.exe' $k6 = Join-Path $appDir 'jobs\ember-kit-6\igneum-app-ember.exe' -if (Test-Path $k6) { $ember = $k6 } +if (Test-Path $k7) { $ember = $k7 } +elseif (Test-Path $k6) { $ember = $k6 } elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force diff --git a/site/miner-priors.json b/site/miner-priors.json index c34d8e4a7..101972625 100644 --- a/site/miner-priors.json +++ b/site/miner-priors.json @@ -20,6 +20,22 @@ "untuned_mh_per_w": 0.411, "sentence": "84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt.", "note": "The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here." + }, + { + "card": "NVIDIA GeForce RTX 4070", + "driver_major": "617", + "class": "l128w0", + "date": "2026-10-06", + "job": "ember-tune-pc1-6", + "point": "1863 MHz core cap, power limit 50% (100 W)", + "mh_s": 28.78, + "watts": 75.6, + "mh_per_w": 0.381, + "untuned_mh_s": 28.72, + "untuned_watts": 106.0, + "untuned_mh_per_w": 0.271, + "sentence": "30 W saved for no rate lost: 28.78 MH/s at 75.6 W (0.381 MH/W) against 28.72 MH/s at 106 W untuned (0.271 MH/W), 41% more hashes per watt.", + "note": "The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45%." } ] } diff --git a/site/miners.html b/site/miners.html index eab8aecec..ccdbd7c9e 100644 --- a/site/miners.html +++ b/site/miners.html @@ -190,7 +190,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform: <p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet's median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs 5 reports before its prior is used.</p> <p>No tune reports yet. The first rows appear once five machines with the same card model have reported.</p> <h3 id="team">Measured by the team</h3> -<ul><li><b>NVIDIA GeForce RTX 5090</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1854 MHz core cap, power limit 100% (575 W). 84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here.</li></ul> +<ul><li><b>NVIDIA GeForce RTX 5090</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1854 MHz core cap, power limit 100% (575 W). 84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here.</li><li><b>NVIDIA GeForce RTX 4070</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1863 MHz core cap, power limit 50% (100 W). 30 W saved for no rate lost: 28.78 MH/s at 75.6 W (0.381 MH/W) against 28.72 MH/s at 106 W untuned (0.271 MH/W), 41% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45%.</li></ul> <p>Rows: 0. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p></article> </div> <p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p> From eaaa7362a22e418b90e1fee3097d6225fbf66bc4 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:45:39 +0000 Subject: [PATCH 16/18] Ember plan: the ADLX offset-not-watts rule in the AMD knob row; the 0.3.14 next-cut table (c896a44, 0aea6e8, the Ember 2 UI and playbook commits) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/plans/ember-tune.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 037871b70..5de75c53c 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -29,7 +29,7 @@ prompt) for both knobs; off, it measures only. | Vendor | Power limit | Core clock cap | Memory clock | How | Rights | |---|---|---|---|---|---| | NVIDIA | `nvidia-smi -pl <W>`, percent of the default, inside `power.min_limit` and `power.max_limit` | `nvidia-smi -lgc 0,<MHz>`, percent of `clocks.max.gr`; `-rgc` = unlocked | never touched (`-lmc` is not used); read back as `clocks.mem` | directly when the engine is elevated, else the one-prompt helper (`<seq> pl <W>`, `<seq> lgc <MHz>`, `<seq> rgc` in `sweep/cmd.txt`) | administrator, so only with Power control on | -| AMD | `igneum-gpu-telemetry --card N --set-plimit <offset>` (0 = default, -20 = 80%), inside the `tune` line's `plimit_range` (PC 1's 9070 XT: -30 to 10, so 70% is the floor) | `--set-gmax` only when the `tune` line's `gmax_range` is absolute MHz (floor 0 or above); on RDNA 4 the range is an offset from stock (-500 to 1000 on PC 1) and the clock knob stays closed until the stock clock is known; `--reset` for the default point | not settable through ADLX on RDNA 4; read back as `mclk_mhz`, and a step whose mean memory clock falls under 95% of the baseline's is marked and cannot win | the helper, one process per request, exit 0 and a `tune ... ok` line | none on Windows (ADLX manual tuning); root on Linux, so measure only there | +| AMD | `igneum-gpu-telemetry --card N --set-plimit <offset>` (0 = default, -20 = 80%), inside the `tune` line's `plimit_range` (PC 1's 9070 XT: -30 to 10, so 70% is the floor). RULE (run 6, 6 October 2026): ADLX reports the limit as an OFFSET, never watts, so an AMD step is applied on the helper's acknowledgement (`Run::applied`: a card with no limit readback confirms on `acked`), and the draw comes from the telemetry stream (`sample_telemetry`), never from a limit readback; `limit_w` is 0 for every AMD card by design | `--set-gmax` only when the `tune` line's `gmax_range` is absolute MHz (floor 0 or above); on RDNA 4 the range is an offset from stock (-500 to 1000 on PC 1) and the clock knob stays closed until the stock clock is known; `--reset` for the default point | not settable through ADLX on RDNA 4; read back as `mclk_mhz`, and a step whose mean memory clock falls under 95% of the baseline's is marked and cannot win | the helper, one process per request, exit 0 and a `tune ... ok` line | none on Windows (ADLX manual tuning); root on Linux, so measure only there | | Apple | none | none | none | measure only | none | Vendor limits are never exceeded and the floor is never undercut: the plan clamps every point (`Limits::clamp_clock`, @@ -284,6 +284,16 @@ After the run (nvidia-smi, the engine gone, the installed miners not yet back): ## 8a. Next-cut notes (for the 0.3.12 shipper) +For the 0.3.14 cut (6 October 2026, after run 6), on ember-tune: + +| Commit | What | Where | +|---|---|---| +| bd7fcf4 | AMD: a power step with no limit readback is applied on the acknowledgement (run 6 aborted the 9070 XT's ladder at step 1 on "card reports 0 W") | ember.rs `Run::applied` + test | +| 200362a | `task_exe` prefers the installed exe for every path that is not itself an install candidate; the helper's `reregister` verb; the clock ladder and floor to 45%; a chosen point on the floor says so; the fleet page's team rows | powertask.rs, ember.rs, engine.rs, site | +| 5165de7, 9fba504, 5b0968f | Ember 2's Settings (goal, price, climb), £/day and the curve on the card; the playbook's AMD watts source and kit-7 preference; run 6 notes | ui, relay/playbooks, docs | + +Earlier (shipped in 0.3.12 and 0.3.13 unless marked): + | Commit | What | Where | |---|---|---| | b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) | From 942acfbc30935a5cdd12c87ab7972ce991f57de3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:51:16 +0000 Subject: [PATCH 17/18] Ember re-point playbook: the helper.log line is read as an array element, not the last character of a string (the run printed helper_log=r) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-helper-repoint-pc1.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/playbooks/ember-helper-repoint-pc1.ps1 b/relay/playbooks/ember-helper-repoint-pc1.ps1 index 0b20f1929..f2b1f56b1 100644 --- a/relay/playbooks/ember-helper-repoint-pc1.ps1 +++ b/relay/playbooks/ember-helper-repoint-pc1.ps1 @@ -41,7 +41,7 @@ else { $got = '' for ($i = 0; $i -lt 30; $i++) { Start-Sleep -Seconds 2 - if (Test-Path -LiteralPath $hlog) { $lines = @(Get-Content -LiteralPath $hlog); if ($lines.Count -gt $mark) { $new = $lines[$mark..($lines.Count - 1)] | Where-Object { $_ -match 'reregister' }; if ($new) { $got = [string]$new[-1]; break } } } + if (Test-Path -LiteralPath $hlog) { $lines = @(Get-Content -LiteralPath $hlog); if ($lines.Count -gt $mark) { $new = @($lines[$mark..($lines.Count - 1)] | Where-Object { $_ -match 'reregister' }); if ($new.Count -gt 0) { $got = [string]$new[$new.Count - 1]; break } } } } Write-Output ('RESULT TUNE helper_log=' + $(if ($got) { $got } else { 'no reregister line within 60 s' })) } From 4a22a2be1aaf4b4032e8fa2e60faca3b2cffbc1e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:52:12 +0000 Subject: [PATCH 18/18] Ember proof playbook (reads only): the installed app's power cap lines, the helper log, the task's action, nvidia-smi, the consent.exe count, the cards' state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-proof-collect-pc1.ps1 | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 relay/playbooks/ember-proof-collect-pc1.ps1 diff --git a/relay/playbooks/ember-proof-collect-pc1.ps1 b/relay/playbooks/ember-proof-collect-pc1.ps1 new file mode 100644 index 000000000..3db3b7e83 --- /dev/null +++ b/relay/playbooks/ember-proof-collect-pc1.ps1 @@ -0,0 +1,30 @@ +# Ember: the no-prompt proof read from PC 1 (6 October 2026). Reads only: the installed app's power cap lines, the +# helper's log, the task's action and state, nvidia-smi's limits, and the count of consent.exe (UAC) starts in the +# last two hours from the Security log. Never writes, quits, pauses or resumes anything. +$ErrorActionPreference = 'Continue' +$a = Join-Path $env:LOCALAPPDATA 'igneum\app' +$l = Get-ChildItem (Join-Path $a 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +if ($l) { + Write-Output ('RESULT PROOF log=' + $l.FullName) + Get-Content -LiteralPath $l.FullName | Where-Object { $_ -match 'power cap|Power Helper|elevat|UAC|runas|tune' } | Select-Object -Last 24 | ForEach-Object { Write-Output ('RESULT PROOF applog ' + $_) } +} +$h = Join-Path $a 'sweep\helper.log' +if (Test-Path -LiteralPath $h) { Get-Content -LiteralPath $h | Select-Object -Last 15 | ForEach-Object { Write-Output ('RESULT PROOF helper ' + $_) } } else { Write-Output 'RESULT PROOF helper none' } +$t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue +if ($t) { Write-Output ('RESULT PROOF task action=' + $t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments + ' state=' + $t.State + ' runlevel=' + $t.Principal.RunLevel + ' user=' + $t.Principal.UserId) } else { Write-Output 'RESULT PROOF task none' } +$smi = Join-Path $env:ProgramFiles 'NVIDIA Corporation\NVSMI\nvidia-smi.exe' +if (-not (Test-Path $smi)) { $smi = Join-Path $env:SystemRoot 'System32\nvidia-smi.exe' } +if (Test-Path $smi) { Write-Output ('RESULT PROOF nvidia ' + ((& $smi --query-gpu=index,name,power.draw,power.limit,clocks.gr,clocks.mem --format=csv,noheader 2>&1 | Out-String) -replace "`r?`n", ' | ')) } +try { + $ev = @(Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4688; StartTime = (Get-Date).AddHours(-2) } -ErrorAction Stop | Where-Object { $_.Message -match 'consent\.exe' }) + Write-Output ('RESULT PROOF consent_exe_starts_last_2h=' + $ev.Count + ' ' + (($ev | ForEach-Object { $_.TimeCreated.ToUniversalTime().ToString('HH:mm:ss') }) -join ',')) +} catch { Write-Output ('RESULT PROOF consent_exe_starts_last_2h=unreadable (' + $_.Exception.Message + ')') } +$u = Join-Path $a 'app.url' +if (Test-Path -LiteralPath $u) { + try { + $js = (Invoke-WebRequest -Uri ((Get-Content -LiteralPath $u -Raw).Trim() + 'api/state') -UseBasicParsing -TimeoutSec 5).Content | ConvertFrom-Json + foreach ($c in @($js.mining.cards)) { Write-Output ('RESULT PROOF card ' + $c.key + ' ' + $c.name + ' mhs=' + $c.mhs + ' power_w=' + $c.power_w + ' power_pct=' + $c.power_pct + ' note=' + $c.power_note + ' tune=' + $c.tune_line) } + Write-Output ('RESULT PROOF settings power_control=' + $js.settings.power_control + ' note=' + $js.settings.power_note) + } catch { Write-Output ('RESULT PROOF state unreadable: ' + $_.Exception.Message) } +} +exit 0