SPB Git

spb/forge-studio Public

The Instruments of LLM training — a native macOS cockpit for Forge. Train language models from scratch on Apple Silicon without a terminal.

Swift 95.7% Shell 4.3%
7.1 KB · 152 lines swift
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// Contract tests: config codec against the REAL forge configs, the4// param-count formula against `forge info`, LTTB invariants, EMA bias5// correction, CSV parser against real and mutated lines, state machine.6import XCTest7@testable import ForgeStudio89final class ForgeStudioTests: XCTestCase {10    let forgeRepo = URL(fileURLWithPath: NSHomeDirectory() + "/Desktop/forge")1112    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    }2829    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    }4243    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 exactly50        XCTAssertEqual(ds.last, pts.last)51        XCTAssertTrue(zip(ds, ds.dropFirst()).allSatisfy { $0.x < $1.x }) // monotonic52        XCTAssertEqual(Downsampler.lttb(pts, threshold: 20_000).count, pts.count)53    }5455    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    }6162    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 sentinel69        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    }8182    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    }9899    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    }106107    // M7 stress: 200k synthetic points through the full ingest+snapshot108    // pipeline. The UI reads only snapshots, so these bounds are what keep109    // hover/zoom hitch-free during a 200M-parameter, 100k+-step run.110    func testStress200kPoints() async {111        let store = MetricsStore()112        let clock = ContinuousClock()113114        // Ingest 200k CSV lines through the real parser.115        var csv = "step,loss,lr,grad_norm,tokens_per_sec,val_loss,elapsed_s\n"116        csv.reserveCapacity(12_000_000)117        for i in 0..<200_000 {118            let val = i % 500 == 499 ? String(format: "%.4f", 3.0 + 2.0 * exp(-Double(i) / 30000)) : "-1.0"119            csv += "\(i),\(String(format: "%.4f", 2.5 + 6.0 * exp(-Double(i) / 20000))),3e-4,0.55,14000.0,\(val),\(Double(i) * 4.7)\n"120        }121        let tmp = FileManager.default.temporaryDirectory122            .appendingPathComponent("stress-\(UUID().uuidString).csv")123        try? csv.write(to: tmp, atomically: true, encoding: .utf8)124        defer { try? FileManager.default.removeItem(at: tmp) }125126        let ingest = await clock.measure { await store.ingestCSV(at: tmp) }127        let count = await store.points.count128        XCTAssertEqual(count, 200_000)129        XCTAssertLessThan(ingest, .seconds(5), "ingest 200k: \(ingest)")130131        // Snapshot (LTTB to chart width + EMA) must stay far under a frame132        // budget's worth of off-main-thread work at 10 Hz refresh.133        let snap = await clock.measure {134            _ = await store.snapshot(maxPoints: 1200, smoothing: 0.6)135        }136        XCTAssertLessThan(snap, .milliseconds(250), "snapshot 200k: \(snap)")137        let s = await store.snapshot(maxPoints: 1200, smoothing: 0.6)138        XCTAssertEqual(s.train.count, 1200)139        XCTAssertEqual(s.count, 200_000)140    }141142    func testLRSchedulePreviewMatchesForgeMath() {143        var t = TrainConfig()144        t.lr = 5e-4; t.warmupSteps = 100; t.maxSteps = 1000145        XCTAssertEqual(t.lrAt(step: 0), 5e-4 * 1.0 / 101.0, accuracy: 1e-12)146        XCTAssertEqual(t.lrAt(step: 999) > t.lr * t.minLrRatio, true)147        XCTAssertEqual(t.lrAt(step: 1000), t.lr * t.minLrRatio, accuracy: 1e-12)148        t.schedule = "wsd"149        XCTAssertEqual(t.lrAt(step: 500), t.lr, accuracy: 1e-15) // plateau150    }151}152