feat(plugins): harden the user-command protocol
- fix: script-command verbs were routed AFTER parsing, which rejects
unknown verbs — .zyq commands could never run; they now route before
the parser with raw macro arguments
- .zyq scripts: ado-style 'args name…' declaration with backtick macro
substitution (`0', `k', `name'); scratch-variable scripts verified
end-to-end against the built-in path
- ZQPluginRegistry: verb-keyed native plugin registry; verbs shadowing
built-ins rejected at session start; parsed commands validated against
the declared ZQSyntaxSpec (varlist/if/in/weights/options)
- mutation gating: plugins return a replacement dataset via
ZQResult.replacementFrame, installed only under 'mutates: true' — a
non-mutating plugin returning one is an error and the dataset is
untouched (ZQContext is now a plain value, no writable back-channel)
- ZScorePlugin ships as the reference native plugin, registered by the
app ('zscore varname' -> standardized z_varname)
- CLAUDE.md: document the mlx-swift build quirks (xcodebuild for GPU
tests, -skipPackagePluginValidation everywhere)
- 87 tests green (swift test and xcodebuild with GPU suites); app builds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +425 −30
modified
CLAUDE.md
+1 −0
@@ -345,6 +345,7 @@ Metrika/ | |||||
| 345 | ``` | 345 | ``` | ||
| 346 | 346 | ||||
| 347 | - **Pre-commit hook** runs `scripts/check_headers.sh`: rejects any staged source file (`.swift`, `.metal`, `.sh`, `.py`, `.zyq`) missing the `Author: Simon-Pierre Boucher` / `Contact: contact@spboucher.ai` header. | 347 | - **Pre-commit hook** runs `scripts/check_headers.sh`: rejects any staged source file (`.swift`, `.metal`, `.sh`, `.py`, `.zyq`) missing the `Author: Simon-Pierre Boucher` / `Contact: contact@spboucher.ai` header. | ||
| 348 | +- **Build quirks (mlx-swift)**: SwiftPM CLI cannot compile Metal shaders, so `swift test` skips the GPU suites (CPU fallback via metallib detection); run `xcodebuild test -scheme MetrikaKit-Package -destination 'platform=macOS' -skipPackagePluginValidation` from `MetrikaKit/` for full coverage. All `xcodebuild` invocations (app and tests) need `-skipPackagePluginValidation` for mlx-swift's CudaBuild plugin. | ||||
| 348 | - Commits: Conventional Commits (`feat(parser): factor variable expansion`). | 349 | - Commits: Conventional Commits (`feat(parser): factor variable expansion`). | ||
| 349 | - Branches: `main` (protected), `dev`, feature branches `feat/*`. | 350 | - Branches: `main` (protected), `dev`, feature branches `feat/*`. | ||
| 350 | - CI: GitHub Actions on self-hosted arm64 macOS runner — build, test, bench, header check. | 351 | - CI: GitHub Actions on self-hosted arm64 macOS runner — build, test, bench, header check. | ||
modified
Metrika/Sources/SessionModel.swift
+4 −1
@@ -12,6 +12,7 @@ import SwiftUI | |||||
| 12 | import ZQData | 12 | import ZQData | ||
| 13 | import ZQEngine | 13 | import ZQEngine | ||
| 14 | import ZQGraphics | 14 | import ZQGraphics | ||
| 15 | +import ZQPlugins | ||||
| 15 | 16 | ||||
| 16 | /// Observable session state for the UI. Every command — typed in the | 17 | /// Observable session state for the UI. Every command — typed in the | ||
| 17 | /// console or replayed from a do-file — goes through the one `ZQSession` | 18 | /// console or replayed from a do-file — goes through the one `ZQSession` | ||
@@ -45,7 +46,9 @@ final class SessionModel { | |||||
| 45 | 46 | ||||
| 46 | init() { | 47 | init() { | ||
| 47 | do { | 48 | do { | ||
| 48 | − session = try ZQSession() | 49 | + // Built-in native plugins ship with the app; user .zyq script | ||
| 50 | + // commands are discovered from Application Support. | ||||
| 51 | + session = try ZQSession(plugins: [ZScorePlugin()]) | ||||
| 49 | } catch { | 52 | } catch { | ||
| 50 | entries.append(ConsoleEntry( | 53 | entries.append(ConsoleEntry( | ||
| 51 | command: "", | 54 | command: "", | ||
modified
MetrikaKit/Sources/ZQEngine/Session.swift
+82 −17
@@ -34,24 +34,56 @@ public actor ZQSession { | |||||
| 34 | public private(set) var timeVariable: String? | 34 | public private(set) var timeVariable: String? | ||
| 35 | 35 | ||||
| 36 | private let store: ZQDataStore | 36 | private let store: ZQDataStore | ||
| 37 | − private let parser = ZQCommandParser() | 37 | + private let parser: ZQCommandParser | ||
| 38 | private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable) | 38 | private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable) | ||
| 39 | private let scriptCommands: [String: ZQScriptCommand] | 39 | private let scriptCommands: [String: ZQScriptCommand] | ||
| 40 | + private let pluginRegistry: ZQPluginRegistry | ||||
| 40 | private var logFileURL: URL? | 41 | private var logFileURL: URL? | ||
| 41 | private var scriptDepth = 0 | 42 | private var scriptDepth = 0 | ||
| 42 | 43 | ||||
| 43 | − public init(discoverUserCommands: Bool = true) throws { | 44 | + /// - Parameters: | ||
| 45 | + /// - discoverUserCommands: scan the commands directory for `.zyq` | ||||
| 46 | + /// script commands. | ||||
| 47 | + /// - commandsDirectory: override of the discovery location (tests). | ||||
| 48 | + /// - plugins: SPM-compiled-in native plugins to register. A plugin | ||||
| 49 | + /// whose verb shadows a built-in command is rejected. | ||||
| 50 | + public init( | ||||
| 51 | + discoverUserCommands: Bool = true, | ||||
| 52 | + commandsDirectory: URL? = nil, | ||||
| 53 | + plugins: [any ZQCommandPlugin] = [] | ||||
| 54 | + ) throws { | ||||
| 44 | self.store = try ZQDataStore() | 55 | self.store = try ZQDataStore() | ||
| 45 | self.frame = try ZQDataFrame() | 56 | self.frame = try ZQDataFrame() | ||
| 57 | + | ||||
| 58 | + var commands: [String: ZQScriptCommand] = [:] | ||||
| 46 | if discoverUserCommands { | 59 | if discoverUserCommands { | ||
| 47 | − var commands: [String: ZQScriptCommand] = [:] | 60 | + let scripts = commandsDirectory.map { | ||
| 48 | − for script in ZQPluginDiscovery.scriptCommands() { | 61 | + ZQPluginDiscovery.scriptCommands(in: $0) | ||
| 62 | + } ?? ZQPluginDiscovery.scriptCommands() | ||||
| 63 | + for script in scripts { | ||||
| 49 | commands[script.verb] = script | 64 | commands[script.verb] = script | ||
| 50 | } | 65 | } | ||
| 51 | − self.scriptCommands = commands | ||||
| 52 | − } else { | ||||
| 53 | − self.scriptCommands = [:] | ||||
| 54 | } | 66 | } | ||
| 67 | + self.scriptCommands = commands | ||||
| 68 | + | ||||
| 69 | + let builtinVerbs = Set(ZQVerbTable.builtin.verbs.keys) | ||||
| 70 | + self.pluginRegistry = try ZQPluginRegistry( | ||||
| 71 | + plugins: plugins, reservedVerbs: builtinVerbs | ||||
| 72 | + ) | ||||
| 73 | + | ||||
| 74 | + // Plugin verbs join the grammar so their commands parse with full | ||||
| 75 | + // varlist/if/in/options structure (no abbreviations for plugins). | ||||
| 76 | + var verbs = ZQVerbTable.builtin.verbs | ||||
| 77 | + for verb in pluginRegistry.verbs { | ||||
| 78 | + verbs[verb] = verb.count | ||||
| 79 | + } | ||||
| 80 | + self.parser = ZQCommandParser(verbTable: ZQVerbTable( | ||||
| 81 | + verbs: verbs, | ||||
| 82 | + fileVerbs: ZQVerbTable.builtin.fileVerbs, | ||||
| 83 | + assignmentVerbs: ZQVerbTable.builtin.assignmentVerbs, | ||||
| 84 | + prefixVerbs: ZQVerbTable.builtin.prefixVerbs, | ||||
| 85 | + compoundVerbs: ZQVerbTable.builtin.compoundVerbs | ||||
| 86 | + )) | ||||
| 55 | } | 87 | } | ||
| 56 | 88 | ||||
| 57 | // MARK: - Entry point | 89 | // MARK: - Entry point | ||
@@ -91,28 +123,61 @@ public actor ZQSession { | |||||
| 91 | } | 123 | } | ||
| 92 | 124 | ||||
| 93 | private func run(_ line: String) async throws -> ZQResult { | 125 | private func run(_ line: String) async throws -> ZQResult { | ||
| 126 | + // Script commands are routed BEFORE the parser: their verbs are | ||||
| 127 | + // not in the grammar and their arguments are raw macro text. | ||||
| 128 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | ||||
| 129 | + let verbWord = String(trimmed.prefix { !$0.isWhitespace }) | ||||
| 130 | + if let script = scriptCommands[verbWord] { | ||||
| 131 | + guard scriptDepth < 8 else { | ||||
| 132 | + throw ZQEngineError("user command recursion too deep") | ||||
| 133 | + } | ||||
| 134 | + scriptDepth += 1 | ||||
| 135 | + defer { scriptDepth -= 1 } | ||||
| 136 | + let arguments = String(trimmed.dropFirst(verbWord.count)) | ||||
| 137 | + .trimmingCharacters(in: .whitespaces) | ||||
| 138 | + let body = script.expandedLines(arguments: arguments) | ||||
| 139 | + .joined(separator: "\n") | ||||
| 140 | + return try await executeScript(body) | ||||
| 141 | + } | ||||
| 142 | + | ||||
| 94 | guard let command = try parser.parse(line) else { | 143 | guard let command = try parser.parse(line) else { | ||
| 95 | return ZQResult(text: "") | 144 | return ZQResult(text: "") | ||
| 96 | } | 145 | } | ||
| 97 | 146 | ||||
| 98 | − // User script commands shadow nothing built-in; check after parse | 147 | + if let registered = pluginRegistry[command.verb] { | ||
| 99 | − // failure would be better UX, but the verb table already resolved. | 148 | + return try await executePlugin(registered, command: command) | ||
| 149 | + } | ||||
| 150 | + | ||||
| 100 | let plan = planner.plan(command, rowCount: frame.rowCount) | 151 | let plan = planner.plan(command, rowCount: frame.rowCount) | ||
| 101 | return try await dispatch(plan.command, backend: plan.backend) | 152 | return try await dispatch(plan.command, backend: plan.backend) | ||
| 102 | } | 153 | } | ||
| 103 | 154 | ||||
| 104 | − private func dispatch( | 155 | + /// Runs a native plugin: validates the parsed command against its | ||
| 105 | − _ command: ZQCommand, backend: ZQBackend = .cpu | 156 | + /// declared syntax, hands it a value copy of the dataset, and installs | ||
| 157 | + /// a returned replacement frame only when `mutates: true`. | ||||
| 158 | + private func executePlugin( | ||||
| 159 | + _ registered: ZQPluginRegistry.Registered, command: ZQCommand | ||||
| 106 | ) async throws -> ZQResult { | 160 | ) async throws -> ZQResult { | ||
| 107 | − if let script = scriptCommands[command.verb] { | 161 | + if let violation = pluginRegistry.validate(command, against: registered.syntax) { | ||
| 108 | − guard scriptDepth < 8 else { | 162 | + throw ZQEngineError("\(registered.verb): \(violation)") | ||
| 109 | − throw ZQEngineError("user command recursion too deep") | 163 | + } | ||
| 164 | + let context = ZQContext(command: command, frame: frame) | ||||
| 165 | + var result = try await registered.plugin.execute(context) | ||||
| 166 | + if let replacement = result.replacementFrame { | ||||
| 167 | + guard registered.syntax.mutates else { | ||||
| 168 | + throw ZQEngineError( | ||||
| 169 | + "\(registered.verb): plugin returned a dataset but does not declare 'mutates'" | ||||
| 170 | + ) | ||||
| 110 | } | 171 | } | ||
| 111 | − scriptDepth += 1 | 172 | + frame = replacement | ||
| 112 | − defer { scriptDepth -= 1 } | 173 | + result.replacementFrame = nil | ||
| 113 | − return try await executeScript(script.lines.joined(separator: "\n")) | ||||
| 114 | } | 174 | } | ||
| 175 | + return result | ||||
| 176 | + } | ||||
| 115 | 177 | ||||
| 178 | + private func dispatch( | ||||
| 179 | + _ command: ZQCommand, backend: ZQBackend = .cpu | ||||
| 180 | + ) async throws -> ZQResult { | ||||
| 116 | switch command.verb { | 181 | switch command.verb { | ||
| 117 | case "use": return try await handleUse(command) | 182 | case "use": return try await handleUse(command) | ||
| 118 | case "save": return try await handleSave(command) | 183 | case "save": return try await handleSave(command) | ||
modified
MetrikaKit/Sources/ZQPlugins/PluginProtocol.swift
+127 −12
@@ -42,32 +42,35 @@ public struct ZQSyntaxSpec: Equatable, Sendable { | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | /// Result of executing a command: rendered console text plus named scalar | 44 | /// Result of executing a command: rendered console text plus named scalar | ||
| 45 | −/// results (Stata's `r()` analog). | 45 | +/// results (Stata's `r()` analog). Mutating plugins return the new | ||
| 46 | +/// working dataset via `replacementFrame`; the engine installs it only | ||||
| 47 | +/// when the plugin's syntax declares `mutates: true`. | ||||
| 46 | public struct ZQResult: Equatable, Sendable { | 48 | public struct ZQResult: Equatable, Sendable { | ||
| 47 | public var text: String | 49 | public var text: String | ||
| 48 | public var scalars: [String: Double] | 50 | public var scalars: [String: Double] | ||
| 51 | + public var replacementFrame: ZQDataFrame? | ||||
| 49 | 52 | ||||
| 50 | − public init(text: String, scalars: [String: Double] = [:]) { | 53 | + public init( | ||
| 54 | + text: String, | ||||
| 55 | + scalars: [String: Double] = [:], | ||||
| 56 | + replacementFrame: ZQDataFrame? = nil | ||||
| 57 | + ) { | ||||
| 51 | self.text = text | 58 | self.text = text | ||
| 52 | self.scalars = scalars | 59 | self.scalars = scalars | ||
| 60 | + self.replacementFrame = replacementFrame | ||||
| 53 | } | 61 | } | ||
| 54 | } | 62 | } | ||
| 55 | 63 | ||||
| 56 | −/// Execution context handed to a plugin: the parsed command and a view of | 64 | +/// Execution context handed to a plugin: the parsed command and a value | ||
| 57 | −/// the working dataset. The frame is a value copy — mutations stay local | 65 | +/// copy of the working dataset — mutations stay local unless returned | ||
| 58 | −/// unless the plugin declares `mutates: true` and returns a replacement | 66 | +/// through `ZQResult.replacementFrame` by a `mutates: true` plugin. | ||
| 59 | −/// via `ZQContext.replacementFrame`. | ||||
| 60 | public struct ZQContext: Sendable { | 67 | public struct ZQContext: Sendable { | ||
| 61 | public var command: ZQCommand | 68 | public var command: ZQCommand | ||
| 62 | public var frame: ZQDataFrame | 69 | public var frame: ZQDataFrame | ||
| 63 | − /// Set by mutating plugins; the engine installs it as the new working | ||||
| 64 | − /// dataset after successful execution. | ||||
| 65 | − public var replacementFrame: ZQDataFrame? | ||||
| 66 | 70 | ||||
| 67 | public init(command: ZQCommand, frame: ZQDataFrame) { | 71 | public init(command: ZQCommand, frame: ZQDataFrame) { | ||
| 68 | self.command = command | 72 | self.command = command | ||
| 69 | self.frame = frame | 73 | self.frame = frame | ||
| 70 | − self.replacementFrame = nil | ||||
| 71 | } | 74 | } | ||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
@@ -80,16 +83,128 @@ public protocol ZQCommandPlugin: Sendable { | |||||
| 80 | } | 83 | } | ||
| 81 | 84 | ||||
| 82 | /// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of | 85 | /// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of | ||
| 83 | −/// ZQL lines the engine replays. | 86 | +/// ZQL lines the engine replays. An optional leading `args name1 name2 …` | ||
| 87 | +/// declaration names positional arguments; script lines reference them | ||||
| 88 | +/// Stata-style: `` `1' ``, `` `name' ``, and `` `0' `` (everything). | ||||
| 84 | public struct ZQScriptCommand: Equatable, Sendable { | 89 | public struct ZQScriptCommand: Equatable, Sendable { | ||
| 85 | public var verb: String | 90 | public var verb: String | ||
| 86 | public var fileURL: URL | 91 | public var fileURL: URL | ||
| 92 | + /// Body lines with the `args` declaration already stripped. | ||||
| 87 | public var lines: [String] | 93 | public var lines: [String] | ||
| 94 | + /// Names from the `args` declaration, in positional order. | ||||
| 95 | + public var parameterNames: [String] | ||||
| 88 | 96 | ||||
| 89 | public init(verb: String, fileURL: URL, lines: [String]) { | 97 | public init(verb: String, fileURL: URL, lines: [String]) { | ||
| 90 | self.verb = verb | 98 | self.verb = verb | ||
| 91 | self.fileURL = fileURL | 99 | self.fileURL = fileURL | ||
| 92 | − self.lines = lines | 100 | + | ||
| 101 | + // Peel a leading `args` declaration (blank/comment lines may | ||||
| 102 | + // precede it). | ||||
| 103 | + var parameterNames: [String] = [] | ||||
| 104 | + var body = lines | ||||
| 105 | + for (index, line) in lines.enumerated() { | ||||
| 106 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | ||||
| 107 | + if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") { | ||||
| 108 | + continue | ||||
| 109 | + } | ||||
| 110 | + if trimmed.hasPrefix("args ") || trimmed.hasPrefix("args\t") { | ||||
| 111 | + parameterNames = trimmed.dropFirst(4) | ||||
| 112 | + .split(separator: " ", omittingEmptySubsequences: true) | ||||
| 113 | + .map(String.init) | ||||
| 114 | + body.remove(at: index) | ||||
| 115 | + } | ||||
| 116 | + break | ||||
| 117 | + } | ||||
| 118 | + self.lines = body | ||||
| 119 | + self.parameterNames = parameterNames | ||||
| 120 | + } | ||||
| 121 | + | ||||
| 122 | + /// Substitutes `` `0' ``, `` `k' ``, and `` `name' `` macros with the | ||||
| 123 | + /// invocation arguments (whitespace-split remainder of the command). | ||||
| 124 | + public func expandedLines(arguments rawArguments: String) -> [String] { | ||||
| 125 | + let arguments = rawArguments | ||||
| 126 | + .split(separator: " ", omittingEmptySubsequences: true) | ||||
| 127 | + .map(String.init) | ||||
| 128 | + var substitutions: [String: String] = ["0": rawArguments] | ||||
| 129 | + for (index, value) in arguments.enumerated() { | ||||
| 130 | + substitutions["\(index + 1)"] = value | ||||
| 131 | + if index < parameterNames.count { | ||||
| 132 | + substitutions[parameterNames[index]] = value | ||||
| 133 | + } | ||||
| 134 | + } | ||||
| 135 | + // Declared but unsupplied parameters expand to nothing. | ||||
| 136 | + for name in parameterNames where substitutions[name] == nil { | ||||
| 137 | + substitutions[name] = "" | ||||
| 138 | + } | ||||
| 139 | + | ||||
| 140 | + return lines.map { line in | ||||
| 141 | + var expanded = line | ||||
| 142 | + for (key, value) in substitutions { | ||||
| 143 | + expanded = expanded.replacingOccurrences(of: "`\(key)'", with: value) | ||||
| 144 | + } | ||||
| 145 | + return expanded | ||||
| 146 | + } | ||||
| 147 | + } | ||||
| 148 | +} | ||||
| 149 | + | ||||
| 150 | +/// Immutable registry of native plugins, verb-keyed. Built once at session | ||||
| 151 | +/// start; verbs that would shadow a built-in are rejected outright. | ||||
| 152 | +public struct ZQPluginRegistry: Sendable { | ||||
| 153 | + public struct Registered: Sendable { | ||||
| 154 | + public let verb: String | ||||
| 155 | + public let syntax: ZQSyntaxSpec | ||||
| 156 | + public let plugin: any ZQCommandPlugin | ||||
| 157 | + } | ||||
| 158 | + | ||||
| 159 | + public struct ShadowingError: Error, CustomStringConvertible { | ||||
| 160 | + public let verb: String | ||||
| 161 | + public var description: String { | ||||
| 162 | + "plugin verb '\(verb)' shadows a built-in command" | ||||
| 163 | + } | ||||
| 164 | + } | ||||
| 165 | + | ||||
| 166 | + private var byVerb: [String: Registered] = [:] | ||||
| 167 | + | ||||
| 168 | + public init( | ||||
| 169 | + plugins: [any ZQCommandPlugin], | ||||
| 170 | + reservedVerbs: Set<String> | ||||
| 171 | + ) throws { | ||||
| 172 | + for plugin in plugins { | ||||
| 173 | + let type = type(of: plugin) | ||||
| 174 | + let verb = type.verb | ||||
| 175 | + guard !reservedVerbs.contains(verb) else { | ||||
| 176 | + throw ShadowingError(verb: verb) | ||||
| 177 | + } | ||||
| 178 | + byVerb[verb] = Registered(verb: verb, syntax: type.syntax, plugin: plugin) | ||||
| 179 | + } | ||||
| 180 | + } | ||||
| 181 | + | ||||
| 182 | + public var verbs: [String] { Array(byVerb.keys) } | ||||
| 183 | + public var isEmpty: Bool { byVerb.isEmpty } | ||||
| 184 | + | ||||
| 185 | + public subscript(verb: String) -> Registered? { byVerb[verb] } | ||||
| 186 | + | ||||
| 187 | + /// Pre-execution validation of a parsed command against the plugin's | ||||
| 188 | + /// declared syntax. | ||||
| 189 | + public func validate(_ command: ZQCommand, against syntax: ZQSyntaxSpec) -> String? { | ||||
| 190 | + if !syntax.acceptsVarlist && !command.varlist.isEmpty { | ||||
| 191 | + return "does not accept a varlist" | ||||
| 192 | + } | ||||
| 193 | + if !syntax.acceptsCondition && command.condition != nil { | ||||
| 194 | + return "does not accept an 'if' qualifier" | ||||
| 195 | + } | ||||
| 196 | + if !syntax.acceptsRange && command.range != nil { | ||||
| 197 | + return "does not accept an 'in' range" | ||||
| 198 | + } | ||||
| 199 | + if !syntax.acceptsWeight && command.weight != nil { | ||||
| 200 | + return "does not accept weights" | ||||
| 201 | + } | ||||
| 202 | + if !syntax.options.isEmpty { | ||||
| 203 | + for option in command.options where !syntax.options.contains(option.name) { | ||||
| 204 | + return "option '\(option.name)' not allowed" | ||||
| 205 | + } | ||||
| 206 | + } | ||||
| 207 | + return nil | ||||
| 93 | } | 208 | } | ||
| 94 | } | 209 | } | ||
| 95 | 210 | ||||
added
MetrikaKit/Sources/ZQPlugins/ZScorePlugin.swift
+69 −0
@@ -0,0 +1,69 @@ | |||||
| 1 | +// | ||||
| 2 | +// ZScorePlugin.swift | ||||
| 3 | +// Metrika | ||||
| 4 | +// | ||||
| 5 | +// Author: Simon-Pierre Boucher | ||||
| 6 | +// Contact: contact@spboucher.ai | ||||
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | ||||
| 8 | +// | ||||
| 9 | + | ||||
| 10 | +import ZQData | ||||
| 11 | +import ZQParser | ||||
| 12 | + | ||||
| 13 | +/// Reference native plugin (CLAUDE.md §4): `zscore varname` generates | ||||
| 14 | +/// `z_varname`, the standardized version of a numeric variable. Declares | ||||
| 15 | +/// `mutates: true` and returns the updated dataset via | ||||
| 16 | +/// `ZQResult.replacementFrame` — the pattern SPM-compiled-in user | ||||
| 17 | +/// plugins follow. | ||||
| 18 | +public struct ZScorePlugin: ZQCommandPlugin { | ||||
| 19 | + public static let verb = "zscore" | ||||
| 20 | + public static let syntax = ZQSyntaxSpec( | ||||
| 21 | + acceptsVarlist: true, | ||||
| 22 | + acceptsCondition: false, | ||||
| 23 | + acceptsRange: false, | ||||
| 24 | + acceptsWeight: false, | ||||
| 25 | + options: [], | ||||
| 26 | + mutates: true | ||||
| 27 | + ) | ||||
| 28 | + | ||||
| 29 | + public init() {} | ||||
| 30 | + | ||||
| 31 | + public func execute(_ ctx: ZQContext) async throws -> ZQResult { | ||||
| 32 | + let names = ctx.command.varlist.flatMap(\.referencedNames) | ||||
| 33 | + guard names.count == 1, let name = names.first else { | ||||
| 34 | + throw ZQDataError("zscore: syntax is 'zscore varname'") | ||||
| 35 | + } | ||||
| 36 | + let (values, missing) = try ctx.frame.requireNumeric(name) | ||||
| 37 | + | ||||
| 38 | + var count = 0 | ||||
| 39 | + var sum = 0.0 | ||||
| 40 | + for i in 0..<values.count where !missing[i] { | ||||
| 41 | + sum += values[i] | ||||
| 42 | + count += 1 | ||||
| 43 | + } | ||||
| 44 | + guard count > 1 else { throw ZQDataError("zscore: insufficient observations") } | ||||
| 45 | + let mean = sum / Double(count) | ||||
| 46 | + var squared = 0.0 | ||||
| 47 | + for i in 0..<values.count where !missing[i] { | ||||
| 48 | + squared += (values[i] - mean) * (values[i] - mean) | ||||
| 49 | + } | ||||
| 50 | + let sd = (squared / Double(count - 1)).squareRoot() | ||||
| 51 | + guard sd > 0 else { throw ZQDataError("zscore: '\(name)' is constant") } | ||||
| 52 | + | ||||
| 53 | + var scores = [Double](repeating: .nan, count: values.count) | ||||
| 54 | + for i in 0..<values.count where !missing[i] { | ||||
| 55 | + scores[i] = (values[i] - mean) / sd | ||||
| 56 | + } | ||||
| 57 | + | ||||
| 58 | + var frame = ctx.frame | ||||
| 59 | + try frame.addColumn(ZQColumn( | ||||
| 60 | + name: "z_\(name)", | ||||
| 61 | + data: .float64(values: scores, missing: missing) | ||||
| 62 | + )) | ||||
| 63 | + return ZQResult( | ||||
| 64 | + text: "(variable z_\(name) generated: mean \(mean), sd \(sd))", | ||||
| 65 | + scalars: ["mean": mean, "sd": sd], | ||||
| 66 | + replacementFrame: frame | ||||
| 67 | + ) | ||||
| 68 | + } | ||||
| 69 | +} | ||||
added
MetrikaKit/Tests/MetrikaKitTests/PluginTests.swift
+142 −0
@@ -0,0 +1,142 @@ | |||||
| 1 | +// | ||||
| 2 | +// PluginTests.swift | ||||
| 3 | +// Metrika | ||||
| 4 | +// | ||||
| 5 | +// Author: Simon-Pierre Boucher | ||||
| 6 | +// Contact: contact@spboucher.ai | ||||
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | ||||
| 8 | +// | ||||
| 9 | + | ||||
| 10 | +import Foundation | ||||
| 11 | +import Testing | ||||
| 12 | +import ZQData | ||||
| 13 | +import ZQEngine | ||||
| 14 | +import ZQParser | ||||
| 15 | +import ZQPlugins | ||||
| 16 | + | ||||
| 17 | +/// User-command tests: `.zyq` script commands with `args` substitution, | ||||
| 18 | +/// and native ZQCommandPlugin registration, validation, and mutation | ||||
| 19 | +/// gating. | ||||
| 20 | +@Suite("Plugin protocol", .serialized) | ||||
| 21 | +struct PluginTests { | ||||
| 22 | + let fixtures: Fixtures | ||||
| 23 | + let commandsDirectory: URL | ||||
| 24 | + | ||||
| 25 | + init() throws { | ||||
| 26 | + self.fixtures = try Fixtures() | ||||
| 27 | + self.commandsDirectory = FileManager.default.temporaryDirectory | ||||
| 28 | + .appendingPathComponent("metrika_commands_\(UUID().uuidString)") | ||||
| 29 | + try FileManager.default.createDirectory( | ||||
| 30 | + at: commandsDirectory, withIntermediateDirectories: true | ||||
| 31 | + ) | ||||
| 32 | + let script = """ | ||||
| 33 | + // robust regression of a logged response — ado-file analog | ||||
| 34 | + args response predictor | ||||
| 35 | + gen __log_response = ln(`response') | ||||
| 36 | + reg __log_response `predictor', robust | ||||
| 37 | + drop __log_response | ||||
| 38 | + """ | ||||
| 39 | + try Data(script.utf8).write( | ||||
| 40 | + to: commandsDirectory.appendingPathComponent("logreg.zyq") | ||||
| 41 | + ) | ||||
| 42 | + } | ||||
| 43 | + | ||||
| 44 | + private func makeSession(plugins: [any ZQCommandPlugin] = []) async throws -> ZQSession { | ||||
| 45 | + let session = try ZQSession( | ||||
| 46 | + discoverUserCommands: true, | ||||
| 47 | + commandsDirectory: commandsDirectory, | ||||
| 48 | + plugins: plugins | ||||
| 49 | + ) | ||||
| 50 | + _ = try await session.execute("use \(fixtures.datasetURL.path)") | ||||
| 51 | + return session | ||||
| 52 | + } | ||||
| 53 | + | ||||
| 54 | + @Test("script command with args substitution matches the built-in path") | ||||
| 55 | + func scriptCommand() async throws { | ||||
| 56 | + let session = try await makeSession() | ||||
| 57 | + let result = try await session.execute("logreg revenue price") | ||||
| 58 | + // Same estimate as running the commands directly. | ||||
| 59 | + expectClose( | ||||
| 60 | + try #require(result.scalars["b_price"]), | ||||
| 61 | + fixtures["ols_b_price"], "b[price] via script command" | ||||
| 62 | + ) | ||||
| 63 | + // The scratch variable was dropped by the script. | ||||
| 64 | + let count = try await session.execute("count") | ||||
| 65 | + #expect(count.scalars["N"] == 60) | ||||
| 66 | + await #expect(throws: (any Error).self) { | ||||
| 67 | + _ = try await session.execute("summarize __log_response") | ||||
| 68 | + } | ||||
| 69 | + } | ||||
| 70 | + | ||||
| 71 | + @Test("args parsing strips the declaration and names parameters") | ||||
| 72 | + func argsDeclaration() throws { | ||||
| 73 | + let script = ZQScriptCommand( | ||||
| 74 | + verb: "demo", | ||||
| 75 | + fileURL: URL(fileURLWithPath: "/tmp/demo.zyq"), | ||||
| 76 | + lines: ["* comment first", "args a b", "reg `a' `b'", "count if `a' > `2'"] | ||||
| 77 | + ) | ||||
| 78 | + #expect(script.parameterNames == ["a", "b"]) | ||||
| 79 | + #expect(script.lines == ["* comment first", "reg `a' `b'", "count if `a' > `2'"]) | ||||
| 80 | + #expect(script.expandedLines(arguments: "y x") == [ | ||||
| 81 | + "* comment first", "reg y x", "count if y > x", | ||||
| 82 | + ]) | ||||
| 83 | + } | ||||
| 84 | + | ||||
| 85 | + @Test("native plugin executes and its mutation is installed") | ||||
| 86 | + func nativePlugin() async throws { | ||||
| 87 | + let session = try await makeSession(plugins: [ZScorePlugin()]) | ||||
| 88 | + let result = try await session.execute("zscore revenue") | ||||
| 89 | + #expect(result.text.contains("z_revenue")) | ||||
| 90 | + | ||||
| 91 | + // The generated column exists and is standardized. | ||||
| 92 | + let summary = try await session.execute("summarize z_revenue") | ||||
| 93 | + #expect(abs(try #require(summary.scalars["mean"])) < 1e-12) | ||||
| 94 | + expectClose(try #require(summary.scalars["sd"]), 1, "sd = 1") | ||||
| 95 | + } | ||||
| 96 | + | ||||
| 97 | + @Test("plugin syntax validation rejects undeclared qualifiers") | ||||
| 98 | + func syntaxValidation() async throws { | ||||
| 99 | + let session = try await makeSession(plugins: [ZScorePlugin()]) | ||||
| 100 | + // ZScorePlugin declares acceptsCondition: false. | ||||
| 101 | + await #expect(throws: ZQEngineError.self) { | ||||
| 102 | + _ = try await session.execute("zscore revenue if price > 10") | ||||
| 103 | + } | ||||
| 104 | + } | ||||
| 105 | + | ||||
| 106 | + @Test("a plugin shadowing a built-in verb is rejected at startup") | ||||
| 107 | + func shadowingRejected() { | ||||
| 108 | + struct Impostor: ZQCommandPlugin { | ||||
| 109 | + static let verb = "regress" | ||||
| 110 | + static let syntax = ZQSyntaxSpec() | ||||
| 111 | + func execute(_ ctx: ZQContext) async throws -> ZQResult { | ||||
| 112 | + ZQResult(text: "hijacked") | ||||
| 113 | + } | ||||
| 114 | + } | ||||
| 115 | + #expect(throws: ZQPluginRegistry.ShadowingError.self) { | ||||
| 116 | + _ = try ZQSession( | ||||
| 117 | + discoverUserCommands: false, plugins: [Impostor()] | ||||
| 118 | + ) | ||||
| 119 | + } | ||||
| 120 | + } | ||||
| 121 | + | ||||
| 122 | + @Test("non-mutating plugin cannot smuggle a replacement frame") | ||||
| 123 | + func mutationGating() async throws { | ||||
| 124 | + struct Sneaky: ZQCommandPlugin { | ||||
| 125 | + static let verb = "sneaky" | ||||
| 126 | + static let syntax = ZQSyntaxSpec(mutates: false) | ||||
| 127 | + func execute(_ ctx: ZQContext) async throws -> ZQResult { | ||||
| 128 | + ZQResult( | ||||
| 129 | + text: "", | ||||
| 130 | + replacementFrame: try ZQDataFrame(columns: []) | ||||
| 131 | + ) | ||||
| 132 | + } | ||||
| 133 | + } | ||||
| 134 | + let session = try await makeSession(plugins: [Sneaky()]) | ||||
| 135 | + await #expect(throws: ZQEngineError.self) { | ||||
| 136 | + _ = try await session.execute("sneaky") | ||||
| 137 | + } | ||||
| 138 | + // Working dataset untouched. | ||||
| 139 | + let count = try await session.execute("count") | ||||
| 140 | + #expect(count.scalars["N"] == 60) | ||||
| 141 | + } | ||||
| 142 | +} | ||||
| 143 | |||||