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%
6.0 KB · 159 lines swift
Raw Blame History
1//2//  EvaluateView.swift3//  Zyquo MLX4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Evaluate section: base-vs-tuned side-by-side qualitative comparison with12/// per-side stats (held-out perplexity via the Python bridge uses the same13/// engine as `--test`; qualitative compare is the flagship workflow).14struct EvaluateView: View {15    @Bindable var model: AppModel16    @State private var controller = EvaluateController()1718    var body: some View {19        if model.models.count < 2 {20            EmptyStateView(21                icon: "checkmark.seal",22                title: "Prove Your Fine-Tune",23                message: "Once you have a base model and a fine-tuned (fused) variant, compare them side by side on the same prompt — with tokens/sec and TTFT for each.")24        } else {25            VStack(spacing: 0) {26                HStack(spacing: ZyquoTheme.spacing12) {27                    Picker("Base", selection: $controller.baseID) {28                        Text("Choose…").tag(String?.none)29                        ForEach(model.models.filter { $0.type == .llm }) {30                            Text($0.name).tag(String?.some($0.id))31                        }32                    }33                    Picker("Candidate", selection: $controller.candidateID) {34                        Text("Choose…").tag(String?.none)35                        ForEach(model.models.filter { $0.type == .llm }) {36                            Text($0.name).tag(String?.some($0.id))37                        }38                    }39                }40                .padding(ZyquoTheme.spacing12)4142                HStack(spacing: ZyquoTheme.spacing8) {43                    TextField("Prompt to compare on…", text: $controller.prompt)44                        .textFieldStyle(.roundedBorder)45                        .onSubmit { controller.run(models: model.models) }46                    Button(controller.isRunning ? "Running…" : "Compare") {47                        controller.run(models: model.models)48                    }49                    .buttonStyle(.borderedProminent)50                    .tint(ZyquoTheme.accent)51                    .disabled(controller.isRunning || controller.baseID == nil52                        || controller.candidateID == nil || controller.prompt.isEmpty)53                }54                .padding(.horizontal, ZyquoTheme.spacing12)55                .padding(.bottom, ZyquoTheme.spacing12)5657                Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)5859                HStack(alignment: .top, spacing: 0) {60                    ResultColumn(title: "Base", output: controller.baseOutput, stats: controller.baseStats)61                    Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline)62                    ResultColumn(title: "Candidate", output: controller.candidateOutput, stats: controller.candidateStats)63                }6465                if let error = controller.errorMessage {66                    Text(error)67                        .font(ZyquoTheme.captionFont)68                        .foregroundStyle(ZyquoTheme.danger)69                        .padding(ZyquoTheme.spacing8)70                }71            }72        }73    }74}7576private struct ResultColumn: View {77    let title: String78    let output: String79    let stats: InferenceStats?8081    var body: some View {82        VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {83            HStack {84                Text(title)85                    .font(ZyquoTheme.headlineFont)86                Spacer()87                if let stats {88                    Text(String(format: "%.1f tok/s · TTFT %.2fs", stats.tokensPerSecond, stats.ttft))89                        .font(ZyquoTheme.monoSmallFont)90                        .foregroundStyle(ZyquoTheme.chartThroughput)91                }92            }93            ScrollView {94                Text(output.isEmpty ? "—" : output)95                    .font(ZyquoTheme.bodyFont)96                    .foregroundStyle(ZyquoTheme.textPrimary)97                    .textSelection(.enabled)98                    .frame(maxWidth: .infinity, alignment: .leading)99            }100        }101        .padding(ZyquoTheme.spacing16)102        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)103    }104}105106@Observable107@MainActor108final class EvaluateController {109    var baseID: String?110    var candidateID: String?111    var prompt = "Explain what a LoRA adapter is in one sentence."112    var baseOutput = ""113    var candidateOutput = ""114    var baseStats: InferenceStats?115    var candidateStats: InferenceStats?116    var isRunning = false117    var errorMessage: String?118119    /// Sequential run (one model in memory at a time — verifiable unload120    /// between the two sides).121    func run(models: [LocalModel]) {122        guard let base = models.first(where: { $0.id == baseID }),123            let candidate = models.first(where: { $0.id == candidateID })124        else { return }125        isRunning = true126        errorMessage = nil127        baseOutput = ""128        candidateOutput = ""129130        Task {131            do {132                (baseOutput, baseStats) = try await generate(model: base)133                (candidateOutput, candidateStats) = try await generate(model: candidate)134            } catch {135                errorMessage = error.localizedDescription136            }137            _ = try? await InferenceEngine.shared.unload()138            isRunning = false139        }140    }141142    private func generate(model: LocalModel) async throws -> (String, InferenceStats?) {143        try await InferenceEngine.shared.load(model: model)144        var output = ""145        var stats: InferenceStats?146        var params = GenerationParams()147        params.maxTokens = 512148        let stream = try await InferenceEngine.shared.generate(149            messages: [.user(prompt)], params: params)150        for try await event in stream {151            switch event {152            case .chunk(let piece): output += piece153            case .finished(let s): stats = s154            }155        }156        return (output, stats)157    }158}159