diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index c249ab908..4b86fd3ef 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -23,8 +23,15 @@ pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50]; /// 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 = 45; +/// A card's clock floor when the vendor reports none: this share of its maximum core clock. 7 October 2026, the PC 1 +/// efficiency passes (docs/bench-log.md): the 5090's best MH per watt sat at 1,200 to 1,300 MHz (39 to 42 percent of +/// 3,090) and the rate fell past 5 percent only at 1,200 (class v3) and 1,100 (class v4), under the old 45 percent +/// floor; so the ladder continues below 45 percent in [`CLOCK_FINE_STEP_MHZ`] steps down to 20 percent of the maximum +/// (618 MHz on the 5090) and the stop rule, not the floor, ends the search. +pub const CLOCK_FLOOR_PCT: u32 = 20; +/// Below the percent ladder's last rung (45 percent) the clock ladder descends in steps of this many MHz until the +/// rate falls more than the tolerance under the cap point's rate (the knee), a step faults, or the floor is reached. +pub const CLOCK_FINE_STEP_MHZ: u32 = 100; /// The measured efficient point per card (docs/bench-log.md: the 5090 at 308 W for 122 MH/s, the 9070 XT at 199 W for /// 18 MH/s), the tuner's ceiling. Rule (main, 7 October 2026, after PC 2 dropped nine minutes after the tuner asked its /// 5090 for 575 W with proving on the same card): the tuner never requests more than the card's measured efficient point @@ -233,6 +240,9 @@ pub struct Plan { pub tolerance_pct: f64, power: Vec, clock_pcts: Vec, + /// the clock ladder below the percent rungs: MHz values from the last rung minus one fine step down to the floor + /// (the core-clock knob of 7 October 2026; empty when the card has no readable maximum clock) + clock_fine: Vec, fixed: Vec, /// Ember 2 (Climb): the start point, the step sizes and the step budget climb: Option, @@ -258,7 +268,7 @@ impl 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 }) } + Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), clock_fine: 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. @@ -327,7 +337,22 @@ impl Plan { } } 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(), climb: None } + // the fine ladder: from the last percent rung down to the floor in CLOCK_FINE_STEP_MHZ steps (the knob of + // 7 October 2026; the stop rule in `next` ends it at the knee) + let mut clock_fine = Vec::new(); + if limits.clock_max_mhz > 0 { + let last_pct = limits.clamp_clock(limits.clock_max_mhz * CLOCK_STEPS_PCT[CLOCK_STEPS_PCT.len() - 1] / 100); + let floor = limits.clock_floor(); + let mut m = (last_pct / CLOCK_FINE_STEP_MHZ) * CLOCK_FINE_STEP_MHZ; + if m >= last_pct { + m = m.saturating_sub(CLOCK_FINE_STEP_MHZ); + } + while m >= floor && m > 0 { + clock_fine.push(m); + m = m.saturating_sub(CLOCK_FINE_STEP_MHZ); + } + } + Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, clock_fine, 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 @@ -346,14 +371,14 @@ impl Plan { 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, climb: None } + Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: 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, climb: None } + Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed, climb: None } } /// How many steps the plan has at most (the clock ladder counts whether or not it runs). @@ -361,7 +386,42 @@ impl Plan { if let Some(c) = &self.climb { return c.budget; } - self.fixed.len() + self.power.len() + self.clock_pcts.len() + self.fixed.len() + self.power.len() + self.clock_pcts.len() + self.clock_fine.len() + } + + /// The cap point's row: the power ladder's choice (the row the clock search is read against), else the first row. + pub fn cap_row(&self, rows: &[Row]) -> Option { + if self.power.is_empty() { + rows.first().cloned() + } else { + choose(&rows[..self.power.len().min(rows.len())], self.tolerance_pct).or_else(|| rows.first().cloned()) + } + } + + /// Why the clock search ended after `rows`, in words, or None while it runs: a faulted clock row (a rejected or + /// mismatched hash during the hold: the fingerprint check), the knee (the rate under the cap point's by more than + /// the tolerance), or the floor. + pub fn clock_stop_reason(&self, rows: &[Row]) -> Option { + let last = rows.last()?; + if last.point.clock_mhz == 0 || !matches!(self.kind, PlanKind::Full) { + return None; + } + let clock_rows = rows.len().saturating_sub(self.power.len()); + if clock_rows == 0 { + return None; + } + if last.mark == Some(Mark::Faulted) { + return Some(format!("fingerprint mismatch at {} MHz, clocks reset", last.point.clock_mhz)); + } + if let Some(cap) = self.cap_row(rows) { + if last.usable() && cap.usable() && cap.mhs > 0.0 && last.mhs < cap.mhs * (1.0 - self.tolerance_pct.max(0.0) / 100.0) { + return Some(format!("rate fell {:.1} percent at {} MHz", 100.0 * (cap.mhs - last.mhs) / cap.mhs, last.point.clock_mhz)); + } + } + if clock_rows >= self.clock_pcts.len() + self.clock_fine.len() { + return Some(format!("the floor at {} MHz", last.point.clock_mhz)); + } + None } pub fn is_empty(&self) -> bool { self.len() == 0 @@ -380,10 +440,22 @@ impl Plan { return Some(self.power[i].clone()); } let k = i - self.power.len(); - let pct = *self.clock_pcts.get(k)?; + // the stop rule (7 October 2026): a faulted clock row or the knee ends the search; the choice is made among + // the rows so far + if k > 0 { + if let Some(reason) = self.clock_stop_reason(rows) { + if !reason.starts_with("the floor") { + return None; + } + } + } + let clock = if k < self.clock_pcts.len() { + self.limits.clamp_clock(self.limits.clock_max_mhz * self.clock_pcts[k] / 100) + } else { + *self.clock_fine.get(k - self.clock_pcts.len())? + }; // the clock ladder rides the power point the power ladder chose (the before point when nothing won) let power_pct = if self.power.is_empty() { self.before.power_pct } else { choose(&rows[..self.power.len()], self.tolerance_pct).map(|r| r.point.power_pct).unwrap_or(self.before.power_pct) }; - let clock = self.limits.clamp_clock(self.limits.clock_max_mhz * pct / 100); // a step whose clamp lands on the previous step's clock is dropped (the floor was reached) if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) { return None; @@ -1072,6 +1144,38 @@ pub fn result_line(kind: PlanKind, mhs: f64, watts: f64, eff: f64) -> String { } } +/// The core-clock knob's result on a card (7 October 2026; the UI lane's field shape): the chosen lock against the cap +/// point's unlocked row, and the stop reason in words. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LockResult { + /// the chosen core clock cap (0 = unlocked) + pub lock_mhz: u32, + pub lock_mhs: f64, + pub lock_w: f64, + pub lock_mhw: f64, + /// the cap point's row (clock 0): the rate and draw the lock is read against + pub unlocked_mhs: f64, + pub unlocked_w: f64, + /// "rate fell 5.1 percent at 1,200 MHz", "fingerprint mismatch at 1,400 MHz, clocks reset", "the floor at 618 MHz", + /// "no lever" (a card without a clock cap), "" while nothing ran + pub lock_note: String, +} + +/// The knob's result from a finished plan's rows and its chosen row. +pub fn lock_result(plan: &Plan, rows: &[Row], chosen: &Row) -> LockResult { + let cap = plan.cap_row(rows); + let (unlocked_mhs, unlocked_w) = cap.as_ref().map(|c| (c.mhs, c.watts)).unwrap_or((0.0, 0.0)); + let ran_clocks = rows.iter().any(|r| r.point.clock_mhz > 0); + let note = if plan.limits.clock_max_mhz == 0 { + "no lever".to_string() + } else if !ran_clocks { + String::new() + } else { + plan.clock_stop_reason(rows).unwrap_or_else(|| format!("stopped at {} MHz", rows.last().map(|r| r.point.clock_mhz).unwrap_or(0))) + }; + LockResult { lock_mhz: chosen.point.clock_mhz, lock_mhs: chosen.mhs, lock_w: chosen.watts, lock_mhw: chosen.eff, unlocked_mhs, unlocked_w, lock_note: note } +} + /// 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 { match vendor { @@ -1167,7 +1271,9 @@ mod tests { #[test] fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() { let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); - assert_eq!(plan.len(), 5 + 6, "five power steps (60% and 50% clamp to 400 W; one kept) and six clock steps (90% down to 45%)"); + // five power steps (60% and 50% clamp to 400 W; one kept), six percent rungs (90% down to 45% = 1,390) and the + // fine ladder 1,300 down to the 20% floor (618): 1,300, 1,200, ..., 700 = 7 steps + assert_eq!(plan.len(), 5 + 6 + 7); let first = plan.next(&[]).unwrap(); assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power)); // the power ladder: 575, 518, 460, 403, 400 @@ -1189,24 +1295,21 @@ mod tests { 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"); - 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!(s.point.clock_mhz, 2163, "70%"); + rows.push(row_at(s.point, 200.0, 118.0)); + // the stop rule (7 October 2026): 118 is 4.8% under the cap point's 124, past the 1% tolerance, so the search + // ends here (the 6 October ladder went on to 1,854, 1,545 and 1,390) assert_eq!(plan.next(&rows), None); + assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("rate fell 4.8 percent at 2163 MHz")); // 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, 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(), 6); + // clocks only: six percent rungs (1,800 .. 900) then the fine ladder 800 .. 400 (the 20% floor) = 5 more + assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 6 + 5); 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()); } @@ -1300,11 +1403,12 @@ mod tests { #[test] fn limits_never_exceed_the_vendor_or_undercut_the_floor() { let l = l5090(); - assert_eq!(l.clock_floor(), 1390); - assert_eq!(l.clamp_clock(1000), 1390); + assert_eq!(l.clock_floor(), 618, "20% of 3,090 (7 October 2026; the 45% floor of 6 October sat on the 5090's knee)"); + assert_eq!(l.clamp_clock(1000), 1000); + assert_eq!(l.clamp_clock(500), 618); 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 45% 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 20% 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); @@ -1604,4 +1708,138 @@ mod tests { assert!(control_reason("amd", &Limits::default(), "1", false, true).is_none()); } } + + /// The core-clock knob (7 October 2026, the PC 1 efficiency passes): a flat ladder walks below the old 45 percent + /// floor in 100 MHz steps to the 20 percent floor, and the result names the floor. + #[test] + fn the_clock_ladder_continues_below_45_percent_in_100_mhz_steps_to_the_floor() { + let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0); + let mut rows = Vec::new(); + for _ in 0..5 { + let s = plan.next(&rows).unwrap(); + rows.push(row_at(s.point, 300.0, 136.8)); + } + let mut clocks = Vec::new(); + while let Some(s) = plan.next(&rows) { + assert_eq!(s.kind, Kind::Clock); + clocks.push(s.point.clock_mhz); + // the rate holds (memory-bound): the draw falls with the clock + rows.push(row_at(s.point, 300.0 - clocks.len() as f64 * 10.0, 136.0)); + } + assert_eq!(clocks, vec![2781, 2472, 2163, 1854, 1545, 1390, 1300, 1200, 1100, 1000, 900, 800, 700]); + assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("the floor at 700 MHz")); + let chosen = choose(&rows, 1.0).unwrap(); + assert_eq!(chosen.point.clock_mhz, 700, "flat rate: the lowest draw wins"); + let r = lock_result(&plan, &rows, &chosen); + assert_eq!((r.lock_mhz, r.lock_w, r.unlocked_mhs, r.unlocked_w), (700, 170.0, 136.8, 300.0)); + assert_eq!(r.lock_note, "the floor at 700 MHz"); + } + + /// The stop rule on PC 1's RTX 5090 rows of 7 October 2026 (class v3, the card alone): the first clock row more + /// than the tolerance under the cap point's rate ends the search and the best MH per watt among the rows within + /// tolerance is chosen. At the 1 percent tolerance the 5090's rate (136.6 at 2,781) is 1.24 percent down at + /// 1,545 MHz, so the search ends there and 1,854 MHz (135.6 MH/s at 239.6 W) is the point; at 1.5 percent it runs + /// on to 1,200 (5.2 percent down) and 1,300 MHz is the point. The tolerance is the manifest's. + #[test] + fn the_clock_search_stops_at_the_knee_and_names_it() { + let measured: Vec<(u32, f64, f64)> = vec![(2781, 317.9, 136.6), (2472, 276.0, 136.4), (2163, 252.4, 136.1), (1854, 239.6, 135.6), (1545, 232.1, 134.9), (1390, 229.0, 134.85), (1300, 223.3, 134.6), (1200, 215.7, 129.5)]; + let walk = |tolerance: f64| -> (Plan, Vec) { + let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, tolerance); + let mut rows = vec![]; + for (mhz, w, mhs) in &measured { + let Some(s) = plan.next(&rows) else { break }; + assert_eq!(s.point.clock_mhz, *mhz); + rows.push(row_at(s.point, *w, *mhs)); + } + (plan, rows) + }; + let (plan, rows) = walk(1.0); + assert_eq!(rows.last().unwrap().point.clock_mhz, 1545, "the search ends on the first row over 1 percent under the cap row"); + assert_eq!(plan.next(&rows), None); + let reason = plan.clock_stop_reason(&rows).unwrap(); + assert!(reason.starts_with("rate fell 1.2 percent at 1545 MHz"), "{reason}"); + let chosen = choose(&rows, 1.0).unwrap(); + assert_eq!(chosen.point.clock_mhz, 1854, "the best MH per watt within 1 percent of the fastest row"); + let r = lock_result(&plan, &rows, &chosen); + assert_eq!((r.lock_mhz, r.unlocked_mhs), (1854, 136.6)); + assert!((r.lock_mhw - 135.6 / 239.6).abs() < 1e-6); + let (plan, rows) = walk(1.5); + assert_eq!(rows.last().unwrap().point.clock_mhz, 1200); + assert_eq!(plan.next(&rows), None); + assert!(plan.clock_stop_reason(&rows).unwrap().starts_with("rate fell 5.2 percent at 1200 MHz")); + assert_eq!(choose(&rows, 1.5).unwrap().point.clock_mhz, 1300); + } + + /// The fingerprint rule: a clock row marked Faulted (a rejected or mismatched hash during the hold) ends the search + /// at once; the choice is made among the usable rows and the note says why. + #[test] + fn a_faulted_clock_row_ends_the_search_and_the_note_says_so() { + let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0); + let mut rows = vec![]; + for (mhz, w, mhs) in [(2781, 317.9, 136.6), (2472, 276.0, 136.4)] { + let s = plan.next(&rows).unwrap(); + assert_eq!(s.point.clock_mhz, mhz); + rows.push(row_at(s.point, w, mhs)); + } + let s = plan.next(&rows).unwrap(); + assert_eq!(s.point.clock_mhz, 2163); + let mut bad = row_at(s.point, 252.4, 136.1); + bad.faults = 1; + bad.mark = Some(Mark::Faulted); + rows.push(bad); + assert_eq!(plan.next(&rows), None, "the search ends on the faulted row"); + assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("fingerprint mismatch at 2163 MHz, clocks reset")); + let chosen = choose(&rows, 1.0).unwrap(); + assert_eq!(chosen.point.clock_mhz, 2472, "the faulted row never wins"); + assert_eq!(lock_result(&plan, &rows, &chosen).lock_note, "fingerprint mismatch at 2163 MHz, clocks reset"); + } + + /// The same through the state machine with a fake helper (the known-failed case first: a mismatch mid-search must + /// reset and abort): the run applies 2,781 and 2,472, a fault lands during 2,163's hold, the row comes out Faulted, + /// the next step is none, and the run's final Apply is the chosen 2,472 point (the reset), then Finished. + #[test] + fn a_mismatch_mid_search_resets_to_the_chosen_point_and_finishes() { + let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0); + let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(2), apply: Duration::from_secs(3) }; + let t0 = Instant::now(); + let mut run = Run::new(0, "0", "card-0", plan, 300.0, false, timing, t0); + let mut t = t0; + let mut applied: Vec = Vec::new(); + let mut finished: Option = None; + let watts_for = |mhz: u32| -> f64 { match mhz { 2781 => 317.9, 2472 => 276.0, _ => 252.4 } }; + for _ in 0..200 { + t += Duration::from_millis(500); + let acked = true; + let limit = run.current.as_ref().map(|s| s.watts).unwrap_or(0.0); + // the fake helper: every setting takes; during 2,163's hold the worker reports a mismatched hash + if let Some(cur) = run.current.clone() { + if matches!(run.phase, Phase::Holding { .. }) { + run.sample_rate(136.4); + run.sample_telemetry(watts_for(cur.point.clock_mhz), cur.point.clock_mhz as f64, 13801.0, 60.0); + if cur.point.clock_mhz == 2163 { + run.sample_fault(); + } + } + } + for o in run.tick(t, Readback { limit_w: limit, acked }) { + match o { + Out::Apply(s) => applied.push(s), + Out::Finished(r) => finished = Some(r), + Out::Failed(e) => panic!("the run failed: {e}"), + Out::Row(_) => {} + } + } + if finished.is_some() { + break; + } + } + let clocks: Vec = applied.iter().map(|s| s.point.clock_mhz).collect(); + assert_eq!(clocks, vec![2781, 2472, 2163, 2472], "2,781, 2,472, the faulted 2,163, then the reset to the chosen 2,472"); + assert_eq!(applied.last().unwrap().kind, Kind::Confirm); + let f = finished.expect("finished"); + assert_eq!(f.point.clock_mhz, 2472); + assert_eq!(run.rows.len(), 3); + assert_eq!(run.rows[2].mark, Some(Mark::Faulted)); + assert_eq!(run.plan.clock_stop_reason(&run.rows).as_deref(), Some("fingerprint mismatch at 2163 MHz, clocks reset")); + } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index ece959604..29637ff73 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -3608,6 +3608,13 @@ impl Engine { cc.tune_steps = of; cc.tune_eta_s = eta; cc.tune_plan = plan.into(); + // the clock search's own step count while a clock step runs (the UI's "locking clocks: step 4 of 9") + if let Some(r) = self.sweep.as_ref() { + let power_steps = r.rows.iter().filter(|x| x.point.clock_mhz == 0 && x.mark.is_some()).count() as u32; + let on_clock = r.current.as_ref().map(|s| s.kind == crate::ember::Kind::Clock).unwrap_or(false); + cc.lock_step = if on_clock { step.saturating_sub(power_steps) } else { 0 }; + cc.lock_steps = if on_clock { of.saturating_sub(power_steps) } else { 0 }; + } } if self.shared.runtime.sweep_only { // the job playbook forwards this to the installed app's /api/tune-progress @@ -3646,6 +3653,22 @@ impl Engine { c.tune_source = kind.name().into(); 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(); + // the core-clock knob's result (7 October 2026): the chosen lock against the cap point, and why it stopped + if kind == crate::ember::PlanKind::Baseline { + c.lock_note = if c.vendor == "apple" || run.plan.limits.clock_max_mhz == 0 { "no lever".into() } else { c.sweep_note.clone() }; + } else { + let lr = crate::ember::lock_result(&run.plan, &run.rows, &row); + c.lock_mhz = lr.lock_mhz; + c.lock_mhs = lr.lock_mhs; + c.lock_w = lr.lock_w; + c.lock_mhw = lr.lock_mhw; + c.unlocked_mhs = lr.unlocked_mhs; + c.unlocked_w = lr.unlocked_w; + c.lock_at = unix as f64; + c.lock_note = lr.lock_note; + } + c.lock_step = 0; + c.lock_steps = 0; let control = c.tune_control; if kind == crate::ember::PlanKind::Baseline { c.sweep_note = if control { String::new() } else { c.sweep_note.clone() }; diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index d60fd2706..bcf362a5a 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -166,6 +166,18 @@ pub struct CardState { // Ember 2: the memory clock the last tune chose and the measured curve (every row of the last plan) pub tune_mem_mhz: u32, pub tune_curve: Vec, + // the core-clock knob (7 October 2026, src/ember.rs lock_result; the UI lane's field shape): the chosen lock + // against the cap point's unlocked row, the step while the clock search runs, the moment and the stop reason + pub lock_mhz: u32, // the chosen core clock cap (0 = unlocked) + pub lock_mhs: f64, + pub lock_w: f64, + pub lock_mhw: f64, + pub unlocked_mhs: f64, // the cap point's row the lock is read against + pub unlocked_w: f64, + pub lock_step: u32, // the clock search's step while it runs (0 otherwise) + pub lock_steps: u32, + pub lock_at: f64, // unix s the lock point was taken (0 = never) + pub lock_note: String, // "rate fell 5.1 percent at 1200 MHz", "fingerprint mismatch at 1400 MHz, clocks reset", "the floor at 700 MHz", "no lever" // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64,