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%
110.4 KB · 2,620 lines swift
Raw Blame History
1//2//  Session.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation11import ZQData12import ZQGPU13import ZQGraphics14import ZQParser15import ZQPlanner16import ZQPlugins17import ZQStats1819public struct ZQEngineError: Error, Equatable, Sendable, CustomStringConvertible {20    public var message: String21    public init(_ message: String) { self.message = message }22    public var description: String { message }23}2425/// One interactive session: working dataset, RNG state, panel settings,26/// logging. Every command mutation — console, do-file, or plugin — goes27/// through `execute(_:)` so all frontends share one execution path28/// (CLAUDE.md §7).29public actor ZQSession {30    public private(set) var frame: ZQDataFrame31    public private(set) var lastPlot: ZQPlotSpec?32    public private(set) var seed: UInt64 = 123_456_78933    public private(set) var panelVariable: String?34    public private(set) var timeVariable: String?3536    private let store: ZQDataStore37    private let parser: ZQCommandParser38    private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable)39    private let scriptCommands: [String: ZQScriptCommand]40    private let pluginRegistry: ZQPluginRegistry41    private var logFileURL: URL?42    private var scriptDepth = 043    private var lastEstimation: EstimationState?4445    /// - Parameters:46    ///   - discoverUserCommands: scan the commands directory for `.zyq`47    ///     script commands.48    ///   - commandsDirectory: override of the discovery location (tests).49    ///   - plugins: SPM-compiled-in native plugins to register. A plugin50    ///     whose verb shadows a built-in command is rejected.51    public init(52        discoverUserCommands: Bool = true,53        commandsDirectory: URL? = nil,54        plugins: [any ZQCommandPlugin] = []55    ) throws {56        self.store = try ZQDataStore()57        self.frame = try ZQDataFrame()5859        var commands: [String: ZQScriptCommand] = [:]60        if discoverUserCommands {61            let scripts = commandsDirectory.map {62                ZQPluginDiscovery.scriptCommands(in: $0)63            } ?? ZQPluginDiscovery.scriptCommands()64            for script in scripts {65                commands[script.verb] = script66            }67        }68        self.scriptCommands = commands6970        let builtinVerbs = Set(ZQVerbTable.builtin.verbs.keys)71        self.pluginRegistry = try ZQPluginRegistry(72            plugins: plugins, reservedVerbs: builtinVerbs73        )7475        // Plugin verbs join the grammar so their commands parse with full76        // varlist/if/in/options structure (no abbreviations for plugins).77        var verbs = ZQVerbTable.builtin.verbs78        for verb in pluginRegistry.verbs {79            verbs[verb] = verb.count80        }81        self.parser = ZQCommandParser(verbTable: ZQVerbTable(82            verbs: verbs,83            fileVerbs: ZQVerbTable.builtin.fileVerbs,84            assignmentVerbs: ZQVerbTable.builtin.assignmentVerbs,85            prefixVerbs: ZQVerbTable.builtin.prefixVerbs,86            compoundVerbs: ZQVerbTable.builtin.compoundVerbs87        ))88    }8990    // MARK: - Entry point9192    public func execute(_ line: String) async throws -> ZQResult {93        let result: ZQResult94        do {95            result = try await run(line)96        } catch {97            try? appendToLog(command: line, output: "error: \(error)")98            throw error99        }100        try? appendToLog(command: line, output: result.text)101        return result102    }103104    /// Executes a do-file: runs each line, concatenating output. Stops at105    /// the first error, reporting the failing line number.106    public func executeScript(_ text: String) async throws -> ZQResult {107        var outputs: [String] = []108        var scalars: [String: Double] = [:]109        for (index, rawLine) in text.split(110            separator: "\n", omittingEmptySubsequences: false111        ).enumerated() {112            let line = String(rawLine)113            do {114                let result = try await execute(line)115                if !result.text.isEmpty {116                    outputs.append(". \(line)\n\(result.text)")117                }118                scalars.merge(result.scalars) { _, new in new }119            } catch {120                throw ZQEngineError("line \(index + 1): \(error)")121            }122        }123        return ZQResult(text: outputs.joined(separator: "\n\n"), scalars: scalars)124    }125126    private func run(_ line: String) async throws -> ZQResult {127        // Script commands are routed BEFORE the parser: their verbs are128        // not in the grammar and their arguments are raw macro text.129        let trimmed = line.trimmingCharacters(in: .whitespaces)130        let verbWord = String(trimmed.prefix { !$0.isWhitespace })131        if let script = scriptCommands[verbWord] {132            guard scriptDepth < 8 else {133                throw ZQEngineError("user command recursion too deep")134            }135            scriptDepth += 1136            defer { scriptDepth -= 1 }137            let arguments = String(trimmed.dropFirst(verbWord.count))138                .trimmingCharacters(in: .whitespaces)139            let body = script.expandedLines(arguments: arguments)140                .joined(separator: "\n")141            return try await executeScript(body)142        }143144        guard let command = try parser.parse(line) else {145            return ZQResult(text: "")146        }147148        if let registered = pluginRegistry[command.verb] {149            return try await executePlugin(registered, command: command)150        }151152        let plan = planner.plan(command, rowCount: frame.rowCount)153        return try await dispatch(plan.command, backend: plan.backend)154    }155156    /// Runs a native plugin: validates the parsed command against its157    /// declared syntax, hands it a value copy of the dataset, and installs158    /// a returned replacement frame only when `mutates: true`.159    private func executePlugin(160        _ registered: ZQPluginRegistry.Registered, command: ZQCommand161    ) async throws -> ZQResult {162        if let violation = pluginRegistry.validate(command, against: registered.syntax) {163            throw ZQEngineError("\(registered.verb): \(violation)")164        }165        let context = ZQContext(command: command, frame: frame)166        var result = try await registered.plugin.execute(context)167        if let replacement = result.replacementFrame {168            guard registered.syntax.mutates else {169                throw ZQEngineError(170                    "\(registered.verb): plugin returned a dataset but does not declare 'mutates'"171                )172            }173            frame = replacement174            result.replacementFrame = nil175        }176        return result177    }178179    private func dispatch(180        _ command: ZQCommand, backend: ZQBackend = .cpu181    ) async throws -> ZQResult {182        switch command.verb {183        case "use": return try await handleUse(command)184        case "sysuse": return try await handleSysuse(command)185        case "save": return try await handleSave(command)186        case "clear": return handleClear()187        case "describe": return handleDescribe()188        case "summarize": return try handleSummarize(command)189        case "generate": return try handleGenerate(command, replace: false)190        case "replace": return try handleGenerate(command, replace: true)191        case "drop": return try handleDropKeep(command, keep: false)192        case "keep": return try handleDropKeep(command, keep: true)193        case "count": return try handleCount(command)194        case "list": return try handleList(command)195        case "regress": return try handleRegress(command)196        case "xtreg": return try handleXTReg(command)197        case "ivregress": return try handleIVRegress(command)198        case "predict": return try handlePredict(command)199        case "margins": return try handleMargins(command)200        case "elasticnet": return try handleElasticNet(command, defaultAlpha: nil)201        case "boost": return try handleBoost(command)202        case "lasso": return try handleElasticNet(command, defaultAlpha: 1)203        case "logit": return try handleGLM(command, family: .logit)204        case "probit": return try handleGLM(command, family: .probit)205        case "poisson": return try handleGLM(command, family: .poisson)206        case "tabulate": return try handleTabulate(command)207        case "correlate": return try handleCorrelate(command)208        case "set": return try handleSet(command)209        case "xtset": return try handleXTSet(command)210        case "display": return try handleDisplay(command)211        case "bootstrap": return try await handleBootstrap(command, backend: backend)212        case "permute": return try await handlePermute(command)213        case "bayes": return try handleBayes(command)214        case "graph": return try handleGraph(command)215        case "histogram", "scatter", "kdensity":216            var promoted = command217            promoted.subverb = command.verb218            promoted.verb = "graph"219            return try handleGraph(promoted)220        case "log": return try handleLog(command)221        case "help": return handleHelp(command)222        default:223            throw ZQEngineError("command '\(command.verb)' is not implemented yet")224        }225    }226227    // MARK: - Data commands228229    private func handleUse(_ command: ZQCommand) async throws -> ZQResult {230        guard let path = command.argument else {231            throw ZQEngineError("use: file name required")232        }233        let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)234        frame = try await store.load(contentsOf: url)235        return ZQResult(236            text: "(\(frame.rowCount) observations, \(frame.columns.count) variables loaded from \(url.lastPathComponent))",237            scalars: ["N": Double(frame.rowCount), "k": Double(frame.columns.count)]238        )239    }240241    /// `sysuse name` — load a bundled sample dataset; bare `sysuse` lists242    /// what ships with the app.243    private func handleSysuse(_ command: ZQCommand) async throws -> ZQResult {244        let samples = ["sales", "mtcars"]245        guard let name = command.argument else {246            return ZQResult(247                text: "Sample datasets: \(samples.joined(separator: ", ")) — load one with 'sysuse <name>'"248            )249        }250        let stem = name.hasSuffix(".csv") ? String(name.dropLast(4)) : name251        guard samples.contains(stem),252              let url = Bundle.module.url(253                forResource: stem, withExtension: "csv", subdirectory: "Samples"254              ) else {255            throw ZQEngineError(256                "sysuse: unknown sample '\(name)' — available: \(samples.joined(separator: ", "))"257            )258        }259        frame = try await store.load(contentsOf: url)260        lastEstimation = nil261        return ZQResult(262            text: "(\(frame.rowCount) observations, \(frame.columns.count) variables — sample dataset '\(stem)')",263            scalars: ["N": Double(frame.rowCount), "k": Double(frame.columns.count)]264        )265    }266267    private func handleSave(_ command: ZQCommand) async throws -> ZQResult {268        guard let path = command.argument else {269            throw ZQEngineError("save: file name required")270        }271        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }272        let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)273        if FileManager.default.fileExists(atPath: url.path),274           !command.hasOption("replace") {275            throw ZQEngineError("file \(url.lastPathComponent) already exists — add ', replace' to overwrite")276        }277        try await store.save(frame, to: url)278        return ZQResult(text: "file \(url.lastPathComponent) saved")279    }280281    private func handleClear() -> ZQResult {282        frame = try! ZQDataFrame()283        panelVariable = nil284        timeVariable = nil285        return ZQResult(text: "")286    }287288    private func handleDescribe() -> ZQResult {289        guard !frame.isEmpty else {290            return ZQResult(text: "Contains data: 0 observations")291        }292        var lines = [293            "Contains data: \(frame.rowCount) observations, \(frame.columns.count) variables",294            "",295            TableFormatter.pad("Variable", 12) + "   " +296            TableFormatter.pad("Type", 8, right: false) + "  Missing",297            String(repeating: "-", count: 34),298        ]299        for column in frame.columns {300            let type = column.data.isNumeric ? "float64" : "string"301            lines.append(302                TableFormatter.pad(column.name, 12) + "   " +303                TableFormatter.pad(type, 8, right: false) + "  " +304                TableFormatter.pad("\(column.data.missingCount)", 7)305            )306        }307        return ZQResult(308            text: lines.joined(separator: "\n"),309            scalars: ["N": Double(frame.rowCount), "k": Double(frame.columns.count)]310        )311    }312313    private func handleSummarize(_ command: ZQCommand) throws -> ZQResult {314        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }315        let mask = try observationMask(command)316        let detail = command.hasOption("detail")317318        var names: [String] = command.varlist.flatMap(\.referencedNames)319        if names.isEmpty {320            names = frame.columns.filter(\.data.isNumeric).map(\.name)321        }322323        var lines = [324            TableFormatter.pad("Variable", 12) + " | " +325            TableFormatter.pad("Obs", 9) + "  " +326            TableFormatter.pad("Mean", 11) + "  " +327            TableFormatter.pad("Std. dev.", 11) + "  " +328            TableFormatter.pad("Min", 10) + "  " +329            TableFormatter.pad("Max", 10),330            String(repeating: "-", count: 13) + "+" + String(repeating: "-", count: 62),331        ]332        var scalars: [String: Double] = [:]333334        for name in names {335            let (values, missing) = try frame.requireNumeric(name)336            let restrictedMissing = (0..<values.count).map { missing[$0] || !mask[$0] }337            let summary = ZQSummarize.summary(338                name: name, values: values, missing: restrictedMissing, detail: detail339            )340            if let summary {341                lines.append(342                    TableFormatter.pad(name, 12) + " | " +343                    TableFormatter.pad("\(summary.observationCount)", 9) + "  " +344                    TableFormatter.pad(TableFormatter.general(summary.mean), 11) + "  " +345                    TableFormatter.pad(TableFormatter.general(summary.standardDeviation), 11) + "  " +346                    TableFormatter.pad(TableFormatter.general(summary.minimum), 10) + "  " +347                    TableFormatter.pad(TableFormatter.general(summary.maximum), 10)348                )349                if let detailBlock = summary.detail {350                    for p in [1, 5, 10, 25, 50, 75, 90, 95, 99] {351                        lines.append(352                            TableFormatter.pad("p\(p)", 16) + "  " +353                            TableFormatter.pad(354                                TableFormatter.general(detailBlock.percentiles[p] ?? .nan), 11355                            )356                        )357                    }358                    lines.append(359                        TableFormatter.pad("skewness", 16) + "  " +360                        TableFormatter.pad(TableFormatter.general(detailBlock.skewness), 11)361                    )362                    lines.append(363                        TableFormatter.pad("kurtosis", 16) + "  " +364                        TableFormatter.pad(TableFormatter.general(detailBlock.kurtosis), 11)365                    )366                }367                scalars["N"] = Double(summary.observationCount)368                scalars["mean"] = summary.mean369                scalars["sd"] = summary.standardDeviation370                scalars["min"] = summary.minimum371                scalars["max"] = summary.maximum372                scalars["Var"] = summary.variance373            } else {374                lines.append(375                    TableFormatter.pad(name, 12) + " | " +376                    TableFormatter.pad("0", 9)377                )378            }379        }380        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)381    }382383    private func handleGenerate(_ command: ZQCommand, replace: Bool) throws -> ZQResult {384        guard let assignment = command.assignment else {385            throw ZQEngineError("\(replace ? "replace" : "generate"): syntax is 'newvar = expression'")386        }387        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }388389        let evaluator = ExpressionEvaluator(frame: frame)390        var (values, missing) = try evaluator.evaluateNumericColumn(assignment.expression)391        let mask = try observationMask(command)392393        if replace {394            let (oldValues, oldMissing) = try frame.requireNumeric(assignment.target)395            var changed = 0396            for i in 0..<frame.rowCount {397                if mask[i] {398                    let differs = oldMissing[i] != missing[i]399                        || (!missing[i] && oldValues[i] != values[i])400                    if differs { changed += 1 }401                } else {402                    values[i] = oldValues[i]403                    missing[i] = oldMissing[i]404                }405            }406            try frame.replaceColumn(ZQColumn(407                name: assignment.target,408                data: .float64(values: values, missing: missing)409            ))410            return ZQResult(text: "(\(changed) real changes made)")411        } else {412            var missingCount = 0413            for i in 0..<frame.rowCount where !mask[i] {414                values[i] = .nan415                missing[i] = true416            }417            for flag in missing where flag { missingCount += 1 }418            try frame.addColumn(ZQColumn(419                name: assignment.target,420                data: .float64(values: values, missing: missing)421            ))422            let note = missingCount > 0 ? "(\(missingCount) missing values generated)" : ""423            return ZQResult(text: note)424        }425    }426427    private func handleDropKeep(_ command: ZQCommand, keep: Bool) throws -> ZQResult {428        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }429430        // Observation form: `drop if expr` / `keep if expr` / `keep in 1/10`.431        if command.varlist.isEmpty, command.condition != nil || command.range != nil {432            let mask = try observationMask(command)433            let keepMask = keep ? mask : mask.map { !$0 }434            let before = frame.rowCount435            frame = try frame.filtered(by: keepMask)436            return ZQResult(text: "(\(before - frame.rowCount) observations deleted)")437        }438439        // Variable form: `drop x y` / `keep x y`.440        let names = command.varlist.flatMap(\.referencedNames)441        guard !names.isEmpty else {442            throw ZQEngineError("\(keep ? "keep" : "drop"): specify variables or an 'if' condition")443        }444        if keep {445            let dropped = frame.columnNames.filter { !names.contains($0) }446            try frame.dropColumns(dropped)447        } else {448            try frame.dropColumns(names)449        }450        return ZQResult(text: "")451    }452453    private func handleCount(_ command: ZQCommand) throws -> ZQResult {454        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }455        let mask = try observationMask(command)456        let count = mask.count { $0 }457        return ZQResult(text: "  \(count)", scalars: ["N": Double(count)])458    }459460    private func handleList(_ command: ZQCommand) throws -> ZQResult {461        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }462        let mask = try observationMask(command)463        var names = command.varlist.flatMap(\.referencedNames)464        if names.isEmpty { names = frame.columnNames }465        let columns = try names.map { try frame.requireColumn($0) }466467        let rows = (0..<frame.rowCount).filter { mask[$0] }.prefix(50)468        var lines = [names.map { TableFormatter.pad($0, 12) }.joined(separator: "  ")]469        for row in rows {470            let cells = columns.map { column -> String in471                switch column.data {472                case .float64(let values, let missing):473                    return TableFormatter.pad(474                        missing[row] ? "." : TableFormatter.general(values[row]), 12475                    )476                case .string(let values):477                    return TableFormatter.pad(values[row] ?? "", 12)478                }479            }480            lines.append(cells.joined(separator: "  "))481        }482        return ZQResult(text: lines.joined(separator: "\n"))483    }484485    // MARK: - Settings486487    private func handleSet(_ command: ZQCommand) throws -> ZQResult {488        switch command.subverb {489        case "seed":490            guard let text = command.argument, let value = UInt64(text) else {491                throw ZQEngineError("set seed: integer seed required")492            }493            seed = value494            return ZQResult(text: "")495        default:496            throw ZQEngineError("set: unknown setting '\(command.subverb ?? "")'")497        }498    }499500    private func handleXTSet(_ command: ZQCommand) throws -> ZQResult {501        let names = command.varlist.flatMap(\.referencedNames)502        guard (1...2).contains(names.count) else {503            throw ZQEngineError("xtset: syntax is 'xtset panelvar [timevar]'")504        }505        for name in names {506            _ = try frame.requireColumn(name)507        }508        panelVariable = names[0]509        timeVariable = names.count > 1 ? names[1] : nil510        var text = "Panel variable: \(names[0])"511        if let timeVariable { text += "\nTime variable: \(timeVariable)" }512        return ZQResult(text: text)513    }514515    private func handleDisplay(_ command: ZQCommand) throws -> ZQResult {516        guard let assignment = command.assignment else {517            throw ZQEngineError("display: expression required")518        }519        let evaluator = ExpressionEvaluator(frame: frame)520        switch try evaluator.evaluate(assignment.expression) {521        case .numericScalar(let value):522            return ZQResult(text: TableFormatter.general(value, significant: 10))523        case .stringScalar(let value):524            return ZQResult(text: value)525        case .missingScalar:526            return ZQResult(text: ".")527        case .numeric(let values, let missing):528            guard let first = values.first else { return ZQResult(text: ".") }529            return ZQResult(530                text: missing[0] ? "." : TableFormatter.general(first, significant: 10)531            )532        case .strings(let values):533            return ZQResult(text: values.first.flatMap { $0 } ?? "")534        }535    }536537    private func handleLog(_ command: ZQCommand) throws -> ZQResult {538        if command.argument == "close" || command.subverb == "close" {539            logFileURL = nil540            return ZQResult(text: "(log closed)")541        }542        guard var path = command.argument else {543            throw ZQEngineError("log: syntax is 'log using filename' or 'log close'")544        }545        if path == "close" {546            logFileURL = nil547            return ZQResult(text: "(log closed)")548        }549        if path.hasPrefix("using ") { path = String(path.dropFirst(6)) }550        let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)551        if !FileManager.default.fileExists(atPath: url.path) {552            FileManager.default.createFile(atPath: url.path, contents: nil)553        }554        logFileURL = url555        return ZQResult(text: "(log started: \(url.path))")556    }557558    /// `help [command]` — renders the shared command reference.559    private func handleHelp(_ command: ZQCommand) -> ZQResult {560        let requested = command.varlist.flatMap(\.referencedNames).first561            ?? command.argument562563        if let requested {564            guard let doc = ZQCommandReference.doc(for: requested) else {565                return ZQResult(text: "help: no entry for '\(requested)' — type 'help' for the full list")566            }567            var lines = [568                "\(doc.verb)\(doc.abbreviation.map { " (abbreviation: \($0))" } ?? "")",569                String(repeating: "-", count: 60),570                doc.summary,571                "",572                "Syntax:  \(doc.syntax)",573            ]574            if !doc.options.isEmpty {575                lines.append("")576                lines.append("Options:")577                for option in doc.options {578                    lines.append("  " + TableFormatter.pad(option.name, 16, right: false) + option.meaning)579                }580            }581            if !doc.examples.isEmpty {582                lines.append("")583                lines.append("Examples:")584                for example in doc.examples { lines.append("  . \(example)") }585            }586            if let notes = doc.notes {587                lines.append("")588                lines.append(notes)589            }590            return ZQResult(text: lines.joined(separator: "\n"))591        }592593        var lines = ["Metrika command reference — 'help <command>' for details", ""]594        for category in ZQCommandReference.categories {595            let docs = ZQCommandReference.all.filter { $0.category == category }596            guard !docs.isEmpty else { continue }597            lines.append(category)598            for doc in docs {599                lines.append(600                    "  " + TableFormatter.pad(doc.verb, 14, right: false) + doc.summary.prefix(60)601                )602            }603            lines.append("")604        }605        return ZQResult(text: lines.joined(separator: "\n"))606    }607608    private func appendToLog(command: String, output: String) throws {609        guard let logFileURL else { return }610        let entry = ". \(command)\n\(output)\n\n"611        let handle = try FileHandle(forWritingTo: logFileURL)612        defer { try? handle.close() }613        try handle.seekToEnd()614        try handle.write(contentsOf: Data(entry.utf8))615    }616617    // MARK: - Estimation618619    /// How a fitted regressor is recomputed from the working dataset —620    /// the estimation-state piece that lets `predict` (and later621    /// `margins`) evaluate xb on arbitrary observations.622    enum PredictorDefinition: Equatable, Sendable {623        case column(String)624        case indicator(variable: String, level: Double)625        case product([String])626        case constant627    }628629    /// Stata's e() analog: what the last estimation command fitted.630    struct EstimationState: Sendable {631        enum Kind: Equatable, Sendable {632            case ols, iv633            case glm(ZQGLMFamily)634            case boost(ZQBoostModel)635        }636637        var kind: Kind638        var responseName: String639        /// Coefficients aligned with their recomputation recipes.640        var coefficients: [(name: String, value: Double, definition: PredictorDefinition)]641        /// Covariance matrix, column-major k×k aligned with `coefficients`.642        var vce: [Double]643        /// Degrees of freedom for t inference; nil means normal (ML).644        var inferenceDF: Double?645        /// Estimation-sample design (column-major n×k incl. constant) —646        /// kept for GLMs, whose average marginal effects need it.647        var sampleDesign: [Double]?648        var sampleSize: Int649    }650651    /// Assembled estimation sample after if/in restriction and listwise652    /// deletion (§6: explicit report line for dropped observations).653    struct RegressionSample {654        var responseName: String655        var y: [Double]656        var predictors: [(name: String, values: [Double])]657        /// Aligned with `predictors`.658        var definitions: [PredictorDefinition]659        var clusterLabels: [Int]?660        var droppedMissing: Int661    }662663    private func buildRegressionSample(664        _ command: ZQCommand, clusterVariable: String?665    ) throws -> RegressionSample {666        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }667        guard let first = command.varlist.first else {668            throw ZQEngineError("\(command.verb): dependent variable required")669        }670        guard case .simple(let responseName) = first else {671            throw ZQEngineError("\(command.verb): dependent variable cannot use factor notation")672        }673674        let mask = try observationMask(command)675        let (yAll, yMissing) = try frame.requireNumeric(responseName)676677        // Collect raw regressor sources (numeric columns) for missingness.678        struct Source {679            var spec: ZQVarSpec680            var columns: [(name: String, values: [Double], missing: [Bool])]681        }682        var sources: [Source] = []683        for spec in command.varlist.dropFirst() {684            var columns: [(String, [Double], [Bool])] = []685            for name in spec.referencedNames {686                let (values, missing) = try frame.requireNumeric(name)687                columns.append((name, values, missing))688            }689            sources.append(Source(spec: spec, columns: columns))690        }691692        var clusterColumn: ZQColumn?693        if let clusterVariable {694            clusterColumn = try frame.requireColumn(clusterVariable)695        }696697        // Listwise deletion over response, regressors, and cluster var.698        var keptRows: [Int] = []699        var droppedMissing = 0700        for i in 0..<frame.rowCount where mask[i] {701            var complete = !yMissing[i]702            if complete {703                outer: for source in sources {704                    for column in source.columns where column.missing[i] {705                        complete = false706                        break outer707                    }708                }709            }710            if complete, let clusterColumn {711                switch clusterColumn.data {712                case .float64(_, let missing): complete = !missing[i]713                case .string(let values): complete = values[i] != nil714                }715            }716            if complete {717                keptRows.append(i)718            } else {719                droppedMissing += 1720            }721        }722        guard !keptRows.isEmpty else {723            throw ZQEngineError("no observations")724        }725726        let y = keptRows.map { yAll[$0] }727728        // Expand specs on the estimation sample (plan-time expansion),729        // recording each column's recomputation recipe for predict.730        var predictors: [(name: String, values: [Double])] = []731        var definitions: [PredictorDefinition] = []732        for source in sources {733            switch source.spec {734            case .simple(let name):735                let (values, _) = try frame.requireNumeric(name)736                predictors.append((name, keptRows.map { values[$0] }))737                definitions.append(.column(name))738            case .factor(let op, let name):739                let (values, missing) = try frame.requireNumeric(name)740                switch op {741                case "i", "ib":742                    let sampleValues = keptRows.map { values[$0] }743                    let sampleMissing = keptRows.map { missing[$0] }744                    let expanded = ZQFactorExpansion.expand(745                        name: name, values: sampleValues, missing: sampleMissing746                    )747                    guard !expanded.isEmpty else {748                        throw ZQEngineError("factor variable '\(name)' has a single level")749                    }750                    for term in expanded {751                        predictors.append((term.name, term.values))752                        definitions.append(.indicator(variable: name, level: term.level))753                    }754                case "c":755                    predictors.append((name, keptRows.map { values[$0] }))756                    definitions.append(.column(name))757                default:758                    throw ZQEngineError("unsupported factor operator '\(op).'")759                }760            case .interaction(let parts, _):761                // Continuous-by-continuous interactions only.762                var label: [String] = []763                var factors: [String] = []764                var product = [Double](repeating: 1, count: keptRows.count)765                for part in parts {766                    guard case .factor(let op, let name) = part, op == "c" else {767                        guard case .simple(let name) = part else {768                            throw ZQEngineError("factor interactions beyond c.#c. are not supported yet")769                        }770                        let (values, _) = try frame.requireNumeric(name)771                        for (j, row) in keptRows.enumerated() { product[j] *= values[row] }772                        label.append(name)773                        factors.append(name)774                        continue775                    }776                    let (values, _) = try frame.requireNumeric(name)777                    for (j, row) in keptRows.enumerated() { product[j] *= values[row] }778                    label.append("c.\(name)")779                    factors.append(name)780                }781                predictors.append((label.joined(separator: "#"), product))782                definitions.append(.product(factors))783            }784        }785786        // Cluster labels → dense integer codes.787        var clusterLabels: [Int]?788        if let clusterColumn {789            var codes: [Int] = []790            codes.reserveCapacity(keptRows.count)791            switch clusterColumn.data {792            case .float64(let values, _):793                var mapping: [Double: Int] = [:]794                for row in keptRows {795                    let value = values[row]796                    if let code = mapping[value] {797                        codes.append(code)798                    } else {799                        mapping[value] = mapping.count800                        codes.append(mapping.count - 1)801                    }802                }803            case .string(let values):804                var mapping: [String: Int] = [:]805                for row in keptRows {806                    let value = values[row] ?? ""807                    if let code = mapping[value] {808                        codes.append(code)809                    } else {810                        mapping[value] = mapping.count811                        codes.append(mapping.count - 1)812                    }813                }814            }815            clusterLabels = codes816        }817818        return RegressionSample(819            responseName: responseName,820            y: y,821            predictors: predictors,822            definitions: definitions,823            clusterLabels: clusterLabels,824            droppedMissing: droppedMissing825        )826    }827828    /// Records the estimation state (Stata's e()) for post-estimation829    /// commands. The constant, when fitted, is the last coefficient.830    private func recordEstimation(831        kind: EstimationState.Kind,832        responseName: String,833        coefficients: [ZQCoefficient],834        definitions: [PredictorDefinition],835        includeConstant: Bool,836        vce: [Double] = [],837        inferenceDF: Double? = nil,838        sampleDesign: [Double]? = nil,839        sampleSize: Int = 0840    ) {841        var recipes = definitions842        if includeConstant { recipes.append(.constant) }843        guard recipes.count == coefficients.count else {844            lastEstimation = nil845            return846        }847        lastEstimation = EstimationState(848            kind: kind,849            responseName: responseName,850            coefficients: zip(coefficients, recipes).map {851                (name: $0.name, value: $0.estimate, definition: $1)852            },853            vce: vce,854            inferenceDF: inferenceDF,855            sampleDesign: sampleDesign,856            sampleSize: sampleSize857        )858    }859860    /// Column-major estimation-sample design from sample predictors.861    private func designMatrix(862        from sample: RegressionSample, includeConstant: Bool863    ) -> [Double] {864        let n = sample.y.count865        var design = [Double]()866        design.reserveCapacity(n * (sample.predictors.count + 1))867        for column in sample.predictors { design.append(contentsOf: column.values) }868        if includeConstant { design.append(contentsOf: [Double](repeating: 1, count: n)) }869        return design870    }871872    private func varianceEstimator(873        _ command: ZQCommand, clusterLabels: [Int]?874    ) throws -> ZQVarianceEstimator {875        if let clusterLabels { return .cluster(clusterLabels) }876        if command.hasOption("hc2") { return .hc2 }877        if command.hasOption("hc3") { return .hc3 }878        if command.hasOption("robust") || command.hasOption("r") { return .hc1 }879        if let vce = command.option("vce")?.firstArgument {880            switch vce {881            case "robust": return .hc1882            case "hc2": return .hc2883            case "hc3": return .hc3884            default:885                throw ZQEngineError("vce(\(vce)) is not supported")886            }887        }888        return .classical889    }890891    private func handleRegress(_ command: ZQCommand) throws -> ZQResult {892        let clusterVariable = command.option("cluster")?.firstArgument893            ?? clusterFromVCE(command)894        let sample = try buildRegressionSample(command, clusterVariable: clusterVariable)895        let variance = try varianceEstimator(command, clusterLabels: sample.clusterLabels)896        let level = try confidenceLevel(command)897898        let result = try ZQOLS.fit(899            y: sample.y,900            predictors: sample.predictors,901            includeConstant: !command.hasOption("noconstant"),902            variance: variance,903            confidenceLevel: level904        )905        recordEstimation(906            kind: .ols,907            responseName: sample.responseName,908            coefficients: result.coefficients,909            definitions: sample.definitions,910            includeConstant: !command.hasOption("noconstant"),911            vce: result.vce,912            inferenceDF: result.inferenceDF,913            sampleSize: result.observationCount914        )915916        var header: [String] = []917        switch variance {918        case .classical: header.append("Linear regression")919        case .cluster: header.append("Linear regression (cluster-robust)")920        default: header.append("Linear regression (robust)")921        }922        if sample.droppedMissing > 0 {923            header.append("(\(sample.droppedMissing) observations dropped due to missing values)")924        }925926        var stats = [927            ("Number of obs", TableFormatter.general(Double(result.observationCount))),928            ("R-squared", TableFormatter.fixed(result.rSquared, decimals: 4)),929            ("Adj R-squared", TableFormatter.fixed(result.adjustedRSquared, decimals: 4)),930            ("Root MSE", TableFormatter.general(result.rootMSE, significant: 6)),931        ]932        if let f = result.fStatistic, let df = result.fDF, let p = result.fPValue {933            stats.insert(934                ("F(\(Int(df.0)), \(Int(df.1)))", TableFormatter.fixed(f, decimals: 2)),935                at: 1936            )937            stats.insert(("Prob > F", TableFormatter.fixed(p, decimals: 4)), at: 2)938        }939        if let g = result.clusterCount {940            stats.append(("Clusters", "\(g)"))941        }942        for (label, value) in stats {943            header.append(944                TableFormatter.pad(label, 46, right: false) + "= " +945                TableFormatter.pad(value, 12)946            )947        }948949        var lines = header950        lines.append("")951        let widths = [12, 12, 11, 8, 8, 22]952        lines.append(953            TableFormatter.pad(sample.responseName, widths[0]) + " | " +954            TableFormatter.pad("Coefficient", widths[1]) + "  " +955            TableFormatter.pad("Std. err.", widths[2]) + "  " +956            TableFormatter.pad("t", widths[3]) + "  " +957            TableFormatter.pad("P>|t|", widths[4]) + "  " +958            TableFormatter.pad("[\(Int(level * 100))% conf. interval]", widths[5])959        )960        lines.append(TableFormatter.rule(widths))961        var scalars: [String: Double] = [962            "N": Double(result.observationCount),963            "r2": result.rSquared,964            "r2_a": result.adjustedRSquared,965            "rmse": result.rootMSE,966            "df_r": result.inferenceDF,967        ]968        if let f = result.fStatistic { scalars["F"] = f }969        if let g = result.clusterCount { scalars["N_clust"] = Double(g) }970971        for coefficient in result.coefficients {972            lines.append(973                TableFormatter.pad(coefficient.name, widths[0]) + " | " +974                TableFormatter.pad(TableFormatter.general(coefficient.estimate), widths[1]) + "  " +975                TableFormatter.pad(TableFormatter.general(coefficient.standardError), widths[2]) + "  " +976                TableFormatter.pad(TableFormatter.fixed(coefficient.tStatistic, decimals: 2), widths[3]) + "  " +977                TableFormatter.pad(TableFormatter.fixed(coefficient.pValue, decimals: 3), widths[4]) + "  " +978                TableFormatter.pad(979                    TableFormatter.general(coefficient.confidenceLower) + "   " +980                    TableFormatter.general(coefficient.confidenceUpper),981                    widths[5]982                )983            )984            scalars["b_\(coefficient.name)"] = coefficient.estimate985            scalars["se_\(coefficient.name)"] = coefficient.standardError986        }987        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)988    }989990    private func handleXTReg(_ command: ZQCommand) throws -> ZQResult {991        guard command.hasOption("fe") else {992            throw ZQEngineError("xtreg: only the fixed-effects estimator is implemented — add ', fe'")993        }994        guard let panelVariable else {995            throw ZQEngineError("xtreg: declare the panel first with 'xtset panelvar [timevar]'")996        }997        // v0.2 restriction: the cluster variable must be the panel variable.998        var clustered = false999        if let clusterName = command.option("cluster")?.firstArgument1000            ?? clusterFromVCE(command) {1001            guard clusterName == panelVariable else {1002                throw ZQEngineError(1003                    "xtreg: clustering on a variable other than the panel (\(panelVariable)) is not supported yet"1004                )1005            }1006            clustered = true1007        }10081009        // Panel codes ride the same listwise-deletion path as cluster codes.1010        let sample = try buildRegressionSample(command, clusterVariable: panelVariable)1011        guard let panelCodes = sample.clusterLabels else {1012            throw ZQEngineError("xtreg: panel variable missing from sample")1013        }10141015        let result = try ZQFixedEffects.fitWithin(1016            y: sample.y,1017            predictors: sample.predictors,1018            groups: panelCodes,1019            clustered: clustered,1020            confidenceLevel: try confidenceLevel(command)1021        )1022        // predict after xtreg needs the estimated u_i — not stored yet.1023        lastEstimation = nil10241025        var header = ["Fixed-effects (within) regression"]1026        if sample.droppedMissing > 0 {1027            header.append("(\(sample.droppedMissing) observations dropped due to missing values)")1028        }1029        var stats: [(String, String)] = [1030            ("Number of obs", "\(result.observationCount)"),1031            ("Number of groups", "\(result.groupCount)"),1032            ("R-squared (within)", TableFormatter.fixed(result.rSquaredWithin, decimals: 4)),1033            ("Root MSE", TableFormatter.general(result.rootMSE, significant: 6)),1034        ]1035        if let f = result.fStatistic, let p = result.fPValue {1036            stats.append(("F", TableFormatter.fixed(f, decimals: 2)))1037            stats.append(("Prob > F", TableFormatter.fixed(p, decimals: 4)))1038        }1039        for (label, value) in stats {1040            header.append(1041                TableFormatter.pad(label, 46, right: false) + "= " +1042                TableFormatter.pad(value, 12)1043            )1044        }10451046        var scalars: [String: Double] = [1047            "N": Double(result.observationCount),1048            "N_g": Double(result.groupCount),1049            "r2_w": result.rSquaredWithin,1050            "rmse": result.rootMSE,1051            "df_r": result.inferenceDF,1052        ]1053        let lines = header + [""] + coefficientTable(1054            response: sample.responseName,1055            coefficients: result.coefficients,1056            statisticLabel: "t",1057            level: try confidenceLevel(command),1058            scalars: &scalars1059        )1060        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)1061    }10621063    private func handleIVRegress(_ command: ZQCommand) throws -> ZQResult {1064        guard command.subverb == "2sls" else {1065            throw ZQEngineError("ivregress: only the 2sls estimator is implemented")1066        }1067        guard let ivSpec = command.ivSpec else {1068            throw ZQEngineError("ivregress: syntax is 'ivregress 2sls depvar [exog] (endog = instruments)'")1069        }1070        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }1071        guard let first = command.varlist.first, case .simple(let response) = first else {1072            throw ZQEngineError("ivregress: dependent variable required")1073        }1074        let exogenousNames = command.varlist.dropFirst().flatMap(\.referencedNames)1075        let clusterVariable = command.option("cluster")?.firstArgument1076            ?? clusterFromVCE(command)10771078        // Listwise deletion across every variable involved.1079        let allNames = [response] + exogenousNames + ivSpec.endogenous + ivSpec.instruments1080        let sources = try allNames.map { try frame.requireNumeric($0) }1081        var clusterColumn: ZQColumn?1082        if let clusterVariable {1083            clusterColumn = try frame.requireColumn(clusterVariable)1084        }1085        let mask = try observationMask(command)1086        var keptRows: [Int] = []1087        var dropped = 01088        for i in 0..<frame.rowCount where mask[i] {1089            var complete = sources.allSatisfy { !$0.missing[i] }1090            if complete, let clusterColumn {1091                switch clusterColumn.data {1092                case .float64(_, let missing): complete = !missing[i]1093                case .string(let values): complete = values[i] != nil1094                }1095            }1096            if complete { keptRows.append(i) } else { dropped += 1 }1097        }1098        guard !keptRows.isEmpty else { throw ZQEngineError("no observations") }10991100        func gather(_ names: [String]) throws -> [(name: String, values: [Double])] {1101            try names.map { name in1102                let (values, _) = try frame.requireNumeric(name)1103                return (name, keptRows.map { values[$0] })1104            }1105        }1106        let y = try frame.requireNumeric(response).values1107        let clusterLabels: [Int]? = try clusterVariable.map { name in1108            let column = try frame.requireColumn(name)1109            var mapping: [String: Int] = [:]1110            var codes: [Int] = []1111            for row in keptRows {1112                let key: String1113                switch column.data {1114                case .float64(let values, _): key = "\(values[row])"1115                case .string(let values): key = values[row] ?? ""1116                }1117                if let code = mapping[key] {1118                    codes.append(code)1119                } else {1120                    mapping[key] = mapping.count1121                    codes.append(mapping.count - 1)1122                }1123            }1124            return codes1125        }11261127        let result = try ZQIV.fit2SLS(1128            y: keptRows.map { y[$0] },1129            endogenous: try gather(ivSpec.endogenous),1130            exogenous: try gather(exogenousNames),1131            instruments: try gather(ivSpec.instruments),1132            includeConstant: !command.hasOption("noconstant"),1133            variance: try varianceEstimator(command, clusterLabels: clusterLabels),1134            confidenceLevel: try confidenceLevel(command)1135        )1136        recordEstimation(1137            kind: .iv,1138            responseName: response,1139            coefficients: result.coefficients,1140            definitions: (ivSpec.endogenous + exogenousNames).map { .column($0) },1141            includeConstant: !command.hasOption("noconstant"),1142            vce: result.vce,1143            inferenceDF: result.inferenceDF,1144            sampleSize: result.observationCount1145        )11461147        var header = ["Instrumental variables (2SLS) regression"]1148        if dropped > 0 {1149            header.append("(\(dropped) observations dropped due to missing values)")1150        }1151        header.append("Instrumented: \(ivSpec.endogenous.joined(separator: " "))")1152        header.append("Instruments:  \(ivSpec.instruments.joined(separator: " "))")1153        var stats: [(String, String)] = [1154            ("Number of obs", "\(result.observationCount)"),1155            ("R-squared", TableFormatter.fixed(result.rSquared, decimals: 4)),1156            ("Root MSE", TableFormatter.general(result.rootMSE, significant: 6)),1157        ]1158        if let f = result.fStatistic, let p = result.fPValue {1159            stats.insert(("F", TableFormatter.fixed(f, decimals: 2)), at: 1)1160            stats.insert(("Prob > F", TableFormatter.fixed(p, decimals: 4)), at: 2)1161        }1162        if let g = result.clusterCount { stats.append(("Clusters", "\(g)")) }1163        for (label, value) in stats {1164            header.append(1165                TableFormatter.pad(label, 46, right: false) + "= " +1166                TableFormatter.pad(value, 12)1167            )1168        }11691170        var scalars: [String: Double] = [1171            "N": Double(result.observationCount),1172            "r2": result.rSquared,1173            "rmse": result.rootMSE,1174            "df_r": result.inferenceDF,1175        ]1176        if let f = result.fStatistic { scalars["F"] = f }1177        let lines = header + [""] + coefficientTable(1178            response: response,1179            coefficients: result.coefficients,1180            statisticLabel: "t",1181            level: try confidenceLevel(command),1182            scalars: &scalars1183        )1184        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)1185    }11861187    /// Shared coefficient-table rendering (regress/xtreg/ivregress/GLMs).1188    private func coefficientTable(1189        response: String,1190        coefficients: [ZQCoefficient],1191        statisticLabel: String,1192        level: Double,1193        scalars: inout [String: Double]1194    ) -> [String] {1195        let widths = [12, 12, 11, 8, 8, 22]1196        var lines = [1197            TableFormatter.pad(response, widths[0]) + " | " +1198            TableFormatter.pad("Coefficient", widths[1]) + "  " +1199            TableFormatter.pad("Std. err.", widths[2]) + "  " +1200            TableFormatter.pad(statisticLabel, widths[3]) + "  " +1201            TableFormatter.pad("P>|\(statisticLabel)|", widths[4]) + "  " +1202            TableFormatter.pad("[\(Int(level * 100))% conf. interval]", widths[5]),1203            TableFormatter.rule(widths),1204        ]1205        for coefficient in coefficients {1206            lines.append(1207                TableFormatter.pad(coefficient.name, widths[0]) + " | " +1208                TableFormatter.pad(TableFormatter.general(coefficient.estimate), widths[1]) + "  " +1209                TableFormatter.pad(TableFormatter.general(coefficient.standardError), widths[2]) + "  " +1210                TableFormatter.pad(TableFormatter.fixed(coefficient.tStatistic, decimals: 2), widths[3]) + "  " +1211                TableFormatter.pad(TableFormatter.fixed(coefficient.pValue, decimals: 3), widths[4]) + "  " +1212                TableFormatter.pad(1213                    TableFormatter.general(coefficient.confidenceLower) + "   " +1214                    TableFormatter.general(coefficient.confidenceUpper),1215                    widths[5]1216                )1217            )1218            scalars["b_\(coefficient.name)"] = coefficient.estimate1219            scalars["se_\(coefficient.name)"] = coefficient.standardError1220        }1221        return lines1222    }12231224    private func handleGLM(1225        _ command: ZQCommand, family: ZQGLMFamily1226    ) throws -> ZQResult {1227        let clusterVariable = command.option("cluster")?.firstArgument1228            ?? clusterFromVCE(command)1229        let sample = try buildRegressionSample(command, clusterVariable: clusterVariable)1230        let variance = try varianceEstimator(command, clusterLabels: sample.clusterLabels)1231        let level = try confidenceLevel(command)12321233        let result = try ZQGLM.fit(1234            y: sample.y,1235            predictors: sample.predictors,1236            family: family,1237            includeConstant: !command.hasOption("noconstant"),1238            variance: variance,1239            confidenceLevel: level1240        )1241        recordEstimation(1242            kind: .glm(family),1243            responseName: sample.responseName,1244            coefficients: result.coefficients,1245            definitions: sample.definitions,1246            includeConstant: !command.hasOption("noconstant"),1247            vce: result.vce,1248            sampleDesign: designMatrix(1249                from: sample,1250                includeConstant: !command.hasOption("noconstant")1251            ),1252            sampleSize: result.observationCount1253        )12541255        let title: String1256        switch family {1257        case .logit: title = "Logistic regression"1258        case .probit: title = "Probit regression"1259        case .poisson: title = "Poisson regression"1260        }12611262        var header = [title]1263        if sample.droppedMissing > 0 {1264            header.append("(\(sample.droppedMissing) observations dropped due to missing values)")1265        }1266        var stats: [(String, String)] = [1267            ("Number of obs", "\(result.observationCount)"),1268        ]1269        if let chi2 = result.chiSquared, let p = result.chiSquaredPValue {1270            let label: String1271            if case .classical = variance {1272                label = "LR chi2(\(result.chiSquaredDF))"1273            } else {1274                label = "Wald chi2(\(result.chiSquaredDF))"1275            }1276            stats.append((label, TableFormatter.fixed(chi2, decimals: 2)))1277            stats.append(("Prob > chi2", TableFormatter.fixed(p, decimals: 4)))1278        }1279        stats.append(("Log likelihood", TableFormatter.fixed(result.logLikelihood, decimals: 4)))1280        stats.append(("Pseudo R2", TableFormatter.fixed(result.pseudoRSquared, decimals: 4)))1281        if let g = result.clusterCount {1282            stats.append(("Clusters", "\(g)"))1283        }1284        for (label, value) in stats {1285            header.append(1286                TableFormatter.pad(label, 46, right: false) + "= " +1287                TableFormatter.pad(value, 12)1288            )1289        }12901291        var lines = header1292        lines.append("")1293        let widths = [12, 12, 11, 8, 8, 22]1294        lines.append(1295            TableFormatter.pad(sample.responseName, widths[0]) + " | " +1296            TableFormatter.pad("Coefficient", widths[1]) + "  " +1297            TableFormatter.pad("Std. err.", widths[2]) + "  " +1298            TableFormatter.pad("z", widths[3]) + "  " +1299            TableFormatter.pad("P>|z|", widths[4]) + "  " +1300            TableFormatter.pad("[\(Int(level * 100))% conf. interval]", widths[5])1301        )1302        lines.append(TableFormatter.rule(widths))13031304        var scalars: [String: Double] = [1305            "N": Double(result.observationCount),1306            "ll": result.logLikelihood,1307            "ll_0": result.nullLogLikelihood,1308            "r2_p": result.pseudoRSquared,1309        ]1310        if let chi2 = result.chiSquared { scalars["chi2"] = chi2 }1311        if let g = result.clusterCount { scalars["N_clust"] = Double(g) }13121313        for coefficient in result.coefficients {1314            lines.append(1315                TableFormatter.pad(coefficient.name, widths[0]) + " | " +1316                TableFormatter.pad(TableFormatter.general(coefficient.estimate), widths[1]) + "  " +1317                TableFormatter.pad(TableFormatter.general(coefficient.standardError), widths[2]) + "  " +1318                TableFormatter.pad(TableFormatter.fixed(coefficient.tStatistic, decimals: 2), widths[3]) + "  " +1319                TableFormatter.pad(TableFormatter.fixed(coefficient.pValue, decimals: 3), widths[4]) + "  " +1320                TableFormatter.pad(1321                    TableFormatter.general(coefficient.confidenceLower) + "   " +1322                    TableFormatter.general(coefficient.confidenceUpper),1323                    widths[5]1324                )1325            )1326            scalars["b_\(coefficient.name)"] = coefficient.estimate1327            scalars["se_\(coefficient.name)"] = coefficient.standardError1328        }1329        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)1330    }13311332    // MARK: - Tabulate & correlate13331334    private func handleTabulate(_ command: ZQCommand) throws -> ZQResult {1335        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }1336        let names = command.varlist.flatMap(\.referencedNames)1337        guard names.count == 1 || names.count == 2 else {1338            throw ZQEngineError("tabulate: syntax is 'tabulate varname [varname2]'")1339        }1340        let mask = try observationMask(command)13411342        /// Rendered level label per observation; nil = missing (excluded1343        /// unless the `missing` option is given).1344        func labels(_ name: String) throws -> [String?] {1345            let column = try frame.requireColumn(name)1346            switch column.data {1347            case .float64(let values, let missing):1348                return (0..<frame.rowCount).map { i in1349                    missing[i] ? nil : TableFormatter.general(values[i])1350                }1351            case .string(let values):1352                return values1353            }1354        }13551356        let includeMissing = command.hasOption("missing")1357        let first = try labels(names[0])13581359        if names.count == 1 {1360            var counts: [String: Int] = [:]1361            var total = 01362            for i in 0..<frame.rowCount where mask[i] {1363                guard let label = includeMissing ? (first[i] ?? ".") : first[i] else {1364                    continue1365                }1366                counts[label, default: 0] += 11367                total += 11368            }1369            guard total > 0 else { throw ZQEngineError("no observations") }13701371            var lines = [1372                TableFormatter.pad(names[0], 14) + " | " +1373                TableFormatter.pad("Freq.", 9) + "  " +1374                TableFormatter.pad("Percent", 9) + "  " +1375                TableFormatter.pad("Cum.", 9),1376                String(repeating: "-", count: 15) + "+" + String(repeating: "-", count: 33),1377            ]1378            var cumulative = 0.01379            for label in counts.keys.sorted(by: numericAwareLess) {1380                let count = counts[label]!1381                let percent = 100 * Double(count) / Double(total)1382                cumulative += percent1383                lines.append(1384                    TableFormatter.pad(label, 14) + " | " +1385                    TableFormatter.pad("\(count)", 9) + "  " +1386                    TableFormatter.pad(TableFormatter.fixed(percent, decimals: 2), 9) + "  " +1387                    TableFormatter.pad(TableFormatter.fixed(cumulative, decimals: 2), 9)1388                )1389            }1390            lines.append(String(repeating: "-", count: 15) + "+" + String(repeating: "-", count: 33))1391            lines.append(1392                TableFormatter.pad("Total", 14) + " | " +1393                TableFormatter.pad("\(total)", 9) + "  " +1394                TableFormatter.pad("100.00", 9)1395            )1396            return ZQResult(1397                text: lines.joined(separator: "\n"),1398                scalars: ["N": Double(total), "r": Double(counts.count)]1399            )1400        }14011402        // Two-way table.1403        let second = try labels(names[1])1404        var cells: [String: [String: Int]] = [:]1405        var rowTotals: [String: Int] = [:]1406        var columnTotals: [String: Int] = [:]1407        var total = 01408        for i in 0..<frame.rowCount where mask[i] {1409            let rowLabel = includeMissing ? (first[i] ?? ".") : first[i]1410            let columnLabel = includeMissing ? (second[i] ?? ".") : second[i]1411            guard let rowLabel, let columnLabel else { continue }1412            cells[rowLabel, default: [:]][columnLabel, default: 0] += 11413            rowTotals[rowLabel, default: 0] += 11414            columnTotals[columnLabel, default: 0] += 11415            total += 11416        }1417        guard total > 0 else { throw ZQEngineError("no observations") }14181419        let rows = rowTotals.keys.sorted(by: numericAwareLess)1420        let columns = columnTotals.keys.sorted(by: numericAwareLess)1421        let width = 91422        var lines = [1423            TableFormatter.pad(names[0], 14) + " | " +1424            columns.map { TableFormatter.pad($0, width) }.joined(separator: " ") +1425            "  " + TableFormatter.pad("Total", width),1426            String(repeating: "-", count: 15) + "+" +1427            String(repeating: "-", count: (width + 1) * (columns.count + 1) + 2),1428        ]1429        for row in rows {1430            let cellsText = columns.map {1431                TableFormatter.pad("\(cells[row]?[$0] ?? 0)", width)1432            }.joined(separator: " ")1433            lines.append(1434                TableFormatter.pad(row, 14) + " | " + cellsText + "  " +1435                TableFormatter.pad("\(rowTotals[row] ?? 0)", width)1436            )1437        }1438        lines.append(1439            TableFormatter.pad("Total", 14) + " | " +1440            columns.map { TableFormatter.pad("\(columnTotals[$0] ?? 0)", width) }1441                .joined(separator: " ") +1442            "  " + TableFormatter.pad("\(total)", width)1443        )1444        return ZQResult(1445            text: lines.joined(separator: "\n"),1446            scalars: [1447                "N": Double(total),1448                "r": Double(rows.count),1449                "c": Double(columns.count),1450            ]1451        )1452    }14531454    /// Sorts numeric labels numerically, everything else lexically.1455    private func numericAwareLess(_ a: String, _ b: String) -> Bool {1456        if let x = Double(a), let y = Double(b) { return x < y }1457        return a < b1458    }14591460    private func handleCorrelate(_ command: ZQCommand) throws -> ZQResult {1461        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }1462        var names = command.varlist.flatMap(\.referencedNames)1463        if names.isEmpty {1464            names = frame.columns.filter(\.data.isNumeric).map(\.name)1465        }1466        guard names.count >= 2 else {1467            throw ZQEngineError("correlate: at least two numeric variables required")1468        }1469        let mask = try observationMask(command)1470        let sources = try names.map { try frame.requireNumeric($0) }14711472        // Listwise deletion across the varlist (Stata correlate).1473        var kept: [Int] = []1474        for i in 0..<frame.rowCount where mask[i] {1475            if sources.allSatisfy({ !$0.missing[i] }) { kept.append(i) }1476        }1477        guard kept.count > 1 else { throw ZQEngineError("no observations") }14781479        let columns = sources.map { source in kept.map { source.values[$0] } }1480        let matrix = ZQCorrelate.matrix(columns: columns)14811482        var lines = [1483            "(obs=\(kept.count))",1484            "",1485            TableFormatter.pad("", 12) + " | " +1486            names.map { TableFormatter.pad($0, 9) }.joined(separator: " "),1487            String(repeating: "-", count: 13) + "+" +1488            String(repeating: "-", count: (9 + 1) * names.count + 1),1489        ]1490        for (i, name) in names.enumerated() {1491            // Lower triangle only, Stata-style.1492            let cells = (0...i).map {1493                TableFormatter.pad(TableFormatter.fixed(matrix[i][$0], decimals: 4), 9)1494            }.joined(separator: " ")1495            lines.append(TableFormatter.pad(name, 12) + " | " + cells)1496        }1497        return ZQResult(1498            text: lines.joined(separator: "\n"),1499            scalars: ["N": Double(kept.count), "rho": matrix[0][1]]1500        )1501    }15021503    private func clusterFromVCE(_ command: ZQCommand) -> String? {1504        guard let vce = command.option("vce"),1505              vce.arguments.count == 2,1506              vce.arguments[0] == "cluster" else { return nil }1507        return vce.arguments[1]1508    }15091510    private func confidenceLevel(_ command: ZQCommand) throws -> Double {1511        guard let text = command.option("level")?.firstArgument else { return 0.95 }1512        guard let value = Double(text), value > 0, value < 100 else {1513            throw ZQEngineError("level() must be between 0 and 100")1514        }1515        return value / 1001516    }15171518    // MARK: - Post-estimation15191520    /// `predict newvar [, xb | residuals | pr | n]` — evaluates the last1521    /// estimation over ALL current observations (missing inputs yield1522    /// missing predictions). Defaults: xb after regress/ivregress, pr1523    /// after logit/probit, n (mean count) after poisson.1524    private func handlePredict(_ command: ZQCommand) throws -> ZQResult {1525        guard let estimation = lastEstimation else {1526            throw ZQEngineError("predict: no estimation results — run a regression first")1527        }1528        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }1529        let names = command.varlist.flatMap(\.referencedNames)1530        guard names.count == 1, let target = names.first else {1531            throw ZQEngineError("predict: syntax is 'predict newvar [, statistic]'")1532        }15331534        // Boosted models predict through their trees, not a linear xb.1535        if case .boost(let model) = estimation.kind {1536            return try predictBoost(1537                model: model, target: target, command: command,1538                responseName: estimation.responseName1539            )1540        }15411542        // Statistic selection and validation against the model kind.1543        enum Statistic { case xb, residuals, probability, mean }1544        var statistic: Statistic1545        switch estimation.kind {1546        case .ols, .iv: statistic = .xb1547        case .glm(.logit), .glm(.probit): statistic = .probability1548        case .glm(.poisson): statistic = .mean1549        case .boost: fatalError("handled above")1550        }1551        for option in command.options {1552            switch option.name {1553            case "xb": statistic = .xb1554            case "residuals", "resid": statistic = .residuals1555            case "pr":1556                switch estimation.kind {1557                case .glm(.logit), .glm(.probit): statistic = .probability1558                default:1559                    throw ZQEngineError("predict: 'pr' requires logit or probit results")1560                }1561            case "n":1562                guard case .glm(.poisson) = estimation.kind else {1563                    throw ZQEngineError("predict: 'n' requires poisson results")1564                }1565                statistic = .mean1566            default:1567                throw ZQEngineError("predict: unknown statistic '\(option.name)'")1568            }1569        }15701571        // Linear predictor over the full dataset.1572        let n = frame.rowCount1573        var xb = [Double](repeating: 0, count: n)1574        var missing = [Bool](repeating: false, count: n)1575        for coefficient in estimation.coefficients {1576            switch coefficient.definition {1577            case .constant:1578                for i in 0..<n { xb[i] += coefficient.value }1579            case .column(let name):1580                let (values, columnMissing) = try frame.requireNumeric(name)1581                for i in 0..<n {1582                    if columnMissing[i] { missing[i] = true } else {1583                        xb[i] += coefficient.value * values[i]1584                    }1585                }1586            case .indicator(let variable, let level):1587                let (values, columnMissing) = try frame.requireNumeric(variable)1588                for i in 0..<n {1589                    if columnMissing[i] { missing[i] = true }1590                    else if values[i] == level { xb[i] += coefficient.value }1591                }1592            case .product(let variables):1593                let columns = try variables.map { try frame.requireNumeric($0) }1594                for i in 0..<n {1595                    var product = 1.01596                    for column in columns {1597                        if column.missing[i] { missing[i] = true; break }1598                        product *= column.values[i]1599                    }1600                    if !missing[i] { xb[i] += coefficient.value * product }1601                }1602            }1603        }16041605        // Transform.1606        var result = [Double](repeating: .nan, count: n)1607        var label: String1608        switch statistic {1609        case .xb:1610            label = "linear prediction"1611            for i in 0..<n where !missing[i] { result[i] = xb[i] }1612        case .residuals:1613            label = "residuals"1614            let (yValues, yMissing) = try frame.requireNumeric(estimation.responseName)1615            for i in 0..<n where !missing[i] && !yMissing[i] {1616                switch estimation.kind {1617                case .ols, .iv:1618                    result[i] = yValues[i] - xb[i]1619                case .glm(.logit):1620                    result[i] = yValues[i] - 1 / (1 + Foundation.exp(-xb[i]))1621                case .glm(.probit):1622                    result[i] = yValues[i] - ZQDistributions.normalCDF(xb[i])1623                case .glm(.poisson):1624                    result[i] = yValues[i] - Foundation.exp(xb[i])1625                case .boost:1626                    break   // handled by predictBoost1627                }1628            }1629            for i in 0..<n where yMissing[i] { missing[i] = true }1630        case .probability:1631            label = "predicted probability"1632            for i in 0..<n where !missing[i] {1633                if case .glm(.probit) = estimation.kind {1634                    result[i] = ZQDistributions.normalCDF(xb[i])1635                } else {1636                    result[i] = 1 / (1 + Foundation.exp(-xb[i]))1637                }1638            }1639        case .mean:1640            label = "predicted mean count"1641            for i in 0..<n where !missing[i] { result[i] = Foundation.exp(xb[i]) }1642        }1643        for i in 0..<n where missing[i] { result[i] = .nan }16441645        try frame.addColumn(ZQColumn(1646            name: target,1647            data: .float64(values: result, missing: missing)1648        ))1649        let missingCount = missing.count { $0 }1650        var note = "(\(label) '\(target)' generated"1651        if missingCount > 0 { note += ", \(missingCount) missing values" }1652        note += ")"1653        return ZQResult(text: note, scalars: ["N": Double(n - missingCount)])1654    }16551656    /// `elasticnet y x…, lambda(#) [alpha(#)]` and `lasso y x…, lambda(#)`1657    /// — penalized linear regression, glmnet conventions. Without1658    /// lambda(), reports λ_max (the smallest all-zero penalty) as a hint.1659    private func handleElasticNet(1660        _ command: ZQCommand, defaultAlpha: Double?1661    ) throws -> ZQResult {1662        let sample = try buildRegressionSample(command, clusterVariable: nil)16631664        var alpha = defaultAlpha ?? 11665        if let text = command.option("alpha")?.firstArgument {1666            guard defaultAlpha == nil else {1667                throw ZQEngineError("lasso: alpha is fixed at 1 — use elasticnet for other mixes")1668            }1669            guard let value = Double(text) else {1670                throw ZQEngineError("elasticnet: invalid alpha()")1671            }1672            alpha = value1673        }1674        guard let lambdaText = command.option("lambda")?.firstArgument,1675              let lambda = Double(lambdaText) else {1676            let hint = ZQElasticNet.lambdaMax(1677                y: sample.y, predictors: sample.predictors, alpha: alpha1678            )1679            throw ZQEngineError(1680                "\(command.verb): lambda(#) required — lambda_max for this data is \(TableFormatter.general(hint, significant: 6))"1681            )1682        }16831684        let result = try ZQElasticNet.fit(1685            y: sample.y,1686            predictors: sample.predictors,1687            alpha: alpha,1688            lambda: lambda1689        )16901691        // predict works after penalized fits; margins (needing a VCE)1692        // deliberately does not.1693        recordEstimation(1694            kind: .ols,1695            responseName: sample.responseName,1696            coefficients: result.coefficients.map {1697                ZQCoefficient(1698                    name: $0.name, estimate: $0.value, standardError: .nan,1699                    tStatistic: .nan, pValue: .nan,1700                    confidenceLower: .nan, confidenceUpper: .nan1701                )1702            } + [ZQCoefficient(1703                name: "_cons", estimate: result.intercept, standardError: .nan,1704                tStatistic: .nan, pValue: .nan,1705                confidenceLower: .nan, confidenceUpper: .nan1706            )],1707            definitions: sample.definitions,1708            includeConstant: true,1709            vce: [],1710            sampleSize: sample.y.count1711        )17121713        var lines = [1714            alpha == 1 ? "Lasso linear model" : "Elastic-net linear model (alpha = \(TableFormatter.general(alpha)))",1715        ]1716        if sample.droppedMissing > 0 {1717            lines.append("(\(sample.droppedMissing) observations dropped due to missing values)")1718        }1719        for (label, value) in [1720            ("Number of obs", "\(sample.y.count)"),1721            ("lambda", TableFormatter.general(lambda, significant: 6)),1722            ("Nonzero coefficients", "\(result.nonzeroCount) of \(result.coefficients.count)"),1723            ("R-squared", TableFormatter.fixed(result.rSquared, decimals: 4)),1724        ] {1725            lines.append(1726                TableFormatter.pad(label, 46, right: false) + "= " +1727                TableFormatter.pad(value, 12)1728            )1729        }1730        lines.append("")1731        lines.append(1732            TableFormatter.pad(sample.responseName, 12) + " | " +1733            TableFormatter.pad("Coefficient", 12)1734        )1735        lines.append(String(repeating: "-", count: 13) + "+" + String(repeating: "-", count: 14))17361737        var scalars: [String: Double] = [1738            "N": Double(sample.y.count),1739            "lambda": lambda,1740            "alpha": alpha,1741            "k_nonzero": Double(result.nonzeroCount),1742            "r2": result.rSquared,1743        ]1744        for coefficient in result.coefficients where coefficient.value != 0 {1745            lines.append(1746                TableFormatter.pad(coefficient.name, 12) + " | " +1747                TableFormatter.pad(TableFormatter.general(coefficient.value), 12)1748            )1749            scalars["b_\(coefficient.name)"] = coefficient.value1750        }1751        // Zeroed coefficients still surface as scalars (as exact 0).1752        for coefficient in result.coefficients where coefficient.value == 0 {1753            scalars["b_\(coefficient.name)"] = 01754        }1755        lines.append(1756            TableFormatter.pad("_cons", 12) + " | " +1757            TableFormatter.pad(TableFormatter.general(result.intercept), 12)1758        )1759        scalars["b__cons"] = result.intercept1760        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)1761    }17621763    /// `boost y x…, rounds(#) [eta(#) maxdepth(#) lambda(#)]` —1764    /// gradient-boosted regression trees (xgboost exact-greedy, squared1765    /// loss). Deterministic: no subsampling.1766    private func handleBoost(_ command: ZQCommand) throws -> ZQResult {1767        let sample = try buildRegressionSample(command, clusterVariable: nil)1768        func numberOption(_ name: String, default defaultValue: Double) throws -> Double {1769            guard let text = command.option(name)?.firstArgument else { return defaultValue }1770            guard let value = Double(text), value > 0 else {1771                throw ZQEngineError("boost: invalid \(name)()")1772            }1773            return value1774        }1775        guard let roundsText = command.option("rounds")?.firstArgument,1776              let rounds = Int(roundsText), rounds > 0 else {1777            throw ZQEngineError("boost: rounds(#) required")1778        }1779        let model = try ZQGradientBoosting.fit(1780            y: sample.y,1781            features: sample.predictors,1782            rounds: rounds,1783            learningRate: try numberOption("eta", default: 0.3),1784            maxDepth: Int(try numberOption("maxdepth", default: 6)),1785            lambda: try numberOption("lambda", default: 1)1786        )17871788        // Training fit.1789        let n = sample.y.count1790        var rss = 0.01791        var tss = 0.01792        let meanY = sample.y.reduce(0, +) / Double(n)1793        for i in 0..<n {1794            let vector = sample.predictors.map { $0.values[i] }1795            let predicted = model.predict(vector)1796            rss += (sample.y[i] - predicted) * (sample.y[i] - predicted)1797            tss += (sample.y[i] - meanY) * (sample.y[i] - meanY)1798        }1799        let rSquared = tss > 0 ? 1 - rss / tss : .nan1800        let rmse = (rss / Double(n)).squareRoot()18011802        recordEstimation(1803            kind: .boost(model),1804            responseName: sample.responseName,1805            coefficients: sample.predictors.map {1806                ZQCoefficient(1807                    name: $0.name, estimate: .nan, standardError: .nan,1808                    tStatistic: .nan, pValue: .nan,1809                    confidenceLower: .nan, confidenceUpper: .nan1810                )1811            },1812            definitions: sample.definitions,1813            includeConstant: false,1814            sampleSize: n1815        )18161817        var lines = ["Gradient-boosted trees (squared loss)"]1818        if sample.droppedMissing > 0 {1819            lines.append("(\(sample.droppedMissing) observations dropped due to missing values)")1820        }1821        for (label, value) in [1822            ("Number of obs", "\(n)"),1823            ("Trees", "\(rounds)"),1824            ("Learning rate (eta)", TableFormatter.general(model.learningRate)),1825            ("Max depth", command.option("maxdepth")?.firstArgument ?? "6"),1826            ("Training R-squared", TableFormatter.fixed(rSquared, decimals: 4)),1827            ("Training RMSE", TableFormatter.general(rmse, significant: 6)),1828        ] {1829            lines.append(1830                TableFormatter.pad(label, 46, right: false) + "= " +1831                TableFormatter.pad(value, 12)1832            )1833        }1834        lines.append("")1835        lines.append("Use 'predict newvar' for fitted values. Training fit is in-sample —")1836        lines.append("expect it to be optimistic relative to held-out data.")1837        return ZQResult(1838            text: lines.joined(separator: "\n"),1839            scalars: [1840                "N": Double(n), "rounds": Double(rounds),1841                "r2": rSquared, "rmse": rmse,1842            ]1843        )1844    }18451846    /// `margins, dydx(varlist)` — average marginal effects with1847    /// delta-method standard errors. OLS/IV effects are the coefficients1848    /// themselves; GLM effects average dμ/dx over the estimation sample:1849    ///   AME_j = β_j · (1/N) Σᵢ g′(xbᵢ)1850    ///   ∂AME_j/∂β_m = (1/N) Σᵢ g″(xbᵢ)·x_im·β_j + δ_jm·(1/N) Σᵢ g′(xbᵢ)1851    /// Only continuous (plain-column) regressors are supported so far.1852    private func handleMargins(_ command: ZQCommand) throws -> ZQResult {1853        guard let estimation = lastEstimation else {1854            throw ZQEngineError("margins: no estimation results — run a regression first")1855        }1856        guard let dydx = command.option("dydx"), !dydx.arguments.isEmpty else {1857            throw ZQEngineError("margins: syntax is 'margins, dydx(varlist)'")1858        }1859        guard !estimation.vce.isEmpty else {1860            throw ZQEngineError("margins: the last estimation stored no covariance matrix")1861        }18621863        let k = estimation.coefficients.count1864        struct Effect {1865            var name: String1866            var dydx: Double1867            var standardError: Double1868        }1869        var effects: [Effect] = []18701871        for name in dydx.arguments {1872            guard let j = estimation.coefficients.firstIndex(where: {1873                $0.definition == .column(name)1874            }) else {1875                if estimation.coefficients.contains(where: {1876                    if case .indicator(let variable, _) = $0.definition { return variable == name }1877                    if case .product(let variables) = $0.definition { return variables.contains(name) }1878                    return false1879                }) {1880                    throw ZQEngineError(1881                        "margins: dydx over factor or interaction terms is not supported yet"1882                    )1883                }1884                throw ZQEngineError("margins: '\(name)' is not a regressor in the last estimation")1885            }1886            let beta = estimation.coefficients[j].value18871888            switch estimation.kind {1889            case .boost:1890                throw ZQEngineError("margins: not available after boost")1891            case .ols, .iv:1892                // Linear model: the marginal effect is the coefficient.1893                effects.append(Effect(1894                    name: name,1895                    dydx: beta,1896                    standardError: estimation.vce[j * k + j].squareRoot()1897                ))18981899            case .glm(let family):1900                guard let design = estimation.sampleDesign,1901                      estimation.sampleSize > 0 else {1902                    throw ZQEngineError("margins: estimation sample unavailable")1903                }1904                let n = estimation.sampleSize1905                // xb over the estimation sample.1906                var xb = [Double](repeating: 0, count: n)1907                for m in 0..<k {1908                    let value = estimation.coefficients[m].value1909                    for i in 0..<n { xb[i] += value * design[m * n + i] }1910                }1911                // g′ and g″ per observation.1912                var meanFirst = 0.01913                var second = [Double](repeating: 0, count: n)1914                for i in 0..<n {1915                    switch family {1916                    case .logit:1917                        let p = 1 / (1 + Foundation.exp(-xb[i]))1918                        meanFirst += p * (1 - p)1919                        second[i] = p * (1 - p) * (1 - 2 * p)1920                    case .probit:1921                        let density = Foundation.exp(-0.5 * xb[i] * xb[i])1922                            / (2 * Double.pi).squareRoot()1923                        meanFirst += density1924                        second[i] = -xb[i] * density1925                    case .poisson:1926                        let mu = Foundation.exp(xb[i])1927                        meanFirst += mu1928                        second[i] = mu1929                    }1930                }1931                meanFirst /= Double(n)19321933                // Delta-method gradient.1934                var gradient = [Double](repeating: 0, count: k)1935                for m in 0..<k {1936                    var meanSecondX = 0.01937                    for i in 0..<n { meanSecondX += second[i] * design[m * n + i] }1938                    gradient[m] = beta * meanSecondX / Double(n)1939                }1940                gradient[j] += meanFirst19411942                var variance = 0.01943                for a in 0..<k {1944                    for b in 0..<k {1945                        variance += gradient[a] * estimation.vce[b * k + a] * gradient[b]1946                    }1947                }1948                effects.append(Effect(1949                    name: name,1950                    dydx: beta * meanFirst,1951                    standardError: variance.squareRoot()1952                ))1953            }1954        }19551956        // Render: t inference when the model carries a df, z otherwise.1957        let level = try confidenceLevel(command)1958        let usesT = estimation.inferenceDF != nil1959        let critical: Double1960        if let df = estimation.inferenceDF {1961            critical = ZQDistributions.studentTQuantile(0.5 + level / 2, df: df)1962        } else {1963            critical = ZQDistributions.normalQuantile(0.5 + level / 2)1964        }19651966        var lines = [1967            "Average marginal effects                      Number of obs = " +1968            TableFormatter.pad("\(estimation.sampleSize)", 10),1969            "",1970        ]1971        let widths = [12, 12, 11, 8, 8, 22]1972        let statLabel = usesT ? "t" : "z"1973        lines.append(1974            TableFormatter.pad("", widths[0]) + " | " +1975            TableFormatter.pad("dy/dx", widths[1]) + "  " +1976            TableFormatter.pad("Std. err.", widths[2]) + "  " +1977            TableFormatter.pad(statLabel, widths[3]) + "  " +1978            TableFormatter.pad("P>|\(statLabel)|", widths[4]) + "  " +1979            TableFormatter.pad("[\(Int(level * 100))% conf. interval]", widths[5])1980        )1981        lines.append(TableFormatter.rule(widths))19821983        var scalars: [String: Double] = ["N": Double(estimation.sampleSize)]1984        for effect in effects {1985            let statistic = effect.dydx / effect.standardError1986            let p: Double1987            if let df = estimation.inferenceDF {1988                p = ZQDistributions.tTestPValue(statistic, df: df)1989            } else {1990                p = 2 * (1 - ZQDistributions.normalCDF(abs(statistic)))1991            }1992            lines.append(1993                TableFormatter.pad(effect.name, widths[0]) + " | " +1994                TableFormatter.pad(TableFormatter.general(effect.dydx), widths[1]) + "  " +1995                TableFormatter.pad(TableFormatter.general(effect.standardError), widths[2]) + "  " +1996                TableFormatter.pad(TableFormatter.fixed(statistic, decimals: 2), widths[3]) + "  " +1997                TableFormatter.pad(TableFormatter.fixed(p, decimals: 3), widths[4]) + "  " +1998                TableFormatter.pad(1999                    TableFormatter.general(effect.dydx - critical * effect.standardError) + "   " +2000                    TableFormatter.general(effect.dydx + critical * effect.standardError),2001                    widths[5]2002                )2003            )2004            scalars["dydx_\(effect.name)"] = effect.dydx2005            scalars["se_\(effect.name)"] = effect.standardError2006        }2007        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)2008    }20092010    /// Boosted-tree predictions: trees route missing features down the2011    /// default (left) branch, so predictions exist for every observation.2012    private func predictBoost(2013        model: ZQBoostModel, target: String, command: ZQCommand,2014        responseName: String2015    ) throws -> ZQResult {2016        for option in command.options2017        where !["xb", "residuals", "resid"].contains(option.name) {2018            throw ZQEngineError("predict: '\(option.name)' is not available after boost")2019        }2020        let wantsResiduals = command.hasOption("residuals") || command.hasOption("resid")20212022        let n = frame.rowCount2023        let columns = try model.featureNames.map { try frame.requireNumeric($0) }2024        var values = [Double](repeating: .nan, count: n)2025        var missing = [Bool](repeating: false, count: n)2026        var yValues = [Double](repeating: 0, count: n)2027        var yMissing = [Bool](repeating: false, count: n)2028        if wantsResiduals {2029            (yValues, yMissing) = try frame.requireNumeric(responseName)2030        }20312032        for i in 0..<n {2033            let vector = columns.map { $0.missing[i] ? Double.nan : $0.values[i] }2034            let predicted = model.predict(vector)2035            if wantsResiduals {2036                if yMissing[i] {2037                    missing[i] = true2038                } else {2039                    values[i] = yValues[i] - predicted2040                }2041            } else {2042                values[i] = predicted2043            }2044        }2045        try frame.addColumn(ZQColumn(2046            name: target, data: .float64(values: values, missing: missing)2047        ))2048        let label = wantsResiduals ? "residuals" : "boosted prediction"2049        return ZQResult(text: "(\(label) '\(target)' generated)")2050    }20512052    // MARK: - Bootstrap prefix20532054    private func handleBootstrap(2055        _ command: ZQCommand, backend: ZQBackend2056    ) async throws -> ZQResult {2057        guard let body = command.body else {2058            throw ZQEngineError("bootstrap: syntax is 'bootstrap, reps(#): command'")2059        }2060        guard body.verb == "regress" else {2061            throw ZQEngineError("bootstrap currently supports 'regress' bodies only")2062        }2063        guard let repsText = command.option("reps")?.firstArgument,2064              let reps = Int(repsText), reps >= 2 else {2065            throw ZQEngineError("bootstrap: reps(#) with # ≥ 2 required")2066        }2067        if let seedText = command.option("seed")?.firstArgument {2068            guard let value = UInt64(seedText) else {2069                throw ZQEngineError("bootstrap: invalid seed")2070            }2071            seed = value2072        }20732074        let sample = try buildRegressionSample(body, clusterVariable: nil)2075        let includeConstant = !body.hasOption("noconstant")2076        let point = try ZQOLS.fit(2077            y: sample.y,2078            predictors: sample.predictors,2079            includeConstant: includeConstant,2080            variance: .classical2081        )20822083        let n = sample.y.count2084        let k = point.coefficients.count2085        let generator = Philox4x32(seed: seed)2086        let y = sample.y2087        let predictors = sample.predictors20882089        // Pairs bootstrap: replicate r regenerates its indices from the2090        // Philox counter stream — deterministic and order-independent, so2091        // replicates can run in parallel CPU chunks or as GPU batches with2092        // identical resamples.2093        let allDraws: [[Double]]2094        if backend == .gpu {2095            allDraws = ZQGPUBootstrap.pairsBootstrapOLS(2096                y: y,2097                predictors: predictors,2098                includeConstant: includeConstant,2099                replicates: reps,2100                seed: seed2101            )2102        } else {2103            allDraws = try await withThrowingTaskGroup(2104                of: [(Int, [Double])].self2105            ) { group in2106                let chunkCount = min(2107                    max(1, ProcessInfo.processInfo.activeProcessorCount), reps2108                )2109                let chunkSize = (reps + chunkCount - 1) / chunkCount2110                for chunk in 0..<chunkCount {2111                    let lower = chunk * chunkSize2112                    let upper = min(reps, lower + chunkSize)2113                    guard lower < upper else { continue }2114                    group.addTask {2115                        var results: [(Int, [Double])] = []2116                        results.reserveCapacity(upper - lower)2117                        for replicate in lower..<upper {2118                            let indices = ZQResampling.pairsBootstrapIndices(2119                                replicate: replicate, sampleSize: n, generator: generator2120                            )2121                            let resampledY = indices.map { y[$0] }2122                            let resampledPredictors = predictors.map { column in2123                                (name: column.name, values: indices.map { column.values[$0] })2124                            }2125                            let fit = try ZQOLS.fit(2126                                y: resampledY,2127                                predictors: resampledPredictors,2128                                includeConstant: includeConstant,2129                                variance: .classical2130                            )2131                            results.append((replicate, fit.coefficients.map(\.estimate)))2132                        }2133                        return results2134                    }2135                }2136                var collected = [[Double]](repeating: [], count: reps)2137                for try await chunkResults in group {2138                    for (replicate, estimates) in chunkResults {2139                        collected[replicate] = estimates2140                    }2141                }2142                return collected2143            }2144        }21452146        // Singular resamples (NaN rows from the GPU path) are dropped,2147        // Stata-style.2148        let draws = allDraws.filter { row in !row.contains(where: \.isNaN) }2149        let completed = draws.count2150        guard completed >= 2 else {2151            throw ZQEngineError("bootstrap: too many failed replicates")2152        }21532154        // Bootstrap standard errors: sd of replicate estimates.2155        let title = backend == .gpu2156            ? "Bootstrap results (pairs, GPU batched)"2157            : "Bootstrap results (pairs)"2158        var lines = [2159            TableFormatter.pad(title, 46, right: false) + "Replications  = " +2160            TableFormatter.pad("\(completed)", 10),2161            "                                              Number of obs = " +2162            TableFormatter.pad("\(n)", 10),2163        ]2164        if completed < reps {2165            lines.append("(\(reps - completed) replicates dropped: singular resample)")2166        }2167        lines.append("")2168        let widths = [12, 12, 12, 8, 8, 22]2169        lines.append(2170            TableFormatter.pad(sample.responseName, widths[0]) + " | " +2171            TableFormatter.pad("Observed", widths[1]) + "  " +2172            TableFormatter.pad("Bootstrap", widths[2]) + "  " +2173            TableFormatter.pad("z", widths[3]) + "  " +2174            TableFormatter.pad("P>|z|", widths[4]) + "  " +2175            TableFormatter.pad("[95% conf. interval]", widths[5])2176        )2177        lines.append(2178            TableFormatter.pad("", widths[0]) + " | " +2179            TableFormatter.pad("coefficient", widths[1]) + "  " +2180            TableFormatter.pad("std. err.", widths[2])2181        )2182        lines.append(TableFormatter.rule(widths))21832184        var scalars: [String: Double] = [2185            "reps": Double(completed),2186            "N": Double(n),2187            "gpu": backend == .gpu ? 1 : 0,2188        ]2189        for j in 0..<k {2190            let estimates = draws.map { $0[j] }2191            let mean = estimates.reduce(0, +) / Double(completed)2192            let variance = estimates.reduce(0) { $0 + ($1 - mean) * ($1 - mean) }2193                / Double(completed - 1)2194            let se = variance.squareRoot()2195            let observed = point.coefficients[j].estimate2196            let z = observed / se2197            let p = 2 * (1 - ZQDistributions.normalCDF(abs(z)))2198            let critical = 1.959963984540054  // Φ⁻¹(0.975)2199            lines.append(2200                TableFormatter.pad(point.coefficients[j].name, widths[0]) + " | " +2201                TableFormatter.pad(TableFormatter.general(observed), widths[1]) + "  " +2202                TableFormatter.pad(TableFormatter.general(se), widths[2]) + "  " +2203                TableFormatter.pad(TableFormatter.fixed(z, decimals: 2), widths[3]) + "  " +2204                TableFormatter.pad(TableFormatter.fixed(p, decimals: 3), widths[4]) + "  " +2205                TableFormatter.pad(2206                    TableFormatter.general(observed - critical * se) + "   " +2207                    TableFormatter.general(observed + critical * se),2208                    widths[5]2209                )2210            )2211            scalars["b_\(point.coefficients[j].name)"] = observed2212            scalars["se_\(point.coefficients[j].name)"] = se2213        }2214        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)2215    }22162217    // MARK: - Bayesian estimation22182219    /// `bayes [, mcmcsize(#) burnin(#) seed(#) normalprior(#)]: reg y x…`2220    /// — Gibbs-sampled Bayesian linear regression with Stata-style default2221    /// priors: N(0, 10000) on coefficients, InvGamma(0.01, 0.01) on σ².2222    private func handleBayes(_ command: ZQCommand) throws -> ZQResult {2223        guard let body = command.body else {2224            throw ZQEngineError("bayes: syntax is 'bayes [, options]: regress …'")2225        }2226        guard body.verb == "regress" else {2227            throw ZQEngineError("bayes currently supports 'regress' bodies only")2228        }2229        if let seedText = command.option("seed")?.firstArgument {2230            guard let value = UInt64(seedText) else {2231                throw ZQEngineError("bayes: invalid seed")2232            }2233            seed = value2234        }2235        func intOption(_ name: String, default defaultValue: Int) throws -> Int {2236            guard let text = command.option(name)?.firstArgument else { return defaultValue }2237            guard let value = Int(text), value > 0 else {2238                throw ZQEngineError("bayes: invalid \(name)()")2239            }2240            return value2241        }2242        let mcmcSize = try intOption("mcmcsize", default: 10_000)2243        let burnIn = try intOption("burnin", default: 2_500)2244        var priorVariance = 10_000.02245        if let text = command.option("normalprior")?.firstArgument {2246            guard let value = Double(text), value > 0 else {2247                throw ZQEngineError("bayes: invalid normalprior()")2248            }2249            priorVariance = value2250        }22512252        let sample = try buildRegressionSample(body, clusterVariable: nil)2253        let result = try ZQBayesianRegression.fitGibbs(2254            y: sample.y,2255            predictors: sample.predictors,2256            includeConstant: !body.hasOption("noconstant"),2257            mcmcSize: mcmcSize,2258            burnIn: burnIn,2259            seed: seed,2260            coefficientPriorVariance: priorVariance2261        )2262        lastEstimation = nil   // predict after bayes needs posterior draws22632264        var lines = ["Bayesian linear regression (Gibbs)"]2265        if sample.droppedMissing > 0 {2266            lines.append("(\(sample.droppedMissing) observations dropped due to missing values)")2267        }2268        for (label, value) in [2269            ("Number of obs", "\(result.observationCount)"),2270            ("MCMC iterations", "\(result.mcmcSize)"),2271            ("Burn-in", "\(result.burnIn)"),2272            ("Priors", "b ~ N(0, \(TableFormatter.general(priorVariance))), sigma2 ~ IG(.01, .01)"),2273        ] {2274            lines.append(2275                TableFormatter.pad(label, 46, right: false) + "= " +2276                TableFormatter.pad(value, 24)2277            )2278        }2279        lines.append("")22802281        let widths = [12, 12, 12, 24]2282        lines.append(2283            TableFormatter.pad(sample.responseName, widths[0]) + " | " +2284            TableFormatter.pad("Mean", widths[1]) + "  " +2285            TableFormatter.pad("Std. dev.", widths[2]) + "  " +2286            TableFormatter.pad("[95% cred. interval]", widths[3])2287        )2288        lines.append(TableFormatter.rule(widths))22892290        var scalars: [String: Double] = [2291            "N": Double(result.observationCount),2292            "mcmcsize": Double(result.mcmcSize),2293            "burnin": Double(result.burnIn),2294        ]2295        for coefficient in result.coefficients + [result.sigma] {2296            lines.append(2297                TableFormatter.pad(coefficient.name, widths[0]) + " | " +2298                TableFormatter.pad(TableFormatter.general(coefficient.posteriorMean), widths[1]) + "  " +2299                TableFormatter.pad(TableFormatter.general(coefficient.posteriorSD), widths[2]) + "  " +2300                TableFormatter.pad(2301                    TableFormatter.general(coefficient.credibleLower) + "   " +2302                    TableFormatter.general(coefficient.credibleUpper),2303                    widths[3]2304                )2305            )2306            scalars["b_\(coefficient.name)"] = coefficient.posteriorMean2307            scalars["sd_\(coefficient.name)"] = coefficient.posteriorSD2308        }2309        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)2310    }23112312    // MARK: - Permutation test23132314    /// `permute, reps(#) [seed(#)]: reg y x…` — permutes the response2315    /// within the estimation sample, refits, and reports the empirical2316    /// two-sided p-value per coefficient: the fraction of permuted |β*|2317    /// at or above the observed |β|. Permutations come from the Philox2318    /// argsort stream, so results are exactly reproducible and2319    /// replicate-addressable.2320    private func handlePermute(_ command: ZQCommand) async throws -> ZQResult {2321        guard let body = command.body else {2322            throw ZQEngineError("permute: syntax is 'permute, reps(#): command'")2323        }2324        guard body.verb == "regress" else {2325            throw ZQEngineError("permute currently supports 'regress' bodies only")2326        }2327        guard let repsText = command.option("reps")?.firstArgument,2328              let reps = Int(repsText), reps >= 1 else {2329            throw ZQEngineError("permute: reps(#) with # ≥ 1 required")2330        }2331        if let seedText = command.option("seed")?.firstArgument {2332            guard let value = UInt64(seedText) else {2333                throw ZQEngineError("permute: invalid seed")2334            }2335            seed = value2336        }23372338        let sample = try buildRegressionSample(body, clusterVariable: nil)2339        let includeConstant = !body.hasOption("noconstant")2340        let observed = try ZQOLS.fit(2341            y: sample.y,2342            predictors: sample.predictors,2343            includeConstant: includeConstant,2344            variance: .classical2345        )2346        let n = sample.y.count2347        let k = observed.coefficients.count2348        let generator = Philox4x32(seed: seed)2349        let y = sample.y2350        let predictors = sample.predictors23512352        // Count |β*| ≥ |β_obs| per coefficient, in parallel chunks —2353        // permutations are counter-addressable so order is irrelevant.2354        let observedMagnitudes = observed.coefficients.map { abs($0.estimate) }2355        let chunkCount = min(2356            max(1, ProcessInfo.processInfo.activeProcessorCount), reps2357        )2358        let chunkSize = (reps + chunkCount - 1) / chunkCount2359        let exceedances: [Int] = try await withThrowingTaskGroup(of: [Int].self) { group in2360            for chunk in 0..<chunkCount {2361                let lower = chunk * chunkSize2362                let upper = min(reps, lower + chunkSize)2363                guard lower < upper else { continue }2364                group.addTask {2365                    var counts = [Int](repeating: 0, count: k)2366                    for replicate in lower..<upper {2367                        let order = ZQResampling.permutationIndices(2368                            replicate: replicate, sampleSize: n, generator: generator2369                        )2370                        let fit = try ZQOLS.fit(2371                            y: order.map { y[$0] },2372                            predictors: predictors,2373                            includeConstant: includeConstant,2374                            variance: .classical2375                        )2376                        for j in 0..<k2377                        where abs(fit.coefficients[j].estimate) >= observedMagnitudes[j] {2378                            counts[j] += 12379                        }2380                    }2381                    return counts2382                }2383            }2384            var total = [Int](repeating: 0, count: k)2385            for try await counts in group {2386                for j in 0..<k { total[j] += counts[j] }2387            }2388            return total2389        }23902391        var lines = [2392            TableFormatter.pad("Permutation test (response permuted)", 46, right: false) +2393            "Replications  = " + TableFormatter.pad("\(reps)", 10),2394            "                                              Number of obs = " +2395            TableFormatter.pad("\(n)", 10),2396            "",2397        ]2398        let widths = [12, 12, 8, 8, 10, 11]2399        lines.append(2400            TableFormatter.pad(sample.responseName, widths[0]) + " | " +2401            TableFormatter.pad("Observed", widths[1]) + "  " +2402            TableFormatter.pad("c", widths[2]) + "  " +2403            TableFormatter.pad("reps", widths[3]) + "  " +2404            TableFormatter.pad("p=c/reps", widths[4]) + "  " +2405            TableFormatter.pad("SE(p)", widths[5])2406        )2407        lines.append(TableFormatter.rule(widths))24082409        var scalars: [String: Double] = ["reps": Double(reps), "N": Double(n)]2410        for j in 0..<k {2411            let name = observed.coefficients[j].name2412            // The constant's permutation distribution is degenerate under2413            // response permutation; Stata omits it too.2414            if name == "_cons" { continue }2415            let p = Double(exceedances[j]) / Double(reps)2416            let standardError = (p * (1 - p) / Double(reps)).squareRoot()2417            lines.append(2418                TableFormatter.pad(name, widths[0]) + " | " +2419                TableFormatter.pad(2420                    TableFormatter.general(observed.coefficients[j].estimate), widths[1]2421                ) + "  " +2422                TableFormatter.pad("\(exceedances[j])", widths[2]) + "  " +2423                TableFormatter.pad("\(reps)", widths[3]) + "  " +2424                TableFormatter.pad(TableFormatter.fixed(p, decimals: 4), widths[4]) + "  " +2425                TableFormatter.pad(TableFormatter.fixed(standardError, decimals: 4), widths[5])2426            )2427            scalars["b_\(name)"] = observed.coefficients[j].estimate2428            scalars["p_\(name)"] = p2429            scalars["c_\(name)"] = Double(exceedances[j])2430        }2431        return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)2432    }24332434    // MARK: - Graphics24352436    private func handleGraph(_ command: ZQCommand) throws -> ZQResult {2437        guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }2438        let kind: ZQPlotSpec.Kind2439        switch command.subverb {2440        case "scatter": kind = .scatter2441        case "line": kind = .line2442        case "histogram": kind = .histogram2443        case "kdensity": kind = .kdensity2444        case let other:2445            throw ZQEngineError("graph \(other ?? ""): not supported yet")2446        }24472448        let names = command.varlist.flatMap(\.referencedNames)2449        let mask = try observationMask(command)24502451        // Single-variable plots: histogram and kernel density.2452        if kind == .histogram || kind == .kdensity {2453            guard names.count == 1 else {2454                throw ZQEngineError("\(command.subverb ?? ""): syntax is '\(command.subverb ?? "") varname'")2455            }2456            let (values, missing) = try frame.requireNumeric(names[0])2457            let data = (0..<frame.rowCount)2458                .filter { mask[$0] && !missing[$0] }2459                .map { values[$0] }2460            guard data.count > 1 else { throw ZQEngineError("no observations") }24612462            let series: ZQPlotSpec.Series2463            if kind == .histogram {2464                series = Self.histogramSeries(2465                    data: data, label: names[0],2466                    binCount: command.option("bins")?.firstArgument.flatMap(Int.init)2467                )2468            } else {2469                series = Self.kernelDensitySeries(data: data, label: names[0])2470            }2471            lastPlot = ZQPlotSpec(2472                kind: kind,2473                xLabel: names[0],2474                yLabel: kind == .histogram ? "Frequency" : "Density",2475                series: [series]2476            )2477            return ZQResult(2478                text: "(plot created: \(data.count) observations)",2479                scalars: ["N": Double(data.count)]2480            )2481        }24822483        guard names.count == 2 else {2484            throw ZQEngineError("graph \(command.subverb ?? ""): syntax is 'graph \(command.subverb ?? "kind") yvar xvar'")2485        }2486        let (yValues, yMissing) = try frame.requireNumeric(names[0])2487        let (xValues, xMissing) = try frame.requireNumeric(names[1])24882489        func series(label: String, rows: [Int]) -> ZQPlotSpec.Series {2490            ZQPlotSpec.Series(2491                label: label,2492                x: rows.map { xValues[$0] },2493                y: rows.map { yValues[$0] }2494            )2495        }24962497        let complete = (0..<frame.rowCount).filter {2498            mask[$0] && !yMissing[$0] && !xMissing[$0]2499        }25002501        var allSeries: [ZQPlotSpec.Series] = []2502        if let byName = command.option("by")?.firstArgument {2503            let (byValues, byMissing) = try frame.requireNumeric(byName)2504            let levels = ZQFactorExpansion.levels(2505                values: complete.map { byValues[$0] },2506                missing: complete.map { byMissing[$0] }2507            )2508            for level in levels {2509                let rows = complete.filter { !byMissing[$0] && byValues[$0] == level }2510                let label = level == level.rounded()2511                    ? "\(byName)=\(Int(level))"2512                    : "\(byName)=\(level)"2513                allSeries.append(series(label: label, rows: rows))2514            }2515        } else {2516            allSeries.append(series(label: names[0], rows: complete))2517        }25182519        lastPlot = ZQPlotSpec(2520            kind: kind,2521            xLabel: names[1],2522            yLabel: names[0],2523            series: allSeries2524        )2525        let pointCount = allSeries.reduce(0) { $0 + $1.x.count }2526        return ZQResult(text: "(plot created: \(pointCount) points)")2527    }25282529    // MARK: - UI support25302531    /// Compiles filter-bar text into a keep-mask over the current dataset2532    /// (the data browser's filter compiles to an `if` expression, §7).2533    /// Missing conditions exclude the observation, like `if` qualifiers.2534    public func conditionMask(_ expressionText: String) throws -> [Bool] {2535        let expression = try parser.parseExpression(expressionText)2536        let evaluator = ExpressionEvaluator(frame: frame)2537        return try evaluator.evaluateCondition(expression)2538    }25392540    /// Frequency histogram: Sturges bin count by default, half-open bins2541    /// with the maximum folded into the last bin. Series points are bin2542    /// midpoints vs counts.2543    static func histogramSeries(2544        data: [Double], label: String, binCount: Int?2545    ) -> ZQPlotSpec.Series {2546        let n = data.count2547        let bins = max(1, binCount ?? Int((Foundation.log2(Double(n))).rounded(.up)) + 1)2548        let minimum = data.min()!2549        let maximum = data.max()!2550        let width = maximum > minimum ? (maximum - minimum) / Double(bins) : 12551        var counts = [Double](repeating: 0, count: bins)2552        for value in data {2553            let raw = Int((value - minimum) / width)2554            counts[min(max(raw, 0), bins - 1)] += 12555        }2556        let midpoints = (0..<bins).map { minimum + (Double($0) + 0.5) * width }2557        return ZQPlotSpec.Series(label: label, x: midpoints, y: counts)2558    }25592560    /// Epanechnikov kernel density on a 100-point grid with Silverman's2561    /// bandwidth h = 0.9·min(sd, IQR/1.349)·n^(−1/5) (Stata's default).2562    static func kernelDensitySeries(2563        data: [Double], label: String2564    ) -> ZQPlotSpec.Series {2565        let n = data.count2566        let mean = data.reduce(0, +) / Double(n)2567        let sd = (data.reduce(0) { $0 + ($1 - mean) * ($1 - mean) }2568            / Double(n - 1)).squareRoot()2569        let sorted = data.sorted()2570        let iqr = ZQSummarize.stataPercentile(sorted: sorted, percent: 75)2571            - ZQSummarize.stataPercentile(sorted: sorted, percent: 25)2572        var spread = min(sd, iqr / 1.349)2573        if spread <= 0 { spread = max(sd, 1e-9) }2574        let bandwidth = 0.9 * spread * pow(Double(n), -0.2)25752576        let lower = sorted.first! - bandwidth2577        let upper = sorted.last! + bandwidth2578        let gridSize = 1002579        let step = (upper - lower) / Double(gridSize - 1)2580        var xs = [Double](repeating: 0, count: gridSize)2581        var densities = [Double](repeating: 0, count: gridSize)2582        for g in 0..<gridSize {2583            let point = lower + Double(g) * step2584            xs[g] = point2585            var total = 0.02586            for value in data {2587                let u = (point - value) / bandwidth2588                if abs(u) < 1 {2589                    total += 0.75 * (1 - u * u)   // Epanechnikov kernel2590                }2591            }2592            densities[g] = total / (Double(n) * bandwidth)2593        }2594        return ZQPlotSpec.Series(label: label, x: xs, y: densities)2595    }25962597    // MARK: - Qualifiers25982599    /// Combined if/in keep-mask over current observations.2600    private func observationMask(_ command: ZQCommand) throws -> [Bool] {2601        var mask = [Bool](repeating: true, count: frame.rowCount)2602        if let condition = command.condition {2603            let evaluator = ExpressionEvaluator(frame: frame)2604            mask = try evaluator.evaluateCondition(condition)2605        }2606        if let range = command.range {2607            let n = frame.rowCount2608            func resolve(_ bound: Int) -> Int {2609                bound >= 0 ? bound : n + bound + 12610            }2611            let lower = max(1, resolve(range.lower))2612            let upper = min(n, resolve(range.upper))2613            for i in 0..<n where i + 1 < lower || i + 1 > upper {2614                mask[i] = false2615            }2616        }2617        return mask2618    }2619}2620