Merge ember-tiers-25 2d188892 into tiers-class-25 (the class-flip engine half: tiers_class, tiers_remeasure_at, knee_loss_pct, the stale refusal, the re-armed search; on release-0.3.25 87d8862d, before the install-detach merge whose .iss reshaping reds ota::return_tests on 8779c80e)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 10:10:30 +00:00
commit dc7df7bd9a
8 changed files with 95 additions and 7 deletions

View file

@ -50,6 +50,9 @@ pub struct CardPref {
pub tier: String,
#[serde(default)]
pub tier_at: u64,
/// the program class the stored tiers were measured under ("" = none)
#[serde(default)]
pub tiers_class: String,
/// 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)]

View file

@ -685,6 +685,32 @@ pub fn tier_from_json(tiers: &[serde_json::Value], id: &str) -> Option<(Point, f
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 class flip (8 October 2026): tiers measured under one program class never apply under another. Stale when the
/// stored class and the card's current class are both known and differ.
pub fn tiers_stale(tiers_class: &str, program_class: &str) -> bool {
!tiers_class.is_empty() && !program_class.is_empty() && tiers_class != program_class
}
/// The refusal and the strip line for a stale set.
pub fn stale_text(card: &str, tiers_class: &str, program_class: &str) -> String {
format!("re-measuring {card} for class {program_class}: the stored tiers were measured under class {tiers_class}")
}
/// The rate loss at the same lock under a new class against the old set: the new efficiency row against the old row at
/// the same clock and cap when both exist, else the table of measured losses by card name (the 5090's class v5 rows at
/// the 1,300 MHz lock, 8 October 2026), else 0 (unknown). Percent; negative = a gain.
pub const KNEE_LOSS_TABLE: &[(&str, &str, f64)] = &[];
pub fn knee_loss_pct(card: &str, new_class: &str, old: &[serde_json::Value], new: &[serde_json::Value]) -> f64 {
let row = |v: &[serde_json::Value]| tier_from_json(v, "efficiency");
if let (Some((op, _, omhs, _)), Some((np, _, nmhs, _))) = (row(old), row(new)) {
if op.clock_mhz == np.clock_mhz && op.power_pct == np.power_pct && omhs > 0.0 && nmhs > 0.0 {
return ((100.0 * (omhs - nmhs) / omhs) * 10.0).round() / 10.0;
}
}
let n = card.to_ascii_uppercase();
KNEE_LOSS_TABLE.iter().find(|(k, c, _)| n.contains(&k.to_ascii_uppercase()) && *c == new_class).map(|(_, _, l)| *l).unwrap_or(0.0)
}
/// 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())
@ -2125,6 +2151,26 @@ mod tests {
assert!(tier_from_json(&[], "balanced").is_none());
}
#[test]
fn tiers_stored_under_v4_read_stale_under_v5_and_never_apply_as_current() {
// known-failed first: the stored class and the live class differ
assert!(tiers_stale("v4", "v5"));
assert_eq!(stale_text("RTX 5090", "v4", "v5"), "re-measuring RTX 5090 for class v5: the stored tiers were measured under class v4");
// the same class, or an unknown on either side, is not stale
assert!(!tiers_stale("v4", "v4"));
assert!(!tiers_stale("", "v5"));
assert!(!tiers_stale("v4", ""));
}
#[test]
fn the_knee_loss_reads_the_new_efficiency_row_against_the_old_at_the_same_lock() {
let old: Vec<serde_json::Value> = tiers(&curve_5090()).iter().map(|t| t.json()).collect();
let mut new_rows = curve_5090();
for r in new_rows.iter_mut() { r.mhs *= 0.95; r.eff = r.mhs / r.watts; }
let new: Vec<serde_json::Value> = tiers(&new_rows).iter().map(|t| t.json()).collect();
assert_eq!(knee_loss_pct("NVIDIA GeForce RTX 5090", "v5", &old, &new), 5.0);
// no old set and no table row: unknown reads 0
assert_eq!(knee_loss_pct("NVIDIA GeForce RTX 5090", "v5", &[], &new), 0.0);
}
#[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();

View file

@ -1541,11 +1541,16 @@ impl Engine {
};
let mut applied = Vec::new();
for idx in targets {
let (name, device, found) = {
let (name, device, found, stale) = {
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 stale = crate::ember::tiers_stale(&c.tiers_class, &c.program_class).then(|| crate::ember::stale_text(&c.name, &c.tiers_class, &c.program_class));
(c.name.clone(), c.device.clone(), crate::ember::tier_from_json(&c.tiers, &tier), stale)
};
if let Some(why) = stale {
self.shared.event("warn", &why);
continue;
}
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;
@ -3079,7 +3084,7 @@ impl Engine {
let forced = self.sweep_queue.contains(&c.key);
let due = match prefs.get(&c.key) {
None => true,
Some(p) => p.sweep_at == 0 || unix.saturating_sub(p.sweep_at) >= ember.period_s || (!c.driver.is_empty() && !p.sweep_driver.is_empty() && crate::ember::driver_major(&c.driver) != crate::ember::driver_major(&p.sweep_driver)) || (!c.program_class.is_empty() && !p.sweep_class.is_empty() && c.program_class != p.sweep_class),
Some(p) => p.sweep_at == 0 || unix.saturating_sub(p.sweep_at) >= ember.period_s || crate::ember::tiers_stale(&p.tiers_class, &c.program_class) || (!c.driver.is_empty() && !p.sweep_driver.is_empty() && crate::ember::driver_major(&c.driver) != crate::ember::driver_major(&p.sweep_driver)) || (!c.program_class.is_empty() && !p.sweep_class.is_empty() && c.program_class != p.sweep_class),
};
let auto = auto_on || !c.tune_control;
// a measure-only card (Apple, NVIDIA without Power control) measures its baseline once a period even
@ -3133,6 +3138,20 @@ impl Engine {
}
}
if let Some((idx, forced)) = pick {
// the class flip: the first pick after the flip says why it re-measures, once
let flip = {
let mut st = self.st();
match st.mining.cards.get_mut(idx) {
Some(c) if crate::ember::tiers_stale(&c.tiers_class, &c.program_class) && c.tiers_remeasure_at == 0.0 => {
c.tiers_remeasure_at = unix as f64;
Some(crate::ember::stale_text(&c.name, &c.tiers_class, &c.program_class))
}
_ => None,
}
};
if let Some(line) = flip {
self.shared.event("info", &line);
}
self.sweep_begin(idx, forced);
} else if self.shared.runtime.sweep_only && self.sweep_queue.is_empty() && self.sweep_pending.is_none() {
self.shared.send(Cmd::Quit("the --sweep run (every card done)"));
@ -3749,9 +3768,15 @@ impl Engine {
c.tier = "max".into();
} else {
let t = crate::ember::tiers(&run.rows);
c.tiers = t.iter().map(|x| x.json()).collect();
let new: Vec<serde_json::Value> = t.iter().map(|x| x.json()).collect();
if crate::ember::tiers_stale(&c.tiers_class, &c.program_class) {
c.knee_loss_pct = crate::ember::knee_loss_pct(&c.name, &c.program_class, &c.tiers, &new);
}
c.tiers = new;
c.tier_note = String::new();
}
c.tiers_class = c.program_class.clone();
c.tiers_remeasure_at = 0.0;
// 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() };
@ -3800,6 +3825,7 @@ impl Engine {
e.tier = t.clone();
e.tier_at = unix;
}
e.tiers_class = c.program_class.clone();
// 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;

View file

@ -168,6 +168,10 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
c.tune_source = p.sweep_source.clone();
c.tune_goal = p.tune_goal.clone();
// Ember Tune tiers (8 October 2026): the tier in force and the class the set was measured under survive a restart
c.tier = p.tier.clone();
c.tier_at = p.tier_at as f64;
c.tiers_class = p.tiers_class.clone();
c.tune_before_watts = p.sweep_before_watts;
c.tune_before_mhs = p.sweep_before_mhs;
c.tune_floor = p.sweep_floor;

View file

@ -309,12 +309,15 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
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::tiers_stale(&c.tiers_class, &c.program_class) {
return Err(crate::ember::stale_text(&c.name, &c.tiers_class, &c.program_class));
}
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(),
None => st.mining.cards.iter().filter(|c| !crate::ember::tiers_stale(&c.tiers_class, &c.program_class) && crate::ember::tier_from_json(&c.tiers, &tier).is_some()).map(|c| c.key.clone()).collect(),
}
};
shared.send(Cmd::TuneTier(key, tier));

View file

@ -187,6 +187,11 @@ pub struct CardState {
pub tier: String, // "" | efficiency | balanced | max
pub tier_note: String, // "" or "no lever: ..."
pub tier_at: f64, // unix s (0 = never)
// the class flip (8 October 2026, main's order): the program class the stored tiers were measured under, when the
// class-flip re-run was queued (0 = not started), and the rate loss at the same lock under the new class (0 = unknown)
pub tiers_class: String,
pub tiers_remeasure_at: f64,
pub knee_loss_pct: f64,
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
pub variant: String,
pub race_mhs: f64,

View file

@ -6,4 +6,5 @@ e223db18bcfa74a1f60335de62390729a19975e6a6bddd7547bf9d6f0676df17
365d32104ede41db22837ece4eb4fa8860505360955c974d3055f1114339d4c5
# 8 October 2026: PC 1's MSVC 0.3.24 host from the installer kit at release-0.3.24 0c47b59a (run-20261008-084900, 7 s)
0e241c94eecfa9323a5fb40a02e428dfaf4a115c30f444e517667c6753c6d7c6
bc8d4f795a039636dd438fd13836f695193414ea4a50ba020d31265b1084365b Igneum Miner.exe 0.3.25 92f004f1 (PC 1, MSVC, 10:37 BST 8 October 2026)
# 8 October 2026: PC 1's MSVC 0.3.25 host from the installer kit at release-0.3.25 92f004f1 (run-20261008-093513, 8 s)
bc8d4f795a039636dd438fd13836f695193414ea4a50ba020d31265b1084365b

View file

@ -1 +1 @@
7bd2940fa5410f5168a0fb60a0cfa6835b46fe0c
e0644958c07c6c6fd7be3c3175e2c41609b4e798