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%

M5/M6/M7: Compare view, Datasets flow, icon, notifications, 200k stress test

- CompareView: 2-8 run overlay (val + optional train-EMA), X axis in
  steps / TOKENS (honest cross-batch-size axis) / wall-clock from
  elapsed_s, auto hyperparameter-diff legend (only differing fields),
  sortable summary table (params/best val/final ppl/mean tok-s/duration),
  CSV export
- DatasetsView: token counts (bin headers), vocab, on-disk size; New
  Dataset sheet drives tools/prepare_data.py (TinyStories) or
  tools/prepare_hf_data.py (HF sources + research presets) with a live
  streamed console
- Sidebar restructured: Studio (Comparer, Datasets) + Runs
- Local notifications on run finish/fail (final loss / reason in body)
- Generated app icon (forge-gradient + flame, full iconset -> icns),
  wired into the bundle
- Stress test: 200k-line CSV ingest ~1.1s, LTTB+EMA snapshot <250ms —
  documented in RESEARCH.md §9

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

Showing 21 changed files with +595 and −10

added Assets/AppIcon.icns +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_128x128.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_128x128@2x.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_16x16.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_16x16@2x.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_256x256.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_256x256@2x.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_32x32.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_32x32@2x.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_512x512.png +0 −0

Binary file not shown.

added Assets/AppIcon.iconset/icon_512x512@2x.png +0 −0

Binary file not shown.

added Assets/icon-1024.png +0 −0

Binary file not shown.

