Skip to content

Commit 6cae777

Browse files
committed
feat(ui): rebuild proving progress as a stat panel, drop the estimate
Progress is now a panel of stat tiles rather than dim 11px lines, and a custom prover's extra fields render in the same grid.
1 parent a353b0b commit 6cae777

3 files changed

Lines changed: 185 additions & 48 deletions

File tree

subs/src/background.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,13 @@ async fn proving_loop(state: AppState) {
207207
}
208208
}
209209
} else {
210-
// Only fetch and store the estimate; proving is user-initiated via the UI
211-
if let Err(e) = fetch_and_store_estimate(&state, &prover_endpoint, prover_auth_token.as_deref(), space, commitment_id, &request).await {
212-
tracing::debug!("[{}] Could not fetch estimate: {}", space, e);
213-
}
210+
// Estimate fetching is disabled along with its UI. It ran on
211+
// every pass — unguarded — and /estimate executes the guest to
212+
// count cycles, so an unproven commitment re-executed it every
213+
// 10s indefinitely, for a figure nothing renders and that read
214+
// ~43% low anyway (calibration measures composite(), real jobs
215+
// run succinct()). Re-enable together with the display.
216+
let _ = &request;
214217
}
215218
}
216219

@@ -220,6 +223,11 @@ async fn proving_loop(state: AppState) {
220223
}
221224

222225
/// Fetch a proving estimate from the prover and store it on the commitment.
226+
///
227+
/// Currently unused: see the call site in the proving loop for why estimates
228+
/// are off. Kept so re-enabling is a one-line change once calibration measures
229+
/// the phase real jobs actually run.
230+
#[allow(dead_code)]
223231
async fn fetch_and_store_estimate(
224232
state: &AppState,
225233
prover_endpoint: &str,

subs/templates/base.html

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,73 @@
756756
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
757757
@keyframes shimmer { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
758758
.shimmer { animation: shimmer 2s ease-in-out infinite; }
759+
/* === PROVING PROGRESS === */
760+
.prove-panel {
761+
margin-top: 12px; padding: 14px;
762+
background: var(--bg-base); border: 1px solid var(--border-subtle);
763+
border-radius: var(--radius);
764+
}
765+
.prove-head {
766+
display: flex; align-items: baseline; justify-content: space-between;
767+
gap: 12px; margin-bottom: 10px;
768+
}
769+
.prove-phase { font-size: 13px; color: var(--text-primary); font-weight: 500; }
770+
.prove-phase-num {
771+
font-family: var(--mono); font-size: 10px; color: var(--text-muted);
772+
text-transform: uppercase; letter-spacing: 0.04em;
773+
}
774+
/* Taller than a hairline: this is the primary indicator on the panel, and at
775+
6px it read as a divider rather than a progress bar. */
776+
.prove-track {
777+
height: 10px; background: var(--bg-raised);
778+
border-radius: 5px; overflow: hidden; position: relative;
779+
}
780+
.prove-fill {
781+
height: 100%; background: var(--accent); border-radius: 5px;
782+
transition: width 1s linear;
783+
}
784+
/* Indeterminate: work is happening but its duration is unknowable. Used for
785+
proving phase 2, where risc0 exposes no hook to measure against. Travels the
786+
full track rather than parking at one end, so it reads as "still working". */
787+
.prove-fill-indeterminate {
788+
position: absolute; top: 0; left: 0; height: 100%; width: 42%;
789+
border-radius: 5px;
790+
background: linear-gradient(90deg,
791+
transparent, var(--accent) 18%, var(--accent) 82%, transparent);
792+
animation: indeterminate 1.8s ease-in-out infinite;
793+
}
794+
/* Travel is kept just short of fully clearing the track at each end, so the
795+
stripe is visible at essentially every frame rather than spending part of
796+
each cycle off-screen looking like a stalled bar. */
797+
@keyframes indeterminate {
798+
0% { transform: translateX(-75%); }
799+
100% { transform: translateX(240%); }
800+
}
801+
/* Auto-fit so a custom prover's extra fields flow into the same grid without
802+
the layout needing to know how many there will be. */
803+
.prove-stats {
804+
margin-top: 12px; display: grid; gap: 10px;
805+
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
806+
}
807+
.prove-stat {
808+
background: var(--bg-surface); border: 1px solid var(--border-subtle);
809+
border-radius: 7px; padding: 8px 10px;
810+
display: flex; flex-direction: column; gap: 3px; min-width: 0;
811+
}
812+
.prove-stat-label {
813+
font-family: var(--mono); font-size: 9px; color: var(--text-muted);
814+
text-transform: uppercase; letter-spacing: 0.05em;
815+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
816+
}
817+
.prove-stat-value {
818+
font-family: var(--mono); font-size: 14px; color: var(--text-primary);
819+
line-height: 1.25; overflow-wrap: anywhere;
820+
}
821+
/* The one number people are actually waiting on. */
822+
.prove-stat-accent .prove-stat-value { color: var(--accent); }
823+
.prove-stat-note {
824+
font-size: 10px; color: var(--text-muted); line-height: 1.3;
825+
}
759826

760827
/* === SCROLLBAR === */
761828
.scrollbar-thin::-webkit-scrollbar { width: 4px; }

subs/templates/space.html

Lines changed: 106 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,16 @@ <h3>Handles</h3>
244244
// ---------------------------------------------------------------------------
245245
// Helpers
246246
// ---------------------------------------------------------------------------
247+
// Escapes for text *and* attribute contexts. The previous version round-tripped
248+
// through div.textContent/innerHTML, which escapes only & < > — leaving quotes
249+
// intact, so anything interpolated into title="…" or onclick="…('…')" could
250+
// break out of the attribute. Values reaching here include field names chosen
251+
// by whichever prover is configured. Entities still render as the literal
252+
// characters in text contexts, so this is safe everywhere.
247253
function esc(t) {
248-
const d = document.createElement('div');
249-
d.textContent = t;
250-
return d.innerHTML;
254+
return String(t == null ? '' : t).replace(/[&<>"']/g, c => ({
255+
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
256+
})[c]);
251257
}
252258

253259
function $(id) { return document.getElementById(id); }
@@ -419,6 +425,31 @@ <h3>Handles</h3>
419425
$('pipelineStepper').innerHTML = h;
420426
}
421427

