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.

Forge Studio M1: SwiftPM app skeleton, full config codec, run supervisor, live dashboard

- RESEARCH.md: ground-truth Forge contract (config schema incl. waves 1-3,
  CLI, log.csv grammar with elapsed_s, checkpoint/resume, signals)
- ForgeConfig: byte-compatible Codable mirror of every model/train field,
  param-count formula cross-checked against forge info, LR-schedule math
- Services: ProcessRunner (actor, line streaming), LogParser (header-driven
  CSV + stdout events), MetricsStore (actor, incremental CSV tail, LTTB
  snapshots), RunStore (atomic registry), RunSupervisor (state machine,
  launch/stop, 1 Hz ingest), ForgeBinaryLocator, SystemInfo
- Charts: LTTB downsampler, bias-corrected EMA
- UI: NavigationSplitView shell, run rows/badges, RunDetail dashboard
  (loss raw+EMA+val, log-Y, best-val marker, LR/tok-s/grad-norm secondary
  charts, console), New Run sheet (presets, derived panel, LR preview,
  inline validation), Settings with forge-info validation
- scripts: package-app.sh (SPM -> .app, ad-hoc) and notarize.sh with the
  real zyquo-term identity (Team 3YM54G49SN, profile MacLustr-Notarize)
- 8 tests green: config round-trip vs real configs, param count, LTTB,
  EMA, CSV/stdout parsers, state machine, LR preview

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 5, 2026) Browse files

25 changed files +2,227 −0

