From 46944c568e08a122e35d2b600abea482b22f387f Mon Sep 17 00:00:00 2001 From: Buffrr Date: Wed, 5 Aug 2026 13:53:28 +0200 Subject: [PATCH 1/2] feat(prover)!: let the prover own what progress is displayed --- prover/src/progress.rs | 209 +++++++++++++++++++++++++++---------- subs/src/routes/proving.rs | 155 +++++++++++++++------------ subs/templates/base.html | 11 ++ subs/templates/space.html | 184 ++++++++++---------------------- 4 files changed, 306 insertions(+), 253 deletions(-) diff --git a/prover/src/progress.rs b/prover/src/progress.rs index 672a7f3..bca824d 100644 --- a/prover/src/progress.rs +++ b/prover/src/progress.rs @@ -1,10 +1,14 @@ //! Live progress for an in-flight proving job. //! -//! The prover is the only place that knows how long a job will take: the -//! session gives the segment count before proving starts, and risc0 fires a -//! hook around each segment as it is proven. Together those turn "processing" -//! into an ETA measured on this GPU, for this job — rather than extrapolated -//! from a synthetic calibration run on some other pod. +//! The prover decides entirely what a client displays: the heading, whether +//! there is a bar and how full it is, which figures appear and in what order. +//! subs renders what it is given and computes nothing. +//! +//! That split matters because the phases are this prover's, not a universal +//! truth. A proxy that rents a pod has a boot phase; a prover that has profiled +//! lift/join can report a fraction where this one cannot. Encoding "there are +//! two phases, the second is unknowable" in the UI would make those +//! unexpressible. use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; @@ -13,50 +17,104 @@ use std::time::Instant; use risc0_zkvm::{Segment, SessionEvents}; use serde::Serialize; +/// How a client should draw the progress bar. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Bar { + /// Draw it filled to `fraction`. + Determinate, + /// Work is happening; its extent is unknown. + Indeterminate, + /// Draw no bar at all — for a status that is not progress, such as + /// "queued". Distinct from Indeterminate, which still asserts that + /// something is underway. + None, +} + +/// One figure to display, already formatted. +/// +/// The prover formats rather than sending raw numbers: it is the only side that +/// knows whether a value is cycles, seconds, or dollars, and a client that +/// re-derives "1.6M" from 1567156 is guessing at units it was never told. +#[derive(Debug, Clone, Serialize)] +pub struct Stat { + pub label: String, + pub value: String, + /// Emphasised. At most one is worth marking — normally whatever the + /// operator is actually waiting on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub accent: bool, +} + +impl Stat { + fn new(label: &str, value: impl Into) -> Self { + Self { label: label.into(), value: value.into(), accent: false } + } + + fn accented(label: &str, value: impl Into) -> Self { + Self { label: label.into(), value: value.into(), accent: true } + } +} + /// A snapshot of a job's progress, safe to serialize into a status response. +/// +/// Every field is optional. A prover with nothing useful to say omits the whole +/// structure; one that only wants to report "booting" sends a `label` and +/// nothing else. #[derive(Debug, Clone, Serialize)] pub struct JobProgress { - /// User cycles executed by the guest. - pub total_cycles: u64, - /// Padded proving cycles across the segments proven so far. Equals the - /// job's true total once `segments_done == segments`. - pub proving_cycles_done: u64, - /// Segments this job will prove. Known before proving starts. - pub segments: usize, - /// Segments proven so far. - pub segments_done: usize, - /// Wall-clock since proving began. - pub elapsed_seconds: f64, - /// Projected total wall-clock. `None` until a segment completes — there is - /// nothing to extrapolate from before that. - pub estimated_total_seconds: Option, - /// Which phase the job is in, 1-based. - /// - /// 1. Proving segments. Determinate: `segments_done` of `segments`. - /// 2. Lift/join/resolve, turning the composite segment receipts into the - /// succinct receipt. risc0 exposes no `SessionEvents` hook for this, so - /// nothing can be observed while it runs — it is genuinely - /// indeterminate, not merely unmeasured. - /// - /// Reporting it matters because phase 2 is not a tail: on a measured - /// single-segment step proof, segments finished at 10.7s of 38.8s, so 72% - /// of the wall-clock happened in phase 2 with the bar already full. - pub phase: u8, - /// Total phases, so the UI need not hardcode it. - pub phase_total: u8, - /// Fraction of phase 1 complete (0.0–1.0), interpolated within the segment - /// currently being proven so the bar advances between completions. - /// - /// `None` while the first segment is proving — there is no measured segment - /// duration to interpolate against yet — and throughout phase 2. - pub phase_one_fraction: Option, - /// Wall-clock of the first segment. - /// - /// Worth reporting separately: only sm_80 gets native SASS in the shipped - /// image, so every other GPU JIT-compiles PTX on its first kernel launch - /// and pays for it here. Comparing this against the mean is what tells an - /// operator whether baking in their card's SASS is worth the build time. - pub first_segment_seconds: Option, + /// What is happening now, in the prover's own words. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Position in a sequence of phases, for an "N of M" indicator. Phases are + /// whatever the prover says they are. + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_total: Option, + /// 0.0–1.0. Only meaningful when `bar` is Determinate. + #[serde(skip_serializing_if = "Option::is_none")] + pub fraction: Option, + /// Omitted means "Determinate if `fraction` is set, else Indeterminate", so + /// the ordinary cases need not send it. + #[serde(skip_serializing_if = "Option::is_none")] + pub bar: Option, + /// Figures to display, in the order they should appear. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub stats: Vec, + /// Lines to surface — pod boot output, queue notices. Replaced wholesale on + /// every poll, so the prover decides how many to keep and how to format + /// them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub log: Vec, +} + +/// Compact duration: "48s", "1m 32s", "2h 5m". +/// +/// Spelled-out forms wrap inside a stat tile and bury the number. +fn fmt_duration(seconds: f64) -> String { + let total = seconds.max(0.0).round() as u64; + if total < 60 { + return format!("{}s", total); + } + let mins = total / 60; + if mins < 60 { + let secs = total % 60; + return if secs == 0 { format!("{}m", mins) } else { format!("{}m {}s", mins, secs) }; + } + let hours = mins / 60; + let rem = mins % 60; + if rem == 0 { format!("{}h", hours) } else { format!("{}h {}m", hours, rem) } +} + +/// Cycle counts run to millions, where exact digits are noise. +fn fmt_cycles(n: u64) -> String { + match n { + n if n >= 1_000_000_000 => format!("{:.1}B", n as f64 / 1e9), + n if n >= 1_000_000 => format!("{:.1}M", n as f64 / 1e6), + n if n >= 1_000 => format!("{:.1}K", n as f64 / 1e3), + n => n.to_string(), + } } /// Shared progress counters, written by the proving hook and read by the @@ -193,7 +251,7 @@ impl ProgressSink { // scale against, so it only starts once one has been measured — the // very first segment has no basis and reports None, which the UI shows // as an indeterminate bar rather than a fabricated position. - let phase_one_fraction = if in_phase_two || segments == 0 || done == 0 || last <= 0.0 { + let fraction = if in_phase_two || segments == 0 || done == 0 || last <= 0.0 { None } else { let mean_segment = last / done as f64; @@ -201,17 +259,54 @@ impl ProgressSink { Some(((done as f64 + within) / segments as f64).min(1.0)) }; + // Nothing has been reported yet: the executor is still running and even + // the segment count is unknown. Saying so beats a bar at zero. + if segments == 0 { + return JobProgress { + label: Some("Executing".into()), + phase: None, + phase_total: None, + fraction: None, + bar: None, + stats: vec![Stat::new("elapsed", fmt_duration(elapsed))], + log: Vec::new(), + }; + } + + let mut stats = Vec::new(); + if let Some(total) = estimated_total_seconds { + stats.push(Stat::accented("remaining", format!("~{}", fmt_duration(total - elapsed)))); + } + stats.push(Stat::new("elapsed", fmt_duration(elapsed))); + if !in_phase_two { + stats.push(Stat::new("segments", format!("{}/{}", done, segments))); + } + let total_cycles = self.total_cycles.load(Ordering::Relaxed); + if total_cycles > 0 { + stats.push(Stat::new("cycles", fmt_cycles(total_cycles))); + } + // Only sm_80 gets native SASS in the shipped image, so every other GPU + // JIT-compiles PTX on its first kernel launch and pays for it here. + // Comparing this against the mean tells an operator whether baking in + // their card's SASS is worth the build time. + if first_ms > 0 { + stats.push(Stat::new("first segment", fmt_duration(first_ms as f64 / 1000.0))); + } + JobProgress { - total_cycles: self.total_cycles.load(Ordering::Relaxed), - proving_cycles_done: self.proving_cycles_done.load(Ordering::Relaxed), - segments, - segments_done: done, - elapsed_seconds: elapsed, - estimated_total_seconds, - phase: if in_phase_two { 2 } else { 1 }, - phase_total: 2, - phase_one_fraction, - first_segment_seconds: (first_ms > 0).then_some(first_ms as f64 / 1000.0), + label: Some( + if in_phase_two { "Producing succinct receipt" } else { "Proving segments" }.into(), + ), + phase: Some(if in_phase_two { 2 } else { 1 }), + phase_total: Some(2), + fraction, + // Left to the default rule: determinate when a fraction is present. + // Phase 2 has none — lift/join/resolve fire no hook — so it draws + // indeterminate, which is the honest shape for work of unknown + // extent rather than a full bar sitting still. + bar: None, + stats, + log: Vec::new(), } } } diff --git a/subs/src/routes/proving.rs b/subs/src/routes/proving.rs index 4cdfc65..637905d 100644 --- a/subs/src/routes/proving.rs +++ b/subs/src/routes/proving.rs @@ -16,42 +16,55 @@ use subs_core::CompressInput; use crate::state::AppState; -fn one() -> u8 { - 1 +/// One figure the prover wants displayed, already formatted by it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stat { + pub label: String, + pub value: String, + #[serde(default)] + pub accent: bool, } /// Live proving progress, forwarded verbatim from the prover. /// -/// The prover is the only place that knows how far along a proof is; subs just -/// relays it so the UI can show a bar instead of a spinner. Fields are optional -/// so a prover that predates progress reporting still deserializes. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// The prover decides what is displayed — heading, bar, figures and their +/// order. subs relays; it computes and formats nothing, because only the prover +/// knows what its phases are or what a value means. +/// +/// **Every field is optional**, deliberately. A prover that is booting a GPU +/// has no segment count and no cycles, and requiring them would leave it +/// choosing between sending zeros it would have to invent and saying nothing at +/// all. An unparseable progress body is dropped entirely, so a required field +/// is not a small cost. +/// Absent fields are skipped on the way out too, not re-emitted as nulls: +/// subs forwards this into the pipeline response, and a prover reporting only +/// "booting" should not turn into a wall of nulls for whoever reads that API. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] pub struct JobProgress { - pub total_cycles: u64, - pub proving_cycles_done: u64, - pub segments: usize, - pub segments_done: usize, - pub elapsed_seconds: f64, - pub estimated_total_seconds: Option, - pub first_segment_seconds: Option, - /// Which phase the prover is in, and how many there are. Defaulted rather - /// than optional so a prover that predates phase reporting still - /// deserializes and simply reads as "phase 1 of 1" — one determinate bar, - /// which is exactly how it used to behave. - #[serde(default = "one")] - pub phase: u8, - #[serde(default = "one")] - pub phase_total: u8, - #[serde(default)] - pub phase_one_fraction: Option, - /// Anything else the prover reported. - /// - /// A custom prover knows things this one cannot — which GPU it rented, what - /// the pod costs, where it is queued. Without this those fields would be - /// dropped on deserialization; flattening keeps them so the UI can display - /// them generically, without subs needing to know what they mean. - #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] - pub extra: serde_json::Map, + /// What is happening now, in the prover's words. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Position in a prover-defined sequence, for an "N of M" indicator. + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_total: Option, + /// 0.0–1.0, meaningful when the bar is determinate. + #[serde(skip_serializing_if = "Option::is_none")] + pub fraction: Option, + /// "determinate" | "indeterminate" | "none". Absent means determinate when + /// `fraction` is set, indeterminate otherwise — so ordinary cases omit it. + /// "none" draws no bar, for a status that is not progress. + #[serde(skip_serializing_if = "Option::is_none")] + pub bar: Option, + /// Figures to display, in the order given. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub stats: Vec, + /// Lines to surface — pod boot output, queue notices. Replaced wholesale + /// each poll; the prover owns retention and formatting. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub log: Vec, } use super::json_error; @@ -637,58 +650,64 @@ pub async fn get_estimate( mod tests { use super::JobProgress; - /// A custom prover's extra fields must survive deserialization. + /// Stats keep the prover's order. /// - /// Without `#[serde(flatten)]` these are dropped silently — the struct - /// still parses, the UI just shows nothing — so this is the kind of - /// regression that would not surface until someone asked why their proxy's - /// fields never appeared. + /// They used to be a flattened `serde_json::Map`, which is a BTreeMap + /// without the `preserve_order` feature — so a prover's fields were + /// silently re-sorted alphabetically before display. #[test] - fn unknown_fields_are_preserved() { + fn stats_keep_the_provers_order() { let json = r#"{ - "total_cycles": 4081240, - "proving_cycles_done": 3145728, - "segments": 5, - "segments_done": 3, - "elapsed_seconds": 333.4, - "estimated_total_seconds": 572.1, - "first_segment_seconds": 94.0, - "gpu": "NVIDIA A100 80GB PCIe", - "hourly_rate": 1.19 + "label": "Proving segments", + "phase": 1, "phase_total": 2, + "fraction": 0.62, + "stats": [ + {"label": "remaining", "value": "~4m 12s", "accent": true}, + {"label": "gpu", "value": "NVIDIA A100 80GB PCIe"}, + {"label": "hourly rate", "value": "$1.19"} + ] }"#; let p: JobProgress = serde_json::from_str(json).expect("deserialize"); - assert_eq!(p.segments_done, 3); - assert_eq!(p.segments, 5); - assert_eq!( - p.extra.get("gpu").and_then(|v| v.as_str()), - Some("NVIDIA A100 80GB PCIe") - ); - assert_eq!(p.extra.get("hourly_rate").and_then(|v| v.as_f64()), Some(1.19)); - // Known fields must not leak into extra, or the UI renders them twice. - assert!(!p.extra.contains_key("segments")); - assert!(!p.extra.contains_key("elapsed_seconds")); + let order: Vec<&str> = p.stats.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(order, ["remaining", "gpu", "hourly rate"]); + assert!(p.stats[0].accent); + assert!(!p.stats[1].accent, "accent defaults to false"); + assert_eq!(p.fraction, Some(0.62)); } - /// A prover that reports nothing extra round-trips with an empty map, and - /// re-serializes without an `extra` key. + /// A prover with no numbers to report must still deserialize. + /// + /// This is the case that forced the redesign: a proxy booting a GPU has no + /// segments and no cycles. When those fields were required it had to invent + /// zeros, because an unparseable body is dropped whole and shows nothing. #[test] - fn plain_progress_round_trips() { + fn a_status_without_any_numbers_is_accepted() { let json = r#"{ - "total_cycles": 100, - "proving_cycles_done": 50, - "segments": 2, - "segments_done": 1, - "elapsed_seconds": 1.5, - "estimated_total_seconds": null, - "first_segment_seconds": null + "label": "Booting GPU server", + "bar": "indeterminate", + "log": ["10:32:01 pulling image", "10:32:44 cuda ready"] }"#; let p: JobProgress = serde_json::from_str(json).expect("deserialize"); - assert!(p.extra.is_empty()); + assert_eq!(p.label.as_deref(), Some("Booting GPU server")); + assert_eq!(p.bar.as_deref(), Some("indeterminate")); + assert_eq!(p.log.len(), 2); + assert!(p.stats.is_empty()); + assert_eq!(p.fraction, None); + } + + /// An empty body is valid and says nothing, rather than failing to parse. + #[test] + fn an_empty_body_deserializes() { + let p: JobProgress = serde_json::from_str("{}").expect("deserialize"); + assert!(p.label.is_none() && p.stats.is_empty() && p.log.is_empty()); + + // Absent fields must not be re-emitted as nulls: subs forwards this + // verbatim, and a wall of nulls is noise for anyone reading the API. let out = serde_json::to_string(&p).expect("serialize"); - assert!(!out.contains("extra"), "flattened map must not emit a key: {out}"); + assert!(!out.contains("null"), "empty progress should stay empty: {out}"); } } diff --git a/subs/templates/base.html b/subs/templates/base.html index 6c0a53d..2db8662 100644 --- a/subs/templates/base.html +++ b/subs/templates/base.html @@ -823,6 +823,17 @@ .prove-stat-note { font-size: 10px; color: var(--text-muted); line-height: 1.3; } +/* Prover-supplied lines — pod boot output, queue notices. Height-capped and + scrolled so a chatty prover cannot grow the page without bound. */ +.prove-log { + margin-top: 10px; padding: 8px 10px; + background: var(--bg-base); border: 1px solid var(--border-subtle); + border-radius: 6px; + font-family: var(--mono); font-size: 11px; line-height: 1.5; + color: var(--text-secondary); + max-height: 132px; overflow: auto; + white-space: pre-wrap; overflow-wrap: anywhere; +} /* === SCROLLBAR === */ .scrollbar-thin::-webkit-scrollbar { width: 4px; } diff --git a/subs/templates/space.html b/subs/templates/space.html index c275a66..be249f0 100644 --- a/subs/templates/space.html +++ b/subs/templates/space.html @@ -425,57 +425,13 @@

