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
@@ -0,0 +1,227 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Run dashboard: status strip, the loss chart (raw + bias-corrected EMA +
4+// val points, log/linear Y, best-val marker), and the console. Live data
5+// arrives via the supervisor's MetricsStore at ~1 Hz.
6+import Charts
7+import SwiftUI
8+
9+struct RunDetailView: View {
10+ let run: Run
11+ @Environment(AppModel.self) private var app
12+ @State private var snapshot: MetricsStore.Snapshot?
13+ @State private var smoothing = 0.6
14+ @State private var logScale = true
15+ @State private var showConsole = false
16+ @State private var historicStore: MetricsStore?
17+
18+ private var isLive: Bool { app.supervisor.activeRunID == run.id }
19+
20+ var body: some View {
21+ VStack(spacing: 0) {
22+ StatusStrip(run: run, snapshot: snapshot)
23+ Divider()
24+ ScrollView {
25+ VStack(alignment: .leading, spacing: 16) {
26+ lossChart
27+ HStack(spacing: 24) {
28+ secondaryChart(title: "Learning rate",
29+ series: snapshot?.lr ?? [], format: "%.2e")
30+ secondaryChart(title: "Tokens/s",
31+ series: snapshot?.tokensPerSec ?? [], format: "%.0f")
32+ secondaryChart(title: "Grad norm",
33+ series: snapshot?.gradNorm ?? [], format: "%.2f",
34+ threshold: run.config.train.gradClip)
35+ }
36+ DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)",
37+ isExpanded: $showConsole) {
38+ ConsoleView(lines: isLive ? app.supervisor.consoleLines
39+ : ["(run terminé — voir run.log)"])
40+ }
41+ }
42+ .padding()
43+ }
44+ }
45+ .toolbar {
46+ if isLive {
47+ ToolbarItem {
48+ Button(role: .destructive) {
49+ Task { await app.supervisor.stop() }
50+ } label: {
51+ Label("Stop", systemImage: "stop.fill")
52+ }
53+ .help("SIGTERM — forge ne checkpointe pas à l'arrêt ; reprise depuis ckpt_latest.bin")
54+ }
55+ }
56+ }
57+ .task(id: run.id) { await refreshLoop() }
58+ .navigationTitle(run.name)
59+ }
60+
61+ private var lossChart: some View {
62+ VStack(alignment: .leading, spacing: 8) {
63+ HStack {
64+ Text("Loss").font(.title3.weight(.semibold))
65+ Spacer()
66+ Toggle("log Y", isOn: $logScale).toggleStyle(.checkbox)
67+ HStack(spacing: 4) {
68+ Text("EMA")
69+ Slider(value: $smoothing, in: 0...0.99).frame(width: 120)
70+ Text(String(format: "%.2f", smoothing)).monospacedDigit()
71+ }
72+ .font(.caption)
73+ }
74+ Chart {
75+ ForEach(snapshot?.train ?? [], id: \.x) { p in
76+ LineMark(x: .value("step", p.x), y: .value("loss", p.y),
77+ series: .value("s", "train"))
78+ .foregroundStyle(.blue.opacity(0.25))
79+ }
80+ ForEach(snapshot?.trainEMA ?? [], id: \.x) { p in
81+ LineMark(x: .value("step", p.x), y: .value("ema", p.y),
82+ series: .value("s", "train EMA"))
83+ .foregroundStyle(.blue)
84+ }
85+ ForEach(snapshot?.val ?? [], id: \.x) { p in
86+ LineMark(x: .value("step", p.x), y: .value("val", p.y),
87+ series: .value("s", "val"))
88+ .foregroundStyle(.orange)
89+ PointMark(x: .value("step", p.x), y: .value("val", p.y))
90+ .foregroundStyle(.orange)
91+ .symbolSize(24)
92+ }
93+ if let best = snapshot?.bestVal {
94+ RuleMark(y: .value("best", best.loss))
95+ .foregroundStyle(.orange.opacity(0.4))
96+ .lineStyle(.init(lineWidth: 1, dash: [4, 4]))
97+ .annotation(position: .topTrailing) {
98+ Text(String(format: "best val %.3f @ %d", best.loss, best.step))
99+ .font(.caption2)
100+ .foregroundStyle(.orange)
101+ }
102+ }
103+ }
104+ .if(logScale) { $0.chartYScale(type: .log) }
105+ .chartLegend(.visible)
106+ .frame(minHeight: 320)
107+ }
108+ }
109+
110+ private func secondaryChart(title: String, series: [Downsampler.XY],
111+ format: String, threshold: Double? = nil) -> some View {
112+ VStack(alignment: .leading, spacing: 4) {
113+ HStack {
114+ Text(title).font(.caption.weight(.semibold))
115+ Spacer()
116+ if let last = series.last {
117+ Text(String(format: format, last.y))
118+ .font(.caption.monospacedDigit())
119+ .foregroundStyle(.secondary)
120+ }
121+ }
122+ Chart {
123+ ForEach(series, id: \.x) { p in
124+ LineMark(x: .value("step", p.x), y: .value("v", p.y))
125+ .foregroundStyle(.teal)
126+ }
127+ if let t = threshold, t > 0 {
128+ RuleMark(y: .value("clip", t))
129+ .lineStyle(.init(lineWidth: 1, dash: [3, 3]))
130+ .foregroundStyle(.red.opacity(0.5))
131+ }
132+ }
133+ .chartXAxis(.hidden)
134+ .frame(height: 110)
135+ }
136+ }
137+
138+ private func refreshLoop() async {
139+ if isLive {
140+ while !Task.isCancelled && app.supervisor.activeRunID == run.id {
141+ snapshot = await app.supervisor.metrics
142+ .snapshot(maxPoints: 1200, smoothing: smoothing)
143+ try? await Task.sleep(for: .milliseconds(500))
144+ }
145+ }
146+ // Historical (or freshly finished): load the whole CSV once.
147+ let store = MetricsStore()
148+ historicStore = store
149+ await store.ingestCSV(at: URL(fileURLWithPath: run.logCSVPath))
150+ snapshot = await store.snapshot(maxPoints: 1200, smoothing: smoothing)
151+ }
152+}
153+
154+struct StatusStrip: View {
155+ let run: Run
156+ let snapshot: MetricsStore.Snapshot?
157+
158+ var body: some View {
159+ HStack(spacing: 20) {
160+ StateBadge(state: run.state)
161+ if let step = run.lastStep {
162+ VStack(alignment: .leading, spacing: 1) {
163+ Text("step \(step + 1) / \(run.config.train.maxSteps)")
164+ .font(.callout.monospacedDigit())
165+ ProgressView(value: run.progress).frame(width: 140)
166+ }
167+ }
168+ if let last = snapshot?.lastPoint {
169+ metric("loss", String(format: "%.4f", last.trainLoss))
170+ metric("ppl", String(format: "%.1f", exp(last.trainLoss)))
171+ metric("tok/s", String(format: "%.0f", last.tokensPerSec))
172+ if let e = last.elapsedS {
173+ metric("écoulé", Duration.seconds(e)
174+ .formatted(.time(pattern: .hourMinuteSecond)))
175+ }
176+ }
177+ if let best = run.bestValLoss {
178+ metric("best val", String(format: "%.4f", best))
179+ }
180+ Spacer()
181+ Text("\(run.config.model.paramCount.formatted(.number.notation(.compactName))) params")
182+ .foregroundStyle(.secondary)
183+ }
184+ .padding(.horizontal)
185+ .padding(.vertical, 10)
186+ }
187+
188+ private func metric(_ label: String, _ value: String) -> some View {
189+ VStack(alignment: .leading, spacing: 1) {
190+ Text(label).font(.caption2).foregroundStyle(.secondary)
191+ Text(value).font(.callout.monospacedDigit())
192+ }
193+ }
194+}
195+
196+struct ConsoleView: View {
197+ let lines: [String]
198+
199+ var body: some View {
200+ ScrollViewReader { proxy in
201+ ScrollView {
202+ LazyVStack(alignment: .leading, spacing: 0) {
203+ ForEach(Array(lines.enumerated()), id: \.offset) { i, line in
204+ Text(line)
205+ .font(.system(.caption, design: .monospaced))
206+ .textSelection(.enabled)
207+ .id(i)
208+ }
209+ }
210+ .frame(maxWidth: .infinity, alignment: .leading)
211+ }
212+ .frame(height: 200)
213+ .background(.black.opacity(0.85))
214+ .clipShape(RoundedRectangle(cornerRadius: 6))
215+ .onChange(of: lines.count) {
216+ proxy.scrollTo(lines.count - 1, anchor: .bottom)
217+ }
218+ }
219+ }
220+}
221+
222+extension View {
223+ @ViewBuilder
224+ func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
225+ if condition { transform(self) } else { self }
226+ }
227+}
added ForgeStudio/Views/SettingsView.swift +68 −0
@@ -0,0 +1,68 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Paths (forge binary, workspace) with live validation via `forge info`.
4+import SwiftUI
5+import UniformTypeIdentifiers
6+
7+struct SettingsView: View {
8+ @Environment(AppModel.self) private var app
9+ @State private var binaryPath = ForgeBinaryLocator.savedBinaryURL?.path ?? ""
10+ @State private var workspacePath = ForgeBinaryLocator.savedWorkspaceURL?.path ?? ""
11+
12+ var body: some View {
13+ Form {
14+ Section("Binaire forge") {
15+ HStack {
16+ TextField("Chemin", text: $binaryPath)
17+ .font(.callout.monospaced())
18+ Button("Choisir…") { pickBinary() }
19+ }
20+ if let device = app.forgeDevice {
21+ Label("Validé — \(device)", systemImage: "checkmark.circle.fill")
22+ .foregroundStyle(.green)
23+ } else if let error = app.forgeError {
24+ Label(error, systemImage: "xmark.circle.fill")
25+ .foregroundStyle(.red)
26+ }
27+ Button("Revalider") {
28+ ForgeBinaryLocator.save(binary: URL(fileURLWithPath: binaryPath))
29+ Task { await app.validateForge() }
30+ }
31+ }
32+ Section("Espace de travail") {
33+ HStack {
34+ TextField("Dossier (runs/, data/)", text: $workspacePath)
35+ .font(.callout.monospaced())
36+ Button("Choisir…") { pickWorkspace() }
37+ }
38+ Text("Runs : \(app.store.workspaceURL.path)/runs")
39+ .font(.caption)
40+ .foregroundStyle(.secondary)
41+ }
42+ }
43+ .formStyle(.grouped)
44+ .frame(width: 560, height: 320)
45+ }
46+
47+ private func pickBinary() {
48+ let panel = NSOpenPanel()
49+ panel.allowedContentTypes = [.unixExecutable, .executable]
50+ panel.canChooseDirectories = false
51+ if panel.runModal() == .OK, let url = panel.url {
52+ binaryPath = url.path
53+ ForgeBinaryLocator.save(binary: url)
54+ Task { await app.validateForge() }
55+ }
56+ }
57+
58+ private func pickWorkspace() {
59+ let panel = NSOpenPanel()
60+ panel.canChooseDirectories = true
61+ panel.canChooseFiles = false
62+ panel.canCreateDirectories = true
63+ if panel.runModal() == .OK, let url = panel.url {
64+ workspacePath = url.path
65+ ForgeBinaryLocator.save(workspace: url)
66+ }
67+ }
68+}
added Package.swift +25 −0
@@ -0,0 +1,25 @@
1+// swift-tools-version: 5.10
2+// Author: Simon-Pierre Boucher — contact@spboucher.ai
3+// Forge Studio — native macOS GUI companion for the Forge LLM training
4+// framework. SwiftPM is the canonical build system (zyquo-term pattern);
5+// scripts/package-app.sh turns the executable into a .app bundle.
6+import PackageDescription
7+
8+let package = Package(
9+ name: "ForgeStudio",
10+ platforms: [.macOS(.v14)],
11+ products: [
12+ .executable(name: "ForgeStudio", targets: ["ForgeStudio"])
13+ ],
14+ targets: [
15+ .executableTarget(
16+ name: "ForgeStudio",
17+ path: "ForgeStudio"
18+ ),
19+ .testTarget(
20+ name: "ForgeStudioTests",
21+ dependencies: ["ForgeStudio"],
22+ path: "Tests"
23+ ),
24+ ]
25+)
added RESEARCH.md +126 −0
@@ -0,0 +1,126 @@
1+<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2+
3+# RESEARCH.md — Ground truth Forge pour Forge Studio
4+### Source : lecture directe du code Forge (même auteur, même machine), août 2026
5+
6+## 1. CLI `forge` (src/main.cpp)
7+
8+```
9+forge train --config <json> --data <dir> --out <dir> [--resume <ckpt>] [--backend metal|cpu]
10+forge generate --checkpoint <ckpt|.forge> --tokenizer <model> --prompt <text>
11+ [--temp t] [--top-k k] [--max-tokens n] [--seed s]
12+forge eval --checkpoint <ckpt|.forge> --data <val.bin> [--batches n]
13+forge export --checkpoint <ckpt.bin> --out <model.forge> [--dtype f32|f16|bf16]
14+ [--shard-mb n] [--tag label]
15+forge info [--config <json>]
16+```
17+- Les flags sont `--key value` ; un flag sans valeur vaut `"true"`.
18+- `generate`/`eval` acceptent un repo `.forge` (répertoire) ou un manifest.json.
19+- Exit code 0 = succès ; erreurs fatales → stderr + abort (code ≠ 0).
20+
21+## 2. Schéma de config (src/nn/config.h) — vérité au 2026-08-05
22+
23+### `model` (défauts entre parenthèses)
24+name("model") · n_layers(6) · d_model(384) · n_heads(6) · n_kv_heads(=n_heads, GQA si <)
25+· d_ff(1024) · vocab_size(4096) · context_length(512) · tied_embeddings(true)
26+· use_rope(true) · rope_theta(10000) · norm("rmsnorm"|"layernorm") · norm_eps(1e-6)
27+· activation("swiglu"|"gelu"|"relu2") · dropout(0)
28+· quant("none"|"int8"|"ternary") · qk_norm(false) · final_softcap(0) ·
29+scale_embeddings(false) · attention_bias(false) · head_dim(0=auto) · nope_every(0)
30+· norm_placement("pre"|"post"|"sandwich") · rope_scale_factor(0=off) ·
31+rope_scale_low(1) · rope_scale_high(4) · rope_scale_orig_ctx(8192)
32+· sliding_window(0) · sliding_global_every(0) · rope_theta_global(0) · attn_softcap(0)
33+· n_experts(0) · moe_top_k(2) · moe_aux_weight(0.01) · n_shared_experts(0)
34+· moe_scoring("softmax"|"sigmoid") · moe_norm_topk(true) · routed_scaling_factor(1)
35+· moe_d_ff(0) · first_k_dense(0) · moe_bias_gamma(0)
36+
37+Validations (levées comme exceptions au parse) : d_model % n_heads == 0 (sauf head_dim
38+explicite) ; n_heads % n_kv_heads == 0 ; head_dim pair ; moe_top_k ∈ [1, n_experts] ;
39+n_shared_experts ⇒ n_experts>0 ; first_k_dense ∈ [0, n_layers] ; enums valides.
40+
41+### `train`
42+lr(6e-4) · min_lr_ratio(0.1) · warmup_steps(2000) · max_steps(100000)
43+· schedule("cosine"|"wsd") · wsd_decay_frac(0.15) · optimizer("adamw"|"muon")
44+· muon_lr(0.02) · muon_momentum(0.95) · beta1(0.9) · beta2(0.95) · eps(1e-8)
45+· weight_decay(0.1) · grad_clip(1.0, 0=off) · batch_size(32, MICRO-batch)
46+· grad_accum_steps(1) · precision("f32", parsé mais pas encore honoré)
47+· checkpoint_every(1000, 0=off) · forge_save(true) · forge_dtype("f32"|"f16"|"bf16")
48+· eval_every(500, 0=off) · eval_batches(20) · seed(1337) · deterministic(false)
49+
50+Dérivés : tokens/step = batch_size × context_length × grad_accum_steps ;
51+param count = formule de ModelConfig::num_params() (répliquée dans ForgeConfig.swift,
52+à cross-checker via `forge info`).
53+
54+## 3. Métriques structurées — log.csv (PAS besoin de patch JSONL)
55+
56+`<out>/log.csv`, en-tête écrit au step 0, **une ligne par step, flushée** :
57+```
58+step,loss,lr,grad_norm,tokens_per_sec,val_loss,elapsed_s
59+584,2.296893,3.680000e-04,0.558962,13692.0,-1.000000,3861.402
60+```
61+- `val_loss = -1.0` quand pas d'éval à ce step (eval tous les `eval_every` steps,
62+ à step ≡ eval_every-1 mod eval_every).
63+- `elapsed_s` (ajouté commit 147560e) = wall-clock depuis le début du run.
64+- Resume : le fichier est rouvert en append (pas de second en-tête) ; les anciens runs
65+ peuvent avoir 6 colonnes (sans elapsed_s) → parser dirigé par l'en-tête.
66+- stdout humain en parallèle : `step %6lld | loss %.4f | lr %.2e | gnorm %.3f | %f tok/s[ | val %.4f]`
67+ + lignes `checkpoint saved: <path>` et `fmodel: <manifest> — N tensors reused…`.
68+ Première ligne : `training <name>: <params> params, <steps> steps, <tps> tokens/step, backend=…, opt=…, sched=…`.
69+
70+## 4. Checkpoints & resume
71+
72+- `<out>/ckpt_%06d.bin` tous les `checkpoint_every` steps + `ckpt_latest.bin` à chaque
73+ fois + checkpoint final à max_steps. Format binaire FRGE v1 (poids + état optimiseur
74+ + step + config JSON embarquée).
75+- **`--resume <ckpt>` EXISTE** et reprend au step sauvé (dataloader re-seedé du step).
76+- `.forge` : si forge_save, chaque checkpoint committe aussi les poids dans
77+ `<out>/model.forge/` (manifests + shards contenu-adressés ; `tools/fmodel.py log`
78+ pour l'historique).
79+
80+## 5. Signaux
81+
82+`forge train` n'installe AUCUN handler SIGINT/SIGTERM : le process meurt
83+immédiatement, sans checkpoint de sortie. Conséquence UI : « Stop » = SIGTERM après
84+confirmation ; la reprise se fait du dernier `ckpt_latest.bin` (perte ≤
85+checkpoint_every steps). L'UI doit l'annoncer honnêtement.
86+
87+## 6. Datasets (tools/)
88+
89+- `prepare_data.py --out data/tinystories [--vocab-size N] [--max-train-mb MB]
90+ [--tokenizer path]` → `train.bin`, `val.bin` (en-tête {magic 20240520, version 1,
91+ num_tokens} puis tokens uint16), `tokN.model` (forgebpe v1, texte).
92+- `prepare_hf_data.py --source|--mix|--preset … --out <dir>` : 13 sources HF
93+ streamées, mélanges pondérés (voir --list).
94+- Contrainte dure : `model.vocab_size` == vocab du tokenizer du dataset.
95+- Token count d'un .bin : lire l'en-tête int32[3] (offset 8 = num_tokens) — pas de
96+ division de taille de fichier.
97+
98+## 7. Signing / notarization (extrait de zyquo-term, RÉEL)
99+
100+- Identité : `Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)` (Team
101+ 3YM54G49SN), certificat dans le login keychain.
102+- Profil notarytool : `MacLustr-Notarize` (keychain profile).
103+- Pattern : SwiftPM canonique (pas de .xcodeproj) → `scripts/package-app.sh`
104+ (bundle + codesign ad-hoc pour dev) → `scripts/notarize.sh` (Developer ID +
105+ hardened runtime + DMG + notarytool submit --wait + stapler).
106+- Zyquo-term signe : binaire (avec entitlements) puis .app puis .dmg, options
107+ `--force --options runtime --timestamp`.
108+
109+## 8. Décisions
110+
111+- **Métriques : log.csv suffit** (structuré, flushé, versionné par en-tête) — pas de
112+ patch --metrics-file nécessaire ; parser regex du stdout gardé en fallback pour
113+ détecter `checkpoint saved:` en live.
114+- **Build : SwiftPM** (pattern zyquo-term), pas de .xcodeproj — Xcode 26 présent si
115+ besoin d'archive.
116+- **Sandbox : non** (l'app pilote des binaires externes arbitraires) ; Developer ID +
117+ Hardened Runtime, comme zyquo-term.
118+
119+## 9. État d'avancement Studio
120+
121+- [x] M1 fondations : Package.swift, modèles Codable complets (schéma §2), services
122+ (ProcessRunner, LogParser, MetricsStore, RunStore, BinaryLocator, SystemInfo),
123+ LTTB + EMA, app squelette naviguable, scripts build/package/notarize.
124+- [ ] M2 éditeur New Run complet (panneau dérivé, preview LR, validation inline)
125+- [ ] M3 dashboard chart interactif complet (hover/zoom/follow-live)
126+- [ ] M4-M8 : voir CLAUDE.md.
added Tests/ForgeStudioTests.swift +116 −0
@@ -0,0 +1,116 @@
1+// Author: Simon-Pierre Boucher — contact@spboucher.ai
2+//
3+// Contract tests: config codec against the REAL forge configs, the
4+// param-count formula against `forge info`, LTTB invariants, EMA bias
5+// correction, CSV parser against real and mutated lines, state machine.
6+import XCTest
7+@testable import ForgeStudio
8+
9+final class ForgeStudioTests: XCTestCase {
10+ let forgeRepo = URL(fileURLWithPath: NSHomeDirectory() + "/Desktop/forge")
11+
12+ func testConfigRoundTripAgainstRealConfigs() throws {
13+ let configsDir = forgeRepo.appendingPathComponent("configs")
14+ let files = (try? FileManager.default.contentsOfDirectory(
15+ at: configsDir, includingPropertiesForKeys: nil))?
16+ .filter { $0.pathExtension == "json" } ?? []
17+ try XCTSkipIf(files.isEmpty, "forge repo introuvable")
18+ for url in files {
19+ let cfg = try ForgeConfig.load(from: url)
20+ // Re-export and re-parse: field names must be byte-compatible.
21+ let data = try cfg.exportJSON()
22+ let reparsed = try JSONDecoder().decode(ForgeConfig.self, from: data)
23+ XCTAssertEqual(cfg, reparsed, url.lastPathComponent)
24+ XCTAssertTrue(cfg.validationErrors.isEmpty,
25+ "\(url.lastPathComponent): \(cfg.validationErrors)")
26+ }
27+ }
28+
29+ func testParamCountMatchesForgeInfo() throws {
30+ // Known-good values printed by `forge info` for the shipped configs.
31+ let expected: [(String, Double)] = [
32+ ("gpt-50m.json", 52.20), ("gpt-50m-moe.json", 151.75),
33+ ("gpt-50m-deep.json", 50.60), ("gpt-50m-mistral.json", 48.10),
34+ ]
35+ for (file, millions) in expected {
36+ let url = forgeRepo.appendingPathComponent("configs/\(file)")
37+ guard let cfg = try? ForgeConfig.load(from: url) else { continue }
38+ XCTAssertEqual(Double(cfg.model.paramCount) / 1e6, millions,
39+ accuracy: 0.01, file)
40+ }
41+ }
42+
43+ func testLTTBInvariants() {
44+ let pts = (0..<10_000).map {
45+ Downsampler.XY(x: Double($0), y: sin(Double($0) / 50) + Double($0) * 0.001)
46+ }
47+ let ds = Downsampler.lttb(pts, threshold: 500)
48+ XCTAssertEqual(ds.count, 500)
49+ XCTAssertEqual(ds.first, pts.first) // endpoints preserved exactly
50+ XCTAssertEqual(ds.last, pts.last)
51+ XCTAssertTrue(zip(ds, ds.dropFirst()).allSatisfy { $0.x < $1.x }) // monotonic
52+ XCTAssertEqual(Downsampler.lttb(pts, threshold: 20_000).count, pts.count)
53+ }
54+
55+ func testEMABiasCorrection() {
56+ // A constant series must stay exactly constant under bias-corrected EMA.
57+ let ema = Smoothing.ema([Double](repeating: 3.5, count: 100), smoothing: 0.9)
58+ XCTAssertTrue(ema.allSatisfy { abs($0 - 3.5) < 1e-9 })
59+ XCTAssertEqual(Smoothing.ema([1, 2, 3], smoothing: 0), [1, 2, 3])
60+ }
61+
62+ func testCSVParser() {
63+ var p = LogParser()
64+ XCTAssertNil(p.parseCSVLine("step,loss,lr,grad_norm,tokens_per_sec,val_loss,elapsed_s"))
65+ let pt = p.parseCSVLine("584,2.296893,3.680000e-04,0.558962,13692.0,-1.000000,3861.402")
66+ XCTAssertEqual(pt?.step, 584)
67+ XCTAssertEqual(pt!.trainLoss, 2.296893, accuracy: 1e-9)
68+ XCTAssertNil(pt?.valLoss) // -1 sentinel
69+ XCTAssertEqual(pt!.elapsedS!, 3861.402, accuracy: 1e-6)
70+ let withVal = p.parseCSVLine("599,2.19,3.05e-04,0.55,13748.0,2.3299,3960.1")
71+ XCTAssertEqual(withVal!.valLoss!, 2.3299, accuracy: 1e-9)
72+ // Legacy 6-column header, garbage, truncation: never crash.
73+ var legacy = LogParser()
74+ _ = legacy.parseCSVLine("step,loss,lr,grad_norm,tokens_per_sec,val_loss")
75+ let old = legacy.parseCSVLine("10,5.2,1e-4,0.9,42000.0,-1.0")
76+ XCTAssertNil(old?.elapsedS)
77+ XCTAssertNil(legacy.parseCSVLine("garbage,🤖,,"))
78+ XCTAssertNil(legacy.parseCSVLine("1,2"))
79+ XCTAssertNil(legacy.parseCSVLine(""))
80+ }
81+
82+ func testStdoutEvents() {
83+ XCTAssertEqual(
84+ LogParser.parseStdout("checkpoint saved: runs/x/ckpt_000200.bin"),
85+ .checkpointSaved(path: "runs/x/ckpt_000200.bin"))
86+ if case .banner(let params, let steps, let tps, let backend)? =
87+ LogParser.parseStdout(
88+ "training gpt-50m: 52196480 params, 1170 steps, 65536 tokens/step, backend=metal, opt=adamw, sched=cosine")
89+ {
90+ XCTAssertEqual(params, 52_196_480)
91+ XCTAssertEqual(steps, 1170)
92+ XCTAssertEqual(tps, 65536)
93+ XCTAssertEqual(backend, "metal")
94+ } else {
95+ XCTFail("banner non parsé")
96+ }
97+ }
98+
99+ func testRunStateMachine() {
100+ // Terminal states allow nothing; queued can't jump to finished.
101+ XCTAssertTrue(RunState.transitions[.finished]!.isEmpty)
102+ XCTAssertTrue(RunState.transitions[.failed]!.isEmpty)
103+ XCTAssertFalse(RunState.transitions[.queued]!.contains(.finished))
104+ XCTAssertTrue(RunState.transitions[.running]!.contains(.finishing))
105+ }
106+
107+ func testLRSchedulePreviewMatchesForgeMath() {
108+ var t = TrainConfig()
109+ t.lr = 5e-4; t.warmupSteps = 100; t.maxSteps = 1000
110+ XCTAssertEqual(t.lrAt(step: 0), 5e-4 * 1.0 / 101.0, accuracy: 1e-12)
111+ XCTAssertEqual(t.lrAt(step: 999) > t.lr * t.minLrRatio, true)
112+ XCTAssertEqual(t.lrAt(step: 1000), t.lr * t.minLrRatio, accuracy: 1e-12)
113+ t.schedule = "wsd"
114+ XCTAssertEqual(t.lrAt(step: 500), t.lr, accuracy: 1e-15) // plateau
115+ }
116+}
added scripts/notarize.sh +42 −0
@@ -0,0 +1,42 @@
1+#!/bin/bash
2+# Author: Simon-Pierre Boucher — contact@spboucher.ai
3+# Developer ID signing, DMG, and notarization for Forge Studio.
4+# Identity and notarization flow reused from the zyquo-term pipeline
5+# (Team 3YM54G49SN, notarytool keychain profile "MacLustr-Notarize").
6+set -euo pipefail
7+cd "$(dirname "$0")/.."
8+
9+IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)"
10+KEYCHAIN_PROFILE="MacLustr-Notarize"
11+APP_DIR="dist/ForgeStudio.app"
12+DMG_NAME="dist/ForgeStudio-0.1.0.dmg"
13+
14+./scripts/package-app.sh release
15+
16+echo "=== Signing (Developer ID, hardened runtime) ==="
17+codesign --force --options runtime --timestamp \
18+ --sign "$IDENTITY" "$APP_DIR/Contents/MacOS/ForgeStudio"
19+codesign --force --options runtime --timestamp \
20+ --sign "$IDENTITY" "$APP_DIR"
21+codesign --verify --deep --strict --verbose=2 "$APP_DIR"
22+
23+echo "=== DMG ==="
24+rm -f "$DMG_NAME"
25+if command -v create-dmg >/dev/null; then
26+ create-dmg --volname "Forge Studio" --app-drop-link 480 200 \
27+ --window-size 660 420 "$DMG_NAME" "$APP_DIR"
28+else
29+ hdiutil create -volname "Forge Studio" -srcfolder "$APP_DIR" \
30+ -ov -format UDZO "$DMG_NAME"
31+fi
32+codesign --force --sign "$IDENTITY" --timestamp "$DMG_NAME"
33+
34+echo "=== Notarizing (profile: $KEYCHAIN_PROFILE) ==="
35+xcrun notarytool submit "$DMG_NAME" --keychain-profile "$KEYCHAIN_PROFILE" --wait
36+
37+echo "=== Stapling ==="
38+xcrun stapler staple "$APP_DIR"
39+xcrun stapler staple "$DMG_NAME"
40+xcrun stapler validate "$DMG_NAME"
41+spctl -a -vv "$APP_DIR"
42+echo "OK: $DMG_NAME"
added scripts/package-app.sh +40 −0
@@ -0,0 +1,40 @@
1+#!/bin/bash
2+# Author: Simon-Pierre Boucher — contact@spboucher.ai
3+# Build the SwiftPM executable and wrap it into ForgeStudio.app (ad-hoc
4+# signed — fast local iteration; scripts/notarize.sh does the real signing).
5+set -euo pipefail
6+cd "$(dirname "$0")/.."
7+
8+CONFIG="${1:-release}"
9+swift build -c "$CONFIG"
10+
11+BIN=".build/$CONFIG/ForgeStudio"
12+APP_DIR="dist/ForgeStudio.app"
13+rm -rf "$APP_DIR"
14+mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
15+
16+cp "$BIN" "$APP_DIR/Contents/MacOS/ForgeStudio"
17+
18+cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
19+<?xml version="1.0" encoding="UTF-8"?>
20+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21+<plist version="1.0">
22+<dict>
23+ <key>CFBundleName</key><string>Forge Studio</string>
24+ <key>CFBundleDisplayName</key><string>Forge Studio</string>
25+ <key>CFBundleIdentifier</key><string>ai.spboucher.forge-studio</string>
26+ <key>CFBundleExecutable</key><string>ForgeStudio</string>
27+ <key>CFBundleVersion</key><string>1</string>
28+ <key>CFBundleShortVersionString</key><string>0.1.0</string>
29+ <key>CFBundlePackageType</key><string>APPL</string>
30+ <key>LSMinimumSystemVersion</key><string>14.0</string>
31+ <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
32+ <key>NSHighResolutionCapable</key><true/>
33+ <key>NSHumanReadableCopyright</key><string>© Simon-Pierre Boucher</string>
34+</dict>
35+</plist>
36+PLIST
37+
38+codesign --force --deep --sign - "$APP_DIR"
39+codesign --verify --deep --strict --verbose=2 "$APP_DIR"
40+echo "OK: $APP_DIR"
41
Menu

Theme