ostia 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -13,8 +13,9 @@ regression. `ostia ci` gates a whole `ostia.config.json`/`ostia.config.ts` of
13
13
  workloads - subprocess commands and in-process `group()`/`task()` suites alike -
14
14
  against a saved baseline, skipping anything whose input fingerprint hasn't changed.
15
15
  Any task can run in its own subprocess for clean JIT/heap isolation from its
16
- suite-mates, and every renderer has a `minimal` JSON-lines mode built for piping
17
- straight into an LLM agent's context.
16
+ suite-mates, and every command has a `--format minimal` mode - a versioned JSON
17
+ protocol built for piping straight into an LLM agent's context (see
18
+ [Using ostia from an AI agent](#using-ostia-from-an-ai-agent)).
18
19
 
19
20
  Zero runtime dependencies. Requires Bun ≥ 1.4.
20
21
 
@@ -101,6 +102,46 @@ ostia ci # on your branch: rerun changed workloads, exit 1 on regre
101
102
  Profile CI: ✓
102
103
  ```
103
104
 
105
+ ## Using ostia from an AI agent
106
+
107
+ `--format minimal` is a versioned protocol (`protocolVersion: 1`) built for piping
108
+ straight into an LLM agent's context or a script: one JSON object per line, nothing
109
+ else on stdout.
110
+
111
+ ```sh
112
+ ostia time --samples 10 "bun a.ts" --format minimal
113
+ ostia compare before.json after.json --format minimal
114
+ ostia ci --format minimal; echo $?
115
+ ```
116
+
117
+ Every line is a JSON object with `event` and `protocolVersion: 1`:
118
+
119
+ | `event` | When | Key fields |
120
+ |---|---|---|
121
+ | `run` | Once per timing measurement, for every command | `workloadId` (join key back to `Workload.id`), `task`, `unit`/`samples`/`batch`/`mean`/`median`/`stddev`/`min`/`max`/`p75`/`p99`/`mad`, `relative`, `noiseFloorPct`, `warnings[]`, and (on `compare`/`ci`) `delta: { medianPct, meanPct, verdict, pass, ci95?, pValue?, effectiveTimingPct, matched }` |
122
+ | `unmatched` | Once per workload present on only one side of `compare`/`ci` | `workloadId`, `task`, `side: "base" \| "cand"` |
123
+ | `summary` | Exactly once, last line - `compare`/`ci` only, never for a bare `time`/`bench` | `command`, `matched`/`regressed`/`improved`/`unchanged`/`unmatched` counts, `cached`/`executed`/`failed`/`missingBaseline` (`ci` only), `geomeanPct`, `effectiveTimingPct`, `noiseFloorPct`, `baseline: { name, path }` (`ci` only), `git: { base?, cand? }`, `exportedTo`, `verdict`, `exitCode` |
124
+
125
+ Stability: keys are never renamed or removed within `protocolVersion: 1` - only ever
126
+ added, so an agent that reads a field it knows keeps working as the protocol grows.
127
+
128
+ Exit codes are the same across every command that produces a verdict: `0` pass, `1`
129
+ at least one workload regressed (`compare`/`ci` only - `time`/`bench` never return
130
+ `1`), `2` a harness error (a command failed to run cleanly, nothing was compared, a
131
+ bad flag, a missing config/baseline). On any exit `2`, stderr's last line is one more
132
+ JSON object - `{ event: "error", protocolVersion: 1, code, message, data? }`, `code`
133
+ one of `invalid-flag` / `config-missing` / `baseline-missing` / `no-matches` /
134
+ `spawn-failed` / `command-failed` / `timeout` / `time-source-no-match` /
135
+ `document-load-failed` / `no-cpu-evidence` / `internal` - so a script doesn't have to
136
+ pattern-match prose to tell one failure from another. This error line (and only this
137
+ line) is on stderr; every `minimal`/`jsonl`/`json` line above is pure JSON on stdout,
138
+ nothing else mixed in.
139
+
140
+ `--format jsonl` is the same idea for the full document instead of the condensed
141
+ protocol above: one line per `Measurement`, plus a `document` header line, each
142
+ tagged `kind: "document" | "measurement"` so a consumer doesn't have to guess a
143
+ line's shape.
144
+
104
145
  ## What ostia is for
105
146
 
106
147
  - Time subprocesses or in-process functions without a profiler attached to the timing runs.
@@ -134,11 +175,26 @@ ostia time --samples 25 --warmup 3 --cpu --heap "bun src/server.ts"
134
175
  ostia time --format json --export-json out.json "bun a.ts"
135
176
  ```
136
177
 
137
- `--samples N` is an exact trial count; `--budget MS`
178
+ Each `<command>` is a string, whitespace-split into argv exactly like hyperfine's `-N`
179
+ (no shell - no quoting, globbing, pipes, or redirection), so it can't express an argument
180
+ that itself contains a space. `ostia time [flags] -- <argv...>` is the escape hatch:
181
+ everything after `--` becomes one more command, given as argv verbatim (space preserved,
182
+ never flag-parsed), alongside any given the normal way:
183
+
184
+ ```sh
185
+ ostia time -- bun -e "console.log('a b')"
186
+ ```
187
+
188
+ There's no `--` equivalent for `--prepare` (below) - a hook that itself needs an argument
189
+ with a space needs `ostia.config.ts`'s array form (`prepare: ["cp", "fixture a", "fixture b"]`)
190
+ instead, since the CLI flag is always one whitespace-split string.
191
+
192
+ `--samples N` is an exact trial count *per command* (with 2+ commands, each gets its
193
+ own N trials, not a total split across them); `--budget MS`
138
194
  is a wall-clock time budget instead (default: a hyperfine-style ~3s min-total-time
139
195
  loop when neither is given); `--min-samples N` is a hard floor when `--samples` isn't
140
196
  given. The same three names work on `ostia bench` (`--budget`/`--samples`/
141
- `--min-samples`), where `--budget` is a per-task sampling window -
197
+ `--min-samples`), where `--samples`/`--budget` are per-task the same way -
142
198
  `warmup` differs by surface, though: a trial count here, a
143
199
  *fraction* of the budget for `ostia bench`, since in-process warmup has no natural
144
200
  "N calls" unit before the JIT has even seen the function once.
