@benchsdk/runner 0.2.0

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.
@@ -0,0 +1,366 @@
1
+ import { TaskResultRecord, JsonObject, BaseParticipant, DefineStepOptions, TaskStepRecord } from '@benchsdk/client';
2
+
3
+ type MetricValue = string | ((record: TaskResultRecord) => number | number[] | undefined);
4
+ interface MetricScoring {
5
+ name: string;
6
+ value?: MetricValue;
7
+ unit: string;
8
+ ceiling: number;
9
+ floor?: number;
10
+ higherIsBetter?: boolean;
11
+ weights: {
12
+ median: number;
13
+ p95: number;
14
+ p99: number;
15
+ };
16
+ trim?: number;
17
+ }
18
+ interface ScoringSpec {
19
+ dimensions?: Record<string, unknown>;
20
+ success?: (record: TaskResultRecord) => boolean;
21
+ metrics: MetricScoring[];
22
+ }
23
+ interface BenchmarkScoreResult {
24
+ provider: string;
25
+ dimensions: JsonObject;
26
+ metrics: {
27
+ name: string;
28
+ unit: string;
29
+ median: number;
30
+ p95: number;
31
+ p99: number;
32
+ }[];
33
+ scalars?: {
34
+ name: string;
35
+ value: number;
36
+ unit: string;
37
+ }[];
38
+ compositeScore: number;
39
+ successRate: number;
40
+ scoringVersion?: string;
41
+ skipped: boolean;
42
+ skipReason?: string;
43
+ }
44
+ type LowerIsBetter = (name: string, opts: {
45
+ unit: string;
46
+ ceiling: number;
47
+ value?: MetricValue;
48
+ weights: {
49
+ median: number;
50
+ p95: number;
51
+ p99: number;
52
+ };
53
+ trim?: number;
54
+ }) => MetricScoring;
55
+ type HigherIsBetter = (name: string, opts: {
56
+ unit: string;
57
+ floor?: number;
58
+ ceiling: number;
59
+ value?: MetricValue;
60
+ weights: {
61
+ median: number;
62
+ p95: number;
63
+ p99: number;
64
+ };
65
+ trim?: number;
66
+ }) => MetricScoring;
67
+ declare const lowerIsBetter: LowerIsBetter;
68
+ declare const higherIsBetter: HigherIsBetter;
69
+ declare function score(outcome: BenchmarkRunOutcome, spec: ScoringSpec): BenchmarkScoreResult[];
70
+
71
+ /**
72
+ * A `*.bench.ts` file is the composition of a **config** and a **task**:
73
+ *
74
+ * export const config = defineBenchmarkConfig({ benchmarkSlug, participants, ... });
75
+ * export const task = defineTask(async (ctx) => { await ctx.step('work', () => ...); });
76
+ *
77
+ * `defineBenchmarkConfig` holds the orchestration knobs (including the
78
+ * participants and an optional `onComplete` hook); `defineTask` holds the
79
+ * workload. The `bench run <file>` binary imports the module, reads those two
80
+ * exports, and drives the run. There is no "mode": all orchestration shapes
81
+ * emerge from the knobs.
82
+ *
83
+ * iterations total tasks to run (default 1)
84
+ * concurrency max tasks in flight at once — 1 = sequential, N = burst (default 1)
85
+ * staggerDelayMs delay each task's start by taskIndex * staggerDelayMs (default 0)
86
+ *
87
+ * Common shapes:
88
+ * sequential { iterations: N, concurrency: 1 }
89
+ * burst { iterations: N, concurrency: N }
90
+ * staggered { iterations: N, concurrency: N, staggerDelayMs: 200 }
91
+ *
92
+ * A benchmark can name these variants up front via `shapes`, so one file backs
93
+ * several platform benchmarks (`bench run <file> --shape burst`) without
94
+ * restating each one's slug/name in scripts and CI.
95
+ *
96
+ * A task is comprised of steps, declared via `ctx.step` inside a task function
97
+ * — it supports closures, conditionals and try/finally, so values (a created
98
+ * sandbox, say) flow naturally between steps. A task that declares no steps is
99
+ * recorded as a single implicit `task` step. Measurements reach the platform
100
+ * via `ctx.measure(...)`; step return values are control flow and never
101
+ * recorded.
102
+ */
103
+
104
+ /** How tasks are ordered across participants. */
105
+ type GroupBy = 'participant' | 'round';
106
+ /**
107
+ * A named variant of a benchmark, selected with `--shape <name>`. A shape
108
+ * carries only the parts that make it a distinct *benchmark* — its platform
109
+ * identity plus any stable distinguishing knob (e.g. staggered's delay). The
110
+ * scale knobs that vary per environment (`--iterations`, `--concurrency`) stay
111
+ * on the invocation, so a shape never sets a value only to have the CLI
112
+ * override it.
113
+ */
114
+ interface BenchmarkShape {
115
+ /** Platform slug this shape reports under (e.g. 'sandbox-tti'). */
116
+ slug: string;
117
+ /** Display name shown on the platform; defaults to the slug. */
118
+ name?: string;
119
+ /** Default stagger delay (ms) for this shape; overridable with `--stagger-delay-ms`. */
120
+ staggerDelayMs?: number;
121
+ }
122
+ /**
123
+ * What a task returns: whatever it measured itself. This replaces the
124
+ * assumption that the framework owns all timing. A plain data payload is
125
+ * written explicitly as `{ data: {...} }`.
126
+ */
127
+ interface TaskResult {
128
+ /** Free-form domain payload attached to the record (tokens, receipts, ...). */
129
+ data?: JsonObject;
130
+ /**
131
+ * Pre-measured steps the task timed itself (e.g. socket phases).
132
+ * Only honored in `groupBy: 'round'` runs, where the runner builds records
133
+ * manually. In `groupBy: 'participant'` runs the platform worker
134
+ * (`client.runWorker`) owns steps, so `steps` and `latencyMs` are ignored.
135
+ */
136
+ steps?: TaskStepRecord[];
137
+ /** Task-owned overall latency; overrides framework wall-clock (round mode only). */
138
+ latencyMs?: number;
139
+ }
140
+ /** Options for a single `ctx.step` invocation. */
141
+ interface TaskStepOptions extends Omit<DefineStepOptions, 'concurrency' | 'stepConcurrency'> {
142
+ /** Per-iteration timeout in milliseconds. If an invocation exceeds this, it is aborted and a `step_timeout` TaskError is thrown. */
143
+ timeoutMs?: number;
144
+ /** Number of times to invoke `fn` in parallel. Defaults to 1. When greater than 1, the step returns an array of results. */
145
+ concurrency?: number;
146
+ }
147
+ /**
148
+ * Throw this from a task to record a failure while preserving domain data and
149
+ * any pre-measured steps (a plain thrown Error loses them).
150
+ */
151
+ declare class TaskError extends Error {
152
+ readonly code?: string;
153
+ readonly data?: JsonObject;
154
+ readonly steps?: TaskStepRecord[];
155
+ constructor(message: string, opts?: {
156
+ code?: string;
157
+ data?: JsonObject;
158
+ steps?: TaskStepRecord[];
159
+ });
160
+ }
161
+ /** Context handed to a benchmark `task` for a single iteration. */
162
+ interface TaskContext<T extends BaseParticipant = BaseParticipant> {
163
+ /** The participant this task is running for. */
164
+ participant: T;
165
+ /** Zero-based global task ordinal (matches the platform record's taskIndex). */
166
+ taskIndex: number;
167
+ /** Current phase name, when the benchmark declares `phases`. */
168
+ phase?: string;
169
+ /**
170
+ * Runs `fn` as a named platform step. Mirrors `@benchsdk/client`'s
171
+ * `RunWorkerContext.step`; supports closures and try/finally. A `concurrency`
172
+ * greater than 1 invokes `fn` that many times in parallel and returns an array.
173
+ * `timeoutMs` aborts any invocation that exceeds it with a `step_timeout` TaskError.
174
+ */
175
+ step<R, C extends number = 1>(name: string, fn: () => Promise<R> | R, options?: TaskStepOptions & {
176
+ concurrency?: C;
177
+ }): Promise<C extends 1 ? R : R[]>;
178
+ /**
179
+ * Attaches a JSON measurement to the platform. Inside a `step` it lands on
180
+ * that step's data; at task top-level it lands on the task record's data.
181
+ */
182
+ measure(data: JsonObject): void;
183
+ /** Appends a line to the worker log, uploaded as an artifact when the worker finishes. */
184
+ log(message: string, meta?: JsonObject): void;
185
+ }
186
+ type BenchmarkTask<T extends BaseParticipant = BaseParticipant> = (ctx: TaskContext<T>) => Promise<TaskResult | void> | TaskResult | void;
187
+ /**
188
+ * A named run segment with its own iteration count. Phases run in order; each
189
+ * record is tagged with the phase name via `data.phase`, and `ctx.phase` lets
190
+ * the task branch on identity instead of index arithmetic.
191
+ */
192
+ interface Phase {
193
+ /** Phase name, tagged onto every record produced in this phase. */
194
+ name: string;
195
+ /** Iterations to run in this phase. */
196
+ iterations: number;
197
+ }
198
+ /** One participant's collected task records from a run. */
199
+ interface ParticipantRecords {
200
+ participant: string;
201
+ records: TaskResultRecord[];
202
+ }
203
+ /** The orchestration knobs a run actually used, after CLI overrides. */
204
+ interface ResolvedRunConfig {
205
+ iterations: number;
206
+ concurrency: number;
207
+ staggerDelayMs: number;
208
+ groupBy: GroupBy;
209
+ providers?: string[];
210
+ }
211
+ /**
212
+ * Result of a benchmark run, passed to `config.onComplete`. Exposes the raw
213
+ * per-participant records so completion hooks can write legacy local results.
214
+ */
215
+ interface BenchmarkRunOutcome {
216
+ runId: string;
217
+ /** Link to this run on the platform dashboard. */
218
+ dashboardUrl: string;
219
+ participants: ParticipantRecords[];
220
+ config: ResolvedRunConfig;
221
+ }
222
+ /**
223
+ * Orchestration config for a benchmark. Holds identity, the knobs, the
224
+ * participants, and the optional completion hook — the workload lives in a
225
+ * separate `defineTask`. `bench run <file>` reads the `config` and `task`
226
+ * exports from the module and drives the run.
227
+ */
228
+ interface BenchmarkConfig<T extends BaseParticipant = BaseParticipant> {
229
+ /**
230
+ * Stable platform slug for this benchmark (e.g. 'sandbox-tti-local').
231
+ * Selectable per run with `--shape` (or overridable with `--benchmark`), so
232
+ * one entrypoint can report under several benchmarks.
233
+ */
234
+ benchmarkSlug: string;
235
+ /** Human-readable name shown on the platform. Overridable with `--name`. */
236
+ benchmarkName: string;
237
+ /**
238
+ * Named variants of this benchmark, selected with `--shape <name>`. Each
239
+ * shape swaps in its own platform identity (and optional stable knob) while
240
+ * reusing the same task and participants, so one bench file can back several
241
+ * platform benchmarks without duplicating the slug/name triple across
242
+ * package scripts and CI.
243
+ */
244
+ shapes?: Record<string, BenchmarkShape>;
245
+ /**
246
+ * Total tasks to run per participant. Default: 1. Mutually exclusive with
247
+ * `phases` — when `phases` is set, total iterations = sum of phase iterations.
248
+ */
249
+ iterations?: number;
250
+ /**
251
+ * Named run segments (e.g. cold/warm). Runs in order; each record is tagged
252
+ * with the phase name via `data.phase`. Mutually exclusive with `iterations`.
253
+ */
254
+ phases?: Phase[];
255
+ /** Max tasks in flight at once. 1 = sequential, N = burst. Default: 1. */
256
+ concurrency?: number;
257
+ /** Delay each task's start by `taskIndex * staggerDelayMs`. Default: 0. */
258
+ staggerDelayMs?: number;
259
+ /**
260
+ * Task ordering across participants. Default: 'participant' (run each
261
+ * participant's tasks to completion, then the next). 'round' takes turns:
262
+ * every participant runs its Nth task before anyone runs their (N+1)th, so
263
+ * all participants' Nth tasks happen back-to-back under the same conditions.
264
+ */
265
+ groupBy?: GroupBy;
266
+ /**
267
+ * Default participant names to run when `--provider` is not passed. Omit to
268
+ * run all env-available participants. `--provider` always overrides this.
269
+ */
270
+ defaultProviders?: string[];
271
+ /** The participants this benchmark can run against. `--provider` selects a subset by name. */
272
+ participants: T[];
273
+ /**
274
+ * Run-level scoring hook, called once with `lowerIsBetter` and `higherIsBetter`
275
+ * primitives after the outcome is assembled but before `onComplete`. Use it to
276
+ * define how the run should be scored and reported to the platform.
277
+ */
278
+ onScore?: (lowerIsBetter: LowerIsBetter, higherIsBetter: HigherIsBetter) => ScoringSpec | Promise<ScoringSpec>;
279
+ /**
280
+ * Run-level completion hook, called once with the full outcome after every
281
+ * participant finishes. Use it for aggregate output (legacy JSON/SVG
282
+ * writers). This is the run-level counterpart to per-step `ctx.measure`.
283
+ */
284
+ onComplete?: (outcome: BenchmarkRunOutcome) => void | Promise<void>;
285
+ }
286
+ /** Validates `config` at file-evaluation time so mistakes surface immediately. */
287
+ declare function defineBenchmarkConfig<T extends BaseParticipant = BaseParticipant>(config: BenchmarkConfig<T>): BenchmarkConfig<T>;
288
+ /**
289
+ * Declares the workload for a benchmark: a function invoked once per iteration.
290
+ * Steps are named via `ctx.step`, which supports closures and try/finally so
291
+ * values flow naturally between steps.
292
+ *
293
+ * export const task = defineTask(async (ctx) => {
294
+ * const sandbox = await ctx.step('create', () => provider.create());
295
+ * try { await ctx.step('exec', () => sandbox.run('node -v')); }
296
+ * finally { await ctx.step('destroy', () => sandbox.destroy()); }
297
+ * });
298
+ */
299
+ declare function defineTask<T extends BaseParticipant = BaseParticipant>(task: BenchmarkTask<T>): BenchmarkTask<T>;
300
+
301
+ /**
302
+ * Thrown when every selected participant was env-gated out, i.e. none of their
303
+ * `requiredEnvVars` are set. This is a "nothing to do" outcome rather than a
304
+ * failure — a benchmark job for a provider whose credentials aren't provisioned
305
+ * should skip, not go red — so callers are expected to exit 0 on it.
306
+ */
307
+ declare class NoAvailableParticipantsError extends Error {
308
+ readonly skipped: {
309
+ name: string;
310
+ missing: string[];
311
+ }[];
312
+ constructor(skipped: {
313
+ name: string;
314
+ missing: string[];
315
+ }[]);
316
+ }
317
+
318
+ interface CliArgs {
319
+ /** Which platform benchmark to report as (`--benchmark`, aka the benchmark slug). */
320
+ benchmark?: string;
321
+ name?: string;
322
+ /** Named variant from the bench file's `shapes` (`--shape`), swapping in its identity. */
323
+ shape?: string;
324
+ /**
325
+ * Idempotency key (`--run-key`): sibling processes passing the same key share
326
+ * one run (get-or-created), instead of each opening its own.
327
+ */
328
+ runKey?: string;
329
+ iterations?: number;
330
+ concurrency?: number;
331
+ staggerDelayMs?: number;
332
+ groupBy?: GroupBy;
333
+ /** Participant names from `--provider a,b` (repeatable). */
334
+ providers?: string[];
335
+ /** When true, run locally and do not ingest/report to the platform. */
336
+ noIngest?: boolean;
337
+ }
338
+ /**
339
+ * Parses the orchestration flags this runner understands, ignoring anything
340
+ * else. Supports both `--flag value` and `--flag=value`; `--provider` accepts
341
+ * a comma-separated list and may be repeated.
342
+ */
343
+ declare function parseCliArgs(argv: string[]): CliArgs;
344
+ /** Merges CLI overrides over config defaults, filling in knob fallbacks. */
345
+ declare function mergeConfig<T extends BaseParticipant>(config: BenchmarkConfig<T>, args: CliArgs): ResolvedRunConfig;
346
+ /**
347
+ * Runs `config`'s `task` against its participants. Selects participants by
348
+ * `--provider` (if given), env-gates them, then drives them per the resolved
349
+ * `groupBy`. `--shape` swaps in a declared variant's identity; `--benchmark`/
350
+ * `--name` retarget the run at a different platform benchmark, so one entrypoint
351
+ * can report under several slugs. With `--run-key`, sibling processes (e.g. one
352
+ * CI job per provider) get-or-create one shared run and each registers only its
353
+ * own participants.
354
+ */
355
+ declare function runBenchmark<T extends BaseParticipant>(fileConfig: BenchmarkConfig<T>, task: BenchmarkTask<T>, argv?: string[]): Promise<BenchmarkRunOutcome>;
356
+
357
+ /**
358
+ * Dispatches one CLI invocation. Throws on bad usage / invalid exports and lets
359
+ * `NoAvailableParticipantsError` propagate so the caller can map it to a clean
360
+ * exit. Does not call `process.exit`.
361
+ */
362
+ declare function runBenchmarkFile(argv: string[]): Promise<void>;
363
+ /** Executable entry: runs the file and maps outcomes to process exit codes. */
364
+ declare function run(argv: string[]): Promise<void>;
365
+
366
+ export { type BenchmarkConfig, type BenchmarkRunOutcome, type BenchmarkScoreResult, type BenchmarkTask, type CliArgs, type GroupBy, type MetricScoring, NoAvailableParticipantsError, type ParticipantRecords, type Phase, type ResolvedRunConfig, type ScoringSpec, type TaskContext, TaskError, type TaskResult, type TaskStepOptions, defineBenchmarkConfig, defineTask, higherIsBetter, lowerIsBetter, mergeConfig, parseCliArgs, run, runBenchmark, runBenchmarkFile, score };