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