|
| 1 | +// V7-H33: LossSurfaceModal — N×M heatmap of (lr_mult × wd_mult) |
| 2 | +// triggered by RunResultModal "Explore loss surface" button. Calls |
| 3 | +// loss_surface.run RPC, renders the best cell + Apply-best button that |
| 4 | +// commits the chosen lr/wd multipliers back to the optim spec. |
| 5 | + |
| 6 | +import { useState } from "react"; |
| 7 | +import type { RpcClient } from "@/lib/rpc"; |
| 8 | +import { HelpIcon } from "@/components/HelpIcon"; |
| 9 | + |
| 10 | +export interface LossSurfaceCell { |
| 11 | + lr_mult: number; |
| 12 | + wd_mult: number; |
| 13 | + status: string; |
| 14 | + final_loss?: number | null; |
| 15 | + throughput_tok_s?: number | null; |
| 16 | + mem_mb?: number | null; |
| 17 | + elapsed_ms?: number; |
| 18 | +} |
| 19 | + |
| 20 | +export interface LossSurfaceResult { |
| 21 | + rows: LossSurfaceCell[][]; |
| 22 | + lr_deltas: number[]; |
| 23 | + wd_deltas: number[]; |
| 24 | + best_lr_mult: number | null; |
| 25 | + best_wd_mult: number | null; |
| 26 | + best_loss: number | null; |
| 27 | +} |
| 28 | + |
| 29 | +export interface LossSurfaceModalProps { |
| 30 | + rpc: RpcClient | null; |
| 31 | + spec: unknown; |
| 32 | + open: boolean; |
| 33 | + onClose: () => void; |
| 34 | + onApplyBest: (lrMult: number, wdMult: number) => void; |
| 35 | +} |
| 36 | + |
| 37 | +const DEFAULT_LR_DELTAS = [0.5, 1.0, 2.0]; |
| 38 | +const DEFAULT_WD_DELTAS = [0.5, 1.0, 2.0]; |
| 39 | + |
| 40 | +function cellColor(loss: number | null | undefined, |
| 41 | + min: number | null, max: number | null): string { |
| 42 | + if (loss == null || min == null || max == null || max <= min) { |
| 43 | + return "#f3f4f6"; |
| 44 | + } |
| 45 | + const t = (loss - min) / (max - min); |
| 46 | + const r = Math.round(40 + 215 * t); |
| 47 | + const g = Math.round(180 - 120 * t); |
| 48 | + return `rgb(${r}, ${g}, 60)`; |
| 49 | +} |
| 50 | + |
| 51 | +export function LossSurfaceModal({ |
| 52 | + rpc, spec, open, onClose, onApplyBest, |
| 53 | +}: LossSurfaceModalProps): JSX.Element | null { |
| 54 | + const [running, setRunning] = useState(false); |
| 55 | + const [result, setResult] = useState<LossSurfaceResult | null>(null); |
| 56 | + const [error, setError] = useState<string | null>(null); |
| 57 | + const [kSteps, setKSteps] = useState<number>(2); |
| 58 | + |
| 59 | + if (!open) return null; |
| 60 | + |
| 61 | + async function run() { |
| 62 | + setRunning(true); |
| 63 | + setError(null); |
| 64 | + setResult(null); |
| 65 | + if (!rpc) { |
| 66 | + setError("no backend connection"); |
| 67 | + setRunning(false); |
| 68 | + return; |
| 69 | + } |
| 70 | + try { |
| 71 | + const r = await rpc.call<LossSurfaceResult>("loss_surface.run", { |
| 72 | + spec, |
| 73 | + lr_deltas: DEFAULT_LR_DELTAS, |
| 74 | + wd_deltas: DEFAULT_WD_DELTAS, |
| 75 | + k_steps: kSteps, |
| 76 | + }); |
| 77 | + setResult(r); |
| 78 | + } catch (e) { |
| 79 | + setError(e instanceof Error ? e.message : String(e)); |
| 80 | + } finally { |
| 81 | + setRunning(false); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + const allLosses = result?.rows.flat() |
| 86 | + .map((c) => c.final_loss) |
| 87 | + .filter((v): v is number => v != null) ?? []; |
| 88 | + const minLoss = allLosses.length ? Math.min(...allLosses) : null; |
| 89 | + const maxLoss = allLosses.length ? Math.max(...allLosses) : null; |
| 90 | + |
| 91 | + return ( |
| 92 | + <div data-testid="loss-surface-modal" |
| 93 | + role="dialog" aria-modal="true" |
| 94 | + style={{ position: "fixed", inset: 0, zIndex: 1000, |
| 95 | + background: "rgba(0,0,0,0.45)", display: "flex", |
| 96 | + alignItems: "center", justifyContent: "center", |
| 97 | + padding: 24, fontFamily: "system-ui, sans-serif" }}> |
| 98 | + <div style={{ background: "white", borderRadius: 8, padding: 18, |
| 99 | + minWidth: 460, maxWidth: 720, maxHeight: "85vh", |
| 100 | + overflow: "auto" }}> |
| 101 | + <div style={{ display: "flex", justifyContent: "space-between", |
| 102 | + alignItems: "center", marginBottom: 8 }}> |
| 103 | + <h3 data-testid="loss-surface-title" |
| 104 | + style={{ margin: 0, fontSize: 16 }}> |
| 105 | + Loss surface · lr_mult × wd_mult |
| 106 | + <HelpIcon topic="loss_surface_explorer" /> |
| 107 | + </h3> |
| 108 | + <button data-testid="loss-surface-close" |
| 109 | + onClick={onClose}>×</button> |
| 110 | + </div> |
| 111 | + |
| 112 | + <div style={{ fontSize: 12, marginBottom: 8 }}> |
| 113 | + <label style={{ marginRight: 8 }}>k_steps:</label> |
| 114 | + <input data-testid="loss-surface-k-steps" |
| 115 | + type="number" min={1} max={64} value={kSteps} |
| 116 | + onChange={(e) => |
| 117 | + setKSteps(Math.max(1, Math.min(64, |
| 118 | + Number(e.target.value) || 2)))} |
| 119 | + style={{ width: 60 }} /> |
| 120 | + <button data-testid="loss-surface-run" |
| 121 | + onClick={() => void run()} |
| 122 | + disabled={running} |
| 123 | + style={{ marginLeft: 10 }}> |
| 124 | + {running ? "Running…" : "Run sweep"} |
| 125 | + </button> |
| 126 | + </div> |
| 127 | + |
| 128 | + {error && ( |
| 129 | + <div data-testid="loss-surface-error" |
| 130 | + style={{ color: "#b91c1c", fontSize: 12 }}> |
| 131 | + {error} |
| 132 | + </div> |
| 133 | + )} |
| 134 | + |
| 135 | + {result && ( |
| 136 | + <div data-testid="loss-surface-result"> |
| 137 | + <table style={{ borderCollapse: "collapse", |
| 138 | + margin: "8px 0" }}> |
| 139 | + <thead> |
| 140 | + <tr> |
| 141 | + <th style={{ padding: "4px 8px", fontSize: 11 }}> |
| 142 | + lr↓ / wd→ |
| 143 | + </th> |
| 144 | + {result.wd_deltas.map((wd) => ( |
| 145 | + <th key={wd} |
| 146 | + data-testid={`loss-surface-col-${wd}`} |
| 147 | + style={{ padding: "4px 8px", fontSize: 11 }}> |
| 148 | + ×{wd} |
| 149 | + </th> |
| 150 | + ))} |
| 151 | + </tr> |
| 152 | + </thead> |
| 153 | + <tbody> |
| 154 | + {result.rows.map((row, ri) => ( |
| 155 | + <tr key={ri}> |
| 156 | + <th data-testid={`loss-surface-row-${result.lr_deltas[ri]}`} |
| 157 | + style={{ padding: "4px 8px", fontSize: 11 }}> |
| 158 | + ×{result.lr_deltas[ri]} |
| 159 | + </th> |
| 160 | + {row.map((cell, ci) => { |
| 161 | + const isBest = result.best_lr_mult === cell.lr_mult |
| 162 | + && result.best_wd_mult === cell.wd_mult; |
| 163 | + return ( |
| 164 | + <td key={ci} |
| 165 | + data-testid={`loss-surface-cell-${ri}-${ci}`} |
| 166 | + style={{ |
| 167 | + padding: "10px 14px", |
| 168 | + background: cellColor(cell.final_loss, |
| 169 | + minLoss, maxLoss), |
| 170 | + color: "white", fontSize: 11, |
| 171 | + border: isBest |
| 172 | + ? "3px solid #facc15" : "1px solid #e5e7eb", |
| 173 | + textAlign: "center", |
| 174 | + minWidth: 56, |
| 175 | + }}> |
| 176 | + {cell.status === "ok" && cell.final_loss != null |
| 177 | + ? cell.final_loss.toFixed(3) |
| 178 | + : cell.status} |
| 179 | + </td> |
| 180 | + ); |
| 181 | + })} |
| 182 | + </tr> |
| 183 | + ))} |
| 184 | + </tbody> |
| 185 | + </table> |
| 186 | + |
| 187 | + <div data-testid="loss-surface-best" |
| 188 | + style={{ fontSize: 12, marginTop: 4 }}> |
| 189 | + {result.best_loss != null ? ( |
| 190 | + <> |
| 191 | + best: lr×{result.best_lr_mult}, wd×{result.best_wd_mult} |
| 192 | + {" → loss "}{result.best_loss.toFixed(4)} |
| 193 | + </> |
| 194 | + ) : ( |
| 195 | + "no successful cells" |
| 196 | + )} |
| 197 | + </div> |
| 198 | + |
| 199 | + {result.best_lr_mult != null && result.best_wd_mult != null && ( |
| 200 | + <button data-testid="loss-surface-apply-best" |
| 201 | + onClick={() => { |
| 202 | + onApplyBest(result.best_lr_mult!, |
| 203 | + result.best_wd_mult!); |
| 204 | + onClose(); |
| 205 | + }} |
| 206 | + style={{ marginTop: 8 }}> |
| 207 | + Apply best to optim |
| 208 | + </button> |
| 209 | + )} |
| 210 | + </div> |
| 211 | + )} |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + ); |
| 215 | +} |
0 commit comments