SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%

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>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 1ce1004

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