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.

Swift 95.7% Shell 4.3%

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 5 days ago (Aug 5, 2026)

Showing 25 changed files with +2,227 and −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