Handles

$('pipelineStepper').innerHTML = h; } -// Cycle counts run to millions, where exact digits are noise. -function fmtCycles(n) { - if (n == null) return null; - if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; - if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; - if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; - return `${n}`; -} - -// Compact form for stat tiles, where "1 minute 32 seconds" wraps to two lines -// and buries the number. Prose elsewhere still uses fmtDuration. -function fmtDurationShort(sec) { - if (sec == null) return null; - const total = Math.max(0, Math.round(sec)); - if (total < 60) return `${total}s`; - const mins = Math.floor(total / 60); - if (mins < 60) { - const secs = total % 60; - return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`; - } - const hours = Math.floor(mins / 60); - const remMins = mins % 60; - return remMins === 0 ? `${hours}h` : `${hours}h ${remMins}m`; -} - -function fmtDuration(sec) { - if (sec == null) return null; - const total = Math.max(0, Math.round(sec)); - const unit = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; - - if (total < 60) return unit(total, 'second'); - - const mins = Math.floor(total / 60); - const secs = total % 60; - if (mins < 60) { - // Seconds stop mattering once it is a long wait. - return mins >= 10 || secs === 0 - ? unit(mins, 'minute') - : `${unit(mins, 'minute')} ${unit(secs, 'second')}`; - } - - const hours = Math.floor(mins / 60); - const remMins = mins % 60; - return remMins === 0 - ? unit(hours, 'hour') - : `${unit(hours, 'hour')} ${unit(remMins, 'minute')}`; -} - +// Draws what the prover reported. Computes and formats nothing: the phases, +// the figures and their order are the prover's to decide, so a proxy that boots +// a pod or a prover that can measure recursion is not forced into this one's +// shape. function renderProvingProgress(p, jobId) { - // Shown even with no progress yet: the id is what correlates this proof - // with the prover's logs and with the runpod proxy. + // Shown even with no progress: the id is what correlates this proof with + // the prover's logs and with a proxy's own records. const idRow = jobId ? `
job @@ -484,93 +440,65 @@