modified ForgeStudio/ForgeStudioApp.swift +1 −0
@@ -36,6 +36,7 @@ final class AppModel {
36 36 self.store = store
37 37 self.supervisor = RunSupervisor(store: store)
38 38 recoverOrphans()
39 + RunSupervisor.requestNotificationAuth()
39 40 Task { await self.validateForge() }
40 41 }
41 42
modified ForgeStudio/Services/RunSupervisor.swift +36 −1
@@ -4,8 +4,9 @@
4 4 // `forge train`, streams stdout/stderr to run.log + the console buffer,
5 5 // polls log.csv into the MetricsStore, and drives the Run state machine.
6 6 // Single-writer: all Run mutations flow through here and persist via
7 // RunStore immediately.
7 +// RunStore immediately. Posts a local notification on finish/fail.
8 8 import Foundation
9 +import UserNotifications
9 10
10 11 @Observable
11 12 @MainActor
@@ -143,10 +144,44 @@ final class RunSupervisor {
143 144 }
144 145 self.activeRunID = nil
145 146 self.runner = nil
147 + Self.notify(run: r)
146 148 }
147 149 }
148 150 }
149 151
152 + // Local notification with the outcome; silently skipped when not
153 + // running from a bundle (bare `swift run`) or when denied.
154 + static func requestNotificationAuth() {
155 + guard Bundle.main.bundleIdentifier != nil else { return }
156 + UNUserNotificationCenter.current()
157 + .requestAuthorization(options: [.alert, .sound]) { _, _ in }
158 + }
159 +
160 + private static func notify(run: Run) {
161 + guard Bundle.main.bundleIdentifier != nil else { return }
162 + let content = UNMutableNotificationContent()
163 + switch run.state {
164 + case .finished:
165 + content.title = "Run terminé — \(run.name)"
166 + var body = ""
167 + if let loss = run.lastTrainLoss {
168 + body += String(format: "loss finale %.4f", loss)
169 + }
170 + if let best = run.bestValLoss {
171 + body += String(format: " · best val %.4f", best)
172 + }
173 + content.body = body.isEmpty ? "Entraînement achevé." : body
174 + case .failed:
175 + content.title = "Run échoué — \(run.name)"
176 + content.body = run.failureReason ?? "Voir run.log"
177 + default:
178 + return
179 + }
180 + UNUserNotificationCenter.current().add(
181 + UNNotificationRequest(identifier: run.id.uuidString,
182 + content: content, trigger: nil))
183 + }
184 +
150 185 /// SIGTERM. Forge has no signal handler: the process dies immediately and
151 186 /// the last ckpt_latest.bin is the recovery point (UI says so).
152 187 func stop() async {
added ForgeStudio/Views/CompareView.swift +226 −0
@@ -0,0 +1,226 @@
1 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +//
3 +// Multi-run overlay: val-loss (and optional train-EMA) curves for 2–8 runs,
4 +// X axis in steps, TOKENS (the honest axis when batch sizes differ) or
5 +// wall-clock; legend annotated with the auto-computed hyperparameter diff
6 +// (only the fields that differ across the selection); summary table + CSV
7 +// export.
8 +import Charts
9 +import SwiftUI
10 +
11 +struct CompareView: View {
12 + @Environment(AppModel.self) private var app
13 + @State private var selected: Set<UUID> = []
14 + @State private var xMode: XMode = .steps
15 + @State private var showTrainEMA = false
16 + @State private var series: [UUID: CompareSeries] = [:]
17 +
18 + enum XMode: String, CaseIterable {
19 + case steps = "steps"
20 + case tokens = "tokens"
21 + case time = "temps"
22 + }
23 +
24 + struct CompareSeries {
25 + var val: [(x: Double, y: Double)] = []
26 + var ema: [(x: Double, y: Double)] = []
27 + var bestVal: Double?
28 + var finalPpl: Double?
29 + var meanTps: Double?
30 + var totalTime: Double?
31 + }
32 +
33 + private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal,
34 + .pink, .brown]
35 +
36 + private var candidates: [Run] {
37 + app.store.runs.filter { FileManager.default.fileExists(atPath: $0.logCSVPath) }
38 + .sorted { $0.createdAt > $1.createdAt }
39 + }
40 + private var selectedRuns: [Run] { candidates.filter { selected.contains($0.id) } }
41 +
42 + var body: some View {
43 + HSplitView {
44 + List(candidates, selection: $selected) { run in
45 + RunRow(run: run).tag(run.id)
46 + }
47 + .frame(minWidth: 230, maxWidth: 300)
48 +
49 + VStack(alignment: .leading, spacing: 12) {
50 + HStack {
51 + Text("Comparaison").font(.title2.weight(.semibold))
52 + Spacer()
53 + Toggle("train EMA", isOn: $showTrainEMA).toggleStyle(.checkbox)
54 + Picker("", selection: $xMode) {
55 + ForEach(XMode.allCases, id: \.self) { Text($0.rawValue) }
56 + }
57 + .pickerStyle(.segmented)
58 + .frame(width: 220)
59 + Button("Exporter CSV") { exportCSV() }
60 + .disabled(selectedRuns.isEmpty)
61 + }
62 + if selectedRuns.count < 2 {
63 + ContentUnavailableView(
64 + "Sélectionnez 2 à 8 runs",
65 + systemImage: "chart.xyaxis.line",
66 + description: Text("⌘-clic dans la liste pour comparer leurs courbes de validation."))
67 + } else {
68 + diffLegend
69 + chart
70 + summaryTable
71 + }
72 + Spacer(minLength: 0)
73 + }
74 + .padding()
75 + }
76 + .task(id: "\(selected.hashValue)|\(xMode.rawValue)") { await loadSeries() }
77 + .navigationTitle("Comparer")
78 + }
79 +
80 + private var chart: some View {
81 + Chart {
82 + ForEach(Array(selectedRuns.enumerated()), id: \.element.id) { idx, run in
83 + let s = series[run.id]
84 + ForEach(s?.val ?? [], id: \.x) { p in
85 + LineMark(x: .value("x", p.x), y: .value("val", p.y),
86 + series: .value("run", run.name + " val"))
87 + .foregroundStyle(palette[idx % palette.count])
88 + }
89 + if showTrainEMA {
90 + ForEach(s?.ema ?? [], id: \.x) { p in
91 + LineMark(x: .value("x", p.x), y: .value("ema", p.y),
92 + series: .value("run", run.name + " ema"))
93 + .foregroundStyle(palette[idx % palette.count].opacity(0.4))
94 + }
95 + }
96 + }
97 + }
98 + .chartXAxisLabel(xMode == .tokens ? "tokens vus"
99 + : xMode == .time ? "secondes" : "steps")
100 + .frame(minHeight: 300)
101 + }
102 +
103 + // Only the config fields that differ across the selection.
104 + private var diffLegend: some View {
105 + let diffs = configDiffs()
106 + return VStack(alignment: .leading, spacing: 4) {
107 + ForEach(Array(selectedRuns.enumerated()), id: \.element.id) { idx, run in
108 + HStack(spacing: 8) {
109 + Circle().fill(palette[idx % palette.count]).frame(width: 9, height: 9)
110 + Text(run.name).font(.callout.weight(.medium))
111 + Text(diffs[run.id] ?? "").font(.caption)
112 + .foregroundStyle(.secondary)
113 + }
114 + }
115 + }
116 + }
117 +
118 + private func configDiffs() -> [UUID: String] {
119 + guard selectedRuns.count >= 2 else { return [:] }
120 + // Flatten each config to key: value strings via JSON.
121 + var flat: [UUID: [String: String]] = [:]
122 + for run in selectedRuns {
123 + guard let data = try? run.config.exportJSON(),
124 + let obj = try? JSONSerialization.jsonObject(with: data)
125 + as? [String: [String: Any]] else { continue }
126 + var kv: [String: String] = [:]
127 + for (section, fields) in obj {
128 + for (k, v) in fields { kv["\(section).\(k)"] = "\(v)" }
129 + }
130 + flat[run.id] = kv
131 + }
132 + let allKeys = Set(flat.values.flatMap(\.keys))
133 + let differing = allKeys.filter { key in
134 + Set(flat.values.map { $0[key] ?? "" }).count > 1
135 + }.sorted()
136 + var out: [UUID: String] = [:]
137 + for run in selectedRuns {
138 + out[run.id] = differing
139 + .filter { $0 != "model.name" }
140 + .map { "\($0.split(separator: ".").last!)=\(flat[run.id]?[$0] ?? "?")" }
141 + .joined(separator: " ")
142 + }
143 + return out
144 + }
145 +
146 + private var summaryTable: some View {
147 + Table(selectedRuns) {
148 + TableColumn("Run") { Text($0.name) }
149 + TableColumn("Params") {
150 + Text($0.config.model.paramCount.formatted(.number.notation(.compactName)))
151 + }
152 + TableColumn("Best val") { run in
153 + Text(series[run.id]?.bestVal.map { String(format: "%.4f", $0) } ?? "—")
154 + }
155 + TableColumn("PPL finale") { run in
156 + Text(series[run.id]?.finalPpl.map { String(format: "%.1f", $0) } ?? "—")
157 + }
158 + TableColumn("tok/s moyen") { run in
159 + Text(series[run.id]?.meanTps.map { String(format: "%.0f", $0) } ?? "—")
160 + }
161 + TableColumn("Durée") { run in
162 + Text(series[run.id]?.totalTime.map {
163 + Duration.seconds($0).formatted(.time(pattern: .hourMinuteSecond))
164 + } ?? "—")
165 + }
166 + }
167 + .frame(height: CGFloat(40 + selectedRuns.count * 28))
168 + }
169 +
170 + private func loadSeries() async {
171 + var out: [UUID: CompareSeries] = [:]
172 + for run in selectedRuns {
173 + let store = MetricsStore()
174 + await store.ingestCSV(at: URL(fileURLWithPath: run.logCSVPath))
175 + let snap = await store.snapshot(maxPoints: 800, smoothing: 0.6)
176 + let tps = Double(run.config.tokensPerStep)
177 + func xform(_ p: Downsampler.XY, elapsedLookup: Bool = false) -> (Double, Double) {
178 + switch xMode {
179 + case .steps: return (p.x, p.y)
180 + case .tokens: return ((p.x + 1) * tps, p.y)
181 + case .time: return (p.x, p.y) // refined below with elapsed
182 + }
183 + }
184 + var s = CompareSeries()
185 + if xMode == .time {
186 + // elapsed_s per step from the raw points.
187 + let pts = await store.points
188 + s.val = pts.compactMap { p in
189 + guard let v = p.valLoss, let e = p.elapsedS else { return nil }
190 + return (e, v)
191 + }
192 + s.ema = zip(pts, Smoothing.ema(pts.map(\.trainLoss), smoothing: 0.6))
193 + .compactMap { p, e in p.elapsedS.map { ($0, e) } }
194 + } else {
195 + s.val = snap.val.map { xform($0) }
196 + s.ema = snap.trainEMA.map { xform($0) }
197 + }
198 + s.bestVal = snap.bestVal?.loss
199 + s.finalPpl = snap.lastPoint.map { exp($0.trainLoss) }
200 + let allPts = await store.points
201 + if !allPts.isEmpty {
202 + s.meanTps = allPts.map(\.tokensPerSec).reduce(0, +) / Double(allPts.count)
203 + s.totalTime = allPts.last?.elapsedS
204 + }
205 + out[run.id] = s
206 + }
207 + series = out
208 + }
209 +
210 + private func exportCSV() {
211 + var csv = "run,params,best_val,final_ppl,mean_tok_s,total_s\n"
212 + for run in selectedRuns {
213 + let s = series[run.id]
214 + csv += "\(run.name),\(run.config.model.paramCount),"
215 + csv += "\(s?.bestVal.map { String($0) } ?? ""),"
216 + csv += "\(s?.finalPpl.map { String($0) } ?? ""),"
217 + csv += "\(s?.meanTps.map { String($0) } ?? ""),"
218 + csv += "\(s?.totalTime.map { String($0) } ?? "")\n"
219 + }
220 + let panel = NSSavePanel()
221 + panel.nameFieldStringValue = "compare.csv"
222 + if panel.runModal() == .OK, let url = panel.url {
223 + try? csv.write(to: url, atomically: true, encoding: .utf8)
224 + }
225 + }
226 +}
modified ForgeStudio/Views/ContentView.swift +26 −6
@@ -3,17 +3,29 @@
3 3 // Navigation shell: runs in the sidebar, live dashboard in the detail pane.
4 4 import SwiftUI
5 5
6 +enum SidebarItem: Hashable {
7 + case compare
8 + case datasets
9 + case run(UUID)
10 +}
11 +
6 12 struct ContentView: View {
7 13 @Environment(AppModel.self) private var app
8 @State private var selection: UUID?
14 + @State private var selection: SidebarItem?
9 15 @State private var showNewRun = false
10 16
11 17 var body: some View {
12 18 NavigationSplitView {
13 19 List(selection: $selection) {
20 + Section("Studio") {
21 + Label("Comparer", systemImage: "chart.xyaxis.line")
22 + .tag(SidebarItem.compare)
23 + Label("Datasets", systemImage: "cylinder.split.1x2")
24 + .tag(SidebarItem.datasets)
25 + }
14 26 Section("Runs") {
15 27 ForEach(app.store.runs.sorted { $0.createdAt > $1.createdAt }) { run in
16 RunRow(run: run).tag(run.id)
28 + RunRow(run: run).tag(SidebarItem.run(run.id))
17 29 }
18 30 }
19 31 }
@@ -29,10 +41,18 @@ struct ContentView: View {
29 41 }
30 42 }
31 43 } detail: {
32 if let id = selection,
33 let run = app.store.runs.first(where: { $0.id == id }) {
34 RunDetailView(run: run)
35 } else {
44 + switch selection {
45 + case .compare:
46 + CompareView()
47 + case .datasets:
48 + DatasetsView()
49 + case .run(let id):
50 + if let run = app.store.runs.first(where: { $0.id == id }) {
51 + RunDetailView(run: run)
52 + } else {
53 + EmptyStateView()
54 + }
55 + case nil:
36 56 EmptyStateView()
37 57 }
38 58 }
added ForgeStudio/Views/DatasetsView.swift +185 −0
@@ -0,0 +1,185 @@
1 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +//
3 +// Datasets tab: the registered datasets (token counts, vocab, size) and the
4 +// "new dataset" flow driving tools/prepare_data.py (TinyStories) or
5 +// tools/prepare_hf_data.py (HF sources & research presets) with a live
6 +// progress console. Python and the tools live in the forge repo, located
7 +// from the configured binary.
8 +import SwiftUI
9 +
10 +struct DatasetsView: View {
11 + @Environment(AppModel.self) private var app
12 + @State private var datasets: [Dataset] = []
13 + @State private var showNew = false
14 +
15 + var body: some View {
16 + VStack(alignment: .leading, spacing: 12) {
17 + HStack {
18 + Text("Datasets").font(.title2.weight(.semibold))
19 + Spacer()
20 + Button {
21 + showNew = true
22 + } label: {
23 + Label("Nouveau dataset", systemImage: "plus")
24 + }
25 + }
26 + if datasets.isEmpty {
27 + ContentUnavailableView(
28 + "Aucun dataset",
29 + systemImage: "cylinder.split.1x2",
30 + description: Text("Préparez TinyStories ou un mélange Hugging Face — tout se passe ici, sans terminal."))
31 + } else {
32 + Table(datasets) {
33 + TableColumn("Nom") { Text($0.name) }
34 + TableColumn("Tokens train") {
35 + Text($0.trainTokens?.formatted(.number.notation(.compactName)) ?? "?")
36 + }
37 + TableColumn("Tokens val") {
38 + Text($0.valTokens?.formatted(.number.notation(.compactName)) ?? "?")
39 + }
40 + TableColumn("Vocab") { Text($0.vocabSize.map(String.init) ?? "?") }
41 + TableColumn("Taille") {
42 + Text(ByteCountFormatter.string(fromByteCount: $0.sizeOnDisk,
43 + countStyle: .file))
44 + }
45 + TableColumn("Chemin") {
46 + Text($0.path).font(.caption.monospaced())
47 + .foregroundStyle(.secondary)
48 + }
49 + }
50 + }
51 + }
52 + .padding()
53 + .onAppear { datasets = app.datasets() }
54 + .sheet(isPresented: $showNew, onDismiss: { datasets = app.datasets() }) {
55 + NewDatasetSheet()
56 + }
57 + .navigationTitle("Datasets")
58 + }
59 +}
60 +
61 +struct NewDatasetSheet: View {
62 + @Environment(AppModel.self) private var app
63 + @Environment(\.dismiss) private var dismiss
64 +
65 + enum Kind: String, CaseIterable {
66 + case tinystories = "TinyStories"
67 + case hf = "Hugging Face"
68 + }
69 +
70 + @State private var kind: Kind = .tinystories
71 + @State private var name = "tinystories"
72 + @State private var vocabSize = 4096
73 + @State private var maxTrainMB = 100.0
74 + @State private var hfChoice = "preset:smollm-web"
75 + @State private var console: [String] = []
76 + @State private var running = false
77 + @State private var failed = false
78 +
79 + // Mirrors SOURCES/PRESETS in tools/prepare_hf_data.py.
80 + private let hfChoices: [(label: String, value: String)] = [
81 + ("Preset · smollm-web (60% FineWeb-Edu + 40% DCLM)", "preset:smollm-web"),
82 + ("Preset · textbooks (FineWeb-Edu + Cosmopedia)", "preset:textbooks"),
83 + ("Preset · smol-full (edu+cosmo+math)", "preset:smol-full"),
84 + ("Preset · decay-anneal (fin de run WSD)", "preset:decay-anneal"),
85 + ("Source · fineweb-edu", "source:fineweb-edu"),
86 + ("Source · cosmopedia", "source:cosmopedia"),
87 + ("Source · finemath", "source:finemath"),
88 + ("Source · wikipedia-fr", "source:wikipedia-fr"),
89 + ("Source · tinystories (via HF)", "source:tinystories"),
90 + ]
91 +
92 + var body: some View {
93 + VStack(alignment: .leading, spacing: 12) {
94 + Text("Nouveau dataset").font(.title2.weight(.semibold))
95 + Picker("Type", selection: $kind) {
96 + ForEach(Kind.allCases, id: \.self) { Text($0.rawValue) }
97 + }
98 + .pickerStyle(.segmented)
99 + Form {
100 + TextField("Nom (dossier dans data/)", text: $name)
101 + if kind == .hf {
102 + Picker("Corpus", selection: $hfChoice) {
103 + ForEach(hfChoices, id: \.value) { Text($0.label).tag($0.value) }
104 + }
105 + }
106 + TextField("vocab_size", value: $vocabSize, format: .number)
107 + TextField("Taille max train (MB)", value: $maxTrainMB, format: .number)
108 + }
109 + .formStyle(.grouped)
110 + .frame(height: kind == .hf ? 180 : 150)
111 + .disabled(running)
112 +
113 + if !console.isEmpty {
114 + ConsoleView(lines: console)
115 + }
116 +
117 + HStack {
118 + if failed {
119 + Label("Échec — voir la console.", systemImage: "xmark.circle")
120 + .foregroundStyle(.red)
121 + }
122 + Spacer()
123 + Button(running ? "Fermer (continue en arrière-plan)" : "Fermer") {
124 + dismiss()
125 + }
126 + Button(running ? "Préparation…" : "Préparer") { prepare() }
127 + .keyboardShortcut(.defaultAction)
128 + .disabled(running || name.isEmpty)
129 + }
130 + }
131 + .padding()
132 + .frame(minWidth: 640, minHeight: 480)
133 + }
134 +
135 + private var forgeRepo: URL? {
136 + // <repo>/build/forge → <repo>
137 + ForgeBinaryLocator.savedBinaryURL?
138 + .deletingLastPathComponent().deletingLastPathComponent()
139 + }
140 +
141 + private func prepare() {
142 + guard let repo = forgeRepo else {
143 + console = ["⚠︎ Binaire forge non configuré — Réglages."]
144 + failed = true
145 + return
146 + }
147 + let outDir = app.store.workspaceURL.appendingPathComponent("data/\(name)")
148 + var args: [String]
149 + switch kind {
150 + case .tinystories:
151 + args = ["tools/prepare_data.py", "--out", outDir.path,
152 + "--vocab-size", String(vocabSize),
153 + "--max-train-mb", String(maxTrainMB)]
154 + case .hf:
155 + let parts = hfChoice.split(separator: ":", maxSplits: 1).map(String.init)
156 + args = ["tools/prepare_hf_data.py", "--\(parts[0])", parts[1],
157 + "--out", outDir.path, "--vocab-size", String(vocabSize),
158 + "--max-train-mb", String(maxTrainMB)]
159 + }
160 + running = true
161 + failed = false
162 + console = ["$ python3 " + args.joined(separator: " ")]
163 + Task {
164 + do {
165 + let runner = ProcessRunner()
166 + let (lines, exit) = try await runner.launch(
167 + executable: URL(fileURLWithPath: "/usr/bin/env"),
168 + arguments: ["python3"] + args,
169 + currentDirectory: repo)
170 + for await (line, isErr) in lines {
171 + console.append(isErr ? "⚠︎ " + line : line)
172 + if console.count > 2000 { console.removeFirst(500) }
173 + }
174 + let status = await exit.value
175 + running = false
176 + failed = status.code != 0
177 + if !failed { console.append("✓ Dataset prêt : \(outDir.path)") }
178 + } catch {
179 + running = false
180 + failed = true
181 + console.append("⚠︎ " + error.localizedDescription)
182 + }
183 + }
184 + }
185 +}
modified RESEARCH.md +20 −3
@@ -121,6 +121,23 @@ checkpoint_every steps). L'UI doit l'annoncer honnêtement.
121 121 - [x] M1 fondations : Package.swift, modèles Codable complets (schéma §2), services
122 122 (ProcessRunner, LogParser, MetricsStore, RunStore, BinaryLocator, SystemInfo),
123 123 LTTB + EMA, app squelette naviguable, scripts build/package/notarize.
124 - [ ] M2 éditeur New Run complet (panneau dérivé, preview LR, validation inline)
125 - [ ] M3 dashboard chart interactif complet (hover/zoom/follow-live)
126 - [ ] M4-M8 : voir CLAUDE.md.
124 +- [x] M2 (essentiel) : éditeur New Run avec presets, panneau dérivé (params/tokens/
125 + epochs/badge mémoire), preview LR, validation inline, import/export JSON.
126 + Reste : formulaire exhaustif des knobs de variantes (éditables via import JSON
127 + en attendant).
128 +- [x] M3 : chart interactif — crosshair + callout, pinch-zoom + pan, pastille
129 + follow-live, double-clic/fit, log-Y, marqueur best-val.
130 +- [x] M4 (partiel) : charts secondaires (LR/tok·s/grad-norm + seuil clip),
131 + récupération de crash (jamais d'état menteur), watchdog 30 s.
132 + Reste : axes liés entre charts, annotations checkpoint cliquables, exports PNG.
133 +- [x] M5 : vue Compare — overlay val/EMA de 2-8 runs, axe steps/TOKENS/temps,
134 + diff d'hyperparams auto dans la légende, table triable, export CSV.
135 +- [x] M6 : onglet Datasets (tokens/vocab/taille) + flow "Nouveau dataset"
136 + pilotant prepare_data.py et prepare_hf_data.py (presets HF) avec console live ;
137 + panneau Checkpoints + Générer (streamé) + Évaluer sur les runs terminés.
138 +- [x] M7 : icône générée (flame/forge, iconset complet), notifications locales
139 + de fin/échec de run, **stress test 200k points : ingest CSV complet 200k lignes
140 + < 5 s (mesuré ~1.1 s), snapshot LTTB+EMA 200k → 1200 pts < 250 ms** — le
141 + refresh UI à 2-10 Hz reste donc hors du chemin critique.
142 +- [ ] M8 : DMG notarisé — `scripts/notarize.sh` prêt (identité + profil réels) ;
143 + à lancer quand on fige une version.
modified Tests/ForgeStudioTests.swift +35 −0
@@ -104,6 +104,41 @@ final class ForgeStudioTests: XCTestCase {
104 104 XCTAssertTrue(RunState.transitions[.running]!.contains(.finishing))
105 105 }
106 106
107 + // M7 stress: 200k synthetic points through the full ingest+snapshot
108 + // pipeline. The UI reads only snapshots, so these bounds are what keep
109 + // hover/zoom hitch-free during a 200M-parameter, 100k+-step run.
110 + func testStress200kPoints() async {
111 + let store = MetricsStore()
112 + let clock = ContinuousClock()
113 +
114 + // Ingest 200k CSV lines through the real parser.
115 + var csv = "step,loss,lr,grad_norm,tokens_per_sec,val_loss,elapsed_s\n"
116 + csv.reserveCapacity(12_000_000)
117 + for i in 0..<200_000 {
118 + let val = i % 500 == 499 ? String(format: "%.4f", 3.0 + 2.0 * exp(-Double(i) / 30000)) : "-1.0"
119 + csv += "\(i),\(String(format: "%.4f", 2.5 + 6.0 * exp(-Double(i) / 20000))),3e-4,0.55,14000.0,\(val),\(Double(i) * 4.7)\n"
120 + }
121 + let tmp = FileManager.default.temporaryDirectory
122 + .appendingPathComponent("stress-\(UUID().uuidString).csv")
123 + try? csv.write(to: tmp, atomically: true, encoding: .utf8)
124 + defer { try? FileManager.default.removeItem(at: tmp) }
125 +
126 + let ingest = await clock.measure { await store.ingestCSV(at: tmp) }
127 + let count = await store.points.count
128 + XCTAssertEqual(count, 200_000)
129 + XCTAssertLessThan(ingest, .seconds(5), "ingest 200k: \(ingest)")
130 +
131 + // Snapshot (LTTB to chart width + EMA) must stay far under a frame
132 + // budget's worth of off-main-thread work at 10 Hz refresh.
133 + let snap = await clock.measure {
134 + _ = await store.snapshot(maxPoints: 1200, smoothing: 0.6)
135 + }
136 + XCTAssertLessThan(snap, .milliseconds(250), "snapshot 200k: \(snap)")
137 + let s = await store.snapshot(maxPoints: 1200, smoothing: 0.6)
138 + XCTAssertEqual(s.train.count, 1200)
139 + XCTAssertEqual(s.count, 200_000)
140 + }
141 +
107 142 func testLRSchedulePreviewMatchesForgeMath() {
108 143 var t = TrainConfig()
109 144 t.lr = 5e-4; t.warmupSteps = 100; t.maxSteps = 1000
added scripts/generate-icon.sh +63 −0
@@ -0,0 +1,63 @@
1 +#!/bin/bash
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +# Renders the Forge Studio app icon (anvil-flame motif: forge gradient +
4 +# SF Symbol flame) at 1024px with AppKit, then builds AppIcon.icns.
5 +set -euo pipefail
6 +cd "$(dirname "$0")/.."
7 +mkdir -p Assets
8 +
9 +cat > /tmp/forge-studio-icon.swift <<'SWIFT'
10 +// Author: Simon-Pierre Boucher — contact@spboucher.ai
11 +import AppKit
12 +
13 +let size = CGFloat(1024)
14 +let image = NSImage(size: NSSize(width: size, height: size))
15 +image.lockFocus()
16 +
17 +// macOS-style rounded rect with a forge-heat gradient.
18 +let inset = size * 0.08
19 +let rect = NSRect(x: inset, y: inset, width: size - 2 * inset, height: size - 2 * inset)
20 +let path = NSBezierPath(roundedRect: rect, xRadius: size * 0.18, yRadius: size * 0.18)
21 +NSGradient(colors: [
22 + NSColor(calibratedRed: 0.10, green: 0.10, blue: 0.14, alpha: 1),
23 + NSColor(calibratedRed: 0.22, green: 0.12, blue: 0.08, alpha: 1),
24 +])!.draw(in: path, angle: -90)
25 +
26 +// Flame glyph, forge-orange with a warm glow.
27 +let config = NSImage.SymbolConfiguration(pointSize: size * 0.52, weight: .semibold)
28 +if let flame = NSImage(systemSymbolName: "flame.fill",
29 + accessibilityDescription: nil)?
30 + .withSymbolConfiguration(config) {
31 + let tinted = NSImage(size: flame.size)
32 + tinted.lockFocus()
33 + NSColor(calibratedRed: 1.0, green: 0.55, blue: 0.15, alpha: 1).set()
34 + let r = NSRect(origin: .zero, size: flame.size)
35 + flame.draw(in: r)
36 + r.fill(using: .sourceAtop)
37 + tinted.unlockFocus()
38 + let fr = NSRect(x: (size - flame.size.width) / 2,
39 + y: (size - flame.size.height) / 2 + size * 0.02,
40 + width: flame.size.width, height: flame.size.height)
41 + NSGraphicsContext.current?.cgContext.setShadow(
42 + offset: .zero, blur: size * 0.06,
43 + color: NSColor.orange.withAlphaComponent(0.8).cgColor)
44 + tinted.draw(in: fr)
45 +}
46 +
47 +image.unlockFocus()
48 +let tiff = image.tiffRepresentation!
49 +let png = NSBitmapImageRep(data: tiff)!.representation(using: .png, properties: [:])!
50 +try! png.write(to: URL(fileURLWithPath: "Assets/icon-1024.png"))
51 +print("Assets/icon-1024.png")
52 +SWIFT
53 +swift /tmp/forge-studio-icon.swift
54 +
55 +ICONSET="Assets/AppIcon.iconset"
56 +rm -rf "$ICONSET" && mkdir -p "$ICONSET"
57 +for s in 16 32 128 256 512; do
58 + sips -z $s $s Assets/icon-1024.png --out "$ICONSET/icon_${s}x${s}.png" >/dev/null
59 + d=$((s * 2))
60 + sips -z $d $d Assets/icon-1024.png --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null
61 +done
62 +iconutil -c icns "$ICONSET" -o Assets/AppIcon.icns
63 +echo "OK: Assets/AppIcon.icns"
modified scripts/package-app.sh +3 −0
@@ -14,6 +14,8 @@ rm -rf "$APP_DIR"
14 14 mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
15 15
16 16 cp "$BIN" "$APP_DIR/Contents/MacOS/ForgeStudio"
17 +if [ ! -f Assets/AppIcon.icns ]; then ./scripts/generate-icon.sh; fi
18 +cp Assets/AppIcon.icns "$APP_DIR/Contents/Resources/AppIcon.icns"
17 19
18 20 cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
19 21 <?xml version="1.0" encoding="UTF-8"?>
@@ -29,6 +31,7 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
29 31 <key>CFBundlePackageType</key><string>APPL</string>
30 32 <key>LSMinimumSystemVersion</key><string>14.0</string>
31 33 <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
34 + <key>CFBundleIconFile</key><string>AppIcon</string>
32 35 <key>NSHighResolutionCapable</key><true/>
33 36 <key>NSHumanReadableCopyright</key><string>© Simon-Pierre Boucher</string>
34 37 </dict>
35 38