ostia 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,119 +1,24 @@
1
1
  export declare function defineConfig(config: OstiaConfigInput): OstiaConfigInput;
2
2
 
3
- export interface CommandSpec {
4
- command: string | string[];
5
- label?: string;
6
- /** Overrides `TimeOptions.prepare` for this command. */
7
- prepare?: PrepareHook;
8
- /** Overrides `TimeOptions.timeSource` for this command. */
9
- timeSource?: TimeSource;
10
- /** Overrides `TimeOptions.timeoutMs` for this command. */
11
- timeoutMs?: number;
12
- /** Overrides `TimeOptions.ignoreExitCodes` for this command. */
13
- ignoreExitCodes?: number[];
14
- }
15
-
16
- export interface TimeOptions {
17
- commands: (string | string[] | CommandSpec)[];
18
- /** Runs before every trial of every command (warmup and instrumented
19
- * trials included), unmeasured, in `cwd`/`env`: a command string / argv
20
- * array spawned and awaited, or a function. hyperfine's `--prepare`. A
21
- * `CommandSpec.prepare` overrides it per command. */
22
- prepare?: PrepareHook;
23
- /** Take every command's timing from a number in its own output instead of
24
- * its wall clock (e.g. a build tool's `built in 342ms` line, which
25
- * excludes runtime startup). A `CommandSpec.timeSource` overrides it per
26
- * command. The parsed value becomes `timing.samples`; each trial keeps
27
- * `wallNs` too. */
28
- timeSource?: TimeSource;
29
- /** Exact trial count. When set, `budgetMs` is ignored. */
30
- samples?: number;
31
- /** Wall-clock time budget for the sampling loop, ms (default: a
32
- * hyperfine-style ~3s min-total-time loop when neither `samples` nor
33
- * `budgetMs` is given). */
34
- budgetMs?: number;
35
- /** Hard floor on trials when no exact `samples` count is given. */
36
- minSamples?: number;
37
- warmup?: number;
38
- /** Round-robin trials across commands (one trial per command, repeated)
39
- * instead of running each command's whole trial loop to completion before
40
- * the next command starts. Default: true when 2+ commands are given (a
41
- * single command has nothing to interleave against). Spreads any drift
42
- * over the run's wall-clock span (thermal throttling, a noisy neighbor
43
- * process) evenly across every command instead of favoring whichever ran
44
- * first or last. */
45
- interleave?: boolean;
46
- cwd?: string;
47
- env?: Record<string, string>;
48
- cpu?: boolean;
49
- heap?: boolean;
50
- cpuIntervalUs?: number;
51
- outDir?: string;
52
- /** Measure this machine's noise floor before the first command (default:
53
- * true) and stamp it on the document as `environment`. Set false to skip
54
- * the ~200ms reference measurement. */
55
- noiseCheck?: boolean;
56
- /** Kills a trial (or prepare hook) that hasn't finished after this many
57
- * ms, with SIGKILL. No default: an unset `timeoutMs` never times out. A
58
- * timed-out trial contributes no sample; if every trial of a command times
59
- * out, that command has no timing stats. `CommandSpec.timeoutMs` overrides
60
- * it per command. */
61
- timeoutMs?: number;
62
- /** Aborting cancels the run: in-flight trials are killed with SIGKILL, no
63
- * further trials are scheduled, and `time()` resolves (never rejects)
64
- * with the document built from whatever measurements had already
65
- * completed, plus an `aborted` warning on the document's last
66
- * measurement. */
67
- signal?: AbortSignal;
68
- /** Exit codes to treat as success (hyperfine's `--ignore-failure`): a
69
- * trial exiting with one of these still contributes its sample and gets
70
- * no `nonzero-exit` warning, as if it had exited 0. `CommandSpec.ignoreExitCodes`
71
- * overrides it per command. */
72
- ignoreExitCodes?: number[];
73
- }
74
-
75
- export declare function time(opts: TimeOptions): Promise<ProfileDocument>;
76
-
77
- interface ProfileOptions {
78
- intervalUs?: number;
79
- origin?: "inspector" | "jsc";
80
- /** `profile()` runs `fn` in this process, so there's no child to kill: an
81
- * already-aborted signal skips the profiler instrumentation entirely and
82
- * just calls `fn` plain (still returning its `result`), with an `aborted`
83
- * warning in place of CPU evidence. A signal that fires mid-capture can't
84
- * interrupt `fn` once it's running. */
85
- signal?: AbortSignal;
86
- }
87
-
88
- interface ProfileResult<T> {
89
- result: T;
90
- measurement: Measurement;
91
- document: ProfileDocument;
92
- }
93
-
94
- export declare function profile<T>(fn: () => T | Promise<T>, opts?: ProfileOptions): Promise<ProfileResult<T>>;
95
-
96
3
  export interface WorkloadConfig {
97
4
  label?: string;
98
5
  command?: string[];
99
6
  suites?: string[];
100
- /** `command` only. Globs of the files this command's timing depends on:
101
- * `ostia ci` reuses a cached run while their contents are unchanged.
102
- * Omitted, the workload always reruns; `[]` declares it depends on
103
- * nothing and caches until the command or config changes. */
7
+ /** `command` only. Globs of the files the timing depends on: `ostia ci`
8
+ * reuses a cached run while their contents are unchanged. Omitted, the
9
+ * workload always reruns; `[]` caches until the command or config changes. */
104
10
  inputs?: string[];
105
11
  /** `command` only. Runs before every trial (warmup included), unmeasured:
106
- * a command string / argv array in both `.ts` and JSON config, or a
107
- * function in `ostia.config.ts`. A function-form hook makes the workload
108
- * uncacheable for `ostia ci` (its effect can't be fingerprinted), so it
109
- * always executes. */
12
+ * a command string / argv array, or in `ostia.config.ts` a function. A
13
+ * function makes the workload uncacheable for `ostia ci`. */
110
14
  prepare?: PrepareHook;
111
15
  /** `command` only. Take timing from a number in the command's own output
112
16
  * instead of its wall clock; see `TimeSource`. */
113
17
  timeSource?: TimeSource;
114
- /** `command` only. Kills a trial (or prepare hook) that hasn't finished
115
- * after this many ms. Overrides `ostia ci`'s 10-minute default per
116
- * workload. */
18
+ /** `command`: kills a trial (or prepare hook) after this many ms.
19
+ * `suites`: kills a suite file's (or isolated task's) process after this
20
+ * many ms, overriding `bench.timeoutMs`. Overrides `ostia ci`'s 10-minute
21
+ * default. */
117
22
  timeoutMs?: number;
118
23
  /** `command` only. Exit codes to treat as success; see
119
24
  * `TimeOptions.ignoreExitCodes`. */
@@ -121,10 +26,9 @@ export interface WorkloadConfig {
121
26
  }
122
27
 
123
28
  interface BenchConfig {
124
- /** Suite file globs, resolved with Bun.Glob against the config's directory
125
- * (e.g. "bench/**\/*.bench.ts"). Ignored when suite files are also given
126
- * on the command line - CLI args replace this list rather than merging
127
- * with it. */
29
+ /** Suite file globs, resolved against the config's directory (e.g.
30
+ * "bench/**\/*.bench.ts"). Suite files given on the command line replace
31
+ * this list. */
128
32
  suites?: string[];
129
33
  preload?: string[];
130
34
  /** Extra flags for the `bun` process that runs each suite file; see
@@ -142,18 +46,37 @@ interface BenchConfig {
142
46
  filter?: string;
143
47
  isolate?: boolean;
144
48
  outDir?: string;
145
- /** Kills a suite file's (or isolated task's) subprocess if it hasn't
146
- * finished after this many ms. Overrides `ostia ci`'s 10-minute default. */
49
+ /** Kills a suite file's (or isolated task's) subprocess after this many
50
+ * ms; overrides `ostia ci`'s 10-minute default. */
147
51
  timeoutMs?: number;
148
52
  }
149
53
 
54
+ export interface AbConfig {
55
+ /** Shell command(s) run once in a freshly extracted base tree, e.g. to
56
+ * build gitignored files the suites import; see `AbOptions.baseSetup`.
57
+ * `--base-setup` replaces this list. */
58
+ setup?: string | string[];
59
+ /** Base trees to keep cached; see `AbOptions.keepTrees`. `--keep-trees`
60
+ * overrides. Default: 5. */
61
+ keepTrees?: number;
62
+ /** Compare retained heap per call; see `AbOptions.alloc`. `--alloc` /
63
+ * `--no-alloc` override. `bench.alloc` doesn't apply to `ab`. */
64
+ alloc?: boolean;
65
+ /** Compare peak RSS of the first call; see `AbOptions.peakMem`.
66
+ * `--peak-mem` / `--no-peak-mem` override. `bench.peakMem` doesn't apply
67
+ * to `ab`. */
68
+ peakMem?: boolean;
69
+ /** Memory verdict threshold, percent; see `AbOptions.memThresholdPct`.
70
+ * `--mem-threshold` overrides. Default: 10. */
71
+ memThresholdPct?: number;
72
+ }
73
+
150
74
  export interface OstiaConfig {
151
- /** `command` workloads: exact trial count, same as `time()`'s `samples`.
152
- * Unset, the `budgetMs`/`minSamples` loop decides. */
75
+ /** `command` workloads: exact trial count; see `time()`. */
153
76
  samples?: number;
154
- /** `command` workloads: wall-clock sampling budget, ms; see `time()`. */
77
+ /** `command` workloads: sampling budget in ms; see `time()`. */
155
78
  budgetMs?: number;
156
- /** `command` workloads: hard floor on trials; see `time()`. */
79
+ /** `command` workloads: floor on trials; see `time()`. */
157
80
  minSamples?: number;
158
81
  warmup: number;
159
82
  outDir: string;
@@ -162,20 +85,15 @@ export interface OstiaConfig {
162
85
  thresholds: Thresholds;
163
86
  workloads: WorkloadConfig[];
164
87
  bench?: BenchConfig;
165
- /** `ostia ci`'s policy when a configured workload has no matching row in
166
- * the baseline (by workload id): `"fail"` exits 2 naming the baseline
167
- * file, `"warn"` lists it in the report without affecting the exit code.
168
- * Unset (the default): `"fail"` when *every* configured workload is
169
- * missing, `"warn"` otherwise - a totally stale/wrong baseline is a hard
170
- * error, a handful of new workloads next to an otherwise-matching
171
- * baseline is not. */
88
+ ab?: AbConfig;
89
+ /** `ostia ci`'s policy for a workload with no matching baseline row (by
90
+ * workload id): `"fail"` exits 2 naming the baseline file, `"warn"` lists
91
+ * it in the report. Unset: `"fail"` when every workload is missing (a
92
+ * stale or wrong baseline), `"warn"` otherwise. */
172
93
  onMissingBaseline?: "warn" | "fail";
173
- /** Measures this machine's noise floor once per `ostia ci` invocation
174
- * (default true) and stamps it on the candidate document as
175
- * `environment`, the same reference measurement `time()`/`bench()` run -
176
- * so `compare`'s noise-floor threshold widening applies to `ci` too, not
177
- * only to ad hoc `time`/`bench` runs. `--no-noise-check` overrides this
178
- * to false per invocation. */
94
+ /** Measure the machine's noise floor once per `ostia ci` run (default
95
+ * true) and stamp it on the candidate document, so `compare` widens its
96
+ * thresholds accordingly. `--no-noise-check` overrides this per run. */
179
97
  noiseCheck?: boolean;
180
98
  }
181
99
 
@@ -188,11 +106,9 @@ export interface Thresholds {
188
106
  frameSelfPct: number;
189
107
  heapTypePct: number;
190
108
  minFrameSelfUs: number;
191
- /** Significance level for the Mann-Whitney p-value: a `regressed` /
192
- * `improved` verdict also requires `pValue < alpha`. */
109
+ /** A `regressed` / `improved` verdict also requires Mann-Whitney `pValue < alpha`. */
193
110
  alpha: number;
194
- /** Bootstrap resample rounds for the timing CI. Capped work regardless:
195
- * see `bootstrapMedianDiffCi`. */
111
+ /** Bootstrap resample rounds for the timing CI. */
196
112
  bootstrapIterations: number;
197
113
  }
198
114
 
@@ -204,6 +120,8 @@ export interface CompareResult {
204
120
  baseOnly: Workload[];
205
121
  candOnly: Workload[];
206
122
  };
123
+ /** Every task on either side; see `ProfileDocument.compared`. */
124
+ compared: ComparedTask[];
207
125
  summary: ComparisonSummary;
208
126
  }
209
127
 
@@ -217,36 +135,31 @@ export interface ProfileDocument {
217
135
  os: string;
218
136
  arch: string;
219
137
  };
138
+ /** ISO timestamp; metadata only, never part of an id. */
220
139
  createdAt: string;
221
140
  workloads: Workload[];
222
141
  measurements: Measurement[];
223
142
  comparisons?: Comparison[];
224
- /** Aggregate across `comparisons`, from `compareDocuments`/`ostia compare`/
225
- * `ostia ci`. Additive, no schema bump. Absent wherever `comparisons` is. */
143
+ /** Aggregate across `comparisons`; present wherever `comparisons` is. */
226
144
  comparisonSummary?: ComparisonSummary;
227
- /** Workload ids present on only one side of a `compareDocuments` call -
228
- * candidates matched by id gone missing from the baseline, or vice versa.
229
- * Additive, no schema bump; absent wherever `comparisons` is. Ids only
230
- * (not full `Workload`s) to keep the document small; `ostia compare`'s
231
- * printed report resolves labels from the two source documents directly. */
145
+ /** Workload ids on only one side of the comparison; present wherever
146
+ * `comparisons` is. */
232
147
  unmatched?: {
233
148
  baseOnly: string[];
234
149
  candOnly: string[];
235
150
  };
236
- /** Machine conditions when this document was measured. Additive, no
237
- * schema bump. Absent when `noiseCheck: false` (or `--no-noise-check`)
238
- * skipped the reference measurement. */
151
+ /** Every task on either side of the comparison, one entry each: what
152
+ * `--format markdown` prints, flat for scripts. Present wherever
153
+ * `comparisons` is. */
154
+ compared?: ComparedTask[];
155
+ /** Machine conditions at measurement time; absent when the noise check was
156
+ * skipped. */
239
157
  environment?: Environment;
240
- /** Run-level result of a paired A/B run (`ostia ab` / `ab()`): what the
241
- * candidate was paired against and the gate's verdict. Additive, no schema
242
- * bump. Present only on documents from `ab()`, whose measurements are
158
+ /** Run-level result of `ostia ab` / `ab()`, whose measurements are
243
159
  * `phase: "paired"`. */
244
160
  ab?: AbSummary;
245
- /** Repo state when this document was measured, from `git rev-parse` /
246
- * `git status --porcelain` in the process's cwd. Additive, no schema
247
- * bump. Absent outside a git repo (or when `git` itself isn't
248
- * available). Metadata only: never part of any fingerprint or id, so a
249
- * commit or a dirty working tree never orphans a cached run or baseline. */
161
+ /** Repo state of the process's cwd; absent outside a git repo. Never part of
162
+ * an id or fingerprint, so a commit or dirty tree doesn't orphan a baseline. */
250
163
  git?: GitMetadata;
251
164
  }