added .gitignore +3 −0
@@ -0,0 +1,3 @@
1+.build/
2+dist/
3+.DS_Store
added CLAUDE.md +27 −0
@@ -0,0 +1,27 @@
1+# CLAUDE.md — Forge Studio
2+
3+<!--
4+Author: Simon-Pierre Boucher
5+Contact: contact@spboucher.ai
6+-->
7+
8+> **Instruction to Claude:** Every source file created in this project (Swift, scripts,
9+> plists, Metal, Makefiles) must begin with a header comment containing:
10+> `Author: Simon-Pierre Boucher — contact@spboucher.ai`
11+
12+> **Quality bar:** This app must feel like a first-party Apple pro tool — the
13+> "Instruments of LLM training." Every screen, every chart, every error message is
14+> judged against that standard. If a feature would ship half-working, it doesn't ship;
15+> it gets fixed. No placeholder UI, no TODO left in released code, no chart that
16+> jitters, no run state that can be lost.
17+
18+(Spec complète fournie par l'utilisateur — voir la conversation d'origine ; ce fichier
19+reprend les points de contrat. L'app est le compagnon GUI de Forge :
20+https://github.com/spboucher-ai/forge — datasets, config, runs, dashboard de métriques
21+légendaire, checkpoints/génération/éval, DMG signé/notarisé.)
22+
23+- macOS 14+, Apple Silicon, Swift 5.10+/SwiftUI/Swift Concurrency, Swift Charts.
24+- Build canonique : SwiftPM (pattern zyquo-term) ; l'app pilote le binaire `forge`
25+ en sous-processus ; métriques via `log.csv` (flush par step, voir RESEARCH.md).
26+- Signing/notarization : identité et profil extraits de zyquo-term (voir
27+ scripts/notarize.sh) — Team 3YM54G49SN, profil keychain "MacLustr-Notarize".
added ForgeStudio/Charts/Downsampler.swift +59 −0
@@ -0,0 +1,59 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// LTTB (largest-triangle-three-buckets) downsampling: preserves the visual
4+// shape of a series — endpoints kept exactly, one representative point per
5+// bucket chosen to maximize the triangle area with its neighbors. Used to
6+// keep chart data at ~2× pixel width regardless of run length.
7+import Foundation
8+
9+enum Downsampler {
10+ struct XY: Equatable, Sendable {
11+ var x: Double
12+ var y: Double
13+ }
14+
15+ static func lttb(_ points: [XY], threshold: Int) -> [XY] {
16+ let n = points.count
17+ guard threshold >= 3, n > threshold else { return points }
18+
19+ var sampled: [XY] = []
20+ sampled.reserveCapacity(threshold)
21+ sampled.append(points[0])
22+
23+ let bucketSize = Double(n - 2) / Double(threshold - 2)
24+ var a = 0 // index of the previously selected point
25+
26+ for i in 0..<(threshold - 2) {
27+ // Average of the NEXT bucket is the third triangle vertex.
28+ let nextStart = Int(Double(i + 1) * bucketSize) + 1
29+ let nextEnd = min(Int(Double(i + 2) * bucketSize) + 1, n)
30+ var avgX = 0.0, avgY = 0.0
31+ let span = max(nextEnd - nextStart, 1)
32+ for j in nextStart..<max(nextEnd, nextStart + 1) where j < n {
33+ avgX += points[j].x
34+ avgY += points[j].y
35+ }
36+ avgX /= Double(span)
37+ avgY /= Double(span)
38+
39+ let start = Int(Double(i) * bucketSize) + 1
40+ let end = min(Int(Double(i + 1) * bucketSize) + 1, n - 1)
41+
42+ var maxArea = -1.0
43+ var chosen = start
44+ let pa = points[a]
45+ for j in start..<max(end, start + 1) {
46+ let area = abs((pa.x - avgX) * (points[j].y - pa.y)
47+ - (pa.x - points[j].x) * (avgY - pa.y))
48+ if area > maxArea {
49+ maxArea = area
50+ chosen = j
51+ }
52+ }
53+ sampled.append(points[chosen])
54+ a = chosen
55+ }
56+ sampled.append(points[n - 1])
57+ return sampled
58+ }
59+}
added ForgeStudio/Charts/Smoothing.swift +22 −0
@@ -0,0 +1,22 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Bias-corrected exponential moving average (TensorBoard semantics): with
4+// smoothing s ∈ [0,1), ema_t = s·ema_{t-1} + (1−s)·x_t, displayed as
5+// ema_t / (1 − s^(t+1)) so early points aren't dragged toward zero.
6+import Foundation
7+
8+enum Smoothing {
9+ static func ema(_ values: [Double], smoothing: Double) -> [Double] {
10+ guard smoothing > 0, smoothing < 1, !values.isEmpty else { return values }
11+ var out: [Double] = []
12+ out.reserveCapacity(values.count)
13+ var acc = 0.0
14+ var correction = 1.0
15+ for v in values {
16+ acc = smoothing * acc + (1 - smoothing) * v
17+ correction *= smoothing
18+ out.append(acc / (1 - correction))
19+ }
20+ return out
21+ }
22+}
added ForgeStudio/ForgeStudioApp.swift +75 −0
@@ -0,0 +1,75 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Forge Studio — GUI companion for the Forge LLM training framework.
4+import SwiftUI
5+
6+@main
7+struct ForgeStudioApp: App {
8+ @State private var model = AppModel()
9+
10+ var body: some Scene {
11+ WindowGroup("Forge Studio") {
12+ ContentView()
13+ .environment(model)
14+ .frame(minWidth: 980, minHeight: 640)
15+ }
16+ Settings {
17+ SettingsView()
18+ .environment(model)
19+ }
20+ }
21+}
22+
23+@Observable
24+@MainActor
25+final class AppModel {
26+ var store: RunStore
27+ var supervisor: RunSupervisor
28+ var forgeDevice: String?
29+ var forgeError: String?
30+
31+ init() {
32+ let workspace = ForgeBinaryLocator.savedWorkspaceURL
33+ ?? FileManager.default.homeDirectoryForCurrentUser
34+ .appendingPathComponent("ForgeStudioWorkspace")
35+ let store = RunStore(workspace: workspace)
36+ self.store = store
37+ self.supervisor = RunSupervisor(store: store)
38+ Task { await self.validateForge() }
39+ }
40+
41+ func validateForge() async {
42+ guard let binary = ForgeBinaryLocator.savedBinaryURL
43+ ?? ForgeBinaryLocator.candidates().first else {
44+ forgeError = "Binaire forge introuvable — choisissez-le dans les Réglages."
45+ return
46+ }
47+ switch await ForgeBinaryLocator.validate(binary: binary) {
48+ case .success(let v):
49+ forgeDevice = v.version
50+ forgeError = nil
51+ ForgeBinaryLocator.save(binary: binary)
52+ case .failure(let e):
53+ forgeError = e.localizedDescription
54+ }
55+ }
56+
57+ func datasets() -> [Dataset] {
58+ let dataDir = store.workspaceURL.appendingPathComponent("data")
59+ let forgeData = ForgeBinaryLocator.savedBinaryURL?
60+ .deletingLastPathComponent().deletingLastPathComponent()
61+ .appendingPathComponent("data")
62+ var found: [Dataset] = []
63+ for root in [dataDir, forgeData].compactMap({ $0 }) {
64+ guard let subdirs = try? FileManager.default.contentsOfDirectory(
65+ at: root, includingPropertiesForKeys: nil) else { continue }
66+ for dir in subdirs {
67+ if let ds = Dataset.scan(directory: dir),
68+ !found.contains(where: { $0.path == ds.path }) {
69+ found.append(ds)
70+ }
71+ }
72+ }
73+ return found.sorted { $0.name < $1.name }
74+ }
75+}
added ForgeStudio/Models/Dataset.swift +74 −0
@@ -0,0 +1,74 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// A prepared dataset folder: train.bin/val.bin (llm.c-style header
4+// {magic 20240520, version, num_tokens} then uint16 tokens) + a forgebpe
5+// tokenizer model whose vocab size must match model.vocab_size.
6+import Foundation
7+
8+struct Dataset: Identifiable, Equatable {
9+ var id: String { path }
10+ var path: String
11+ var name: String
12+ var trainTokens: Int?
13+ var valTokens: Int?
14+ var vocabSize: Int?
15+ var sizeOnDisk: Int64
16+
17+ static let binMagic: Int32 = 20_240_520
18+
19+ // num_tokens lives at int32 offset 2 of the 256-int32 header.
20+ static func tokenCount(binURL: URL) -> Int? {
21+ guard let fh = try? FileHandle(forReadingFrom: binURL),
22+ let data = try? fh.read(upToCount: 12), data.count == 12 else {
23+ return nil
24+ }
25+ try? fh.close()
26+ let ints = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: (Int32, Int32, Int32).self) }
27+ guard ints.0 == binMagic else { return nil }
28+ return Int(ints.2)
29+ }
30+
31+ // forgebpe v1 header: line 1 "forgebpe v1", line 2 vocab size.
32+ static func vocabSize(tokenizerURL: URL) -> Int? {
33+ guard let fh = try? FileHandle(forReadingFrom: tokenizerURL),
34+ let data = try? fh.read(upToCount: 64),
35+ let head = String(data: data, encoding: .utf8) else { return nil }
36+ try? fh.close()
37+ let lines = head.split(separator: "\n", maxSplits: 2)
38+ guard lines.count >= 2, lines[0].hasPrefix("forgebpe") else { return nil }
39+ return Int(lines[1].trimmingCharacters(in: .whitespaces))
40+ }
41+
42+ static func scan(directory: URL) -> Dataset? {
43+ let fm = FileManager.default
44+ let train = directory.appendingPathComponent("train.bin")
45+ guard fm.fileExists(atPath: train.path) else { return nil }
46+ let val = directory.appendingPathComponent("val.bin")
47+ let tok = (try? fm.contentsOfDirectory(at: directory,
48+ includingPropertiesForKeys: nil))?
49+ .first { $0.pathExtension == "model" }
50+ var size: Int64 = 0
51+ if let items = try? fm.contentsOfDirectory(at: directory,
52+ includingPropertiesForKeys: [.fileSizeKey]) {
53+ for f in items {
54+ size += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
55+ }
56+ }
57+ return Dataset(
58+ path: directory.path,
59+ name: directory.lastPathComponent,
60+ trainTokens: tokenCount(binURL: train),
61+ valTokens: fm.fileExists(atPath: val.path) ? tokenCount(binURL: val) : nil,
62+ vocabSize: tok.flatMap { vocabSize(tokenizerURL: $0) },
63+ sizeOnDisk: size)
64+ }
65+}
66+
67+struct Checkpoint: Identifiable, Equatable {
68+ var id: String { path }
69+ var path: String
70+ var step: Int
71+ var sizeBytes: Int64
72+ var modifiedAt: Date
73+ var valLossAtStep: Double?
74+}
added ForgeStudio/Models/ForgeConfig.swift +295 −0
@@ -0,0 +1,295 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Codable mirror of Forge's JSON config (src/nn/config.h, RESEARCH.md §2).
4+// Field names are byte-compatible with the C++ parser; defaults match the
5+// C++ defaults so a partially-specified JSON round-trips identically.
6+import Foundation
7+
8+struct ModelConfig: Codable, Equatable {
9+ var name = "model"
10+ var nLayers = 6
11+ var dModel = 384
12+ var nHeads = 6
13+ var nKvHeads = 6
14+ var dFf = 1024
15+ var vocabSize = 4096
16+ var contextLength = 512
17+ var tiedEmbeddings = true
18+ var useRope = true
19+ var ropeTheta = 10000.0
20+ var norm = "rmsnorm" // rmsnorm | layernorm
21+ var normEps = 1e-6
22+ var activation = "swiglu" // swiglu | gelu | relu2
23+ var dropout = 0.0
24+ var quant = "none" // none | int8 | ternary
25+ var qkNorm = false
26+ var finalSoftcap = 0.0
27+ var scaleEmbeddings = false
28+ var attentionBias = false
29+ var headDimOverride = 0 // JSON key "head_dim"; 0 = d_model/n_heads
30+ var nopeEvery = 0
31+ var normPlacement = "pre" // pre | post | sandwich
32+ var ropeScaleFactor = 0.0
33+ var ropeScaleLow = 1.0
34+ var ropeScaleHigh = 4.0
35+ var ropeScaleOrigCtx = 8192
36+ var slidingWindow = 0
37+ var slidingGlobalEvery = 0
38+ var ropeThetaGlobal = 0.0
39+ var attnSoftcap = 0.0
40+ var nExperts = 0
41+ var moeTopK = 2
42+ var moeAuxWeight = 0.01
43+ var nSharedExperts = 0
44+ var moeScoring = "softmax" // softmax | sigmoid
45+ var moeNormTopk = true
46+ var routedScalingFactor = 1.0
47+ var moeDFf = 0
48+ var firstKDense = 0
49+ var moeBiasGamma = 0.0
50+
51+ enum CodingKeys: String, CodingKey {
52+ case name
53+ case nLayers = "n_layers", dModel = "d_model", nHeads = "n_heads"
54+ case nKvHeads = "n_kv_heads", dFf = "d_ff", vocabSize = "vocab_size"
55+ case contextLength = "context_length", tiedEmbeddings = "tied_embeddings"
56+ case useRope = "use_rope", ropeTheta = "rope_theta", norm
57+ case normEps = "norm_eps", activation, dropout, quant
58+ case qkNorm = "qk_norm", finalSoftcap = "final_softcap"
59+ case scaleEmbeddings = "scale_embeddings", attentionBias = "attention_bias"
60+ case headDimOverride = "head_dim", nopeEvery = "nope_every"
61+ case normPlacement = "norm_placement"
62+ case ropeScaleFactor = "rope_scale_factor", ropeScaleLow = "rope_scale_low"
63+ case ropeScaleHigh = "rope_scale_high", ropeScaleOrigCtx = "rope_scale_orig_ctx"
64+ case slidingWindow = "sliding_window"
65+ case slidingGlobalEvery = "sliding_global_every"
66+ case ropeThetaGlobal = "rope_theta_global", attnSoftcap = "attn_softcap"
67+ case nExperts = "n_experts", moeTopK = "moe_top_k"
68+ case moeAuxWeight = "moe_aux_weight", nSharedExperts = "n_shared_experts"
69+ case moeScoring = "moe_scoring", moeNormTopk = "moe_norm_topk"
70+ case routedScalingFactor = "routed_scaling_factor", moeDFf = "moe_d_ff"
71+ case firstKDense = "first_k_dense", moeBiasGamma = "moe_bias_gamma"
72+ }
73+
74+ // Forge tolerates missing keys everywhere — mirror that.
75+ init() {}
76+ init(from decoder: Decoder) throws {
77+ let c = try decoder.container(keyedBy: CodingKeys.self)
78+ func g<T: Decodable>(_ k: CodingKeys, _ d: T) -> T {
79+ (try? c.decodeIfPresent(T.self, forKey: k)) as? T ?? d
80+ }
81+ name = g(.name, name); nLayers = g(.nLayers, nLayers)
82+ dModel = g(.dModel, dModel); nHeads = g(.nHeads, nHeads)
83+ nKvHeads = g(.nKvHeads, nHeads); dFf = g(.dFf, dFf)
84+ vocabSize = g(.vocabSize, vocabSize)
85+ contextLength = g(.contextLength, contextLength)
86+ tiedEmbeddings = g(.tiedEmbeddings, tiedEmbeddings)
87+ useRope = g(.useRope, useRope); ropeTheta = g(.ropeTheta, ropeTheta)
88+ norm = g(.norm, norm); normEps = g(.normEps, normEps)
89+ activation = g(.activation, activation); dropout = g(.dropout, dropout)
90+ quant = g(.quant, quant); qkNorm = g(.qkNorm, qkNorm)
91+ finalSoftcap = g(.finalSoftcap, finalSoftcap)
92+ scaleEmbeddings = g(.scaleEmbeddings, scaleEmbeddings)
93+ attentionBias = g(.attentionBias, attentionBias)
94+ headDimOverride = g(.headDimOverride, headDimOverride)
95+ nopeEvery = g(.nopeEvery, nopeEvery)
96+ normPlacement = g(.normPlacement, normPlacement)
97+ ropeScaleFactor = g(.ropeScaleFactor, ropeScaleFactor)
98+ ropeScaleLow = g(.ropeScaleLow, ropeScaleLow)
99+ ropeScaleHigh = g(.ropeScaleHigh, ropeScaleHigh)
100+ ropeScaleOrigCtx = g(.ropeScaleOrigCtx, ropeScaleOrigCtx)
101+ slidingWindow = g(.slidingWindow, slidingWindow)
102+ slidingGlobalEvery = g(.slidingGlobalEvery, slidingGlobalEvery)
103+ ropeThetaGlobal = g(.ropeThetaGlobal, ropeThetaGlobal)
104+ attnSoftcap = g(.attnSoftcap, attnSoftcap)
105+ nExperts = g(.nExperts, nExperts); moeTopK = g(.moeTopK, moeTopK)
106+ moeAuxWeight = g(.moeAuxWeight, moeAuxWeight)
107+ nSharedExperts = g(.nSharedExperts, nSharedExperts)
108+ moeScoring = g(.moeScoring, moeScoring)
109+ moeNormTopk = g(.moeNormTopk, moeNormTopk)
110+ routedScalingFactor = g(.routedScalingFactor, routedScalingFactor)
111+ moeDFf = g(.moeDFf, moeDFf); firstKDense = g(.firstKDense, firstKDense)
112+ moeBiasGamma = g(.moeBiasGamma, moeBiasGamma)
113+ }
114+
115+ var headDim: Int { headDimOverride > 0 ? headDimOverride : dModel / max(nHeads, 1) }
116+
117+ // Same formula as ModelConfig::num_params() — cross-checked by
118+ // ParamCountTests against `forge info`.
119+ var paramCount: Int {
120+ let hd = headDim
121+ var attn = dModel * nHeads * hd + 2 * dModel * nKvHeads * hd
122+ + nHeads * hd * dModel
123+ if attentionBias { attn += (nHeads + 2 * nKvHeads) * hd }
124+ let actMats = activation == "swiglu" ? 3 : 2
125+ let mlpDense = actMats * dModel * dFf
126+ let expertDff = (nExperts > 0 && moeDFf > 0) ? moeDFf : dFf
127+ let mlpMoe = (nExperts + nSharedExperts) * actMats * dModel * expertDff
128+ + nExperts * dModel
129+ let nMoeLayers = nExperts > 0 ? nLayers - min(firstKDense, nLayers) : 0
130+ let mlpTotal = nMoeLayers * mlpMoe + (nLayers - nMoeLayers) * mlpDense
131+ let normsPerLayer = normPlacement == "sandwich" ? 4 : 2
132+ var norms = (norm == "layernorm" ? 2 : 1) * dModel
133+ * (normsPerLayer * nLayers + 1)
134+ if qkNorm { norms += 2 * hd * nLayers }
135+ var total = nLayers * attn + mlpTotal + norms + vocabSize * dModel
136+ if !tiedEmbeddings { total += vocabSize * dModel }
137+ if !useRope { total += contextLength * dModel }
138+ return total
139+ }
140+
141+ // Mirrors the C++ parse-time validation; returns human-actionable errors.
142+ var validationErrors: [String] {
143+ var e: [String] = []
144+ if headDimOverride == 0 && nHeads > 0 && dModel % nHeads != 0 {
145+ e.append("d_model doit être divisible par n_heads (ou fixer head_dim)")
146+ }
147+ if nKvHeads > 0 && nHeads % nKvHeads != 0 {
148+ e.append("n_heads doit être divisible par n_kv_heads")
149+ }
150+ if headDim % 2 != 0 { e.append("head_dim doit être pair (paires RoPE)") }
151+ if !["rmsnorm", "layernorm"].contains(norm) { e.append("norm invalide") }
152+ if !["swiglu", "gelu", "relu2"].contains(activation) {
153+ e.append("activation invalide")
154+ }
155+ if !["pre", "post", "sandwich"].contains(normPlacement) {
156+ e.append("norm_placement invalide")
157+ }
158+ if !["none", "int8", "ternary"].contains(quant) { e.append("quant invalide") }
159+ if nExperts > 0 && !(1...nExperts).contains(moeTopK) {
160+ e.append("moe_top_k doit être dans [1, n_experts]")
161+ }
162+ if nSharedExperts > 0 && nExperts == 0 {
163+ e.append("n_shared_experts requiert n_experts > 0")
164+ }
165+ if firstKDense < 0 || firstKDense > nLayers {
166+ e.append("first_k_dense doit être dans [0, n_layers]")
167+ }
168+ for (v, n) in [(nLayers, "n_layers"), (dModel, "d_model"), (nHeads, "n_heads"),
169+ (dFf, "d_ff"), (vocabSize, "vocab_size"),
170+ (contextLength, "context_length")] where v <= 0 {
171+ e.append("\(n) doit être > 0")
172+ }
173+ return e
174+ }
175+}
176+
177+struct TrainConfig: Codable, Equatable {
178+ var lr = 6e-4
179+ var minLrRatio = 0.1
180+ var warmupSteps = 2000
181+ var maxSteps = 100_000
182+ var schedule = "cosine" // cosine | wsd
183+ var wsdDecayFrac = 0.15
184+ var optimizer = "adamw" // adamw | muon
185+ var muonLr = 0.02
186+ var muonMomentum = 0.95
187+ var beta1 = 0.9
188+ var beta2 = 0.95
189+ var eps = 1e-8
190+ var weightDecay = 0.1
191+ var gradClip = 1.0
192+ var batchSize = 32
193+ var gradAccumSteps = 1
194+ var precision = "f32"
195+ var checkpointEvery = 1000
196+ var forgeSave = true
197+ var forgeDtype = "f32"
198+ var evalEvery = 500
199+ var evalBatches = 20
200+ var seed = 1337
201+ var deterministic = false
202+
203+ enum CodingKeys: String, CodingKey {
204+ case lr, minLrRatio = "min_lr_ratio", warmupSteps = "warmup_steps"
205+ case maxSteps = "max_steps", schedule, wsdDecayFrac = "wsd_decay_frac"
206+ case optimizer, muonLr = "muon_lr", muonMomentum = "muon_momentum"
207+ case beta1, beta2, eps, weightDecay = "weight_decay"
208+ case gradClip = "grad_clip", batchSize = "batch_size"
209+ case gradAccumSteps = "grad_accum_steps", precision
210+ case checkpointEvery = "checkpoint_every", forgeSave = "forge_save"
211+ case forgeDtype = "forge_dtype", evalEvery = "eval_every"
212+ case evalBatches = "eval_batches", seed, deterministic
213+ }
214+
215+ init() {}
216+ init(from decoder: Decoder) throws {
217+ let c = try decoder.container(keyedBy: CodingKeys.self)
218+ func g<T: Decodable>(_ k: CodingKeys, _ d: T) -> T {
219+ (try? c.decodeIfPresent(T.self, forKey: k)) as? T ?? d
220+ }
221+ lr = g(.lr, lr); minLrRatio = g(.minLrRatio, minLrRatio)
222+ warmupSteps = g(.warmupSteps, warmupSteps); maxSteps = g(.maxSteps, maxSteps)
223+ schedule = g(.schedule, schedule)
224+ wsdDecayFrac = g(.wsdDecayFrac, wsdDecayFrac)
225+ optimizer = g(.optimizer, optimizer); muonLr = g(.muonLr, muonLr)
226+ muonMomentum = g(.muonMomentum, muonMomentum)
227+ beta1 = g(.beta1, beta1); beta2 = g(.beta2, beta2); eps = g(.eps, eps)
228+ weightDecay = g(.weightDecay, weightDecay); gradClip = g(.gradClip, gradClip)
229+ batchSize = g(.batchSize, batchSize)
230+ gradAccumSteps = g(.gradAccumSteps, gradAccumSteps)
231+ precision = g(.precision, precision)
232+ checkpointEvery = g(.checkpointEvery, checkpointEvery)
233+ forgeSave = g(.forgeSave, forgeSave); forgeDtype = g(.forgeDtype, forgeDtype)
234+ evalEvery = g(.evalEvery, evalEvery); evalBatches = g(.evalBatches, evalBatches)
235+ seed = g(.seed, seed); deterministic = g(.deterministic, deterministic)
236+ }
237+
238+ var validationErrors: [String] {
239+ var e: [String] = []
240+ if lr <= 0 { e.append("lr doit être > 0") }
241+ if warmupSteps > maxSteps { e.append("warmup_steps ≤ max_steps requis") }
242+ if !["cosine", "wsd"].contains(schedule) { e.append("schedule invalide") }
243+ if !["adamw", "muon"].contains(optimizer) { e.append("optimizer invalide") }
244+ if !(0.0..<1.0).contains(wsdDecayFrac) || wsdDecayFrac <= 0 {
245+ e.append("wsd_decay_frac doit être dans (0, 1)")
246+ }
247+ if !["f32", "f16", "bf16"].contains(forgeDtype) {
248+ e.append("forge_dtype invalide")
249+ }
250+ if batchSize <= 0 || gradAccumSteps <= 0 || maxSteps <= 0 {
251+ e.append("batch/accum/max_steps doivent être > 0")
252+ }
253+ return e
254+ }
255+
256+ // LR schedule preview (same math as src/train/scheduler.h).
257+ func lrAt(step: Int) -> Double {
258+ let minLr = lr * minLrRatio
259+ if step < warmupSteps {
260+ return lr * Double(step + 1) / Double(warmupSteps + 1)
261+ }
262+ if schedule == "wsd" {
263+ let decaySteps = max(1, Int(Double(maxSteps) * wsdDecayFrac))
264+ let decayStart = maxSteps - decaySteps
265+ if step < decayStart { return lr }
266+ if step >= maxSteps { return minLr }
267+ let ratio = Double(step - decayStart) / Double(decaySteps)
268+ return minLr + (lr - minLr) * (1.0 - ratio.squareRoot())
269+ }
270+ if step >= maxSteps { return minLr }
271+ let ratio = Double(step - warmupSteps) / Double(maxSteps - warmupSteps)
272+ return minLr + 0.5 * (1.0 + cos(.pi * ratio)) * (lr - minLr)
273+ }
274+}
275+
276+struct ForgeConfig: Codable, Equatable {
277+ var model = ModelConfig()
278+ var train = TrainConfig()
279+
280+ var tokensPerStep: Int { train.batchSize * model.contextLength * train.gradAccumSteps }
281+ var totalTokens: Int { tokensPerStep * train.maxSteps }
282+ var validationErrors: [String] {
283+ model.validationErrors + train.validationErrors
284+ }
285+
286+ static func load(from url: URL) throws -> ForgeConfig {
287+ try JSONDecoder().decode(ForgeConfig.self, from: Data(contentsOf: url))
288+ }
289+
290+ func exportJSON() throws -> Data {
291+ let enc = JSONEncoder()
292+ enc.outputFormatting = [.prettyPrinted, .sortedKeys]
293+ return try enc.encode(self)
294+ }
295+}
added ForgeStudio/Models/MetricPoint.swift +18 −0
@@ -0,0 +1,18 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// One row of log.csv (RESEARCH.md §3). valLoss is nil when the CSV holds
4+// the -1 sentinel; elapsedS is nil for pre-elapsed_s runs (6-column header).
5+import Foundation
6+
7+struct MetricPoint: Equatable, Sendable {
8+ var step: Int
9+ var trainLoss: Double
10+ var lr: Double
11+ var gradNorm: Double
12+ var tokensPerSec: Double
13+ var valLoss: Double?
14+ var elapsedS: Double?
15+
16+ var tokensSeen: (Int) -> Int { { tokensPerStep in (step + 1) * tokensPerStep } }
17+ var perplexity: Double { exp(trainLoss) }
18+}
added ForgeStudio/Models/Run.swift +53 −0
@@ -0,0 +1,53 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// A training run: identity, config, directories, and the state machine
4+// (RunSupervisor is the only mutator; every transition is persisted
5+// atomically by RunStore).
6+import Foundation
7+
8+enum RunState: String, Codable, CaseIterable {
9+ case queued, launching, running, finishing, finished, failed, stopped
10+
11+ var isActive: Bool {
12+ switch self {
13+ case .launching, .running, .finishing: return true
14+ default: return false
15+ }
16+ }
17+
18+ // Legal transitions — RunSupervisor refuses anything else.
19+ static let transitions: [RunState: Set<RunState>] = [
20+ .queued: [.launching, .stopped],
21+ .launching: [.running, .failed, .stopped],
22+ .running: [.finishing, .finished, .failed, .stopped],
23+ .finishing: [.finished, .failed, .stopped],
24+ .finished: [], .failed: [], .stopped: [],
25+ ]
26+}
27+
28+struct Run: Codable, Identifiable, Equatable {
29+ var id: UUID = UUID()
30+ var name: String
31+ var createdAt: Date
32+ var state: RunState = .queued
33+ var config: ForgeConfig
34+ var datasetPath: String
35+ var outDirectory: String // <workspace>/runs/<name>-<timestamp>
36+ var resumeCheckpoint: String? // forge train --resume
37+ var pid: Int32? // live forge process (crash recovery)
38+ var lastStep: Int?
39+ var lastTrainLoss: Double?
40+ var bestValLoss: Double?
41+ var bestValStep: Int?
42+ var exitCode: Int32?
43+ var failureReason: String?
44+
45+ var logCSVPath: String { outDirectory + "/log.csv" }
46+ var runLogPath: String { outDirectory + "/run.log" }
47+ var configPath: String { outDirectory + "/config.json" }
48+
49+ var progress: Double {
50+ guard let step = lastStep, config.train.maxSteps > 0 else { return 0 }
51+ return min(1.0, Double(step + 1) / Double(config.train.maxSteps))
52+ }
53+}
added ForgeStudio/Services/ForgeBinaryLocator.swift +66 −0
@@ -0,0 +1,66 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Finds and validates the forge binary: a candidate is accepted only if
4+// `forge info` runs and reports a device — the same probe surfaces the GPU
5+// name for the header. Paths persist in UserDefaults.
6+import Foundation
7+
8+struct ForgeValidation: Equatable, Sendable {
9+ var version: String // device line, e.g. "Apple M5 Max"
10+ var binaryPath: String
11+}
12+
13+enum ForgeBinaryLocator {
14+ static let defaultsKey = "forge.binary.path"
15+ static let workspaceKey = "forge.workspace.path"
16+
17+ static var savedBinaryURL: URL? {
18+ UserDefaults.standard.string(forKey: defaultsKey).map { URL(fileURLWithPath: $0) }
19+ }
20+
21+ static var savedWorkspaceURL: URL? {
22+ UserDefaults.standard.string(forKey: workspaceKey).map { URL(fileURLWithPath: $0) }
23+ }
24+
25+ static func save(binary: URL) {
26+ UserDefaults.standard.set(binary.path, forKey: defaultsKey)
27+ }
28+
29+ static func save(workspace: URL) {
30+ UserDefaults.standard.set(workspace.path, forKey: workspaceKey)
31+ }
32+
33+ /// Candidate locations, most specific first.
34+ static func candidates() -> [URL] {
35+ var urls: [URL] = []
36+ if let saved = savedBinaryURL { urls.append(saved) }
37+ let home = FileManager.default.homeDirectoryForCurrentUser
38+ urls.append(home.appendingPathComponent("Desktop/forge/build/forge"))
39+ urls.append(URL(fileURLWithPath: "/usr/local/bin/forge"))
40+ return urls.filter { FileManager.default.isExecutableFile(atPath: $0.path) }
41+ }
42+
43+ /// Runs `forge info` and parses the device line.
44+ static func validate(binary: URL) async -> Result<ForgeValidation, Error> {
45+ let runner = ProcessRunner()
46+ do {
47+ let (lines, exit) = try await runner.launch(
48+ executable: binary, arguments: ["info"],
49+ currentDirectory: binary.deletingLastPathComponent())
50+ var device = ""
51+ for await (line, _) in lines where line.hasPrefix("device: ") {
52+ device = String(line.dropFirst("device: ".count))
53+ }
54+ let status = await exit.value
55+ guard status.code == 0, !device.isEmpty else {
56+ throw NSError(domain: "ForgeStudio", code: 1, userInfo: [
57+ NSLocalizedDescriptionKey:
58+ "forge info a échoué (code \(status.code)). Vérifiez que forge.metallib est à côté du binaire.",
59+ ])
60+ }
61+ return .success(ForgeValidation(version: device, binaryPath: binary.path))
62+ } catch {
63+ return .failure(error)
64+ }
65+ }
66+}
added ForgeStudio/Services/LogParser.swift +85 −0
@@ -0,0 +1,85 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Parsers for Forge's two output channels (RESEARCH.md §3):
4+// - log.csv: header-driven, the structured metrics source of truth.
5+// Handles both the 7-column (elapsed_s) and legacy 6-column layouts.
6+// - stdout: events the CSV doesn't carry (checkpoint saved, fmodel commit,
7+// run banner), parsed defensively — garbage lines are ignored, never fatal.
8+import Foundation
9+
10+struct LogParser {
11+ // ---- log.csv --------------------------------------------------------
12+
13+ struct CSVSchema: Equatable {
14+ var columns: [String]
15+ static let current = CSVSchema(columns: [
16+ "step", "loss", "lr", "grad_norm", "tokens_per_sec", "val_loss",
17+ "elapsed_s",
18+ ])
19+ }
20+
21+ private(set) var schema: CSVSchema?
22+
23+ /// Feed one CSV line (header or data). Returns a point for data lines.
24+ mutating func parseCSVLine(_ line: String) -> MetricPoint? {
25+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
26+ guard !trimmed.isEmpty else { return nil }
27+ if trimmed.hasPrefix("step,") {
28+ schema = CSVSchema(columns: trimmed.split(separator: ",").map(String.init))
29+ return nil
30+ }
31+ let cols = schema?.columns ?? CSVSchema.current.columns
32+ let parts = trimmed.split(separator: ",", omittingEmptySubsequences: false)
33+ guard parts.count >= 5 else { return nil }
34+ func f(_ name: String) -> Double? {
35+ guard let i = cols.firstIndex(of: name), i < parts.count else { return nil }
36+ return Double(parts[i])
37+ }
38+ guard let step = f("step").map({ Int($0) }),
39+ let loss = f("loss") else { return nil }
40+ let val = f("val_loss")
41+ return MetricPoint(
42+ step: step,
43+ trainLoss: loss,
44+ lr: f("lr") ?? 0,
45+ gradNorm: f("grad_norm") ?? 0,
46+ tokensPerSec: f("tokens_per_sec") ?? 0,
47+ valLoss: (val ?? -1) >= 0 ? val : nil,
48+ elapsedS: f("elapsed_s"))
49+ }
50+
51+ // ---- stdout events ---------------------------------------------------
52+
53+ enum StdoutEvent: Equatable {
54+ case banner(params: Int, steps: Int, tokensPerStep: Int, backend: String)
55+ case checkpointSaved(path: String)
56+ case forgeCommit(manifest: String)
57+ }
58+
59+ static func parseStdout(_ line: String) -> StdoutEvent? {
60+ if line.hasPrefix("checkpoint saved: ") {
61+ return .checkpointSaved(path: String(line.dropFirst("checkpoint saved: ".count)))
62+ }
63+ if line.hasPrefix("fmodel: ") {
64+ let rest = line.dropFirst("fmodel: ".count)
65+ let manifest = rest.split(separator: " ").first.map(String.init) ?? ""
66+ return .forgeCommit(manifest: manifest)
67+ }
68+ if line.hasPrefix("training ") {
69+ // "training <name>: <p> params, <s> steps, <t> tokens/step, backend=<b>…"
70+ // The model name may contain digits — only parse after the colon.
71+ guard let colon = line.range(of: ": ") else { return nil }
72+ let tail = line[colon.upperBound...]
73+ let numbers = tail.split(whereSeparator: { !"0123456789".contains($0) })
74+ .compactMap { Int($0) }
75+ let backend = line.range(of: "backend=").map {
76+ String(line[$0.upperBound...].prefix(while: { $0 != "," }))
77+ }
78+ if numbers.count >= 3 {
79+ return .banner(params: numbers[0], steps: numbers[1],
80+ tokensPerStep: numbers[2], backend: backend ?? "?")
81+ }
82+ }
83+ return nil
84+ }
85+}
added ForgeStudio/Services/MetricsStore.swift +80 −0
@@ -0,0 +1,80 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Per-run time series. Append-only (raw data is never thrown away); the UI
4+// asks for downsampled snapshots sized to the chart's pixel width. An actor
5+// so live ingest and UI reads never race.
6+import Foundation
7+
8+actor MetricsStore {
9+ private(set) var points: [MetricPoint] = []
10+ private var parser = LogParser()
11+ private var csvOffset: UInt64 = 0
12+
13+ struct Snapshot: Sendable {
14+ var train: [Downsampler.XY]
15+ var trainEMA: [Downsampler.XY]
16+ var val: [Downsampler.XY]
17+ var lr: [Downsampler.XY]
18+ var tokensPerSec: [Downsampler.XY]
19+ var gradNorm: [Downsampler.XY]
20+ var lastPoint: MetricPoint?
21+ var bestVal: (step: Int, loss: Double)?
22+ var count: Int
23+ }
24+
25+ func append(_ p: MetricPoint) { points.append(p) }
26+
27+ func reset() {
28+ points.removeAll()
29+ parser = LogParser()
30+ csvOffset = 0
31+ }
32+
33+ /// Incremental tail of log.csv: reads only bytes past the last offset,
34+ /// so polling during a live run costs O(new lines).
35+ func ingestCSV(at url: URL) {
36+ guard let fh = try? FileHandle(forReadingFrom: url) else { return }
37+ defer { try? fh.close() }
38+ try? fh.seek(toOffset: csvOffset)
39+ guard let data = try? fh.readToEnd(), !data.isEmpty else { return }
40+ // Only consume complete lines; leave a partial tail for next poll.
41+ var consumable = data
42+ if let lastNL = data.lastIndex(of: 0x0A) {
43+ consumable = data[data.startIndex...lastNL]
44+ } else {
45+ return
46+ }
47+ csvOffset += UInt64(consumable.count)
48+ guard let text = String(data: consumable, encoding: .utf8) else { return }
49+ for line in text.split(separator: "\n") {
50+ if let p = parser.parseCSVLine(String(line)) { points.append(p) }
51+ }
52+ }
53+
54+ func snapshot(maxPoints: Int, smoothing: Double) -> Snapshot {
55+ func series(_ f: (MetricPoint) -> Double?) -> [Downsampler.XY] {
56+ points.compactMap { p in f(p).map { .init(x: Double(p.step), y: $0) } }
57+ }
58+ let train = series { $0.trainLoss }
59+ let emaValues = Smoothing.ema(train.map(\.y), smoothing: smoothing)
60+ let ema = zip(train, emaValues).map { Downsampler.XY(x: $0.x, y: $1) }
61+ let val = series { $0.valLoss }
62+ var best: (Int, Double)?
63+ for p in points {
64+ if let v = p.valLoss, v.isFinite, best == nil || v < best!.1 {
65+ best = (p.step, v)
66+ }
67+ }
68+ func ds(_ s: [Downsampler.XY]) -> [Downsampler.XY] {
69+ Downsampler.lttb(s, threshold: maxPoints)
70+ }
71+ return Snapshot(
72+ train: ds(train), trainEMA: ds(ema), val: val, // val is sparse: keep raw
73+ lr: ds(series { $0.lr }),
74+ tokensPerSec: ds(series { $0.tokensPerSec }),
75+ gradNorm: ds(series { $0.gradNorm }),
76+ lastPoint: points.last,
77+ bestVal: best,
78+ count: points.count)
79+ }
80+}
added ForgeStudio/Services/ProcessRunner.swift +111 −0
@@ -0,0 +1,111 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Actor wrapping Foundation.Process: launch, stream stdout/stderr line by
4+// line (partial lines buffered, never lost), signal, await exit. All
5+// consumers get lines through AsyncStream — nothing ever touches the main
6+// thread.
7+import Foundation
8+
9+actor ProcessRunner {
10+ struct Exit: Sendable {
11+ let code: Int32
12+ let wasSignaled: Bool
13+ }
14+
15+ enum RunnerError: LocalizedError {
16+ case notRunning
17+ case launchFailed(String)
18+ var errorDescription: String? {
19+ switch self {
20+ case .notRunning: return "Le processus n'est pas en cours d'exécution."
21+ case .launchFailed(let why): return "Échec du lancement : \(why)"
22+ }
23+ }
24+ }
25+
26+ private var process: Process?
27+
28+ var pid: Int32? { process?.processIdentifier }
29+ var isRunning: Bool { process?.isRunning ?? false }
30+
31+ /// Launches `executable args`, returning a line stream (stdout+stderr
32+ /// merged, tagged) and a task that resolves with the exit status.
33+ func launch(executable: URL, arguments: [String],
34+ currentDirectory: URL? = nil,
35+ environment: [String: String]? = nil)
36+ throws -> (lines: AsyncStream<(line: String, isStderr: Bool)>,
37+ exit: Task<Exit, Never>)
38+ {
39+ let p = Process()
40+ p.executableURL = executable
41+ p.arguments = arguments
42+ if let cwd = currentDirectory { p.currentDirectoryURL = cwd }
43+ if let env = environment {
44+ p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 }
45+ }
46+ let outPipe = Pipe(), errPipe = Pipe()
47+ p.standardOutput = outPipe
48+ p.standardError = errPipe
49+
50+ var continuation: AsyncStream<(line: String, isStderr: Bool)>.Continuation!
51+ let stream = AsyncStream<(line: String, isStderr: Bool)> { continuation = $0 }
52+ let cont = continuation!
53+
54+ // Two readers feeding one stream; a small actor-free state via
55+ // DispatchQueue keeps partial-line buffers private per pipe.
56+ let group = DispatchGroup()
57+ for (pipe, isErr) in [(outPipe, false), (errPipe, true)] {
58+ group.enter()
59+ let handle = pipe.fileHandleForReading
60+ DispatchQueue.global(qos: .userInitiated).async {
61+ var buffer = Data()
62+ while true {
63+ let chunk = handle.availableData
64+ if chunk.isEmpty { break } // EOF
65+ buffer.append(chunk)
66+ while let nl = buffer.firstIndex(of: 0x0A) {
67+ let lineData = buffer[buffer.startIndex..<nl]
68+ buffer.removeSubrange(buffer.startIndex...nl)
69+ if let line = String(data: lineData, encoding: .utf8) {
70+ cont.yield((line, isErr))
71+ }
72+ }
73+ }
74+ if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {
75+ cont.yield((tail, isErr)) // final unterminated line
76+ }
77+ group.leave()
78+ }
79+ }
80+
81+ do { try p.run() } catch {
82+ cont.finish()
83+ throw RunnerError.launchFailed(error.localizedDescription)
84+ }
85+ process = p
86+
87+ let exitTask = Task<Exit, Never> {
88+ await withCheckedContinuation { (k: CheckedContinuation<Void, Never>) in
89+ group.notify(queue: .global()) { k.resume() }
90+ }
91+ p.waitUntilExit()
92+ cont.finish()
93+ return Exit(code: p.terminationStatus,
94+ wasSignaled: p.terminationReason == .uncaughtSignal)
95+ }
96+ return (stream, exitTask)
97+ }
98+
99+ /// SIGTERM (forge has no SIGINT handler — see RESEARCH.md §5: the
100+ /// process dies without a final checkpoint; recovery = ckpt_latest.bin).
101+ func terminate() throws {
102+ guard let p = process, p.isRunning else { throw RunnerError.notRunning }
103+ p.terminate()
104+ }
105+
106+ /// Escalation of last resort; the caller confirms with the user first.
107+ func kill() throws {
108+ guard let p = process, p.isRunning else { throw RunnerError.notRunning }
109+ Darwin.kill(p.processIdentifier, SIGKILL)
110+ }
111+}
added ForgeStudio/Services/RunStore.swift +60 −0
@@ -0,0 +1,60 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Run registry with atomic persistence: every mutation writes
4+// runs.json.tmp then renames — the app survives kill -9 at any moment with
5+// either the old or the new registry, never a torn one.
6+import Foundation
7+
8+@Observable
9+final class RunStore {
10+ private(set) var runs: [Run] = []
11+ let workspaceURL: URL
12+ private var registryURL: URL { workspaceURL.appendingPathComponent("runs.json") }
13+
14+ init(workspace: URL) {
15+ workspaceURL = workspace
16+ load()
17+ }
18+
19+ func load() {
20+ guard let data = try? Data(contentsOf: registryURL),
21+ let decoded = try? JSONDecoder().decode([Run].self, from: data) else {
22+ return
23+ }
24+ runs = decoded
25+ }
26+
27+ func upsert(_ run: Run) {
28+ if let i = runs.firstIndex(where: { $0.id == run.id }) {
29+ runs[i] = run
30+ } else {
31+ runs.append(run)
32+ }
33+ persist()
34+ }
35+
36+ func remove(_ run: Run) {
37+ runs.removeAll { $0.id == run.id }
38+ persist()
39+ }
40+
41+ private func persist() {
42+ do {
43+ let enc = JSONEncoder()
44+ enc.outputFormatting = [.prettyPrinted, .sortedKeys]
45+ enc.dateEncodingStrategy = .iso8601
46+ let data = try enc.encode(runs)
47+ let tmp = registryURL.appendingPathExtension("tmp")
48+ try FileManager.default
49+ .createDirectory(at: workspaceURL, withIntermediateDirectories: true)
50+ try data.write(to: tmp, options: .atomic)
51+ _ = try FileManager.default.replaceItemAt(registryURL, withItemAt: tmp)
52+ } catch {
53+ // Persistence failure must be visible, never silent: kept on the
54+ // store for the UI to surface.
55+ lastPersistError = error.localizedDescription
56+ }
57+ }
58+
59+ private(set) var lastPersistError: String?
60+}
added ForgeStudio/Services/RunSupervisor.swift +158 −0
@@ -0,0 +1,158 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Owns a live run end to end: builds the out directory, launches
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.
6+// Single-writer: all Run mutations flow through here and persist via
7+// RunStore immediately.
8+import Foundation
9+
10+@Observable
11+@MainActor
12+final class RunSupervisor {
13+ let store: RunStore
14+ let metrics = MetricsStore()
15+
16+ private(set) var activeRunID: UUID?
17+ private(set) var consoleLines: [String] = []
18+ private var runner: ProcessRunner?
19+ private var csvPoller: Task<Void, Never>?
20+
21+ init(store: RunStore) {
22+ self.store = store
23+ }
24+
25+ enum SupervisorError: LocalizedError {
26+ case busy, invalidConfig([String]), forgeNotConfigured
27+ var errorDescription: String? {
28+ switch self {
29+ case .busy:
30+ return "Un entraînement est déjà en cours — Forge Studio les exécute séquentiellement."
31+ case .invalidConfig(let errs):
32+ return "Config invalide : " + errs.joined(separator: " · ")
33+ case .forgeNotConfigured:
34+ return "Binaire forge non configuré (Réglages)."
35+ }
36+ }
37+ }
38+
39+ private func transition(_ run: inout Run, to next: RunState) {
40+ guard RunState.transitions[run.state]?.contains(next) == true else { return }
41+ run.state = next
42+ store.upsert(run)
43+ }
44+
45+ func start(config: ForgeConfig, datasetPath: String, name: String,
46+ resumeFrom: String? = nil) async throws {
47+ guard activeRunID == nil else { throw SupervisorError.busy }
48+ let errors = config.validationErrors
49+ guard errors.isEmpty else { throw SupervisorError.invalidConfig(errors) }
50+ guard let forge = ForgeBinaryLocator.savedBinaryURL else {
51+ throw SupervisorError.forgeNotConfigured
52+ }
53+
54+ let stamp = ISO8601DateFormatter().string(from: .now)
55+ .replacingOccurrences(of: ":", with: "-")
56+ let outDir = store.workspaceURL
57+ .appendingPathComponent("runs/\(name)-\(stamp)")
58+ try FileManager.default.createDirectory(at: outDir,
59+ withIntermediateDirectories: true)
60+ var run = Run(name: name, createdAt: .now, config: config,
61+ datasetPath: datasetPath, outDirectory: outDir.path,
62+ resumeCheckpoint: resumeFrom)
63+ try config.exportJSON().write(to: URL(fileURLWithPath: run.configPath))
64+ store.upsert(run)
65+
66+ var args = ["train", "--config", run.configPath, "--data", datasetPath,
67+ "--out", run.outDirectory]
68+ if let resume = resumeFrom { args += ["--resume", resume] }
69+
70+ transition(&run, to: .launching)
71+ activeRunID = run.id
72+ consoleLines.removeAll()
73+ await metrics.reset()
74+
75+ let processRunner = ProcessRunner()
76+ runner = processRunner
77+ let (lines, exit) = try await processRunner.launch(
78+ executable: forge, arguments: args,
79+ currentDirectory: forge.deletingLastPathComponent())
80+ run.pid = await processRunner.pid
81+ transition(&run, to: .running)
82+
83+ // Console + run.log + stdout events.
84+ let logURL = URL(fileURLWithPath: run.runLogPath)
85+ FileManager.default.createFile(atPath: logURL.path, contents: nil)
86+ let logHandle = try? FileHandle(forWritingTo: logURL)
87+ let runID = run.id
88+ Task { [weak self] in
89+ for await (line, isErr) in lines {
90+ logHandle?.write(Data((line + "\n").utf8))
91+ await MainActor.run {
92+ guard let self else { return }
93+ self.consoleLines.append(isErr ? "⚠︎ " + line : line)
94+ if self.consoleLines.count > 5000 {
95+ self.consoleLines.removeFirst(1000)
96+ }
97+ }
98+ }
99+ try? logHandle?.close()
100+ }
101+
102+ // CSV poller: 1 Hz incremental tail, updates run summary fields.
103+ let csvURL = URL(fileURLWithPath: run.logCSVPath)
104+ csvPoller = Task { [weak self] in
105+ while !Task.isCancelled {
106+ guard let self else { return }
107+ await self.metrics.ingestCSV(at: csvURL)
108+ let snap = await self.metrics.snapshot(maxPoints: 4, smoothing: 0)
109+ await MainActor.run {
110+ guard var r = self.store.runs.first(where: { $0.id == runID })
111+ else { return }
112+ if let last = snap.lastPoint {
113+ r.lastStep = last.step
114+ r.lastTrainLoss = last.trainLoss
115+ }
116+ if let best = snap.bestVal {
117+ r.bestValLoss = best.loss
118+ r.bestValStep = best.step
119+ }
120+ self.store.upsert(r)
121+ }
122+ try? await Task.sleep(for: .seconds(1))
123+ }
124+ }
125+
126+ // Exit watcher.
127+ Task { [weak self] in
128+ let status = await exit.value
129+ await MainActor.run {
130+ guard let self,
131+ var r = self.store.runs.first(where: { $0.id == runID })
132+ else { return }
133+ self.csvPoller?.cancel()
134+ r.pid = nil
135+ r.exitCode = status.code
136+ if status.code == 0 {
137+ self.transition(&r, to: .finished)
138+ } else if r.state == .finishing || status.wasSignaled {
139+ self.transition(&r, to: .stopped)
140+ } else {
141+ r.failureReason = "forge s'est terminé avec le code \(status.code) — voir run.log"
142+ self.transition(&r, to: .failed)
143+ }
144+ self.activeRunID = nil
145+ self.runner = nil
146+ }
147+ }
148+ }
149+
150+ /// SIGTERM. Forge has no signal handler: the process dies immediately and
151+ /// the last ckpt_latest.bin is the recovery point (UI says so).
152+ func stop() async {
153+ guard let id = activeRunID,
154+ var run = store.runs.first(where: { $0.id == id }) else { return }
155+ transition(&run, to: .finishing)
156+ try? await runner?.terminate()
157+ }
158+}
added ForgeStudio/Services/SystemInfo.swift +37 −0
@@ -0,0 +1,37 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Chip name, core count and unified memory via sysctl — feeds the memory
4+// estimate badge in the New Run form.
5+import Foundation
6+
7+enum SystemInfo {
8+ static func sysctlString(_ name: String) -> String? {
9+ var size = 0
10+ guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil }
11+ var buf = [CChar](repeating: 0, count: size)
12+ guard sysctlbyname(name, &buf, &size, nil, 0) == 0 else { return nil }
13+ return String(cString: buf)
14+ }
15+
16+ static func sysctlInt(_ name: String) -> Int64? {
17+ var value: Int64 = 0
18+ var size = MemoryLayout<Int64>.size
19+ guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil }
20+ return value
21+ }
22+
23+ static var chipName: String { sysctlString("machdep.cpu.brand_string") ?? "Apple Silicon" }
24+ static var memoryBytes: Int64 { sysctlInt("hw.memsize") ?? 0 }
25+ static var cpuCores: Int { Int(sysctlInt("hw.ncpu") ?? 0) }
26+
27+ /// Rough training footprint: weights+grads+AdamW moments in f32 (4 copies
28+ /// of params × 4 bytes) + activation estimate per micro-batch.
29+ static func estimatedTrainingBytes(config: ForgeConfig) -> Int64 {
30+ let params = Int64(config.model.paramCount)
31+ let states = params * 4 * 4
32+ let actPerToken = Int64(config.model.nLayers * config.model.dModel * 24)
33+ let activations = actPerToken
34+ * Int64(config.train.batchSize * config.model.contextLength)
35+ return states + activations
36+ }
37+}
added ForgeStudio/Views/ContentView.swift +118 −0
@@ -0,0 +1,118 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Navigation shell: runs in the sidebar, live dashboard in the detail pane.
4+import SwiftUI
5+
6+struct ContentView: View {
7+ @Environment(AppModel.self) private var app
8+ @State private var selection: UUID?
9+ @State private var showNewRun = false
10+
11+ var body: some View {
12+ NavigationSplitView {
13+ List(selection: $selection) {
14+ Section("Runs") {
15+ ForEach(app.store.runs.sorted { $0.createdAt > $1.createdAt }) { run in
16+ RunRow(run: run).tag(run.id)
17+ }
18+ }
19+ }
20+ .navigationSplitViewColumnWidth(min: 220, ideal: 260)
21+ .toolbar {
22+ ToolbarItem(placement: .primaryAction) {
23+ Button {
24+ showNewRun = true
25+ } label: {
26+ Label("Nouveau run", systemImage: "plus")
27+ }
28+ .disabled(app.forgeError != nil)
29+ }
30+ }
31+ } detail: {
32+ if let id = selection,
33+ let run = app.store.runs.first(where: { $0.id == id }) {
34+ RunDetailView(run: run)
35+ } else {
36+ EmptyStateView()
37+ }
38+ }
39+ .sheet(isPresented: $showNewRun) {
40+ NewRunSheet()
41+ }
42+ }
43+}
44+
45+struct RunRow: View {
46+ let run: Run
47+
48+ var body: some View {
49+ VStack(alignment: .leading, spacing: 2) {
50+ HStack {
51+ Text(run.name).font(.headline)
52+ Spacer()
53+ StateBadge(state: run.state)
54+ }
55+ HStack(spacing: 8) {
56+ Text("\(run.config.model.paramCount.formatted(.number.notation(.compactName))) params")
57+ if let loss = run.lastTrainLoss {
58+ Text(String(format: "loss %.3f", loss))
59+ }
60+ if run.state.isActive {
61+ ProgressView(value: run.progress).frame(width: 60)
62+ }
63+ }
64+ .font(.caption)
65+ .foregroundStyle(.secondary)
66+ }
67+ .padding(.vertical, 2)
68+ }
69+}
70+
71+struct StateBadge: View {
72+ let state: RunState
73+
74+ var color: Color {
75+ switch state {
76+ case .running, .launching: return .green
77+ case .finished: return .blue
78+ case .failed: return .red
79+ case .stopped: return .orange
80+ case .queued, .finishing: return .gray
81+ }
82+ }
83+
84+ var body: some View {
85+ Text(state.rawValue)
86+ .font(.caption2.weight(.semibold))
87+ .padding(.horizontal, 6)
88+ .padding(.vertical, 2)
89+ .background(color.opacity(0.18), in: Capsule())
90+ .foregroundStyle(color)
91+ }
92+}
93+
94+struct EmptyStateView: View {
95+ @Environment(AppModel.self) private var app
96+
97+ var body: some View {
98+ VStack(spacing: 12) {
99+ Image(systemName: "flame")
100+ .font(.system(size: 48))
101+ .foregroundStyle(.orange)
102+ Text("Forge Studio").font(.largeTitle.weight(.semibold))
103+ if let device = app.forgeDevice {
104+ Label(device, systemImage: "cpu")
105+ .foregroundStyle(.secondary)
106+ }
107+ if let error = app.forgeError {
108+ Label(error, systemImage: "exclamationmark.triangle")
109+ .foregroundStyle(.red)
110+ SettingsLink { Text("Ouvrir les Réglages…") }
111+ } else {
112+ Text("Sélectionnez un run, ou créez-en un nouveau (+).")
113+ .foregroundStyle(.secondary)
114+ }
115+ }
116+ .frame(maxWidth: .infinity, maxHeight: .infinity)
117+ }
118+}
added ForgeStudio/Views/NewRunSheet.swift +242 −0
@@ -0,0 +1,242 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// New Run editor: preset picker, the high-traffic model/train fields, the
4+// live derived panel (params, tokens/step, epochs, memory badge) and the LR
5+// schedule preview. Inline validation gates the Start button. (The long
6+// tail of variant knobs lives in the "Avancé" JSON editor — full form in M2.)
7+import Charts
8+import SwiftUI
9+
10+struct NewRunSheet: View {
11+ @Environment(AppModel.self) private var app
12+ @Environment(\.dismiss) private var dismiss
13+ @State private var config = ForgeConfig()
14+ @State private var runName = "run"
15+ @State private var datasetPath = ""
16+ @State private var startError: String?
17+
18+ private var datasets: [Dataset] { app.datasets() }
19+ private var dataset: Dataset? { datasets.first { $0.path == datasetPath } }
20+ private var errors: [String] {
21+ var e = config.validationErrors
22+ if datasetPath.isEmpty { e.append("choisir un dataset") }
23+ if let v = dataset?.vocabSize, v != config.model.vocabSize {
24+ e.append("vocab_size (\(config.model.vocabSize)) ≠ tokenizer du dataset (\(v))")
25+ }
26+ return e
27+ }
28+
29+ var body: some View {
30+ VStack(spacing: 0) {
31+ HStack {
32+ Text("Nouveau run").font(.title2.weight(.semibold))
33+ Spacer()
34+ presetMenu
35+ }
36+ .padding()
37+ Divider()
38+ HSplitView {
39+ Form {
40+ Section("Run") {
41+ TextField("Nom", text: $runName)
42+ Picker("Dataset", selection: $datasetPath) {
43+ Text("—").tag("")
44+ ForEach(datasets) { ds in
45+ Text("\(ds.name) · vocab \(ds.vocabSize ?? 0) · \((ds.trainTokens ?? 0).formatted(.number.notation(.compactName))) tokens")
46+ .tag(ds.path)
47+ }
48+ }
49+ .onChange(of: datasetPath) {
50+ if let v = dataset?.vocabSize { config.model.vocabSize = v }
51+ }
52+ }
53+ Section("Modèle") {
54+ intField("n_layers", $config.model.nLayers)
55+ intField("d_model", $config.model.dModel)
56+ intField("n_heads", $config.model.nHeads)
57+ intField("n_kv_heads (GQA)", $config.model.nKvHeads)
58+ intField("d_ff", $config.model.dFf)
59+ intField("context_length", $config.model.contextLength)
60+ Picker("activation", selection: $config.model.activation) {
61+ ForEach(["swiglu", "gelu", "relu2"], id: \.self) { Text($0) }
62+ }
63+ Picker("norm", selection: $config.model.norm) {
64+ ForEach(["rmsnorm", "layernorm"], id: \.self) { Text($0) }
65+ }
66+ Toggle("tied_embeddings", isOn: $config.model.tiedEmbeddings)
67+ Toggle("qk_norm", isOn: $config.model.qkNorm)
68+ intField("sliding_window (0 = full)", $config.model.slidingWindow)
69+ }
70+ Section("Entraînement") {
71+ doubleField("lr", $config.train.lr)
72+ intField("max_steps", $config.train.maxSteps)
73+ intField("warmup_steps", $config.train.warmupSteps)
74+ Picker("schedule", selection: $config.train.schedule) {
75+ ForEach(["cosine", "wsd"], id: \.self) { Text($0) }
76+ }
77+ Picker("optimizer", selection: $config.train.optimizer) {
78+ ForEach(["adamw", "muon"], id: \.self) { Text($0) }
79+ }
80+ intField("batch_size (micro)", $config.train.batchSize)
81+ intField("grad_accum_steps", $config.train.gradAccumSteps)
82+ intField("checkpoint_every", $config.train.checkpointEvery)
83+ intField("eval_every", $config.train.evalEvery)
84+ }
85+ }
86+ .formStyle(.grouped)
87+ .frame(minWidth: 380)
88+
89+ DerivedPanel(config: config, dataset: dataset)
90+ .frame(minWidth: 300)
91+ .padding()
92+ }
93+ Divider()
94+ HStack {
95+ if let first = errors.first {
96+ Label(first, systemImage: "exclamationmark.triangle")
97+ .foregroundStyle(.red)
98+ .font(.callout)
99+ }
100+ if let startError {
101+ Text(startError).foregroundStyle(.red).font(.callout)
102+ }
103+ Spacer()
104+ Button("Annuler") { dismiss() }
105+ Button("Démarrer l'entraînement") {
106+ Task {
107+ do {
108+ config.model.name = runName
109+ try await app.supervisor.start(
110+ config: config, datasetPath: datasetPath,
111+ name: runName)
112+ dismiss()
113+ } catch {
114+ startError = error.localizedDescription
115+ }
116+ }
117+ }
118+ .keyboardShortcut(.defaultAction)
119+ .disabled(!errors.isEmpty)
120+ }
121+ .padding()
122+ }
123+ .frame(minWidth: 860, minHeight: 620)
124+ .onAppear {
125+ if let first = datasets.first { datasetPath = first.path }
126+ }
127+ }
128+
129+ private var presetMenu: some View {
130+ Menu("Presets") {
131+ ForEach(Presets.all, id: \.name) { preset in
132+ Button(preset.name) {
133+ config = preset.config
134+ runName = preset.config.model.name
135+ if let v = dataset?.vocabSize { config.model.vocabSize = v }
136+ }
137+ }
138+ }
139+ .frame(width: 120)
140+ }
141+
142+ private func intField(_ label: String, _ value: Binding<Int>) -> some View {
143+ TextField(label, value: value, format: .number)
144+ }
145+
146+ private func doubleField(_ label: String, _ value: Binding<Double>) -> some View {
147+ TextField(label, value: value, format: .number.precision(.significantDigits(1...6)))
148+ }
149+}
150+
151+struct DerivedPanel: View {
152+ let config: ForgeConfig
153+ let dataset: Dataset?
154+
155+ var body: some View {
156+ VStack(alignment: .leading, spacing: 14) {
157+ Text("Dérivés").font(.headline)
158+ derived("Paramètres",
159+ config.model.paramCount.formatted(.number.notation(.compactName)))
160+ derived("Tokens/step", config.tokensPerStep.formatted())
161+ derived("Tokens totaux",
162+ config.totalTokens.formatted(.number.notation(.compactName)))
163+ if let t = dataset?.trainTokens, t > 0 {
164+ derived("Epochs sur le dataset",
165+ String(format: "%.2f", Double(config.totalTokens) / Double(t)))
166+ }
167+ memoryBadge
168+ Divider()
169+ Text("Schedule LR").font(.headline)
170+ Chart {
171+ let steps = stride(from: 0, to: config.train.maxSteps,
172+ by: max(1, config.train.maxSteps / 200))
173+ ForEach(Array(steps), id: \.self) { s in
174+ LineMark(x: .value("step", s),
175+ y: .value("lr", config.train.lrAt(step: s)))
176+ .foregroundStyle(.purple)
177+ }
178+ }
179+ .frame(height: 140)
180+ Spacer()
181+ }
182+ }
183+
184+ private var memoryBadge: some View {
185+ let bytes = SystemInfo.estimatedTrainingBytes(config: config)
186+ let machine = SystemInfo.memoryBytes
187+ let gb = Double(bytes) / 1e9
188+ let ok = bytes < machine * 8 / 10
189+ return HStack {
190+ Image(systemName: ok ? "memorychip" : "exclamationmark.triangle.fill")
191+ Text(String(format: "~%.1f GB estimés / %.0f GB unifiés", gb,
192+ Double(machine) / 1e9))
193+ }
194+ .font(.callout)
195+ .foregroundStyle(ok ? Color.secondary : Color.orange)
196+ }
197+
198+ private func derived(_ label: String, _ value: String) -> some View {
199+ HStack {
200+ Text(label).foregroundStyle(.secondary)
201+ Spacer()
202+ Text(value).monospacedDigit()
203+ }
204+ .font(.callout)
205+ }
206+}
207+
208+enum Presets {
209+ struct Preset {
210+ let name: String
211+ let config: ForgeConfig
212+ }
213+
214+ // Mirrors configs/ in the forge repo (gpt-10m-1epoch, gpt-50m…).
215+ static let all: [Preset] = {
216+ var p10 = ForgeConfig()
217+ p10.model.name = "gpt-10m"
218+ p10.model.nLayers = 6; p10.model.dModel = 384; p10.model.nHeads = 6
219+ p10.model.nKvHeads = 6; p10.model.dFf = 1024; p10.model.vocabSize = 4096
220+ p10.model.contextLength = 512
221+ p10.train.lr = 6e-4; p10.train.warmupSteps = 58; p10.train.maxSteps = 584
222+ p10.train.batchSize = 64; p10.train.gradAccumSteps = 1
223+ p10.train.checkpointEvery = 200; p10.train.evalEvery = 100
224+
225+ var p50 = ForgeConfig()
226+ p50.model.name = "gpt-50m"
227+ p50.model.nLayers = 10; p50.model.dModel = 640; p50.model.nHeads = 10
228+ p50.model.nKvHeads = 10; p50.model.dFf = 1728; p50.model.vocabSize = 4096
229+ p50.model.contextLength = 1024
230+ p50.train.lr = 5e-4; p50.train.warmupSteps = 117; p50.train.maxSteps = 1170
231+ p50.train.batchSize = 8; p50.train.gradAccumSteps = 8
232+ p50.train.checkpointEvery = 200; p50.train.evalEvery = 100
233+
234+ var muon = p50
235+ muon.model.name = "gpt-50m-muon"
236+ muon.train.optimizer = "muon"; muon.train.schedule = "wsd"
237+
238+ return [Preset(name: "gpt-10m (1 epoch)", config: p10),
239+ Preset(name: "gpt-50m", config: p50),
240+ Preset(name: "gpt-50m Muon+WSD", config: muon)]
241+ }()
242+}
added ForgeStudio/Views/RunDetailView.swift +227 −0
Large diff (227 lines) is not loaded by default to keep the page fast.Load diff
added ForgeStudio/Views/SettingsView.swift +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added Package.swift +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added RESEARCH.md +126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added Tests/ForgeStudioTests.swift +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added scripts/notarize.sh +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added scripts/package-app.sh +40 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme