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