phase2: InferenceEngine actor + ChatSession + MemoryAdvisor; PoC gate green (130 tok/s)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 11 changed files with +804 and −5
modified
Sources/ZyquoLocal/App/PoCRunner.swift
+84 −4
@@ -8,11 +8,91 @@ | ||
| 8 | 8 | |
| 9 | 9 | import Foundation |
| 10 | 10 | |
| 11 | −/// CLI proof-of-concept mode: `ZyquoLocal --poc <model-dir> "<prompt>"`. | |
| 12 | −/// Fully implemented in Phase 2 alongside the inference engine. | |
| 11 | +/// CLI proof-of-concept mode — the Phase 2 gate: | |
| 12 | +/// `ZyquoLocal --poc <model-dir> "<prompt>"` loads an MLX model from a local | |
| 13 | +/// directory and streams generated tokens to stdout, ending with stats. | |
| 13 | 14 | enum PoCRunner { |
| 14 | 15 | static func run(arguments: [String]) async { |
| 15 | − FileHandle.standardError.write(Data("--poc arrives with the Phase 2 inference engine.\n".utf8)) | |
| 16 | − exit(64) | |
| 16 | + guard let flagIndex = arguments.firstIndex(of: "--poc"), | |
| 17 | + arguments.count >= flagIndex + 3 | |
| 18 | + else { | |
| 19 | + err("usage: ZyquoLocal --poc <model-dir> \"<prompt>\"\n") | |
| 20 | + exit(64) | |
| 21 | + } | |
| 22 | + let directory = URL(fileURLWithPath: arguments[flagIndex + 1]) | |
| 23 | + let prompt = arguments[flagIndex + 2] | |
| 24 | + | |
| 25 | + let size = (try? FileManager.default.allocatedSizeOfDirectory(at: directory)) ?? 0 | |
| 26 | + let model = LocalModel( | |
| 27 | + repoID: directory.pathComponents.suffix(2).joined(separator: "/"), | |
| 28 | + directory: directory, | |
| 29 | + sizeBytes: size | |
| 30 | + ) | |
| 31 | + | |
| 32 | + let engine = InferenceEngine() | |
| 33 | + do { | |
| 34 | + err("Loading \(model.repoID) (\(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)))…\n") | |
| 35 | + let loadStart = Date() | |
| 36 | + try await engine.load(model: model) | |
| 37 | + err(String(format: "Loaded in %.2fs. Generating…\n\n", Date().timeIntervalSince(loadStart))) | |
| 38 | + | |
| 39 | + let conversation = Conversation(modelID: model.repoID) | |
| 40 | + try await engine.startSession(conversation: conversation) | |
| 41 | + | |
| 42 | + var stats: GenerationStats? | |
| 43 | + let stream = try await engine.generate(prompt: prompt, params: GenerationParams()) | |
| 44 | + for try await event in stream { | |
| 45 | + switch event { | |
| 46 | + case .token(let text): | |
| 47 | + out(text) | |
| 48 | + case .stats(let s): | |
| 49 | + stats = s | |
| 50 | + case .finished(let reason): | |
| 51 | + out("\n") | |
| 52 | + if let s = stats { | |
| 53 | + err(String( | |
| 54 | + format: "\n⚡ %.1f tok/s · %d tokens · %.2fs to first token · prompt %d tokens · peak %@ · stop: %@\n", | |
| 55 | + s.tokensPerSecond, | |
| 56 | + s.generationTokenCount, | |
| 57 | + s.timeToFirstToken, | |
| 58 | + s.promptTokenCount, | |
| 59 | + ByteCountFormatter.string(fromByteCount: Int64(s.peakMemoryBytes), countStyle: .memory), | |
| 60 | + reason.rawValue | |
| 61 | + )) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + await engine.unload() | |
| 66 | + exit(0) | |
| 67 | + } catch { | |
| 68 | + err("error: \(error.localizedDescription)\n") | |
| 69 | + exit(1) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + private static func out(_ text: String) { | |
| 74 | + FileHandle.standardOutput.write(Data(text.utf8)) | |
| 75 | + } | |
| 76 | + | |
| 77 | + private static func err(_ text: String) { | |
| 78 | + FileHandle.standardError.write(Data(text.utf8)) | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 82 | +extension FileManager { | |
| 83 | + /// Total allocated size of a directory tree in bytes. | |
| 84 | + func allocatedSizeOfDirectory(at url: URL) throws -> Int64 { | |
| 85 | + var total: Int64 = 0 | |
| 86 | + let keys: Set<URLResourceKey> = [.totalFileAllocatedSizeKey, .isRegularFileKey] | |
| 87 | + guard let enumerator = enumerator(at: url, includingPropertiesForKeys: Array(keys)) else { | |
| 88 | + return 0 | |
| 89 | + } | |
| 90 | + for case let file as URL in enumerator { | |
| 91 | + let values = try file.resourceValues(forKeys: keys) | |
| 92 | + if values.isRegularFile == true { | |
| 93 | + total += Int64(values.totalFileAllocatedSize ?? 0) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return total | |
| 17 | 97 | } |
| 18 | 98 | } |
added
Sources/ZyquoLocal/Engine/ChatSession.swift
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +// | |
| 2 | +// ChatSession.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLXLMCommon | |
| 11 | + | |
| 12 | +/// Zyquo Local's multi-turn session: converts persisted conversation history | |
| 13 | +/// into the model's chat-template form, truncates oldest turns when the | |
| 14 | +/// context window would overflow (always keeping the system prompt), and | |
| 15 | +/// reuses the KV cache across turns via the underlying MLX session. | |
| 16 | +/// | |
| 17 | +/// Not thread-safe by design — owned and accessed only by `InferenceEngine`. | |
| 18 | +final class ChatSession { | |
| 19 | + /// Conversation this session is bound to. | |
| 20 | + let conversationID: UUID | |
| 21 | + private let contextWindow: Int | |
| 22 | + private let mlxSession: MLXLMCommon.ChatSession | |
| 23 | + | |
| 24 | + /// Rough chars-per-token estimate used for truncation budgeting. | |
| 25 | + private static let charsPerToken = 3.5 | |
| 26 | + /// Fraction of the context window budgeted for history (rest is reserved | |
| 27 | + /// for the generated response). | |
| 28 | + private static let historyBudgetFraction = 0.7 | |
| 29 | + | |
| 30 | + /// Number of tokens estimated to be used by history + system prompt. | |
| 31 | + private(set) var estimatedHistoryTokens: Int | |
| 32 | + | |
| 33 | + init( | |
| 34 | + container: ModelContainer, | |
| 35 | + conversation: Conversation, | |
| 36 | + contextWindow: Int, | |
| 37 | + params: GenerationParams | |
| 38 | + ) { | |
| 39 | + self.conversationID = conversation.id | |
| 40 | + self.contextWindow = contextWindow | |
| 41 | + | |
| 42 | + let truncated = Self.truncatedHistory( | |
| 43 | + messages: conversation.messages, | |
| 44 | + contextWindow: contextWindow | |
| 45 | + ) | |
| 46 | + self.estimatedHistoryTokens = Self.estimateTokens( | |
| 47 | + truncated.map(\.content).joined() + (conversation.systemPrompt ?? "") | |
| 48 | + ) | |
| 49 | + | |
| 50 | + let history: [Chat.Message] = truncated.map { message in | |
| 51 | + switch message.role { | |
| 52 | + case .system: .system(message.content) | |
| 53 | + case .user: .user(message.content) | |
| 54 | + case .assistant: .assistant(message.content) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + self.mlxSession = MLXLMCommon.ChatSession( | |
| 58 | + container, | |
| 59 | + instructions: conversation.systemPrompt, | |
| 60 | + history: history, | |
| 61 | + generateParameters: params.toMLX() | |
| 62 | + ) | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Streams a response to a new user prompt, honoring per-call parameters | |
| 66 | + /// without losing the KV cache. | |
| 67 | + func stream(prompt: String, params: GenerationParams) -> AsyncThrowingStream<Generation, Error> { | |
| 68 | + mlxSession.generateParameters = params.toMLX() | |
| 69 | + estimatedHistoryTokens += Self.estimateTokens(prompt) | |
| 70 | + return mlxSession.streamDetails(to: prompt) | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// Estimated context usage in tokens (for the UI context bar). | |
| 74 | + var estimatedContextUsage: (used: Int, window: Int) { | |
| 75 | + (estimatedHistoryTokens, contextWindow) | |
| 76 | + } | |
| 77 | + | |
| 78 | + func noteResponse(_ text: String) { | |
| 79 | + estimatedHistoryTokens += Self.estimateTokens(text) | |
| 80 | + } | |
| 81 | + | |
| 82 | + // MARK: - Truncation | |
| 83 | + | |
| 84 | + static func estimateTokens(_ text: String) -> Int { | |
| 85 | + Int(Double(text.count) / charsPerToken) + 1 | |
| 86 | + } | |
| 87 | + | |
| 88 | + /// Drops oldest non-system turns until the estimated history fits the | |
| 89 | + /// budgeted share of the context window. The system prompt (a leading | |
| 90 | + /// system message, if any) is always kept. | |
| 91 | + static func truncatedHistory(messages: [Message], contextWindow: Int) -> [Message] { | |
| 92 | + let budget = Int(Double(contextWindow) * historyBudgetFraction) | |
| 93 | + var system: [Message] = [] | |
| 94 | + var turns: [Message] = [] | |
| 95 | + for m in messages { | |
| 96 | + if m.role == .system && turns.isEmpty { | |
| 97 | + system.append(m) | |
| 98 | + } else { | |
| 99 | + turns.append(m) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + func total(_ list: [Message]) -> Int { | |
| 103 | + list.reduce(0) { $0 + estimateTokens($1.content) } | |
| 104 | + } | |
| 105 | + while turns.count > 1, total(system) + total(turns) > budget { | |
| 106 | + turns.removeFirst() | |
| 107 | + } | |
| 108 | + return system + turns | |
| 109 | + } | |
| 110 | +} | |
added
Sources/ZyquoLocal/Engine/GenerationParams.swift
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +// | |
| 2 | +// GenerationParams.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLXLMCommon | |
| 11 | + | |
| 12 | +/// App-level, persistable generation parameters. Mapped to MLX | |
| 13 | +/// `GenerateParameters` at the engine boundary — inference behavior never | |
| 14 | +/// leaks out of the Engine layer. | |
| 15 | +struct GenerationParams: Codable, Hashable, Sendable { | |
| 16 | + var temperature: Float | |
| 17 | + var topP: Float | |
| 18 | + var repetitionPenalty: Float? | |
| 19 | + /// nil = no cap (bounded by the context window). | |
| 20 | + var maxTokens: Int? | |
| 21 | + /// nil = fresh entropy per generation. | |
| 22 | + var seed: UInt64? | |
| 23 | + | |
| 24 | + init( | |
| 25 | + temperature: Float = 0.7, | |
| 26 | + topP: Float = 0.95, | |
| 27 | + repetitionPenalty: Float? = nil, | |
| 28 | + maxTokens: Int? = nil, | |
| 29 | + seed: UInt64? = nil | |
| 30 | + ) { | |
| 31 | + self.temperature = temperature | |
| 32 | + self.topP = topP | |
| 33 | + self.repetitionPenalty = repetitionPenalty | |
| 34 | + self.maxTokens = maxTokens | |
| 35 | + self.seed = seed | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +extension GenerationParams { | |
| 40 | + /// Engine-internal mapping to MLX parameters. | |
| 41 | + func toMLX() -> GenerateParameters { | |
| 42 | + var p = GenerateParameters() | |
| 43 | + p.temperature = temperature | |
| 44 | + p.topP = topP | |
| 45 | + p.repetitionPenalty = repetitionPenalty | |
| 46 | + p.maxTokens = maxTokens | |
| 47 | + p.seed = seed | |
| 48 | + return p | |
| 49 | + } | |
| 50 | +} | |
added
Sources/ZyquoLocal/Engine/InferenceEngine.swift
+263 −0
@@ -0,0 +1,263 @@ | ||
| 1 | +// | |
| 2 | +// InferenceEngine.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLX | |
| 11 | +import MLXHuggingFace | |
| 12 | +import MLXLLM | |
| 13 | +import MLXLMCommon | |
| 14 | +import Tokenizers | |
| 15 | + | |
| 16 | +/// Events streamed by the engine during generation. | |
| 17 | +enum GenerationEvent: Sendable { | |
| 18 | + case token(String) | |
| 19 | + case stats(GenerationStats) | |
| 20 | + case finished(GenerationFinishReason) | |
| 21 | +} | |
| 22 | + | |
| 23 | +enum GenerationFinishReason: String, Sendable { | |
| 24 | + case stop | |
| 25 | + case length | |
| 26 | + case cancelled | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// Final per-response statistics. | |
| 30 | +struct GenerationStats: Sendable { | |
| 31 | + var timeToFirstToken: TimeInterval | |
| 32 | + var tokensPerSecond: Double | |
| 33 | + var promptTokenCount: Int | |
| 34 | + var generationTokenCount: Int | |
| 35 | + var peakMemoryBytes: Int | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Engine lifecycle state. | |
| 39 | +enum EngineState: Sendable, Equatable { | |
| 40 | + case unloaded | |
| 41 | + case loading(repoID: String) | |
| 42 | + case ready(repoID: String) | |
| 43 | + case generating(repoID: String) | |
| 44 | +} | |
| 45 | + | |
| 46 | +/// Errors surfaced by the engine, with human-readable messages. | |
| 47 | +enum EngineError: LocalizedError { | |
| 48 | + case noModelLoaded | |
| 49 | + case modelDirectoryInvalid(URL) | |
| 50 | + case loadFailed(String, underlying: String) | |
| 51 | + case unsupportedArchitecture(String) | |
| 52 | + case outOfMemory(String) | |
| 53 | + | |
| 54 | + var errorDescription: String? { | |
| 55 | + switch self { | |
| 56 | + case .noModelLoaded: | |
| 57 | + "No model is loaded. Load a model from the Library first." | |
| 58 | + case .modelDirectoryInvalid(let url): | |
| 59 | + "The model folder is missing required files (config.json, weights, tokenizer): \(url.path)" | |
| 60 | + case .loadFailed(let repo, let underlying): | |
| 61 | + "Could not load \(repo): \(underlying)" | |
| 62 | + case .unsupportedArchitecture(let arch): | |
| 63 | + "This model's architecture “\(arch)” is not supported by the MLX engine yet." | |
| 64 | + case .outOfMemory(let repo): | |
| 65 | + "Not enough memory to load \(repo). Try a smaller quantization (e.g. 4-bit) or a smaller model." | |
| 66 | + } | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// The single owner of all inference. One model loaded at a time (v1). | |
| 71 | +/// States: unloaded → loading → ready ⇄ generating. | |
| 72 | +actor InferenceEngine { | |
| 73 | + private(set) var state: EngineState = .unloaded | |
| 74 | + | |
| 75 | + private var container: ModelContainer? | |
| 76 | + private var loadedModel: LocalModel? | |
| 77 | + private var session: ChatSession? | |
| 78 | + private var generationTask: Task<Void, Never>? | |
| 79 | + | |
| 80 | + /// Context window of the loaded model (from config.json), defaulting | |
| 81 | + /// conservatively when absent. | |
| 82 | + private(set) var contextWindow: Int = 4096 | |
| 83 | + | |
| 84 | + // MARK: - Load / unload | |
| 85 | + | |
| 86 | + /// Loads a model from its local directory, replacing any loaded model. | |
| 87 | + func load(model: LocalModel) async throws { | |
| 88 | + await stopGeneration() | |
| 89 | + unloadInternal() | |
| 90 | + state = .loading(repoID: model.repoID) | |
| 91 | + do { | |
| 92 | + guard FileManager.default.fileExists( | |
| 93 | + atPath: model.directory.appendingPathComponent("config.json").path) | |
| 94 | + else { | |
| 95 | + throw EngineError.modelDirectoryInvalid(model.directory) | |
| 96 | + } | |
| 97 | + contextWindow = Self.readContextWindow(directory: model.directory) ?? 4096 | |
| 98 | + let container = try await loadModelContainer( | |
| 99 | + from: model.directory, | |
| 100 | + using: #huggingFaceTokenizerLoader() | |
| 101 | + ) | |
| 102 | + self.container = container | |
| 103 | + self.loadedModel = model | |
| 104 | + state = .ready(repoID: model.repoID) | |
| 105 | + } catch let error as EngineError { | |
| 106 | + state = .unloaded | |
| 107 | + throw error | |
| 108 | + } catch { | |
| 109 | + state = .unloaded | |
| 110 | + let text = String(describing: error) | |
| 111 | + if text.localizedCaseInsensitiveContains("Unsupported model type") { | |
| 112 | + throw EngineError.unsupportedArchitecture(model.architecture ?? "unknown") | |
| 113 | + } | |
| 114 | + if text.localizedCaseInsensitiveContains("memory") { | |
| 115 | + throw EngineError.outOfMemory(model.repoID) | |
| 116 | + } | |
| 117 | + throw EngineError.loadFailed(model.repoID, underlying: error.localizedDescription) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Unloads the current model and verifiably frees memory. | |
| 122 | + func unload() async { | |
| 123 | + await stopGeneration() | |
| 124 | + unloadInternal() | |
| 125 | + } | |
| 126 | + | |
| 127 | + private func unloadInternal() { | |
| 128 | + session = nil | |
| 129 | + container = nil | |
| 130 | + loadedModel = nil | |
| 131 | + MemoryAdvisor.reclaimMemory() | |
| 132 | + state = .unloaded | |
| 133 | + } | |
| 134 | + | |
| 135 | + var currentModel: LocalModel? { loadedModel } | |
| 136 | + | |
| 137 | + // MARK: - Sessions | |
| 138 | + | |
| 139 | + /// Binds (or rebinds) the engine to a conversation. Rebuilding the session | |
| 140 | + /// drops the KV cache, so this is only done when switching conversations, | |
| 141 | + /// or when the system prompt changed. | |
| 142 | + func startSession(conversation: Conversation) throws { | |
| 143 | + guard let container else { throw EngineError.noModelLoaded } | |
| 144 | + session = ChatSession( | |
| 145 | + container: container, | |
| 146 | + conversation: conversation, | |
| 147 | + contextWindow: contextWindow, | |
| 148 | + params: conversation.params | |
| 149 | + ) | |
| 150 | + } | |
| 151 | + | |
| 152 | + /// Ensures the active session matches the conversation, preserving the | |
| 153 | + /// KV cache when it does. | |
| 154 | + func ensureSession(conversation: Conversation) throws { | |
| 155 | + if session?.conversationID != conversation.id { | |
| 156 | + try startSession(conversation: conversation) | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + var contextUsage: (used: Int, window: Int)? { | |
| 161 | + session?.estimatedContextUsage | |
| 162 | + } | |
| 163 | + | |
| 164 | + // MARK: - Generation | |
| 165 | + | |
| 166 | + /// Streams a response to `prompt` inside the bound session. | |
| 167 | + /// Cancellation: cancel the consuming task or call `stopGeneration()` — | |
| 168 | + /// the underlying generation loop actually stops. | |
| 169 | + func generate(prompt: String, params: GenerationParams) throws | |
| 170 | + -> AsyncThrowingStream<GenerationEvent, Error> | |
| 171 | + { | |
| 172 | + guard let session else { throw EngineError.noModelLoaded } | |
| 173 | + guard let repoID = loadedModel?.repoID else { throw EngineError.noModelLoaded } | |
| 174 | + | |
| 175 | + let (stream, continuation) = AsyncThrowingStream.makeStream(of: GenerationEvent.self) | |
| 176 | + state = .generating(repoID: repoID) | |
| 177 | + MemoryAdvisor.resetPeakMemory() | |
| 178 | + | |
| 179 | + let task = Task { | |
| 180 | + let start = ContinuousClock.now | |
| 181 | + var firstTokenTime: TimeInterval? | |
| 182 | + var response = "" | |
| 183 | + var finished: GenerationFinishReason = .cancelled | |
| 184 | + do { | |
| 185 | + for try await generation in session.stream(prompt: prompt, params: params) { | |
| 186 | + if Task.isCancelled { break } | |
| 187 | + switch generation { | |
| 188 | + case .chunk(let text): | |
| 189 | + if firstTokenTime == nil { | |
| 190 | + firstTokenTime = Self.seconds(since: start) | |
| 191 | + } | |
| 192 | + response += text | |
| 193 | + continuation.yield(.token(text)) | |
| 194 | + case .info(let info): | |
| 195 | + let stats = GenerationStats( | |
| 196 | + timeToFirstToken: firstTokenTime ?? info.promptTime, | |
| 197 | + tokensPerSecond: info.tokensPerSecond, | |
| 198 | + promptTokenCount: info.promptTokenCount, | |
| 199 | + generationTokenCount: info.generationTokenCount, | |
| 200 | + peakMemoryBytes: MemoryAdvisor.peakMemoryBytes | |
| 201 | + ) | |
| 202 | + finished = switch info.stopReason { | |
| 203 | + case .stop: .stop | |
| 204 | + case .length: .length | |
| 205 | + case .cancelled: .cancelled | |
| 206 | + } | |
| 207 | + continuation.yield(.stats(stats)) | |
| 208 | + case .toolCall: | |
| 209 | + break | |
| 210 | + } | |
| 211 | + } | |
| 212 | + session.noteResponse(response) | |
| 213 | + continuation.yield(.finished(Task.isCancelled ? .cancelled : finished)) | |
| 214 | + continuation.finish() | |
| 215 | + } catch { | |
| 216 | + continuation.finish(throwing: error) | |
| 217 | + } | |
| 218 | + self.finishGeneration() | |
| 219 | + } | |
| 220 | + generationTask = task | |
| 221 | + continuation.onTermination = { termination in | |
| 222 | + if case .cancelled = termination { task.cancel() } | |
| 223 | + } | |
| 224 | + return stream | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// Stops any in-flight generation and waits for it to wind down. | |
| 228 | + func stopGeneration() async { | |
| 229 | + generationTask?.cancel() | |
| 230 | + _ = await generationTask?.value | |
| 231 | + generationTask = nil | |
| 232 | + } | |
| 233 | + | |
| 234 | + private func finishGeneration() { | |
| 235 | + if let repoID = loadedModel?.repoID { | |
| 236 | + state = .ready(repoID: repoID) | |
| 237 | + } | |
| 238 | + generationTask = nil | |
| 239 | + } | |
| 240 | + | |
| 241 | + // MARK: - Helpers | |
| 242 | + | |
| 243 | + private static func seconds(since start: ContinuousClock.Instant) -> TimeInterval { | |
| 244 | + let duration = start.duration(to: .now) | |
| 245 | + return Double(duration.components.seconds) | |
| 246 | + + Double(duration.components.attoseconds) * 1e-18 | |
| 247 | + } | |
| 248 | + | |
| 249 | + /// Reads max_position_embeddings from config.json. | |
| 250 | + static func readContextWindow(directory: URL) -> Int? { | |
| 251 | + let url = directory.appendingPathComponent("config.json") | |
| 252 | + guard let data = try? Data(contentsOf: url), | |
| 253 | + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] | |
| 254 | + else { return nil } | |
| 255 | + if let value = json["max_position_embeddings"] as? Int { return value } | |
| 256 | + if let text = json["text_config"] as? [String: Any], | |
| 257 | + let value = text["max_position_embeddings"] as? Int | |
| 258 | + { | |
| 259 | + return value | |
| 260 | + } | |
| 261 | + return nil | |
| 262 | + } | |
| 263 | +} | |
added
Sources/ZyquoLocal/Engine/MemoryAdvisor.swift
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +// | |
| 2 | +// MemoryAdvisor.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLX | |
| 11 | + | |
| 12 | +/// RAM estimation and fits/tight/won't-fit verdicts for THIS Mac. | |
| 13 | +/// Thresholds follow docs/MLX-RESEARCH.md §7: the macOS GPU-wired ceiling is | |
| 14 | +/// ~70–75 % of unified memory, so weights ≤60 % is comfortable, ≤75 % tight. | |
| 15 | +enum MemoryAdvisor { | |
| 16 | + enum Verdict: String, Codable, Sendable { | |
| 17 | + case fits | |
| 18 | + case tight | |
| 19 | + case tooLarge | |
| 20 | + | |
| 21 | + var label: String { | |
| 22 | + switch self { | |
| 23 | + case .fits: "Fits" | |
| 24 | + case .tight: "Tight" | |
| 25 | + case .tooLarge: "Too large" | |
| 26 | + } | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// Physical unified memory of this Mac in bytes. | |
| 31 | + static var physicalMemoryBytes: UInt64 { HardwareGate.physicalMemory } | |
| 32 | + | |
| 33 | + /// Estimated RAM needed to run a model: weights + KV cache + overhead. | |
| 34 | + /// weights×1.2 covers activations/prefill working set; +1.5 GB covers a | |
| 35 | + /// useful KV cache at moderate context. | |
| 36 | + static func estimatedRuntimeBytes(weightsBytes: Int64) -> Int64 { | |
| 37 | + Int64(Double(weightsBytes) * 1.2) + 1_500_000_000 | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// Verdict for a model of the given on-disk weights size. | |
| 41 | + static func verdict(weightsBytes: Int64) -> Verdict { | |
| 42 | + let ram = Double(physicalMemoryBytes) | |
| 43 | + let weights = Double(weightsBytes) | |
| 44 | + if weights <= ram * 0.60 { return .fits } | |
| 45 | + if weights <= ram * 0.75 { return .tight } | |
| 46 | + return .tooLarge | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Verdict for running two models side by side (Compare mode gate). | |
| 50 | + static func verdict(combinedWeightsBytes: Int64) -> Verdict { | |
| 51 | + verdict(weightsBytes: combinedWeightsBytes) | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Live MLX memory snapshot (bytes actively used by the loaded model). | |
| 55 | + static var activeMemoryBytes: Int { MLX.Memory.activeMemory } | |
| 56 | + static var peakMemoryBytes: Int { MLX.Memory.peakMemory } | |
| 57 | + | |
| 58 | + static func resetPeakMemory() { MLX.GPU.resetPeakMemory() } | |
| 59 | + | |
| 60 | + /// Returns cached MLX buffers to the OS — called after unloading a model. | |
| 61 | + static func reclaimMemory() { MLX.Memory.clearCache() } | |
| 62 | +} | |
added
Sources/ZyquoLocal/Models/Conversation.swift
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +// | |
| 2 | +// Conversation.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A persisted chat conversation. Remembers its model and generation params. | |
| 12 | +struct Conversation: Identifiable, Codable, Hashable, Sendable { | |
| 13 | + var id: UUID | |
| 14 | + var title: String | |
| 15 | + var messages: [Message] | |
| 16 | + /// Repo ID of the model this conversation uses (e.g. "mlx-community/Qwen3-8B-4bit"). | |
| 17 | + var modelID: String? | |
| 18 | + var systemPrompt: String? | |
| 19 | + var params: GenerationParams | |
| 20 | + var pinned: Bool | |
| 21 | + var createdAt: Date | |
| 22 | + var updatedAt: Date | |
| 23 | + | |
| 24 | + init( | |
| 25 | + id: UUID = UUID(), | |
| 26 | + title: String = "New Chat", | |
| 27 | + messages: [Message] = [], | |
| 28 | + modelID: String? = nil, | |
| 29 | + systemPrompt: String? = nil, | |
| 30 | + params: GenerationParams = GenerationParams(), | |
| 31 | + pinned: Bool = false, | |
| 32 | + createdAt: Date = Date(), | |
| 33 | + updatedAt: Date = Date() | |
| 34 | + ) { | |
| 35 | + self.id = id | |
| 36 | + self.title = title | |
| 37 | + self.messages = messages | |
| 38 | + self.modelID = modelID | |
| 39 | + self.systemPrompt = systemPrompt | |
| 40 | + self.params = params | |
| 41 | + self.pinned = pinned | |
| 42 | + self.createdAt = createdAt | |
| 43 | + self.updatedAt = updatedAt | |
| 44 | + } | |
| 45 | +} | |
added
Sources/ZyquoLocal/Models/DownloadTask.swift
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// | |
| 2 | +// DownloadTask.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// State of one model download managed by `DownloadManager` (Phase 3). | |
| 12 | +struct DownloadTask: Identifiable, Codable, Hashable, Sendable { | |
| 13 | + enum State: String, Codable, Sendable { | |
| 14 | + case queued | |
| 15 | + case downloading | |
| 16 | + case paused | |
| 17 | + case verifying | |
| 18 | + case completed | |
| 19 | + case failed | |
| 20 | + case cancelled | |
| 21 | + } | |
| 22 | + | |
| 23 | + /// Progress of a single file inside the download. | |
| 24 | + struct FileProgress: Codable, Hashable, Sendable { | |
| 25 | + var path: String | |
| 26 | + var totalBytes: Int64 | |
| 27 | + var receivedBytes: Int64 | |
| 28 | + /// LFS sha256 (from x-linked-etag) when available, for integrity checks. | |
| 29 | + var sha256: String? | |
| 30 | + var completed: Bool | |
| 31 | + } | |
| 32 | + | |
| 33 | + var repoID: String | |
| 34 | + var state: State | |
| 35 | + var files: [FileProgress] | |
| 36 | + var errorDescription: String? | |
| 37 | + var startedAt: Date | |
| 38 | + | |
| 39 | + var id: String { repoID } | |
| 40 | + | |
| 41 | + var totalBytes: Int64 { files.reduce(0) { $0 + $1.totalBytes } } | |
| 42 | + var receivedBytes: Int64 { files.reduce(0) { $0 + $1.receivedBytes } } | |
| 43 | + | |
| 44 | + var fractionCompleted: Double { | |
| 45 | + let total = totalBytes | |
| 46 | + guard total > 0 else { return 0 } | |
| 47 | + return Double(receivedBytes) / Double(total) | |
| 48 | + } | |
| 49 | +} | |
added
Sources/ZyquoLocal/Models/LocalModel.swift
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +// | |
| 2 | +// LocalModel.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A model installed on disk under the Models folder. | |
| 12 | +/// The one and only term for this concept is `LocalModel`. | |
| 13 | +struct LocalModel: Identifiable, Codable, Hashable, Sendable { | |
| 14 | + /// Full repo ID, e.g. "mlx-community/Qwen3-8B-4bit". Also the identity. | |
| 15 | + var repoID: String | |
| 16 | + /// Absolute directory containing config.json + weights + tokenizer. | |
| 17 | + var directory: URL | |
| 18 | + /// Total size on disk in bytes. | |
| 19 | + var sizeBytes: Int64 | |
| 20 | + /// `model_type` from config.json (architecture). | |
| 21 | + var architecture: String? | |
| 22 | + /// Quantization label parsed from the repo name ("4bit", "8bit", "bf16"…). | |
| 23 | + var quantization: String? | |
| 24 | + /// Context window (max_position_embeddings) from config.json. | |
| 25 | + var contextWindow: Int? | |
| 26 | + var lastUsed: Date? | |
| 27 | + /// Per-model default generation parameters (overrides app defaults). | |
| 28 | + var defaultParams: GenerationParams? | |
| 29 | + | |
| 30 | + var id: String { repoID } | |
| 31 | + | |
| 32 | + var organization: String { | |
| 33 | + repoID.split(separator: "/").first.map(String.init) ?? "" | |
| 34 | + } | |
| 35 | + | |
| 36 | + var name: String { | |
| 37 | + repoID.split(separator: "/").last.map(String.init) ?? repoID | |
| 38 | + } | |
| 39 | +} | |
added
Sources/ZyquoLocal/Models/Message.swift
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +// | |
| 2 | +// Message.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A single chat message. `thinking` holds the extracted `<think>…</think>` | |
| 12 | +/// content for reasoning models; `stats` is present on assistant messages. | |
| 13 | +struct Message: Identifiable, Codable, Hashable, Sendable { | |
| 14 | + enum Role: String, Codable, Sendable { | |
| 15 | + case system | |
| 16 | + case user | |
| 17 | + case assistant | |
| 18 | + } | |
| 19 | + | |
| 20 | + var id: UUID | |
| 21 | + var role: Role | |
| 22 | + var content: String | |
| 23 | + var thinking: String? | |
| 24 | + var stats: MessageStats? | |
| 25 | + var date: Date | |
| 26 | + | |
| 27 | + init( | |
| 28 | + id: UUID = UUID(), | |
| 29 | + role: Role, | |
| 30 | + content: String, | |
| 31 | + thinking: String? = nil, | |
| 32 | + stats: MessageStats? = nil, | |
| 33 | + date: Date = Date() | |
| 34 | + ) { | |
| 35 | + self.id = id | |
| 36 | + self.role = role | |
| 37 | + self.content = content | |
| 38 | + self.thinking = thinking | |
| 39 | + self.stats = stats | |
| 40 | + self.date = date | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Per-response performance statistics (first-class per the spec). | |
| 45 | +struct MessageStats: Codable, Hashable, Sendable { | |
| 46 | + /// Seconds from send to the first streamed token. | |
| 47 | + var timeToFirstToken: TimeInterval | |
| 48 | + /// Generation-phase tokens per second. | |
| 49 | + var tokensPerSecond: Double | |
| 50 | + var promptTokenCount: Int | |
| 51 | + var generationTokenCount: Int | |
| 52 | + /// Peak MLX GPU memory during the response, in bytes. | |
| 53 | + var peakMemoryBytes: Int | |
| 54 | +} | |
added
Sources/ZyquoLocal/Models/Persona.swift
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +// | |
| 2 | +// Persona.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A reusable persona: system prompt + preferred model + parameters. | |
| 12 | +struct Persona: Identifiable, Codable, Hashable, Sendable { | |
| 13 | + var id: UUID | |
| 14 | + var name: String | |
| 15 | + var systemPrompt: String | |
| 16 | + /// Preferred model repo ID; nil = whatever is loaded. | |
| 17 | + var preferredModelID: String? | |
| 18 | + var params: GenerationParams? | |
| 19 | + | |
| 20 | + init( | |
| 21 | + id: UUID = UUID(), | |
| 22 | + name: String, | |
| 23 | + systemPrompt: String, | |
| 24 | + preferredModelID: String? = nil, | |
| 25 | + params: GenerationParams? = nil | |
| 26 | + ) { | |
| 27 | + self.id = id | |
| 28 | + self.name = name | |
| 29 | + self.systemPrompt = systemPrompt | |
| 30 | + self.preferredModelID = preferredModelID | |
| 31 | + self.params = params | |
| 32 | + } | |
| 33 | +} | |
modified
docs/PLAN.md
+15 −1
@@ -48,7 +48,21 @@ via swift-transformers. 30-model catalog live-verified with real sizes. | ||
| 48 | 48 | compiles under the pinned recipe. Bundle carries mlx-swift_Cmlx.bundle (metallib), |
| 49 | 49 | Hub + Crypto resource bundles. `codesign -dv`: com.zyquo.local, arm64, adhoc. |
| 50 | 50 | Executable dispatches --poc and exits 64 as designed. |
| 51 | −## Phase 2 — Architecture + inference PoC — pending | |
| 51 | +## Phase 2 — Architecture + inference PoC — ✅ DONE (2026-07-30) | |
| 52 | + | |
| 53 | +- [x] Models/: Conversation, Message (+MessageStats), LocalModel, DownloadTask, Persona | |
| 54 | +- [x] Engine/GenerationParams.swift (Codable app params → GenerateParameters at engine boundary) | |
| 55 | +- [x] Engine/MemoryAdvisor.swift (verdicts 60 %/75 % of hw.memsize, live MLX.Memory readouts) | |
| 56 | +- [x] Engine/ChatSession.swift (history → chat template via MLX session, oldest-turn truncation keeping system prompt, KV reuse, per-call params without KV loss) | |
| 57 | +- [x] Engine/InferenceEngine.swift (actor; unloaded→loading→ready⇄generating; AsyncThrowingStream<GenerationEvent>; cancellation wired via task cancel + onTermination; unload → clearCache) | |
| 58 | +- [x] PoCRunner: `ZyquoLocal --poc <dir> "<prompt>"` | |
| 59 | +- [x] PHASE GATE: Qwen3-0.6B-4bit loaded from local dir, streamed to stdout — | |
| 60 | + 130.4 tok/s, TTFT 1.59 s, 329 tokens, peak 388 MB, stop reason `stop`. | |
| 61 | + | |
| 62 | +**Checkpoint:** Zero warnings. Engine layer fully owns inference; stats are | |
| 63 | +first-class; `<think>` content streams through (reasoning display feeds on it | |
| 64 | +in Phase 6). Multi-turn KV reuse + cancellation get their formal end-to-end | |
| 65 | +tests in the Phase 7 harness. | |
| 52 | 66 | ## Phase 3 — Hub browse & download — pending |
| 53 | 67 | ## Phase 4 — Design system & UI spec — pending |
| 54 | 68 | ## Phase 5 — App icon — pending |
| 55 | 69 | |