252
165
 
@@ -257,12 +170,15 @@ interface GitMetadata {
257
170
  }
258
171
 
259
172
  interface NoiseFloor {
260
- /** `mad / median` of the reference workload's trial times, as a percent -
261
- * how noisy this machine is right now, independent of what's being
262
- * measured. */
173
+ /** `mad / median` of a reference workload, percent: how noisy the machine is
174
+ * right now, independent of what's measured. */
263
175
  floorPct: number;
264
176
  referenceMedianNs: number;
265
177
  samples: number;
178
+ /** How much slower (positive) the reference ran at the end of the run than
179
+ * at the start, percent: the machine's drift over the run. Absent when the
180
+ * run didn't re-measure it (`profile()`, older documents). */
181
+ driftPct?: number;
266
182
  }
267
183
 
268
184
  interface Environment {
@@ -274,69 +190,51 @@ interface Environment {
274
190
  }
275
191
 
276
192
  export interface Workload {
277
- /** Identifies what is measured, not where or when it ran: for a
278
- * `subprocess` workload, a hash of the command argv, `prepare`
279
- * (command form or function source), and `timeSource` spec -
280
- * deliberately excluding `process.cwd()`, so a baseline saved from one
281
- * checkout (a CI runner, a different worktree) still matches a candidate
282
- * measured from another. For an `inprocess` workload, a hash of the
283
- * function source (or, for a registry entry, the file/task name plus
284
- * `params`). `label`, `description`, and `baseline` are annotations and
285
- * never affect it. */
193
+ /** Identifies what is measured, not where or when. Subprocess: a hash of
194
+ * argv, `prepare` and `timeSource`, excluding `process.cwd()`. In-process: a
195
+ * hash of the function source, or for a registry entry the file, task name
196
+ * and `params`. Annotations (`label`, `description`, `baseline`) never
197
+ * affect it. */
286
198
  id: string;
287
199
  kind: "subprocess" | "inprocess";
288
200
  label?: string;
289
201
  command?: string[];
290
- /** Command form of the `prepare` hook that ran before every trial of this
291
- * command (`ostia time --prepare`, `time({ prepare })`, config `prepare`).
292
- * Part of the workload id: the same command with and without a prepare
293
- * step measures different things. A function-form hook isn't
294
- * serializable and is omitted here (its source text is still hashed into
295
- * the id). */
202
+ /** Command-form `prepare` hook run before every trial; part of the id. A
203
+ * function-form hook isn't serializable and is omitted (its source is still
204
+ * hashed into the id). */
296
205
  prepare?: string[];
297
- /** Where this command's timing samples came from when not the subprocess
298
- * wall clock: a regex over the command's own output and the unit of the
299
- * number it captures. Part of the workload id, so the same command timed
300
- * by wall clock and by its own report are two workloads. */
206
+ /** Takes samples from a number in the command's own output instead of the
207
+ * wall clock; part of the id. */
301
208
  timeSource?: {
302
209
  pattern: string;
303
210
  group?: number;
304
211
  unit?: "ns" | "us" | "ms" | "s";
305
212
  };
306
- /** `task` is the "group/name" id the bench registry assigns; `group` is the
307
- * enclosing `group()` name when there is one. Renderers prefer `group` over
308
- * splitting `task` on "/", so task names may contain slashes. */
213
+ /** `task` is the registry's "group/name" id; `group` is the enclosing
214
+ * `group()` name. Renderers prefer `group` over splitting `task` on "/". */
309
215
  entry?: {
310
216
  file: string;
311
217
  task: string;
312
218
  group?: string;
313
219
  };
314
- /** Marks this task as the in-run Relative reference for its group (see
315
- * `task(name, fn, { baseline: true })`). At most one per group is
316
- * meaningful; renderers use the first they encounter. */
220
+ /** The Relative reference for its group; renderers use the first one found. */
317
221
  baseline?: boolean;
318
- /** What this task measures and why, from `task(name, fn, { description })`.
319
- * Travels with the data so a reader of the document has intent, not just
320
- * numbers. */
222
+ /** What this task measures and why, from `task(..., { description })`. */
321
223
  description?: string;
322
- /** The enclosing group's `group(name, fn, { description })`. Repeated on every
323
- * workload in the group so each record is self-contained. */
224
+ /** The enclosing group's description, repeated on every workload in it. */
324
225
  groupDescription?: string;
325
- /** Whether this task ran in a subprocess dedicated to it alone (`isolate`
326
- * on the task, its group, or the suite), vs. sharing its suite file's
327
- * subprocess with other tasks. */
226
+ /** Ran in a subprocess of its own rather than sharing its suite file's. */
328
227
  isolated?: boolean;
329
- /** Structured parameters this task point represents (e.g. `{ size: 800,
330
- * impl: "fast" }`), from `task(name, fn, { params })` or a `sweep()` point.
331
- * Lets renderers pivot and `compare` match on them instead of only on the
332
- * task name. Part of the workload id when present, so two points that
333
- * share a task name (a `sweep()`'s whole point) don't collide. */
228
+ /** From `task(..., { params })` or a `sweep()` point. Part of the id, so
229
+ * points sharing a task name don't collide. */
334
230
  params?: Record<string, string | number | boolean>;
335
- /** From `task.skip()` or a `group.skip()` this task was inside. The runner
336
- * never measures it, so this workload has no matching `Measurement`; a
337
- * renderer prints it as a "- skipped" row instead of omitting it, and
338
- * `compare` treats it as `unchanged` (with a `skipped` warning) rather
339
- * than silently passing or failing to match it. */
231
+ /** Ids this task had under its former names (`task(..., { aliases })`), so
232
+ * `compare` and `ab` match a base measured before a rename. Never part of
233
+ * the id. */
234
+ formerIds?: string[];
235
+ /** From `task.skip()` / `group.skip()`: never measured, so no `Measurement`.
236
+ * Renderers print a "- skipped" row; `compare` treats it as `unchanged` with
237
+ * a `skipped` warning. */
340
238
  skipped?: boolean;
341
239
  }
342
240
 
@@ -355,38 +253,39 @@ interface Measurement {
355
253
  heap?: HeapEvidence;
356
254
  memory?: MemoryEvidence;
357
255
  jit?: JitTierBreakdown;
358
- /** `phase: "paired"` only: the base side and the per-round ratios. The
359
- * measurement's `timing` is the candidate side. */
256
+ /** `phase: "paired"` only; `timing` is then the candidate side. */
360
257
  paired?: PairedEvidence;
258
+ /** `phase: "paired"` only: the task threw on one side or both
259
+ * (`"both"`). Usually instead of `timing`/`paired`; when it threw in a
260
+ * confirmation repeat, `repeat` (from 1) says which, and `timing`/`paired`
261
+ * stay as the first process measured them. Either way the task isn't in
262
+ * `matched` or the geomean. */
263
+ threw?: {
264
+ side: "base" | "cand" | "both";
265
+ message: string;
266
+ repeat?: number;
267
+ };
361
268
  warnings: Warning[];
362
269
  artifacts: ArtifactRef[];
363
- /** True when this timing measurement's trials were run round-robin against
364
- * the other commands in the same `time()` call (`--interleave`, default on
365
- * for 2+ commands) rather than run to completion before the next command
366
- * started, so drift over the run's wall-clock span (thermal throttling, a
367
- * noisy neighbor process) lands on every command equally instead of
368
- * favoring whichever ran first or last. */
270
+ /** Trials ran round-robin with the other commands of the same `time()` call,
271
+ * so drift over the run hits every command equally. */
369
272
  interleaved?: boolean;
370
273
  }
371
274
 
372
275
  interface Trial {
373
276
  i: number;
374
277
  wallNs: number;
375
- /** The command's self-reported time (ns) when the workload has a
376
- * `timeSource`; `timing.samples` are these, not `wallNs`, in that case.
377
- * `wallNs` stays alongside so a document keeps both. */
278
+ /** The command's self-reported time (ns) under a `timeSource`; these, not
279
+ * `wallNs`, are then the `timing.samples`. */
378
280
  reportedNs?: number;
379
281
  exitCode?: number;
380
282
  userNs?: number;
381
283
  systemNs?: number;
382
284
  maxRssBytes?: number;
383
- /** Set when the trial was killed by `timeoutMs` before it exited on its
384
- * own. `exitCode` is absent in that case: the kill signal, not the
385
- * command, decided how the process ended. Contributes no sample. */
285
+ /** Killed by `timeoutMs`; `exitCode` is absent. Contributes no sample. */
386
286
  timedOut?: true;
387
- /** Set when the workload has a `timeSource` and this trial's output didn't
388
- * match its pattern. `reportedNs` is absent in that case (never a
389
- * fallback to `wallNs`). Contributes no sample. */
287
+ /** Output didn't match the `timeSource` pattern; `reportedNs` is absent
288
+ * (never a `wallNs` fallback). Contributes no sample. */
390
289
  timeSourceNoMatch?: true;
391
290
  }
392
291
 
@@ -408,15 +307,11 @@ interface TimingStats {
408
307
  p75: number;
409
308
  /** 99th percentile, ns. */
410
309
  p99: number;
411
- /** Median absolute deviation, ns: the median of `|sample - median|` across
412
- * all samples. A robust spread measure that (unlike stddev) isn't skewed
413
- * by the long right tail typical of wall-clock timings. */
310
+ /** Median absolute deviation, ns: a spread measure that, unlike stddev, the
311
+ * long right tail of wall-clock timings doesn't skew. */
414
312
  mad: number;
415
- /** In-process trials batched into one timed block (see
416
- * `measure/inprocess.ts`'s `sizeBatch`), set only when batching occurred.
417
- * Absent for every subprocess timing measurement, and for an in-process
418
- * one whose single call already cleared the batching threshold - a
419
- * renderer treats an absent value the same as `1`. */
313
+ /** Calls batched into one timed block; absent (read as 1) unless batching
314
+ * occurred, and always absent for subprocess timing. */
420
315
  batch?: number;
421
316
  }
422
317
 
@@ -466,26 +361,22 @@ interface HeapEvidence {
466
361
 
467
362
  interface MemoryEvidence {
468
363
  origin: "resourceUsage" | "heapStats";
469
- /** What a `phase: "memstats"` measurement measured: `"retained"` for
470
- * `--alloc` (`bytesPerOp`), `"peak"` for `--peak-mem` (`peakBytes`).
471
- * Absent on subprocess timing's `maxRssBytes`, and on `memstats`
472
- * measurements written before this field existed, which are always
473
- * `"retained"`. */
364
+ /** `memstats` only: `"retained"` (`--alloc`, `bytesPerOp`) or `"peak"`
365
+ * (`--peak-mem`, `peakBytes`). Absent means `"retained"` on older documents. */
474
366
  kind?: "retained" | "peak";
475
367
  /** Largest `Trial.maxRssBytes` across a timing measurement's trials. */
476
368
  maxRssBytes?: number;
477
- /** Retained heap growth per call, from `ostia bench --alloc`: heap size
478
- * delta (`bun:jsc`'s `heapStats().heapSize`, falling back to
479
- * `process.memoryUsage().heapUsed`) around one batch bracketed by
480
- * `Bun.gc(true)` on both sides, divided by the batch size. Garbage the
481
- * calls create and drop is collected before the second reading, so this
482
- * is what the calls keep alive (a leak check), not what they allocate. */
369
+ /** `--alloc`: heap growth per call across one batch bracketed by full GCs,
370
+ * so what the calls keep alive (a leak check), not what they allocate. */
483
371
  bytesPerOp?: number;
484
- /** How far one call raised the process's RSS above where it started,
485
- * garbage included, from `ostia bench --peak-mem`: median over 3 fresh
486
- * processes, each making the task's first call after its `before` hooks
487
- * and a full GC. A `peak-hidden` warning says when it may read low. */
372
+ /** `--peak-mem`: how far one call raised RSS, garbage included; median over
373
+ * 3 fresh processes. A `peak-hidden` warning says when it may read low. */
488
374
  peakBytes?: number;
375
+ /** In-process timing: the JS heap's size as of its last collection, just
376
+ * before the task's warmup and just after its last trial. Read without
377
+ * forcing a GC, so it never perturbs the timing. */
378
+ heapUsedBeforeBytes?: number;
379
+ heapUsedAfterBytes?: number;
489
380
  }
490
381
 
491
382
  interface JitTierBreakdown {
@@ -504,8 +395,7 @@ interface JitTierBreakdown {
504
395
  }
505
396
 
506
397
  interface PairedEvidence {
507
- /** Rounds run: one base batch and one candidate batch each, alternating
508
- * which goes first. */
398
+ /** One base batch and one candidate batch per round, alternating order. */
509
399
  rounds: number;
510
400
  /** Calls per side per round. */
511
401
  batch: number;
@@ -517,27 +407,48 @@ interface PairedEvidence {
517
407
  ratios: number[];
518
408
  medianRatio: number;
519
409
  /** 25th/75th percentile of `ratios`. */
520
- p25: number;
521
- p75: number;
522
- /** Set when this process's rounds crossed the threshold: median ratio
523
- * past `1 ± threshold` and on the same side of 1 in at least three
524
- * quarters of rounds. */
410
+ ratioP25: number;
411
+ ratioP75: number;
412
+ /** Median ratio past `1 ± threshold` and on the same side of 1 in at least
413
+ * three quarters of rounds. */
525
414
  flagged?: "regressed" | "improved";
526
415
  /** Each fresh-process re-measurement of a flagged workload. */
527
416
  repeats?: {
528
417
  medianRatio: number;
529
- p25: number;
530
- p75: number;
418
+ ratioP25: number;
419
+ ratioP75: number;
531
420
  flagged?: "regressed" | "improved";
532
421
  }[];
533
422
  /** Set when `flagged` is: whether every repeat flagged the same way. */
534
423
  confirmed?: boolean;
535
424
  /** `flagged` when confirmed, else `"unchanged"`. */
536
425
  verdict: "regressed" | "improved" | "unchanged";
537
- /** Whether the first call's return value was deep-equal on both sides
538
- * (`Bun.deepEquals`, prototypes ignored). Informational: expected to be
539
- * `false` for a change in behavior, never a failure. */
426
+ /** First call's return values were deep-equal on both sides; informational,
427
+ * never a failure. */
540
428
  sameOutput: boolean;
429
+ /** Set when the suite file's text differs from the base ref's copy, so
430
+ * each side may run a different benchmark (a fixed fixture, a changed
431
+ * input). With `sameOutput: false` too, the task is "not comparable": its
432
+ * verdict is `"unchanged"` and it's left out of the geomean. */
433
+ suiteChanged?: true;
434
+ /** `--alloc`: heap each call keeps alive after a full GC, per side, after
435
+ * a warmup batch; each the median of 3 fresh processes. */
436
+ retained?: MemoryChange;
437
+ /** `--peak-mem`: how far the task's first call raises RSS, per side,
438
+ * each the median of 3 fresh processes. */
439
+ peak?: MemoryChange;
440
+ }
441
+
442
+ interface MemoryChange {
443
+ baseBytes: number;
444
+ candBytes: number;
445
+ /** Past both `AbSummary.memory.thresholdPct` of the base and
446
+ * `floorBytes`; always `"unchanged"` on a not-comparable task. */
447
+ verdict: "regressed" | "improved" | "unchanged";
448
+ /** The smallest change that counts, bytes: 16 KiB over the batch's calls
449
+ * for `retained`; for `peak`, 1 MiB, or the freed memory a `peak-hidden`
450
+ * warning reports. */
451
+ floorBytes: number;
541
452
  }
542
453
 
543
454
  interface AbSummary {
@@ -545,6 +456,9 @@ interface AbSummary {
545
456
  ref: string;
546
457
  sha: string;
547
458
  };
459
+ /** Suite files, as given, that don't exist at the base ref: their tasks
460
+ * are all candidate-only. */
461
+ newSuites?: string[];
548
462
  rounds: number;
549
463
  thresholdPct: number;
550
464
  geomeanThresholdPct: number;
@@ -559,17 +473,31 @@ interface AbSummary {
559
473
  unconfirmed: number;
560
474
  /** Workloads whose first call returned different values on each side. */
561
475
  outputDiffers: number;
562
- /** Geometric mean of each workload's median ratio, as a signed percent
563
- * (negative: candidate faster on average). A flagged workload contributes
564
- * the median over its main run and repeats. `null` when nothing was
565
- * paired. */
476
+ /** Workloads whose suite file and output both changed (see
477
+ * `PairedEvidence.suiteChanged`); counted in `unchanged`, left out of
478
+ * `geomeanPct`. */
479
+ notComparable: number;
480
+ /** Workloads that threw on either side or both, and so weren't timed;
481
+ * not counted in `matched`. */
482
+ threw: number;
483
+ /** Present when any task had a memory reading (`--alloc`, `--peak-mem`):
484
+ * how many tasks' memory regressed or improved, on either reading. */
485
+ memory?: {
486
+ thresholdPct: number;
487
+ regressed: number;
488
+ improved: number;
489
+ };
490
+ /** Geometric mean of the workloads' median ratios, signed percent (negative:
491
+ * candidate faster); a flagged workload contributes its median over its main
492
+ * run and repeats. `null` when nothing was paired. */
566
493
  geomeanPct: number | null;
567
- /** `"fail"` when any workload regressed (confirmed) or `geomeanPct`
568
- * exceeds `geomeanThresholdPct`. */
494
+ /** `"fail"` when any workload regressed (confirmed), threw on the
495
+ * candidate side only, or `geomeanPct` exceeds `geomeanThresholdPct`, or
496
+ * any task's memory regressed. */
569
497
  verdict: "pass" | "fail";
570
498
  }
571
499
 
572
- declare const WARNING_CODES: readonly ["slow-first-run", "outliers-detected", "fast-command", "nonzero-exit", "artifact-missing", "empty-profile", "low-sample-count", "thin-comparison", "noisy-machine", "skipped", "jit-cold", "timeout", "aborted", "time-source-no-match", "environment-mismatch", "peak-hidden"];
500
+ declare const WARNING_CODES: readonly ["slow-first-run", "outliers-detected", "fast-command", "nonzero-exit", "artifact-missing", "empty-profile", "low-sample-count", "thin-comparison", "noisy-machine", "skipped", "jit-cold", "timeout", "aborted", "time-source-no-match", "environment-mismatch", "peak-hidden", "suite-changed"];
573
501
 
574
502
  export type WarningCode = (typeof WARNING_CODES)[number];
575
503
 
@@ -594,19 +522,16 @@ export interface Comparison {
594
522
  timing?: {
595
523
  medianDeltaPct: number;
596
524
  meanDeltaPct: number;
597
- /** 95% bootstrap confidence interval on the difference of medians,
598
- * percent of the baseline median. Absent when either side had fewer
599
- * than 5 samples (see the `thin-comparison` warning). */
525
+ /** 95% bootstrap CI on the difference of medians, percent of the baseline
526
+ * median. Absent below 5 samples a side (`thin-comparison`). */
600
527
  ci95?: [number, number];
601
- /** Two-sided Mann-Whitney U p-value, tie-corrected normal approximation.
602
- * Same absence condition as `ci95`. */
528
+ /** Two-sided Mann-Whitney U p-value; absent whenever `ci95` is. */
603
529
  pValue?: number;
604
- /** Seed for the bootstrap's PRNG, so `ci95` is reproducible. */
530
+ /** Bootstrap PRNG seed, so `ci95` is reproducible. */
605
531
  seed?: number;
606
532
  verdict: "improved" | "regressed" | "unchanged";
607
533
  };
608
- /** Attached when timing fell back to the point-estimate rule (thin
609
- * samples) or otherwise carries a caveat about this comparison. */
534
+ /** Caveats about this comparison, e.g. a thin-sample point-estimate verdict. */
610
535
  warnings?: Warning[];
611
536
  frames?: {
612
537
  frameKey: string;
@@ -630,32 +555,81 @@ export interface Comparison {
630
555
  minFrameSelfUs: number;
631
556
  alpha: number;
632
557
  bootstrapIterations: number;
633
- /** `max(timingPct, base.environment.noise.floorPct,
634
- * cand.environment.noise.floorPct)` - the threshold timing was actually
635
- * tested against, once machine noise widens it past `timingPct`. */
558
+ /** `max(timingPct, both documents' noise floors)`: what timing was tested against. */
636
559
  effectiveTimingPct: number;
637
560
  };
638
561
  verdict: "pass" | "fail";
639
562
  }
640
563
 
641
564
  interface ComparisonSummary {
642
- /** `comparisons.length`: workloads present (and comparable) on both sides. */
565
+ /** `comparisons.length`. */
643
566
  matched: number;
644
567
  regressed: number;
645
568
  improved: number;
646
569
  unchanged: number;
647
- /** Geometric mean of `cand/base` median ratios over matched timing
648
- * comparisons, as a signed percent (negative: candidate faster on
649
- * average). `null` when no comparison had a finite timing ratio. */
570
+ /** Geometric mean of `cand/base` median ratios, signed percent (negative:
571
+ * candidate faster). `null` when no comparison had a finite ratio. */
650
572
  geomeanPct: number | null;
651
- /** Same value as `Comparison.thresholds.effectiveTimingPct` - one number
652
- * for the whole document pair, since it depends only on `thresholds` and
653
- * the two documents' `environment.noise.floorPct`, never per-workload. */
573
+ /** `Comparison.thresholds.effectiveTimingPct`, which is the same for the
574
+ * whole document pair. */
654
575
  effectiveTimingPct: number;
576
+ /** The machine's drift, percent, added to the timing threshold: the
577
+ * largest of each run's own drift (`NoiseFloor.driftPct`) and how far the
578
+ * reference loop's speed moved between the two runs. Absent when either
579
+ * document lacks a noise check. */
580
+ driftPct?: number;
655
581
  /** `"fail"` when any comparison's verdict is `"fail"`. */
656
582
  verdict: "pass" | "fail";
657
583
  }
658
584
 
585
+ interface ComparedTask {
586
+ /** The candidate's workload id; the base's on a `missing-cand` task. */
587
+ workloadId: string;
588
+ /** Display name: the label, else the command, else the id. */
589
+ task: string;
590
+ group?: string;
591
+ /** Set when the base was matched through one of the task's `aliases`: the
592
+ * base's workload id and display name. */
593
+ renamedFrom?: {
594
+ workloadId: string;
595
+ task: string;
596
+ };
597
+ verdict: "improved" | "unchanged" | "regressed" | "missing-base" | "missing-cand";
598
+ /** The `Comparison` holding this task's frame and heap-type deltas and
599
+ * warnings; absent on a one-sided task. */
600
+ comparisonId?: string;
601
+ /** Whether the comparison passed the gate (timing, frames, heap types);
602
+ * absent on a one-sided task, which never fails it. */
603
+ pass?: boolean;
604
+ /** Absent on a one-sided or skipped task. */
605
+ timing?: {
606
+ unit: "ns";
607
+ baseMedian: number;
608
+ candMedian: number;
609
+ /** Absent on a document compared before ostia recorded it. */
610
+ baseSamples?: number;
611
+ candSamples: number;
612
+ medianDeltaPct: number;
613
+ /** Absent on a thin (<5 samples a side) comparison, with `pValue`. */
614
+ ci95?: [number, number];
615
+ pValue?: number;
616
+ /** `Comparison.thresholds.effectiveTimingPct`. */
617
+ thresholdPct: number;
618
+ };
619
+ /** Memory readings present on both sides; informational, never gated. */
620
+ memory?: MemoryDelta[];
621
+ }
622
+
623
+ interface MemoryDelta {
624
+ /** `heapUsedBefore`/`heapUsedAfter`: in-process heap around the timing
625
+ * run; `retained`: `--alloc`; `peak`: `--peak-mem`; `maxRss`: subprocess
626
+ * timing. */
627
+ reading: "heapUsedBefore" | "heapUsedAfter" | "retained" | "peak" | "maxRss";
628
+ baseBytes: number;
629
+ candBytes: number;
630
+ deltaPct: number;
631
+ }
632
+
659
633
  export interface PrepareRun {
660
634
  phase: "warmup" | "timing" | "cpu" | "heap";
661
635
  index: number;
@@ -673,36 +647,36 @@ export interface TimeSource {
673
647
  unit?: TimeUnit;
674
648
  }
675
649
 
676
- interface InprocessTimingOptions {
677
- /** Wall-clock budget for the sampling loop, per task (default: 500). The loop
678
- * always runs for at least this long, unless `samples` is set. */
679
- budgetMs?: number;
680
- /** Exact trial count. When set, the budget is ignored and the loop runs
681
- * exactly this many trials, however slow each one is - the in-process
682
- * equivalent of `time()`'s `samples`. */
683
- samples?: number;
684
- /** Hard floor on the number of trials when no exact `samples` count is
685
- * given. When set, the loop keeps sampling past the time budget until this
686
- * many trials exist, however slow each one is. When unset, the floor is
687
- * cost-aware (see `defaultSampleFloor`): as many trials as fit in the
688
- * budget, capped at 20, but never below the rigor floor the task's per-trial cost
689
- * earns it (3 at ≤1ms, rising to 10 for multi-second calls). */
690
- minSamples?: number;
691
- /** Warmup budget as a fraction of `budgetMs` (default: 0.1) - a fraction,
692
- * not a call count, named `warmup` for cross-surface consistency with
693
- * `time()`'s trial-count `warmup`; the two are genuinely different units (a
694
- * fraction here, a count there), not papered over. Warmup always runs at
695
- * least one call; for a task slower than the warmup budget that single call
696
- * is the whole warmup. */
697
- warmup?: number;
698
- gc?: boolean;
699
- }
700
-
701
- export declare function keep(value: unknown): void;
650
+ export type AbProgress = {
651
+ phase: "setup";
652
+ command: string;
653
+ } | {
654
+ phase: "measure";
655
+ suite: number;
656
+ suites: number;
657
+ file: string;
658
+ /** Each suite runs twice, once with each side first. */
659
+ pass: number;
660
+ passes: number;
661
+ task: number;
662
+ tasks: number;
663
+ label: string;
664
+ } | {
665
+ phase: "confirm";
666
+ repeat: number;
667
+ repeats: number;
668
+ label: string;
669
+ } | {
670
+ phase: "memory";
671
+ run: number;
672
+ runs: number;
673
+ side: Side;
674
+ label: string;
675
+ };
702
676
 
703
- interface AbOptions {
677
+ export interface AbOptions {
704
678
  suites: string[];
705
- /** Git ref whose committed tree is the base side (default: `"HEAD"`). The
679
+ /** Git ref whose committed tree is the base side (default: `"HEAD"`); the
706
680
  * candidate is the working tree, uncommitted changes included. */
707
681
  base?: string;
708
682
  /** Rounds per workload, each one base batch and one candidate batch of
@@ -715,9 +689,8 @@ interface AbOptions {
715
689
  * this many percent (default: 1.5): a broad slowdown too small to flag any
716
690
  * one workload. */
717
691
  geomeanThresholdPct?: number;
718
- /** Fresh-process repeats of each flagged workload; it only counts when
719
- * every repeat flags it the same way (default: 2; 0 trusts the first
720
- * process). */
692
+ /** Fresh-process repeats of each flagged workload, which only counts when
693
+ * every repeat flags it the same way (default: 2; 0 trusts the first). */
721
694
  confirm?: number;
722
695
  filter?: string;
723
696
  preload?: string[];
@@ -725,98 +698,112 @@ interface AbOptions {
725
698
  outDir?: string;
726
699
  cwd?: string;
727
700
  noiseCheck?: boolean;
728
- /** Kills a runner process (one suite file, or one repeat) after this many
729
- * ms. No default. */
701
+ /** Also compare each side's retained heap per call (see
702
+ * `bench({ alloc })`) and how far its first call raises RSS (see
703
+ * `bench({ peakMem })`), each the median of 3 fresh processes per side.
704
+ * Task/group options override these. */
705
+ alloc?: boolean;
706
+ peakMem?: boolean;
707
+ /** Flag a memory reading that moves past this many percent of the base's
708
+ * and past its noise floor (default: 10). A memory regression fails the
709
+ * run. */
710
+ memThresholdPct?: number;
711
+ /** Shell commands run once, in order, in a freshly extracted base tree
712
+ * before it's used: to build what the suites import but git doesn't hold
713
+ * (generated or gitignored files). Each runs with its cwd at the tree's
714
+ * counterpart of `cwd`, the project's `node_modules` linked in, and
715
+ * `OSTIA_AB_SHA` / `OSTIA_AB_CANDIDATE_DIR` (the working tree's `cwd`) in
716
+ * its environment. The tree is cached per commit and command list. */
717
+ baseSetup?: string | string[];
718
+ /** Base trees to keep under `<outDir>/ab`, counting this run's: after a
719
+ * run, older ones go, least recently used first, unless one was used in
720
+ * the last hour (another run may still need it). Default: 5. */
721
+ keepTrees?: number;
722
+ /** SIGKILLs a runner process (one suite file or repeat), or a `baseSetup`
723
+ * command, after this many ms. */
730
724
  timeoutMs?: number;
731
725
  /** Aborting kills the running process and resolves with the suites that
732
- * had finished, plus an `aborted` warning; repeats not yet run leave their
726
+ * had finished, plus an `aborted` warning; unrun repeats leave their
733
727
  * workloads unconfirmed. */
734
728
  signal?: AbortSignal;
729
+ /** Called as each setup command, task, confirmation repeat and memory
730
+ * process starts. */
731
+ onProgress?: (progress: AbProgress) => void;
732
+ }
733
+
734
+ export declare class AbBaseError extends OstiaUsageError {
735
+ }
736
+
737
+ export declare class AbSuiteError extends Error {
738
+ readonly side: Side;
739
+ constructor(message: string, side: Side);
740
+ }
741
+
742
+ export declare class AbSetupError extends Error {
735
743
  }
736
744
 
737
745
  export declare function ab(opts: AbOptions): Promise<ProfileDocument>;
738
746
 
739
- interface BenchOptions {
747
+ declare class OstiaUsageError extends Error {
748
+ }
749
+
750
+ type Side = "base" | "cand";
751
+
752
+ export interface BenchOptions {
740
753
  suites: string[];
741
754
  /** Wall-clock sampling budget per task, ms (default 500). */
742
755
  budgetMs?: number;
743
- /** Exact trial count per task. When set, the budget is ignored - the
744
- * in-process equivalent of `time()`'s `samples`. */
756
+ /** Exact trial count per task; the budget is ignored when set. */
745
757
  samples?: number;
746
758
  minSamples?: number;
747
759
  gc?: boolean;
748
760
  /** Capture one extra `phase: "cpu"` measurement per task (the task looped
749
- * under the JSC sampling profiler for long enough to collect ~2,000
750
- * samples, JIT tiers included), never mixed into the timing numbers.
751
- * `TaskOptions.cpu` / `GroupOptions.cpu` override this per task or group. */
761
+ * under the JSC sampling profiler for ~2,000 samples, JIT tiers included),
762
+ * never mixed into the timing numbers. Task/group `cpu` overrides this. */
752
763
  cpu?: boolean;
753
764
  /** `cpu`'s sampling interval, µs (default: 100). A coarser interval
754
765
  * lengthens the capture to keep the sample count, up to 10s per task. */
755
766
  cpuIntervalUs?: number;
756
767
  /** Capture one extra `phase: "memstats"` measurement per task: retained
757
768
  * heap growth per call, from a batch with `Bun.gc(true)` on both sides.
758
- * What the calls keep alive, not what they allocate - garbage is collected
759
- * before the second reading. `TaskOptions.alloc` / `GroupOptions.alloc`
760
- * override this per task or group. */
769
+ * What the calls keep alive, not what they allocate. Task/group `alloc`
770
+ * overrides this. */
761
771
  alloc?: boolean;
762
772
  /** Capture one extra `phase: "memstats"` measurement per task: how far the
763
- * task's first call raises RSS, garbage included - the reading
764
- * `alloc` can't give. Median of 3 fresh processes, each run with
765
- * `OSTIA_PEAK_MEM=1` in its environment (see `measurePeakMem`).
766
- * `TaskOptions.peakMem` / `GroupOptions.peakMem` override this per task or
767
- * group. */
773
+ * task's first call raises RSS, garbage included. Median of 3 fresh
774
+ * processes, each run with `OSTIA_PEAK_MEM=1` in its environment (see
775
+ * `measurePeakMem`). Task/group `peakMem` overrides this. */
768
776
  peakMem?: boolean;
769
777
  filter?: string;
770
- /** Suite files to run at once, each still in its own child process (default:
771
- * 1). Files are independent by design, so this is a wall-clock win for
772
- * multi-file suites, but concurrent CPU-bound processes contend for cores,
773
- * caches, memory bandwidth and turbo headroom: timings taken under `jobs > 1`
774
- * are noisier and not like-for-like with a baseline measured at 1. When
775
- * `isolate` puts some tasks in their own subprocess, `jobs` pools across
776
- * those per-task processes the same way - so the same noise/wall-clock
777
- * tradeoff now scales with task count, not just file count. */
778
+ /** Suite files (or isolated tasks) to run at once, each in its own child
779
+ * process (default: 1). Concurrent CPU-bound processes contend for cores,
780
+ * caches and turbo headroom, so timings under `jobs > 1` are noisier and
781
+ * not like-for-like with a baseline measured at 1. */
778
782
  jobs?: number;
779
783
  outDir?: string;
780
784
  cwd?: string;
781
785
  /** Give every task its own subprocess instead of sharing its suite file's,
782
- * isolating each task's JIT tier state, inline caches and heap shape from
783
- * every other task the way suite files are already isolated from each
784
- * other. `TaskOptions.isolate` / `GroupOptions.isolate` override this per
785
- * task or group for mixed suites (e.g. a few outlier-prone tasks isolated,
786
- * many cheap ones sharing a process). Multiplies process-spawn overhead by
787
- * task count instead of file count. */
786
+ * isolating JIT tier state, inline caches and heap shape between tasks.
787
+ * Task/group `isolate` overrides this. Multiplies spawn overhead by task
788
+ * count instead of file count. */
788
789
  isolate?: boolean;
789
- /** Scripts run, in order, before each suite file loads - in the same
790
- * subprocess, so they can install globals (jsdom's `document`/`window`) or
791
- * register a `Bun.plugin()` file-loader (e.g. for `.svelte`/`.vue`) ahead
792
- * of the suite's own top-level code. Consumer-authored; ostia ships no
793
- * preload scripts itself. */
790
+ /** Scripts run, in order, before each suite file loads, in the same
791
+ * subprocess: install globals (jsdom) or a `Bun.plugin()` loader ahead of
792
+ * the suite's top-level code. */
794
793
  preload?: string[];
795
- /** Extra flags passed through to the `bun` invocation that runs each suite
796
- * file (e.g. `["--conditions", "browser"]`), inserted before the runner
797
- * script path so `bun` itself parses them rather than the runner. Useful
798
- * for suites that import packages whose `exports` map branches on a
799
- * resolution condition Bun doesn't set by default (e.g. Svelte/Vue's
800
- * `browser` vs `default` builds). */
794
+ /** Extra flags for the `bun` invocation that runs each suite file (e.g.
795
+ * `["--conditions", "browser"]`), placed before the runner script path. */
801
796
  bunFlags?: string[];
802
797
  /** Measure this machine's noise floor once, before any suite runs
803
- * (default: true), and stamp it on the document as
804
- * `environment`. Set false to skip the ~200ms reference measurement. */
798
+ * (default: true), and stamp it on the document as `environment`. */
805
799
  noiseCheck?: boolean;
806
- /** Kills a suite file's subprocess (or, under `isolate`, one task's
807
- * dedicated subprocess) with SIGKILL if it hasn't finished after this many
808
- * ms. No default: an unset `timeoutMs` never times out. Applies to the
809
- * whole subprocess, not per task - the same granularity `isolate` already
810
- * runs at. */
800
+ /** SIGKILLs a suite file's subprocess (or, under `isolate`, one task's) that
801
+ * hasn't finished after this many ms. No default. */
811
802
  timeoutMs?: number;
812
- /** Aborting cancels the run: in-flight suite/isolated-task subprocesses
813
- * are killed with SIGKILL, no new ones are started, and `bench()` resolves
814
- * (never rejects) with whatever suites/tasks had already finished when the
815
- * signal fired, plus an `aborted` warning on the document's last
816
- * measurement. A suite subprocess killed mid-run contributes nothing (it
817
- * only writes its result once, at the end), so a suite that was in flight
818
- * when the signal fired is dropped entirely rather than partially
819
- * represented. */
803
+ /** Aborting kills in-flight subprocesses, starts no new ones, and resolves
804
+ * (never rejects) with whatever suites/tasks had finished, plus an
805
+ * `aborted` warning on the last measurement. A suite killed mid-run is
806
+ * dropped entirely: it only writes its result once, at the end. */
820
807
  signal?: AbortSignal;
821
808
  }
822
809
 
@@ -838,6 +825,10 @@ export interface TaskOptions {
838
825
  /** Per-task hard floor on trials; overrides the suite-wide `--min-samples` /
839
826
  * `minSamples`. */
840
827
  minSamples?: number;
828
+ /** Former names of this task in the same group, so `compare` and `ab`
829
+ * match a base measured before a rename instead of reporting the old name
830
+ * as removed and the new one as new. */
831
+ aliases?: string[];
841
832
  /** What this task measures and why. Flows into `Workload.description` so the
842
833
  * intent travels with the numbers instead of living only in a source comment. */
843
834
  description?: string;
@@ -907,38 +898,39 @@ export interface GroupOptions {
907
898
  after?: Hook;
908
899
  }
909
900
 
910
- interface GroupFn {
911
- (name: string, fn: () => void, opts?: GroupOptions): void;
901
+ type GroupRegistrar = (name: string, fn: () => void, opts?: GroupOptions) => void;
902
+
903
+ interface GroupFn extends GroupRegistrar {
912
904
  /** Registers every task inside as skipped: the runner never measures
913
905
  * them, but the document still carries their workloads (marked
914
906
  * `Workload.skipped`). */
915
- skip: (name: string, fn: () => void, opts?: GroupOptions) => void;
907
+ skip: GroupRegistrar;
916
908
  /** When any task or group in the suite uses `.only`, the runner restricts
917
909
  * the whole suite file to only those tasks (before `--filter` narrows
918
910
  * further) and prints a one-line notice to stderr. */
919
- only: (name: string, fn: () => void, opts?: GroupOptions) => void;
911
+ only: GroupRegistrar;
920
912
  }
921
913
 
922
914
  export declare const group: GroupFn;
923
915
 
924
- interface TaskFn {
925
- (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions): void;
916
+ type TaskRegistrar = (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
917
+
918
+ interface TaskFn extends TaskRegistrar {
926
919
  /** Registers the task as skipped: the runner never measures it, but the
927
920
  * document still carries its workload (marked `Workload.skipped`) so a
928
921
  * renderer or `compare` can say so explicitly instead of the task simply
929
922
  * being absent. */
930
- skip: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
923
+ skip: TaskRegistrar;
931
924
  /** When any task or group in the suite uses `.only`, the runner restricts
932
925
  * the whole suite file to only those tasks (before `--filter` narrows
933
926
  * further) and prints a one-line notice to stderr. */
934
- only: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
927
+ only: TaskRegistrar;
935
928
  }
936
929
 
937
930
  export declare const task: TaskFn;
938
931
 
939
932
  export interface RunOptions extends MeasureTasksOpts {
940
- /** Regex, matched against "group/name" task ids - same semantics as
941
- * `ostia bench --filter` / `bench({ filter })`. */
933
+ /** Regex matched against "group/name" task ids, as in `bench({ filter })`. */
942
934
  filter?: string;
943
935
  /** Skip printing the report to stdout; still returns the document. */
944
936
  quiet?: boolean;
@@ -950,6 +942,11 @@ export declare function run(opts?: RunOptions): Promise<ProfileDocument>;
950
942
 
951
943
  type FormatName = "table" | "json" | "markdown" | "jsonl" | "minimal" | "collapsed" | "mermaid" | "speedscope" | "cpuprofile";
952
944
 
945
+ export interface VizOptions {
946
+ /** Render this measurement only, instead of every CPU measurement. */
947
+ measurementId?: string;
948
+ }
949
+
953
950
  interface RenderResult {
954
951
  text?: string;
955
952
  files?: {
@@ -964,32 +961,44 @@ interface Renderer<O = unknown> {
964
961
  }
965
962
 
966
963
  interface MeasureTasksOpts extends InprocessTimingOptions {
967
- /** Suite-wide default for capturing an extra `phase: "cpu"` measurement
968
- * per task (task/group `TaskOptions.cpu`/`GroupOptions.cpu` still win). */
964
+ /** Suite-wide default for capturing an extra `phase: "cpu"` measurement per
965
+ * task (task/group `cpu` still wins). */
969
966
  cpu?: boolean;
970
967
  /** `cpu`'s sampling interval, µs (default: 100). */
971
968
  cpuIntervalUs?: number;
972
969
  /** Suite-wide default for capturing an extra `phase: "memstats"`
973
- * measurement per task (task/group `TaskOptions.alloc`/`GroupOptions.alloc`
974
- * still win). */
970
+ * measurement per task (task/group `alloc` still wins). */
975
971
  alloc?: boolean;
976
- /** Stamped onto every workload this call produces, recording whether it
977
- * ran in a subprocess dedicated to it alone. */
972
+ /** Stamped onto every workload: it ran in a subprocess dedicated to it. */
978
973
  markIsolated?: boolean;
979
974
  /** Measure this machine's noise floor before the first task (default:
980
- * true) and stamp it on the document as `environment`. Set false to skip
981
- * the ~200ms reference measurement. */
975
+ * true) and stamp it on the document as `environment`. */
982
976
  noiseCheck?: boolean;
983
977
  }
984
978
 
979
+ interface InprocessTimingOptions {
980
+ /** Wall-clock budget for the sampling loop, per task (default: 500). */
981
+ budgetMs?: number;
982
+ /** Exact trial count; the budget is ignored. */
983
+ samples?: number;
984
+ /** Keep sampling past the budget until this many trials exist. When unset
985
+ * the floor is cost-aware (see `defaultSampleFloor`). */
986
+ minSamples?: number;
987
+ /** Warmup budget as a fraction of `budgetMs` (default: 0.1); unlike
988
+ * `time()`'s `warmup`, which is a trial count. Always runs at least one call. */
989
+ warmup?: number;
990
+ gc?: boolean;
991
+ }
992
+
993
+ export declare function keep(value: unknown): void;
994
+
985
995
  type SweepPoint<T extends Record<string, readonly unknown[]>> = {
986
996
  [K in keyof T]: T[K][number];
987
997
  };
988
998
 
989
999
  export declare function sweep<T extends Record<string, readonly unknown[]>>(dims: T, fn: (point: SweepPoint<T>) => void): void;
990
1000
 
991
- export declare function newDocument(workloads: Workload[], measurements: Measurement[], environment?: Environment): ProfileDocument;
992
- export { newDocument as createDocument };
1001
+ export declare function createDocument(workloads: Workload[], measurements: Measurement[], environment?: Environment): ProfileDocument;
993
1002
 
994
1003
  export declare function saveDocument(doc: ProfileDocument, path: string): Promise<void>;
995
1004
 
@@ -1005,9 +1014,45 @@ export declare class OstiaDocumentError extends Error {
1005
1014
 
1006
1015
  export declare function loadDocument(path: string): Promise<ProfileDocument>;
1007
1016
 
1008
- export declare const renderers: Record<FormatName, Renderer<any>>;
1017
+ export interface ProfileOptions {
1018
+ /** Sampling interval in µs; default 1000. Named like `time()`'s and
1019
+ * `bench()`'s option, the config key and `--cpu-interval`. */
1020
+ cpuIntervalUs?: number;
1021
+ /** Names the workload: it becomes the label and, instead of the function's
1022
+ * name and source, what the id hashes, so closures that differ only in
1023
+ * captured values get distinct ids and the id survives editing the function. */
1024
+ name?: string;
1025
+ /** `"inspector"` (default) captures through CDP and writes a portable
1026
+ * .cpuprofile; `"jsc"` uses `bun:jsc.profile` and adds LLInt/Baseline/DFG/FTL
1027
+ * tier data. */
1028
+ origin?: "inspector" | "jsc";
1029
+ /** There's no child to kill, so an already-aborted signal skips the profiler,
1030
+ * calls `fn` plain (still returning its `result`) and records an `aborted`
1031
+ * warning instead of CPU evidence. It can't interrupt a running `fn`. */
1032
+ signal?: AbortSignal;
1033
+ }
1034
+
1035
+ export interface ProfileResult<T> {
1036
+ result: T;
1037
+ measurement: Measurement;
1038
+ document: ProfileDocument;
1039
+ }
1040
+
1041
+ export declare function profile<T>(fn: () => T | Promise<T>, opts?: ProfileOptions): Promise<ProfileResult<T>>;
1042
+
1043
+ export declare const renderers: {
1044
+ table: Renderer<unknown>;
1045
+ json: Renderer<unknown>;
1046
+ markdown: Renderer<unknown>;
1047
+ jsonl: Renderer<unknown>;
1048
+ minimal: Renderer<MinimalRenderOptions>;
1049
+ collapsed: Renderer<VizOptions>;
1050
+ mermaid: Renderer<MermaidOptions>;
1051
+ speedscope: Renderer<VizOptions>;
1052
+ cpuprofile: Renderer<VizOptions>;
1053
+ };
1009
1054
 
1010
- export declare const MINIMAL_PROTOCOL_VERSION: 1;
1055
+ export declare const MINIMAL_PROTOCOL_VERSION: 2;
1011
1056
 
1012
1057
  interface MinimalWarning {
1013
1058
  code: string;
@@ -1015,64 +1060,72 @@ interface MinimalWarning {
1015
1060
  }
1016
1061
 
1017
1062
  interface MinimalDelta {
1063
+ /** The base's median, ns: the row's own `median` is the candidate's. */
1064
+ baseMedian?: number;
1018
1065
  medianPct: number;
1019
1066
  meanPct: number;
1020
1067
  verdict: "improved" | "regressed" | "unchanged";
1021
1068
  pass: boolean;
1022
- /** 95% bootstrap CI on the difference of medians and the Mann-Whitney
1023
- * p-value behind the verdict. Absent on a thin (<5 samples/side)
1024
- * comparison, which falls back to a point-estimate threshold. */
1069
+ /** 95% bootstrap CI on the difference of medians; absent (with `pValue`)
1070
+ * on a thin (<5 samples/side) comparison. */
1025
1071
  ci95?: [number, number];
1026
1072
  pValue?: number;
1027
- /** The threshold this delta was actually tested against, once machine
1028
- * noise widened it past `thresholds.timingPct` - see
1029
- * `Comparison.thresholds.effectiveTimingPct`. */
1073
+ /** The threshold actually tested against, after machine noise widened it. */
1030
1074
  effectiveTimingPct: number;
1031
1075
  matched: true;
1076
+ /** The base task's name, when it was matched through an alias. */
1077
+ renamedFrom?: string;
1032
1078
  }
1033
1079
 
1034
1080
  interface MinimalPaired {
1035
1081
  /** Base side's median per-call time, ns. */
1036
1082
  baseMedian: number;
1037
1083
  medianRatio: number;
1038
- p25: number;
1039
- p75: number;
1084
+ ratioP25: number;
1085
+ ratioP75: number;
1040
1086
  rounds: number;
1041
- /** Final: `flagged` when fresh processes confirmed it, else
1042
- * `"unchanged"`. */
1087
+ /** `flagged` when fresh processes confirmed it, else `"unchanged"`. */
1043
1088
  verdict: "regressed" | "improved" | "unchanged";
1044
- /** What this workload's first process saw, before confirmation. */
1089
+ /** What the first process saw, before confirmation. */
1045
1090
  flagged?: "regressed" | "improved";
1046
1091
  confirmed?: boolean;
1047
1092
  /** Median ratio of each fresh-process repeat. */
1048
1093
  repeats?: number[];
1049
1094
  sameOutput: boolean;
1095
+ /** The suite file differs from the base's copy. */
1096
+ suiteChanged?: true;
1097
+ /** `--alloc`: retained heap per call on each side. */
1098
+ retained?: MinimalMemoryChange;
1099
+ /** `--peak-mem`: first-call RSS rise on each side. */
1100
+ peak?: MinimalMemoryChange;
1101
+ }
1102
+
1103
+ interface MinimalMemoryChange {
1104
+ base: number;
1105
+ cand: number;
1106
+ verdict: "regressed" | "improved" | "unchanged";
1107
+ floor: number;
1050
1108
  }
1051
1109
 
1052
1110
  interface MinimalRunLine {
1053
1111
  event: "run";
1054
1112
  protocolVersion: typeof MINIMAL_PROTOCOL_VERSION;
1055
1113
  schemaVersion: ProfileDocument["schemaVersion"];
1056
- /** Join key back to `Workload.id` / `Comparison.candidateMeasurementId` -
1057
- * stable across a `time`/`compare`/`ci` invocation of the same command,
1058
- * unlike `task` (a label, not an identity). */
1114
+ /** Join key to `Workload.id` / `Comparison.candidateMeasurementId`; unlike
1115
+ * `task`, stable across invocations. */
1059
1116
  workloadId: string;
1060
1117
  task: string;
1061
1118
  group?: string;
1062
1119
  description?: string;
1063
1120
  groupDescription?: string;
1064
- /** From `task(name, fn, { params })` or a `sweep()` point. */
1065
1121
  params?: Record<string, string | number | boolean>;
1066
- /** From `task.skip()` / `group.skip()`: no measurement was taken, so every
1067
- * stats field below is absent on this line. */
1122
+ /** `task.skip()` / `group.skip()`: no measurement, so every stats field is absent. */
1068
1123
  skipped?: true;
1069
1124
  unit?: "ns";
1125
+ /** 0, with every stats field absent, when no trial produced a sample (all
1126
+ * timed out or missed the `timeSource` pattern); see `warnings`. */
1070
1127
  samples?: number;
1071
- /** In-process trials batched into one timed block (see
1072
- * `measure/inprocess.ts`'s `sizeBatch`) so the timer's own resolution
1073
- * doesn't dominate a sub-microsecond task's reading. 1 when the timing
1074
- * engine never batched (every subprocess run, and any in-process run
1075
- * whose single call already clears the batching threshold). */
1128
+ /** In-process trials batched into one timed block; 1 when never batched. */
1076
1129
  batch: number;
1077
1130
  mean?: number;
1078
1131
  median?: number;
@@ -1087,24 +1140,30 @@ interface MinimalRunLine {
1087
1140
  /** Median user / system CPU time per trial, ns: subprocess commands only. */
1088
1141
  userNs?: number;
1089
1142
  systemNs?: number;
1090
- /** `--alloc`: heap each call keeps alive after a full GC, whole bytes. */
1143
+ /** `--alloc`: heap each call keeps alive after a full GC, bytes. */
1091
1144
  retainedBytesPerOp?: number;
1092
1145
  /** `--peak-mem`: how far the task's first call raised RSS, bytes. */
1093
1146
  peakBytes?: number;
1094
1147
  /** Median over the group's reference median (its baseline task, else its
1095
- * fastest). Only present when the document has more than one timing run. */
1148
+ * fastest); only when the document has more than one timing run. */
1096
1149
  relative?: number;
1097
1150
  baseline?: true;
1098
- /** `mad / median` of the machine's ~200ms reference measurement - how
1099
- * noisy this machine is right now, independent of what's being measured.
1100
- * From `document.environment`; absent when `noiseCheck: false` skipped it. */
1151
+ /** The machine's noise floor from `document.environment`; absent when the
1152
+ * noise check was skipped. */
1101
1153
  noiseFloorPct?: number;
1102
1154
  warnings: MinimalWarning[];
1103
- /** From `comparisons` when present (ostia compare / ci): the change against
1104
- * the baseline document for this task. */
1155
+ /** The change against the baseline document (`compare` / `ci`). */
1105
1156
  delta?: MinimalDelta;
1106
- /** `ab` only; see `MinimalPaired`. */
1107
1157
  paired?: MinimalPaired;
1158
+ /** `ab` only, instead of stats and `paired`: the task threw on one side
1159
+ * (`"base"`/`"cand"`) or both, so it isn't judged on time. `message` is
1160
+ * the error's first lines; `repeat` (from 1) is set when it threw in a
1161
+ * confirmation repeat. */
1162
+ threw?: {
1163
+ side: "base" | "cand" | "both";
1164
+ message: string;
1165
+ repeat?: number;
1166
+ };
1108
1167
  }
1109
1168
 
1110
1169
  interface MinimalUnmatchedLine {
@@ -1131,6 +1190,8 @@ interface MinimalSummaryLine {
1131
1190
  missingBaseline?: number;
1132
1191
  geomeanPct: number | null;
1133
1192
  effectiveTimingPct: number;
1193
+ /** `compare`/`ci`: the machine's drift, added to the timing threshold. */
1194
+ driftPct?: number;
1134
1195
  noiseFloorPct?: number;
1135
1196
  /** `ci` only. */
1136
1197
  baseline?: {
@@ -1144,19 +1205,112 @@ interface MinimalSummaryLine {
1144
1205
  };
1145
1206
  /** `ab` only: the gate on `geomeanPct`. */
1146
1207
  geomeanThresholdPct?: number;
1147
- /** `ab` only: flagged in the first process but not reproduced by the
1148
- * fresh-process repeats; counted in `unchanged`. */
1208
+ /** `ab` only: flagged in the first process but not reproduced; counted in `unchanged`. */
1149
1209
  unconfirmed?: number;
1150
- /** `ab` only: workloads whose first call returned different values on the
1151
- * two sides. Informational. */
1210
+ /** `ab` only: workloads whose first call returned different values on the two sides. */
1152
1211
  outputDiffers?: number;
1212
+ notComparable?: number;
1213
+ threw?: number;
1214
+ newSuites?: string[];
1215
+ /** `ab` only, with `--alloc` or `--peak-mem`: tasks whose memory
1216
+ * regressed or improved, and the threshold, percent. */
1217
+ memory?: {
1218
+ thresholdPct: number;
1219
+ regressed: number;
1220
+ improved: number;
1221
+ };
1153
1222
  git?: {
1154
1223
  base?: GitMetadata;
1155
1224
  cand?: GitMetadata;
1156
1225
  };
1157
1226
  exportedTo?: string;
1158
- verdict: "pass" | "fail";
1227
+ /** `pass` (exit 0), `fail` (exit 1: a regression), else `error` (exit 2, or
1228
+ * 130 when cancelled): no verdict was reached. */
1229
+ verdict: "pass" | "fail" | "error";
1159
1230
  exitCode: number;
1160
1231
  }
1161
1232
 
1162
1233
  export type MinimalEvent = MinimalRunLine | MinimalUnmatchedLine | MinimalSummaryLine;
1234
+
1235
+ export interface MinimalProtocolContext {
1236
+ command: "compare" | "ci" | "ab";
1237
+ exitCode: number;
1238
+ unmatched?: {
1239
+ baseOnly: Workload[];
1240
+ candOnly: Workload[];
1241
+ };
1242
+ baseGit?: GitMetadata;
1243
+ candGit?: GitMetadata;
1244
+ baseline?: {
1245
+ name: string;
1246
+ path: string;
1247
+ };
1248
+ cached?: number;
1249
+ executed?: number;
1250
+ failed?: number;
1251
+ missingBaseline?: number;
1252
+ exportedTo?: string;
1253
+ }
1254
+
1255
+ export interface MinimalRenderOptions {
1256
+ protocol?: MinimalProtocolContext;
1257
+ }
1258
+
1259
+ export interface MermaidOptions extends VizOptions {
1260
+ topN?: number;
1261
+ }
1262
+
1263
+ export interface CommandSpec {
1264
+ command: string | string[];
1265
+ label?: string;
1266
+ /** Overrides `TimeOptions.prepare` for this command. */
1267
+ prepare?: PrepareHook;
1268
+ /** Overrides `TimeOptions.timeSource` for this command. */
1269
+ timeSource?: TimeSource;
1270
+ /** Overrides `TimeOptions.timeoutMs` for this command. */
1271
+ timeoutMs?: number;
1272
+ /** Overrides `TimeOptions.ignoreExitCodes` for this command. */
1273
+ ignoreExitCodes?: number[];
1274
+ }
1275
+
1276
+ export interface TimeOptions {
1277
+ commands: (string | string[] | CommandSpec)[];
1278
+ /** Runs unmeasured before every trial (warmup and instrumented included), in
1279
+ * `cwd`/`env`: a command spawned and awaited, or a function. */
1280
+ prepare?: PrepareHook;
1281
+ /** Take timing from a number in each command's own output (e.g. a build
1282
+ * tool's `built in 342ms`) instead of its wall clock. */
1283
+ timeSource?: TimeSource;
1284
+ /** Exact trial count; when set, `budgetMs` is ignored. */
1285
+ samples?: number;
1286
+ /** Wall-clock budget for the sampling loop, ms. Default: ~3s when neither
1287
+ * `samples` nor `budgetMs` is given. */
1288
+ budgetMs?: number;
1289
+ /** Floor on trials when no exact `samples` is given. */
1290
+ minSamples?: number;
1291
+ warmup?: number;
1292
+ /** Round-robin trials across commands instead of finishing each command
1293
+ * first, so drift hits every command equally. Default: true with 2+ commands. */
1294
+ interleave?: boolean;
1295
+ cwd?: string;
1296
+ env?: Record<string, string>;
1297
+ cpu?: boolean;
1298
+ heap?: boolean;
1299
+ cpuIntervalUs?: number;
1300
+ outDir?: string;
1301
+ /** Measure the machine's noise floor first (default true, ~200ms) and stamp
1302
+ * it on the document as `environment`. */
1303
+ noiseCheck?: boolean;
1304
+ /** SIGKILLs a trial (or prepare hook) still running after this many ms. No
1305
+ * default. A timed-out trial contributes no sample. */
1306
+ timeoutMs?: number;
1307
+ /** Abort kills in-flight trials, schedules no more, and resolves (never
1308
+ * rejects) with the completed measurements plus an `aborted` warning on the
1309
+ * last one. */
1310
+ signal?: AbortSignal;
1311
+ /** Exit codes to treat as success (hyperfine's `--ignore-failure`): the
1312
+ * trial still contributes its sample and gets no `nonzero-exit` warning. */
1313
+ ignoreExitCodes?: number[];
1314
+ }
1315
+
1316
+ export declare function time(opts: TimeOptions): Promise<ProfileDocument>;