|
| 1 | +// V7-F53 dimension-scaling sweep panel. Runs the same preset four |
| 2 | +// times with H ∈ {64, 128, 256, 512}, captures losses per H, and |
| 3 | +// renders a multi-line LossChart overlay so the architect can see at |
| 4 | +// a glance how loss scales with hidden size. |
| 5 | + |
| 6 | +import { useState } from "react"; |
| 7 | +import { LossChart, type LossSeries } from "@/components/LossChart"; |
| 8 | +import { HelpIcon } from "@/components/HelpIcon"; |
| 9 | + |
| 10 | +export interface SweepRunner { |
| 11 | + // Runs a 2-step train at the given H and returns the per-step loss |
| 12 | + // trajectory. Caller is responsible for spinning up a real RPC |
| 13 | + // pipeline; SweepPanel is unit-testable with a fake runner. |
| 14 | + (H: number): Promise<number[]>; |
| 15 | +} |
| 16 | + |
| 17 | +export interface SweepPanelProps { |
| 18 | + runner: SweepRunner; |
| 19 | + hSizes?: readonly number[]; |
| 20 | +} |
| 21 | + |
| 22 | +const DEFAULT_H = [64, 128, 256, 512] as const; |
| 23 | + |
| 24 | +export function SweepPanel({ |
| 25 | + runner, hSizes = DEFAULT_H, |
| 26 | +}: SweepPanelProps): JSX.Element { |
| 27 | + const [results, setResults] = useState<Map<number, number[]>>(new Map()); |
| 28 | + const [running, setRunning] = useState<number | null>(null); |
| 29 | + const [error, setError] = useState<string | null>(null); |
| 30 | + |
| 31 | + async function runSweep() { |
| 32 | + setError(null); |
| 33 | + setResults(new Map()); |
| 34 | + for (const H of hSizes) { |
| 35 | + setRunning(H); |
| 36 | + try { |
| 37 | + const losses = await runner(H); |
| 38 | + setResults((prev) => new Map(prev).set(H, losses)); |
| 39 | + } catch (e) { |
| 40 | + setError(`H=${H} failed: ${(e as Error).message}`); |
| 41 | + break; |
| 42 | + } |
| 43 | + } |
| 44 | + setRunning(null); |
| 45 | + } |
| 46 | + |
| 47 | + const series: LossSeries[] = hSizes |
| 48 | + .filter((H) => results.has(H)) |
| 49 | + .map((H) => ({ |
| 50 | + label: `H${H}`, |
| 51 | + values: results.get(H) ?? [], |
| 52 | + })); |
| 53 | + |
| 54 | + return ( |
| 55 | + <div data-testid="sweep-panel" |
| 56 | + style={{ padding: 12, fontFamily: "system-ui, sans-serif", |
| 57 | + fontSize: 12, display: "flex", flexDirection: "column", |
| 58 | + gap: 8 }}> |
| 59 | + <div style={{ display: "flex", alignItems: "center", gap: 8 }}> |
| 60 | + <h3 style={{ margin: 0, fontSize: 14 }}> |
| 61 | + Mini → full dimension scaling sweep |
| 62 | + </h3> |
| 63 | + <HelpIcon topic="dim_env_H" /> |
| 64 | + <button data-testid="scaling-sweep-run" |
| 65 | + onClick={runSweep} |
| 66 | + disabled={running !== null} |
| 67 | + style={{ padding: "4px 10px", |
| 68 | + background: running !== null ? "#e5e7eb" : "#2563eb", |
| 69 | + color: running !== null ? "#6b7280" : "white", |
| 70 | + border: "none", borderRadius: 4, |
| 71 | + cursor: running !== null ? "wait" : "pointer" }}> |
| 72 | + {running !== null |
| 73 | + ? `Running H=${running}…` |
| 74 | + : `Run sweep ${hSizes.join(", ")}`} |
| 75 | + </button> |
| 76 | + {running !== null && ( |
| 77 | + <span data-testid="sweep-progress" |
| 78 | + style={{ color: "#6b7280" }}> |
| 79 | + {results.size}/{hSizes.length} complete |
| 80 | + </span> |
| 81 | + )} |
| 82 | + </div> |
| 83 | + {error && ( |
| 84 | + <div data-testid="sweep-error" |
| 85 | + style={{ color: "#991b1b", background: "#fee2e2", |
| 86 | + padding: 8, borderRadius: 4 }}> |
| 87 | + {error} |
| 88 | + </div> |
| 89 | + )} |
| 90 | + {results.size > 0 && ( |
| 91 | + <LossChart |
| 92 | + losses={[]} |
| 93 | + series={series} |
| 94 | + width={520} height={200} |
| 95 | + testidPrefix="sweep-chart" |
| 96 | + /> |
| 97 | + )} |
| 98 | + </div> |
| 99 | + ); |
| 100 | +} |
0 commit comments