M3/M4: interactive chart, checkpoints/generate/eval panel, crash recovery, watchdog
- TrainingChartView: hover crosshair with step/train/EMA/ppl/val callout, pinch-zoom + horizontal pan (chartScrollableAxes), follow-live pill that disengages on manual pan and re-pins to the latest data, double-click / fit reset, log-Y, best-val rule — replaces the static chart - CheckpointsPanel on finished runs: checkpoint list (step/size), Generate (prompt/temp/top-k, streamed output) and Eval (loss/ppl) via forge CLI - Crash recovery: runs persisted as active at launch are truthfully marked stopped, with the live-PID case called out; no lying states - Watchdog: 'possibly stalled' badge when a running run's log.csv stops growing for 30s - New Run: import/export Forge-compatible JSON configs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 6 changed files with +475 and −59
added
ForgeStudio/Charts/TrainingChartView.swift
+226 −0
@@ -0,0 +1,226 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// | |
| 3 | +// The main loss chart: raw train (low opacity) + bias-corrected EMA + val | |
| 4 | +// points, log/linear Y, best-val rule, hover crosshair with a full callout | |
| 5 | +// (step, train, EMA, val, ppl, lr, tok/s), pinch-zoom + horizontal pan with | |
| 6 | +// a "follow live" pill when zoomed during a running train, double-click to | |
| 7 | +// reset. Data arrives pre-downsampled from MetricsStore. | |
| 8 | +import Charts | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +struct TrainingChartView: View { | |
| 12 | + let snapshot: MetricsStore.Snapshot? | |
| 13 | + let isLive: Bool | |
| 14 | + @Binding var smoothing: Double | |
| 15 | + @Binding var logScale: Bool | |
| 16 | + | |
| 17 | + @State private var visibleLength: Double? // nil = fit all | |
| 18 | + @State private var scrollX: Double = 0 | |
| 19 | + @State private var followLive = true | |
| 20 | + @State private var hoverX: Double? | |
| 21 | + @State private var magnifyBase: Double? | |
| 22 | + | |
| 23 | + private var maxX: Double { snapshot?.train.last?.x ?? 1 } | |
| 24 | + private var minX: Double { snapshot?.train.first?.x ?? 0 } | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + VStack(alignment: .leading, spacing: 8) { | |
| 28 | + controls | |
| 29 | + chart | |
| 30 | + .frame(minHeight: 320) | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + private var controls: some View { | |
| 35 | + HStack { | |
| 36 | + Text("Loss").font(.title3.weight(.semibold)) | |
| 37 | + if let h = hoverInfo { | |
| 38 | + calloutText(h) | |
| 39 | + } | |
| 40 | + Spacer() | |
| 41 | + if isLive && visibleLength != nil && !followLive { | |
| 42 | + Button { | |
| 43 | + followLive = true | |
| 44 | + } label: { | |
| 45 | + Label("suivre ⏵", systemImage: "forward.fill") | |
| 46 | + .font(.caption) | |
| 47 | + } | |
| 48 | + .buttonStyle(.borderedProminent) | |
| 49 | + .controlSize(.small) | |
| 50 | + } | |
| 51 | + Toggle("log Y", isOn: $logScale).toggleStyle(.checkbox) | |
| 52 | + Button("fit") { | |
| 53 | + visibleLength = nil | |
| 54 | + followLive = true | |
| 55 | + } | |
| 56 | + .controlSize(.small) | |
| 57 | + HStack(spacing: 4) { | |
| 58 | + Text("EMA") | |
| 59 | + Slider(value: $smoothing, in: 0...0.99).frame(width: 110) | |
| 60 | + Text(String(format: "%.2f", smoothing)).monospacedDigit() | |
| 61 | + } | |
| 62 | + .font(.caption) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + private var chart: some View { | |
| 67 | + Chart { | |
| 68 | + ForEach(snapshot?.train ?? [], id: \.x) { p in | |
| 69 | + LineMark(x: .value("step", p.x), y: .value("loss", p.y), | |
| 70 | + series: .value("s", "train")) | |
| 71 | + .foregroundStyle(.blue.opacity(0.22)) | |
| 72 | + } | |
| 73 | + ForEach(snapshot?.trainEMA ?? [], id: \.x) { p in | |
| 74 | + LineMark(x: .value("step", p.x), y: .value("ema", p.y), | |
| 75 | + series: .value("s", "train EMA")) | |
| 76 | + .foregroundStyle(.blue) | |
| 77 | + } | |
| 78 | + ForEach(snapshot?.val ?? [], id: \.x) { p in | |
| 79 | + LineMark(x: .value("step", p.x), y: .value("val", p.y), | |
| 80 | + series: .value("s", "val")) | |
| 81 | + .foregroundStyle(.orange) | |
| 82 | + PointMark(x: .value("step", p.x), y: .value("val", p.y)) | |
| 83 | + .foregroundStyle(.orange) | |
| 84 | + .symbolSize(24) | |
| 85 | + } | |
| 86 | + if let best = snapshot?.bestVal { | |
| 87 | + RuleMark(y: .value("best", best.loss)) | |
| 88 | + .foregroundStyle(.orange.opacity(0.4)) | |
| 89 | + .lineStyle(.init(lineWidth: 1, dash: [4, 4])) | |
| 90 | + .annotation(position: .topTrailing) { | |
| 91 | + Text(String(format: "best val %.3f @ %d", best.loss, best.step)) | |
| 92 | + .font(.caption2) | |
| 93 | + .foregroundStyle(.orange) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + if let x = hoverX { | |
| 97 | + RuleMark(x: .value("hover", x)) | |
| 98 | + .foregroundStyle(.secondary.opacity(0.5)) | |
| 99 | + .lineStyle(.init(lineWidth: 1)) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + .modifier(LogScaleModifier(enabled: logScale)) | |
| 103 | + .modifier(ScrollModifier(visibleLength: visibleLength, scrollX: $scrollX)) | |
| 104 | + .chartLegend(.visible) | |
| 105 | + .chartOverlay { proxy in | |
| 106 | + GeometryReader { geo in | |
| 107 | + Color.clear | |
| 108 | + .contentShape(Rectangle()) | |
| 109 | + .onContinuousHover { phase in | |
| 110 | + switch phase { | |
| 111 | + case .active(let pt): | |
| 112 | + let plot = geo[proxy.plotFrame!] | |
| 113 | + hoverX = proxy.value(atX: pt.x - plot.origin.x, as: Double.self) | |
| 114 | + case .ended: | |
| 115 | + hoverX = nil | |
| 116 | + } | |
| 117 | + } | |
| 118 | + .gesture(magnify) | |
| 119 | + .onTapGesture(count: 2) { | |
| 120 | + visibleLength = nil | |
| 121 | + followLive = true | |
| 122 | + } | |
| 123 | + } | |
| 124 | + } | |
| 125 | + .onChange(of: scrollX) { old, new in | |
| 126 | + // A user-initiated pan while live breaks auto-follow. | |
| 127 | + if isLive, followLive, let len = visibleLength, | |
| 128 | + abs(new - (maxX - len)) > len * 0.05 { | |
| 129 | + followLive = false | |
| 130 | + } | |
| 131 | + } | |
| 132 | + .onChange(of: snapshot?.count ?? 0) { | |
| 133 | + if isLive, followLive, let len = visibleLength { | |
| 134 | + scrollX = max(minX, maxX - len) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + private var magnify: some Gesture { | |
| 140 | + MagnifyGesture() | |
| 141 | + .onChanged { g in | |
| 142 | + let base = magnifyBase ?? (visibleLength ?? (maxX - minX)) | |
| 143 | + magnifyBase = base | |
| 144 | + let span = maxX - minX | |
| 145 | + let newLen = min(max(base / g.magnification, span * 0.01), span) | |
| 146 | + visibleLength = newLen < span * 0.999 ? newLen : nil | |
| 147 | + if followLive, let len = visibleLength { | |
| 148 | + scrollX = max(minX, maxX - len) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + .onEnded { _ in magnifyBase = nil } | |
| 152 | + } | |
| 153 | + | |
| 154 | + // Nearest-point lookup for the callout (train series is x-sorted). | |
| 155 | + private struct HoverInfo { | |
| 156 | + var step: Int | |
| 157 | + var train: Double? | |
| 158 | + var ema: Double? | |
| 159 | + var val: Double? | |
| 160 | + } | |
| 161 | + | |
| 162 | + private var hoverInfo: HoverInfo? { | |
| 163 | + guard let x = hoverX, let snap = snapshot, !snap.train.isEmpty else { return nil } | |
| 164 | + func nearest(_ s: [Downsampler.XY]) -> Downsampler.XY? { | |
| 165 | + guard !s.isEmpty else { return nil } | |
| 166 | + var lo = 0, hi = s.count - 1 | |
| 167 | + while lo < hi { | |
| 168 | + let mid = (lo + hi) / 2 | |
| 169 | + if s[mid].x < x { lo = mid + 1 } else { hi = mid } | |
| 170 | + } | |
| 171 | + if lo > 0, abs(s[lo - 1].x - x) < abs(s[lo].x - x) { lo -= 1 } | |
| 172 | + return s[lo] | |
| 173 | + } | |
| 174 | + let t = nearest(snap.train) | |
| 175 | + return HoverInfo(step: Int(t?.x ?? x), | |
| 176 | + train: t?.y, | |
| 177 | + ema: nearest(snap.trainEMA)?.y, | |
| 178 | + val: nearest(snap.val).flatMap { | |
| 179 | + abs($0.x - x) < max(8, (maxX - minX) * 0.02) ? $0.y : nil | |
| 180 | + }) | |
| 181 | + } | |
| 182 | + | |
| 183 | + private func calloutText(_ h: HoverInfo) -> some View { | |
| 184 | + HStack(spacing: 10) { | |
| 185 | + Text("step \(h.step)").foregroundStyle(.secondary) | |
| 186 | + if let t = h.train { | |
| 187 | + Text(String(format: "train %.4f", t)).foregroundStyle(.blue) | |
| 188 | + Text(String(format: "ppl %.1f", exp(t))).foregroundStyle(.secondary) | |
| 189 | + } | |
| 190 | + if let e = h.ema { | |
| 191 | + Text(String(format: "EMA %.4f", e)).foregroundStyle(.blue) | |
| 192 | + } | |
| 193 | + if let v = h.val { | |
| 194 | + Text(String(format: "val %.4f", v)).foregroundStyle(.orange) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + .font(.caption.monospacedDigit()) | |
| 198 | + .padding(.horizontal, 8) | |
| 199 | + .padding(.vertical, 3) | |
| 200 | + .background(.quaternary.opacity(0.5), in: Capsule()) | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +// Conditional modifiers kept out of the Chart body so the result builder | |
| 205 | +// stays type-checkable. | |
| 206 | +private struct LogScaleModifier: ViewModifier { | |
| 207 | + let enabled: Bool | |
| 208 | + func body(content: Content) -> some View { | |
| 209 | + if enabled { content.chartYScale(type: .log) } else { content } | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +private struct ScrollModifier: ViewModifier { | |
| 214 | + let visibleLength: Double? | |
| 215 | + @Binding var scrollX: Double | |
| 216 | + func body(content: Content) -> some View { | |
| 217 | + if let len = visibleLength { | |
| 218 | + content | |
| 219 | + .chartScrollableAxes(.horizontal) | |
| 220 | + .chartXVisibleDomain(length: len) | |
| 221 | + .chartScrollPosition(x: $scrollX) | |
| 222 | + } else { | |
| 223 | + content | |
| 224 | + } | |
| 225 | + } | |
| 226 | +} | |
modified
ForgeStudio/ForgeStudioApp.swift
+22 −0
@@ -35,9 +35,31 @@ final class AppModel { | ||
| 35 | 35 | let store = RunStore(workspace: workspace) |
| 36 | 36 | self.store = store |
| 37 | 37 | self.supervisor = RunSupervisor(store: store) |
| 38 | + recoverOrphans() | |
| 38 | 39 | Task { await self.validateForge() } |
| 39 | 40 | } |
| 40 | 41 | |
| 42 | + /// Crash recovery: a run persisted as active means the app died mid-run. | |
| 43 | + /// If its forge PID is still alive we leave the process alone and record | |
| 44 | + /// the truth (stopped tracking); if it's gone, the run is marked stopped | |
| 45 | + /// with an honest reason. A run never stays in a lying state. | |
| 46 | + private func recoverOrphans() { | |
| 47 | + for var run in store.runs where run.state.isActive { | |
| 48 | + if let pid = run.pid, kill(pid, 0) == 0 { | |
| 49 | + run.failureReason = | |
| 50 | + "L'app a quitté pendant le run ; forge (pid \(pid)) tourne encore — " | |
| 51 | + + "métriques reprises depuis log.csv, contrôle du process perdu." | |
| 52 | + } else { | |
| 53 | + run.failureReason = | |
| 54 | + "L'app a quitté pendant le run ; forge n'est plus actif. " | |
| 55 | + + "Reprise possible depuis ckpt_latest.bin." | |
| 56 | + } | |
| 57 | + run.state = .stopped | |
| 58 | + run.pid = nil | |
| 59 | + store.upsert(run) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 41 | 63 | func validateForge() async { |
| 42 | 64 | guard let binary = ForgeBinaryLocator.savedBinaryURL |
| 43 | 65 | ?? ForgeBinaryLocator.candidates().first else { |
modified
ForgeStudio/Services/MetricsStore.swift
+11 −2
@@ -20,8 +20,11 @@ actor MetricsStore { | ||
| 20 | 20 | var lastPoint: MetricPoint? |
| 21 | 21 | var bestVal: (step: Int, loss: Double)? |
| 22 | 22 | var count: Int |
| 23 | + var lastGrowth: Date? // watchdog: when new rows last arrived | |
| 23 | 24 | } |
| 24 | 25 | |
| 26 | + private var lastGrowth: Date? | |
| 27 | + | |
| 25 | 28 | func append(_ p: MetricPoint) { points.append(p) } |
| 26 | 29 | |
| 27 | 30 | func reset() { |
@@ -46,9 +49,14 @@ actor MetricsStore { | ||
| 46 | 49 | } |
| 47 | 50 | csvOffset += UInt64(consumable.count) |
| 48 | 51 | guard let text = String(data: consumable, encoding: .utf8) else { return } |
| 52 | + var grew = false | |
| 49 | 53 | for line in text.split(separator: "\n") { |
| 50 | − if let p = parser.parseCSVLine(String(line)) { points.append(p) } | |
| 54 | + if let p = parser.parseCSVLine(String(line)) { | |
| 55 | + points.append(p) | |
| 56 | + grew = true | |
| 57 | + } | |
| 51 | 58 | } |
| 59 | + if grew { lastGrowth = .now } | |
| 52 | 60 | } |
| 53 | 61 | |
| 54 | 62 | func snapshot(maxPoints: Int, smoothing: Double) -> Snapshot { |
@@ -75,6 +83,7 @@ actor MetricsStore { | ||
| 75 | 83 | gradNorm: ds(series { $0.gradNorm }), |
| 76 | 84 | lastPoint: points.last, |
| 77 | 85 | bestVal: best, |
| 78 | − count: points.count) | |
| 86 | + count: points.count, | |
| 87 | + lastGrowth: lastGrowth) | |
| 79 | 88 | } |
| 80 | 89 | } |
added
ForgeStudio/Views/CheckpointsPanel.swift
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// | |
| 3 | +// Per-run checkpoints with Generate (streamed token-by-token) and Eval | |
| 4 | +// (loss/ppl) panels — thin drivers over `forge generate` / `forge eval`. | |
| 5 | +import SwiftUI | |
| 6 | + | |
| 7 | +struct CheckpointsPanel: View { | |
| 8 | + let run: Run | |
| 9 | + @Environment(AppModel.self) private var app | |
| 10 | + @State private var checkpoints: [Checkpoint] = [] | |
| 11 | + @State private var selected: String? | |
| 12 | + @State private var prompt = "Once upon a time" | |
| 13 | + @State private var temp = 0.8 | |
| 14 | + @State private var topK = 40 | |
| 15 | + @State private var maxTokens = 200 | |
| 16 | + @State private var output = "" | |
| 17 | + @State private var evalResult: String? | |
| 18 | + @State private var busy = false | |
| 19 | + @State private var errorText: String? | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + VStack(alignment: .leading, spacing: 12) { | |
| 23 | + Text("Checkpoints").font(.title3.weight(.semibold)) | |
| 24 | + if checkpoints.isEmpty { | |
| 25 | + Text("Aucun checkpoint pour l'instant (écrits tous les \(run.config.train.checkpointEvery) steps).") | |
| 26 | + .foregroundStyle(.secondary) | |
| 27 | + } else { | |
| 28 | + Picker("Checkpoint", selection: $selected) { | |
| 29 | + ForEach(checkpoints) { ck in | |
| 30 | + Text("step \(ck.step) · \(ByteCountFormatter.string(fromByteCount: ck.sizeBytes, countStyle: .file))") | |
| 31 | + .tag(Optional(ck.path)) | |
| 32 | + } | |
| 33 | + } | |
| 34 | + .frame(maxWidth: 420) | |
| 35 | + | |
| 36 | + GroupBox("Génération") { | |
| 37 | + VStack(alignment: .leading, spacing: 8) { | |
| 38 | + TextField("Prompt", text: $prompt) | |
| 39 | + HStack { | |
| 40 | + Text("temp") | |
| 41 | + Slider(value: $temp, in: 0.1...1.5).frame(width: 100) | |
| 42 | + Text(String(format: "%.2f", temp)).monospacedDigit() | |
| 43 | + Stepper("top-k \(topK)", value: $topK, in: 1...200, step: 10) | |
| 44 | + Stepper("tokens \(maxTokens)", value: $maxTokens, | |
| 45 | + in: 20...1000, step: 20) | |
| 46 | + Spacer() | |
| 47 | + Button(busy ? "…" : "Générer") { generate() } | |
| 48 | + .disabled(busy || selected == nil) | |
| 49 | + } | |
| 50 | + .font(.callout) | |
| 51 | + if !output.isEmpty { | |
| 52 | + ScrollView { | |
| 53 | + Text(output) | |
| 54 | + .font(.system(.body, design: .serif)) | |
| 55 | + .textSelection(.enabled) | |
| 56 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 57 | + } | |
| 58 | + .frame(height: 160) | |
| 59 | + .padding(8) | |
| 60 | + .background(.quaternary.opacity(0.4), | |
| 61 | + in: RoundedRectangle(cornerRadius: 6)) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + GroupBox("Évaluation") { | |
| 67 | + HStack { | |
| 68 | + Button(busy ? "…" : "Évaluer (50 batches)") { evaluate() } | |
| 69 | + .disabled(busy || selected == nil) | |
| 70 | + if let r = evalResult { | |
| 71 | + Text(r).font(.callout.monospacedDigit()) | |
| 72 | + } | |
| 73 | + Spacer() | |
| 74 | + } | |
| 75 | + } | |
| 76 | + } | |
| 77 | + if let e = errorText { | |
| 78 | + Label(e, systemImage: "exclamationmark.triangle").foregroundStyle(.red) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + .onAppear { scan() } | |
| 82 | + } | |
| 83 | + | |
| 84 | + private func scan() { | |
| 85 | + let dir = URL(fileURLWithPath: run.outDirectory) | |
| 86 | + let files = (try? FileManager.default.contentsOfDirectory( | |
| 87 | + at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey])) | |
| 88 | + ?? [] | |
| 89 | + checkpoints = files.compactMap { url in | |
| 90 | + let name = url.lastPathComponent | |
| 91 | + guard name.hasPrefix("ckpt_"), name.hasSuffix(".bin"), | |
| 92 | + let step = Int(name.dropFirst(5).dropLast(4)) else { return nil } | |
| 93 | + let vals = try? url.resourceValues(forKeys: [.fileSizeKey, | |
| 94 | + .contentModificationDateKey]) | |
| 95 | + return Checkpoint(path: url.path, step: step, | |
| 96 | + sizeBytes: Int64(vals?.fileSize ?? 0), | |
| 97 | + modifiedAt: vals?.contentModificationDate ?? .now, | |
| 98 | + valLossAtStep: nil) | |
| 99 | + } | |
| 100 | + .sorted { $0.step > $1.step } | |
| 101 | + selected = checkpoints.first?.path | |
| 102 | + } | |
| 103 | + | |
| 104 | + private var tokenizerPath: String? { | |
| 105 | + let dir = URL(fileURLWithPath: run.datasetPath) | |
| 106 | + return (try? FileManager.default.contentsOfDirectory( | |
| 107 | + at: dir, includingPropertiesForKeys: nil))? | |
| 108 | + .first { $0.pathExtension == "model" }?.path | |
| 109 | + } | |
| 110 | + | |
| 111 | + private func generate() { | |
| 112 | + guard let ckpt = selected, let forge = ForgeBinaryLocator.savedBinaryURL, | |
| 113 | + let tok = tokenizerPath else { | |
| 114 | + errorText = "tokenizer introuvable dans le dossier du dataset" | |
| 115 | + return | |
| 116 | + } | |
| 117 | + busy = true | |
| 118 | + output = "" | |
| 119 | + errorText = nil | |
| 120 | + Task { | |
| 121 | + defer { busy = false } | |
| 122 | + do { | |
| 123 | + let runner = ProcessRunner() | |
| 124 | + let (lines, exit) = try await runner.launch( | |
| 125 | + executable: forge, | |
| 126 | + arguments: ["generate", "--checkpoint", ckpt, | |
| 127 | + "--tokenizer", tok, "--prompt", prompt, | |
| 128 | + "--temp", String(temp), "--top-k", String(topK), | |
| 129 | + "--max-tokens", String(maxTokens)], | |
| 130 | + currentDirectory: forge.deletingLastPathComponent()) | |
| 131 | + for await (line, isErr) in lines where !isErr { | |
| 132 | + output += line + "\n" | |
| 133 | + } | |
| 134 | + let status = await exit.value | |
| 135 | + if status.code != 0 { | |
| 136 | + errorText = "forge generate a échoué (code \(status.code))" | |
| 137 | + } | |
| 138 | + } catch { | |
| 139 | + errorText = error.localizedDescription | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + private func evaluate() { | |
| 145 | + guard let ckpt = selected, let forge = ForgeBinaryLocator.savedBinaryURL | |
| 146 | + else { return } | |
| 147 | + let valBin = run.datasetPath + "/val.bin" | |
| 148 | + busy = true | |
| 149 | + evalResult = nil | |
| 150 | + errorText = nil | |
| 151 | + Task { | |
| 152 | + defer { busy = false } | |
| 153 | + do { | |
| 154 | + let runner = ProcessRunner() | |
| 155 | + let (lines, exit) = try await runner.launch( | |
| 156 | + executable: forge, | |
| 157 | + arguments: ["eval", "--checkpoint", ckpt, "--data", valBin, | |
| 158 | + "--batches", "50"], | |
| 159 | + currentDirectory: forge.deletingLastPathComponent()) | |
| 160 | + for await (line, _) in lines where line.hasPrefix("val loss") { | |
| 161 | + evalResult = line | |
| 162 | + } | |
| 163 | + let status = await exit.value | |
| 164 | + if status.code != 0 { | |
| 165 | + errorText = "forge eval a échoué (code \(status.code))" | |
| 166 | + } | |
| 167 | + } catch { | |
| 168 | + errorText = error.localizedDescription | |
| 169 | + } | |
| 170 | + } | |
| 171 | + } | |
| 172 | +} | |
modified
ForgeStudio/Views/NewRunSheet.swift
+32 −7
@@ -127,16 +127,41 @@ struct NewRunSheet: View { | ||
| 127 | 127 | } |
| 128 | 128 | |
| 129 | 129 | private var presetMenu: some View { |
| 130 | − Menu("Presets") { | |
| 131 | − ForEach(Presets.all, id: \.name) { preset in | |
| 132 | − Button(preset.name) { | |
| 133 | − config = preset.config | |
| 134 | − runName = preset.config.model.name | |
| 135 | − if let v = dataset?.vocabSize { config.model.vocabSize = v } | |
| 130 | + HStack { | |
| 131 | + Menu("Presets") { | |
| 132 | + ForEach(Presets.all, id: \.name) { preset in | |
| 133 | + Button(preset.name) { | |
| 134 | + config = preset.config | |
| 135 | + runName = preset.config.model.name | |
| 136 | + if let v = dataset?.vocabSize { config.model.vocabSize = v } | |
| 137 | + } | |
| 136 | 138 | } |
| 137 | 139 | } |
| 140 | + .frame(width: 110) | |
| 141 | + Button("Importer…") { importConfig() } | |
| 142 | + Button("Exporter…") { exportConfig() } | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + private func importConfig() { | |
| 147 | + let panel = NSOpenPanel() | |
| 148 | + panel.allowedContentTypes = [.json] | |
| 149 | + if panel.runModal() == .OK, let url = panel.url, | |
| 150 | + let cfg = try? ForgeConfig.load(from: url) { | |
| 151 | + config = cfg | |
| 152 | + runName = cfg.model.name | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + private func exportConfig() { | |
| 157 | + let panel = NSSavePanel() | |
| 158 | + panel.allowedContentTypes = [.json] | |
| 159 | + panel.nameFieldStringValue = "\(runName).json" | |
| 160 | + if panel.runModal() == .OK, let url = panel.url { | |
| 161 | + var cfg = config | |
| 162 | + cfg.model.name = runName | |
| 163 | + try? cfg.exportJSON().write(to: url) | |
| 138 | 164 | } |
| 139 | − .frame(width: 120) | |
| 140 | 165 | } |
| 141 | 166 | |
| 142 | 167 | private func intField(_ label: String, _ value: Binding<Int>) -> some View { |
modified
ForgeStudio/Views/RunDetailView.swift
+12 −50
@@ -23,7 +23,8 @@ struct RunDetailView: View { | ||
| 23 | 23 | Divider() |
| 24 | 24 | ScrollView { |
| 25 | 25 | VStack(alignment: .leading, spacing: 16) { |
| 26 | − lossChart | |
| 26 | + TrainingChartView(snapshot: snapshot, isLive: isLive, | |
| 27 | + smoothing: $smoothing, logScale: $logScale) | |
| 27 | 28 | HStack(spacing: 24) { |
| 28 | 29 | secondaryChart(title: "Learning rate", |
| 29 | 30 | series: snapshot?.lr ?? [], format: "%.2e") |
@@ -33,6 +34,9 @@ struct RunDetailView: View { | ||
| 33 | 34 | series: snapshot?.gradNorm ?? [], format: "%.2f", |
| 34 | 35 | threshold: run.config.train.gradClip) |
| 35 | 36 | } |
| 37 | + if !run.state.isActive { | |
| 38 | + CheckpointsPanel(run: run) | |
| 39 | + } | |
| 36 | 40 | DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)", |
| 37 | 41 | isExpanded: $showConsole) { |
| 38 | 42 | ConsoleView(lines: isLive ? app.supervisor.consoleLines |
@@ -58,55 +62,6 @@ struct RunDetailView: View { | ||
| 58 | 62 | .navigationTitle(run.name) |
| 59 | 63 | } |
| 60 | 64 | |
| 61 | − private var lossChart: some View { | |
| 62 | − VStack(alignment: .leading, spacing: 8) { | |
| 63 | − HStack { | |
| 64 | − Text("Loss").font(.title3.weight(.semibold)) | |
| 65 | − Spacer() | |
| 66 | − Toggle("log Y", isOn: $logScale).toggleStyle(.checkbox) | |
| 67 | − HStack(spacing: 4) { | |
| 68 | − Text("EMA") | |
| 69 | − Slider(value: $smoothing, in: 0...0.99).frame(width: 120) | |
| 70 | − Text(String(format: "%.2f", smoothing)).monospacedDigit() | |
| 71 | − } | |
| 72 | − .font(.caption) | |
| 73 | − } | |
| 74 | − Chart { | |
| 75 | − ForEach(snapshot?.train ?? [], id: \.x) { p in | |
| 76 | − LineMark(x: .value("step", p.x), y: .value("loss", p.y), | |
| 77 | − series: .value("s", "train")) | |
| 78 | − .foregroundStyle(.blue.opacity(0.25)) | |
| 79 | − } | |
| 80 | − ForEach(snapshot?.trainEMA ?? [], id: \.x) { p in | |
| 81 | − LineMark(x: .value("step", p.x), y: .value("ema", p.y), | |
| 82 | − series: .value("s", "train EMA")) | |
| 83 | − .foregroundStyle(.blue) | |
| 84 | − } | |
| 85 | − ForEach(snapshot?.val ?? [], id: \.x) { p in | |
| 86 | − LineMark(x: .value("step", p.x), y: .value("val", p.y), | |
| 87 | − series: .value("s", "val")) | |
| 88 | − .foregroundStyle(.orange) | |
| 89 | − PointMark(x: .value("step", p.x), y: .value("val", p.y)) | |
| 90 | − .foregroundStyle(.orange) | |
| 91 | − .symbolSize(24) | |
| 92 | − } | |
| 93 | − if let best = snapshot?.bestVal { | |
| 94 | − RuleMark(y: .value("best", best.loss)) | |
| 95 | − .foregroundStyle(.orange.opacity(0.4)) | |
| 96 | − .lineStyle(.init(lineWidth: 1, dash: [4, 4])) | |
| 97 | − .annotation(position: .topTrailing) { | |
| 98 | − Text(String(format: "best val %.3f @ %d", best.loss, best.step)) | |
| 99 | − .font(.caption2) | |
| 100 | − .foregroundStyle(.orange) | |
| 101 | − } | |
| 102 | − } | |
| 103 | − } | |
| 104 | − .if(logScale) { $0.chartYScale(type: .log) } | |
| 105 | − .chartLegend(.visible) | |
| 106 | − .frame(minHeight: 320) | |
| 107 | − } | |
| 108 | − } | |
| 109 | − | |
| 110 | 65 | private func secondaryChart(title: String, series: [Downsampler.XY], |
| 111 | 66 | format: String, threshold: Double? = nil) -> some View { |
| 112 | 67 | VStack(alignment: .leading, spacing: 4) { |
@@ -177,6 +132,13 @@ struct StatusStrip: View { | ||
| 177 | 132 | if let best = run.bestValLoss { |
| 178 | 133 | metric("best val", String(format: "%.4f", best)) |
| 179 | 134 | } |
| 135 | + if run.state == .running, let growth = snapshot?.lastGrowth, | |
| 136 | + Date.now.timeIntervalSince(growth) > 30 { | |
| 137 | + Label("possiblement bloqué", systemImage: "exclamationmark.triangle") | |
| 138 | + .font(.caption) | |
| 139 | + .foregroundStyle(.orange) | |
| 140 | + .help("Aucune nouvelle métrique depuis \(Int(Date.now.timeIntervalSince(growth))) s — le run n'a pas été interrompu.") | |
| 141 | + } | |
| 180 | 142 | Spacer() |
| 181 | 143 | Text("\(run.config.model.paramCount.formatted(.number.notation(.compactName))) params") |
| 182 | 144 | .foregroundStyle(.secondary) |
| 183 | 145 | |