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%
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