diff --git a/vein/web/src/app.tsx b/vein/web/src/app.tsx index c970b5f31..603478127 100644 --- a/vein/web/src/app.tsx +++ b/vein/web/src/app.tsx @@ -57,10 +57,15 @@ function countIterations(events: api.RunEvent[], prefix: string): number { } export function App() { + // Deep-links: ?wf=&run=&v= select on load and are + // kept in sync (replaceState) as the selection changes, so the address bar + // is always a shareable link to what's on screen. (?chat is read below and + // preserved by the sync.) + const [initialUrl] = useState(() => new URLSearchParams(location.search)); const [workflows, setWorkflows] = useState([]); const [runs, setRuns] = useState([]); - const [selectedWf, setSelectedWf] = useState(null); - const [selectedRun, setSelectedRun] = useState(null); + const [selectedWf, setSelectedWf] = useState(initialUrl.get("wf")); + const [selectedRun, setSelectedRun] = useState(initialUrl.get("run")); const [events, setEvents] = useState([]); const [running, setRunning] = useState(false); // Bumped after a durable resume so the run-view effect re-tails the log @@ -124,7 +129,10 @@ export function App() { // Version picker: which version of the selected workflow the canvas shows. // Stored with the workflow it was pinned for, so the pin self-invalidates // when the selection changes (no effect-ordering games). null = active. - const [versionPin, setVersionPin] = useState<{ wf: string | null; v: string | null }>({ wf: null, v: null }); + const [versionPin, setVersionPin] = useState<{ wf: string | null; v: string | null }>(() => ({ + wf: initialUrl.get("v") ? initialUrl.get("wf") : null, + v: initialUrl.get("v"), + })); const viewVersion = versionPin.wf === selectedWf ? versionPin.v : null; const setViewVersion = useCallback( (v: string | null) => setVersionPin({ wf: selectedWf, v }), @@ -134,6 +142,22 @@ export function App() { // against the active version (Publish always builds on active, Run runs it). const viewingOld = viewVersion != null && activeVersion != null && viewVersion !== activeVersion; + // Mirror the selection into the address bar (replaceState — no history + // spam) so the current view is always copy-paste shareable. Unrelated + // params (e.g. ?chat) are preserved. + useEffect(() => { + const p = new URLSearchParams(location.search); + const put = (k: string, val: string | null) => (val ? p.set(k, val) : p.delete(k)); + put("wf", selectedWf); + put("run", selectedRun); + put("v", viewVersion); + const qs = p.toString(); + const next = `${location.pathname}${qs ? `?${qs}` : ""}${location.hash}`; + if (next !== `${location.pathname}${location.search}${location.hash}`) { + history.replaceState(null, "", next); + } + }, [selectedWf, selectedRun, viewVersion]); + // ── Sidebar grouping ───────────────────────────────────────────────────── // Workflows grouped by category; groups (and workflows within them) are // ordered by most-recent run so the active experiment floats to the top. diff --git a/vein/web/src/components/EventsPanel.tsx b/vein/web/src/components/EventsPanel.tsx index 96ab13b06..1bcca640c 100644 --- a/vein/web/src/components/EventsPanel.tsx +++ b/vein/web/src/components/EventsPanel.tsx @@ -4,6 +4,7 @@ import { eventTone, statusTone } from "../helpers"; import { ValueFields } from "./ValueFields"; import { StepData } from "../flow-to-canvas"; import { CloseIcon } from "../icons"; +import { EvolveChart } from "./EvolveChart"; import yaml from "js-yaml"; // ── Events Panel (expandable rows) ───────────────────────────────────────── @@ -62,6 +63,7 @@ export function EventsPanel(props: { return (
Events ({props.events.length})
+ {props.events.map((evt, i) => { const hasData = evt.input != null || evt.output != null || evt.error != null; const isOpen = expanded === i; diff --git a/vein/web/src/components/EvolveChart.tsx b/vein/web/src/components/EvolveChart.tsx new file mode 100644 index 000000000..cb2b7e082 --- /dev/null +++ b/vein/web/src/components/EvolveChart.tsx @@ -0,0 +1,241 @@ +import * as api from "../api"; + +// ── Evolve hill-climb chart ──────────────────────────────────────────────── +// +// Renders the fitness trajectory of an eval/evolve-loop step from the run's +// event stream alone — no API beyond the events the panel already has. The +// loop emits synthetic per-generation events at `#` whose +// step.end/step.replayed output carries { gen, fitness, bestFitness, bestGen, +// directive, version, knownCost, runs } (see eval/evolve-loop's emitGen), so +// the chart derives everything live: a dashed baseline, the best-so-far +// staircase, and one dot per generation (filled = new best, amber stroke = +// explore directive, ✕ = failed generation, pulsing = still running). +// Clicking a dot opens that generation's run via the same run-ref plumbing +// the event rows use. + +interface GenPoint { + gen: number; + status: "done" | "replayed" | "error" | "pending"; + fitness?: number; + bestFitness?: number; + bestGen?: number; + directive?: string; + version?: string; + knownCost?: number; + run?: { workflow: string; runId: string }; + error?: string; +} + +interface EvolveSeries { + baseline?: number; + points: GenPoint[]; +} + +/** Collect the evolve-loop's per-generation events into an ordered series. + * Returns null when the run has no evolve-loop generation events at all. */ +export function evolveSeries(events: api.RunEvent[]): EvolveSeries | null { + const byGen = new Map(); + let baseline: number | undefined; + // Multiple evolve loops in one run is theoretical; pin to the first path + // prefix seen so a second loop can't interleave garbage into the chart. + let prefix: string | undefined; + + for (const e of events) { + if (e.stepType !== "eval/evolve-loop") continue; + const hi = e.path.lastIndexOf("#"); + if (hi < 0) continue; + const p = e.path.slice(0, hi); + if (prefix === undefined) prefix = p; + else if (p !== prefix) continue; + const gen = Number(e.path.slice(hi + 1)); + if (!Number.isInteger(gen) || gen < 0) continue; + + const cur: GenPoint = byGen.get(gen) ?? { gen, status: "pending" }; + if (e.type === "step.start") { + const inp = e.input as { directive?: string; bestFitness?: number } | undefined; + if (typeof inp?.directive === "string") cur.directive = inp.directive; + // Gen 0's start is briefed with bestFitness === the baseline fitness. + if (gen === 0 && typeof inp?.bestFitness === "number") baseline = inp.bestFitness; + } else if (e.type === "step.end" || e.type === "step.replayed") { + const o = (e.output ?? {}) as Record; + cur.status = e.type === "step.end" ? "done" : "replayed"; + if (typeof o.fitness === "number") cur.fitness = o.fitness; + if (typeof o.bestFitness === "number") cur.bestFitness = o.bestFitness; + if (typeof o.bestGen === "number") cur.bestGen = o.bestGen; + if (typeof o.directive === "string") cur.directive = o.directive; + if (typeof o.version === "string") cur.version = o.version; + if (typeof o.knownCost === "number") cur.knownCost = o.knownCost; + const r = Array.isArray(o.runs) ? (o.runs[0] as Record | undefined) : undefined; + if (r && typeof r.workflow === "string" && typeof r.runId === "string") { + cur.run = { workflow: r.workflow, runId: r.runId }; + } + // A replayed gen 0 that had not yet beaten the baseline still tells us + // the baseline (its bestFitness anchor is the baseline itself). + if (gen === 0 && baseline === undefined && cur.bestGen === -1 && typeof cur.bestFitness === "number") { + baseline = cur.bestFitness; + } + } else if (e.type === "step.error") { + cur.status = "error"; + cur.error = e.error?.message; + cur.fitness = 0; + } + byGen.set(gen, cur); + } + + if (byGen.size === 0) return null; + const points = [...byGen.values()].sort((a, b) => a.gen - b.gen); + return { baseline, points }; +} + +const PAD_L = 34; // room for y labels +const PAD_R = 14; +const PAD_T = 12; +const PAD_B = 20; // room for gen labels +const X_STEP = 56; +const PLOT_H = 96; + +export function EvolveChart(props: { + events: api.RunEvent[]; + onOpenRun?: (workflow: string, runId: string) => void; +}) { + const series = evolveSeries(props.events); + if (!series) return null; + const { baseline, points } = series; + + const H = PAD_T + PLOT_H + PAD_B; + // x slot 0 is the baseline anchor; generations start at slot 1. + const slots = points.length + 1; + const W = PAD_L + (slots - 1) * X_STEP + PAD_R; + const x = (slot: number) => PAD_L + slot * X_STEP; + const y = (fitness: number) => PAD_T + (1 - Math.max(0, Math.min(1, fitness))) * PLOT_H; + + // Best-so-far staircase, anchored at the baseline. Each finished gen's + // output.bestFitness is the loop's own computation — no re-deriving. + let prevBest = baseline; + const stair: string[] = []; + if (baseline !== undefined) stair.push(`M ${x(0)} ${y(baseline)}`); + points.forEach((p, i) => { + const b = p.bestFitness ?? prevBest; + if (b === undefined) return; + const xi = x(i + 1); + if (stair.length === 0) stair.push(`M ${xi} ${y(b)}`); + else { + if (prevBest !== undefined) stair.push(`L ${xi} ${y(prevBest)}`); + stair.push(`L ${xi} ${y(b)}`); + } + prevBest = b; + }); + + const bestPoint = points.reduce( + (acc, p) => (p.fitness !== undefined && (!acc || p.fitness > (acc.fitness ?? 0)) ? p : acc), + null, + ); + const best = prevBest; + const gridLines = [0, 0.25, 0.5, 0.75, 1]; + + return ( +
+
+ hill-climb + {baseline !== undefined && baseline {fmt(baseline)}} + {best !== undefined && ( + + best {fmt(best)} + {bestPoint && bestPoint.bestGen !== undefined && bestPoint.bestGen >= 0 ? ` (gen ${bestPoint.bestGen})` : ""} + + )} +
+ + {gridLines.map((g) => ( + + + + {g === 0 || g === 1 || g === 0.5 ? g : ""} + + + ))} + + {baseline !== undefined && ( + + )} + {stair.length > 1 && } + + {/* baseline anchor */} + {baseline !== undefined && ( + + + base + + )} + + {points.map((p, i) => { + const xi = x(i + 1); + const label = `gen ${p.gen}`; + const clickable = p.run && props.onOpenRun; + const open = () => clickable && props.onOpenRun!(p.run!.workflow, p.run!.runId); + const tip = [ + label, + p.version ? `version ${p.version}` : null, + p.fitness !== undefined ? `fitness ${fmt(p.fitness)}` : null, + best !== undefined && p.fitness !== undefined + ? `Δ best ${fmt(p.fitness - (p.bestGen === p.gen ? (points[i - 1]?.bestFitness ?? baseline ?? 0) : (p.bestFitness ?? 0)))}` + : null, + p.directive ? `directive: ${p.directive}` : null, + p.knownCost !== undefined ? `cost $${p.knownCost}` : null, + p.status === "error" ? `FAILED: ${p.error ?? "unknown"}` : null, + p.status === "pending" ? "running…" : null, + clickable ? "click to open run" : null, + ] + .filter(Boolean) + .join("\n"); + + if (p.status === "error") { + return ( + + {tip} + {p.gen} + + ); + } + if (p.status === "pending") { + const yy = y(prevBestBefore(points, i, baseline)); + return ( + + {tip} + {p.gen} + + ); + } + const improved = p.bestGen === p.gen; + const explore = p.directive === "explore"; + return ( + + + {tip} + + {p.gen} + + ); + })} + +
+ ); +} + +/** Best fitness known just BEFORE generation i (where a pending dot hovers). */ +function prevBestBefore(points: GenPoint[], i: number, baseline?: number): number { + for (let j = i - 1; j >= 0; j--) { + const b = points[j]?.bestFitness; + if (typeof b === "number") return b; + } + return baseline ?? 0.5; +} + +function fmt(n: number): string { + return String(Math.round(n * 1000) / 1000); +} diff --git a/vein/web/src/styles/components.css b/vein/web/src/styles/components.css index 267f24332..3c6e25b6d 100644 --- a/vein/web/src/styles/components.css +++ b/vein/web/src/styles/components.css @@ -1703,3 +1703,76 @@ body.is-resizing-flyout * { font-size: 11px; } + +/* ─── evolve hill-climb chart ────────────────────────────────── */ + +.evolve-chart { + margin: var(--sp-2) 0 var(--sp-3); + padding: var(--sp-2) var(--sp-3); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow-x: auto; +} +.evolve-chart-header { + display: flex; + align-items: baseline; + gap: var(--sp-3); + margin-bottom: var(--sp-1); + font-size: 11px; +} +.evolve-chart-title { + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.evolve-chart-stat { + color: var(--text-dim); + font-family: var(--font-mono); +} +.evolve-chart-best { color: var(--ok); } +.evolve-chart-svg { display: block; } +.evolve-grid { stroke: var(--border); stroke-width: 1; } +.evolve-ylabel, .evolve-xlabel { + fill: var(--text-dim); + font-size: 9px; + font-family: var(--font-mono); + text-anchor: middle; +} +.evolve-ylabel { text-anchor: end; } +.evolve-baseline { + stroke: var(--text-dim); + stroke-width: 1; + stroke-dasharray: 4 3; + opacity: 0.7; +} +.evolve-stair { + fill: none; + stroke: var(--ok); + stroke-width: 1.5; + opacity: 0.85; +} +.evolve-dot-base { fill: var(--text-dim); } +.evolve-dot { stroke-width: 1.5; } +.evolve-dot-best { fill: var(--ok); stroke: var(--ok); } +.evolve-dot-miss { fill: var(--surface); stroke: var(--accent); } +.evolve-dot-explore { stroke: var(--warning); } +.evolve-dot-pending { + fill: none; + stroke: var(--accent); + stroke-width: 1.5; + animation: evolve-pulse 1.4s ease-in-out infinite; +} +.evolve-err { + fill: var(--danger); + font-size: 11px; + text-anchor: middle; + font-weight: 700; +} +.evolve-pt-link { cursor: pointer; } +.evolve-pt-link:hover .evolve-dot { stroke-width: 2.5; } +@keyframes evolve-pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } +}