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: bootstrap Metrika v0.1 skeleton with working ZQL vertical slice

- MetrikaKit SPM package: ZQParser, ZQPlanner, ZQEngine, ZQData, ZQStats,
  ZQGPU, ZQGraphics, ZQPlugins (Swift 6, strict concurrency)
- ZQL parser: lexer, Pratt expressions, factor variables, prefix commands,
  column-cited errors with Levenshtein verb suggestions
- ZQData: DuckDB-backed load/save (parquet, csv, json, arrow)
- ZQStats: OLS via LAPACK QR, HC0-HC3 and cluster-robust SE, summarize,
  t/F distributions accurate in the far tails
- ZQGPU: Philox4x32-10 reference RNG, counter-addressable bootstrap
- ZQEngine: session actor with use/save/gen/replace/drop/keep/summarize/
  regress/count/list/graph/bootstrap/set seed/xtset/log
- SwiftUI app (xcodegen): console with history, variables sidebar,
  Swift Charts plots; sandboxed + hardened runtime entitlements
- Tests: 44 green (parser golden, R fixtures at 1e-10, Philox KAT,
  end-to-end engine); Tests/Fixtures/generate.R; Tests/Bench harness
- scripts: check_headers.sh + pre-commit hook, make_icns.sh, release.sh

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 5 days ago (Aug 5, 2026)

Showing 69 changed files with +6,758 and −0

added .gitignore +22 −0
@@ -0,0 +1,22 @@
1 +# macOS
2 +.DS_Store
3 +
4 +# Xcode
5 +build/
6 +DerivedData/
7 +*.xcuserstate
8 +xcuserdata/
9 +*.xcscmblueprint
10 +*.xccheckout
11 +
12 +# SPM
13 +.build/
14 +.swiftpm/
15 +
16 +# Generated artifacts
17 +*.icns
18 +*.dmg
19 +
20 +# Local credentials (never commit)
21 +Metrika-local/
22 +Zyquo-local/
added Assets/AppIcon/metrika-icon.svg +131 −0
@@ -0,0 +1,131 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!--
3 + metrika-icon.svg — Metrika
4 + Author: Simon-Pierre Boucher
5 + Contact: contact@spboucher.ai
6 + Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
7 +-->
8 +<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
9 + <defs>
10 + <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
11 + <stop offset="0%" stop-color="#0B1026"/>
12 + <stop offset="45%" stop-color="#141B3D"/>
13 + <stop offset="100%" stop-color="#1E0B38"/>
14 + </linearGradient>
15 + <radialGradient id="glow" cx="50%" cy="52%" r="55%">
16 + <stop offset="0%" stop-color="#7C5CFF" stop-opacity="0.55"/>
17 + <stop offset="55%" stop-color="#4A3AC8" stop-opacity="0.18"/>
18 + <stop offset="100%" stop-color="#000000" stop-opacity="0"/>
19 + </radialGradient>
20 + <linearGradient id="mstroke" x1="0%" y1="0%" x2="100%" y2="0%">
21 + <stop offset="0%" stop-color="#3EF2D4"/>
22 + <stop offset="50%" stop-color="#7C5CFF"/>
23 + <stop offset="100%" stop-color="#FF4FA3"/>
24 + </linearGradient>
25 + <linearGradient id="mcore" x1="0%" y1="0%" x2="100%" y2="0%">
26 + <stop offset="0%" stop-color="#EAFFFB"/>
27 + <stop offset="50%" stop-color="#E4DBFF"/>
28 + <stop offset="100%" stop-color="#FFE1F0"/>
29 + </linearGradient>
30 + <linearGradient id="area" x1="0%" y1="0%" x2="0%" y2="100%">
31 + <stop offset="0%" stop-color="#7C5CFF" stop-opacity="0.30"/>
32 + <stop offset="100%" stop-color="#7C5CFF" stop-opacity="0"/>
33 + </linearGradient>
34 + <filter id="blurBig" x="-40%" y="-40%" width="180%" height="180%">
35 + <feGaussianBlur stdDeviation="26"/>
36 + </filter>
37 + <filter id="blurSoft" x="-40%" y="-40%" width="180%" height="180%">
38 + <feGaussianBlur stdDeviation="7"/>
39 + </filter>
40 + <linearGradient id="sheen" x1="0%" y1="0%" x2="0%" y2="100%">
41 + <stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.16"/>
42 + <stop offset="35%" stop-color="#FFFFFF" stop-opacity="0.04"/>
43 + <stop offset="60%" stop-color="#FFFFFF" stop-opacity="0"/>
44 + </linearGradient>
45 + <clipPath id="squircle">
46 + <rect x="64" y="64" width="896" height="896" rx="200"/>
47 + </clipPath>
48 + </defs>
49 +
50 + <rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
51 +
52 + <g clip-path="url(#squircle)">
53 + <!-- Plot grid -->
54 + <g stroke="#8FA3FF" stroke-opacity="0.07" stroke-width="2">
55 + <line x1="64" y1="288" x2="960" y2="288"/>
56 + <line x1="64" y1="464" x2="960" y2="464"/>
57 + <line x1="64" y1="640" x2="960" y2="640"/>
58 + <line x1="64" y1="816" x2="960" y2="816"/>
59 + <line x1="256" y1="64" x2="256" y2="960"/>
60 + <line x1="432" y1="64" x2="432" y2="960"/>
61 + <line x1="608" y1="64" x2="608" y2="960"/>
62 + <line x1="784" y1="64" x2="784" y2="960"/>
63 + </g>
64 +
65 + <rect x="64" y="64" width="896" height="896" fill="url(#glow)"/>
66 +
67 + <!-- Area fill under the M trace (chart vibe) -->
68 + <path d="M 220 800 L 220 300 L 420 640 L 512 380 L 604 640 L 804 300 L 804 800 Z"
69 + fill="url(#area)"/>
70 +
71 + <!-- Scatter cloud around the trace -->
72 + <g fill="#9DB4FF" fill-opacity="0.55">
73 + <circle cx="286" cy="418" r="9"/>
74 + <circle cx="330" cy="512" r="7"/>
75 + <circle cx="378" cy="566" r="10"/>
76 + <circle cx="452" cy="536" r="8"/>
77 + <circle cx="486" cy="446" r="7"/>
78 + <circle cx="542" cy="452" r="9"/>
79 + <circle cx="580" cy="560" r="8"/>
80 + <circle cx="646" cy="576" r="10"/>
81 + <circle cx="700" cy="470" r="7"/>
82 + <circle cx="748" cy="392" r="9"/>
83 + <circle cx="264" cy="640" r="7"/>
84 + <circle cx="760" cy="640" r="8"/>
85 + </g>
86 + <g fill="#3EF2D4" fill-opacity="0.85">
87 + <circle cx="252" cy="356" r="6"/>
88 + <circle cx="470" cy="410" r="6"/>
89 + <circle cx="676" cy="520" r="6"/>
90 + </g>
91 + <g fill="#FF4FA3" fill-opacity="0.8">
92 + <circle cx="356" cy="600" r="6"/>
93 + <circle cx="556" cy="500" r="7"/>
94 + <circle cx="772" cy="340" r="6"/>
95 + </g>
96 +
97 + <!-- The M : time-series trace -->
98 + <path d="M 220 800 L 220 300 L 420 640 L 512 380 L 604 640 L 804 300 L 804 800"
99 + fill="none" stroke="url(#mstroke)" stroke-width="96"
100 + stroke-linecap="round" stroke-linejoin="round"
101 + filter="url(#blurBig)" opacity="0.75"/>
102 + <path d="M 220 800 L 220 300 L 420 640 L 512 380 L 604 640 L 804 300 L 804 800"
103 + fill="none" stroke="url(#mstroke)" stroke-width="74"
104 + stroke-linecap="round" stroke-linejoin="round"/>
105 + <path d="M 220 800 L 220 300 L 420 640 L 512 380 L 604 640 L 804 300 L 804 800"
106 + fill="none" stroke="url(#mcore)" stroke-width="20"
107 + stroke-linecap="round" stroke-linejoin="round"
108 + filter="url(#blurSoft)" opacity="0.9"/>
109 +
110 + <!-- Data-point nodes at the vertices of the trace -->
111 + <g fill="#FFFFFF">
112 + <circle cx="220" cy="300" r="16"/>
113 + <circle cx="420" cy="640" r="16"/>
114 + <circle cx="512" cy="380" r="16"/>
115 + <circle cx="604" cy="640" r="16"/>
116 + <circle cx="804" cy="300" r="16"/>
117 + </g>
118 + <g fill="none" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="6">
119 + <circle cx="220" cy="300" r="30"/>
120 + <circle cx="420" cy="640" r="30"/>
121 + <circle cx="512" cy="380" r="30"/>
122 + <circle cx="604" cy="640" r="30"/>
123 + <circle cx="804" cy="300" r="30"/>
124 + </g>
125 +
126 + <rect x="64" y="64" width="896" height="520" fill="url(#sheen)"/>
127 + </g>
128 +
129 + <rect x="66" y="66" width="892" height="892" rx="198" fill="none"
130 + stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="3"/>
131 +</svg>
added CLAUDE.md +374 −0
@@ -0,0 +1,374 @@
1 +# CLAUDE.md — Metrika
2 +
3 +> **Metrika** — A next-generation statistical & data science application for macOS.
4 +> Stata-class command syntax, GPU-accelerated by Apple Silicon (MLX/Metal), powered by DuckDB.
5 +> Native Swift + SwiftUI. No Electron. No Python runtime dependency. No compromises.
6 +
7 +---
8 +
9 +## 0. Non-Negotiable Project Rules
10 +
11 +1. **Every source file MUST begin with this header** (adapt comment style to the language):
12 +
13 +```swift
14 +//
15 +// <FileName>.swift
16 +// Metrika
17 +//
18 +// Author: Simon-Pierre Boucher
19 +// Contact: contact@spboucher.ai
20 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
21 +//
22 +```
23 +
24 +For Metal shaders (`.metal`), shell scripts, and Python tooling scripts, use the equivalent comment syntax (`//` or `#`). **No file is committed without this header.** A pre-commit hook (see §12) enforces it.
25 +
26 +2. Language: **Swift 6**, strict concurrency enabled (`-strict-concurrency=complete`).
27 +3. UI: **SwiftUI first**, AppKit bridges (`NSViewRepresentable`) only where SwiftUI is insufficient (text editor, data grid virtualization).
28 +4. Minimum target: **macOS 14 (Sonoma)**, Apple Silicon only (`arm64`). No Intel builds.
29 +5. All numerical results must match reference implementations (R / Stata) to at least 1e-10 relative tolerance for CPU paths; GPU paths documented tolerance 1e-6 (float32) or 1e-10 (float64 where MLX supports it).
30 +6. English for all code, comments, commit messages, and docs.
31 +
32 +---
33 +
34 +## 1. Product Vision
35 +
36 +Metrika is a complete data science environment:
37 +
38 +- **Command-driven**, like Stata: `reg y x1 x2, robust cluster(id)` — one line, publication-ready output.
39 +- **Scriptable**: `.zyq` do-files with full reproducibility (logs, seeds, versioned datasets).
40 +- **GPU-native**: massively parallel workloads (bootstrap, Monte Carlo, MCMC, ML training, permutation tests) run on Apple GPU via MLX with unified memory.
41 +- **Extensible**: user-defined commands ("home-made" commands) via a plugin protocol — drop a Swift package or a `.zyq` ado-style script into `~/Library/Application Support/Metrika/Commands/`.
42 +- **Big-data capable**: DuckDB engine → out-of-core, columnar, multi-threaded. 100M+ rows on a laptop.
43 +
44 +### Feature pillars (v1 scope)
45 +
46 +| Pillar | Contents |
47 +|---|---|
48 +| Data management | import/export CSV, Parquet, Arrow, Excel, Stata `.dta`, JSON; merge, append, reshape, collapse, egen-style generators |
49 +| Estimation | summarize, tabulate, correlate, `regress` (OLS), `logit`, `probit`, `poisson`, `ivregress` (2SLS), `xtreg` (FE/RE), `areg`, quantile regression |
50 +| Inference | robust (HC0–HC3), cluster-robust SE, **GPU bootstrap** (pairs, wild, cluster), permutation tests, jackknife |
51 +| Simulation | Monte Carlo engine, random number generation (Philox counter-based, reproducible on GPU) |
52 +| ML | gradient boosting, elastic net, k-means, PCA — MLX-backed |
53 +| Graphics | scatter, line, histogram, kdensity, coefplot, binscatter — Swift Charts + custom Metal renderer for >1M points |
54 +| Reproducibility | do-file editor, logging (`log using`), `set seed` honored across CPU & GPU |
55 +
56 +---
57 +
58 +## 2. App Name, Bundle & Identity
59 +
60 +- **App name:** Metrika
61 +- **Bundle ID:** `ai.spboucher.metrika`
62 +- **Version scheme:** SemVer (`MARKETING_VERSION`), build number = CI run number.
63 +- **Icon:** `Assets/AppIcon/metrika-icon.svg` is the master (see §11). Rasterize to all required sizes via `scripts/make_icns.sh`.
64 +
65 +---
66 +
67 +## 3. Architecture Overview
68 +
69 +```
70 +┌─────────────────────────────────────────────────────────┐
71 +│ MetrikaApp (SwiftUI) │
72 +│ CommandConsole · DoFileEditor · DataBrowser · Plots │
73 +└──────────────────────────┬──────────────────────────────┘
74 +
75 +┌──────────────────────────▼──────────────────────────────┐
76 +│ MetrikaKit (SPM package) │
77 +│ │
78 +│ ZQParser command grammar → AST │
79 +│ ZQPlanner AST → ExecutionPlan (CPU/GPU/hybrid) │
80 +│ ZQEngine orchestration, sessions, logging │
81 +│ ZQData DataFrame façade over DuckDB/Arrow │
82 +│ ZQStats CPU estimators (Accelerate/LAPACK) │
83 +│ ZQGPU MLX batched estimators, RNG, kernels │
84 +│ ZQGraphics plot spec → Swift Charts / Metal │
85 +│ ZQPlugins user command discovery & sandboxing │
86 +└─────────────────────────────────────────────────────────┘
87 +```
88 +
89 +### Module rules
90 +- `MetrikaKit` is a **Swift Package** with zero UI dependencies → fully testable via `swift test`.
91 +- The app target only imports `MetrikaKit` and renders.
92 +- `ZQGPU` is the **only** module allowed to import MLX/Metal. `ZQStats` is the only one importing Accelerate. This keeps backends swappable.
93 +
94 +### Dependencies (SPM)
95 +```
96 +duckdb/duckdb-swift — data engine
97 +ml-explore/mlx-swift — GPU tensor compute
98 +apple/swift-collections — deques, ordered dicts
99 +pointfreeco/swift-parsing — command grammar
100 +apple/swift-argument-parser — metrika-cli companion tool
101 +```
102 +
103 +---
104 +
105 +## 4. The Command Language (ZQL)
106 +
107 +Stata-compatible mental model, cleaner grammar:
108 +
109 +```
110 +command [varlist] [if expr] [in range] [weight] [, options]
111 +```
112 +
113 +Examples that MUST work in v1:
114 +
115 +```
116 +use sales.parquet
117 +summarize revenue price, detail
118 +gen log_rev = ln(revenue)
119 +reg log_rev price i.region, robust
120 +bootstrap, reps(100000) seed(42): reg log_rev price
121 +xtset firm_id year
122 +xtreg log_rev price, fe cluster(firm_id)
123 +graph scatter log_rev price, by(region)
124 +```
125 +
126 +### Parser (`ZQParser`)
127 +- Built with `swift-parsing`; grammar defined in `Sources/ZQParser/Grammar/`.
128 +- Produces a typed AST: `ZQCommand { verb, varlist, condition, range, weight, options }`.
129 +- Factor variables (`i.region`, `c.age#c.age`) expand at plan time, not parse time.
130 +- Error messages must cite column position and suggest fixes (Levenshtein on known verbs).
131 +
132 +### Home-made commands
133 +Two tiers:
134 +1. **Script commands** (`.zyq` files): sequences of ZQL with `args`/`syntax` declarations — the Stata ado-file analog.
135 +2. **Native plugins**: Swift packages conforming to:
136 +
137 +```swift
138 +public protocol ZQCommandPlugin: Sendable {
139 + static var verb: String { get }
140 + static var syntax: ZQSyntaxSpec { get }
141 + func execute(_ ctx: ZQContext) async throws -> ZQResult
142 +}
143 +```
144 +
145 +Discovery path: `~/Library/Application Support/Metrika/Commands/`. Plugins run in-process but receive a **read-only DataFrame view** unless they declare `mutates: true`.
146 +
147 +---
148 +
149 +## 5. Execution Planner — CPU vs GPU Dispatch
150 +
151 +The planner decides the backend **automatically**. The user never chooses.
152 +
153 +```swift
154 +enum Backend { case cpu, gpu, hybrid }
155 +
156 +func plan(_ cmd: ZQCommand, data: ZQDataFrame) -> ExecutionPlan {
157 + // Heuristics, tuned by benchmarks in Tests/Bench:
158 + // 1. Single estimation, n < 5_000_000 → .cpu (LAPACK QR, dqrls-style)
159 + // 2. reps ≥ 500 independent replicates → .gpu (batched solve)
160 + // 3. MCMC / iterative + large n → .hybrid (GPU likelihood, CPU control)
161 +}
162 +```
163 +
164 +### CPU path (`ZQStats`)
165 +- OLS: QR via LAPACK `dgeqrf`/`dormqr` (never form X'X for conditioning reasons).
166 +- Robust/cluster SE: sandwich estimators, computed with BLAS level-3.
167 +- MLE (logit/probit/poisson): Newton–Raphson with analytic Hessians; IRLS fallback.
168 +- All CPU compute in **Float64**.
169 +
170 +### GPU path (`ZQGPU`, MLX)
171 +- **Batched OLS**: resample indices as `(reps, n)` int tensor → gather → batched Cholesky/QR solve on `(reps, k, k)` — thousands of regressions per GPU pass.
172 +- **RNG**: Philox4x32 counter-based generator → identical streams CPU/GPU given same seed; `set seed` maps to Philox key. Reproducibility is a release blocker.
173 +- **Cluster bootstrap** (ragged n per replicate): pad-to-max with weight masks; custom Metal kernel only if profiling shows >2× overhead from padding.
174 +- Precision: MLX float32 by default; accumulate cross-products in float64 emulation (Kahan/two-sum) where it matters; document tolerances per command.
175 +- **Chunk reps** to respect memory: `chunk = min(reps, budget / (n*k*4))`, stream results, report progress to UI via `AsyncStream`.
176 +
177 +### Hybrid
178 +- Example: Bayesian regression — GPU computes log-likelihood over chains in parallel, CPU runs the sampler control loop.
179 +
180 +---
181 +
182 +## 6. Data Layer (`ZQData`)
183 +
184 +- Canonical store: **DuckDB** in-process database; DataFrames are Arrow views over query results.
185 +- Zero-copy handoff Arrow → MLX tensors where dtype allows; otherwise single cast pass.
186 +- `use file.parquet` → registers a DuckDB view, lazy. `gen`/`replace` compile to SQL expressions when possible; fall back to vectorized Swift over Arrow buffers.
187 +- Missing values: Arrow validity bitmaps end-to-end; every estimator does listwise deletion by default with an explicit report line ("(12 observations dropped due to missing values)").
188 +- `.dta` reader/writer implemented natively in `ZQData/Stata/` (spec v117–v121).
189 +
190 +---
191 +
192 +## 7. UI (SwiftUI)
193 +
194 +Windows/panes:
195 +1. **Console** — command input with history (↑/↓), autocomplete on verbs & varnames, ANSI-style rich results (monospaced, aligned tables via `AttributedString`).
196 +2. **Do-file editor**`NSTextView` bridge, syntax highlighting for ZQL, ⌘R runs selection.
197 +3. **Data browser** — virtualized grid (custom `NSTableView` bridge; SwiftUI `Table` is too slow past ~100k rows), sortable, filter bar compiles to `if` expressions.
198 +4. **Plots** — Swift Charts for standard plots; custom `MTKView` renderer for scatter >1M points (instanced point sprites).
199 +5. **Sidebar** — variables list with types, labels, missing counts.
200 +
201 +State: `@Observable` session model; every command mutation goes through `ZQEngine` so console, do-files, and plugins share one execution path. Undo = dataset snapshots via DuckDB `CHECKPOINT` + copy-on-write.
202 +
203 +---
204 +
205 +## 8. Performance Budgets (release blockers)
206 +
207 +| Operation | Data | Budget (M3 Pro) |
208 +|---|---|---|
209 +| `use` parquet | 10M rows × 20 cols | < 1.5 s |
210 +| `summarize` all vars | 10M × 20 | < 300 ms |
211 +| `reg` 5 covariates | 10M rows | < 900 ms |
212 +| `bootstrap, reps(10000): reg` | 100k rows | < 3 s |
213 +| `bootstrap, reps(100000): reg` | 100k rows | < 25 s |
214 +| Scatter render | 2M points | 60 fps pan/zoom |
215 +
216 +Benchmarks live in `Tests/Bench/` and run in CI on self-hosted Apple Silicon runner; regressions >10% fail the build.
217 +
218 +---
219 +
220 +## 9. Testing
221 +
222 +- `swift test` on MetrikaKit: parser golden tests, estimator numerical tests vs fixtures generated by R (`Tests/Fixtures/generate.R`).
223 +- Property tests: OLS invariance (scaling, permutation), bootstrap CI coverage simulation (n small, known DGP).
224 +- GPU vs CPU cross-checks: same seed ⇒ identical resample indices; estimates within documented tolerance.
225 +- UI: XCUITest smoke — launch, load sample dataset, run `reg`, assert output table.
226 +
227 +---
228 +
229 +## 10. Build, Signing, Notarization & DMG
230 +
231 +> ⚠️ **PLACEHOLDERS** — the `Zyquo-local/` folder containing the real notarization credentials was not available when this file was generated. Fill every `<...>` below from `Zyquo-local/notarization.md` (or equivalent) before the first release build. Do **not** commit real credentials; keep them in the local keychain / CI secrets.
232 +
233 +### Identifiers
234 +```
235 +DEVELOPMENT_TEAM = <TEAM_ID> # from Metrika-local
236 +CODE_SIGN_IDENTITY = "Developer ID Application: Simon-Pierre Boucher (<TEAM_ID>)"
237 +NOTARY_PROFILE = "<KEYCHAIN_PROFILE_NAME>" # created via: xcrun notarytool store-credentials
238 +BUNDLE_ID = ai.spboucher.metrika
239 +```
240 +
241 +### One-time setup
242 +```bash
243 +xcrun notarytool store-credentials "<KEYCHAIN_PROFILE_NAME>" \
244 + --apple-id "<APPLE_ID_EMAIL>" \
245 + --team-id "<TEAM_ID>" \
246 + --password "<APP_SPECIFIC_PASSWORD>"
247 +```
248 +
249 +### Release pipeline — `scripts/release.sh`
250 +```bash
251 +#!/bin/bash
252 +#
253 +# release.sh — Metrika
254 +# Author: Simon-Pierre Boucher
255 +# Contact: contact@spboucher.ai
256 +#
257 +set -euo pipefail
258 +
259 +APP="Metrika"
260 +SCHEME="Metrika"
261 +BUILD_DIR="build"
262 +DMG="${APP}.dmg"
263 +
264 +# 1. Archive
265 +xcodebuild -scheme "$SCHEME" -configuration Release -arch arm64 \
266 + -archivePath "$BUILD_DIR/$APP.xcarchive" archive
267 +
268 +# 2. Export with Developer ID
269 +xcodebuild -exportArchive \
270 + -archivePath "$BUILD_DIR/$APP.xcarchive" \
271 + -exportOptionsPlist scripts/ExportOptions.plist \
272 + -exportPath "$BUILD_DIR/export"
273 +
274 +# 3. Verify hardened runtime + entitlements
275 +codesign -dv --verbose=4 "$BUILD_DIR/export/$APP.app"
276 +codesign --verify --deep --strict "$BUILD_DIR/export/$APP.app"
277 +
278 +# 4. Build DMG (create-dmg, background art in Assets/DMG/)
279 +create-dmg \
280 + --volname "$APP" \
281 + --window-size 540 380 \
282 + --icon-size 128 \
283 + --icon "$APP.app" 130 190 \
284 + --app-drop-link 400 190 \
285 + --background "Assets/DMG/background.png" \
286 + "$DMG" "$BUILD_DIR/export/"
287 +
288 +# 5. Sign the DMG itself
289 +codesign --sign "Developer ID Application: Simon-Pierre Boucher (<TEAM_ID>)" "$DMG"
290 +
291 +# 6. Notarize & wait
292 +xcrun notarytool submit "$DMG" \
293 + --keychain-profile "<KEYCHAIN_PROFILE_NAME>" \
294 + --wait
295 +
296 +# 7. Staple
297 +xcrun stapler staple "$DMG"
298 +xcrun stapler validate "$DMG"
299 +
300 +echo "✅ $DMG notarized and stapled."
301 +```
302 +
303 +### `scripts/ExportOptions.plist`
304 +```xml
305 +<?xml version="1.0" encoding="UTF-8"?>
306 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
307 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
308 +<plist version="1.0"><dict>
309 + <key>method</key><string>developer-id</string>
310 + <key>teamID</key><string><TEAM_ID></string>
311 + <key>signingStyle</key><string>automatic</string>
312 + <key>destination</key><string>export</string>
313 +</dict></plist>
314 +```
315 +
316 +### Entitlements (`Metrika.entitlements`)
317 +- Hardened Runtime: ON.
318 +- App Sandbox: ON, with `com.apple.security.files.user-selected.read-write` (open/save datasets) and bookmark entitlements for recent files.
319 +- No JIT, no unsigned executable memory (MLX does not require them).
320 +- If plugin loading of external dylibs is added later, revisit `com.apple.security.cs.disable-library-validation` — avoid if possible; prefer SPM-compiled-in plugins.
321 +
322 +---
323 +
324 +## 11. App Icon
325 +
326 +- Master: `Assets/AppIcon/metrika-icon.svg` (1024×1024 design).
327 +- Concept: a glowing **M-shaped time-series line** — a zigzag chart trace cutting through a scatter of data points on a deep space-gradient squircle — econometrics meets GPU power.
328 +- Generate `.icns`: `scripts/make_icns.sh` rasterizes the SVG (via `rsvg-convert` or `qlmanage`) to 16→1024 px and runs `iconutil -c icns`.
329 +- macOS Sonoma+ icon grid: content within the 824×824 safe zone of the 1024 canvas; system applies the squircle mask — but we bake our own rounded-rect background per Big Sur+ convention.
330 +
331 +---
332 +
333 +## 12. Repo Conventions
334 +
335 +```
336 +Metrika/
337 +├── CLAUDE.md
338 +├── Metrika.xcodeproj
339 +├── Metrika/ # app target (UI)
340 +├── MetrikaKit/ # SPM package (all logic)
341 +│ └── Sources/{ZQParser,ZQPlanner,ZQEngine,ZQData,ZQStats,ZQGPU,ZQGraphics,ZQPlugins}
342 +├── Assets/{AppIcon,DMG}
343 +├── scripts/{release.sh,make_icns.sh,check_headers.sh}
344 +└── Tests/{Unit,Bench,Fixtures}
345 +```
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.
348 +- Commits: Conventional Commits (`feat(parser): factor variable expansion`).
349 +- Branches: `main` (protected), `dev`, feature branches `feat/*`.
350 +- CI: GitHub Actions on self-hosted arm64 macOS runner — build, test, bench, header check.
351 +
352 +---
353 +
354 +## 13. Roadmap
355 +
356 +**v0.1 (MVP, ~8 weeks of focused work)**
357 +use/save (parquet, csv, dta) · summarize · tabulate · gen/replace · reg (robust, cluster) · logit · basic scatter/histogram · console + data browser · do-file execution.
358 +
359 +**v0.2** — GPU bootstrap + permutation tests · xtreg FE · ivregress · Swift Charts full suite · plugin protocol.
360 +
361 +**v0.3** — MCMC/Bayesian module · gradient boosting & elastic net (MLX) · Metal 2M-point renderer · margins/predict.
362 +
363 +**v1.0** — notarized DMG release, docs site, sample datasets, command reference (`help regress` in-app).
364 +
365 +---
366 +
367 +## 14. What Claude Should Do When Working in This Repo
368 +
369 +1. Read this file first; respect §0 rules absolutely (headers, Swift 6 strict concurrency).
370 +2. Before touching numerics, read the matching fixture in `Tests/Fixtures/` and keep tests green.
371 +3. Any new command: grammar entry → AST case → planner rule → CPU impl → (optional) GPU impl → golden test → in-app help entry. All six or the PR is incomplete.
372 +4. Never introduce a Python or Node runtime dependency into the app bundle.
373 +5. When unsure between CPU and GPU, implement CPU first — correctness before speed — then add the GPU path behind the planner.
374 +6. Update the performance table (§8) whenever a benchmark materially changes.
added Metrika.xcodeproj/project.pbxproj +383 −0
@@ -0,0 +1,383 @@
1 +// !$*UTF8*$!
2 +{
3 + archiveVersion = 1;
4 + classes = {
5 + };
6 + objectVersion = 77;
7 + objects = {
8 +
9 +/* Begin PBXBuildFile section */
10 + 0F7000A49C9879593F09A0D3 /* MetrikaKit in Frameworks */ = {isa = PBXBuildFile; productRef = 498C4115F45BADDF16191A57 /* MetrikaKit */; };
11 + 3084AE2A328ED6746D01F46E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */; };
12 + 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0453FB0F256E853D2C2F481C /* SessionModel.swift */; };
13 + 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; };
14 + 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; };
15 + DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; };
16 +/* End PBXBuildFile section */
17 +
18 +/* Begin PBXFileReference section */
19 + 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
20 + 0453FB0F256E853D2C2F481C /* SessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionModel.swift; sourceTree = "<group>"; };
21 + 159695CF89D6BB25DF883F07 /* ConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleView.swift; sourceTree = "<group>"; };
22 + 24D84DAB354D702D8575D587 /* MetrikaKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetrikaKit; path = MetrikaKit; sourceTree = SOURCE_ROOT; };
23 + 4AD551AA707CD1853C3D6D21 /* PlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotView.swift; sourceTree = "<group>"; };
24 + 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; };
25 + 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; };
26 + 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; };
27 +/* End PBXFileReference section */
28 +
29 +/* Begin PBXFrameworksBuildPhase section */
30 + 168283EE9449D9A4324BFE40 /* Frameworks */ = {
31 + isa = PBXFrameworksBuildPhase;
32 + buildActionMask = 2147483647;
33 + files = (
34 + 0F7000A49C9879593F09A0D3 /* MetrikaKit in Frameworks */,
35 + );
36 + runOnlyForDeploymentPostprocessing = 0;
37 + };
38 +/* End PBXFrameworksBuildPhase section */
39 +
40 +/* Begin PBXGroup section */
41 + 133558B8D49331BB5120F8F8 /* Products */ = {
42 + isa = PBXGroup;
43 + children = (
44 + 5A0213CC9FE05A339DEEFA28 /* Metrika.app */,
45 + );
46 + name = Products;
47 + sourceTree = "<group>";
48 + };
49 + 58DB3AB5F820BD1E6C82E1CB /* Sources */ = {
50 + isa = PBXGroup;
51 + children = (
52 + 159695CF89D6BB25DF883F07 /* ConsoleView.swift */,
53 + 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */,
54 + 4AD551AA707CD1853C3D6D21 /* PlotView.swift */,
55 + 0453FB0F256E853D2C2F481C /* SessionModel.swift */,
56 + );
57 + path = Sources;
58 + sourceTree = "<group>";
59 + };
60 + 63F77F28B4EF42E0407BEC4F /* Packages */ = {
61 + isa = PBXGroup;
62 + children = (
63 + 24D84DAB354D702D8575D587 /* MetrikaKit */,
64 + );
65 + name = Packages;
66 + sourceTree = "<group>";
67 + };
68 + A905F8EA05CC983967935417 /* Metrika */ = {
69 + isa = PBXGroup;
70 + children = (
71 + 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */,
72 + 6E94C5817E96C83B888FB579 /* Metrika.entitlements */,
73 + 58DB3AB5F820BD1E6C82E1CB /* Sources */,
74 + );
75 + path = Metrika;
76 + sourceTree = "<group>";
77 + };
78 + DC657A037F59093C5529DD71 = {
79 + isa = PBXGroup;
80 + children = (
81 + A905F8EA05CC983967935417 /* Metrika */,
82 + 63F77F28B4EF42E0407BEC4F /* Packages */,
83 + 133558B8D49331BB5120F8F8 /* Products */,
84 + );
85 + sourceTree = "<group>";
86 + };
87 +/* End PBXGroup section */
88 +
89 +/* Begin PBXNativeTarget section */
90 + 5E03B33ABE9DE3162C326F87 /* Metrika */ = {
91 + isa = PBXNativeTarget;
92 + buildConfigurationList = A087F0DEACCF786E9B7CC9A5 /* Build configuration list for PBXNativeTarget "Metrika" */;
93 + buildPhases = (
94 + C8918D5868AE76549F9247E4 /* Sources */,
95 + D09C5D18E3FF44946887A6C9 /* Resources */,
96 + 168283EE9449D9A4324BFE40 /* Frameworks */,
97 + );
98 + buildRules = (
99 + );
100 + dependencies = (
101 + );
102 + name = Metrika;
103 + packageProductDependencies = (
104 + 498C4115F45BADDF16191A57 /* MetrikaKit */,
105 + );
106 + productName = Metrika;
107 + productReference = 5A0213CC9FE05A339DEEFA28 /* Metrika.app */;
108 + productType = "com.apple.product-type.application";
109 + };
110 +/* End PBXNativeTarget section */
111 +
112 +/* Begin PBXProject section */
113 + 8571D0059EB0A97DE47F96F7 /* Project object */ = {
114 + isa = PBXProject;
115 + attributes = {
116 + BuildIndependentTargetsInParallel = YES;
117 + LastUpgradeCheck = 1430;
118 + TargetAttributes = {
119 + 5E03B33ABE9DE3162C326F87 = {
120 + ProvisioningStyle = Automatic;
121 + };
122 + };
123 + };
124 + buildConfigurationList = 02D661894CE6081B5C88E20C /* Build configuration list for PBXProject "Metrika" */;
125 + developmentRegion = en;
126 + hasScannedForEncodings = 0;
127 + knownRegions = (
128 + Base,
129 + en,
130 + );
131 + mainGroup = DC657A037F59093C5529DD71;
132 + minimizedProjectReferenceProxies = 1;
133 + packageReferences = (
134 + D626AF14268CCEC6F4756ABC /* XCLocalSwiftPackageReference "MetrikaKit" */,
135 + );
136 + preferredProjectObjectVersion = 77;
137 + productRefGroup = 133558B8D49331BB5120F8F8 /* Products */;
138 + projectDirPath = "";
139 + projectRoot = "";
140 + targets = (
141 + 5E03B33ABE9DE3162C326F87 /* Metrika */,
142 + );
143 + };
144 +/* End PBXProject section */
145 +
146 +/* Begin PBXResourcesBuildPhase section */
147 + D09C5D18E3FF44946887A6C9 /* Resources */ = {
148 + isa = PBXResourcesBuildPhase;
149 + buildActionMask = 2147483647;
150 + files = (
151 + 3084AE2A328ED6746D01F46E /* Assets.xcassets in Resources */,
152 + );
153 + runOnlyForDeploymentPostprocessing = 0;
154 + };
155 +/* End PBXResourcesBuildPhase section */
156 +
157 +/* Begin PBXSourcesBuildPhase section */
158 + C8918D5868AE76549F9247E4 /* Sources */ = {
159 + isa = PBXSourcesBuildPhase;
160 + buildActionMask = 2147483647;
161 + files = (
162 + 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */,
163 + 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */,
164 + DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */,
165 + 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */,
166 + );
167 + runOnlyForDeploymentPostprocessing = 0;
168 + };
169 +/* End PBXSourcesBuildPhase section */
170 +
171 +/* Begin XCBuildConfiguration section */
172 + 3CB58B068179C9DB2CD1CAC1 /* Release */ = {
173 + isa = XCBuildConfiguration;
174 + buildSettings = {
175 + ALWAYS_SEARCH_USER_PATHS = NO;
176 + CLANG_ANALYZER_NONNULL = YES;
177 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
178 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
179 + CLANG_CXX_LIBRARY = "libc++";
180 + CLANG_ENABLE_MODULES = YES;
181 + CLANG_ENABLE_OBJC_ARC = YES;
182 + CLANG_ENABLE_OBJC_WEAK = YES;
183 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
184 + CLANG_WARN_BOOL_CONVERSION = YES;
185 + CLANG_WARN_COMMA = YES;
186 + CLANG_WARN_CONSTANT_CONVERSION = YES;
187 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
188 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
189 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
190 + CLANG_WARN_EMPTY_BODY = YES;
191 + CLANG_WARN_ENUM_CONVERSION = YES;
192 + CLANG_WARN_INFINITE_RECURSION = YES;
193 + CLANG_WARN_INT_CONVERSION = YES;
194 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
195 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
196 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
197 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
198 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
199 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
200 + CLANG_WARN_STRICT_PROTOTYPES = YES;
201 + CLANG_WARN_SUSPICIOUS_MOVE = YES;
202 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
203 + CLANG_WARN_UNREACHABLE_CODE = YES;
204 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
205 + COPY_PHASE_STRIP = NO;
206 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
207 + ENABLE_NS_ASSERTIONS = NO;
208 + ENABLE_STRICT_OBJC_MSGSEND = YES;
209 + GCC_C_LANGUAGE_STANDARD = gnu11;
210 + GCC_NO_COMMON_BLOCKS = YES;
211 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
212 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
213 + GCC_WARN_UNDECLARED_SELECTOR = YES;
214 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
215 + GCC_WARN_UNUSED_FUNCTION = YES;
216 + GCC_WARN_UNUSED_VARIABLE = YES;
217 + MACOSX_DEPLOYMENT_TARGET = 14.0;
218 + MTL_ENABLE_DEBUG_INFO = NO;
219 + MTL_FAST_MATH = YES;
220 + PRODUCT_NAME = "$(TARGET_NAME)";
221 + SDKROOT = macosx;
222 + SWIFT_COMPILATION_MODE = wholemodule;
223 + SWIFT_OPTIMIZATION_LEVEL = "-O";
224 + SWIFT_VERSION = 5.0;
225 + };
226 + name = Release;
227 + };
228 + 4D5E4B6EED8197B03EAD7E51 /* Debug */ = {
229 + isa = XCBuildConfiguration;
230 + buildSettings = {
231 + ARCHS = arm64;
232 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
233 + CODE_SIGN_ENTITLEMENTS = Metrika/Metrika.entitlements;
234 + CODE_SIGN_STYLE = Automatic;
235 + COMBINE_HIDPI_IMAGES = YES;
236 + CURRENT_PROJECT_VERSION = 1;
237 + ENABLE_APP_SANDBOX = YES;
238 + ENABLE_HARDENED_RUNTIME = YES;
239 + GENERATE_INFOPLIST_FILE = YES;
240 + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Simon-Pierre Boucher. All rights reserved.";
241 + INFOPLIST_KEY_NSPrincipalClass = NSApplication;
242 + LD_RUNPATH_SEARCH_PATHS = (
243 + "$(inherited)",
244 + "@executable_path/../Frameworks",
245 + );
246 + MARKETING_VERSION = 0.1.0;
247 + ONLY_ACTIVE_ARCH = YES;
248 + PRODUCT_BUNDLE_IDENTIFIER = ai.spboucher.metrika;
249 + SDKROOT = macosx;
250 + SWIFT_STRICT_CONCURRENCY = complete;
251 + SWIFT_VERSION = 6.0;
252 + };
253 + name = Debug;
254 + };
255 + C241F8724816C2E6FEFA98F2 /* Release */ = {
256 + isa = XCBuildConfiguration;
257 + buildSettings = {
258 + ARCHS = arm64;
259 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
260 + CODE_SIGN_ENTITLEMENTS = Metrika/Metrika.entitlements;
261 + CODE_SIGN_STYLE = Automatic;
262 + COMBINE_HIDPI_IMAGES = YES;
263 + CURRENT_PROJECT_VERSION = 1;
264 + ENABLE_APP_SANDBOX = YES;
265 + ENABLE_HARDENED_RUNTIME = YES;
266 + GENERATE_INFOPLIST_FILE = YES;
267 + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Simon-Pierre Boucher. All rights reserved.";
268 + INFOPLIST_KEY_NSPrincipalClass = NSApplication;
269 + LD_RUNPATH_SEARCH_PATHS = (
270 + "$(inherited)",
271 + "@executable_path/../Frameworks",
272 + );
273 + MARKETING_VERSION = 0.1.0;
274 + ONLY_ACTIVE_ARCH = YES;
275 + PRODUCT_BUNDLE_IDENTIFIER = ai.spboucher.metrika;
276 + SDKROOT = macosx;
277 + SWIFT_STRICT_CONCURRENCY = complete;
278 + SWIFT_VERSION = 6.0;
279 + };
280 + name = Release;
281 + };
282 + DC41815671596F5C0AC0A0A6 /* Debug */ = {
283 + isa = XCBuildConfiguration;
284 + buildSettings = {
285 + ALWAYS_SEARCH_USER_PATHS = NO;
286 + CLANG_ANALYZER_NONNULL = YES;
287 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
288 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
289 + CLANG_CXX_LIBRARY = "libc++";
290 + CLANG_ENABLE_MODULES = YES;
291 + CLANG_ENABLE_OBJC_ARC = YES;
292 + CLANG_ENABLE_OBJC_WEAK = YES;
293 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
294 + CLANG_WARN_BOOL_CONVERSION = YES;
295 + CLANG_WARN_COMMA = YES;
296 + CLANG_WARN_CONSTANT_CONVERSION = YES;
297 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
298 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
299 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
300 + CLANG_WARN_EMPTY_BODY = YES;
301 + CLANG_WARN_ENUM_CONVERSION = YES;
302 + CLANG_WARN_INFINITE_RECURSION = YES;
303 + CLANG_WARN_INT_CONVERSION = YES;
304 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
305 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
306 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
307 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
308 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
309 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
310 + CLANG_WARN_STRICT_PROTOTYPES = YES;
311 + CLANG_WARN_SUSPICIOUS_MOVE = YES;
312 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
313 + CLANG_WARN_UNREACHABLE_CODE = YES;
314 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
315 + COPY_PHASE_STRIP = NO;
316 + DEBUG_INFORMATION_FORMAT = dwarf;
317 + ENABLE_STRICT_OBJC_MSGSEND = YES;
318 + ENABLE_TESTABILITY = YES;
319 + GCC_C_LANGUAGE_STANDARD = gnu11;
320 + GCC_DYNAMIC_NO_PIC = NO;
321 + GCC_NO_COMMON_BLOCKS = YES;
322 + GCC_OPTIMIZATION_LEVEL = 0;
323 + GCC_PREPROCESSOR_DEFINITIONS = (
324 + "$(inherited)",
325 + "DEBUG=1",
326 + );
327 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
328 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
329 + GCC_WARN_UNDECLARED_SELECTOR = YES;
330 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
331 + GCC_WARN_UNUSED_FUNCTION = YES;
332 + GCC_WARN_UNUSED_VARIABLE = YES;
333 + MACOSX_DEPLOYMENT_TARGET = 14.0;
334 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
335 + MTL_FAST_MATH = YES;
336 + ONLY_ACTIVE_ARCH = YES;
337 + PRODUCT_NAME = "$(TARGET_NAME)";
338 + SDKROOT = macosx;
339 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
340 + SWIFT_OPTIMIZATION_LEVEL = "-Onone";
341 + SWIFT_VERSION = 5.0;
342 + };
343 + name = Debug;
344 + };
345 +/* End XCBuildConfiguration section */
346 +
347 +/* Begin XCConfigurationList section */
348 + 02D661894CE6081B5C88E20C /* Build configuration list for PBXProject "Metrika" */ = {
349 + isa = XCConfigurationList;
350 + buildConfigurations = (
351 + DC41815671596F5C0AC0A0A6 /* Debug */,
352 + 3CB58B068179C9DB2CD1CAC1 /* Release */,
353 + );
354 + defaultConfigurationIsVisible = 0;
355 + defaultConfigurationName = Debug;
356 + };
357 + A087F0DEACCF786E9B7CC9A5 /* Build configuration list for PBXNativeTarget "Metrika" */ = {
358 + isa = XCConfigurationList;
359 + buildConfigurations = (
360 + 4D5E4B6EED8197B03EAD7E51 /* Debug */,
361 + C241F8724816C2E6FEFA98F2 /* Release */,
362 + );
363 + defaultConfigurationIsVisible = 0;
364 + defaultConfigurationName = Debug;
365 + };
366 +/* End XCConfigurationList section */
367 +
368 +/* Begin XCLocalSwiftPackageReference section */
369 + D626AF14268CCEC6F4756ABC /* XCLocalSwiftPackageReference "MetrikaKit" */ = {
370 + isa = XCLocalSwiftPackageReference;
371 + relativePath = MetrikaKit;
372 + };
373 +/* End XCLocalSwiftPackageReference section */
374 +
375 +/* Begin XCSwiftPackageProductDependency section */
376 + 498C4115F45BADDF16191A57 /* MetrikaKit */ = {
377 + isa = XCSwiftPackageProductDependency;
378 + productName = MetrikaKit;
379 + };
380 +/* End XCSwiftPackageProductDependency section */
381 + };
382 + rootObject = 8571D0059EB0A97DE47F96F7 /* Project object */;
383 +}
added Metrika.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 −0
@@ -0,0 +1,7 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<Workspace
3 + version = "1.0">
4 + <FileRef
5 + location = "self:">
6 + </FileRef>
7 +</Workspace>
added Metrika/Assets.xcassets/AppIcon.appiconset/Contents.json +15 −0
@@ -0,0 +1,15 @@
1 +{
2 + "images": [
3 + { "filename": "icon_16.png", "idiom": "mac", "scale": "1x", "size": "16x16" },
4 + { "filename": "icon_16@2x.png", "idiom": "mac", "scale": "2x", "size": "16x16" },
5 + { "filename": "icon_32.png", "idiom": "mac", "scale": "1x", "size": "32x32" },
6 + { "filename": "icon_32@2x.png", "idiom": "mac", "scale": "2x", "size": "32x32" },
7 + { "filename": "icon_128.png", "idiom": "mac", "scale": "1x", "size": "128x128" },
8 + { "filename": "icon_128@2x.png", "idiom": "mac", "scale": "2x", "size": "128x128" },
9 + { "filename": "icon_256.png", "idiom": "mac", "scale": "1x", "size": "256x256" },
10 + { "filename": "icon_256@2x.png", "idiom": "mac", "scale": "2x", "size": "256x256" },
11 + { "filename": "icon_512.png", "idiom": "mac", "scale": "1x", "size": "512x512" },
12 + { "filename": "icon_512@2x.png", "idiom": "mac", "scale": "2x", "size": "512x512" }
13 + ],
14 + "info": { "author": "xcode", "version": 1 }
15 +}
added Metrika/Assets.xcassets/AppIcon.appiconset/icon_128.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_16.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_256.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_32.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_512.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png +0 −0

