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%
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Run dashboard: status strip, the loss chart (raw + bias-corrected EMA +4// val points, log/linear Y, best-val marker), and the console. Live data5// arrives via the supervisor's MetricsStore at ~1 Hz.6import Charts7import SwiftUI89struct RunDetailView: View {10 let run: Run11 @Environment(AppModel.self) private var app12 @State private var snapshot: MetricsStore.Snapshot?13 @State private var smoothing = 0.614 @State private var logScale = true15 @State private var showConsole = false16 @State private var historicStore: MetricsStore?17 @State private var xState = ChartXState()18 @State private var checkpointSteps: [Int] = []19 @State private var selectedCheckpoint: String?2021 private var isLive: Bool { app.supervisor.activeRunID == run.id }2223 private func scanCheckpoints() {24 let dir = URL(fileURLWithPath: run.outDirectory)25 checkpointSteps = ((try? FileManager.default.contentsOfDirectory(26 at: dir, includingPropertiesForKeys: nil)) ?? [])27 .compactMap { url -> Int? in28 let n = url.lastPathComponent29 guard n.hasPrefix("ckpt_"), n.hasSuffix(".bin"),30 let s = Int(n.dropFirst(5).dropLast(4)) else { return nil }31 return s32 }33 .sorted()34 }3536 var body: some View {37 VStack(spacing: 0) {38 StatusStrip(run: run, snapshot: snapshot)39 Divider()40 ScrollView {41 VStack(alignment: .leading, spacing: 16) {42 TrainingChartView(snapshot: snapshot, isLive: isLive,43 smoothing: $smoothing, logScale: $logScale,44 xState: xState,45 checkpointSteps: checkpointSteps,46 onCheckpointTap: { step in47 selectedCheckpoint = run.outDirectory48 + String(format: "/ckpt_%06d.bin", step)49 })50 HStack(spacing: 24) {51 secondaryChart(title: "Learning rate",52 series: snapshot?.lr ?? [], format: "%.2e")53 secondaryChart(title: "Tokens/s",54 series: snapshot?.tokensPerSec ?? [], format: "%.0f")55 secondaryChart(title: "Grad norm",56 series: snapshot?.gradNorm ?? [], format: "%.2f",57 threshold: run.config.train.gradClip)58 }59 if !run.state.isActive {60 CheckpointsPanel(run: run, selected: $selectedCheckpoint)61 }62 DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)",63 isExpanded: $showConsole) {64 ConsoleView(lines: isLive ? app.supervisor.consoleLines65 : ["(run terminé — voir run.log)"])66 }67 }68 .padding()69 }70 }71 .toolbar {72 ToolbarItem {73 Menu {74 Button("Chart en PNG (2×)…") { exportPNG() }75 Button("Métriques en CSV…") { exportCSV() }76 } label: {77 Label("Exporter", systemImage: "square.and.arrow.up")78 }79 .disabled(snapshot == nil)80 }81 if isLive {82 ToolbarItem {83 Button(role: .destructive) {84 Task { await app.supervisor.stop() }85 } label: {86 Label("Stop", systemImage: "stop.fill")87 }88 .help("SIGTERM — forge ne checkpointe pas à l'arrêt ; reprise depuis ckpt_latest.bin")89 }90 }91 }92 .task(id: run.id) {93 scanCheckpoints()94 await refreshLoop()95 }96 .navigationTitle(run.name)97 }9899 // Secondary charts share the main chart's X window and crosshair100 // (linked axes): zooming/hovering up top moves everything.101 private func secondaryChart(title: String, series: [Downsampler.XY],102 format: String, threshold: Double? = nil) -> some View {103 VStack(alignment: .leading, spacing: 4) {104 HStack {105 Text(title).font(.caption.weight(.semibold))106 Spacer()107 if let h = xState.hoverX,108 let near = series.min(by: { abs($0.x - h) < abs($1.x - h) }) {109 Text(String(format: format, near.y))110 .font(.caption.monospacedDigit())111 .foregroundStyle(.teal)112 } else if let last = series.last {113 Text(String(format: format, last.y))114 .font(.caption.monospacedDigit())115 .foregroundStyle(.secondary)116 }117 }118 Chart {119 ForEach(series, id: \.x) { p in120 LineMark(x: .value("step", p.x), y: .value("v", p.y))121 .foregroundStyle(.teal)122 }123 if let t = threshold, t > 0 {124 RuleMark(y: .value("clip", t))125 .lineStyle(.init(lineWidth: 1, dash: [3, 3]))126 .foregroundStyle(.red.opacity(0.5))127 }128 if let h = xState.hoverX {129 RuleMark(x: .value("hover", h))130 .foregroundStyle(.secondary.opacity(0.5))131 .lineStyle(.init(lineWidth: 1))132 }133 }134 .chartXAxis(.hidden)135 .modifier(LinkedScrollModifier(xState: xState))136 .frame(height: 110)137 }138 }139140 private func exportPNG() {141 let chart = TrainingChartView(snapshot: snapshot, isLive: false,142 smoothing: $smoothing, logScale: $logScale,143 xState: ChartXState(),144 checkpointSteps: checkpointSteps)145 .frame(width: 1200, height: 500)146 .padding()147 .background(Color(nsColor: .windowBackgroundColor))148 let renderer = ImageRenderer(content: chart)149 renderer.scale = 2.0150 guard let image = renderer.nsImage,151 let tiff = image.tiffRepresentation,152 let png = NSBitmapImageRep(data: tiff)?153 .representation(using: .png, properties: [:]) else { return }154 let panel = NSSavePanel()155 panel.nameFieldStringValue = "\(run.name)-loss.png"156 if panel.runModal() == .OK, let url = panel.url {157 try? png.write(to: url)158 }159 }160161 private func exportCSV() {162 let panel = NSSavePanel()163 panel.nameFieldStringValue = "\(run.name)-metrics.csv"164 if panel.runModal() == .OK, let url = panel.url {165 try? FileManager.default.removeItem(at: url)166 try? FileManager.default.copyItem(167 at: URL(fileURLWithPath: run.logCSVPath), to: url)168 }169 }170171 private func refreshLoop() async {172 if isLive {173 while !Task.isCancelled && app.supervisor.activeRunID == run.id {174 snapshot = await app.supervisor.metrics175 .snapshot(maxPoints: 1200, smoothing: smoothing)176 try? await Task.sleep(for: .milliseconds(500))177 }178 }179 // Historical (or freshly finished): load the whole CSV once.180 let store = MetricsStore()181 historicStore = store182 await store.ingestCSV(at: URL(fileURLWithPath: run.logCSVPath))183 snapshot = await store.snapshot(maxPoints: 1200, smoothing: smoothing)184 }185}186187struct StatusStrip: View {188 let run: Run189 let snapshot: MetricsStore.Snapshot?190191 var body: some View {192 HStack(spacing: 20) {193 StateBadge(state: run.state)194 if let step = run.lastStep {195 VStack(alignment: .leading, spacing: 1) {196 Text("step \(step + 1) / \(run.config.train.maxSteps)")197 .font(.callout.monospacedDigit())198 ProgressView(value: run.progress).frame(width: 140)199 }200 }201 if let last = snapshot?.lastPoint {202 metric("loss", String(format: "%.4f", last.trainLoss))203 metric("ppl", String(format: "%.1f", exp(last.trainLoss)))204 metric("tok/s", String(format: "%.0f", last.tokensPerSec))205 if let e = last.elapsedS {206 metric("écoulé", Duration.seconds(e)207 .formatted(.time(pattern: .hourMinuteSecond)))208 }209 }210 if let best = run.bestValLoss {211 metric("best val", String(format: "%.4f", best))212 }213 if run.state == .running, let growth = snapshot?.lastGrowth,214 Date.now.timeIntervalSince(growth) > 30 {215 Label("possiblement bloqué", systemImage: "exclamationmark.triangle")216 .font(.caption)217 .foregroundStyle(.orange)218 .help("Aucune nouvelle métrique depuis \(Int(Date.now.timeIntervalSince(growth))) s — le run n'a pas été interrompu.")219 }220 Spacer()221 Text("\(run.config.model.paramCount.formatted(.number.notation(.compactName))) params")222 .foregroundStyle(.secondary)223 }224 .padding(.horizontal)225 .padding(.vertical, 10)226 }227228 private func metric(_ label: String, _ value: String) -> some View {229 VStack(alignment: .leading, spacing: 1) {230 Text(label).font(.caption2).foregroundStyle(.secondary)231 Text(value).font(.callout.monospacedDigit())232 }233 }234}235236struct ConsoleView: View {237 let lines: [String]238239 var body: some View {240 ScrollViewReader { proxy in241 ScrollView {242 LazyVStack(alignment: .leading, spacing: 0) {243 ForEach(Array(lines.enumerated()), id: \.offset) { i, line in244 Text(line)245 .font(.system(.caption, design: .monospaced))246 .textSelection(.enabled)247 .id(i)248 }249 }250 .frame(maxWidth: .infinity, alignment: .leading)251 }252 .frame(height: 200)253 .background(.black.opacity(0.85))254 .clipShape(RoundedRectangle(cornerRadius: 6))255 .onChange(of: lines.count) {256 proxy.scrollTo(lines.count - 1, anchor: .bottom)257 }258 }259 }260}261262extension View {263 @ViewBuilder264 func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {265 if condition { transform(self) } else { self }266 }267}268