phase2: architecture + inference POC — domain models, InferenceEngine actor, ModelStore, MemoryAdvisor, CLI POC (LLM 603 tok/s + embeddings verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 12 changed files with +870 and −5
modified
Makefile
+1 −1
@@ -36,7 +36,7 @@ check-toolchain: | ||
| 36 | 36 | @test -d $(XCODE_DEV_DIR) || { echo "error: Xcode toolchain not found at /Applications/Xcode.app — see docs/BUILD.md §2"; exit 1; } |
| 37 | 37 | |
| 38 | 38 | build: check-toolchain |
| 39 | − $(DEV) swift build --build-system swiftbuild -c $(CONFIG) | |
| 39 | + $(DEV) swift build --build-system swiftbuild --arch arm64 -c $(CONFIG) | |
| 40 | 40 | |
| 41 | 41 | build-xcodebuild: check-toolchain |
| 42 | 42 | $(DEV) xcodebuild build \ |
added
Sources/ZyquoMLX/App/CLI.swift
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +// | |
| 2 | +// CLI.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLXLMCommon | |
| 11 | + | |
| 12 | +/// Command-line proof-of-concept mode (Phase 2 gate): run inference on local | |
| 13 | +/// model directories without the UI. | |
| 14 | +/// | |
| 15 | +/// ZyquoMLX --infer <model-dir> [--prompt "…"] [--max-tokens N] [--image <path>] | |
| 16 | +/// ZyquoMLX --embed <model-dir> --text "…" [--text "…"]… | |
| 17 | +enum CLI { | |
| 18 | + | |
| 19 | + static var shouldRun: Bool { | |
| 20 | + let args = CommandLine.arguments | |
| 21 | + return args.contains("--infer") || args.contains("--embed") | |
| 22 | + } | |
| 23 | + | |
| 24 | + static func run() async -> Int32 { | |
| 25 | + do { | |
| 26 | + let args = CommandLine.arguments | |
| 27 | + if let dir = value(after: "--infer", in: args) { | |
| 28 | + try await infer( | |
| 29 | + directory: dir, | |
| 30 | + prompt: value(after: "--prompt", in: args) | |
| 31 | + ?? "Explain in one short sentence what MLX is.", | |
| 32 | + maxTokens: value(after: "--max-tokens", in: args).flatMap(Int.init) ?? 256, | |
| 33 | + imagePath: value(after: "--image", in: args) | |
| 34 | + ) | |
| 35 | + return 0 | |
| 36 | + } | |
| 37 | + if let dir = value(after: "--embed", in: args) { | |
| 38 | + let texts = values(after: "--text", in: args) | |
| 39 | + try await embed( | |
| 40 | + directory: dir, | |
| 41 | + texts: texts.isEmpty | |
| 42 | + ? ["The quick brown fox", "A fast auburn fox", "Quarterly revenue grew 4%"] | |
| 43 | + : texts) | |
| 44 | + return 0 | |
| 45 | + } | |
| 46 | + FileHandle.standardError.write(Data("usage: ZyquoMLX --infer <dir> | --embed <dir>\n".utf8)) | |
| 47 | + return 2 | |
| 48 | + } catch { | |
| 49 | + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) | |
| 50 | + return 1 | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + // MARK: - Subcommands | |
| 55 | + | |
| 56 | + private static func infer(directory: String, prompt: String, maxTokens: Int, imagePath: String?) async throws { | |
| 57 | + let url = URL(fileURLWithPath: (directory as NSString).expandingTildeInPath) | |
| 58 | + let model = try await ModelStore.shared.describe(directory: url) | |
| 59 | + print("model: \(model.name) [\(model.type.displayName)\(model.quantization.map { ", \($0.label)" } ?? "")]") | |
| 60 | + print("verdict: \(MemoryAdvisor.inferenceVerdict(for: model).displayName)") | |
| 61 | + | |
| 62 | + let engine = InferenceEngine.shared | |
| 63 | + let loadStart = Date() | |
| 64 | + try await engine.load(model: model) | |
| 65 | + print("loaded in \(String(format: "%.2f", Date().timeIntervalSince(loadStart)))s\n") | |
| 66 | + | |
| 67 | + var message = Chat.Message.user(prompt) | |
| 68 | + if let imagePath { | |
| 69 | + let imageURL = URL(fileURLWithPath: (imagePath as NSString).expandingTildeInPath) | |
| 70 | + message = Chat.Message.user(prompt, images: [.url(imageURL)]) | |
| 71 | + } | |
| 72 | + | |
| 73 | + var params = GenerationParams() | |
| 74 | + params.maxTokens = maxTokens | |
| 75 | + | |
| 76 | + let stream = try await engine.generate(messages: [message], params: params) | |
| 77 | + var stats: InferenceStats? | |
| 78 | + for try await event in stream { | |
| 79 | + switch event { | |
| 80 | + case .chunk(let text): | |
| 81 | + print(text, terminator: "") | |
| 82 | + fflush(stdout) | |
| 83 | + case .finished(let s): | |
| 84 | + stats = s | |
| 85 | + } | |
| 86 | + } | |
| 87 | + print("\n") | |
| 88 | + if let stats { | |
| 89 | + print("── stats ──────────────────────────────") | |
| 90 | + print("prompt tokens: \(stats.promptTokens)") | |
| 91 | + print("generated tokens: \(stats.generatedTokens)") | |
| 92 | + print(String(format: "ttft: %.2fs", stats.ttft)) | |
| 93 | + print(String(format: "speed: %.1f tok/s", stats.tokensPerSecond)) | |
| 94 | + print("stop reason: \(stats.stopReason)") | |
| 95 | + } | |
| 96 | + let freed = try await engine.unload() | |
| 97 | + print("unloaded (freed \(ByteCountFormatter.string(fromByteCount: freed, countStyle: .memory)))") | |
| 98 | + } | |
| 99 | + | |
| 100 | + private static func embed(directory: String, texts: [String]) async throws { | |
| 101 | + let url = URL(fileURLWithPath: (directory as NSString).expandingTildeInPath) | |
| 102 | + let model = try await ModelStore.shared.describe(directory: url) | |
| 103 | + print("model: \(model.name) [\(model.type.displayName)]") | |
| 104 | + | |
| 105 | + let engine = InferenceEngine.shared | |
| 106 | + try await engine.load(model: model) | |
| 107 | + | |
| 108 | + let start = Date() | |
| 109 | + let vectors = try await engine.embed(texts: texts) | |
| 110 | + let elapsed = Date().timeIntervalSince(start) | |
| 111 | + | |
| 112 | + for (text, vector) in zip(texts, vectors) { | |
| 113 | + let preview = vector.prefix(4).map { String(format: "%+.4f", $0) }.joined(separator: ", ") | |
| 114 | + print("dim=\(vector.count) [\(preview), …] \"\(text)\"") | |
| 115 | + } | |
| 116 | + if vectors.count >= 2 { | |
| 117 | + print("\n── cosine similarity ──────────────────") | |
| 118 | + for i in 0..<vectors.count { | |
| 119 | + for j in (i + 1)..<vectors.count { | |
| 120 | + let sim = InferenceEngine.cosineSimilarity(vectors[i], vectors[j]) | |
| 121 | + print(String(format: "%.4f \"%@\" ↔ \"%@\"", sim, texts[i], texts[j])) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + } | |
| 125 | + print(String(format: "\nembedded %d texts in %.2fs", texts.count, elapsed)) | |
| 126 | + try await engine.unload() | |
| 127 | + } | |
| 128 | + | |
| 129 | + // MARK: - Arg parsing | |
| 130 | + | |
| 131 | + private static func value(after flag: String, in args: [String]) -> String? { | |
| 132 | + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil } | |
| 133 | + return args[index + 1] | |
| 134 | + } | |
| 135 | + | |
| 136 | + private static func values(after flag: String, in args: [String]) -> [String] { | |
| 137 | + var out: [String] = [] | |
| 138 | + var i = 0 | |
| 139 | + while i < args.count { | |
| 140 | + if args[i] == flag, i + 1 < args.count { | |
| 141 | + out.append(args[i + 1]) | |
| 142 | + i += 2 | |
| 143 | + } else { | |
| 144 | + i += 1 | |
| 145 | + } | |
| 146 | + } | |
| 147 | + return out | |
| 148 | + } | |
| 149 | +} | |
added
Sources/ZyquoMLX/App/Main.swift
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +// | |
| 2 | +// Main.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Process entry point: CLI proof-of-concept mode when inference flags are | |
| 12 | +/// present (Phase 2 gate), otherwise the SwiftUI app. | |
| 13 | +@main | |
| 14 | +enum Main { | |
| 15 | + static func main() async { | |
| 16 | + if CLI.shouldRun { | |
| 17 | + exit(await CLI.run()) | |
| 18 | + } | |
| 19 | + ZyquoMLXApp.main() | |
| 20 | + } | |
| 21 | +} | |
modified
Sources/ZyquoMLX/App/ZyquoMLXApp.swift
+3 −3
@@ -10,9 +10,9 @@ import SwiftUI | ||
| 10 | 10 | |
| 11 | 11 | /// Zyquo MLX — the local MLX foundry for the Mac. |
| 12 | 12 | /// |
| 13 | −/// Entry point. Gates on Apple Silicon (MLX requires it), activates the app | |
| 14 | −/// properly when launched from a terminal, and hosts the main workbench window. | |
| 15 | −@main | |
| 13 | +/// The SwiftUI application (invoked from `Main` unless CLI mode runs). Gates | |
| 14 | +/// on Apple Silicon (MLX requires it), activates properly when launched from | |
| 15 | +/// a terminal, and hosts the main workbench window. | |
| 16 | 16 | struct ZyquoMLXApp: App { |
| 17 | 17 | |
| 18 | 18 | init() { |
added
Sources/ZyquoMLX/Engine/GenerationParams.swift
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +// | |
| 2 | +// GenerationParams.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLXLMCommon | |
| 11 | + | |
| 12 | +/// App-level generation parameters (Codable for persistence, presets, and | |
| 13 | +/// per-run notes). Mapped 1:1 onto `MLXLMCommon.GenerateParameters` | |
| 14 | +/// (verified fields — docs/MLX-RESEARCH.md §4.3). | |
| 15 | +struct GenerationParams: Codable, Hashable, Sendable { | |
| 16 | + var temperature: Float = 0.6 | |
| 17 | + var topP: Float = 1.0 | |
| 18 | + var topK: Int = 0 | |
| 19 | + var minP: Float = 0.0 | |
| 20 | + var maxTokens: Int? = 2048 | |
| 21 | + var repetitionPenalty: Float? | |
| 22 | + var repetitionContextSize: Int = 20 | |
| 23 | + var seed: UInt64? | |
| 24 | + /// KV-cache quantization bits (nil = off). | |
| 25 | + var kvBits: Int? | |
| 26 | + var kvGroupSize: Int = 64 | |
| 27 | + /// Cap on KV cache length (RotatingKVCache when set). | |
| 28 | + var maxKVSize: Int? | |
| 29 | + | |
| 30 | + var asMLX: GenerateParameters { | |
| 31 | + GenerateParameters( | |
| 32 | + maxTokens: maxTokens, | |
| 33 | + maxKVSize: maxKVSize, | |
| 34 | + kvBits: kvBits, | |
| 35 | + kvGroupSize: kvGroupSize, | |
| 36 | + temperature: temperature, | |
| 37 | + topP: topP, | |
| 38 | + topK: topK, | |
| 39 | + minP: minP, | |
| 40 | + repetitionPenalty: repetitionPenalty, | |
| 41 | + repetitionContextSize: repetitionContextSize, | |
| 42 | + seed: seed | |
| 43 | + ) | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// Final statistics for one generation (from `GenerateCompletionInfo`). | |
| 48 | +struct InferenceStats: Codable, Hashable, Sendable { | |
| 49 | + var promptTokens: Int | |
| 50 | + var generatedTokens: Int | |
| 51 | + /// Time to first token ≈ prompt processing time. | |
| 52 | + var ttft: TimeInterval | |
| 53 | + var generateTime: TimeInterval | |
| 54 | + var stopReason: String | |
| 55 | + | |
| 56 | + var tokensPerSecond: Double { | |
| 57 | + generateTime > 0 ? Double(generatedTokens) / generateTime : 0 | |
| 58 | + } | |
| 59 | +} | |
added
Sources/ZyquoMLX/Engine/InferenceEngine.swift
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// | |
| 2 | +// InferenceEngine.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 MLXEmbedders | |
| 12 | +import MLXLMCommon | |
| 13 | + | |
| 14 | +/// The in-process inference engine. An actor: one loaded model at a time, | |
| 15 | +/// streaming generation for LLM/VLM, vector embedding, and verifiable | |
| 16 | +/// memory release on unload (charter Phase 2). | |
| 17 | +actor InferenceEngine { | |
| 18 | + | |
| 19 | + static let shared = InferenceEngine() | |
| 20 | + | |
| 21 | + private var adapter: LoadedModelAdapter? | |
| 22 | + private(set) var loadedModel: LocalModel? | |
| 23 | + | |
| 24 | + // MARK: - Load / unload | |
| 25 | + | |
| 26 | + /// Load a local model (replacing any previously loaded one). | |
| 27 | + func load(model: LocalModel) async throws { | |
| 28 | + try await unload() | |
| 29 | + adapter = try await ModelTypeAdapters.load(model: model) | |
| 30 | + loadedModel = model | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// Unload the current model and free GPU buffer cache. Returns the | |
| 34 | + /// active-memory delta so callers can verify the release. | |
| 35 | + @discardableResult | |
| 36 | + func unload() async throws -> Int64 { | |
| 37 | + let before = Memory.snapshot().activeMemory | |
| 38 | + adapter = nil | |
| 39 | + loadedModel = nil | |
| 40 | + Memory.clearCache() | |
| 41 | + let after = Memory.snapshot().activeMemory | |
| 42 | + return Int64(before - after) | |
| 43 | + } | |
| 44 | + | |
| 45 | + func memorySnapshot() -> (active: Int, cache: Int, peak: Int) { | |
| 46 | + let snap = Memory.snapshot() | |
| 47 | + return (snap.activeMemory, snap.cacheMemory, snap.peakMemory) | |
| 48 | + } | |
| 49 | + | |
| 50 | + // MARK: - Text / chat generation (LLM & VLM) | |
| 51 | + | |
| 52 | + /// Stream a response for a chat conversation. Images/videos attach to the | |
| 53 | + /// user message for VLMs. Cancellation: cancel the surrounding Task. | |
| 54 | + func generate( | |
| 55 | + messages: [Chat.Message], | |
| 56 | + params: GenerationParams | |
| 57 | + ) async throws -> AsyncThrowingStream<InferenceEvent, Error> { | |
| 58 | + guard let adapter else { throw InferenceEngineError.noModelLoaded } | |
| 59 | + guard case .language(let container) = adapter else { | |
| 60 | + throw InferenceEngineError.wrongModelKind(expected: "a language model") | |
| 61 | + } | |
| 62 | + | |
| 63 | + let userInput = UserInput(chat: messages) | |
| 64 | + let mlxParams = params.asMLX | |
| 65 | + | |
| 66 | + return AsyncThrowingStream { continuation in | |
| 67 | + let task = Task { | |
| 68 | + do { | |
| 69 | + try await container.perform(nonSendable: userInput) { (context: ModelContext, userInput: UserInput) in | |
| 70 | + let input = try await context.processor.prepare(input: userInput) | |
| 71 | + let stream = try MLXLMCommon.generate( | |
| 72 | + input: input, parameters: mlxParams, context: context) | |
| 73 | + for await generation in stream { | |
| 74 | + if Task.isCancelled { break } | |
| 75 | + switch generation { | |
| 76 | + case .chunk(let text): | |
| 77 | + continuation.yield(.chunk(text)) | |
| 78 | + case .info(let info): | |
| 79 | + continuation.yield( | |
| 80 | + .finished( | |
| 81 | + InferenceStats( | |
| 82 | + promptTokens: info.promptTokenCount, | |
| 83 | + generatedTokens: info.generationTokenCount, | |
| 84 | + ttft: info.promptTime, | |
| 85 | + generateTime: info.generateTime, | |
| 86 | + stopReason: String(describing: info.stopReason) | |
| 87 | + ))) | |
| 88 | + case .toolCall: | |
| 89 | + break // tool use surfaces in Phase 6 | |
| 90 | + } | |
| 91 | + } | |
| 92 | + continuation.finish() | |
| 93 | + } | |
| 94 | + } catch { | |
| 95 | + continuation.finish(throwing: error) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + continuation.onTermination = { _ in task.cancel() } | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + // MARK: - Embeddings | |
| 103 | + | |
| 104 | + /// Embed a batch of texts into L2-normalized vectors using the model's | |
| 105 | + /// configured pooling strategy (docs/MLX-RESEARCH.md §4.7). | |
| 106 | + func embed(texts: [String]) async throws -> [[Float]] { | |
| 107 | + guard let adapter else { throw InferenceEngineError.noModelLoaded } | |
| 108 | + guard case .embedder(let container) = adapter else { | |
| 109 | + throw InferenceEngineError.wrongModelKind(expected: "an embedding model") | |
| 110 | + } | |
| 111 | + | |
| 112 | + return await container.perform { (context: EmbedderModelContext) in | |
| 113 | + var vectors: [[Float]] = [] | |
| 114 | + for text in texts { | |
| 115 | + let tokens = context.tokenizer.encode(text: text, addSpecialTokens: true) | |
| 116 | + let ids = MLXArray(tokens)[.newAxis, 0...] | |
| 117 | + let output = context.model( | |
| 118 | + ids, positionIds: nil, tokenTypeIds: nil, attentionMask: nil) | |
| 119 | + let pooled = context.pooling(output, normalize: true) | |
| 120 | + eval(pooled) | |
| 121 | + vectors.append(pooled.squeezed().asArray(Float.self)) | |
| 122 | + } | |
| 123 | + return vectors | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Cosine similarity between two embedding vectors (both L2-normalized → | |
| 128 | + /// plain dot product). | |
| 129 | + static func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { | |
| 130 | + guard a.count == b.count, !a.isEmpty else { return 0 } | |
| 131 | + return zip(a, b).reduce(0) { $0 + $1.0 * $1.1 } | |
| 132 | + } | |
| 133 | +} | |
added
Sources/ZyquoMLX/Engine/MemoryAdvisor.swift
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +// | |
| 2 | +// MemoryAdvisor.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLX | |
| 11 | + | |
| 12 | +/// RAM feasibility verdicts for inference and training on *this* Mac. | |
| 13 | +/// | |
| 14 | +/// Formulas per docs/MODELS.md §3 and docs/TRAINING-RESEARCH.md §6: | |
| 15 | +/// - weights: bits-per-param + group-scale overhead (4-bit gs64 affine | |
| 16 | +/// = 4.5 bits ≈ 0.5625 B/param, confirmed against real repo sizes) | |
| 17 | +/// - inference ≈ weights + KV cache + ~20% overhead | |
| 18 | +/// - LoRA (bf16 base) ≈ 2 B/param + activations; QLoRA ≈ quantized weights | |
| 19 | +/// + activations; full FT ≈ 8 B/param (Adam) + activations | |
| 20 | +/// - the GPU working-set ceiling comes from the device, not a constant: | |
| 21 | +/// `GPU.deviceInfo().maxRecommendedWorkingSetSize` | |
| 22 | +enum MemoryVerdict: String, Codable, Sendable { | |
| 23 | + case comfortable | |
| 24 | + case tight | |
| 25 | + case wontFit | |
| 26 | + | |
| 27 | + var displayName: String { | |
| 28 | + switch self { | |
| 29 | + case .comfortable: "Fits" | |
| 30 | + case .tight: "Tight" | |
| 31 | + case .wontFit: "Won't fit" | |
| 32 | + } | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +enum MemoryAdvisor { | |
| 37 | + | |
| 38 | + /// Physical unified memory of this Mac. | |
| 39 | + static var physicalMemory: Int64 { Int64(ProcessInfo.processInfo.physicalMemory) } | |
| 40 | + | |
| 41 | + /// The GPU-visible working-set ceiling reported by Metal via MLX. | |
| 42 | + static var recommendedWorkingSet: Int64 { | |
| 43 | + let info = GPU.deviceInfo() | |
| 44 | + let value = info.maxRecommendedWorkingSetSize | |
| 45 | + return value > 0 ? Int64(value) : physicalMemory * 3 / 4 | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Effective bytes per parameter for a given quantization | |
| 49 | + /// (group-scale overhead included: bits + 32/groupSize extra bits). | |
| 50 | + static func bytesPerParameter(quantization: QuantizationInfo?) -> Double { | |
| 51 | + guard let q = quantization else { return 2.0 } // bf16 | |
| 52 | + let effectiveBits = Double(q.bits) + 32.0 / Double(q.groupSize) | |
| 53 | + return effectiveBits / 8.0 | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Estimated KV-cache bytes for a typical mid-size GQA model at the given | |
| 57 | + /// context. Uses conservative defaults when architecture details are | |
| 58 | + /// unknown (calibrated in Phase 7). | |
| 59 | + static func estimatedKVCache(parameterCount: Int64?, contextLength: Int) -> Int64 { | |
| 60 | + // Scale a measured anchor: ~1.2 GB at 8k ctx for an 8B GQA model | |
| 61 | + // (docs/MLX-RESEARCH.md §7), linear in context and sublinear in params. | |
| 62 | + let params = Double(parameterCount ?? 8_000_000_000) | |
| 63 | + let anchor = 1.2 * 1_073_741_824.0 | |
| 64 | + let scale = (params / 8_000_000_000.0).squareRoot() | |
| 65 | + return Int64(anchor * scale * Double(contextLength) / 8192.0) | |
| 66 | + } | |
| 67 | + | |
| 68 | + // MARK: - Inference | |
| 69 | + | |
| 70 | + static func inferenceBytes(for model: LocalModel, contextLength: Int = 8192) -> Int64 { | |
| 71 | + let weights = Double(model.weightsSize) | |
| 72 | + let kv = Double(estimatedKVCache(parameterCount: model.parameterCount, contextLength: contextLength)) | |
| 73 | + return Int64(weights * 1.2 + kv) | |
| 74 | + } | |
| 75 | + | |
| 76 | + static func inferenceVerdict(for model: LocalModel, contextLength: Int = 8192) -> MemoryVerdict { | |
| 77 | + verdict(needed: inferenceBytes(for: model, contextLength: contextLength)) | |
| 78 | + } | |
| 79 | + | |
| 80 | + // MARK: - Training | |
| 81 | + | |
| 82 | + static func trainingBytes(for model: LocalModel, method: FineTuneMethod, params: HyperParams) -> Int64 { | |
| 83 | + let count = Double(model.parameterCount ?? 0) | |
| 84 | + guard count > 0 else { return .max } | |
| 85 | + | |
| 86 | + // Activation estimate: batch × seq drives it; grad-checkpoint ~halves it. | |
| 87 | + var activations = Double(params.batchSize) * Double(params.maxSeqLength) / (4.0 * 2048.0) | |
| 88 | + * 2.0 * 1_073_741_824.0 | |
| 89 | + if params.gradCheckpoint { activations *= 0.55 } | |
| 90 | + | |
| 91 | + let base: Double | |
| 92 | + switch method { | |
| 93 | + case .lora, .dora: | |
| 94 | + base = count * 2.0 // bf16 base held in memory | |
| 95 | + case .qlora: | |
| 96 | + base = Double(model.weightsSize) // quantized base stays quantized | |
| 97 | + case .full: | |
| 98 | + base = count * 8.0 // weights + grads + 2 Adam moments (bf16) | |
| 99 | + } | |
| 100 | + // Adapter weights + their optimizer state are negligible (MB-scale). | |
| 101 | + return Int64(base + activations) | |
| 102 | + } | |
| 103 | + | |
| 104 | + static func trainingVerdict(for model: LocalModel, method: FineTuneMethod, params: HyperParams) -> MemoryVerdict { | |
| 105 | + verdict(needed: trainingBytes(for: model, method: method, params: params)) | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Suggested remedies when a config won't fit (charter 3.B). | |
| 109 | + static func suggestions(for model: LocalModel, method: FineTuneMethod, params: HyperParams) -> [String] { | |
| 110 | + var out: [String] = [] | |
| 111 | + if method == .lora || method == .dora { | |
| 112 | + out.append("Use QLoRA (train on a 4-bit base) — largest single saving") | |
| 113 | + } | |
| 114 | + if params.batchSize > 1 { out.append("Reduce batch size (\(params.batchSize) → \(max(1, params.batchSize / 2))) and use gradient accumulation") } | |
| 115 | + if params.numLayers == -1 || params.numLayers > 8 { out.append("Adapt fewer layers (--num-layers 8 or 4)") } | |
| 116 | + if !params.gradCheckpoint { out.append("Enable gradient checkpointing") } | |
| 117 | + if params.maxSeqLength > 1024 { out.append("Lower max sequence length or pre-split long samples") } | |
| 118 | + out.append("Choose a smaller base model") | |
| 119 | + return out | |
| 120 | + } | |
| 121 | + | |
| 122 | + // MARK: - Verdict core | |
| 123 | + | |
| 124 | + private static func verdict(needed: Int64) -> MemoryVerdict { | |
| 125 | + let ceiling = recommendedWorkingSet | |
| 126 | + if needed <= Int64(Double(ceiling) * 0.75) { return .comfortable } | |
| 127 | + if needed <= ceiling { return .tight } | |
| 128 | + return .wontFit | |
| 129 | + } | |
| 130 | +} | |
added
Sources/ZyquoMLX/Engine/ModelTypeAdapters.swift
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +// | |
| 2 | +// ModelTypeAdapters.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 MLXEmbedders | |
| 12 | +import MLXHuggingFace | |
| 13 | +import MLXLLM | |
| 14 | +import MLXLMCommon | |
| 15 | +import MLXVLM | |
| 16 | +import Tokenizers // required by the #huggingFaceTokenizerLoader macro expansion | |
| 17 | + | |
| 18 | +/// A loaded model held by the engine, dispatched by `ModelType`. | |
| 19 | +/// | |
| 20 | +/// LLM and VLM share `ModelContainer` (both come from `ModelFactory` | |
| 21 | +/// implementations); embeddings use `EmbedderModelContainer`. Speech and | |
| 22 | +/// image generation run through the Python bridge (Phase 3+) and never hold | |
| 23 | +/// in-process state here. | |
| 24 | +enum LoadedModelAdapter { | |
| 25 | + case language(ModelContainer) // .llm and .vlm | |
| 26 | + case embedder(EmbedderModelContainer) | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// Events streamed to callers during generation. | |
| 30 | +enum InferenceEvent: Sendable { | |
| 31 | + case chunk(String) | |
| 32 | + case finished(InferenceStats) | |
| 33 | +} | |
| 34 | + | |
| 35 | +enum InferenceEngineError: LocalizedError { | |
| 36 | + case unsupportedType(ModelType) | |
| 37 | + case noModelLoaded | |
| 38 | + case wrongModelKind(expected: String) | |
| 39 | + | |
| 40 | + var errorDescription: String? { | |
| 41 | + switch self { | |
| 42 | + case .unsupportedType(let type): | |
| 43 | + "\(type.displayName) models run through the Python pipelines — not the in-process engine." | |
| 44 | + case .noModelLoaded: | |
| 45 | + "No model is loaded." | |
| 46 | + case .wrongModelKind(let expected): | |
| 47 | + "The loaded model is not \(expected)." | |
| 48 | + } | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +enum ModelTypeAdapters { | |
| 53 | + | |
| 54 | + /// Load a local model directory into the right container for its type. | |
| 55 | + /// Tokenizers come from swift-transformers via the MLXHuggingFace macro | |
| 56 | + /// (docs/MLX-RESEARCH.md §4.1/§4.4). | |
| 57 | + static func load(model: LocalModel) async throws -> LoadedModelAdapter { | |
| 58 | + switch model.type { | |
| 59 | + case .llm: | |
| 60 | + let container = try await LLMModelFactory.shared.loadContainer( | |
| 61 | + from: model.directory, using: #huggingFaceTokenizerLoader()) | |
| 62 | + return .language(container) | |
| 63 | + case .vlm: | |
| 64 | + let container = try await VLMModelFactory.shared.loadContainer( | |
| 65 | + from: model.directory, using: #huggingFaceTokenizerLoader()) | |
| 66 | + return .language(container) | |
| 67 | + case .embedding: | |
| 68 | + let container = try await EmbedderModelFactory.shared.loadContainer( | |
| 69 | + from: model.directory, using: #huggingFaceTokenizerLoader()) | |
| 70 | + return .embedder(container) | |
| 71 | + case .speech, .imageGeneration: | |
| 72 | + throw InferenceEngineError.unsupportedType(model.type) | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | |
added
Sources/ZyquoMLX/Hub/ModelStore.swift
+180 −0
@@ -0,0 +1,180 @@ | ||
| 1 | +// | |
| 2 | +// ModelStore.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Errors surfaced when a directory is not a usable MLX model. | |
| 12 | +enum ModelValidationError: LocalizedError { | |
| 13 | + case notADirectory(URL) | |
| 14 | + case missingConfig(URL) | |
| 15 | + case missingWeights(URL) | |
| 16 | + case inconsistentShards(missing: [String]) | |
| 17 | + | |
| 18 | + var errorDescription: String? { | |
| 19 | + switch self { | |
| 20 | + case .notADirectory(let url): | |
| 21 | + "Not a folder: \(url.path)" | |
| 22 | + case .missingConfig(let url): | |
| 23 | + "No config.json found in \(url.lastPathComponent) — this is not an MLX model folder." | |
| 24 | + case .missingWeights(let url): | |
| 25 | + "No weight files (*.safetensors) found in \(url.lastPathComponent)." | |
| 26 | + case .inconsistentShards(let missing): | |
| 27 | + "The weight index references missing shards: \(missing.joined(separator: ", "))." | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// Scans, validates, and describes the local model library | |
| 33 | +/// (`~/Library/Application Support/ZyquoMLX/Models/`). | |
| 34 | +/// | |
| 35 | +/// Validation follows the on-disk MLX format (docs/MLX-RESEARCH.md §5.3): | |
| 36 | +/// `config.json` (+ optional `quantization` dict), `model*.safetensors` | |
| 37 | +/// (single or sharded + index), tokenizer files. | |
| 38 | +actor ModelStore { | |
| 39 | + | |
| 40 | + static let shared = ModelStore() | |
| 41 | + | |
| 42 | + private let root: URL | |
| 43 | + | |
| 44 | + init(root: URL = PersistenceService.modelsDirectory) { | |
| 45 | + self.root = root | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// All valid models in the library. Invalid directories are skipped | |
| 49 | + /// (they surface through `validate` when the user targets them directly). | |
| 50 | + func scan() throws -> [LocalModel] { | |
| 51 | + let fm = FileManager.default | |
| 52 | + guard fm.fileExists(atPath: root.path) else { return [] } | |
| 53 | + let entries = try fm.contentsOfDirectory( | |
| 54 | + at: root, includingPropertiesForKeys: [.isDirectoryKey], | |
| 55 | + options: .skipsHiddenFiles) | |
| 56 | + return entries.compactMap { try? describe(directory: $0) } | |
| 57 | + .sorted { $0.installedAt > $1.installedAt } | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Validate and describe one model directory (works for any path, not | |
| 61 | + /// just the library root — Playground can open arbitrary folders). | |
| 62 | + func describe(directory: URL) throws -> LocalModel { | |
| 63 | + let fm = FileManager.default | |
| 64 | + var isDir: ObjCBool = false | |
| 65 | + guard fm.fileExists(atPath: directory.path, isDirectory: &isDir), isDir.boolValue else { | |
| 66 | + throw ModelValidationError.notADirectory(directory) | |
| 67 | + } | |
| 68 | + | |
| 69 | + let configURL = directory.appendingPathComponent("config.json") | |
| 70 | + var config: [String: Any]? | |
| 71 | + if let data = try? Data(contentsOf: configURL) { | |
| 72 | + config = try JSONSerialization.jsonObject(with: data) as? [String: Any] | |
| 73 | + } | |
| 74 | + | |
| 75 | + let type = ModelType.detect(directory: directory, config: config) | |
| 76 | + | |
| 77 | + // Image-generation layouts (FLUX) keep weights in component subdirs and | |
| 78 | + // have no root config; everything else must have config.json. | |
| 79 | + if config == nil && type != .imageGeneration { | |
| 80 | + throw ModelValidationError.missingConfig(directory) | |
| 81 | + } | |
| 82 | + | |
| 83 | + let allFiles = (try? fm.subpathsOfDirectory(atPath: directory.path)) ?? [] | |
| 84 | + let weightFiles = allFiles.filter { $0.hasSuffix(".safetensors") || $0.hasSuffix(".npz") } | |
| 85 | + guard !weightFiles.isEmpty else { | |
| 86 | + throw ModelValidationError.missingWeights(directory) | |
| 87 | + } | |
| 88 | + | |
| 89 | + // Shard-index consistency (model.safetensors.index.json → weight_map). | |
| 90 | + let indexURL = directory.appendingPathComponent("model.safetensors.index.json") | |
| 91 | + var totalParameters: Int64? | |
| 92 | + if let indexData = try? Data(contentsOf: indexURL), | |
| 93 | + let index = try? JSONSerialization.jsonObject(with: indexData) as? [String: Any] | |
| 94 | + { | |
| 95 | + if let weightMap = index["weight_map"] as? [String: String] { | |
| 96 | + let referenced = Set(weightMap.values) | |
| 97 | + let present = Set(weightFiles.map { ($0 as NSString).lastPathComponent }) | |
| 98 | + let missing = referenced.subtracting(present).sorted() | |
| 99 | + if !missing.isEmpty { | |
| 100 | + throw ModelValidationError.inconsistentShards(missing: missing) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + if let meta = index["metadata"] as? [String: Any], | |
| 104 | + let params = meta["total_parameters"] as? Int64 ?? (meta["total_parameters"] as? Int).map(Int64.init) | |
| 105 | + { | |
| 106 | + totalParameters = params | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + var weightsSize: Int64 = 0 | |
| 111 | + var diskSize: Int64 = 0 | |
| 112 | + for path in allFiles { | |
| 113 | + let fileURL = directory.appendingPathComponent(path) | |
| 114 | + guard let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize | |
| 115 | + else { continue } | |
| 116 | + diskSize += Int64(size) | |
| 117 | + if path.hasSuffix(".safetensors") || path.hasSuffix(".npz") { | |
| 118 | + weightsSize += Int64(size) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + var quantization: QuantizationInfo? | |
| 123 | + if let quantDict = config?["quantization"] as? [String: Any], | |
| 124 | + let data = try? JSONSerialization.data(withJSONObject: quantDict) | |
| 125 | + { | |
| 126 | + quantization = try? JSONDecoder().decode(QuantizationInfo.self, from: data) | |
| 127 | + } | |
| 128 | + | |
| 129 | + let name = directory.lastPathComponent | |
| 130 | + let installedAt = | |
| 131 | + (try? directory.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .now | |
| 132 | + | |
| 133 | + return LocalModel( | |
| 134 | + id: name, | |
| 135 | + name: name, | |
| 136 | + repoID: Self.repoID(fromDirectoryName: name), | |
| 137 | + type: type, | |
| 138 | + directory: directory, | |
| 139 | + architecture: config?["model_type"] as? String, | |
| 140 | + parameterCount: totalParameters ?? Self.parameterCount(fromName: name, quantization: quantization, weightsSize: weightsSize), | |
| 141 | + quantization: quantization, | |
| 142 | + weightsSize: weightsSize, | |
| 143 | + diskSize: diskSize, | |
| 144 | + installedAt: installedAt | |
| 145 | + ) | |
| 146 | + } | |
| 147 | + | |
| 148 | + /// Delete a model from the library. | |
| 149 | + func delete(_ model: LocalModel) throws { | |
| 150 | + try FileManager.default.removeItem(at: model.directory) | |
| 151 | + } | |
| 152 | + | |
| 153 | + // MARK: - Name parsing | |
| 154 | + | |
| 155 | + /// Library directories are named "org--repo" (HF convention for local caches). | |
| 156 | + private static func repoID(fromDirectoryName name: String) -> String? { | |
| 157 | + guard name.contains("--") else { return nil } | |
| 158 | + return name.replacingOccurrences(of: "--", with: "/") | |
| 159 | + } | |
| 160 | + | |
| 161 | + /// Parse "…-4B-…" / "…-0.6B-…" / "…-135M-…" out of a repo name; fall back | |
| 162 | + /// to estimating from weight bytes (docs/MODELS.md §3 bytes-per-param). | |
| 163 | + static func parameterCount(fromName name: String, quantization: QuantizationInfo?, weightsSize: Int64) -> Int64? { | |
| 164 | + if let range = name.range(of: #"(\d+(?:\.\d+)?)\s*[bB](?=[-_.]|$)"#, options: .regularExpression) { | |
| 165 | + let token = name[range].dropLast() | |
| 166 | + if let value = Double(token.trimmingCharacters(in: .whitespaces)) { | |
| 167 | + return Int64(value * 1_000_000_000) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + if let range = name.range(of: #"(\d+(?:\.\d+)?)\s*[mM](?=[-_.]|$)"#, options: .regularExpression) { | |
| 171 | + let token = name[range].dropLast() | |
| 172 | + if let value = Double(token.trimmingCharacters(in: .whitespaces)) { | |
| 173 | + return Int64(value * 1_000_000) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + guard weightsSize > 0 else { return nil } | |
| 177 | + let bytesPerParam = MemoryAdvisor.bytesPerParameter(quantization: quantization) | |
| 178 | + return Int64(Double(weightsSize) / bytesPerParam) | |
| 179 | + } | |
| 180 | +} | |
added
Sources/ZyquoMLX/Services/PersistenceService.swift
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// | |
| 2 | +// PersistenceService.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Canonical data locations (charter naming conventions) and small JSON | |
| 12 | +/// persistence helpers used across stores. | |
| 13 | +enum PersistenceService { | |
| 14 | + | |
| 15 | + /// `~/Library/Application Support/ZyquoMLX/` | |
| 16 | + static var appSupportDirectory: URL { | |
| 17 | + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 18 | + .appendingPathComponent("ZyquoMLX", isDirectory: true) | |
| 19 | + } | |
| 20 | + | |
| 21 | + static var modelsDirectory: URL { | |
| 22 | + appSupportDirectory.appendingPathComponent("Models", isDirectory: true) | |
| 23 | + } | |
| 24 | + | |
| 25 | + static var datasetsDirectory: URL { | |
| 26 | + appSupportDirectory.appendingPathComponent("Datasets", isDirectory: true) | |
| 27 | + } | |
| 28 | + | |
| 29 | + static var runsDirectory: URL { | |
| 30 | + appSupportDirectory.appendingPathComponent("Runs", isDirectory: true) | |
| 31 | + } | |
| 32 | + | |
| 33 | + static var pythonDirectory: URL { | |
| 34 | + appSupportDirectory.appendingPathComponent("py", isDirectory: true) | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// Create all canonical directories if missing (first launch). | |
| 38 | + static func ensureDirectories() throws { | |
| 39 | + let fm = FileManager.default | |
| 40 | + for url in [appSupportDirectory, modelsDirectory, datasetsDirectory, runsDirectory] { | |
| 41 | + try fm.createDirectory(at: url, withIntermediateDirectories: true) | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + static func loadJSON<T: Decodable>(_ type: T.Type, from url: URL) throws -> T { | |
| 46 | + let data = try Data(contentsOf: url) | |
| 47 | + let decoder = JSONDecoder() | |
| 48 | + decoder.dateDecodingStrategy = .iso8601 | |
| 49 | + return try decoder.decode(type, from: data) | |
| 50 | + } | |
| 51 | + | |
| 52 | + static func saveJSON<T: Encodable>(_ value: T, to url: URL) throws { | |
| 53 | + let encoder = JSONEncoder() | |
| 54 | + encoder.dateEncodingStrategy = .iso8601 | |
| 55 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 56 | + try encoder.encode(value).write(to: url, options: .atomic) | |
| 57 | + } | |
| 58 | +} | |
added
Sources/ZyquoMLX/Training/HyperParams.swift
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// | |
| 2 | +// HyperParams.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Training hyperparameters. Field names/defaults mirror mlx-lm's | |
| 12 | +/// `CONFIG_DEFAULTS` exactly (docs/TRAINING-RESEARCH.md §1.2); serialized to | |
| 13 | +/// the YAML config the Python bridge consumes (`lora_parameters` and | |
| 14 | +/// `lr_schedule` are config-only upstream). | |
| 15 | +struct HyperParams: Codable, Hashable, Sendable { | |
| 16 | + // LoRA-specific (config-only upstream) | |
| 17 | + var rank: Int = 8 | |
| 18 | + /// MLX uses a single scale factor instead of alpha/rank (default 20.0). | |
| 19 | + var scale: Double = 20.0 | |
| 20 | + var dropout: Double = 0.0 | |
| 21 | + /// Target modules; nil = adapt all linear layers in targeted blocks. | |
| 22 | + var keys: [String]? | |
| 23 | + | |
| 24 | + // Core loop | |
| 25 | + var numLayers: Int = 16 // -1 = all layers | |
| 26 | + var batchSize: Int = 4 | |
| 27 | + var iterations: Int = 1000 | |
| 28 | + var learningRate: Double = 1e-5 | |
| 29 | + var maxSeqLength: Int = 2048 | |
| 30 | + var seed: Int = 0 | |
| 31 | + | |
| 32 | + // Optimizer: adam | adamw | muon | sgd | adafactor | |
| 33 | + var optimizer: String = "adam" | |
| 34 | + var gradCheckpoint: Bool = false | |
| 35 | + var gradAccumulationSteps: Int = 1 | |
| 36 | + var maskPrompt: Bool = false | |
| 37 | + | |
| 38 | + // Cadences | |
| 39 | + var stepsPerReport: Int = 10 | |
| 40 | + var stepsPerEval: Int = 200 | |
| 41 | + var saveEvery: Int = 100 | |
| 42 | + var valBatches: Int = 25 | |
| 43 | +} | |
modified
docs/PLAN.md
+18 −1
@@ -83,7 +83,24 @@ emits `mlx-swift_Cmlx.bundle/default.metallib` (~90 s clean, 0 warnings), so | ||
| 83 | 83 | no xcodebuild is needed. `make app` assembles an ad-hoc-signed `Zyquo MLX.app` |
| 84 | 84 | with all resource bundles; app launches with the Apple-Silicon gate in place. |
| 85 | 85 | BUILD.md §3.1 updated with the resolved recipe. |
| 86 | −## Phase 2 — Architecture + Inference POC (not started) | |
| 86 | +## Phase 2 — Architecture + Inference POC ✅ (completed 2026-07-30) | |
| 87 | + | |
| 88 | +- [x] Folder architecture per charter (App/ Models/ Engine/ Training/ Hub/ Services/ established with real code; Convert/ Data/ PyBridge/ arrive with Phase 3, DesignSystem/ ViewModels/ Views/ with Phase 4 — no dead placeholder files) | |
| 89 | +- [x] `Models/` domain types: `LocalModel`, `ModelType`, `Dataset`, `TrainingRun`, `Checkpoint`, `Job` (+ `HyperParams` mirroring mlx-lm defaults) | |
| 90 | +- [x] `Engine/`: `InferenceEngine` actor + `ModelTypeAdapters` (LLM/VLM/Embeddings via LLMModelFactory/VLMModelFactory/EmbedderModelFactory) + `GenerationParams` + `MemoryAdvisor` (device-derived working-set gating) | |
| 91 | +- [x] `Hub/ModelStore`: local library scan/validate (config.json, shard-index consistency, quantization metadata, param-count derivation) | |
| 92 | +- [x] CLI POC mode (`ZyquoMLX --infer <dir>` / `--embed <dir>`) before UI | |
| 93 | +- [x] PHASE GATE: LLM (Qwen3-0.6B-4bit) streamed at **603.7 tok/s, TTFT 0.83 s**, unload freed 319.8 MB (verified); embeddings (all-MiniLM-L6-v2-4bit) returned 384-dim L2-normed vectors with correct similarity ordering (0.86 related vs 0.72 unrelated) | |
| 94 | +- [x] Phase checkpoint: build green, 0 warnings, arm64-only | |
| 95 | + | |
| 96 | +**Phase 2 summary:** Domain models, `InferenceEngine` actor with typed | |
| 97 | +adapters, `ModelStore` validation, and `MemoryAdvisor` are in place; every | |
| 98 | +dependency API was verified against the checked-out mlx-swift-lm 3.31.4 | |
| 99 | +sources (no guessed names — `#huggingFaceTokenizerLoader` macro requires | |
| 100 | +`import Tokenizers` at the expansion site; non-Sendable `UserInput` goes | |
| 101 | +through `perform(nonSendable:)`). CLI POC proves the two-model-type gate with | |
| 102 | +real downloads in the app's Models library. Build is arm64-only via | |
| 103 | +`--arch arm64` (swiftbuild otherwise builds universal and x86_64 fails). | |
| 87 | 104 | ## Phase 3 — Training, Quantization & Conversion (not started) |
| 88 | 105 | ## Phase 4 — Design System & UI (not started) |
| 89 | 106 | ## Phase 5 — App Icon (not started) |
| 90 | 107 | |