A card never shows 0 MH/s without a reason word (the project lead, 6 October 2026, watching PC 1 during the table run): the Mine row reads 'tuning: step k of n · measuring W W' with the live rate, 'held for a remote job: <title>' while a job holds the miners, and the Tuned line at the end; the big button reads 'Tuning, mining again in about N min'; the strip carries one tune line; a measurement engine prints a TUNE progress line every 10 s and the playbook forwards it (and TUNE chosen as done) to the installed app's POST /api/tune-progress, a post of state, never quit, pause or resume; the rule in ember-tune.md 6a; UI tests for the three states

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 15:36:07 +00:00
parent 7bc0e1ce1a
commit 7ed0d75160
9 changed files with 211 additions and 13 deletions

View file

@ -591,6 +591,19 @@ 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) };

View file

@ -73,6 +73,8 @@ 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),
/// 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),
@ -1142,6 +1144,7 @@ impl Engine {
self.shared.log("sweep helper: exited");
}
}
Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::TuneSet(seq, r) => match r {
Ok(text) => {
self.tune_acked = Some(seq);
@ -2527,6 +2530,49 @@ 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));
}
/// 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.
fn sweep_drive(&mut self, now: Instant) {
let Some((idx, device, started, seq)) = self.sweep.as_ref().map(|r| (r.card, r.device.clone(), r.started, r.seq)) else { return };
@ -2542,7 +2588,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())
@ -2591,9 +2637,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();
}
}
}
@ -2616,6 +2673,12 @@ 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_source = kind.name().into();
c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff);
@ -2701,6 +2764,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;
@ -2874,6 +2943,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() {
@ -2890,7 +2966,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);
}

View file

@ -325,6 +325,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

@ -125,6 +125,12 @@ 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
// (the project lead, 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,
// 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

@ -13,7 +13,7 @@
5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
@ -120,8 +120,19 @@ var Notices = (function () {
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
var tn = tuneNotice(s.mining && s.mining.cards);
if (tn) out.push(tn);
return out;
}
// the one line while a card is being tuned: which card, which plan, the step, when mining is back
function tuneNotice(cards) {
var l = (cards || []).filter(function (c) { return c.state === 'tuning'; });
if (!l.length) return null;
var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune';
var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : '';
var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : '';
return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' });
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
function pick(list, dismissed) {
var best = null;
@ -132,7 +143,7 @@ var Notices = (function () {
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@ -282,9 +293,11 @@ var View = (function () {
function cardRow(cd) {
var removed = cd.removed_at > 0, unusable = !!cd.problem;
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
// the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word
var tuning = st === 'tuning', held = st === 'held';
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
@ -297,12 +310,28 @@ var View = (function () {
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) };
}
// "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet)
function tuneWord(cd) {
var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : '';
var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : '';
var rest = [step, w].filter(Boolean).join(' · ');
return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning');
}
// "about 12 min" / "under a minute" from a tune's seconds left
function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; }
// the card a tune is running on, or null
function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); return l.length ? l[0] : null; }
// the big button on Mine: m = state.mining, n = state.node, s = { quitting }
function toggle(m, n, s) {
var cards = shownCards(m.cards).filter(present);
var on = cards.filter(function (c) { return c.enabled; }).length;
var mining = cards.filter(function (c) { return c.state === 'mining'; }).length;
if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' };
var tc = tuningCard(cards);
if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' };
var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length;
if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
@ -384,7 +413,7 @@ var View = (function () {
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
return parts.join(' ');
}
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -414,7 +443,7 @@ var TuneLine = (function () {
function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; }
return { model: model, point: point, rel: rel };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; }
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; }
if (typeof document !== 'undefined') (function () {
'use strict';

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

View file

@ -161,6 +161,21 @@ 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.
## 6a. A card never shows 0 MH/s without a reason word (the project lead, 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).
## 7a. One administrator approval, ever (0.3.13; the project lead, 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

View file

@ -126,6 +126,24 @@ function EndTree([int] $procId, [string] $why) {
}
$seen = 0
$rows = 0
# the installed app shows the tune instead of a bare "off" (the project lead, 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
@ -165,7 +183,8 @@ while (-not $p.HasExited) {
$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 }
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 }

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