Merge release-0.3.14: Igneum Miner 0.3.14 (exec-sync deep-reorg reload, miner-ui-3, ember-tune), publish 1 on the thirteen-field object

The node pin moves to 4c6b129d (release-0.3.14-node), which is what the live payload inputs carry, so windows-ci's payload-inputs step is green again (red since 630b537 on master's 0.3.13 pin against the 0.3.14 inputs).
Conflicts: infra/fast-time/override-60x.json keeps master's side (the fresh-rule field was already there at u64::MAX; the release line would be a duplicate key); tools/ci/playbook-quit-check.sh keeps master's rule 2 and pre-rule list with the release side's Ember allow entry and the 0.3.15 expiry check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-06 19:18:17 +01:00
commit c16af2cc2c
101 changed files with 3479 additions and 803 deletions

View file

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

View file

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

View file

@ -6,8 +6,8 @@
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION 0,3,13,0
PRODUCTVERSION 0,3,13,0
FILEVERSION 0,3,14,0
PRODUCTVERSION 0,3,14,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
@ -20,12 +20,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Miner engine"
VALUE "FileVersion", "0.3.13"
VALUE "FileVersion", "0.3.14"
VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.13"
VALUE "ProductVersion", "0.3.14"
END
END
BLOCK "VarFileInfo"

View file

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

View file

@ -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).
@ -49,6 +51,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 +72,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 +92,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 +111,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 +123,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 +178,7 @@ impl PlanKind {
PlanKind::Full => "full",
PlanKind::Confirm => "confirm",
PlanKind::Baseline => "baseline",
PlanKind::Climb => "climb",
}
}
}
@ -134,9 +194,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 +281,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 +327,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 +346,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 +444,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 +460,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 +594,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;
}
@ -463,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,
@ -482,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 { "" },
})
}
@ -591,9 +733,28 @@ 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) };
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();
@ -611,10 +772,15 @@ 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 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
}
}
@ -719,6 +885,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)
}
}
}
}
@ -733,6 +906,21 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
}
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Measured: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
}
/// The row's line for a plan's result.
pub fn result_line(kind: PlanKind, mhs: f64, watts: f64, eff: f64) -> String {
if kind == PlanKind::Baseline {
measured_line(mhs, watts, eff)
} else {
tuned_line(mhs, watts, eff)
}
}
/// Why a card cannot be tuned beyond measuring, or None when both knobs are available.
pub fn control_reason(vendor: &str, limits: &Limits, device: &str, power_control: bool, amd_helper: bool) -> Option<String> {
match vendor {
@ -754,7 +942,7 @@ mod tests {
/// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is
/// read at the first tune (3,090 MHz is the shape used here, not a measurement).
fn l5090() -> Limits {
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0 }
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 {
@ -763,10 +951,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);
assert_eq!(plan.len(), 5 + 4, "five power steps (60% and 50% clamp to 400 W; one kept) and four clock steps");
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
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 }, 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();
@ -781,35 +969,59 @@ 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);
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();
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
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());
}
#[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 a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
assert_eq!(result_line(PlanKind::Full, 127.71, 226.8, 0.5632), "Tuned: 127.7 MH/s at 227 W (0.563 MH/W)");
assert_eq!(result_line(PlanKind::Confirm, 127.71, 226.8, 0.5632), "Tuned: 127.7 MH/s at 227 W (0.563 MH/W)");
}
#[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);
@ -817,7 +1029,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));
@ -844,7 +1056,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
@ -862,15 +1074,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;
@ -919,13 +1131,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));
@ -934,20 +1146,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;
@ -979,8 +1191,9 @@ 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 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 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, 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");
@ -999,7 +1212,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());
@ -1016,6 +1229,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();

View file

@ -73,6 +73,10 @@ 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),
/// 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),
@ -119,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, 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();
@ -1142,6 +1146,29 @@ impl Engine {
self.shared.log("sweep helper: exited");
}
}
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);
@ -1790,7 +1817,7 @@ impl Engine {
// once, ever (src/powertask.rs): a registered task sets the caps with no prompt; the readback judges it
if cfg!(windows) && self.power_task_registered() {
let pairs: Vec<(String, u64)> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0 && !c.power_applied).map(|c| (c.device.clone(), requested_watts(c).round() as u64)).collect();
let dir = self.sweep_dir();
let dir = crate::powertask::helper_dir();
let shared = self.shared.clone();
self.shared.log("power cap: through the Igneum Power Helper task (no prompt)");
std::thread::spawn(move || {
@ -1818,7 +1845,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<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(" & "),
}
@ -1908,8 +1936,9 @@ impl Engine {
}
if cfg!(windows) && self.power_task_registered() {
// the kill switch: the task unregisters itself (elevated) and exits; nothing is left behind
let dir = self.sweep_dir();
let _ = std::fs::write(dir.join("cmd.txt"), "remove\n");
let hdir = crate::powertask::helper_dir();
let _ = std::fs::create_dir_all(&hdir);
let _ = std::fs::write(hdir.join("cmd.txt"), "remove\n");
match crate::powertask::start() {
Ok(()) => self.shared.log("power control off: the Igneum Power Helper task removes itself"),
Err(e) => self.shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler")),
@ -2114,6 +2143,16 @@ impl Engine {
self.shared.runtime.app_dir.join("sweep")
}
/// Where this engine's helper reads its commands: the task's own folder when the task is the helper, else this
/// engine's sweep folder (the prompted helper script was handed that path on its command line).
fn helper_cmd_dir(&self) -> PathBuf {
if self.sweep_helper_is_task {
crate::powertask::helper_dir()
} else {
self.sweep_dir()
}
}
/// The manifest's tuning object (<app data>/tuning.json, written by the updater from the signed manifest), or
/// None: the Ember settings (kill switch, thresholds) and the fleet priors live under it.
fn tuning_object(&self) -> Option<Value> {
@ -2148,6 +2187,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;
@ -2235,16 +2275,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", &current.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" => {
@ -2263,7 +2306,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() },
})));
@ -2295,13 +2338,15 @@ 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 {
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),
@ -2310,6 +2355,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> = 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() {
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();
@ -2327,16 +2392,24 @@ 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);
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 {
@ -2380,6 +2453,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);
@ -2388,7 +2462,8 @@ impl Engine {
/// The elevated helper (src/sweep.rs helper_script_*): one administrator prompt; it polls <app>/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();
@ -2406,8 +2481,11 @@ impl Engine {
format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore)
};
if cfg!(windows) && self.power_task_registered() {
// once, ever: the registered task is the helper; it reads the same command file, no prompt
let _ = std::fs::write(dir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
// once, ever: the registered task is the helper; it reads ITS command file (powertask::helper_dir, never
// under a scratch IGNEUM_APP_DATA), no prompt
let hdir = crate::powertask::helper_dir();
let _ = std::fs::create_dir_all(&hdir);
let _ = std::fs::write(hdir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
crate::powertask::start()?;
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
self.sweep_helper = true;
@ -2427,7 +2505,7 @@ impl Engine {
fn sweep_helper_quit(&mut self) {
if self.sweep_helper {
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n");
let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), "quit\n");
if self.sweep_helper_is_task {
// the task exits on quit and reports nothing back; the next tune starts it again
self.sweep_helper = false;
@ -2446,6 +2524,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() {
@ -2469,6 +2548,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) }));
});
}
@ -2478,8 +2566,8 @@ 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 _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd);
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), cmd);
// the helper polls twice a second and nvidia-smi answers within a second or two
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(4));
@ -2520,7 +2608,50 @@ 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
/// 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.
@ -2538,7 +2669,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())
@ -2587,9 +2718,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();
}
}
}
@ -2612,9 +2754,17 @@ 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_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_line = crate::ember::result_line(kind, 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() };
@ -2623,6 +2773,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;
@ -2637,6 +2788,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();
@ -2647,18 +2799,21 @@ 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 { "" }));
// 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);
let line = crate::ember::result_line(kind, row.mhs, row.watts, row.eff);
match kind {
crate::ember::PlanKind::Baseline => self.shared.event("info", &format!("{name}: measured as it runs: {line}{}", if control { String::new() } else { " (measure only; no control on this card)".into() })),
crate::ember::PlanKind::Baseline => self.shared.event("info", &format!("{name}: {line}{}", if control { String::new() } else { " (measure only; no control on this card)".into() })),
_ if pinned => self.shared.event("ok", &format!("{name}: best point {} ({line}); your setting stays pinned", point_words(&row.point))),
_ => 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 });
}
@ -2686,7 +2841,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,
@ -2697,6 +2852,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;
@ -2870,6 +3031,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() {
@ -2886,7 +3054,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);
}
@ -3970,6 +4140,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:

View file

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

View file

@ -58,7 +58,7 @@ fn main() {
if args.iter().any(|a| a == "--power-helper") {
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
let dir = powertask::sweep_dir(&platform::data_root().join("app"));
let dir = powertask::helper_dir();
std::process::exit(powertask::run_helper(&dir));
}
if args.iter().any(|a| a == "--launch") {

View file

@ -103,6 +103,14 @@ pub fn data_root() -> PathBuf {
if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") {
return PathBuf::from(p);
}
fixed_data_root()
}
/// The platform's own root, with IGNEUM_APP_DATA ignored: where the Power Helper task (started by Windows with no
/// environment of ours) reads its command file, so a measurement engine under a scratch root (IGNEUM_APP_DATA set)
/// writes its task commands here and not under its own root. 6 October 2026: found while planning the unelevated
/// climb through the task; the elevated runs never crossed it (they set the limits directly).
pub fn fixed_data_root() -> PathBuf {
#[cfg(target_os = "macos")]
{
home().join("Library").join("Application Support").join("Igneum")
@ -430,11 +438,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 +529,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]

View file

@ -43,8 +43,14 @@ 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,
/// 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
@ -65,7 +71,10 @@ 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()))),
[_, "reregister"] => Some((seq, HelperCmd::Reregister)),
_ => None,
}
}
@ -76,10 +85,47 @@ 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,
}
}
/// 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 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 {
// 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.
@ -174,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();
@ -206,6 +272,12 @@ pub fn sweep_dir(app_dir: &Path) -> PathBuf {
app_dir.join("sweep")
}
/// The one folder the task's helper reads: `<fixed data root>/app/sweep` (never under IGNEUM_APP_DATA). Every
/// engine that talks to the task writes its command file here; the `--power-helper` entry reads here.
pub fn helper_dir() -> PathBuf {
sweep_dir(&crate::platform::fixed_data_root().join("app"))
}
#[cfg(test)]
mod tests {
use super::*;
@ -215,10 +287,21 @@ 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)));
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"));
// the helper's folder never follows IGNEUM_APP_DATA (the task has no environment of ours)
assert!(helper_dir().ends_with(Path::new("app").join("sweep")));
assert!(helper_dir().starts_with(crate::platform::fixed_data_root()));
// 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:?}");
@ -253,6 +336,24 @@ 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);
// 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]
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

View file

