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%

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>
simon-pierre boucher committed 6 days ago (Aug 5, 2026) parent d891c8b

Showing 6 changed files with +425 and −30

modified CLAUDE.md +1 −0
@@ -345,6 +345,7 @@ Metrika/
345 345 ```
346 346
347 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 349 - Commits: Conventional Commits (`feat(parser): factor variable expansion`).
349 350 - Branches: `main` (protected), `dev`, feature branches `feat/*`.
350 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 12 import ZQData
13 13 import ZQEngine
14 14 import ZQGraphics
15 +import ZQPlugins
15 16
16 17 /// Observable session state for the UI. Every command — typed in the
17 18 /// console or replayed from a do-file — goes through the one `ZQSession`
@@ -45,7 +46,9 @@ final class SessionModel {
45 46
46 47 init() {
47 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 52 } catch {
50 53 entries.append(ConsoleEntry(
51 54 command: "",
modified MetrikaKit/Sources/ZQEngine/Session.swift +82 −17
@@ -34,24 +34,56 @@ public actor ZQSession {
34 34 public private(set) var timeVariable: String?
35 35
36 36 private let store: ZQDataStore
37 private let parser = ZQCommandParser()
37 + private let parser: ZQCommandParser
38 38 private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable)
39 39 private let scriptCommands: [String: ZQScriptCommand]
40 + private let pluginRegistry: ZQPluginRegistry
40 41 private var logFileURL: URL?
41 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 55 self.store = try ZQDataStore()
45 56 self.frame = try ZQDataFrame()
57 +
58 + var commands: [String: ZQScriptCommand] = [:]
46 59 if discoverUserCommands {
47 var commands: [String: ZQScriptCommand] = [:]
48 for script in ZQPluginDiscovery.scriptCommands() {
60 + let scripts = commandsDirectory.map {
61 + ZQPluginDiscovery.scriptCommands(in: $0)
62 + } ?? ZQPluginDiscovery.scriptCommands()
63 + for script in scripts {
49 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 89 // MARK: - Entry point
@@ -91,28 +123,61 @@ public actor ZQSession {
91 123 }
92 124
93 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 143 guard let command = try parser.parse(line) else {
95 144 return ZQResult(text: "")
96 145 }
97 146
98 // User script commands shadow nothing built-in; check after parse
99 // failure would be better UX, but the verb table already resolved.
147 + if let registered = pluginRegistry[command.verb] {
148 + return try await executePlugin(registered, command: command)
149 + }
150 +
100 151 let plan = planner.plan(command, rowCount: frame.rowCount)
101 152 return try await dispatch(plan.command, backend: plan.backend)
102 153 }
103 154
104 private func dispatch(
105 _ command: ZQCommand, backend: ZQBackend = .cpu
155 + /// Runs a native plugin: validates the parsed command against its
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 160 ) async throws -> ZQResult {
107 if let script = scriptCommands[command.verb] {
108 guard scriptDepth < 8 else {
109 throw ZQEngineError("user command recursion too deep")
161 + if let violation = pluginRegistry.validate(command, against: registered.syntax) {
162 + throw ZQEngineError("\(registered.verb): \(violation)")
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
112 defer { scriptDepth -= 1 }
113 return try await executeScript(script.lines.joined(separator: "\n"))
172 + frame = replacement
173 + result.replacementFrame = nil
114 174 }
175 + return result
176 + }
115 177
178 + private func dispatch(
179 + _ command: ZQCommand, backend: ZQBackend = .cpu
180 + ) async throws -> ZQResult {
116 181 switch command.verb {
117 182 case "use": return try await handleUse(command)
118 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 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 48 public struct ZQResult: Equatable, Sendable {
47 49 public var text: String
48 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 58 self.text = text
52 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
57 /// the working dataset. The frame is a value copy — mutations stay local
58 /// unless the plugin declares `mutates: true` and returns a replacement
59 /// via `ZQContext.replacementFrame`.
64 +/// Execution context handed to a plugin: the parsed command and a value
65 +/// copy of the working dataset — mutations stay local unless returned
66 +/// through `ZQResult.replacementFrame` by a `mutates: true` plugin.
60 67 public struct ZQContext: Sendable {
61 68 public var command: ZQCommand
62 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 71 public init(command: ZQCommand, frame: ZQDataFrame) {
68 72 self.command = command
69 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 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 89 public struct ZQScriptCommand: Equatable, Sendable {
85 90 public var verb: String
86 91 public var fileURL: URL
92 + /// Body lines with the `args` declaration already stripped.
87 93 public var lines: [String]
94 + /// Names from the `args` declaration, in positional order.
95 + public var parameterNames: [String]
88 96
89 97 public init(verb: String, fileURL: URL, lines: [String]) {
90 98 self.verb = verb
91 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