SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
11.9 KB · 287 lines swift
Raw Blame History
1//2//  VerifyRunner.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation1011/// 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.14enum 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, qwen318        "mlx-community/Llama-3.2-1B-Instruct-4bit",      // tiny, llama19        "mlx-community/SmolLM3-3B-4bit",                 // 3B, smollm320        "mlx-community/gemma-3-4b-it-qat-4bit",          // 4B, gemma321        "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit",  // coding, qwen222        "mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit",  // reasoning distill23    ]2425    struct RunResult {26        var repoID: String27        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    }3738    @MainActor39    static func run(arguments: [String]) async {40        let keepAll = arguments.contains("--keep")41        var results: [RunResult] = []4243        let store = ModelStore()44        let hub = HubService(token: nil)45        let downloads = DownloadManager(hub: hub, store: store)4647        for repoID in testRepos {48            var result = RunResult(repoID: repoID)49            log("\n═══ \(repoID) ═══")5051            // 1 — download (or reuse)52            if store.model(for: repoID) == nil {53                log("downloading…")54                await downloads.download(repoID: repoID)55                var done = false56                var lastPercent = -157                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                        break62                    }63                    switch t.state {64                    case .completed:65                        done = true66                    case .failed:67                        result.note = t.errorDescription ?? "download failed"68                        done = true69                    default:70                        let percent = Int(t.fractionCompleted * 100)71                        if percent / 10 != lastPercent / 10 {72                            lastPercent = percent73                            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                continue84            }85            guard ModelStore.isValidModelDirectory(model.directory) else {86                result.download = "❌"87                result.note = "invalid directory after download"88                results.append(result)89                continue90            }91            result.download = "✅"92            log("  download ✅ (\(formatBytes(model.sizeBytes)), arch=\(model.architecture ?? "?"))")9394            // 2 — load95            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.localizedDescription103                results.append(result)104                log("  load ❌ \(error.localizedDescription)")105                continue106            }107108            // 3 — deterministic generation109            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?.timeToFirstToken125                    log("  generate ✅ “\(String(cleaned.prefix(60)))”")126                }127            } catch {128                result.generate = "❌"129                result.note = error.localizedDescription130                log("  generate ❌ \(error.localizedDescription)")131            }132133            // 4 — multi-turn context carry-over134            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?.tokensPerSecond148                    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.localizedDescription158            }159160            // 5 — streaming cancellation161            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 = 0168                let start = Date()169                var sawEnd = false170                let consumer = Task {171                    for try await event in stream {172                        if case .token = event {173                            tokens += 1174                            if tokens == 12 { break }  // cancels via onTermination175                        }176                    }177                }178                _ = try? await consumer.value179                await engine.stopGeneration()180                sawEnd = true181                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.localizedDescription191            }192193            // 6 — unload + memory release194            let before = MemoryAdvisor.activeMemoryBytes195            await engine.unload()196            try? await Task.sleep(for: .milliseconds(500))197            let after = MemoryAdvisor.activeMemoryBytes198            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            }206207            results.append(result)208        }209210        // 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        }217218        // ── 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_000225                let deviation = abs(gb - entry.sizeGB) / entry.sizeGB226                let sizeOK = deviation < 0.10227                catalogRows.append((228                    entry.repoID,229                    "✅ \(files.count) files",230                    sizeOK231                        ? 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        }241242        // ── Results table ───────────────────────────────────────────────────243        var table = """244            | Model | Download | Load | Generate | Multi-turn | Cancel | Unload | tok/s | TTFT |245            |---|---|---|---|---|---|---|---|---|246247            """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)252253        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    }264265    /// Collects one full generation, returning the raw text + final stats.266    @MainActor267    private static func collect(268        engine: InferenceEngine, prompt: String, params: GenerationParams269    ) 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 += t276            case .stats(let s): stats = s277            case .finished: break278            }279        }280        return (text, stats)281    }282283    private static func log(_ text: String) {284        FileHandle.standardError.write(Data((text + "\n").utf8))285    }286}287