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(app): user manual — in-app reference and console help

- ZQCommandReference: one registry of every command (syntax, options,
  examples, notes) shared by the console and the app so they cannot
  drift; 35 commands across 10 categories
- console: 'help' lists the reference by category, 'help <command>'
  (abbreviations resolve too) renders the full entry
- app: Manual pane — searchable sidebar grouped by category, per-command
  pages (syntax/options/examples), and a 'How Metrika works' overview
  covering the command grammar, data handling, estimation conventions,
  the CPU/GPU planner, reproducibility, and user commands
- release plumbing: real team ID + notary keychain-profile NAME in
  release.sh (both configuration, not credentials, per the security
  conventions), manual Developer ID signing with hardened runtime,
  -skipPackagePluginValidation, DMG background art
- tests: help coverage test pins a manual entry for every implemented
  verb; UI smoke drives the Manual pane; 106 green

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

Showing 10 changed files with +698 and −13

modified Metrika.xcodeproj/project.pbxproj +4 −0
@@ -14,6 +14,7 @@
14 14 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; };
15 15 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; };
16 16 CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; };
17 + D01BD59F090074D4C3FC392D /* ManualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C64C028218A21FE8E5D9D1 /* ManualView.swift */; };
17 18 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; };
18 19 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */; };
19 20 /* End PBXBuildFile section */
@@ -39,6 +40,7 @@
39 40 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; };
40 41 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; };
41 42 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; };
43 + A7C64C028218A21FE8E5D9D1 /* ManualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualView.swift; sourceTree = "<group>"; };
42 44 AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; };
43 45 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoFileEditorView.swift; sourceTree = "<group>"; };
44 46 /* End PBXFileReference section */
@@ -78,6 +80,7 @@
78 80 159695CF89D6BB25DF883F07 /* ConsoleView.swift */,
79 81 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */,
80 82 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */,
83 + A7C64C028218A21FE8E5D9D1 /* ManualView.swift */,
81 84 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */,
82 85 4AD551AA707CD1853C3D6D21 /* PlotView.swift */,
83 86 0453FB0F256E853D2C2F481C /* SessionModel.swift */,
@@ -230,6 +233,7 @@
230 233 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */,
231 234 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */,
232 235 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */,
236 + D01BD59F090074D4C3FC392D /* ManualView.swift in Sources */,
233 237 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */,
234 238 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */,
235 239 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */,
added Metrika/Sources/ManualView.swift +233 −0
@@ -0,0 +1,233 @@
1 +//
2 +// ManualView.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 +import ZQEngine
12 +
13 +/// User manual pane: a searchable reference of every command (rendered
14 +/// from the same registry as the console's `help`), plus an overview of
15 +/// how Metrika works.
16 +struct ManualPane: View {
17 + @State private var searchText = ""
18 + @State private var selection: String? = ManualPane.overviewID
19 +
20 + static let overviewID = "__overview"
21 +
22 + private var filteredDocs: [ZQCommandDoc] {
23 + let needle = searchText.trimmingCharacters(in: .whitespaces).lowercased()
24 + guard !needle.isEmpty else { return ZQCommandReference.all }
25 + return ZQCommandReference.all.filter {
26 + $0.verb.lowercased().contains(needle)
27 + || $0.summary.lowercased().contains(needle)
28 + || $0.category.lowercased().contains(needle)
29 + }
30 + }
31 +
32 + var body: some View {
33 + HSplitView {
34 + VStack(spacing: 0) {
35 + HStack(spacing: 6) {
36 + Image(systemName: "magnifyingglass")
37 + .foregroundStyle(.secondary)
38 + TextField("Search commands", text: $searchText)
39 + .textFieldStyle(.plain)
40 + }
41 + .padding(8)
42 + Divider()
43 + List(selection: $selection) {
44 + if searchText.isEmpty {
45 + Label("How Metrika works", systemImage: "info.circle")
46 + .tag(Self.overviewID)
47 + }
48 + ForEach(ZQCommandReference.categories, id: \.self) { category in
49 + let docs = filteredDocs.filter { $0.category == category }
50 + if !docs.isEmpty {
51 + Section(category) {
52 + ForEach(docs) { doc in
53 + Text(doc.verb)
54 + .fontDesign(.monospaced)
55 + .tag(doc.verb)
56 + }
57 + }
58 + }
59 + }
60 + }
61 + .listStyle(.sidebar)
62 + }
63 + .frame(minWidth: 200, maxWidth: 280)
64 +
65 + ScrollView {
66 + Group {
67 + if selection == Self.overviewID {
68 + OverviewPage()
69 + } else if let doc = ZQCommandReference.all.first(where: {
70 + $0.verb == selection
71 + }) {
72 + CommandPage(doc: doc)
73 + } else {
74 + ContentUnavailableView(
75 + "Select a command", systemImage: "book",
76 + description: Text("Pick a topic from the list.")
77 + )
78 + }
79 + }
80 + .frame(maxWidth: 640, alignment: .leading)
81 + .padding(24)
82 + }
83 + .frame(maxWidth: .infinity)
84 + }
85 + }
86 +}
87 +
88 +/// One command's reference page.
89 +private struct CommandPage: View {
90 + let doc: ZQCommandDoc
91 +
92 + var body: some View {
93 + VStack(alignment: .leading, spacing: 14) {
94 + HStack(alignment: .firstTextBaseline, spacing: 10) {
95 + Text(doc.verb)
96 + .font(.title2.weight(.semibold))
97 + .fontDesign(.monospaced)
98 + if let abbreviation = doc.abbreviation {
99 + Text("abbreviation: \(abbreviation)")
100 + .font(.caption)
101 + .foregroundStyle(.secondary)
102 + }
103 + Spacer()
104 + Text(doc.category)
105 + .font(.caption)
106 + .padding(.horizontal, 8)
107 + .padding(.vertical, 3)
108 + .background(.quaternary, in: Capsule())
109 + }
110 +
111 + Text(doc.summary)
112 +
113 + GroupBox("Syntax") {
114 + Text(doc.syntax)
115 + .fontDesign(.monospaced)
116 + .font(.system(size: 12))
117 + .textSelection(.enabled)
118 + .frame(maxWidth: .infinity, alignment: .leading)
119 + .padding(4)
120 + }
121 +
122 + if !doc.options.isEmpty {
123 + GroupBox("Options") {
124 + VStack(alignment: .leading, spacing: 6) {
125 + ForEach(doc.options, id: \.name) { option in
126 + HStack(alignment: .firstTextBaseline, spacing: 12) {
127 + Text(option.name)
128 + .fontDesign(.monospaced)
129 + .font(.system(size: 12))
130 + .frame(width: 130, alignment: .leading)
131 + Text(option.meaning)
132 + .font(.system(size: 12))
133 + }
134 + }
135 + }
136 + .frame(maxWidth: .infinity, alignment: .leading)
137 + .padding(4)
138 + }
139 + }
140 +
141 + if !doc.examples.isEmpty {
142 + GroupBox("Examples") {
143 + VStack(alignment: .leading, spacing: 4) {
144 + ForEach(doc.examples, id: \.self) { example in
145 + Text(". \(example)")
146 + .fontDesign(.monospaced)
147 + .font(.system(size: 12))
148 + .textSelection(.enabled)
149 + }
150 + }
151 + .frame(maxWidth: .infinity, alignment: .leading)
152 + .padding(4)
153 + }
154 + }
155 +
156 + if let notes = doc.notes {
157 + Text(notes)
158 + .font(.callout)
159 + .foregroundStyle(.secondary)
160 + }
161 + }
162 + }
163 +}
164 +
165 +/// "How Metrika works" — the architecture from a user's point of view.
166 +private struct OverviewPage: View {
167 + var body: some View {
168 + VStack(alignment: .leading, spacing: 16) {
169 + Text("How Metrika works")
170 + .font(.title2.weight(.semibold))
171 +
172 + section("The command line", """
173 + Everything in Metrika is a command, following one grammar:
174 +
175 + command [varlist] [if condition] [in range] [, options]
176 +
177 + Type commands in the Console pane (↑/↓ recall history), run whole \
178 + scripts from the Do-file pane (⌘R runs the selection or the file), \
179 + or filter the Data pane — its filter bar compiles to the same `if` \
180 + expressions. All three routes share one execution engine, so a \
181 + do-file replays exactly what you typed.
182 + """)
183 +
184 + section("Data", """
185 + `use` loads parquet, csv, json, arrow, or Stata .dta files; `save` \
186 + writes them back. Data is columnar in memory (DuckDB underneath), \
187 + numeric values are 64-bit floats, and missing values are tracked \
188 + explicitly — every estimator reports how many observations \
189 + listwise deletion dropped. The sidebar always shows the working \
190 + dataset's variables and missing counts.
191 + """)
192 +
193 + section("Estimation", """
194 + Estimators follow Stata conventions: `reg y x, robust \
195 + cluster(id)` gives HC1 or cluster-robust standard errors, factor \
196 + variables expand with `i.var`, and every CPU result is validated \
197 + against R to 1e-10. After fitting, `predict` generates fitted \
198 + values or residuals and `margins, dydx()` computes average \
199 + marginal effects with delta-method standard errors.
200 + """)
201 +
202 + section("The GPU, invisibly", """
203 + A planner decides where each command runs — you never choose. \
204 + Large bootstrap runs execute batched on the Apple GPU; small jobs \
205 + stay on the CPU. Random numbers come from a counter-based Philox \
206 + generator, so `set seed 42` produces bit-identical resamples on \
207 + either backend, in any chunk order.
208 + """)
209 +
210 + section("Extending Metrika", """
211 + Drop a .zyq script into ~/Library/Application Support/Metrika/\
212 + Commands/ and its filename becomes a command — an optional \
213 + `args name…` first line names the arguments, referenced as \
214 + `name' in the body. Native plugins are Swift types implementing \
215 + ZQCommandPlugin (see `help zscore` for the shipped example).
216 + """)
217 +
218 + section("Reproducibility", """
219 + `log using file` records a session. Seeds are honored across CPU \
220 + and GPU. Do-files replay top to bottom with the errors pointing \
221 + at the failing line. The same engine powers the metrika-cli \
222 + terminal tool, so pipelines can run headless.
223 + """)
224 + }
225 + }
226 +
227 + private func section(_ title: String, _ body: String) -> some View {
228 + VStack(alignment: .leading, spacing: 6) {
229 + Text(title).font(.headline)
230 + Text(body).font(.callout)
231 + }
232 + }
233 +}
modified Metrika/Sources/MetrikaApp.swift +3 −0
@@ -28,6 +28,7 @@ struct ContentView: View {
28 28 case console = "Console"
29 29 case data = "Data"
30 30 case doFile = "Do-file"
31 + case manual = "Manual"
31 32 }
32 33
33 34 @Environment(SessionModel.self) private var model
@@ -52,6 +53,8 @@ struct ContentView: View {
52 53 DataBrowserPane()
53 54 case .doFile:
54 55 DoFileEditorPane()
56 + case .manual:
57 + ManualPane()
55 58 }
56 59 }
57 60 .toolbar {
added MetrikaKit/Assets/DMG/background.png +0 −0

Binary file not shown.

added MetrikaKit/Sources/ZQEngine/CommandHelp.swift +331 −0
@@ -0,0 +1,331 @@
1 +//
2 +// CommandHelp.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 +/// The in-app command reference (CLAUDE.md §13 v1.0: `help regress`
11 +/// in-app). One registry serves both the console `help` command and the
12 +/// app's Manual pane, so they can never drift apart.
13 +public struct ZQCommandDoc: Identifiable, Equatable, Sendable {
14 + public var id: String { verb }
15 + public var verb: String
16 + /// Shortest accepted abbreviation ("reg" for regress); nil if none.
17 + public var abbreviation: String?
18 + public var category: String
19 + public var summary: String
20 + public var syntax: String
21 + public var options: [(name: String, meaning: String)]
22 + public var examples: [String]
23 + public var notes: String?
24 +
25 + public init(
26 + verb: String, abbreviation: String? = nil, category: String,
27 + summary: String, syntax: String,
28 + options: [(name: String, meaning: String)] = [],
29 + examples: [String] = [], notes: String? = nil
30 + ) {
31 + self.verb = verb
32 + self.abbreviation = abbreviation
33 + self.category = category
34 + self.summary = summary
35 + self.syntax = syntax
36 + self.options = options
37 + self.examples = examples
38 + self.notes = notes
39 + }
40 +
41 + public static func == (lhs: ZQCommandDoc, rhs: ZQCommandDoc) -> Bool {
42 + lhs.verb == rhs.verb
43 + }
44 +}
45 +
46 +public enum ZQCommandReference {
47 + public static let categories = [
48 + "Data management", "Descriptive statistics", "Estimation",
49 + "Machine learning", "Resampling & simulation", "Post-estimation",
50 + "Graphics", "Panel data", "Session & scripting", "User commands",
51 + ]
52 +
53 + public static func doc(for verb: String) -> ZQCommandDoc? {
54 + all.first { $0.verb == verb || $0.abbreviation == verb }
55 + }
56 +
57 + public static let all: [ZQCommandDoc] = [
58 + // ------------------------------------------------ Data management
59 + ZQCommandDoc(
60 + verb: "use", category: "Data management",
61 + summary: "Load a dataset into memory. Format is inferred from the extension: .parquet, .csv, .tsv, .json, .arrow, and Stata .dta (formats 117–119).",
62 + syntax: "use filename",
63 + examples: ["use sales.parquet", "use ~/data/panel.dta"],
64 + notes: "Loading goes through DuckDB for columnar formats and a native reader for .dta. The sandboxed app reads user-selected files and its own container."
65 + ),
66 + ZQCommandDoc(
67 + verb: "save", category: "Data management",
68 + summary: "Write the working dataset to disk (.parquet, .csv, or Stata .dta format 118).",
69 + syntax: "save filename [, replace]",
70 + options: [("replace", "overwrite an existing file")],
71 + examples: ["save results.dta, replace"]
72 + ),
73 + ZQCommandDoc(
74 + verb: "clear", category: "Data management",
75 + summary: "Drop the working dataset from memory.",
76 + syntax: "clear"
77 + ),
78 + ZQCommandDoc(
79 + verb: "describe", abbreviation: "d", category: "Data management",
80 + summary: "List variables with their storage types and missing counts.",
81 + syntax: "describe"
82 + ),
83 + ZQCommandDoc(
84 + verb: "list", abbreviation: "l", category: "Data management",
85 + summary: "Print observations (first 50 shown).",
86 + syntax: "list [varlist] [if] [in]",
87 + examples: ["list revenue price in 1/10"]
88 + ),
89 + ZQCommandDoc(
90 + verb: "count", abbreviation: "cou", category: "Data management",
91 + summary: "Count observations satisfying a condition.",
92 + syntax: "count [if]",
93 + examples: ["count if revenue > 100 & !missing(price)"]
94 + ),
95 + ZQCommandDoc(
96 + verb: "generate", abbreviation: "gen", category: "Data management",
97 + summary: "Create a new variable from an expression. Observations excluded by `if` become missing.",
98 + syntax: "generate newvar = expression [if] [in]",
99 + examples: [
100 + "gen log_rev = ln(revenue)",
101 + "gen high = revenue > 120 if !missing(revenue)",
102 + ],
103 + notes: "Functions: ln, log, log10, exp, sqrt, abs, floor, ceil, round, int, min, max, missing. Missing values propagate through arithmetic; division by zero and ln of a nonpositive number yield missing."
104 + ),
105 + ZQCommandDoc(
106 + verb: "replace", category: "Data management",
107 + summary: "Overwrite values of an existing variable; reports the number of real changes.",
108 + syntax: "replace varname = expression [if] [in]",
109 + examples: ["replace flag = 0 if missing(flag)"]
110 + ),
111 + ZQCommandDoc(
112 + verb: "drop", category: "Data management",
113 + summary: "Remove variables, or observations with `if`/`in`.",
114 + syntax: "drop varlist | drop if expression | drop in range",
115 + examples: ["drop temp1 temp2", "drop if price < 0"]
116 + ),
117 + ZQCommandDoc(
118 + verb: "keep", category: "Data management",
119 + summary: "Keep only the listed variables, or only observations satisfying a condition.",
120 + syntax: "keep varlist | keep if expression | keep in range",
121 + examples: ["keep in 1/1000"]
122 + ),
123 +
124 + // ------------------------------------------ Descriptive statistics
125 + ZQCommandDoc(
126 + verb: "summarize", abbreviation: "su", category: "Descriptive statistics",
127 + summary: "Means, standard deviations, and ranges; `detail` adds percentiles, skewness, and kurtosis (Stata definitions).",
128 + syntax: "summarize [varlist] [if] [in] [, detail]",
129 + options: [("detail", "percentiles p1–p99, skewness, kurtosis")],
130 + examples: ["summarize revenue price, detail"]
131 + ),
132 + ZQCommandDoc(
133 + verb: "tabulate", abbreviation: "tab", category: "Descriptive statistics",
134 + summary: "One-way frequency table or two-way cross-tabulation with totals.",
135 + syntax: "tabulate varname [varname2] [if] [, missing]",
136 + options: [("missing", "include missing values as a category")],
137 + examples: ["tab region", "tabulate region purchase"]
138 + ),
139 + ZQCommandDoc(
140 + verb: "correlate", abbreviation: "cor", category: "Descriptive statistics",
141 + summary: "Pearson correlation matrix with listwise deletion.",
142 + syntax: "correlate [varlist] [if]",
143 + examples: ["correlate revenue price orders"]
144 + ),
145 +
146 + // ------------------------------------------------------ Estimation
147 + ZQCommandDoc(
148 + verb: "regress", abbreviation: "reg", category: "Estimation",
149 + summary: "Ordinary least squares via LAPACK QR. Factor variables (i.var) expand to indicators; c.a#c.b forms continuous interactions.",
150 + syntax: "regress depvar [indepvars] [if] [in] [, robust hc2 hc3 cluster(varname) noconstant level(#)]",
151 + options: [
152 + ("robust", "HC1 heteroskedasticity-consistent SE (Stata's robust)"),
153 + ("hc2 / hc3", "leverage-adjusted sandwich estimators"),
154 + ("cluster(var)", "cluster-robust SE, t on G−1 df"),
155 + ("noconstant", "suppress the intercept"),
156 + ("level(#)", "confidence level, default 95"),
157 + ],
158 + examples: [
159 + "reg log_rev price, robust",
160 + "reg log_rev price i.region, cluster(firm_id)",
161 + "reg wage c.age#c.age education",
162 + ],
163 + notes: "Results match R to 1e-10. Listwise deletion is reported explicitly."
164 + ),
165 + ZQCommandDoc(
166 + verb: "logit", category: "Estimation",
167 + summary: "Logistic regression by Fisher scoring; reports LR χ², McFadden pseudo-R², z statistics.",
168 + syntax: "logit depvar indepvars [if] [, robust cluster(varname) level(#)]",
169 + examples: ["logit purchase price, robust"]
170 + ),
171 + ZQCommandDoc(
172 + verb: "probit", category: "Estimation",
173 + summary: "Probit regression (normal link), same options as logit.",
174 + syntax: "probit depvar indepvars [if] [, robust cluster(varname)]",
175 + examples: ["probit purchase price"]
176 + ),
177 + ZQCommandDoc(
178 + verb: "poisson", category: "Estimation",
179 + summary: "Poisson regression for counts (log link).",
180 + syntax: "poisson depvar indepvars [if] [, robust cluster(varname)]",
181 + examples: ["poisson orders price, robust"]
182 + ),
183 + ZQCommandDoc(
184 + verb: "ivregress", category: "Estimation",
185 + summary: "Two-stage least squares. Endogenous regressors and their instruments go in the parenthesized group.",
186 + syntax: "ivregress 2sls depvar [exogvars] (endogvars = instruments) [, robust cluster(varname)]",
187 + examples: ["ivregress 2sls log_rev (price = z1 z2), robust"],
188 + notes: "Residuals come from the original regressors; inference follows Stata's `small` convention (t statistics on N−K df)."
189 + ),
190 + ZQCommandDoc(
191 + verb: "xtreg", category: "Estimation",
192 + summary: "Panel fixed-effects (within) estimator. Declare the panel with xtset first.",
193 + syntax: "xtreg depvar indepvars, fe [cluster(panelvar)]",
194 + options: [
195 + ("fe", "fixed effects (required — the only estimator so far)"),
196 + ("cluster(panelvar)", "panel-clustered SE, t on G−1 df"),
197 + ],
198 + examples: ["xtset firm_id", "xtreg log_rev price, fe cluster(firm_id)"]
199 + ),
200 +
201 + // ------------------------------------------------ Machine learning
202 + ZQCommandDoc(
203 + verb: "lasso", category: "Machine learning",
204 + summary: "L1-penalized linear regression (coordinate descent, glmnet conventions). Selects variables by zeroing coefficients.",
205 + syntax: "lasso depvar indepvars, lambda(#)",
206 + options: [("lambda(#)", "penalty strength; omit it to see lambda_max for your data")],
207 + examples: ["lasso log_rev price z1 z2 orders, lambda(0.05)"]
208 + ),
209 + ZQCommandDoc(
210 + verb: "elasticnet", category: "Machine learning",
211 + summary: "Elastic-net linear regression mixing L1 and L2 penalties.",
212 + syntax: "elasticnet depvar indepvars, lambda(#) [alpha(#)]",
213 + options: [
214 + ("alpha(#)", "1 = lasso, 0 = ridge; default 1"),
215 + ("lambda(#)", "penalty strength"),
216 + ],
217 + examples: ["elasticnet log_rev price z1 z2, alpha(0.4) lambda(0.02)"],
218 + notes: "Matches R glmnet, including its gaussian y-standardization convention (ridge penalties scale with sd of the response)."
219 + ),
220 +
221 + // ------------------------------------------ Resampling & simulation
222 + ZQCommandDoc(
223 + verb: "bootstrap", category: "Resampling & simulation",
224 + summary: "Pairs bootstrap of a regression. Large replication counts run batched on the Apple GPU; results are identical either way for a given seed.",
225 + syntax: "bootstrap, reps(#) [seed(#)]: regress depvar indepvars",
226 + options: [
227 + ("reps(#)", "number of replications"),
228 + ("seed(#)", "Philox seed — full reproducibility across CPU and GPU"),
229 + ],
230 + examples: ["bootstrap, reps(10000) seed(42): reg log_rev price"],
231 + notes: "Replicates are counter-addressable: any subset recomputes identically regardless of chunking or backend. The planner dispatches ≥500 reps to the GPU when available."
232 + ),
233 + ZQCommandDoc(
234 + verb: "permute", category: "Resampling & simulation",
235 + summary: "Permutation test: the response is permuted, the model refit, and empirical two-sided p-values reported per coefficient.",
236 + syntax: "permute, reps(#) [seed(#)]: regress depvar indepvars",
237 + examples: ["permute, reps(1000) seed(42): reg log_rev orders"]
238 + ),
239 + ZQCommandDoc(
240 + verb: "set", category: "Resampling & simulation",
241 + summary: "Set session parameters. `set seed` fixes the Philox key used by bootstrap and permute.",
242 + syntax: "set seed #",
243 + examples: ["set seed 42"]
244 + ),
245 +
246 + // ------------------------------------------------- Post-estimation
247 + ZQCommandDoc(
248 + verb: "predict", category: "Post-estimation",
249 + summary: "Generate predictions from the last estimation over all current observations.",
250 + syntax: "predict newvar [, xb residuals pr n]",
251 + options: [
252 + ("xb", "linear prediction (default after regress/ivregress)"),
253 + ("residuals", "response residuals"),
254 + ("pr", "predicted probability (default after logit/probit)"),
255 + ("n", "predicted mean count (default after poisson)"),
256 + ],
257 + examples: ["reg log_rev price", "predict yhat", "predict e, residuals"]
258 + ),
259 + ZQCommandDoc(
260 + verb: "margins", abbreviation: "marg", category: "Post-estimation",
261 + summary: "Average marginal effects with delta-method standard errors. After OLS/IV the effect is the coefficient; after logit/probit/poisson it averages dμ/dx over the estimation sample.",
262 + syntax: "margins, dydx(varlist) [level(#)]",
263 + examples: ["logit purchase price", "margins, dydx(price)"],
264 + notes: "Continuous regressors only for now; factor and interaction terms are rejected with a message."
265 + ),
266 +
267 + // ------------------------------------------------------- Graphics
268 + ZQCommandDoc(
269 + verb: "scatter", category: "Graphics",
270 + summary: "Scatter plot (also available as `graph scatter`). `by()` splits into colored series.",
271 + syntax: "scatter yvar xvar [if] [, by(varname)]",
272 + examples: ["scatter log_rev price, by(region)"]
273 + ),
274 + ZQCommandDoc(
275 + verb: "histogram", abbreviation: "hist", category: "Graphics",
276 + summary: "Frequency histogram of one variable (Sturges bins by default).",
277 + syntax: "histogram varname [if] [, bins(#)]",
278 + options: [("bins(#)", "override the bin count")],
279 + examples: ["histogram revenue, bins(12)"]
280 + ),
281 + ZQCommandDoc(
282 + verb: "kdensity", abbreviation: "kden", category: "Graphics",
283 + summary: "Kernel density estimate (Epanechnikov kernel, Silverman bandwidth).",
284 + syntax: "kdensity varname [if]",
285 + examples: ["kdensity revenue"]
286 + ),
287 + ZQCommandDoc(
288 + verb: "graph", abbreviation: "gr", category: "Graphics",
289 + summary: "General plotting front end: graph scatter, graph line, graph histogram.",
290 + syntax: "graph scatter|line yvar xvar [if] [, by(varname)]",
291 + examples: ["graph line gdp year"]
292 + ),
293 +
294 + // ------------------------------------------------------ Panel data
295 + ZQCommandDoc(
296 + verb: "xtset", category: "Panel data",
297 + summary: "Declare the panel structure (unit and, optionally, time variable) for xtreg.",
298 + syntax: "xtset panelvar [timevar]",
299 + examples: ["xtset firm_id year"]
300 + ),
301 +
302 + // ---------------------------------------------- Session & scripting
303 + ZQCommandDoc(
304 + verb: "display", abbreviation: "di", category: "Session & scripting",
305 + summary: "Evaluate and print an expression.",
306 + syntax: "display expression",
307 + examples: ["display 2 + 2 * 3", "display ln(100)"]
308 + ),
309 + ZQCommandDoc(
310 + verb: "log", category: "Session & scripting",
311 + summary: "Record commands and output to a text file.",
312 + syntax: "log using filename | log close",
313 + examples: ["log using session.log"]
314 + ),
315 + ZQCommandDoc(
316 + verb: "help", category: "Session & scripting",
317 + summary: "Show this reference, or the entry for one command.",
318 + syntax: "help [command]",
319 + examples: ["help regress"]
320 + ),
321 +
322 + // ---------------------------------------------------- User commands
323 + ZQCommandDoc(
324 + verb: "zscore", category: "User commands",
325 + summary: "Sample native plugin: generates z_varname, the standardized version of a variable.",
326 + syntax: "zscore varname",
327 + examples: ["zscore revenue"],
328 + notes: "Native plugins are Swift types conforming to ZQCommandPlugin, compiled into the app. Script commands are .zyq files in ~/Library/Application Support/Metrika/Commands/ — an optional leading `args name…` line names positional arguments, referenced as `name' or `1' in the body."
329 + ),
330 + ]
331 +}
modified MetrikaKit/Sources/ZQEngine/Session.swift +51 −0
@@ -215,6 +215,7 @@ public actor ZQSession {
215 215 promoted.verb = "graph"
216 216 return try handleGraph(promoted)
217 217 case "log": return try handleLog(command)
218 + case "help": return handleHelp(command)
218 219 default:
219 220 throw ZQEngineError("command '\(command.verb)' is not implemented yet")
220 221 }
@@ -525,6 +526,56 @@ public actor ZQSession {
525 526 return ZQResult(text: "(log started: \(url.path))")
526 527 }
527 528
529 + /// `help [command]` — renders the shared command reference.
530 + private func handleHelp(_ command: ZQCommand) -> ZQResult {
531 + let requested = command.varlist.flatMap(\.referencedNames).first
532 + ?? command.argument
533 +
534 + if let requested {
535 + guard let doc = ZQCommandReference.doc(for: requested) else {
536 + return ZQResult(text: "help: no entry for '\(requested)' — type 'help' for the full list")
537 + }
538 + var lines = [
539 + "\(doc.verb)\(doc.abbreviation.map { " (abbreviation: \($0))" } ?? "")",
540 + String(repeating: "-", count: 60),
541 + doc.summary,
542 + "",
543 + "Syntax: \(doc.syntax)",
544 + ]
545 + if !doc.options.isEmpty {
546 + lines.append("")
547 + lines.append("Options:")
548 + for option in doc.options {
549 + lines.append(" " + TableFormatter.pad(option.name, 16, right: false) + option.meaning)
550 + }
551 + }
552 + if !doc.examples.isEmpty {
553 + lines.append("")
554 + lines.append("Examples:")
555 + for example in doc.examples { lines.append(" . \(example)") }
556 + }
557 + if let notes = doc.notes {
558 + lines.append("")
559 + lines.append(notes)
560 + }
561 + return ZQResult(text: lines.joined(separator: "\n"))
562 + }
563 +
564 + var lines = ["Metrika command reference — 'help <command>' for details", ""]
565 + for category in ZQCommandReference.categories {
566 + let docs = ZQCommandReference.all.filter { $0.category == category }
567 + guard !docs.isEmpty else { continue }
568 + lines.append(category)
569 + for doc in docs {
570 + lines.append(
571 + " " + TableFormatter.pad(doc.verb, 14, right: false) + doc.summary.prefix(60)
572 + )
573 + }
574 + lines.append("")
575 + }
576 + return ZQResult(text: lines.joined(separator: "\n"))
577 + }
578 +
528 579 private func appendToLog(command: String, output: String) throws {
529 580 guard let logFileURL else { return }
530 581 let entry = ". \(command)\n\(output)\n\n"
modified MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift +30 −0
@@ -183,6 +183,36 @@ struct EngineTests {
183 183 #expect(result.scalars["N"] == fixtures["ols_n"])
184 184 }
185 185
186 + @Test("help lists every category; help <verb> shows the entry")
187 + func help() async throws {
188 + let index = try await session.execute("help")
189 + for category in ZQCommandReference.categories {
190 + #expect(index.text.contains(category), Comment(rawValue: category))
191 + }
192 + let entry = try await session.execute("help regress")
193 + #expect(entry.text.contains("cluster(var)"))
194 + #expect(entry.text.contains("reg log_rev price, robust"))
195 + let byAbbreviation = try await session.execute("help reg")
196 + #expect(byAbbreviation.text.contains("Ordinary least squares"))
197 + let unknown = try await session.execute("help nosuchthing")
198 + #expect(unknown.text.contains("no entry"))
199 + }
200 +
201 + @Test("every dispatched estimation and data verb has a manual entry")
202 + func manualCoverage() {
203 + let documented = Set(ZQCommandReference.all.map(\.verb))
204 + for verb in [
205 + "use", "save", "clear", "describe", "list", "count", "generate",
206 + "replace", "drop", "keep", "summarize", "tabulate", "correlate",
207 + "regress", "logit", "probit", "poisson", "ivregress", "xtreg",
208 + "xtset", "lasso", "elasticnet", "bootstrap", "permute", "predict",
209 + "margins", "scatter", "histogram", "kdensity", "graph", "display",
210 + "set", "log", "help", "zscore",
211 + ] {
212 + #expect(documented.contains(verb), Comment(rawValue: "missing manual entry: \(verb)"))
213 + }
214 + }
215 +
186 216 @Test("display evaluates scalar expressions")
187 217 func display() async throws {
188 218 let result = try await session.execute("display 2 + 2 * 3")
modified Tests/UITests/ConsoleSmokeTests.swift +16 −0
@@ -124,6 +124,22 @@ final class ConsoleSmokeTests: XCTestCase {
124 124 staticText(containing: "high", in: app).waitForExistence(timeout: 5),
125 125 "sidebar missing variable generated by do-file"
126 126 )
127 +
128 + // Manual pane: overview page and a command entry render.
129 + switchPane(to: "Manual", in: app)
130 + XCTAssertTrue(
131 + staticText(containing: "How Metrika works", in: app)
132 + .waitForExistence(timeout: 5),
133 + "manual overview missing"
134 + )
135 + let regressEntry = app.staticTexts["regress"].firstMatch
136 + XCTAssertTrue(regressEntry.waitForExistence(timeout: 5), "regress entry missing")
137 + regressEntry.click()
138 + XCTAssertTrue(
139 + staticText(containing: "Ordinary least squares", in: app)
140 + .waitForExistence(timeout: 5),
141 + "regress manual page missing"
142 + )
127 143 }
128 144
129 145 /// The pane switcher is a segmented picker in the toolbar; SwiftUI
modified scripts/ExportOptions.plist +3 −2
@@ -3,7 +3,8 @@
3 3 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 4 <plist version="1.0"><dict>
5 5 <key>method</key><string>developer-id</string>
6 <key>teamID</key><string><TEAM_ID></string>
7 <key>signingStyle</key><string>automatic</string>
6 + <key>teamID</key><string>3YM54G49SN</string>
7 + <key>signingStyle</key><string>manual</string>
8 + <key>signingCertificate</key><string>Developer ID Application</string>
8 9 <key>destination</key><string>export</string>
9 10 </dict></plist>
modified scripts/release.sh +27 −11
@@ -1,17 +1,15 @@
1 1 #!/bin/bash
2 2 #
3 3 # release.sh — Metrika
4 #
5 4 # Author: Simon-Pierre Boucher
6 5 # Contact: contact@spboucher.ai
7 6 # Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 7 #
9 8 # 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.
9 +# sign → notarize → staple. The team ID and keychain-profile NAME are
10 +# configuration, not credentials — the actual Apple ID and app-specific
11 +# password live only in the login keychain (stored once via
12 +# `xcrun notarytool store-credentials "MacLustr-Notarize"`).
15 13 #
16 14 set -euo pipefail
17 15
@@ -19,10 +17,27 @@ APP="Metrika"
19 17 SCHEME="Metrika"
20 18 BUILD_DIR="build"
21 19 DMG="${APP}.dmg"
20 +TEAM_ID="3YM54G49SN"
21 +NOTARY_PROFILE="MacLustr-Notarize"
22 +SIGN_IDENTITY="Developer ID Application: Simon-Pierre Boucher (${TEAM_ID})"
23 +
24 +cd "$(dirname "$0")/.."
25 +rm -rf "$BUILD_DIR" "$DMG"
22 26
23 # 1. Archive
24 xcodebuild -scheme "$SCHEME" -configuration Release -arch arm64 \
25 -archivePath "$BUILD_DIR/$APP.xcarchive" archive
27 +# 0. Regenerate the Xcode project and the icon from their sources.
28 +xcodegen generate
29 +./scripts/make_icns.sh
30 +
31 +# 1. Archive (Developer ID signing; mlx-swift needs the plugin skip).
32 +xcodebuild -project "$APP.xcodeproj" -scheme "$SCHEME" \
33 + -configuration Release -arch arm64 \
34 + -archivePath "$BUILD_DIR/$APP.xcarchive" \
35 + -skipPackagePluginValidation \
36 + DEVELOPMENT_TEAM="$TEAM_ID" \
37 + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \
38 + CODE_SIGN_STYLE=Manual \
39 + OTHER_CODE_SIGN_FLAGS="--timestamp --options runtime" \
40 + archive
26 41
27 42 # 2. Export with Developer ID
28 43 xcodebuild -exportArchive \
@@ -42,14 +57,15 @@ create-dmg \
42 57 --icon "$APP.app" 130 190 \
43 58 --app-drop-link 400 190 \
44 59 --background "Assets/DMG/background.png" \
60 + --no-internet-enable \
45 61 "$DMG" "$BUILD_DIR/export/"
46 62
47 63 # 5. Sign the DMG itself
48 codesign --sign "Developer ID Application: Simon-Pierre Boucher (<TEAM_ID>)" "$DMG"
64 +codesign --sign "$SIGN_IDENTITY" --timestamp "$DMG"
49 65
50 66 # 6. Notarize & wait
51 67 xcrun notarytool submit "$DMG" \
52 --keychain-profile "<KEYCHAIN_PROFILE_NAME>" \
68 + --keychain-profile "$NOTARY_PROFILE" \
53 69 --wait
54 70
55 71 # 7. Staple
56 72