phase3: foundry core — PyBridge (PythonRunner, uv venv env, JSON-protocol scripts), DatasetService, TrainingService/RunStore/MetricsStream, ConversionService (Swift quant + Py fuse/convert), CLI drivers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 17 changed files with +1,666 and −4
modified
Package.swift
+6 −1
@@ -38,7 +38,12 @@ let package = Package( | ||
| 38 | 38 | .product(name: "HuggingFace", package: "swift-huggingface"), |
| 39 | 39 | .product(name: "Tokenizers", package: "swift-transformers"), |
| 40 | 40 | ], |
| 41 | − path: "Sources/ZyquoMLX" | |
| 41 | + path: "Sources/ZyquoMLX", | |
| 42 | + resources: [ | |
| 43 | + // Pinned Python helper scripts (PyBridge) — versioned with the | |
| 44 | + // app, never fetched remotely (docs/BUILD.md §3.4). | |
| 45 | + .copy("PyBridge/scripts") | |
| 46 | + ] | |
| 42 | 47 | ) |
| 43 | 48 | ] |
| 44 | 49 | ) |
modified
Sources/ZyquoMLX/App/CLI.swift
+2 −2
@@ -126,9 +126,9 @@ enum CLI { | ||
| 126 | 126 | try await engine.unload() |
| 127 | 127 | } |
| 128 | 128 | |
| 129 | − // MARK: - Arg parsing | |
| 129 | + // MARK: - Arg parsing (shared with CLIFoundry) | |
| 130 | 130 | |
| 131 | − private static func value(after flag: String, in args: [String]) -> String? { | |
| 131 | + static func value(after flag: String, in args: [String]) -> String? { | |
| 132 | 132 | guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil } |
| 133 | 133 | return args[index + 1] |
| 134 | 134 | } |
added
Sources/ZyquoMLX/App/CLIFoundry.swift
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +// | |
| 2 | +// CLIFoundry.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// CLI drivers for the Phase 3 foundry services (training, fusion, datasets). | |
| 12 | +/// | |
| 13 | +/// ZyquoMLX --train --model <dir> --dataset <jsonl-or-dataset-dir> [--iters N] [--method lora|qlora|dora|full] | |
| 14 | +/// ZyquoMLX --fuse --model <dir> --adapter <dir> --out <name> [--dequantize] | |
| 15 | +/// ZyquoMLX --validate-dataset <file.jsonl> | |
| 16 | +extension CLI { | |
| 17 | + | |
| 18 | + static var shouldRunFoundry: Bool { | |
| 19 | + let args = CommandLine.arguments | |
| 20 | + return args.contains("--train") || args.contains("--fuse") | |
| 21 | + || args.contains("--validate-dataset") | |
| 22 | + } | |
| 23 | + | |
| 24 | + static func runFoundry() async -> Int32 { | |
| 25 | + do { | |
| 26 | + let args = CommandLine.arguments | |
| 27 | + if args.contains("--train") { | |
| 28 | + try await train(args: args) | |
| 29 | + return 0 | |
| 30 | + } | |
| 31 | + if args.contains("--fuse") { | |
| 32 | + try await fuse(args: args) | |
| 33 | + return 0 | |
| 34 | + } | |
| 35 | + if let path = value(after: "--validate-dataset", in: args) { | |
| 36 | + try await validateDataset(path: path) | |
| 37 | + return 0 | |
| 38 | + } | |
| 39 | + return 2 | |
| 40 | + } catch { | |
| 41 | + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) | |
| 42 | + return 1 | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + // MARK: - Train | |
| 47 | + | |
| 48 | + private static func train(args: [String]) async throws { | |
| 49 | + guard let modelPath = value(after: "--model", in: args), | |
| 50 | + let dataPath = value(after: "--dataset", in: args) | |
| 51 | + else { | |
| 52 | + throw CLIError.usage("--train requires --model <dir> and --dataset <jsonl|dir>") | |
| 53 | + } | |
| 54 | + | |
| 55 | + let modelURL = URL(fileURLWithPath: (modelPath as NSString).expandingTildeInPath) | |
| 56 | + let model = try await ModelStore.shared.describe(directory: modelURL) | |
| 57 | + | |
| 58 | + // Accept a raw JSONL (imported on the fly) or an existing dataset dir. | |
| 59 | + let dataURL = URL(fileURLWithPath: (dataPath as NSString).expandingTildeInPath) | |
| 60 | + let dataset: Dataset | |
| 61 | + if dataURL.pathExtension == "jsonl" { | |
| 62 | + let report = try await DatasetService.shared.importJSONL( | |
| 63 | + from: dataURL, name: dataURL.deletingPathExtension().lastPathComponent) | |
| 64 | + dataset = report.dataset | |
| 65 | + print("dataset: \(dataset.name) [\(dataset.format.displayName)] " | |
| 66 | + + "train=\(dataset.trainCount) valid=\(dataset.validCount)" | |
| 67 | + + (report.issues.isEmpty ? "" : " (\(report.issues.count) rows skipped)")) | |
| 68 | + for issue in report.issues.prefix(5) { | |
| 69 | + print(" line \(issue.line): \(issue.problem) → \(issue.fix)") | |
| 70 | + } | |
| 71 | + } else { | |
| 72 | + dataset = try PersistenceService.loadJSON( | |
| 73 | + Dataset.self, from: dataURL.appendingPathComponent("dataset.json")) | |
| 74 | + } | |
| 75 | + | |
| 76 | + var hp = HyperParams() | |
| 77 | + hp.iterations = value(after: "--iters", in: args).flatMap(Int.init) ?? 100 | |
| 78 | + hp.batchSize = value(after: "--batch-size", in: args).flatMap(Int.init) ?? 2 | |
| 79 | + hp.saveEvery = value(after: "--save-every", in: args).flatMap(Int.init) ?? 50 | |
| 80 | + hp.stepsPerEval = value(after: "--steps-per-eval", in: args).flatMap(Int.init) ?? 50 | |
| 81 | + hp.stepsPerReport = 5 | |
| 82 | + | |
| 83 | + let methodName = value(after: "--method", in: args) ?? (model.quantization != nil ? "qlora" : "lora") | |
| 84 | + guard let method = FineTuneMethod(rawValue: methodName) else { | |
| 85 | + throw CLIError.usage("unknown --method \(methodName)") | |
| 86 | + } | |
| 87 | + | |
| 88 | + let service = TrainingService.shared | |
| 89 | + let run = try await service.createRun( | |
| 90 | + name: "cli-\(model.name)-\(method.rawValue)", | |
| 91 | + baseModel: model, dataset: dataset, method: method, hyperParams: hp) | |
| 92 | + print("run: \(run.id.uuidString)") | |
| 93 | + print("method: \(method.displayName) on \(model.name)") | |
| 94 | + print("verdict: \(MemoryAdvisor.trainingVerdict(for: model, method: method, params: hp).displayName)\n") | |
| 95 | + | |
| 96 | + let events = try await service.start(run: run, baseModel: model, dataset: dataset) | |
| 97 | + for await event in events { | |
| 98 | + switch event { | |
| 99 | + case .started(let model, let iterations): | |
| 100 | + print("training \(model) for \(iterations) iterations…") | |
| 101 | + case .metric(let m): | |
| 102 | + if let loss = m.trainLoss { | |
| 103 | + let speed = m.tokensPerSecond.map { String(format: "%.0f tok/s", $0) } ?? "" | |
| 104 | + let mem = m.peakMemoryGB.map { String(format: "peak %.1f GB", $0) } ?? "" | |
| 105 | + print("iter \(m.iteration): train loss \(String(format: "%.3f", loss)) \(speed) \(mem)") | |
| 106 | + } | |
| 107 | + if let loss = m.valLoss { | |
| 108 | + print("iter \(m.iteration): VAL loss \(String(format: "%.3f", loss))") | |
| 109 | + } | |
| 110 | + case .checkpointSaved(let name, _): | |
| 111 | + print("checkpoint saved: \(name)") | |
| 112 | + case .finished: | |
| 113 | + print("\ntraining finished ✅") | |
| 114 | + case .failed(let message): | |
| 115 | + print("\ntraining FAILED: \(message)") | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + let finished = try await RunStore.shared.load(id: run.id) | |
| 120 | + print("state: \(finished.state.rawValue), adapters: \(await RunStore.shared.latestAdapter(for: finished)?.path ?? "none")") | |
| 121 | + } | |
| 122 | + | |
| 123 | + // MARK: - Fuse | |
| 124 | + | |
| 125 | + private static func fuse(args: [String]) async throws { | |
| 126 | + guard let modelPath = value(after: "--model", in: args), | |
| 127 | + let adapterPath = value(after: "--adapter", in: args), | |
| 128 | + let outName = value(after: "--out", in: args) | |
| 129 | + else { | |
| 130 | + throw CLIError.usage("--fuse requires --model <dir>, --adapter <dir>, --out <name>") | |
| 131 | + } | |
| 132 | + let model = try await ModelStore.shared.describe( | |
| 133 | + directory: URL(fileURLWithPath: (modelPath as NSString).expandingTildeInPath)) | |
| 134 | + let adapter = URL(fileURLWithPath: (adapterPath as NSString).expandingTildeInPath) | |
| 135 | + | |
| 136 | + let events = try await ConversionService.shared.fuse( | |
| 137 | + baseModel: model, adapterDirectory: adapter, outputName: outName, | |
| 138 | + dequantize: args.contains("--dequantize")) | |
| 139 | + for await event in events { | |
| 140 | + switch event { | |
| 141 | + case .stage(let stage, _): | |
| 142 | + print("stage: \(stage)") | |
| 143 | + case .finished(let output): | |
| 144 | + print("fused model → \(output.path) ✅") | |
| 145 | + case .failed(let message): | |
| 146 | + throw CLIError.usage("fuse failed: \(message)") | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + // MARK: - Dataset validation | |
| 152 | + | |
| 153 | + private static func validateDataset(path: String) async throws { | |
| 154 | + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) | |
| 155 | + let report = try await DatasetService.shared.importJSONL( | |
| 156 | + from: url, name: url.deletingPathExtension().lastPathComponent + "-validated") | |
| 157 | + let d = report.dataset | |
| 158 | + print("format: \(d.format.displayName)") | |
| 159 | + print("valid rows: \(d.sampleCount) (train \(d.trainCount) / valid \(d.validCount) / test \(d.testCount))") | |
| 160 | + print("est. tokens: \(d.totalTokens ?? 0) total, longest sample ≈ \(d.maxSequenceTokens ?? 0)") | |
| 161 | + if report.issues.isEmpty { | |
| 162 | + print("issues: none ✅") | |
| 163 | + } else { | |
| 164 | + print("issues: \(report.issues.count) rows skipped") | |
| 165 | + for issue in report.issues.prefix(10) { | |
| 166 | + print(" line \(issue.line): \(issue.problem) → \(issue.fix)") | |
| 167 | + } | |
| 168 | + } | |
| 169 | + print("written to: \(d.directory.path)") | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +enum CLIError: LocalizedError { | |
| 174 | + case usage(String) | |
| 175 | + var errorDescription: String? { | |
| 176 | + if case .usage(let message) = self { return message } | |
| 177 | + return nil | |
| 178 | + } | |
| 179 | +} | |
modified
Sources/ZyquoMLX/App/Main.swift
+3 −0
@@ -16,6 +16,9 @@ enum Main { | ||
| 16 | 16 | if CLI.shouldRun { |
| 17 | 17 | exit(await CLI.run()) |
| 18 | 18 | } |
| 19 | + if CLI.shouldRunFoundry { | |
| 20 | + exit(await CLI.runFoundry()) | |
| 21 | + } | |
| 19 | 22 | ZyquoMLXApp.main() |
| 20 | 23 | } |
| 21 | 24 | } |
added
Sources/ZyquoMLX/Convert/ConversionService.swift
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +// | |
| 2 | +// ConversionService.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLX | |
| 11 | +import MLXLLM | |
| 12 | +import MLXLMCommon | |
| 13 | + | |
| 14 | +/// Progress for conversion/quantization/fusion jobs. | |
| 15 | +enum ConversionEvent: Sendable { | |
| 16 | + case stage(String, fraction: Double?) | |
| 17 | + case finished(outputDirectory: URL) | |
| 18 | + case failed(message: String) | |
| 19 | +} | |
| 20 | + | |
| 21 | +enum ConversionServiceError: LocalizedError { | |
| 22 | + case alreadyQuantized | |
| 23 | + case outputExists(URL) | |
| 24 | + | |
| 25 | + var errorDescription: String? { | |
| 26 | + switch self { | |
| 27 | + case .alreadyQuantized: | |
| 28 | + "This model is already quantized — de-quantize first if you want a different recipe." | |
| 29 | + case .outputExists(let url): | |
| 30 | + "Output folder already exists: \(url.lastPathComponent). Choose another name or delete it." | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Conversion, quantization, and adapter fusion (charter 3.C). | |
| 36 | +/// | |
| 37 | +/// Execution split per docs/MLX-RESEARCH.md §2/§6: | |
| 38 | +/// - Quantizing a local safetensors LLM → **native Swift** | |
| 39 | +/// (`LLMModelFactory.convert`, affine/mxfp4). | |
| 40 | +/// - Fusing adapters to disk, HF→MLX conversion of arbitrary repos | |
| 41 | +/// (incl. `.bin`), de-quantize → **Python bridge** (`mlx_lm` pinned). | |
| 42 | +actor ConversionService { | |
| 43 | + | |
| 44 | + static let shared = ConversionService() | |
| 45 | + | |
| 46 | + // MARK: - Swift-native quantization | |
| 47 | + | |
| 48 | + /// Quantize a local, unquantized safetensors LLM into a new model | |
| 49 | + /// directory in the library. | |
| 50 | + func quantize(model: LocalModel, config: QuantConfig, outputName: String) | |
| 51 | + -> AsyncStream<ConversionEvent> | |
| 52 | + { | |
| 53 | + let output = PersistenceService.modelsDirectory | |
| 54 | + .appendingPathComponent(outputName, isDirectory: true) | |
| 55 | + | |
| 56 | + let (stream, continuation) = AsyncStream.makeStream(of: ConversionEvent.self) | |
| 57 | + Task { | |
| 58 | + do { | |
| 59 | + guard model.quantization == nil else { throw ConversionServiceError.alreadyQuantized } | |
| 60 | + guard !FileManager.default.fileExists(atPath: output.path) else { | |
| 61 | + throw ConversionServiceError.outputExists(output) | |
| 62 | + } | |
| 63 | + let options = ModelConversionOptions( | |
| 64 | + bits: config.bits, | |
| 65 | + groupSize: config.groupSize, | |
| 66 | + mode: config.mode == .mxfp4 ? .mxfp4 : .affine | |
| 67 | + ) | |
| 68 | + _ = try await LLMModelFactory.shared.convert( | |
| 69 | + from: model.directory, | |
| 70 | + to: output, | |
| 71 | + options: options, | |
| 72 | + progressHandler: { progress in | |
| 73 | + continuation.yield( | |
| 74 | + .stage(progress.stage.rawValue, fraction: progress.fractionCompleted)) | |
| 75 | + } | |
| 76 | + ) | |
| 77 | + Memory.clearCache() | |
| 78 | + continuation.yield(.finished(outputDirectory: output)) | |
| 79 | + } catch { | |
| 80 | + continuation.yield(.failed(message: error.localizedDescription)) | |
| 81 | + } | |
| 82 | + continuation.finish() | |
| 83 | + } | |
| 84 | + return stream | |
| 85 | + } | |
| 86 | + | |
| 87 | + // MARK: - Python-bridge pipelines | |
| 88 | + | |
| 89 | + /// Fuse a trained adapter into its base model, producing a standalone | |
| 90 | + /// MLX model directory in the library. | |
| 91 | + func fuse(baseModel: LocalModel, adapterDirectory: URL, outputName: String, dequantize: Bool) | |
| 92 | + async throws -> AsyncStream<ConversionEvent> | |
| 93 | + { | |
| 94 | + let output = PersistenceService.modelsDirectory | |
| 95 | + .appendingPathComponent(outputName, isDirectory: true) | |
| 96 | + guard !FileManager.default.fileExists(atPath: output.path) else { | |
| 97 | + throw ConversionServiceError.outputExists(output) | |
| 98 | + } | |
| 99 | + | |
| 100 | + var arguments = [ | |
| 101 | + "--model", baseModel.directory.path, | |
| 102 | + "--adapter-path", adapterDirectory.path, | |
| 103 | + "--save-path", output.path, | |
| 104 | + ] | |
| 105 | + if dequantize { arguments.append("--dequantize") } | |
| 106 | + | |
| 107 | + return try await pythonJob(script: "zyquo_fuse", arguments: arguments, output: output) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Convert an HF repo/directory (any weights format mlx-lm handles) to | |
| 111 | + /// MLX, optionally quantized. | |
| 112 | + func convert(hfPath: String, outputName: String, quantize: QuantConfig?) | |
| 113 | + async throws -> AsyncStream<ConversionEvent> | |
| 114 | + { | |
| 115 | + let output = PersistenceService.modelsDirectory | |
| 116 | + .appendingPathComponent(outputName, isDirectory: true) | |
| 117 | + guard !FileManager.default.fileExists(atPath: output.path) else { | |
| 118 | + throw ConversionServiceError.outputExists(output) | |
| 119 | + } | |
| 120 | + | |
| 121 | + var arguments = ["--hf-path", hfPath, "--mlx-path", output.path] | |
| 122 | + if let quantize { | |
| 123 | + arguments += [ | |
| 124 | + "--quantize", | |
| 125 | + "--q-bits", String(quantize.bits), | |
| 126 | + "--q-group-size", String(quantize.groupSize), | |
| 127 | + "--q-mode", quantize.mode.rawValue, | |
| 128 | + ] | |
| 129 | + } | |
| 130 | + | |
| 131 | + return try await pythonJob(script: "zyquo_convert", arguments: arguments, output: output) | |
| 132 | + } | |
| 133 | + | |
| 134 | + private func pythonJob(script: String, arguments: [String], output: URL) | |
| 135 | + async throws -> AsyncStream<ConversionEvent> | |
| 136 | + { | |
| 137 | + let pythonStream = try await PythonRunner.shared.stream( | |
| 138 | + script: script, arguments: arguments) | |
| 139 | + let (stream, continuation) = AsyncStream.makeStream(of: ConversionEvent.self) | |
| 140 | + Task { | |
| 141 | + do { | |
| 142 | + for try await event in pythonStream { | |
| 143 | + switch event.event { | |
| 144 | + case "start": | |
| 145 | + continuation.yield(.stage(event.string("stage") ?? "working", fraction: nil)) | |
| 146 | + case "done": | |
| 147 | + continuation.yield(.finished(outputDirectory: output)) | |
| 148 | + case "error": | |
| 149 | + continuation.yield(.failed(message: event.string("message") ?? "unknown")) | |
| 150 | + default: | |
| 151 | + break | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } catch { | |
| 155 | + continuation.yield(.failed(message: error.localizedDescription)) | |
| 156 | + } | |
| 157 | + continuation.finish() | |
| 158 | + } | |
| 159 | + return stream | |
| 160 | + } | |
| 161 | +} | |
added
Sources/ZyquoMLX/Convert/QuantConfig.swift
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// | |
| 2 | +// QuantConfig.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Quantization recipe (docs/MLX-RESEARCH.md §5: affine supports bits | |
| 12 | +/// 2/3/4/5/6/8 with group sizes 32/64/128; mxfp4/nvfp4/mxfp8 have fixed | |
| 13 | +/// bits/groups). | |
| 14 | +struct QuantConfig: Codable, Hashable, Sendable { | |
| 15 | + enum Mode: String, Codable, CaseIterable, Sendable { | |
| 16 | + case affine | |
| 17 | + case mxfp4 | |
| 18 | + case nvfp4 | |
| 19 | + case mxfp8 | |
| 20 | + } | |
| 21 | + | |
| 22 | + var bits: Int = 4 | |
| 23 | + var groupSize: Int = 64 | |
| 24 | + var mode: Mode = .affine | |
| 25 | + | |
| 26 | + static let affineBits = [2, 3, 4, 5, 6, 8] | |
| 27 | + static let affineGroupSizes = [32, 64, 128] | |
| 28 | + | |
| 29 | + /// Effective bytes/param for size previews (group-scale overhead included). | |
| 30 | + var bytesPerParameter: Double { | |
| 31 | + (Double(bits) + 32.0 / Double(groupSize)) / 8.0 | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Predicted weight size after quantizing a model with the given | |
| 35 | + /// parameter count (before/after preview in the Convert UI). | |
| 36 | + func predictedWeightBytes(parameterCount: Int64) -> Int64 { | |
| 37 | + Int64(Double(parameterCount) * bytesPerParameter) | |
| 38 | + } | |
| 39 | + | |
| 40 | + var label: String { "\(bits)-bit \(mode.rawValue) (g\(groupSize))" } | |
| 41 | +} | |
added
Sources/ZyquoMLX/Data/DatasetFormats.swift
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +// | |
| 2 | +// DatasetFormats.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A problem found in one dataset row, with a concrete fix | |
| 12 | +/// (charter 3.A: report malformed rows with fixes). | |
| 13 | +struct DatasetRowIssue: Identifiable, Codable, Hashable, Sendable { | |
| 14 | + var id: Int { line } | |
| 15 | + /// 1-based line number in the source file. | |
| 16 | + var line: Int | |
| 17 | + var problem: String | |
| 18 | + var fix: String | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// One parsed sample, normalized for preview. | |
| 22 | +struct DatasetSample: Sendable { | |
| 23 | + var messages: [(role: String, content: String)] | |
| 24 | + var estimatedTokens: Int | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// Format detection + row validation for the JSONL shapes mlx-lm accepts | |
| 28 | +/// (docs/TRAINING-RESEARCH.md §4.2 — detection order: completions, chat, text). | |
| 29 | +enum DatasetFormats { | |
| 30 | + | |
| 31 | + static func detect(firstRow row: [String: Any]) -> DatasetFormat? { | |
| 32 | + if row["prompt"] != nil && row["completion"] != nil { return .completions } | |
| 33 | + if row["messages"] != nil { return .chat } | |
| 34 | + if row["text"] != nil { return .text } | |
| 35 | + return nil | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Validate one row against a format. Returns an issue or nil when valid. | |
| 39 | + static func validate(row: [String: Any], format: DatasetFormat, line: Int) -> DatasetRowIssue? { | |
| 40 | + switch format { | |
| 41 | + case .chat: | |
| 42 | + guard let messages = row["messages"] as? [[String: Any]] else { | |
| 43 | + return DatasetRowIssue( | |
| 44 | + line: line, | |
| 45 | + problem: "Missing or non-array \"messages\" key.", | |
| 46 | + fix: "Use {\"messages\": [{\"role\": \"user\", \"content\": \"…\"}, …]}.") | |
| 47 | + } | |
| 48 | + if messages.isEmpty { | |
| 49 | + return DatasetRowIssue( | |
| 50 | + line: line, problem: "\"messages\" is empty.", | |
| 51 | + fix: "Provide at least a user and an assistant message.") | |
| 52 | + } | |
| 53 | + let validRoles: Set<String> = ["system", "user", "assistant", "tool"] | |
| 54 | + for (i, message) in messages.enumerated() { | |
| 55 | + guard let role = message["role"] as? String, validRoles.contains(role) else { | |
| 56 | + return DatasetRowIssue( | |
| 57 | + line: line, | |
| 58 | + problem: "Message \(i + 1) has a missing/invalid \"role\".", | |
| 59 | + fix: "Use one of: system, user, assistant, tool.") | |
| 60 | + } | |
| 61 | + guard message["content"] is String else { | |
| 62 | + return DatasetRowIssue( | |
| 63 | + line: line, | |
| 64 | + problem: "Message \(i + 1) (\(role)) has no string \"content\".", | |
| 65 | + fix: "Every message needs a \"content\" string.") | |
| 66 | + } | |
| 67 | + } | |
| 68 | + if (messages.last?["role"] as? String) != "assistant" { | |
| 69 | + return DatasetRowIssue( | |
| 70 | + line: line, | |
| 71 | + problem: "Conversation does not end with an assistant message.", | |
| 72 | + fix: "The final message must be the assistant reply the model should learn.") | |
| 73 | + } | |
| 74 | + return nil | |
| 75 | + | |
| 76 | + case .completions: | |
| 77 | + guard row["prompt"] is String else { | |
| 78 | + return DatasetRowIssue( | |
| 79 | + line: line, problem: "Missing string \"prompt\".", | |
| 80 | + fix: "Use {\"prompt\": \"…\", \"completion\": \"…\"}.") | |
| 81 | + } | |
| 82 | + guard row["completion"] is String else { | |
| 83 | + return DatasetRowIssue( | |
| 84 | + line: line, problem: "Missing string \"completion\".", | |
| 85 | + fix: "Use {\"prompt\": \"…\", \"completion\": \"…\"}.") | |
| 86 | + } | |
| 87 | + return nil | |
| 88 | + | |
| 89 | + case .text: | |
| 90 | + guard let text = row["text"] as? String, !text.isEmpty else { | |
| 91 | + return DatasetRowIssue( | |
| 92 | + line: line, problem: "Missing or empty string \"text\".", | |
| 93 | + fix: "Use {\"text\": \"…\"} with non-empty content.") | |
| 94 | + } | |
| 95 | + return nil | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + /// Normalize a valid row into a preview sample. | |
| 100 | + static func sample(from row: [String: Any], format: DatasetFormat) -> DatasetSample { | |
| 101 | + var messages: [(String, String)] = [] | |
| 102 | + switch format { | |
| 103 | + case .chat: | |
| 104 | + for message in row["messages"] as? [[String: Any]] ?? [] { | |
| 105 | + messages.append( | |
| 106 | + (message["role"] as? String ?? "?", message["content"] as? String ?? "")) | |
| 107 | + } | |
| 108 | + case .completions: | |
| 109 | + messages = [ | |
| 110 | + ("user", row["prompt"] as? String ?? ""), | |
| 111 | + ("assistant", row["completion"] as? String ?? ""), | |
| 112 | + ] | |
| 113 | + case .text: | |
| 114 | + messages = [("text", row["text"] as? String ?? "")] | |
| 115 | + } | |
| 116 | + let characters = messages.reduce(0) { $0 + $1.1.count } | |
| 117 | + // ~4 chars/token heuristic; precise counts come from the tokenizer at | |
| 118 | + // training time (trainer truncates over max-seq-length with a warning). | |
| 119 | + return DatasetSample(messages: messages, estimatedTokens: max(1, characters / 4)) | |
| 120 | + } | |
| 121 | +} | |
added
Sources/ZyquoMLX/Data/DatasetService.swift
+197 −0
@@ -0,0 +1,197 @@ | ||
| 1 | +// | |
| 2 | +// DatasetService.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +enum DatasetImportError: LocalizedError { | |
| 12 | + case emptyFile | |
| 13 | + case undetectableFormat | |
| 14 | + case allRowsInvalid(issues: [DatasetRowIssue]) | |
| 15 | + case tooFewSamples(count: Int) | |
| 16 | + | |
| 17 | + var errorDescription: String? { | |
| 18 | + switch self { | |
| 19 | + case .emptyFile: | |
| 20 | + "The file contains no data rows." | |
| 21 | + case .undetectableFormat: | |
| 22 | + "Could not detect the dataset format. Rows must be JSON objects with \"messages\", \"prompt\"+\"completion\", or \"text\" keys." | |
| 23 | + case .allRowsInvalid(let issues): | |
| 24 | + "No valid rows found. First problem (line \(issues.first?.line ?? 1)): \(issues.first?.problem ?? "")" | |
| 25 | + case .tooFewSamples(let count): | |
| 26 | + "Only \(count) valid samples — at least 8 are needed for a meaningful train/valid split." | |
| 27 | + } | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +/// Result of a dataset import: what was written plus the validation report. | |
| 32 | +struct DatasetImportReport: Sendable { | |
| 33 | + var dataset: Dataset | |
| 34 | + var issues: [DatasetRowIssue] | |
| 35 | + var skippedRows: Int | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Imports, validates, splits, and previews training datasets | |
| 39 | +/// (charter 3.A). mlx-lm does not auto-split local JSONL | |
| 40 | +/// (docs/TRAINING-RESEARCH.md §4.5) — the split is ours. | |
| 41 | +actor DatasetService { | |
| 42 | + | |
| 43 | + static let shared = DatasetService() | |
| 44 | + | |
| 45 | + private let root: URL | |
| 46 | + | |
| 47 | + init(root: URL = PersistenceService.datasetsDirectory) { | |
| 48 | + self.root = root | |
| 49 | + } | |
| 50 | + | |
| 51 | + // MARK: - Import | |
| 52 | + | |
| 53 | + /// Import a JSONL file: validate every row, split into | |
| 54 | + /// train/valid(/test), and write the dataset directory. | |
| 55 | + func importJSONL( | |
| 56 | + from sourceURL: URL, | |
| 57 | + name: String, | |
| 58 | + validFraction: Double = 0.1, | |
| 59 | + testFraction: Double = 0.0, | |
| 60 | + seed: UInt64 = 0 | |
| 61 | + ) throws -> DatasetImportReport { | |
| 62 | + let content = try String(contentsOf: sourceURL, encoding: .utf8) | |
| 63 | + let lines = content.split(separator: "\n", omittingEmptySubsequences: true) | |
| 64 | + guard !lines.isEmpty else { throw DatasetImportError.emptyFile } | |
| 65 | + | |
| 66 | + var format: DatasetFormat? | |
| 67 | + var validRows: [String] = [] | |
| 68 | + var issues: [DatasetRowIssue] = [] | |
| 69 | + var totalTokens = 0 | |
| 70 | + var maxTokens = 0 | |
| 71 | + | |
| 72 | + for (index, line) in lines.enumerated() { | |
| 73 | + let lineNumber = index + 1 | |
| 74 | + guard | |
| 75 | + let data = line.data(using: .utf8), | |
| 76 | + let row = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] | |
| 77 | + else { | |
| 78 | + issues.append( | |
| 79 | + DatasetRowIssue( | |
| 80 | + line: lineNumber, problem: "Not a valid JSON object.", | |
| 81 | + fix: "Each line must be one complete JSON object (no trailing commas, no multi-line objects).")) | |
| 82 | + continue | |
| 83 | + } | |
| 84 | + | |
| 85 | + if format == nil { | |
| 86 | + format = DatasetFormats.detect(firstRow: row) | |
| 87 | + guard format != nil else { throw DatasetImportError.undetectableFormat } | |
| 88 | + } | |
| 89 | + | |
| 90 | + if let issue = DatasetFormats.validate(row: row, format: format!, line: lineNumber) { | |
| 91 | + issues.append(issue) | |
| 92 | + continue | |
| 93 | + } | |
| 94 | + | |
| 95 | + let sample = DatasetFormats.sample(from: row, format: format!) | |
| 96 | + totalTokens += sample.estimatedTokens | |
| 97 | + maxTokens = max(maxTokens, sample.estimatedTokens) | |
| 98 | + validRows.append(String(line)) | |
| 99 | + } | |
| 100 | + | |
| 101 | + guard let detectedFormat = format else { throw DatasetImportError.undetectableFormat } | |
| 102 | + guard !validRows.isEmpty else { throw DatasetImportError.allRowsInvalid(issues: issues) } | |
| 103 | + guard validRows.count >= 8 else { throw DatasetImportError.tooFewSamples(count: validRows.count) } | |
| 104 | + | |
| 105 | + // Deterministic shuffle then split. | |
| 106 | + var generator = SeededGenerator(seed: seed) | |
| 107 | + validRows.shuffle(using: &generator) | |
| 108 | + | |
| 109 | + let testCount = Int(Double(validRows.count) * testFraction) | |
| 110 | + let validCount = max(1, Int(Double(validRows.count) * validFraction)) | |
| 111 | + let trainCount = validRows.count - validCount - testCount | |
| 112 | + | |
| 113 | + let directory = root.appendingPathComponent(sanitize(name), isDirectory: true) | |
| 114 | + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| 115 | + | |
| 116 | + let train = validRows[0..<trainCount] | |
| 117 | + let valid = validRows[trainCount..<(trainCount + validCount)] | |
| 118 | + let test = validRows[(trainCount + validCount)...] | |
| 119 | + | |
| 120 | + try write(rows: Array(train), to: directory.appendingPathComponent("train.jsonl")) | |
| 121 | + try write(rows: Array(valid), to: directory.appendingPathComponent("valid.jsonl")) | |
| 122 | + if !test.isEmpty { | |
| 123 | + try write(rows: Array(test), to: directory.appendingPathComponent("test.jsonl")) | |
| 124 | + } | |
| 125 | + | |
| 126 | + let dataset = Dataset( | |
| 127 | + id: UUID(), | |
| 128 | + name: name, | |
| 129 | + directory: directory, | |
| 130 | + format: detectedFormat, | |
| 131 | + trainCount: trainCount, | |
| 132 | + validCount: validCount, | |
| 133 | + testCount: test.count, | |
| 134 | + totalTokens: totalTokens, | |
| 135 | + maxSequenceTokens: maxTokens, | |
| 136 | + importedAt: .now | |
| 137 | + ) | |
| 138 | + try PersistenceService.saveJSON(dataset, to: directory.appendingPathComponent("dataset.json")) | |
| 139 | + | |
| 140 | + return DatasetImportReport( | |
| 141 | + dataset: dataset, issues: issues, skippedRows: issues.count) | |
| 142 | + } | |
| 143 | + | |
| 144 | + // MARK: - Library | |
| 145 | + | |
| 146 | + func scan() throws -> [Dataset] { | |
| 147 | + let fm = FileManager.default | |
| 148 | + guard fm.fileExists(atPath: root.path) else { return [] } | |
| 149 | + let entries = try fm.contentsOfDirectory( | |
| 150 | + at: root, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) | |
| 151 | + return entries.compactMap { | |
| 152 | + try? PersistenceService.loadJSON( | |
| 153 | + Dataset.self, from: $0.appendingPathComponent("dataset.json")) | |
| 154 | + } | |
| 155 | + .sorted { $0.importedAt > $1.importedAt } | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// First N samples of a split, parsed for preview. | |
| 159 | + func preview(dataset: Dataset, split: String = "train", count: Int = 8) throws -> [DatasetSample] { | |
| 160 | + let url = dataset.directory.appendingPathComponent("\(split).jsonl") | |
| 161 | + let content = try String(contentsOf: url, encoding: .utf8) | |
| 162 | + return content.split(separator: "\n").prefix(count).compactMap { line in | |
| 163 | + guard | |
| 164 | + let data = line.data(using: .utf8), | |
| 165 | + let row = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] | |
| 166 | + else { return nil } | |
| 167 | + return DatasetFormats.sample(from: row, format: dataset.format) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + func delete(_ dataset: Dataset) throws { | |
| 172 | + try FileManager.default.removeItem(at: dataset.directory) | |
| 173 | + } | |
| 174 | + | |
| 175 | + // MARK: - Helpers | |
| 176 | + | |
| 177 | + private func write(rows: [String], to url: URL) throws { | |
| 178 | + try (rows.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) | |
| 179 | + } | |
| 180 | + | |
| 181 | + private func sanitize(_ name: String) -> String { | |
| 182 | + name.replacingOccurrences(of: "[^A-Za-z0-9._-]+", with: "-", options: .regularExpression) | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +/// Deterministic RNG for reproducible splits (SplitMix64). | |
| 187 | +struct SeededGenerator: RandomNumberGenerator { | |
| 188 | + private var state: UInt64 | |
| 189 | + init(seed: UInt64) { state = seed &+ 0x9E37_79B9_7F4A_7C15 } | |
| 190 | + mutating func next() -> UInt64 { | |
| 191 | + state &+= 0x9E37_79B9_7F4A_7C15 | |
| 192 | + var z = state | |
| 193 | + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 | |
| 194 | + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB | |
| 195 | + return z ^ (z >> 31) | |
| 196 | + } | |
| 197 | +} | |
added
Sources/ZyquoMLX/PyBridge/PythonEnvironment.swift
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +// | |
| 2 | +// PythonEnvironment.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Manages the isolated, pinned Python environment in | |
| 12 | +/// `~/Library/Application Support/ZyquoMLX/py/venv` (docs/BUILD.md §3.4). | |
| 13 | +/// Provisioned with `uv` on first use; never touches system Python; lives | |
| 14 | +/// outside the signed bundle (notarization posture, docs/BUILD.md §4). | |
| 15 | +actor PythonEnvironment { | |
| 16 | + | |
| 17 | + static let shared = PythonEnvironment() | |
| 18 | + | |
| 19 | + /// Exact pins per docs/MLX-RESEARCH.md §6 (core set; mlx-vlm/whisper/audio | |
| 20 | + /// install on demand when those capabilities are first used). | |
| 21 | + static let pythonVersion = "3.12" | |
| 22 | + static let corePins = ["mlx-lm==0.31.3", "pyyaml==6.0.3"] | |
| 23 | + | |
| 24 | + enum EnvironmentError: LocalizedError { | |
| 25 | + case uvNotFound | |
| 26 | + case provisioningFailed(String) | |
| 27 | + | |
| 28 | + var errorDescription: String? { | |
| 29 | + switch self { | |
| 30 | + case .uvNotFound: | |
| 31 | + "The 'uv' tool is required to set up the Python environment. Install it with: brew install uv" | |
| 32 | + case .provisioningFailed(let detail): | |
| 33 | + "Python environment setup failed: \(detail)" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + var venvURL: URL { | |
| 39 | + PersistenceService.pythonDirectory.appendingPathComponent("venv", isDirectory: true) | |
| 40 | + } | |
| 41 | + | |
| 42 | + var pythonURL: URL { | |
| 43 | + venvURL.appendingPathComponent("bin/python") | |
| 44 | + } | |
| 45 | + | |
| 46 | + private var markerURL: URL { | |
| 47 | + PersistenceService.pythonDirectory.appendingPathComponent("provisioned.json") | |
| 48 | + } | |
| 49 | + | |
| 50 | + var isProvisioned: Bool { | |
| 51 | + FileManager.default.fileExists(atPath: pythonURL.path) | |
| 52 | + && FileManager.default.fileExists(atPath: markerURL.path) | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Locate `uv` in the usual installation paths (the app has no shell PATH). | |
| 56 | + static func findUV() -> URL? { | |
| 57 | + let candidates = [ | |
| 58 | + "\(NSHomeDirectory())/.local/bin/uv", | |
| 59 | + "/opt/homebrew/bin/uv", | |
| 60 | + "/usr/local/bin/uv", | |
| 61 | + ] | |
| 62 | + return candidates.first { FileManager.default.fileExists(atPath: $0) } | |
| 63 | + .map { URL(fileURLWithPath: $0) } | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// Create the venv and install core pins. Idempotent. | |
| 67 | + func provision(progress: @Sendable (String) -> Void = { _ in }) async throws { | |
| 68 | + if isProvisioned { return } | |
| 69 | + guard let uv = Self.findUV() else { throw EnvironmentError.uvNotFound } | |
| 70 | + | |
| 71 | + try FileManager.default.createDirectory( | |
| 72 | + at: PersistenceService.pythonDirectory, withIntermediateDirectories: true) | |
| 73 | + | |
| 74 | + progress("Installing Python \(Self.pythonVersion)…") | |
| 75 | + try runUV(uv, ["python", "install", Self.pythonVersion]) | |
| 76 | + | |
| 77 | + progress("Creating environment…") | |
| 78 | + try runUV(uv, ["venv", venvURL.path, "--python", Self.pythonVersion]) | |
| 79 | + | |
| 80 | + progress("Installing MLX packages…") | |
| 81 | + try runUV(uv, ["pip", "install", "--python", pythonURL.path] + Self.corePins) | |
| 82 | + | |
| 83 | + let marker: [String: String] = [ | |
| 84 | + "python": Self.pythonVersion, | |
| 85 | + "pins": Self.corePins.joined(separator: " "), | |
| 86 | + "provisionedAt": ISO8601DateFormatter().string(from: .now), | |
| 87 | + ] | |
| 88 | + try PersistenceService.saveJSON(marker, to: markerURL) | |
| 89 | + progress("Python environment ready.") | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Install additional pinned packages (e.g. mlx-vlm when VLM Python | |
| 93 | + /// pipelines are first needed). | |
| 94 | + func install(pins: [String]) throws { | |
| 95 | + guard let uv = Self.findUV() else { throw EnvironmentError.uvNotFound } | |
| 96 | + try runUV(uv, ["pip", "install", "--python", pythonURL.path] + pins) | |
| 97 | + } | |
| 98 | + | |
| 99 | + /// Delete and re-provision (Settings › Python Environment › Repair). | |
| 100 | + func repair(progress: @Sendable (String) -> Void = { _ in }) async throws { | |
| 101 | + try? FileManager.default.removeItem(at: venvURL) | |
| 102 | + try? FileManager.default.removeItem(at: markerURL) | |
| 103 | + try await provision(progress: progress) | |
| 104 | + } | |
| 105 | + | |
| 106 | + private func runUV(_ uv: URL, _ arguments: [String]) throws { | |
| 107 | + let process = Process() | |
| 108 | + process.executableURL = uv | |
| 109 | + process.arguments = arguments | |
| 110 | + let errPipe = Pipe() | |
| 111 | + process.standardError = errPipe | |
| 112 | + process.standardOutput = Pipe() | |
| 113 | + try process.run() | |
| 114 | + process.waitUntilExit() | |
| 115 | + if process.terminationStatus != 0 { | |
| 116 | + let data = errPipe.fileHandleForReading.readDataToEndOfFile() | |
| 117 | + throw EnvironmentError.provisioningFailed( | |
| 118 | + String(data: data, encoding: .utf8) ?? "exit \(process.terminationStatus)") | |
| 119 | + } | |
| 120 | + } | |
| 121 | +} | |
added
Sources/ZyquoMLX/PyBridge/PythonRunner.swift
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +// | |
| 2 | +// PythonRunner.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One JSON-lines event from a PyBridge helper script. | |
| 12 | +/// All scripts emit `{"event": "...", ...}` objects, one per line | |
| 13 | +/// (protocol defined in `PyBridge/scripts/` — docs/TRAINING-RESEARCH.md §5). | |
| 14 | +struct PythonEvent: @unchecked Sendable { | |
| 15 | + let event: String | |
| 16 | + /// JSONSerialization plist values (NSString/NSNumber/…) — value types in | |
| 17 | + /// practice, hence the unchecked-Sendable annotation. | |
| 18 | + let payload: [String: Any] | |
| 19 | + | |
| 20 | + func double(_ key: String) -> Double? { | |
| 21 | + (payload[key] as? Double) ?? (payload[key] as? Int).map(Double.init) | |
| 22 | + } | |
| 23 | + func int(_ key: String) -> Int? { | |
| 24 | + (payload[key] as? Int) ?? (payload[key] as? Double).map(Int.init) | |
| 25 | + } | |
| 26 | + func string(_ key: String) -> String? { payload[key] as? String } | |
| 27 | +} | |
| 28 | + | |
| 29 | +enum PythonRunnerError: LocalizedError { | |
| 30 | + case scriptNotFound(String) | |
| 31 | + case environmentNotProvisioned | |
| 32 | + case processFailed(exitCode: Int32, stderr: String) | |
| 33 | + | |
| 34 | + var errorDescription: String? { | |
| 35 | + switch self { | |
| 36 | + case .scriptNotFound(let name): | |
| 37 | + "Bundled Python script missing: \(name)" | |
| 38 | + case .environmentNotProvisioned: | |
| 39 | + "The Python environment is not set up yet. It is provisioned automatically on first use." | |
| 40 | + case .processFailed(let code, let stderr): | |
| 41 | + "Python pipeline failed (exit \(code)). \(stderr.suffix(500))" | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +/// All Python interaction goes through here (charter engineering standard): | |
| 47 | +/// spawns the pinned venv's interpreter on a bundled script and streams the | |
| 48 | +/// JSON-lines protocol back. Cancellation terminates the process. | |
| 49 | +actor PythonRunner { | |
| 50 | + | |
| 51 | + static let shared = PythonRunner() | |
| 52 | + | |
| 53 | + /// Resolve a bundled helper script by name. | |
| 54 | + static func scriptURL(named name: String) throws -> URL { | |
| 55 | + guard | |
| 56 | + let url = Bundle.module.url( | |
| 57 | + forResource: name, withExtension: "py", subdirectory: "scripts") | |
| 58 | + else { | |
| 59 | + throw PythonRunnerError.scriptNotFound("\(name).py") | |
| 60 | + } | |
| 61 | + return url | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// Run a script and stream its events. The stream throws on nonzero exit | |
| 65 | + /// (unless an `error` event already surfaced the failure) and finishes | |
| 66 | + /// after the process exits. Terminating the stream kills the process. | |
| 67 | + func stream(script: String, arguments: [String]) async throws | |
| 68 | + -> AsyncThrowingStream<PythonEvent, Error> | |
| 69 | + { | |
| 70 | + let env = PythonEnvironment.shared | |
| 71 | + if await !env.isProvisioned { | |
| 72 | + try await env.provision() | |
| 73 | + } | |
| 74 | + let python = await env.pythonURL | |
| 75 | + let scriptURL = try Self.scriptURL(named: script) | |
| 76 | + | |
| 77 | + let process = Process() | |
| 78 | + process.executableURL = python | |
| 79 | + process.arguments = ["-u", scriptURL.path] + arguments | |
| 80 | + // Isolated, reproducible interpreter environment. | |
| 81 | + process.environment = [ | |
| 82 | + "PATH": "/usr/bin:/bin", | |
| 83 | + "HOME": NSHomeDirectory(), | |
| 84 | + "PYTHONUNBUFFERED": "1", | |
| 85 | + ] | |
| 86 | + | |
| 87 | + let stdout = Pipe() | |
| 88 | + let stderr = Pipe() | |
| 89 | + process.standardOutput = stdout | |
| 90 | + process.standardError = stderr | |
| 91 | + | |
| 92 | + return AsyncThrowingStream { continuation in | |
| 93 | + let stderrBuffer = LockedBuffer() | |
| 94 | + | |
| 95 | + stderr.fileHandleForReading.readabilityHandler = { handle in | |
| 96 | + stderrBuffer.append(handle.availableData) | |
| 97 | + } | |
| 98 | + | |
| 99 | + let lineBuffer = LockedBuffer() | |
| 100 | + stdout.fileHandleForReading.readabilityHandler = { handle in | |
| 101 | + let data = handle.availableData | |
| 102 | + guard !data.isEmpty else { return } | |
| 103 | + for line in lineBuffer.appendAndExtractLines(data) { | |
| 104 | + if let event = Self.decode(line: line) { | |
| 105 | + continuation.yield(event) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + process.terminationHandler = { proc in | |
| 111 | + stdout.fileHandleForReading.readabilityHandler = nil | |
| 112 | + stderr.fileHandleForReading.readabilityHandler = nil | |
| 113 | + // Flush any trailing output. | |
| 114 | + let rest = stdout.fileHandleForReading.readDataToEndOfFile() | |
| 115 | + for line in lineBuffer.appendAndExtractLines(rest, flush: true) { | |
| 116 | + if let event = Self.decode(line: line) { | |
| 117 | + continuation.yield(event) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + stderrBuffer.append(stderr.fileHandleForReading.readDataToEndOfFile()) | |
| 121 | + if proc.terminationStatus == 0 { | |
| 122 | + continuation.finish() | |
| 123 | + } else { | |
| 124 | + continuation.finish( | |
| 125 | + throwing: PythonRunnerError.processFailed( | |
| 126 | + exitCode: proc.terminationStatus, | |
| 127 | + stderr: stderrBuffer.string)) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + continuation.onTermination = { termination in | |
| 132 | + if case .cancelled = termination, process.isRunning { | |
| 133 | + process.terminate() | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + do { | |
| 138 | + try process.run() | |
| 139 | + } catch { | |
| 140 | + continuation.finish(throwing: error) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + private static func decode(line: Data) -> PythonEvent? { | |
| 146 | + guard | |
| 147 | + let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], | |
| 148 | + let event = object["event"] as? String | |
| 149 | + else { return nil } | |
| 150 | + return PythonEvent(event: event, payload: object) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +/// Thread-safe byte buffer for pipe readability handlers (they run on a | |
| 155 | +/// private DispatchQueue while the stream lives elsewhere). | |
| 156 | +private final class LockedBuffer: @unchecked Sendable { | |
| 157 | + private let lock = NSLock() | |
| 158 | + private var data = Data() | |
| 159 | + | |
| 160 | + func append(_ chunk: Data) { | |
| 161 | + lock.lock() | |
| 162 | + defer { lock.unlock() } | |
| 163 | + data.append(chunk) | |
| 164 | + } | |
| 165 | + | |
| 166 | + /// Append then split out complete `\n`-terminated lines (keeps the tail). | |
| 167 | + func appendAndExtractLines(_ chunk: Data, flush: Bool = false) -> [Data] { | |
| 168 | + lock.lock() | |
| 169 | + defer { lock.unlock() } | |
| 170 | + data.append(chunk) | |
| 171 | + var lines: [Data] = [] | |
| 172 | + while let newline = data.firstIndex(of: UInt8(ascii: "\n")) { | |
| 173 | + lines.append(data.subdata(in: data.startIndex..<newline)) | |
| 174 | + data.removeSubrange(data.startIndex...newline) | |
| 175 | + } | |
| 176 | + if flush, !data.isEmpty { | |
| 177 | + lines.append(data) | |
| 178 | + data.removeAll() | |
| 179 | + } | |
| 180 | + return lines | |
| 181 | + } | |
| 182 | + | |
| 183 | + var string: String { | |
| 184 | + lock.lock() | |
| 185 | + defer { lock.unlock() } | |
| 186 | + return String(data: data, encoding: .utf8) ?? "" | |
| 187 | + } | |
| 188 | +} | |
added
Sources/ZyquoMLX/PyBridge/scripts/zyquo_convert.py
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# | |
| 2 | +# zyquo_convert.py | |
| 3 | +# Zyquo MLX | |
| 4 | +# | |
| 5 | +# Author: Simon-Pierre Boucher | |
| 6 | +# Mail: contact@spboucher.ai | |
| 7 | +# | |
| 8 | +# Conversion/quantization driver: HF model → MLX format, optionally | |
| 9 | +# quantized (mlx_lm.convert semantics — docs/MLX-RESEARCH.md §5.2). | |
| 10 | +# JSON-lines protocol on stdout. | |
| 11 | +# | |
| 12 | +# Usage: zyquo_convert.py --hf-path <repo-or-dir> --mlx-path <dir> | |
| 13 | +# [--quantize --q-bits 4 --q-group-size 64 --q-mode affine] | |
| 14 | +# [--dtype float16|bfloat16|float32] [--dequantize] | |
| 15 | + | |
| 16 | +import argparse | |
| 17 | +import json | |
| 18 | +import sys | |
| 19 | +import time | |
| 20 | + | |
| 21 | + | |
| 22 | +def emit(obj): | |
| 23 | + sys.stdout.write(json.dumps(obj) + "\n") | |
| 24 | + sys.stdout.flush() | |
| 25 | + | |
| 26 | + | |
| 27 | +def main(): | |
| 28 | + parser = argparse.ArgumentParser() | |
| 29 | + parser.add_argument("--hf-path", required=True) | |
| 30 | + parser.add_argument("--mlx-path", required=True) | |
| 31 | + parser.add_argument("--quantize", action="store_true") | |
| 32 | + parser.add_argument("--q-bits", type=int, default=None) | |
| 33 | + parser.add_argument("--q-group-size", type=int, default=None) | |
| 34 | + parser.add_argument("--q-mode", default="affine", | |
| 35 | + choices=["affine", "mxfp4", "nvfp4", "mxfp8"]) | |
| 36 | + parser.add_argument("--dtype", default=None, | |
| 37 | + choices=["float16", "bfloat16", "float32"]) | |
| 38 | + parser.add_argument("--dequantize", action="store_true") | |
| 39 | + args = parser.parse_args() | |
| 40 | + | |
| 41 | + try: | |
| 42 | + emit({"event": "start", "stage": "convert", "hf_path": args.hf_path, "ts": time.time()}) | |
| 43 | + | |
| 44 | + from mlx_lm import convert | |
| 45 | + | |
| 46 | + kwargs = { | |
| 47 | + "hf_path": args.hf_path, | |
| 48 | + "mlx_path": args.mlx_path, | |
| 49 | + "quantize": args.quantize, | |
| 50 | + "dequantize": args.dequantize, | |
| 51 | + } | |
| 52 | + if args.q_bits is not None: | |
| 53 | + kwargs["q_bits"] = args.q_bits | |
| 54 | + if args.q_group_size is not None: | |
| 55 | + kwargs["q_group_size"] = args.q_group_size | |
| 56 | + if args.quantize: | |
| 57 | + kwargs["q_mode"] = args.q_mode | |
| 58 | + if args.dtype: | |
| 59 | + kwargs["dtype"] = args.dtype | |
| 60 | + | |
| 61 | + convert(**kwargs) | |
| 62 | + emit({"event": "done", "mlx_path": args.mlx_path}) | |
| 63 | + except Exception as exc: # noqa: BLE001 | |
| 64 | + emit({"event": "error", "message": str(exc), "type": type(exc).__name__}) | |
| 65 | + sys.exit(1) | |
| 66 | + | |
| 67 | + | |
| 68 | +if __name__ == "__main__": | |
| 69 | + main() | |
added
Sources/ZyquoMLX/PyBridge/scripts/zyquo_fuse.py
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +# | |
| 2 | +# zyquo_fuse.py | |
| 3 | +# Zyquo MLX | |
| 4 | +# | |
| 5 | +# Author: Simon-Pierre Boucher | |
| 6 | +# Mail: contact@spboucher.ai | |
| 7 | +# | |
| 8 | +# Adapter-fuse driver: fuses a LoRA adapter into its base model and saves a | |
| 9 | +# standalone MLX model directory (mlx_lm.fuse semantics — | |
| 10 | +# docs/TRAINING-RESEARCH.md §2.2). JSON-lines protocol on stdout. | |
| 11 | +# | |
| 12 | +# Usage: zyquo_fuse.py --model <base> --adapter-path <dir> --save-path <dir> | |
| 13 | +# [--dequantize] | |
| 14 | + | |
| 15 | +import argparse | |
| 16 | +import json | |
| 17 | +import sys | |
| 18 | +import time | |
| 19 | + | |
| 20 | + | |
| 21 | +def emit(obj): | |
| 22 | + sys.stdout.write(json.dumps(obj) + "\n") | |
| 23 | + sys.stdout.flush() | |
| 24 | + | |
| 25 | + | |
| 26 | +def main(): | |
| 27 | + parser = argparse.ArgumentParser() | |
| 28 | + parser.add_argument("--model", required=True) | |
| 29 | + parser.add_argument("--adapter-path", required=True) | |
| 30 | + parser.add_argument("--save-path", required=True) | |
| 31 | + parser.add_argument("--dequantize", action="store_true") | |
| 32 | + args = parser.parse_args() | |
| 33 | + | |
| 34 | + try: | |
| 35 | + emit({"event": "start", "stage": "fuse", "model": args.model, "ts": time.time()}) | |
| 36 | + | |
| 37 | + # mlx_lm.fuse exposes only a CLI main(); call it with argv to stay | |
| 38 | + # on the supported surface of the pinned version. | |
| 39 | + from mlx_lm import fuse | |
| 40 | + | |
| 41 | + argv = [ | |
| 42 | + "--model", args.model, | |
| 43 | + "--adapter-path", args.adapter_path, | |
| 44 | + "--save-path", args.save_path, | |
| 45 | + ] | |
| 46 | + if args.dequantize: | |
| 47 | + argv.append("--de-quantize") | |
| 48 | + | |
| 49 | + old_argv = sys.argv | |
| 50 | + sys.argv = ["mlx_lm.fuse"] + argv | |
| 51 | + try: | |
| 52 | + fuse.main() | |
| 53 | + finally: | |
| 54 | + sys.argv = old_argv | |
| 55 | + | |
| 56 | + emit({"event": "done", "save_path": args.save_path}) | |
| 57 | + except Exception as exc: # noqa: BLE001 | |
| 58 | + emit({"event": "error", "message": str(exc), "type": type(exc).__name__}) | |
| 59 | + sys.exit(1) | |
| 60 | + | |
| 61 | + | |
| 62 | +if __name__ == "__main__": | |
| 63 | + main() | |
added
Sources/ZyquoMLX/PyBridge/scripts/zyquo_train.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +# | |
| 2 | +# zyquo_train.py | |
| 3 | +# Zyquo MLX | |
| 4 | +# | |
| 5 | +# Author: Simon-Pierre Boucher | |
| 6 | +# Mail: contact@spboucher.ai | |
| 7 | +# | |
| 8 | +# Training driver for the Zyquo MLX Swift app. | |
| 9 | +# | |
| 10 | +# Wraps mlx_lm.lora.run() with a custom TrainingCallback and emits a stable | |
| 11 | +# JSON-lines protocol on stdout (one JSON object per line, event-typed). | |
| 12 | +# We NEVER rely on mlx-lm's own stdout format (it changed between 0.31.3 and | |
| 13 | +# main — docs/TRAINING-RESEARCH.md §5). Pinned against mlx-lm==0.31.3. | |
| 14 | +# | |
| 15 | +# Usage: zyquo_train.py --config <run-config.yaml> | |
| 16 | +# | |
| 17 | +# Events: {"event":"start", ...} {"event":"train", ...} {"event":"val", ...} | |
| 18 | +# {"event":"save", ...} {"event":"done"} {"event":"error", ...} | |
| 19 | + | |
| 20 | +import argparse | |
| 21 | +import json | |
| 22 | +import sys | |
| 23 | +import time | |
| 24 | +import types | |
| 25 | + | |
| 26 | + | |
| 27 | +def emit(obj): | |
| 28 | + sys.stdout.write(json.dumps(obj) + "\n") | |
| 29 | + sys.stdout.flush() | |
| 30 | + | |
| 31 | + | |
| 32 | +class ZyquoCallback: | |
| 33 | + """Receives the stable mlx-lm TrainingCallback dict payloads | |
| 34 | + (docs/TRAINING-RESEARCH.md §5.2) and re-emits them as JSON lines.""" | |
| 35 | + | |
| 36 | + def on_train_loss_report(self, info): | |
| 37 | + emit({"event": "train", **info, "ts": time.time()}) | |
| 38 | + | |
| 39 | + def on_val_loss_report(self, info): | |
| 40 | + emit({"event": "val", **info, "ts": time.time()}) | |
| 41 | + | |
| 42 | + | |
| 43 | +def main(): | |
| 44 | + parser = argparse.ArgumentParser() | |
| 45 | + parser.add_argument("--config", required=True, help="YAML run config (mlx-lm schema)") | |
| 46 | + args = parser.parse_args() | |
| 47 | + | |
| 48 | + try: | |
| 49 | + import yaml | |
| 50 | + from mlx_lm import lora | |
| 51 | + | |
| 52 | + with open(args.config) as f: | |
| 53 | + config = yaml.safe_load(f) | |
| 54 | + | |
| 55 | + # Build the args namespace exactly like mlx_lm.lora's CLI does: | |
| 56 | + # defaults first, then config overrides. | |
| 57 | + run_args = dict(lora.CONFIG_DEFAULTS) | |
| 58 | + run_args.update(config) | |
| 59 | + ns = types.SimpleNamespace(**run_args) | |
| 60 | + | |
| 61 | + emit({ | |
| 62 | + "event": "start", | |
| 63 | + "model": ns.model, | |
| 64 | + "fine_tune_type": ns.fine_tune_type, | |
| 65 | + "iters": ns.iters, | |
| 66 | + "batch_size": ns.batch_size, | |
| 67 | + "learning_rate": ns.learning_rate, | |
| 68 | + "adapter_path": ns.adapter_path, | |
| 69 | + }) | |
| 70 | + | |
| 71 | + lora.run(ns, training_callback=ZyquoCallback()) | |
| 72 | + emit({"event": "done"}) | |
| 73 | + except KeyboardInterrupt: | |
| 74 | + emit({"event": "error", "message": "cancelled"}) | |
| 75 | + sys.exit(130) | |
| 76 | + except Exception as exc: # noqa: BLE001 - single funnel to the app | |
| 77 | + emit({"event": "error", "message": str(exc), "type": type(exc).__name__}) | |
| 78 | + sys.exit(1) | |
| 79 | + | |
| 80 | + | |
| 81 | +if __name__ == "__main__": | |
| 82 | + main() | |
added
Sources/ZyquoMLX/Training/MetricsStream.swift
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +// | |
| 2 | +// MetricsStream.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Typed training events, decoded from the PyBridge JSON-lines protocol | |
| 12 | +/// (`zyquo_train.py`; payload fields are the stable mlx-lm callback dicts — | |
| 13 | +/// docs/TRAINING-RESEARCH.md §5.2). | |
| 14 | +enum TrainingEvent: Sendable { | |
| 15 | + case started(model: String, iterations: Int) | |
| 16 | + case metric(TrainingMetric) | |
| 17 | + case checkpointSaved(fileName: String, iteration: Int) | |
| 18 | + case finished | |
| 19 | + case failed(message: String) | |
| 20 | +} | |
| 21 | + | |
| 22 | +enum MetricsStream { | |
| 23 | + | |
| 24 | + /// Decode one Python event into a training event (nil = ignorable). | |
| 25 | + static func decode(_ event: PythonEvent) -> TrainingEvent? { | |
| 26 | + switch event.event { | |
| 27 | + case "start": | |
| 28 | + return .started( | |
| 29 | + model: event.string("model") ?? "?", | |
| 30 | + iterations: event.int("iters") ?? 0) | |
| 31 | + | |
| 32 | + case "train": | |
| 33 | + return .metric( | |
| 34 | + TrainingMetric( | |
| 35 | + iteration: event.int("iteration") ?? 0, | |
| 36 | + trainLoss: event.double("train_loss"), | |
| 37 | + valLoss: nil, | |
| 38 | + learningRate: event.double("learning_rate"), | |
| 39 | + iterationsPerSecond: event.double("iterations_per_second"), | |
| 40 | + tokensPerSecond: event.double("tokens_per_second"), | |
| 41 | + trainedTokens: event.int("trained_tokens"), | |
| 42 | + peakMemoryGB: event.double("peak_memory"), | |
| 43 | + timestamp: .now)) | |
| 44 | + | |
| 45 | + case "val": | |
| 46 | + return .metric( | |
| 47 | + TrainingMetric( | |
| 48 | + iteration: event.int("iteration") ?? 0, | |
| 49 | + trainLoss: nil, | |
| 50 | + valLoss: event.double("val_loss"), | |
| 51 | + learningRate: nil, | |
| 52 | + iterationsPerSecond: nil, | |
| 53 | + tokensPerSecond: nil, | |
| 54 | + trainedTokens: nil, | |
| 55 | + peakMemoryGB: nil, | |
| 56 | + timestamp: .now)) | |
| 57 | + | |
| 58 | + case "done": | |
| 59 | + return .finished | |
| 60 | + | |
| 61 | + case "error": | |
| 62 | + return .failed(message: event.string("message") ?? "unknown error") | |
| 63 | + | |
| 64 | + default: | |
| 65 | + return nil | |
| 66 | + } | |
| 67 | + } | |
| 68 | +} | |
added
Sources/ZyquoMLX/Training/RunStore.swift
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +// | |
| 2 | +// RunStore.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Persists training runs, their metrics history, and checkpoint inventory | |
| 12 | +/// under `~/Library/Application Support/ZyquoMLX/Runs/<run-id>/`. | |
| 13 | +/// | |
| 14 | +/// Layout: | |
| 15 | +/// run.json — the `TrainingRun` (state, config, our iteration counter) | |
| 16 | +/// metrics.jsonl — append-only `TrainingMetric` history (chart replay) | |
| 17 | +/// adapters/ — mlx-lm adapter dir (adapters.safetensors, NNNNNNN_… checkpoints) | |
| 18 | +/// config.yaml — the exact mlx-lm config the run used | |
| 19 | +actor RunStore { | |
| 20 | + | |
| 21 | + static let shared = RunStore() | |
| 22 | + | |
| 23 | + private let root: URL | |
| 24 | + | |
| 25 | + init(root: URL = PersistenceService.runsDirectory) { | |
| 26 | + self.root = root | |
| 27 | + } | |
| 28 | + | |
| 29 | + func directory(for id: UUID) -> URL { | |
| 30 | + root.appendingPathComponent(id.uuidString, isDirectory: true) | |
| 31 | + } | |
| 32 | + | |
| 33 | + func adaptersDirectory(for run: TrainingRun) -> URL { | |
| 34 | + run.directory.appendingPathComponent("adapters", isDirectory: true) | |
| 35 | + } | |
| 36 | + | |
| 37 | + // MARK: - CRUD | |
| 38 | + | |
| 39 | + func create(_ run: TrainingRun) throws { | |
| 40 | + try FileManager.default.createDirectory( | |
| 41 | + at: run.directory, withIntermediateDirectories: true) | |
| 42 | + try save(run) | |
| 43 | + } | |
| 44 | + | |
| 45 | + func save(_ run: TrainingRun) throws { | |
| 46 | + try PersistenceService.saveJSON(run, to: run.directory.appendingPathComponent("run.json")) | |
| 47 | + } | |
| 48 | + | |
| 49 | + func load(id: UUID) throws -> TrainingRun { | |
| 50 | + try PersistenceService.loadJSON( | |
| 51 | + TrainingRun.self, from: directory(for: id).appendingPathComponent("run.json")) | |
| 52 | + } | |
| 53 | + | |
| 54 | + func scan() throws -> [TrainingRun] { | |
| 55 | + let fm = FileManager.default | |
| 56 | + guard fm.fileExists(atPath: root.path) else { return [] } | |
| 57 | + return try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) | |
| 58 | + .compactMap { | |
| 59 | + try? PersistenceService.loadJSON( | |
| 60 | + TrainingRun.self, from: $0.appendingPathComponent("run.json")) | |
| 61 | + } | |
| 62 | + .sorted { $0.createdAt > $1.createdAt } | |
| 63 | + } | |
| 64 | + | |
| 65 | + func delete(_ run: TrainingRun) throws { | |
| 66 | + try FileManager.default.removeItem(at: run.directory) | |
| 67 | + } | |
| 68 | + | |
| 69 | + // MARK: - Metrics history | |
| 70 | + | |
| 71 | + func append(metric: TrainingMetric, to run: TrainingRun) { | |
| 72 | + guard let data = try? JSONEncoder().encode(metric), | |
| 73 | + let line = String(data: data, encoding: .utf8) | |
| 74 | + else { return } | |
| 75 | + let url = run.directory.appendingPathComponent("metrics.jsonl") | |
| 76 | + if let handle = FileHandle(forWritingAtPath: url.path) { | |
| 77 | + handle.seekToEndOfFile() | |
| 78 | + handle.write(Data((line + "\n").utf8)) | |
| 79 | + try? handle.close() | |
| 80 | + } else { | |
| 81 | + try? (line + "\n").write(to: url, atomically: true, encoding: .utf8) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + func metricsHistory(for run: TrainingRun) -> [TrainingMetric] { | |
| 86 | + guard | |
| 87 | + let content = try? String( | |
| 88 | + contentsOf: run.directory.appendingPathComponent("metrics.jsonl"), | |
| 89 | + encoding: .utf8) | |
| 90 | + else { return [] } | |
| 91 | + let decoder = JSONDecoder() | |
| 92 | + return content.split(separator: "\n").compactMap { | |
| 93 | + $0.data(using: .utf8).flatMap { try? decoder.decode(TrainingMetric.self, from: $0) } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + // MARK: - Checkpoints | |
| 98 | + | |
| 99 | + /// Scan the adapter directory for numbered checkpoints | |
| 100 | + /// (`{iter:07d}_adapters.safetensors` — docs/TRAINING-RESEARCH.md §2.1). | |
| 101 | + func checkpoints(for run: TrainingRun) -> [Checkpoint] { | |
| 102 | + let dir = adaptersDirectory(for: run) | |
| 103 | + guard | |
| 104 | + let files = try? FileManager.default.contentsOfDirectory( | |
| 105 | + at: dir, includingPropertiesForKeys: [.creationDateKey]) | |
| 106 | + else { return [] } | |
| 107 | + return files.compactMap { url -> Checkpoint? in | |
| 108 | + let name = url.lastPathComponent | |
| 109 | + guard name.hasSuffix("_adapters.safetensors"), | |
| 110 | + let iteration = Int(name.prefix(7)) | |
| 111 | + else { return nil } | |
| 112 | + let savedAt = | |
| 113 | + (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .now | |
| 114 | + return Checkpoint(fileName: name, iteration: iteration, fileURL: url, savedAt: savedAt) | |
| 115 | + } | |
| 116 | + .sorted { $0.iteration < $1.iteration } | |
| 117 | + } | |
| 118 | + | |
| 119 | + /// Latest usable adapter weights for resume/fuse/inference. | |
| 120 | + func latestAdapter(for run: TrainingRun) -> URL? { | |
| 121 | + let main = adaptersDirectory(for: run).appendingPathComponent("adapters.safetensors") | |
| 122 | + if FileManager.default.fileExists(atPath: main.path) { return main } | |
| 123 | + return checkpoints(for: run).last?.fileURL | |
| 124 | + } | |
| 125 | +} | |
added
Sources/ZyquoMLX/Training/TrainingService.swift
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +// | |
| 2 | +// TrainingService.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +enum TrainingServiceError: LocalizedError { | |
| 12 | + case memoryGate(verdict: MemoryVerdict, suggestions: [String]) | |
| 13 | + case qloraNeedsQuantizedBase | |
| 14 | + case alreadyRunning(UUID) | |
| 15 | + case nothingToResume | |
| 16 | + | |
| 17 | + var errorDescription: String? { | |
| 18 | + switch self { | |
| 19 | + case .memoryGate(_, let suggestions): | |
| 20 | + "This configuration won't fit in memory on this Mac. Try: " | |
| 21 | + + suggestions.joined(separator: " · ") | |
| 22 | + case .qloraNeedsQuantizedBase: | |
| 23 | + "QLoRA requires a quantized base model. Pick a quantized base (e.g. a -4bit model) or quantize it first in Convert." | |
| 24 | + case .alreadyRunning(let id): | |
| 25 | + "Run \(id.uuidString.prefix(8)) is already active." | |
| 26 | + case .nothingToResume: | |
| 27 | + "No saved adapter weights found to resume from." | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// Orchestrates fine-tuning runs (charter 3.B): builds the mlx-lm YAML | |
| 33 | +/// config, drives `zyquo_train.py` through `PythonRunner`, persists state and | |
| 34 | +/// metrics via `RunStore`, and exposes a typed event stream. Runs are | |
| 35 | +/// cancellable; resume is an honest warm start (docs/TRAINING-RESEARCH.md §5.3). | |
| 36 | +actor TrainingService { | |
| 37 | + | |
| 38 | + static let shared = TrainingService() | |
| 39 | + | |
| 40 | + private var activeRunID: UUID? | |
| 41 | + private var activeStreamTask: Task<Void, Never>? | |
| 42 | + | |
| 43 | + // MARK: - Configure | |
| 44 | + | |
| 45 | + /// Create (and persist) a new run after memory-gating it. | |
| 46 | + func createRun( | |
| 47 | + name: String, | |
| 48 | + baseModel: LocalModel, | |
| 49 | + dataset: Dataset, | |
| 50 | + method: FineTuneMethod, | |
| 51 | + hyperParams: HyperParams | |
| 52 | + ) async throws -> TrainingRun { | |
| 53 | + // QLoRA = LoRA on a quantized base; LoRA on a quantized base IS QLoRA — | |
| 54 | + // keep the user's mental model consistent with reality. | |
| 55 | + if method == .qlora && baseModel.quantization == nil { | |
| 56 | + throw TrainingServiceError.qloraNeedsQuantizedBase | |
| 57 | + } | |
| 58 | + | |
| 59 | + let verdict = MemoryAdvisor.trainingVerdict( | |
| 60 | + for: baseModel, method: method, params: hyperParams) | |
| 61 | + if verdict == .wontFit { | |
| 62 | + throw TrainingServiceError.memoryGate( | |
| 63 | + verdict: verdict, | |
| 64 | + suggestions: MemoryAdvisor.suggestions( | |
| 65 | + for: baseModel, method: method, params: hyperParams)) | |
| 66 | + } | |
| 67 | + | |
| 68 | + let id = UUID() | |
| 69 | + let run = TrainingRun( | |
| 70 | + id: id, | |
| 71 | + name: name, | |
| 72 | + baseModelID: baseModel.id, | |
| 73 | + datasetID: dataset.id, | |
| 74 | + method: method, | |
| 75 | + hyperParams: hyperParams, | |
| 76 | + state: .configured, | |
| 77 | + directory: await RunStore.shared.directory(for: id), | |
| 78 | + createdAt: .now, | |
| 79 | + startedAt: nil, | |
| 80 | + finishedAt: nil, | |
| 81 | + completedIterations: 0, | |
| 82 | + checkpoints: [], | |
| 83 | + lastError: nil | |
| 84 | + ) | |
| 85 | + try await RunStore.shared.create(run) | |
| 86 | + try writeConfig(run: run, baseModel: baseModel, dataset: dataset, resumeFrom: nil) | |
| 87 | + return run | |
| 88 | + } | |
| 89 | + | |
| 90 | + // MARK: - Start / resume | |
| 91 | + | |
| 92 | + /// Start (or warm-start-resume) a run, streaming typed events. | |
| 93 | + func start(run: TrainingRun, baseModel: LocalModel, dataset: Dataset, resume: Bool = false) | |
| 94 | + async throws -> AsyncStream<TrainingEvent> | |
| 95 | + { | |
| 96 | + if let activeRunID { throw TrainingServiceError.alreadyRunning(activeRunID) } | |
| 97 | + | |
| 98 | + var run = run | |
| 99 | + var resumeAdapter: URL? | |
| 100 | + if resume { | |
| 101 | + guard let adapter = await RunStore.shared.latestAdapter(for: run) else { | |
| 102 | + throw TrainingServiceError.nothingToResume | |
| 103 | + } | |
| 104 | + resumeAdapter = adapter | |
| 105 | + } | |
| 106 | + try writeConfig(run: run, baseModel: baseModel, dataset: dataset, resumeFrom: resumeAdapter) | |
| 107 | + | |
| 108 | + run.state = .running | |
| 109 | + run.startedAt = run.startedAt ?? .now | |
| 110 | + run.lastError = nil | |
| 111 | + try await RunStore.shared.save(run) | |
| 112 | + activeRunID = run.id | |
| 113 | + | |
| 114 | + let configPath = run.directory.appendingPathComponent("config.yaml").path | |
| 115 | + let pythonStream = try await PythonRunner.shared.stream( | |
| 116 | + script: "zyquo_train", arguments: ["--config", configPath]) | |
| 117 | + | |
| 118 | + let (stream, continuation) = AsyncStream.makeStream(of: TrainingEvent.self) | |
| 119 | + let runSnapshot = run | |
| 120 | + | |
| 121 | + activeStreamTask = Task { | |
| 122 | + var current = runSnapshot | |
| 123 | + do { | |
| 124 | + for try await pythonEvent in pythonStream { | |
| 125 | + guard let event = MetricsStream.decode(pythonEvent) else { continue } | |
| 126 | + switch event { | |
| 127 | + case .metric(let metric): | |
| 128 | + await RunStore.shared.append(metric: metric, to: current) | |
| 129 | + current.completedIterations = max( | |
| 130 | + current.completedIterations, metric.iteration) | |
| 131 | + case .failed(let message): | |
| 132 | + current.lastError = message | |
| 133 | + default: | |
| 134 | + break | |
| 135 | + } | |
| 136 | + continuation.yield(event) | |
| 137 | + } | |
| 138 | + current.state = current.lastError == nil ? .completed : .failed | |
| 139 | + } catch is CancellationError { | |
| 140 | + current.state = .cancelled | |
| 141 | + } catch { | |
| 142 | + current.state = .failed | |
| 143 | + current.lastError = error.localizedDescription | |
| 144 | + continuation.yield(.failed(message: error.localizedDescription)) | |
| 145 | + } | |
| 146 | + current.finishedAt = .now | |
| 147 | + current.checkpoints = await RunStore.shared.checkpoints(for: current) | |
| 148 | + try? await RunStore.shared.save(current) | |
| 149 | + self.clearActive() | |
| 150 | + continuation.finish() | |
| 151 | + } | |
| 152 | + | |
| 153 | + return stream | |
| 154 | + } | |
| 155 | + | |
| 156 | + /// Cancel the active run (terminates the Python process via the stream's | |
| 157 | + /// onTermination; state persists as cancelled with checkpoints intact for | |
| 158 | + /// warm-start resume). | |
| 159 | + func cancel() { | |
| 160 | + activeStreamTask?.cancel() | |
| 161 | + } | |
| 162 | + | |
| 163 | + | |
| 164 | + private func clearActive() { | |
| 165 | + activeRunID = nil | |
| 166 | + activeStreamTask = nil | |
| 167 | + } | |
| 168 | + | |
| 169 | + // MARK: - Config generation | |
| 170 | + | |
| 171 | + /// Serialize the run into mlx-lm's YAML schema | |
| 172 | + /// (exact keys/defaults per docs/TRAINING-RESEARCH.md §1.2; the | |
| 173 | + /// config-only keys `lora_parameters` make YAML mandatory). | |
| 174 | + private func writeConfig( | |
| 175 | + run: TrainingRun, baseModel: LocalModel, dataset: Dataset, resumeFrom: URL? | |
| 176 | + ) throws { | |
| 177 | + let hp = run.hyperParams | |
| 178 | + let adapterPath = run.directory.appendingPathComponent("adapters").path | |
| 179 | + | |
| 180 | + // .qlora maps to upstream "lora" — QLoRA is implied by the quantized base. | |
| 181 | + let fineTuneType = run.method == .qlora ? "lora" : run.method.rawValue | |
| 182 | + | |
| 183 | + var lines: [String] = [ | |
| 184 | + "# Generated by Zyquo MLX — run \(run.id.uuidString)", | |
| 185 | + "model: \(yamlQuote(baseModel.directory.path))", | |
| 186 | + "train: true", | |
| 187 | + "data: \(yamlQuote(dataset.directory.path))", | |
| 188 | + "fine_tune_type: \(fineTuneType)", | |
| 189 | + "optimizer: \(hp.optimizer)", | |
| 190 | + "mask_prompt: \(hp.maskPrompt)", | |
| 191 | + "num_layers: \(hp.numLayers)", | |
| 192 | + "batch_size: \(hp.batchSize)", | |
| 193 | + "iters: \(hp.iterations)", | |
| 194 | + "val_batches: \(hp.valBatches)", | |
| 195 | + "learning_rate: \(hp.learningRate)", | |
| 196 | + "steps_per_report: \(hp.stepsPerReport)", | |
| 197 | + "steps_per_eval: \(hp.stepsPerEval)", | |
| 198 | + "grad_accumulation_steps: \(hp.gradAccumulationSteps)", | |
| 199 | + "save_every: \(hp.saveEvery)", | |
| 200 | + "adapter_path: \(yamlQuote(adapterPath))", | |
| 201 | + "max_seq_length: \(hp.maxSeqLength)", | |
| 202 | + "grad_checkpoint: \(hp.gradCheckpoint)", | |
| 203 | + "seed: \(hp.seed)", | |
| 204 | + ] | |
| 205 | + | |
| 206 | + if let resumeFrom { | |
| 207 | + lines.append("resume_adapter_file: \(yamlQuote(resumeFrom.path))") | |
| 208 | + } | |
| 209 | + | |
| 210 | + if run.method != .full { | |
| 211 | + lines.append("lora_parameters:") | |
| 212 | + lines.append(" rank: \(hp.rank)") | |
| 213 | + lines.append(" scale: \(hp.scale)") | |
| 214 | + lines.append(" dropout: \(hp.dropout)") | |
| 215 | + if let keys = hp.keys, !keys.isEmpty { | |
| 216 | + lines.append(" keys: [\(keys.map(yamlQuote).joined(separator: ", "))]") | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + try (lines.joined(separator: "\n") + "\n") | |
| 221 | + .write( | |
| 222 | + to: run.directory.appendingPathComponent("config.yaml"), | |
| 223 | + atomically: true, encoding: .utf8) | |
| 224 | + } | |
| 225 | + | |
| 226 | + private func yamlQuote(_ value: String) -> String { | |
| 227 | + "\"" + value.replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 228 | + } | |
| 229 | +} | |
modified
docs/PLAN.md
+11 −1
@@ -101,7 +101,17 @@ sources (no guessed names — `#huggingFaceTokenizerLoader` macro requires | ||
| 101 | 101 | through `perform(nonSendable:)`). CLI POC proves the two-model-type gate with |
| 102 | 102 | real downloads in the app's Models library. Build is arm64-only via |
| 103 | 103 | `--arch arm64` (swiftbuild otherwise builds universal and x86_64 fails). |
| 104 | −## Phase 3 — Training, Quantization & Conversion (not started) | |
| 104 | +## Phase 3 — Training, Quantization & Conversion (in progress) | |
| 105 | + | |
| 106 | +- [ ] `PyBridge/PythonRunner` — Process wrapper over the venv, JSON-lines progress protocol, cancellation | |
| 107 | +- [ ] `PyBridge/PythonEnvironment` — uv-provisioned pinned venv in App Support (status/repair; mlx-lm now, vlm/whisper/audio on demand) | |
| 108 | +- [ ] `PyBridge/scripts/` — zyquo_train.py (TrainingCallback → JSON), zyquo_fuse.py, zyquo_convert.py | |
| 109 | +- [ ] `Data/DatasetService` + `DatasetFormats` — import/validate JSONL (chat/completions/text), auto split, malformed-row report, token stats, preview | |
| 110 | +- [ ] `Training/TrainingService` + `RunStore` + `MetricsStream` — cancellable/resumable runs, persisted state, checkpoints, live metrics | |
| 111 | +- [ ] `Convert/ConversionService` + `QuantConfig` — Swift-native affine quant for safetensors LLMs; Python bridge for fuse/dequantize | |
| 112 | +- [ ] CLI: `--train`, `--fuse`, `--validate-dataset` to drive the services (UI in Phase 6) | |
| 113 | +- [ ] PHASE GATE: real LoRA/QLoRA fine-tune on a tiny dataset → live loss ↓, adapter saved, fused, fused model generates differently | |
| 114 | +- [ ] Phase checkpoint: build green, gate verified, summary | |
| 105 | 115 | ## Phase 4 — Design System & UI (not started) |
| 106 | 116 | ## Phase 5 — App Icon (not started) |
| 107 | 117 | ## Phase 6 — Features (not started) |
| 108 | 118 | |