428+
// Cycle counts run to millions, where exact digits are noise.
429+
function fmtCycles(n) {
430+
if (n == null) return null;
431+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
432+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
433+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
434+
return `${n}`;
435+
}
436+
437+
// Compact form for stat tiles, where "1 minute 32 seconds" wraps to two lines
438+
// and buries the number. Prose elsewhere still uses fmtDuration.
439+
function fmtDurationShort(sec) {
440+
if (sec == null) return null;
441+
const total = Math.max(0, Math.round(sec));
442+
if (total < 60) return `${total}s`;
443+
const mins = Math.floor(total / 60);
444+
if (mins < 60) {
445+
const secs = total % 60;
446+
return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`;
447+
}
448+
const hours = Math.floor(mins / 60);
449+
const remMins = mins % 60;
450+
return remMins === 0 ? `${hours}h` : `${hours}h ${remMins}m`;
451+
}
452+
422453
function fmtDuration(sec) {
423454
if (sec == null) return null;
424455
const total = Math.max(0, Math.round(sec));
@@ -457,51 +488,86 @@ <h3>Handles</h3>
457488
// executor is still running before the first segment is proven.
458489
if (!p || !p.segments) return idRow;
459490

460-
const pct = Math.min(100, Math.round((p.segments_done / p.segments) * 100));
461-
const elapsed = fmtDuration(p.elapsed_seconds);
462-
const remaining = p.estimated_total_seconds != null
463-
? fmtDuration(Math.max(0, p.estimated_total_seconds - p.elapsed_seconds))
464-
: null;
491+
// `title` carries any explanation, so the tiles stay uniform instead of
492+
// growing a line of prose underneath.
493+
const stat = (label, value, opts = {}) => `
494+
<div class="prove-stat${opts.accent ? ' prove-stat-accent' : ''}"${opts.title ? ` title="${esc(opts.title)}"` : ''}>
495+
<span class="prove-stat-label">${esc(label)}</span>
496+
<span class="prove-stat-value">${esc(value)}</span>
497+
</div>`;
498+
499+
const elapsed = fmtDurationShort(p.elapsed_seconds);
500+
const phaseTotal = p.phase_total || 1;
501+
// Phase 2 is lift/join/resolve. risc0 fires no hook during it, so there is
502+
// nothing to measure and nothing to extrapolate — it gets a moving bar
503+
// with no percentage rather than a full one that sits there. On a measured
504+
// single-segment proof this phase was 28.1s of 38.8s, so a bar pinned at
505+
// 100% for its duration was the most misleading thing on the page.
506+
const inPhaseTwo = phaseTotal > 1 && p.phase >= 2;
507+
// An indeterminate bar is also right for the first segment: nothing has
508+
// been timed yet, so there is no honest position to draw.
509+
const indeterminate = inPhaseTwo || p.phase_one_fraction == null;
510+
// Just the counter — the phase's description is the panel heading.
511+
const phaseLabel = phaseTotal > 1 ? `Phase ${p.phase} of ${phaseTotal}` : null;
465512

466-
let meta = `${p.segments_done}/${p.segments} segments · ${elapsed} elapsed`;
467513
// No ETA until a segment lands — there is nothing to extrapolate from.
468-
if (remaining) meta += ` · ~${remaining} remaining`;
514+
// Absent for all of phase 2, by design. Its absence is not annotated: it is
515+
// the normal case here, and saying so reads as a fault.
516+
const remaining = p.estimated_total_seconds != null
517+
? fmtDurationShort(Math.max(0, p.estimated_total_seconds - p.elapsed_seconds))
518+
: null;
469519

470-
let h = `<div style="margin-top:10px">
471-
<div style="height:6px;background:var(--bg-base);border-radius:3px;overflow:hidden">
472-
<div style="height:100%;width:${pct}%;background:var(--accent);transition:width .4s ease"></div>
520+
// Driven by the prover's interpolated fraction, which advances within the
521+
// segment being proven. A bar keyed on segments_done alone would sit still
522+
// for the minute-plus each segment takes, then jump.
523+
const bar = indeterminate
524+
? `<div class="prove-fill-indeterminate"></div>`
525+
: `<div class="prove-fill" style="width:${Math.min(100, Math.round(p.phase_one_fraction * 100))}%"></div>`;
526+
527+
let h = `<div class="prove-panel">
528+
<div class="prove-head">
529+
<span class="prove-phase">${esc(inPhaseTwo ? 'Producing succinct receipt' : 'Proving segments')}</span>
530+
${phaseLabel ? `<span class="prove-phase-num">${esc(phaseLabel)}</span>` : ''}
473531
</div>
474-
<div style="margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--text-muted)">
475-
${esc(meta)}
476-
</div>`;
477-
478-
// The first segment carries GPU warm-up and any PTX JIT, so it is called
479-
// out rather than averaged into the rate.
480-
if (p.first_segment_seconds != null && p.segments_done > 1) {
481-
h += `<div style="font-family:var(--mono);font-size:11px;color:var(--text-muted)">
482-
first segment ${esc(fmtDuration(p.first_segment_seconds))} (includes warm-up)
483-
</div>`;
532+
<div class="prove-track">${bar}</div>
533+
<div class="prove-stats">`;
534+
535+
// Ordered by what someone watching a proof actually wants: how much longer,
536+
// then how long so far, then the work being done.
537+
if (remaining) h += stat('remaining', `~${remaining}`, { accent: true });
538+
h += stat('elapsed', elapsed);
539+
if (!inPhaseTwo) h += stat('segments', `${p.segments_done}/${p.segments}`);
540+
// Collected by the prover and forwarded all along, but previously listed in
541+
// KNOWN (so skipped by the extras block) without being rendered anywhere —
542+
// so cycle counts never reached the page at all.
543+
if (p.total_cycles) h += stat('cycles', fmtCycles(p.total_cycles));
544+
// Gated on `segments_done > 1` before, which a single-segment job never
545+
// reaches — so on the proofs this actually produces it never rendered. It
546+
// is the only number separating warm-up (PTX JIT) from steady-state rate.
547+
if (p.first_segment_seconds != null && p.segments_done >= 1) {
548+
h += stat('first segment', fmtDurationShort(p.first_segment_seconds), {
549+
title: 'The first segment includes GPU warm-up and any PTX JIT, so it runs slower than the ones after it.',
550+
});
484551
}
485552

486553
// Whatever else the prover chose to report. A custom prover — the runpod
487554
// proxy, say — knows things this UI cannot anticipate: the GPU it rented,
488-
// the pod's hourly rate, queue position. Rendered generically so no subs
489-
// change is needed to surface a new one.
555+
// the pod's hourly rate, queue position. They flow into the same grid as
556+
// the built-in stats, so a new field looks native without a subs change.
490557
const KNOWN = new Set([
491558
'total_cycles', 'proving_cycles_done', 'segments', 'segments_done',
492559
'elapsed_seconds', 'estimated_total_seconds', 'first_segment_seconds',
560+
'phase', 'phase_total', 'phase_one_fraction',
493561
]);
494-
const extras = Object.entries(p).filter(([k, v]) =>
495-
!KNOWN.has(k) && v != null && typeof v !== 'object');
496-
497-
for (const [k, v] of extras) {
562+
for (const [k, v] of Object.entries(p)) {
563+
if (KNOWN.has(k) || v == null || typeof v === 'object') continue;
498564
const label = k.replace(/_/g, ' ');
499-
const val = typeof v === 'number' ? v.toLocaleString() : String(v);
500-
h += `<div style="font-family:var(--mono);font-size:11px;color:var(--text-muted)">
501-
${esc(label)}: <span style="color:var(--text-secondary)">${esc(val)}</span>
502-
</div>`;
565+
// Labels are ellipsized to keep tiles uniform, so the full name has to
566+
// stay reachable — a custom prover can name a field anything.
567+
h += stat(label, typeof v === 'number' ? v.toLocaleString() : String(v), { title: label });
503568
}
504569

570+
h += '</div>';
505571
return h + '</div>' + idRow;
506572
}
507573

@@ -537,17 +603,13 @@ <h3>Handles</h3>
537603
<span style="color:var(--text-muted)">(${total} pending)</span></span></div>`;
538604
h += renderProvingProgress(r.proving_progress, r.proving_job_id);
539605
}
540-
if (r.estimate) {
541-
h += '<div style="margin-top:10px;padding:12px;background:var(--bg-base);border:1px solid var(--border-subtle);border-radius:8px">';
542-
for (const [k, v] of Object.entries(r.estimate)) {
543-
if (v == null || typeof v === 'object') continue;
544-
const label = k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
545-
const val = typeof v === 'number' ? v.toLocaleString() : String(v);
546-
h += `<div style="font-family:var(--mono);font-size:11px;color:var(--text-muted);margin-bottom:2px">
547-
<span>${esc(label)}:</span> <span style="color:var(--text-secondary)">${esc(val)}</span></div>`;
548-
}
549-
h += '</div>';
550-
}
606+
// The pre-prove estimate is not rendered. It is persisted on the
607+
// commitment when fetched, so it outlives the prover that produced
608+
// it and kept showing after calibration was turned off. Its timings
609+
// are also wrong: calibration measures ProverOpts::composite(),
610+
// which skips lift/join, while real jobs run succinct() — so it
611+
// read ~43% low against a measured proof. The live progress in
612+
// renderProvingProgress supersedes it.
551613
if (!r.proving_job_active) {
552614
h += `<div class="flex gap-3" style="margin-top:10px">
553615
<button onclick="startProving()" class="btn-primary">Prove${proofLabel ? ' ' + proofLabel.trim() : ''}</button>

0 commit comments

Comments
 (0)