From c883b7437ae6a98671c39acec1183401322af077 Mon Sep 17 00:00:00 2001 From: Alfred Date: Mon, 15 Jun 2026 13:50:44 +0200 Subject: [PATCH 1/4] fix: font, eval browser --- src/pg_store.rs | 145 ++++++++++ src/server.rs | 177 ++++++++++-- src/store.rs | 10 +- ui/src/App.svelte | 7 +- ui/src/components/EvalSeriesCard.svelte | 59 +++- ui/src/components/Result.svelte | 43 ++- ui/src/components/RolloutViewer.svelte | 140 +++++++++- ui/src/lib/api.ts | 15 +- ui/src/lib/router.svelte.ts | 5 +- ui/src/lib/types.ts | 21 ++ ui/src/views/ArtifactPanel.svelte | 2 +- ui/src/views/RolloutBrowserView.svelte | 344 ++++++++++++++++++++++++ ui/src/views/RunPanel.svelte | 40 ++- ui/src/views/RunsView.svelte | 16 +- 14 files changed, 951 insertions(+), 73 deletions(-) create mode 100644 ui/src/views/RolloutBrowserView.svelte diff --git a/src/pg_store.rs b/src/pg_store.rs index 52f4a61..fab9fa2 100644 --- a/src/pg_store.rs +++ b/src/pg_store.rs @@ -860,6 +860,14 @@ impl PgStore { er.eval_run_id, COALESCE(er_run.status, 'pending') AS state, cp.metadata_json AS checkpoint_metadata, + ( + SELECT a.id + FROM run_outputs ro + JOIN artifacts a ON a.id = ro.artifact_id + WHERE ro.run_id = er.eval_run_id AND a.kind = 'eval_result' + ORDER BY a.created_at, a.id + LIMIT 1 + ) AS eval_result_artifact_id, ( SELECT a.metadata_json FROM run_outputs ro @@ -892,6 +900,143 @@ impl PgStore { eval_run_id: r.try_get("eval_run_id")?, state: r.try_get("state")?, checkpoint_metadata: checkpoint_metadata.map(|j| j.0), + eval_result_artifact_id: r.try_get("eval_result_artifact_id")?, + eval_result_metadata: eval_result_metadata.map(|j| j.0), + }) + }) + .collect() + } + + /// Like [`Self::eval_series_rows`], but for the rollout *browser*: it + /// aggregates a training run's evals one export hop down, not just its + /// direct checkpoints. In the per-checkpoint-export workflow the + /// rollout evals reference HF-export checkpoints whose `producer_run_id` + /// is a per-checkpoint *export* run, not the training run — so the + /// one-hop `eval_series_rows` (cp.producer_run_id = run) misses them. + /// + /// Matches an eval when its checkpoint was produced either directly by + /// `run_id`, or by a run that consumed a checkpoint produced by `run_id` + /// (the export run). Direct-eval workflows (text SFT) still match via the + /// first clause. The `step` still comes from the eval's checkpoint + /// metadata, which carries it across the export. + pub async fn rollout_series_rows(&self, run_id: &str) -> Result> { + let rows = sqlx::query( + "SELECT + er.eval_key, + er.checkpoint_artifact_id, + er.eval_recipe_hash, + er.policy_id, + er.eval_run_id, + COALESCE(er_run.status, 'pending') AS state, + cp.metadata_json AS checkpoint_metadata, + ( + SELECT a.id + FROM run_outputs ro + JOIN artifacts a ON a.id = ro.artifact_id + WHERE ro.run_id = er.eval_run_id AND a.kind = 'eval_result' + ORDER BY a.created_at, a.id + LIMIT 1 + ) AS eval_result_artifact_id, + ( + SELECT a.metadata_json + FROM run_outputs ro + JOIN artifacts a ON a.id = ro.artifact_id + WHERE ro.run_id = er.eval_run_id AND a.kind = 'eval_result' + ORDER BY a.created_at, a.id + LIMIT 1 + ) AS eval_result_metadata + FROM eval_requests er + LEFT JOIN artifacts cp ON cp.id = er.checkpoint_artifact_id + LEFT JOIN runs er_run ON er_run.id = er.eval_run_id + WHERE cp.producer_run_id = $1 + OR er.eval_run_id = $1 + OR cp.producer_run_id IN ( + SELECT ri.run_id + FROM run_inputs ri + JOIN artifacts mc ON mc.id = ri.artifact_id + WHERE mc.producer_run_id = $1 + ) + ORDER BY er.created_at", + ) + .bind(run_id) + .fetch_all(&self.pool) + .await + .with_context(|| format!("rollout_series_rows({run_id})"))?; + rows.into_iter() + .map(|r| { + let checkpoint_metadata: Option> = + r.try_get("checkpoint_metadata")?; + let eval_result_metadata: Option> = + r.try_get("eval_result_metadata")?; + Ok(EvalSeriesRow { + eval_key: r.try_get("eval_key")?, + checkpoint_artifact_id: r.try_get("checkpoint_artifact_id")?, + eval_recipe_hash: r.try_get("eval_recipe_hash")?, + policy_id: r.try_get("policy_id")?, + eval_run_id: r.try_get("eval_run_id")?, + state: r.try_get("state")?, + checkpoint_metadata: checkpoint_metadata.map(|j| j.0), + eval_result_artifact_id: r.try_get("eval_result_artifact_id")?, + eval_result_metadata: eval_result_metadata.map(|j| j.0), + }) + }) + .collect() + } + + /// Rollout evals discovered purely by *lineage*, not via `eval_requests`. + /// Picks up manually-submitted freeroll evals (which never create an + /// eval_request row) so they still appear in the rollout browser. Finds + /// every `eval_result` artifact whose producing run consumed a checkpoint + /// that is produced directly by `run_id`, or by a run that consumed a + /// checkpoint produced by `run_id` (the one export hop) — the same + /// reachability as [`Self::rollout_series_rows`]. + /// + /// `policy_id` is set to the eval run's `recipe_name`; the caller + /// normalizes it (collapsing the `step` token) so freerolls of one + /// family at different checkpoints group into a single browsable series. + /// Rows already covered by `eval_requests` are deduped by the caller. + pub async fn lineage_rollout_rows(&self, run_id: &str) -> Result> { + let rows = sqlx::query( + "SELECT DISTINCT + a.id AS eval_result_artifact_id, + a.metadata_json AS eval_result_metadata, + a.producer_run_id AS eval_run_id, + COALESCE(er_run.status, 'pending') AS state, + COALESCE(er_run.recipe_name, '') AS recipe_name, + cp.id AS checkpoint_artifact_id, + cp.metadata_json AS checkpoint_metadata + FROM artifacts a + JOIN runs er_run ON er_run.id = a.producer_run_id + JOIN run_inputs ri ON ri.run_id = a.producer_run_id + JOIN artifacts cp ON cp.id = ri.artifact_id AND cp.kind = 'checkpoint' + WHERE a.kind = 'eval_result' + AND ( cp.producer_run_id = $1 + OR cp.producer_run_id IN ( + SELECT ri2.run_id + FROM run_inputs ri2 + JOIN artifacts mc ON mc.id = ri2.artifact_id + WHERE mc.producer_run_id = $1 + ) )", + ) + .bind(run_id) + .fetch_all(&self.pool) + .await + .with_context(|| format!("lineage_rollout_rows({run_id})"))?; + rows.into_iter() + .map(|r| { + let checkpoint_metadata: Option> = + r.try_get("checkpoint_metadata")?; + let eval_result_metadata: Option> = + r.try_get("eval_result_metadata")?; + Ok(EvalSeriesRow { + eval_key: String::new(), + checkpoint_artifact_id: r.try_get("checkpoint_artifact_id")?, + eval_recipe_hash: String::new(), + policy_id: r.try_get("recipe_name")?, + eval_run_id: r.try_get("eval_run_id")?, + state: r.try_get("state")?, + checkpoint_metadata: checkpoint_metadata.map(|j| j.0), + eval_result_artifact_id: r.try_get("eval_result_artifact_id")?, eval_result_metadata: eval_result_metadata.map(|j| j.0), }) }) diff --git a/src/server.rs b/src/server.rs index 355496f..2c8898d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -100,6 +100,7 @@ pub async fn serve(cluster: ClusterConfig, pg: Arc, addr: SocketAddr) - .route("/runs/:id", get(get_run)) .route("/runs/:id/log", get(get_run_log)) .route("/runs/:id/events", get(get_run_events)) + .route("/runs/:id/rollouts", get(get_run_rollouts)) .route("/recipes/:name/history", get(get_recipe_history)) .route("/recipes/:name", get(get_recipe)) // Top-level so it doesn't collide with the /runs/:id route — matchit @@ -437,6 +438,8 @@ fn build_eval_series(rows: &[crate::store::EvalSeriesRow]) -> Vec { metric_name: Option, eval_run_id: Option, checkpoint_artifact_id: String, + eval_result_artifact_id: Option, + has_rollout: bool, state: String, } @@ -457,6 +460,21 @@ fn build_eval_series(rows: &[crate::store::EvalSeriesRow]) -> Vec { .map(|(n, v)| (Some(n), Some(v))) .unwrap_or((None, None)); + // A point is rollout-browsable when its eval_result recorded a GUI + // rollout — single shape (top-level traj_path/gif_path) or multi + // shape (a `runs[]` array). Mirrors the detection in the rollout + // viewer; lets the browser skip pending/metric-only evals. + let has_rollout = ev.eval_result_artifact_id.is_some() + && ev + .eval_result_metadata + .as_ref() + .and_then(|m| m.get("result")) + .is_some_and(|r| { + r.get("traj_path").is_some() + || r.get("gif_path").is_some() + || r.get("runs").and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty()) + }); + by_policy .entry(ev.policy_id.clone()) .or_default() @@ -466,6 +484,8 @@ fn build_eval_series(rows: &[crate::store::EvalSeriesRow]) -> Vec { metric_name, eval_run_id: ev.eval_run_id.clone(), checkpoint_artifact_id: ev.checkpoint_artifact_id.clone(), + eval_result_artifact_id: ev.eval_result_artifact_id.clone(), + has_rollout, state: ev.state.clone(), }); } @@ -510,6 +530,8 @@ fn build_eval_series(rows: &[crate::store::EvalSeriesRow]) -> Vec { "metric_name": p.metric_name, "eval_run_id": p.eval_run_id, "checkpoint_artifact_id": p.checkpoint_artifact_id, + "eval_result_artifact_id": p.eval_result_artifact_id, + "has_rollout": p.has_rollout, "state": p.state, })) .collect::>(), @@ -518,6 +540,74 @@ fn build_eval_series(rows: &[crate::store::EvalSeriesRow]) -> Vec { .collect() } +/// Rollout browser data for one run: its evals aggregated one export hop +/// down (see [`PgStore::rollout_series_rows`]), grouped by policy and sorted +/// by checkpoint step — the same shape as a run's `eval_series`, reusing +/// [`build_eval_series`]. The browser steps through the `has_rollout` points. +/// Also returns the run's recipe name for the page header. +async fn get_run_rollouts( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let pg = &state.pg; + let run = pg + .get_run(&id) + .await + .ok() + .flatten() + .ok_or_else(|| not_found(format!("run not found: {id}")))?; + + // Policy-dispatched evals (eval_requests), plus manually-submitted evals + // found by lineage (e.g. freerolls, which create no eval_request). Dedupe + // the lineage rows against eval_result artifacts already covered above, and + // normalize their recipe-name group key so a freeroll family at different + // checkpoints collapses into one browsable series. + let mut rows = pg.rollout_series_rows(&id).await?; + let seen: std::collections::HashSet = rows + .iter() + .filter_map(|r| r.eval_result_artifact_id.clone()) + .collect(); + for mut lr in pg.lineage_rollout_rows(&id).await? { + match &lr.eval_result_artifact_id { + Some(aid) if !seen.contains(aid) => {} + _ => continue, + } + lr.policy_id = normalize_eval_policy(&lr.policy_id); + rows.push(lr); + } + + let series = build_eval_series(&rows); + Ok(axum::Json(json!({ + "run_id": run.id, + "recipe_name": run.recipe_name, + "series": series, + }))) +} + +/// Collapse a `step` token in an eval recipe name so manually-submitted +/// freerolls of the same family at different checkpoints (…step6742…, +/// …step8000…) group into a single browsable series. +fn normalize_eval_policy(name: &str) -> String { + let chars: Vec = name.chars().collect(); + let mut out = String::with_capacity(name.len()); + let mut i = 0; + while i < chars.len() { + if chars[i..].starts_with(&['s', 't', 'e', 'p']) + && chars.get(i + 4).is_some_and(|c| c.is_ascii_digit()) + { + out.push_str("step"); + i += 4; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + /// All metric points emitted by a run's evals, flattened: one row per /// (metric, eval, checkpoint). Drives the metric-pivot used by compare / /// recipe views. @@ -1560,12 +1650,64 @@ async fn get_cluster(State(state): State) -> axum::Json { // ---------- rollout viewer ---------- +/// Optional `?task=N` selector for multi-instruction eval rollouts. Absent +/// (or on a single-rollout artifact) means "the one rollout". +#[derive(Deserialize)] +struct RolloutQuery { + task: Option, +} + +/// Resolve the `(trajectory.jsonl, steps/)` pair for a rollout, supporting +/// both eval_result shapes the runner emits: +/// * single — top-level `result.traj_path` (absolute); `steps/` is its +/// sibling. `task` is ignored. This is the legacy shape. +/// * multi — `result.runs[]`, one entry per instruction, each with a +/// `subdir` relative to the artifact's path. `task` selects a run +/// (default 0). traj.jsonl + steps/ live under `//`. +/// +/// The two shapes are disjoint — the multi aggregate result.json has no +/// top-level `traj_path` — so detection is unambiguous. +fn resolve_rollout_paths( + artifact: &crate::store::ArtifactRow, + task: Option, +) -> Result<(PathBuf, PathBuf), ApiError> { + let result = artifact.metadata_json.get("result"); + + // Multi shape: per-instruction subdirs under the artifact path. + if let Some(runs) = result.and_then(|r| r.get("runs")).and_then(|v| v.as_array()) { + let idx = task.unwrap_or(0); + let run = runs.get(idx).ok_or_else(|| { + not_found(format!("task {idx} out of range ({} run(s))", runs.len())) + })?; + let subdir = run + .get("subdir") + .and_then(|v| v.as_str()) + .ok_or_else(|| not_found(format!("run {idx} has no subdir in result")))?; + let dir = artifact.path.join(subdir); + return Ok((dir.join("trajectory.jsonl"), dir.join("steps"))); + } + + // Single shape: trust the absolute traj_path the runner wrote. + let traj_path_str = result + .and_then(|r| r.get("traj_path")) + .and_then(|v| v.as_str()) + .ok_or_else(|| not_found("artifact has no traj_path or runs in result".to_string()))?; + let traj_path = PathBuf::from(traj_path_str); + let steps_dir = traj_path + .parent() + .ok_or_else(|| not_found("invalid traj_path: no parent dir".to_string()))? + .join("steps"); + Ok((traj_path, steps_dir)) +} + /// Return parsed traj.jsonl + frame count for an eval_result artifact that -/// recorded a GUI rollout. The artifact's metadata.result must contain -/// `traj_path` (absolute path to traj.jsonl) written by the runner. +/// recorded a GUI rollout. Supports both single- and multi-instruction +/// shapes; `?task=N` selects an instruction in the multi shape (see +/// [`resolve_rollout_paths`]). async fn get_artifact_rollout( State(state): State, Path(id): Path, + Query(q): Query, ) -> Result, ApiError> { let artifact = { let pg = &state.pg; @@ -1574,19 +1716,7 @@ async fn get_artifact_rollout( .ok_or_else(|| not_found(format!("artifact not found: {id}")))? }; - let traj_path_str = artifact - .metadata_json - .get("result") - .and_then(|r| r.get("traj_path")) - .and_then(|v| v.as_str()) - .ok_or_else(|| not_found("artifact has no traj_path in result".to_string()))? - .to_string(); - - let traj_path = PathBuf::from(&traj_path_str); - let steps_dir = traj_path - .parent() - .ok_or_else(|| not_found("invalid traj_path: no parent dir".to_string()))? - .join("steps"); + let (traj_path, steps_dir) = resolve_rollout_paths(&artifact, q.task)?; // The traj.jsonl + steps/ dir live on NFS; both reads can stall. // One spawn_blocking covers both so the handler never blocks the @@ -1627,10 +1757,12 @@ async fn get_artifact_rollout( } /// Serve step_NNN.png for a GUI rollout artifact. Frame index N is -/// zero-based and must match a file written by the runner. +/// zero-based and must match a file written by the runner. `?task=N` +/// selects an instruction in the multi shape (see [`resolve_rollout_paths`]). async fn get_artifact_frame( State(state): State, Path((id, n)): Path<(String, u32)>, + Query(q): Query, ) -> Result { let artifact = { let pg = &state.pg; @@ -1639,18 +1771,7 @@ async fn get_artifact_frame( .ok_or_else(|| not_found(format!("artifact not found: {id}")))? }; - let traj_path_str = artifact - .metadata_json - .get("result") - .and_then(|r| r.get("traj_path")) - .and_then(|v| v.as_str()) - .ok_or_else(|| not_found("artifact has no traj_path in result".to_string()))? - .to_string(); - - let steps_dir = std::path::Path::new(&traj_path_str) - .parent() - .ok_or_else(|| not_found("invalid traj_path: no parent dir".to_string()))? - .join("steps"); + let (_traj_path, steps_dir) = resolve_rollout_paths(&artifact, q.task)?; let frame_path = steps_dir.join(format!("step_{n:03}.png")); diff --git a/src/store.rs b/src/store.rs index 938a090..70d65d6 100644 --- a/src/store.rs +++ b/src/store.rs @@ -104,10 +104,11 @@ pub struct InputResolution { /// One enriched row from the eval-series query: an `eval_requests` row /// joined with its checkpoint artifact's metadata (`checkpoint_metadata`, /// source of the `step` field) and with the first `eval_result` -/// artifact's metadata produced by the eval run (`eval_result_metadata`, -/// source of the headline metric). Both metadata fields are `Option` -/// because the checkpoint may have been GC'd or the eval run may not -/// have produced an `eval_result` artifact yet. +/// artifact's id + metadata produced by the eval run +/// (`eval_result_artifact_id` / `eval_result_metadata`, source of the +/// headline metric and the rollout the browser steps through). The +/// metadata/id fields are `Option` because the checkpoint may have been +/// GC'd or the eval run may not have produced an `eval_result` artifact yet. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EvalSeriesRow { pub eval_key: String, @@ -117,6 +118,7 @@ pub struct EvalSeriesRow { pub eval_run_id: Option, pub state: String, pub checkpoint_metadata: Option, + pub eval_result_artifact_id: Option, pub eval_result_metadata: Option, } diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 97305b8..52a7c87 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -18,6 +18,7 @@ import RecipeView from "./views/RecipeView.svelte"; import CompareView from "./views/CompareView.svelte"; import ColophonView from "./views/ColophonView.svelte"; + import RolloutBrowserView from "./views/RolloutBrowserView.svelte"; let cluster = $derived(store.cluster); @@ -80,7 +81,7 @@
- {#if router.view !== "lineage" && router.view !== "recipes" && router.view !== "compare" && router.view !== "colophon" && !(router.view === "policies" && router.selected)} + {#if router.view !== "lineage" && router.view !== "recipes" && router.view !== "compare" && router.view !== "colophon" && router.view !== "rollouts" && !(router.view === "policies" && router.selected)} {/if}
@@ -121,6 +122,10 @@
+ {:else if router.view === "rollouts" && router.selected} +
+ +
{/if}
diff --git a/ui/src/components/EvalSeriesCard.svelte b/ui/src/components/EvalSeriesCard.svelte index 4f44ba9..bddf347 100644 --- a/ui/src/components/EvalSeriesCard.svelte +++ b/ui/src/components/EvalSeriesCard.svelte @@ -11,10 +11,30 @@ interface Props { series: EvalSeries; + /** Parent training run id — anchors the rollout browser. */ + runId: string; } - let { series }: Props = $props(); + let { series, runId }: Props = $props(); let expanded = $state(false); + // Checkpoints whose eval recorded a browsable rollout (single or multi). + let hasRollouts = $derived(series.points.some((p) => p.has_rollout)); + + function pointIndex(p: EvalSeriesPoint): number { + return series.points.findIndex( + (q) => q.checkpoint_artifact_id === p.checkpoint_artifact_id && q.step === p.step, + ); + } + + // Open the full-page rollout browser for this run+policy, positioned at + // `index` (into series.points). Falls back to the first rollout-bearing + // checkpoint when no index is given. + function openBrowser(index?: number) { + const i = index ?? series.points.findIndex((p) => p.has_rollout); + const q = new URLSearchParams({ policy: series.policy_id, i: String(Math.max(0, i)) }); + router.go("rollouts", runId, q); + } + let delta = $derived.by(() => { if (series.latest_value == null || series.previous_value == null) return null; return series.latest_value - series.previous_value; @@ -43,8 +63,12 @@ return String(s); } + // Clicking a chart point: jump straight into the rollout browser at that + // checkpoint when it has a rollout; otherwise fall back to the eval run's + // own panel (e.g. to read logs of a pending/failed eval). function onPointClick(_seriesId: string, p: EvalSeriesPoint) { - if (p.eval_run_id) router.go("runs", p.eval_run_id); + if (p.has_rollout) openBrowser(pointIndex(p)); + else if (p.eval_run_id) router.go("runs", p.eval_run_id); } let chartSeries = $derived([ { id: series.policy_id, points: series.points }, @@ -84,6 +108,12 @@
+ {#if hasRollouts} + + {/if} + {:else}
@@ -192,6 +223,22 @@ padding: 0 4px; } + .browse { + align-self: flex-start; + background: transparent; + border: 1px solid theme("colors.line.1"); + border-radius: 5px; + padding: 4px 10px; + color: theme("colors.accent.DEFAULT"); + font-size: 11px; + font-family: theme("fontFamily.mono"); + cursor: pointer; + } + .browse:hover { + background: theme("colors.accent.soft"); + border-color: theme("colors.accent.DEFAULT"); + } + .expand { background: transparent; border: none; diff --git a/ui/src/components/Result.svelte b/ui/src/components/Result.svelte index f753170..eaa628f 100644 --- a/ui/src/components/Result.svelte +++ b/ui/src/components/Result.svelte @@ -4,36 +4,61 @@ // `lib/metrics.ts`. Anything that doesn't match a metric pattern falls // through to the JSON tree. // - // If the result contains `traj_path` or `gif_path` (written by - // osworld_one_task_runner) and an artifactId is supplied, a frame-by-frame - // RolloutViewer is rendered above the metric table. + // A GUI rollout is rendered above the metric table when an artifactId is + // supplied and the result blob matches one of two shapes: + // * single — top-level `traj_path`/`gif_path` (legacy single-instruction) + // * multi — a `runs[]` array, one entry per instruction (each with + // index/slug/instruction/subdir). The viewer then shows a task picker. import ResultTable from "./ResultTable.svelte"; import JsonTree from "./JsonTree.svelte"; import RolloutViewer from "./RolloutViewer.svelte"; import { extractMetrics } from "../lib/metrics"; + import type { RolloutTask } from "../lib/types"; interface Props { /** The full `metadata.result` blob from an eval_result artifact. */ result: unknown; /** Artifact id — required to fetch rollout frames via the API. */ artifactId?: string; + /** Optional controlled task selection for the rollout viewer, passed + * straight through (used by the rollout browser to keep the instruction + * selected across checkpoint switches). */ + task?: number; + onTaskChange?: (index: number) => void; } - let { result, artifactId }: Props = $props(); + let { result, artifactId, task, onTaskChange }: Props = $props(); let metrics = $derived(extractMetrics(result)); - /** True when the result blob looks like a GUI rollout (has traj_path or - * gif_path fields). We only render the viewer when we also have an id. */ - let isRollout = $derived.by(() => { - if (!artifactId || typeof result !== "object" || result === null) return false; + /** Multi-instruction tasks parsed from `result.runs[]`, or null for the + * single-rollout (legacy) shape. */ + let rolloutTasks = $derived.by(() => { + if (typeof result !== "object" || result === null) return null; + const runs = (result as Record).runs; + if (!Array.isArray(runs) || runs.length === 0) return null; + return runs.map((r, i) => { + const o = (r ?? {}) as Record; + return { + index: typeof o.index === "number" ? o.index : i, + slug: typeof o.slug === "string" ? o.slug : `task_${i}`, + instruction: typeof o.instruction === "string" ? o.instruction : "", + }; + }); + }); + + /** True when the result blob is a single (legacy) GUI rollout. */ + let isSingleRollout = $derived.by(() => { + if (typeof result !== "object" || result === null) return false; const r = result as Record; return typeof r.traj_path === "string" || typeof r.gif_path === "string"; }); + + let isRollout = $derived(!!artifactId && (isSingleRollout || !!rolloutTasks)); {#if isRollout && artifactId} - + {/if} {#if metrics} diff --git a/ui/src/components/RolloutViewer.svelte b/ui/src/components/RolloutViewer.svelte index 0a2fbaf..7752ed2 100644 --- a/ui/src/components/RolloutViewer.svelte +++ b/ui/src/components/RolloutViewer.svelte @@ -1,11 +1,19 @@ + +
+ 1 ? undefined : series?.policy_id} + backLabel="run" + onBack={back} + > + {#snippet actions()} + {#if rolloutSeries.length > 1} + + {/if} + {#if rolloutIdxs.length} +
+ + + {#if currentPoint}step {fmtStep(currentPoint.step)}{/if} + {#if rolloutPos >= 0} · {rolloutPos + 1}/{rolloutIdxs.length}{/if} + + +
+ {/if} + {/snippet} +
+ + {#if error} +

Failed to load: {error}

+ {:else if !data} +
+ {:else if !series || rolloutIdxs.length === 0} + + {#snippet sub()} + None of this run's checkpoint evals recorded a GUI rollout yet. They may + still be pending, or this policy only reports metrics. + {/snippet} + + {:else} +
+ + + + +
+ {#if !currentArtifactId} +

This checkpoint has no rollout. Pick another from the rail.

+ {:else if !artifactDetail} +
+ {:else if result} + + (taskIdx = i)} + /> + {:else} +

No result payload for this eval.

+ {/if} +
+
+ {/if} +
+ + diff --git a/ui/src/views/RunPanel.svelte b/ui/src/views/RunPanel.svelte index c62b4bd..1828761 100644 --- a/ui/src/views/RunPanel.svelte +++ b/ui/src/views/RunPanel.svelte @@ -196,6 +196,18 @@ · {r.repo}@{r.recipe_hash.slice(0, 7)}
+ + {#if detail.outputs.some((o) => o.kind === "checkpoint") || detail.eval_series.some((s) => s.points.some((p) => p.has_rollout))} + + {/if} + clean Inter sans; meta below in 11px mono. -->