@ -132,6 +132,25 @@ struct Tools {
cuda: bool,
}
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
/// state: unprovable on every node).
fn exec_boundary(shared: &Shared) -> u64 {
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
Ok(v) => v,
Err(_) => return 0,
};
if let Some(n) = st["restartNumber"].as_u64() {
return n;
}
if let Some(n) = st["restartNumber"].as_str().and_then(|x| u64::from_str_radix(x.trim_start_matches("0x"), 16).ok()) {
return n;
}
let text = st["startedFrom"].as_str().unwrap_or("");
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
}
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
@ -602,6 +621,9 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
continue;
}
}
// shards below the re-derivation boundary are never attempted (no bodies, no state on any node)
let boundary = exec_boundary(&shared);
let work: Vec<Work> = work.into_iter().filter(|w| w.number >= boundary).collect();
let Some(w) = choose(&work, &attempted) else {
set(&shared, |p| {
p.status = if submitted.is_empty() { "idle".into() } else { "submitted".into() };
@ -634,7 +656,11 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let fixture = dir.join(format!("block-{}.json", w.number));
let results = dir.join(format!("results-{}-{}.json", w.number, w.shard));
let outcome: Result<(), String> = (|| {
let export = evm_rpc(&shared, "igneum_exportSegments", json!(["0x0", format!("{:#x}", w.number)]), Duration::from_secs(120))?;
// the export from one block below (0.3.14): the node's account dump after block n-1 seeds the exporter;
// never from 0 (on a restarted node the records below the restart carry zero roots and the exporter
// refused every cut, the fleet 16:02Z)
let from = w.number.saturating_sub(1);
let export = evm_rpc(&shared, "igneum_exportSegments", json!([format!("{from:#x}"), format!("{:#x}", w.number)]), Duration::from_secs(120))?;
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
@ -750,6 +776,9 @@ fn pick_segment(shared: &Shared, work: &[Work], key_hash: &str, attempted: &mut
return None;
}
let (start, n, unproven, tip_daa) = (hexu(&v1["start"]), hexu(&v1["segmentBlocks"]).max(1), hexu(&v1["unprovenDaa"]), hexu(&st["tipDaa"]));
// segments below the re-derivation boundary are never claimed: no bodies, no state, unprovable anywhere
let boundary = exec_boundary(shared);
let start = if boundary > start { boundary.div_ceil(n) * n } else { start };
let segs = crate::segments::whole_segments(start, n, unproven, work);
let need = crate::segments::need_daa(last_secs);
let cands = crate::segments::candidates(&segs, tip_daa, need, key_hash, attempted);
@ -815,7 +844,10 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
shared.log(&format!("prover: segment {first}..{last} claimed ({} shards{}): export, cut, chain ({}), sign, submit", seg.shards.len(), if prev_file.is_some() { ", continuing the previous segment's proof" } else { ", fresh" }, if t.cuda { "CUDA" } else { "CPU" }));
// 1. export once, cut every block
let seq = dir.join("seq.json");
let export = evm_rpc(shared, "igneum_exportSegments", json!(["0x0", format!("{last:#x}")]), Duration::from_secs(300))?;
// the export from one block below the segment (0.3.14): the node's account dump after block first-1 seeds the
// exporter; nothing older is read
let from = first.saturating_sub(1);
let export = evm_rpc(shared, "igneum_exportSegments", json!([format!("{from:#x}"), format!("{last:#x}")]), Duration::from_secs(300))?;
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
let mut fixtures: Vec<String> = Vec::new();
for b in first..=last {

View file

@ -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());
@ -325,6 +333,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));

View file

@ -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
@ -125,6 +126,15 @@ 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
// (Josh, 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,
// 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,
@ -283,6 +293,13 @@ 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 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`)

View file

@ -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");
}
}
}

View file

@ -1,8 +1,9 @@
/* Igneum Miner dashboard (miner-ui-2). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone,
ash; Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary.
One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), one accent (ember), used by every page.
Layout: a rail on the left (six sections), a thin top bar, the status strip under it, the page in the middle, the
log drawer at the bottom. The window lays out from 900 x 600 up (the hosts say the same minimum).
/* Igneum Miner dashboard (miner-ui-3). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone,
ash; Unbounded for the page title, the big button and the hero numbers; IBM Plex Sans for words; IBM Plex Mono for
labels, units and small numbers. Fonts ship in the binary. One type scale (--t-*), one spacing scale (--s-*), one
accent (ember). Dark by default, light under prefers-color-scheme or [data-theme=light]. Layout: a rail on the left
(four sections), a thin top bar, the status strip under it, the page in the middle, the log drawer at the bottom.
The window lays out from 900 x 600 (the hosts' minimum); under 720 px the rail becomes a bottom tab bar.
Nothing here is newer than 2022 CSS (the WebView2 host). */
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')}
@ -14,36 +15,42 @@
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
:root{
/* colour */
--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A;--rail-bg:#111114;
/* colour, dark */
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
/* type scale */
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px;
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
/* spacing scale */
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px;
--gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3);
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
--gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4);
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}}
:root[data-theme="light"]{
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-lg);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
.dim{color:var(--ash)}
h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance}
h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em}
h2{font-weight:700;font-size:var(--t-h2)}
h3{font-weight:700;font-size:var(--t-h3)}
h3{font-weight:700;font-size:var(--t-xl)}
p{margin:0}
a{color:inherit}
button{font:inherit;color:inherit}
input,textarea{font-variant-numeric:tabular-nums}
.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap}
.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.16em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap}
.eyebrow.ember{color:var(--ember)}
[hidden]{display:none !important}
::selection{background:rgba(242,84,27,.45)}
/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:var(--t-lg);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:40px;padding:8px 18px;border-radius:10px;font-weight:600;font-size:var(--t-md);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap}
.btn:hover{transform:translateY(-1px);border-color:var(--ash)}
.btn:active{transform:none}
.btn:disabled{opacity:.4;cursor:default;transform:none}
@ -68,9 +75,9 @@ body.mac .rail-brand{padding-top:30px}
.rail-nav{display:flex;flex-direction:column;gap:3px}
.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
.nav:hover{background:rgba(255,255,255,.04);color:var(--bone)}
.nav:hover{background:var(--hover);color:var(--bone)}
.nav:hover svg{color:var(--ink-2)}
.nav.on{background:var(--ember-12);border-color:rgba(242,84,27,.28);color:var(--bone);font-weight:600}
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
.nav.on svg{color:var(--ember)}
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
@ -85,7 +92,7 @@ body.mac .rail-brand{padding-top:30px}
.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em}
/* top bar */
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag}
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:var(--top-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);z-index:20;-webkit-app-region:drag}
.top button,.top .pill{-webkit-app-region:no-drag}
body.mac:not(.has-rail) .top{padding-left:92px}
body.has-rail .top{left:var(--rail)}
@ -96,21 +103,17 @@ body.has-rail .top{left:var(--rail)}
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
.top-status{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px}
.top-status .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)}
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
.pill.on{color:var(--molten);border-color:var(--molten-40)}
.pill.warn{color:var(--ember)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
.dot.small{width:7px;height:7px;flex-basis:7px}
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
.warn .dot{background:var(--ember);animation:none}
.warn .dot,.dot.bad{background:var(--ember);animation:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
/* the status strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's
top moves once per change with a 150 ms transition (layoutStrip), never per poll. Tones: default molten (running,
available, done), bad ember (failed, clock block, urgent). */
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
body.has-rail .notices{left:var(--rail)}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
@ -124,11 +127,18 @@ body.has-rail .notices{left:var(--rail)}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice.update-urgent .notice-close{color:#fff}
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
.notice .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
body.has-rail .ask-wrap{left:var(--rail)}
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
/* screens and pages */
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
@media (prefers-reduced-motion:reduce){main{transition:none}}
body.has-rail main{left:var(--rail)}
body.drawer-open main{bottom:var(--drawer-h)}
@ -140,15 +150,14 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease}
/* welcome */
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-7) 0 48px}
.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px}
.coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite}
.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))}
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-6) 0 48px}
.mark-wrap{position:relative;width:120px;height:120px;border-radius:26px;background:#0C0C0E;display:flex;align-items:center;justify-content:center;margin-bottom:6px;box-shadow:0 20px 50px rgba(242,84,27,.25)}
.mark-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite;z-index:-1}
@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
@media (prefers-reduced-motion:reduce){.coin-wrap::before{animation:none}}
.lead{font-size:var(--t-2xl);color:var(--ink-2);max-width:54ch}
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left}
.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
@media (prefers-reduced-motion:reduce){.mark-wrap::before{animation:none}}
.lead{font-size:var(--t-xl);color:var(--ink-2);max-width:54ch}
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3);width:100%;max-width:860px;margin-top:10px;text-align:left}
.tile{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0}
.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)}
.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25}
.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45}
@ -162,6 +171,9 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.note{font-size:var(--t-base);color:var(--ash);line-height:1.5}
.note.small{font-size:var(--t-sm);word-break:break-all}
.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch}
.line-text{font-size:var(--t-md);color:var(--ink-2);line-height:1.5}
.line-text.ok{color:var(--molten)}
.line-text.bad{color:var(--ember)}
.cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)}
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
.card.detecting{display:flex;align-items:center;gap:18px}
@ -169,11 +181,13 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)}
.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px}
@keyframes spin{to{transform:rotate(360deg)}}
.top-gap{margin-top:var(--s-4)}
.indent{margin-left:52px}
/* GPU rows (first run) */
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
.gpu-row.off{opacity:.72}
.badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
.badge.apple{color:var(--bone)}
.badge.nvidia{color:var(--nvidia)}
.badge.amd{color:var(--ember)}
@ -183,17 +197,15 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.kind.discrete,.kind.apple{color:var(--molten);border-color:var(--molten-40)}
.kind.external{color:var(--bone)}
.gpu-row .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px}
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
.gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto}
/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */
.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5}
.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)}
.gpu-row.removed,.gpu-line.removed{opacity:.5}
.gpu-row.unusable .name,.gpu-line.unusable .name{color:var(--ember)}
/* the Mine page: the big switch and the three numbers */
.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)}
.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:var(--tile-r);border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0}
/* the Mine page: the big button and the live total */
.hero-row{display:grid;grid-template-columns:minmax(220px,1fr) 2fr;gap:var(--s-3)}
.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:14px;border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0}
.toggle-big:hover{background:var(--ember-hi);border-color:var(--ember-hi);transform:translateY(-1px)}
.toggle-big:disabled{opacity:.45;cursor:default;transform:none}
.toggle-big .ring{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(12,12,14,.18);margin-bottom:6px}
@ -201,64 +213,50 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.toggle-big .tl{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap}
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
.toggle-big.stop:hover{border-color:var(--ash);background:#1b1b20}
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
.toggle-big.stop .ts{color:var(--molten);opacity:1}
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
.cell.ember .v{color:var(--ember)}
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
.cell .v.state{text-transform:capitalize;font-size:22px}
.cell .v.small{font-size:18px}
.cell .v.dim{color:var(--ash)}
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cell.ok .v.state{color:var(--molten)}
.cell.bad .v.state{color:var(--ember)}
.cell.bad .s{color:var(--ember)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);align-items:start}
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
.tot:first-child{padding-left:0;border-left:0}
.tot .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.01em}
.tot .v.ember{color:var(--ember)}
.tot .v.ask-price{font-family:var(--sans);font-size:var(--t-md);font-weight:600;color:var(--molten);text-decoration:underline;text-underline-offset:3px;cursor:pointer;padding:9px 0 8px}
.tot .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--ash);margin-top:4px}
.tot .s{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
#dag{display:block;width:100%;height:170px;border-radius:12px;background:var(--obsidian)}
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
.sw.ember{background:var(--ember);border-color:var(--ember)}
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
.head-right{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;min-width:0}
.stats{font-size:var(--t-sm);color:var(--ash)}
#dag{display:block;width:100%;height:110px;border-radius:10px;background:var(--obsidian);margin-bottom:var(--s-3)}
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
.empty.err{color:var(--ember)}
.kv{display:flex;flex-direction:column}
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
.kv>div{display:grid;grid-template-columns:120px auto 1fr;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
.kv>div:last-child{border-bottom:0}
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kv .v.ok{color:var(--molten)}
.kv .v.warn{color:var(--ember)}
.kv .v.dim{color:var(--ash)}
.kv .m{font-size:var(--t-sm);color:var(--ash);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card .note{margin-top:10px}
.card .help+.help{margin-top:6px}
.big-word{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.2;margin:2px 0 8px;word-break:break-word}
.big-word.ok{color:var(--molten)}
.big-word.warn{color:var(--ember)}
.big-word.dim{color:var(--ash)}
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:260px;overflow:auto}
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2)}
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:7px 0;align-items:baseline}
.feed>div:last-child{border-bottom:0}
.feed>div>span:first-child{min-width:0}
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error{color:var(--ember)}
.feed .k.warn{color:var(--ember)}
.feed .k.block{color:var(--ember)}
.feed .k.build{color:var(--ink-2)}
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
.feed .k.build,.feed .k.info{color:var(--ash)}
/* the GPU rows on the Mine page: badge, name, numbers, the switch */
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
the tune line under; the details under that */
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
.gpu-line{display:grid;grid-template-columns:44px minmax(160px,1.4fr) repeat(3,minmax(84px,.7fr)) 48px;align-items:center;gap:var(--s-4);padding:12px 14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);min-width:0}
.gpu-line{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
.gpu-line.off{opacity:.62}
.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px}
.gpu-line.open{border-color:var(--line-2)}
.gl-main{display:grid;grid-template-columns:44px minmax(150px,1.2fr) auto auto;align-items:center;gap:var(--s-4);padding:12px 14px}
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
.gpu-line .name{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.gpu-line .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
@ -267,46 +265,75 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.gpu-line .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
.gpu-line .msg{font-size:var(--t-sm);color:var(--ember);line-height:1.4}
.gpu-line .msg.dim{color:var(--ash)}
.gpu-line .num{display:flex;flex-direction:column;gap:2px;min-width:0}
.gpu-line .num .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.gpu-line .num .v{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:5px}
.gpu-line .num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em}
.gpu-line .num .v.hot{color:var(--ember)}
.gpu-line .num .v.warm{color:var(--molten)}
.gpu-line .num .v.na{color:var(--ash);font-weight:500;font-size:var(--t-md)}
.nums{display:flex;gap:var(--s-5);justify-content:flex-end;align-items:flex-start;min-width:0;flex-wrap:wrap}
.num{display:flex;flex-direction:column;gap:1px;min-width:56px;align-items:flex-end;text-align:right}
.num .v{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.15;white-space:nowrap;display:flex;align-items:baseline;gap:4px}
.num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em}
.num .k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.num .v.hot{color:var(--ember)}
.num .v.warm{color:var(--molten)}
.gpu-line.on .num.hash .v{color:var(--ember)}
.gpu-line .switch{padding:0;justify-self:end}
/* the Settings page: per-card controls */
.set-cards{display:flex;flex-direction:column;gap:var(--s-3);margin-bottom:var(--s-3)}
.set-card{border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}
.set-card .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.set-card .head .name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
.set-card .head .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
.set-card .ctl{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:var(--s-3)}
.set-card .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500}
.set-card .ctl .pv{font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);min-width:110px;text-align:right;white-space:nowrap}
.set-card input[type=range]{width:100%;accent-color:var(--ember);margin:0}
.set-card .ctl .help{grid-column:1 / -1;margin-top:-4px}
.set-card .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}
.set-card .line b{color:var(--ink-2);font-weight:500}
.set-card .line.ok{color:var(--molten)}
.set-card .line.hot{color:var(--ember)}
.set-card .line.on{color:var(--molten)}
.set-card .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
.acts{display:flex;align-items:center;gap:var(--s-3);justify-self:end}
.acts .switch{padding:0}
.chev-btn{width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ash)}
.chev-btn:hover{border-color:var(--line-2);color:var(--bone)}
.chev{width:9px;height:9px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-2px);transition:transform .15s ease;display:inline-block}
.open .chev,.chev-btn.open .chev{transform:rotate(225deg) translateY(-2px)}
.gl-tune{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);border-top:1px solid var(--line)}
.gl-tune .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.gl-tune .n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gl-tune.running{color:var(--molten)}
.gl-tune.stopped,.gl-tune.paused{color:var(--ember)}
.gl-tune .bar{flex-basis:100%;height:3px;border-radius:2px;background:var(--line);overflow:hidden;display:block}
.gl-tune .bar b{display:block;height:100%;background:var(--molten);transition:width .5s linear}
.gl-details{padding:12px 14px 14px 72px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:10px;user-select:text;-webkit-user-select:text}
.gl-details .ctl{display:grid;grid-template-columns:120px 1fr auto;align-items:center;gap:var(--s-3)}
.gl-details .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500}
.gl-details .ctl .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:100px;text-align:right;white-space:nowrap}
.gl-details input[type=range]{width:100%;accent-color:var(--ember);margin:0}
.gl-details .ctl .help{grid-column:1 / -1;margin-top:-4px}
.gl-details .ctl .help:first-of-type{grid-column:2}
.gl-details .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}
.gl-details .line b{color:var(--ink-2);font-weight:500}
.gl-details .line.ok{color:var(--molten)}
.gl-details .line.hot{color:var(--ember)}
.gl-details .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
.curve{font-size:var(--t-sm);font-family:var(--mono);border-collapse:collapse;width:100%}
.curve th{text-align:left;font-weight:500;color:var(--ash);font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:3px 8px 3px 0}
.curve td{padding:3px 8px 3px 0;border-top:1px solid var(--line);color:var(--ink-2)}
.curve tr.marked td{color:var(--ash);text-decoration:line-through}
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto;justify-self:end}
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
.ids button:hover{border-color:var(--ash)}
.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text}
.ids input:focus{outline:none;border-color:var(--ember)}
.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.lead-card .lead-row,.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
.lead-text h3{font-size:var(--t-2xl)}
/* the tuning card: the goal, its consequence, the schedule, Power control when it is the fix */
.goal-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap;margin-bottom:var(--s-3)}
.seg{display:inline-flex;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:3px;gap:2px}
.seg-b{border:0;background:transparent;color:var(--ash);font-size:var(--t-base);font-weight:500;padding:6px 14px;border-radius:8px;cursor:pointer;transition:background .15s ease,color .15s ease;white-space:nowrap}
.seg-b:hover{color:var(--bone)}
.seg-b.on{background:var(--graphite);color:var(--bone);box-shadow:0 1px 3px rgba(0,0,0,.25);font-weight:600}
.goal-line{font-size:var(--t-base);color:var(--ink-2);min-width:0}
/* the node line and the disclosures */
.disclose{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);width:100%;border:0;background:transparent;padding:0;cursor:pointer;text-align:left;color:var(--bone);font-size:var(--t-md);font-weight:500;min-height:32px}
.disclose:hover{color:var(--bone)}
.disclose .chev{color:var(--ash);flex:0 0 auto;margin-right:4px}
.disclose-right{display:flex;align-items:center;gap:var(--s-3);min-width:0}
.disclose-right .dim{font-size:var(--t-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52ch}
.node-line{display:inline-flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.node-card.bad .node-line{color:var(--ember)}
.card .disclose+.disclose,.card .details+.disclose,.card .help+.disclose,.card .addr-big+.help+.disclose{margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid var(--line)}
.details{padding-top:var(--s-3);display:flex;flex-direction:column;gap:var(--s-3)}
.switch-list{display:flex;flex-direction:column;gap:3px;font-size:var(--t-sm);color:var(--ash);margin-top:8px}
.switch-list span{display:flex;justify-content:space-between;gap:12px}
.switch-list span.past{opacity:.55}
.switch-list span.next{color:var(--molten)}
.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
.lead-text h3{font-size:var(--t-xl)}
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text;margin-bottom:10px}
.addr-big span{flex:1;min-width:0;color:var(--molten)}
.card.adv{padding:0}
@ -317,22 +344,35 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.card.adv summary .eyebrow{margin-left:0}
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
.card.adv>:last-child{margin-bottom:var(--card-pad)}
.job-history table{margin-top:var(--s-2)}
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase}
.job-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
.job-history tr.running td{color:var(--molten)}
.clock-card{border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)}
#s-jobs-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase}
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
#s-jobs-history tr.running td{color:var(--molten)}
.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)}
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
.clock-card .note{margin-top:0}
/* earnings: money first */
.money{display:flex;flex-direction:column;margin-bottom:var(--s-2)}
.money-row{display:flex;align-items:baseline;gap:var(--s-3);padding:8px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
.money-row:last-child{border-bottom:0}
.money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px}
.money-row .v.small{font-size:var(--t-num)}
.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0}
/* prove */
.tier-list{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column;gap:6px;font-size:var(--t-md);color:var(--ink-2)}
.tier-list li{display:flex;gap:10px;align-items:baseline}
.tier-list li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px}
#pv-line{margin-top:var(--s-3)}
/* address options */
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
.option:hover{border-color:var(--line-2)}
.option.on{border-color:var(--ember);background:#1A1614}
.option.on{border-color:var(--ember);background:var(--ember-12)}
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
.option.on .radio{border-color:var(--ember)}
@ -342,15 +382,15 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.option .s{font-size:var(--t-md);color:var(--ash);line-height:1.5}
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid var(--molten-40);border-radius:999px;padding:2px 8px}
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
.addr-input.short{width:100px;flex:0 0 100px}
.addr-input:focus{outline:none;border-color:var(--ember)}
.option:not(.on) .addr-input{display:none}
.err{font-size:var(--t-base);color:var(--ember)}
/* the update card (app.js, UpdateCard; the wallet carries the same block): the mark with its ring, the name, one
line, up to three note lines, the size, Install now and Later. Over the key sheet and the pages. */
.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
/* the update card (app.js, UpdateCard; the wallet carries the same block) */
.upd-wrap{position:fixed;inset:0;z-index:35;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
@keyframes fade{from{opacity:0}to{opacity:1}}
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px var(--shadow),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none}
.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)}
.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))}
@ -381,8 +421,8 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
/* sheet (the key) */
.sheet-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}
.sheet-wrap{position:fixed;inset:0;z-index:30;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px var(--shadow);animation:rise .3s ease}
.sheet .sub{font-size:var(--t-lg);color:var(--ink-2)}
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
@ -398,13 +438,13 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
/* rows, switches */
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.between{justify-content:space-between}
.row.wrap{flex-wrap:wrap}
.row .addr-input{margin-top:0;min-width:0}
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:8px 0 2px;line-height:1.35}
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
.switch+.switch{border-top:1px solid var(--line)}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
.switch input:checked+.track{background:var(--ember)}
.switch input:checked+.track::after{transform:translateX(18px)}
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
@ -412,10 +452,12 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
.switch.lg .track::after{width:24px;height:24px}
.switch.lg input:checked+.track::after{transform:translateX(22px)}
.switch+.help{margin-bottom:6px}
.sw-text{min-width:0}
.sw-text b{font-weight:600}
.sw-text .dim{font-size:var(--t-base)}
/* log drawer */
.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px var(--shadow)}
body.has-rail .drawer{left:var(--rail)}
body.drawer-open .drawer{height:var(--drawer-h)}
body.drawer-drag .drawer{transition:none}
@ -450,8 +492,8 @@ body.drawer-drag main{pointer-events:none}
.log-ruler .t1{right:0;border-radius:0 0 0 6px}
.log-ruler .mk{position:absolute;top:4px;bottom:4px;width:2px;margin-left:-1px;background:var(--ember);opacity:.9;pointer-events:none}
.log-ruler .mk.swap{background:var(--molten)}
.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(244,241,236,.14);border-left:1px solid rgba(244,241,236,.35);border-right:1px solid rgba(244,241,236,.35);min-width:3px;pointer-events:none}
.log-ruler:hover .win{background:rgba(244,241,236,.2)}
.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(127,127,127,.14);border-left:1px solid rgba(127,127,127,.35);border-right:1px solid rgba(127,127,127,.35);min-width:3px;pointer-events:none}
.log-ruler:hover .win{background:rgba(127,127,127,.2)}
.log-view{position:relative;flex:1;overflow:auto;overscroll-behavior:contain;user-select:text;-webkit-user-select:text;contain:strict}
.log-pad{position:relative;min-height:100%}
.log{margin:0;position:absolute;top:0;left:0;right:0;padding:6px var(--gutter);font-size:var(--t-sm);line-height:19px;color:var(--ink-2);white-space:pre;will-change:transform}
@ -459,7 +501,7 @@ body.drawer-drag main{pointer-events:none}
.log-view.wrap .log{position:relative;padding-bottom:12px}
.log-view.wrap .log .ln{height:auto;white-space:pre-wrap;word-break:break-all;overflow:visible}
.log .src{color:var(--ash)}
.log .src.node{color:var(--node-blue)}
.log .src.node{color:#7FA7C9}
.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4,.log .src.miner5,.log .src.miner6,.log .src.miner7,.log .src.miner8{color:var(--molten)}
.log .src.app{color:var(--ember)}
.log .src.watch{color:var(--ash)}
@ -467,22 +509,18 @@ body.drawer-drag main{pointer-events:none}
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px}
.log-jump:hover{background:#1c1a18;border-color:var(--molten)}
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
.log-jump:hover{border-color:var(--molten)}
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px var(--shadow);max-width:min(90vw,520px);text-align:center}
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
/* narrow windows (the 900 px floor): the rail folds to icons with small labels, the hero row and the strips fold to
two columns, the two-column grid to one, the GPU rows drop a number */
/* narrow windows: 1180 the hero wraps; 1000 the rail folds to icons; 900 the row's cells wrap; 720 a bottom tab bar */
@media (max-width:1180px){
.hero-row{grid-template-columns:1fr 1fr}
.toggle-big{grid-row:span 1}
.strip{grid-template-columns:repeat(2,minmax(0,1fr))}
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid2{grid-template-columns:1fr}
.top-status{max-width:220px}
.hero-row{grid-template-columns:1fr}
.totals{grid-template-columns:repeat(4,minmax(0,1fr))}
.nums{gap:var(--s-4)}
}
@media (max-width:1000px){
:root{--rail:76px;--gutter:var(--s-5)}
@ -497,29 +535,73 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.rail-status{display:none}
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
.rail-version .chain{display:none}
.gpu-line{grid-template-columns:44px minmax(140px,1.4fr) repeat(2,minmax(80px,.7fr)) 48px}
.gpu-line .num.power{display:none}
.page-sub{display:none}
.top-status{display:none}
.strip.three{grid-template-columns:repeat(2,minmax(0,1fr))}
.set-card .ctl{grid-template-columns:120px 1fr auto}
.gl-main{grid-template-columns:44px minmax(140px,1fr) auto}
.nums{grid-column:2;justify-content:flex-start}
.acts{grid-column:3;grid-row:1}
.gl-tune,.gl-details{padding-left:14px}
.kv>div{grid-template-columns:110px auto 1fr}
}
@media (max-width:860px){
.three{grid-template-columns:1fr}
h1{font-size:40px}
.totals{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--s-3)}
.tot:nth-child(3){padding-left:0;border-left:0}
.money-row .v{min-width:0}
}
/* short windows (the 600 px floor): tighter paddings, a lower hero, a smaller canvas, so the drawer always has room */
@media (max-width:720px){
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
body.has-rail .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:64px;flex-direction:row;align-items:center;border-right:0;border-top:1px solid var(--line);padding:0 var(--s-2)}
.rail-brand{display:none}
.rail-nav{flex-direction:row;flex:1;gap:0;justify-content:space-around}
.nav{min-height:56px;padding:6px 2px;font-size:10px;border-radius:10px;flex:1;max-width:120px}
.nav.on::before{display:none}
.rail-foot{margin-top:0;padding-top:0;border-top:0;flex-direction:row;gap:0}
.rail-foot .nav.small{min-height:56px;flex:0 0 auto;padding:6px 8px}
.rail-version{display:none}
body.has-rail .top,body.has-rail .notices,body.has-rail main,body.has-rail .drawer,body.has-rail .ask-wrap{left:0}
body.has-rail main{bottom:var(--bottom)}
body.has-rail .toast{left:50%;bottom:calc(var(--bottom) + 12px)}
body.drawer-open .drawer{bottom:var(--bottom)}
.ask-wrap{bottom:var(--bottom)}
.top{padding:0 var(--s-4)}
.page-title h1{font-size:17px}
.pill{min-width:0}
.hero-row{grid-template-columns:1fr}
.toggle-big{min-height:88px}
.gl-main{grid-template-columns:44px 1fr;gap:var(--s-3);padding:12px}
.nums{grid-column:1 / -1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s-2) var(--s-3);justify-items:start}
.acts{grid-column:1 / -1;grid-row:auto;justify-self:end}
.gpu-line .name{font-size:var(--t-base)}
.num{align-items:flex-start;text-align:left}
.acts{gap:var(--s-2)}
.gl-tune,.gl-details{padding-left:12px;padding-right:12px}
.gl-details .ctl{grid-template-columns:1fr;gap:6px}
.gl-details .ctl .help:first-of-type{grid-column:1}
.ids{justify-self:start}
.kv>div{grid-template-columns:96px 1fr;gap:4px var(--s-3)}
.kv .m{grid-column:1 / -1;white-space:normal}
.goal-row{align-items:flex-start;flex-direction:column}
.seg{width:100%}
.seg-b{flex:1;padding:6px 8px}
.indent{margin-left:0}
.disclose-right .dim{display:none}
.lead-row{flex-direction:column}
.step{padding:32px 0}
.money-row .v{font-size:var(--t-num)}
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
}
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
@media (max-height:700px){
:root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)}
:root{--card-pad:18px;--gap:var(--s-3)}
#screen-dashboard{padding:16px 0 20px}
#dag{height:140px}
#dag{height:90px}
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
.coin-wrap{width:104px;height:104px}
.coin{width:104px;height:104px}
.mark-wrap{width:88px;height:88px}
h1{font-size:40px}
.lead{font-size:var(--t-xl)}
.lead{font-size:var(--t-lg)}
.step{padding:32px 0 32px}
.feed{max-height:200px}
.drawer-head{padding-bottom:6px}
.toggle-big{min-height:96px}
}

File diff suppressed because it is too large Load diff

View file

@ -4,13 +4,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Igneum Miner</title>
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body class="phase-welcome" data-phase="welcome" data-page="mine">
<!-- the rail: one button per section (miner-ui-2). Shown on the dashboard; the setup screens have no rail. -->
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
is a bottom tab bar. The setup screens have no rail. -->
<aside class="rail" id="rail" hidden>
<div class="rail-brand">
<img src="mark.svg" width="28" height="28" alt="">
@ -18,11 +19,9 @@
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
<button class="nav" data-page="rewards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Rewards</span></button>
<button class="nav" data-page="node"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg><span>Node</span></button>
<button class="nav" data-page="updates"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5"/><path d="M4 19h16"/></svg><span>Updates</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
</nav>
<div class="rail-foot">
<div class="rail-status mono" id="rail-status"></div>
@ -42,13 +41,11 @@
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
<span class="top-status mono" id="top-status"></span>
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
</div>
</header>
<!-- the status strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
it from the state (Notices) and the content below moves once when it appears or goes. -->
<!-- the status strip: one notice at a time (app.js, Notices) -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
@ -59,36 +56,45 @@
</div>
</div>
<!-- the quit question, in place of a dialog (the viewer never shows one) -->
<div class="ask-wrap" id="ask-quit" hidden>
<div class="ask">
<span class="ask-text">Quit Igneum Miner? Mining stops first, then the node.</span>
<button class="btn small primary" id="ask-quit-yes">Quit</button>
<button class="btn small ghost" id="ask-quit-no">Cancel</button>
</div>
</div>
<main id="main">
<!-- 1. welcome -->
<section class="screen" id="screen-welcome">
<div class="hero">
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
<div class="mark-wrap"><img src="mark.svg" width="72" height="72" alt=""></div>
<div class="eyebrow ember">devnet v4 &middot; nothing is bought or sold</div>
<h1>Igneum Miner</h1>
<p class="lead">Mined by GPUs. Proven by fire. This app runs a node on this machine and mines Igneum with your graphics card.</p>
<p class="lead">Runs a node and mines Igneum with your graphics card. Set up in two steps.</p>
<div class="three">
<div class="tile">
<div class="k">01</div>
<div class="t">Runs a node</div>
<div class="s">Syncs the chain from the seed node, usually under a minute.</div>
<div class="t">Runs in the background</div>
<div class="s">The window can close. The miner keeps going from the menu bar or the tray.</div>
</div>
<div class="tile">
<div class="k">02</div>
<div class="t">Mines on your GPU</div>
<div class="s">A new program every hour. The next one compiles while you mine.</div>
<div class="t">Uses your graphics card</div>
<div class="s">Each card is tuned for hashes per watt by itself. You can tune again any time.</div>
</div>
<div class="tile">
<div class="k">03</div>
<div class="t">Pays your address</div>
<div class="s">Block rewards go to an EVM address you choose or one made here.</div>
<div class="t">Pays an address you own</div>
<div class="s">Every block this machine finds pays one address: one made here, or one you paste.</div>
</div>
</div>
<div class="cta">
<button class="btn primary big" id="btn-begin">Get started</button>
</div>
<p class="seedline mono">Nobody from Igneum will ever ask for your seed.</p>
<p class="seedline mono">Nobody from Igneum will ever ask for your key.</p>
</div>
</section>
@ -96,20 +102,20 @@
<section class="screen" id="screen-cards">
<div class="step">
<div class="eyebrow">step 1 of 2</div>
<h2>Your GPU</h2>
<h2>Your graphics card</h2>
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
<div class="cards" id="cards-list">
<div class="card detecting" id="cards-detecting">
<div class="spinner"></div>
<div>
<div class="t">Detecting</div>
<div class="s mono" id="detect-text">loading the Metal worker</div>
<div class="s mono" id="detect-text">asking the graphics cards to report in</div>
</div>
</div>
</div>
<p class="note" id="cards-note" hidden></p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable. This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. Settings has a slider per card.</p>
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
<p class="note" id="cards-help" hidden>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
@ -122,14 +128,14 @@
<div class="step">
<div class="eyebrow">step 2 of 2</div>
<h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one EVM address. Pick one way. One block in 100 pays the miner software's dev fee; Settings turns it off.</p>
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
<div class="options">
<label class="option on" id="opt-generate">
<input type="radio" name="mode" value="generate" checked>
<span class="radio"></span>
<span class="body">
<span class="t">Make me an address <span class="tag">recommended</span></span>
<span class="s">A secp256k1 key is made on this machine and stored in the app folder with the file locked to your user. You see the key once, to save it.</span>
<span class="s">A key is made on this machine and stored in the app folder, readable by your user only. You see it once, to save it.</span>
</span>
</label>
<label class="option" id="opt-paste">
@ -137,98 +143,174 @@
<span class="radio"></span>
<span class="body">
<span class="t">Use my own address</span>
<span class="s">Paste an EVM address you control. 0x followed by 40 hex characters.</span>
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
<span class="err" id="addr-err" hidden>That is not an EVM address. 0x followed by 40 hex characters.</span>
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
</span>
</label>
</div>
<p class="err" id="start-blocked" hidden></p>
<div class="cta">
<button class="btn primary big" id="btn-start">Start mining</button>
<button class="btn primary big" id="btn-start">Continue</button>
<button class="btn ghost" id="btn-address-back">Back</button>
</div>
</div>
</section>
<!-- 4. the dashboard: six pages behind the rail -->
<!-- 4. the dashboard: four pages behind the rail -->
<section class="screen" id="screen-dashboard">
<!-- Mine -->
<section class="page" id="page-mine" data-page="mine">
<div class="clock-card" id="m-clock" hidden>
<p class="clock-msg" id="m-clock-msg"></p>
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
<p class="note" id="m-clock-hint"></p>
</div>
<div class="hero-row">
<button class="toggle-big" id="btn-toggle" disabled>
<span class="ring"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg></span>
<span class="tl" id="btn-toggle-text">Start mining</span>
<span class="ts" id="btn-toggle-sub">waiting for the engine</span>
</button>
<div class="cell big ember">
<div class="k">hash rate</div>
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
<div class="s" id="d-hash-sub">waiting for the worker</div>
</div>
<div class="cell big">
<div class="k">blocks found</div>
<div class="v" id="d-blocks">0</div>
<div class="s" id="d-blocks-sub">accepted by the node</div>
</div>
<div class="cell big">
<div class="k">next program</div>
<div class="v" id="d-eta">--:--</div>
<div class="s" id="d-eta-sub">waiting for the node</div>
<div class="totals" id="totals">
<div class="tot"><span class="v ember" id="t-hash">0</span><span class="k">MH/s</span><span class="s" id="t-hash-sub">waiting</span></div>
<div class="tot" id="t-power-cell"><span class="v" id="t-power">0</span><span class="k">W</span><span class="s" id="t-power-sub">drawn now</span></div>
<div class="tot" id="t-money-cell"><span class="v" id="t-money">£0.00</span><span class="k">a day</span><span class="s" id="t-money-sub">electricity</span></div>
<div class="tot"><span class="v" id="t-blocks">0</span><span class="k">blocks</span><span class="s" id="t-blocks-sub">this run</span></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Your GPUs</h3><div class="eyebrow" id="d-cards-eyebrow">detecting</div></div>
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="note" id="d-cards-note" hidden></p>
</div>
<div class="card">
<div class="card-head">
<h3>Your blocks</h3>
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
<div class="card" id="tune-card">
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
</div>
<span class="goal-line" id="goal-line"></span>
</div>
<p class="line-text" id="tune-schedule"></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
<div class="card node-card" id="node-card">
<button class="disclose" id="node-toggle" aria-expanded="false" aria-controls="node-details">
<span class="node-line"><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span></span>
<span class="disclose-right"><span class="dim" id="node-sub"></span><span class="chev" aria-hidden="true"></span></span>
</button>
<div class="details" id="node-details" hidden>
<div class="kv">
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
<div><span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the block DAG right now</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain</span></div>
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s</span></div>
</div>
<div class="field">
<div class="k">rules digest</div>
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
</div>
<div class="field">
<div class="k">next rule switch</div>
<div class="line-text" id="n-switch">none planned</div>
<p class="help" id="n-switch-help"></p>
<div class="switch-list mono" id="n-switches"></div>
</div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
</div>
<div class="card">
<div class="card-head"><h3>Activity</h3><div class="eyebrow">newest first</div></div>
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
</div>
</section>
<!-- Earnings -->
<section class="page" id="page-earnings" data-page="earnings" hidden>
<div class="card">
<div class="money">
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the miner software's author</span></label>
<p class="help" id="r-devfee-line"></p>
</div>
<div class="card">
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
<button class="disclose" id="addr-toggle" aria-expanded="false" aria-controls="addr-details"><span>Change address</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="addr-details" hidden>
<div class="row">
<input type="text" class="addr-input mono" id="s-address-input" placeholder="0x" spellcheck="false" autocomplete="off" aria-label="New rewards address">
<button class="btn small" id="s-address-save">Change</button>
</div>
<div class="err" id="s-address-err" hidden></div>
<div class="ask inline" id="ask-address" hidden>
<span class="ask-text" id="ask-address-text"></span>
<button class="btn small primary" id="ask-address-yes">Confirm</button>
<button class="btn small ghost" id="ask-address-no">Cancel</button>
</div>
</div>
<button class="disclose" id="key-toggle" aria-expanded="false" aria-controls="key-details"><span id="key-toggle-text">Show my key</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="key-details" hidden>
<p class="help" id="r-key-help"></p>
<div class="ask inline" id="ask-key">
<span class="ask-text">Show the private key on screen? Anyone who sees it can spend what the address holds.</span>
<button class="btn small primary" id="s-reveal">Show</button>
<button class="btn small ghost" id="ask-key-no">Cancel</button>
</div>
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button><button class="btn tiny ghost" id="s-hide">Hide</button></div>
<p class="note mono small" id="r-key-file"></p>
</div>
<div class="row wrap top-gap"><button class="btn small" id="r-wallet">Open the wallet</button><span class="note">Your balance is in the wallet.</span></div>
</div>
</section>
<!-- Prove -->
<section class="page" id="page-prove" data-page="prove" hidden>
<div class="card lead-card">
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3>Prove shards on this machine</h3>
<p class="help">Every block on Igneum is turned into a short mathematical proof, in pieces called shards. The chain assigns shards to your keys; this machine proves them and is paid for each one. On by default on an NVIDIA card with 24 GB or more (a full shard needs 20.4 GB of GPU memory, measured); off on a Mac, whose CPU prover is slow.</p>
<h3>Prove on this machine</h3>
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
</div>
<label class="switch lg" title="Prove assigned shards"><input type="checkbox" id="s-prove" aria-label="Prove shards on this machine"><span class="track"></span></label>
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<p class="note" id="pv-note">Off. Switch it on and this machine proves the shards the chain assigns to its keys.</p>
<ul class="tier-list" id="pv-tiers"></ul>
<p class="line-text" id="pv-line"></p>
<p class="note" id="pv-note"></p>
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
</div>
<div class="strip four">
<div class="cell"><div class="k">state</div><div class="v state" id="pv-state">off</div><div class="s" id="pv-state-sub">not proving</div></div>
<div class="cell"><div class="k">assigned</div><div class="v" id="pv-assigned">0</div><div class="s">shards given to your keys</div></div>
<div class="cell"><div class="k">proven</div><div class="v" id="pv-submitted">0</div><div class="s">proofs sent to the node</div></div>
<div class="cell"><div class="k">paid</div><div class="v" id="pv-paid">0</div><div class="s" id="pv-paid-sub">shards paid out</div></div>
</div>
<p class="note" id="pv-seg-note" hidden></p>
<div class="grid2">
<div class="card">
<div class="card-head"><h3>Verifier</h3><div class="eyebrow">the node's check</div></div>
<div class="big-word" id="pv-verifier">not read yet</div>
<p class="help" id="pv-verifier-help">Before a proof counts, the node checks it. A node without a verifier passes proofs along and never includes them.</p>
<p class="note" id="pv-verifier-note" hidden></p>
</div>
<div class="card">
<div class="card-head"><h3>Program</h3><div class="eyebrow">pinned guest</div></div>
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="pv-details" hidden>
<div class="kv">
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments this machine proved</span></div>
</div>
<div class="field">
<div class="k">shard program id</div>
<div class="box mono"><span id="pv-program">not read yet</span><button class="btn tiny" data-copy="pv-program">Copy</button></div>
@ -236,105 +318,63 @@
<div class="field">
<div class="k">aggregator id</div>
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
</div>
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
</div>
</div>
</section>
<!-- Rewards -->
<section class="page" id="page-rewards" data-page="rewards" hidden>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
</div>
<div class="strip three">
<div class="cell"><div class="k">blocks found</div><div class="v" id="r-blocks">0</div><div class="s">lifetime, accepted by the node</div></div>
<div class="cell"><div class="k">this run</div><div class="v" id="r-session">0</div><div class="s" id="r-session-sub">since the app started</div></div>
<div class="cell"><div class="k">balance</div><div class="v dim" id="r-balance">--</div><div class="s">shown in the wallet, not here yet</div></div>
</div>
<div class="grid2">
<div class="card" id="r-key-card">
<div class="card-head"><h3>Save your key</h3><div class="eyebrow ember">once</div></div>
<p class="help" id="r-key-help">The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe: anyone with the key can spend what the address holds.</p>
<div class="row" id="r-key-row"><button class="btn small" id="s-reveal">Show my key</button><button class="btn small ghost" id="s-hide" hidden>Hide</button></div>
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
<p class="note mono small" id="r-key-file"></p>
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
</div>
<span class="goal-line" id="goal-line-2"></span>
</div>
<div class="card">
<div class="card-head"><h3>Use another address</h3></div>
<p class="help">Paste an EVM address you control. The miner restarts and pays the new address from the next block.</p>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.</span></span></label>
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.</span><span class="dim" id="s-power-note"></span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span></span></label>
<div class="field">
<div class="k">electricity price</div>
<div class="row">
<input type="text" class="addr-input mono" id="s-address-input" placeholder="0x" spellcheck="false" autocomplete="off" aria-label="New rewards address">
<button class="btn small" id="s-address-save">Change</button>
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses it.</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
<div class="err" id="s-address-err" hidden></div>
<p class="note" id="r-devfee-line"></p>
<div class="row"><button class="btn small ghost" id="r-wallet">Open the wallet page</button></div>
</div>
<p class="line-text" id="s-tune-schedule"></p>
</div>
</section>
<!-- Node -->
<section class="page" id="page-node" data-page="node" hidden>
<div class="strip four">
<div class="cell" id="n-state-cell"><div class="k">node</div><div class="v state" id="d-node">starting</div><div class="s" id="d-node-sub">opening the database</div></div>
<div class="cell"><div class="k">height</div><div class="v" id="n-blocks">0</div><div class="s" id="n-blocks-sub">blocks this node holds</div></div>
<div class="cell"><div class="k">peers</div><div class="v" id="n-peers">0</div><div class="s" id="n-peers-sub">other nodes it talks to</div></div>
<div class="cell"><div class="k">version</div><div class="v small" id="n-version">--</div><div class="s" id="d-node-net">devnet v4</div></div>
</div>
<div class="clock-card" id="n-clock" hidden>
<p class="clock-msg" id="n-clock-msg"></p>
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
<p class="note" id="n-clock-hint"></p>
</div>
<div class="grid2">
<div class="card">
<div class="card-head"><h3>Chain</h3><div class="eyebrow" id="n-reading">reading</div></div>
<div class="kv">
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span></div>
<div><span class="k">daa score</span><span class="v mono" id="n-daa">0</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span></div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<div class="details indent" id="s-jobs-history" hidden></div>
<p class="note mono small indent" id="s-jobs-key" hidden></p>
<div class="field">
<div class="k">name</div>
<div class="row">
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
<button class="btn small" id="s-name-save">Rename</button>
</div>
<p class="help">Headers arrive before blocks. The DAA score counts blocks the whole network made; the difficulty is how hard the next one is to find.</p>
<p class="help">A label for you only.</p>
</div>
<div class="card">
<div class="card-head"><h3>Consensus</h3><div class="eyebrow">the rules</div></div>
<div class="field">
<div class="k">digest</div>
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
<p class="help">The fingerprint of the rules this node runs. Every node on the network shows the same one; a peer with another is refused.</p>
<div class="field">
<div class="k">appearance</div>
<div class="seg" id="theme-seg" role="radiogroup" aria-label="Appearance">
<button class="seg-b" data-theme="system" role="radio" aria-checked="true">System</button>
<button class="seg-b" data-theme="light" role="radio" aria-checked="false">Light</button>
<button class="seg-b" data-theme="dark" role="radio" aria-checked="false">Dark</button>
</div>
<div class="field">
<div class="k">next switch</div>
<div class="big-word" id="n-switch">none planned</div>
<p class="help" id="n-switch-help">A switch is a planned rule change. The node applies it by itself when the chain reaches that height.</p>
<div class="switch-list mono" id="n-switches"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Finality</h3><div class="eyebrow">miner-only</div></div>
<div class="kv">
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span></div>
<div><span class="k">age</span><span class="v mono" id="f-age">n/a</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span></div>
</div>
<p class="help" id="f-note">A lock is a point the miners have agreed can never be undone. This machine votes on one every 30 s.</p>
</div>
<div class="card">
<div class="card-head"><h3>Sync</h3><div class="eyebrow" id="n-sync-eyebrow"></div></div>
<div class="big-word" id="n-sync-word">starting</div>
<p class="help" id="n-note"></p>
</div>
</div>
</section>
<!-- Updates -->
<section class="page" id="page-updates" data-page="updates" hidden>
<div class="card lead-card">
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3 id="s-version">Igneum Miner</h3>
@ -342,79 +382,39 @@
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check now</button>
<button class="btn small" id="s-update">Check</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself</span></label>
<p class="help">Downloads in the background and installs at a quiet moment, never mid-program. Off: it downloads, then waits for Install now.</p>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs at a quiet moment, never mid-program.</span></span></label>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3>Remote jobs</h3>
<p class="help">Igneum publishes signed jobs (a benchmark, a script, logs to collect) next to the update manifest. This machine runs each one once and reports back. Only jobs signed by Igneum's key run.</p>
</div>
<button class="btn small" id="s-jobs-check">Check now</button>
</div>
<p class="note" id="s-jobs-note"></p>
<div class="job-history" id="s-jobs-history"></div>
<p class="note mono small" id="s-jobs-key"></p>
</div>
</section>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<div class="card-head"><h3>Graphics cards</h3><div class="eyebrow" id="s-cards-eyebrow"></div></div>
<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>
<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>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
<p class="help">The miner opens when you sign in and keeps mining in the background.</p>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum</span></label>
<p class="help">Signed jobs from Igneum run on this machine and report back. Updates shows what ran.</p>
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
<p class="help">Your miner signs a checkpoint every 30 s. Votes are what lock the chain; leave it on.</p>
<div class="field">
<div class="k">name</div>
<div class="row">
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
<button class="btn small" id="s-name-save">Rename</button>
</div>
<p class="help">A label for you only. Keys come from the machine id <span class="mono" id="s-mid"></span>, never from the name.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Dev fee</h3></div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
<p class="help" id="s-devfee-note">One block in 100 is mined for the miner software's author, the same way every GPU miner takes a fee. The protocol itself takes nothing. This switch turns it off.</p>
</div>
<div class="card">
<div class="card-head"><h3>Logs</h3></div>
<div class="row wrap">
<button class="btn small" id="s-log-copy">Copy the log</button>
<button class="btn small ghost" id="s-log-open">Show the log</button>
<span class="note">The last lines the node and the miner wrote, for a support message.</span>
</div>
<p class="help">Copies the last lines the node and the miner wrote, for a support message. Show the log opens the drawer with every line.</p>
<p class="note mono small" id="s-log-dir"></p>
<p class="note mono small" id="s-node-dir"></p>
</div>
<details class="card adv" id="s-advanced">
<summary><h3>Advanced</h3><span class="eyebrow">devnet tools</span></summary>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them</span></label>
<p class="help" id="s-trust-note">Devnet only. When no verifier is found next to the engine, the node includes proof records it never checked. A found verifier always wins. Changing this restarts the node.</p>
<div class="row"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button></div>
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span></span></label>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the node.</span></span></label>
<div class="ask inline" id="ask-trust" hidden>
<span class="ask-text" id="ask-trust-text"></span>
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
<button class="btn small ghost" id="ask-trust-no">Cancel</button>
</div>
<div class="row wrap"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button><span class="note mono small" id="s-mid"></span></div>
</details>
</section>
</section>
</main>
<!-- the update card (app.js, UpdateCard): one update, centred; Later, Escape or the backdrop leaves the strip above -->
<!-- the update card (app.js, UpdateCard) -->
<div class="upd-wrap" id="upd" hidden>
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
<div class="upd-mark" id="upd-mark">
@ -440,16 +440,15 @@
<div class="eyebrow ember">shown once</div>
<h2>Save your key</h2>
<p class="sub">This key controls the rewards address. Keep a copy somewhere safe. Anyone with the key can spend what the address holds.</p>
<div class="field">
<div class="k">rewards address</div>
<div class="box mono"><span id="key-address"></span><button class="btn tiny" data-copy="key-address">Copy</button></div>
</div>
<div class="field">
<div class="k">private key</div>
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Rewards can show it again.</p>
<p class="note">On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet.</p>
<div class="field">
<div class="k">rewards address</div>
<div class="box mono"><span id="key-address"></span><button class="btn tiny" data-copy="key-address">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Earnings can show it again.</p>
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
<div class="cta">
<button class="btn primary big" id="btn-key-done" disabled>Start mining</button>
@ -457,8 +456,7 @@
</div>
</div>
<!-- the log drawer: chips filter by source, search filters by text, the ruler and the jump controls move in time.
The list is virtualised (fixed row height, only the visible rows are in the DOM) so 20,000 lines scroll smoothly. -->
<!-- the log drawer (unchanged from miner-ui-2) -->
<div class="drawer" id="drawer" aria-label="Logs">
<div class="drawer-grip" id="drawer-grip" title="Drag to resize"><i></i></div>
<div class="drawer-head">

View file

@ -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,39 @@ 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');
});
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');
});

View file

@ -38,7 +38,7 @@ test('release notes: sentences, three lines under 70 characters, the rest behind
test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => {
const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {});
assert.equal(a.name, 'Igneum Ember 0.3.7'); assert.equal(a.line, 'is available.'); assert.equal(a.stage, 'available');
assert.equal(a.name, 'Igneum Miner 0.3.7'); assert.equal(a.line, 'is available.'); assert.equal(a.stage, 'available');
assert.equal(a.key, 'update:0.3.7:pending'); assert.equal(a.ring, 'none'); assert.equal(a.size, '21 MB');
assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']); assert.equal(a.dismissable, true);
const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {});

View file

@ -14,9 +14,9 @@ const V = mod.exports.View;
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
test('the six sections and their order', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']);
assert.equal(V.page('node').title, 'Node');
test('the four sections and their order (miner-ui-3)', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
assert.equal(V.page('earnings').title, 'Earnings');
assert.equal(V.page('nonsense').id, 'mine');
});
@ -129,9 +129,9 @@ test('the prove words follow the switch, the setup, the node and the status', ()
assert.equal(V.verifierWords({}).word, 'not read yet');
});
test('the dev-fee lines name the share and where Settings turns it off', () => {
test('the dev-fee lines name the share and the switch that turns it off', () => {
const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } });
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.');
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). The switch above turns it off.');
assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.');
assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678');
assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/);
@ -139,7 +139,7 @@ test('the dev-fee lines name the share and where Settings turns it off', () => {
test('the remote-jobs line and the helpers', () => {
const title = (j) => j.title || j.kind;
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.');
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until the switch above allows remote jobs.');
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
@ -198,3 +198,120 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
assert.equal(t2.sub, 'mining on 1 of 1 card');
});
// ---- miner-ui-3: the row as the hero object, the money, the tune line, the prove tier, the node line, the asks ----
const NOW = 1_800_000_000;
const settings = (over) => ({ settings: { sweep: true, tuning_off: false, tune_period_s: 604800, power_control: true, ...over }, now: NOW });
test('the row word: every state carries a reason, and a paused miner says paused on a card that is on', () => {
const m = { paused: true, state: 'paused' };
assert.equal(V.rowWord(V.cardRow(card({ state: 'off', hash_now: 0 })), m), 'paused');
assert.equal(V.rowWord(V.cardRow(card({ state: 'waiting', hash_now: 0 })), m), 'paused');
assert.equal(V.rowWord(V.cardRow(card()), { paused: false, state: 'mining' }), 'mining');
assert.equal(V.rowWord(V.cardRow(card({ enabled: false, state: 'off' })), m), 'off');
assert.equal(V.cardRow(card({ state: 'held', hash_now: 0, message: 'held for a remote job: shard benchmark' })).word, 'held for a remote job: shard benchmark');
assert.equal(V.cardRow(card({ state: 'tuning', tune_step: 3, tune_steps: 9, power_w: 300 })).word, 'tuning: step 3 of 9 · measuring 300 W');
assert.equal(V.cardRow(card({ state: 'starting', hash_now: 0 })).word, 'starting');
assert.equal(V.cardRow(card({ state: 'failed', hash_now: 0 })).tone, 'bad');
});
test('money: £ a day from watts at a price, nothing without one, the cells leave out what is not reported', () => {
assert.equal(V.poundsPerDay(310, 28.5).toFixed(4), '2.1204');
assert.equal(V.money(V.poundsPerDay(310, 28.5)), '£2.12');
assert.equal(V.poundsPerDay(310, 0), null);
assert.equal(V.poundsPerDay(0, 28), null);
assert.equal(V.money(123.4), '£123');
assert.equal(V.money(null), '');
const c = V.cells(card(), 28);
assert.equal(c.hash, '124'); assert.equal(c.power, '410 W'); assert.equal(c.money, '£2.76'); assert.equal(c.temp, '61 °C'); assert.equal(c.needsPrice, false); assert.equal(c.noReading, '');
assert.equal(c.eff, '0.30 MH/W');
const noPrice = V.cells(card(), 0);
assert.equal(noPrice.money, ''); assert.equal(noPrice.needsPrice, true);
const apple = V.cells(card({ vendor: 'apple', kind: 'apple', power_w: 0, temp_gpu: 0, temp_mem: 0, eff_mhw: 0 }), 28);
assert.equal(apple.power, ''); assert.equal(apple.money, ''); assert.equal(apple.temp, ''); assert.equal(apple.needsPrice, false);
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
});
test('the tune line: never run, running with a step and a bar, tuned with the point and the next check, the prior, measured only, pinned, stopped, fleet pause, no control', () => {
const nv = (over) => card({ sweep_state: 'idle', sweep_note: '', sweep_pct: 0, sweep_at: 0, tune_line: '', tune_source: '', tune_clock_mhz: 0, tune_control: true, pinned: false, tune_step: 0, tune_steps: 0, tune_eta_s: 0, ...over });
const idle = V.tuneWords(nv(), settings(), NOW);
assert.equal(idle.kind, 'idle'); assert.equal(idle.text, 'Not tuned yet: starts after 2 min of steady mining'); assert.equal(idle.button, 'tune'); assert.equal(idle.progress, -1);
assert.equal(V.tuneWords(nv(), settings({ sweep: false }), NOW).text, 'Not tuned yet: Ember Tune is off in Settings');
const run = V.tuneWords(nv({ state: 'tuning', tune_step: 7, tune_steps: 9, tune_eta_s: 230 }), settings(), NOW);
assert.equal(run.kind, 'running'); assert.equal(run.text, 'Tuning: step 7 of 9 · about 4 min left'); assert.equal(run.button, 'stop'); assert.equal(run.progress.toFixed(2), '0.67');
const runNoStep = V.tuneWords(nv({ sweep_state: 'running', sweep_note: 'tuning: waits for 120 s of steady mining' }), settings(), NOW);
assert.equal(runNoStep.text, 'Tuning: waits for 120 s of steady mining');
const tuned = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 7200 }), settings(), NOW);
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
assert.match(prior.text, /^Tuned 2 min ago from the fleet prior, confirmed · 2470 MHz at 100%/);
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
const off = V.tuneWords(nv({ tune_control: false, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)' }), settings({ power_control: false }), NOW);
assert.equal(off.kind, 'idle'); assert.equal(off.note, 'Measured only until Power control is on in Settings (Windows asks for administrator rights once)');
const pinned = V.tuneWords(nv({ tune_line: 'Tuned: 120 MH/s at 300 W (0.400 MH/W)', tune_source: 'full', pinned: true, power_pct: 80, sweep_at: NOW - 60 }), settings(), NOW);
assert.match(pinned.text, /^Your cap stays pinned at 80% · Tuned 1 min ago/);
const stopped = V.tuneWords(nv({ sweep_note: 'tuning stopped: a remote job took the GPU' }), settings(), NOW);
assert.equal(stopped.kind, 'stopped'); assert.equal(stopped.text, 'Tuning stopped: a remote job took the GPU');
const paused = V.tuneWords(nv(), settings({ tuning_off: true, tuning_note: 'tuning paused fleet-wide by the signed manifest' }), NOW);
assert.equal(paused.kind, 'paused'); assert.equal(paused.button, '');
assert.equal(V.tuneWords(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
assert.equal(V.tunable(card({ vendor: 'apple', kind: 'apple' })), true);
assert.equal(V.tunable(card({ removed_at: 5 })), false);
});
test('the next check is a weekday inside a week, a date beyond it, due now when overdue', () => {
assert.equal(V.nextCheck(0, 604800, NOW), '');
assert.equal(V.nextCheck(NOW - 700000, 604800, NOW), 'next check due now');
assert.equal(V.nextCheck(NOW - 604800 + 3600, 604800, NOW), 'next check within a day');
const inThree = V.nextCheck(NOW - 604800 + 3 * 86400, 604800, NOW);
assert.match(inThree, /^next check (Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)$/);
assert.match(V.nextCheck(NOW, 604800 * 3, NOW), /^next check \d{1,2} [A-Z][a-z]{2}$/);
assert.equal(V.nextCheck(NOW - 100, 0, NOW), V.nextCheck(NOW - 100, 604800, NOW), 'no period means seven days');
});
test('the Tuning card schedule and the goal consequence', () => {
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work by hand.');
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
assert.equal(V.schedule([nv()], settings({ tuning_off: true, tuning_note: 'tuning paused fleet-wide by the signed manifest' }), NOW), 'tuning paused fleet-wide by the signed manifest');
const g = V.goalWords('efficiency', [card({ sweep_watts: 290, power_w: 410 })], 28);
assert.equal(g.watts, 290); assert.equal(g.consequence, 'about £1.95 a day at 290 W');
assert.equal(V.goalWords('rate', [card({ power_default_w: 575 })], 28).watts, 575);
assert.equal(V.goalWords('balanced', [card()], 28).watts, 410);
assert.equal(V.goalWords('nonsense', [card()], 0).goal, 'balanced');
assert.equal(V.goalWords('balanced', [card()], 0).consequence, 'about 410 W');
});
test('the prove tier sentence per card, and the counts in one line', () => {
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (the prover is CUDA only).');
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
});
test('the node line, the activity line, the asks', () => {
const n = { state: 'synced', blocks: 109859, headers: 109859, peers: 6, daa: 198000, last_reading_age_s: 5, message: '' };
const l = V.nodeLine(n, { severity: 'none' }, '');
assert.equal(l.text, 'Node synced · 6 peers · 109,859 blocks'); assert.equal(l.tone, 'ok'); assert.match(l.sub, /every block/);
assert.equal(V.nodeLine({ ...n, peers: 1, blocks: 0, state: 'starting' }, { severity: 'none' }, '').text, 'Node starting · 1 peer');
assert.equal(V.nodeLine(n, { severity: 'block', skew_s: -412 }, '').tone, 'bad');
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? The miner restarts.');
assert.match(V.trustAsk(true), /^Include proof records the node never checked\? Devnet only; the node restarts\.$/);
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
});

View file

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

View file

@ -3,6 +3,6 @@
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.3.13"
#define IGNEUM_HOST_VERSION_RC 0,3,13,0
#define IGNEUM_HOST_VERSION_STR "0.3.14"
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
#endif

View file

@ -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 Josh'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: Josh 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)
@ -2508,3 +2520,27 @@ a 50 blocks/s flood from one peer, honest template p50/p95/max 0.4/0.6/1.4 ms, r
same once; a pool user nothing; a fleet operator gets a node that keeps its only peer, and a seed that stops amplifying old certificates to
every peer (13,354 lines of work it did not need in seven minutes). Owed: the fleet agent's synced-node reading; a receiver-side limit on
certificates per index per minute as a second belt once the seed is fixed; the formatter's reflow of `finality.rs` (taken out of the commit).
## 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.
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.
Re-point and proof, 16:48 to 16:53Z: the Power Helper task re-pointed by the helper itself ("1 reregister ok: the
task now runs ...Programs\Igneum Miner\igneum-app.exe --power-helper"), then the installed app's caps through the
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
through the installed app. Ember closed 16:53Z.

View file

@ -231,7 +231,7 @@ Two preconditions on the cut, from main (17:0x UTC, both from today's incident),
Facts for the cut from the node lane (docs/plans/counter-asic-3-node.md): the devnet digest moves (c562d70e... to 3c505021...), so the cut is a one-sweep binary rollout and a 0.3.13 node is refused at the handshake afterwards (intended, fleet-wide); a 0.3.13 miner reads the v4 height as never (an optional proto field), so miners and nodes move together; `infra/fast-time/override-60x.json` as committed carried the proving-v1 block twice and lacked four 0.3.12 and 0.3.13 fields (the node refused the file; fixed, with a new CI check `override-json-check.sh`); the 48 GiB target clone `vendor/igneum-node-ca3v4/target-ca3v4` can go after the cut.
THE ONE LINE FOR JOSH: class v4 (`mx8+sh256x27`, 100,000 ops per hash in the latency shadow) has passed every gate on the fixed tree (the program-id blocker closed with its own failed case) and is ready for a whole-fleet one-sweep cut (the digest moves to ac8e60ce... at the floor N6) once P1 passes: P2, miner-signalled activation (95 percent of blue-block weight over a one-day window, the floor height after which it flips regardless), is designed, implemented and GREEN on the fast-time gate with its failed case and on G6; P1, the rehearsal on the rented fleet as a staging network, has its plan and objects written and waits for the fleet agent's run; the clean-day wait removed by Josh ("can we run the v4 class now?", 18:2x UTC): the P1 rehearsal runs NOW on the fleet (15 prover boxes plus the four Devnet 2 boxes, the wave joining about 18:50Z) and the 0.3.15 cut (class v4 plus the fourteenth field, one digest move, miners first, hands last) goes tonight on Josh's go the minute P1 passes; its cost is the 5090 at 431 W instead of 350 for 0.2 percent less rate (a rig pays about 23 percent more electricity), the M5 Max at 37 W instead of 21 for 1.5 percent, the 9070 XT no rate at all (its watts pending), the verifier +0.27 ms per warp; what it buys is the stored-dataset chip's per-joule edge over the 5090 falling from 5.6x to 2.1x at a chip core equal to the GPU's; proposed for a day when no other cut is in flight, not tonight (0.3.14 and the fleet night come first).
THE ONE LINE FOR JOSH: class v4 (`mx8+sh256x27`, 100,000 ops per hash in the latency shadow) has passed every gate on the fixed tree (the program-id blocker closed with its own failed case) and is ready for a whole-fleet one-sweep cut (the digest moves to ac8e60ce... at the floor N6) once P1 passes: P2, miner-signalled activation (95 percent of blue-block weight over a one-day window, the floor height after which it flips regardless), is designed, implemented and GREEN on the fast-time gate with its failed case and on G6; P1, the rehearsal on the rented fleet as a staging network, has its plan and objects written and waits for the fleet agent's run; no date until 0.3.14 has run a clean day; its cost is the 5090 at 431 W instead of 350 for 0.2 percent less rate (a rig pays about 23 percent more electricity), the M5 Max at 37 W instead of 21 for 1.5 percent, the 9070 XT no rate at all (its watts pending), the verifier +0.27 ms per warp; what it buys is the stored-dataset chip's per-joule edge over the 5090 falling from 5.6x to 2.1x at a chip core equal to the GPU's; proposed for a day when no other cut is in flight, not tonight (0.3.14 and the fleet night come first).
## 6. Decisions for Josh
@ -291,13 +291,6 @@ A script never switches the installed app's cards: a POST of enabled false to /a
| 1 | the DRAM energy figures are streaming figures applied to random 32-byte reads; the GDDR7 burst and HBM3 tFAW are behind the JEDEC paywall; no chip has been built or torn down | |
| all | every AMD RDNA 4 number in this run | PC 1 is Josh's desk today. 16:0x UTC: the RX 9070 XT is back on PC 1 (amd:gfx1201, 16,304 MiB) beside the 5090 and an RTX 4070; main has asked for the PC 1 jobs to be PREPARED, not published: (1) G1 AMD plus item 8's ladder on the 9070 XT (about 15 min, the card alone), (2) item 6's family step costs on AMD (about 3 min), (3) item 2's dr736 build and compile on AMD (about 3 min), (4) optional: the 4070 ladder (about 10 min); branch ca3-pc1-amd (1f33cc6, e054ed7, merged): the four scripts pass every CI check, the kit zip sha256 a70fce5b... verified, the AMD watts readback is igneum-gpu-telemetry.exe (ADLX board watts); the commands and the row map in tools/ca3-pc1-amd/README.md; published one at a time on "go PC 1 AMD" after the 0.3.13 update and the Ember table run, about 33 min of PC 1 in all |
## 7a. The two fast-forwards to master (decision 5)
| Push | Commit | ci | windows-ci |
|---|---|---|---|
| 1 | 88f5026 (ca3-coord merged with master: Counter ASIC 3.0 complete, the two CI check fixes, the close) | GREEN (37508680115) | red on "payload inputs (payload-inputs.zip from the downloads host ...)", red since 630b537 at 17:51Z, the shipper's 0.3.14 payload on the downloads host; the last green windows-ci 2cf8851 at 15:53Z; untouched by this branch |
| 2 | 0396580 (35776fe: the runner's `--cards-off` with the restore on any exit, the quit-check's rule 2 with the dated allow list, the stripped PC 1 scripts; plus the executable bit on the quit-check) | GREEN (37509530709) | cancelled (37509530516): superseded by the build-server fix 3e6a488 pushed to master minutes later; the payload-inputs step is the shipper's to turn green with 0.3.15's payload |
## 8. Close
Closed 6 October 2026, 18:1x UTC. The lanes: item 1 (ca3-analysis), item 2 (ca3-derive), items 4 and 5 (ca3-detector), item 3 (ca3-crypto-brief), items 6 and 7 (ca3-reserve), item 8 (ca3-shadow), the PC 1 AMD jobs (ca3-pc1-amd), the hash gates (ca3-v4-hash) and the node gates with both preconditions (ca3-v4-node and the fork): thank you, every one of you, for the numbers and for the faults you found in your own work and in mine before they reached a cut.

View file

@ -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`,
@ -161,7 +161,54 @@ 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.
## 7a. One administrator approval, ever (0.3.13; Josh, 6 October 2026, 11:50 UTC)
## 6a. A card never shows 0 MH/s without a reason word (Josh, 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).
## 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
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; Josh, 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".
@ -184,8 +231,85 @@ 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, Josh'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), 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.
### The re-point and the no-prompt proof (6 October 2026, 16:48 to 16:53Z)
`ember-repoint-pc1-1` (unelevated): task action before = the scratch copy; kit-7 (bd7fcf4) put in its place;
`Start-ScheduledTask`; `1 reregister` written; helper.log at 16:48:42Z: "1 reregister ok: the task now runs
C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe --power-helper"; action read back = the
installed exe. Three seconds later the installed app's own caps went through the task with no prompt (helper.log:
"nvidia-smi -i 0 -pl 460: Power limit for GPU 01:00.0 was set to 460.00 W from 575.00 W"; "-i 1 -pl 160: set to
160.00 W from 100.00 W"). `ember-proof-pc1-2` (reads only): task state Running, run level Highest, user Admin;
5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W; all three mining through the installed app. The
Security log's process-start audit is off on PC 1, so a consent.exe count is unreadable; the helper's log is the
record. Known in 0.3.13 on PC 1 until 0.3.14: the rows show the app's own earlier baseline (no /api/tune-progress
to receive the run's result), and the 80% caps are the app's setting; the draw is the tuned one regardless because
the driver holds the clock locks and the caps do not bind.
## 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 |
|---|---|---|
| (this cut) | every task command goes to the helper's fixed folder (`powertask::helper_dir` = `<platform data root>/app/sweep`, IGNEUM_APP_DATA ignored): a scratch-root measurement engine talking to the task wrote under its own root, where the task never reads (found 6 October 2026 planning the unelevated climb; the elevated runs set limits directly and never crossed it) | platform.rs `fixed_data_root`, powertask.rs, engine.rs `helper_cmd_dir`, main.rs + test |
| (this cut) | a baseline result's row reads "Measured: ..." not "Tuned: ..." (`ember::result_line`); PC 1's 0.3.13 rows read "Tuned: 114.2 MH/s at 305 W" for a baseline | ember.rs, engine.rs + test |
| 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) |

View file

@ -0,0 +1,283 @@
# Igneum Miner UI 3: the audit, 6 October 2026
Josh, 18:00 UTC: "run another audit of the miner app and make it as gorgeous as possible and as simple as possible but
not lacking any features, think of this as an Apple product in terms of UI and UX, with tuning easy, buttons to tune
each card again, or if automated then fair enough." Branch `miner-ui-3`, worktree `../igneum-wt-miner-ui-3`, from
master b79f2cb. The design is `docs/plans/miner-ui-3.md`; this file is what was found.
## 1. How it was looked at
| What | How |
|---|---|
| The installed 0.3.13 app on this Mac | its URL from `~/Library/Application Support/Igneum/app/app.url`, opened in the built-in browser pane and shot headless with Playwright's own chromium (never Chrome.app). The miner is paused on Josh's order and was not resumed; `/api/state` read only. |
| The states this Mac cannot show (an NVIDIA rig, syncing, a node restart, a clock block, no cards, the setup screens) | `tools/ui-mock/server.mjs` on port 4317 from this worktree (the one on 4310 is another agent's and serves the old one-page UI; do not read it as master), scenarios `fresh`, `nvidia`, `syncing`, `nodedown`, `nocards`, `clock`. |
| The update card and the strips | the UI's own `?update=` and `?job=` sample states on the live app (client side only, nothing posted). |
| Sizes | 1280 x 820 (the shipped window), 900 x 600 (the hosts' floor), 390 x 844 (a narrow Windows window, a phone). |
Screenshots: `docs/plans/miner-ui-3/00-*.png` to `26-*.png`, named below. The live app's state while shooting: Apple
M5 Max, paused, node synced at 109,859 blocks with 6 peers, 23,056 lifetime blocks, proving off, external node.
## 2. What a first-time home miner sees, screen by screen
Rating: 10 = nothing to change. The three questions per screen: what does it say in jargon, which number has no
unit or no meaning, which 0 or blank has no reason word.
### 2.1 Welcome (`00-welcome.png`), 7/10
Good: one heading, one lead, three tiles, one button. The eyebrow "devnet v4 · nothing is bought or sold" is honest.
| Problem | Where |
|---|---|
| "Mined by GPUs. Proven by fire." is an aphorism (copy law). | lead |
| "Syncs the chain from the seed node", "A new program every hour. The next one compiles while you mine", "EVM address", "seed" (the last line) are jargon a home miner has never met. | the three tiles, the seed line |
| The coin is the retired ringed coin (`brand/README.md`: "the old ringed coin. Retired as a source"). The app icon is the plain square. | the hero |
| The welcome has no idea what the user will earn or spend; the one question a home miner has ("is this worth running?") is unanswered until the third screen. | whole screen |
Apple would: keep one line of what it does, three tiles in plain words (runs in the background, uses your graphics
card, pays an address you own), the button. Drop the seed line (there is no seed phrase in this app; it is a key).
### 2.2 Set up, step 1, your GPU (`01-setup-gpu.png`), 7/10
Good: one row per card, the switch, a sentence. The integrated-GPU rule is explained.
| Problem | Where |
|---|---|
| "worker Metal", "40 GPU cores", "64 GB unified": three facts, none the user chose or can change. "worker" is internal. | the row's meta line |
| "Each card you switch on gets its own worker" is engine talk. | the help note |
| The NVIDIA note about the 80% cap and the administrator prompt appears only when an NVIDIA card is present; that is right, but it is 44 words for a decision the user is not taking here. | `#cards-power` |
| No number: the row does not say what the card is expected to make. The fleet priors (`site/miner-priors.json`) know the model's MH/s and W; the row could say "about 120 MH/s at 290 W" for a known model. | the row |
Apple would: one row, name, switch, one line under the list. The cap note goes to the card where it applies.
### 2.3 Set up, step 2, where rewards go (`02-setup-address.png`), 8/10
Good: two options, one recommended, the paste box opens on choice, one button.
| Problem | Where |
|---|---|
| "EVM address", "secp256k1 key", "0x followed by 40 hex characters": the first is unavoidable once, the second is jargon that helps nobody. | the lead, option 1 |
| The dev-fee sentence sits here, on the address screen, as a surprise. It belongs where the fee is switched (Settings) and on the earnings line. | the lead |
| "Start mining" on this screen does not start mining when "Make me an address" is chosen: the key sheet comes first. The button lies once. | the primary button |
### 2.4 The key sheet (`03-setup-key.png`), 8/10
Good: shown once, both values with Copy, the file path, the checkbox gate.
| Problem | Where |
|---|---|
| "On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet." is an internal note on a user's first sheet. | the second note |
| "Settings can show it again": the key lives on Rewards, not Settings (`r-key-card`). Wrong page named. | the first note |
| The address box repeats what the next screen shows; the sheet could show the key alone, with the address beneath in small. | the two boxes |
### 2.5 Mine (`04-mine-live.png`, rig: `15-mine-nvidia-rig.png`), 6/10
What a home miner sees first after setup. Good: the big button, the rate, the rows, the blocks strip.
| Problem | Where |
|---|---|
| Four tiles compete with the big button: hash rate, blocks found, next program. "Next program 49:52 · next program not known yet (…)" is a countdown to an internal event, cut off mid-sentence. A home miner does not need an epoch timer on the first screen. | hero row |
| The card row's numbers are `n/a` three times on this Mac (hash rate, temperature, power), in ember, which reads as an error. Apple silicon reports no temperature or power, so the words should be "not reported on Apple silicon", once, not three red `n/a`. | GPU row |
| The row says "off · 64 GB unified" while the switch is ON and the big button says "Start mining": three states on one row. The card is on; the miner is paused. The row needs the word "paused". | GPU row |
| 0.0 MH/s in ember with "paused" under it: the number is right, but the eye reads a red zero. The rule (Josh, 6 October 2026, ember-tune.md 6a): a card never shows 0 MH/s without a reason word. The tile does carry "paused", so it passes, but in grey, under a red 0. | hash rate tile |
| No money anywhere. The page shows MH/s, W, °C, blocks. It never says what a block is worth, what the electricity costs, or what the machine made today. The one number a home miner wants is missing. | whole page |
| MH/W (efficiency) is on Settings, not on the row where the rate and the watts are. | GPU row |
| "Your blocks": "last 10 min 0 · last hour 0 · dev fee 0 · chain 109.8k". Four bare counts; "chain 109.8k" is the chain height with no label of what it means; "dev fee 0" is the count of blocks mined for the dev fee, with no unit word (the tooltip has it). | blocks card head |
| The blocks strip is 170 px of empty canvas for most of a home miner's day (one block an hour on a small card). The legend has three items for one kind of mark. | blocks card |
| Activity shows raw engine lines: "consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000, ..." as a JSON blob, "remote job: update check now; a newer version installs at once". The feed is a developer log, not a user's activity. | Activity |
| The rig shot: the 4070 row says "restart in 4 s · 12 GB · 6783 blocks · ne…" and the hash reads `n/a` in ember; the second sentence is cut. The reason for the restart (the engine's `message`) is not on the row. | 15 |
| "A power cap is not applied yet: Settings has a Retry for the administrator prompt." A problem on the Mine page sends the user to another page to fix it. Two taps minimum. | the note under the rows |
| The top bar repeats the pill: "paused · node synced · up 24 min" and then a pill saying PAUSED. | top bar |
| "1 OF 1 ON" eyebrow in capitals is a count of switches, not of cards mining. | card head |
Apple would: one hero (the big button and the live total: MH/s, W, £ a day), the card rows as the only list, a
one-line "today" (blocks, £, kWh), and move the blocks strip and the activity feed behind one "Activity" view.
### 2.6 Prove (`05-prove-live.png`), 5/10
| Problem | Where |
|---|---|
| 95 words of explanation before the switch: "short mathematical proof, in pieces called shards. The chain assigns shards to your keys ... a full shard needs 20.4 GB of GPU memory, measured". A home miner wants one sentence: can my card do this, and do I earn from it. | lead card |
| Two paragraphs say "Off" twice ("Off. Switch it on and this machine proves..."). | lead card |
| Four tiles with 0: state Off, assigned 0, proven 0, paid 0 "0.0000 IGN earned". Three zeros with no reason word. The reason is on the first tile ("off") and nowhere near the others. | the strip |
| "Verifier: off: relay only" in ember, then 50 words on what a verifier is, then "external node: the app did not start it, so it set no verifier". Internal plumbing. | Verifier card |
| "Program · pinned guest", "shard program id", "aggregator id": two 66-character hashes with Copy. Nobody at home copies these. | Program card |
| "Proving on; the first shard arrives within a minute" toast, "needs setup, one-time install, about 20 minutes" row: the only user-facing decision (install the prover) is a small button in the lead card. | pv-setup |
Apple would: one card. A tier sentence per card ("RTX 5090 proves while it mines", "RTX 4070 (12 GB) cannot prove: a
proof needs 20.4 GB", "Apple M5 Max proves on the CPU, slowly"), the switch, and one earnings line ("0.0000 IGN from 0
proofs"). The ids and the verifier go behind "Details".
### 2.7 Rewards (`06-rewards-live.png`), 6/10
| Problem | Where |
|---|---|
| "Balance: --, shown in the wallet, not here yet". A placeholder on the money page. The engine has no balance field (miner-ui-2 follow-up, never built). | balance tile |
| "This run: 0 since the app started, 24 min a…" cut off. | tile |
| No money. Blocks found 23,056 with no value. No reward per block, no IGN total, no £. | whole page |
| "Save your key" is a card with ember "ONCE" every time the page opens, even after the key is saved and acknowledged. | key card |
| "Use another address" is an open text box with Change next to it, on the same screen as the key. Pasting a wrong address and tapping Change moves every future block with no confirmation. | address card |
| The dev-fee line sits under the address box, in the "Use another address" card, where it has nothing to do. | r-devfee-line |
| "Open the wallet page" is a ghost button at the bottom of the second card. The wallet is where the balance is. | r-wallet |
Apple would: one card, money first (IGN earned, blocks found, £ a day at today's rate when a price exists), the
address with Copy, then "Change address" and "Show my key" as two rows that confirm in place.
### 2.8 Node (`07-node-live.png`, syncing `18-node-syncing.png`, clock `21-node-clock.png`), 5/10
| Problem | Where |
|---|---|
| Four tiles, four cards, eleven numbers: headers, DAA score, difficulty, tips, blue score, digest, next switch, last lock, age, votes sent, peers. A home miner needs one line: "the node is synced, 6 peers". | whole page |
| "DAA score", "blue score", "tips", "difficulty 137.73M", "consensus digest not printed yet", "finality miner-only", "last lock none yet", "age n/a", "votes sent 0 · waiting for the miner": every one is jargon or a 0 without a reason (the finality 0 has one, in a note, in grey). | Chain, Consensus, Finality |
| "Synced" appears three times (the tile, the Sync card, the top bar). | tiles, Sync card |
| The Sync card repeats the node tile's sentence word for word. | Sync card |
| The clock block (21) is right in shape (a card with Sync clock and the manual hint) but sits on the Node page; a home miner whose clock is wrong is looking at Mine, where the hash is 0. The rule here says the strip carries the clock only on the setup screens. | n-clock |
| "Read 5 s ago", "read every 10 s": two staleness marks for one reading. | tile, Chain eyebrow |
Apple would: one line on Mine ("Node synced · 6 peers · 109,859 blocks") and a Details view for the rest, since the
digest and the switches are needed when something is wrong, not every day.
### 2.9 Updates (`08-updates-live.png`, job strips `13`, `14`), 6/10
| Problem | Where |
|---|---|
| "Consensus upgrade at height 198,000." under the version with no meaning (the height is behind the node already: 198,490). | the lead line |
| The remote-jobs card: 60 words of explanation, a Check now, a 20-row table with columns job / kind / started / exit / report, job ids like `update-now-0313-switch-d937c69d`, "done (0)", "uploaded". This is the fleet operator's console, shown to every user. | Remote jobs |
| "signing key sha256:8f186e37b48…" at the foot. | s-jobs-key |
| Two "Check now" buttons on one page, one per card, both the same words. | both cards |
| The switch "Install updates by itself" and the paragraph under it are right. | lead card |
Apple would: one card: the version, "up to date, checked 13 min ago", the switch, Check. The jobs go into a history
row ("Igneum ran 20 jobs on this machine, the last 16:36") that opens the table.
### 2.10 Settings (`09-settings-live.png`, rig `16-settings-nvidia-rig.png`), 4/10
The page with the most work in it and the hardest to use.
| Problem | Where |
|---|---|
| The per-card block mixes a slider, two status lines, a tune line with a button, and an identities stepper with 30 words of help, per card. On the rig shot each card is 370 px tall; three cards fill two screens. | set-card |
| "Power cap 80% · 460 W", "cap applied: 460 W", "draw 441 W GPU 71 °C memory 92 °C efficiency n/a": the same watts three times; efficiency `n/a` while draw and rate are both known (the engine's `eff_mhw` was 0 in the mock; live it is computed). | set-card lines |
| "tuning: not run yet (starts after 120 s of steady mining)" with a Tune now button; nothing says when the next scheduled tune is, or when the last one ran. | tune line |
| Ember Tune's switch has a 118-word paragraph under it: the power ladder, the clock ladder, 75 s a step, 1% tolerance, rejected hashes, hot GPUs, dragged memory clocks, fleet priors, privacy. This is the engineering spec, on a switch. | s-sweep help |
| Power control: three sentences, then the same sentence again from the state ("Off: NVIDIA cards measure only; AMD cards need no rights." twice in the live shot). | s-power-control help |
| "Identities: 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." A stepper 1 to 64 for a concept the user cannot picture. The engine picks a default per card already. | ids |
| "cap not applied (needs the administrator prompt) Retry" in ember: this is the one thing a Windows NVIDIA user must do, and it is a tiny button inside the second card's fourth line. | 16, the 4070 |
| "name: a name for this machine · Rename. A label for you only. Keys come from the machine id d937c69d, never from the name." Jargon for a text box. | This machine |
| "Vote on finality checkpoints: Your miner signs a checkpoint every 30 s. Votes are what lock the chain; leave it on." A switch whose help says never to touch it. | s-vote |
| "Trust proof records without verifying them" under Advanced: devnet only, restarts the node. | s-trust |
| "Dev fee 1% (1 block in 100)" with a switch: right, but the fee's own line is "Dev fee 1% (1 block in 100); the miner names the address when it starts". | s-devfee |
| Logs: two buttons and two file paths. Right, but the log is also in the rail. | Logs card |
| No electricity price anywhere, so nothing in the app can say £. | whole page |
Apple would: Settings as a list of plain rows: Tuning (goal, Power control, schedule), Start at login, Updates,
Remote jobs, Dev fee, Electricity price, This machine's name, Logs, Advanced. The per-card controls live on the
card, on Mine, where the numbers are.
### 2.11 The update card (`11-update-card-ready.png`), 9/10
The best screen in the app: the mark with a ring, the name, one line, three notes, the size, two buttons. It says
"Igneum Ember 0.3.7" while the app is "Igneum Miner" everywhere else (`UpdateCard.NAME`). Keep it, fix the name.
### 2.12 The notices strip (`12-strip-update-downloading.png`, `13-strip-job-running.png`, `14-strip-job-failed.png`), 7/10
One notice at a time, actions on the right, a close. Right. Two problems: the job strip shows raw `RESULT shard=2
prove_s=39.8` lines as the detail (engine output to a user), and the strip pushes the whole page down 100 px when it
appears (the hero row jumps).
### 2.13 The log drawer (`10-logs-drawer-live.png`), 8/10
Chips, search, a time jump, last error, last swap, wrap, copy, follow, a ruler. Complete and quick (virtualised).
It is a developer's tool and it is fine as one. "WATCH" as a source chip means nothing to a user; the lines under it
are the observer's. Keep, rename nothing, hide behind Logs as now.
### 2.14 The tray and the menu bar item
macOS (`app/mac/IgneumMiner.swift`): Open Igneum Miner, Pause mining / Resume mining, "Node: synced, 109,859 blocks,
6 peers", "Blocks found: 23,056", Quit Igneum Miner. Windows (`app/windows/host.cpp`): the tip "Igneum Miner: 17.0
MH/s, 8,058 blocks found, node synced", a menu with Open, Pause and Quit, and a balloon "The window is in the tray.
Right-click the icon to pause or quit." Both fine. Neither shows money or watts; both get them when the state has
them (the Rust side formats the tip from `hash_total`, `accepted_total`, `node.state`; a `watts_total` and a
`pounds_per_day` field would complete it; owed, not built here).
### 2.15 Narrow (`22-narrow-900-mine.png`, `23`, phone `24-phone-mine.png`, `25`)
900 x 600: the rail folds to icons, the hero goes two by two, the rows drop the power column. Usable. 390 wide: the
rail still takes 150 px, "Stop mining" is cut to "Stop minin", the GPU row's numbers overflow the card to the right
(`413` half off screen), the tiles truncate every sub-line. The Windows window can be dragged to 400 px wide and the
layout breaks there. No phone layout exists.
### 2.16 Light mode (`26-light-scheme-mine.png`)
There is none. `<meta name="color-scheme" content="dark">`, one token set on `:root`, no `prefers-color-scheme`
block. The page is dark whatever the OS says.
## 3. Every feature the old UI has (the build's tick list)
| # | Feature | Where it is today | Route |
|---|---|---|---|
| 1 | Welcome, GPU pick, address choice, key sheet | the three setup screens | `api/detect`, `api/cards`, `api/setup`, `api/key/saved`, `api/start` |
| 2 | Start / stop mining | Mine big button | `api/pause`, `api/resume` |
| 3 | Per-card on/off switch | Mine row, setup row | `api/cards` (the whole list) |
| 4 | Hash rate, temperature, power per card; totals | Mine row, hero tiles | state |
| 5 | Blocks found, this run, last 10 min, last hour, dev fee count, chain height | Mine tiles and blocks card | state |
| 6 | Next program countdown and epoch | Mine tile | `program` |
| 7 | Blocks strip (10 min canvas) | Mine | state `found` |
| 8 | Activity feed | Mine | `events` |
| 9 | Hot-plug states: new card, removed, not usable (Code 43) | Mine row, strip | state |
| 10 | Proving switch, setup button, state/assigned/proven/paid, segments line, verifier, program ids | Prove | `api/prove`, `api/prove/setup` |
| 11 | Rewards address with Copy, source, blocks, this run, balance placeholder | Rewards | state |
| 12 | Show / hide the private key, the wallet file path | Rewards | `api/key/reveal` |
| 13 | Change the rewards address | Rewards | `api/settings {address}` |
| 14 | Open the wallet page | Rewards | `api/open` |
| 15 | Node state, height, peers, version, chain numbers, digest with Copy, switches list, next switch, finality, sync ETA | Node | state |
| 16 | Clock skew card with Sync clock and the manual hint | Node, setup strip | `api/clock/sync` |
| 17 | Version, update note, Check now, Install now, auto-update switch | Updates | `api/update/check`, `install`, `auto`, `open` |
| 18 | The update card (modal) and the strip notices | everywhere | same |
| 19 | Remote jobs: allow switch, Check now, note, history table, signing key | Updates, Settings | `api/jobs/allow`, `api/jobs/check` |
| 20 | Per-card power cap slider (NVIDIA), applied / not applied + Retry, draw and temperatures, efficiency | Settings card | `api/cards {power_pct}`, `api/power/apply` |
| 21 | Per-card identities stepper | Settings card, setup row | `api/cards {identities}` |
| 22 | Ember Tune switch, per-card Tune now / Stop / Unpin, the tune line | Settings | `api/sweep/enable`, `start`, `stop`, `pin` |
| 23 | Power control switch | Settings | `api/power/control` |
| 24 | Start at login, vote, dev fee, machine name, trust mode, live page | Settings | `api/settings` |
| 25 | Copy the log, show the log, the log and chain folders | Settings | `api/log` |
| 26 | The log drawer (chips, search, time, last error, last swap, wrap, copy, follow, ruler, resize) | rail | `api/log` |
| 27 | Quit (with a confirm dialog) | rail | `api/quit` |
| 28 | The pill, the top status, the rail foot (name, address, version, chain) | chrome | state |
| 29 | Keyboard: arrow keys on the rail, Escape on the card, Enter on the time box | chrome | none |
| 30 | `?page=`, `?screen=`, `?update=`, `?job=`, `?logs=1`, `?visible=1`, `?debug=1`, `?host=mac` | URL | none |
Ember's new fields (ember-tune e9738dd, from the agent): `tune_step`, `tune_steps`, `tune_eta_s`, `tune_plan`
(full | confirm | baseline | climb), `tune_curve`, `tune_mem_mhz`, `mem_cap_mhz`, card states `tuning` and `held`;
`settings.tune_goal` (efficiency | balanced | rate), `settings.power_price_pence`, `settings.tune_climb`,
`settings.tune_period_s`; `POST /api/tune/goal {goal, price_pence, climb}`. No next-tune field: next = `sweep_at +
tune_period_s`, "due now" when `sweep_at` is 0.
## 4. The top ten findings
| # | Finding | Consequence per tier |
|---|---|---|
| 1 | No money anywhere. No electricity price, no £ a day, no IGN earned on Mine or Rewards. | Every tier. A home miner with one card cannot tell whether the app pays for its power; a rig owner cannot compare cards. |
| 2 | Three red `n/a` per card row on Apple silicon, 0.0 MH/s in ember, "off" on a row whose switch is on. A paused Mac looks broken. | macOS, every card. Apple silicon never reports W or °C, so every Mac user sees this forever. |
| 3 | Tuning is split: the schedule and the switch on Settings, the row on Mine, the tune line per card on Settings, no "tuned 2 h ago, next Sunday", no Tune all, no goal. | Every tier with a tunable card (NVIDIA, AMD on Windows). |
| 4 | Settings per-card block is 370 px per card: slider, identities, tune line, telemetry. A six-card rig scrolls through 2,200 px of controls. | Rigs most; single cards get a page that looks like a rig's. |
| 5 | Prove is 95 words plus four zeros plus two hashes. The tier sentence ("this card cannot prove: 12 GB") is not said anywhere. | 8, 12 and 16 GB cards see a switch that will fail; 24 and 32 GB cards do not see that they can. |
| 6 | Node is eleven numbers for one fact. DAA, blue score, tips, digest, lock, votes: all jargon with no reason words. | Every tier; nothing to do per tier. |
| 7 | The remote-jobs history table and the signing key are on every user's Updates page. | Every tier sees the fleet console. |
| 8 | Fixes live on another page: "cap not applied, Settings has a Retry", "Switch a GPU on below first", "Settings turns it off". Two taps for every fix. | Windows NVIDIA most (the administrator prompt). |
| 9 | No light mode; no layout under 900 px (the Windows window can be narrower; the rail eats 150 px at 390). | Windows laptop users with a half-screen window. |
| 10 | Developer lines leak into user surfaces: the Activity feed's JSON, the job strip's `RESULT` lines, "Igneum Ember" on the update card, "worker Metal", "pinned guest", "miner-only", "shown in the wallet, not here yet". | Every tier. |
Smaller, fixed in the build with no mention elsewhere: the key sheet says "Settings can show it again" (it is
Rewards); "Start mining" on the address screen opens the key sheet instead; "Synced" three times on Node; two
"Check now" on Updates; "Once" eyebrow on the key card forever; the retired ringed coin on Welcome; "Mined by GPUs.
Proven by fire." (aphorism); the quit `confirm()` dialog (the viewer never shows dialogs: the rule in the brief).
## 5. What the previous redesign (miner-ui-2) set out to do and what it achieved
It set out to split one page into sections, put every setting on a section with one line of help, and lay out from
900 x 600. It achieved all three: the rail works, the keyboard works, nothing was lost, the update card and the
strip are good. What it did not do, by its own section 4: the balance, the pause-while-I-use-it switch, the log
export. What it did not see: it moved the controls off the cards and on to a page, so the thing the user tunes (a
card) and the controls that tune it are two sections apart; it kept the engine's vocabulary on every page; and it
never put a currency on screen. UI 3 keeps the rail's sections as views, puts the card back at the centre, and
adds the money.

277
docs/plans/miner-ui-3.md Normal file
View file

@ -0,0 +1,277 @@
# Igneum Miner UI 3: the card is the product
6 October 2026. Josh, 18:00 UTC: "as gorgeous as possible and as simple as possible but not lacking any features,
think of this as an Apple product in terms of UI and UX, with tuning easy, buttons to tune each card again, or if
automated then fair enough." The audit is `docs/plans/miner-ui-3-audit.md`. Branch `miner-ui-3`, worktree
`../igneum-wt-miner-ui-3` from master b79f2cb. The 0.3.14 cut takes it. Ember Tune's engine (branch `ember-tune`,
agent a855dcc4bd05e0615) is not touched: this plan reads its fields and posts to its routes.
## 1. The one job of each screen
| Screen | Its one job | What it holds | What left it |
|---|---|---|---|
| Welcome, GPU, address, key | get to mining in two taps with an address the user owns | as miner-ui-2, shorter words, the mark instead of the coin, the key sheet alone | the aphorism, "seed", "secp256k1", "worker", the devnet vote-key note |
| Mine | is the machine mining, and what is it worth | the big button, the live total (MH/s, W, £ a day), the card rows, the tuning card, the node line, activity | the next-program tile (to the node details), the four bare counts, the JSON in the feed |
| Earnings | what did it make, where does it go | money and IGN first, blocks, the address, change address, show key, the dev fee, the wallet | the balance placeholder, the "once" eyebrow |
| Prove | can this machine prove, and is it | one switch, one tier sentence per card, one counts sentence, Set up when needed; details behind a disclosure | the 95-word lead, the four zero tiles, the two hashes on the surface |
| Settings | the switches, one sentence each | tuning (goal, Ember Tune, Power control, electricity price), this machine, updates (one card), remote jobs (one row, history behind it), logs, appearance, advanced | the per-card controls (to the card), the spec paragraph, the second "Off" sentence |
| Node | gone as a section | one line on Mine; "Details" opens the chain numbers, digest, switches, finality, version, clock | the eleven-number page |
| Updates | gone as a section | one card on Settings; the update card and the strip as they are | the jobs table on the surface |
| Logs | the engineer's view | the drawer as it is | nothing |
Four sections in the rail: Mine, Earnings, Prove, Settings. Logs and Quit in the foot. The rail keeps the arrow
keys and `aria-current`.
## 2. The card row, the hero object
One row per card, ordered by performance (branch `card-order` ffb2bfa, `View.shownCards`). Everything a card can
say is on its row; everything a card can be told is on its row or one tap under it.
```
[NV] NVIDIA GeForce RTX 5090 DISCRETE 122 MH/s 290 W £1.95 a day 61 °C [Tune] (o)
● mining · 32 GB · 3 blocks 0.42 MH/W at 28p/kWh
Tuned 2 h ago · 2470 MHz at 100% · next check Sunday ⌄
```
| Part | Rule |
|---|---|
| Name and model | `cd.name`, the kind pill (Discrete, Integrated, External, Apple silicon), the tooltip as today (`View.cardTitle`) |
| State word | always present, with the reason: `mining`, `tuning: step k of n` (plus "about N min left"), `paused`, `waiting for the node`, `restarting in N s` (plus the engine's message), `starting`, `off` (plus the reason when the engine gives one), `held for a remote job: <title>`, `stopped after repeated faults`, `failed`, `not usable (Code 43)` (plus the reboot hint), `removed` |
| Rate | `hash_now` as `122 MH/s` (one decimal under 100); blank with the state word when not mining; never a 0 |
| Power | `power_w` as `290 W` with `0.42 MH/W` under it (`eff_mhw`, or rate over watts); a card that reports no draw (Apple silicon, an integrated card) hides the cell and the sub-line says `no power or temperature reading on Apple silicon` once |
| Money | `£1.95 a day` = W × 24 / 1000 × price / 100 at the electricity price; with no price the cell reads `set a price` and taps to the price field; with no draw the cell is hidden |
| Temperature | `61 °C`, amber from 86 °C (memory 90), ember from 92 °C (memory 95); hidden when not reported |
| Switch | the card on or off, as today (`api/cards` with the whole list) |
| Tune | `Tune` queues `api/sweep/start {key}`; during a run the button is `Stop` (`api/sweep/stop`); hidden for a card with no control and no measurement (`sweep_supported` false and vendor other) |
| Tune line | the schedule sentence, section 4 |
| Disclosure | the chevron opens the card's details: the power cap slider (NVIDIA, with the watt number, the applied or Retry line), identities (stepper, one sentence), pin or unpin, the memory temperature, the clocks, the device facts, the last tune's table (`tune_curve`) when there is one |
The rule from ember-tune.md 6a holds everywhere: a card never shows 0 MH/s without a reason word. A paused miner's
row says `paused`; the switch stays on because the card is on. The old `n/a` cells are gone: a cell is shown with a
number or not shown.
## 3. The Mine page
```
[ Start mining ] 122 MH/s · 290 W · £1.95 a day · 3 blocks today
mining on 1 of 1 card
Your cards [Tune all]
row
row
Tuning Efficiency | Balanced | Maximum about £1.95 a day at this goal
Ember Tune on: once after install, then every 7 days. Last tune 2 h ago, next check Sunday.
Power control [switch] Lets the app set NVIDIA power and clock limits. Windows asks once.
Node synced · 6 peers · 109,859 blocks · read 5 s ago Details ⌄
Activity Open the log
strip (today's blocks on a 10-minute line)
the last 8 events, one line each
```
| Element | Rule |
|---|---|
| The big button | the only primary (ember) control on the page. Start mining / Stop mining / Tuning (disabled, "mining again in about N min") / Held / Stopping. `View.toggle`, with ember-tune's tuning and held states. |
| The live total | `hash_total` MH/s; W = the sum of `power_w` over present cards; £ a day from that sum; blocks today = `found` in the last 24 h is not on the state, so "N this run" (`accepted_session`) with the lifetime count in the sub-line |
| Tune all | queues `api/sweep/start` for every present, enabled, tunable card in list order (the engine runs one at a time). Hidden when Ember Tune is off fleet-wide (`settings.tuning_off`), where the line says why. |
| Goal | a three-way segmented control, `settings.tune_goal` efficiency / balanced / rate (labelled Maximum), `POST /api/tune/goal {goal}`. The consequence line: at Efficiency the tuned watts (`sweep_watts` summed) at the price; at Maximum the default watts (`power_default_w` summed); Balanced between. A build without the route (0.3.13) gets a toast and the control stays on its old value. |
| Schedule | `sweep_at` and `settings.tune_period_s` (default 604,800): "Last tune 2 h ago, next check Sunday" per the last-tuned card; "Not tuned yet: the first tune starts 2 min into steady mining" when nothing has run; "Tuning now: RTX 5090, step 7 of 9" while one runs. |
| Power control | one switch, one sentence. Shown on Mine only when an NVIDIA card is present and control is off (the fix where the problem is); always on Settings. |
| Node line | `View.nodeWords`; Details opens the old Node page's content in place: the chain numbers (headers, DAA score, difficulty, tips, blue score, each with its one-line meaning), the digest with Copy, the switches with the next one, finality, the version, the clock card when the clock is off (the clock card also shows above the big button when it blocks mining). |
| Activity | the blocks canvas at 110 px with "N blocks in the last 10 min · N in the last hour"; the feed shows the newest 8 events, a line cut at 140 characters, a JSON tail replaced by "(details in the log)"; a link opens the drawer |
## 4. Tuning in words
| State | The row's tune line | Where else |
|---|---|---|
| never run, Ember Tune on | `Not tuned yet: starts after 2 min of steady mining` | the Tuning card's schedule line |
| running | `Tuning: step 7 of 9 · about 4 min left` with a thin progress bar (`tune_step / tune_steps`) and the Stop button; the state word reads `tuning: step 7 of 9` and the rate stays live | the strip (ember-tune's `tuneNotice`), the big button |
| tuned | `Tuned 2 h ago · 2470 MHz at 100% · next check Sunday`, the Tuned line's numbers (`tune_line`: 122.3 MH/s at 290 W, 0.422 MH/W) in the details | the Tuning card |
| from the fleet prior | `Tuned 2 min ago from the fleet prior, confirmed · next check Sunday` | |
| measure only (Apple, NVIDIA with Power control off, AMD on Linux) | `Measured 122 MH/s at 290 W (0.42 MH/W) as it runs` and the reason from `sweep_note` (`measure only on Apple silicon: the system sets the clocks and the power`) | Power control switch on Mine for the NVIDIA case |
| pinned | `Your cap stays pinned at 80% · measured ...` with Unpin in the details | |
| stopped | `Tuning stopped: a remote job took the GPU` until the next run | |
| fleet pause | `Tuning paused fleet-wide by the signed manifest` on the Tuning card; the Tune buttons hidden | Settings |
| no control, no measurement (an integrated card, vendor other) | no tune line, no button | |
Next check = `sweep_at + settings.tune_period_s`, as the weekday when under 7 days away, else the date; "due now"
when `sweep_at` is 0 and the card mines. The engine also re-tunes on a driver or program change; the line does not
predict that.
## 5. Earnings
```
£0.00 earned IGN: nothing is bought or sold on devnet
0.0000 IGN from 0 proofs
23,056 blocks lifetime · 0 this run · 1 in 100 to the dev fee (0 so far) [dev fee switch]
£1.95 a day electricity at 28p/kWh for 290 W Set the price
Rewards address 0xdd44…86E8 [Copy] made on this machine
Change address ⌄ → a box, Change, then in place: "Pay 0x12…34 from the next block? Confirm Cancel"
Show my key ⌄ → in place: "Show the private key on screen? Anyone who sees it can spend." Show Cancel
then the key box, Copy, Hide, the file path
[Open the wallet] the balance is in the wallet
```
Money first, IGN second, as asked. Devnet has no price for IGN, so the £ earned line says why it is £0.00 instead
of showing a placeholder; the electricity line is real money and is shown as a cost. When a market price exists the
engine adds `price_gbp_per_ign` and the line fills in (owed, engine).
## 6. Prove
```
Prove on this machine [switch]
RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).
RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).
Apple M5 Max: proves on the CPU, slowly.
State: idle · nothing assigned · 0 assigned · 0 proven · 0 paid · 0.0000 IGN
[Set up] About 20 minutes, once. (only when the prover is not installed)
Details ⌄ verifier (word and reason), the program id and the aggregator id with Copy, the segments line
```
The tier sentence per card: NVIDIA with 24 GB or more "proves while it mines"; NVIDIA under 24 GB "cannot prove a
full shard (N GB; a full shard needs 20.4 GB)"; Apple silicon "proves on the CPU, slowly"; AMD and other "cannot
prove yet (the prover is CUDA only)". The 20.4 GB and the 24 GB rule are the app's own words today
(`index.html`, Prove lead); the engine's `proving.available` and `backend` win when they disagree.
## 7. Settings
Plain rows, one sentence each, grouped:
| Group | Rows |
|---|---|
| Tuning | goal (segmented) · Ember Tune (switch: "Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.") · Power control (switch: "Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.") · Electricity price (pence per kWh; the £ figures use it) · Hill climb (switch, Ember 2, when `tune_climb` is on the state) |
| This machine | Start at login · Name · Allow remote jobs from Igneum ("Signed jobs run here once and report back.") with "History" opening the table and the signing key · Appearance: System / Light / Dark |
| Updates | one card: "Igneum Miner 0.3.13 · up to date, checked 13 min ago" · Check · Install now when ready · "Install updates by itself" switch |
| Logs | Copy the log · Show the log · the two folders |
| Advanced | Vote on finality checkpoints · Trust proof records (devnet, confirm in place, restarts the node) · Open the live devnet page · the machine id |
The dev fee moved to Earnings (it is money). Identities and the power cap moved to the card.
## 8. Confirmations in place
No `confirm()`, no dialogs (the viewer never shows them). A destructive or costly action turns its own row into a
question with two buttons:
| Action | The row says |
|---|---|
| Quit | `Quit Igneum Miner? Mining stops first, then the node. Quit Cancel` |
| Change address | `Pay 0x12…34 from the next block? The miner restarts. Confirm Cancel` |
| Show my key | `Show the private key on screen? Anyone who sees it can spend what the address holds. Show Cancel` |
| Trust mode | `Include unverified proof records? Devnet only; the node restarts. Turn on Cancel` |
| Stop a tune, stop mining, switch a card off, Tune all, the dev fee, identities | no question: each reverts in one tap and the toast says what happened |
## 9. Type, spacing, colour
| Token | Value | Use |
|---|---|---|
| `--t-xs` 11, `--t-sm` 12, `--t-base` 13, `--t-md` 14, `--t-lg` 15, `--t-xl` 17, `--t-num` 24, `--t-hero` 34, `--t-h2` 28, `--t-h1` 44 | px | eight sizes; Unbounded only for the page title, the big button, the hero numbers and the row's rate; IBM Plex Sans for words; IBM Plex Mono for labels, units and small numbers |
| `--s-1` 4, `--s-2` 8, `--s-3` 12, `--s-4` 16, `--s-5` 24, `--s-6` 32 | px | one spacing scale; cards pad `--s-5`, rows pad `--s-4`, gaps `--s-3` |
| radius | 16 card, 12 row, 10 button, 999 pill | |
| dark | obsidian `#0C0C0E` page, graphite `#16161A` card, `#111114` row, lines `#2A2A30` | the brand |
| light | `#F4F1EC` (bone) page, `#FFFFFF` card, `#FAF8F5` row, lines `#E2DED8`, ink `#16161A`, ash `#6B6B70` | `prefers-color-scheme: light` and `[data-theme=light]`; ember stays `#F2541B`, molten darkens to `#B8731F` for text on light |
| ember | the big button, the one primary per view, the rate, errors | one accent |
| molten | live states (mining, synced), the pill | |
Contrast: every text on its background at 4.5:1 or more (ash on obsidian 5.9:1; ash on bone 5.2:1).
## 10. Phone width and the narrow window
| Width | Layout |
|---|---|
| 1180 px and up | rail 196 px, the hero in one row, the rows with every cell |
| 900 to 1180 | rail folds to icons, the hero wraps to two lines |
| 720 to 900 | rail 76 px, the row's money and temperature cells go under the name as a second line |
| under 720 | the rail becomes a bottom tab bar (Mine, Earnings, Prove, Settings; Logs and Quit under Settings), the hero stacks, every row is two lines (name and state; numbers in a 2 by 2 grid), the big button full width, 16 px gutters, no horizontal scroll |
## 11. What the engine is asked for (none of it built here)
| Field or route | Why | Owner |
|---|---|---|
| `settings.tune_goal`, `settings.power_price_pence`, `settings.tune_period_s`, `POST /api/tune/goal` | the goal, the price, the schedule | ember-tune (exists at e9738dd) |
| `tune_step`, `tune_steps`, `tune_eta_s`, `tune_plan`, `tune_curve`, states `tuning` and `held` | the running line, the details table | ember-tune (exists) |
| `watts_total` on `mining` and `pounds_per_day` | the tray tip and the menu bar; the UI sums the cards itself | engine, owed |
| `price_gbp_per_ign` | £ earned when a market exists | engine, later |
| `address.balance_wei` | the balance on Earnings (miner-ui-2 follow-up) | engine, owed |
Until a field arrives the UI treats it as unset: no goal control change is sent to a build without the route (the
toast says "needs the Ember Tune update"), the price is kept in the page's own storage as a fallback, the schedule
line says "not tuned yet".
## 12. Tests
`view.test.mjs` grows with: the row model for every state word (mining, tuning with step and eta, paused, waiting,
restarting with the message, off with the reason, held, faulted, failed, unusable, removed), the money line at a
price and without one, the power and temperature cells hidden when not reported, the tune line for never, running,
tuned, prior, measured, pinned, stopped and fleet-paused, the next-check weekday, the prove tier sentence per vendor
and memory, the node line, the goal consequence, the quit and address confirmations' words. `tune-line.test.mjs`
is ember-tune's version (the row word, the button, the strip). `notices.test.mjs` and `update-card.test.mjs` are
unchanged and pass.
## 13. The build (6 October 2026, evening)
Branch `miner-ui-3`, three UI files rewritten, nothing on the Rust side (the engine is read as it is; the Ember
fields are read when present and treated as unset when a build lacks them). Screenshots of the result beside the
audit's: `docs/plans/miner-ui-3/n00-*.png` to `n26-*.png` (the mock on port 4317 from this worktree, the rig,
the Mac, tuning, tuned with the details open, the node details, earnings with the address ask, prove, settings,
the quit ask, paused, syncing, the clock, the update card, the job strip, the logs, 900 px, 390 px, light).
| File | What |
|---|---|
| `app/igneum-app/ui/index.html` | four pages behind the rail; the Mine hero (big button, the live total: MH/s, W, £ a day, blocks); the Tuning card (goal, schedule, Power control when it is the fix); the node line with Details; Activity with the strip and eight events; Earnings (money rows, the dev-fee switch, the address, Change address and Show my key as disclosures with their asks in place); Prove (switch, tier list, one counts line, Set up, Details); Settings (Tuning, This machine with the jobs row and History, Appearance, the update card, Logs, Advanced with the trust ask); the quit ask strip; the key sheet with the key first; the welcome with the mark, not the retired coin |
| `app/igneum-app/ui/app.css` | dark and light token sets (`prefers-color-scheme` and `[data-theme]`), the row grid (`.gl-main`, `.nums`, `.acts`, `.gl-tune`, `.gl-details`), the totals, the segmented control, the disclosures, the asks, the bottom tab bar under 720 px, the folded rail under 1000 px |
| `app/igneum-app/ui/app.js` | `Notices` with ember-tune's `tuneNotice`; `UpdateCard.NAME` is "Igneum Miner" (was "Igneum Ember"); `View` adds `rowWord`, `cells`, `poundsPerDay`, `money`, `effOf`, `wattsTotal`, `noReading`, `tuneWords`, `nextCheck`, `schedule`, `goalWords`, `nodeLine`, `proveTier`, `proveLine`, `eventLine`, `addressAsk`, `trustAsk` and ember-tune's `tuneWord`, `tuneEta`, `tuningCard` and toggle states; `TuneLine` is ember-tune's plus `pounds`; the DOM block renders the four pages, the row with its details (cap slider, identities, pin, telemetry, the tune table, the facts), the asks, the goal (`POST api/tune/goal`, a toast when the build lacks it), the price (the engine's `power_price_pence` when set, else the page's own storage), the appearance; `?tune=running|tuned|measured` for screenshots |
| `app/igneum-app/ui/view.test.mjs` | 7 new tests: the row word per state (paused on an on card, held, tuning, starting, failed), money and the cells, the tune line for every state, the next check, the schedule and the goal, the prove tier and line, the node line, the activity line, the asks |
| `app/igneum-app/ui/tune-line.test.mjs` | ember-tune's version (e9738dd) as is, so the ember-tune merge carries no test change |
| `app/igneum-app/ui/update-card.test.mjs` | one expectation: the card's name |
`node --test` on the four UI files: 35 pass. The Rust side is untouched, so the app's unit tests are as master's; the
0.3.14 shipper runs them on PC 2 as usual (the rule: test suites run on the PCs).
### The tick list (section 3 of the audit)
| # | Feature | Where it is now |
|---|---|---|
| 1 | Setup screens and the key sheet | as before; the address screen's button says Continue until "Use my own address" is picked |
| 2 | Start / stop | the big button |
| 3 | Per-card switch | the row |
| 4 | Rate, temperature, power per card; totals | the row's cells (left out when not reported); the hero's total with W and £ |
| 5 | Blocks found, this run, last 10 min, last hour, dev-fee count, chain height | the hero (this run, lifetime), Activity's line, the dev fee on Earnings, the height on the node line |
| 6 | Next program countdown and epoch | the node details |
| 7 | Blocks strip | Activity, 110 px |
| 8 | Activity feed | Activity, eight lines, JSON cut |
| 9 | Hot-plug states | the row (the words and the strip as before) |
| 10 | Proving switch, setup, counts, segments, verifier, program ids | Prove: the switch, the tier list, one line, Set up, Details |
| 11 | Address with Copy, source, blocks | Earnings |
| 12 | Show / hide the key, the file path | Earnings, "Show my key" with the ask |
| 13 | Change the address | Earnings, "Change address" with the ask |
| 14 | Open the wallet | Earnings |
| 15 | Node state, height, peers, version, chain numbers, digest, switches, finality, sync ETA | the node line and its Details |
| 16 | Clock card with Sync clock | Mine, above the hero (and the strip on the setup screens) |
| 17 | Version, update note, Check, Install now, auto-update | Settings, one card |
| 18 | The update card and the strip | unchanged |
| 19 | Remote jobs: allow, Check now, note, history, signing key | Settings, This machine: the switch, the line, History |
| 20 | Power cap slider, applied / Retry, telemetry, efficiency | the row's details; MH/W on the row |
| 21 | Identities | the row's details |
| 22 | Ember Tune switch, Tune / Stop / Unpin, the tune line | Settings (switch); the row (Tune, Stop, the line); Unpin in the details; Tune all on the card list |
| 23 | Power control | Settings, and Mine's Tuning card when an NVIDIA card has it off |
| 24 | Start at login, vote, dev fee, name, trust, live page | Settings (vote and trust under Advanced), the dev fee on Earnings |
| 25 | Copy the log, show the log, the folders | Settings, Logs |
| 26 | The log drawer | unchanged |
| 27 | Quit | the rail, with the ask strip instead of a dialog |
| 28 | The pill, the rail foot | the pill (with "tuning"); the foot has the name, the address, the uptime |
| 29 | Keyboard | the rail's arrows (left and right too), Escape on the card and the quit ask, Enter on the price and the time box |
| 30 | The URL switches | as before, plus `?tune=` |
| new | Goal, Tune all, the price, £ a day, MH/W on the row, the tune schedule, the prove tier, the node line, light mode, appearance, the bottom tab bar | this build |
### Owed
| What | Who |
|---|---|
| `watts_total` and `pounds_per_day` on the state for the tray tip and the menu bar; `address.balance_wei`; `price_gbp_per_ign` | the engine (section 11) |
| The wallet's copy of the update card still says "Igneum Ember" (`app/igneum-wallet/ui/update-card.js`) | the wallet |
| A Windows and a PC screenshot of the row with a real NVIDIA card (the telemetry cells, the cap slider, Retry) | the 0.3.14 cut, first thing to look at |
| The row's state word while tuning carries the meta after it ("tuning: step 7 of 9 · measuring 441 W · 32 GB · ...") and is cut on a narrow row | a later pass: drop the meta while tuning |
| The merge with `ember-tune`: `app.js` takes this branch's file (it already carries ember-tune's UI words and test); the engine files merge by themselves | the 0.3.14 shipper |

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View file

@ -0,0 +1,110 @@
# Igneum Miner 0.3.14: node-only, the execution layer survives a deep reorg and a moved pruning point; prepared to the gate, 6 October 2026
Release engineer, from 15:55 UTC, on the coordinator's decision of 15:53Z (the "0.3.13.1" name is refused by the ship tooling, so 0.3.14).
Worktree `/Users/joshm/Projects/igneum-wt-ship0314`, branch `release-0.3.14` from master 2cf8851 (the 0.3.13 merge); the fork worktree
`vendor/igneum-node-0314`, branch `release-0.3.14-node`, at bb43e9a8 (the 0.3.13 node) until the proving agent's fix lands on it. The
0.3.13 recipe; igneum-josh commits; times UTC.
## 1. Why, and what it carries
After 0.3.13's publish 2 (6 October, `release-0.3.13.md` section 4a): the fresh rule armed at DAA 198,000 inside the rollout window, the
chain ran two-sided for a minute, the hands took a 274-chain-block reorg, and the exec layer on every executing node replayed from genesis
("reorg deeper than the snapshot ring"), overwrote its good snapshot with a tip-0 one, and could not take the restart path again because the
devnet's pruning point had moved from bb45cf0d (DAA 45,537) to eb2a5d70 (DAA 88,763), stranding the anchor 27,276. Every node reads
`eth_blockNumber` 0 and `paidShards` 0 since 15:44:47Z; the chain, the finality locks and the hash are untouched.
| Change | Where | State |
|---|---|---|
| A deep reorg reloads the newest persisted generation at or below the fork (`exec-snapshot.bin`, then `.prev.bin`) and otherwise blocks loudly and asks a peer; never a genesis replay on a node whose retention root is above genesis; the ring 2,048, a loaded snapshot seeds it; `persist()` never writes a tip-0 state and never a lower tip over a file whose tip is still on the chain; `reexecuting` in `igneum_getExecStatus`, a warn a minute on every wait | the proving agent's `exec-sync-0313` 1f59c5d0 (591581b8 inside) | merged as release-0.3.14-node **4c6b129d** (11 files); igneum-exec 19; `tools/exec-sync/reorg.mjs` 18 of 18 (a 300-block reorg from the ring in 134.6 s, from the persisted generation after a restart in 261.6 s, both reaching the other node's state root, nothing from genesis); `net.mjs` 15 of 15 |
| A moved pruning point does not strand the anchor: the restart refuses cleanly below the pruning point; a node keeps executing from its persisted state; "snapshot wanted" whenever blocked | the same | in |
| `exec_restart_state_root`, the FOURTEENTH override field (block 27,276's post-state root 0xed27bb2d6128daf50493ed5539a73bb93f9d7c63ad7f70a7cbb46ba81c9e164e, read on node 1 and confirmed by the fleet on both node kinds; in the digest once set): a state that differs at the restart block is set aside and blocked, a p2p snapshot that differs is refused | the same; the packaged line 220dc03 | in: a DIGEST CHANGE, so the two-publish shape (section 2) |
| The prover's app side: the prover exports [n-1, n] and [first-1, last], seeds the exporter from the account dump (`igneum_exportSegments` `preState`), claims nothing below the restart; the exporter (`proving/igneum-prove/export`) | the proving agent's `proving-v2` 9c2a148, based on the 0.3.12 app: conflicts in seven files against ember-tune and miner-ui-3 and drags the RISC Zero tree; its rebase onto release-0.3.14 asked at 17:0xZ | (pending its tip) |
| A flag file (`--igneum-exec-snapshot`) that is missing, unreadable, mispinned or unknown to consensus blocks the executor loudly and is retried every 2 s, never runs as if unset (on the seed, as user igneum, a file under /root was ignored silently, 16:16Z) | 1f59c5d0 | in |
| The p2p snapshot path (`p2p_snapshot_gate`, unit-tested) refuses tip 0, below the restart, at or below the own tip, and anything while the executor runs unblocked (the seed pulled and loaded a 2,629-byte tip-0 snapshot from a fleet box at 16:16:30Z) | 1f59c5d0 | in |
| The hands' and the seed's restart scripts: `IGNEUMD_EXEC_SNAPSHOT` / `IGNEUMD_EXEC_SNAPSHOT_FILE` (the flag; on the seed the file installed for the igneum user under `/var/lib/igneum-v4`, the quoted `EXTRA_ARGS`) and `IGNEUM_EXEC_RESET` (the persisted exec files deleted after the stop); node 1 on its own eth port 26791 | 32e004f, 0585b9e, db1205b, dd9044e, 08ddee7 (this branch; `release-0.3.13` carries the 26791 line only) | in |
| The six version files | 4b25760 | in |
No re-pin of the anchor (the coordinator's decision): the anchor stays 27,276. The fourteenth field changes the digest, so this is the
two-publish shape: publish 1 the binary with the thirteen-field object carried over (digest b18ed271... unchanged, the hands and the seed
first, then the apps), publish 2 the fourteen-field object (a new digest; the hands and the seed, then the switch jobs in the order the
coordinator rules: the proving agent proposes every miner before the hands, the 0.3.12/0.3.13 order is the hands first). DEVNET 2 between
the payload and the live gate.
## 2. The order at the go
Runbook: the session scratchpad's `r0314/rollout-0314.sh` (the 0.3.13 one with the names moved; `step_1a_*` is the whole hand swap when the
object is carried over).
| Step | What | Gate |
|---|---|---|
| 0 | the baseline: `step_check_observer` reads 0 / genesis-or-snapshot-at-0 / 0 shards today | |
| 1 | the observer, node 1 (on its own eth port 26791 now) and the seed on the 0.3.14 binary with the SAME thirteen-field file | each prints b18ed271...; within minutes `eth_blockNumber` climbs to the sink, `igneum_getExecStatus` startedFrom "snapshot" (the persisted generation) or "restart at chain block 27276" (if the walk can meet it) or a peer's snapshot, `blocked null`; `igneum_getProvingStatus` active, paidShards >= 1,482 |
| 1b DEVNET 2 (Josh, 17:0xZ: every release crosses the rented fleet as its own staging chain first) | after the payload (the inputs, the Windows run, the DMG) and the Rust suite on PC 2: the payload URL and the manifest contents to the fleet agent; the cut waits on its devnet2-gate PASS line | zero rejected blocks across the activation, exec roots agreeing on every box, a segment record paid, every node on the new version |
| 2 | the ship (`--from ci`, consensus carried over), then update-now Mac, PC 2, PC 1 (no activation height anywhere near the window) | each node's exec back the same way |
| 3 | the fleet agent's boxes on the new Linux node (handed before the publish), the sweep, the proving agent's fresh join on the rented 4090 | the per-node times |
## 2a. The fallback (the coordinator's rule, 17:0xZ)
If the proving agent's exec fix has an ETA past 19:00Z, 0.3.14 ships without it. Without it there is NO node change: the 0.3.14 node is
bb43e9a8, the 0.3.13 node; the tree then carries miner-ui-3 ae88457 (the UI rewritten, 35 UI tests), master cc89e3f and the two hand-node
restart scripts (the snapshot flag, the reset step, node 1's own eth port, the stop() fix: hand tooling, not a payload). That cut is
app-side, the object carried over, no node restart anywhere, and does nothing for the exec layer or proving (the PCs stay at exec 0 until
a node cut; the hands execute on the snapshot path). The proving agent's branch at 17:00Z: 591581b8 (the export carries execRestart), no
deep-reorg rule, no snapshot floor, no loud flag file yet; its ETA asked at 16:49Z.
## 3. Builds and artefacts (node 4c6b129d, app a90f6a5)
| What | Command | Result |
|---|---|---|
| The fork's Mac node | `CARGO_TARGET_DIR=vendor/igneum-node/target-0314 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` from `vendor/igneum-node-0314`, under the lock | 16:52:08 to 16:56:36Z: igneumd **97157740d0b7d4355183f6c29b569409ec4fae96f43ebc793f09234af1c66ef1** (41,752,720), igneum-miner fe056d93... (8,763,856); igneum-exec 19 of 19 |
| The seed's Linux node (glibc 2.36, zig) | `infra/cross/build-linux.sh` under the lock | 16:52:18 to 16:5xZ: igneumd **934f393cacc31a06d0c45a9fe2e2f504941a32533110b51851968e70cf90fa3a** (48,316,072), igneum-miner 7e296541...; with the fleet agent 16:58Z for the Devnet 2 canary |
| The Windows node exes | `proto-cuda/windows-node/cross-build.sh <fork> 4` (mingw) under the lock | 16:52:28 to 16:56:06Z: igneumd.exe **44fa74c02415ff258b5956ef55909dc97890e3f29fca3d2db26f7152ef4ce541** (52,584,448), igneum-miner.exe 819ea9ce... (11,040,256) |
| The digests on 97157740 (ports 61005/61006, 22 s each, under `run`) | the thirteen-field file: **b18ed271...** (unchanged: publish 1 changes no handshake); the fourteen-field file: **23e7693634a9b8b3c0643f16c8a01e4cf057985c7e0e0e044f70bc919a3c0fef** (publish 2's, not tonight) | 17:07:18 to 17:08:03Z |
| The inputs, the pin | `IGNEUM_WIN_RELEASE=<target-0314-win>/x86_64-pc-windows-gnu/release IGNEUM_NODE_SRC=vendor/igneum-node-0314 packaging/windows/push-inputs.sh`, 17:05:16Z; the 0.3.11 workers and the telemetry helper unchanged | `payload-inputs.zip` 1d1ca18ecfd74b97179b09bfd69269868c59d7ff92bdaaf852f61bcda35edd7b (65,421,351), signed, live; `node-source.pin` 4c6b129d as **a90f6a5** (the Windows-build commit) |
| The prover host and export (the exporter changed: bfebff1) | `cargo build --release -j 4 -p igneum-prove-export -p igneum-prove-host` in `proving/igneum-prove` under the lock | 17:07Z: igneum-prove-host d58e8bb06ca6561a62f92341e47c73347c2aca0d7d1d06fd031f0479fc738377 (58,627,344), igneum-prove-export 065b6a94ae95cd4f922ec13f00788ef035983816a9b95adf29be9733c81203ba (2,826,176); shard program id 0x2b1a81cb413236cf... (the pin unchanged) |
| The DMG | `NODE=<fork igneumd> MINER=... PROVE_HOST/PROVE_EXPORT=<this tree's> packaging/mac/build-dmg.sh` under the lock, with the THIRTEEN-field packaged line (ac2d18d: a fresh install joins the live digest) | 17:09:38 to 17:10:02Z: `Igneum-Miner-0.3.14.dmg` **e9e4c24db81a262a36b6bac11609ce6d24ed03220973da4142df935d41fc4e40** (41,892,337), engine 0.3.14, node 4c6b129d (a4e2def6... inside), `packaged-config` 13 fields, no state root; the earlier 7946786e... (14 fields) discarded |
| The HiveOS package | `make-hive-package.sh` from the 934f393c Linux node and the 0.3.11 Linux workers, staged in `dl/public` for the ship's deploy | `igneum-hive-0.3.14.tar.gz` **c4699f2487580dcf06b5c22a36a2eb8383a3307b6137699c76c45eefebdd4c88** (24,659,507) |
| The Windows run | `windows.yml` run 37501010655 on a90f6a5 (dispatched 17:06:50Z after the inputs push; the first dispatch 404ed under the flipped gh account); `ci` 37500950177 | both GREEN 17:13:24Z: `Igneum-Miner-Setup-0.3.14.exe` **08e75effc732f88bca11006b3b7cdf2bc533576f56819f79509fee47a8f5dd59** (45,416,619); `igneum-windows-app.zip` **1e0b0b96b79b9b74a633f2a66e375df6a33fcaa9df04b6a5d221166a6d90b310** (65,711,836), its igneumd.exe 44fa74c0... (the cross-build); fetched 17:13:46Z, not deployed. The 0.3.14 CI verdict: ci 37500950177 on a90f6a5; the Windows build 37501010655 on a90f6a5 (the tree is ac2d18d+, the packaged line and docs only since) |
| The suites | the app tests and the six node suites with the igneum-pow feature on the Mac (17:08 to 17:11Z); the PC 2 build-and-suite job published 16:57:01Z on the 3.0 coordinator's word | the Mac: app 153 + 28 + 8; igneum-exec 19, igneum-miner 18, p2p-flows 33, p2p-lib 19, kaspa-pow 14, db_compat 7; kaspa-consensus 98 passed, 2 failed (the known M20 era test; the known flaky `ban_is_decided_by_the_carrying_block...`); consensus-core 107 passed, 1 failed (the fast-time file test, now "lacks the field exec_restart_hash": the dedup left the four exec fields out; a devnet-profile test, the proving agent's 0.3.14.1 item). PC 2 `build-20261006-165701` (16:57:31 to 17:03:58Z): both targets built, the app suite exit 0, the node suite exit 101 on the flaky finality test alone (`UnexpectedDifficulty` in the test's own chain setup; 97 of 98), the five other crates not run after it (the runner has no --no-fail-fast: a rule row, the runner carries it from the next cut); the coordinator's ruling: rerun with the list split, the flake recorded with a fix owner (its chain setup must build difficulty deterministically) |
| The PC 2 rerun | the five crates that had not run (kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows), node only, published 17:14:23Z (job `build-20261006-171423`) | FAILED 17:25:31Z on "upload incomplete": both builds done (Linux 131 s, Windows), seven outputs uploaded, then the relay's blob service refused the final PUT (`service_unavailable`, "no url in the reply") and the test stage's result never reached the intake, so the rerun gave nothing. The coordinator accepted the suite as run: PC 2's consensus 97 of 98 with the flake recorded, the five other crates green on the Mac on the same node commit |
| The Devnet 2 canary | the fleet agent's `tools/fleet/canary.sh` on six provers and two miners with 934f393c and the thirteen-field file, 10 minutes: zero rejected blocks, exec roots equal across the eight and the hub, paidSegments up on a prover, every node on the new version | (pending its PASS line) |
## 4. The rollout (Josh's word 17:55 UK: out within 30 minutes; the coordinator's (B) at 17:4xZ: ship on the 5090's evidence)
| Step | Time | Result |
|---|---|---|
| the canary (Devnet 2, the fleet agent) | runs 1 to 5 died on Vast's ssh1 proxy hanging during the install (not the binary); run 6 started 17:43:20Z on the three provers off that proxy (5090, 3090-2, 3090-3) and the two miners (3080, A5000), its line due about 17:57Z | the evidence the coordinator shipped on: the 5090 ran 934f393c with the thirteen-field file since run 3's partial install at 17:12Z, paidSegments 45 at 17:27Z and 48 at 17:33Z (records paid on the new binary), exec roots agreeing with the hub, 0 rejected; run 6's formal line appended when it lands; a FAIL on any box = the hands and the manifest rolled back to 0.3.13 at once |
| 1a the observer, node 1 | 17:43:45Z (the observer only: the hands script's stop() filter failed the pipeline on a trailing caffeinate pid and `set -e` ended the script before node 1; fixed e9150bd); 17:45:30Z (pid 60505) and 17:45:44Z (pid 60677) both | `igneumd/2.1.0-4c6b129d` on the thirteen-field file, b18ed271...; the exec layer carried across the restart on the snapshot path (the observer 139,617 at 17:46Z, startedFrom snapshot, paidShards 2,034, paidWei 2789.61 IGN; node 1 the same on 26791) |
| 1a the seed | 17:44:10Z (MainPID 142776) | `igneumd/2.1.0-4c6b129d`, b18ed271..., its exec carried |
| 1b publish 1 | the ship 17:47:03 to 17:49:11Z from c2704f8 (master merged first, three docs commits) | ci "already" (37501010655), fetch "already", dmg "already", copy ok, manifest 0.3.14 published 17:47:06Z with `consensus` CARRIED OVER (the thirteen-field object, activation 198000), deploy 17:47:57Z, verify ok (every file and the manifest match the local bytes, the public aliases serve them), console #372. The first run of the runbook's ship step died on a missing `ov10.json` reference copied from the 0.3.13 runbook (supplied) |
| 1c update-now, the Mac | 17:50:22Z (`update-now-0314-d937c69d`; the first attempt reused the 0313 id and was refused) | ran 17:51:xxZ; engine restart 17:52:22Z (run `mac-d937c69d-20261006-175222`), "updated to Igneum Miner 0.3.14 from 0.3.13"; `/api/state` version 0.3.14, node = node 1 (4c6b129d) 6 peers synced, mining paused as ordered |
| 1c update-now, PC 2 | 17:49:51Z (`update-now-0314-1ccfe586`) | ran 17:55:26Z (five minutes after the wake); engine restart 17:55:38Z (run `win-1ccfe586-20261006-175538`), "updated ... from 0.3.13", igneumd 4c6b129d on the thirteen-field file, b18ed271, miner back 17:56:38Z (114.9 MH/s). Its exec at start: "exec state loaded from a snapshot: tip 0", "resumed from exec-snapshot.prev.bin", "exec restart at chain block 27276 ... not yet possible": BLOCKED loudly with "snapshot wanted", the p2p snapshot path's first live test (the hands serve over protocol 16) |
| the wallet 0.1.5 DMG on this node | 17:55:39Z | `Igneum-Wallet-0.1.5.dmg` 093412b4e5c61b8a4cc03cfe2a6ae3a239f4f369fa4c9de8aa8b8797003583ec (20,098,795), engine 0.1.5, node 4c6b129d (a4e2def6...) inside, 13 fields, the manifest URL set (`release-wallet-0.1.5.md`) |
| 1c update-now, PC 1 | on the coordinator's "PC 1 free" (the 3.0 coordinator's watts job runs there) | |
## 4a. The recovery on the shipped binary (16:11 to 16:18Z), before this cut
The proving agent's export of node 1's copy (13:45Z: tip chain block 130,272, 4,612 blocks below the fork point, 114,830,936 bytes, sha256
ac101f13576179fd7d7f5e8ee902c9a7b6cc47730e3a3c069f389f0ca46d9221) loaded through `--igneum-exec-snapshot=<file>,0x<sha>` after the persisted
tip-0 files were deleted: the observer 16:11:18Z (`startedFrom snapshot`, `eth_blockNumber` 136,415 at 16:12:03Z and climbing, blocked null,
paidShards 1,482, paidWei 1825.699240038 IGN), node 1 16:11:32Z (136,459, on 26791), the seed 16:18:01Z (136,636). Three traps on the way:
the hands script's `pgrep -f` matched the caller's own shell when the pattern's text sat in the command (11 minutes lost; a rule: a
restart script's pattern never appears in the caller's command line, and `pgrep` excludes the caller); the seed's env file is sourced, so an
unquoted `EXTRA_ARGS` with a space ran the flag as a command and the unit crash-looped eight times (the seed off the air 16:14 to 16:16Z);
the unit's user could not read a file under `/root` and the flag did nothing, silently. The PCs and the fleet's boxes stay at 0 until this
cut or the same manual recovery (the fleet agent has the recipe; the PCs' app node takes no flag).
## 5. Owed (the coordinator's rows, tonight, each with a fix owner to name)
| Row | What | Owner |
|---|---|---|
| The relay/job defect | the PC runner must retry a failed output upload (the relay's blob service refused a PUT at 17:25Z, `service_unavailable`), and the intake must report a partial job as FAILED with the stage that died, never as nothing (the rerun job showed no test result at all) | (to name) |
| The flaky finality test | `processes::finality::tests::ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list`: its chain setup must build difficulty deterministically (`UnexpectedDifficulty` under load; fails inside full runs, passes alone) | (to name) |
| The PC runner's `--no-fail-fast` | the test units run every crate even after a failing one (tonight the five other crates did not run after the flake) | the app's `jobbuild.rs`, next cut |
| The fast-time file | `infra/fast-time/override-60x.json` deduplicated without the four exec fields (`exec_restart_number`, `_hash`, `_trust_daa`, `_state_root`): the devnet-profile test `fast_time_60x_file_is_the_devnet_at_60x` is the gate | the proving agent, 0.3.14.1 |
| The playbook-quit allow entries | expire at 0.3.15 (the check fails the tree while they stand): the agg-cost scripts to `--stop-miners`, the Ember playbook rewritten | the aggregation-cost agent, the Ember agent |
| Publish 2 | the fourteen-field object (digest 23e76936...), after Devnet 2 has crossed that exact digest move in the same order, never inside a rollout window; the miners first (the fleet by script inside one minute, then PC 1, PC 2 and the Mac on their clicks), the hands last | the next cut |
| The wallet 0.1.5 | its final DMG on the 0.3.14 node, the clean-data-dir fresh-joiner check, the Windows question | `release-wallet-0.1.5.md` |
| The gh account rule | every gh call switches to igneum-josh first and fails on any other active account (a CLAUDE.md row and the runbook's `gh_josh`) | this plan's runbook; CLAUDE.md to carry it |

View file

@ -12,21 +12,31 @@ BIN="${IGNEUMD:-/Users/joshm/Projects/igneum/vendor/igneum-node/target-036/relea
# the fork commit the binary must carry (0.3.9: a24ab01a, release-0.3.6 with txgen-export; 2b6d23ef read the switch too)
COMMIT="${IGNEUMD_COMMIT:-a24ab01a}"
OV=/tmp/igneum-devnet/override-v3.json
# 6 October 2026 (the exec recovery after the 15:44Z reorg): IGNEUMD_EXEC_SNAPSHOT="<path>,<sha256>" adds --igneum-exec-snapshot
# to both nodes (the exec state to start from when the data dir's own file is bad or missing); IGNEUM_EXEC_RESET=1 deletes each
# node's persisted exec files (exec-snapshot.bin, the tip-0 file a genesis replay writes, and exec-snapshot.prev.bin) after the
# stop, so the start takes the flag's file and not the bad one. Both off by default.
SNAP="${IGNEUMD_EXEC_SNAPSHOT:-}"; SNAPARG=""; [ -n "$SNAP" ] && SNAPARG="--igneum-exec-snapshot=$SNAP"
reset_exec() { if [ "${IGNEUM_EXEC_RESET:-0}" = 1 ]; then rm -f "$1/igneum-devnet/datadir/evm/exec-snapshot.bin" "$1/igneum-devnet/datadir/evm/exec-snapshot.prev.bin"; echo "exec files reset under $1"; fi; }
echo "$OVJSON" | python3 -c 'import json,sys; o=json.load(sys.stdin); assert isinstance(o, dict) and "fees_v1_activation_daa" in o, "the object must carry fees_v1_activation_daa"; print("override:", json.dumps(o, sort_keys=True))'
# grep -c, not grep -q: -q closes the pipe early and pipefail turns strings' SIGPIPE into a refusal of a good binary (5 October 2026, 17:48Z)
[ "$(strings "$BIN" | grep -c "$COMMIT")" -gt 0 ] || { echo "$BIN is not the $COMMIT node (a 0.3.5 node refuses an override file with fees_v1_activation_daa)"; exit 1; }
cp -p "$OV" "$OV.prev-$(date -u +%Y%m%dT%H%M%SZ)" 2>/dev/null || true
printf '%s\n' "$OVJSON" > "$OV"
stop() { local p; p=$(pgrep -f "$1" | head -1 || true); if [ -n "$p" ]; then kill -INT "$p"; while kill -0 "$p" 2>/dev/null; do sleep 1; done; fi; }
# the pid is an igneumd process, never a shell whose command line carries the pattern's text (6 October 2026: pgrep -f matched the
# caller's own zsh -c "..." and the stop waited 11 minutes on it)
stop() { local p; p=$( { for c in $(pgrep -f "$1" || true); do if [ "$(ps -o comm= -p "$c" 2>/dev/null | xargs basename 2>/dev/null)" = igneumd ]; then echo "$c"; fi; done; true; } | head -1 ); if [ -n "$p" ]; then kill -INT "$p"; while kill -0 "$p" 2>/dev/null; do sleep 1; done; fi; } # the filter never fails the pipeline (17:43Z: a trailing caffeinate pid made the loop exit 1 and set -e ended the script before node 1)
lines() { sleep 10; grep -E "Calibrated v1 fees from the override file|Fees on igneum-devnet|Consensus params digest" "$1" | tail -3 || true; }
# the observer first
stop "igneumd --devnet.*observer-v4"
reset_exec /tmp/igneum-devnet/observer-v4
nohup "$BIN" --devnet --nodnsseed --disable-upnp --appdir=/tmp/igneum-devnet/observer-v4 --rpclisten=127.0.0.1:26640 --rpclisten-json=127.0.0.1:28640 --listen=127.0.0.1:26641 \
--addpeer=127.0.0.1:26611 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/observer-v4.out 2>&1 &
--addpeer=127.0.0.1:26611 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" $SNAPARG --nologfiles --yes >> /tmp/igneum-devnet/observer-v4.out 2>&1 &
lines /tmp/igneum-devnet/observer-v4.out
# node 1, under caffeinate as it runs today
stop "igneumd --devnet.*appdir=/tmp/igneum-devnet/node1 "
reset_exec /tmp/igneum-devnet/node1
nohup caffeinate -dims "$BIN" --devnet --nodnsseed --disable-upnp --enable-unsynced-mining --appdir=/tmp/igneum-devnet/node1 --rpclisten=0.0.0.0:26610 --listen=0.0.0.0:26611 --evm-rpclisten=127.0.0.1:26791 \
--addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 &
--addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" $SNAPARG --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 &
lines /tmp/igneum-devnet/node1.out
echo "running now:"; ps -o pid=,lstart=,command= -p "$(pgrep -f 'igneumd --devnet' | tr '\n' ',' | sed 's/,$//')" | cut -c1-160

View file

@ -14,6 +14,13 @@ WANT="${IGNEUMD_LINUX_SHA256:-7e26e374586ad8ba539e371c6cbbd74f0f77aee1e2e97cd25f
echo "$OVJSON" | python3 -c 'import json,sys; o=json.load(sys.stdin); assert isinstance(o, dict) and "fees_v1_activation_daa" in o; print("override:", json.dumps(o, sort_keys=True))'
test "$(shasum -a 256 "$SRC" | cut -c1-64)" = "$WANT" || { echo "$SRC is not the 2b6d23ef Linux node ($WANT)"; exit 1; }
SSH="ssh -i $HOME/.ssh/igneum_ed25519 -o ConnectTimeout=20 root@188.245.5.161"
# 6 October 2026 (the exec recovery after the 15:44Z reorg): IGNEUMD_EXEC_SNAPSHOT_FILE=<local file> copies that exec snapshot to the
# seed (/root/v4/exec-snapshot.bin) and adds --igneum-exec-snapshot=<path>,0x<sha256> to EXTRA_ARGS in seed-v4.env (replacing an
# earlier one); IGNEUM_EXEC_RESET=1 deletes the seed's persisted exec files (the tip-0 file a genesis replay writes, and the .prev)
# while the unit is stopped. Both off by default.
SNAPF="${IGNEUMD_EXEC_SNAPSHOT_FILE:-}"; SNAPSHA=""
if [ -n "$SNAPF" ]; then SNAPSHA="$(shasum -a 256 "$SNAPF" | cut -c1-64)"; scp -i "$HOME/.ssh/igneum_ed25519" "$SNAPF" root@188.245.5.161:/root/v4/exec-snapshot.bin && ssh -i "$HOME/.ssh/igneum_ed25519" root@188.245.5.161 "install -o igneum -g igneum -m 644 /root/v4/exec-snapshot.bin /var/lib/igneum-v4/exec-snapshot-recovery.bin"; fi
RESET="${IGNEUM_EXEC_RESET:-0}"
scp -i "$HOME/.ssh/igneum_ed25519" "$SRC" root@188.245.5.161:/root/v4/out/igneumd.039
$SSH "set -e
test \"\$(sha256sum /root/v4/out/igneumd.039 | cut -c1-64)\" = $WANT
@ -21,8 +28,14 @@ $SSH "set -e
cp /root/v4/out/igneumd.039 /opt/igneum/v4/bin/igneumd.new && chmod +x /opt/igneum/v4/bin/igneumd.new
cp -p /etc/igneum/override-v3.json /etc/igneum/override-v3.json.prev-\$(date -u +%Y%m%dT%H%M%SZ) || true
printf '%s\n' '$OVJSON' > /etc/igneum/override-v3.json
grep -q '^EXTRA_ARGS=--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env
grep -qE '^EXTRA_ARGS=\"?--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env
systemctl stop igneumd-v4
if [ '$RESET' = 1 ]; then rm -f /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.bin /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.prev.bin; echo 'exec files reset on the seed'; fi
if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /var/lib/igneum-v4/exec-snapshot-recovery.bin | cut -c1-64)\" = $SNAPSHA; python3 -c 'import sys,re; p=\"/etc/igneum/seed-v4.env\"; t=open(p).read(); out=[]
for l in t.split(\"\\n\"):
if l.startswith(\"EXTRA_ARGS=\"): l=\"EXTRA_ARGS=\\\"--override-params-file=/etc/igneum/override-v3.json --igneum-exec-snapshot=/var/lib/igneum-v4/exec-snapshot-recovery.bin,0x$SNAPSHA\\\"\"
out.append(l)
open(p,\"w\").write(\"\\n\".join(out))'; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi
mv -f /opt/igneum/v4/bin/igneumd.new /opt/igneum/v4/bin/igneumd
systemctl start igneumd-v4
sleep 10; systemctl is-active igneumd-v4; systemctl show -p ActiveEnterTimestamp,MainPID igneumd-v4

View file

@ -11,7 +11,7 @@
<key>CFBundleVersion</key>
<string>VERSION_STAMP</string>
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<string>0.3.14</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>

View file

@ -9,7 +9,7 @@
#define ArtDir "..\..\brand\icons"
#endif
#ifndef AppVersion
#define AppVersion "0.3.13"
#define AppVersion "0.3.14"
#endif
#define AppName "Igneum Miner"
#define Publisher "Igneum"

View file

@ -1 +1 @@
bb43e9a85f2683786fccc98d5533e85828b24138
4c6b129d75c3d77a3689d22f1e1dc721b556aebb

View file

@ -193,11 +193,54 @@ fn main() -> Result<()> {
// account nonce 1 (EIP-161), so the registry starts at nonce 1.
db.insert_account(DEVELOPER_REGISTRY_ADDRESS, 1, U256::ZERO, &registry_code, &[]);
// The account dump (0.3.14, 6 October 2026): an export from a node's ring carries the full state after its first
// segment (`preState`); the port seeds from it, checks its root against the node's at that segment and replays
// from the next one. Nothing older is needed, so a snapshot-restored node (thin older records) cuts the same
// fixture as a node with the whole history.
let mut first_index = 0usize;
if let Some(rows) = export["preState"].as_array() {
let mut db2 = IgneumDb::new();
for row in rows {
let storage: Vec<(U256, U256)> = row["storage"].as_array().map(|a| a.iter().map(|kv| Ok((kv[0].as_str().context("slot")?.parse()?, kv[1].as_str().context("value")?.parse()?))).collect::<Result<Vec<_>>>()).transpose()?.unwrap_or_default();
db2.insert_account(addr(&row["address"])?, row["nonce"].as_u64().context("nonce")?, u256(&row["balance"])?, &bytes(&row["code"])?, &storage);
}
let node_root = b256(&segments[0]["stateRoot"])?;
let our_root = db2.state_root();
if our_root != node_root {
bail!("the account dump at segment {}: the port's state root {our_root} differs from the node's {node_root}; the dump or the port is wrong, stop", segments[0]["number"]);
}
println!("account dump: {} accounts after chain block {}, state root {our_root} (equals the node's); the replay starts at the next segment", rows.len(), segments[0]["number"]);
db = db2;
first_index = 1;
if want <= segments[0]["number"].as_u64().unwrap_or(0) {
bail!("block {want} is not above the dump's block {}; export from one block below the wanted block", segments[0]["number"]);
}
}
// The exec restart (6 October 2026): the node's EVM state restarted empty at chain block R after the bodies
// below its pruning point were gone (`execRestart` in the export); the records below R are header-only, so a
// replay without a dump starts at R from the registry-only state (as the node did).
let restart = u64_of(&export["execRestart"]["number"])?.unwrap_or(0);
if restart > 0 && first_index == 0 {
println!("exec restart: the node's state restarted empty at chain block {restart}; the replay starts there, segments below are header-only");
if want < restart {
bail!("block {want} lies below the exec restart at chain block {restart}: no state and no bodies exist for it");
}
}
let mut hashes: Vec<(u64, B256)> = Vec::new();
let mut fixture: Option<Fixture> = None;
let mut checked = 0usize;
for (n, seg) in segments.iter().enumerate() {
let number = seg["number"].as_u64().context("number")?;
if n < first_index {
// the dump's own segment: its state is the seed, its hash enters the BLOCKHASH ring
hashes.push((number, b256(&seg["hash"])?));
db.push_block_hash(number, b256(&seg["hash"])?);
continue;
}
if number < restart && first_index == 0 {
continue;
}
let f = fixture_of(&export, &segments, n, &hashes, &db, fees)?;
let pre_root = db.state_root();
let node_root = b256(&seg["stateRoot"])?;
@ -289,7 +332,7 @@ fn main() -> Result<()> {
if db.state_root() != final_root {
bail!("final state root {} differs from the export's {final_root}", db.state_root());
}
println!("replayed {checked} segments from genesis; every state root equals the node's; final root {final_root}");
println!("replayed {checked} segments{}; every state root equals the node's; final root {final_root}", if first_index > 0 { " from the account dump" } else if restart > 0 { " from the exec restart" } else { " from genesis" });
let fixture = fixture.with_context(|| format!("block {want} is not in the export (0 to {})", segments.len() - 1))?;
let json = serde_json::to_string_pretty(&fixture)?;
std::fs::write(out_path, &json)?;

View file

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

View file

@ -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.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' }))
}
$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

View file

@ -0,0 +1,68 @@
# Ember: the SHIPPED app tunes itself (6 October 2026). Unelevated, no kit, no second engine, no --stop-miners:
# the installed app (0.3.14 or later: Ember Tune, the Power Helper task, /api/tune/goal, the climb) runs one tune
# per card through the registered task (no prompt) while it mines. This script only asks and watches:
# POST api/tune/goal (the climb on, balanced) and POST api/sweep/start per card, then reads api/state and the app's
# own log for the TUNE rows. It never quits, pauses or resumes the installed app (the 5 October rule) and never
# touches the cards itself. At the end the climb switch goes back to what it was.
# Order: the AMD card's ladder first (the ADLX apply-on-ack fix, bd7fcf4), then each NVIDIA card's climb.
$ErrorActionPreference = 'Continue'
function Say([string] $s) { Write-Output ('[' + (Get-Date -Format 'HH:mm:ss') + '] ' + $s) }
$appDir = Join-Path $env:LOCALAPPDATA 'igneum\app'
$u = Join-Path $appDir 'app.url'
if (-not (Test-Path -LiteralPath $u)) { Write-Output 'RESULT TUNE error=no_app_url (installed app not running)'; exit 1 }
$base = (Get-Content -LiteralPath $u -Raw).Trim()
function State() { try { return ((Invoke-WebRequest -Uri ($base + 'api/state') -UseBasicParsing -TimeoutSec 5).Content | ConvertFrom-Json) } catch { return $null } }
function Post([string] $path, [string] $json) { try { Invoke-WebRequest -Uri ($base + $path) -Method POST -Body $json -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null; return $true } catch { Say ($path + ': ' + $_.Exception.Message); return $false } }
$s = State
if (-not $s) { Write-Output 'RESULT TUNE error=state_unreadable'; exit 1 }
$ver = [string]$s.version
Write-Output ('RESULT TUNE installed app ' + $ver + ' power_control=' + $s.settings.power_control + ' tuning_off=' + $s.settings.tuning_off + ' climb_before=' + $s.settings.tune_climb + ' goal_before=' + $s.settings.tune_goal)
$t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue
Write-Output ('RESULT TUNE task ' + $(if ($t) { $t.Actions[0].Execute + ' state=' + $t.State } else { 'NOT REGISTERED' }))
if (-not $t) { Write-Output 'RESULT TUNE error=task_not_registered (the NVIDIA climbs would need a prompt; stopping)'; exit 1 }
if (-not $s.settings.power_control) { Write-Output 'RESULT TUNE error=power_control_off'; exit 1 }
$climbBefore = [bool]$s.settings.tune_climb
$cards = @($s.mining.cards | Where-Object { $_.enabled -and ($_.vendor -eq 'nvidia' -or $_.vendor -eq 'amd') -and $_.name -notmatch 'Radeon\(TM\) Graphics' })
Write-Output ('RESULT TUNE cards ' + (($cards | ForEach-Object { $_.key + ' (' + $_.name + ', ' + $_.vendor + ', sweep_state ' + $_.sweep_state + ', line "' + $_.tune_line + '")' }) -join ' | '))
foreach ($c in $cards) { Write-Output ('RESULT TUNE before key=' + $c.key + ' power_w=' + $c.power_w + ' power_pct=' + $c.power_pct + ' clock_cap=' + $c.clock_cap_mhz + ' mhs=' + $c.mhs + ' line=' + $c.tune_line) }
# the climb on (NVIDIA cards; an AMD card has no clock knob and runs its ladder regardless)
if (-not (Post 'api/tune/goal' '{"goal":"balanced","climb":true}')) { Write-Output 'RESULT TUNE error=tune_goal_refused'; exit 1 }
Start-Sleep -Seconds 2
$order = @($cards | Where-Object { $_.vendor -eq 'amd' }) + @($cards | Where-Object { $_.vendor -eq 'nvidia' })
$deadline = (Get-Date).AddMinutes(27)
$logs = Join-Path $appDir 'logs'
function LogMark() { $l = Get-ChildItem -Path $logs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($l) { return @{ f = $l.FullName; n = (Get-Content -LiteralPath $l.FullName).Count } } else { return @{ f = ''; n = 0 } } }
function LogSince($m) { if (-not $m.f) { return @() }; $all = @(Get-Content -LiteralPath $m.f -ErrorAction SilentlyContinue); if ($all.Count -le $m.n) { return @() }; return @($all[$m.n..($all.Count - 1)]) }
$rows = 0
foreach ($c in $order) {
if ((Get-Date) -gt $deadline) { Say 'budget spent before ' + $c.key; break }
$m = LogMark
$body = '{"key":"' + ($c.key -replace '"', '\"') + '"}'
if (-not (Post 'api/sweep/start' $body)) { Write-Output ('RESULT TUNE error=sweep_start_refused key=' + $c.key); continue }
Say ('tune asked for ' + $c.key)
$seen = 0; $done = $false; $started = Get-Date
while (-not $done -and (Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
$new = @(LogSince $m)
while ($seen -lt $new.Count) { $l = [string]$new[$seen]; $seen++; if ($l -match 'TUNE (card=|chosen|start|aborted|progress)') { if ($l -notmatch 'TUNE progress') { Write-Output ('RESULT ' + ($l -replace '^.*?(TUNE )', 'TUNE ')) }; if ($l -match 'TUNE card=') { $rows++ } } }
$st = State
if ($st) {
$cc = $st.mining.cards | Where-Object { $_.key -eq $c.key } | Select-Object -First 1
if ($cc) {
$running = ($cc.sweep_state -eq 'running')
if (-not $running -and ((Get-Date) - $started).TotalSeconds -gt 180) { $done = $true }
if ($running -and $cc.tune_steps -gt 0 -and ($seen % 6 -eq 0)) { Say ($c.key + ': step ' + $cc.tune_step + ' of ' + $cc.tune_steps + ', ' + $cc.sweep_note) }
}
}
}
$st = State
if ($st) { $cc = $st.mining.cards | Where-Object { $_.key -eq $c.key } | Select-Object -First 1; if ($cc) { Write-Output ('RESULT TUNE after key=' + $c.key + ' power_w=' + $cc.power_w + ' power_pct=' + $cc.power_pct + ' clock_cap=' + $cc.clock_cap_mhz + ' line=' + $cc.tune_line + ' source=' + $cc.tune_source + ' note=' + $cc.sweep_note); if ($cc.tune_curve) { Write-Output ('RESULT TUNE curve key=' + $c.key + ' ' + (($cc.tune_curve | ForEach-Object { ($_.clock_mhz) + 'MHz/' + ($_.mem_mhz) + 'mem/' + ($_.power_pct) + '%:' + $_.mhs + 'MH/s ' + $_.watts + 'W ' + $_.eff + ' ' + $_.mark }) -join ' | ')) } } }
}
# the climb switch back to what it was
Post 'api/tune/goal' ('{"climb":' + $(if ($climbBefore) { 'true' } else { 'false' }) + '}') | Out-Null
Write-Output ('RESULT TUNE climb_restored=' + $climbBefore + ' rows=' + $rows)
$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,clocks.mem --format=csv,noheader 2>&1 | Out-String) -replace "`r?`n", ' | ')) }
if ($rows -eq 0) { Write-Output 'RESULT TUNE error=no_rows'; exit 1 }
exit 0

View file

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

View file

@ -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)
@ -47,10 +47,13 @@ $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 }
# 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 $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
@ -127,12 +130,33 @@ function EndTree([int] $procId, [string] $why) {
}
$seen = 0
$rows = 0
# the installed app shows the tune instead of a bare "off" (Josh, 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
# its budget silently again
$firstStatusAt = $null
$lastMining = $null
$idleSince = $null
$idleSamples = 0
$lastIdleStatus = ''
$lastEngineLine = ''
function EngineLogDump([string] $why) {
Write-Output ('===== engine log tail (' + $why + ')')
@ -140,33 +164,55 @@ 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
}
# 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 }
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++
if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } }
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) ') { }
else { Say $l }
@ -185,11 +231,26 @@ 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'
}
}
$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'
# Josh, 6 October 2026, 07:25Z: both cards stay on their best MH/W points (the tune pins them); no factory reset here.

View file

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

View file

@ -404,6 +404,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' }];

View file

@ -2,5 +2,40 @@
"_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."
},
{
"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%."
}
]
}

View file

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

View file

@ -49,8 +49,17 @@ fi
# PC 2 family probe and derive job; and the two hand-restore scripts pc1-amd-identities.ps1 and pc1-amd-flag-amd1.ps1,
# allowed by the rule owner (6 October 2026, 19:xx UTC) because they post enabled TRUE only (a restore, never a switch off)
PRE_RULE='^(relay/playbooks/(mixer-x4-pc1-bench|mixer-x4-5090-bench|mixer-x4-9070-bench|readwidth-5090-bench|readwidth-5090|readwidth-9070-bench|readwidth-9070|ca2-era-pc1|ca2-hot-5090-bench|ca2-hot-9070-bench|dot4-probe|ca3-derive-pc2)\.ps1|tools/ca3-reserve/pc2-family-probe\.ps1|tools/ca3-pc1-amd/pc1-amd-identities\.ps1|tools/ca3-pc1-amd/pc1-amd-flag-amd1\.ps1)$'
ALLOW='^(packaging/windows/stop-igneum\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$'
# 6 October 2026 (0.3.14 gate): ember-tune-pc1.ps1 and the two agg-cost scripts are allowed pending the agg-cost --stop-miners change
# and the Ember playbook's rewrite (the coordinator's 15:30Z ruling); EXPIRES 0.3.15 (the check below fails the tree at 0.3.15 or later while they stand)
ALLOW='^(packaging/windows/stop-igneum\.ps1|relay/playbooks/ember-tune-pc1\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$'
fail=0
while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; [[ "$f" =~ $PRE_RULE ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/**' 'packaging/**' | grep -E '\.(ps1|sh)$' | grep -v '^tools/ci/')
# expiry of the dated allow entries (6 October 2026): at 0.3.15 or later the three must be gone from ALLOW
ver="$(sed -n 's/^version = "\(.*\)"/\1/p' app/igneum-app/Cargo.toml 2>/dev/null | head -1)"
if [ -n "$ver" ] && [ "$(printf '%s\n0.3.15\n' "$ver" | sort -V | head -1)" = "0.3.15" ]; then
for e in relay/playbooks/ember-tune-pc1.ps1 tools/proving-v1/pc2-agg-cost.ps1 tools/proving-v1/pc2-agg-cost-restore.ps1; do
case "$ALLOW" in *"${e//./\\.}"*) echo "playbook-quit: the allow entry for $e expired at 0.3.15 (the tree says $ver): the pause/resume rule applies to it now"; fail=1 ;; esac
done
fi
[ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses, resumes or switches the cards of the installed app"
exit $fail

View file

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

165
tools/exec-sync/net.mjs Normal file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env node
// Exec sync harness (6 October 2026): the executor's persisted state and the snapshot file, on a private fast-time
// simnet (ports 29970+, suffix 957; never the live devnet). Cases, each asserted:
// 1. known-finished: node A mines past N chain blocks; `igneum_exportExecSnapshot` writes a file; its tip, state
// root and sha256 are reported; A is stopped (the state is persisted at stop) and started again: it resumes from
// the data dir's snapshot (startedFrom "snapshot", snapshotTip = the persisted tip) and keeps executing
// 2. a fresh node B with --igneum-exec-snapshot=<file>,<sha256> and no bodies of its own beyond what it syncs:
// startedFrom "snapshot", snapshotTip = the file's tip, eth_blockNumber reaches A's tip, every miner balance
// and the state root at the file's tip equal A's
// 3. known-failed: a fresh node C with the file and a WRONG pin ignores the file (startedFrom "genesis" here, since
// a simnet peer still serves every body) and says why in its log
// 4. a fresh node D with the file and NO consensus data of its own but the file's tip unknown: refused with
// "unknown to consensus" (the file's tip is from another chain: a second simnet)
// 5. known-failed: a node E whose flag names a file that cannot be read blocks loudly (0.3.13.1), never genesis
// 6. the account dump (0.3.14): export [tip-1, tip] from B carries preState; the exporter seeds from it and cuts
// Usage: node tools/exec-sync/net.mjs [--blocks 60]
// IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner> (default vendor/igneum-node/target-exec-sync/release)
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
import { createHash } from 'node:crypto';
const ROOT = new URL('../../', import.meta.url).pathname;
const REL = process.env.IGNEUM_EXEC_BIN || `${ROOT}vendor/igneum-node/target-exec-sync/release`;
const IGNEUMD = `${REL}/igneumd`;
const MINER = `${REL}/igneum-miner`;
const EXPORTER = process.env.IGNEUM_EXPORTER || `${ROOT}proving/igneum-prove/target/release/igneum-prove-export`;
const TMP = '/tmp/igneum-exec-sync';
const BASE = 29970, SUFFIX = 957;
const flag = (name, d) => { const i = process.argv.indexOf(name); return i >= 0 ? Number(process.argv[i + 1]) : d; };
const BLOCKS = flag('--blocks', 60);
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
for (const b of [IGNEUMD, MINER, EXPORTER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const override = `${TMP}/override.json`;
// the exec-sync node is cut from 0.3.12 and knows no proof-system-2 field: drop it as text (JSON.parse would turn
// the u64::MAX "never" values into floats the node refuses)
// the 60x profile ships skip_proof_of_work false; the fast-time miner (vmine) submits unmined nonces, so it is on here
let overrideText = readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, '').replace(/"skip_proof_of_work":\s*false/, '"skip_proof_of_work": true');
if (!/"skip_proof_of_work": true/.test(overrideText)) throw new Error('override edit failed: skip_proof_of_work');
writeFileSync(override, overrideText);
const t0 = Date.now();
const log = (m) => console.log(`${new Date().toISOString().slice(11, 23)} t=${((Date.now() - t0) / 1000).toFixed(1)}s ${m}`);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const hexn = (h) => Number(BigInt(h));
const started = [];
class Node {
constructor(i, connect = [], extra = [], suffix = SUFFIX) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
this.connect = connect; this.extra = extra; this.suffix = suffix; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${this.suffix}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...this.connect.map(c => `--addpeer=${c}`), ...this.extra];
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust' } });
started.push(this.proc);
for (let k = 0; k < 120; k++) { try { await this.eth('eth_chainId'); return this; } catch { await sleep(500); } }
throw new Error(`node ${this.i} did not answer on ${this.evm}`);
}
async stop() { try { this.proc.kill('SIGINT'); } catch { } for (let k = 0; k < 60; k++) { if (this.proc.exitCode !== null) return; await sleep(500); } try { this.proc.kill('SIGKILL'); } catch { } }
async eth(method, params = []) {
const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
}
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
}
// the fast-time miner of the proving harness: `vmine` submits templates at the given share of 1 block/s
function mine(node, label) {
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
const p = spawn(MINER, ['vmine', `grpc://127.0.0.1:${node.grpcPort}`, '3600', '--label', label, '--share', '1', '--bps', '1', '--evm-address', '0x4343434343434343434343434343434343434343'], { stdio: ['ignore', out, out] });
started.push(p); return p;
}
const checks = [];
const check = (name, ok, detail) => { checks.push({ name, ok }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail).slice(0, 300) : ''}`); if (!ok) throw new Error(`check failed: ${name}`); };
async function waitTip(n, want, secs = 600) { for (let k = 0; k < secs * 2; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= want) return tip; await sleep(500); } throw new Error(`node ${n.i} did not reach ${want}`); }
function sha256(path) { return '0x' + createHash('sha256').update(readFileSync(path)).digest('hex'); }
async function main() {
const a = await new Node(0).start();
log(`node A up on ${a.evm}`);
const miner = mine(a, 'exec-sync-a');
await waitTip(a, BLOCKS, 900);
const tipA = hexn(await a.eth('eth_blockNumber'));
const st0 = await a.eth('igneum_getExecStatus');
check('A executes from genesis', st0.startedFrom === 'genesis' && st0.blocked === null && hexn(st0.executedTip) >= BLOCKS, st0);
// case 1: the export, the stop, the resume
const file = `${TMP}/snapshot.bin`;
const ex = await a.eth('igneum_exportExecSnapshot', [file]);
const sha = sha256(file);
check('the export writes the file with its tip, root and sha256', existsSync(file) && ex.sha256 === sha && hexn(ex.tip) >= BLOCKS && ex.records === hexn(ex.tip) + 1, { tip: ex.tip, bytes: ex.bytes, sha256: ex.sha256, accounts: ex.accounts });
const blockAtTip = await a.eth('eth_getBlockByNumber', [ex.tip, false]);
check('the export\'s state root is the tip block\'s', blockAtTip.stateRoot === ex.stateRoot, { root: ex.stateRoot });
try { miner.kill('SIGINT'); } catch { }
await a.stop();
const persistedLine = a.logText().split('\n').find(l => l.includes('exec state persisted at stop'));
check('A persisted its state at stop', !!persistedLine, persistedLine && persistedLine.slice(-120));
await a.start();
const resumed = a.logText().split('\n').filter(l => l.includes('exec sync: resumed from')).pop();
const st1 = await a.eth('igneum_getExecStatus');
check('A resumed from the data dir\'s snapshot', st1.startedFrom === 'snapshot' && hexn(st1.snapshotTip) >= BLOCKS && !!resumed, { startedFrom: st1.startedFrom, snapshotTip: st1.snapshotTip, line: resumed && resumed.slice(-160) });
const miner2 = mine(a, 'exec-sync-a2');
await waitTip(a, hexn(st1.snapshotTip) + 5, 300);
check('A keeps executing after the resume', hexn(await a.eth('eth_blockNumber')) > hexn(st1.snapshotTip));
// case 2: a fresh node from the file with the right pin
const b = await new Node(1, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${sha}`]).start();
const tipNow = hexn(await a.eth('eth_blockNumber'));
await waitTip(b, tipNow, 600);
const stB = await b.eth('igneum_getExecStatus');
check('B started from the file', stB.startedFrom === 'snapshot' && hexn(stB.snapshotTip) === hexn(ex.tip) && stB.snapshotSha256 === sha, { startedFrom: stB.startedFrom, snapshotTip: stB.snapshotTip });
const blkB = await b.eth('eth_getBlockByNumber', [ex.tip, false]);
check('B\'s state root at the file\'s tip equals A\'s', blkB.stateRoot === ex.stateRoot, { b: blkB.stateRoot });
const payout = '0x4343434343434343434343434343434343434343';
const h = '0x' + tipNow.toString(16);
const [balA, balB] = await Promise.all([a.eth('eth_getBalance', [payout, h]), b.eth('eth_getBalance', [payout, h])]);
check('the miner\'s balance at the same height equals on A and B', balA === balB && BigInt(balA) > 0n, { balA, balB, height: tipNow });
const [rootA, rootB] = await Promise.all([a.eth('eth_getBlockByNumber', [h, false]), b.eth('eth_getBlockByNumber', [h, false])]);
check('the state root at the same height equals on A and B', rootA.stateRoot === rootB.stateRoot, { height: tipNow });
// case 3: known-failed, the wrong pin
const wrong = '0x' + 'ab'.repeat(32);
const c = await new Node(2, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${wrong}`]).start();
await sleep(3000);
const stC = await c.eth('igneum_getExecStatus');
const cline = c.logText().split('\n').find(l => l.includes('is not the pinned'));
check('known-failed: C refuses the file under a wrong pin and says so', stC.startedFrom !== 'snapshot' && !!cline, { startedFrom: stC.startedFrom, line: cline && cline.slice(-140) });
// case 4: known-failed, a file from another chain
const z = await new Node(3, [], [], 958).start();
const zm = mine(z, 'exec-sync-z');
await waitTip(z, 12, 300);
const fileZ = `${TMP}/snapshot-z.bin`;
const exZ = await z.eth('igneum_exportExecSnapshot', [fileZ]);
try { zm.kill('SIGINT'); } catch { }
const d = await new Node(4, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${fileZ},${exZ.sha256}`]).start();
await sleep(3000);
const stD = await d.eth('igneum_getExecStatus');
const dline = d.logText().split('\n').find(l => l.includes('unknown to consensus') || l.includes('not on this node') || l.includes('is not this network'));
check('known-failed: D refuses a snapshot from another chain and says why', stD.startedFrom !== 'snapshot' && !!dline, { startedFrom: stD.startedFrom, line: dline && dline.slice(-160) });
try { miner2.kill('SIGINT'); } catch { }
// 6. the account dump (0.3.14): an export [tip-1, tip] from B (a snapshot-started node) carries the full state after
// tip-1 (preState); the exporter seeds from it and cuts block tip without any older record
{
const tipB = hexn(await b.eth('eth_blockNumber'));
const want = tipB - 2;
const exp = await b.eth('igneum_exportSegments', ['0x' + (want - 1).toString(16), '0x' + want.toString(16)]);
check('the export from one block below carries the account dump at that block', Array.isArray(exp.preState) && exp.preState.length >= 2 && exp.segments.length === 2 && exp.from === want - 1, { accounts: exp.preState && exp.preState.length, from: exp.from, to: exp.to });
const seq = `${TMP}/seq-${want}.json`, fix = `${TMP}/block-${want}.json`;
writeFileSync(seq, JSON.stringify(exp));
const r = spawnSync(EXPORTER, [seq, String(want), fix], { encoding: 'utf8', timeout: 120000 });
const out = (r.stdout || '') + (r.stderr || '');
check('the exporter seeds from the dump and cuts the block (state root equals the node\'s)', r.status === 0 && existsSync(fix) && /account dump: \d+ accounts after chain block/.test(out) && !/differs from the node/.test(out), { status: r.status, line: (out.split('\n').find(l => /account dump/.test(l)) || out.slice(-200)).slice(0, 200) });
}
// 5. known-failed: a node E whose flag names a file that does not exist (the seed, 6 October 2026: unreadable under
// /root) blocks loudly, never runs as if the flag were unset, never executes genesis
const e = await new Node(5, [`127.0.0.1:${a.p2pPort}`], ['--igneum-exec-snapshot=/nonexistent/exec-snapshot.bin,0x00']).start();
await sleep(4000);
const stE = await e.eth('igneum_getExecStatus');
const eline = e.logText().split('\n').find(l => /the flag's file .* does not exist or cannot be read/.test(l));
check('known-failed: E with an unreadable flag file blocks loudly and never starts at genesis', !!eline && stE.blocked !== null && /did not load/.test(stE.blocked) && hexn(stE.executedTip) === 0 && stE.startedFrom === 'fresh' && !/starts at genesis/.test(e.logText()), { line: eline && eline.slice(-160), blocked: stE.blocked && stE.blocked.slice(0, 80) });
await e.stop();
log(`RESULT exec sync harness: PASSED (${checks.length} checks) in ${((Date.now() - t0) / 1000).toFixed(1)} s; snapshot ${ex.bytes} bytes at tip ${hexn(ex.tip)}, ${ex.accounts} accounts`);
}
main().then(() => cleanup(0)).catch(e => { log(`FAILED: ${e.message}`); cleanup(1); });
function cleanup(code) { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } setTimeout(() => { for (const p of started) { try { p.kill('SIGKILL'); } catch { } } process.exit(code); }, 3000); }

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