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.

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 (Aug 5, 2026) parent 22ada2e Browse files

21 changed files +595 −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 self.store = store 36 self.store = store
37 self.supervisor = RunSupervisor(store: store) 37 self.supervisor = RunSupervisor(store: store)
38 recoverOrphans() 38 recoverOrphans()
39+ RunSupervisor.requestNotificationAuth()
39 Task { await self.validateForge() } 40 Task { await self.validateForge() }
40 } 41 }
41 42
modified ForgeStudio/Services/RunSupervisor.swift +36 −1
@@ -4,8 +4,9 @@
4 // `forge train`, streams stdout/stderr to run.log + the console buffer, 4 // `forge train`, streams stdout/stderr to run.log + the console buffer,
5 // polls log.csv into the MetricsStore, and drives the Run state machine. 5 // polls log.csv into the MetricsStore, and drives the Run state machine.
6 // Single-writer: all Run mutations flow through here and persist via 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 import Foundation 8 import Foundation
9+import UserNotifications
9 10
10 @Observable 11 @Observable
11 @MainActor 12 @MainActor
@@ -143,10 +144,44 @@ final class RunSupervisor {
143 } 144 }
144 self.activeRunID = nil 145 self.activeRunID = nil
145 self.runner = nil 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 /// SIGTERM. Forge has no signal handler: the process dies immediately and 185 /// SIGTERM. Forge has no signal handler: the process dies immediately and
151 /// the last ckpt_latest.bin is the recovery point (UI says so). 186 /// the last ckpt_latest.bin is the recovery point (UI says so).
152 func stop() async { 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 // Navigation shell: runs in the sidebar, live dashboard in the detail pane. 3 // Navigation shell: runs in the sidebar, live dashboard in the detail pane.
4 import SwiftUI 4 import SwiftUI
5 5
6+enum SidebarItem: Hashable {
7+ case compare
8+ case datasets
9+ case run(UUID)
10+}
11+
6 struct ContentView: View { 12 struct ContentView: View {
7 @Environment(AppModel.self) private var app 13 @Environment(AppModel.self) private var app
8− @State private var selection: UUID? 14+ @State private var selection: SidebarItem?
9 @State private var showNewRun = false 15 @State private var showNewRun = false
10 16
11 var body: some View { 17 var body: some View {
12 NavigationSplitView { 18 NavigationSplitView {
13 List(selection: $selection) { 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 Section("Runs") { 26 Section("Runs") {
15 ForEach(app.store.runs.sorted { $0.createdAt > $1.createdAt }) { run in 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 } detail: { 43 } detail: {
32− if let id = selection, 44+ switch selection {
33− let run = app.store.runs.first(where: { $0.id == id }) { 45+ case .compare:
34− RunDetailView(run: run) 46+ CompareView()
35− } else { 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 EmptyStateView() 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 - [x] M1 fondations : Package.swift, modèles Codable complets (schéma §2), services 121 - [x] M1 fondations : Package.swift, modèles Codable complets (schéma §2), services
122 (ProcessRunner, LogParser, MetricsStore, RunStore, BinaryLocator, SystemInfo), 122 (ProcessRunner, LogParser, MetricsStore, RunStore, BinaryLocator, SystemInfo),
123 LTTB + EMA, app squelette naviguable, scripts build/package/notarize. 123 LTTB + EMA, app squelette naviguable, scripts build/package/notarize.
124−- [ ] M2 éditeur New Run complet (panneau dérivé, preview LR, validation inline) 124+- [x] M2 (essentiel) : éditeur New Run avec presets, panneau dérivé (params/tokens/
125−- [ ] M3 dashboard chart interactif complet (hover/zoom/follow-live) 125+ epochs/badge mémoire), preview LR, validation inline, import/export JSON.
126−- [ ] M4-M8 : voir CLAUDE.md. 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 XCTAssertTrue(RunState.transitions[.running]!.contains(.finishing)) 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 func testLRSchedulePreviewMatchesForgeMath() { 142 func testLRSchedulePreviewMatchesForgeMath() {
108 var t = TrainConfig() 143 var t = TrainConfig()
109 t.lr = 5e-4; t.warmupSteps = 100; t.maxSteps = 1000 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 mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" 14 mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
15 15
16 cp "$BIN" "$APP_DIR/Contents/MacOS/ForgeStudio" 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 cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' 20 cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
19 <?xml version="1.0" encoding="UTF-8"?> 21 <?xml version="1.0" encoding="UTF-8"?>
@@ -29,6 +31,7 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
29 <key>CFBundlePackageType</key><string>APPL</string> 31 <key>CFBundlePackageType</key><string>APPL</string>
30 <key>LSMinimumSystemVersion</key><string>14.0</string> 32 <key>LSMinimumSystemVersion</key><string>14.0</string>
31 <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string> 33 <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
34+ <key>CFBundleIconFile</key><string>AppIcon</string>
32 <key>NSHighResolutionCapable</key><true/> 35 <key>NSHighResolutionCapable</key><true/>
33 <key>NSHumanReadableCopyright</key><string>© Simon-Pierre Boucher</string> 36 <key>NSHumanReadableCopyright</key><string>© Simon-Pierre Boucher</string>
34 </dict> 37 </dict>
35 38
Menu

Theme