Ember Tune: the core-clock knob's display (knob-24, 0.3.24; the hash lane's engine tip 2caf39c1 on release-0.3.24, the shipper's line of 7 October 2026): the card row and the Ember panel read the card's lock_* fields. The row's tune line carries the lock against the unlocked row at the same cap ("the lock: 112 W less for 1.3% of rate, knee at 1200 MHz"; "no rate lost" when nothing was lost; "clock unlocked, the knee came at 2472 MHz" when the first lock step was already past it) and, while the clock ladder runs, the search step inside the tune's ("locking clocks: step 4 of 9 · about 7 min left"). The panel adds a lock line (the measured point, the unlocked row, the knee in words: the rate fell, a hash mismatch with the clocks reset, or the floor with no knee), the rule the knob keeps (Ember never locks the clock below the knee by itself; going lower is the user's choice) and the knee as a dashed point on the curve with its sentence in the note; "no lever" names a card without a clock lock; a build without the fields shows nothing new. View.lockWords, kneeWords and LOCK_RULE in the pure block; the rows re-render when the lock fields change (sig). The mock gains scenario=knob (the 5090 locked at 1,300 MHz with the knee at 1,200, the 4070 in the clock search) and knob-search; ?tune=tuned and ?tune=running carry the fields. view.test.mjs: the lock, the knee words, the search step, no lever, the untouched older shape (known-failed first on build-2: "V.lockWords is not a function"; then 69 green). Display only: the knob and its fields stay the engine's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 08:32:19 +00:00
parent 1411411e00
commit 6ada573e0c
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 },