|
1 |
+// |
|
2 |
+// VerifyRunner.swift |
|
3 |
+// Zyquo Local |
|
4 |
+// |
|
5 |
+// Author: Simon-Pierre Boucher |
|
6 |
+// Mail: contact@spboucher.ai |
|
7 |
+// |
|
8 |
+ |
|
9 |
+import Foundation |
|
10 |
+ |
|
11 |
+/// Phase 7 verification harness: `ZyquoLocal --verify [--keep]`. |
|
12 |
+/// Proves downloading and running real models works end-to-end on this Mac, |
|
13 |
+/// and dry-verifies the entire Featured catalog against the live Hub. |
|
14 |
+enum VerifyRunner { |
|
15 |
+ /// Models under test: span architectures and sizes that fit this Mac. |
|
16 |
+ private static let testRepos = [ |
|
17 |
+ "mlx-community/Qwen3-0.6B-4bit", // tiny, qwen3 |
|
18 |
+ "mlx-community/Llama-3.2-1B-Instruct-4bit", // tiny, llama |
|
19 |
+ "mlx-community/SmolLM3-3B-4bit", // 3B, smollm3 |
|
20 |
+ "mlx-community/gemma-3-4b-it-qat-4bit", // 4B, gemma3 |
|
21 |
+ "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", // coding, qwen2 |
|
22 |
+ "mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit", // reasoning distill |
|
23 |
+ ] |
|
24 |
+ |
|
25 |
+ struct RunResult { |
|
26 |
+ var repoID: String |
|
27 |
+ var download = "–" |
|
28 |
+ var load = "–" |
|
29 |
+ var generate = "–" |
|
30 |
+ var multiTurn = "–" |
|
31 |
+ var cancel = "–" |
|
32 |
+ var unload = "–" |
|
33 |
+ var tokensPerSecond: Double? |
|
34 |
+ var ttft: TimeInterval? |
|
35 |
+ var note = "" |
|
36 |
+ } |
|
37 |
+ |
|
38 |
+ @MainActor |
|
39 |
+ static func run(arguments: [String]) async { |
|
40 |
+ let keepAll = arguments.contains("--keep") |
|
41 |
+ var results: [RunResult] = [] |
|
42 |
+ |
|
43 |
+ let store = ModelStore() |
|
44 |
+ let hub = HubService(token: nil) |
|
45 |
+ let downloads = DownloadManager(hub: hub, store: store) |
|
46 |
+ |
|
47 |
+ for repoID in testRepos { |
|
48 |
+ var result = RunResult(repoID: repoID) |
|
49 |
+ log("\n═══ \(repoID) ═══") |
|
50 |
+ |
|
51 |
+ // 1 — download (or reuse) |
|
52 |
+ if store.model(for: repoID) == nil { |
|
53 |
+ log("downloading…") |
|
54 |
+ await downloads.download(repoID: repoID) |
|
55 |
+ var done = false |
|
56 |
+ var lastPercent = -1 |
|
57 |
+ while !done { |
|
58 |
+ try? await Task.sleep(for: .milliseconds(500)) |
|
59 |
+ guard let t = downloads.task(for: repoID) else { |
|
60 |
+ result.note = "download task vanished" |
|
61 |
+ break |
|
62 |
+ } |
|
63 |
+ switch t.state { |
|
64 |
+ case .completed: |
|
65 |
+ done = true |
|
66 |
+ case .failed: |
|
67 |
+ result.note = t.errorDescription ?? "download failed" |
|
68 |
+ done = true |
|
69 |
+ default: |
|
70 |
+ let percent = Int(t.fractionCompleted * 100) |
|
71 |
+ if percent / 10 != lastPercent / 10 { |
|
72 |
+ lastPercent = percent |
|
73 |
+ log(" \(percent)% \(downloads.speeds[repoID].map(formatSpeed) ?? "")") |
|
74 |
+ } |
|
75 |
+ } |
|
76 |
+ } |
|
77 |
+ } |
|
78 |
+ store.rescan() |
|
79 |
+ guard let model = store.model(for: repoID) else { |
|
80 |
+ result.download = "❌" |
|
81 |
+ results.append(result) |
|
82 |
+ log(" download ❌ \(result.note)") |
|
83 |
+ continue |
|
84 |
+ } |
|
85 |
+ guard ModelStore.isValidModelDirectory(model.directory) else { |
|
86 |
+ result.download = "❌" |
|
87 |
+ result.note = "invalid directory after download" |
|
88 |
+ results.append(result) |
|
89 |
+ continue |
|
90 |
+ } |
|
91 |
+ result.download = "✅" |
|
92 |
+ log(" download ✅ (\(formatBytes(model.sizeBytes)), arch=\(model.architecture ?? "?"))") |
|
93 |
+ |
|
94 |
+ // 2 — load |
|
95 |
+ let engine = InferenceEngine() |
|
96 |
+ do { |
|
97 |
+ try await engine.load(model: model) |
|
98 |
+ result.load = "✅" |
|
99 |
+ log(" load ✅") |
|
100 |
+ } catch { |
|
101 |
+ result.load = "❌" |
|
102 |
+ result.note = error.localizedDescription |
|
103 |
+ results.append(result) |
|
104 |
+ log(" load ❌ \(error.localizedDescription)") |
|
105 |
+ continue |
|
106 |
+ } |
|
107 |
+ |
|
108 |
+ // 3 — deterministic generation |
|
109 |
+ do { |
|
110 |
+ let conversation = Conversation( |
|
111 |
+ params: GenerationParams(temperature: 0, maxTokens: 600)) |
|
112 |
+ try await engine.startSession(conversation: conversation) |
|
113 |
+ let (text, stats) = try await collect( |
|
114 |
+ engine: engine, prompt: "Reply with exactly: OK", |
|
115 |
+ params: conversation.params) |
|
116 |
+ var parser = ThinkTagParser() |
|
117 |
+ let (visible, _, _) = parser.consume(text) |
|
118 |
+ let cleaned = visible.trimmingCharacters(in: .whitespacesAndNewlines) |
|
119 |
+ if cleaned.isEmpty { |
|
120 |
+ result.generate = "❌" |
|
121 |
+ result.note = "empty output" |
|
122 |
+ } else { |
|
123 |
+ result.generate = "✅" |
|
124 |
+ result.ttft = stats?.timeToFirstToken |
|
125 |
+ log(" generate ✅ “\(String(cleaned.prefix(60)))”") |
|
126 |
+ } |
|
127 |
+ } catch { |
|
128 |
+ result.generate = "❌" |
|
129 |
+ result.note = error.localizedDescription |
|
130 |
+ log(" generate ❌ \(error.localizedDescription)") |
|
131 |
+ } |
|
132 |
+ |
|
133 |
+ // 4 — multi-turn context carry-over |
|
134 |
+ do { |
|
135 |
+ let conversation = Conversation( |
|
136 |
+ params: GenerationParams(temperature: 0, maxTokens: 800)) |
|
137 |
+ try await engine.startSession(conversation: conversation) |
|
138 |
+ _ = try await collect( |
|
139 |
+ engine: engine, |
|
140 |
+ prompt: "My favorite color is vermilion. Just say: noted.", |
|
141 |
+ params: conversation.params) |
|
142 |
+ let (answer, stats) = try await collect( |
|
143 |
+ engine: engine, prompt: "What is my favorite color? Answer in one word.", |
|
144 |
+ params: conversation.params) |
|
145 |
+ if answer.localizedCaseInsensitiveContains("vermilion") { |
|
146 |
+ result.multiTurn = "✅" |
|
147 |
+ result.tokensPerSecond = stats?.tokensPerSecond |
|
148 |
+ log(" multi-turn ✅") |
|
149 |
+ } else { |
|
150 |
+ result.multiTurn = "❌" |
|
151 |
+ result.note = "no context carry-over: “\(String(answer.suffix(80)))”" |
|
152 |
+ log(" multi-turn ❌ \(result.note)") |
|
153 |
+ } |
|
154 |
+ if result.tokensPerSecond == nil { result.tokensPerSecond = stats?.tokensPerSecond } |
|
155 |
+ } catch { |
|
156 |
+ result.multiTurn = "❌" |
|
157 |
+ result.note = error.localizedDescription |
|
158 |
+ } |
|
159 |
+ |
|
160 |
+ // 5 — streaming cancellation |
|
161 |
+ do { |
|
162 |
+ let conversation = Conversation(params: GenerationParams(temperature: 0.7)) |
|
163 |
+ try await engine.startSession(conversation: conversation) |
|
164 |
+ let stream = try await engine.generate( |
|
165 |
+ prompt: "Write a very long story about the ocean.", |
|
166 |
+ params: conversation.params) |
|
167 |
+ var tokens = 0 |
|
168 |
+ let start = Date() |
|
169 |
+ var sawEnd = false |
|
170 |
+ let consumer = Task { |
|
171 |
+ for try await event in stream { |
|
172 |
+ if case .token = event { |
|
173 |
+ tokens += 1 |
|
174 |
+ if tokens == 12 { break } // cancels via onTermination |
|
175 |
+ } |
|
176 |
+ } |
|
177 |
+ } |
|
178 |
+ _ = try? await consumer.value |
|
179 |
+ await engine.stopGeneration() |
|
180 |
+ sawEnd = true |
|
181 |
+ let elapsed = Date().timeIntervalSince(start) |
|
182 |
+ if sawEnd && elapsed < 30 { |
|
183 |
+ result.cancel = "✅" |
|
184 |
+ log(" cancel ✅ (stopped after \(tokens) tokens, \(String(format: "%.1f", elapsed))s)") |
|
185 |
+ } else { |
|
186 |
+ result.cancel = "❌" |
|
187 |
+ } |
|
188 |
+ } catch { |
|
189 |
+ result.cancel = "❌" |
|
190 |
+ result.note = error.localizedDescription |
|
191 |
+ } |
|
192 |
+ |
|
193 |
+ // 6 — unload + memory release |
|
194 |
+ let before = MemoryAdvisor.activeMemoryBytes |
|
195 |
+ await engine.unload() |
|
196 |
+ try? await Task.sleep(for: .milliseconds(500)) |
|
197 |
+ let after = MemoryAdvisor.activeMemoryBytes |
|
198 |
+ if after < max(200_000_000, before / 4) { |
|
199 |
+ result.unload = "✅" |
|
200 |
+ log(" unload ✅ (\(formatBytes(Int64(before))) → \(formatBytes(Int64(after))))") |
|
201 |
+ } else { |
|
202 |
+ result.unload = "❌" |
|
203 |
+ result.note += " memory not released (\(formatBytes(Int64(after))))" |
|
204 |
+ log(" unload ❌ (\(formatBytes(Int64(before))) → \(formatBytes(Int64(after))))") |
|
205 |
+ } |
|
206 |
+ |
|
207 |
+ results.append(result) |
|
208 |
+ } |
|
209 |
+ |
|
210 |
+ // Reclaim disk: keep the smallest model for ongoing dev. |
|
211 |
+ if !keepAll { |
|
212 |
+ for repoID in testRepos.dropFirst() where store.model(for: repoID) != nil { |
|
213 |
+ store.delete(repoID: repoID) |
|
214 |
+ log("deleted \(repoID) to reclaim disk") |
|
215 |
+ } |
|
216 |
+ } |
|
217 |
+ |
|
218 |
+ // ── Catalog dry-verification ──────────────────────────────────────── |
|
219 |
+ log("\n═══ Featured catalog dry-verification (30 repos) ═══") |
|
220 |
+ var catalogRows: [(String, String, String)] = [] |
|
221 |
+ for entry in ModelCatalog.featured { |
|
222 |
+ do { |
|
223 |
+ let (files, total) = try await hub.requiredFiles(of: entry.repoID) |
|
224 |
+ let gb = Double(total) / 1_000_000_000 |
|
225 |
+ let deviation = abs(gb - entry.sizeGB) / entry.sizeGB |
|
226 |
+ let sizeOK = deviation < 0.10 |
|
227 |
+ catalogRows.append(( |
|
228 |
+ entry.repoID, |
|
229 |
+ "✅ \(files.count) files", |
|
230 |
+ sizeOK |
|
231 |
+ ? String(format: "✅ %.2f GB", gb) |
|
232 |
+ : String(format: "⚠️ %.2f GB (catalog says %.2f)", gb, entry.sizeGB) |
|
233 |
+ )) |
|
234 |
+ } catch { |
|
235 |
+ catalogRows.append((entry.repoID, "❌ \(error.localizedDescription)", "–")) |
|
236 |
+ } |
|
237 |
+ } |
|
238 |
+ for row in catalogRows { |
|
239 |
+ log(" \(row.0): \(row.1) · \(row.2)") |
|
240 |
+ } |
|
241 |
+ |
|
242 |
+ // ── Results table ─────────────────────────────────────────────────── |
|
243 |
+ var table = """ |
|
244 |
+ | Model | Download | Load | Generate | Multi-turn | Cancel | Unload | tok/s | TTFT | |
|
245 |
+ |---|---|---|---|---|---|---|---|---| |
|
246 |
+ |
|
247 |
+ """ |
|
248 |
+ for r in results { |
|
249 |
+ table += "| \(shortModelName(r.repoID)) | \(r.download) | \(r.load) | \(r.generate) | \(r.multiTurn) | \(r.cancel) | \(r.unload) | \(r.tokensPerSecond.map { String(format: "%.1f", $0) } ?? "–") | \(r.ttft.map { String(format: "%.2fs", $0) } ?? "–") |\n" |
|
250 |
+ } |
|
251 |
+ log("\n" + table) |
|
252 |
+ |
|
253 |
+ let allGreen = results.allSatisfy { |
|
254 |
+ $0.download == "✅" && $0.load == "✅" && $0.generate == "✅" |
|
255 |
+ && $0.multiTurn == "✅" && $0.cancel == "✅" && $0.unload == "✅" |
|
256 |
+ } |
|
257 |
+ let catalogGreen = catalogRows.allSatisfy { $0.1.hasPrefix("✅") } |
|
258 |
+ log(allGreen && catalogGreen ? "\nVERIFY: ALL GREEN" : "\nVERIFY: FAILURES PRESENT") |
|
259 |
+ for r in results where !r.note.isEmpty { |
|
260 |
+ log(" note[\(shortModelName(r.repoID))]: \(r.note)") |
|
261 |
+ } |
|
262 |
+ exit(allGreen && catalogGreen ? 0 : 1) |
|
263 |
+ } |
|
264 |
+ |
|
265 |
+ /// Collects one full generation, returning the raw text + final stats. |
|
266 |
+ @MainActor |
|
267 |
+ private static func collect( |
|
268 |
+ engine: InferenceEngine, prompt: String, params: GenerationParams |
|
269 |
+ ) async throws -> (String, GenerationStats?) { |
|
270 |
+ var text = "" |
|
271 |
+ var stats: GenerationStats? |
|
272 |
+ let stream = try await engine.generate(prompt: prompt, params: params) |
|
273 |
+ for try await event in stream { |
|
274 |
+ switch event { |
|
275 |
+ case .token(let t): text += t |
|
276 |
+ case .stats(let s): stats = s |
|
277 |
+ case .finished: break |
|
278 |
+ } |
|
279 |
+ } |
|
280 |
+ return (text, stats) |
|
281 |
+ } |
|
282 |
+ |
|
283 |
+ private static func log(_ text: String) { |
|
284 |
+ FileHandle.standardError.write(Data((text + "\n").utf8)) |
|
285 |
+ } |
|
286 |
+} |