@@ -166,6 +222,15 @@ The prepare command is part of the workload id (and lands on the document as
166
222
  caches them separately. The library API also takes a function
167
223
  (`prepare: ({ phase, index }) => ...`, see [`time(opts)`](#timeopts--profiledocument)).
168
224
 
225
+ A hook's stderr is captured rather than streamed live to the terminal (it would otherwise
226
+ flood it, re-running before every one of possibly hundreds of trials) - bounded to 1 MiB
227
+ (head 512 KiB + tail 512 KiB, joined by a `bytes elided` marker if it goes over) and
228
+ folded into the thrown error on the trial where the hook actually times out or exits
229
+ non-zero, so a broken setup script is still easy to debug without an unbounded capture
230
+ risking the run's memory. Contrast `--time-source`'s own output capture (above), which is
231
+ deliberately *not* bounded: the summary line the regex needs could be anywhere in a large
232
+ output, so truncating it there would trade a memory bound for silently-wrong matches.
233
+
169
234
  `--time-source REGEX` takes each trial's time from the command's *own output* instead of
170
235
  its wall clock: the first `REGEX` match in stdout (then stderr), capture group 1, in
171
236
  `--time-unit` units (`ns` | `us` | `ms` | `s`, default `ms`). Meant for tools that report a
@@ -179,12 +244,59 @@ ostia time --time-source "built in (\d+)ms" "bun build.ts"
179
244
 
180
245
  The parsed value becomes `timing.samples`, so `compare`/`ci`/every renderer treat it
181
246
  exactly like wall time; each trial keeps `wallNs` alongside `reportedNs` so the document
182
- has both. A trial whose output doesn't match aborts the run with the output quoted (the
183
- workload asked for a number that isn't there). To gate wall time *and* the reported time
184
- independently, declare the command twice - once plain, once with `--time-source` - and
185
- they're two workloads with two verdicts. Note the reported number has whatever resolution
186
- the tool printed (usually whole ms), so its confidence interval is coarser than a
187
- nanosecond wall clock's.
247
+ has both. A trial whose output doesn't match the pattern contributes no sample (it's
248
+ never a fallback to `wallNs`, which would silently mix wall-clock time into a
249
+ reported-time series) and the measurement carries a `time-source-no-match` warning
250
+ (`data: { pattern, trials, output }`, `output` capped at 2 KiB) - it no longer aborts the
251
+ whole run the way it used to. If *every* trial of a command misses, that command has no
252
+ timing stats at all (same as every trial timing out - see `--timeout` above); other
253
+ commands in the same `time()` call keep their data regardless. To gate wall time *and*
254
+ the reported time independently, declare the command twice - once plain, once with
255
+ `--time-source` - and they're two workloads with two verdicts. Note the reported number
256
+ has whatever resolution the tool printed (usually whole ms), so its confidence interval
257
+ is coarser than a nanosecond wall clock's.
258
+
259
+ A `RegExp` pattern must not carry the `g`, `y`, or `d` flag - the same compiled pattern is
260
+ `exec`'d once per trial for the run's whole life, and `g`/`y` would make it alternate
261
+ match/no-match across trials via `lastIndex` instead of testing the same thing every time.
262
+ Constructing a workload with one throws `RangeError: timeSource pattern must not use the
263
+ g or y flag` immediately, before any trial runs. A plain (flagless) `RegExp` or a string
264
+ pattern is always safe to reuse. (`--time-source` on the CLI is always a plain string, so
265
+ this only comes up with a `RegExp` literal in the library API.)
266
+
267
+ `--timeout MS` kills a trial (or `--prepare` hook) with SIGKILL if it hasn't finished after
268
+ `MS` ms, so a hung command can't stall the whole run. No default for `time`/`bench` (unset
269
+ never times out); `ostia ci` defaults every workload to 10 minutes unless its config sets
270
+ `timeoutMs`. A timed-out trial resolves (never throws) with `Trial.timedOut: true` and
271
+ contributes no sample; if every trial of a command times out, that command has no timing
272
+ stats and prints like a skipped workload instead of an empty row.
273
+
274
+ `time(opts)` / `bench(opts)` also take a `signal?: AbortSignal`: aborting kills every
275
+ in-flight child process with SIGKILL, stops scheduling new trials, and resolves (never
276
+ rejects) with the document built from whatever measurements had already completed, plus
277
+ an `aborted` warning on the document's last measurement. `Ctrl-C` on the CLI wires this up
278
+ for you - `ostia time`/`ostia bench` cancel cleanly, still write `--export-json` of
279
+ whatever finished, and exit `130`, instead of the process just dying mid-spawn.
280
+
281
+ A non-zero exit doesn't stop a command's trial loop by default: every trial still runs,
282
+ each trial's own exit code lands on `Trial.exitCode`, and the measurement carries a
283
+ `nonzero-exit` warning (`data.exitCodes`) same as always. `--ignore-failure[=CODE,...]`
284
+ (hyperfine's flag; given bare, ignores every exit code) treats the listed codes as
285
+ success - the trial still contributes its sample, just with no warning and no effect on
286
+ the exit code below. `--fail-on-nonzero` stops a command's loop after its *first*
287
+ non-ignored non-zero exit instead of always running its full sample count (that trial's
288
+ sample is still recorded):
289
+
290
+ ```sh
291
+ ostia time --ignore-failure=1 "may-exit-1-harmlessly.sh"
292
+ ostia time --fail-on-nonzero "bun build.ts"
293
+ ```
294
+
295
+ Exit codes: `0` pass, `2` harness error (a command had a non-ignored non-zero exit, or a
296
+ workload has no timing stats at all - see `--timeout`/`--time-source` above - or a bad
297
+ flag/missing command), `130` cancelled with `Ctrl-C`. `1` is never returned by `time` or
298
+ `bench` - it's reserved for `compare`/`ci` regressions, so a script can tell "the
299
+ benchmark itself couldn't run cleanly" apart from "it ran, and got slower."
188
300
 
189
301
  Timing table (two commands get a Relative column automatically):
190
302
 
@@ -231,6 +343,13 @@ trials either way; only the few expensive tasks in a suite pay for the extra rig
231
343
  those are exactly where a 3-sample mean is shakiest. Fast calls are batched so a trial
232
344
  spans at least 1µs and a full budget yields about 10k trials at most.
233
345
 
346
+ Exit codes: `0` pass, `2` harness error (a suite file failed to import/run, a suite or
347
+ isolated task's subprocess timed out - see `--timeout` below - or a bad flag), `130`
348
+ cancelled with `Ctrl-C`. Tasks are in-process function calls, not subprocesses, so there's
349
+ no per-task exit code / `--ignore-failure` the way `ostia time` has; a task that throws
350
+ fails its suite's subprocess the same way it always has. `1` is never returned - it's
351
+ reserved for `compare`/`ci` regressions.
352
+
234
353
  | per-trial cost | fits in 500ms | default floor |
235
354
  |---|---|---|
236
355
  | 30ns | thousands | 20 (time-bound; ends in the tens of thousands) |
@@ -257,6 +376,12 @@ headroom, so numbers taken at `--jobs > 1` are noisier and not like-for-like wit
257
376
  baseline measured at 1. It defaults to 1 for that reason; opt in for exploratory runs,
258
377
  keep 1 for anything you `compare` or `ci` against.
259
378
 
379
+ `--gc`/`--cpu`/`--alloc`/`--isolate` each take a `--no-` counterpart
380
+ (`--no-gc`/`--no-cpu`/`--no-alloc`/`--no-isolate`) that resolves to an explicit `false`,
381
+ overriding a `true` from `ostia.config.json`'s `bench` section the same way the plain
382
+ flag overrides a config `false` - each flag is `cli ?? config ?? builtin default`, so
383
+ `ostia bench --no-gc` always wins over a config-wide `{ "gc": true }` for that one run.
384
+
260
385
  `--isolate` gives every task its own child process instead of sharing its suite file's,
261
386
  isolating each task's JIT tier state, inline caches and heap shape from every other task
262
387
  in the run - the same guarantee suite files already get from each other, at task
@@ -288,6 +413,12 @@ extension its timing) may not reflect steady state - the cpu measurement carries
288
413
  `jit-cold` warning (`{ llintPct, baselinePct, dfgPct, ftlPct }`), printed alongside the
289
414
  CPU capture in the terminal table and folded into the task's line in `--format minimal`.
290
415
 
416
+ `--timeout MS` kills a suite file's subprocess (or, under `--isolate`, one task's dedicated
417
+ subprocess) with SIGKILL if it hasn't finished after `MS` ms - the same option `ostia time`
418
+ has, applied at the subprocess granularity `--isolate` already runs at rather than per task.
419
+ No default (unset never times out); `ostia ci` defaults every `suites` entry to 10 minutes
420
+ unless its config sets `bench.timeoutMs`.
421
+
291
422
  `--preload PATH` (repeatable) imports a script before each suite file loads, in the same
292
423
  subprocess - the same shape as Bun's own `--preload` / `bunfig.toml`'s `preload` array. Use
