From 03b2648479f264a1b2ce5bd3075dfc5b31671819 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:11:20 +0000 Subject: [PATCH 1/2] Ember Tune tiers (engine half, 0.3.25): the three measured rows of the card's own search as card-state fields (tiers, tier, tier_note, tier_at; efficiency = best MH/W outright, balanced = best MH/W within 1 percent of the top rate, max = the top-rate row, source measured or stock, nothing interpolated), the fleet tier setting (balanced from install, sweep on), Cmd::TuneTier and POST /api/tune/tier through the cap and lock path with no re-search, an automatic tune lands on the tier's row, the first-run strip line, a no-lever card's one stock tier with its reason; tests on the 5090 and 5080 curves of 7 to 8 October Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/config.rs | 14 +++- app/igneum-app/src/ember.rs | 140 +++++++++++++++++++++++++++++++++++ app/igneum-app/src/engine.rs | 103 +++++++++++++++++++++++++- app/igneum-app/src/server.rs | 23 ++++++ app/igneum-app/src/state.rs | 8 ++ 5 files changed, 286 insertions(+), 2 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 5d5b6ba3a..7b863e7f4 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -45,6 +45,11 @@ pub struct CardPref { /// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal #[serde(default)] pub tune_goal: String, + /// Ember Tune tiers (8 October 2026): this card's tier in force and when it was applied + #[serde(default)] + pub tier: String, + #[serde(default)] + pub tier_at: u64, /// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can /// read "saves 84 W, 0.15% of rate"; 0 = never measured #[serde(default)] @@ -110,6 +115,9 @@ pub struct Settings { /// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders). #[serde(default = "balanced")] pub tune_goal: String, + /// Ember Tune tiers: the fleet tier (efficiency | balanced | max); balanced from install + #[serde(default = "default_tier")] + pub tune_tier: String, #[serde(default)] pub power_price_pence: f64, #[serde(default)] @@ -213,9 +221,13 @@ fn yes() -> bool { true } +fn default_tier() -> String { + "balanced".into() +} + impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), tune_tier: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false } } } diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 863042fa1..61c062cb6 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -621,6 +621,73 @@ pub fn choose(rows: &[Row], tolerance_pct: f64) -> Option { best.cloned() } +/// Ember Tune tiers (8 October 2026, the founder's definition relayed by main): the card is tuned once (the search at +/// first steady mining, weekly, or on Tune) and the three tiers are rows of THAT search, applied at once with no +/// re-search. `efficiency` is the best MH per watt row outright; `balanced` is the best MH per watt row within one +/// percent of the top rate (the default from install); `max` is the top-rate row. `source` is "stock" when the row +/// is the unlocked 100 percent point (the search's first row), else "measured". Nothing is interpolated. +#[derive(Clone, Debug, PartialEq)] +pub struct Tier { + pub id: &'static str, + pub row: Row, + pub source: &'static str, +} + +impl Tier { + pub fn json(&self) -> serde_json::Value { + serde_json::json!({ "id": self.id, "clock_mhz": self.row.point.clock_mhz, "power_pct": self.row.point.power_pct, "w": r1(self.row.watts), "mhs": r2(self.row.mhs), "mhw": r4(self.row.eff), "source": self.source, "limit_w": r1(self.row.limit), "mem_mhz": self.row.point.mem_mhz }) + } +} + +pub const TIER_IDS: [&str; 3] = ["efficiency", "balanced", "max"]; +pub const BALANCED_TOLERANCE_PCT: f64 = 1.0; + +fn is_stock(r: &Row) -> bool { + r.point.clock_mhz == 0 && r.point.power_pct >= 100 && r.point.mem_mhz == 0 +} + +/// The three tiers of a search, or none when no row is usable. With one usable row all three name it. +pub fn tiers(rows: &[Row]) -> Vec { + let usable: Vec<&Row> = rows.iter().filter(|r| r.usable()).collect(); + if usable.is_empty() { + return Vec::new(); + } + let efficiency = usable.iter().copied().fold(None::<&Row>, |b, r| match b { Some(b) if b.eff >= r.eff => Some(b), _ => Some(r) }).cloned().unwrap(); + let max = usable.iter().copied().fold(None::<&Row>, |b, r| match b { Some(b) if b.mhs >= r.mhs => Some(b), _ => Some(r) }).cloned().unwrap(); + let balanced = choose(rows, BALANCED_TOLERANCE_PCT).unwrap_or_else(|| efficiency.clone()); + let src = |r: &Row| if is_stock(r) { "stock" } else { "measured" }; + vec![ + Tier { id: "efficiency", source: src(&efficiency), row: efficiency }, + Tier { id: "balanced", source: src(&balanced), row: balanced }, + Tier { id: "max", source: src(&max), row: max }, + ] +} + +/// The one tier of a card with no lever: its stock row, and the reason in the note. +pub fn stock_tier(row: &Row) -> Vec { + vec![Tier { id: "max", source: "stock", row: row.clone() }] +} + +/// The tier's point and cap back from the card state's json (what /api/tune/tier applies with no re-search). +pub fn tier_from_json(tiers: &[serde_json::Value], id: &str) -> Option<(Point, f64, f64, f64)> { + let t = tiers.iter().find(|t| t.get("id").and_then(|v| v.as_str()) == Some(id))?; + let n = |k: &str| t.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0); + Some((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 }, n("limit_w"), n("mhs"), n("w"))) +} + +/// The row a tier name points at in a tier list. +pub fn tier_row(tiers: &[Tier], id: &str) -> Option { + tiers.iter().find(|t| t.id == id).map(|t| t.row.clone()) +} + +/// The first-run line the strip shows: "Ember Tune set to Balanced: 122.1 MH/s at 221 W, 77 W under stock". +pub fn tier_set_line(card: &str, id: &str, row: &Row, stock_w: f64) -> String { + let mut c = id.chars(); + let title = match c.next() { Some(f) => f.to_uppercase().collect::() + c.as_str(), None => String::new() }; + let saving = if stock_w > row.watts { format!(", {:.0} W under stock", stock_w - row.watts) } else { String::new() }; + format!("Ember Tune set {card} to {title}: {:.1} MH/s at {:.0} W{saving}", row.mhs, row.watts) +} + /// A confirm check's verdict: the prior stands, or the neighbour beat it by over CONFIRM_GAIN_PCT (the full plan is /// due), or nothing could be read. #[derive(Clone, Debug, PartialEq)] @@ -1843,4 +1910,77 @@ mod tests { 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")); } + + // ---- tiers (8 October 2026): the 5090 and 5080 curves of the night of 7 to 8 October as fixtures ---- + fn curve_row(clock: u32, pct: u32, watts: f64, mhs: f64) -> Row { + Row { point: Point { clock_mhz: clock, power_pct: pct, mem_mhz: 0 }, limit: watts, watts, mhs, eff: mhs / watts, draws: 12, rates: 6, gclk: clock as f64, mclk: 0.0, tmax: 60.0, faults: 0, mark: Some(Mark::Ok) } + } + /// PC 1's RTX 5090, the app's stored full curve (8 October 2026, 06:08Z read): stock 122.71 at 298.4 W, the clock + /// ladder 2,781 down to 1,390 MHz + fn curve_5090() -> Vec { + vec![ + curve_row(0, 100, 298.4, 122.71), curve_row(0, 90, 301.0, 122.69), curve_row(0, 80, 301.5, 122.53), curve_row(0, 70, 302.3, 123.05), curve_row(0, 60, 302.7, 122.29), + curve_row(2781, 100, 292.7, 122.16), curve_row(2472, 100, 255.2, 122.4), curve_row(2163, 100, 231.1, 121.06), curve_row(1854, 100, 221.0, 122.06), curve_row(1545, 100, 213.7, 120.91), curve_row(1390, 100, 204.3, 118.56), + ] + } + #[test] + fn tiers_of_the_5090_curve_are_three_measured_rows_of_that_search() { + let t = tiers(&curve_5090()); + assert_eq!(t.len(), 3); + // efficiency: the best MH/W outright is the 1,390 MHz row (0.5803) + assert_eq!((t[0].id, t[0].row.point.clock_mhz, t[0].source), ("efficiency", 1390, "measured")); + // max: the top rate is the 70 percent cap row (123.05), a measured row, not stock + assert_eq!((t[2].id, t[2].row.point.power_pct, t[2].row.point.clock_mhz, t[2].source), ("max", 70, 0, "measured")); + // balanced: within 1 percent of 123.05 (121.82 and up) the best MH/W is 1,854 MHz (0.5522) + assert_eq!((t[1].id, t[1].row.point.clock_mhz, t[1].source), ("balanced", 1854, "measured")); + assert!(t[1].row.mhs >= 0.99 * t[2].row.mhs); + assert!(t[0].row.eff >= t[1].row.eff && t[1].row.eff >= t[2].row.eff); + } + #[test] + fn the_max_tier_reads_stock_when_the_unlocked_row_is_the_top() { + // PC 1's RTX 5080 climb (06:05Z): the stock row at 80 percent is not the stock point; a synthetic stock row on top + let rows = vec![curve_row(0, 100, 160.0, 61.0), curve_row(2936, 80, 123.2, 60.27), curve_row(0, 80, 149.8, 60.13)]; + let t = tiers(&rows); + assert_eq!((t[2].id, t[2].source, t[2].row.watts), ("max", "stock", 160.0)); + assert_eq!((t[0].id, t[0].row.point.clock_mhz, t[0].source), ("efficiency", 2936, "measured")); + // balanced: 60.27 is within 1 percent of 61.0 (60.39)? no: 60.27 < 60.39, so balanced is the stock row itself + assert_eq!((t[1].id, t[1].source), ("balanced", "stock")); + } + #[test] + fn no_usable_row_gives_no_tier_and_one_row_names_all_three() { + assert!(tiers(&[]).is_empty()); + let mut bad = curve_row(0, 100, 300.0, 120.0); + bad.mark = Some(Mark::Faulted); + assert!(tiers(&[bad]).is_empty()); + let one = vec![curve_row(1854, 100, 221.0, 122.06)]; + let t = tiers(&one); + assert_eq!(t.iter().map(|x| x.id).collect::>(), vec!["efficiency", "balanced", "max"]); + assert!(t.iter().all(|x| x.row.point.clock_mhz == 1854 && x.source == "measured")); + assert_eq!(tier_row(&t, "balanced").unwrap().point.clock_mhz, 1854); + assert!(tier_row(&t, "nothing").is_none()); + } + #[test] + fn a_tier_read_back_from_the_card_state_json_is_the_row_that_was_written() { + let t = tiers(&curve_5090()); + let js: Vec = t.iter().map(|x| x.json()).collect(); + let (p, limit, mhs, w) = tier_from_json(&js, "efficiency").unwrap(); + assert_eq!((p.clock_mhz, p.power_pct, p.mem_mhz), (1390, 100, 0)); + assert_eq!((limit, mhs, w), (204.3, 118.56, 204.3)); + assert!(tier_from_json(&js, "nothing").is_none()); + assert!(tier_from_json(&[], "balanced").is_none()); + } + #[test] + fn the_tier_json_and_the_set_line_carry_the_fields_the_ui_reads() { + let t = tiers(&curve_5090()); + let j = t[1].json(); + for k in ["id", "clock_mhz", "power_pct", "w", "mhs", "mhw", "source"] { + assert!(j.get(k).is_some(), "{k} missing"); + } + assert_eq!(j["w"], 221.0); + assert_eq!(j["mhs"], 122.06); + assert_eq!(tier_set_line("NVIDIA GeForce RTX 5090", "balanced", &t[1].row, 298.4), "Ember Tune set NVIDIA GeForce RTX 5090 to Balanced: 122.1 MH/s at 221 W, 77 W under stock"); + assert_eq!(tier_set_line("card", "max", &t[2].row, 298.4), "Ember Tune set card to Max: 123.1 MH/s at 302 W"); + let st = stock_tier(&curve_row(0, 100, 202.0, 18.9)); + assert_eq!((st.len(), st[0].id, st[0].source), (1, "max", "stock")); + } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 29637ff73..f90b70532 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -102,6 +102,9 @@ pub enum Cmd { TuneGoal(Option, Option, Option), /// one card's goal override (key, goal; "" = back to the global goal) TuneCardGoal(String, String), + /// Ember Tune tiers (8 October 2026): apply a tier (efficiency | balanced | max) to one card (key) or every card + /// (None, which also sets the fleet tier) through the cap and lock path, no re-search + TuneTier(Option, String), /// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step) Region(Option, Option, Option), /// the network step: the chosen network ("devnet-3" | "testnet-1") and whether the user confirmed what resets @@ -194,7 +197,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), network: settings.network.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead }; + 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(), tune_tier: settings.tune_tier.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), network: settings.network.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead }; st.heat = heat_state_off(settings.heat_on, settings.heat_set_c); 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(); @@ -1524,6 +1527,60 @@ impl Engine { } } } + Cmd::TuneTier(key, tier) => { + if key.is_none() { + { + let mut s = self.shared.settings.lock().unwrap(); + s.tune_tier = tier.clone(); + } + self.st().settings.tune_tier = tier.clone(); + } + let targets: Vec = { + let st = self.st(); + st.mining.cards.iter().enumerate().filter(|(_, c)| key.as_ref().map(|k| &c.key == k).unwrap_or(true)).map(|(i, _)| i).collect() + }; + let mut applied = Vec::new(); + for idx in targets { + let (name, device, found) = { + let st = self.st(); + let Some(c) = st.mining.cards.get(idx) else { continue }; + (c.name.clone(), c.device.clone(), crate::ember::tier_from_json(&c.tiers, &tier)) + }; + let Some((point, limit_w, mhs, w)) = found else { + self.shared.event("warn", &format!("{name}: not measured yet: the search runs 2 min into steady mining")); + continue; + }; + let unix = crate::platform::unix_now(); + { + let mut settings = self.shared.settings.lock().unwrap(); + let mut st = self.st(); + let Some(c) = st.mining.cards.get_mut(idx) else { continue }; + c.power_pct = point.power_pct; + c.clock_cap_mhz = point.clock_mhz; + c.mem_cap_mhz = point.mem_mhz; + c.tier = tier.clone(); + c.tier_at = unix as f64; + if c.vendor == "nvidia" && limit_w > 0.0 { + c.power_limit_w = limit_w; + c.power_applied = true; + c.power_note = format!("power cap: {} W held by the {} tier", limit_w as u64, tier); + } + let e = settings.cards.entry(c.key.clone()).or_insert_with(|| crate::config::CardPref { enabled: c.enabled, identities: c.identities, ..Default::default() }); + e.tier = tier.clone(); + e.tier_at = unix; + e.power_pct = point.power_pct; + e.sweep_clock_mhz = point.clock_mhz; + e.sweep_mem_mhz = point.mem_mhz; + } + self.tune_apply(idx, &device, &crate::ember::Step { point, watts: if limit_w > 0.0 { limit_w } else { w }, kind: crate::ember::Kind::Confirm }); + self.shared.event("ok", &format!("{name}: {} tier applied, {:.1} MH/s at {:.0} W ({})", tier, mhs, w, point_words(&point))); + applied.push(name); + } + self.shared.save_settings(); + if applied.is_empty() { + self.shared.event("warn", &format!("tier {tier}: no card has a measured set yet")); + } + } Cmd::TuneCardGoal(key, goal) => { { let mut s = self.shared.settings.lock().unwrap(); @@ -3628,6 +3685,23 @@ impl Engine { /// let the helper go. A confirm check that found a better neighbour queues the full plan. fn sweep_finish(&mut self, row: crate::ember::Row) { let Some(run) = self.sweep.take() else { return }; + // Ember Tune tiers: an automatic tune lands on the card's tier (its own, else the fleet's; balanced from install), + // the tier's row of THIS search, instead of the goal's choice; the first time, the strip gets its one line + let (row, tier_applied, first_tier) = { + let settings = self.shared.settings.lock().unwrap(); + let st = self.st(); + match st.mining.cards.get(run.card) { + Some(c) if !c.pinned && run.plan.kind != crate::ember::PlanKind::Baseline && c.vendor != "apple" && run.plan.limits.clock_max_mhz > 0 && c.tune_control => { + let want = settings.cards.get(&c.key).map(|p| p.tier.clone()).filter(|t| !t.is_empty()).unwrap_or_else(|| settings.tune_tier.clone()); + let want = if crate::ember::TIER_IDS.contains(&want.as_str()) { want } else { "balanced".to_string() }; + match crate::ember::tier_row(&crate::ember::tiers(&run.rows), &want) { + Some(r) => (r, Some(want), c.tier_at == 0.0), + None => (row, None, false), + } + } + _ => (row, None, false), + } + }; let idx = run.card; let unix = crate::platform::unix_now(); let kind = run.plan.kind; @@ -3653,6 +3727,16 @@ 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(); + // Ember Tune tiers (8 October 2026): the three rows of this search; a card with no lever carries its stock row + if kind == crate::ember::PlanKind::Baseline || c.vendor == "apple" || run.plan.limits.clock_max_mhz == 0 || !c.tune_control { + c.tiers = crate::ember::stock_tier(&row).iter().map(|t| t.json()).collect(); + c.tier_note = if c.vendor == "apple" { "no lever: Apple silicon sets its own clocks and power".into() } else if !c.tune_control { "no lever: measure only until Power control is on".into() } else if run.plan.limits.clock_max_mhz == 0 { "no lever: no maximum clock reported".into() } else { "no lever: the search measured the stock point only".into() }; + c.tier = "max".into(); + } else { + let t = crate::ember::tiers(&run.rows); + c.tiers = t.iter().map(|x| x.json()).collect(); + c.tier_note = String::new(); + } // 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() }; @@ -3678,6 +3762,10 @@ impl Engine { c.power_pct = row.point.power_pct; c.clock_cap_mhz = row.point.clock_mhz; c.mem_cap_mhz = row.point.mem_mhz; + if let Some(t) = &tier_applied { + c.tier = t.clone(); + c.tier_at = unix as f64; + } if c.vendor == "nvidia" { c.power_limit_w = row.limit; c.power_applied = true; @@ -3693,6 +3781,10 @@ impl Engine { e.sweep_mhs = row.mhs; e.sweep_clock_mhz = row.point.clock_mhz; e.sweep_mem_mhz = row.point.mem_mhz; + if let Some(t) = &tier_applied { + e.tier = t.clone(); + e.tier_at = unix; + } // Miner UI 4: the row's sub-line reads "(the ladder's floor)" when the chosen clock is the lowest step let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor(); e.sweep_floor = floor_point; @@ -3728,6 +3820,15 @@ impl Engine { _ 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 let Some(t) = &tier_applied { + if !pinned { + self.tune_apply(idx, &run.device, &crate::ember::Step { point: row.point, watts: row.limit, kind: crate::ember::Kind::Confirm }); + } + if first_tier { + let stock_w = run.rows.first().map(|r| r.watts).unwrap_or(0.0); + self.shared.event("ok", &crate::ember::tier_set_line(&name, t, &row, stock_w)); + } + } 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, 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); diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index b124fd80b..f6c0e1bbd 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -297,6 +297,29 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + // Ember Tune tiers (8 October 2026): apply a measured tier to one card or every card, no re-search + let tier = body.get("tier").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if !crate::ember::TIER_IDS.contains(&tier.as_str()) { + return Err("tier must be efficiency, balanced or max".into()); + } + let key = body.get("key").and_then(|v| v.as_str()).filter(|k| !k.is_empty()).map(|k| k.to_string()); + let applied: Vec = { + let st = shared.state.lock().unwrap(); + match &key { + Some(k) => { + let Some(c) = st.mining.cards.iter().find(|c| &c.key == k) else { return Err("no such card".into()) }; + if crate::ember::tier_from_json(&c.tiers, &tier).is_none() { + return Err("not measured yet: the search runs 2 min into steady mining".into()); + } + vec![c.key.clone()] + } + None => st.mining.cards.iter().filter(|c| crate::ember::tier_from_json(&c.tiers, &tier).is_some()).map(|c| c.key.clone()).collect(), + } + }; + shared.send(Cmd::TuneTier(key, tier)); + Ok(json!({ "ok": true, "applied": applied })) + } "/api/tune/goal" => { // Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index bcf362a5a..fc71f75ff 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -178,6 +178,12 @@ pub struct CardState { pub lock_steps: u32, pub lock_at: f64, // unix s the lock point was taken (0 = never) pub lock_note: String, // "rate fell 5.1 percent at 1200 MHz", "fingerprint mismatch at 1400 MHz, clocks reset", "the floor at 700 MHz", "no lever" + // Ember Tune tiers (8 October 2026, src/ember.rs tiers; the UI lane's field shape): the three rows of the card's own + // search (efficiency, balanced, max), the tier in force, why only stock exists, and when the tier was applied + pub tiers: Vec, + pub tier: String, // "" | efficiency | balanced | max + pub tier_note: String, // "" or "no lever: ..." + pub tier_at: f64, // unix s (0 = never) // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64, @@ -372,6 +378,8 @@ pub struct SettingsState { pub tune_goal: String, pub power_price_pence: f64, pub tune_climb: bool, + /// Ember Tune tiers: the fleet tier (efficiency | balanced | max), balanced from install + pub tune_tier: String, /// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as /// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset pub region: String, From d6765ececee119384ae8dd739113b69ebe65e900 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:12:20 +0000 Subject: [PATCH 2/2] tiers test: 123.05 formats as 123.0 (the binary value sits under the half) Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/ember.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 61c062cb6..6b6b0cb9e 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -1979,7 +1979,7 @@ mod tests { assert_eq!(j["w"], 221.0); assert_eq!(j["mhs"], 122.06); assert_eq!(tier_set_line("NVIDIA GeForce RTX 5090", "balanced", &t[1].row, 298.4), "Ember Tune set NVIDIA GeForce RTX 5090 to Balanced: 122.1 MH/s at 221 W, 77 W under stock"); - assert_eq!(tier_set_line("card", "max", &t[2].row, 298.4), "Ember Tune set card to Max: 123.1 MH/s at 302 W"); + assert_eq!(tier_set_line("card", "max", &t[2].row, 298.4), "Ember Tune set card to Max: 123.0 MH/s at 302 W"); let st = stock_tier(&curve_row(0, 100, 202.0, 18.9)); assert_eq!((st.len(), st[0].id, st[0].source), (1, "max", "stock")); }