ostia 0.2.9 → 0.2.10

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
 
@@ -217,36 +133,27 @@ export interface ProfileDocument {
217
133
  os: string;
218
134
  arch: string;
219
135
  };
136
+ /** ISO timestamp; metadata only, never part of an id. */
220
137
  createdAt: string;
221
138
  workloads: Workload[];
222
139
  measurements: Measurement[];
223
140
  comparisons?: Comparison[];
224
- /** Aggregate across `comparisons`, from `compareDocuments`/`ostia compare`/
225
- * `ostia ci`. Additive, no schema bump. Absent wherever `comparisons` is. */
141
+ /** Aggregate across `comparisons`; present wherever `comparisons` is. */
226
142
  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. */
143
+ /** Workload ids on only one side of the comparison; present wherever
144
+ * `comparisons` is. */
232
145
  unmatched?: {
233
146
  baseOnly: string[];
234
147
  candOnly: string[];
235
148
  };
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. */
149
+ /** Machine conditions at measurement time; absent when the noise check was
150
+ * skipped. */
239
151
  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
152
+ /** Run-level result of `ostia ab` / `ab()`, whose measurements are
243
153
  * `phase: "paired"`. */
244
154
  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. */
155
+ /** Repo state of the process's cwd; absent outside a git repo. Never part of
156
+ * an id or fingerprint, so a commit or dirty tree doesn't orphan a baseline. */
250
157
  git?: GitMetadata;
251
158
  }
252
159
 
@@ -257,9 +164,8 @@ interface GitMetadata {
257
164
  }
258
165
 
259
166
  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. */
167
+ /** `mad / median` of a reference workload, percent: how noisy the machine is
168
+ * right now, independent of what's measured. */
263
169
  floorPct: number;
264
170
  referenceMedianNs: number;
265
171
  samples: number;
@@ -274,69 +180,47 @@ interface Environment {
274
180
  }
275
181
 
276
182
  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. */
183
+ /** Identifies what is measured, not where or when. Subprocess: a hash of
184
+ * argv, `prepare` and `timeSource`, excluding `process.cwd()`. In-process: a
185
+ * hash of the function source, or for a registry entry the file, task name
186
+ * and `params`. Annotations (`label`, `description`, `baseline`) never
187
+ * affect it. */
286
188
  id: string;
287
189
  kind: "subprocess" | "inprocess";
288
190
  label?: string;
289
191
  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). */
192
+ /** Command-form `prepare` hook run before every trial; part of the id. A
193
+ * function-form hook isn't serializable and is omitted (its source is still
194
+ * hashed into the id). */
296
195
  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. */
196
+ /** Takes samples from a number in the command's own output instead of the
197
+ * wall clock; part of the id. */
301
198
  timeSource?: {
302
199
  pattern: string;
303
200
  group?: number;
304
201
  unit?: "ns" | "us" | "ms" | "s";
305
202
  };
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. */
203
+ /** `task` is the registry's "group/name" id; `group` is the enclosing
204
+ * `group()` name. Renderers prefer `group` over splitting `task` on "/". */
309
205
  entry?: {
310
206
  file: string;
311
207
  task: string;
312
208
  group?: string;
313
209
  };
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. */
210
+ /** The Relative reference for its group; renderers use the first one found. */
317
211
  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. */
212
+ /** What this task measures and why, from `task(..., { description })`. */
321
213
  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. */
214
+ /** The enclosing group's description, repeated on every workload in it. */
324
215
  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. */
216
+ /** Ran in a subprocess of its own rather than sharing its suite file's. */
328
217
  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. */
218
+ /** From `task(..., { params })` or a `sweep()` point. Part of the id, so
219
+ * points sharing a task name don't collide. */
334
220
  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. */
221
+ /** From `task.skip()` / `group.skip()`: never measured, so no `Measurement`.
222
+ * Renderers print a "- skipped" row; `compare` treats it as `unchanged` with
223
+ * a `skipped` warning. */
340
224
  skipped?: boolean;
341
225
  }
342
226
 
@@ -355,38 +239,39 @@ interface Measurement {
355
239
  heap?: HeapEvidence;
356
240
  memory?: MemoryEvidence;
357
241
  jit?: JitTierBreakdown;
358
- /** `phase: "paired"` only: the base side and the per-round ratios. The
359
- * measurement's `timing` is the candidate side. */
242
+ /** `phase: "paired"` only; `timing` is then the candidate side. */
360
243
  paired?: PairedEvidence;
244
+ /** `phase: "paired"` only: the task threw on one side or both
245
+ * (`"both"`). Usually instead of `timing`/`paired`; when it threw in a
246
+ * confirmation repeat, `repeat` (from 1) says which, and `timing`/`paired`
247
+ * stay as the first process measured them. Either way the task isn't in
248
+ * `matched` or the geomean. */
249
+ threw?: {
250
+ side: "base" | "cand" | "both";
251
+ message: string;
252
+ repeat?: number;
253
+ };
361
254
  warnings: Warning[];
362
255
  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. */
256
+ /** Trials ran round-robin with the other commands of the same `time()` call,
257
+ * so drift over the run hits every command equally. */
369
258
  interleaved?: boolean;
370
259
  }
