@@ -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.
247253function 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+ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' ,
256+ } ) [ c ] ) ;
251257}
252258
253259function $ ( 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+
422453function 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