Merge knob-24 6ada573e into release-0.3.24 (the core-clock knob's display: the lock against the unlocked row on the card row, the Ember panel's lock line and the knee, the rule sentence)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 08:38:14 +00:00
commit 6923f84f10
4 changed files with 125 additions and 10 deletions

View file

@ -710,6 +710,11 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.curve-svg circle{fill:var(--ember)}
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
/* the knee (knob-24): the first clock step whose rate fell past the tolerance; dashed, so it reads apart from the chosen ring and a rejected point */
.curve-svg circle.knee{fill:none;stroke:var(--molten);stroke-width:1.4;stroke-dasharray:2 2}
.gl-details .line.lock b{color:var(--bone)}
.gl-details .line.lock.unlocked b,.gl-details .line.lock.nolever b{color:var(--ink-2);font-weight:400}
.gl-details .line.lock.searching{color:var(--molten)}
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
.gl-details .ctl .seg{justify-self:start}

View file

@ -837,23 +837,61 @@ var View = (function () {
if (cd.tune_source === 'full' && r && r.watts > 0 && (r.power_pct || 0) >= 100 && !(r.clock_mhz > 0)) return { watts: r.watts, mhs: r.mhs || 0 };
return null;
}
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points}
// ---- the core-clock knob's display (knob-24, 7 October 2026; src/ember.rs lock_result, state.rs lock_*): the lock
// against the unlocked row at the same cap, the knee in words, the search step, no lever. Display only: the
// knob is the engine's; the panel states the rule and never offers a lock below the knee ----
var LOCK_RULE = 'Ember never locks the clock below the knee by itself. Going lower is your choice.';
function mhzOf(note) { var m = /at (\d+) MHz/.exec(note || ''); return m ? parseInt(m[1], 10) : 0; }
// the stop reason in the panel's words: the knee (rate fell), a faulted row (clocks reset), the floor (no knee), or the bare stop
function kneeWords(note) {
note = String(note || ''); var m;
if ((m = /^rate fell ([\d.]+) percent at (\d+) MHz/.exec(note))) return 'the knee at ' + m[2] + ' MHz, where the rate fell ' + m[1] + '%';
if ((m = /^fingerprint mismatch at (\d+) MHz/.exec(note))) return 'stopped at ' + m[1] + ' MHz on a hash mismatch, clocks reset to the last good step';
if ((m = /^the floor at (\d+) MHz/.exec(note))) return 'no knee down to the floor at ' + m[1] + ' MHz';
return note;
}
// {kind: none|nolever|searching|locked|unlocked, text, against, knee, row, savingW, ratePct, kneeMhz, mhz}
function lockWords(cd) {
cd = cd || {}; var out = { kind: 'none', text: '', against: '', knee: '', row: '', savingW: 0, ratePct: 0, kneeMhz: 0, mhz: 0 };
if (cd.lock_note === 'no lever') { out.kind = 'nolever'; out.text = 'No clock lock on this card: the system sets its clocks'; return out; }
if (cd.lock_steps > 0) { out.kind = 'searching'; out.text = 'Locking clocks: step ' + (cd.lock_step || 1) + ' of ' + cd.lock_steps; out.row = 'locking clocks: step ' + (cd.lock_step || 1) + ' of ' + cd.lock_steps; return out; }
if (!(cd.lock_at > 0)) return out;
var note = String(cd.lock_note || ''), fell = /^rate fell ([\d.]+) percent at (\d+) MHz/.exec(note);
out.kneeMhz = fell ? parseInt(fell[2], 10) : 0; out.knee = kneeWords(note);
if (!(cd.lock_mhz > 0)) {
out.kind = 'unlocked';
out.text = 'Clock left unlocked' + (fell ? ': the first lock step fell ' + fell[1] + '% at ' + fell[2] + ' MHz' : note ? ': ' + kneeWords(note) : '');
out.row = 'clock unlocked' + (fell ? ', the knee came at ' + fell[2] + ' MHz' : '');
return out;
}
out.kind = 'locked'; out.mhz = cd.lock_mhz;
out.text = 'Locked at ' + cd.lock_mhz + ' MHz: ' + (cd.lock_mhs || 0).toFixed(1) + ' MH/s at ' + Math.round(cd.lock_w || 0) + ' W (' + (cd.lock_mhw || 0).toFixed(3) + ' MH/W)';
if (cd.unlocked_w > 0) {
out.against = 'unlocked at the same cap: ' + (cd.unlocked_mhs || 0).toFixed(1) + ' MH/s at ' + Math.round(cd.unlocked_w) + ' W';
out.savingW = Math.max(0, Math.round(cd.unlocked_w - (cd.lock_w || 0)));
out.ratePct = cd.unlocked_mhs > 0 ? Math.max(0, (cd.unlocked_mhs - (cd.lock_mhs || 0)) / cd.unlocked_mhs * 100) : 0;
out.row = 'the lock: ' + out.savingW + ' W less' + (out.ratePct < 0.05 ? ', no rate lost' : ' for ' + out.ratePct.toFixed(1) + '% of rate') + (out.kneeMhz ? ', knee at ' + out.kneeMhz + ' MHz' : '');
} else out.row = 'locked at ' + cd.lock_mhz + ' MHz' + (out.kneeMhz ? ', knee at ' + out.kneeMhz + ' MHz' : '');
return out;
}
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points, lock}
function ember(cd, s, now) {
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [] };
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [], lock: lockWords(cd) };
if (tw.kind === 'none') return out;
var st = (s && s.settings) || {};
out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, '');
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; });
var kneeMhz = out.lock.kneeMhz;
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05, knee: kneeMhz > 0 && r.clock_mhz === kneeMhz }; });
if (cd.tune_floor) out.floor = true;
out.heat = heatRowWords(s && s.heat);
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; }
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = [out.lock.kind === 'searching' ? out.lock.row : '', cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''].filter(Boolean).join(' · '); return out; }
if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; }
if (tw.kind === 'stopped') { out.label = tw.text; return out; }
if (tw.kind === 'measured') { out.fill = 0.5; out.label = 'Measured, not tuned'; out.sub = /Power control/i.test(cd.sweep_note || '') ? 'Power control is off' : cd.vendor === 'apple' ? 'Apple silicon sets its own power' : (tw.note || '').replace(/^Measured only /, ''); return out; }
if (tw.kind === 'tuned') {
out.fill = 1; var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
out.label = 'Tuned' + (when ? ' ' + when : '') + (cd.tune_source === 'confirm' ? ' from what other miners found' : '');
out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.next ? ' · next check ' + out.next : '');
out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.lock.row ? ' · ' + out.lock.row : '') + (out.next ? ' · next check ' + out.next : '');
var b = beforeOf(cd); out.after = cd.sweep_watts > 0 ? cd.sweep_watts : 0; out.before = b ? b.watts : 0;
if (out.before > 0 && out.after > 0 && out.before > out.after) {
out.savingW = Math.round(out.before - out.after);
@ -1296,7 +1334,7 @@ var View = (function () {
}
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, LOCK_RULE: LOCK_RULE, lockWords: lockWords, kneeWords: kneeWords, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})();
/* ---------- 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
@ -2354,10 +2392,11 @@ if (typeof document !== 'undefined') (function () {
var X = function (w) { return L + (w1 > w0 ? (w - w0) / (w1 - w0) : 0.5) * (W - L - 8); }, Y = function (m) { return 6 + (m1 > m0 ? 1 - (m - m0) / (m1 - m0) : 0.5) * (H - B - 6); };
var sorted = points.slice().sort(function (a, b) { return a.w - b.w; });
var path = sorted.map(function (p, i) { return (i ? 'L' : 'M') + X(p.w).toFixed(1) + ' ' + Y(p.mhs).toFixed(1); }).join(' ');
var dots = sorted.map(function (p) { return '<circle class="' + (p.chosen ? 'chosen' : p.marked ? 'marked' : '') + '" cx="' + X(p.w).toFixed(1) + '" cy="' + Y(p.mhs).toFixed(1) + '" r="' + (p.chosen ? 5 : 3) + '"/>'; }).join('');
var dots = sorted.map(function (p) { return '<circle class="' + (p.chosen ? 'chosen' : p.marked ? 'marked' : p.knee ? 'knee' : '') + '" cx="' + X(p.w).toFixed(1) + '" cy="' + Y(p.mhs).toFixed(1) + '" r="' + (p.chosen ? 5 : p.knee ? 4 : 3) + '"/>'; }).join('');
var knee = points.some(function (p) { return p.knee; });
return '<div class="curve-wrap"><svg class="curve-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="The tune\'s curve: watts across, hash rate up"><path class="ln" d="' + path + '"/>' + dots +
'<text x="' + L + '" y="' + (H - 4) + '">' + Math.round(w0) + ' W</text><text x="' + (W - 8) + '" y="' + (H - 4) + '" text-anchor="end">' + Math.round(w1) + ' W</text>' +
'<text x="2" y="' + (H - B - 2) + '">' + m0.toFixed(0) + '</text><text x="2" y="12">' + m1.toFixed(0) + '</text></svg><span class="note">Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.</span></div>';
'<text x="2" y="' + (H - B - 2) + '">' + m0.toFixed(0) + '</text><text x="2" y="12">' + m1.toFixed(0) + '</text></svg><span class="note">Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.' + (knee ? ' The dashed point is the knee: the first clock step whose rate fell past the tolerance, so the lock stays above it.' : '') + '</span></div>';
}
// the card's details (one chevron): the cap slider, identities, pin, the telemetry, the facts, the last tune's table
function detailsHtml(cd, s) {
@ -2384,6 +2423,12 @@ if (typeof document !== 'undefined') (function () {
h += '<div class="ctl"><span class="k">Goal</span>' + goalSeg + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">' + (cd.tune_goal === undefined ? 'A goal for one card needs the next Ember Tune update. Until then every card follows the goal above.' : 'This card’s own goal. Default follows the goal set for all cards. A new goal applies from the next tune.') + '</p></div>';
}
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + (em.savingText ? ' <span class="save">' + esc(em.savingText) + '</span>' : '') + '</div>';
// the core-clock knob (knob-24): the lock against the unlocked row, the knee, the rule; nothing for a build without the fields
var lk = em.lock || View.lockWords(cd);
if (lk.kind !== 'none') {
h += '<div class="line lock ' + lk.kind + '"><b>' + esc(lk.text) + '</b>' + (lk.against ? '<span class="dim">' + esc(lk.against) + '</span>' : '') + (lk.knee && lk.kind !== 'unlocked' ? '<span class="dim">' + esc(lk.knee) + '</span>' : '') + (lk.savingW > 0 ? '<span class="save">' + esc(lk.savingW + ' W less' + (lk.ratePct < 0.05 ? ', no rate lost' : ' for ' + lk.ratePct.toFixed(1) + '% of rate')) + '</span>' : '') + '</div>';
if (lk.kind === 'locked' || lk.kind === 'unlocked') h += '<p class="help">' + esc(View.LOCK_RULE) + ' The lock is read against the unlocked row at the same power cap. A step that fell past the tolerance, or faulted, ends the search.</p>';
}
h += curveHtml(em.points);
h += '<div class="ctl"><span class="k">Miners on this card</span><p class="help">How many miners this card runs as. Each one votes and is paid on its own. 8 suits a big card, 2 a small one, 1 a built-in GPU. Changing it restarts the card.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">&minus;</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
var facts = [cd.worker ? 'runs on ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver_os ? 'driver ' + cd.driver_os : cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean);
@ -2414,7 +2459,7 @@ if (typeof document !== 'undefined') (function () {
var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; });
setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT');
var sig = JSON.stringify([rows, s.drivers, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.driver_offer, c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain)]; }), goalNow()]);
var sig = JSON.stringify([rows, s.drivers, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.driver_offer, c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain), c.lock_mhz, c.lock_at, c.lock_note, c.lock_step, c.lock_steps]; }), goalNow()]);
if (sig !== mineRowsSig && !busy) {
mineRowsSig = sig;
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '</div>';
@ -2763,7 +2808,8 @@ if (typeof document !== 'undefined') (function () {
var c = s.mining.cards[0]; if (!c) return;
if (kind === 'running') { c.state = 'tuning'; c.sweep_state = 'running'; c.tune_step = 7; c.tune_steps = 9; c.tune_eta_s = 230; c.tune_plan = 'full'; c.sweep_note = 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)'; if (!(c.power_w > 0)) c.power_w = 219.6; if (!(c.hash_now > 0)) c.hash_now = 123.4; }
var curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2780, power_pct: 80, watts: 298, mhs: 122.4, eff: 0.411 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 2160, power_pct: 80, watts: 281, mhs: 117.0, eff: 0.416 }, { clock_mhz: 1850, power_pct: 80, watts: 270, mhs: 109.2, eff: 0.404 }];
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; }
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; c.lock_mhz = 2470; c.lock_mhs = 122.3; c.lock_w = 290; c.lock_mhw = 0.422; c.unlocked_mhs = 122.3; c.unlocked_w = 302; c.lock_step = 0; c.lock_steps = 0; c.lock_at = s.now - 7200; c.lock_note = 'rate fell 4.3 percent at 2160 MHz'; }
if (kind === 'running') { c.lock_step = 2; c.lock_steps = 4; }
if (kind === 'mix' && s.mining.cards[1]) { var d = s.mining.cards[1]; d.state = 'tuning'; d.sweep_state = 'running'; d.tune_step = 4; d.tune_steps = 9; d.tune_eta_s = 410; d.tune_plan = 'full'; d.tune_control = true; d.hash_now = d.hash_now || 98.1; }
if (kind === 'measured') { c.tune_line = 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)'; c.tune_source = 'baseline'; c.tune_control = false; c.sweep_at = s.now - 600; c.sweep_note = 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed'; }
}

View file

@ -1126,3 +1126,50 @@ test('the dashboard cap scales with the window above 1080p and Settings gains a
assert.match(block, /#page-settings\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/, 'Settings two columns at the wide cap');
assert.match(block, /#page-settings>\.card\{max-width:640px/, 'a Settings card never past its design width');
});
// the core-clock knob's display (knob-24, 7 October 2026; the engine's lock_* fields from src/ember.rs lock_result):
// the lock against the unlocked point at the same cap, the knee, the search step while the clock ladder runs, no lever
test('the core-clock knob on the row and the panel: the lock, the knee, the search step, no lever', () => {
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 },
{ clock_mhz: 2472, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 1854, power_pct: 80, watts: 227, mhs: 121.6, eff: 0.536 }, { clock_mhz: 1545, power_pct: 80, watts: 206, mhs: 121.1, eff: 0.588 },
{ clock_mhz: 1300, power_pct: 80, watts: 190, mhs: 120.7, eff: 0.635 }, { clock_mhz: 1200, power_pct: 80, watts: 182, mhs: 116.0, eff: 0.637 }];
const locked = card({ tune_line: 'Tuned: 120.7 MH/s at 190 W (0.635 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 1300, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 190, sweep_mhs: 120.7, tune_curve: curve, sweep_state: 'idle', sweep_note: '',
lock_mhz: 1300, lock_mhs: 120.7, lock_w: 190, lock_mhw: 0.635, unlocked_mhs: 122.3, unlocked_w: 302, lock_step: 0, lock_steps: 0, lock_at: NOW - 7200, lock_note: 'rate fell 5.2 percent at 1200 MHz' });
const lw = V.lockWords(locked);
assert.equal(lw.kind, 'locked');
assert.equal(lw.text, 'Locked at 1300 MHz: 120.7 MH/s at 190 W (0.635 MH/W)');
assert.equal(lw.against, 'unlocked at the same cap: 122.3 MH/s at 302 W');
assert.equal(lw.knee, 'the knee at 1200 MHz, where the rate fell 5.2%');
assert.equal(lw.row, 'the lock: 112 W less for 1.3% of rate, knee at 1200 MHz');
assert.equal(lw.savingW, 112); assert.equal(lw.ratePct.toFixed(1), '1.3'); assert.equal(lw.kneeMhz, 1200);
const e = V.ember(locked, settings(), NOW);
assert.equal(e.lock.kind, 'locked');
assert.match(e.sub, /^1300 MHz at 80% · the lock: 112 W less for 1\.3% of rate, knee at 1200 MHz · next check /);
assert.deepEqual(e.points.filter((p) => p.knee).map((p) => p.w), [182], 'the knee row is flagged on the curve');
assert.equal(e.points.filter((p) => p.chosen).length, 1);
// a lock that lost no rate says so
assert.equal(V.lockWords({ ...locked, lock_mhs: 122.4 }).row, 'the lock: 112 W less, no rate lost, knee at 1200 MHz');
// the search ended on a faulted row: the clocks were reset to the last good step
assert.equal(V.lockWords({ ...locked, lock_mhz: 2472, lock_note: 'fingerprint mismatch at 2163 MHz, clocks reset' }).knee, 'stopped at 2163 MHz on a hash mismatch, clocks reset to the last good step');
assert.equal(V.lockWords({ ...locked, lock_note: 'the floor at 700 MHz' }).knee, 'no knee down to the floor at 700 MHz');
assert.equal(V.lockWords({ ...locked, lock_note: 'stopped at 1400 MHz' }).knee, 'stopped at 1400 MHz');
assert.equal(V.ember({ ...locked, lock_note: 'the floor at 700 MHz' }, settings(), NOW).points.filter((p) => p.knee).length, 0);
// the first lock step was already past the knee: the clock stays unlocked and the row says why
const un = V.lockWords({ ...locked, lock_mhz: 0, tune_clock_mhz: 0, lock_mhs: 122.3, lock_w: 302, lock_mhw: 0.405, lock_note: 'rate fell 3.0 percent at 2472 MHz' });
assert.equal(un.kind, 'unlocked');
assert.equal(un.text, 'Clock left unlocked: the first lock step fell 3.0% at 2472 MHz');
assert.equal(un.row, 'clock unlocked, the knee came at 2472 MHz');
// while the clock ladder runs: the row's sub names the search step inside the tune's step
const searching = V.ember(card({ state: 'tuning', sweep_state: 'running', tune_step: 9, tune_steps: 14, tune_eta_s: 410, lock_step: 4, lock_steps: 9 }), settings(), NOW);
assert.equal(searching.label, 'Tuning: step 9 of 14'); assert.equal(searching.sub, 'locking clocks: step 4 of 9 · about 7 min left');
assert.equal(searching.lock.kind, 'searching'); assert.equal(searching.lock.text, 'Locking clocks: step 4 of 9');
// no lever (Apple silicon, or a card without a maximum clock): the panel says so, the row adds nothing
const nl = V.lockWords(card({ vendor: 'apple', lock_note: 'no lever', lock_at: 0, lock_mhz: 0 }));
assert.equal(nl.kind, 'nolever'); assert.equal(nl.text, 'No clock lock on this card: the system sets its clocks'); assert.equal(nl.row, '');
// a build without the fields, or a card not yet tuned: nothing is claimed
assert.equal(V.lockWords(card()).kind, 'none');
assert.equal(V.lockWords(card({ lock_at: 0, lock_note: '' })).kind, 'none');
assert.equal(V.ember(card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_state: 'idle', sweep_note: '' }), settings(), NOW).sub.indexOf('the lock'), -1);
// the rule the panel states: the knob never sets a lock below the knee without the user's own choice
assert.equal(V.LOCK_RULE, 'Ember never locks the clock below the knee by itself. Going lower is your choice.');
});

View file

@ -29,6 +29,8 @@
// POST /api/card accepts the block card's PNG and answers a path.
// euro currency-21: region Germany, 38.69 c/kWh, every money line in euro (add &lang=de-DE for German placement)
// rig gpu-logos: one card per vendor (RTX 5090, RX 9070 XT, Arc B580, a UHD 770 iGPU off), every mark on one page
// knob knob-24: the 5090 locked at 1,300 MHz with the knee at 1,200 on its curve, the 4070 in the clock search
// (knob-search: both cards searching)
// heat Ember Heat (mission item 7): a 5090 and a 4070 in heat mode holding 20 °C (heating, 60% duty), the
// region United Kingdom at the Ofgem price; ?page=cards|earnings|settings for the three lines
// The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised.
@ -259,6 +261,21 @@ function stateNow() {
s.mining.hash_total = 156.4; s.display_name = 'rig-01';
s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'balanced', power_price_pence: 26.32, region: 'gb' };
}
if (scenario === 'knob' || scenario === 'knob-search') {
// the core-clock knob (knob-24, 7 October 2026): the 5090 locked at 1,300 MHz after the ladder stopped at the knee
// (1,200 MHz, the rate 5.2% under the cap row), read against the unlocked row at the same 80% cap; the 4070 in the
// clock search (knob-search: both cards searching). The rows are the shape of the PC 1 passes, not a measurement.
const search = scenario === 'knob-search';
const ladder = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 },
{ clock_mhz: 2472, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 1854, power_pct: 80, watts: 227, mhs: 121.6, eff: 0.536 }, { clock_mhz: 1545, power_pct: 80, watts: 206, mhs: 121.1, eff: 0.588 }, { clock_mhz: 1300, power_pct: 80, watts: 190, mhs: 120.7, eff: 0.635 }, { clock_mhz: 1200, power_pct: 80, watts: 182, mhs: 116.0, eff: 0.637 }];
const locked = { lock_mhz: 1300, lock_mhs: 120.7, lock_w: 190, lock_mhw: 0.635, unlocked_mhs: 122.3, unlocked_w: 302, lock_step: 0, lock_steps: 0, lock_at: t - 7200, lock_note: 'rate fell 5.2 percent at 1200 MHz' };
const searching = { state: 'tuning', sweep_state: 'running', tune_plan: 'full', tune_step: 8, tune_steps: 13, tune_eta_s: 390, lock_step: 4, lock_steps: 9, sweep_note: 'tuning: holding 1545 MHz · 80% · 41 s (step 8 of 13)', lock_mhz: 0, lock_mhs: 0, lock_w: 0, lock_mhw: 0, unlocked_mhs: 0, unlocked_w: 0, lock_at: 0, lock_note: '' };
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: search ? 206 : 190, temp_gpu: 58, temp_mem: 66, gclk_mhz: search ? 1545 : 1300, clock_cap_mhz: search ? 1545 : 1300, telemetry_at: t, hash_now: search ? 121.1 : 120.7, hash_avg: 120.4, accepted: 102, sweep_supported: true, sweep_pct: 80, tune_control: true,
...(search ? searching : { state: 'mining', sweep_state: 'idle', sweep_note: '', tune_line: 'Tuned: 120.7 MH/s at 190 W (0.635 MH/W)', tune_source: 'full', tune_clock_mhz: 1300, sweep_at: t - 7200, sweep_watts: 190, sweep_mhs: 120.7, sweep_eff: 0.635, tune_before_watts: 374, tune_before_mhs: 122.5, tune_curve: ladder, ...locked }) },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: true, power_limit_w: 160, power_w: 149, temp_gpu: 57, temp_mem: 0, gclk_mhz: 2100, clock_cap_mhz: 2100, telemetry_at: t, hash_now: 60.2, hash_avg: 60.0, sweep_supported: true, sweep_pct: 80, tune_control: true, ...searching, tune_step: 7, tune_steps: 12, tune_eta_s: 330, lock_step: 3, lock_steps: 8, sweep_note: 'tuning: holding 2100 MHz · 80% · 22 s (step 7 of 12)' }];
s.mining.hash_total = search ? 181.3 : 180.9; s.mining.state = 'mining'; s.display_name = 'study-pc';
s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'efficiency', power_price_pence: 26.32, region: 'gb' };
}
if (scenario === 'nvidia') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 },