Handles

` : ''; - // Absent for a prover that predates progress reporting, or while the - // executor is still running before the first segment is proven. - if (!p || !p.segments) return idRow; - - // `title` carries any explanation, so the tiles stay uniform instead of - // growing a line of prose underneath. - const stat = (label, value, opts = {}) => ` -
- ${esc(label)} - ${esc(value)} -
`; - - const elapsed = fmtDurationShort(p.elapsed_seconds); - const phaseTotal = p.phase_total || 1; - // Phase 2 is lift/join/resolve. risc0 fires no hook during it, so there is - // nothing to measure and nothing to extrapolate — it gets a moving bar - // with no percentage rather than a full one that sits there. On a measured - // single-segment proof this phase was 28.1s of 38.8s, so a bar pinned at - // 100% for its duration was the most misleading thing on the page. - const inPhaseTwo = phaseTotal > 1 && p.phase >= 2; - // An indeterminate bar is also right for the first segment: nothing has - // been timed yet, so there is no honest position to draw. - const indeterminate = inPhaseTwo || p.phase_one_fraction == null; - // Just the counter — the phase's description is the panel heading. - const phaseLabel = phaseTotal > 1 ? `Phase ${p.phase} of ${phaseTotal}` : null; - - // No ETA until a segment lands — there is nothing to extrapolate from. - // Absent for all of phase 2, by design. Its absence is not annotated: it is - // the normal case here, and saying so reads as a fault. - const remaining = p.estimated_total_seconds != null - ? fmtDurationShort(Math.max(0, p.estimated_total_seconds - p.elapsed_seconds)) + if (!p) return idRow; + + const stats = Array.isArray(p.stats) ? p.stats : []; + const log = Array.isArray(p.log) ? p.log : []; + // A prover that sent a body with nothing in it gets nothing drawn, rather + // than an empty panel. + if (!p.label && !stats.length && !log.length && p.fraction == null) return idRow; + + // Absent means determinate when there is a fraction to draw, indeterminate + // otherwise. "none" is the explicit opt-out, for a status that is not + // progress at all — without it, "no bar" and "extent unknown" would be + // indistinguishable. + const mode = p.bar || (p.fraction != null ? 'determinate' : 'indeterminate'); + const pct = Math.max(0, Math.min(100, Math.round((p.fraction || 0) * 100))); + const track = mode === 'none' ? '' : `
${ + mode === 'determinate' + ? `
` + : `
` + }
`; + + const counter = (p.phase && p.phase_total && p.phase_total > 1) + ? `Phase ${p.phase} of ${p.phase_total}` : null; - // Driven by the prover's interpolated fraction, which advances within the - // segment being proven. A bar keyed on segments_done alone would sit still - // for the minute-plus each segment takes, then jump. - const bar = indeterminate - ? `
` - : `
`; - - let h = `
-
- ${esc(inPhaseTwo ? 'Producing succinct receipt' : 'Proving segments')} - ${phaseLabel ? `${esc(phaseLabel)}` : ''} -
-
${bar}
-
`; - - // Ordered by what someone watching a proof actually wants: how much longer, - // then how long so far, then the work being done. - if (remaining) h += stat('remaining', `~${remaining}`, { accent: true }); - h += stat('elapsed', elapsed); - if (!inPhaseTwo) h += stat('segments', `${p.segments_done}/${p.segments}`); - // Collected by the prover and forwarded all along, but previously listed in - // KNOWN (so skipped by the extras block) without being rendered anywhere — - // so cycle counts never reached the page at all. - if (p.total_cycles) h += stat('cycles', fmtCycles(p.total_cycles)); - // Gated on `segments_done > 1` before, which a single-segment job never - // reaches — so on the proofs this actually produces it never rendered. It - // is the only number separating warm-up (PTX JIT) from steady-state rate. - if (p.first_segment_seconds != null && p.segments_done >= 1) { - h += stat('first segment', fmtDurationShort(p.first_segment_seconds), { - title: 'The first segment includes GPU warm-up and any PTX JIT, so it runs slower than the ones after it.', - }); + let h = '
'; + if (p.label || counter) { + h += `
+ ${esc(p.label || '')} + ${counter ? `${esc(counter)}` : ''} +
`; + } + h += track; + + // Rendered in the order given, formatted by the prover. Only it knows + // whether a value is cycles, seconds or dollars. + if (stats.length) { + h += '
'; + for (const s of stats) { + if (!s || s.value == null) continue; + const label = String(s.label ?? ''); + h += `
+ ${esc(label)} + ${esc(s.value)} +
`; + } + h += '
'; } - // Whatever else the prover chose to report. A custom prover — the runpod - // proxy, say — knows things this UI cannot anticipate: the GPU it rented, - // the pod's hourly rate, queue position. They flow into the same grid as - // the built-in stats, so a new field looks native without a subs change. - const KNOWN = new Set([ - 'total_cycles', 'proving_cycles_done', 'segments', 'segments_done', - 'elapsed_seconds', 'estimated_total_seconds', 'first_segment_seconds', - 'phase', 'phase_total', 'phase_one_fraction', - ]); - for (const [k, v] of Object.entries(p)) { - if (KNOWN.has(k) || v == null || typeof v === 'object') continue; - const label = k.replace(/_/g, ' '); - // Labels are ellipsized to keep tiles uniform, so the full name has to - // stay reachable — a custom prover can name a field anything. - h += stat(label, typeof v === 'number' ? v.toLocaleString() : String(v), { title: label }); + // Capped and scrolled: a chatty prover must not be able to grow the page + // without bound. Newest last, so it reads like a terminal. + if (log.length) { + const shown = log.slice(-200); + h += `
${shown.map(l => esc(l)).join('\n')}
`; } - h += '
'; return h + '
' + idRow; } + async function cancelProving() { if (!confirm('Cancel this proof?\n\nA queued job stops immediately. One already running finishes on the prover and its result is discarded — the GPU time is not reclaimed.')) return; const { ok, data } = await api(`${spaceUrl}/proving/cancel`, { method: 'POST' }); From df1a5a0f5a171a0b1b0d73e365fbf5558ee9321e Mon Sep 17 00:00:00 2001 From: Buffrr Date: Wed, 5 Aug 2026 15:28:52 +0200 Subject: [PATCH 2/2] test(prover): cover the progress contract --- prover/src/progress.rs | 89 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/prover/src/progress.rs b/prover/src/progress.rs index bca824d..64a18ad 100644 --- a/prover/src/progress.rs +++ b/prover/src/progress.rs @@ -176,7 +176,10 @@ impl ProgressSink { self.proving_cycles_done .fetch_add(1u64 << po2, Ordering::Relaxed); let done = self.segments_done.fetch_add(1, Ordering::Relaxed) + 1; - let ms = self.started.elapsed().as_millis() as u64; + // Floored at 1: zero is the "no segment yet" sentinel, and a segment + // that lands inside a millisecond would otherwise read as one that + // never happened, dropping both the ETA and the first-segment figure. + let ms = (self.started.elapsed().as_millis() as u64).max(1); self.last_segment_millis.store(ms, Ordering::Relaxed); if done == 1 { self.first_segment_millis.store(ms, Ordering::Relaxed); @@ -327,3 +330,87 @@ impl SessionEvents for SegmentProgress { self.sink.on_segment_proven(segment.po2()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn durations_are_compact() { + assert_eq!(fmt_duration(0.4), "0s"); + assert_eq!(fmt_duration(48.0), "48s"); + assert_eq!(fmt_duration(92.0), "1m 32s"); + assert_eq!(fmt_duration(120.0), "2m"); + assert_eq!(fmt_duration(7500.0), "2h 5m"); + } + + #[test] + fn cycles_are_abbreviated() { + assert_eq!(fmt_cycles(842), "842"); + assert_eq!(fmt_cycles(93_823), "93.8K"); + assert_eq!(fmt_cycles(1_567_156), "1.6M"); + assert_eq!(fmt_cycles(8_451_200), "8.5M"); + } + + /// Before any segment lands there is no count and no rate, so the snapshot + /// says what it is doing rather than drawing a bar at zero. + #[test] + fn executing_reports_no_bar_position() { + let sink = ProgressSink::new(); + let p = sink.snapshot(); + assert_eq!(p.label.as_deref(), Some("Executing")); + assert_eq!(p.fraction, None); + assert_eq!(p.stats.len(), 1); + assert_eq!(p.stats[0].label, "elapsed"); + } + + /// The prover formats its own values: the wire carries "1.6M", not 1567156. + #[test] + fn proving_reports_formatted_stats_in_order() { + let sink = ProgressSink::new(); + sink.on_session_ready(1_567_156, 2); + sink.on_segment_proven(18); + + let p = sink.snapshot(); + assert_eq!(p.label.as_deref(), Some("Proving segments")); + assert_eq!(p.phase, Some(1)); + assert_eq!(p.phase_total, Some(2)); + + let labels: Vec<&str> = p.stats.iter().map(|s| s.label.as_str()).collect(); + assert_eq!(labels, ["remaining", "elapsed", "segments", "cycles", "first segment"]); + assert!(p.stats[0].accent, "remaining is what an operator waits on"); + assert_eq!(p.stats[2].value, "1/2"); + assert_eq!(p.stats[3].value, "1.6M"); + } + + /// Once every segment is proven, lift/join/resolve run with no hook to + /// observe. No fraction is sent, so the bar defaults to indeterminate + /// rather than sitting full for the majority of the job. + #[test] + fn recursion_reports_no_fraction() { + let sink = ProgressSink::new(); + sink.on_session_ready(93_823, 1); + sink.on_segment_proven(17); + + let p = sink.snapshot(); + assert_eq!(p.label.as_deref(), Some("Producing succinct receipt")); + assert_eq!(p.phase, Some(2)); + assert_eq!(p.fraction, None, "nothing to extrapolate from"); + assert!(p.bar.is_none(), "default rule covers it"); + let labels: Vec<&str> = p.stats.iter().map(|s| s.label.as_str()).collect(); + assert!(!labels.contains(&"segments"), "all done; the count is noise now"); + } + + /// A finished job must stop ageing. + #[test] + fn finish_freezes_elapsed() { + let sink = ProgressSink::new(); + sink.on_session_ready(100, 1); + sink.finish(); + let a = sink.snapshot(); + std::thread::sleep(std::time::Duration::from_millis(30)); + let b = sink.snapshot(); + let val = |p: &JobProgress| p.stats.iter().find(|s| s.label == "elapsed").unwrap().value.clone(); + assert_eq!(val(&a), val(&b)); + } +}