371
260
 
372
261
  interface Trial {
373
262
  i: number;
374
263
  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. */
264
+ /** The command's self-reported time (ns) under a `timeSource`; these, not
265
+ * `wallNs`, are then the `timing.samples`. */
378
266
  reportedNs?: number;
379
267
  exitCode?: number;
380
268
  userNs?: number;
381
269
  systemNs?: number;
382
270
  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. */
271
+ /** Killed by `timeoutMs`; `exitCode` is absent. Contributes no sample. */
386
272
  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. */
273
+ /** Output didn't match the `timeSource` pattern; `reportedNs` is absent
274
+ * (never a `wallNs` fallback). Contributes no sample. */
390
275
  timeSourceNoMatch?: true;
391
276
  }
392
277
 
@@ -408,15 +293,11 @@ interface TimingStats {
408
293
  p75: number;
409
294
  /** 99th percentile, ns. */
410
295
  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. */
296
+ /** Median absolute deviation, ns: a spread measure that, unlike stddev, the
297
+ * long right tail of wall-clock timings doesn't skew. */
414
298
  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`. */
299
+ /** Calls batched into one timed block; absent (read as 1) unless batching
300
+ * occurred, and always absent for subprocess timing. */
420
301
  batch?: number;
421
302
  }
422
303
 
@@ -466,25 +347,16 @@ interface HeapEvidence {
466
347
 
467
348
  interface MemoryEvidence {
468
349
  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"`. */
350
+ /** `memstats` only: `"retained"` (`--alloc`, `bytesPerOp`) or `"peak"`
351
+ * (`--peak-mem`, `peakBytes`). Absent means `"retained"` on older documents. */
474
352
  kind?: "retained" | "peak";
475
353
  /** Largest `Trial.maxRssBytes` across a timing measurement's trials. */
476
354
  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. */
355
+ /** `--alloc`: heap growth per call across one batch bracketed by full GCs,
356
+ * so what the calls keep alive (a leak check), not what they allocate. */
483
357
  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. */
358
+ /** `--peak-mem`: how far one call raised RSS, garbage included; median over
359
+ * 3 fresh processes. A `peak-hidden` warning says when it may read low. */
488
360
  peakBytes?: number;
489
361
  }
490
362
 
@@ -504,8 +376,7 @@ interface JitTierBreakdown {
504
376
  }
505
377
 
