SPB Git

spb/metrika Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

Swift 92.4% HTML 3.3% R 3% Shell 1.3%
4.9 KB · 146 lines swift
Raw Blame History
1//2//  SessionModel.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Observation11import SwiftUI12import ZQData13import ZQEngine14import ZQGraphics15import ZQPlugins1617/// Observable session state for the UI. Every command — typed in the18/// console or replayed from a do-file — goes through the one `ZQSession`19/// execution path (CLAUDE.md §7).20@Observable @MainActor21final class SessionModel {22    struct ConsoleEntry: Identifiable, Equatable {23        let id = UUID()24        let command: String25        let output: String26        let isError: Bool27    }2829    struct VariableInfo: Identifiable, Equatable {30        var id: String { name }31        let name: String32        let type: String33        let missingCount: Int34    }3536    private(set) var entries: [ConsoleEntry] = []37    private(set) var variables: [VariableInfo] = []38    private(set) var observationCount = 039    private(set) var lastPlot: ZQPlotSpec?40    private(set) var isRunning = false41    private(set) var history: [String] = []42    /// Snapshot of the working dataset for the data browser.43    private(set) var frame = try! ZQDataFrame()   // empty init cannot fail4445    private var session: ZQSession?4647    init() {48        do {49            // Built-in native plugins ship with the app; user .zyq script50            // commands are discovered from Application Support.51            session = try ZQSession(plugins: [ZScorePlugin()])52        } catch {53            entries.append(ConsoleEntry(54                command: "",55                output: "failed to start engine: \(error)",56                isError: true57            ))58        }59        #if DEBUG60        // XCUITest hook: the sandboxed app cannot read files staged by the61        // test runner, so the test passes CSV content via the environment62        // and the app materializes it inside its own container.63        if let csv = ProcessInfo.processInfo.environment["METRIKA_UITEST_CSV"] {64            let url = FileManager.default.temporaryDirectory65                .appendingPathComponent("uitest_smoke.csv")66            try? Data(csv.utf8).write(to: url)67            Task { await run("use \(url.path)") }68        }69        // Headless driving: semicolon-separated commands run at startup.70        if let script = ProcessInfo.processInfo.environment["METRIKA_AUTORUN"] {71            Task {72                for command in script.split(separator: ";") {73                    await run(command.trimmingCharacters(in: .whitespaces))74                }75            }76        }77        #endif78    }7980    func run(_ command: String) async {81        let line = command.trimmingCharacters(in: .whitespaces)82        guard !line.isEmpty, let session else { return }83        history.append(line)84        isRunning = true85        defer { isRunning = false }86        do {87            let result = try await session.execute(line)88            entries.append(ConsoleEntry(command: line, output: result.text, isError: false))89        } catch {90            entries.append(ConsoleEntry(command: line, output: "\(error)", isError: true))91        }92        await refreshDatasetState()93    }9495    private func refreshDatasetState() async {96        guard let session else { return }97        let frame = await session.frame98        self.frame = frame99        observationCount = frame.rowCount100        variables = frame.columns.map { column in101            VariableInfo(102                name: column.name,103                type: column.data.isNumeric ? "float64" : "string",104                missingCount: column.data.missingCount105            )106        }107        lastPlot = await session.lastPlot108    }109110    struct FilterError: Error, CustomStringConvertible {111        let description: String112    }113114    /// Filter-bar support: compiles text to an `if` mask over the current115    /// dataset. Fails with the parser's error message.116    func filterMask(_ expression: String) async -> Result<[Bool], FilterError> {117        guard let session else { return .failure(FilterError(description: "engine not running")) }118        do {119            return .success(try await session.conditionMask(expression))120        } catch {121            return .failure(FilterError(description: "\(error)"))122        }123    }124125    /// Do-file support: runs a whole script through the shared execution126    /// path, appending its output to the console.127    func runScript(_ text: String) async {128        guard let session else { return }129        isRunning = true130        defer { isRunning = false }131        do {132            let result = try await session.executeScript(text)133            entries.append(ConsoleEntry(134                command: "do-file (\(text.split(separator: "\n").count) lines)",135                output: result.text,136                isError: false137            ))138        } catch {139            entries.append(ConsoleEntry(140                command: "do-file", output: "\(error)", isError: true141            ))142        }143        await refreshDatasetState()144    }145}146