Binary file not shown.

added Metrika/Assets.xcassets/Contents.json +1 −0
@@ -0,0 +1 @@
1 +{ "info": { "author": "xcode", "version": 1 } }
added Metrika/Metrika.entitlements +13 −0
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 +<plist version="1.0">
5 +<dict>
6 + <key>com.apple.security.app-sandbox</key>
7 + <true/>
8 + <key>com.apple.security.files.user-selected.read-write</key>
9 + <true/>
10 + <key>com.apple.security.files.bookmarks.app-scope</key>
11 + <true/>
12 +</dict>
13 +</plist>
added Metrika/Sources/ConsoleView.swift +120 −0
@@ -0,0 +1,120 @@
1 +//
2 +// ConsoleView.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 SwiftUI
11 +
12 +/// Command console: scrollback of executed commands with their rendered
13 +/// output, plus an input line with ↑/↓ history recall (CLAUDE.md §7).
14 +struct ConsoleView: View {
15 + @Environment(SessionModel.self) private var model
16 + @State private var input = ""
17 + @State private var historyCursor: Int?
18 + @FocusState private var inputFocused: Bool
19 +
20 + var body: some View {
21 + VStack(spacing: 0) {
22 + ScrollViewReader { proxy in
23 + ScrollView {
24 + LazyVStack(alignment: .leading, spacing: 10) {
25 + ForEach(model.entries) { entry in
26 + VStack(alignment: .leading, spacing: 2) {
27 + if !entry.command.isEmpty {
28 + Text(". \(entry.command)")
29 + .foregroundStyle(.secondary)
30 + }
31 + if !entry.output.isEmpty {
32 + Text(entry.output)
33 + .foregroundStyle(
34 + entry.isError ? Color.red : Color.primary
35 + )
36 + .textSelection(.enabled)
37 + }
38 + }
39 + .id(entry.id)
40 + }
41 + }
42 + .padding(12)
43 + .frame(maxWidth: .infinity, alignment: .leading)
44 + }
45 + .onChange(of: model.entries) {
46 + if let last = model.entries.last {
47 + proxy.scrollTo(last.id, anchor: .bottom)
48 + }
49 + }
50 + }
51 +
52 + Divider()
53 +
54 + HStack(spacing: 8) {
55 + Text(".")
56 + .foregroundStyle(.secondary)
57 + TextField("Type a command — e.g. use sales.parquet", text: $input)
58 + .textFieldStyle(.plain)
59 + .focused($inputFocused)
60 + .onSubmit(submit)
61 + .onKeyPress(.upArrow) { recallHistory(step: -1) }
62 + .onKeyPress(.downArrow) { recallHistory(step: 1) }
63 + if model.isRunning {
64 + ProgressView()
65 + .controlSize(.small)
66 + }
67 + }
68 + .padding(10)
69 + }
70 + .fontDesign(.monospaced)
71 + .font(.system(size: 12))
72 + .onAppear { inputFocused = true }
73 + }
74 +
75 + private func submit() {
76 + let command = input
77 + input = ""
78 + historyCursor = nil
79 + Task { await model.run(command) }
80 + }
81 +
82 + private func recallHistory(step: Int) -> KeyPress.Result {
83 + let history = model.history
84 + guard !history.isEmpty else { return .ignored }
85 + var cursor = historyCursor ?? history.count
86 + cursor = min(max(cursor + step, 0), history.count)
87 + historyCursor = cursor
88 + input = cursor < history.count ? history[cursor] : ""
89 + return .handled
90 + }
91 +}
92 +
93 +/// Sidebar: variables of the working dataset with type and missing count
94 +/// (CLAUDE.md §7, pane 5).
95 +struct VariablesSidebar: View {
96 + @Environment(SessionModel.self) private var model
97 +
98 + var body: some View {
99 + List {
100 + Section("Variables (\(model.observationCount) obs)") {
101 + ForEach(model.variables) { variable in
102 + HStack {
103 + Text(variable.name)
104 + .fontDesign(.monospaced)
105 + Spacer()
106 + if variable.missingCount > 0 {
107 + Text("\(variable.missingCount) mi")
108 + .foregroundStyle(.orange)
109 + .font(.caption)
110 + }
111 + Text(variable.type)
112 + .foregroundStyle(.secondary)
113 + .font(.caption)
114 + }
115 + }
116 + }
117 + }
118 + .listStyle(.sidebar)
119 + }
120 +}
added Metrika/Sources/MetrikaApp.swift +44 −0
@@ -0,0 +1,44 @@
1 +//
2 +// MetrikaApp.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 SwiftUI
11 +
12 +@main
13 +struct MetrikaApp: App {
14 + @State private var model = SessionModel()
15 +
16 + var body: some Scene {
17 + WindowGroup {
18 + ContentView()
19 + .environment(model)
20 + .frame(minWidth: 900, minHeight: 560)
21 + }
22 + .windowStyle(.automatic)
23 + }
24 +}
25 +
26 +struct ContentView: View {
27 + @Environment(SessionModel.self) private var model
28 +
29 + var body: some View {
30 + NavigationSplitView {
31 + VariablesSidebar()
32 + } detail: {
33 + HSplitView {
34 + ConsoleView()
35 + .frame(minWidth: 480)
36 + if model.lastPlot != nil {
37 + PlotView()
38 + .frame(minWidth: 300)
39 + }
40 + }
41 + }
42 + .navigationTitle("Metrika")
43 + }
44 +}
added Metrika/Sources/PlotView.swift +58 −0
@@ -0,0 +1,58 @@
1 +//
2 +// PlotView.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 Charts
11 +import SwiftUI
12 +import ZQGraphics
13 +
14 +/// Swift Charts rendering of the session's latest plot spec. The custom
15 +/// Metal renderer takes over past ~1M points (v0.3, CLAUDE.md §7).
16 +struct PlotView: View {
17 + @Environment(SessionModel.self) private var model
18 +
19 + var body: some View {
20 + if let plot = model.lastPlot {
21 + Chart {
22 + ForEach(Array(plot.series.enumerated()), id: \.offset) { _, series in
23 + ForEach(series.x.indices, id: \.self) { index in
24 + switch plot.kind {
25 + case .scatter:
26 + PointMark(
27 + x: .value(plot.xLabel, series.x[index]),
28 + y: .value(plot.yLabel, series.y[index])
29 + )
30 + .symbolSize(20)
31 + .foregroundStyle(by: .value("Series", series.label))
32 + case .line, .kdensity:
33 + LineMark(
34 + x: .value(plot.xLabel, series.x[index]),
35 + y: .value(plot.yLabel, series.y[index])
36 + )
37 + .foregroundStyle(by: .value("Series", series.label))
38 + case .histogram:
39 + BarMark(
40 + x: .value(plot.xLabel, series.x[index]),
41 + y: .value(plot.yLabel, series.y[index])
42 + )
43 + .foregroundStyle(by: .value("Series", series.label))
44 + }
45 + }
46 + }
47 + }
48 + .chartXAxisLabel(plot.xLabel)
49 + .chartYAxisLabel(plot.yLabel)
50 + .padding(12)
51 + } else {
52 + ContentUnavailableView(
53 + "No plot", systemImage: "chart.xyaxis.line",
54 + description: Text("Run a graph command to see it here.")
55 + )
56 + }
57 + }
58 +}
added Metrika/Sources/SessionModel.swift +84 −0
@@ -0,0 +1,84 @@
1 +//
2 +// SessionModel.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 Observation
11 +import SwiftUI
12 +import ZQData
13 +import ZQEngine
14 +import ZQGraphics
15 +
16 +/// 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 +/// execution path (CLAUDE.md §7).
19 +@Observable @MainActor
20 +final class SessionModel {
21 + struct ConsoleEntry: Identifiable, Equatable {
22 + let id = UUID()
23 + let command: String
24 + let output: String
25 + let isError: Bool
26 + }
27 +
28 + struct VariableInfo: Identifiable, Equatable {
29 + var id: String { name }
30 + let name: String
31 + let type: String
32 + let missingCount: Int
33 + }
34 +
35 + private(set) var entries: [ConsoleEntry] = []
36 + private(set) var variables: [VariableInfo] = []
37 + private(set) var observationCount = 0
38 + private(set) var lastPlot: ZQPlotSpec?
39 + private(set) var isRunning = false
40 + private(set) var history: [String] = []
41 +
42 + private var session: ZQSession?
43 +
44 + init() {
45 + do {
46 + session = try ZQSession()
47 + } catch {
48 + entries.append(ConsoleEntry(
49 + command: "",
50 + output: "failed to start engine: \(error)",
51 + isError: true
52 + ))
53 + }
54 + }
55 +
56 + func run(_ command: String) async {
57 + let line = command.trimmingCharacters(in: .whitespaces)
58 + guard !line.isEmpty, let session else { return }
59 + history.append(line)
60 + isRunning = true
61 + defer { isRunning = false }
62 + do {
63 + let result = try await session.execute(line)
64 + entries.append(ConsoleEntry(command: line, output: result.text, isError: false))
65 + } catch {
66 + entries.append(ConsoleEntry(command: line, output: "\(error)", isError: true))
67 + }
68 + await refreshDatasetState()
69 + }
70 +
71 + private func refreshDatasetState() async {
72 + guard let session else { return }
73 + let frame = await session.frame
74 + observationCount = frame.rowCount
75 + variables = frame.columns.map { column in
76 + VariableInfo(
77 + name: column.name,
78 + type: column.data.isNumeric ? "float64" : "string",
79 + missingCount: column.data.missingCount
80 + )
81 + }
82 + lastPlot = await session.lastPlot
83 + }
84 +}
added MetrikaKit/Package.resolved +33 −0
@@ -0,0 +1,33 @@
1 +{
2 + "originHash" : "e4ae512a67bec7ae66f2abd23f1e87240c39d28811734cdfafc7a91e4323f6db",
3 + "pins" : [
4 + {
5 + "identity" : "duckdb-swift",
6 + "kind" : "remoteSourceControl",
7 + "location" : "https://github.com/duckdb/duckdb-swift",
8 + "state" : {
9 + "revision" : "9583f44fedc20a8e0b6151d2e43f6d850016712f",
10 + "version" : "1.1.3"
11 + }
12 + },
13 + {
14 + "identity" : "swift-argument-parser",
15 + "kind" : "remoteSourceControl",
16 + "location" : "https://github.com/apple/swift-argument-parser",
17 + "state" : {
18 + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
19 + "version" : "1.8.2"
20 + }
21 + },
22 + {
23 + "identity" : "swift-collections",
24 + "kind" : "remoteSourceControl",
25 + "location" : "https://github.com/apple/swift-collections",
26 + "state" : {
27 + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
28 + "version" : "1.6.0"
29 + }
30 + }
31 + ],
32 + "version" : 3
33 +}
added MetrikaKit/Package.swift +96 −0
@@ -0,0 +1,96 @@
1 +// swift-tools-version: 6.0
2 +//
3 +// Package.swift
4 +// Metrika
5 +//
6 +// Author: Simon-Pierre Boucher
7 +// Contact: contact@spboucher.ai
8 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
9 +//
10 +
11 +import PackageDescription
12 +
13 +let strictConcurrency: [SwiftSetting] = [
14 + .swiftLanguageMode(.v6)
15 +]
16 +
17 +let package = Package(
18 + name: "MetrikaKit",
19 + platforms: [.macOS(.v14)],
20 + products: [
21 + .library(
22 + name: "MetrikaKit",
23 + targets: [
24 + "ZQParser", "ZQPlanner", "ZQEngine", "ZQData",
25 + "ZQStats", "ZQGPU", "ZQGraphics", "ZQPlugins",
26 + ]
27 + ),
28 + .executable(name: "metrika-cli", targets: ["MetrikaCLI"]),
29 + ],
30 + dependencies: [
31 + .package(url: "https://github.com/duckdb/duckdb-swift", from: "1.0.0"),
32 + .package(url: "https://github.com/apple/swift-collections", from: "1.1.0"),
33 + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
34 + ],
35 + targets: [
36 + .target(
37 + name: "ZQParser",
38 + swiftSettings: strictConcurrency
39 + ),
40 + .target(
41 + name: "ZQData",
42 + dependencies: [
43 + .product(name: "DuckDB", package: "duckdb-swift"),
44 + .product(name: "Collections", package: "swift-collections"),
45 + ],
46 + swiftSettings: strictConcurrency
47 + ),
48 + .target(
49 + name: "ZQStats",
50 + dependencies: ["ZQData"],
51 + swiftSettings: strictConcurrency
52 + ),
53 + .target(
54 + name: "ZQGPU",
55 + dependencies: ["ZQData"],
56 + swiftSettings: strictConcurrency
57 + ),
58 + .target(
59 + name: "ZQPlanner",
60 + dependencies: ["ZQParser", "ZQData"],
61 + swiftSettings: strictConcurrency
62 + ),
63 + .target(
64 + name: "ZQGraphics",
65 + dependencies: ["ZQData"],
66 + swiftSettings: strictConcurrency
67 + ),
68 + .target(
69 + name: "ZQPlugins",
70 + dependencies: ["ZQParser", "ZQData"],
71 + swiftSettings: strictConcurrency
72 + ),
73 + .target(
74 + name: "ZQEngine",
75 + dependencies: [
76 + "ZQParser", "ZQPlanner", "ZQData", "ZQStats",
77 + "ZQGPU", "ZQGraphics", "ZQPlugins",
78 + ],
79 + swiftSettings: strictConcurrency
80 + ),
81 + .executableTarget(
82 + name: "MetrikaCLI",
83 + dependencies: [
84 + "ZQEngine",
85 + .product(name: "ArgumentParser", package: "swift-argument-parser"),
86 + ],
87 + swiftSettings: strictConcurrency
88 + ),
89 + .testTarget(
90 + name: "MetrikaKitTests",
91 + dependencies: ["ZQParser", "ZQPlanner", "ZQEngine", "ZQData", "ZQStats", "ZQGPU"],
92 + resources: [.copy("Fixtures")],
93 + swiftSettings: strictConcurrency
94 + ),
95 + ]
96 +)
added MetrikaKit/Sources/MetrikaCLI/MetrikaCLI.swift +65 −0
@@ -0,0 +1,65 @@
1 +//
2 +// MetrikaCLI.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 ArgumentParser
11 +import Foundation
12 +import ZQEngine
13 +
14 +/// Companion command-line tool: runs ZQL commands or `.zyq` do-files
15 +/// against a headless session. Interactive REPL when no input is given.
16 +@main
17 +struct MetrikaCLI: AsyncParsableCommand {
18 + static let configuration = CommandConfiguration(
19 + commandName: "metrika-cli",
20 + abstract: "Metrika statistical engine — command-line interface."
21 + )
22 +
23 + @Option(
24 + name: [.short, .customLong("execute")],
25 + help: "Execute a ZQL command (repeatable, runs in order)."
26 + )
27 + var execute: [String] = []
28 +
29 + @Argument(help: "A .zyq do-file to run.")
30 + var doFile: String?
31 +
32 + mutating func run() async throws {
33 + let session = try ZQSession()
34 +
35 + if let doFile {
36 + let url = URL(fileURLWithPath: (doFile as NSString).expandingTildeInPath)
37 + let text = try String(contentsOf: url, encoding: .utf8)
38 + let result = try await session.executeScript(text)
39 + print(result.text)
40 + return
41 + }
42 +
43 + if !execute.isEmpty {
44 + for command in execute {
45 + let result = try await session.execute(command)
46 + if !result.text.isEmpty { print(result.text) }
47 + }
48 + return
49 + }
50 +
51 + // REPL
52 + print("Metrika — type ZQL commands, 'exit' to quit.")
53 + while true {
54 + print(". ", terminator: "")
55 + guard let line = readLine() else { break }
56 + if line == "exit" || line == "quit" { break }
57 + do {
58 + let result = try await session.execute(line)
59 + if !result.text.isEmpty { print(result.text) }
60 + } catch {
61 + print("error: \(error)")
62 + }
63 + }
64 + }
65 +}
added MetrikaKit/Sources/ZQData/DataFrame.swift +163 −0
@@ -0,0 +1,163 @@
1 +//
2 +// DataFrame.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 +/// Column payload. Numeric data is Float64 end-to-end (CLAUDE.md §5: all
11 +/// CPU compute in Float64) with an explicit validity mask mirroring Arrow
12 +/// semantics: `missing[i] == true` means observation `i` is missing, and
13 +/// the corresponding value slot is undefined.
14 +public enum ZQColumnData: Equatable, Sendable {
15 + case float64(values: [Double], missing: [Bool])
16 + case string([String?])
17 +
18 + public var count: Int {
19 + switch self {
20 + case .float64(let values, _): return values.count
21 + case .string(let values): return values.count
22 + }
23 + }
24 +
25 + public var missingCount: Int {
26 + switch self {
27 + case .float64(_, let missing): return missing.count { $0 }
28 + case .string(let values): return values.count { $0 == nil }
29 + }
30 + }
31 +
32 + public var isNumeric: Bool {
33 + if case .float64 = self { return true }
34 + return false
35 + }
36 +}
37 +
38 +/// A named column of a data frame.
39 +public struct ZQColumn: Equatable, Sendable {
40 + public var name: String
41 + public var data: ZQColumnData
42 +
43 + public init(name: String, data: ZQColumnData) {
44 + self.name = name
45 + self.data = data
46 + }
47 +
48 + public var count: Int { data.count }
49 +
50 + /// Numeric view: values with their validity mask, or nil for string
51 + /// columns.
52 + public var numeric: (values: [Double], missing: [Bool])? {
53 + if case .float64(let values, let missing) = data {
54 + return (values, missing)
55 + }
56 + return nil
57 + }
58 +}
59 +
60 +public struct ZQDataError: Error, Equatable, Sendable, CustomStringConvertible {
61 + public var message: String
62 + public init(_ message: String) { self.message = message }
63 + public var description: String { message }
64 +}
65 +
66 +/// In-memory data frame: the working dataset of a session. Materialized
67 +/// from DuckDB query results; columnar, immutable value semantics.
68 +public struct ZQDataFrame: Equatable, Sendable {
69 + public private(set) var columns: [ZQColumn]
70 + public let rowCount: Int
71 +
72 + public init(columns: [ZQColumn] = []) throws(ZQDataError) {
73 + let counts = Set(columns.map(\.count))
74 + guard counts.count <= 1 else {
75 + throw ZQDataError("columns have inconsistent lengths: \(counts.sorted())")
76 + }
77 + self.columns = columns
78 + self.rowCount = counts.first ?? 0
79 + }
80 +
81 + public var isEmpty: Bool { columns.isEmpty || rowCount == 0 }
82 + public var columnNames: [String] { columns.map(\.name) }
83 +
84 + public func column(named name: String) -> ZQColumn? {
85 + columns.first { $0.name == name }
86 + }
87 +
88 + public func requireColumn(_ name: String) throws(ZQDataError) -> ZQColumn {
89 + guard let column = column(named: name) else {
90 + throw ZQDataError("variable '\(name)' not found")
91 + }
92 + return column
93 + }
94 +
95 + public func requireNumeric(
96 + _ name: String
97 + ) throws(ZQDataError) -> (values: [Double], missing: [Bool]) {
98 + guard let numeric = try requireColumn(name).numeric else {
99 + throw ZQDataError("variable '\(name)' is a string variable")
100 + }
101 + return numeric
102 + }
103 +
104 + /// Adds a new column; fails if the name already exists (Stata `generate`
105 + /// semantics — use `replaceColumn` for `replace`).
106 + public mutating func addColumn(_ column: ZQColumn) throws(ZQDataError) {
107 + guard self.column(named: column.name) == nil else {
108 + throw ZQDataError("variable '\(column.name)' already defined")
109 + }
110 + guard columns.isEmpty || column.count == rowCount else {
111 + throw ZQDataError(
112 + "column '\(column.name)' has \(column.count) rows, expected \(rowCount)"
113 + )
114 + }
115 + columns.append(column)
116 + }
117 +
118 + /// Replaces an existing column in place (Stata `replace`).
119 + public mutating func replaceColumn(_ column: ZQColumn) throws(ZQDataError) {
120 + guard let index = columns.firstIndex(where: { $0.name == column.name }) else {
121 + throw ZQDataError("variable '\(column.name)' not found")
122 + }
123 + guard column.count == rowCount else {
124 + throw ZQDataError(
125 + "column '\(column.name)' has \(column.count) rows, expected \(rowCount)"
126 + )
127 + }
128 + columns[index] = column
129 + }
130 +
131 + public mutating func dropColumns(_ names: [String]) throws(ZQDataError) {
132 + for name in names {
133 + guard columns.contains(where: { $0.name == name }) else {
134 + throw ZQDataError("variable '\(name)' not found")
135 + }
136 + }
137 + columns.removeAll { names.contains($0.name) }
138 + }
139 +
140 + /// Row subset by keep-mask (`mask[i] == true` keeps observation `i`).
141 + public func filtered(by mask: [Bool]) throws(ZQDataError) -> ZQDataFrame {
142 + guard mask.count == rowCount else {
143 + throw ZQDataError("filter mask length \(mask.count) != \(rowCount) rows")
144 + }
145 + let kept = (0..<rowCount).filter { mask[$0] }
146 + let newColumns = columns.map { column -> ZQColumn in
147 + switch column.data {
148 + case .float64(let values, let missing):
149 + return ZQColumn(
150 + name: column.name,
151 + data: .float64(
152 + values: kept.map { values[$0] },
153 + missing: kept.map { missing[$0] }
154 + )
155 + )
156 + case .string(let values):
157 + return ZQColumn(name: column.name, data: .string(kept.map { values[$0] }))
158 + }
159 + }
160 + // Lengths are uniform by construction.
161 + return try! ZQDataFrame(columns: newColumns)
162 + }
163 +}
added MetrikaKit/Sources/ZQData/DuckDBStore.swift +153 −0
@@ -0,0 +1,153 @@
1 +//
2 +// DuckDBStore.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 DuckDB
11 +import Foundation
12 +
13 +/// DuckDB-backed data store (CLAUDE.md §6). File loading goes through
14 +/// DuckDB readers (`read_parquet`, `read_csv`) and query results are
15 +/// materialized into `ZQDataFrame` columns: every numeric database type
16 +/// widens to Float64, text stays as strings.
17 +public actor ZQDataStore {
18 + private let database: Database
19 + private let connection: Connection
20 +
21 + public init() throws {
22 + self.database = try Database(store: .inMemory)
23 + self.connection = try database.connect()
24 + }
25 +
26 + // MARK: - Loading
27 +
28 + /// Loads a dataset file into a data frame. Format is inferred from the
29 + /// file extension: .parquet, .csv, .tsv, .json, .arrow.
30 + public func load(contentsOf url: URL) throws -> ZQDataFrame {
31 + let path = url.path
32 + guard FileManager.default.fileExists(atPath: path) else {
33 + throw ZQDataError("file not found: \(path)")
34 + }
35 + let escaped = path.replacingOccurrences(of: "'", with: "''")
36 + let reader: String
37 + switch url.pathExtension.lowercased() {
38 + case "parquet", "pq":
39 + reader = "read_parquet('\(escaped)')"
40 + case "csv", "tsv", "txt":
41 + reader = "read_csv('\(escaped)')"
42 + case "json", "ndjson", "jsonl":
43 + reader = "read_json('\(escaped)')"
44 + case "arrow", "feather", "ipc":
45 + reader = "read_ipc('\(escaped)')"
46 + case let ext:
47 + throw ZQDataError("unsupported file format '.\(ext)'")
48 + }
49 + return try dataFrame(fromQuery: "SELECT * FROM \(reader)")
50 + }
51 +
52 + /// Runs an arbitrary SQL query and materializes the result.
53 + public func dataFrame(fromQuery sql: String) throws -> ZQDataFrame {
54 + let result: ResultSet
55 + do {
56 + result = try connection.query(sql)
57 + } catch {
58 + throw ZQDataError("query failed: \(error)")
59 + }
60 + var columns: [ZQColumn] = []
61 + for index in 0..<result.columnCount {
62 + let name = result.columnName(at: index)
63 + let untyped = result.column(at: index)
64 + columns.append(materialize(untyped, name: name))
65 + }
66 + return try ZQDataFrame(columns: columns)
67 + }
68 +
69 + private func materialize(_ column: Column<Void>, name: String) -> ZQColumn {
70 + switch column.underlyingDatabaseType {
71 + case .double, .float, .decimal:
72 + return numericColumn(name: name, column.cast(to: Double.self).map { $0 })
73 + case .tinyint, .smallint, .integer, .bigint,
74 + .utinyint, .usmallint, .uinteger:
75 + let values = column.cast(to: Int64.self).map { $0.map(Double.init) }
76 + return numericColumn(name: name, values)
77 + case .ubigint:
78 + let values = column.cast(to: UInt64.self).map { $0.map(Double.init) }
79 + return numericColumn(name: name, values)
80 + case .boolean:
81 + let values = column.cast(to: Bool.self).map { $0.map { $0 ? 1.0 : 0.0 } }
82 + return numericColumn(name: name, values)
83 + default:
84 + // Text, dates, and everything else: keep the string rendering.
85 + let values = column.cast(to: String.self).map { $0 }
86 + return ZQColumn(name: name, data: .string(Array(values)))
87 + }
88 + }
89 +
90 + private func numericColumn(name: String, _ optionals: [Double?]) -> ZQColumn {
91 + var values = [Double](repeating: 0, count: optionals.count)
92 + var missing = [Bool](repeating: false, count: optionals.count)
93 + for (i, value) in optionals.enumerated() {
94 + if let value, !value.isNaN {
95 + values[i] = value
96 + } else {
97 + missing[i] = true
98 + values[i] = .nan
99 + }
100 + }
101 + return ZQColumn(name: name, data: .float64(values: values, missing: missing))
102 + }
103 +
104 + // MARK: - Saving
105 +
106 + /// Writes a data frame to disk via DuckDB `COPY`. Format inferred from
107 + /// the extension (.parquet or .csv).
108 + public func save(_ frame: ZQDataFrame, to url: URL) throws {
109 + let format: String
110 + switch url.pathExtension.lowercased() {
111 + case "parquet", "pq": format = "PARQUET"
112 + case "csv": format = "CSV, HEADER"
113 + case let ext: throw ZQDataError("unsupported save format '.\(ext)'")
114 + }
115 +
116 + try register(frame, as: "__metrika_save")
117 + defer { _ = try? connection.query("DROP TABLE IF EXISTS __metrika_save") }
118 + let escaped = url.path.replacingOccurrences(of: "'", with: "''")
119 + _ = try connection.query(
120 + "COPY __metrika_save TO '\(escaped)' (FORMAT \(format))"
121 + )
122 + }
123 +
124 + /// Registers a data frame as a DuckDB table (used by save and by SQL
125 + /// pushdown of `gen`/`collapse` expressions).
126 + private func register(_ frame: ZQDataFrame, as table: String) throws {
127 + _ = try? connection.query("DROP TABLE IF EXISTS \(table)")
128 + let declarations = frame.columns.map { column in
129 + let type = column.data.isNumeric ? "DOUBLE" : "VARCHAR"
130 + return "\"\(column.name)\" \(type)"
131 + }
132 + guard !declarations.isEmpty else {
133 + throw ZQDataError("nothing to save: dataset has no variables")
134 + }
135 + _ = try connection.query(
136 + "CREATE TABLE \(table) (\(declarations.joined(separator: ", ")))"
137 + )
138 +
139 + let appender = try Appender(connection: connection, table: table)
140 + for row in 0..<frame.rowCount {
141 + for column in frame.columns {
142 + switch column.data {
143 + case .float64(let values, let missing):
144 + try appender.append(missing[row] ? nil : values[row])
145 + case .string(let values):
146 + try appender.append(values[row])
147 + }
148 + }
149 + try appender.endRow()
150 + }
151 + try appender.flush()
152 + }
153 +}
added MetrikaKit/Sources/ZQData/ZQDataModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQDataModule.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 +/// Module marker for ZQData.
11 +public enum ZQDataModule {
12 + public static let name = "ZQData"
13 +}
added MetrikaKit/Sources/ZQEngine/ExpressionEvaluator.swift +332 −0
@@ -0,0 +1,332 @@
1 +//
2 +// ExpressionEvaluator.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 ZQData
12 +import ZQParser
13 +
14 +/// Vectorized evaluation of a `ZQExpression` against the working dataset.
15 +///
16 +/// Missing-value semantics: any arithmetic or comparison touching a
17 +/// missing operand yields missing; undefined operations (division by
18 +/// zero, log of a non-positive number) yield missing. The `missing(x)`
19 +/// function itself never returns missing. In `if` qualifiers a missing
20 +/// condition excludes the observation.
21 +struct ExpressionEvaluator {
22 + struct EvaluationError: Error, CustomStringConvertible {
23 + let message: String
24 + var description: String { message }
25 + }
26 +
27 + /// A column vector or a broadcastable scalar.
28 + enum Value {
29 + case numeric(values: [Double], missing: [Bool])
30 + case strings([String?])
31 + case numericScalar(Double)
32 + case stringScalar(String)
33 + case missingScalar
34 + }
35 +
36 + let frame: ZQDataFrame
37 +
38 + func evaluate(_ expression: ZQExpression) throws -> Value {
39 + switch expression {
40 + case .number(let value):
41 + return .numericScalar(value)
42 + case .string(let value):
43 + return .stringScalar(value)
44 + case .missing:
45 + return .missingScalar
46 + case .variable(let name):
47 + guard let column = frame.column(named: name) else {
48 + throw EvaluationError(message: "variable '\(name)' not found")
49 + }
50 + switch column.data {
51 + case .float64(let values, let missing):
52 + return .numeric(values: values, missing: missing)
53 + case .string(let values):
54 + return .strings(values)
55 + }
56 + case .unary(let op, let operand):
57 + return try evaluateUnary(op: op, operand: operand)
58 + case .binary(let op, let lhs, let rhs):
59 + return try evaluateBinary(op: op, lhs: lhs, rhs: rhs)
60 + case .call(let name, let arguments):
61 + return try evaluateCall(name: name, arguments: arguments)
62 + }
63 + }
64 +
65 + /// Evaluates to a full-length numeric column (broadcasting scalars).
66 + func evaluateNumericColumn(
67 + _ expression: ZQExpression
68 + ) throws -> (values: [Double], missing: [Bool]) {
69 + switch try evaluate(expression) {
70 + case .numeric(let values, let missing):
71 + return (values, missing)
72 + case .numericScalar(let value):
73 + return (
74 + [Double](repeating: value, count: frame.rowCount),
75 + [Bool](repeating: false, count: frame.rowCount)
76 + )
77 + case .missingScalar:
78 + return (
79 + [Double](repeating: .nan, count: frame.rowCount),
80 + [Bool](repeating: true, count: frame.rowCount)
81 + )
82 + case .strings, .stringScalar:
83 + throw EvaluationError(message: "type mismatch: expected a numeric expression")
84 + }
85 + }
86 +
87 + /// Evaluates an `if` qualifier into a keep-mask. Missing → excluded.
88 + func evaluateCondition(_ expression: ZQExpression) throws -> [Bool] {
89 + let (values, missing) = try evaluateNumericColumn(expression)
90 + return (0..<values.count).map { !missing[$0] && values[$0] != 0 }
91 + }
92 +
93 + // MARK: - Operators
94 +
95 + private func evaluateUnary(op: String, operand: ZQExpression) throws -> Value {
96 + let value = try evaluate(operand)
97 + switch op {
98 + case "-":
99 + return try mapNumeric(value) { -$0 }
100 + case "!":
101 + return try mapNumeric(value) { $0 == 0 ? 1 : 0 }
102 + default:
103 + throw EvaluationError(message: "unknown unary operator '\(op)'")
104 + }
105 + }
106 +
107 + private func evaluateBinary(
108 + op: String, lhs: ZQExpression, rhs: ZQExpression
109 + ) throws -> Value {
110 + let left = try evaluate(lhs)
111 + let right = try evaluate(rhs)
112 +
113 + // String equality against a literal or another string column.
114 + if isString(left) || isString(right) {
115 + guard op == "==" || op == "!=" else {
116 + throw EvaluationError(
117 + message: "operator '\(op)' is not defined for strings"
118 + )
119 + }
120 + return try compareStrings(left, right, equal: op == "==")
121 + }
122 +
123 + let combine: (Double, Double) -> Double
124 + switch op {
125 + case "+": combine = (+)
126 + case "-": combine = (-)
127 + case "*": combine = (*)
128 + case "/": combine = { $1 == 0 ? .nan : $0 / $1 }
129 + case "^": combine = { pow($0, $1) }
130 + case "==": combine = { $0 == $1 ? 1 : 0 }
131 + case "!=": combine = { $0 != $1 ? 1 : 0 }
132 + case "<": combine = { $0 < $1 ? 1 : 0 }
133 + case "<=": combine = { $0 <= $1 ? 1 : 0 }
134 + case ">": combine = { $0 > $1 ? 1 : 0 }
135 + case ">=": combine = { $0 >= $1 ? 1 : 0 }
136 + case "&": combine = { ($0 != 0 && $1 != 0) ? 1 : 0 }
137 + case "|": combine = { ($0 != 0 || $1 != 0) ? 1 : 0 }
138 + default:
139 + throw EvaluationError(message: "unknown operator '\(op)'")
140 + }
141 + return try zipNumeric(left, right, combine)
142 + }
143 +
144 + private func evaluateCall(name: String, arguments: [ZQExpression]) throws -> Value {
145 + // missing(x): 1/0 indicator, itself never missing.
146 + if name == "missing" || name == "mi" {
147 + guard arguments.count == 1 else {
148 + throw EvaluationError(message: "missing() takes exactly one argument")
149 + }
150 + switch try evaluate(arguments[0]) {
151 + case .numeric(_, let missing):
152 + return .numeric(
153 + values: missing.map { $0 ? 1.0 : 0.0 },
154 + missing: [Bool](repeating: false, count: missing.count)
155 + )
156 + case .strings(let values):
157 + return .numeric(
158 + values: values.map { $0 == nil ? 1.0 : 0.0 },
159 + missing: [Bool](repeating: false, count: values.count)
160 + )
161 + case .missingScalar:
162 + return .numericScalar(1)
163 + case .numericScalar, .stringScalar:
164 + return .numericScalar(0)
165 + }
166 + }
167 +
168 + let unaryFunctions: [String: @Sendable (Double) -> Double] = [
169 + "ln": { $0 > 0 ? Foundation.log($0) : .nan },
170 + "log": { $0 > 0 ? Foundation.log($0) : .nan },
171 + "log10": { $0 > 0 ? Foundation.log10($0) : .nan },
172 + "exp": Foundation.exp,
173 + "sqrt": { $0 >= 0 ? $0.squareRoot() : .nan },
174 + "abs": abs,
175 + "floor": { $0.rounded(.down) },
176 + "ceil": { $0.rounded(.up) },
177 + "round": { $0.rounded(.toNearestOrAwayFromZero) },
178 + "int": { $0.rounded(.towardZero) },
179 + ]
180 + if let function = unaryFunctions[name] {
181 + guard arguments.count == 1 else {
182 + throw EvaluationError(message: "\(name)() takes exactly one argument")
183 + }
184 + return try mapNumeric(try evaluate(arguments[0]), function)
185 + }
186 +
187 + if name == "max" || name == "min" {
188 + guard arguments.count >= 2 else {
189 + throw EvaluationError(message: "\(name)() takes at least two arguments")
190 + }
191 + var result = try evaluate(arguments[0])
192 + for argument in arguments.dropFirst() {
193 + let next = try evaluate(argument)
194 + result = try zipNumeric(result, next, name == "max" ? max : min)
195 + }
196 + return result
197 + }
198 +
199 + throw EvaluationError(message: "unknown function '\(name)()'")
200 + }
201 +
202 + // MARK: - Helpers
203 +
204 + private func isString(_ value: Value) -> Bool {
205 + switch value {
206 + case .strings, .stringScalar: return true
207 + default: return false
208 + }
209 + }
210 +
211 + private func compareStrings(
212 + _ lhs: Value, _ rhs: Value, equal: Bool
213 + ) throws -> Value {
214 + func stringAt(_ value: Value, _ index: Int) -> String?? {
215 + switch value {
216 + case .strings(let values): return values[index]
217 + case .stringScalar(let value): return value
218 + default: return Optional<String?>.none
219 + }
220 + }
221 + let count: Int
222 + switch (lhs, rhs) {
223 + case (.strings(let values), _), (_, .strings(let values)):
224 + count = values.count
225 + default:
226 + // scalar vs scalar
227 + if case .stringScalar(let a) = lhs, case .stringScalar(let b) = rhs {
228 + return .numericScalar((a == b) == equal ? 1 : 0)
229 + }
230 + throw EvaluationError(message: "type mismatch in string comparison")
231 + }
232 + var values = [Double](repeating: 0, count: count)
233 + let missing = [Bool](repeating: false, count: count)
234 + for i in 0..<count {
235 + guard let a = stringAt(lhs, i), let b = stringAt(rhs, i) else {
236 + throw EvaluationError(message: "type mismatch in string comparison")
237 + }
238 + switch (a, b) {
239 + case (nil, nil), (nil, _), (_, nil):
240 + // Stata treats "" as the string missing value; comparisons
241 + // against it still evaluate.
242 + let equalValues = (a ?? "") == (b ?? "")
243 + values[i] = equalValues == equal ? 1 : 0
244 + default:
245 + values[i] = (a! == b!) == equal ? 1 : 0
246 + }
247 + }
248 + return .numeric(values: values, missing: missing)
249 + }
250 +
251 + private func mapNumeric(
252 + _ value: Value, _ transform: (Double) -> Double
253 + ) throws -> Value {
254 + switch value {
255 + case .numeric(let values, var missing):
256 + var result = [Double](repeating: .nan, count: values.count)
257 + for i in 0..<values.count where !missing[i] {
258 + result[i] = transform(values[i])
259 + if result[i].isNaN { missing[i] = true }
260 + }
261 + return .numeric(values: result, missing: missing)
262 + case .numericScalar(let value):
263 + let result = transform(value)
264 + return result.isNaN ? .missingScalar : .numericScalar(result)
265 + case .missingScalar:
266 + return .missingScalar
267 + case .strings, .stringScalar:
268 + throw EvaluationError(message: "type mismatch: expected a numeric operand")
269 + }
270 + }
271 +
272 + private func zipNumeric(
273 + _ lhs: Value, _ rhs: Value, _ combine: (Double, Double) -> Double
274 + ) throws -> Value {
275 + switch (lhs, rhs) {
276 + case (.numericScalar(let a), .numericScalar(let b)):
277 + let result = combine(a, b)
278 + return result.isNaN ? .missingScalar : .numericScalar(result)
279 + case (.missingScalar, _), (_, .missingScalar):
280 + if case .numeric(let values, _) = lhs {
281 + return .numeric(
282 + values: [Double](repeating: .nan, count: values.count),
283 + missing: [Bool](repeating: true, count: values.count)
284 + )
285 + }
286 + if case .numeric(let values, _) = rhs {
287 + return .numeric(
288 + values: [Double](repeating: .nan, count: values.count),
289 + missing: [Bool](repeating: true, count: values.count)
290 + )
291 + }
292 + return .missingScalar
293 + default:
294 + let (leftValues, leftMissing) = try broadcast(lhs)
295 + let (rightValues, rightMissing) = try broadcast(rhs)
296 + guard leftValues.count == rightValues.count else {
297 + throw EvaluationError(message: "operand length mismatch")
298 + }
299 + var values = [Double](repeating: .nan, count: leftValues.count)
300 + var missing = [Bool](repeating: true, count: leftValues.count)
301 + for i in 0..<leftValues.count where !leftMissing[i] && !rightMissing[i] {
302 + let result = combine(leftValues[i], rightValues[i])
303 + if !result.isNaN {
304 + values[i] = result
305 + missing[i] = false
306 + }
307 + }
308 + return .numeric(values: values, missing: missing)
309 + }
310 + }
311 +
312 + private func broadcast(
313 + _ value: Value
314 + ) throws -> (values: [Double], missing: [Bool]) {
315 + switch value {
316 + case .numeric(let values, let missing):
317 + return (values, missing)
318 + case .numericScalar(let scalar):
319 + return (
320 + [Double](repeating: scalar, count: frame.rowCount),
321 + [Bool](repeating: false, count: frame.rowCount)
322 + )
323 + case .missingScalar:
324 + return (
325 + [Double](repeating: .nan, count: frame.rowCount),
326 + [Bool](repeating: true, count: frame.rowCount)
327 + )
328 + case .strings, .stringScalar:
329 + throw EvaluationError(message: "type mismatch: expected a numeric operand")
330 + }
331 + }
332 +}
added MetrikaKit/Sources/ZQEngine/Session.swift +956 −0
@@ -0,0 +1,956 @@
1 +//
2 +// Session.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 ZQData
12 +import ZQGPU
13 +import ZQGraphics
14 +import ZQParser
15 +import ZQPlanner
16 +import ZQPlugins
17 +import ZQStats
18 +
19 +public struct ZQEngineError: Error, Equatable, Sendable, CustomStringConvertible {
20 + public var message: String
21 + public init(_ message: String) { self.message = message }
22 + public var description: String { message }
23 +}
24 +
25 +/// One interactive session: working dataset, RNG state, panel settings,
26 +/// logging. Every command mutation — console, do-file, or plugin — goes
27 +/// through `execute(_:)` so all frontends share one execution path
28 +/// (CLAUDE.md §7).
29 +public actor ZQSession {
30 + public private(set) var frame: ZQDataFrame
31 + public private(set) var lastPlot: ZQPlotSpec?
32 + public private(set) var seed: UInt64 = 123_456_789
33 + public private(set) var panelVariable: String?
34 + public private(set) var timeVariable: String?
35 +
36 + private let store: ZQDataStore
37 + private let parser = ZQCommandParser()
38 + private let planner = ZQPlanner()
39 + private let scriptCommands: [String: ZQScriptCommand]
40 + private var logFileURL: URL?
41 + private var scriptDepth = 0
42 +
43 + public init(discoverUserCommands: Bool = true) throws {
44 + self.store = try ZQDataStore()
45 + self.frame = try ZQDataFrame()
46 + if discoverUserCommands {
47 + var commands: [String: ZQScriptCommand] = [:]
48 + for script in ZQPluginDiscovery.scriptCommands() {
49 + commands[script.verb] = script
50 + }
51 + self.scriptCommands = commands
52 + } else {
53 + self.scriptCommands = [:]
54 + }
55 + }
56 +
57 + // MARK: - Entry point
58 +
59 + public func execute(_ line: String) async throws -> ZQResult {
60 + let result: ZQResult
61 + do {
62 + result = try await run(line)
63 + } catch {
64 + try? appendToLog(command: line, output: "error: \(error)")
65 + throw error
66 + }
67 + try? appendToLog(command: line, output: result.text)
68 + return result
69 + }
70 +
71 + /// Executes a do-file: runs each line, concatenating output. Stops at
72 + /// the first error, reporting the failing line number.
73 + public func executeScript(_ text: String) async throws -> ZQResult {
74 + var outputs: [String] = []
75 + var scalars: [String: Double] = [:]
76 + for (index, rawLine) in text.split(
77 + separator: "\n", omittingEmptySubsequences: false
78 + ).enumerated() {
79 + let line = String(rawLine)
80 + do {
81 + let result = try await execute(line)
82 + if !result.text.isEmpty {
83 + outputs.append(". \(line)\n\(result.text)")
84 + }
85 + scalars.merge(result.scalars) { _, new in new }
86 + } catch {
87 + throw ZQEngineError("line \(index + 1): \(error)")
88 + }
89 + }
90 + return ZQResult(text: outputs.joined(separator: "\n\n"), scalars: scalars)
91 + }
92 +
93 + private func run(_ line: String) async throws -> ZQResult {
94 + guard let command = try parser.parse(line) else {
95 + return ZQResult(text: "")
96 + }
97 +
98 + // User script commands shadow nothing built-in; check after parse
99 + // failure would be better UX, but the verb table already resolved.
100 + let plan = planner.plan(command, rowCount: frame.rowCount)
101 + return try await dispatch(plan.command)
102 + }
103 +
104 + private func dispatch(_ command: ZQCommand) async throws -> ZQResult {
105 + if let script = scriptCommands[command.verb] {
106 + guard scriptDepth < 8 else {
107 + throw ZQEngineError("user command recursion too deep")
108 + }
109 + scriptDepth += 1
110 + defer { scriptDepth -= 1 }
111 + return try await executeScript(script.lines.joined(separator: "\n"))
112 + }
113 +
114 + switch command.verb {
115 + case "use": return try await handleUse(command)
116 + case "save": return try await handleSave(command)
117 + case "clear": return handleClear()
118 + case "describe": return handleDescribe()
119 + case "summarize": return try handleSummarize(command)
120 + case "generate": return try handleGenerate(command, replace: false)
121 + case "replace": return try handleGenerate(command, replace: true)
122 + case "drop": return try handleDropKeep(command, keep: false)
123 + case "keep": return try handleDropKeep(command, keep: true)
124 + case "count": return try handleCount(command)
125 + case "list": return try handleList(command)
126 + case "regress": return try handleRegress(command)
127 + case "set": return try handleSet(command)
128 + case "xtset": return try handleXTSet(command)
129 + case "display": return try handleDisplay(command)
130 + case "bootstrap": return try await handleBootstrap(command)
131 + case "graph": return try handleGraph(command)
132 + case "log": return try handleLog(command)
133 + default:
134 + throw ZQEngineError("command '\(command.verb)' is not implemented yet")
135 + }
136 + }
137 +
138 + // MARK: - Data commands
139 +
140 + private func handleUse(_ command: ZQCommand) async throws -> ZQResult {
141 + guard let path = command.argument else {
142 + throw ZQEngineError("use: file name required")
143 + }
144 + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
145 + frame = try await store.load(contentsOf: url)
146 + return ZQResult(
147 + text: "(\(frame.rowCount) observations, \(frame.columns.count) variables loaded from \(url.lastPathComponent))",
148 + scalars: ["N": Double(frame.rowCount), "k": Double(frame.columns.count)]
149 + )
150 + }
151 +
152 + private func handleSave(_ command: ZQCommand) async throws -> ZQResult {
153 + guard let path = command.argument else {
154 + throw ZQEngineError("save: file name required")
155 + }
156 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
157 + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
158 + if FileManager.default.fileExists(atPath: url.path),
159 + !command.hasOption("replace") {
160 + throw ZQEngineError("file \(url.lastPathComponent) already exists — add ', replace' to overwrite")
161 + }
162 + try await store.save(frame, to: url)
163 + return ZQResult(text: "file \(url.lastPathComponent) saved")
164 + }
165 +
166 + private func handleClear() -> ZQResult {
167 + frame = try! ZQDataFrame()
168 + panelVariable = nil
169 + timeVariable = nil
170 + return ZQResult(text: "")
171 + }
172 +
173 + private func handleDescribe() -> ZQResult {
174 + guard !frame.isEmpty else {
175 + return ZQResult(text: "Contains data: 0 observations")
176 + }
177 + var lines = [
178 + "Contains data: \(frame.rowCount) observations, \(frame.columns.count) variables",
179 + "",
180 + TableFormatter.pad("Variable", 12) + " " +
181 + TableFormatter.pad("Type", 8, right: false) + " Missing",
182 + String(repeating: "-", count: 34),
183 + ]
184 + for column in frame.columns {
185 + let type = column.data.isNumeric ? "float64" : "string"
186 + lines.append(
187 + TableFormatter.pad(column.name, 12) + " " +
188 + TableFormatter.pad(type, 8, right: false) + " " +
189 + TableFormatter.pad("\(column.data.missingCount)", 7)
190 + )
191 + }
192 + return ZQResult(
193 + text: lines.joined(separator: "\n"),
194 + scalars: ["N": Double(frame.rowCount), "k": Double(frame.columns.count)]
195 + )
196 + }
197 +
198 + private func handleSummarize(_ command: ZQCommand) throws -> ZQResult {
199 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
200 + let mask = try observationMask(command)
201 + let detail = command.hasOption("detail")
202 +
203 + var names: [String] = command.varlist.flatMap(\.referencedNames)
204 + if names.isEmpty {
205 + names = frame.columns.filter(\.data.isNumeric).map(\.name)
206 + }
207 +
208 + var lines = [
209 + TableFormatter.pad("Variable", 12) + " | " +
210 + TableFormatter.pad("Obs", 9) + " " +
211 + TableFormatter.pad("Mean", 11) + " " +
212 + TableFormatter.pad("Std. dev.", 11) + " " +
213 + TableFormatter.pad("Min", 10) + " " +
214 + TableFormatter.pad("Max", 10),
215 + String(repeating: "-", count: 13) + "+" + String(repeating: "-", count: 62),
216 + ]
217 + var scalars: [String: Double] = [:]
218 +
219 + for name in names {
220 + let (values, missing) = try frame.requireNumeric(name)
221 + let restrictedMissing = (0..<values.count).map { missing[$0] || !mask[$0] }
222 + let summary = ZQSummarize.summary(
223 + name: name, values: values, missing: restrictedMissing, detail: detail
224 + )
225 + if let summary {
226 + lines.append(
227 + TableFormatter.pad(name, 12) + " | " +
228 + TableFormatter.pad("\(summary.observationCount)", 9) + " " +
229 + TableFormatter.pad(TableFormatter.general(summary.mean), 11) + " " +
230 + TableFormatter.pad(TableFormatter.general(summary.standardDeviation), 11) + " " +
231 + TableFormatter.pad(TableFormatter.general(summary.minimum), 10) + " " +
232 + TableFormatter.pad(TableFormatter.general(summary.maximum), 10)
233 + )
234 + if let detailBlock = summary.detail {
235 + for p in [1, 5, 10, 25, 50, 75, 90, 95, 99] {
236 + lines.append(
237 + TableFormatter.pad("p\(p)", 16) + " " +
238 + TableFormatter.pad(
239 + TableFormatter.general(detailBlock.percentiles[p] ?? .nan), 11
240 + )
241 + )
242 + }
243 + lines.append(
244 + TableFormatter.pad("skewness", 16) + " " +
245 + TableFormatter.pad(TableFormatter.general(detailBlock.skewness), 11)
246 + )
247 + lines.append(
248 + TableFormatter.pad("kurtosis", 16) + " " +
249 + TableFormatter.pad(TableFormatter.general(detailBlock.kurtosis), 11)
250 + )
251 + }
252 + scalars["N"] = Double(summary.observationCount)
253 + scalars["mean"] = summary.mean
254 + scalars["sd"] = summary.standardDeviation
255 + scalars["min"] = summary.minimum
256 + scalars["max"] = summary.maximum
257 + scalars["Var"] = summary.variance
258 + } else {
259 + lines.append(
260 + TableFormatter.pad(name, 12) + " | " +
261 + TableFormatter.pad("0", 9)
262 + )
263 + }
264 + }
265 + return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
266 + }
267 +
268 + private func handleGenerate(_ command: ZQCommand, replace: Bool) throws -> ZQResult {
269 + guard let assignment = command.assignment else {
270 + throw ZQEngineError("\(replace ? "replace" : "generate"): syntax is 'newvar = expression'")
271 + }
272 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
273 +
274 + let evaluator = ExpressionEvaluator(frame: frame)
275 + var (values, missing) = try evaluator.evaluateNumericColumn(assignment.expression)
276 + let mask = try observationMask(command)
277 +
278 + if replace {
279 + let (oldValues, oldMissing) = try frame.requireNumeric(assignment.target)
280 + var changed = 0
281 + for i in 0..<frame.rowCount {
282 + if mask[i] {
283 + let differs = oldMissing[i] != missing[i]
284 + || (!missing[i] && oldValues[i] != values[i])
285 + if differs { changed += 1 }
286 + } else {
287 + values[i] = oldValues[i]
288 + missing[i] = oldMissing[i]
289 + }
290 + }
291 + try frame.replaceColumn(ZQColumn(
292 + name: assignment.target,
293 + data: .float64(values: values, missing: missing)
294 + ))
295 + return ZQResult(text: "(\(changed) real changes made)")
296 + } else {
297 + var missingCount = 0
298 + for i in 0..<frame.rowCount where !mask[i] {
299 + values[i] = .nan
300 + missing[i] = true
301 + }
302 + for flag in missing where flag { missingCount += 1 }
303 + try frame.addColumn(ZQColumn(
304 + name: assignment.target,
305 + data: .float64(values: values, missing: missing)
306 + ))
307 + let note = missingCount > 0 ? "(\(missingCount) missing values generated)" : ""
308 + return ZQResult(text: note)
309 + }
310 + }
311 +
312 + private func handleDropKeep(_ command: ZQCommand, keep: Bool) throws -> ZQResult {
313 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
314 +
315 + // Observation form: `drop if expr` / `keep if expr` / `keep in 1/10`.
316 + if command.varlist.isEmpty, command.condition != nil || command.range != nil {
317 + let mask = try observationMask(command)
318 + let keepMask = keep ? mask : mask.map { !$0 }
319 + let before = frame.rowCount
320 + frame = try frame.filtered(by: keepMask)
321 + return ZQResult(text: "(\(before - frame.rowCount) observations deleted)")
322 + }
323 +
324 + // Variable form: `drop x y` / `keep x y`.
325 + let names = command.varlist.flatMap(\.referencedNames)
326 + guard !names.isEmpty else {
327 + throw ZQEngineError("\(keep ? "keep" : "drop"): specify variables or an 'if' condition")
328 + }
329 + if keep {
330 + let dropped = frame.columnNames.filter { !names.contains($0) }
331 + try frame.dropColumns(dropped)
332 + } else {
333 + try frame.dropColumns(names)
334 + }
335 + return ZQResult(text: "")
336 + }
337 +
338 + private func handleCount(_ command: ZQCommand) throws -> ZQResult {
339 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
340 + let mask = try observationMask(command)
341 + let count = mask.count { $0 }
342 + return ZQResult(text: " \(count)", scalars: ["N": Double(count)])
343 + }
344 +
345 + private func handleList(_ command: ZQCommand) throws -> ZQResult {
346 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
347 + let mask = try observationMask(command)
348 + var names = command.varlist.flatMap(\.referencedNames)
349 + if names.isEmpty { names = frame.columnNames }
350 + let columns = try names.map { try frame.requireColumn($0) }
351 +
352 + let rows = (0..<frame.rowCount).filter { mask[$0] }.prefix(50)
353 + var lines = [names.map { TableFormatter.pad($0, 12) }.joined(separator: " ")]
354 + for row in rows {
355 + let cells = columns.map { column -> String in
356 + switch column.data {
357 + case .float64(let values, let missing):
358 + return TableFormatter.pad(
359 + missing[row] ? "." : TableFormatter.general(values[row]), 12
360 + )
361 + case .string(let values):
362 + return TableFormatter.pad(values[row] ?? "", 12)
363 + }
364 + }
365 + lines.append(cells.joined(separator: " "))
366 + }
367 + return ZQResult(text: lines.joined(separator: "\n"))
368 + }
369 +
370 + // MARK: - Settings
371 +
372 + private func handleSet(_ command: ZQCommand) throws -> ZQResult {
373 + switch command.subverb {
374 + case "seed":
375 + guard let text = command.argument, let value = UInt64(text) else {
376 + throw ZQEngineError("set seed: integer seed required")
377 + }
378 + seed = value
379 + return ZQResult(text: "")
380 + default:
381 + throw ZQEngineError("set: unknown setting '\(command.subverb ?? "")'")
382 + }
383 + }
384 +
385 + private func handleXTSet(_ command: ZQCommand) throws -> ZQResult {
386 + let names = command.varlist.flatMap(\.referencedNames)
387 + guard (1...2).contains(names.count) else {
388 + throw ZQEngineError("xtset: syntax is 'xtset panelvar [timevar]'")
389 + }
390 + for name in names {
391 + _ = try frame.requireColumn(name)
392 + }
393 + panelVariable = names[0]
394 + timeVariable = names.count > 1 ? names[1] : nil
395 + var text = "Panel variable: \(names[0])"
396 + if let timeVariable { text += "\nTime variable: \(timeVariable)" }
397 + return ZQResult(text: text)
398 + }
399 +
400 + private func handleDisplay(_ command: ZQCommand) throws -> ZQResult {
401 + guard let assignment = command.assignment else {
402 + throw ZQEngineError("display: expression required")
403 + }
404 + let evaluator = ExpressionEvaluator(frame: frame)
405 + switch try evaluator.evaluate(assignment.expression) {
406 + case .numericScalar(let value):
407 + return ZQResult(text: TableFormatter.general(value, significant: 10))
408 + case .stringScalar(let value):
409 + return ZQResult(text: value)
410 + case .missingScalar:
411 + return ZQResult(text: ".")
412 + case .numeric(let values, let missing):
413 + guard let first = values.first else { return ZQResult(text: ".") }
414 + return ZQResult(
415 + text: missing[0] ? "." : TableFormatter.general(first, significant: 10)
416 + )
417 + case .strings(let values):
418 + return ZQResult(text: values.first.flatMap { $0 } ?? "")
419 + }
420 + }
421 +
422 + private func handleLog(_ command: ZQCommand) throws -> ZQResult {
423 + if command.argument == "close" || command.subverb == "close" {
424 + logFileURL = nil
425 + return ZQResult(text: "(log closed)")
426 + }
427 + guard var path = command.argument else {
428 + throw ZQEngineError("log: syntax is 'log using filename' or 'log close'")
429 + }
430 + if path == "close" {
431 + logFileURL = nil
432 + return ZQResult(text: "(log closed)")
433 + }
434 + if path.hasPrefix("using ") { path = String(path.dropFirst(6)) }
435 + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
436 + if !FileManager.default.fileExists(atPath: url.path) {
437 + FileManager.default.createFile(atPath: url.path, contents: nil)
438 + }
439 + logFileURL = url
440 + return ZQResult(text: "(log started: \(url.path))")
441 + }
442 +
443 + private func appendToLog(command: String, output: String) throws {
444 + guard let logFileURL else { return }
445 + let entry = ". \(command)\n\(output)\n\n"
446 + let handle = try FileHandle(forWritingTo: logFileURL)
447 + defer { try? handle.close() }
448 + try handle.seekToEnd()
449 + try handle.write(contentsOf: Data(entry.utf8))
450 + }
451 +
452 + // MARK: - Estimation
453 +
454 + /// Assembled estimation sample after if/in restriction and listwise
455 + /// deletion (§6: explicit report line for dropped observations).
456 + struct RegressionSample {
457 + var responseName: String
458 + var y: [Double]
459 + var predictors: [(name: String, values: [Double])]
460 + var clusterLabels: [Int]?
461 + var droppedMissing: Int
462 + }
463 +
464 + private func buildRegressionSample(
465 + _ command: ZQCommand, clusterVariable: String?
466 + ) throws -> RegressionSample {
467 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
468 + guard let first = command.varlist.first else {
469 + throw ZQEngineError("\(command.verb): dependent variable required")
470 + }
471 + guard case .simple(let responseName) = first else {
472 + throw ZQEngineError("\(command.verb): dependent variable cannot use factor notation")
473 + }
474 +
475 + let mask = try observationMask(command)
476 + let (yAll, yMissing) = try frame.requireNumeric(responseName)
477 +
478 + // Collect raw regressor sources (numeric columns) for missingness.
479 + struct Source {
480 + var spec: ZQVarSpec
481 + var columns: [(name: String, values: [Double], missing: [Bool])]
482 + }
483 + var sources: [Source] = []
484 + for spec in command.varlist.dropFirst() {
485 + var columns: [(String, [Double], [Bool])] = []
486 + for name in spec.referencedNames {
487 + let (values, missing) = try frame.requireNumeric(name)
488 + columns.append((name, values, missing))
489 + }
490 + sources.append(Source(spec: spec, columns: columns))
491 + }
492 +
493 + var clusterColumn: ZQColumn?
494 + if let clusterVariable {
495 + clusterColumn = try frame.requireColumn(clusterVariable)
496 + }
497 +
498 + // Listwise deletion over response, regressors, and cluster var.
499 + var keptRows: [Int] = []
500 + var droppedMissing = 0
501 + for i in 0..<frame.rowCount where mask[i] {
502 + var complete = !yMissing[i]
503 + if complete {
504 + outer: for source in sources {
505 + for column in source.columns where column.missing[i] {
506 + complete = false
507 + break outer
508 + }
509 + }
510 + }
511 + if complete, let clusterColumn {
512 + switch clusterColumn.data {
513 + case .float64(_, let missing): complete = !missing[i]
514 + case .string(let values): complete = values[i] != nil
515 + }
516 + }
517 + if complete {
518 + keptRows.append(i)
519 + } else {
520 + droppedMissing += 1
521 + }
522 + }
523 + guard !keptRows.isEmpty else {
524 + throw ZQEngineError("no observations")
525 + }
526 +
527 + let y = keptRows.map { yAll[$0] }
528 +
529 + // Expand specs on the estimation sample (plan-time expansion).
530 + var predictors: [(name: String, values: [Double])] = []
531 + for source in sources {
532 + switch source.spec {
533 + case .simple(let name):
534 + let (values, _) = try frame.requireNumeric(name)
535 + predictors.append((name, keptRows.map { values[$0] }))
536 + case .factor(let op, let name):
537 + let (values, missing) = try frame.requireNumeric(name)
538 + switch op {
539 + case "i", "ib":
540 + let sampleValues = keptRows.map { values[$0] }
541 + let sampleMissing = keptRows.map { missing[$0] }
542 + let expanded = ZQFactorExpansion.expand(
543 + name: name, values: sampleValues, missing: sampleMissing
544 + )
545 + guard !expanded.isEmpty else {
546 + throw ZQEngineError("factor variable '\(name)' has a single level")
547 + }
548 + predictors.append(contentsOf: expanded)
549 + case "c":
550 + predictors.append((name, keptRows.map { values[$0] }))
551 + default:
552 + throw ZQEngineError("unsupported factor operator '\(op).'")
553 + }
554 + case .interaction(let parts, _):
555 + // v0.1: continuous-by-continuous interactions only.
556 + var label: [String] = []
557 + var product = [Double](repeating: 1, count: keptRows.count)
558 + for part in parts {
559 + guard case .factor(let op, let name) = part, op == "c" else {
560 + guard case .simple(let name) = part else {
561 + throw ZQEngineError("factor interactions beyond c.#c. arrive with xtreg (v0.2)")
562 + }
563 + let (values, _) = try frame.requireNumeric(name)
564 + for (j, row) in keptRows.enumerated() { product[j] *= values[row] }
565 + label.append(name)
566 + continue
567 + }
568 + let (values, _) = try frame.requireNumeric(name)
569 + for (j, row) in keptRows.enumerated() { product[j] *= values[row] }
570 + label.append("c.\(name)")
571 + }
572 + predictors.append((label.joined(separator: "#"), product))
573 + }
574 + }
575 +
576 + // Cluster labels → dense integer codes.
577 + var clusterLabels: [Int]?
578 + if let clusterColumn {
579 + var codes: [Int] = []
580 + codes.reserveCapacity(keptRows.count)
581 + switch clusterColumn.data {
582 + case .float64(let values, _):
583 + var mapping: [Double: Int] = [:]
584 + for row in keptRows {
585 + let value = values[row]
586 + if let code = mapping[value] {
587 + codes.append(code)
588 + } else {
589 + mapping[value] = mapping.count
590 + codes.append(mapping.count - 1)
591 + }
592 + }
593 + case .string(let values):
594 + var mapping: [String: Int] = [:]
595 + for row in keptRows {
596 + let value = values[row] ?? ""
597 + if let code = mapping[value] {
598 + codes.append(code)
599 + } else {
600 + mapping[value] = mapping.count
601 + codes.append(mapping.count - 1)
602 + }
603 + }
604 + }
605 + clusterLabels = codes
606 + }
607 +
608 + return RegressionSample(
609 + responseName: responseName,
610 + y: y,
611 + predictors: predictors,
612 + clusterLabels: clusterLabels,
613 + droppedMissing: droppedMissing
614 + )
615 + }
616 +
617 + private func varianceEstimator(
618 + _ command: ZQCommand, clusterLabels: [Int]?
619 + ) throws -> ZQVarianceEstimator {
620 + if let clusterLabels { return .cluster(clusterLabels) }
621 + if command.hasOption("hc2") { return .hc2 }
622 + if command.hasOption("hc3") { return .hc3 }
623 + if command.hasOption("robust") || command.hasOption("r") { return .hc1 }
624 + if let vce = command.option("vce")?.firstArgument {
625 + switch vce {
626 + case "robust": return .hc1
627 + case "hc2": return .hc2
628 + case "hc3": return .hc3
629 + default:
630 + throw ZQEngineError("vce(\(vce)) is not supported")
631 + }
632 + }
633 + return .classical
634 + }
635 +
636 + private func handleRegress(_ command: ZQCommand) throws -> ZQResult {
637 + let clusterVariable = command.option("cluster")?.firstArgument
638 + ?? clusterFromVCE(command)
639 + let sample = try buildRegressionSample(command, clusterVariable: clusterVariable)
640 + let variance = try varianceEstimator(command, clusterLabels: sample.clusterLabels)
641 + let level = try confidenceLevel(command)
642 +
643 + let result = try ZQOLS.fit(
644 + y: sample.y,
645 + predictors: sample.predictors,
646 + includeConstant: !command.hasOption("noconstant"),
647 + variance: variance,
648 + confidenceLevel: level
649 + )
650 +
651 + var header: [String] = []
652 + switch variance {
653 + case .classical: header.append("Linear regression")
654 + case .cluster: header.append("Linear regression (cluster-robust)")
655 + default: header.append("Linear regression (robust)")
656 + }
657 + if sample.droppedMissing > 0 {
658 + header.append("(\(sample.droppedMissing) observations dropped due to missing values)")
659 + }
660 +
661 + var stats = [
662 + ("Number of obs", TableFormatter.general(Double(result.observationCount))),
663 + ("R-squared", TableFormatter.fixed(result.rSquared, decimals: 4)),
664 + ("Adj R-squared", TableFormatter.fixed(result.adjustedRSquared, decimals: 4)),
665 + ("Root MSE", TableFormatter.general(result.rootMSE, significant: 6)),
666 + ]
667 + if let f = result.fStatistic, let df = result.fDF, let p = result.fPValue {
668 + stats.insert(
669 + ("F(\(Int(df.0)), \(Int(df.1)))", TableFormatter.fixed(f, decimals: 2)),
670 + at: 1
671 + )
672 + stats.insert(("Prob > F", TableFormatter.fixed(p, decimals: 4)), at: 2)
673 + }
674 + if let g = result.clusterCount {
675 + stats.append(("Clusters", "\(g)"))
676 + }
677 + for (label, value) in stats {
678 + header.append(
679 + TableFormatter.pad(label, 46, right: false) + "= " +
680 + TableFormatter.pad(value, 12)
681 + )
682 + }
683 +
684 + var lines = header
685 + lines.append("")
686 + let widths = [12, 12, 11, 8, 8, 22]
687 + lines.append(
688 + TableFormatter.pad(sample.responseName, widths[0]) + " | " +
689 + TableFormatter.pad("Coefficient", widths[1]) + " " +
690 + TableFormatter.pad("Std. err.", widths[2]) + " " +
691 + TableFormatter.pad("t", widths[3]) + " " +
692 + TableFormatter.pad("P>|t|", widths[4]) + " " +
693 + TableFormatter.pad("[\(Int(level * 100))% conf. interval]", widths[5])
694 + )
695 + lines.append(TableFormatter.rule(widths))
696 + var scalars: [String: Double] = [
697 + "N": Double(result.observationCount),
698 + "r2": result.rSquared,
699 + "r2_a": result.adjustedRSquared,
700 + "rmse": result.rootMSE,
701 + "df_r": result.inferenceDF,
702 + ]
703 + if let f = result.fStatistic { scalars["F"] = f }
704 + if let g = result.clusterCount { scalars["N_clust"] = Double(g) }
705 +
706 + for coefficient in result.coefficients {
707 + lines.append(
708 + TableFormatter.pad(coefficient.name, widths[0]) + " | " +
709 + TableFormatter.pad(TableFormatter.general(coefficient.estimate), widths[1]) + " " +
710 + TableFormatter.pad(TableFormatter.general(coefficient.standardError), widths[2]) + " " +
711 + TableFormatter.pad(TableFormatter.fixed(coefficient.tStatistic, decimals: 2), widths[3]) + " " +
712 + TableFormatter.pad(TableFormatter.fixed(coefficient.pValue, decimals: 3), widths[4]) + " " +
713 + TableFormatter.pad(
714 + TableFormatter.general(coefficient.confidenceLower) + " " +
715 + TableFormatter.general(coefficient.confidenceUpper),
716 + widths[5]
717 + )
718 + )
719 + scalars["b_\(coefficient.name)"] = coefficient.estimate
720 + scalars["se_\(coefficient.name)"] = coefficient.standardError
721 + }
722 + return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
723 + }
724 +
725 + private func clusterFromVCE(_ command: ZQCommand) -> String? {
726 + guard let vce = command.option("vce"),
727 + vce.arguments.count == 2,
728 + vce.arguments[0] == "cluster" else { return nil }
729 + return vce.arguments[1]
730 + }
731 +
732 + private func confidenceLevel(_ command: ZQCommand) throws -> Double {
733 + guard let text = command.option("level")?.firstArgument else { return 0.95 }
734 + guard let value = Double(text), value > 0, value < 100 else {
735 + throw ZQEngineError("level() must be between 0 and 100")
736 + }
737 + return value / 100
738 + }
739 +
740 + // MARK: - Bootstrap prefix
741 +
742 + private func handleBootstrap(_ command: ZQCommand) async throws -> ZQResult {
743 + guard let body = command.body else {
744 + throw ZQEngineError("bootstrap: syntax is 'bootstrap, reps(#): command'")
745 + }
746 + guard body.verb == "regress" else {
747 + throw ZQEngineError("bootstrap currently supports 'regress' bodies only")
748 + }
749 + guard let repsText = command.option("reps")?.firstArgument,
750 + let reps = Int(repsText), reps >= 2 else {
751 + throw ZQEngineError("bootstrap: reps(#) with # ≥ 2 required")
752 + }
753 + if let seedText = command.option("seed")?.firstArgument {
754 + guard let value = UInt64(seedText) else {
755 + throw ZQEngineError("bootstrap: invalid seed")
756 + }
757 + seed = value
758 + }
759 +
760 + let sample = try buildRegressionSample(body, clusterVariable: nil)
761 + let includeConstant = !body.hasOption("noconstant")
762 + let point = try ZQOLS.fit(
763 + y: sample.y,
764 + predictors: sample.predictors,
765 + includeConstant: includeConstant,
766 + variance: .classical
767 + )
768 +
769 + let n = sample.y.count
770 + let k = point.coefficients.count
771 + let generator = Philox4x32(seed: seed)
772 + let y = sample.y
773 + let predictors = sample.predictors
774 +
775 + // Pairs bootstrap: replicate r regenerates its indices from the
776 + // Philox counter stream — deterministic and order-independent, so
777 + // replicates can be computed in parallel chunks.
778 + let chunkCount = min(
779 + max(1, ProcessInfo.processInfo.activeProcessorCount), reps
780 + )
781 + let chunkSize = (reps + chunkCount - 1) / chunkCount
782 + let draws: [[Double]] = try await withThrowingTaskGroup(
783 + of: [(Int, [Double])].self
784 + ) { group in
785 + for chunk in 0..<chunkCount {
786 + let lower = chunk * chunkSize
787 + let upper = min(reps, lower + chunkSize)
788 + guard lower < upper else { continue }
789 + group.addTask {
790 + var results: [(Int, [Double])] = []
791 + results.reserveCapacity(upper - lower)
792 + for replicate in lower..<upper {
793 + let indices = ZQResampling.pairsBootstrapIndices(
794 + replicate: replicate, sampleSize: n, generator: generator
795 + )
796 + let resampledY = indices.map { y[$0] }
797 + let resampledPredictors = predictors.map { column in
798 + (name: column.name, values: indices.map { column.values[$0] })
799 + }
800 + let fit = try ZQOLS.fit(
801 + y: resampledY,
802 + predictors: resampledPredictors,
803 + includeConstant: includeConstant,
804 + variance: .classical
805 + )
806 + results.append((replicate, fit.coefficients.map(\.estimate)))
807 + }
808 + return results
809 + }
810 + }
811 + var collected = [[Double]](repeating: [], count: reps)
812 + for try await chunkResults in group {
813 + for (replicate, estimates) in chunkResults {
814 + collected[replicate] = estimates
815 + }
816 + }
817 + return collected
818 + }
819 +
820 + // Bootstrap standard errors: sd of replicate estimates.
821 + var lines = [
822 + "Bootstrap results (pairs) Replications = " +
823 + TableFormatter.pad("\(reps)", 10),
824 + " Number of obs = " +
825 + TableFormatter.pad("\(n)", 10),
826 + "",
827 + ]
828 + let widths = [12, 12, 12, 8, 8, 22]
829 + lines.append(
830 + TableFormatter.pad(sample.responseName, widths[0]) + " | " +
831 + TableFormatter.pad("Observed", widths[1]) + " " +
832 + TableFormatter.pad("Bootstrap", widths[2]) + " " +
833 + TableFormatter.pad("z", widths[3]) + " " +
834 + TableFormatter.pad("P>|z|", widths[4]) + " " +
835 + TableFormatter.pad("[95% conf. interval]", widths[5])
836 + )
837 + lines.append(
838 + TableFormatter.pad("", widths[0]) + " | " +
839 + TableFormatter.pad("coefficient", widths[1]) + " " +
840 + TableFormatter.pad("std. err.", widths[2])
841 + )
842 + lines.append(TableFormatter.rule(widths))
843 +
844 + var scalars: [String: Double] = ["reps": Double(reps), "N": Double(n)]
845 + for j in 0..<k {
846 + let estimates = draws.map { $0[j] }
847 + let mean = estimates.reduce(0, +) / Double(reps)
848 + let variance = estimates.reduce(0) { $0 + ($1 - mean) * ($1 - mean) }
849 + / Double(reps - 1)
850 + let se = variance.squareRoot()
851 + let observed = point.coefficients[j].estimate
852 + let z = observed / se
853 + let p = 2 * (1 - ZQDistributions.normalCDF(abs(z)))
854 + let critical = 1.959963984540054 // Φ⁻¹(0.975)
855 + lines.append(
856 + TableFormatter.pad(point.coefficients[j].name, widths[0]) + " | " +
857 + TableFormatter.pad(TableFormatter.general(observed), widths[1]) + " " +
858 + TableFormatter.pad(TableFormatter.general(se), widths[2]) + " " +
859 + TableFormatter.pad(TableFormatter.fixed(z, decimals: 2), widths[3]) + " " +
860 + TableFormatter.pad(TableFormatter.fixed(p, decimals: 3), widths[4]) + " " +
861 + TableFormatter.pad(
862 + TableFormatter.general(observed - critical * se) + " " +
863 + TableFormatter.general(observed + critical * se),
864 + widths[5]
865 + )
866 + )
867 + scalars["b_\(point.coefficients[j].name)"] = observed
868 + scalars["se_\(point.coefficients[j].name)"] = se
869 + }
870 + return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
871 + }
872 +
873 + // MARK: - Graphics
874 +
875 + private func handleGraph(_ command: ZQCommand) throws -> ZQResult {
876 + guard !frame.isEmpty else { throw ZQEngineError("no data in memory") }
877 + let kind: ZQPlotSpec.Kind
878 + switch command.subverb {
879 + case "scatter": kind = .scatter
880 + case "line": kind = .line
881 + case "histogram": kind = .histogram
882 + case let other:
883 + throw ZQEngineError("graph \(other ?? ""): not supported yet")
884 + }
885 +
886 + let names = command.varlist.flatMap(\.referencedNames)
887 + guard names.count == 2 else {
888 + throw ZQEngineError("graph \(command.subverb ?? ""): syntax is 'graph \(command.subverb ?? "kind") yvar xvar'")
889 + }
890 + let mask = try observationMask(command)
891 + let (yValues, yMissing) = try frame.requireNumeric(names[0])
892 + let (xValues, xMissing) = try frame.requireNumeric(names[1])
893 +
894 + func series(label: String, rows: [Int]) -> ZQPlotSpec.Series {
895 + ZQPlotSpec.Series(
896 + label: label,
897 + x: rows.map { xValues[$0] },
898 + y: rows.map { yValues[$0] }
899 + )
900 + }
901 +
902 + let complete = (0..<frame.rowCount).filter {
903 + mask[$0] && !yMissing[$0] && !xMissing[$0]
904 + }
905 +
906 + var allSeries: [ZQPlotSpec.Series] = []
907 + if let byName = command.option("by")?.firstArgument {
908 + let (byValues, byMissing) = try frame.requireNumeric(byName)
909 + let levels = ZQFactorExpansion.levels(
910 + values: complete.map { byValues[$0] },
911 + missing: complete.map { byMissing[$0] }
912 + )
913 + for level in levels {
914 + let rows = complete.filter { !byMissing[$0] && byValues[$0] == level }
915 + let label = level == level.rounded()
916 + ? "\(byName)=\(Int(level))"
917 + : "\(byName)=\(level)"
918 + allSeries.append(series(label: label, rows: rows))
919 + }
920 + } else {
921 + allSeries.append(series(label: names[0], rows: complete))
922 + }
923 +
924 + lastPlot = ZQPlotSpec(
925 + kind: kind,
926 + xLabel: names[1],
927 + yLabel: names[0],
928 + series: allSeries
929 + )
930 + let pointCount = allSeries.reduce(0) { $0 + $1.x.count }
931 + return ZQResult(text: "(plot created: \(pointCount) points)")
932 + }
933 +
934 + // MARK: - Qualifiers
935 +
936 + /// Combined if/in keep-mask over current observations.
937 + private func observationMask(_ command: ZQCommand) throws -> [Bool] {
938 + var mask = [Bool](repeating: true, count: frame.rowCount)
939 + if let condition = command.condition {
940 + let evaluator = ExpressionEvaluator(frame: frame)
941 + mask = try evaluator.evaluateCondition(condition)
942 + }
943 + if let range = command.range {
944 + let n = frame.rowCount
945 + func resolve(_ bound: Int) -> Int {
946 + bound >= 0 ? bound : n + bound + 1
947 + }
948 + let lower = max(1, resolve(range.lower))
949 + let upper = min(n, resolve(range.upper))
950 + for i in 0..<n where i + 1 < lower || i + 1 > upper {
951 + mask[i] = false
952 + }
953 + }
954 + return mask
955 + }
956 +}
added MetrikaKit/Sources/ZQEngine/TableFormatter.swift +48 −0
@@ -0,0 +1,48 @@
1 +//
2 +// TableFormatter.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 +
12 +/// Console table rendering: monospaced, right-aligned numeric columns in
13 +/// the Stata output tradition.
14 +enum TableFormatter {
15 +
16 + /// Stata-flavored general format: up to `significant` significant
17 + /// digits, no trailing zeros, leading "0." collapsed to ".".
18 + static func general(_ value: Double, significant: Int = 7) -> String {
19 + if value.isNaN { return "." }
20 + if value == 0 { return "0" }
21 + var text = String(format: "%.\(significant)g", value)
22 + if text.contains("."), !text.contains("e"), !text.contains("E") {
23 + while text.hasSuffix("0") { text.removeLast() }
24 + if text.hasSuffix(".") { text.removeLast() }
25 + }
26 + if text.hasPrefix("0.") { text.removeFirst() }
27 + if text.hasPrefix("-0.") { text = "-" + text.dropFirst(2) }
28 + return text
29 + }
30 +
31 + static func fixed(_ value: Double, decimals: Int) -> String {
32 + value.isNaN ? "." : String(format: "%.\(decimals)f", value)
33 + }
34 +
35 + static func pad(_ text: String, _ width: Int, right: Bool = true) -> String {
36 + if text.count >= width { return text }
37 + let padding = String(repeating: " ", count: width - text.count)
38 + return right ? padding + text : text + padding
39 + }
40 +
41 + /// Renders rows of cells with per-column widths; the first column is
42 + /// left-padded to its width and separated by " | ".
43 + static func rule(_ widths: [Int]) -> String {
44 + let first = String(repeating: "-", count: widths[0] + 1)
45 + let rest = widths.dropFirst().reduce(0) { $0 + $1 } + (widths.count - 1) * 2
46 + return first + "+" + String(repeating: "-", count: rest + 1)
47 + }
48 +}
added MetrikaKit/Sources/ZQEngine/ZQEngineModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQEngineModule.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 +/// Module marker for ZQEngine.
11 +public enum ZQEngineModule {
12 + public static let name = "ZQEngine"
13 +}
added MetrikaKit/Sources/ZQGPU/Philox.swift +101 −0
@@ -0,0 +1,101 @@
1 +//
2 +// Philox.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 +/// Philox4x32-10 counter-based random number generator (Salmon et al.,
11 +/// SC'11). Every 128-bit counter maps to four independent 32-bit outputs,
12 +/// so draws are addressable by index — the property that makes CPU and
13 +/// GPU streams identical for the same seed (CLAUDE.md §5: reproducibility
14 +/// is a release blocker). This pure-Swift implementation is the reference
15 +/// the MLX kernel path must match bit-for-bit.
16 +public struct Philox4x32: Sendable {
17 + private static let multiplier0: UInt32 = 0xD251_1F53
18 + private static let multiplier1: UInt32 = 0xCD9E_8D57
19 + private static let weyl0: UInt32 = 0x9E37_79B9
20 + private static let weyl1: UInt32 = 0xBB67_AE85
21 + private static let rounds = 10
22 +
23 + public let key: (UInt32, UInt32)
24 +
25 + /// `set seed` maps the 64-bit seed directly onto the Philox key.
26 + public init(seed: UInt64) {
27 + self.key = (UInt32(truncatingIfNeeded: seed),
28 + UInt32(truncatingIfNeeded: seed >> 32))
29 + }
30 +
31 + /// One Philox block: 128-bit counter → four 32-bit words.
32 + public func block(counter: (UInt32, UInt32, UInt32, UInt32))
33 + -> (UInt32, UInt32, UInt32, UInt32) {
34 + var c = counter
35 + var k = key
36 + for _ in 0..<Self.rounds {
37 + let product0 = UInt64(Self.multiplier0) * UInt64(c.0)
38 + let product1 = UInt64(Self.multiplier1) * UInt64(c.2)
39 + let high0 = UInt32(truncatingIfNeeded: product0 >> 32)
40 + let low0 = UInt32(truncatingIfNeeded: product0)
41 + let high1 = UInt32(truncatingIfNeeded: product1 >> 32)
42 + let low1 = UInt32(truncatingIfNeeded: product1)
43 + c = (high1 ^ c.1 ^ k.0, low1, high0 ^ c.3 ^ k.1, low0)
44 + k.0 = k.0 &+ Self.weyl0
45 + k.1 = k.1 &+ Self.weyl1
46 + }
47 + return c
48 + }
49 +
50 + /// The i-th 32-bit word of the stream (block = i/4, lane = i%4).
51 + public func word(at index: UInt64) -> UInt32 {
52 + let blockIndex = index / 4
53 + let lane = Int(index % 4)
54 + let output = block(counter: (
55 + UInt32(truncatingIfNeeded: blockIndex),
56 + UInt32(truncatingIfNeeded: blockIndex >> 32),
57 + 0, 0
58 + ))
59 + switch lane {
60 + case 0: return output.0
61 + case 1: return output.1
62 + case 2: return output.2
63 + default: return output.3
64 + }
65 + }
66 +
67 + /// Uniform Double in [0, 1) from the i-th draw, using 53 bits built
68 + /// from two consecutive 32-bit words.
69 + public func uniform(at index: UInt64) -> Double {
70 + let high = UInt64(word(at: index &* 2))
71 + let low = UInt64(word(at: index &* 2 &+ 1))
72 + let bits53 = ((high << 32) | low) >> 11
73 + return Double(bits53) * (1.0 / 9_007_199_254_740_992.0) // 2^-53
74 + }
75 +
76 + /// Uniform integer in 0..<bound from the i-th draw (used for
77 + /// bootstrap resampling indices). Bounded by 64-bit multiply-shift;
78 + /// the tiny modulo bias (< 2⁻³² for realistic n) is acceptable and,
79 + /// critically, is computed identically on CPU and GPU.
80 + public func integer(at index: UInt64, bound: Int) -> Int {
81 + precondition(bound > 0)
82 + let word = UInt64(self.word(at: index))
83 + return Int((word &* UInt64(bound)) >> 32)
84 + }
85 +}
86 +
87 +/// Bootstrap resampling indices: replicate r draws its n indices from
88 +/// dedicated counter positions, so any subset of replicates can be
89 +/// regenerated independently — on either backend.
90 +public enum ZQResampling {
91 + public static func pairsBootstrapIndices(
92 + replicate: Int, sampleSize: Int, generator: Philox4x32
93 + ) -> [Int] {
94 + let base = UInt64(replicate) &* UInt64(sampleSize)
95 + var indices = [Int](repeating: 0, count: sampleSize)
96 + for i in 0..<sampleSize {
97 + indices[i] = generator.integer(at: base &+ UInt64(i), bound: sampleSize)
98 + }
99 + return indices
100 + }
101 +}
added MetrikaKit/Sources/ZQGPU/ZQGPUModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQGPUModule.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 +/// Module marker for ZQGPU.
11 +public enum ZQGPUModule {
12 + public static let name = "ZQGPU"
13 +}
added MetrikaKit/Sources/ZQGraphics/PlotSpec.swift +43 −0
@@ -0,0 +1,43 @@
1 +//
2 +// PlotSpec.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 +
12 +/// Backend-neutral plot description produced by `graph` commands. The app
13 +/// layer renders it with Swift Charts, or the Metal point renderer once
14 +/// the series crosses ~1M points (CLAUDE.md §7).
15 +public struct ZQPlotSpec: Equatable, Sendable {
16 + public enum Kind: String, Equatable, Sendable {
17 + case scatter, line, histogram, kdensity
18 + }
19 +
20 + public struct Series: Equatable, Sendable {
21 + public var label: String
22 + public var x: [Double]
23 + public var y: [Double]
24 +
25 + public init(label: String, x: [Double], y: [Double]) {
26 + self.label = label
27 + self.x = x
28 + self.y = y
29 + }
30 + }
31 +
32 + public var kind: Kind
33 + public var xLabel: String
34 + public var yLabel: String
35 + public var series: [Series]
36 +
37 + public init(kind: Kind, xLabel: String, yLabel: String, series: [Series]) {
38 + self.kind = kind
39 + self.xLabel = xLabel
40 + self.yLabel = yLabel
41 + self.series = series
42 + }
43 +}
added MetrikaKit/Sources/ZQGraphics/ZQGraphicsModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQGraphicsModule.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 +/// Module marker for ZQGraphics.
11 +public enum ZQGraphicsModule {
12 + public static let name = "ZQGraphics"
13 +}
added MetrikaKit/Sources/ZQParser/AST.swift +167 −0
@@ -0,0 +1,167 @@
1 +//
2 +// AST.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 +/// A variable specification appearing in a varlist. Factor notation is
11 +/// preserved verbatim; expansion into indicator columns happens at plan
12 +/// time (CLAUDE.md §4), never at parse time.
13 +public indirect enum ZQVarSpec: Equatable, Sendable {
14 + /// Plain variable reference: `price`
15 + case simple(String)
16 + /// Factor-operator variable: `i.region`, `c.age`, `ib2.group`
17 + case factor(op: String, name: String)
18 + /// Interaction: `c.age#c.age`, `i.region#c.price`
19 + case interaction([ZQVarSpec], full: Bool)
20 +
21 + /// The underlying dataset column names this spec references.
22 + public var referencedNames: [String] {
23 + switch self {
24 + case .simple(let n): return [n]
25 + case .factor(_, let n): return [n]
26 + case .interaction(let parts, _): return parts.flatMap(\.referencedNames)
27 + }
28 + }
29 +}
30 +
31 +/// Expression AST used for `if` qualifiers, `gen`/`replace` right-hand
32 +/// sides, and option arguments.
33 +public indirect enum ZQExpression: Equatable, Sendable {
34 + case number(Double)
35 + case string(String)
36 + case variable(String)
37 + /// Stata-style missing-value literal `.`
38 + case missing
39 + case unary(op: String, ZQExpression)
40 + case binary(op: String, ZQExpression, ZQExpression)
41 + case call(name: String, arguments: [ZQExpression])
42 +}
43 +
44 +/// Observation range qualifier: `in 5/200`. Negative values count from the
45 +/// end (`in -10/-1`), 1-based like Stata.
46 +public struct ZQRange: Equatable, Sendable {
47 + public var lower: Int
48 + public var upper: Int
49 +
50 + public init(lower: Int, upper: Int) {
51 + self.lower = lower
52 + self.upper = upper
53 + }
54 +}
55 +
56 +/// Weight qualifier: `[aweight = expr]`.
57 +public struct ZQWeight: Equatable, Sendable {
58 + public enum Kind: String, Equatable, Sendable {
59 + case aweight, fweight, pweight, iweight
60 + }
61 +
62 + public var kind: Kind
63 + public var expression: ZQExpression
64 +
65 + public init(kind: Kind, expression: ZQExpression) {
66 + self.kind = kind
67 + self.expression = expression
68 + }
69 +}
70 +
71 +/// A trailing option: bare flag (`robust`, `detail`) or parenthesized
72 +/// (`cluster(id)`, `reps(100000)`, `by(region year)`).
73 +public struct ZQOption: Equatable, Sendable {
74 + public var name: String
75 + /// Raw argument tokens re-rendered as strings; interpretation is
76 + /// command-specific and happens at plan/execution time.
77 + public var arguments: [String]
78 +
79 + public init(name: String, arguments: [String] = []) {
80 + self.name = name
81 + self.arguments = arguments
82 + }
83 +
84 + public var firstArgument: String? { arguments.first }
85 +}
86 +
87 +/// Assignment payload for `gen`/`replace`-style commands:
88 +/// `gen log_rev = ln(revenue)`.
89 +public struct ZQAssignment: Equatable, Sendable {
90 + public var target: String
91 + public var expression: ZQExpression
92 +
93 + public init(target: String, expression: ZQExpression) {
94 + self.target = target
95 + self.expression = expression
96 + }
97 +}
98 +
99 +/// A fully parsed ZQL command:
100 +/// `command [varlist] [if expr] [in range] [weight] [, options]`,
101 +/// optionally wrapped by a prefix command (`bootstrap, reps(1000): reg …`).
102 +public struct ZQCommand: Equatable, Sendable {
103 + /// Canonical verb (abbreviations already resolved: `reg` → `regress`).
104 + public var verb: String
105 + /// Sub-verb for compound commands (`graph scatter …` → "scatter").
106 + public var subverb: String?
107 + public var varlist: [ZQVarSpec]
108 + /// `gen`/`replace` assignment, mutually exclusive with a plain varlist.
109 + public var assignment: ZQAssignment?
110 + /// Raw argument such as a file path (`use sales.parquet`).
111 + public var argument: String?
112 + public var condition: ZQExpression?
113 + public var range: ZQRange?
114 + public var weight: ZQWeight?
115 + public var options: [ZQOption]
116 + /// Inner command of a prefix construct: for
117 + /// `bootstrap, reps(1000): reg y x`, the outer command is `bootstrap`
118 + /// and `body` holds the parsed `regress` command.
119 + public var body: ZQCommand? {
120 + get { bodyBox?.value }
121 + set { bodyBox = newValue.map(Box.init) }
122 + }
123 +
124 + private var bodyBox: Box<ZQCommand>?
125 +
126 + public init(
127 + verb: String,
128 + subverb: String? = nil,
129 + varlist: [ZQVarSpec] = [],
130 + assignment: ZQAssignment? = nil,
131 + argument: String? = nil,
132 + condition: ZQExpression? = nil,
133 + range: ZQRange? = nil,
134 + weight: ZQWeight? = nil,
135 + options: [ZQOption] = [],
136 + body: ZQCommand? = nil
137 + ) {
138 + self.verb = verb
139 + self.subverb = subverb
140 + self.varlist = varlist
141 + self.assignment = assignment
142 + self.argument = argument
143 + self.condition = condition
144 + self.range = range
145 + self.weight = weight
146 + self.options = options
147 + self.bodyBox = body.map(Box.init)
148 + }
149 +
150 + public func option(_ name: String) -> ZQOption? {
151 + options.first { $0.name == name }
152 + }
153 +
154 + public func hasOption(_ name: String) -> Bool {
155 + option(name) != nil
156 + }
157 +}
158 +
159 +/// Reference box that lets `ZQCommand` nest itself as a prefix body.
160 +private final class Box<T>: @unchecked Sendable {
161 + let value: T
162 + init(_ value: T) { self.value = value }
163 +}
164 +
165 +extension Box: Equatable where T: Equatable {
166 + static func == (lhs: Box<T>, rhs: Box<T>) -> Bool { lhs.value == rhs.value }
167 +}
added MetrikaKit/Sources/ZQParser/Grammar/CommandParser.swift +480 −0
@@ -0,0 +1,480 @@
1 +//
2 +// CommandParser.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 +
12 +/// Parser for one ZQL command line:
13 +///
14 +/// command [varlist] [if expr] [in range] [weight] [, options]
15 +///
16 +/// Prefix commands (`bootstrap, reps(1000): reg y x`) recurse on the text
17 +/// after the colon. File-taking verbs (`use`, `save`, …) treat everything
18 +/// before the first top-level comma as a raw path.
19 +public struct ZQCommandParser: Sendable {
20 + public let verbTable: ZQVerbTable
21 +
22 + public init(verbTable: ZQVerbTable = .builtin) {
23 + self.verbTable = verbTable
24 + }
25 +
26 + /// Parses a single line. Returns nil for blank lines and comments.
27 + public func parse(_ line: String) throws(ZQParseError) -> ZQCommand? {
28 + let trimmed = line.trimmingCharacters(in: .whitespaces)
29 + if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") {
30 + return nil
31 + }
32 +
33 + // Extract the verb word to decide the parsing strategy.
34 + let verbWord = String(trimmed.prefix { !$0.isWhitespace && $0 != "," })
35 + let verbColumn = columnOf(word: verbWord, in: line)
36 + guard let verb = verbTable.resolve(verbWord.lowercased()) else {
37 + throw ZQParseError(
38 + message: "unknown command '\(verbWord)'",
39 + column: verbColumn,
40 + suggestion: verbTable.closest(to: verbWord.lowercased())
41 + )
42 + }
43 +
44 + if verbTable.prefixVerbs.contains(verb) {
45 + return try parsePrefixCommand(verb: verb, line: line, trimmed: trimmed)
46 + }
47 + if verbTable.fileVerbs.contains(verb) {
48 + return try parseFileCommand(verb: verb, trimmed: trimmed)
49 + }
50 + return try parseStandardCommand(verb: verb, line: line, trimmed: trimmed)
51 + }
52 +
53 + // MARK: - Prefix commands
54 +
55 + private func parsePrefixCommand(
56 + verb: String, line: String, trimmed: String
57 + ) throws(ZQParseError) -> ZQCommand {
58 + // Split on the first top-level colon: `bootstrap, reps(1000): reg y x`
59 + guard let colonIndex = topLevelColonIndex(in: trimmed) else {
60 + throw ZQParseError(
61 + message: "prefix command '\(verb)' requires ': command'",
62 + column: trimmed.count + 1
63 + )
64 + }
65 + let head = String(trimmed[trimmed.startIndex..<colonIndex])
66 + let tail = String(trimmed[trimmed.index(after: colonIndex)...])
67 + .trimmingCharacters(in: .whitespaces)
68 +
69 + var command = try parseStandardCommand(verb: verb, line: head, trimmed: head)
70 + guard let body = try parse(tail) else {
71 + throw ZQParseError(
72 + message: "prefix command '\(verb)' has an empty body",
73 + column: trimmed.count + 1
74 + )
75 + }
76 + command.body = body
77 + return command
78 + }
79 +
80 + /// Index of the first colon that is not nested inside parentheses,
81 + /// brackets, or a string literal.
82 + private func topLevelColonIndex(in text: String) -> String.Index? {
83 + var depth = 0
84 + var inString = false
85 + var index = text.startIndex
86 + while index < text.endIndex {
87 + let c = text[index]
88 + if c == "\"" { inString.toggle() }
89 + if !inString {
90 + switch c {
91 + case "(", "[": depth += 1
92 + case ")", "]": depth -= 1
93 + case ":" where depth == 0: return index
94 + default: break
95 + }
96 + }
97 + index = text.index(after: index)
98 + }
99 + return nil
100 + }
101 +
102 + // MARK: - File commands
103 +
104 + private func parseFileCommand(
105 + verb: String, trimmed: String
106 + ) throws(ZQParseError) -> ZQCommand {
107 + // Everything after the verb and before the first top-level comma is
108 + // a raw path (quoted paths keep spaces); options follow the comma.
109 + let afterVerb = trimmed.drop { !$0.isWhitespace }
110 + var path = String(afterVerb).trimmingCharacters(in: .whitespaces)
111 + var options: [ZQOption] = []
112 +
113 + if let commaIndex = topLevelCommaIndex(in: path) {
114 + let optionText = String(path[path.index(after: commaIndex)...])
115 + path = String(path[path.startIndex..<commaIndex])
116 + .trimmingCharacters(in: .whitespaces)
117 + let lexer = ZQLexer()
118 + let tokens = try lexer.tokenize(optionText)
119 + var cursor = ExpressionParser(tokens: tokens)
120 + options = try parseOptions(&cursor)
121 + }
122 +
123 + // `use file` / `import file` accept an optional leading `using`.
124 + for keyword in ["using ", "using\t"] where path.hasPrefix(keyword) {
125 + path = String(path.dropFirst(keyword.count))
126 + .trimmingCharacters(in: .whitespaces)
127 + }
128 + if path.hasPrefix("\""), path.hasSuffix("\""), path.count >= 2 {
129 + path = String(path.dropFirst().dropLast())
130 + }
131 +
132 + return ZQCommand(
133 + verb: verb,
134 + argument: path.isEmpty ? nil : path,
135 + options: options
136 + )
137 + }
138 +
139 + private func topLevelCommaIndex(in text: String) -> String.Index? {
140 + var depth = 0
141 + var inString = false
142 + var index = text.startIndex
143 + while index < text.endIndex {
144 + let c = text[index]
145 + if c == "\"" { inString.toggle() }
146 + if !inString {
147 + switch c {
148 + case "(", "[": depth += 1
149 + case ")", "]": depth -= 1
150 + case "," where depth == 0: return index
151 + default: break
152 + }
153 + }
154 + index = text.index(after: index)
155 + }
156 + return nil
157 + }
158 +
159 + // MARK: - Standard commands
160 +
161 + private func parseStandardCommand(
162 + verb: String, line: String, trimmed: String
163 + ) throws(ZQParseError) -> ZQCommand {
164 + let lexer = ZQLexer()
165 + let tokens = try lexer.tokenize(trimmed)
166 + var cursor = ExpressionParser(tokens: tokens)
167 +
168 + // Consume the (already resolved) verb token.
169 + _ = cursor.advance()
170 + var command = ZQCommand(verb: verb)
171 +
172 + // Compound verbs: `graph scatter y x`
173 + if verbTable.compoundVerbs.contains(verb) {
174 + guard case .identifier(let sub) = cursor.current.kind else {
175 + throw ZQParseError(
176 + message: "'\(verb)' requires a sub-command",
177 + column: cursor.current.column
178 + )
179 + }
180 + _ = cursor.advance()
181 + command.subverb = sub
182 + }
183 +
184 + // `set seed 42` → subverb "seed", argument "42"
185 + if verb == "set" {
186 + guard case .identifier(let sub) = cursor.current.kind else {
187 + throw ZQParseError(
188 + message: "'set' requires a parameter name",
189 + column: cursor.current.column
190 + )
191 + }
192 + _ = cursor.advance()
193 + command.subverb = sub
194 + if case .number(let value) = cursor.current.kind {
195 + _ = cursor.advance()
196 + command.argument = renderNumber(value)
197 + } else if case .identifier(let word) = cursor.current.kind {
198 + _ = cursor.advance()
199 + command.argument = word
200 + }
201 + try expectEnd(&cursor)
202 + return command
203 + }
204 +
205 + // `display 2 + 2` — a bare expression command.
206 + if verb == "display" {
207 + let expression = try cursor.parseExpression()
208 + command.assignment = ZQAssignment(target: "", expression: expression)
209 + try expectEnd(&cursor)
210 + return command
211 + }
212 +
213 + // Assignment verbs: `gen log_rev = ln(revenue)`
214 + if verbTable.assignmentVerbs.contains(verb) {
215 + guard case .identifier(let target) = cursor.current.kind else {
216 + throw ZQParseError(
217 + message: "'\(verb)' requires a variable name",
218 + column: cursor.current.column
219 + )
220 + }
221 + _ = cursor.advance()
222 + try cursor.expect(.op("="))
223 + let expression = try cursor.parseExpression()
224 + command.assignment = ZQAssignment(target: target, expression: expression)
225 + } else {
226 + command.varlist = try parseVarlist(&cursor)
227 + }
228 +
229 + // Qualifiers may appear in any sensible order; Stata fixes the
230 + // order as if → in → weight, which we enforce implicitly.
231 + if consumeKeyword(&cursor, "if") {
232 + command.condition = try cursor.parseExpression()
233 + }
234 + if consumeKeyword(&cursor, "in") {
235 + command.range = try parseRange(&cursor)
236 + }
237 + if cursor.isKind(.lbracket) {
238 + command.weight = try parseWeight(&cursor)
239 + }
240 + if cursor.consumeIfKind(.comma) {
241 + command.options = try parseOptions(&cursor)
242 + }
243 + try expectEnd(&cursor)
244 + return command
245 + }
246 +
247 + // MARK: - Varlist
248 +
249 + private func parseVarlist(
250 + _ cursor: inout ExpressionParser
251 + ) throws(ZQParseError) -> [ZQVarSpec] {
252 + var specs: [ZQVarSpec] = []
253 + while case .identifier(let name) = cursor.current.kind {
254 + if name == "if" || name == "in" { break }
255 + specs.append(try parseVarSpec(&cursor))
256 + }
257 + return specs
258 + }
259 +
260 + private func parseVarSpec(
261 + _ cursor: inout ExpressionParser
262 + ) throws(ZQParseError) -> ZQVarSpec {
263 + var terms: [ZQVarSpec] = [try parseFactorTerm(&cursor)]
264 + var full = false
265 + while case .op(let symbol) = cursor.current.kind, symbol == "#" || symbol == "##" {
266 + full = full || symbol == "##"
267 + _ = cursor.advance()
268 + terms.append(try parseFactorTerm(&cursor))
269 + }
270 + return terms.count == 1 ? terms[0] : .interaction(terms, full: full)
271 + }
272 +
273 + private func parseFactorTerm(
274 + _ cursor: inout ExpressionParser
275 + ) throws(ZQParseError) -> ZQVarSpec {
276 + guard case .identifier(let first) = cursor.current.kind else {
277 + throw ZQParseError(
278 + message: "expected a variable name, found \(cursor.current.kind.describe)",
279 + column: cursor.current.column
280 + )
281 + }
282 + _ = cursor.advance()
283 + if cursor.isKind(.op(".")) {
284 + _ = cursor.advance()
285 + guard case .identifier(let name) = cursor.current.kind else {
286 + throw ZQParseError(
287 + message: "expected a variable name after '\(first).'",
288 + column: cursor.current.column
289 + )
290 + }
291 + _ = cursor.advance()
292 + return .factor(op: first, name: name)
293 + }
294 + return .simple(first)
295 + }
296 +
297 + // MARK: - Qualifiers
298 +
299 + private func consumeKeyword(_ cursor: inout ExpressionParser, _ keyword: String) -> Bool {
300 + if case .identifier(let word) = cursor.current.kind, word == keyword {
301 + _ = cursor.advance()
302 + return true
303 + }
304 + return false
305 + }
306 +
307 + private func parseRange(
308 + _ cursor: inout ExpressionParser
309 + ) throws(ZQParseError) -> ZQRange {
310 + let lower = try parseRangeBound(&cursor)
311 + try cursor.expect(.op("/"))
312 + let upper = try parseRangeBound(&cursor)
313 + return ZQRange(lower: lower, upper: upper)
314 + }
315 +
316 + private func parseRangeBound(
317 + _ cursor: inout ExpressionParser
318 + ) throws(ZQParseError) -> Int {
319 + var sign = 1
320 + if cursor.consumeIfKind(.op("-")) { sign = -1 }
321 + // `f` and `l` mean first and last observation.
322 + if case .identifier(let word) = cursor.current.kind {
323 + if word == "f" { _ = cursor.advance(); return 1 }
324 + if word == "l" { _ = cursor.advance(); return -1 }
325 + }
326 + guard case .number(let value) = cursor.current.kind,
327 + value == value.rounded() else {
328 + throw ZQParseError(
329 + message: "expected an integer observation number",
330 + column: cursor.current.column
331 + )
332 + }
333 + _ = cursor.advance()
334 + return sign * Int(value)
335 + }
336 +
337 + private func parseWeight(
338 + _ cursor: inout ExpressionParser
339 + ) throws(ZQParseError) -> ZQWeight {
340 + try cursor.expect(.lbracket)
341 + guard case .identifier(let kindWord) = cursor.current.kind else {
342 + throw ZQParseError(
343 + message: "expected a weight type (aweight, fweight, pweight, iweight)",
344 + column: cursor.current.column
345 + )
346 + }
347 + let normalized: ZQWeight.Kind? = switch kindWord {
348 + case "aweight", "aw", "w", "weight": .aweight
349 + case "fweight", "fw": .fweight
350 + case "pweight", "pw": .pweight
351 + case "iweight", "iw": .iweight
352 + default: nil
353 + }
354 + guard let kind = normalized else {
355 + throw ZQParseError(
356 + message: "unknown weight type '\(kindWord)'",
357 + column: cursor.current.column
358 + )
359 + }
360 + _ = cursor.advance()
361 + try cursor.expect(.op("="))
362 + let expression = try cursor.parseExpression()
363 + try cursor.expect(.rbracket)
364 + return ZQWeight(kind: kind, expression: expression)
365 + }
366 +
367 + // MARK: - Options
368 +
369 + private func parseOptions(
370 + _ cursor: inout ExpressionParser
371 + ) throws(ZQParseError) -> [ZQOption] {
372 + var options: [ZQOption] = []
373 + while case .identifier(let name) = cursor.current.kind {
374 + _ = cursor.advance()
375 + var arguments: [String] = []
376 + if cursor.consumeIfKind(.lparen) {
377 + var depth = 1
378 + var pieces: [String] = []
379 + while depth > 0 {
380 + let token = cursor.current
381 + switch token.kind {
382 + case .lparen:
383 + depth += 1
384 + pieces.append("(")
385 + case .rparen:
386 + depth -= 1
387 + if depth > 0 { pieces.append(")") }
388 + case .endOfLine:
389 + throw ZQParseError(
390 + message: "unclosed '(' in option '\(name)'",
391 + column: token.column
392 + )
393 + case .identifier(let word):
394 + pieces.append(word)
395 + case .number(let value):
396 + pieces.append(renderNumber(value))
397 + case .string(let text):
398 + pieces.append("\"\(text)\"")
399 + case .comma:
400 + pieces.append(",")
401 + case .colon:
402 + pieces.append(":")
403 + case .lbracket:
404 + pieces.append("[")
405 + case .rbracket:
406 + pieces.append("]")
407 + case .op(let symbol):
408 + pieces.append(symbol)
409 + }
410 + _ = cursor.advance()
411 + }
412 + // Arguments split on top-level spaces in the original text
413 + // are reassembled here token-by-token: identifiers and
414 + // numbers stand alone, operator glue joins with neighbors.
415 + arguments = assembleOptionArguments(pieces)
416 + }
417 + options.append(ZQOption(name: name, arguments: arguments))
418 + // Optional commas between options are tolerated.
419 + _ = cursor.consumeIfKind(.comma)
420 + }
421 + return options
422 + }
423 +
424 + /// Joins raw option-argument pieces into logical arguments: glue
425 + /// operators (`.`, `=`, comparison, arithmetic) bind to their
426 + /// neighbors, and standalone words split on whitespace boundaries.
427 + private func assembleOptionArguments(_ pieces: [String]) -> [String] {
428 + let glue: Set<String> = [
429 + ".", "=", "==", "!=", "<", "<=", ">", ">=",
430 + "+", "-", "*", "/", "^", "#", "##", "&", "|", "!",
431 + ]
432 + var arguments: [String] = []
433 + var pending = ""
434 + var previousWasGlue = false
435 +
436 + for piece in pieces {
437 + if piece == "," {
438 + if !pending.isEmpty { arguments.append(pending); pending = "" }
439 + previousWasGlue = false
440 + continue
441 + }
442 + if glue.contains(piece) {
443 + pending += piece
444 + previousWasGlue = true
445 + } else if previousWasGlue || pending.isEmpty {
446 + pending += piece
447 + previousWasGlue = false
448 + } else {
449 + arguments.append(pending)
450 + pending = piece
451 + previousWasGlue = false
452 + }
453 + }
454 + if !pending.isEmpty { arguments.append(pending) }
455 + return arguments
456 + }
457 +
458 + // MARK: - Helpers
459 +
460 + private func expectEnd(_ cursor: inout ExpressionParser) throws(ZQParseError) {
461 + guard cursor.isKind(.endOfLine) else {
462 + throw ZQParseError(
463 + message: "unexpected \(cursor.current.kind.describe) after end of command",
464 + column: cursor.current.column
465 + )
466 + }
467 + }
468 +
469 + private func renderNumber(_ value: Double) -> String {
470 + if value == value.rounded(), abs(value) < 1e15 {
471 + return String(Int64(value))
472 + }
473 + return String(value)
474 + }
475 +
476 + private func columnOf(word: String, in line: String) -> Int {
477 + guard let range = line.range(of: word) else { return 1 }
478 + return line.distance(from: line.startIndex, to: range.lowerBound) + 1
479 + }
480 +}
added MetrikaKit/Sources/ZQParser/Grammar/ExpressionParser.swift +128 −0
@@ -0,0 +1,128 @@
1 +//
2 +// ExpressionParser.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 +/// Pratt (precedence-climbing) parser for ZQL expressions, shared by `if`
11 +/// qualifiers, `gen`/`replace` right-hand sides, and weight expressions.
12 +///
13 +/// Precedence (low → high):
14 +/// | · & · comparison (== != < <= > >=) · + - · * / · ^ · unary (- !)
15 +struct ExpressionParser {
16 + var tokens: [ZQToken]
17 + var position: Int
18 +
19 + init(tokens: [ZQToken], position: Int = 0) {
20 + self.tokens = tokens
21 + self.position = position
22 + }
23 +
24 + var current: ZQToken { tokens[position] }
25 +
26 + mutating func advance() -> ZQToken {
27 + let token = tokens[position]
28 + if position < tokens.count - 1 { position += 1 }
29 + return token
30 + }
31 +
32 + private static let precedence: [String: Int] = [
33 + "|": 10,
34 + "&": 20,
35 + "==": 30, "!=": 30, "<": 30, "<=": 30, ">": 30, ">=": 30,
36 + "+": 40, "-": 40,
37 + "*": 50, "/": 50,
38 + "^": 60,
39 + ]
40 +
41 + mutating func parseExpression(minPrecedence: Int = 0) throws(ZQParseError) -> ZQExpression {
42 + var left = try parseUnary()
43 +
44 + while case .op(let symbol) = current.kind,
45 + let precedence = Self.precedence[symbol],
46 + precedence >= minPrecedence {
47 + _ = advance()
48 + // ^ is right-associative; everything else is left-associative.
49 + let nextMin = symbol == "^" ? precedence : precedence + 1
50 + let right = try parseExpression(minPrecedence: nextMin)
51 + left = .binary(op: symbol, left, right)
52 + }
53 + return left
54 + }
55 +
56 + private mutating func parseUnary() throws(ZQParseError) -> ZQExpression {
57 + if case .op(let symbol) = current.kind, symbol == "-" || symbol == "!" {
58 + _ = advance()
59 + let operand = try parseUnary()
60 + return .unary(op: symbol, operand)
61 + }
62 + return try parsePrimary()
63 + }
64 +
65 + private mutating func parsePrimary() throws(ZQParseError) -> ZQExpression {
66 + let token = current
67 + switch token.kind {
68 + case .number(let value):
69 + _ = advance()
70 + return .number(value)
71 +
72 + case .string(let value):
73 + _ = advance()
74 + return .string(value)
75 +
76 + case .op("."):
77 + // Bare `.` is the missing-value literal (`if income == .`).
78 + _ = advance()
79 + return .missing
80 +
81 + case .identifier(let name):
82 + _ = advance()
83 + if case .lparen = current.kind {
84 + _ = advance()
85 + var arguments: [ZQExpression] = []
86 + if !isKind(.rparen) {
87 + repeat {
88 + arguments.append(try parseExpression())
89 + } while consumeIfKind(.comma)
90 + }
91 + try expect(.rparen)
92 + return .call(name: name, arguments: arguments)
93 + }
94 + return .variable(name)
95 +
96 + case .lparen:
97 + _ = advance()
98 + let inner = try parseExpression()
99 + try expect(.rparen)
100 + return inner
101 +
102 + default:
103 + throw ZQParseError(
104 + message: "expected an expression, found \(token.kind.describe)",
105 + column: token.column
106 + )
107 + }
108 + }
109 +
110 + func isKind(_ kind: ZQToken.Kind) -> Bool { current.kind == kind }
111 +
112 + mutating func consumeIfKind(_ kind: ZQToken.Kind) -> Bool {
113 + if current.kind == kind {
114 + _ = advance()
115 + return true
116 + }
117 + return false
118 + }
119 +
120 + mutating func expect(_ kind: ZQToken.Kind) throws(ZQParseError) {
121 + guard consumeIfKind(kind) else {
122 + throw ZQParseError(
123 + message: "expected \(kind.describe), found \(current.kind.describe)",
124 + column: current.column
125 + )
126 + }
127 + }
128 +}
added MetrikaKit/Sources/ZQParser/KnownVerbs.swift +109 −0
@@ -0,0 +1,109 @@
1 +//
2 +// KnownVerbs.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 +/// Registry of built-in ZQL verbs with their minimum abbreviations,
11 +/// Stata-style: `reg` resolves to `regress`, `su` to `summarize`.
12 +public struct ZQVerbTable: Sendable {
13 + /// canonical verb → minimum abbreviation length
14 + public let verbs: [String: Int]
15 + /// Verbs whose first argument is a raw file path rather than a varlist.
16 + public let fileVerbs: Set<String>
17 + /// Verbs taking a `newvar = expression` assignment.
18 + public let assignmentVerbs: Set<String>
19 + /// Prefix verbs that wrap another command after a colon.
20 + public let prefixVerbs: Set<String>
21 + /// Compound verbs followed by a sub-verb (`graph scatter …`).
22 + public let compoundVerbs: Set<String>
23 +
24 + public static let builtin = ZQVerbTable(
25 + verbs: [
26 + "use": 3,
27 + "save": 4,
28 + "import": 3,
29 + "export": 3,
30 + "summarize": 2,
31 + "tabulate": 3,
32 + "correlate": 3,
33 + "describe": 1,
34 + "list": 1,
35 + "generate": 3,
36 + "replace": 7,
37 + "drop": 4,
38 + "keep": 4,
39 + "regress": 3,
40 + "logit": 5,
41 + "probit": 6,
42 + "poisson": 7,
43 + "ivregress": 9,
44 + "xtreg": 5,
45 + "xtset": 5,
46 + "areg": 4,
47 + "bootstrap": 9,
48 + "permute": 7,
49 + "jackknife": 9,
50 + "graph": 2,
51 + "histogram": 4,
52 + "scatter": 7,
53 + "display": 2,
54 + "log": 3,
55 + "set": 3,
56 + "clear": 5,
57 + "count": 3,
58 + "sort": 4,
59 + "merge": 5,
60 + "append": 6,
61 + "reshape": 7,
62 + "collapse": 8,
63 + "egen": 4,
64 + "predict": 7,
65 + "test": 4,
66 + "help": 4,
67 + ],
68 + fileVerbs: ["use", "save", "import", "export", "log"],
69 + assignmentVerbs: ["generate", "replace", "egen"],
70 + prefixVerbs: ["bootstrap", "permute", "jackknife"],
71 + compoundVerbs: ["graph"]
72 + )
73 +
74 + public init(
75 + verbs: [String: Int],
76 + fileVerbs: Set<String>,
77 + assignmentVerbs: Set<String>,
78 + prefixVerbs: Set<String>,
79 + compoundVerbs: Set<String>
80 + ) {
81 + self.verbs = verbs
82 + self.fileVerbs = fileVerbs
83 + self.assignmentVerbs = assignmentVerbs
84 + self.prefixVerbs = prefixVerbs
85 + self.compoundVerbs = compoundVerbs
86 + }
87 +
88 + /// Resolves a typed verb (possibly abbreviated) to its canonical form.
89 + /// Returns nil when no verb matches unambiguously.
90 + public func resolve(_ typed: String) -> String? {
91 + if let minLength = verbs[typed], typed.count >= minLength { return typed }
92 + if verbs.keys.contains(typed) { return typed }
93 +
94 + let candidates = verbs.filter { verb, minLength in
95 + typed.count >= minLength && verb.hasPrefix(typed)
96 + }
97 + return candidates.count == 1 ? candidates.first!.key : nil
98 + }
99 +
100 + /// Closest known verb by edit distance, for error suggestions.
101 + /// Only returns matches within distance 2 to avoid absurd hints.
102 + public func closest(to typed: String) -> String? {
103 + let scored = verbs.keys
104 + .map { (verb: $0, distance: levenshteinDistance(typed, $0)) }
105 + .sorted { $0.distance < $1.distance }
106 + guard let best = scored.first, best.distance <= 2 else { return nil }
107 + return best.verb
108 + }
109 +}
added MetrikaKit/Sources/ZQParser/Lexer.swift +169 −0
@@ -0,0 +1,169 @@
1 +//
2 +// Lexer.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 +/// Tokenizer for a single ZQL command line.
11 +///
12 +/// Numbers support decimal and exponent notation (`1.5e-3`). A `.` is lexed
13 +/// as an operator when not part of a number, which lets the grammar layer
14 +/// assemble factor-variable notation (`i.region`, `c.age#c.age`).
15 +public struct ZQLexer: Sendable {
16 + public init() {}
17 +
18 + public func tokenize(_ line: String) throws(ZQParseError) -> [ZQToken] {
19 + var tokens: [ZQToken] = []
20 + let chars = Array(line)
21 + var i = 0
22 +
23 + func column(_ index: Int) -> Int { index + 1 }
24 +
25 + while i < chars.count {
26 + let c = chars[i]
27 +
28 + if c.isWhitespace { i += 1; continue }
29 +
30 + // Comments: // and * (only at start of line, Stata-style) end the command.
31 + if c == "/", i + 1 < chars.count, chars[i + 1] == "/" { break }
32 + if c == "*", tokens.isEmpty { break }
33 +
34 + // String literal
35 + if c == "\"" {
36 + let start = i
37 + i += 1
38 + var value = ""
39 + while i < chars.count, chars[i] != "\"" {
40 + value.append(chars[i])
41 + i += 1
42 + }
43 + guard i < chars.count else {
44 + throw ZQParseError(
45 + message: "unterminated string literal",
46 + column: column(start)
47 + )
48 + }
49 + i += 1
50 + tokens.append(ZQToken(kind: .string(value), column: column(start)))
51 + continue
52 + }
53 +
54 + // Number: digits, or leading dot followed by a digit
55 + if c.isNumber || (c == "." && i + 1 < chars.count && chars[i + 1].isNumber) {
56 + let start = i
57 + var text = ""
58 + while i < chars.count, chars[i].isNumber || chars[i] == "." {
59 + text.append(chars[i])
60 + i += 1
61 + }
62 + if i < chars.count, chars[i] == "e" || chars[i] == "E" {
63 + var j = i + 1
64 + var exp = String(chars[i])
65 + if j < chars.count, chars[j] == "+" || chars[j] == "-" {
66 + exp.append(chars[j])
67 + j += 1
68 + }
69 + if j < chars.count, chars[j].isNumber {
70 + while j < chars.count, chars[j].isNumber {
71 + exp.append(chars[j])
72 + j += 1
73 + }
74 + text.append(exp)
75 + i = j
76 + }
77 + }
78 + guard let value = Double(text) else {
79 + throw ZQParseError(
80 + message: "invalid numeric literal '\(text)'",
81 + column: column(start)
82 + )
83 + }
84 + tokens.append(ZQToken(kind: .number(value), column: column(start)))
85 + continue
86 + }
87 +
88 + // Identifier: [A-Za-z_][A-Za-z0-9_]*
89 + if c.isLetter || c == "_" {
90 + let start = i
91 + var text = ""
92 + while i < chars.count, chars[i].isLetter || chars[i].isNumber || chars[i] == "_" {
93 + text.append(chars[i])
94 + i += 1
95 + }
96 + tokens.append(ZQToken(kind: .identifier(text), column: column(start)))
97 + continue
98 + }
99 +
100 + // Punctuation & operators
101 + let start = i
102 + func push(_ kind: ZQToken.Kind, advance: Int) {
103 + tokens.append(ZQToken(kind: kind, column: column(start)))
104 + i += advance
105 + }
106 +
107 + switch c {
108 + case ",": push(.comma, advance: 1)
109 + case ":": push(.colon, advance: 1)
110 + case "(": push(.lparen, advance: 1)
111 + case ")": push(.rparen, advance: 1)
112 + case "[": push(.lbracket, advance: 1)
113 + case "]": push(.rbracket, advance: 1)
114 + case "=":
115 + if i + 1 < chars.count, chars[i + 1] == "=" {
116 + push(.op("=="), advance: 2)
117 + } else {
118 + push(.op("="), advance: 1)
119 + }
120 + case "!":
121 + if i + 1 < chars.count, chars[i + 1] == "=" {
122 + push(.op("!="), advance: 2)
123 + } else {
124 + push(.op("!"), advance: 1)
125 + }
126 + case "~":
127 + if i + 1 < chars.count, chars[i + 1] == "=" {
128 + push(.op("!="), advance: 2)
129 + } else {
130 + throw ZQParseError(message: "unexpected character '~'", column: column(start))
131 + }
132 + case "<":
133 + if i + 1 < chars.count, chars[i + 1] == "=" {
134 + push(.op("<="), advance: 2)
135 + } else {
136 + push(.op("<"), advance: 1)
137 + }
138 + case ">":
139 + if i + 1 < chars.count, chars[i + 1] == "=" {
140 + push(.op(">="), advance: 2)
141 + } else {
142 + push(.op(">"), advance: 1)
143 + }
144 + case "&": push(.op("&"), advance: 1)
145 + case "|": push(.op("|"), advance: 1)
146 + case "+": push(.op("+"), advance: 1)
147 + case "-": push(.op("-"), advance: 1)
148 + case "*": push(.op("*"), advance: 1)
149 + case "/": push(.op("/"), advance: 1)
150 + case "^": push(.op("^"), advance: 1)
151 + case "#":
152 + if i + 1 < chars.count, chars[i + 1] == "#" {
153 + push(.op("##"), advance: 2)
154 + } else {
155 + push(.op("#"), advance: 1)
156 + }
157 + case ".": push(.op("."), advance: 1)
158 + default:
159 + throw ZQParseError(
160 + message: "unexpected character '\(c)'",
161 + column: column(start)
162 + )
163 + }
164 + }
165 +
166 + tokens.append(ZQToken(kind: .endOfLine, column: chars.count + 1))
167 + return tokens
168 + }
169 +}
added MetrikaKit/Sources/ZQParser/ParseError.swift +52 −0
@@ -0,0 +1,52 @@
1 +//
2 +// ParseError.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 +/// Parse failure with the 1-based source column and, where possible, a
11 +/// suggested fix (CLAUDE.md §4: errors must cite column position and
12 +/// suggest fixes via Levenshtein distance on known verbs).
13 +public struct ZQParseError: Error, Equatable, Sendable, CustomStringConvertible {
14 + public var message: String
15 + public var column: Int
16 + public var suggestion: String?
17 +
18 + public init(message: String, column: Int, suggestion: String? = nil) {
19 + self.message = message
20 + self.column = column
21 + self.suggestion = suggestion
22 + }
23 +
24 + public var description: String {
25 + var text = "parse error at column \(column): \(message)"
26 + if let suggestion {
27 + text += "\n did you mean '\(suggestion)'?"
28 + }
29 + return text
30 + }
31 +}
32 +
33 +/// Levenshtein edit distance, used to suggest the closest known verb for
34 +/// an unrecognized command.
35 +public func levenshteinDistance(_ a: String, _ b: String) -> Int {
36 + let a = Array(a), b = Array(b)
37 + if a.isEmpty { return b.count }
38 + if b.isEmpty { return a.count }
39 +
40 + var previous = Array(0...b.count)
41 + var current = [Int](repeating: 0, count: b.count + 1)
42 +
43 + for i in 1...a.count {
44 + current[0] = i
45 + for j in 1...b.count {
46 + let substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1)
47 + current[j] = min(previous[j] + 1, current[j - 1] + 1, substitution)
48 + }
49 + swap(&previous, &current)
50 + }
51 + return previous[b.count]
52 +}
added MetrikaKit/Sources/ZQParser/Token.swift +55 −0
@@ -0,0 +1,55 @@
1 +//
2 +// Token.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 +/// A lexical token of the ZQL command language, carrying its 1-based
11 +/// source column so parse errors can point at the offending position.
12 +public struct ZQToken: Equatable, Sendable {
13 + public enum Kind: Equatable, Sendable {
14 + case identifier(String)
15 + case number(Double)
16 + case string(String)
17 + case comma
18 + case colon
19 + case lparen
20 + case rparen
21 + case lbracket
22 + case rbracket
23 + /// Operators: + - * / ^ = == != < <= > >= & | ! # .
24 + case op(String)
25 + case endOfLine
26 + }
27 +
28 + public let kind: Kind
29 + /// 1-based column of the first character of the token.
30 + public let column: Int
31 +
32 + public init(kind: Kind, column: Int) {
33 + self.kind = kind
34 + self.column = column
35 + }
36 +}
37 +
38 +extension ZQToken.Kind {
39 + /// Human-readable rendering used in error messages.
40 + public var describe: String {
41 + switch self {
42 + case .identifier(let s): return "'\(s)'"
43 + case .number(let n): return "number \(n)"
44 + case .string(let s): return "\"\(s)\""
45 + case .comma: return "','"
46 + case .colon: return "':'"
47 + case .lparen: return "'('"
48 + case .rparen: return "')'"
49 + case .lbracket: return "'['"
50 + case .rbracket: return "']'"
51 + case .op(let o): return "'\(o)'"
52 + case .endOfLine: return "end of line"
53 + }
54 + }
55 +}
added MetrikaKit/Sources/ZQParser/ZQParserModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQParserModule.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 +/// Module marker for ZQParser.
11 +public enum ZQParserModule {
12 + public static let name = "ZQParser"
13 +}
added MetrikaKit/Sources/ZQPlanner/Planner.swift +106 −0
@@ -0,0 +1,106 @@
1 +//
2 +// Planner.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 +/// Execution backend. The planner chooses automatically; the user never
14 +/// does (CLAUDE.md §5).
15 +public enum ZQBackend: String, Equatable, Sendable {
16 + case cpu, gpu, hybrid
17 +}
18 +
19 +/// A planned command: the parsed AST plus dispatch decision. Factor
20 +/// variables expand here (plan time), not at parse time.
21 +public struct ZQExecutionPlan: Equatable, Sendable {
22 + public var command: ZQCommand
23 + public var backend: ZQBackend
24 +
25 + public init(command: ZQCommand, backend: ZQBackend) {
26 + self.command = command
27 + self.backend = backend
28 + }
29 +}
30 +
31 +public struct ZQPlanner: Sendable {
32 + /// Single-estimation row-count threshold below which the CPU LAPACK
33 + /// path wins (heuristic 1, tuned by Tests/Bench).
34 + public var cpuRowThreshold: Int
35 + /// Independent-replicate threshold at which batched GPU solves win
36 + /// (heuristic 2).
37 + public var gpuReplicateThreshold: Int
38 + /// Whether a GPU backend is available at all. The pure-Swift Philox
39 + /// reference keeps plans reproducible even when this is false.
40 + public var gpuAvailable: Bool
41 +
42 + public init(
43 + cpuRowThreshold: Int = 5_000_000,
44 + gpuReplicateThreshold: Int = 500,
45 + gpuAvailable: Bool = false
46 + ) {
47 + self.cpuRowThreshold = cpuRowThreshold
48 + self.gpuReplicateThreshold = gpuReplicateThreshold
49 + self.gpuAvailable = gpuAvailable
50 + }
51 +
52 + public func plan(_ command: ZQCommand, rowCount: Int) -> ZQExecutionPlan {
53 + ZQExecutionPlan(command: command, backend: backend(for: command, rowCount: rowCount))
54 + }
55 +
56 + private func backend(for command: ZQCommand, rowCount: Int) -> ZQBackend {
57 + guard gpuAvailable else { return .cpu }
58 +
59 + // Heuristic 2: ≥ threshold independent replicates → batched GPU.
60 + if command.verb == "bootstrap" || command.verb == "permute" {
61 + if let reps = command.option("reps")?.firstArgument,
62 + let count = Int(reps), count >= gpuReplicateThreshold {
63 + return .gpu
64 + }
65 + }
66 +
67 + // Heuristic 1: single estimation under the row threshold → CPU.
68 + if rowCount < cpuRowThreshold { return .cpu }
69 +
70 + // Large single estimations stay on CPU until the batched GPU
71 + // estimators land (v0.2); MCMC-style workloads will plan .hybrid.
72 + return .cpu
73 + }
74 +}
75 +
76 +/// Plan-time factor-variable expansion (`i.region` → one indicator column
77 +/// per level, base level omitted).
78 +public enum ZQFactorExpansion {
79 +
80 + /// Distinct sorted levels of a numeric variable, ignoring missing.
81 + public static func levels(values: [Double], missing: [Bool]) -> [Double] {
82 + var seen = Set<Double>()
83 + for i in 0..<values.count where !missing[i] {
84 + seen.insert(values[i])
85 + }
86 + return seen.sorted()
87 + }
88 +
89 + /// Expands one factor spec into named indicator columns against the
90 + /// given data. The lowest level is the omitted base (Stata default).
91 + public static func expand(
92 + name: String, values: [Double], missing: [Bool]
93 + ) -> [(name: String, values: [Double])] {
94 + let allLevels = levels(values: values, missing: missing)
95 + guard allLevels.count > 1 else { return [] }
96 + return allLevels.dropFirst().map { level in
97 + let rendered = level == level.rounded()
98 + ? String(Int(level))
99 + : String(level)
100 + let indicator = values.enumerated().map { index, value in
101 + (!missing[index] && value == level) ? 1.0 : 0.0
102 + }
103 + return (name: "\(rendered).\(name)", values: indicator)
104 + }
105 + }
106 +}
added MetrikaKit/Sources/ZQPlanner/ZQPlannerModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQPlannerModule.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 +/// Module marker for ZQPlanner.
11 +public enum ZQPlannerModule {
12 + public static let name = "ZQPlanner"
13 +}
added MetrikaKit/Sources/ZQPlugins/PluginProtocol.swift +128 −0
@@ -0,0 +1,128 @@
1 +//
2 +// PluginProtocol.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 ZQData
12 +import ZQParser
13 +
14 +/// Declarative syntax specification for a user command: which qualifiers
15 +/// and options the engine should accept and pre-validate.
16 +public struct ZQSyntaxSpec: Equatable, Sendable {
17 + public var acceptsVarlist: Bool
18 + public var acceptsCondition: Bool
19 + public var acceptsRange: Bool
20 + public var acceptsWeight: Bool
21 + /// Known option names; empty means any option is passed through.
22 + public var options: [String]
23 + /// Whether the plugin may mutate the working dataset. Plugins without
24 + /// this receive a read-only view (CLAUDE.md §4).
25 + public var mutates: Bool
26 +
27 + public init(
28 + acceptsVarlist: Bool = true,
29 + acceptsCondition: Bool = true,
30 + acceptsRange: Bool = true,
31 + acceptsWeight: Bool = false,
32 + options: [String] = [],
33 + mutates: Bool = false
34 + ) {
35 + self.acceptsVarlist = acceptsVarlist
36 + self.acceptsCondition = acceptsCondition
37 + self.acceptsRange = acceptsRange
38 + self.acceptsWeight = acceptsWeight
39 + self.options = options
40 + self.mutates = mutates
41 + }
42 +}
43 +
44 +/// Result of executing a command: rendered console text plus named scalar
45 +/// results (Stata's `r()` analog).
46 +public struct ZQResult: Equatable, Sendable {
47 + public var text: String
48 + public var scalars: [String: Double]
49 +
50 + public init(text: String, scalars: [String: Double] = [:]) {
51 + self.text = text
52 + self.scalars = scalars
53 + }
54 +}
55 +
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`.
60 +public struct ZQContext: Sendable {
61 + public var command: ZQCommand
62 + 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 +
67 + public init(command: ZQCommand, frame: ZQDataFrame) {
68 + self.command = command
69 + self.frame = frame
70 + self.replacementFrame = nil
71 + }
72 +}
73 +
74 +/// Native plugin protocol (CLAUDE.md §4). Conforming Swift packages are
75 +/// discovered under `~/Library/Application Support/Metrika/Commands/`.
76 +public protocol ZQCommandPlugin: Sendable {
77 + static var verb: String { get }
78 + static var syntax: ZQSyntaxSpec { get }
79 + func execute(_ ctx: ZQContext) async throws -> ZQResult
80 +}
81 +
82 +/// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of
83 +/// ZQL lines the engine replays.
84 +public struct ZQScriptCommand: Equatable, Sendable {
85 + public var verb: String
86 + public var fileURL: URL
87 + public var lines: [String]
88 +
89 + public init(verb: String, fileURL: URL, lines: [String]) {
90 + self.verb = verb
91 + self.fileURL = fileURL
92 + self.lines = lines
93 + }
94 +}
95 +
96 +/// Discovers `.zyq` script commands in the user commands directory.
97 +public enum ZQPluginDiscovery {
98 + public static var defaultDirectory: URL {
99 + FileManager.default
100 + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
101 + .appendingPathComponent("Metrika/Commands", isDirectory: true)
102 + }
103 +
104 + public static func scriptCommands(
105 + in directory: URL = defaultDirectory
106 + ) -> [ZQScriptCommand] {
107 + guard let contents = try? FileManager.default.contentsOfDirectory(
108 + at: directory, includingPropertiesForKeys: nil
109 + ) else { return [] }
110 +
111 + return contents
112 + .filter { $0.pathExtension == "zyq" }
113 + .compactMap { url in
114 + guard let text = try? String(contentsOf: url, encoding: .utf8) else {
115 + return nil
116 + }
117 + let lines = text.split(
118 + separator: "\n", omittingEmptySubsequences: false
119 + ).map(String.init)
120 + return ZQScriptCommand(
121 + verb: url.deletingPathExtension().lastPathComponent,
122 + fileURL: url,
123 + lines: lines
124 + )
125 + }
126 + .sorted { $0.verb < $1.verb }
127 + }
128 +}
added MetrikaKit/Sources/ZQPlugins/ZQPluginsModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQPluginsModule.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 +/// Module marker for ZQPlugins.
11 +public enum ZQPluginsModule {
12 + public static let name = "ZQPlugins"
13 +}
added MetrikaKit/Sources/ZQStats/Distributions.swift +149 −0
@@ -0,0 +1,149 @@
1 +//
2 +// Distributions.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 +
12 +/// Special functions and distribution CDFs used for inference output.
13 +/// Implementations follow the classic Lanczos / continued-fraction
14 +/// formulations and are accurate to well beyond the 1e-10 project
15 +/// tolerance for the parameter ranges used in regression output.
16 +public enum ZQDistributions {
17 +
18 + /// Natural log of the gamma function (Lanczos approximation, g=7).
19 + public static func logGamma(_ x: Double) -> Double {
20 + precondition(x > 0, "logGamma requires x > 0")
21 + let coefficients: [Double] = [
22 + 676.5203681218851, -1259.1392167224028, 771.32342877765313,
23 + -176.61502916214059, 12.507343278686905, -0.13857109526572012,
24 + 9.9843695780195716e-6, 1.5056327351493116e-7,
25 + ]
26 + if x < 0.5 {
27 + // Reflection formula.
28 + return log(.pi / sin(.pi * x)) - logGamma(1 - x)
29 + }
30 + let z = x - 1
31 + var sum = 0.99999999999980993
32 + for (i, c) in coefficients.enumerated() {
33 + sum += c / (z + Double(i) + 1)
34 + }
35 + let t = z + 7.5
36 + return 0.5 * log(2 * .pi) + (z + 0.5) * log(t) - t + log(sum)
37 + }
38 +
39 + /// Regularized incomplete beta function I_x(a, b) via the Lentz
40 + /// continued-fraction algorithm.
41 + public static func incompleteBeta(a: Double, b: Double, x: Double) -> Double {
42 + precondition(a > 0 && b > 0, "incompleteBeta requires a, b > 0")
43 + if x <= 0 { return 0 }
44 + if x >= 1 { return 1 }
45 +
46 + let logBeta = logGamma(a + b) - logGamma(a) - logGamma(b)
47 + let front = exp(logBeta + a * log(x) + b * log(1 - x))
48 +
49 + // Use the symmetry relation to keep the continued fraction in its
50 + // rapidly converging region.
51 + if x < (a + 1) / (a + b + 2) {
52 + return front * betaContinuedFraction(a: a, b: b, x: x) / a
53 + } else {
54 + return 1 - front * betaContinuedFraction(a: b, b: a, x: 1 - x) / b
55 + }
56 + }
57 +
58 + private static func betaContinuedFraction(a: Double, b: Double, x: Double) -> Double {
59 + let tiny = 1e-300
60 + let epsilon = 1e-16
61 + let qab = a + b
62 + let qap = a + 1
63 + let qam = a - 1
64 +
65 + var c = 1.0
66 + var d = 1 - qab * x / qap
67 + if abs(d) < tiny { d = tiny }
68 + d = 1 / d
69 + var h = d
70 +
71 + for m in 1...300 {
72 + let dm = Double(m)
73 + // Even step.
74 + var numerator = dm * (b - dm) * x / ((qam + 2 * dm) * (a + 2 * dm))
75 + d = 1 + numerator * d
76 + if abs(d) < tiny { d = tiny }
77 + c = 1 + numerator / c
78 + if abs(c) < tiny { c = tiny }
79 + d = 1 / d
80 + h *= d * c
81 + // Odd step.
82 + numerator = -(a + dm) * (qab + dm) * x / ((a + 2 * dm) * (qap + 2 * dm))
83 + d = 1 + numerator * d
84 + if abs(d) < tiny { d = tiny }
85 + c = 1 + numerator / c
86 + if abs(c) < tiny { c = tiny }
87 + d = 1 / d
88 + let delta = d * c
89 + h *= delta
90 + if abs(delta - 1) < epsilon { break }
91 + }
92 + return h
93 + }
94 +
95 + /// Standard normal CDF.
96 + public static func normalCDF(_ z: Double) -> Double {
97 + 0.5 * erfc(-z / 2.0.squareRoot())
98 + }
99 +
100 + /// Student t CDF with `df` degrees of freedom.
101 + public static func studentTCDF(_ t: Double, df: Double) -> Double {
102 + precondition(df > 0, "studentTCDF requires df > 0")
103 + let x = df / (df + t * t)
104 + let tail = 0.5 * incompleteBeta(a: df / 2, b: 0.5, x: x)
105 + return t > 0 ? 1 - tail : tail
106 + }
107 +
108 + /// Two-sided p-value for a t statistic, computed directly from the
109 + /// tail integral: 2·P(T > |t|) = I_{df/(df+t²)}(df/2, 1/2). Going
110 + /// through `2·(1 − CDF)` would lose all precision below ~1e-16.
111 + public static func tTestPValue(_ t: Double, df: Double) -> Double {
112 + incompleteBeta(a: df / 2, b: 0.5, x: df / (df + t * t))
113 + }
114 +
115 + /// F distribution CDF with (df1, df2) degrees of freedom.
116 + public static func fCDF(_ f: Double, df1: Double, df2: Double) -> Double {
117 + precondition(df1 > 0 && df2 > 0, "fCDF requires positive df")
118 + if f <= 0 { return 0 }
119 + let x = df1 * f / (df1 * f + df2)
120 + return incompleteBeta(a: df1 / 2, b: df2 / 2, x: x)
121 + }
122 +
123 + /// Upper-tail p-value for an F statistic, computed directly as
124 + /// I_{df2/(df2+df1·f)}(df2/2, df1/2) to preserve precision for large F.
125 + public static func fTestPValue(_ f: Double, df1: Double, df2: Double) -> Double {
126 + if f <= 0 { return 1 }
127 + return incompleteBeta(a: df2 / 2, b: df1 / 2, x: df2 / (df2 + df1 * f))
128 + }
129 +
130 + /// Student t quantile (inverse CDF) via bisection refined with Newton
131 + /// steps — used for confidence intervals.
132 + public static func studentTQuantile(_ p: Double, df: Double) -> Double {
133 + precondition(p > 0 && p < 1, "quantile requires 0 < p < 1")
134 + if abs(p - 0.5) < 1e-15 { return 0 }
135 +
136 + var low = -1000.0, high = 1000.0
137 + var mid = 0.0
138 + for _ in 0..<200 {
139 + mid = 0.5 * (low + high)
140 + if studentTCDF(mid, df: df) < p {
141 + low = mid
142 + } else {
143 + high = mid
144 + }
145 + if high - low < 1e-13 * max(1, abs(mid)) { break }
146 + }
147 + return 0.5 * (low + high)
148 + }
149 +}
added MetrikaKit/Sources/ZQStats/LinearAlgebra.swift +199 −0
@@ -0,0 +1,199 @@
1 +//
2 +// LinearAlgebra.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 Accelerate
11 +
12 +/// Thin LAPACK/BLAS wrappers used by the CPU estimators. All matrices are
13 +/// stored column-major (LAPACK convention) as flat [Double] buffers.
14 +enum LinearAlgebra {
15 + struct Failure: Error, CustomStringConvertible {
16 + let routine: String
17 + let info: Int
18 + var description: String { "\(routine) failed with info=\(info)" }
19 + }
20 +
21 + /// QR decomposition of an n×k matrix (n ≥ k) via `dgeqrf`. CLAUDE.md §5:
22 + /// never form X'X — coefficient solves and (X'X)⁻¹ all go through R.
23 + struct QR {
24 + /// Factored matrix as returned by dgeqrf: R in the upper triangle,
25 + /// Householder vectors below the diagonal.
26 + private(set) var factored: [Double]
27 + private(set) var tau: [Double]
28 + let rows: Int
29 + let cols: Int
30 +
31 + init(matrix: [Double], rows: Int, cols: Int) throws {
32 + precondition(matrix.count == rows * cols, "matrix size mismatch")
33 + precondition(rows >= cols, "QR requires rows ≥ cols")
34 + var a = matrix
35 + var m = __CLPK_integer(rows)
36 + var n = __CLPK_integer(cols)
37 + var lda = m
38 + var tau = [Double](repeating: 0, count: cols)
39 + var info: __CLPK_integer = 0
40 +
41 + // Workspace query, then factorization.
42 + var lwork: __CLPK_integer = -1
43 + var workQuery = [Double](repeating: 0, count: 1)
44 + dgeqrf_(&m, &n, &a, &lda, &tau, &workQuery, &lwork, &info)
45 + lwork = __CLPK_integer(workQuery[0])
46 + var work = [Double](repeating: 0, count: max(1, Int(lwork)))
47 + dgeqrf_(&m, &n, &a, &lda, &tau, &work, &lwork, &info)
48 + guard info == 0 else { throw Failure(routine: "dgeqrf", info: Int(info)) }
49 +
50 + self.factored = a
51 + self.tau = tau
52 + self.rows = rows
53 + self.cols = cols
54 + }
55 +
56 + /// Solves min ‖Ax − b‖ using the stored factorization:
57 + /// x = R⁻¹ (Q'b)[0..<k].
58 + func solve(rhs: [Double]) throws -> [Double] {
59 + precondition(rhs.count == rows, "rhs size mismatch")
60 + var qtb = rhs
61 + var side: Int8 = Int8(UInt8(ascii: "L"))
62 + var trans: Int8 = Int8(UInt8(ascii: "T"))
63 + var m = __CLPK_integer(rows)
64 + var nrhs: __CLPK_integer = 1
65 + var k = __CLPK_integer(cols)
66 + var lda = m
67 + var ldc = m
68 + var info: __CLPK_integer = 0
69 + var a = factored
70 + var tauCopy = tau
71 +
72 + var lwork: __CLPK_integer = -1
73 + var workQuery = [Double](repeating: 0, count: 1)
74 + dormqr_(&side, &trans, &m, &nrhs, &k, &a, &lda, &tauCopy, &qtb, &ldc,
75 + &workQuery, &lwork, &info)
76 + lwork = __CLPK_integer(workQuery[0])
77 + var work = [Double](repeating: 0, count: max(1, Int(lwork)))
78 + dormqr_(&side, &trans, &m, &nrhs, &k, &a, &lda, &tauCopy, &qtb, &ldc,
79 + &work, &lwork, &info)
80 + guard info == 0 else { throw Failure(routine: "dormqr", info: Int(info)) }
81 +
82 + // Back-substitute R x = (Q'b)[0..<k].
83 + var uplo: Int8 = Int8(UInt8(ascii: "U"))
84 + var transN: Int8 = Int8(UInt8(ascii: "N"))
85 + var diag: Int8 = Int8(UInt8(ascii: "N"))
86 + var n = __CLPK_integer(cols)
87 + var solution = Array(qtb[0..<cols])
88 + var ldb = n
89 + dtrtrs_(&uplo, &transN, &diag, &n, &nrhs, &a, &lda, &solution, &ldb, &info)
90 + guard info == 0 else { throw Failure(routine: "dtrtrs", info: Int(info)) }
91 + return solution
92 + }
93 +
94 + /// (X'X)⁻¹ = R⁻¹ R⁻ᵀ, computed from the k×k triangular factor.
95 + func crossProductInverse() throws -> [Double] {
96 + // Extract the k×k upper triangle of R (column-major).
97 + var r = [Double](repeating: 0, count: cols * cols)
98 + for j in 0..<cols {
99 + for i in 0...j {
100 + r[j * cols + i] = factored[j * rows + i]
101 + }
102 + }
103 + var uplo: Int8 = Int8(UInt8(ascii: "U"))
104 + var diag: Int8 = Int8(UInt8(ascii: "N"))
105 + var n = __CLPK_integer(cols)
106 + var lda = n
107 + var info: __CLPK_integer = 0
108 + dtrtri_(&uplo, &diag, &n, &r, &lda, &info)
109 + guard info == 0 else { throw Failure(routine: "dtrtri", info: Int(info)) }
110 +
111 + // XtXinv = Rinv · Rinvᵀ (symmetric k×k).
112 + var result = [Double](repeating: 0, count: cols * cols)
113 + for j in 0..<cols {
114 + for i in 0..<cols {
115 + var sum = 0.0
116 + // Rinv is upper triangular: Rinv[i, l] nonzero for l ≥ i.
117 + for l in max(i, j)..<cols {
118 + sum += r[l * cols + i] * r[l * cols + j]
119 + }
120 + result[j * cols + i] = sum
121 + }
122 + }
123 + return result
124 + }
125 +
126 + /// Explicit thin Q (n×k, column-major) via `dorgqr`, used for
127 + /// leverage values h_i = ‖q_i‖².
128 + func thinQ() throws -> [Double] {
129 + var a = factored
130 + var m = __CLPK_integer(rows)
131 + var n = __CLPK_integer(cols)
132 + var k = n
133 + var lda = m
134 + var info: __CLPK_integer = 0
135 + var tauCopy = tau
136 +
137 + var lwork: __CLPK_integer = -1
138 + var workQuery = [Double](repeating: 0, count: 1)
139 + dorgqr_(&m, &n, &k, &a, &lda, &tauCopy, &workQuery, &lwork, &info)
140 + lwork = __CLPK_integer(workQuery[0])
141 + var work = [Double](repeating: 0, count: max(1, Int(lwork)))
142 + dorgqr_(&m, &n, &k, &a, &lda, &tauCopy, &work, &lwork, &info)
143 + guard info == 0 else { throw Failure(routine: "dorgqr", info: Int(info)) }
144 + return a
145 + }
146 + }
147 +
148 + /// y ← A·x for column-major A (rows×cols).
149 + static func multiply(
150 + matrix a: [Double], rows: Int, cols: Int, vector x: [Double]
151 + ) -> [Double] {
152 + precondition(x.count == cols)
153 + var result = [Double](repeating: 0, count: rows)
154 + cblas_dgemv(
155 + CblasColMajor, CblasNoTrans,
156 + Int32(rows), Int32(cols),
157 + 1.0, a, Int32(rows), x, 1, 0.0, &result, 1
158 + )
159 + return result
160 + }
161 +
162 + /// Symmetric sandwich product B · M · B for k×k column-major matrices.
163 + static func sandwich(bread: [Double], meat: [Double], k: Int) -> [Double] {
164 + precondition(bread.count == k * k && meat.count == k * k)
165 + var bm = [Double](repeating: 0, count: k * k)
166 + cblas_dgemm(
167 + CblasColMajor, CblasNoTrans, CblasNoTrans,
168 + Int32(k), Int32(k), Int32(k),
169 + 1.0, bread, Int32(k), meat, Int32(k),
170 + 0.0, &bm, Int32(k)
171 + )
172 + var result = [Double](repeating: 0, count: k * k)
173 + cblas_dgemm(
174 + CblasColMajor, CblasNoTrans, CblasNoTrans,
175 + Int32(k), Int32(k), Int32(k),
176 + 1.0, bm, Int32(k), bread, Int32(k),
177 + 0.0, &result, Int32(k)
178 + )
179 + return result
180 + }
181 +
182 + /// Solves the symmetric positive-definite system A·x = b (k×k) via
183 + /// Cholesky — used for Wald tests on coefficient subsets.
184 + static func solveSymmetric(
185 + _ a: [Double], k: Int, rhs: [Double]
186 + ) throws -> [Double] {
187 + var factor = a
188 + var solution = rhs
189 + var uplo: Int8 = Int8(UInt8(ascii: "U"))
190 + var n = __CLPK_integer(k)
191 + var nrhs: __CLPK_integer = 1
192 + var lda = n
193 + var ldb = n
194 + var info: __CLPK_integer = 0
195 + dposv_(&uplo, &n, &nrhs, &factor, &lda, &solution, &ldb, &info)
196 + guard info == 0 else { throw Failure(routine: "dposv", info: Int(info)) }
197 + return solution
198 + }
199 +}
added MetrikaKit/Sources/ZQStats/OLS.swift +316 −0
@@ -0,0 +1,316 @@
1 +//
2 +// OLS.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 +
12 +/// Variance estimator selection for linear regression.
13 +public enum ZQVarianceEstimator: Equatable, Sendable {
14 + /// Classical homoskedastic σ²(X'X)⁻¹.
15 + case classical
16 + /// Heteroskedasticity-consistent sandwich estimators. `robust` in
17 + /// Stata parlance maps to `.hc1`.
18 + case hc0, hc1, hc2, hc3
19 + /// Cluster-robust: `cluster` holds one group label per observation.
20 + case cluster([Int])
21 +}
22 +
23 +/// One row of a regression coefficient table.
24 +public struct ZQCoefficient: Equatable, Sendable {
25 + public var name: String
26 + public var estimate: Double
27 + public var standardError: Double
28 + public var tStatistic: Double
29 + public var pValue: Double
30 + public var confidenceLower: Double
31 + public var confidenceUpper: Double
32 +}
33 +
34 +/// Full OLS fit result.
35 +public struct ZQOLSResult: Equatable, Sendable {
36 + public var coefficients: [ZQCoefficient]
37 + public var observationCount: Int
38 + public var degreesOfFreedomResidual: Int
39 + /// Degrees of freedom used for t statistics and CIs: n−k classically,
40 + /// G−1 under clustering (Stata convention).
41 + public var inferenceDF: Double
42 + public var rSquared: Double
43 + public var adjustedRSquared: Double
44 + public var rootMSE: Double
45 + public var fStatistic: Double?
46 + public var fPValue: Double?
47 + public var fDF: (Double, Double)? {
48 + get { fDFStorage.map { ($0.first, $0.second) } }
49 + set { fDFStorage = newValue.map { FDF($0.0, $0.1) } }
50 + }
51 + public var clusterCount: Int?
52 + public var residuals: [Double]
53 +
54 + private struct FDF: Equatable, Sendable {
55 + let first: Double
56 + let second: Double
57 + init(_ first: Double, _ second: Double) {
58 + self.first = first
59 + self.second = second
60 + }
61 + var asTuple: (Double, Double) { (first, second) }
62 + }
63 +
64 + private var fDFStorage: FDF?
65 +
66 + public init(
67 + coefficients: [ZQCoefficient],
68 + observationCount: Int,
69 + degreesOfFreedomResidual: Int,
70 + inferenceDF: Double,
71 + rSquared: Double,
72 + adjustedRSquared: Double,
73 + rootMSE: Double,
74 + fStatistic: Double?,
75 + fPValue: Double?,
76 + fDF: (Double, Double)?,
77 + clusterCount: Int?,
78 + residuals: [Double]
79 + ) {
80 + self.coefficients = coefficients
81 + self.observationCount = observationCount
82 + self.degreesOfFreedomResidual = degreesOfFreedomResidual
83 + self.inferenceDF = inferenceDF
84 + self.rSquared = rSquared
85 + self.adjustedRSquared = adjustedRSquared
86 + self.rootMSE = rootMSE
87 + self.fStatistic = fStatistic
88 + self.fPValue = fPValue
89 + self.fDFStorage = fDF.map { FDF($0.0, $0.1) }
90 + self.clusterCount = clusterCount
91 + self.residuals = residuals
92 + }
93 +}
94 +
95 +public struct ZQStatsError: Error, Equatable, Sendable, CustomStringConvertible {
96 + public var message: String
97 + public init(_ message: String) { self.message = message }
98 + public var description: String { message }
99 +}
100 +
101 +/// Ordinary least squares on fully observed (already listwise-deleted)
102 +/// data. Solved by LAPACK QR (dgeqrf/dormqr) — X'X is never formed for
103 +/// the coefficient path (CLAUDE.md §5).
104 +public enum ZQOLS {
105 +
106 + /// - Parameters:
107 + /// - y: response, length n
108 + /// - predictors: named regressor columns (length n each), intercept
109 + /// excluded — it is appended automatically as `_cons` unless
110 + /// `includeConstant` is false.
111 + public static func fit(
112 + y: [Double],
113 + predictors: [(name: String, values: [Double])],
114 + includeConstant: Bool = true,
115 + variance: ZQVarianceEstimator = .classical,
116 + confidenceLevel: Double = 0.95
117 + ) throws -> ZQOLSResult {
118 + let n = y.count
119 + var names = predictors.map(\.name)
120 + if includeConstant { names.append("_cons") }
121 + let k = names.count
122 + guard n > k else {
123 + throw ZQStatsError("insufficient observations: n=\(n), k=\(k)")
124 + }
125 + for column in predictors where column.values.count != n {
126 + throw ZQStatsError("regressor '\(column.name)' has wrong length")
127 + }
128 +
129 + // Design matrix, column-major.
130 + var x = [Double]()
131 + x.reserveCapacity(n * k)
132 + for column in predictors { x.append(contentsOf: column.values) }
133 + if includeConstant { x.append(contentsOf: [Double](repeating: 1, count: n)) }
134 +
135 + let qr: LinearAlgebra.QR
136 + let beta: [Double]
137 + let xtxInverse: [Double]
138 + do {
139 + qr = try LinearAlgebra.QR(matrix: x, rows: n, cols: k)
140 + beta = try qr.solve(rhs: y)
141 + xtxInverse = try qr.crossProductInverse()
142 + } catch {
143 + throw ZQStatsError(
144 + "design matrix is rank deficient or QR failed: \(error)"
145 + )
146 + }
147 +
148 + // Residuals and fit statistics.
149 + let fitted = LinearAlgebra.multiply(matrix: x, rows: n, cols: k, vector: beta)
150 + var residuals = [Double](repeating: 0, count: n)
151 + var rss = 0.0
152 + for i in 0..<n {
153 + residuals[i] = y[i] - fitted[i]
154 + rss += residuals[i] * residuals[i]
155 + }
156 + let meanY = y.reduce(0, +) / Double(n)
157 + let tss = y.reduce(0) { $0 + ($1 - meanY) * ($1 - meanY) }
158 + let dfResidual = n - k
159 + let sigma2 = rss / Double(dfResidual)
160 + let rSquared = tss > 0 ? 1 - rss / tss : .nan
161 + let dfModel = k - (includeConstant ? 1 : 0)
162 + let adjustedRSquared =
163 + 1 - (1 - rSquared) * Double(n - (includeConstant ? 1 : 0)) / Double(dfResidual)
164 +
165 + // Covariance matrix.
166 + var clusterCount: Int? = nil
167 + let vce: [Double]
168 + switch variance {
169 + case .classical:
170 + vce = xtxInverse.map { $0 * sigma2 }
171 +
172 + case .hc0, .hc1, .hc2, .hc3:
173 + let leverage: [Double]?
174 + if variance == .hc2 || variance == .hc3 {
175 + let q = try qr.thinQ()
176 + var h = [Double](repeating: 0, count: n)
177 + for j in 0..<k {
178 + for i in 0..<n {
179 + let value = q[j * n + i]
180 + h[i] += value * value
181 + }
182 + }
183 + leverage = h
184 + } else {
185 + leverage = nil
186 + }
187 + var weights = [Double](repeating: 0, count: n)
188 + for i in 0..<n {
189 + let e2 = residuals[i] * residuals[i]
190 + switch variance {
191 + case .hc0: weights[i] = e2
192 + case .hc1: weights[i] = e2 * Double(n) / Double(dfResidual)
193 + case .hc2: weights[i] = e2 / (1 - leverage![i])
194 + case .hc3: weights[i] = e2 / ((1 - leverage![i]) * (1 - leverage![i]))
195 + default: break
196 + }
197 + }
198 + let meat = weightedCrossProduct(x: x, n: n, k: k, weights: weights)
199 + vce = LinearAlgebra.sandwich(bread: xtxInverse, meat: meat, k: k)
200 +
201 + case .cluster(let groups):
202 + guard groups.count == n else {
203 + throw ZQStatsError("cluster variable has wrong length")
204 + }
205 + var scores: [Int: [Double]] = [:]
206 + for i in 0..<n {
207 + var u = scores[groups[i]] ?? [Double](repeating: 0, count: k)
208 + for j in 0..<k {
209 + u[j] += x[j * n + i] * residuals[i]
210 + }
211 + scores[groups[i]] = u
212 + }
213 + let g = scores.count
214 + guard g > 1 else {
215 + throw ZQStatsError("cluster variable must define at least 2 groups")
216 + }
217 + clusterCount = g
218 + var meat = [Double](repeating: 0, count: k * k)
219 + for u in scores.values {
220 + for j in 0..<k {
221 + for i in 0..<k {
222 + meat[j * k + i] += u[i] * u[j]
223 + }
224 + }
225 + }
226 + // Stata regress small-sample factor: G/(G−1) · (n−1)/(n−k).
227 + let scale = Double(g) / Double(g - 1) * Double(n - 1) / Double(dfResidual)
228 + for index in meat.indices { meat[index] *= scale }
229 + vce = LinearAlgebra.sandwich(bread: xtxInverse, meat: meat, k: k)
230 + }
231 +
232 + // Inference: t statistics against n−k df, or G−1 under clustering.
233 + let inferenceDF: Double
234 + if let g = clusterCount {
235 + inferenceDF = Double(g - 1)
236 + } else {
237 + inferenceDF = Double(dfResidual)
238 + }
239 + let tCritical = ZQDistributions.studentTQuantile(
240 + 0.5 + confidenceLevel / 2, df: inferenceDF
241 + )
242 +
243 + var coefficients: [ZQCoefficient] = []
244 + for j in 0..<k {
245 + let se = vce[j * k + j].squareRoot()
246 + let t = beta[j] / se
247 + coefficients.append(ZQCoefficient(
248 + name: names[j],
249 + estimate: beta[j],
250 + standardError: se,
251 + tStatistic: t,
252 + pValue: ZQDistributions.tTestPValue(t, df: inferenceDF),
253 + confidenceLower: beta[j] - tCritical * se,
254 + confidenceUpper: beta[j] + tCritical * se
255 + ))
256 + }
257 +
258 + // Overall Wald F test on all non-constant coefficients.
259 + var fStatistic: Double? = nil
260 + var fPValue: Double? = nil
261 + var fDF: (Double, Double)? = nil
262 + if dfModel > 0 {
263 + let restricted = Array(0..<dfModel)
264 + var subVce = [Double](repeating: 0, count: dfModel * dfModel)
265 + var subBeta = [Double](repeating: 0, count: dfModel)
266 + for (jj, j) in restricted.enumerated() {
267 + subBeta[jj] = beta[j]
268 + for (ii, i) in restricted.enumerated() {
269 + subVce[jj * dfModel + ii] = vce[j * k + i]
270 + }
271 + }
272 + if let solved = try? LinearAlgebra.solveSymmetric(subVce, k: dfModel, rhs: subBeta) {
273 + let wald = zip(subBeta, solved).reduce(0) { $0 + $1.0 * $1.1 }
274 + let f = wald / Double(dfModel)
275 + fStatistic = f
276 + fDF = (Double(dfModel), inferenceDF)
277 + fPValue = ZQDistributions.fTestPValue(
278 + f, df1: Double(dfModel), df2: inferenceDF
279 + )
280 + }
281 + }
282 +
283 + return ZQOLSResult(
284 + coefficients: coefficients,
285 + observationCount: n,
286 + degreesOfFreedomResidual: dfResidual,
287 + inferenceDF: inferenceDF,
288 + rSquared: rSquared,
289 + adjustedRSquared: adjustedRSquared,
290 + rootMSE: sigma2.squareRoot(),
291 + fStatistic: fStatistic,
292 + fPValue: fPValue,
293 + fDF: fDF,
294 + clusterCount: clusterCount,
295 + residuals: residuals
296 + )
297 + }
298 +
299 + /// meat = Σ_i w_i · x_i x_iᵀ for column-major X.
300 + private static func weightedCrossProduct(
301 + x: [Double], n: Int, k: Int, weights: [Double]
302 + ) -> [Double] {
303 + var meat = [Double](repeating: 0, count: k * k)
304 + for j in 0..<k {
305 + for i in j..<k {
306 + var sum = 0.0
307 + for row in 0..<n {
308 + sum += weights[row] * x[i * n + row] * x[j * n + row]
309 + }
310 + meat[j * k + i] = sum
311 + meat[i * k + j] = sum
312 + }
313 + }
314 + return meat
315 + }
316 +}
added MetrikaKit/Sources/ZQStats/Summarize.swift +121 −0
@@ -0,0 +1,121 @@
1 +//
2 +// Summarize.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 +
12 +/// Descriptive statistics for one variable, Stata `summarize` semantics.
13 +public struct ZQSummary: Equatable, Sendable {
14 + public var name: String
15 + public var observationCount: Int
16 + public var mean: Double
17 + /// Sample variance (n−1 denominator).
18 + public var variance: Double
19 + public var standardDeviation: Double
20 + public var minimum: Double
21 + public var maximum: Double
22 + /// Detail block (percentiles, skewness, kurtosis) — only populated for
23 + /// `summarize, detail`.
24 + public var detail: Detail?
25 +
26 + public struct Detail: Equatable, Sendable {
27 + /// Percentiles keyed by percent (1, 5, 10, 25, 50, 75, 90, 95, 99),
28 + /// computed with Stata's definition: for np/100 integral, the mean
29 + /// of adjacent order statistics; otherwise the next order statistic.
30 + public var percentiles: [Int: Double]
31 + /// Skewness m₃/m₂^{3/2} and kurtosis m₄/m₂² on central moments
32 + /// with n denominators (Stata definitions).
33 + public var skewness: Double
34 + public var kurtosis: Double
35 + public var smallest: [Double]
36 + public var largest: [Double]
37 + }
38 +}
39 +
40 +public enum ZQSummarize {
41 +
42 + /// Summarizes non-missing values. Returns nil when all values are
43 + /// missing (Stata prints a zero-observation row in that case; the
44 + /// caller renders it).
45 + public static func summary(
46 + name: String, values: [Double], missing: [Bool], detail: Bool = false
47 + ) -> ZQSummary? {
48 + var data: [Double] = []
49 + data.reserveCapacity(values.count)
50 + for i in 0..<values.count where !missing[i] {
51 + data.append(values[i])
52 + }
53 + let n = data.count
54 + guard n > 0 else { return nil }
55 +
56 + var sum = 0.0
57 + for v in data { sum += v }
58 + let mean = sum / Double(n)
59 +
60 + var m2 = 0.0, m3 = 0.0, m4 = 0.0
61 + var minimum = data[0], maximum = data[0]
62 + for v in data {
63 + let d = v - mean
64 + let d2 = d * d
65 + m2 += d2
66 + m3 += d2 * d
67 + m4 += d2 * d2
68 + if v < minimum { minimum = v }
69 + if v > maximum { maximum = v }
70 + }
71 + let variance = n > 1 ? m2 / Double(n - 1) : 0
72 +
73 + var summary = ZQSummary(
74 + name: name,
75 + observationCount: n,
76 + mean: mean,
77 + variance: variance,
78 + standardDeviation: variance.squareRoot(),
79 + minimum: minimum,
80 + maximum: maximum,
81 + detail: nil
82 + )
83 +
84 + if detail {
85 + let sorted = data.sorted()
86 + var percentiles: [Int: Double] = [:]
87 + for p in [1, 5, 10, 25, 50, 75, 90, 95, 99] {
88 + percentiles[p] = stataPercentile(sorted: sorted, percent: Double(p))
89 + }
90 + let mc2 = m2 / Double(n)
91 + let mc3 = m3 / Double(n)
92 + let mc4 = m4 / Double(n)
93 + summary.detail = ZQSummary.Detail(
94 + percentiles: percentiles,
95 + skewness: mc2 > 0 ? mc3 / pow(mc2, 1.5) : .nan,
96 + kurtosis: mc2 > 0 ? mc4 / (mc2 * mc2) : .nan,
97 + smallest: Array(sorted.prefix(4)),
98 + largest: Array(sorted.suffix(4))
99 + )
100 + }
101 + return summary
102 + }
103 +
104 + /// Stata percentile definition: with np = n·p/100, if np is integral
105 + /// the percentile is (x₍np₎ + x₍np+1₎)/2, otherwise x₍⌈np⌉₎ (1-based
106 + /// order statistics).
107 + static func stataPercentile(sorted: [Double], percent: Double) -> Double {
108 + let n = sorted.count
109 + precondition(n > 0)
110 + let np = Double(n) * percent / 100
111 + let isIntegral = np == np.rounded()
112 + if isIntegral {
113 + let index = Int(np)
114 + if index >= n { return sorted[n - 1] }
115 + if index < 1 { return sorted[0] }
116 + return 0.5 * (sorted[index - 1] + sorted[index])
117 + }
118 + let index = min(n, max(1, Int(np.rounded(.up))))
119 + return sorted[index - 1]
120 + }
121 +}
added MetrikaKit/Sources/ZQStats/ZQStatsModule.swift +13 −0
@@ -0,0 +1,13 @@
1 +//
2 +// ZQStatsModule.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 +/// Module marker for ZQStats.
11 +public enum ZQStatsModule {
12 + public static let name = "ZQStats"
13 +}
added MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift +147 −0
@@ -0,0 +1,147 @@
1 +//
2 +// EngineTests.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 Testing
11 +import ZQEngine
12 +
13 +/// End-to-end tests: full command lines through the session, checking the
14 +/// returned scalar results against the R fixtures.
15 +@Suite("ZQEngine end-to-end", .serialized)
16 +struct EngineTests {
17 + let fixtures: Fixtures
18 + let session: ZQSession
19 +
20 + init() async throws {
21 + self.fixtures = try Fixtures()
22 + self.session = try ZQSession(discoverUserCommands: false)
23 + _ = try await session.execute("use \(fixtures.datasetURL.path)")
24 + _ = try await session.execute("gen log_rev = ln(revenue)")
25 + }
26 +
27 + @Test("use reports dataset dimensions")
28 + func loadedDimensions() async throws {
29 + let result = try await session.execute("count")
30 + #expect(result.scalars["N"] == 60)
31 + }
32 +
33 + @Test("regress with listwise deletion matches R")
34 + func regress() async throws {
35 + let result = try await session.execute("reg log_rev price")
36 + #expect(result.scalars["N"] == fixtures["ols_n"])
37 + expectClose(try #require(result.scalars["b_price"]), fixtures["ols_b_price"], "b[price]")
38 + expectClose(try #require(result.scalars["b__cons"]), fixtures["ols_b_cons"], "b[_cons]")
39 + expectClose(
40 + try #require(result.scalars["se_price"]),
41 + fixtures["ols_se_classical_price"], "se[price]"
42 + )
43 + expectClose(try #require(result.scalars["r2"]), fixtures["ols_r2"], "R²")
44 + #expect(result.text.contains("observations dropped due to missing values"))
45 + }
46 +
47 + @Test("regress, robust matches R HC1")
48 + func regressRobust() async throws {
49 + let result = try await session.execute("reg log_rev price, robust")
50 + expectClose(
51 + try #require(result.scalars["se_price"]),
52 + fixtures["ols_se_hc1_price"], "robust se[price]"
53 + )
54 + }
55 +
56 + @Test("regress, cluster matches R with Stata small-sample factor")
57 + func regressCluster() async throws {
58 + let result = try await session.execute("reg log_rev price, cluster(firm_id)")
59 + #expect(result.scalars["N_clust"] == fixtures["ols_G"])
60 + expectClose(
61 + try #require(result.scalars["se_price"]),
62 + fixtures["ols_se_cluster_price"], "cluster se[price]"
63 + )
64 + }
65 +
66 + @Test("factor variable expansion i.region matches R factor()")
67 + func regressFactor() async throws {
68 + let result = try await session.execute("reg log_rev price i.region")
69 + expectClose(try #require(result.scalars["b_price"]), fixtures["ols2_b_price"], "b[price]")
70 + expectClose(
71 + try #require(result.scalars["b_2.region"]),
72 + fixtures["ols2_b_region2"], "b[2.region]"
73 + )
74 + expectClose(
75 + try #require(result.scalars["b_3.region"]),
76 + fixtures["ols2_b_region3"], "b[3.region]"
77 + )
78 + expectClose(try #require(result.scalars["b__cons"]), fixtures["ols2_b_cons"], "b[_cons]")
79 + expectClose(try #require(result.scalars["r2"]), fixtures["ols2_r2"], "R²")
80 + }
81 +
82 + @Test("summarize scalars match R")
83 + func summarize() async throws {
84 + let result = try await session.execute("summarize revenue")
85 + expectClose(
86 + try #require(result.scalars["mean"]),
87 + fixtures["sum_revenue_mean"], "mean"
88 + )
89 + expectClose(try #require(result.scalars["sd"]), fixtures["sum_revenue_sd"], "sd")
90 + }
91 +
92 + @Test("if qualifier and count")
93 + func conditionalCount() async throws {
94 + let all = try await session.execute("count")
95 + let some = try await session.execute("count if missing(price)")
96 + #expect(all.scalars["N"] == 60)
97 + #expect(some.scalars["N"] == fixtures["ols_dropped"])
98 + }
99 +
100 + @Test("bootstrap is reproducible for a fixed seed")
101 + func bootstrapReproducible() async throws {
102 + let first = try await session.execute(
103 + "bootstrap, reps(200) seed(42): reg log_rev price"
104 + )
105 + let second = try await session.execute(
106 + "bootstrap, reps(200) seed(42): reg log_rev price"
107 + )
108 + #expect(first.scalars["se_price"] == second.scalars["se_price"])
109 + // Bootstrap SE should be in the neighborhood of the analytic one.
110 + let analytic = fixtures["ols_se_classical_price"]
111 + let bootstrap = try #require(first.scalars["se_price"])
112 + #expect(bootstrap > analytic / 3 && bootstrap < analytic * 3)
113 + }
114 +
115 + @Test("generate honors if, replace counts changes")
116 + func generateReplace() async throws {
117 + _ = try await session.execute("gen flag = 1 if price > 10")
118 + let count = try await session.execute("count if flag == 1")
119 + let expected = try await session.execute("count if price > 10")
120 + #expect(count.scalars["N"] == expected.scalars["N"])
121 + let replaced = try await session.execute("replace flag = 0 if missing(flag)")
122 + #expect(replaced.text.contains("real changes"))
123 + _ = try await session.execute("drop flag")
124 + }
125 +
126 + @Test("graph scatter produces a plot spec")
127 + func graphScatter() async throws {
128 + _ = try await session.execute("graph scatter log_rev price, by(region)")
129 + let plot = try #require(await session.lastPlot)
130 + #expect(plot.kind == .scatter)
131 + #expect(plot.series.count == 3) // three regions
132 + #expect(plot.xLabel == "price")
133 + }
134 +
135 + @Test("display evaluates scalar expressions")
136 + func display() async throws {
137 + let result = try await session.execute("display 2 + 2 * 3")
138 + #expect(result.text == "8")
139 + }
140 +
141 + @Test("unknown variable yields a helpful error")
142 + func unknownVariable() async throws {
143 + await #expect(throws: (any Error).self) {
144 + _ = try await session.execute("summarize nonexistent_var")
145 + }
146 + }
147 +}
added MetrikaKit/Tests/MetrikaKitTests/FixtureSupport.swift +61 −0
@@ -0,0 +1,61 @@
1 +//
2 +// FixtureSupport.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 +
13 +/// Golden values produced by Tests/Fixtures/generate.R.
14 +struct Fixtures {
15 + let expected: [String: Double]
16 + let datasetURL: URL
17 +
18 + init() throws {
19 + guard let tsv = Bundle.module.url(
20 + forResource: "expected", withExtension: "tsv", subdirectory: "Fixtures"
21 + ), let csv = Bundle.module.url(
22 + forResource: "regression", withExtension: "csv", subdirectory: "Fixtures"
23 + ) else {
24 + throw NSError(domain: "Fixtures", code: 1, userInfo: [
25 + NSLocalizedDescriptionKey: "fixture resources missing — run Tests/Fixtures/generate.R",
26 + ])
27 + }
28 + var values: [String: Double] = [:]
29 + for line in try String(contentsOf: tsv, encoding: .utf8)
30 + .split(separator: "\n") {
31 + let parts = line.split(separator: "\t")
32 + guard parts.count == 2, let value = Double(parts[1]) else { continue }
33 + values[String(parts[0])] = value
34 + }
35 + self.expected = values
36 + self.datasetURL = csv
37 + }
38 +
39 + subscript(key: String) -> Double {
40 + guard let value = expected[key] else {
41 + Issue.record("missing fixture key '\(key)'")
42 + return .nan
43 + }
44 + return value
45 + }
46 +}
47 +
48 +/// Relative-tolerance comparison (CLAUDE.md §0: CPU paths match reference
49 +/// implementations to at least 1e-10 relative tolerance).
50 +func expectClose(
51 + _ actual: Double, _ expected: Double, rtol: Double = 1e-10,
52 + _ label: String = "", sourceLocation: SourceLocation = #_sourceLocation
53 +) {
54 + let scale = max(abs(expected), abs(actual), 1e-300)
55 + let relative = abs(actual - expected) / scale
56 + #expect(
57 + relative <= rtol,
58 + "\(label): actual \(actual) vs expected \(expected) (rel. err. \(relative))",
59 + sourceLocation: sourceLocation
60 + )
61 +}
added MetrikaKit/Tests/MetrikaKitTests/Fixtures/expected.tsv +47 −0
@@ -0,0 +1,47 @@
1 +sum_revenue_n 60
2 +sum_revenue_mean 22.041739716666665
3 +sum_revenue_var 75.456382944246272
4 +sum_revenue_sd 8.6865633563709341
5 +sum_revenue_min 9.9938909999999996
6 +sum_revenue_max 48.213110999999998
7 +sum_revenue_skewness 0.83927308973767201
8 +sum_revenue_kurtosis 3.0099405407589113
9 +ols_n 57
10 +ols_dropped 3
11 +ols_b_price -0.076945323253264752
12 +ols_b_cons 4.066766357217416
13 +ols_r2 0.81945138939762363
14 +ols_r2a 0.81616868738667137
15 +ols_rmse 0.16467909008395276
16 +ols_F 249.62710190070032
17 +ols_se_classical_price 0.0048700829803485389
18 +ols_se_classical_cons 0.069041588469169066
19 +ols_t_price -15.799591827028372
20 +ols_p_price 4.2432524575354669e-22
21 +ols_ci_lower_price -0.086705187644217502
22 +ols_ci_upper_price -0.067185458862312003
23 +ols_se_hc0_price 0.0046993275536147206
24 +ols_se_hc0_cons 0.070340462372229157
25 +ols_se_hc1_price 0.0047840069340958705
26 +ols_se_hc1_cons 0.071607960053223257
27 +ols_se_hc2_price 0.0048195183325538586
28 +ols_se_hc2_cons 0.072119681978632416
29 +ols_se_hc3_price 0.0049433978526440998
30 +ols_se_hc3_cons 0.073955625269990985
31 +ols_G 12
32 +ols_se_cluster_price 0.0053437360051300847
33 +ols_se_cluster_cons 0.071520043363908228
34 +ols_p_cluster_price 1.7511622894896076e-08
35 +ols2_b_price -0.078097115374682891
36 +ols2_b_region2 0.071445724920196046
37 +ols2_b_region3 0.21232048832355585
38 +ols2_b_cons 3.9851984093144015
39 +ols2_r2 0.87452597383166375
40 +dist_pt_2p5_df10 0.98427657788169554
41 +dist_pt_m1p3_df3 0.14223375436394847
42 +dist_pt_0p05_df57 0.51985140594489099
43 +dist_pf_3p7_2_30 0.96337776983412116
44 +dist_pf_0p5_5_100 0.22441048076451794
45 +dist_qt_0p975_df12 2.1788128296672284
46 +dist_qt_0p995_df4 4.6040948713499921
47 +dist_pnorm_1p64 0.94949741652589625
added MetrikaKit/Tests/MetrikaKitTests/Fixtures/regression.csv +61 −0
@@ -0,0 +1,61 @@
1 +revenue,price,region,firm_id
2 +13.073013,18.7221,1,1
3 +14.603161,19.0561,2,1
4 +37.036281,9.2921,3,1
5 +12.331106,17.4567,1,1
6 +20.199281,14.6262,2,1
7 +18.532705,12.7864,3,2
8 +13.442576,,1,2
9 +30.288376,7.02,2,2
10 +14.146638,14.8549,3,2
11 +15.789387,15.576,1,2
12 +24.085548,11.8661,2,3
13 +17.866099,15.7867,3,3
14 +13.358007,19.0201,1,3
15 +26.695153,8.8314,2,3
16 +20.905369,11.9344,3,3
17 +12.640513,19.1002,1,4
18 +11.071996,19.6734,2,4
19 +48.213111,6.7623,3,4
20 +19.400193,12.125,1,4
21 +22.781459,13.405,2,4
22 +15.854431,18.5605,3,5
23 +27.549049,7.0807,1,5
24 +15.637737,,2,5
25 +15.806717,19.2,3,5
26 +33.600334,6.2366,1,5
27 +22.746857,12.7132,2,6
28 +30.989393,10.8531,3,6
29 +12.510734,18.5861,1,6
30 +15.099717,11.7045,2,6
31 +17.10762,17.5401,3,6
32 +14.293473,16.0639,1,7
33 +15.713512,17.1658,2,7
34 +30.616712,10.8216,3,7
35 +19.841208,15.2775,1,7
36 +36.095565,5.0592,2,7
37 +20.003055,17.4937,3,8
38 +38.151955,5.11,1,8
39 +36.839992,8.1149,2,8
40 +17.29105,18.599,3,8
41 +19.569965,14.1767,1,8
42 +21.934484,,2,9
43 +26.14202,11.5366,3,9
44 +38.421147,5.5615,1,9
45 +10.899566,19.6031,2,9
46 +24.544112,11.4763,3,9
47 +12.655109,19.3636,1,10
48 +15.641427,18.3163,2,10
49 +22.233252,14.5997,3,10
50 +9.993891,19.5645,1,10
51 +16.320549,14.2826,2,10
52 +37.592103,10.0014,3,11
53 +25.093077,10.2012,1,11
54 +25.408455,10.9773,2,11
55 +17.119771,16.7704,3,11
56 +29.198899,5.584,1,11
57 +18.051847,16.2319,2,12
58 +19.195829,15.1592,3,12
59 +28.993336,7.569,1,12
60 +34.011476,8.9163,2,12
61 +27.274985,12.7162,3,12
added MetrikaKit/Tests/MetrikaKitTests/ParserTests.swift +174 −0
@@ -0,0 +1,174 @@
1 +//
2 +// ParserTests.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 Testing
11 +@testable import ZQParser
12 +
13 +@Suite("ZQL parser golden tests")
14 +struct ParserTests {
15 + let parser = ZQCommandParser()
16 +
17 + @Test("use file")
18 + func useFile() throws {
19 + let command = try #require(try parser.parse("use sales.parquet"))
20 + #expect(command.verb == "use")
21 + #expect(command.argument == "sales.parquet")
22 + }
23 +
24 + @Test("summarize with detail option")
25 + func summarizeDetail() throws {
26 + let command = try #require(try parser.parse("summarize revenue price, detail"))
27 + #expect(command.verb == "summarize")
28 + #expect(command.varlist == [.simple("revenue"), .simple("price")])
29 + #expect(command.hasOption("detail"))
30 + }
31 +
32 + @Test("verb abbreviation resolution")
33 + func abbreviations() throws {
34 + #expect(try parser.parse("su price")?.verb == "summarize")
35 + #expect(try parser.parse("reg y x")?.verb == "regress")
36 + #expect(try parser.parse("gen z = 1")?.verb == "generate")
37 + #expect(try parser.parse("d")?.verb == "describe")
38 + }
39 +
40 + @Test("gen with expression")
41 + func generate() throws {
42 + let command = try #require(try parser.parse("gen log_rev = ln(revenue)"))
43 + #expect(command.verb == "generate")
44 + let assignment = try #require(command.assignment)
45 + #expect(assignment.target == "log_rev")
46 + #expect(assignment.expression == .call(name: "ln", arguments: [.variable("revenue")]))
47 + }
48 +
49 + @Test("regress with factor variable and options")
50 + func regressFactors() throws {
51 + let command = try #require(try parser.parse("reg log_rev price i.region, robust"))
52 + #expect(command.verb == "regress")
53 + #expect(command.varlist == [
54 + .simple("log_rev"), .simple("price"), .factor(op: "i", name: "region"),
55 + ])
56 + #expect(command.hasOption("robust"))
57 + }
58 +
59 + @Test("cluster option carries its argument")
60 + func clusterOption() throws {
61 + let command = try #require(try parser.parse("xtreg log_rev price, fe cluster(firm_id)"))
62 + #expect(command.verb == "xtreg")
63 + #expect(command.hasOption("fe"))
64 + #expect(command.option("cluster")?.firstArgument == "firm_id")
65 + }
66 +
67 + @Test("bootstrap prefix command")
68 + func bootstrapPrefix() throws {
69 + let command = try #require(
70 + try parser.parse("bootstrap, reps(100000) seed(42): reg log_rev price")
71 + )
72 + #expect(command.verb == "bootstrap")
73 + #expect(command.option("reps")?.firstArgument == "100000")
74 + #expect(command.option("seed")?.firstArgument == "42")
75 + let body = try #require(command.body)
76 + #expect(body.verb == "regress")
77 + #expect(body.varlist == [.simple("log_rev"), .simple("price")])
78 + }
79 +
80 + @Test("xtset panel declaration")
81 + func xtset() throws {
82 + let command = try #require(try parser.parse("xtset firm_id year"))
83 + #expect(command.verb == "xtset")
84 + #expect(command.varlist == [.simple("firm_id"), .simple("year")])
85 + }
86 +
87 + @Test("graph compound verb with by option")
88 + func graphScatter() throws {
89 + let command = try #require(try parser.parse("graph scatter log_rev price, by(region)"))
90 + #expect(command.verb == "graph")
91 + #expect(command.subverb == "scatter")
92 + #expect(command.option("by")?.firstArgument == "region")
93 + }
94 +
95 + @Test("if, in, and weight qualifiers")
96 + func qualifiers() throws {
97 + let command = try #require(
98 + try parser.parse("summarize price if revenue > 100 & !missing(price) in 1/50 [aweight = pop]")
99 + )
100 + #expect(command.range == ZQRange(lower: 1, upper: 50))
101 + #expect(command.weight == ZQWeight(kind: .aweight, expression: .variable("pop")))
102 + let condition = try #require(command.condition)
103 + #expect(condition == .binary(
104 + op: "&",
105 + .binary(op: ">", .variable("revenue"), .number(100)),
106 + .unary(op: "!", .call(name: "missing", arguments: [.variable("price")]))
107 + ))
108 + }
109 +
110 + @Test("continuous interaction c.age#c.age")
111 + func interaction() throws {
112 + let command = try #require(try parser.parse("reg wage c.age#c.age education"))
113 + #expect(command.varlist == [
114 + .simple("wage"),
115 + .interaction(
116 + [.factor(op: "c", name: "age"), .factor(op: "c", name: "age")],
117 + full: false
118 + ),
119 + .simple("education"),
120 + ])
121 + }
122 +
123 + @Test("missing-value literal in expressions")
124 + func missingLiteral() throws {
125 + let command = try #require(try parser.parse("count if income == ."))
126 + #expect(command.condition == .binary(op: "==", .variable("income"), .missing))
127 + }
128 +
129 + @Test("comments and blank lines yield nil")
130 + func comments() throws {
131 + #expect(try parser.parse("") == nil)
132 + #expect(try parser.parse(" ") == nil)
133 + #expect(try parser.parse("// a comment") == nil)
134 + #expect(try parser.parse("* stata-style comment") == nil)
135 + }
136 +
137 + @Test("unknown verb error carries column and suggestion")
138 + func unknownVerbSuggestion() {
139 + do {
140 + _ = try parser.parse("regresss y x")
141 + Issue.record("expected a parse error")
142 + } catch let error as ZQParseError {
143 + #expect(error.column == 1)
144 + #expect(error.suggestion == "regress")
145 + } catch {
146 + Issue.record("unexpected error type: \(error)")
147 + }
148 + }
149 +
150 + @Test("error cites the offending column")
151 + func errorColumn() {
152 + do {
153 + _ = try parser.parse("summarize price, level(")
154 + Issue.record("expected a parse error")
155 + } catch let error as ZQParseError {
156 + // The unclosed '(' is reported at end of line: one past the
157 + // final character of the 23-character command.
158 + #expect(error.column == 24)
159 + } catch {
160 + Issue.record("unexpected error type: \(error)")
161 + }
162 + }
163 +
164 + @Test("operator precedence in expressions")
165 + func precedence() throws {
166 + let command = try #require(try parser.parse("count if a + b * c == d ^ 2 ^ 3"))
167 + // b*c binds before +; ^ is right-associative.
168 + #expect(command.condition == .binary(
169 + op: "==",
170 + .binary(op: "+", .variable("a"), .binary(op: "*", .variable("b"), .variable("c"))),
171 + .binary(op: "^", .variable("d"), .binary(op: "^", .number(2), .number(3)))
172 + ))
173 + }
174 +}
added MetrikaKit/Tests/MetrikaKitTests/PhiloxTests.swift +92 −0
@@ -0,0 +1,92 @@
1 +//
2 +// PhiloxTests.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 Testing
11 +import ZQGPU
12 +
13 +@Suite("Philox4x32-10 RNG")
14 +struct PhiloxTests {
15 +
16 + @Test("Random123 known-answer vectors")
17 + func knownAnswers() {
18 + // Zero counter, zero key.
19 + let zero = Philox4x32(seed: 0).block(counter: (0, 0, 0, 0))
20 + #expect(zero.0 == 0x6627_e8d5)
21 + #expect(zero.1 == 0xe169_c58d)
22 + #expect(zero.2 == 0xbc57_ac4c)
23 + #expect(zero.3 == 0x9b00_dbd8)
24 +
25 + // All-ones counter and key (Random123 kat_vectors).
26 + let ones = Philox4x32(seed: .max).block(
27 + counter: (0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff)
28 + )
29 + #expect(ones.0 == 0x408f_276d)
30 + #expect(ones.1 == 0x41c8_3b0e)
31 + #expect(ones.2 == 0xa20b_c7c6)
32 + #expect(ones.3 == 0x6d54_51fd)
33 + }
34 +
35 + @Test("same seed produces identical streams — reproducibility blocker")
36 + func determinism() {
37 + let a = Philox4x32(seed: 42)
38 + let b = Philox4x32(seed: 42)
39 + for index in [0, 1, 2, 1_000, 1_000_000] as [UInt64] {
40 + #expect(a.word(at: index) == b.word(at: index))
41 + #expect(a.uniform(at: index) == b.uniform(at: index))
42 + }
43 + }
44 +
45 + @Test("different seeds diverge")
46 + func seedsDiffer() {
47 + let a = Philox4x32(seed: 42)
48 + let b = Philox4x32(seed: 43)
49 + let differing = (0..<64).count { a.word(at: UInt64($0)) != b.word(at: UInt64($0)) }
50 + #expect(differing > 60)
51 + }
52 +
53 + @Test("uniforms are in [0, 1) and pass a coarse mean check")
54 + func uniformRange() {
55 + let generator = Philox4x32(seed: 7)
56 + var sum = 0.0
57 + let n = 100_000
58 + for i in 0..<n {
59 + let u = generator.uniform(at: UInt64(i))
60 + #expect(u >= 0 && u < 1)
61 + sum += u
62 + }
63 + let mean = sum / Double(n)
64 + #expect(abs(mean - 0.5) < 0.005, "mean \(mean) too far from 0.5")
65 + }
66 +
67 + @Test("bounded integers stay in range and hit every value")
68 + func integerBounds() {
69 + let generator = Philox4x32(seed: 11)
70 + var seen = Set<Int>()
71 + for i in 0..<10_000 {
72 + let value = generator.integer(at: UInt64(i), bound: 10)
73 + #expect(value >= 0 && value < 10)
74 + seen.insert(value)
75 + }
76 + #expect(seen.count == 10)
77 + }
78 +
79 + @Test("bootstrap replicates are independent of computation order")
80 + func resamplingAddressable() {
81 + let generator = Philox4x32(seed: 42)
82 + let early = ZQResampling.pairsBootstrapIndices(
83 + replicate: 5, sampleSize: 100, generator: generator
84 + )
85 + // Recomputing replicate 5 in isolation gives the same draw.
86 + let again = ZQResampling.pairsBootstrapIndices(
87 + replicate: 5, sampleSize: 100, generator: Philox4x32(seed: 42)
88 + )
89 + #expect(early == again)
90 + #expect(early.allSatisfy { (0..<100).contains($0) })
91 + }
92 +}
added MetrikaKit/Tests/MetrikaKitTests/StatsTests.swift +218 −0
@@ -0,0 +1,218 @@
1 +//
2 +// StatsTests.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 ZQStats
14 +
15 +/// Numerical tests against R-generated fixtures (Tests/Fixtures/generate.R)
16 +/// at 1e-10 relative tolerance (CLAUDE.md §0).
17 +@Suite("ZQStats vs R fixtures")
18 +struct StatsTests {
19 + let fixtures: Fixtures
20 + let y: [Double] // log(revenue), estimation sample
21 + let price: [Double]
22 + let region: [Double]
23 + let firmID: [Int]
24 +
25 + init() async throws {
26 + self.fixtures = try Fixtures()
27 +
28 + // Load the fixture CSV through the real data path (DuckDB).
29 + let store = try ZQDataStore()
30 + let frame = try await store.load(contentsOf: fixtures.datasetURL)
31 + let (revenue, revenueMissing) = try frame.requireNumeric("revenue")
32 + let (priceAll, priceMissing) = try frame.requireNumeric("price")
33 + let (regionAll, _) = try frame.requireNumeric("region")
34 + let (firmAll, _) = try frame.requireNumeric("firm_id")
35 +
36 + // Listwise deletion, mirroring generate.R.
37 + var y: [Double] = [], price: [Double] = []
38 + var region: [Double] = [], firm: [Int] = []
39 + for i in 0..<frame.rowCount where !priceMissing[i] && !revenueMissing[i] {
40 + y.append(Foundation.log(revenue[i]))
41 + price.append(priceAll[i])
42 + region.append(regionAll[i])
43 + firm.append(Int(firmAll[i]))
44 + }
45 + self.y = y
46 + self.price = price
47 + self.region = region
48 + self.firmID = firm
49 + }
50 +
51 + @Test("summarize matches R moments")
52 + func summarize() async throws {
53 + let store = try ZQDataStore()
54 + let frame = try await store.load(contentsOf: fixtures.datasetURL)
55 + let (values, missing) = try frame.requireNumeric("revenue")
56 + let summary = try #require(ZQSummarize.summary(
57 + name: "revenue", values: values, missing: missing, detail: true
58 + ))
59 + #expect(summary.observationCount == Int(fixtures["sum_revenue_n"]))
60 + expectClose(summary.mean, fixtures["sum_revenue_mean"], "mean")
61 + expectClose(summary.variance, fixtures["sum_revenue_var"], "variance")
62 + expectClose(summary.standardDeviation, fixtures["sum_revenue_sd"], "sd")
63 + expectClose(summary.minimum, fixtures["sum_revenue_min"], "min")
64 + expectClose(summary.maximum, fixtures["sum_revenue_max"], "max")
65 + let detail = try #require(summary.detail)
66 + expectClose(detail.skewness, fixtures["sum_revenue_skewness"], "skewness")
67 + expectClose(detail.kurtosis, fixtures["sum_revenue_kurtosis"], "kurtosis")
68 + }
69 +
70 + @Test("OLS point estimates and classical inference")
71 + func classicalOLS() throws {
72 + let result = try ZQOLS.fit(
73 + y: y, predictors: [("price", price)], variance: .classical
74 + )
75 + #expect(result.observationCount == Int(fixtures["ols_n"]))
76 + let priceCoef = result.coefficients[0]
77 + let constant = result.coefficients[1]
78 + expectClose(priceCoef.estimate, fixtures["ols_b_price"], "b[price]")
79 + expectClose(constant.estimate, fixtures["ols_b_cons"], "b[_cons]")
80 + expectClose(priceCoef.standardError, fixtures["ols_se_classical_price"], "se[price]")
81 + expectClose(constant.standardError, fixtures["ols_se_classical_cons"], "se[_cons]")
82 + expectClose(priceCoef.tStatistic, fixtures["ols_t_price"], "t[price]")
83 + expectClose(priceCoef.pValue, fixtures["ols_p_price"], "p[price]")
84 + expectClose(priceCoef.confidenceLower, fixtures["ols_ci_lower_price"], "ci lower")
85 + expectClose(priceCoef.confidenceUpper, fixtures["ols_ci_upper_price"], "ci upper")
86 + expectClose(result.rSquared, fixtures["ols_r2"], "R²")
87 + expectClose(result.adjustedRSquared, fixtures["ols_r2a"], "adj. R²")
88 + expectClose(result.rootMSE, fixtures["ols_rmse"], "root MSE")
89 + expectClose(try #require(result.fStatistic), fixtures["ols_F"], "F")
90 + }
91 +
92 + @Test("HC0–HC3 sandwich standard errors", arguments: [
93 + ("hc0", ZQVarianceEstimator.hc0),
94 + ("hc1", ZQVarianceEstimator.hc1),
95 + ("hc2", ZQVarianceEstimator.hc2),
96 + ("hc3", ZQVarianceEstimator.hc3),
97 + ])
98 + func robustSE(_ label: String, _ variance: ZQVarianceEstimator) throws {
99 + let result = try ZQOLS.fit(
100 + y: y, predictors: [("price", price)], variance: variance
101 + )
102 + expectClose(
103 + result.coefficients[0].standardError,
104 + fixtures["ols_se_\(label)_price"], "\(label) se[price]"
105 + )
106 + expectClose(
107 + result.coefficients[1].standardError,
108 + fixtures["ols_se_\(label)_cons"], "\(label) se[_cons]"
109 + )
110 + }
111 +
112 + @Test("cluster-robust standard errors (Stata small-sample factor)")
113 + func clusterSE() throws {
114 + let result = try ZQOLS.fit(
115 + y: y, predictors: [("price", price)], variance: .cluster(firmID)
116 + )
117 + #expect(result.clusterCount == Int(fixtures["ols_G"]))
118 + expectClose(
119 + result.coefficients[0].standardError,
120 + fixtures["ols_se_cluster_price"], "cluster se[price]"
121 + )
122 + expectClose(
123 + result.coefficients[1].standardError,
124 + fixtures["ols_se_cluster_cons"], "cluster se[_cons]"
125 + )
126 + expectClose(
127 + result.coefficients[0].pValue,
128 + fixtures["ols_p_cluster_price"], "cluster p[price] (t on G−1 df)"
129 + )
130 + }
131 +
132 + @Test("OLS invariance: permuting observations leaves estimates unchanged")
133 + func permutationInvariance() throws {
134 + let base = try ZQOLS.fit(y: y, predictors: [("price", price)])
135 + var order = Array(0..<y.count)
136 + // Deterministic shuffle (LCG) — no seeding dependency in tests.
137 + var state: UInt64 = 88_172_645_463_325_252
138 + for i in stride(from: order.count - 1, through: 1, by: -1) {
139 + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
140 + order.swapAt(i, Int(state % UInt64(i + 1)))
141 + }
142 + let permuted = try ZQOLS.fit(
143 + y: order.map { y[$0] },
144 + predictors: [("price", order.map { price[$0] })]
145 + )
146 + for (a, b) in zip(base.coefficients, permuted.coefficients) {
147 + expectClose(a.estimate, b.estimate, "permuted b[\(a.name)]")
148 + expectClose(a.standardError, b.standardError, "permuted se[\(a.name)]")
149 + }
150 + }
151 +
152 + @Test("OLS invariance: scaling a regressor rescales its coefficient")
153 + func scalingInvariance() throws {
154 + let base = try ZQOLS.fit(y: y, predictors: [("price", price)])
155 + let scaled = try ZQOLS.fit(
156 + y: y, predictors: [("price", price.map { $0 * 100 })]
157 + )
158 + expectClose(
159 + scaled.coefficients[0].estimate * 100,
160 + base.coefficients[0].estimate,
161 + "scaled coefficient"
162 + )
163 + expectClose(scaled.rSquared, base.rSquared, "scaled R²")
164 + }
165 +}
166 +
167 +@Suite("Distribution functions vs R")
168 +struct DistributionTests {
169 + let fixtures = try! Fixtures()
170 +
171 + @Test("Student t CDF")
172 + func studentT() {
173 + expectClose(
174 + ZQDistributions.studentTCDF(2.5, df: 10),
175 + fixtures["dist_pt_2p5_df10"], rtol: 1e-12, "pt(2.5, 10)"
176 + )
177 + expectClose(
178 + ZQDistributions.studentTCDF(-1.3, df: 3),
179 + fixtures["dist_pt_m1p3_df3"], rtol: 1e-12, "pt(-1.3, 3)"
180 + )
181 + expectClose(
182 + ZQDistributions.studentTCDF(0.05, df: 57),
183 + fixtures["dist_pt_0p05_df57"], rtol: 1e-12, "pt(0.05, 57)"
184 + )
185 + }
186 +
187 + @Test("F CDF")
188 + func fisher() {
189 + expectClose(
190 + ZQDistributions.fCDF(3.7, df1: 2, df2: 30),
191 + fixtures["dist_pf_3p7_2_30"], rtol: 1e-12, "pf(3.7, 2, 30)"
192 + )
193 + expectClose(
194 + ZQDistributions.fCDF(0.5, df1: 5, df2: 100),
195 + fixtures["dist_pf_0p5_5_100"], rtol: 1e-12, "pf(0.5, 5, 100)"
196 + )
197 + }
198 +
199 + @Test("t quantile")
200 + func tQuantile() {
201 + expectClose(
202 + ZQDistributions.studentTQuantile(0.975, df: 12),
203 + fixtures["dist_qt_0p975_df12"], rtol: 1e-10, "qt(0.975, 12)"
204 + )
205 + expectClose(
206 + ZQDistributions.studentTQuantile(0.995, df: 4),
207 + fixtures["dist_qt_0p995_df4"], rtol: 1e-10, "qt(0.995, 4)"
208 + )
209 + }
210 +
211 + @Test("normal CDF")
212 + func normal() {
213 + expectClose(
214 + ZQDistributions.normalCDF(1.64),
215 + fixtures["dist_pnorm_1p64"], rtol: 1e-14, "pnorm(1.64)"
216 + )
217 + }
218 +}
added Tests/Bench/bench.sh +54 −0
@@ -0,0 +1,54 @@
1 +#!/bin/bash
2 +#
3 +# bench.sh — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Performance benchmark harness (CLAUDE.md §8). Generates a synthetic
10 +# dataset and times the core pipeline through metrika-cli. CI compares
11 +# against the budget table; regressions >10% fail the build.
12 +#
13 +# Usage: Tests/Bench/bench.sh [rows] (default 1,000,000)
14 +#
15 +set -euo pipefail
16 +export LC_ALL=C
17 +
18 +ROWS="${1:-1000000}"
19 +ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
20 +DATA="$(mktemp -d)/bench.csv"
21 +trap 'rm -rf "$(dirname "$DATA")"' EXIT
22 +
23 +echo "generating $ROWS rows…"
24 +awk -v n="$ROWS" 'BEGIN {
25 + srand(42)
26 + print "revenue,price,region,firm_id"
27 + for (i = 1; i <= n; i++) {
28 + price = 5 + 15 * rand()
29 + region = (i % 3) + 1
30 + revenue = exp(4 - 0.08 * price + 0.1 * region + 0.15 * (rand() - 0.5))
31 + printf "%.6f,%.4f,%d,%d\n", revenue, price, region, (i % 500) + 1
32 + }
33 +}' > "$DATA"
34 +
35 +cd "$ROOT/MetrikaKit"
36 +swift build -c release --product metrika-cli >/dev/null
37 +CLI=".build/release/metrika-cli"
38 +
39 +bench() { # bench <label> <command...>
40 + local label="$1"; shift
41 + local start end
42 + start=$(python3 -c 'import time; print(time.time())')
43 + "$CLI" "$@" >/dev/null
44 + end=$(python3 -c 'import time; print(time.time())')
45 + printf "%-28s %8.3f s\n" "$label" "$(echo "$end - $start" | bc)"
46 +}
47 +
48 +bench "use csv ($ROWS rows)" -e "use $DATA"
49 +bench "use + summarize" -e "use $DATA" -e "summarize"
50 +bench "use + reg (robust)" -e "use $DATA" -e "gen log_rev = ln(revenue)" \
51 + -e "reg log_rev price, robust"
52 +bench "use + bootstrap(1000)" -e "use $DATA" -e "keep in 1/100000" \
53 + -e "gen log_rev = ln(revenue)" \
54 + -e "bootstrap, reps(1000) seed(42): reg log_rev price"
added Tests/Fixtures/generate.R +161 −0
@@ -0,0 +1,161 @@
1 +#!/usr/bin/env Rscript
2 +#
3 +# generate.R — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Generates the golden numerical fixtures for MetrikaKit estimator tests
10 +# (CLAUDE.md §9). Writes a reference dataset (CSV) plus expected values
11 +# (TSV, key<TAB>value at 17 significant digits) into the Swift test
12 +# resources at MetrikaKit/Tests/MetrikaKitTests/Fixtures/.
13 +#
14 +# All expected values are computed from the CSV **after** a write/read
15 +# round-trip so Swift and R consume bit-identical inputs.
16 +#
17 +
18 +out_dir <- file.path(
19 + dirname(dirname(normalizePath(sub("--file=", "", grep("--file=", commandArgs(FALSE), value = TRUE))))),
20 + "..", "MetrikaKit", "Tests", "MetrikaKitTests", "Fixtures"
21 +)
22 +out_dir <- normalizePath(out_dir, mustWork = FALSE)
23 +dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
24 +
25 +# ---------------------------------------------------------------- dataset
26 +set.seed(42)
27 +n <- 60
28 +region <- rep(1:3, length.out = n)
29 +firm_id <- rep(1:12, each = 5)
30 +price <- round(runif(n, 5, 20), 4)
31 +noise <- round(rnorm(n, 0, 0.15), 6)
32 +log_rev <- 4 - 0.08 * price + 0.10 * (region == 2) + 0.20 * (region == 3) + noise
33 +revenue <- round(exp(log_rev), 6)
34 +
35 +# Inject missing values to exercise listwise deletion.
36 +price_missing <- price
37 +price_missing[c(7, 23, 41)] <- NA
38 +
39 +data <- data.frame(
40 + revenue = revenue,
41 + price = price_missing,
42 + region = region,
43 + firm_id = firm_id
44 +)
45 +csv_path <- file.path(out_dir, "regression.csv")
46 +write.csv(data, csv_path, row.names = FALSE, quote = FALSE, na = "")
47 +
48 +# Round-trip: recompute everything from what was actually written.
49 +data <- read.csv(csv_path)
50 +
51 +sink_path <- file.path(out_dir, "expected.tsv")
52 +lines <- character(0)
53 +emit <- function(key, value) {
54 + lines <<- c(lines, sprintf("%s\t%.17g", key, value))
55 +}
56 +
57 +# ------------------------------------------------------------- summarize
58 +x <- data$revenue
59 +emit("sum_revenue_n", length(x))
60 +emit("sum_revenue_mean", mean(x))
61 +emit("sum_revenue_var", var(x))
62 +emit("sum_revenue_sd", sd(x))
63 +emit("sum_revenue_min", min(x))
64 +emit("sum_revenue_max", max(x))
65 +m2 <- mean((x - mean(x))^2)
66 +m3 <- mean((x - mean(x))^3)
67 +m4 <- mean((x - mean(x))^4)
68 +emit("sum_revenue_skewness", m3 / m2^1.5) # Stata definition
69 +emit("sum_revenue_kurtosis", m4 / m2^2)
70 +
71 +# ------------------------------------------------------------ regression
72 +# Estimation sample: listwise deletion on price.
73 +complete <- !is.na(data$price)
74 +d <- data[complete, ]
75 +d$log_rev <- log(d$revenue)
76 +n_est <- nrow(d)
77 +
78 +X <- cbind(price = d$price, `_cons` = 1)
79 +y <- d$log_rev
80 +k <- ncol(X)
81 +
82 +fit <- lm(log_rev ~ price, data = d)
83 +beta <- coef(fit) # (Intercept), price
84 +resid <- residuals(fit)
85 +XtXinv <- solve(t(X) %*% X)
86 +df_r <- n_est - k
87 +sigma2 <- sum(resid^2) / df_r
88 +
89 +emit("ols_n", n_est)
90 +emit("ols_dropped", sum(!complete))
91 +emit("ols_b_price", beta["price"])
92 +emit("ols_b_cons", beta["(Intercept)"])
93 +emit("ols_r2", summary(fit)$r.squared)
94 +emit("ols_r2a", summary(fit)$adj.r.squared)
95 +emit("ols_rmse", sqrt(sigma2))
96 +emit("ols_F", summary(fit)$fstatistic[["value"]])
97 +
98 +V_classical <- sigma2 * XtXinv
99 +emit("ols_se_classical_price", sqrt(V_classical["price", "price"]))
100 +emit("ols_se_classical_cons", sqrt(V_classical["_cons", "_cons"]))
101 +t_price <- beta["price"] / sqrt(V_classical["price", "price"])
102 +emit("ols_t_price", t_price)
103 +emit("ols_p_price", 2 * pt(-abs(t_price), df_r))
104 +crit <- qt(0.975, df_r)
105 +emit("ols_ci_lower_price", beta["price"] - crit * sqrt(V_classical["price", "price"]))
106 +emit("ols_ci_upper_price", beta["price"] + crit * sqrt(V_classical["price", "price"]))
107 +
108 +# HC0–HC3 sandwich estimators (manual, no packages).
109 +h <- hatvalues(fit)
110 +sandwich_se <- function(w) {
111 + meat <- t(X * w) %*% X
112 + V <- XtXinv %*% meat %*% XtXinv
113 + sqrt(diag(V))
114 +}
115 +se_hc0 <- sandwich_se(resid^2)
116 +se_hc1 <- sandwich_se(resid^2 * n_est / df_r)
117 +se_hc2 <- sandwich_se(resid^2 / (1 - h))
118 +se_hc3 <- sandwich_se(resid^2 / (1 - h)^2)
119 +emit("ols_se_hc0_price", se_hc0["price"])
120 +emit("ols_se_hc0_cons", se_hc0["_cons"])
121 +emit("ols_se_hc1_price", se_hc1["price"])
122 +emit("ols_se_hc1_cons", se_hc1["_cons"])
123 +emit("ols_se_hc2_price", se_hc2["price"])
124 +emit("ols_se_hc2_cons", se_hc2["_cons"])
125 +emit("ols_se_hc3_price", se_hc3["price"])
126 +emit("ols_se_hc3_cons", se_hc3["_cons"])
127 +
128 +# Cluster-robust with Stata regress small-sample factor.
129 +cl <- d$firm_id
130 +u <- rowsum(X * resid, cl)
131 +G <- length(unique(cl))
132 +meat_cl <- (G / (G - 1)) * ((n_est - 1) / df_r) * (t(u) %*% u)
133 +V_cl <- XtXinv %*% meat_cl %*% XtXinv
134 +emit("ols_G", G)
135 +emit("ols_se_cluster_price", sqrt(V_cl["price", "price"]))
136 +emit("ols_se_cluster_cons", sqrt(V_cl["_cons", "_cons"]))
137 +t_cl <- beta["price"] / sqrt(V_cl["price", "price"])
138 +emit("ols_p_cluster_price", 2 * pt(-abs(t_cl), G - 1))
139 +
140 +# Factor-variable regression: log_rev ~ price + i.region (base = 1).
141 +fit2 <- lm(log_rev ~ price + factor(region), data = d)
142 +b2 <- coef(fit2)
143 +emit("ols2_b_price", b2[["price"]])
144 +emit("ols2_b_region2", b2[["factor(region)2"]])
145 +emit("ols2_b_region3", b2[["factor(region)3"]])
146 +emit("ols2_b_cons", b2[["(Intercept)"]])
147 +emit("ols2_r2", summary(fit2)$r.squared)
148 +
149 +# --------------------------------------------------------- distributions
150 +emit("dist_pt_2p5_df10", pt(2.5, 10))
151 +emit("dist_pt_m1p3_df3", pt(-1.3, 3))
152 +emit("dist_pt_0p05_df57", pt(0.05, 57))
153 +emit("dist_pf_3p7_2_30", pf(3.7, 2, 30))
154 +emit("dist_pf_0p5_5_100", pf(0.5, 5, 100))
155 +emit("dist_qt_0p975_df12", qt(0.975, 12))
156 +emit("dist_qt_0p995_df4", qt(0.995, 4))
157 +emit("dist_pnorm_1p64", pnorm(1.64))
158 +
159 +writeLines(lines, sink_path)
160 +cat("wrote", csv_path, "\n")
161 +cat("wrote", sink_path, "with", length(lines), "expected values\n")
added project.yml +37 −0
@@ -0,0 +1,37 @@
1 +name: Metrika
2 +options:
3 + bundleIdPrefix: ai.spboucher
4 + deploymentTarget:
5 + macOS: "14.0"
6 + createIntermediateGroups: true
7 +
8 +packages:
9 + MetrikaKit:
10 + path: MetrikaKit
11 +
12 +targets:
13 + Metrika:
14 + type: application
15 + platform: macOS
16 + sources:
17 + - Metrika
18 + dependencies:
19 + - package: MetrikaKit
20 + product: MetrikaKit
21 + settings:
22 + base:
23 + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.metrika
24 + MARKETING_VERSION: 0.1.0
25 + CURRENT_PROJECT_VERSION: 1
26 + SWIFT_VERSION: "6.0"
27 + SWIFT_STRICT_CONCURRENCY: complete
28 + ARCHS: arm64
29 + ONLY_ACTIVE_ARCH: YES
30 + ENABLE_HARDENED_RUNTIME: YES
31 + ENABLE_APP_SANDBOX: YES
32 + CODE_SIGN_ENTITLEMENTS: Metrika/Metrika.entitlements
33 + CODE_SIGN_STYLE: Automatic
34 + GENERATE_INFOPLIST_FILE: YES
35 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
36 + INFOPLIST_KEY_NSHumanReadableCopyright: "© 2026 Simon-Pierre Boucher. All rights reserved."
37 + INFOPLIST_KEY_NSPrincipalClass: NSApplication
added scripts/ExportOptions.plist +9 −0
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 +<plist version="1.0"><dict>
5 + <key>method</key><string>developer-id</string>
6 + <key>teamID</key><string><TEAM_ID></string>
7 + <key>signingStyle</key><string>automatic</string>
8 + <key>destination</key><string>export</string>
9 +</dict></plist>
added scripts/check_headers.sh +45 −0
@@ -0,0 +1,45 @@
1 +#!/bin/bash
2 +#
3 +# check_headers.sh — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Rejects any source file (.swift, .metal, .sh, .py, .zyq) missing the
10 +# mandatory Metrika file header (CLAUDE.md §0). With --staged, checks only
11 +# files staged in git (pre-commit mode); otherwise checks the whole tree.
12 +#
13 +set -euo pipefail
14 +
15 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
16 +MODE="${1:-all}"
17 +
18 +if [[ "$MODE" == "--staged" ]]; then
19 + FILES=$(git -C "$ROOT" diff --cached --name-only --diff-filter=ACMR \
20 + | grep -E '\.(swift|metal|sh|py|zyq)$' || true)
21 +else
22 + FILES=$(cd "$ROOT" && find . \
23 + -not -path './.git/*' -not -path '*/.build/*' -not -path '*/build/*' \
24 + -not -path '*/DerivedData/*' -not -path '*/.swiftpm/*' \
25 + \( -name '*.swift' -o -name '*.metal' -o -name '*.sh' -o -name '*.py' -o -name '*.zyq' \) \
26 + | sed 's|^\./||')
27 +fi
28 +
29 +FAIL=0
30 +for f in $FILES; do
31 + [[ -f "$ROOT/$f" ]] || continue
32 + HEAD_BLOCK=$(head -n 12 "$ROOT/$f")
33 + if ! grep -q 'Author: Simon-Pierre Boucher' <<<"$HEAD_BLOCK" \
34 + || ! grep -q 'Contact: contact@spboucher.ai' <<<"$HEAD_BLOCK"; then
35 + echo "❌ missing Metrika header: $f"
36 + FAIL=1
37 + fi
38 +done
39 +
40 +if [[ $FAIL -ne 0 ]]; then
41 + echo ""
42 + echo "Every source file must begin with the Metrika header (see CLAUDE.md §0)."
43 + exit 1
44 +fi
45 +echo "✅ header check passed"
added scripts/install_hooks.sh +14 −0
@@ -0,0 +1,14 @@
1 +#!/bin/bash
2 +#
3 +# install_hooks.sh — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Installs the repo git hooks. Run once after cloning.
10 +#
11 +set -euo pipefail
12 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
13 +install -m 755 "$ROOT/scripts/pre-commit" "$ROOT/.git/hooks/pre-commit"
14 +echo "✅ pre-commit hook installed"
added scripts/make_icns.sh +41 −0
@@ -0,0 +1,41 @@
1 +#!/bin/bash
2 +#
3 +# make_icns.sh — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Rasterizes Assets/AppIcon/metrika-icon.svg to all required sizes and
10 +# produces Assets/AppIcon/Metrika.icns via iconutil (CLAUDE.md §11).
11 +# Requires rsvg-convert (brew install librsvg); falls back to qlmanage.
12 +#
13 +set -euo pipefail
14 +
15 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
16 +SVG="$ROOT/Assets/AppIcon/metrika-icon.svg"
17 +ICONSET="$ROOT/Assets/AppIcon/Metrika.iconset"
18 +ICNS="$ROOT/Assets/AppIcon/Metrika.icns"
19 +
20 +[[ -f "$SVG" ]] || { echo "❌ missing $SVG"; exit 1; }
21 +rm -rf "$ICONSET" && mkdir -p "$ICONSET"
22 +
23 +rasterize() { # rasterize <px> <outfile>
24 + local px="$1" out="$2"
25 + if command -v rsvg-convert >/dev/null; then
26 + rsvg-convert -w "$px" -h "$px" "$SVG" -o "$out"
27 + else
28 + qlmanage -t -s "$px" -o "$(dirname "$out")" "$SVG" >/dev/null
29 + mv "$(dirname "$out")/$(basename "$SVG").png" "$out"
30 + fi
31 +}
32 +
33 +# macOS iconset naming: size @1x and @2x pairs.
34 +for size in 16 32 128 256 512; do
35 + rasterize "$size" "$ICONSET/icon_${size}x${size}.png"
36 + rasterize "$((size*2))" "$ICONSET/icon_${size}x${size}@2x.png"
37 +done
38 +
39 +iconutil -c icns "$ICONSET" -o "$ICNS"
40 +rm -rf "$ICONSET"
41 +echo "✅ $ICNS"
added scripts/pre-commit +13 −0
@@ -0,0 +1,13 @@
1 +#!/bin/bash
2 +#
3 +# pre-commit — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Git pre-commit hook: rejects staged source files missing the Metrika
10 +# header (CLAUDE.md §0). Installed by scripts/install_hooks.sh.
11 +#
12 +set -euo pipefail
13 +exec "$(git rev-parse --show-toplevel)/scripts/check_headers.sh" --staged
added scripts/release.sh +59 −0
@@ -0,0 +1,59 @@
1 +#!/bin/bash
2 +#
3 +# release.sh — Metrika
4 +#
5 +# Author: Simon-Pierre Boucher
6 +# Contact: contact@spboucher.ai
7 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +#
9 +# Release pipeline (CLAUDE.md §10): archive → export → verify → DMG →
10 +# sign → notarize → staple.
11 +#
12 +# ⚠️ PLACEHOLDERS: fill <TEAM_ID> and <KEYCHAIN_PROFILE_NAME> from the
13 +# local credentials folder before the first release build. Never commit
14 +# real credentials.
15 +#
16 +set -euo pipefail
17 +
18 +APP="Metrika"
19 +SCHEME="Metrika"
20 +BUILD_DIR="build"
21 +DMG="${APP}.dmg"
22 +
23 +# 1. Archive
24 +xcodebuild -scheme "$SCHEME" -configuration Release -arch arm64 \
25 + -archivePath "$BUILD_DIR/$APP.xcarchive" archive
26 +
27 +# 2. Export with Developer ID
28 +xcodebuild -exportArchive \
29 + -archivePath "$BUILD_DIR/$APP.xcarchive" \
30 + -exportOptionsPlist scripts/ExportOptions.plist \
31 + -exportPath "$BUILD_DIR/export"
32 +
33 +# 3. Verify hardened runtime + entitlements
34 +codesign -dv --verbose=4 "$BUILD_DIR/export/$APP.app"
35 +codesign --verify --deep --strict "$BUILD_DIR/export/$APP.app"
36 +
37 +# 4. Build DMG (create-dmg, background art in Assets/DMG/)
38 +create-dmg \
39 + --volname "$APP" \
40 + --window-size 540 380 \
41 + --icon-size 128 \
42 + --icon "$APP.app" 130 190 \
43 + --app-drop-link 400 190 \
44 + --background "Assets/DMG/background.png" \
45 + "$DMG" "$BUILD_DIR/export/"
46 +
47 +# 5. Sign the DMG itself
48 +codesign --sign "Developer ID Application: Simon-Pierre Boucher (<TEAM_ID>)" "$DMG"
49 +
50 +# 6. Notarize & wait
51 +xcrun notarytool submit "$DMG" \
52 + --keychain-profile "<KEYCHAIN_PROFILE_NAME>" \
53 + --wait
54 +
55 +# 7. Staple
56 +xcrun stapler staple "$DMG"
57 +xcrun stapler validate "$DMG"
58 +
59 +echo "✅ $DMG notarized and stapled."
60