SPB Git

spb/metrika

Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

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 (Aug 5, 2026) parent d6c1004 Browse files

10 changed files +698 −13

modified Metrika.xcodeproj/project.pbxproj +4 −0
@@ -14,6 +14,7 @@
14 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; }; 14 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; };
15 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; }; 15 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; };
16 CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; }; 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 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; }; 18 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; };
18 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */; }; 19 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */; };
19 /* End PBXBuildFile section */ 20 /* End PBXBuildFile section */
@@ -39,6 +40,7 @@
39 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; };
40 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; }; 41 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; };
41 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; }; 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 AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; }; 44 AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; };
43 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoFileEditorView.swift; sourceTree = "<group>"; }; 45 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoFileEditorView.swift; sourceTree = "<group>"; };
44 /* End PBXFileReference section */ 46 /* End PBXFileReference section */
@@ -78,6 +80,7 @@
78 159695CF89D6BB25DF883F07 /* ConsoleView.swift */, 80 159695CF89D6BB25DF883F07 /* ConsoleView.swift */,
79 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */, 81 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */,
80 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */, 82 C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */,
83+ A7C64C028218A21FE8E5D9D1 /* ManualView.swift */,
81 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */, 84 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */,
82 4AD551AA707CD1853C3D6D21 /* PlotView.swift */, 85 4AD551AA707CD1853C3D6D21 /* PlotView.swift */,
83 0453FB0F256E853D2C2F481C /* SessionModel.swift */, 86 0453FB0F256E853D2C2F481C /* SessionModel.swift */,
@@ -230,6 +233,7 @@
230 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */, 233 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */,
231 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */, 234 F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */,
232 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */, 235 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */,
236+ D01BD59F090074D4C3FC392D /* ManualView.swift in Sources */,
233 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */, 237 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */,
234 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */, 238 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */,
235 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */, 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 case console = "Console" 28 case console = "Console"
29 case data = "Data" 29 case data = "Data"
30 case doFile = "Do-file" 30 case doFile = "Do-file"
31+ case manual = "Manual"
31 } 32 }
32 33
33 @Environment(SessionModel.self) private var model 34 @Environment(SessionModel.self) private var model
@@ -52,6 +53,8 @@ struct ContentView: View {
52 DataBrowserPane() 53 DataBrowserPane()
53 case .doFile: 54 case .doFile:
54 DoFileEditorPane() 55 DoFileEditorPane()
56+ case .manual:
57+ ManualPane()
55 } 58 }
56 } 59 }
57 .toolbar { 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 promoted.verb = "graph" 215 promoted.verb = "graph"
216 return try handleGraph(promoted) 216 return try handleGraph(promoted)
217 case "log": return try handleLog(command) 217 case "log": return try handleLog(command)
218+ case "help": return handleHelp(command)
218 default: 219 default:
219 throw ZQEngineError("command '\(command.verb)' is not implemented yet") 220 throw ZQEngineError("command '\(command.verb)' is not implemented yet")
220 } 221 }
@@ -525,6 +526,56 @@ public actor ZQSession {
525 return ZQResult(text: "(log started: \(url.path))") 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 private func appendToLog(command: String, output: String) throws { 579 private func appendToLog(command: String, output: String) throws {
529 guard let logFileURL else { return } 580 guard let logFileURL else { return }
530 let entry = ". \(command)\n\(output)\n\n" 581 let entry = ". \(command)\n\(output)\n\n"
modified MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift +30 −0
@@ -183,6 +183,36 @@ struct EngineTests {
183 #expect(result.scalars["N"] == fixtures["ols_n"]) 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 @Test("display evaluates scalar expressions") 216 @Test("display evaluates scalar expressions")
187 func display() async throws { 217 func display() async throws {
188 let result = try await session.execute("display 2 + 2 * 3") 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 staticText(containing: "high", in: app).waitForExistence(timeout: 5), 124 staticText(containing: "high", in: app).waitForExistence(timeout: 5),
125 "sidebar missing variable generated by do-file" 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 /// The pane switcher is a segmented picker in the toolbar; SwiftUI 145 /// The pane switcher is a segmented picker in the toolbar; SwiftUI
modified scripts/ExportOptions.plist +3 −2
@@ -3,7 +3,8 @@
3 "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 <plist version="1.0"><dict> 4 <plist version="1.0"><dict>
5 <key>method</key><string>developer-id</string> 5 <key>method</key><string>developer-id</string>
6− <key>teamID</key><string><TEAM_ID></string> 6+ <key>teamID</key><string>3YM54G49SN</string>
7− <key>signingStyle</key><string>automatic</string> 7+ <key>signingStyle</key><string>manual</string>
8+ <key>signingCertificate</key><string>Developer ID Application</string>
8 <key>destination</key><string>export</string> 9 <key>destination</key><string>export</string>
9 </dict></plist> 10 </dict></plist>
modified scripts/release.sh +27 −11
@@ -1,17 +1,15 @@
1 #!/bin/bash 1 #!/bin/bash
2 # 2 #
3 # release.sh — Metrika 3 # release.sh — Metrika
4−#
5 # Author: Simon-Pierre Boucher 4 # Author: Simon-Pierre Boucher
6 # Contact: contact@spboucher.ai 5 # Contact: contact@spboucher.ai
7 # Copyright © 2026 Simon-Pierre Boucher. All rights reserved. 6 # Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 # 7 #
9 # Release pipeline (CLAUDE.md §10): archive → export → verify → DMG → 8 # Release pipeline (CLAUDE.md §10): archive → export → verify → DMG →
10−# sign → notarize → staple. 9+# sign → notarize → staple. The team ID and keychain-profile NAME are
11−# 10+# configuration, not credentials — the actual Apple ID and app-specific
12−# ⚠️ PLACEHOLDERS: fill <TEAM_ID> and <KEYCHAIN_PROFILE_NAME> from the 11+# password live only in the login keychain (stored once via
13−# local credentials folder before the first release build. Never commit 12+# `xcrun notarytool store-credentials "MacLustr-Notarize"`).
14−# real credentials.
15 # 13 #
16 set -euo pipefail 14 set -euo pipefail
17 15
@@ -19,10 +17,27 @@ APP="Metrika"
19 SCHEME="Metrika" 17 SCHEME="Metrika"
20 BUILD_DIR="build" 18 BUILD_DIR="build"
21 DMG="${APP}.dmg" 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 27+# 0. Regenerate the Xcode project and the icon from their sources.
24−xcodebuild -scheme "$SCHEME" -configuration Release -arch arm64 \ 28+xcodegen generate
25− -archivePath "$BUILD_DIR/$APP.xcarchive" archive 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 # 2. Export with Developer ID 42 # 2. Export with Developer ID
28 xcodebuild -exportArchive \ 43 xcodebuild -exportArchive \
@@ -42,14 +57,15 @@ create-dmg \
42 --icon "$APP.app" 130 190 \ 57 --icon "$APP.app" 130 190 \
43 --app-drop-link 400 190 \ 58 --app-drop-link 400 190 \
44 --background "Assets/DMG/background.png" \ 59 --background "Assets/DMG/background.png" \
60+ --no-internet-enable \
45 "$DMG" "$BUILD_DIR/export/" 61 "$DMG" "$BUILD_DIR/export/"
46 62
47 # 5. Sign the DMG itself 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 # 6. Notarize & wait 66 # 6. Notarize & wait
51 xcrun notarytool submit "$DMG" \ 67 xcrun notarytool submit "$DMG" \
52− --keychain-profile "<KEYCHAIN_PROFILE_NAME>" \ 68+ --keychain-profile "$NOTARY_PROFILE" \
53 --wait 69 --wait
54 70
55 # 7. Staple 71 # 7. Staple
56 72
Menu

Theme