Merge ember-tiers-25 04e5b25a into tiers-25 (the engine half: tiers[], tier, tier_note, tier_at, settings.tune_tier, POST /api/tune/tier, sweep on at balanced from install)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 09:14:20 +00:00
commit 290d4e1d03
5 changed files with 286 additions and 2 deletions

View file

@ -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 /// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
#[serde(default)] #[serde(default)]
pub tune_goal: String, 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 /// 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 /// read "saves 84 W, 0.15% of rate"; 0 = never measured
#[serde(default)] #[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). /// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders).
#[serde(default = "balanced")] #[serde(default = "balanced")]
pub tune_goal: String, 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)] #[serde(default)]
pub power_price_pence: f64, pub power_price_pence: f64,
#[serde(default)] #[serde(default)]
@ -213,9 +221,13 @@ fn yes() -> bool {
true true
} }
fn default_tier() -> String {
"balanced".into()
}
impl Default for Settings { impl Default for Settings {
fn default() -> 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 }
} }
} }

View file

@ -621,6 +621,73 @@ pub fn choose(rows: &[Row], tolerance_pct: f64) -> Option<Row> {
best.cloned() 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<Tier> {
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<Tier> {
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<Row> {
tiers.iter().find(|t| t.id == id).map(|t| t.row.clone())
}
/// The first-run line the strip shows: "Ember Tune set <card> 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::<String>() + 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 /// 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. /// due), or nothing could be read.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@ -1843,4 +1910,77 @@ mod tests {
assert_eq!(run.rows[2].mark, Some(Mark::Faulted)); 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")); 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<Row> {
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<_>>(), 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<serde_json::Value> = 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.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"));
}
} }

View file

@ -102,6 +102,9 @@ pub enum Cmd {
TuneGoal(Option<String>, Option<f64>, Option<bool>), TuneGoal(Option<String>, Option<f64>, Option<bool>),
/// one card's goal override (key, goal; "" = back to the global goal) /// one card's goal override (key, goal; "" = back to the global goal)
TuneCardGoal(String, String), 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>, String),
/// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step) /// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step)
Region(Option<String>, Option<f64>, Option<String>), Region(Option<String>, Option<f64>, Option<String>),
/// the network step: the chosen network ("devnet-3" | "testnet-1") and whether the user confirmed what resets /// 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.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total; st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path); 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.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.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.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<usize> = {
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) => { Cmd::TuneCardGoal(key, goal) => {
{ {
let mut s = self.shared.settings.lock().unwrap(); 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. /// 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) { fn sweep_finish(&mut self, row: crate::ember::Row) {
let Some(run) = self.sweep.take() else { return }; 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 idx = run.card;
let unix = crate::platform::unix_now(); let unix = crate::platform::unix_now();
let kind = run.plan.kind; let kind = run.plan.kind;
@ -3653,6 +3727,16 @@ impl Engine {
c.tune_source = kind.name().into(); c.tune_source = kind.name().into();
c.tune_line = crate::ember::result_line(kind, 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(); 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 // 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 { 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() }; 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.power_pct = row.point.power_pct;
c.clock_cap_mhz = row.point.clock_mhz; c.clock_cap_mhz = row.point.clock_mhz;
c.mem_cap_mhz = row.point.mem_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" { if c.vendor == "nvidia" {
c.power_limit_w = row.limit; c.power_limit_w = row.limit;
c.power_applied = true; c.power_applied = true;
@ -3693,6 +3781,10 @@ impl Engine {
e.sweep_mhs = row.mhs; e.sweep_mhs = row.mhs;
e.sweep_clock_mhz = row.point.clock_mhz; e.sweep_clock_mhz = row.point.clock_mhz;
e.sweep_mem_mhz = row.point.mem_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 // 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(); let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
e.sweep_floor = floor_point; 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))), _ 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))), _ => 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 { 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 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); let w = self.st().mining.cards.get(idx).map(requested_watts).unwrap_or(run.before_w);

View file

@ -297,6 +297,29 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ApplyCards(choices)); shared.send(Cmd::ApplyCards(choices));
Ok(json!({ "ok": true })) Ok(json!({ "ok": true }))
} }
"/api/tune/tier" => {
// 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<String> = {
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" => { "/api/tune/goal" => {
// Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch // 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 goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());

View file

@ -178,6 +178,12 @@ pub struct CardState {
pub lock_steps: u32, pub lock_steps: u32,
pub lock_at: f64, // unix s the lock point was taken (0 = never) 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" 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<serde_json::Value>,
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 // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
pub variant: String, pub variant: String,
pub race_mhs: f64, pub race_mhs: f64,
@ -372,6 +378,8 @@ pub struct SettingsState {
pub tune_goal: String, pub tune_goal: String,
pub power_price_pence: f64, pub power_price_pence: f64,
pub tune_climb: bool, 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 /// 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 /// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
pub region: String, pub region: String,