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%

v0.2.0: linked axes, clickable checkpoint marks, full variant form, exports, run queue

- ChartXState shared between the main chart and all secondary charts:
  zooming/panning up top moves every chart, and the hover crosshair is
  synchronized - secondary headers show the value AT the cursor
- Checkpoint annotations on the loss chart (green dashed rules with a
  drive glyph); clicking one selects that checkpoint in the panel below
- New Run form now covers EVERY Forge config field: attention/positions
  (rope theta/global/scaling, NoPE, head_dim, bias, softcap), norm
  placement, QAT, the full MoE section (V3 sigmoid/noaux/scaling/shared/
  first-k-dense), advanced optimizer (Muon, WSD, betas, forge_save/dtype)
- Export menu: chart as 2x PNG (ImageRenderer), metrics as CSV
- Run queue: starting a run while one is live persists it as "queued";
  the supervisor launches the oldest queued run when the active one
  exits - trainings never overlap, nothing is lost

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 5 days ago (Aug 5, 2026) parent 72f2f40

Showing 7 changed files with +254 and −18

modified ForgeStudio/Charts/TrainingChartView.swift +66 −5
@@ -8,18 +8,40 @@
8 8 import Charts
9 9 import SwiftUI
10 10
11 +// Shared X-window + crosshair state: the main chart drives it, secondary
12 +// charts observe it, so zooming/hovering is synchronized everywhere.
13 +@Observable
14 +final class ChartXState {
15 + var visibleLength: Double? // nil = fit all
16 + var scrollX: Double = 0
17 + var hoverX: Double?
18 +}
19 +
11 20 struct TrainingChartView: View {
12 21 let snapshot: MetricsStore.Snapshot?
13 22 let isLive: Bool
14 23 @Binding var smoothing: Double
15 24 @Binding var logScale: Bool
25 + var xState: ChartXState
26 + var checkpointSteps: [Int] = []
27 + var onCheckpointTap: ((Int) -> Void)?
16 28
17 @State private var visibleLength: Double? // nil = fit all
18 @State private var scrollX: Double = 0
19 29 @State private var followLive = true
20 @State private var hoverX: Double?
21 30 @State private var magnifyBase: Double?
22 31
32 + private var visibleLength: Double? {
33 + get { xState.visibleLength }
34 + nonmutating set { xState.visibleLength = newValue }
35 + }
36 + private var scrollX: Double {
37 + get { xState.scrollX }
38 + nonmutating set { xState.scrollX = newValue }
39 + }
40 + private var hoverX: Double? {
41 + get { xState.hoverX }
42 + nonmutating set { xState.hoverX = newValue }
43 + }
44 +
23 45 private var maxX: Double { snapshot?.train.last?.x ?? 1 }
24 46 private var minX: Double { snapshot?.train.first?.x ?? 0 }
25 47
@@ -93,6 +115,16 @@ struct TrainingChartView: View {
93 115 .foregroundStyle(.orange)
94 116 }
95 117 }
118 + ForEach(checkpointSteps, id: \.self) { step in
119 + RuleMark(x: .value("ckpt", Double(step)))
120 + .foregroundStyle(.green.opacity(0.25))
121 + .lineStyle(.init(lineWidth: 1, dash: [2, 4]))
122 + .annotation(position: .top, alignment: .leading) {
123 + Image(systemName: "externaldrive.fill")
124 + .font(.system(size: 7))
125 + .foregroundStyle(.green.opacity(0.6))
126 + }
127 + }
96 128 if let x = hoverX {
97 129 RuleMark(x: .value("hover", x))
98 130 .foregroundStyle(.secondary.opacity(0.5))
@@ -100,7 +132,10 @@ struct TrainingChartView: View {
100 132 }
101 133 }
102 134 .modifier(LogScaleModifier(enabled: logScale))
103 .modifier(ScrollModifier(visibleLength: visibleLength, scrollX: $scrollX))
135 + .modifier(ScrollModifier(
136 + visibleLength: visibleLength,
137 + scrollX: Binding(get: { xState.scrollX },
138 + set: { xState.scrollX = $0 })))
104 139 .chartLegend(.visible)
105 140 .chartOverlay { proxy in
106 141 GeometryReader { geo in
@@ -120,9 +155,19 @@ struct TrainingChartView: View {
120 155 visibleLength = nil
121 156 followLive = true
122 157 }
158 + .onTapGesture(count: 1) {
159 + // Click near a checkpoint rule selects it (M4:
160 + // "clickable → jumps to that checkpoint").
161 + guard let x = hoverX,
162 + let nearest = checkpointSteps
163 + .min(by: { abs(Double($0) - x) < abs(Double($1) - x) }),
164 + abs(Double(nearest) - x) < max(4, (maxX - minX) * 0.01)
165 + else { return }
166 + onCheckpointTap?(nearest)
167 + }
123 168 }
124 169 }
125 .onChange(of: scrollX) { old, new in
170 + .onChange(of: xState.scrollX) { old, new in
126 171 // A user-initiated pan while live breaks auto-follow.
127 172 if isLive, followLive, let len = visibleLength,
128 173 abs(new - (maxX - len)) > len * 0.05 {
@@ -210,6 +255,22 @@ private struct LogScaleModifier: ViewModifier {
210 255 }
211 256 }
212 257
258 +// Read-only link to the main chart's X window (secondary charts).
259 +struct LinkedScrollModifier: ViewModifier {
260 + var xState: ChartXState
261 + func body(content: Content) -> some View {
262 + if let len = xState.visibleLength {
263 + content
264 + .chartScrollableAxes(.horizontal)
265 + .chartXVisibleDomain(length: len)
266 + .chartScrollPosition(x: Binding(get: { xState.scrollX },
267 + set: { _ in })) // main chart drives
268 + } else {
269 + content
270 + }
271 + }
272 +}
273 +
213 274 private struct ScrollModifier: ViewModifier {
214 275 let visibleLength: Double?
215 276 @Binding var scrollX: Double
modified ForgeStudio/Services/RunSupervisor.swift +35 −5
@@ -43,12 +43,14 @@ final class RunSupervisor {
43 43 store.upsert(run)
44 44 }
45 45
46 + /// Public entry: creates the run immediately. If a training is live the
47 + /// run stays `queued` (persisted) and launches automatically when the
48 + /// active one exits — trainings never overlap.
46 49 func start(config: ForgeConfig, datasetPath: String, name: String,
47 50 resumeFrom: String? = nil) async throws {
48 guard activeRunID == nil else { throw SupervisorError.busy }
49 51 let errors = config.validationErrors
50 52 guard errors.isEmpty else { throw SupervisorError.invalidConfig(errors) }
51 guard let forge = ForgeBinaryLocator.savedBinaryURL else {
53 + guard ForgeBinaryLocator.savedBinaryURL != nil else {
52 54 throw SupervisorError.forgeNotConfigured
53 55 }
54 56
@@ -58,15 +60,42 @@ final class RunSupervisor {
58 60 .appendingPathComponent("runs/\(name)-\(stamp)")
59 61 try FileManager.default.createDirectory(at: outDir,
60 62 withIntermediateDirectories: true)
61 var run = Run(name: name, createdAt: .now, config: config,
63 + let run = Run(name: name, createdAt: .now, config: config,
62 64 datasetPath: datasetPath, outDirectory: outDir.path,
63 65 resumeCheckpoint: resumeFrom)
64 66 try config.exportJSON().write(to: URL(fileURLWithPath: run.configPath))
65 67 store.upsert(run)
68 + if activeRunID == nil {
69 + try await launch(run)
70 + } // else: stays queued; launchNextQueued() picks it up on exit
71 + }
72 +
73 + private func launchNextQueued() {
74 + guard activeRunID == nil,
75 + let next = store.runs.filter({ $0.state == .queued })
76 + .min(by: { $0.createdAt < $1.createdAt }) else { return }
77 + Task {
78 + do {
79 + try await launch(next)
80 + } catch {
81 + var r = next
82 + r.failureReason = error.localizedDescription
83 + transition(&r, to: .launching)
84 + transition(&r, to: .failed)
85 + }
86 + }
87 + }
88 +
89 + private func launch(_ queued: Run) async throws {
90 + guard activeRunID == nil else { throw SupervisorError.busy }
91 + guard let forge = ForgeBinaryLocator.savedBinaryURL else {
92 + throw SupervisorError.forgeNotConfigured
93 + }
94 + var run = queued
66 95
67 var args = ["train", "--config", run.configPath, "--data", datasetPath,
96 + var args = ["train", "--config", run.configPath, "--data", run.datasetPath,
68 97 "--out", run.outDirectory]
69 if let resume = resumeFrom { args += ["--resume", resume] }
98 + if let resume = run.resumeCheckpoint { args += ["--resume", resume] }
70 99
71 100 transition(&run, to: .launching)
72 101 activeRunID = run.id
@@ -145,6 +174,7 @@ final class RunSupervisor {
145 174 self.activeRunID = nil
146 175 self.runner = nil
147 176 Self.notify(run: r)
177 + self.launchNextQueued()
148 178 }
149 179 }
150 180 }
modified ForgeStudio/Views/CheckpointsPanel.swift +2 −2
@@ -6,9 +6,9 @@ import SwiftUI
6 6
7 7 struct CheckpointsPanel: View {
8 8 let run: Run
9 + @Binding var selected: String?
9 10 @Environment(AppModel.self) private var app
10 11 @State private var checkpoints: [Checkpoint] = []
11 @State private var selected: String?
12 12 @State private var prompt = "Once upon a time"
13 13 @State private var temp = 0.8
14 14 @State private var topK = 40
@@ -98,7 +98,7 @@ struct CheckpointsPanel: View {
98 98 valLossAtStep: nil)
99 99 }
100 100 .sorted { $0.step > $1.step }
101 selected = checkpoints.first?.path
101 + if selected == nil { selected = checkpoints.first?.path }
102 102 }
103 103
104 104 private var tokenizerPath: String? {
modified ForgeStudio/Views/NewRunSheet.swift +67 −0
@@ -67,6 +67,52 @@ struct NewRunSheet: View {
67 67 Toggle("qk_norm", isOn: $config.model.qkNorm)
68 68 intField("sliding_window (0 = full)", $config.model.slidingWindow)
69 69 }
70 + Section("Attention & positions") {
71 + Toggle("use_rope", isOn: $config.model.useRope)
72 + doubleField("rope_theta", $config.model.ropeTheta)
73 + doubleField("rope_theta_global (0 = idem)", $config.model.ropeThetaGlobal)
74 + intField("sliding_global_every (Gemma3: 6)", $config.model.slidingGlobalEvery)
75 + intField("nope_every (SmolLM3: 4)", $config.model.nopeEvery)
76 + intField("head_dim (0 = auto)", $config.model.headDimOverride)
77 + Toggle("attention_bias (Qwen2.5)", isOn: $config.model.attentionBias)
78 + doubleField("attn_softcap (Gemma2: 50)", $config.model.attnSoftcap)
79 + doubleField("rope_scale_factor (llama3: 32, 0 = off)",
80 + $config.model.ropeScaleFactor)
81 + if config.model.ropeScaleFactor > 0 {
82 + doubleField("rope_scale_low", $config.model.ropeScaleLow)
83 + doubleField("rope_scale_high", $config.model.ropeScaleHigh)
84 + intField("rope_scale_orig_ctx", $config.model.ropeScaleOrigCtx)
85 + }
86 + }
87 + Section("Normalisation & sorties") {
88 + Picker("norm_placement", selection: $config.model.normPlacement) {
89 + ForEach(["pre", "post", "sandwich"], id: \.self) { Text($0) }
90 + }
91 + doubleField("norm_eps", $config.model.normEps)
92 + doubleField("final_softcap (Gemma2: 30)", $config.model.finalSoftcap)
93 + Toggle("scale_embeddings (Gemma)", isOn: $config.model.scaleEmbeddings)
94 + Picker("quant (QAT)", selection: $config.model.quant) {
95 + ForEach(["none", "int8", "ternary"], id: \.self) { Text($0) }
96 + }
97 + }
98 + Section("Mixture of Experts") {
99 + intField("n_experts (0 = dense)", $config.model.nExperts)
100 + if config.model.nExperts > 0 {
101 + intField("moe_top_k", $config.model.moeTopK)
102 + intField("n_shared_experts (V3: 1)", $config.model.nSharedExperts)
103 + Picker("moe_scoring", selection: $config.model.moeScoring) {
104 + ForEach(["softmax", "sigmoid"], id: \.self) { Text($0) }
105 + }
106 + Toggle("moe_norm_topk", isOn: $config.model.moeNormTopk)
107 + doubleField("routed_scaling_factor (V3: 2.5)",
108 + $config.model.routedScalingFactor)
109 + intField("moe_d_ff (0 = d_ff)", $config.model.moeDFf)
110 + intField("first_k_dense (V3: 3)", $config.model.firstKDense)
111 + doubleField("moe_bias_gamma (noaux, V3: 0.001)",
112 + $config.model.moeBiasGamma)
113 + doubleField("moe_aux_weight", $config.model.moeAuxWeight)
114 + }
115 + }
70 116 Section("Entraînement") {
71 117 doubleField("lr", $config.train.lr)
72 118 intField("max_steps", $config.train.maxSteps)
@@ -82,6 +128,27 @@ struct NewRunSheet: View {
82 128 intField("checkpoint_every", $config.train.checkpointEvery)
83 129 intField("eval_every", $config.train.evalEvery)
84 130 }
131 + Section("Optimiseur avancé") {
132 + if config.train.optimizer == "muon" {
133 + doubleField("muon_lr", $config.train.muonLr)
134 + doubleField("muon_momentum", $config.train.muonMomentum)
135 + }
136 + if config.train.schedule == "wsd" {
137 + doubleField("wsd_decay_frac", $config.train.wsdDecayFrac)
138 + }
139 + doubleField("min_lr_ratio", $config.train.minLrRatio)
140 + doubleField("beta1", $config.train.beta1)
141 + doubleField("beta2", $config.train.beta2)
142 + doubleField("weight_decay", $config.train.weightDecay)
143 + doubleField("grad_clip (0 = off)", $config.train.gradClip)
144 + intField("eval_batches", $config.train.evalBatches)
145 + intField("seed", $config.train.seed)
146 + Toggle("forge_save (.forge à chaque checkpoint)",
147 + isOn: $config.train.forgeSave)
148 + Picker("forge_dtype", selection: $config.train.forgeDtype) {
149 + ForEach(["f32", "f16", "bf16"], id: \.self) { Text($0) }
150 + }
151 + }
85 152 }
86 153 .formStyle(.grouped)
87 154 .frame(minWidth: 380)
modified ForgeStudio/Views/RunDetailView.swift +82 −4
@@ -14,9 +14,25 @@ struct RunDetailView: View {
14 14 @State private var logScale = true
15 15 @State private var showConsole = false
16 16 @State private var historicStore: MetricsStore?
17 + @State private var xState = ChartXState()
18 + @State private var checkpointSteps: [Int] = []
19 + @State private var selectedCheckpoint: String?
17 20
18 21 private var isLive: Bool { app.supervisor.activeRunID == run.id }
19 22
23 + 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? in
28 + let n = url.lastPathComponent
29 + guard n.hasPrefix("ckpt_"), n.hasSuffix(".bin"),
30 + let s = Int(n.dropFirst(5).dropLast(4)) else { return nil }
31 + return s
32 + }
33 + .sorted()
34 + }
35 +
20 36 var body: some View {
21 37 VStack(spacing: 0) {
22 38 StatusStrip(run: run, snapshot: snapshot)
@@ -24,7 +40,13 @@ struct RunDetailView: View {
24 40 ScrollView {
25 41 VStack(alignment: .leading, spacing: 16) {
26 42 TrainingChartView(snapshot: snapshot, isLive: isLive,
27 smoothing: $smoothing, logScale: $logScale)
43 + smoothing: $smoothing, logScale: $logScale,
44 + xState: xState,
45 + checkpointSteps: checkpointSteps,
46 + onCheckpointTap: { step in
47 + selectedCheckpoint = run.outDirectory
48 + + String(format: "/ckpt_%06d.bin", step)
49 + })
28 50 HStack(spacing: 24) {
29 51 secondaryChart(title: "Learning rate",
30 52 series: snapshot?.lr ?? [], format: "%.2e")
@@ -35,7 +57,7 @@ struct RunDetailView: View {
35 57 threshold: run.config.train.gradClip)
36 58 }
37 59 if !run.state.isActive {
38 CheckpointsPanel(run: run)
60 + CheckpointsPanel(run: run, selected: $selectedCheckpoint)
39 61 }
40 62 DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)",
41 63 isExpanded: $showConsole) {
@@ -47,6 +69,15 @@ struct RunDetailView: View {
47 69 }
48 70 }
49 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 + }
50 81 if isLive {
51 82 ToolbarItem {
52 83 Button(role: .destructive) {
@@ -58,17 +89,27 @@ struct RunDetailView: View {
58 89 }
59 90 }
60 91 }
61 .task(id: run.id) { await refreshLoop() }
92 + .task(id: run.id) {
93 + scanCheckpoints()
94 + await refreshLoop()
95 + }
62 96 .navigationTitle(run.name)
63 97 }
64 98
99 + // Secondary charts share the main chart's X window and crosshair
100 + // (linked axes): zooming/hovering up top moves everything.
65 101 private func secondaryChart(title: String, series: [Downsampler.XY],
66 102 format: String, threshold: Double? = nil) -> some View {
67 103 VStack(alignment: .leading, spacing: 4) {
68 104 HStack {
69 105 Text(title).font(.caption.weight(.semibold))
70 106 Spacer()
71 if let last = series.last {
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 {
72 113 Text(String(format: format, last.y))
73 114 .font(.caption.monospacedDigit())
74 115 .foregroundStyle(.secondary)
@@ -84,12 +125,49 @@ struct RunDetailView: View {
84 125 .lineStyle(.init(lineWidth: 1, dash: [3, 3]))
85 126 .foregroundStyle(.red.opacity(0.5))
86 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 + }
87 133 }
88 134 .chartXAxis(.hidden)
135 + .modifier(LinkedScrollModifier(xState: xState))
89 136 .frame(height: 110)
90 137 }
91 138 }
92 139
140 + 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.0
150 + 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 + }
160 +
161 + 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 + }
170 +
93 171 private func refreshLoop() async {
94 172 if isLive {
95 173 while !Task.isCancelled && app.supervisor.activeRunID == run.id {
modified scripts/notarize.sh +1 −1
@@ -9,7 +9,7 @@ cd "$(dirname "$0")/.."
9 9 IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)"
10 10 KEYCHAIN_PROFILE="MacLustr-Notarize"
11 11 APP_DIR="dist/ForgeStudio.app"
12 DMG_NAME="dist/ForgeStudio-0.1.0.dmg"
12 +DMG_NAME="dist/ForgeStudio-0.2.0.dmg"
13 13
14 14 ./scripts/package-app.sh release
15 15
modified scripts/package-app.sh +1 −1
@@ -27,7 +27,7 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
27 27 <key>CFBundleIdentifier</key><string>ai.spboucher.forge-studio</string>
28 28 <key>CFBundleExecutable</key><string>ForgeStudio</string>
29 29 <key>CFBundleVersion</key><string>1</string>
30 <key>CFBundleShortVersionString</key><string>0.1.0</string>
30 + <key>CFBundleShortVersionString</key><string>0.2.0</string>
31 31 <key>CFBundlePackageType</key><string>APPL</string>
32 32 <key>LSMinimumSystemVersion</key><string>14.0</string>
33 33 <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
34 34