293
424
  it to install globals a suite needs at import time (jsdom's `document`/`window`) or register
@@ -369,6 +500,36 @@ ostia compare after.json --baseline .ostia/baselines/main.json
369
500
  timing: +11.2% median, 95% CI [+10.0%, +16.4%], p<0.001 (regressed)
370
501
  ```
371
502
 
503
+ Exit codes: `0` pass, `1` at least one workload regressed, `2` nothing was compared (zero
504
+ matched workloads - a stale baseline, a totally rewritten config) or a harness error
505
+ (documents failed to load, or a bad flag).
506
+
507
+ A workload id present on only one document prints in an `Unmatched` section (table and
508
+ markdown formats) instead of silently vanishing:
509
+
510
+ ```
511
+ Unmatched:
512
+ baseline only: old-task
513
+ candidate only: new-task
514
+ ```
515
+
516
+ `--format table` also prints a `threshold` header line when the machine's noise floor
517
+ widened the effective threshold past `thresholds.timingPct`:
518
+
519
+ ```
520
+ threshold 5% (widened to 6.2% by noise floor)
521
+ ```
522
+
523
+ `ostia compare` reads `ostia.config.ts`/`ostia.config.json`'s `thresholds` when present
524
+ (same discovery as `ostia ci`), so a project-tuned threshold applies here too instead of
525
+ only gating `ostia ci`; `--no-config` ignores it and uses `DEFAULT_THRESHOLDS`.
526
+ `--timing-pct N` / `--alpha N` override individual fields on top of whichever base was
527
+ picked. The source is printed above the report:
528
+
529
+ ```
530
+ thresholds: ostia.config.ts
531
+ ```
532
+
372
533
  When both documents carry `git` metadata (see below), `ostia compare` prints a summary
373
534
  line above the verdicts:
374
535
 
@@ -383,6 +544,15 @@ just a point estimate past the threshold - see
383
544
  below. Comparisons with fewer than 5 samples on either side fall back to the
384
545
  old point-estimate rule and carry a `thin-comparison` warning instead.
385
546
 
547
+ When `base`/`cand` differ in `platform.os`, `platform.arch`, `bunVersion`, or (when both
548
+ carry `environment`) `cpuModel`/`cores`, every comparison in the document carries an
549
+ `environment-mismatch` warning (`data.fields`: `{ field, base, cand }[]`) - a timing delta
550
+ between two different machines or Bun versions may reflect that, not the code change under
551
+ test. `table` and `markdown` print it once, in the header, instead of once per workload;
552
+ `minimal` folds it into each task line's `warnings[]` alongside its measurement warnings,
553
+ so `line.warnings.some(w => w.code === "environment-mismatch")` keeps working the same way
554
+ it does for a measurement warning.
555
+
386
556
  #### Statistics: a real significance test, not a percentage threshold
387
557
 
388
558
  A point estimate past `timingPct` is not enough to call something a
@@ -421,7 +591,9 @@ already past 75% of available cores at measurement time.
421
591
  Render a saved `ProfileDocument` without re-running anything. `--format` covers
422
592
  both the data formats (`table`/`json`/`jsonl`/`markdown`/`minimal`) and the CPU
423
593
  visualization formats (`collapsed`/`mermaid`/`speedscope`/`cpuprofile`) - one
424
- command instead of two.
594
+ command instead of two. `time`/`bench`/`compare --format` only accept the data
595
+ formats; export the document and run `ostia report --format <viz>` on it for
596
+ a visualization.
425
597
 
426
598
  ```sh
427
599
  ostia report out.json # table (default)
@@ -432,17 +604,17 @@ ostia report out.json --format minimal
432
604
  ```
433
605
 
434
606
  Minimal format - one JSON object per timing run, no header, no raw sample array, no prose.
435
- Built to pipe straight into an LLM agent's context: the full document carries every
436
- sample (tens of thousands for a fast task), which is tokens a reviewer never reads.
437
- Numbers stay in ns so they line up with `compare` deltas and the JSON document.
607
+ See [Using ostia from an AI agent](#using-ostia-from-an-ai-agent) above for the full
608
+ protocol (event types, the exit-code contract, the stderr error line). A `run` event:
438
609
 
439
610
  ```
440
- {"task":"diffText()/append at end","group":"diffText()","samples":9282,"mean":50213.4,"median":49871,"stddev":2104.7,"stddevPct":4.19,"min":48120,"max":81002,"p75":50920,"p99":58011,"mad":1780,"relative":1,"warnings":[],"unit":"ns"}
441
- {"task":"repaint/4000 chars","group":"repaint","description":"full repaint every keystroke","samples":3,"mean":2.61e9,"median":2.4e9,"stddevPct":15.3,"relative":47800,"warnings":[{"code":"low-sample-count","data":{"samples":3,"target":10}}],"unit":"ns"}
611
+ {"event":"run","protocolVersion":1,"schemaVersion":2,"workloadId":"wl_1a2b3c4d5e6f7890","task":"diffText()/append at end","group":"diffText()","unit":"ns","samples":9282,"batch":1,"mean":50213.4,"median":49871,"stddev":2104.7,"stddevPct":4.19,"min":48120,"max":81002,"p75":50920,"p99":58011,"mad":1780,"relative":1,"warnings":[]}
442
612
  ```
443
613
 
444
- `ostia compare ... --format minimal` adds `delta: { medianPct, meanPct, verdict, pass }` to
445
- each line, so "did this PR regress" is `lines.some(l => l.delta?.verdict === "regressed")`.
614
+ `ostia compare`/`ostia ci --format minimal` add `delta: { medianPct, meanPct, verdict, pass,
615
+ ci95?, pValue?, effectiveTimingPct, matched }` to each `run` line, so "did this PR regress" is
616
+ `lines.some(l => l.delta?.verdict === "regressed")` - and a trailing `summary` line carries
617
+ the same verdict for the whole run.
446
618
 
447
619
  Markdown:
448
620
 
@@ -500,13 +672,14 @@ ostia ci --full # ignore cache
500
672
  ostia ci --baseline main
501
673
  ostia ci --export-json out.json
502
674
  ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
675
+ ostia ci --on-missing-baseline fail
676
+ ostia ci --no-noise-check
503
677
  ```
504
678
 
505
679
  Pass:
506
680
 
507
681
  ```
508
682
  1 workloads
509
- 1 affected by this change
510
683
  0 cached
511
684
  1 executed
512
685
  1 passed 0 regressed
@@ -518,7 +691,6 @@ Fail:
518
691
 
519
692
  ```
520
693
  1 workloads
521
- 1 affected by this change
522
694
  0 cached
523
695
  1 executed
524
696
  0 passed 1 regressed (+1278.7% median on work)
@@ -526,7 +698,26 @@ Fail:
526
698
  Profile CI: ✗
527
699
  ```
528
700
 
529
- Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline, spawn failure).
701
+ Exit codes: `0` pass, `1` regression, `2` harness error - missing config/baseline, every
702
+ sampled trial of a `command` workload exited non-zero (a harness failure, reported as
703
+ `N failed` and distinct from a timing regression), an `onMissingBaseline: "fail"` mismatch,
704
+ or a spawn failure.
705
+
706
+ A configured workload with no matching row in the baseline (by workload id) doesn't just
707
+ silently pass: `onMissingBaseline` (config field, or `--on-missing-baseline warn|fail`)
708
+ decides what happens. Left unset, `ci` exits `2` (naming the baseline file and suggesting
709
+ `ostia baseline save`) only when *every* configured workload is missing - a totally
710
+ stale/wrong baseline - and otherwise lists what's missing in the report without affecting
711
+ the exit code, since one new workload next to an otherwise-matching baseline isn't a hard
712
+ error. `"fail"`/`"warn"` explicitly always fail/never fail on any mismatch, regardless of
713
+ how many workloads are missing.
714
+
715
+ `ci` also measures this machine's noise floor once per invocation (the same ~200ms
716
+ reference measurement `time()`/`bench()` take) and stamps it on both the candidate
717
+ document and `ostia baseline save`'s output, so `compare`'s noise-floor threshold widening
718
+ (see [Statistics](#statistics-a-real-significance-test-not-a-percentage-threshold)) applies
719
+ to `ci`-gated regressions too, not only ad hoc `time`/`bench` runs. `noiseCheck: false` in
720
+ config, or `--no-noise-check`, skips it.
530
721
 
531
722
  #### `ostia.config.ts` / `ostia.config.json`
532
723
 
@@ -594,20 +785,39 @@ with `pattern` a regex source string (or a `RegExp` in `.ts`). Both are part of
594
785
  id. A function-form `prepare` can't be fingerprinted, so that workload never comes from
595
786
  cache - it always executes, like a workload with no `inputs`.
596
787
 
788
+ `timeoutMs`, `ignoreExitCodes`, and `failOnNonzero` are also `command`-only and mean the
789
+ same as `ostia time`'s `--timeout` / `--ignore-failure` / `--fail-on-nonzero`. `ostia ci`
790
+ defaults every workload's `timeoutMs` to 10 minutes when the workload doesn't set one
791
+ (`bench.timeoutMs` does the same for `suites` workloads); none of the three are part of
792
+ the workload id, so tuning them doesn't orphan a cached run or a saved baseline.
793
+
597
794
  Two directory options, both optional: `outDir` (default `node_modules/.cache/ostia`) for
598
795
  scratch/cache/artifacts, and `baselineDir` (default `.ostia/baselines`) for baselines. They're
599
796
  independent - `baselineDir` doesn't move just because you override `outDir`.
600
797
 
798
+ `onMissingBaseline` (`"warn"` | `"fail"`, default unset - see [`ostia
799
+ ci`](#ostia-ci) above) and `noiseCheck` (default `true`) are top-level config fields, not
800
+ per-workload: `--on-missing-baseline` / `--no-noise-check` override them per invocation.
801
+
601
802
  #### Baselines (local and CI)
602
803
 
603
804
  Baselines are JSON under `.ostia/baselines/` (gitignored). `ostia ci` only needs the file
604
805
  on disk; it does not need to be committed.
605
806
 
807
+ A workload's id identifies *what* is measured, not where the measuring process ran: for a
808
+ `command` workload it hashes the command argv, `prepare`, and `timeSource`, and
809
+ deliberately excludes `process.cwd()`. That means the same command measured from a CI
810
+ runner, a developer's checkout, or a different git worktree of the same repo produces the
811
+ same id and matches the same baseline row - `label` changes and switching directories
812
+ never orphan a baseline.
813
+
606
814
  `ostia baseline save [name]` measures every configured workload (the same code path
607
815
  `ostia ci` gates against, no comparison) and writes it to `<baselineDir>/<name>.json`
608
816
  (default name: config's `"baseline"` field, or `"main"`). `ostia baseline list` shows every
609
817
  saved baseline (name, created date, workload count, and git sha/branch when available);
610
- `ostia baseline show <name> [--format]` renders one (delegates to `ostia report`).
818
+ `ostia baseline show <name> [--format]` renders one (delegates to `ostia report`). A
819
+ baseline name must match `/^[A-Za-z0-9._-]+$/` and can't start with `-` - a typo'd flag
820
+ (`ostia baseline save --verbose`) is a usage error instead of a literal filename.
611
821
 
612
822
  Every document stamps `git: { sha, branch, dirty }` (from `git rev-parse` / `git status
613
823
  --porcelain` in the process's cwd, 200ms timeout, silently absent outside a repo or
@@ -690,6 +900,10 @@ const doc = await time({
690
900
  cpuIntervalUs: 200,
691
901
  outDir: "node_modules/.cache/ostia", // default; artifacts land under here
692
902
  noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
903
+ timeoutMs: 30_000, // kill a hung trial/prepare hook with SIGKILL; no default
904
+ signal: controller.signal, // abort to cancel: kills in-flight children, keeps partial results
905
+ ignoreExitCodes: [1], // treat exit code 1 as success; still samples, no nonzero-exit warning
906
+ failOnNonzero: false, // default; true stops a command's loop after its first bad exit
693
907
  })
694
908
  ```
695
909
 
@@ -716,7 +930,11 @@ const doc = await time({
716
930
  In-process capture. `origin: "jsc"` is the only path that reports JIT tiers
717
931
  (LLInt / Baseline / DFG / FTL). Default `origin: "inspector"` writes portable CDP-shaped
718
932
  evidence instead. `document` is a full `ProfileDocument` (the one workload and
719
- measurement), so it composes with `renderers.*` or `saveDocument` directly.
933
+ measurement), so it composes with `renderers.*` or `saveDocument` directly. `profile()`
934
+ also takes a `signal?: AbortSignal`, but `fn` runs in this process - there's no child to
935
+ kill, so an already-aborted signal only skips the profiler instrumentation (still running
936
+ `fn` plain and returning its `result`, with an `aborted` warning in place of CPU evidence);
937
+ it can't interrupt `fn` once it's running.
720
938
 
721
939
  ```ts
722
940
  const { result, measurement, document } = await profile(
@@ -925,6 +1143,10 @@ range(100, 10_000) // -> [100, 800, 6400, 10000]
925
1143
  range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]
926
1144
  ```
927
1145
 
1146
+ ### `bench(opts)` → `ProfileDocument`
1147
+
1148
+ In-process suite runner, same behavior as `ostia bench`.
1149
+
928
1150
  ```ts
929
1151
  // demo.ts
930
1152
  import { bench } from "ostia"
@@ -939,30 +1161,56 @@ const doc = await bench({
939
1161
  })
940
1162
  ```
941
1163
 
942
- A command can also be an object - `{ command, label?, prepare?, timeSource? }` - whose
943
- `prepare`/`timeSource` override the top-level ones for that command. That's how one
944
- command becomes several labeled workloads in the same document:
1164
+ ### `run(opts?)` → `ProfileDocument`
1165
+
1166
+ In-file entrypoint: call it at the bottom of a suite file run directly with
1167
+ `bun suite.ts` (no `ostia bench` CLI, no `bench({ suites })` call) to execute every
1168
+ `group()`/`task()` registered so far, print a report, and return the document.
945
1169
 
946
1170
  ```ts
947
- const build = ["bun", "cli.ts", "build", "fixture"]
948
- const inMs = { pattern: /in (\d+)ms/ }
949
- const doc = await time({
950
- commands: [
951
- { command: build, label: "warm", timeSource: inMs },
952
- { command: build, label: "incremental", timeSource: inMs, prepare: () => touchPost() },
953
- { command: build, label: "cold", timeSource: inMs, prepare: "rm -rf fixture/dist" },
954
- { command: build, label: "wall clock" }, // same command, no timeSource: wall time
955
- ],
956
- samples: 5,
1171
+ // suite.ts
1172
+ import { rm } from "node:fs/promises"
1173
+ import { group, run, task } from "ostia"
1174
+
1175
+ group("parse", () => {
1176
+ task("small input", () => parse(smallBuf))
1177
+ task("large input", () => parse(largeBuf))
957
1178
  })
1179
+
1180
+ try {
1181
+ await run()
1182
+ } finally {
1183
+ await rm(fixtureDir, { recursive: true })
1184
+ }
958
1185
  ```
959
1186
 
960
- ### `compareDocuments(base, cand, thresholds?)` → `Comparison[]`
1187
+ ```sh
1188
+ bun suite.ts
1189
+ ```
1190
+
1191
+ `run({ filter: "parse" })` narrows to matching `group/name` ids, same regex as `ostia bench
1192
+ --filter`/`bench({ filter })` - there's no CLI here to read a `--filter` flag from, so pass it
1193
+ as an option, e.g. from `process.argv` or an env var your `run()` call reads itself.
1194
+
1195
+ This trades away the isolation `ostia bench`/`bench()` give each suite file (and each
1196
+ isolated task under `--isolate`) its own fresh subprocess: everything under `run()` runs in
1197
+ the process that already imported the suite, so `TaskOptions.isolate` has nothing to isolate
1198
+ into and is ignored. Prefer `ostia bench`/`bench()` for numbers you'll `compare`/`ci`
1199
+ against; reach for `run()` for a single suite file's inline edit/run loop, or when a `finally`
1200
+ around the run needs to clean up fixtures the suite set up (`ostia bench`'s subprocess model
1201
+ has no call in the file that returns after every task finishes, so that cleanup would
1202
+ otherwise need a `process.on("exit", ...)` hook instead).
1203
+
1204
+ `run(opts)` accepts the same suite-wide `filter`/`budgetMs`/`samples`/`minSamples`/`warmup`/
1205
+ `gc`/`cpu`/`alloc`/`noiseCheck` fields as `bench(opts)`, plus `quiet` (skip the printed
1206
+ report, still return the document) and `format` (renderer for that report, default `"table"`).
1207
+
1208
+ ### `compareDocuments(base, cand, thresholds?)` → `CompareResult`
961
1209
 
962
1210
  Same matching and thresholds as `ostia compare` / `ostia ci`.
963
1211
 
964
1212
  ```ts
965
- const diffs = compareDocuments(baselineDoc, candidateDoc, {
1213
+ const result = compareDocuments(baselineDoc, candidateDoc, {
966
1214
  timingPct: 5,
967
1215
  frameSelfPct: 10,
968
1216
  heapTypePct: 10,
@@ -970,8 +1218,20 @@ const diffs = compareDocuments(baselineDoc, candidateDoc, {
970
1218
  alpha: 0.01, // Mann-Whitney significance level
971
1219
  bootstrapIterations: 2000,
972
1220
  })
1221
+
1222
+ result.comparisons // Comparison[], one per workload id present on both sides
1223
+ result.unmatched // { baseOnly: Workload[]; candOnly: Workload[] } - present on only one side
1224
+ result.summary // { matched, regressed, improved, unchanged, geomeanPct, effectiveTimingPct, verdict }
973
1225
  ```
974
1226
 
1227
+ `summary.geomeanPct` is the geometric mean of `cand/base` median ratios over matched timing
1228
+ comparisons, as a signed percent (negative: candidate faster on average); `null` when no
1229
+ comparison had a finite timing ratio. `summary.verdict` is `"fail"` when any comparison
1230
+ failed. `ostia compare` persists `result.comparisons` as `comparisons`, `result.summary` as
1231
+ `comparisonSummary`, and `result.unmatched`'s workload ids (not full `Workload`s, to keep the
1232
+ document small) as `unmatched: { baseOnly: string[]; candOnly: string[] }` on the candidate
1233
+ document it writes/renders.
1234
+
975
1235
  ### `saveDocument` / `loadDocument`
976
1236
 
977
1237
  ```ts
@@ -993,8 +1253,8 @@ Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files?
993
1253
  | `table` | terminal timing / CPU / heap / comparison text |
994
1254
  | `markdown` | agent- and human-readable report |
995
1255
  | `json` | pretty JSON document |
996
- | `jsonl` | one metadata line, then one line per run |
997
- | `minimal` | one compact line per timing run, no sample array; for LLM/CI consumption |
1256
+ | `jsonl` | one `kind: "document"` header line, then one `kind: "measurement"` line per run |
1257
+ | `minimal` | protocol v1: one `run`/`unmatched`/`summary` event per line, no sample array; for LLM/CI consumption (see [Using ostia from an AI agent](#using-ostia-from-an-ai-agent)) |
998
1258
  | `collapsed` | folded stacks (`name;name;name count`) |
999
1259
  | `mermaid` | top-N call tree |
1000
1260
  | `speedscope` | speedscope.app JSON |
@@ -1014,6 +1274,7 @@ Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
1014
1274
  | mitata / hyperfine | ostia |
1015
1275
  |---|---|
1016
1276
  | `bench("name", fn)` | `task("name", fn)` |
1277
+ | `run({ filter })` at the bottom of the suite file | `run({ filter })` at the bottom of the suite file (see [`run(opts?)`](#runopts--profiledocument)) |
1017
1278
  | `baseline()` | `{ baseline: true }` on a `task()` |
1018
1279
  | `.range(name, start, end, mult)` | `sweep({ dim: range(start, end, mult) }, ...)` |
1019
1280
  | generator setup (`function* () { ...; yield () => fn() }`) | `task(name, fn, { before, after })` |
@@ -0,0 +1,12 @@
1
+ // @bun
2
+ class we extends Error{}function I(n){if(typeof n.pattern==="string")return;let{flags:s}=n.pattern;if(s.includes("g")||s.includes("y")||s.includes("d"))throw RangeError("timeSource pattern must not use the g or y flag")}function O(...n){let s=n.filter((o)=>o!==void 0);if(s.length===0)return;if(s.length===1)return s[0];return AbortSignal.any(s)}var Ke={ns:1,us:1000,ms:1e6,s:1e9};async function z(n){let s=n.timeSource!==void 0,o=Bun.nanoseconds(),u=!1,l=n.timeoutMs!==void 0?AbortSignal.timeout(n.timeoutMs):void 0;l?.addEventListener("abort",()=>{u=!0},{once:!0});let f=O(l,n.signal),T=Bun.spawn(n.argv,{cwd:n.cwd,env:n.env,stdout:s?"pipe":"ignore",stderr:s?"pipe":"ignore",stdin:"ignore",...f&&{signal:f,killSignal:"SIGKILL"}}),B=s?Promise.all([new Response(T.stdout).text(),new Response(T.stderr).text()]):void 0,C=await T.exited,_=Bun.nanoseconds(),U=T.resourceUsage?.(),M={wallNs:_-o,exitCode:u?null:C,userNs:U?Number(U.cpuTime.user)*1000:void 0,systemNs:U?Number(U.cpuTime.system)*1000:void 0,maxRssBytes:U?.maxRSS,...u?{timedOut:!0}:{}};if(B){let[W,ne]=await B;if(n.timeSource&&!u)try{M.reportedNs=Ye(n.timeSource,W,ne,n.argv)}catch(Q){if(!(Q instanceof we))throw Q;M.timeSourceNoMatch=!0,M.timeSourceMissOutput=Ze(W,ne)}}return M}function Ye(n,s,o,u=[]){let l=typeof n.pattern==="string"?new RegExp(n.pattern):n.pattern,f=n.group??1,T=l.exec(s)??l.exec(o),B=u.length>0?` for "${u.join(" ")}"`:"";if(!T)throw new we(`timeSource pattern ${l} did not match the output${B}. Output was:
3
+ ${ve(s,o)}`);let C=T[f];if(C===void 0)throw Error(`timeSource pattern ${l} matched${B} but has no capture group ${f} (matched text: "${T[0]}").`);let _=Number(C);if(!Number.isFinite(_))throw Error(`timeSource pattern ${l} group ${f} captured "${C}"${B}, which is not a number.`);return _*Ke[n.unit??"ms"]}function ve(n,s){let u=(f)=>f.length>800?`${f.slice(0,800)}\u2026(${f.length-800} more)`:f,l=[];if(n.trim())l.push(`--- stdout ---
4
+ ${u(n.trimEnd())}`);if(s.trim())l.push(`--- stderr ---
5
+ ${u(s.trimEnd())}`);return l.length>0?l.join(`
6
+ `):"(empty)"}var he=2048;function Ze(n,s){let o=ve(n,s);if(Buffer.byteLength(o,"utf8")<=he)return o;let u=o.slice(0,he);while(Buffer.byteLength(u,"utf8")>he)u=u.slice(0,-1);return`${u}\u2026`}var Xe=1048576,Qe=(n)=>`
7
+ \u2026(${n} bytes elided)\u2026
8
+ `;async function en(n,s=Xe){let o=Math.floor(s/2),u=n.getReader(),l=[],f=0,T=[],B=0,C=0;try{for(;;){let{done:ne,value:Q}=await u.read();if(ne)break;if(!Q||Q.byteLength===0)continue;C+=Q.byteLength;let j=Q;if(f<o){let G=o-f;if(j.byteLength<=G){l.push(j),f+=j.byteLength;continue}l.push(j.subarray(0,G)),f+=G,j=j.subarray(G)}T.push(j),B+=j.byteLength;while(B>o&&T.length>0){let G=T[0],Z=B-o;if(G.byteLength<=Z)T.shift(),B-=G.byteLength;else T[0]=G.subarray(Z),B-=Z}}}finally{u.releaseLock()}let _=new TextDecoder,U=_.decode(Re(l));if(C<=f+B)return U;let M=_.decode(Re(T)),W=C-f-B;return`${U}${Qe(W)}${M}`}function Re(n){let s=n.reduce((l,f)=>l+f.byteLength,0),o=new Uint8Array(s),u=0;for(let l of n)o.set(l,u),u+=l.byteLength;return o}async function N(n,s,o){if(typeof n==="function"){await n(s);return}let u=ke(n),l=!1,f=o.timeoutMs!==void 0?AbortSignal.timeout(o.timeoutMs):void 0;f?.addEventListener("abort",()=>{l=!0},{once:!0});let T=O(f,o.signal),B=Bun.spawn(u,{cwd:o.cwd,env:o.env,stdout:"ignore",stderr:"pipe",stdin:"ignore",...T&&{signal:T,killSignal:"SIGKILL"}}),C=en(B.stderr).catch(()=>""),_=await B.exited;if(l){let U=(await C).trim();throw Error(`prepare command "${u.join(" ")}" timed out after ${o.timeoutMs}ms before ${s.phase} trial ${s.index}.${U?`
9
+ ${U}`:""}`)}if(o.signal?.aborted)return;if(_!==0){let U=(await C).trim();throw Error(`prepare command "${u.join(" ")}" exited with code ${_} before ${s.phase} trial ${s.index}.${U?`
10
+ ${U}`:""}`)}}function ke(n){if(n===void 0||typeof n==="function")return;return Array.isArray(n)?n:b(n)}function Ie(n){if(n===void 0)return;return{pattern:typeof n.pattern==="string"?n.pattern:n.pattern.source,...n.group!==void 0&&{group:n.group},...n.unit!==void 0&&{unit:n.unit}}}function b(n){return n.trim().split(/\s+/).filter(Boolean)}import{renameSync as tn}from"fs";var Ne={name:"ostia",version:"0.2.3",description:"Fast profiling and benchmarking for Bun.",type:"module",engines:{bun:">=1.4.0"},exports:{".":"./src/index.ts"},bin:{ostia:"./src/cli/main.ts"},scripts:{build:"bun scripts/build.ts",test:"bun test",typecheck:"tsc6",lint:"biome check .","lint:fix":"biome check --write . --unsafe","lint:changed":"biome check --changed --no-errors-on-unmatched .","lint:fix:changed":"biome check --write --unsafe --changed --no-errors-on-unmatched .",knip:"knip-bun",bench:"bun src/cli/main.ts bench bench/*.ts","bench:baseline":"bun src/cli/main.ts bench bench/*.ts --budget 3000 --min-samples 30 --export-json .ostia/baselines/bench-main.json",baseline:"bun src/cli/main.ts baseline save",dogfood:"bun test && bun src/cli/main.ts ci",examples:"bun scripts/run-examples.ts"},devDependencies:{"@biomejs/biome":"2.5.12","@types/bun":"^1.4.0",knip:"^6.34.0",typescript:"npm:@typescript/typescript6@^6.0.2"}};var w=Ne.version;function R(n){return JSON.stringify(fe(n))}function fe(n){if(Array.isArray(n))return n.map(fe);if(n!==null&&typeof n==="object"){let s={};for(let o of Object.keys(n).sort())s[o]=fe(n[o]);return s}return n}function e(n,...s){let o=Bun.CryptoHasher.hash("sha256",R(s),"hex");return`${n}_${o.slice(0,16)}`}var ge,Ee=!1;function ye(n){let s=Bun.spawnSync(["git",...n],{timeout:200,stdout:"pipe",stderr:"ignore"});return s.success?s.stdout.toString().trim():void 0}function Pe(){if(Ee)return ge;Ee=!0;try{let n=ye(["rev-parse","--short","HEAD"]);if(n===void 0)ge=void 0;else{let s=ye(["rev-parse","--abbrev-ref","HEAD"]),o=ye(["status","--porcelain"]);ge={sha:n,branch:s??"HEAD",dirty:(o?.length??0)>0}}}catch{ge=void 0}return ge}function t(n,s,o){let u=Pe();return{schemaVersion:2,toolVersion:w,bunVersion:Bun.version,platform:{os:process.platform,arch:process.arch},createdAt:new Date().toISOString(),workloads:n,measurements:s,...o!==void 0&&{environment:o},...u!==void 0&&{git:u}}}function k(n,s,o={}){if(o.timeSource)I(o.timeSource);let u=ke(o.prepare),l=Ie(o.timeSource),f=typeof o.prepare==="function"?o.prepare.toString():u;return{id:e("wl","subprocess",n,...f!==void 0||l!==void 0?[f??null,l??null]:[]),kind:"subprocess",command:n,label:s,...u!==void 0&&{prepare:u},...l!==void 0&&{timeSource:l}}}function q(n,s){return{id:e("wl","inprocess",n.name,n.toString()),kind:"inprocess",label:s}}function Be(n,s,o={}){return{id:o.params!==void 0?e("wl","inprocess-entry",n,s,o.params):e("wl","inprocess-entry",n,s),kind:"inprocess",entry:{file:n,task:s,...o.group!==void 0&&{group:o.group}},...o.label!==void 0&&{label:o.label},...o.baseline!==void 0&&{baseline:o.baseline},...o.description!==void 0&&{description:o.description},...o.groupDescription!==void 0&&{groupDescription:o.groupDescription},...o.isolated!==void 0&&{isolated:o.isolated},...o.params!==void 0&&{params:o.params},...o.skipped!==void 0&&{skipped:o.skipped}}}function m(n){return{id:e("run",n.workload.id,"timing",n.configFingerprint,Bun.version,w),workloadId:n.workload.id,phase:"timing",instrumented:!1,configFingerprint:n.configFingerprint,trials:n.trials,timing:n.timing,warnings:n.warnings,artifacts:[],memory:rn(n.trials),...n.interleaved!==void 0&&{interleaved:n.interleaved}}}function rn(n){let s=n.map((o)=>o.maxRssBytes).filter((o)=>o!==void 0);if(s.length===0)return;return{origin:"resourceUsage",perTrial:n.map((o)=>({rssBytes:o.maxRssBytes})),maxRssBytes:Math.max(...s)}}function d(n){return{id:e("run",n.workload.id,n.phase,n.configFingerprint,Bun.version,w),workloadId:n.workload.id,phase:n.phase,instrumented:!0,configFingerprint:n.configFingerprint,trials:[{i:0,wallNs:n.diagnosticWallNs,exitCode:n.exitCode}],diagnosticWallNs:n.diagnosticWallNs,cpu:n.cpu,heap:n.heap,memory:n.memory,jit:n.jit,warnings:n.warnings,artifacts:n.artifacts}}async function K(n,s,o){let l=await Bun.file(o).arrayBuffer(),f=new Bun.CryptoHasher("sha256");return f.update(l),{id:e("art",n,s,o),kind:s,path:o,sha256:f.digest("hex"),bytes:l.byteLength}}function a(n){return e("cfg",n)}function V(n){return`${JSON.stringify(fe(n),null,2)}
11
+ `}async function c(n,s){let o=V(n),u=`${s}.tmp-${process.pid}`;await Bun.write(u,o),tn(u,s)}function on(n){if(n.schemaVersion===2)return n;let{runs:s,comparisons:o,...u}=n;return{...u,schemaVersion:2,measurements:s.map(({baselineRunId:l,...f})=>f),...o!==void 0&&{comparisons:o.map(({baselineRunId:l,candidateRunId:f,...T})=>({...T,baselineMeasurementId:l,candidateMeasurementId:f}))}}}class D extends Error{code;path;schemaVersion;constructor(n,s,o={}){super(s);this.name="OstiaDocumentError",this.code=n,this.path=o.path,this.schemaVersion=o.schemaVersion}}async function r(n){let s=await Bun.file(n).text(),o;try{o=JSON.parse(s)}catch(l){let f=l instanceof Error?l.message:String(l);throw new D("invalid-json",`${n}: invalid JSON (${f})`,{path:n})}let u=o!==null&&typeof o==="object"?o.schemaVersion:void 0;if(typeof u!=="number")throw new D("not-a-document",`${n}: not a ProfileDocument (missing schemaVersion)`,{path:n});if(u!==1&&u!==2)throw new D("unsupported-schema",`${n}: unsupported ProfileDocument schemaVersion ${u} (this ostia reads 1\u20132)`,{path:n,schemaVersion:u});return on(o)}import Te from"os";function h(n){if(n.length===0)throw Error("computeTimingStats: samples must be non-empty");let s=n.length,o=Oe(n),u=0;for(let g=0;g<s;g++)u+=n[g];let l=u/s,f=i(o,0.5),T=0;for(let g=0;g<s;g++){let L=n[g]-l;T+=L*L}let B=Math.sqrt(T/s),C=o[0],_=o[s-1],U=i(o,0.25),M=i(o,0.75),W=M-U,ne=U-1.5*W,Q=M+1.5*W,j=U-3*W,G=M+3*W,Z=0,te=0;for(let g=0;g<s;g++){let L=n[g];if(L<j||L>G)te++;else if(L<ne||L>Q)Z++}let ue=i(o,0.99),ee=new Float64Array(s);for(let g=0;g<s;g++)ee[g]=Math.abs(n[g]-f);ee.sort();let ie=i(ee,0.5);return{unit:"ns",samples:n,mean:l,median:f,stddev:B,min:C,max:_,outliers:{mild:Z,severe:te},p75:M,p99:ue,mad:ie}}function Oe(n){let s=new Float64Array(n.length);return s.set(n),s.sort(),s}function i(n,s){let o=n.length;if(o===1)return n[0];let u=s*(o-1),l=Math.floor(u),f=Math.ceil(u);if(l===f)return n[l];let T=u-l;return n[l]*(1-T)+n[f]*T}var sn=5000000,an=200;function S(n,s,o="subprocess",u=[]){let l=[],f=n.samples[0];if(f!==void 0){let C=Oe(n.samples),_=i(C,0.25),M=i(C,0.75)-_;if(f>n.median+3*M&&M>0)l.push({code:"slow-first-run",message:`First run took ${(f/1e6).toFixed(2)}ms, much slower than the median ${(n.median/1e6).toFixed(2)}ms. Consider more warmup.`,data:{firstNs:f,medianNs:n.median}})}if(n.outliers.mild+n.outliers.severe>0)l.push({code:"outliers-detected",message:`${n.outliers.mild+n.outliers.severe} outlier(s) detected (${n.outliers.severe} severe, ${n.outliers.mild} mild).`,data:n.outliers});if(o==="subprocess"&&n.median<sn)l.push({code:"fast-command",message:`Median run time (${(n.median/1e6).toFixed(3)}ms) is very fast; results may be dominated by spawn overhead.`,data:{medianNs:n.median}});if(o==="inprocess"&&n.median<an)l.push({code:"below-timer-resolution",message:`Median run time (${n.median.toFixed(0)}ns) is close to timer resolution; consider a larger batch size or a coarser operation.`,data:{medianNs:n.median}});let T=new Set(u),B=s.filter((C)=>C!==void 0&&C!==0&&!T.has(C));if(B.length>0)l.push({code:"nonzero-exit",message:`${B.length} of ${s.length} trial(s) exited non-zero.`,data:{exitCodes:B}});return l}var un=200,Ae=64,cn=(()=>{let n=new Uint8Array(4096);for(let s=0;s<n.length;s++)n[s]=s*2654435761&255;return n})();function ln(n,s){let o=s;for(let u=0;u<n.length;u++)o^=n[u],o=Math.imul(o,16777619);return o>>>0}function mn(n){let s=h(n),o=s.mad??0;return{floorPct:s.median===0?0:o/s.median*100,referenceMedianNs:s.median,samples:n.length}}function Fe(n=un){let s=n*1e6,o=[],u=0,l=Bun.nanoseconds(),f=0;while(f<s){let T=Bun.nanoseconds();for(let C=0;C<Ae;C++)u^=ln(cn,C);let B=Bun.nanoseconds();o.push((B-T)/Ae),f=Bun.nanoseconds()-l}return mn(o)}var dn=0.75;function p(){let[n=0,s=0]=Te.loadavg();return{cpuModel:Te.cpus()[0]?.model??"unknown",cores:Te.availableParallelism(),loadAvg1:n,loadAvg5:s,noise:Fe()}}function F(n){if(n.loadAvg1<=n.cores*dn)return;return{code:"noisy-machine",message:`Load average ${n.loadAvg1.toFixed(2)} exceeds 75% of ${n.cores} available core(s); timing noise may be elevated.`,data:{loadAvg1:n.loadAvg1,cores:n.cores}}}import{profile as fn}from"bun:jsc";var pn=new Map([["LLInt","llint"],["Baseline","baseline"],["DFG","dfg"],["FTL","ftl"]]),Ce=4294967295;function Ue(n,s){let o=s??n.interval*1e6,u=new Map,l=[];function f(g,L,J,ce){let le=u.get(g);if(le===void 0)le=new Map,u.set(g,le);let ae=L??"",me=le.get(ae);if(me===void 0)me=l.length,le.set(ae,me),l.push({key:e("fr",g,ae),name:g,url:L,line:J,col:ce});return me}function T(g){let L=g.line===Ce,J=L?void 0:g.line-1,ce=L||g.column===Ce?void 0:g.column-1;return f(g.name,g.sourceURL,J,ce)}let B=f("(root)",void 0,void 0,void 0),C=1,_={id:0,frameIx:B,children:new Map,selfUs:0,samples:0,totalUs:0},U=new Map([[0,_]]),M={llint:0,baseline:0,dfg:0,ftl:0},W=new Map,ne=[],Q=[];for(let g of n.traces){let L=g.frames,J=_;for(let ae=L.length-1;ae>=0;ae--){let me=T(L[ae]),de=J.children.get(me);if(!de)de={id:C++,frameIx:me,children:new Map,selfUs:0,samples:0,totalUs:0},J.children.set(me,de),U.set(de.id,de);J=de}J.selfUs+=o,J.samples+=1,ne.push(J.id),Q.push(o);let ce=L[0],le=ce&&pn.get(ce.category);if(le){M[le]++;let ae=W.get(le)??new Map;ae.set(J.frameIx,(ae.get(J.frameIx)??0)+1),W.set(le,ae)}}function j(g){let L=g.selfUs;for(let J of g.children.values())L+=j(J);return g.totalUs=L,L}j(_);let G=new Map;function Z(g){let L=G.get(g.frameIx);if(L)L.selfUs+=g.selfUs,L.totalUs+=g.totalUs,L.samples+=g.samples;else G.set(g.frameIx,{frameIx:g.frameIx,selfUs:g.selfUs,totalUs:g.totalUs,samples:g.samples});for(let J of g.children.values())Z(J)}Z(_);let te=[...U.values()].map((g)=>({id:g.id,frameIx:g.frameIx,children:[...g.children.values()].map((L)=>L.id)})),ue={origin:"jsc-profile",samplingIntervalUs:o,frames:l,nodes:te,totals:[...G.values()].sort((g,L)=>L.selfUs-g.selfUs),samples:{nodeIds:ne,timeDeltasUs:Q}},ee=[...W.entries()].flatMap(([g,L])=>[...L.entries()].sort((J,ce)=>ce[1]-J[1]).slice(0,3).map(([J,ce])=>({tier:g,frameKey:l[J].key,samples:ce})));return{cpu:ue,jit:{origin:"jsc-profile",tiers:M,topFramesByTier:ee}}}var gn=1000;async function E(n,s={}){let o=s.intervalUs??gn,u,l=Bun.nanoseconds(),f=await fn(async()=>(u=await n(),u),o),T=Bun.nanoseconds()-l,{cpu:B,jit:C}=Ue(f.stackTraces,o);return{result:u,cpu:B,jit:C,diagnosticWallNs:T}}var bn=500,hn=20,$e=3,wn=10,kn=2,yn=0.1,Tn=1000,xn=1e4;function _e(n){let s=Math.log10(Math.max(1,n)/1e6),o=Math.round($e+kn*s);return Math.min(wn,Math.max($e,o))}function Sn(n,s){let o=Math.floor(s/n);return Math.min(hn,Math.max(o,_e(n)))}var De=0;function A(n){if(typeof n==="number")De+=n;else if(n!==void 0&&n!==null)De+=1}function be(n){return n!==null&&typeof n==="object"&&typeof n.then==="function"}function Le(n,s){return Math.max(1,Math.ceil(Tn/n),Math.ceil(s/(n*xn)))}async function We(n,s={}){let o=(s.budgetMs??bn)*1e6,u=o*(s.warmup??yn),l=Bun.nanoseconds(),f=0,T=0;while(T<u){let ee=n();A(be(ee)?await ee:ee),f++,T=Bun.nanoseconds()-l}let B;if(f>0)B=Math.max(1,T/f);else{let ee=Bun.nanoseconds(),ie=n();A(be(ie)?await ie:ie),B=Math.max(1,Bun.nanoseconds()-ee)}let C=Le(B,o);if(C>1){let ee=Bun.nanoseconds();for(let ie=0;ie<C;ie++){let g=n();A(be(g)?await g:g)}B=Math.max(1,(Bun.nanoseconds()-ee)/C),C=Le(B,o)}let _=B*C,U=s.samples??s.minSamples??Sn(_,o),M=s.samples!==void 0?0:o,W=[],ne=Bun.nanoseconds(),Q=0,j=0;while(j<U||Q<M){let ee=Bun.nanoseconds();for(let g=0;g<C;g++){let L=n();A(be(L)?await L:L)}let ie=Bun.nanoseconds();if(W.push({i:j,wallNs:(ie-ee)/C}),j++,Q=Bun.nanoseconds()-ne,s.gc)Bun.gc(!0);if(s.samples!==void 0&&j>=s.samples)break}let G=W.map((ee)=>ee.wallNs),Z=h(G);if(C>1)Z.batch=C;let te=S(Z,[],"inprocess"),ue=_e(_);if(W.length<ue)te.push({code:"low-sample-count",message:`Only ${W.length} sample(s) at ~${Mn(_)} per trial; ${ue} is the floor for this cost class. Raise minSamples or the time budget for a steadier number.`,data:{samples:W.length,target:ue,trialCostNs:_}});return{trials:W,timing:Z,warnings:te}}function Mn(n){if(n>=1e9)return`${(n/1e9).toFixed(2)}s`;if(n>=1e6)return`${(n/1e6).toFixed(1)}ms`;if(n>=1000)return`${(n/1000).toFixed(1)}\xB5s`;return`${n.toFixed(0)}ns`}var Me=[],oe,pe;function xe(n,s,o,u){let l=oe;oe={name:n,description:o?.description,isolate:o?.isolate,gc:o?.gc,cpu:o?.cpu,alloc:o?.alloc,before:o?.before,after:o?.after,skip:u.skip,only:u.only};try{s()}finally{oe=l}}var re=Object.assign((n,s,o)=>xe(n,s,o,{}),{skip:(n,s,o)=>xe(n,s,o,{skip:!0}),only:(n,s,o)=>xe(n,s,o,{only:!0})});function Se(n,s,o,u){let l=pe!==void 0||o?.params!==void 0?{...pe,...o?.params}:void 0;Me.push({groupName:oe?.name,groupDescription:oe?.description,groupIsolate:oe?.isolate,groupGc:oe?.gc,groupCpu:oe?.cpu,groupAlloc:oe?.alloc,groupBefore:oe?.before,groupAfter:oe?.after,name:n,fn:s,baseline:o?.baseline,params:l,skipped:u.skip||oe?.skip,only:u.only||oe?.only,opts:o})}var se=Object.assign((n,s,o)=>Se(n,s,o,{}),{skip:(n,s,o)=>Se(n,s,o,{skip:!0}),only:(n,s,o)=>Se(n,s,o,{only:!0})});function x(){return Me}function Y(){Me.length=0,oe=void 0,pe=void 0}function X(n,s){let o=pe;pe=n;try{return s()}finally{pe=o}}function y(n){return n.groupName?`${n.groupName}/${n.name}`:n.name}function H(n,s){return n.opts?.isolate??n.groupIsolate??s}function Ge(n,s){return n.opts?.gc??n.groupGc??s}function je(n,s){return n.opts?.cpu??n.groupCpu??s}function He(n,s){return n.opts?.alloc??n.groupAlloc??s}function v(n,s){if(!s)return[...n];let o=new RegExp(s);return n.filter((u)=>o.test(y(u)))}import{heapStats as Rn}from"bun:jsc";var vn=100;function Je(){try{return Rn().heapSize}catch{return process.memoryUsage().heapUsed}}async function Ve(n,s=vn){let o=Bun.nanoseconds();Bun.gc(!0);let u=Je();for(let T=0;T<s;T++){let B=n();if(B instanceof Promise)await B}Bun.gc(!0);let l=Je(),f=Bun.nanoseconds()-o;return{memory:{origin:"heapStats",bytesPerOp:Math.max(0,(l-u)/s)},diagnosticWallNs:f}}var In=200,Nn=20;async function qe(n,s=In){let o=s*1e6,u=async()=>{let B=Bun.nanoseconds();while(Bun.nanoseconds()-B<o){let C=n();if(C instanceof Promise)await C}},{cpu:l,jit:f,diagnosticWallNs:T}=await E(u);return{cpu:l,jit:f,diagnosticWallNs:T}}function ze(n){let{llint:s,baseline:o,dfg:u,ftl:l}=n.tiers,f=s+o+u+l;if(f===0)return;let T=s/f*100,B=o/f*100,C=u/f*100,_=l/f*100;if(T+B<=Nn)return;return{code:"jit-cold",message:`${(T+B).toFixed(1)}% of CPU samples were in the llint/baseline tiers: the JIT never warmed this task up.`,data:{llintPct:T,baselinePct:B,dfgPct:C,ftlPct:_}}}async function P(n,s,o){let l=s.some((U)=>!U.skipped)&&o.noiseCheck!==!1?p():void 0,f=l?F(l):void 0,T=new Map,B=new Map;s.forEach((U,M)=>{if(U.groupName===void 0||U.skipped)return;if(!T.has(U.groupName))T.set(U.groupName,M);B.set(U.groupName,M)});let C=[],_=[];for(let U=0;U<s.length;U++){let M=s[U],W=y(M),ne=Be(n,W,{label:W,baseline:M.baseline,group:M.groupName,description:M.opts?.description,groupDescription:M.groupDescription,isolated:o.markIsolated,params:M.params,skipped:M.skipped});if(C.push(ne),M.skipped)continue;let Q=M.groupName!==void 0&&T.get(M.groupName)===U,j=M.groupName!==void 0&&B.get(M.groupName)===U;if(Q&&M.groupBefore)await M.groupBefore();if(M.opts?.before)await M.opts.before();let G={budgetMs:M.opts?.budgetMs??o.budgetMs,samples:M.opts?.samples??o.samples,minSamples:M.opts?.minSamples??o.minSamples,warmup:o.warmup,gc:Ge(M,o.gc??!1)},Z=await We(M.fn,G);if(_.push(m({workload:ne,configFingerprint:a({budgetMs:G.budgetMs??null,samples:G.samples??null,minSamples:G.minSamples??null,gc:G.gc??!1}),trials:Z.trials,timing:Z.timing,warnings:f&&_.length===0?[...Z.warnings,f]:Z.warnings})),je(M,o.cpu??!1)){let te=await qe(M.fn),ue=ze(te.jit);_.push(d({workload:ne,phase:"cpu",configFingerprint:a({cpu:!0}),diagnosticWallNs:te.diagnosticWallNs,cpu:te.cpu,jit:te.jit,warnings:ue?[ue]:[],artifacts:[]}))}if(He(M,o.alloc??!1)){let te=await Ve(M.fn);_.push(d({workload:ne,phase:"memstats",configFingerprint:a({alloc:!0}),diagnosticWallNs:te.diagnosticWallNs,memory:te.memory,warnings:[],artifacts:[]}))}if(M.opts?.after)await M.opts.after();if(j&&M.groupAfter)await M.groupAfter()}return t(C,_,l)}
12
+ export{R,e,h,i,S,I,O,z,N,b,w,t,k,q,m,d,K,a,V,c,D,r,p,F,E,A,re,se,x,Y,X,y,H,v,P};