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