diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css
index 82434f427..42d2b67e2 100644
--- a/app/igneum-app/ui/app.css
+++ b/app/igneum-app/ui/app.css
@@ -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}
diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js
index 9fc61652d..b519382a5 100644
--- a/app/igneum-app/ui/app.js
+++ b/app/igneum-app/ui/app.js
@@ -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 ''; }).join('');
+ var dots = sorted.map(function (p) { return ''; }).join('');
+ var knee = points.some(function (p) { return p.knee; });
return '
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.
';
+ '' + m0.toFixed(0) + '' + m1.toFixed(0) + '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.' : '') + '';
}
// 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 += '
' + (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.') + '
';
}
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '
';
+ // 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 += '
';
+ if (lk.kind === 'locked' || lk.kind === 'unlocked') h += '
' + 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.
';
+ }
h += curveHtml(em.points);
h += '
Miners on this card
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.