506
378
  interface PairedEvidence {
507
- /** Rounds run: one base batch and one candidate batch each, alternating
508
- * which goes first. */
379
+ /** One base batch and one candidate batch per round, alternating order. */
509
380
  rounds: number;
510
381
  /** Calls per side per round. */
511
382
  batch: number;
@@ -517,27 +388,48 @@ interface PairedEvidence {
517
388
  ratios: number[];
518
389
  medianRatio: number;
519
390
  /** 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. */
391
+ ratioP25: number;
392
+ ratioP75: number;
393
+ /** Median ratio past `1 ± threshold` and on the same side of 1 in at least
394
+ * three quarters of rounds. */
525
395
  flagged?: "regressed" | "improved";
526
396
  /** Each fresh-process re-measurement of a flagged workload. */
527
397
  repeats?: {
528
398
  medianRatio: number;
529
- p25: number;
530
- p75: number;
399
+ ratioP25: number;
400
+ ratioP75: number;
531
401
  flagged?: "regressed" | "improved";
532
402
  }[];
533
403
  /** Set when `flagged` is: whether every repeat flagged the same way. */
534
404
  confirmed?: boolean;
535
405
  /** `flagged` when confirmed, else `"unchanged"`. */
536
406
  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. */
407
+ /** First call's return values were deep-equal on both sides; informational,
408
+ * never a failure. */
540
409
  sameOutput: boolean;
410
+ /** Set when the suite file's text differs from the base ref's copy, so
411
+ * each side may run a different benchmark (a fixed fixture, a changed
412
+ * input). With `sameOutput: false` too, the task is "not comparable": its
413
+ * verdict is `"unchanged"` and it's left out of the geomean. */
414
+ suiteChanged?: true;
415
+ /** `--alloc`: heap each call keeps alive after a full GC, per side, after
416
+ * a warmup batch; each the median of 3 fresh processes. */
417
+ retained?: MemoryChange;
418
+ /** `--peak-mem`: how far the task's first call raises RSS, per side,
419
+ * each the median of 3 fresh processes. */
420
+ peak?: MemoryChange;
421
+ }
422
+
423
+ interface MemoryChange {
424
+ baseBytes: number;
425
+ candBytes: number;
426
+ /** Past both `AbSummary.memory.thresholdPct` of the base and
427
+ * `floorBytes`; always `"unchanged"` on a not-comparable task. */
428
+ verdict: "regressed" | "improved" | "unchanged";
429
+ /** The smallest change that counts, bytes: 16 KiB over the batch's calls
430
+ * for `retained`; for `peak`, 1 MiB, or the freed memory a `peak-hidden`
431
+ * warning reports. */
432
+ floorBytes: number;
541
433
  }
542
434
 
543
435
  interface AbSummary {
@@ -545,6 +437,9 @@ interface AbSummary {
545
437
  ref: string;
546
438
  sha: string;
547
439
  };
440
+ /** Suite files, as given, that don't exist at the base ref: their tasks
441
+ * are all candidate-only. */
442
+ newSuites?: string[];
548
443
  rounds: number;
549
444
  thresholdPct: number;
550
445
  geomeanThresholdPct: number;
@@ -559,17 +454,31 @@ interface AbSummary {
559
454
  unconfirmed: number;
560
455
  /** Workloads whose first call returned different values on each side. */
561
456
  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. */
457
+ /** Workloads whose suite file and output both changed (see
458
+ * `PairedEvidence.suiteChanged`); counted in `unchanged`, left out of
459
+ * `geomeanPct`. */
460
+ notComparable: number;
461
+ /** Workloads that threw on either side or both, and so weren't timed;
462
+ * not counted in `matched`. */
463
+ threw: number;
464
+ /** Present when any task had a memory reading (`--alloc`, `--peak-mem`):
465
+ * how many tasks' memory regressed or improved, on either reading. */
466
+ memory?: {
467
+ thresholdPct: number;
468
+ regressed: number;
469
+ improved: number;
470
+ };
471
+ /** Geometric mean of the workloads' median ratios, signed percent (negative:
472
+ * candidate faster); a flagged workload contributes its median over its main
473
+ * run and repeats. `null` when nothing was paired. */
566
474
  geomeanPct: number | null;
567
- /** `"fail"` when any workload regressed (confirmed) or `geomeanPct`
568
- * exceeds `geomeanThresholdPct`. */
475
+ /** `"fail"` when any workload regressed (confirmed), threw on the
476
+ * candidate side only, or `geomeanPct` exceeds `geomeanThresholdPct`, or
477
+ * any task's memory regressed. */
569
478
  verdict: "pass" | "fail";
570
479
  }
571
480
 
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"];
481
+ 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
482
 
574
483
  export type WarningCode = (typeof WARNING_CODES)[number];
575
484
 
@@ -594,19 +503,16 @@ export interface Comparison {
594
503
  timing?: {
595
504
  medianDeltaPct: number;
596
505
  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). */
506
+ /** 95% bootstrap CI on the difference of medians, percent of the baseline
507
+ * median. Absent below 5 samples a side (`thin-comparison`). */
600
508
  ci95?: [number, number];
601
- /** Two-sided Mann-Whitney U p-value, tie-corrected normal approximation.
602
- * Same absence condition as `ci95`. */
509
+ /** Two-sided Mann-Whitney U p-value; absent whenever `ci95` is. */
603
510
  pValue?: number;
604
- /** Seed for the bootstrap's PRNG, so `ci95` is reproducible. */
511
+ /** Bootstrap PRNG seed, so `ci95` is reproducible. */
605
512
  seed?: number;
606
513
  verdict: "improved" | "regressed" | "unchanged";
607
514
  };
608
- /** Attached when timing fell back to the point-estimate rule (thin
609
- * samples) or otherwise carries a caveat about this comparison. */
515
+ /** Caveats about this comparison, e.g. a thin-sample point-estimate verdict. */
610
516
  warnings?: Warning[];
611
517
  frames?: {
612
518
  frameKey: string;
@@ -630,27 +536,23 @@ export interface Comparison {
630
536
  minFrameSelfUs: number;
631
537
  alpha: number;
632
538
  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`. */
539
+ /** `max(timingPct, both documents' noise floors)`: what timing was tested against. */
636
540
  effectiveTimingPct: number;
637
541
  };
638
542
  verdict: "pass" | "fail";
639
543
  }
640
544
 
641
545
  interface ComparisonSummary {
642
- /** `comparisons.length`: workloads present (and comparable) on both sides. */
546
+ /** `comparisons.length`. */
643
547
  matched: number;
644
548
  regressed: number;
645
549
  improved: number;
646
550
  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. */
551
+ /** Geometric mean of `cand/base` median ratios, signed percent (negative:
552
+ * candidate faster). `null` when no comparison had a finite ratio. */
650
553
  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. */
554
+ /** `Comparison.thresholds.effectiveTimingPct`, which is the same for the
555
+ * whole document pair. */
654
556
  effectiveTimingPct: number;
655
557
  /** `"fail"` when any comparison's verdict is `"fail"`. */
656
558
  verdict: "pass" | "fail";
@@ -673,36 +575,36 @@ export interface TimeSource {
673
575
  unit?: TimeUnit;
674
576
  }
675
577
 
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;
578
+ export type AbProgress = {
579
+ phase: "setup";
580
+ command: string;
581
+ } | {
582
+ phase: "measure";
583
+ suite: number;
584
+ suites: number;
585
+ file: string;
586
+ /** Each suite runs twice, once with each side first. */
587
+ pass: number;
588
+ passes: number;
589
+ task: number;
590
+ tasks: number;
591
+ label: string;
592
+ } | {
593
+ phase: "confirm";
594
+ repeat: number;
595
+ repeats: number;
596
+ label: string;
597
+ } | {
598
+ phase: "memory";
599
+ run: number;
600
+ runs: number;
601
+ side: Side;
602
+ label: string;
603
+ };
702
604
 
703
- interface AbOptions {
605
+ export interface AbOptions {
704
606
  suites: string[];
705
- /** Git ref whose committed tree is the base side (default: `"HEAD"`). The
607
+ /** Git ref whose committed tree is the base side (default: `"HEAD"`); the
706
608
  * candidate is the working tree, uncommitted changes included. */
707
609
  base?: string;
708
610
  /** Rounds per workload, each one base batch and one candidate batch of
@@ -715,9 +617,8 @@ interface AbOptions {
715
617
  * this many percent (default: 1.5): a broad slowdown too small to flag any
716
618
  * one workload. */
717
619
  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). */
620
+ /** Fresh-process repeats of each flagged workload, which only counts when
621
+ * every repeat flags it the same way (default: 2; 0 trusts the first). */
721
622
  confirm?: number;
722
623
  filter?: string;
723
624
  preload?: string[];
@@ -725,98 +626,112 @@ interface AbOptions {
725
626
  outDir?: string;
726
627
  cwd?: string;
727
628
  noiseCheck?: boolean;
728
- /** Kills a runner process (one suite file, or one repeat) after this many
729
- * ms. No default. */
629
+ /** Also compare each side's retained heap per call (see
630
+ * `bench({ alloc })`) and how far its first call raises RSS (see
631
+ * `bench({ peakMem })`), each the median of 3 fresh processes per side.
632
+ * Task/group options override these. */
633
+ alloc?: boolean;
634
+ peakMem?: boolean;
635
+ /** Flag a memory reading that moves past this many percent of the base's
636
+ * and past its noise floor (default: 10). A memory regression fails the
637
+ * run. */
638
+ memThresholdPct?: number;
639
+ /** Shell commands run once, in order, in a freshly extracted base tree
640
+ * before it's used: to build what the suites import but git doesn't hold
641
+ * (generated or gitignored files). Each runs with its cwd at the tree's
642
+ * counterpart of `cwd`, the project's `node_modules` linked in, and
643
+ * `OSTIA_AB_SHA` / `OSTIA_AB_CANDIDATE_DIR` (the working tree's `cwd`) in
644
+ * its environment. The tree is cached per commit and command list. */
645
+ baseSetup?: string | string[];
646
+ /** Base trees to keep under `<outDir>/ab`, counting this run's: after a
647
+ * run, older ones go, least recently used first, unless one was used in
648
+ * the last hour (another run may still need it). Default: 5. */
649
+ keepTrees?: number;
650
+ /** SIGKILLs a runner process (one suite file or repeat), or a `baseSetup`
651
+ * command, after this many ms. */
730
652
  timeoutMs?: number;
731
653
  /** Aborting kills the running process and resolves with the suites that
732
- * had finished, plus an `aborted` warning; repeats not yet run leave their
654
+ * had finished, plus an `aborted` warning; unrun repeats leave their
733
655
  * workloads unconfirmed. */
734
656
  signal?: AbortSignal;
657
+ /** Called as each setup command, task, confirmation repeat and memory
658
+ * process starts. */
659
+ onProgress?: (progress: AbProgress) => void;
660
+ }
661
+
662
+ export declare class AbBaseError extends OstiaUsageError {
663
+ }
664
+
665
+ export declare class AbSuiteError extends Error {
666
+ readonly side: Side;
667
+ constructor(message: string, side: Side);
668
+ }
669
+
670
+ export declare class AbSetupError extends Error {
735
671
  }
736
672
 
737
673
  export declare function ab(opts: AbOptions): Promise<ProfileDocument>;
738
674
 
739
- interface BenchOptions {
675
+ declare class OstiaUsageError extends Error {
676
+ }
677
+
678
+ type Side = "base" | "cand";
679
+
680
+ export interface BenchOptions {
740
681
  suites: string[];
741
682
  /** Wall-clock sampling budget per task, ms (default 500). */
742
683
  budgetMs?: number;
743
- /** Exact trial count per task. When set, the budget is ignored - the
744
- * in-process equivalent of `time()`'s `samples`. */
684
+ /** Exact trial count per task; the budget is ignored when set. */
745
685
  samples?: number;
746
686
  minSamples?: number;
747
687
  gc?: boolean;
748
688
  /** 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. */
689
+ * under the JSC sampling profiler for ~2,000 samples, JIT tiers included),
690
+ * never mixed into the timing numbers. Task/group `cpu` overrides this. */
752
691
  cpu?: boolean;
753
692
  /** `cpu`'s sampling interval, µs (default: 100). A coarser interval
754
693
  * lengthens the capture to keep the sample count, up to 10s per task. */
755
694
  cpuIntervalUs?: number;
756
695
  /** Capture one extra `phase: "memstats"` measurement per task: retained
757
696
  * 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. */
697
+ * What the calls keep alive, not what they allocate. Task/group `alloc`
698
+ * overrides this. */
761
699
  alloc?: boolean;
762
700
  /** 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. */
701
+ * task's first call raises RSS, garbage included. Median of 3 fresh
702
+ * processes, each run with `OSTIA_PEAK_MEM=1` in its environment (see
703
+ * `measurePeakMem`). Task/group `peakMem` overrides this. */
768
704
  peakMem?: boolean;
769
705
  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. */
706
+ /** Suite files (or isolated tasks) to run at once, each in its own child
707
+ * process (default: 1). Concurrent CPU-bound processes contend for cores,
708
+ * caches and turbo headroom, so timings under `jobs > 1` are noisier and
709
+ * not like-for-like with a baseline measured at 1. */
778
710
  jobs?: number;
779
711
  outDir?: string;
780
712
  cwd?: string;
781
713
  /** 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. */
714
+ * isolating JIT tier state, inline caches and heap shape between tasks.
715
+ * Task/group `isolate` overrides this. Multiplies spawn overhead by task
716
+ * count instead of file count. */
788
717
  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. */
718
+ /** Scripts run, in order, before each suite file loads, in the same
719
+ * subprocess: install globals (jsdom) or a `Bun.plugin()` loader ahead of
720
+ * the suite's top-level code. */
794
721
  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). */
722
+ /** Extra flags for the `bun` invocation that runs each suite file (e.g.
723
+ * `["--conditions", "browser"]`), placed before the runner script path. */
801
724
  bunFlags?: string[];
802
725
  /** 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. */
726
+ * (default: true), and stamp it on the document as `environment`. */
805
727
  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. */
728
+ /** SIGKILLs a suite file's subprocess (or, under `isolate`, one task's) that
729
+ * hasn't finished after this many ms. No default. */
811
730
  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. */
731
+ /** Aborting kills in-flight subprocesses, starts no new ones, and resolves
732
+ * (never rejects) with whatever suites/tasks had finished, plus an
733
+ * `aborted` warning on the last measurement. A suite killed mid-run is
734
+ * dropped entirely: it only writes its result once, at the end. */
820
735
  signal?: AbortSignal;
821
736
  }
822
737
 
@@ -907,38 +822,39 @@ export interface GroupOptions {
907
822
  after?: Hook;
908
823
  }
909
824
 
910
- interface GroupFn {
911
- (name: string, fn: () => void, opts?: GroupOptions): void;
825
+ type GroupRegistrar = (name: string, fn: () => void, opts?: GroupOptions) => void;
826
+
827
+ interface GroupFn extends GroupRegistrar {
912
828
  /** Registers every task inside as skipped: the runner never measures
913
829
  * them, but the document still carries their workloads (marked
914
830
  * `Workload.skipped`). */
915
- skip: (name: string, fn: () => void, opts?: GroupOptions) => void;
831
+ skip: GroupRegistrar;
916
832
  /** When any task or group in the suite uses `.only`, the runner restricts
917
833
  * the whole suite file to only those tasks (before `--filter` narrows
918
834
  * further) and prints a one-line notice to stderr. */
919
- only: (name: string, fn: () => void, opts?: GroupOptions) => void;
835
+ only: GroupRegistrar;
920
836
  }
921
837
 
922
838
  export declare const group: GroupFn;
923
839
 
924
- interface TaskFn {
925
- (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions): void;
840
+ type TaskRegistrar = (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
841
+
842
+ interface TaskFn extends TaskRegistrar {
926
843
  /** Registers the task as skipped: the runner never measures it, but the
927
844
  * document still carries its workload (marked `Workload.skipped`) so a
928
845
  * renderer or `compare` can say so explicitly instead of the task simply
929
846
  * being absent. */
930
- skip: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
847
+ skip: TaskRegistrar;
931
848
  /** When any task or group in the suite uses `.only`, the runner restricts
932
849
  * the whole suite file to only those tasks (before `--filter` narrows
933
850
  * further) and prints a one-line notice to stderr. */
934
- only: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
851
+ only: TaskRegistrar;
935
852
  }
936
853
 
937
854
  export declare const task: TaskFn;
938
855
 
939
856
  export interface RunOptions extends MeasureTasksOpts {
940
- /** Regex, matched against "group/name" task ids - same semantics as
941
- * `ostia bench --filter` / `bench({ filter })`. */
857
+ /** Regex matched against "group/name" task ids, as in `bench({ filter })`. */
942
858
  filter?: string;
943
859
  /** Skip printing the report to stdout; still returns the document. */
944
860
  quiet?: boolean;
@@ -950,6 +866,11 @@ export declare function run(opts?: RunOptions): Promise<ProfileDocument>;
950
866
 
951
867
  type FormatName = "table" | "json" | "markdown" | "jsonl" | "minimal" | "collapsed" | "mermaid" | "speedscope" | "cpuprofile";
952
868
 
869
+ export interface VizOptions {
870
+ /** Render this measurement only, instead of every CPU measurement. */
871
+ measurementId?: string;
872
+ }
873
+
953
874
  interface RenderResult {
954
875
  text?: string;
955
876
  files?: {
@@ -964,32 +885,44 @@ interface Renderer<O = unknown> {
964
885
  }
965
886
 
966
887
  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). */
888
+ /** Suite-wide default for capturing an extra `phase: "cpu"` measurement per
889
+ * task (task/group `cpu` still wins). */
969
890
  cpu?: boolean;
970
891
  /** `cpu`'s sampling interval, µs (default: 100). */
971
892
  cpuIntervalUs?: number;
972
893
  /** Suite-wide default for capturing an extra `phase: "memstats"`
973
- * measurement per task (task/group `TaskOptions.alloc`/`GroupOptions.alloc`
974
- * still win). */
894
+ * measurement per task (task/group `alloc` still wins). */
975
895
  alloc?: boolean;
976
- /** Stamped onto every workload this call produces, recording whether it
977
- * ran in a subprocess dedicated to it alone. */
896
+ /** Stamped onto every workload: it ran in a subprocess dedicated to it. */
978
897
  markIsolated?: boolean;
979
898
  /** 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. */
899
+ * true) and stamp it on the document as `environment`. */
982
900
  noiseCheck?: boolean;
983
901
  }
984
902
 
903
+ interface InprocessTimingOptions {
904
+ /** Wall-clock budget for the sampling loop, per task (default: 500). */
905
+ budgetMs?: number;
906
+ /** Exact trial count; the budget is ignored. */
907
+ samples?: number;
908
+ /** Keep sampling past the budget until this many trials exist. When unset
909
+ * the floor is cost-aware (see `defaultSampleFloor`). */
910
+ minSamples?: number;
911
+ /** Warmup budget as a fraction of `budgetMs` (default: 0.1); unlike
912
+ * `time()`'s `warmup`, which is a trial count. Always runs at least one call. */
913
+ warmup?: number;
914
+ gc?: boolean;
915
+ }
916
+
917
+ export declare function keep(value: unknown): void;
918
+
985
919
  type SweepPoint<T extends Record<string, readonly unknown[]>> = {
986
920
  [K in keyof T]: T[K][number];
987
921
  };
988
922
 
989
923
  export declare function sweep<T extends Record<string, readonly unknown[]>>(dims: T, fn: (point: SweepPoint<T>) => void): void;
990
924
 
991
- export declare function newDocument(workloads: Workload[], measurements: Measurement[], environment?: Environment): ProfileDocument;
992
- export { newDocument as createDocument };
925
+ export declare function createDocument(workloads: Workload[], measurements: Measurement[], environment?: Environment): ProfileDocument;
993
926
 
994
927
  export declare function saveDocument(doc: ProfileDocument, path: string): Promise<void>;
995
928
 
@@ -1005,9 +938,45 @@ export declare class OstiaDocumentError extends Error {
1005
938
 
1006
939
  export declare function loadDocument(path: string): Promise<ProfileDocument>;
1007
940
 
1008
- export declare const renderers: Record<FormatName, Renderer<any>>;
941
+ export interface ProfileOptions {
942
+ /** Sampling interval in µs; default 1000. Named like `time()`'s and
943
+ * `bench()`'s option, the config key and `--cpu-interval`. */
944
+ cpuIntervalUs?: number;
945
+ /** Names the workload: it becomes the label and, instead of the function's
946
+ * name and source, what the id hashes, so closures that differ only in
947
+ * captured values get distinct ids and the id survives editing the function. */
948
+ name?: string;
949
+ /** `"inspector"` (default) captures through CDP and writes a portable
950
+ * .cpuprofile; `"jsc"` uses `bun:jsc.profile` and adds LLInt/Baseline/DFG/FTL
951
+ * tier data. */
952
+ origin?: "inspector" | "jsc";
953
+ /** There's no child to kill, so an already-aborted signal skips the profiler,
954
+ * calls `fn` plain (still returning its `result`) and records an `aborted`
955
+ * warning instead of CPU evidence. It can't interrupt a running `fn`. */
956
+ signal?: AbortSignal;
957
+ }
958
+
959
+ export interface ProfileResult<T> {
960
+ result: T;
961
+ measurement: Measurement;
962
+ document: ProfileDocument;
963
+ }
1009
964
 
1010
- export declare const MINIMAL_PROTOCOL_VERSION: 1;
965
+ export declare function profile<T>(fn: () => T | Promise<T>, opts?: ProfileOptions): Promise<ProfileResult<T>>;
966
+
967
+ export declare const renderers: {
968
+ table: Renderer<unknown>;
969
+ json: Renderer<unknown>;
970
+ markdown: Renderer<unknown>;
971
+ jsonl: Renderer<unknown>;
972
+ minimal: Renderer<MinimalRenderOptions>;
973
+ collapsed: Renderer<VizOptions>;
974
+ mermaid: Renderer<MermaidOptions>;
975
+ speedscope: Renderer<VizOptions>;
976
+ cpuprofile: Renderer<VizOptions>;
977
+ };
978
+
979
+ export declare const MINIMAL_PROTOCOL_VERSION: 2;
1011
980
 
1012
981
  interface MinimalWarning {
1013
982
  code: string;
@@ -1019,14 +988,11 @@ interface MinimalDelta {
1019
988
  meanPct: number;
1020
989
  verdict: "improved" | "regressed" | "unchanged";
1021
990
  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. */
991
+ /** 95% bootstrap CI on the difference of medians; absent (with `pValue`)
992
+ * on a thin (<5 samples/side) comparison. */
1025
993
  ci95?: [number, number];
1026
994
  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`. */
995
+ /** The threshold actually tested against, after machine noise widened it. */
1030
996
  effectiveTimingPct: number;
1031
997
  matched: true;
1032
998
  }
@@ -1035,44 +1001,51 @@ interface MinimalPaired {
1035
1001
  /** Base side's median per-call time, ns. */
1036
1002
  baseMedian: number;
1037
1003
  medianRatio: number;
1038
- p25: number;
1039
- p75: number;
1004
+ ratioP25: number;
1005
+ ratioP75: number;
1040
1006
  rounds: number;
1041
- /** Final: `flagged` when fresh processes confirmed it, else
1042
- * `"unchanged"`. */
1007
+ /** `flagged` when fresh processes confirmed it, else `"unchanged"`. */
1043
1008
  verdict: "regressed" | "improved" | "unchanged";
1044
- /** What this workload's first process saw, before confirmation. */
1009
+ /** What the first process saw, before confirmation. */
1045
1010
  flagged?: "regressed" | "improved";
1046
1011
  confirmed?: boolean;
1047
1012
  /** Median ratio of each fresh-process repeat. */
1048
1013
  repeats?: number[];
1049
1014
  sameOutput: boolean;
1015
+ /** The suite file differs from the base's copy. */
1016
+ suiteChanged?: true;
1017
+ /** `--alloc`: retained heap per call on each side. */
1018
+ retained?: MinimalMemoryChange;
1019
+ /** `--peak-mem`: first-call RSS rise on each side. */
1020
+ peak?: MinimalMemoryChange;
1021
+ }
1022
+
1023
+ interface MinimalMemoryChange {
1024
+ base: number;
1025
+ cand: number;
1026
+ verdict: "regressed" | "improved" | "unchanged";
1027
+ floor: number;
1050
1028
  }
1051
1029
 
1052
1030
  interface MinimalRunLine {
1053
1031
  event: "run";
1054
1032
  protocolVersion: typeof MINIMAL_PROTOCOL_VERSION;
1055
1033
  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). */
1034
+ /** Join key to `Workload.id` / `Comparison.candidateMeasurementId`; unlike
1035
+ * `task`, stable across invocations. */
1059
1036
  workloadId: string;
1060
1037
  task: string;
1061
1038
  group?: string;
1062
1039
  description?: string;
1063
1040
  groupDescription?: string;
1064
- /** From `task(name, fn, { params })` or a `sweep()` point. */
1065
1041
  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. */
1042
+ /** `task.skip()` / `group.skip()`: no measurement, so every stats field is absent. */
1068
1043
  skipped?: true;
1069
1044
  unit?: "ns";
1045
+ /** 0, with every stats field absent, when no trial produced a sample (all
1046
+ * timed out or missed the `timeSource` pattern); see `warnings`. */
1070
1047
  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). */
1048
+ /** In-process trials batched into one timed block; 1 when never batched. */
1076
1049
  batch: number;
1077
1050
  mean?: number;
1078
1051
  median?: number;
@@ -1087,24 +1060,30 @@ interface MinimalRunLine {
1087
1060
  /** Median user / system CPU time per trial, ns: subprocess commands only. */
1088
1061
  userNs?: number;
1089
1062
  systemNs?: number;
1090
- /** `--alloc`: heap each call keeps alive after a full GC, whole bytes. */
1063
+ /** `--alloc`: heap each call keeps alive after a full GC, bytes. */
1091
1064
  retainedBytesPerOp?: number;
1092
1065
  /** `--peak-mem`: how far the task's first call raised RSS, bytes. */
1093
1066
  peakBytes?: number;
1094
1067
  /** 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. */
1068
+ * fastest); only when the document has more than one timing run. */
1096
1069
  relative?: number;
1097
1070
  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. */
1071
+ /** The machine's noise floor from `document.environment`; absent when the
1072
+ * noise check was skipped. */
1101
1073
  noiseFloorPct?: number;
1102
1074
  warnings: MinimalWarning[];
1103
- /** From `comparisons` when present (ostia compare / ci): the change against
1104
- * the baseline document for this task. */
1075
+ /** The change against the baseline document (`compare` / `ci`). */
1105
1076
  delta?: MinimalDelta;
1106
- /** `ab` only; see `MinimalPaired`. */
1107
1077
  paired?: MinimalPaired;
1078
+ /** `ab` only, instead of stats and `paired`: the task threw on one side
1079
+ * (`"base"`/`"cand"`) or both, so it isn't judged on time. `message` is
1080
+ * the error's first lines; `repeat` (from 1) is set when it threw in a
1081
+ * confirmation repeat. */
1082
+ threw?: {
1083
+ side: "base" | "cand" | "both";
1084
+ message: string;
1085
+ repeat?: number;
1086
+ };
1108
1087
  }
1109
1088
 
1110
1089
  interface MinimalUnmatchedLine {
@@ -1144,19 +1123,112 @@ interface MinimalSummaryLine {
1144
1123
  };
1145
1124
  /** `ab` only: the gate on `geomeanPct`. */
1146
1125
  geomeanThresholdPct?: number;
1147
- /** `ab` only: flagged in the first process but not reproduced by the
1148
- * fresh-process repeats; counted in `unchanged`. */
1126
+ /** `ab` only: flagged in the first process but not reproduced; counted in `unchanged`. */
1149
1127
  unconfirmed?: number;
1150
- /** `ab` only: workloads whose first call returned different values on the
1151
- * two sides. Informational. */
1128
+ /** `ab` only: workloads whose first call returned different values on the two sides. */
1152
1129
  outputDiffers?: number;
1130
+ notComparable?: number;
1131
+ threw?: number;
1132
+ newSuites?: string[];
1133
+ /** `ab` only, with `--alloc` or `--peak-mem`: tasks whose memory
1134
+ * regressed or improved, and the threshold, percent. */
1135
+ memory?: {
1136
+ thresholdPct: number;
1137
+ regressed: number;
1138
+ improved: number;
1139
+ };
1153
1140
  git?: {
1154
1141
  base?: GitMetadata;
1155
1142
  cand?: GitMetadata;
1156
1143
  };
1157
1144
  exportedTo?: string;
1158
- verdict: "pass" | "fail";
1145
+ /** `pass` (exit 0), `fail` (exit 1: a regression), else `error` (exit 2, or
1146
+ * 130 when cancelled): no verdict was reached. */
1147
+ verdict: "pass" | "fail" | "error";
1159
1148
  exitCode: number;
1160
1149
  }
1161
1150
 
1162
1151
  export type MinimalEvent = MinimalRunLine | MinimalUnmatchedLine | MinimalSummaryLine;
1152
+
1153
+ export interface MinimalProtocolContext {
1154
+ command: "compare" | "ci" | "ab";
1155
+ exitCode: number;
1156
+ unmatched?: {
1157
+ baseOnly: Workload[];
1158
+ candOnly: Workload[];
1159
+ };
1160
+ baseGit?: GitMetadata;
1161
+ candGit?: GitMetadata;
1162
+ baseline?: {
1163
+ name: string;
1164
+ path: string;
1165
+ };
1166
+ cached?: number;
1167
+ executed?: number;
1168
+ failed?: number;
1169
+ missingBaseline?: number;
1170
+ exportedTo?: string;
1171
+ }
1172
+
1173
+ export interface MinimalRenderOptions {
1174
+ protocol?: MinimalProtocolContext;
1175
+ }
1176
+
1177
+ export interface MermaidOptions extends VizOptions {
1178
+ topN?: number;
1179
+ }
1180
+
1181
+ export interface CommandSpec {
1182
+ command: string | string[];
1183
+ label?: string;
1184
+ /** Overrides `TimeOptions.prepare` for this command. */
1185
+ prepare?: PrepareHook;
1186
+ /** Overrides `TimeOptions.timeSource` for this command. */
1187
+ timeSource?: TimeSource;
1188
+ /** Overrides `TimeOptions.timeoutMs` for this command. */
1189
+ timeoutMs?: number;
1190
+ /** Overrides `TimeOptions.ignoreExitCodes` for this command. */
1191
+ ignoreExitCodes?: number[];
1192
+ }
1193
+
1194
+ export interface TimeOptions {
1195
+ commands: (string | string[] | CommandSpec)[];
1196
+ /** Runs unmeasured before every trial (warmup and instrumented included), in
1197
+ * `cwd`/`env`: a command spawned and awaited, or a function. */
1198
+ prepare?: PrepareHook;
1199
+ /** Take timing from a number in each command's own output (e.g. a build
1200
+ * tool's `built in 342ms`) instead of its wall clock. */
1201
+ timeSource?: TimeSource;
1202
+ /** Exact trial count; when set, `budgetMs` is ignored. */
1203
+ samples?: number;
1204
+ /** Wall-clock budget for the sampling loop, ms. Default: ~3s when neither
1205
+ * `samples` nor `budgetMs` is given. */
1206
+ budgetMs?: number;
1207
+ /** Floor on trials when no exact `samples` is given. */
1208
+ minSamples?: number;
1209
+ warmup?: number;
1210
+ /** Round-robin trials across commands instead of finishing each command
1211
+ * first, so drift hits every command equally. Default: true with 2+ commands. */
1212
+ interleave?: boolean;
1213
+ cwd?: string;
1214
+ env?: Record<string, string>;
1215
+ cpu?: boolean;
1216
+ heap?: boolean;
1217
+ cpuIntervalUs?: number;
1218
+ outDir?: string;
1219
+ /** Measure the machine's noise floor first (default true, ~200ms) and stamp
1220
+ * it on the document as `environment`. */
1221
+ noiseCheck?: boolean;
1222
+ /** SIGKILLs a trial (or prepare hook) still running after this many ms. No
1223
+ * default. A timed-out trial contributes no sample. */
1224
+ timeoutMs?: number;
1225
+ /** Abort kills in-flight trials, schedules no more, and resolves (never
1226
+ * rejects) with the completed measurements plus an `aborted` warning on the
1227
+ * last one. */
1228
+ signal?: AbortSignal;
1229
+ /** Exit codes to treat as success (hyperfine's `--ignore-failure`): the
1230
+ * trial still contributes its sample and gets no `nonzero-exit` warning. */
1231
+ ignoreExitCodes?: number[];
1232
+ }
1233
+
1234
+ export declare function time(opts: TimeOptions): Promise<ProfileDocument>;