@aethervtt/shard-core 0.1.0 → 0.2.1

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.
@@ -1,22 +1,281 @@
1
+ import { ShardError } from "../error.js";
1
2
  import { defineResource } from "../schema/resource.js";
3
+ function defineSpan(name) {
4
+ return { name, id: -1 };
5
+ }
6
+ const TRACK = { main: 0, gpu: 1, async: 2, gc: 3, worker: 4 };
7
+ function trackName(track) {
8
+ if (track === TRACK.main) return "main";
9
+ if (track === TRACK.gpu) return "gpu";
10
+ if (track === TRACK.async) return "async";
11
+ if (track === TRACK.gc) return "gc";
12
+ return "worker";
13
+ }
14
+ function spanCovers(key, name) {
15
+ if (name === key) return true;
16
+ const n = key.length;
17
+ if (n === 0 || name.length <= n || !name.startsWith(key)) return false;
18
+ return key.charCodeAt(n - 1) === SLASH || name.charCodeAt(n) === SLASH;
19
+ }
20
+ const SLASH = 47;
21
+ const MAX_SPANS = 65536;
22
+ const MAX_DEPTH = 64;
23
+ const ASYNC_SLOTS = 256;
24
+ const spanNames = [];
25
+ const spanTracks = [];
26
+ const spanIds = /* @__PURE__ */ new Map();
27
+ function internSpan(name, track = defaultTrack(name)) {
28
+ const known = spanIds.get(name);
29
+ if (known !== void 0) return known;
30
+ const id = spanNames.length;
31
+ if (id >= MAX_SPANS) {
32
+ throw new ShardError("perf/too-many-spans", `More than ${MAX_SPANS} span names`, {
33
+ hint: 'Span names are fixed strings (e.g. "terrain/encode"), not per-entity or per-frame text.'
34
+ });
35
+ }
36
+ spanNames.push(name);
37
+ spanTracks.push(track);
38
+ spanIds.set(name, id);
39
+ return id;
40
+ }
41
+ function spanCount() {
42
+ return spanNames.length;
43
+ }
44
+ function spanId(name) {
45
+ return spanIds.get(name) ?? -1;
46
+ }
47
+ function spanName(id) {
48
+ return spanNames[id] ?? `span#${id}`;
49
+ }
50
+ function spanTrack(id) {
51
+ return spanTracks[id] ?? TRACK.main;
52
+ }
53
+ function defaultTrack(name) {
54
+ if (name.startsWith("gpu:")) return TRACK.gpu;
55
+ if (name.startsWith("worker/")) return TRACK.worker;
56
+ if (name === "gc" || name.startsWith("gc/")) return TRACK.gc;
57
+ return TRACK.main;
58
+ }
59
+ function idOf(span, track) {
60
+ if (typeof span === "string") return spanIds.get(span) ?? internSpan(span, track);
61
+ let id = span.id;
62
+ if (id < 0) {
63
+ id = internSpan(span.name, track);
64
+ span.id = id;
65
+ }
66
+ return id;
67
+ }
68
+ const ProfilerSettings = defineResource("core/ProfilerSettings", {
69
+ description: "Turns the profiler (0074) on or off, and how many runs its aggregates cover. Off, spans cost a branch.",
70
+ hostWritable: true,
71
+ init: () => ({ enabled: true, window: 120 })
72
+ });
73
+ const host = globalThis;
74
+ function defaultNow() {
75
+ return host.performance ? host.performance.now() : Date.now();
76
+ }
77
+ let measuredClock;
78
+ function clockInfo() {
79
+ if (measuredClock) return measuredClock;
80
+ let resolution = Number.POSITIVE_INFINITY;
81
+ let prev = defaultNow();
82
+ let steps = 0;
83
+ for (let i = 0; i < 2e6 && steps < 20; i++) {
84
+ const t = defaultNow();
85
+ if (t !== prev) {
86
+ if (t - prev < resolution) resolution = t - prev;
87
+ prev = t;
88
+ steps++;
89
+ }
90
+ }
91
+ const isolated = host.document === void 0 ? true : host.crossOriginIsolated === true;
92
+ measuredClock = {
93
+ resolutionMs: Number.isFinite(resolution) ? Math.round(resolution * 1e6) / 1e6 : 0,
94
+ isolated
95
+ };
96
+ return measuredClock;
97
+ }
2
98
  class Profiler {
3
- window;
4
- series = /* @__PURE__ */ new Map();
5
- constructor(window = 120) {
6
- this.window = window;
99
+ settings;
100
+ /** The clock, in ms. */
101
+ now;
102
+ /** Receives every span while a capture runs. */
103
+ sink;
104
+ /** Gets `perf/span-mismatch` and similar warnings; the app logs them. */
105
+ onWarning;
106
+ /** `Time.frame` of the frame running now (or that ran last). */
107
+ frame = 0;
108
+ /** The last span `end` measured, in ms. */
109
+ lastMs = 0;
110
+ /** What the renderer said about GPU timing: `unavailable` without timestamp queries. */
111
+ gpu = {
112
+ status: "unknown",
113
+ quantized: false
114
+ };
115
+ windowSize;
116
+ series = [];
117
+ stackIds = new Int32Array(MAX_DEPTH);
118
+ stackStarts = new Float64Array(MAX_DEPTH);
119
+ depth = 0;
120
+ frameStartAt = 0;
121
+ warned = new Uint8Array(256);
122
+ asyncIds = new Int32Array(ASYNC_SLOTS).fill(-1);
123
+ asyncKeys = new Array(ASYNC_SLOTS);
124
+ asyncStarts = new Float64Array(ASYNC_SLOTS);
125
+ asyncCount = 0;
126
+ frameSpan = defineSpan("frame");
127
+ constructor(options = {}) {
128
+ const o = typeof options === "number" ? { settings: { enabled: true, window: options } } : options;
129
+ this.settings = o.settings ?? { enabled: true, window: 120 };
130
+ this.now = o.now ?? defaultNow;
131
+ this.windowSize = Math.max(1, this.settings.window | 0);
132
+ }
133
+ get window() {
134
+ return this.windowSize;
135
+ }
136
+ get enabled() {
137
+ return this.settings.enabled;
7
138
  }
8
- record(name, ms) {
9
- let series = this.series.get(name);
10
- if (!series) {
11
- series = { samples: new Float64Array(this.window), cursor: 0, count: 0 };
12
- this.series.set(name, series);
139
+ // --- spans -------------------------------------------------------------------
140
+ /** Starts a span on the main track. Returns a token for `end`; -1 when the profiler is off. */
141
+ begin(span) {
142
+ if (!this.settings.enabled) return -1;
143
+ const id = span.id >= 0 ? span.id : idOf(span, TRACK.main);
144
+ const d = this.depth;
145
+ if (d >= MAX_DEPTH) {
146
+ this.warn(id, WARN_DEPTH);
147
+ return -1;
13
148
  }
14
- series.samples[series.cursor] = ms;
15
- series.cursor = (series.cursor + 1) % this.window;
16
- if (series.count < this.window) series.count++;
149
+ this.stackIds[d] = id;
150
+ this.depth = d + 1;
151
+ this.stackStarts[d] = this.now();
152
+ return d * MAX_SPANS + id;
153
+ }
154
+ /** Ends the span `begin` returned `token` for, recording it. */
155
+ end(token) {
156
+ if (token < 0) return;
157
+ const at = this.now();
158
+ const d = token / MAX_SPANS | 0;
159
+ const id = token - d * MAX_SPANS;
160
+ if (d !== this.depth - 1 || this.stackIds[d] !== id) {
161
+ this.warn(id, WARN_ORDER);
162
+ if (d >= this.depth || this.stackIds[d] !== id) return;
163
+ }
164
+ this.depth = d;
165
+ const start = this.stackStarts[d];
166
+ const ms = at - start;
167
+ this.lastMs = ms;
168
+ this.push(id, ms);
169
+ const sink = this.sink;
170
+ if (sink !== void 0) sink.event(id, TRACK.main, d, start, ms, -1);
17
171
  }
172
+ /** Drops the span `token` and everything begun inside it, recording nothing (an exception unwound it). */
173
+ cancel(token) {
174
+ if (token < 0) return;
175
+ const d = token / MAX_SPANS | 0;
176
+ if (d < this.depth) this.depth = d;
177
+ }
178
+ /**
179
+ * Time measured elsewhere (a GPU pass, a worker job): its aggregate, and a timeline event on
180
+ * `track` that ended now, or started at `start`. `frame`: the frame it belongs to, when it lands
181
+ * later (GPU timings).
182
+ */
183
+ record(span, ms, track, start, frame = -1) {
184
+ if (!this.settings.enabled) return;
185
+ const id = idOf(span, track);
186
+ this.push(id, ms);
187
+ const sink = this.sink;
188
+ if (sink !== void 0) {
189
+ const t = track ?? spanTracks[id];
190
+ sink.event(id, t, t === TRACK.main ? this.depth : 0, start ?? this.now() - ms, ms, frame);
191
+ }
192
+ }
193
+ /** Only the aggregate (`render/<node>` sums its views into one sample a frame). */
194
+ sample(span, ms) {
195
+ if (!this.settings.enabled) return;
196
+ this.push(idOf(span), ms);
197
+ }
198
+ /** Only the timeline event, when a capture runs. */
199
+ event(span, track, start, ms, frame = -1) {
200
+ const sink = this.sink;
201
+ if (sink === void 0 || !this.settings.enabled) return;
202
+ sink.event(idOf(span, track), track, track === TRACK.main ? this.depth : 0, start, ms, frame);
203
+ }
204
+ /** Whether a capture is listening (callers skip work that only feeds the timeline). */
205
+ get capturing() {
206
+ return this.sink !== void 0;
207
+ }
208
+ /**
209
+ * Work that starts in one frame and ends in another (an asset load, a page fetch): an async span,
210
+ * matched to its `endAsync` by span and key.
211
+ */
212
+ beginAsync(span, key) {
213
+ if (!this.settings.enabled) return;
214
+ const id = span.id >= 0 ? span.id : idOf(span, TRACK.async);
215
+ if (this.asyncCount >= ASYNC_SLOTS) {
216
+ this.warn(id, WARN_ASYNC);
217
+ return;
218
+ }
219
+ for (let i = 0; i < ASYNC_SLOTS; i++) {
220
+ if (this.asyncIds[i] !== -1) continue;
221
+ this.asyncIds[i] = id;
222
+ this.asyncKeys[i] = key;
223
+ this.asyncStarts[i] = this.now();
224
+ this.asyncCount++;
225
+ return;
226
+ }
227
+ }
228
+ endAsync(span, key) {
229
+ if (!this.settings.enabled || span.id < 0 || this.asyncCount === 0) return;
230
+ const id = span.id;
231
+ for (let i = 0; i < ASYNC_SLOTS; i++) {
232
+ if (this.asyncIds[i] !== id || this.asyncKeys[i] !== key) continue;
233
+ const start = this.asyncStarts[i];
234
+ const ms = this.now() - start;
235
+ this.asyncIds[i] = -1;
236
+ this.asyncKeys[i] = void 0;
237
+ this.asyncCount--;
238
+ this.push(id, ms);
239
+ const sink = this.sink;
240
+ if (sink !== void 0) sink.event(id, TRACK.async, 0, start, ms, -1);
241
+ return;
242
+ }
243
+ }
244
+ // --- frames ------------------------------------------------------------------
245
+ /**
246
+ * A frame starts: spans left open from before are dropped (and reported once), and the `frame`
247
+ * span begins. Returns its token for `endFrame`.
248
+ */
249
+ beginFrame(frame) {
250
+ if (!this.settings.enabled) return -1;
251
+ if (this.depth > 0) {
252
+ const id = this.stackIds[this.depth - 1];
253
+ this.warn(id, WARN_OPEN_AT_START);
254
+ this.depth = 0;
255
+ }
256
+ if (this.settings.window !== this.windowSize) this.resize();
257
+ this.frame = frame;
258
+ const token = this.begin(this.frameSpan);
259
+ this.frameStartAt = this.stackStarts[0];
260
+ const sink = this.sink;
261
+ if (sink !== void 0) sink.frameStart(frame, this.frameStartAt);
262
+ return token;
263
+ }
264
+ endFrame(token) {
265
+ if (token < 0) return;
266
+ if (this.depth > 1) {
267
+ const id = this.stackIds[this.depth - 1];
268
+ this.warn(id, WARN_OPEN_AT_END);
269
+ this.depth = 1;
270
+ }
271
+ this.end(token);
272
+ const sink = this.sink;
273
+ if (sink !== void 0) sink.frameEnd(this.frame, this.lastMs);
274
+ }
275
+ // --- reading -----------------------------------------------------------------
18
276
  timing(name) {
19
- const series = this.series.get(name);
277
+ const id = spanIds.get(name);
278
+ const series = id === void 0 ? void 0 : this.series[id];
20
279
  if (!series || series.count === 0) return void 0;
21
280
  let sum = 0;
22
281
  let max = 0;
@@ -25,20 +284,174 @@ class Profiler {
25
284
  sum += v;
26
285
  if (v > max) max = v;
27
286
  }
28
- const last = series.samples[(series.cursor - 1 + this.window) % this.window];
287
+ const last = series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
29
288
  return { last, avg: sum / series.count, max, samples: series.count };
30
289
  }
290
+ /** A span's samples over the window, in ring order (not by age); undefined if it never ran. */
291
+ samples(name) {
292
+ const id = spanIds.get(name);
293
+ const series = id === void 0 ? void 0 : this.series[id];
294
+ return series?.count ? series.samples.subarray(0, series.count) : void 0;
295
+ }
296
+ /** Every span that ran, by name. */
31
297
  all() {
32
298
  const out = {};
33
- for (const name of this.series.keys()) out[name] = this.timing(name);
299
+ for (let id = 0; id < this.series.length; id++) {
300
+ if (!this.series[id]?.count) continue;
301
+ const name = spanNames[id];
302
+ out[name] = this.timing(name);
303
+ }
304
+ return out;
305
+ }
306
+ /**
307
+ * How many samples a span has had since the profiler started (or its aggregates were reset), by
308
+ * id: 0 if none. Allocates nothing, for per-frame readers such as feature budgets (0075).
309
+ */
310
+ runsOf(id) {
311
+ return this.series[id]?.total ?? 0;
312
+ }
313
+ /**
314
+ * The span that was open around a span's latest sample (its parent on the main track), by id, or
315
+ * -1: what lets aggregates count time two covering keys share once (0075's slices).
316
+ */
317
+ parentOf(id) {
318
+ return this.series[id]?.parent ?? -1;
319
+ }
320
+ /** A span's latest sample in ms, by id, or 0 if it has none. Allocates nothing. */
321
+ lastOf(id) {
322
+ const series = this.series[id];
323
+ if (series === void 0 || series.count === 0) return 0;
324
+ return series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
325
+ }
326
+ /** Names of every span with samples. */
327
+ names() {
328
+ const out = [];
329
+ for (let id = 0; id < this.series.length; id++)
330
+ if (this.series[id]?.count) out.push(spanNames[id]);
34
331
  return out;
35
332
  }
333
+ /** Forgets every aggregate (`perf.reset`). */
334
+ reset() {
335
+ for (const series of this.series) {
336
+ if (!series) continue;
337
+ series.count = 0;
338
+ series.cursor = 0;
339
+ series.total = 0;
340
+ }
341
+ }
342
+ // --- internals ---------------------------------------------------------------
343
+ push(id, ms) {
344
+ let series = this.series[id];
345
+ if (series === void 0) series = this.createSeries(id);
346
+ series.samples[series.cursor] = ms;
347
+ series.cursor = series.cursor + 1 === this.windowSize ? 0 : series.cursor + 1;
348
+ if (series.count < this.windowSize) series.count++;
349
+ series.total++;
350
+ series.parent = this.depth > 0 ? this.stackIds[this.depth - 1] : -1;
351
+ }
352
+ createSeries(id) {
353
+ while (this.series.length <= id) this.series.push(void 0);
354
+ const series = {
355
+ samples: new Float64Array(this.windowSize),
356
+ cursor: 0,
357
+ count: 0,
358
+ total: 0,
359
+ parent: -1
360
+ };
361
+ this.series[id] = series;
362
+ return series;
363
+ }
364
+ resize() {
365
+ this.windowSize = Math.max(1, this.settings.window | 0);
366
+ for (let id = 0; id < this.series.length; id++) {
367
+ if (this.series[id])
368
+ this.series[id] = {
369
+ samples: new Float64Array(this.windowSize),
370
+ cursor: 0,
371
+ count: 0,
372
+ total: this.series[id].total,
373
+ parent: this.series[id].parent
374
+ };
375
+ }
376
+ }
377
+ /** Reports a problem once per span name. */
378
+ warn(id, kind) {
379
+ if (id >= this.warned.length) {
380
+ const grown = new Uint8Array(Math.max(id + 1, this.warned.length * 2));
381
+ grown.set(this.warned);
382
+ this.warned = grown;
383
+ }
384
+ if (this.warned[id]) return;
385
+ this.warned[id] = 1;
386
+ this.onWarning?.(warningOf(kind, spanName(id)));
387
+ }
388
+ }
389
+ const WARN_ORDER = 0;
390
+ const WARN_OPEN_AT_START = 1;
391
+ const WARN_OPEN_AT_END = 2;
392
+ const WARN_DEPTH = 3;
393
+ const WARN_ASYNC = 4;
394
+ const MISMATCH = [
395
+ "ended out of order: spans end in the reverse order they began",
396
+ "was still open when a frame began",
397
+ "was still open when its frame ended"
398
+ ];
399
+ function warningOf(kind, name) {
400
+ if (kind === WARN_DEPTH) {
401
+ return new ShardError(
402
+ "perf/span-overflow",
403
+ `Spans nest deeper than ${MAX_DEPTH} at "${name}"`,
404
+ {
405
+ hint: "A span that begins in a loop needs its end inside the loop too.",
406
+ path: name
407
+ }
408
+ );
409
+ }
410
+ if (kind === WARN_ASYNC) {
411
+ return new ShardError("perf/async-overflow", `More than ${ASYNC_SLOTS} async spans are open`, {
412
+ hint: "Every beginAsync(span, key) needs an endAsync(span, key), even when the work fails.",
413
+ path: name
414
+ });
415
+ }
416
+ return new ShardError("perf/span-mismatch", `Span "${name}" ${MISMATCH[kind]}`, {
417
+ hint: "Every profiler.begin(span) needs one profiler.end(token), in the same frame.",
418
+ path: name
419
+ });
420
+ }
421
+ function spanStats(profiler, name) {
422
+ const timing = profiler.timing(name);
423
+ const samples = profiler.samples(name);
424
+ if (!timing || !samples) return void 0;
425
+ const sorted = Float64Array.from(samples).sort();
426
+ const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
427
+ const out = {
428
+ span: name,
429
+ track: trackName(spanTrack(spanIds.get(name))),
430
+ ...timing,
431
+ p50: at(0.5),
432
+ p95: at(0.95)
433
+ };
434
+ const resolution = clockInfo().resolutionMs;
435
+ if (out.track !== "gpu" && resolution > 0 && timing.avg < 4 * resolution) out.coarse = true;
436
+ return out;
36
437
  }
37
438
  const ProfilerResource = defineResource("core/Profiler", {
38
- description: "Per-system CPU timings over recent frames.",
439
+ description: "Span timings (0074): systems, schedules, frames, render passes, GPU and workers.",
39
440
  init: () => new Profiler()
40
441
  });
41
442
  export {
42
443
  Profiler,
43
- ProfilerResource
444
+ ProfilerResource,
445
+ ProfilerSettings,
446
+ TRACK,
447
+ clockInfo,
448
+ defineSpan,
449
+ internSpan,
450
+ spanCount,
451
+ spanCovers,
452
+ spanId,
453
+ spanName,
454
+ spanStats,
455
+ spanTrack,
456
+ trackName
44
457
  };
@@ -1,5 +1,5 @@
1
1
  import type { World } from '../ecs/world.js';
2
- import type { Profiler } from './profiler.js';
2
+ import { type Profiler } from './profiler.js';
3
3
  import { SystemConfig, type SystemDef, type SystemSetConfig } from './system.js';
4
4
  export interface ScheduleLabel {
5
5
  readonly kind: 'schedule';
@@ -33,6 +33,10 @@ export interface SystemDescription {
33
33
  */
34
34
  export declare class Schedule {
35
35
  readonly label: ScheduleLabel;
36
+ /** `schedule/<Label>`: one run of the schedule (0074). */
37
+ private readonly span;
38
+ /** `commands/<Label>`: command application over one run. */
39
+ private readonly commandsSpan;
36
40
  private readonly entries;
37
41
  private readonly setConfigs;
38
42
  private order;
@@ -1,5 +1,6 @@
1
1
  import { Commands } from "../ecs/commands.js";
2
2
  import { ShardError } from "../error.js";
3
+ import { defineSpan, TRACK } from "./profiler.js";
3
4
  import {
4
5
  conditionLabel,
5
6
  SystemConfig
@@ -35,16 +36,23 @@ class Context {
35
36
  }
36
37
  class Schedule {
37
38
  label;
39
+ /** `schedule/<Label>`: one run of the schedule (0074). */
40
+ span;
41
+ /** `commands/<Label>`: command application over one run. */
42
+ commandsSpan;
38
43
  entries = [];
39
44
  setConfigs = [];
40
45
  order;
41
46
  constructor(label) {
42
47
  this.label = label;
48
+ this.span = defineSpan(`schedule/${label.name}`);
49
+ this.commandsSpan = defineSpan(`commands/${label.name}`);
43
50
  }
44
51
  add(system) {
45
52
  const config = system instanceof SystemConfig ? system : new SystemConfig(system);
46
53
  this.entries.push({
47
54
  config,
55
+ span: defineSpan(config.system.name),
48
56
  index: this.entries.length,
49
57
  state: void 0,
50
58
  ctx: void 0,
@@ -77,6 +85,9 @@ class Schedule {
77
85
  }
78
86
  run(world, options) {
79
87
  const order = this.resolve();
88
+ const profiler = options.profiler;
89
+ const scheduleToken = profiler !== void 0 ? profiler.begin(this.span) : -1;
90
+ let commandsMs = 0;
80
91
  for (let i = 0; i < order.length; i++) {
81
92
  const entry = order[i];
82
93
  const conditions = entry.conditions;
@@ -95,10 +106,11 @@ class Schedule {
95
106
  }
96
107
  const ctx = entry.ctx;
97
108
  ctx.thisRunTick = world.incrementTick();
98
- const start = options.now();
109
+ const token = profiler !== void 0 ? profiler.begin(entry.span) : -1;
99
110
  try {
100
111
  system.run(entry.state, world, ctx);
101
112
  } catch (err) {
113
+ profiler?.cancel(scheduleToken);
102
114
  const message = err instanceof Error ? err.message : String(err);
103
115
  const code = err instanceof ShardError ? ` [${err.code}]` : "";
104
116
  throw new ShardError(
@@ -110,10 +122,28 @@ class Schedule {
110
122
  }
111
123
  );
112
124
  }
113
- options.profiler?.record(system.name, options.now() - start);
125
+ if (profiler !== void 0) profiler.end(token);
114
126
  ctx.lastRunTick = ctx.thisRunTick;
115
127
  world.incrementTick();
116
- if (ctx.commands.length > 0) ctx.commands.apply();
128
+ if (ctx.commands.length > 0) {
129
+ if (profiler === void 0) ctx.commands.apply();
130
+ else {
131
+ const start = profiler.now();
132
+ try {
133
+ ctx.commands.apply();
134
+ } catch (err) {
135
+ profiler.cancel(scheduleToken);
136
+ throw err;
137
+ }
138
+ const ms = profiler.now() - start;
139
+ commandsMs += ms;
140
+ profiler.event(this.commandsSpan, TRACK.main, start, ms);
141
+ }
142
+ }
143
+ }
144
+ if (profiler !== void 0) {
145
+ profiler.sample(this.commandsSpan, commandsMs);
146
+ profiler.end(scheduleToken);
117
147
  }
118
148
  }
119
149
  describe() {
@@ -9,12 +9,106 @@
9
9
  *
10
10
  * Write every timing assertion through `budget()`, `slack` or `allocationChecks`, and it lands in
11
11
  * the right tier.
12
+ *
13
+ * Budgets are data (spec 0075): `bench/perf/budgets.json` holds a number per key and named machine
14
+ * (`bench/perf/machines.json`), and `budget(key)` reads it. Core can't read files, so the numbers
15
+ * arrive resolved, as JSON in the `SHARD_BUDGETS` environment variable:
16
+ *
17
+ * - `pnpm bench` (`scripts/bench.mjs`) detects the machine, resolves every key for it, and passes
18
+ * the result (`ResolvedBudgets`) to the test processes. On an unknown machine it passes the
19
+ * closest machine's numbers with `enforce: false`: budgets stay unlimited, and the report
20
+ * (`bench/perf/report.json`) compares against them.
21
+ * - `pnpm test` gets the keys with no machine from `testFiles()` (`scripts/test-shard.mjs`), so an
22
+ * unknown key fails there too (`perf/unknown-budget`) while every budget is unlimited.
23
+ *
24
+ * Under `pnpm bench`, `SHARD_PERF_REPORT` names a folder: each `budget(key)` call is noted on
25
+ * `globalThis.__shardBudget`, and `scripts/perf-report.setup.mjs` records the value the next
26
+ * comparison matcher (`toBeLessThan` and the like) checks against it.
12
27
  */
13
28
  export type TimingMode = 'bench' | 'test' | 'ci';
14
29
  export declare const timingMode: TimingMode;
15
30
  /** The factor budgets are multiplied by (and rate floors divided by): 1 under `pnpm bench`, else ∞. */
16
31
  export declare const slack: number;
17
- /** A time budget in ms: exact under `pnpm bench`, unlimited in `pnpm test` and CI. */
32
+ /** One key of `budgets.json`, resolved for a machine. `value` is null without one. */
33
+ export interface ResolvedSpan {
34
+ kind: 'target' | 'unit' | 'rate' | 'guard';
35
+ value: number | null;
36
+ /** Guards: the limit is `value × (1 + margin)`. */
37
+ margin?: number;
38
+ /** Units and rates: what one item is ("prop", "point"). */
39
+ per?: string;
40
+ /** What the number measures when it isn't ms: 'frames', 'ratio'. */
41
+ in?: string;
42
+ }
43
+ export type Track = 'gpu' | 'cpu';
44
+ export interface ResolvedScenario {
45
+ /** The frame budget per track, in ms. */
46
+ frame: Record<Track, number | null>;
47
+ /** Each slice in ms (its share of the frame, or the machine's override). */
48
+ slices: Record<Track, Record<string, number | null>>;
49
+ }
50
+ /** What `SHARD_BUDGETS` carries. */
51
+ export interface ResolvedBudgets {
52
+ /** The machine the numbers are for; null outside `pnpm bench`. */
53
+ machine: string | null;
54
+ /** False on an unknown machine and outside `pnpm bench`: every budget is unlimited. */
55
+ enforce: boolean;
56
+ spans: Record<string, ResolvedSpan>;
57
+ scenarios: Record<string, ResolvedScenario>;
58
+ /**
59
+ * How the machine checks GPU slices (machines.json): per-pass timestamps, or ablation where
60
+ * passes overlap (tile-based GPUs). Absent outside `pnpm bench`.
61
+ */
62
+ passTiming?: 'ablation' | 'timestamps' | null;
63
+ }
64
+ export interface BudgetOptions {
65
+ /** Unit costs: how many items the measurement covered (the budget is the unit cost × this). */
66
+ count?: number;
67
+ /** Scenarios: a slice's budget instead of the whole frame's. */
68
+ slice?: string;
69
+ /** Scenarios: 'gpu' or 'cpu'. Default: 'gpu' for a `gpu:` slice, else 'cpu'. */
70
+ track?: Track;
71
+ }
72
+ /** A key's number on the resolved machine, and the limit a test checks: ∞ (0 for rates) when off. */
73
+ export interface ResolvedLimit {
74
+ /** What the test compares against. */
75
+ limit: number;
76
+ /** The machine's number for the comparison (null without one), enforced or not. */
77
+ reference: number | null;
78
+ kind: ResolvedSpan['kind'] | 'slice';
79
+ }
80
+ /**
81
+ * The limit for `key` from resolved budgets: a span's number (a unit's times `count`, a guard's
82
+ * plus its margin), or a scenario's frame or slice in ms. Unlimited unless `enforce`: ∞, or 0 for a
83
+ * rate (a floor). Throws `perf/unknown-budget` for a key, scenario or slice budgets.json lacks.
84
+ */
85
+ export declare function resolveLimit(budgets: ResolvedBudgets | undefined, key: string, opts?: BudgetOptions, enforce?: boolean): ResolvedLimit;
86
+ /** The last `budget(key)` call, for `scripts/perf-report.setup.mjs` (under `pnpm bench` only). */
87
+ export interface BudgetCall extends ResolvedLimit {
88
+ key: string;
89
+ opts: BudgetOptions;
90
+ used: boolean;
91
+ }
92
+ /**
93
+ * A scenario's slice keys per track, `headroom` aside (0075): what a scenario test measures. Throws
94
+ * `perf/unknown-budget` for a scenario budgets.json lacks, in `pnpm test` too.
95
+ */
96
+ export declare function scenarioSlices(name: string): Record<Track, string[]>;
97
+ /**
98
+ * How GPU slices are measured on this machine (0075): `'ablation'` under `pnpm bench` on a machine
99
+ * whose passes overlap (machines.json's `passTiming`), else `'timestamps'`.
100
+ */
101
+ export declare function passTiming(): 'ablation' | 'timestamps';
102
+ /**
103
+ * A time budget from `bench/perf/budgets.json`: the detected machine's number under `pnpm bench`,
104
+ * unlimited in `pnpm test`, CI, and on an unknown machine (∞; 0 for a rate, which is a floor).
105
+ * `{ count }` scales a unit cost; `{ slice, track }` reads a scenario's slice. An unknown key throws
106
+ * `perf/unknown-budget`.
107
+ *
108
+ * `budget(ms)`, a literal, still works (exact under `pnpm bench`): `scripts/budget-literals.mjs`
109
+ * lists those calls, and the goal is none.
110
+ */
111
+ export declare function budget(key: string, opts?: BudgetOptions): number;
18
112
  export declare function budget(ms: number): number;
19
113
  /** A test or hook timeout in ms: 5× in CI, unchanged everywhere else. */
20
114
  export declare function timeout(ms: number): number;