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>
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 | |