SPB Git

spb/forge-studio Public

The Instruments of LLM training — a native macOS cockpit for Forge. Train language models from scratch on Apple Silicon without a terminal.

Swift 95.7% Shell 4.3%

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>
simon-pierre boucher committed 5 days ago (Aug 5, 2026) parent 94570c7

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