@zakkster/lite-perf-gate 1.3.0 → 1.4.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.
package/PerfGate.js CHANGED
@@ -2,22 +2,32 @@
2
2
  * @zakkster/lite-perf-gate
3
3
  * Zero-GC and performance regression gate for node:test.
4
4
  *
5
- * Three measurement signals, each for what it can actually see:
5
+ * Five measurement signals, each for what it can actually see:
6
6
  * 1. Scavenge count (perf_hooks 'gc', minor) -- the reliable detector
7
- * of TRANSIENT allocation. Retained-heap misses it (freed before
8
- * snapshot); the V8 sampling profiler misses it (reports live-at-stop).
7
+ * of TRANSIENT young-generation allocation. Retained-heap misses it
8
+ * (freed before snapshot); the V8 sampling profiler misses it
9
+ * (reports live-at-stop).
9
10
  * 2. Custom counters (user-supplied statsOf) -- exact engine internals.
10
11
  * 3. Retained-heap delta (memoryUsage + gc) -- leak detector.
12
+ * 4. Old-gen activity (perf_hooks 'gc', major + incremental) -- catches
13
+ * churn that promotes or triggers old-space marking, which the scavenge
14
+ * counter alone cannot see (PG-03a). This is a GATE signal, not a
15
+ * profiler: when it trips, diagnose the cause with @zakkster/lite-gc-profiler.
16
+ * 5. External/arrayBuffers delta (memoryUsage().arrayBuffers + gc) -- catches
17
+ * backing-store memory that heapUsed excludes (PG-03b). Same boundary:
18
+ * it names that external memory grew and points at lite-gc-profiler.
11
19
  *
12
- * Pass/fail uses scaling: measure at N and k*N. Zero-alloc => ~0 scavenges
13
- * at both; allocation => scavenges scale with total bytes. Controls validate
14
- * the detector on every run.
20
+ * Pass/fail uses scaling on scavenges: measure at N and k*N. Zero-alloc =>
21
+ * ~0 scavenges at both; allocation => scavenges scale with total bytes. The
22
+ * old-gen and external lanes are ABSOLUTE (rare events, so a scaling lane on
23
+ * a 0-or-1 signal is noise -- decisions/0003). Controls validate the detector
24
+ * on every run.
15
25
  *
16
26
  * Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
17
27
  * MIT License
18
28
  */
19
29
 
20
- export const VERSION = '1.3.0';
30
+ export const VERSION = '1.4.0';
21
31
 
22
32
  import {PerformanceObserver, constants} from 'node:perf_hooks';
23
33
  import {setTimeout as sleep} from 'node:timers/promises';
@@ -26,18 +36,22 @@ import assert from 'node:assert/strict';
26
36
 
27
37
  const MINOR = constants.NODE_PERFORMANCE_GC_MINOR;
28
38
  const MAJOR = constants.NODE_PERFORMANCE_GC_MAJOR;
39
+ const INCR = constants.NODE_PERFORMANCE_GC_INCREMENTAL;
40
+ const WEAK = constants.NODE_PERFORMANCE_GC_WEAKCB;
29
41
 
30
42
  // ---------------------------------------------------------------------------
31
43
  // GC counter
32
44
  // ---------------------------------------------------------------------------
33
45
 
34
46
  function makeGcCounter() {
35
- const c = {minor: 0, major: 0};
47
+ const c = {minor: 0, major: 0, incremental: 0, weakcb: 0};
36
48
  const obs = new PerformanceObserver(function (list) {
37
49
  for (const e of list.getEntries()) {
38
50
  const k = e.detail ? e.detail.kind : e.kind;
39
51
  if (k === MINOR) c.minor++;
40
52
  else if (k === MAJOR) c.major++;
53
+ else if (k === INCR) c.incremental++;
54
+ else if (k === WEAK) c.weakcb++;
41
55
  }
42
56
  });
43
57
  obs.observe({entryTypes: ['gc']});
@@ -77,6 +91,171 @@ if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH
77
91
  if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
78
92
  }
79
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // Fail-closed doors (v1.4.0) -- all cold path
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const NO_GC_MSG = 'lite-perf-gate: measure: globalThis.gc is not a function -- ' +
99
+ 'the retained signal cannot be measured and the scavenge counts are unsettled. ' +
100
+ 'Run: node --expose-gc --max-semi-space-size=4 <entry> ' +
101
+ '(or node --expose-gc --max-semi-space-size=4 --test <file>). ' +
102
+ 'Pass allowNoGc: true to gate scavenges and counters only.';
103
+ const NO_RETAINED = 'retained is ungateable without --expose-gc ' +
104
+ '(allowNoGc: true with an explicit maxRetainedKB) -- drop one of them.';
105
+ const NO_ARRAYBUFFERS = 'arrayBuffers is ungateable without --expose-gc ' +
106
+ '(allowNoGc: true with an explicit maxArrayBuffersKB) -- drop one of them.';
107
+ const EMPTY_HINT = ' -- pass allowEmpty: true for a controls-only detector smoke run.';
108
+
109
+ /**
110
+ * The ONE option/threshold/scenario door. measure, verdict, zgcSuite and
111
+ * runGate all call it; nothing bypasses it and there is no skip flag.
112
+ * Cold path by construction -- no caller is a hot body.
113
+ *
114
+ * @param {'measure'|'verdict'|'zgcSuite'|'runGate'} where message prefix + mode
115
+ * @param {object|undefined|null} cfg measure options / verdict thresholds / gate config
116
+ * @param {Scenario|null} scenario the single scenario for measure(), else null
117
+ * @returns {{N: number, k: number, flushMs: number, allowNoGc: boolean, allowEmpty: boolean}}
118
+ * @throws {TypeError|RangeError} naming the field and the offending value
119
+ */
120
+ function validateGate(where, cfg, scenario) {
121
+ const P = 'lite-perf-gate: ' + where + ': ';
122
+ if (cfg !== undefined && cfg !== null && typeof cfg !== 'object') {
123
+ throw new TypeError(P + 'config must be an object (got ' + typeof cfg + ')');
124
+ }
125
+ const c = cfg || {};
126
+
127
+ let N = 200000;
128
+ if (c.N !== undefined) {
129
+ if (!Number.isInteger(c.N) || c.N < 1) {
130
+ throw new RangeError(P + 'N must be an integer >= 1 (got ' + String(c.N) + ')');
131
+ }
132
+ N = c.N;
133
+ }
134
+ let k = 8;
135
+ if (c.k !== undefined) {
136
+ if (!Number.isInteger(c.k) || c.k < 2) {
137
+ throw new RangeError(P + 'k must be an integer >= 2 (got ' + String(c.k) + ')');
138
+ }
139
+ k = c.k;
140
+ }
141
+ let flushMs = DEFAULT_FLUSH_MS;
142
+ if (c.flushMs !== undefined) {
143
+ if (typeof c.flushMs !== 'number' || !isFinite(c.flushMs) || c.flushMs < 0) {
144
+ throw new RangeError(P + 'flushMs must be a finite number >= 0 (got ' + String(c.flushMs) + ')');
145
+ }
146
+ flushMs = c.flushMs; // 0 is legal and IS honored (PG-09)
147
+ }
148
+ if (c.allowNoGc !== undefined && c.allowNoGc !== true && c.allowNoGc !== false) {
149
+ throw new TypeError(P + 'allowNoGc must be true or false (got ' + String(c.allowNoGc) + ')');
150
+ }
151
+ if (c.allowEmpty !== undefined && c.allowEmpty !== true && c.allowEmpty !== false) {
152
+ throw new TypeError(P + 'allowEmpty must be true or false (got ' + String(c.allowEmpty) + ')');
153
+ }
154
+ const allowNoGc = c.allowNoGc === true;
155
+ const allowEmpty = c.allowEmpty === true;
156
+
157
+ if (where !== 'measure') {
158
+ if (c.maxScavenges !== undefined &&
159
+ (typeof c.maxScavenges !== 'number' || !isFinite(c.maxScavenges) || c.maxScavenges < 0)) {
160
+ throw new RangeError(P + 'maxScavenges must be a finite number >= 0 (got ' + String(c.maxScavenges) + ')');
161
+ }
162
+ if (c.maxRetainedKB !== undefined &&
163
+ (typeof c.maxRetainedKB !== 'number' || !isFinite(c.maxRetainedKB) || c.maxRetainedKB < 0)) {
164
+ throw new RangeError(P + 'maxRetainedKB must be a finite number >= 0 (got ' + String(c.maxRetainedKB) + ')');
165
+ }
166
+ if (c.maxOldGen !== undefined &&
167
+ (typeof c.maxOldGen !== 'number' || !isFinite(c.maxOldGen) || c.maxOldGen < 0)) {
168
+ throw new RangeError(P + 'maxOldGen must be a finite number >= 0 (got ' + String(c.maxOldGen) + ')');
169
+ }
170
+ if (c.maxArrayBuffersKB !== undefined &&
171
+ (typeof c.maxArrayBuffersKB !== 'number' || !isFinite(c.maxArrayBuffersKB) || c.maxArrayBuffersKB < 0)) {
172
+ throw new RangeError(P + 'maxArrayBuffersKB must be a finite number >= 0 (got ' + String(c.maxArrayBuffersKB) + ')');
173
+ }
174
+ if (c.counters !== undefined && c.counters !== null) {
175
+ if (typeof c.counters !== 'object') {
176
+ throw new TypeError(P + 'counters must be an object of numeric maxima (got ' + typeof c.counters + ')');
177
+ }
178
+ for (const key in c.counters) {
179
+ const t = c.counters[key];
180
+ if (typeof t !== 'number' || !isFinite(t)) {
181
+ throw new RangeError(P + 'counters.' + key + ' must be a finite number (got ' + String(t) + ')');
182
+ }
183
+ }
184
+ }
185
+ if (allowNoGc && c.maxRetainedKB !== undefined) throw new RangeError(P + NO_RETAINED);
186
+ if (allowNoGc && c.maxArrayBuffersKB !== undefined) throw new RangeError(P + NO_ARRAYBUFFERS);
187
+ }
188
+
189
+ let list = null;
190
+ let labels = null;
191
+ if (where === 'measure') {
192
+ list = [scenario];
193
+ labels = ['scenario'];
194
+ } else if (where === 'zgcSuite' || where === 'runGate') {
195
+ const sc = c.scenarios;
196
+ if (!Array.isArray(sc)) {
197
+ throw new TypeError(P + 'scenarios must be an array (got ' +
198
+ (sc === undefined ? 'undefined' : typeof sc) + ')' + EMPTY_HINT);
199
+ }
200
+ if (sc.length === 0 && !allowEmpty) {
201
+ throw new RangeError(P + 'scenarios must be a non-empty array' + EMPTY_HINT);
202
+ }
203
+ list = [];
204
+ labels = [];
205
+ for (let i = 0; i < sc.length; i++) {
206
+ list.push(sc[i]);
207
+ labels.push('scenarios[' + i + ']');
208
+ }
209
+ if (c.mustFail !== undefined) {
210
+ if (!Array.isArray(c.mustFail)) {
211
+ throw new TypeError(P + 'mustFail must be an array (got ' + typeof c.mustFail + ')');
212
+ }
213
+ for (let i = 0; i < c.mustFail.length; i++) {
214
+ list.push(c.mustFail[i]);
215
+ labels.push('mustFail[' + i + ']');
216
+ }
217
+ }
218
+ if (c.positiveControl !== undefined) {
219
+ list.push(c.positiveControl);
220
+ labels.push('positiveControl');
221
+ }
222
+ if (c.negativeControl !== undefined) {
223
+ list.push(c.negativeControl);
224
+ labels.push('negativeControl');
225
+ }
226
+ if (c.largeControl !== undefined) {
227
+ list.push(c.largeControl);
228
+ labels.push('largeControl');
229
+ }
230
+ }
231
+ if (list !== null) {
232
+ for (let i = 0; i < list.length; i++) {
233
+ const s = list[i];
234
+ const L = labels[i];
235
+ if (!s || typeof s !== 'object') {
236
+ throw new TypeError(P + L + ' must be a Scenario object (got ' +
237
+ (s === null ? 'null' : typeof s) + ')');
238
+ }
239
+ if (typeof s.name !== 'string' || s.name.length === 0) {
240
+ throw new TypeError(P + L + '.name must be a non-empty string (got ' + String(s.name) + ')');
241
+ }
242
+ if (typeof s.setup !== 'function') {
243
+ throw new TypeError(P + L + ' "' + s.name + '": setup must be a function (got ' + typeof s.setup + ')');
244
+ }
245
+ if (typeof s.hot !== 'function') {
246
+ throw new TypeError(P + L + ' "' + s.name + '": hot must be a function (got ' + typeof s.hot + ')');
247
+ }
248
+ if (s.statsOf !== undefined && typeof s.statsOf !== 'function') {
249
+ throw new TypeError(P + L + ' "' + s.name + '": statsOf must be a function when present (got ' + typeof s.statsOf + ')');
250
+ }
251
+ if (s.teardown !== undefined && typeof s.teardown !== 'function') {
252
+ throw new TypeError(P + L + ' "' + s.name + '": teardown must be a function when present (got ' + typeof s.teardown + ')');
253
+ }
254
+ }
255
+ }
256
+ return {N: N, k: k, flushMs: flushMs, allowNoGc: allowNoGc, allowEmpty: allowEmpty};
257
+ }
258
+
80
259
  // ---------------------------------------------------------------------------
81
260
  // Core measurement
82
261
  // ---------------------------------------------------------------------------
@@ -87,7 +266,9 @@ async function meterOnce(scenario, iters, flushMs) {
87
266
  await gc2();
88
267
 
89
268
  const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
90
- const heapBefore = process.memoryUsage().heapUsed;
269
+ const mu0 = process.memoryUsage();
270
+ const heapBefore = mu0.heapUsed;
271
+ const abBefore = mu0.arrayBuffers;
91
272
 
92
273
  const gcc = makeGcCounter();
93
274
  scenario.hot(state, iters);
@@ -96,8 +277,16 @@ async function meterOnce(scenario, iters, flushMs) {
96
277
  const major = gcc.c.major;
97
278
  gcc.close();
98
279
 
280
+ // Old-gen activity = major + incremental. incremental is read AFTER
281
+ // gcc.close() on purpose: the measurement window (makeGcCounter -> close)
282
+ // stays byte-identical to v1.4.0, and the observer callback has already
283
+ // tallied every 'gc' entry delivered during the flush sleep. decisions/0003.
284
+ const oldGen = major + gcc.c.incremental;
285
+
99
286
  await gc2();
100
- const heapAfter = process.memoryUsage().heapUsed;
287
+ const mu1 = process.memoryUsage();
288
+ const heapAfter = mu1.heapUsed;
289
+ const abAfter = mu1.arrayBuffers;
101
290
  const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
102
291
  if (scenario.teardown) scenario.teardown(state);
103
292
 
@@ -111,7 +300,12 @@ async function meterOnce(scenario, iters, flushMs) {
111
300
  }
112
301
  }
113
302
 
114
- return {minor: minor, major: major, retainedKB: (heapAfter - heapBefore) / 1024, counters: counters};
303
+ return {
304
+ minor: minor, major: major, oldGen: oldGen,
305
+ retainedKB: (heapAfter - heapBefore) / 1024,
306
+ arrayBuffersKB: (abAfter - abBefore) / 1024,
307
+ counters: counters
308
+ };
115
309
  }
116
310
 
117
311
  // ---------------------------------------------------------------------------
@@ -136,25 +330,31 @@ async function meterOnce(scenario, iters, flushMs) {
136
330
  * allocation via scavenge scaling.
137
331
  *
138
332
  * @param {Scenario} scenario
139
- * @param {{ N?: number, k?: number, flushMs?: number }} [options]
140
- * flushMs: how long to wait after the hot loop before reading the GC
141
- * observer buffer. Default 100ms (or PERF_GATE_FLUSH_MS env var).
142
- * Bump if you see zero scavenges on scenarios that should allocate --
143
- * noisy CI runners may need 250-500ms.
333
+ * @param {{ N?: number, k?: number, flushMs?: number, allowNoGc?: boolean }} [options]
334
+ * N integer >= 1 (default 200000), k integer >= 2 (default 8), flushMs a
335
+ * finite number >= 0 (0 is legal and honored). flushMs is how long to wait
336
+ * after the hot loop before reading the GC observer buffer. Default 100ms
337
+ * (or PERF_GATE_FLUSH_MS env var). Bump if you see zero scavenges on
338
+ * scenarios that should allocate -- noisy CI runners may need 250-500ms.
339
+ * allowNoGc: run without --expose-gc; the retained signal is dropped and
340
+ * the result carries retainedReliable: false. Bad values throw.
144
341
  * @returns {Promise<MeasureResult>}
145
342
  */
146
343
  export async function measure(scenario, options) {
147
- const N = (options && options.N) || 200000;
148
- const k = (options && options.k) || 8;
149
- const flushMs = (options && options.flushMs) || DEFAULT_FLUSH_MS;
150
- const lo = await meterOnce(scenario, N, flushMs);
151
- const hi = await meterOnce(scenario, k * N, flushMs);
344
+ const o = validateGate('measure', options, scenario);
345
+ const gcOk = typeof globalThis.gc === 'function';
346
+ if (!gcOk && !o.allowNoGc) throw new Error(NO_GC_MSG);
347
+ const lo = await meterOnce(scenario, o.N, o.flushMs);
348
+ const hi = await meterOnce(scenario, o.k * o.N, o.flushMs);
152
349
  return {
153
- name: scenario.name, N: N, k: k,
350
+ name: scenario.name, N: o.N, k: o.k,
154
351
  minorLo: lo.minor, minorHi: hi.minor,
155
352
  majorLo: lo.major, majorHi: hi.major,
353
+ oldGenLo: lo.oldGen, oldGenHi: hi.oldGen,
156
354
  retainedKB_lo: lo.retainedKB, retainedKB_hi: hi.retainedKB,
157
- counters_lo: lo.counters, counters_hi: hi.counters
355
+ arrayBuffersKB_lo: lo.arrayBuffersKB, arrayBuffersKB_hi: hi.arrayBuffersKB,
356
+ counters_lo: lo.counters, counters_hi: hi.counters,
357
+ retainedReliable: gcOk
158
358
  };
159
359
  }
160
360
 
@@ -220,6 +420,90 @@ export const controlNegative = {
220
420
  }
221
421
  };
222
422
 
423
+ // controlLarge -- the detector control for the EXTERNAL/arrayBuffers signal
424
+ // (signal 5). The census (decisions/0003) proved the two things that fix this
425
+ // shape: (1) 256KB-string LO churn -- the planned controlLarge candidate --
426
+ // trips NEITHER new signal on Node v26.3.1 (oldGen 0, arrayBuffers 0), so it
427
+ // is not a control; (2) the oldGen positive control at suite defaults
428
+ // (Float64Array churn) costs ~2730ms and jitters 2744..2944, i.e. a per-run
429
+ // oldGen floor is noise amplification (Axis C's own rejection). A mask-gated
430
+ // ACCUMULATING 64KB-ArrayBuffer pool, by contrast, is measured DETERMINISTIC
431
+ // at 832KB arrayBuffers (6/6 reps), oldGen 0, ~200ms at the pinned
432
+ // {N:200000,k:8} window below -- 13x the 64KB gate, so the same measurement
433
+ // both validates the detector and demonstrates the gate catches it. That is
434
+ // the clean, cheap, always-on detector for the external lane; the oldGen
435
+ // lane's detector is the negative-control old-gen clause (no false positive)
436
+ // plus torture T3's must-catch C2 fixture (true positive). Axis D landed on an
437
+ // adapted D1: always-on placement, arrayBuffers clause.
438
+ //
439
+ // RESIDUE (decisions/0003 "controlLarge residue"): a retained ArrayBuffer pool
440
+ // raises V8's scheduled-scavenge accounting, which then fires PERIODIC
441
+ // scavenges INSIDE the next sequential measurement window -- mechanism proven
442
+ // H-a: the spurious entries' startTime lands in-window, not late delivery.
443
+ // This is not uncollected garbage, no gc clears it (forcing collections made
444
+ // it WORSE), and NO pool size is immune (256KB still poisoned 2/50). The
445
+ // residue is forward-only, so the fix is ORDERING: controlLarge is measured
446
+ // AFTER every scavenge-gated measurement. See measureLargeControl().
447
+ const CONTROL_LARGE_MASK = 131071; // one 64KB ArrayBuffer every 131072 iters
448
+ const CONTROL_LARGE_BYTES = 65536;
449
+ const CONTROL_LARGE_N = 200000; // pinned so the control's evidence does
450
+ const CONTROL_LARGE_K = 8; // not get cheaper with a consumer's N.
451
+
452
+ /**
453
+ * Large-object / external control: retains a bounded, mask-gated pool of 64KB
454
+ * ArrayBuffers so the arrayBuffers delta at k*N is large and repeatable
455
+ * (~832KB, 13x the default gate) at a fixed footprint regardless of n. Trips
456
+ * signal 5. Cleanup is owned two ways: teardown() drops the pool, and the
457
+ * detector call sites isolate its scheduled-scavenge residue by ORDERING --
458
+ * controlLarge is measured LAST, so nothing scavenge-gated follows it (the
459
+ * residue is never drained; draining makes it worse). decisions/0003.
460
+ */
461
+ export const controlLarge = {
462
+ name: 'CONTROL_LARGE (64KB ArrayBuffer every 131072 iters, accumulating pool)',
463
+ setup: function () {
464
+ return {pool: []};
465
+ },
466
+ hot: function (s, n) {
467
+ const p = s.pool;
468
+ for (let i = 0; i < n; i++) {
469
+ if ((i & CONTROL_LARGE_MASK) === 0) p.push(new ArrayBuffer(CONTROL_LARGE_BYTES));
470
+ }
471
+ },
472
+ // Drop every backing store so the pool is collectable the instant the pass
473
+ // ends. Necessary but not sufficient alone (meterOnce runs teardown after
474
+ // its final gc2); the next measurement is isolated by ORDERING -- measuring
475
+ // controlLarge LAST, not by draining the residue (draining makes it worse).
476
+ // decisions/0003 "controlLarge residue".
477
+ teardown: function (s) {
478
+ const p = s.pool;
479
+ for (let i = 0; i < p.length; i++) p[i] = null;
480
+ s.pool = null;
481
+ }
482
+ };
483
+
484
+ /**
485
+ * Measure controlLarge at its pinned window. The ONE place both detector call
486
+ * sites (zgcSuite, runGate) go through -- no diverging measurement logic.
487
+ *
488
+ * controlLarge leaves a scheduled-scavenge residue (H-a, decisions/0003
489
+ * "controlLarge residue") that poisons the NEXT scavenge-gated measurement.
490
+ * Forcing collections does NOT clear it -- measured, it made poisoning WORSE
491
+ * (drain 8/20/40 -> 3/8/9 caught of 10 cold processes). The residue is
492
+ * forward-only, so the fix is ORDERING, not draining: this control is measured
493
+ * AFTER every scavenge-gated measurement. runGate measures it last (below);
494
+ * zgcSuite measures it in the detector test, whose node:test-block boundary
495
+ * dissipates the residue before the first scenario test (verified 15/15).
496
+ *
497
+ * @param {Scenario} largeCtrl
498
+ * @param {number} flushMs
499
+ * @param {boolean} allowNoGc
500
+ * @returns {Promise<MeasureResult>}
501
+ */
502
+ async function measureLargeControl(largeCtrl, flushMs, allowNoGc) {
503
+ return measure(largeCtrl,
504
+ {N: CONTROL_LARGE_N, k: CONTROL_LARGE_K, flushMs: flushMs, allowNoGc: allowNoGc});
505
+ }
506
+
223
507
  // ---------------------------------------------------------------------------
224
508
  // Detector validation
225
509
  // ---------------------------------------------------------------------------
@@ -230,17 +514,24 @@ export const controlNegative = {
230
514
  const CONTROL_FLOOR = 6; // positive control: scavenges at k*N
231
515
  const CONTROL_SCALE = 2; // positive control: minorHi / minorLo
232
516
  const CONTROL_NEG_CEIL = 2; // negative control: hard ceiling
517
+ // Large-control floor: floor(observed 832KB / 3) -- a 3x margin under the
518
+ // deterministic reading, and 4x over the default maxArrayBuffersKB, so the
519
+ // same measurement both validates the detector and demonstrates the gate would
520
+ // catch it. decisions/0003.
521
+ const CONTROL_LARGE_FLOOR = 277; // arrayBuffers KB at k*N
233
522
 
234
523
  /**
235
524
  * The ONE detector-validation predicate. zgcSuite and runGate both call it;
236
- * neither forks it. Comparisons are written so a NaN count fails closed.
525
+ * neither forks it. Comparisons are written so a NaN/undefined count fails
526
+ * closed (`!(x >= f)` / `!(x <= c)`).
237
527
  *
238
- * @param {MeasureResult} pos positive-control measurement
239
- * @param {MeasureResult} neg negative-control measurement
240
- * @param {number} maxScav suite scavenge threshold (tightens neg only)
528
+ * @param {MeasureResult} pos positive-control measurement
529
+ * @param {MeasureResult} neg negative-control measurement
530
+ * @param {MeasureResult} large controlLarge measurement (external lane)
531
+ * @param {number} maxScav suite scavenge threshold (tightens neg only)
241
532
  * @returns {{ ok: boolean, reason: string | null }}
242
533
  */
243
- function validateDetector(pos, neg, maxScav) {
534
+ function validateDetector(pos, neg, large, maxScav) {
244
535
  const flags = ' Run with --expose-gc --max-semi-space-size=4.';
245
536
  if (!(pos.minorHi >= CONTROL_FLOOR)) {
246
537
  return {ok: false, reason: 'positive control forced ' + pos.minorHi +
@@ -257,6 +548,22 @@ function validateDetector(pos, neg, maxScav) {
257
548
  ' scavenges (need <=' + negCeil + ') -- noisy process, or a prior ' +
258
549
  'measurement poisoned it.' + flags};
259
550
  }
551
+ // Old-gen floor on the NEGATIVE control: the census (decisions/0003) shows
552
+ // C5 pure arithmetic fires 0 old-gen collections across 100 reps, so a
553
+ // nonzero here means a noisy instrument, not a clean hot path -- refuse to
554
+ // judge the oldgen lane on it. `!(<=)` fails closed on NaN/undefined.
555
+ if (!(neg.oldGenHi <= 0)) {
556
+ return {ok: false, reason: 'negative control forced ' + neg.oldGenHi +
557
+ ' old-gen collections (need 0) -- noisy process, the oldgen lane ' +
558
+ 'cannot be trusted.' + flags};
559
+ }
560
+ if (!(large.arrayBuffersKB_hi >= CONTROL_LARGE_FLOOR)) {
561
+ const got = typeof large.arrayBuffersKB_hi === 'number'
562
+ ? large.arrayBuffersKB_hi.toFixed(0) : String(large.arrayBuffersKB_hi);
563
+ return {ok: false, reason: 'large control forced ' + got +
564
+ 'KB arrayBuffers at ' + large.k + 'N (need >=' + CONTROL_LARGE_FLOOR +
565
+ 'KB) -- the external signal is blind.' + flags};
566
+ }
260
567
  return {ok: true, reason: null};
261
568
  }
262
569
 
@@ -267,31 +574,89 @@ function validateDetector(pos, neg, maxScav) {
267
574
  /**
268
575
  * Evaluate a measurement against thresholds.
269
576
  *
577
+ * Thresholds are validated at config time (bad values throw); measured
578
+ * signals fail closed with named reasons, in this contract format:
579
+ * '<signal>: not a number (fail closed)' -- scavenges / retained / counter
580
+ * '<key>: no such counter measured (statsOf keys: ...)' -- typo'd counter key
581
+ * 'counters: thresholds set (...) but the scenario measured no counters
582
+ * -- add statsOf (fail closed)' -- counters_hi === null
583
+ *
270
584
  * @param {MeasureResult} r
271
585
  * @param {object} [thresholds]
272
- * @param {number} [thresholds.maxScavenges=2]
273
- * @param {number} [thresholds.maxRetainedKB=64]
586
+ * @param {number} [thresholds.maxScavenges=2] finite >= 0, else throws
587
+ * @param {number} [thresholds.maxRetainedKB=64] finite >= 0, else throws
274
588
  * @param {Record<string, number>} [thresholds.counters]
275
- * Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`.
589
+ * Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`. Each
590
+ * maximum must be a finite number, else throws.
276
591
  * @returns {{ pass: boolean, reasons: string[] }}
277
592
  */
278
593
  export function verdict(r, thresholds) {
594
+ validateGate('verdict', thresholds, null);
595
+ if (thresholds && thresholds.maxRetainedKB !== undefined && r && r.retainedReliable === false) {
596
+ throw new RangeError('lite-perf-gate: verdict: ' + NO_RETAINED);
597
+ }
598
+ if (thresholds && thresholds.maxArrayBuffersKB !== undefined && r && r.retainedReliable === false) {
599
+ throw new RangeError('lite-perf-gate: verdict: ' + NO_ARRAYBUFFERS);
600
+ }
279
601
  const maxScav = (thresholds && thresholds.maxScavenges !== undefined) ? thresholds.maxScavenges : 2;
280
602
  const maxRetKB = (thresholds && thresholds.maxRetainedKB !== undefined) ? thresholds.maxRetainedKB : 64;
603
+ const maxOldGen = (thresholds && thresholds.maxOldGen !== undefined) ? thresholds.maxOldGen : 0;
604
+ const maxAbKB = (thresholds && thresholds.maxArrayBuffersKB !== undefined) ? thresholds.maxArrayBuffersKB : 64;
281
605
  const ct = (thresholds && thresholds.counters) || null;
282
606
  const reasons = [];
283
607
 
284
- if (r.minorHi > maxScav) {
608
+ if (typeof r.minorHi !== 'number' || r.minorHi !== r.minorHi) {
609
+ reasons.push('scavenges: not a number (fail closed)');
610
+ } else if (r.minorHi > maxScav) {
285
611
  reasons.push('scavenges: ' + r.minorHi + ' > ' + maxScav + ' (transient allocation)');
286
612
  }
287
- if (r.retainedKB_hi > maxRetKB) {
288
- reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
613
+ if (r.retainedReliable !== false) {
614
+ if (typeof r.retainedKB_hi !== 'number' || r.retainedKB_hi !== r.retainedKB_hi) {
615
+ reasons.push('retained: not a number (fail closed)');
616
+ } else if (r.retainedKB_hi > maxRetKB) {
617
+ reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
618
+ }
619
+ }
620
+ // Old-gen lane (major + incremental). GATED on presence for back-compat:
621
+ // hand-built results (suiteGate's per-budget call) omit oldGenHi and must
622
+ // not gain a reason (decision 0002 policy 2). Reliable without --expose-gc:
623
+ // it comes from the observer, not memoryUsage, so it is NOT skipped when
624
+ // retainedReliable is false.
625
+ if (r.oldGenHi !== undefined) {
626
+ if (typeof r.oldGenHi !== 'number' || r.oldGenHi !== r.oldGenHi) {
627
+ reasons.push('oldgen: not a number (fail closed)');
628
+ } else if (r.oldGenHi > maxOldGen) {
629
+ reasons.push('oldgen: ' + r.oldGenHi + ' > ' + maxOldGen +
630
+ ' (old-gen GC activity -- diagnose with @zakkster/lite-gc-profiler)');
631
+ }
632
+ }
633
+ // External/arrayBuffers lane. GATED on presence, and -- like retained --
634
+ // skipped when retainedReliable is false, because the delta is bracketed
635
+ // by the same gc2() calls that no-op without --expose-gc.
636
+ if (r.arrayBuffersKB_hi !== undefined && r.retainedReliable !== false) {
637
+ if (typeof r.arrayBuffersKB_hi !== 'number' || r.arrayBuffersKB_hi !== r.arrayBuffersKB_hi) {
638
+ reasons.push('arrayBuffers: not a number (fail closed)');
639
+ } else if (r.arrayBuffersKB_hi > maxAbKB) {
640
+ reasons.push('arrayBuffers: ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB > ' + maxAbKB +
641
+ 'KB (external memory -- diagnose with @zakkster/lite-gc-profiler)');
642
+ }
289
643
  }
290
- if (ct !== null && r.counters_hi !== null) {
291
- for (const key in ct) {
292
- const actual = r.counters_hi[key];
293
- if (actual !== undefined && actual > ct[key]) {
294
- reasons.push(key + ': ' + actual + ' > ' + ct[key]);
644
+ if (ct !== null) {
645
+ if (r.counters_hi === null || r.counters_hi === undefined) {
646
+ reasons.push('counters: thresholds set (' + Object.keys(ct).join(', ') +
647
+ ') but the scenario measured no counters -- add statsOf (fail closed)');
648
+ } else {
649
+ const ks = Object.keys(r.counters_hi);
650
+ for (const key in ct) {
651
+ const actual = r.counters_hi[key];
652
+ if (actual === undefined) {
653
+ reasons.push(key + ': no such counter measured (statsOf keys: ' +
654
+ (ks.length ? ks.join(', ') : 'none') + ')');
655
+ } else if (typeof actual !== 'number' || !isFinite(actual)) {
656
+ reasons.push(key + ': not a number (fail closed)');
657
+ } else if (actual > ct[key]) {
658
+ reasons.push(key + ': ' + actual + ' > ' + ct[key]);
659
+ }
295
660
  }
296
661
  }
297
662
  }
@@ -312,6 +677,12 @@ export function formatResult(r) {
312
677
  '\n scavenges N:' + String(r.minorLo).padStart(3) +
313
678
  ' ' + r.k + 'N:' + String(r.minorHi).padStart(3) +
314
679
  ' retained ' + r.retainedKB_hi.toFixed(0) + 'KB';
680
+ if (typeof r.oldGenHi === 'number' && r.oldGenHi > 0) {
681
+ s += ' oldgen ' + r.oldGenHi;
682
+ }
683
+ if (typeof r.arrayBuffersKB_hi === 'number' && r.arrayBuffersKB_hi > 0.5) {
684
+ s += ' arrayBuffers ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB';
685
+ }
315
686
  if (r.counters_hi !== null) {
316
687
  for (const key in r.counters_hi) {
317
688
  s += ' ' + key + ' \u0394' + String(r.counters_hi[key]).padStart(6);
@@ -354,27 +725,49 @@ export function formatResult(r) {
354
725
  * @param {Scenario} [config.negativeControl] Override negative control.
355
726
  * @param {Scenario[]} [config.mustFail]
356
727
  * Scenarios that MUST trip the gate (injected allocation self-tests).
728
+ * @param {number} [config.flushMs]
729
+ * Wait (ms) after the hot loop before reading the GC observer buffer.
730
+ * Default 100 (or PERF_GATE_FLUSH_MS env var). 0 is legal and honored.
731
+ * @param {boolean} [config.allowEmpty=false]
732
+ * Permit an empty scenarios array for a controls-only detector smoke run
733
+ * -- the ONLY reason this option exists.
734
+ * @param {boolean} [config.allowNoGc=false]
735
+ * Run without --expose-gc; the retained rule is not applied and an
736
+ * explicit maxRetainedKB throws.
357
737
  */
358
738
  export function zgcSuite(config) {
359
- const scenarios = config.scenarios;
360
- const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
739
+ const o = validateGate('zgcSuite', config, null);
740
+ const cf = config || {};
741
+ const scenarios = cf.scenarios;
742
+ const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
361
743
  const thresholds = {
362
- maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
363
- maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
364
- counters: config.counters || null
744
+ maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
745
+ maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
746
+ maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
747
+ maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
748
+ counters: cf.counters || null
365
749
  };
366
- const posCtrl = config.positiveControl || controlPositive;
367
- const negCtrl = config.negativeControl || controlNegative;
368
- const mustFail = config.mustFail || [];
750
+ const posCtrl = cf.positiveControl || controlPositive;
751
+ const negCtrl = cf.negativeControl || controlNegative;
752
+ const largeCtrl = cf.largeControl || controlLarge;
753
+ const mustFail = cf.mustFail || [];
369
754
  const maxScav = thresholds.maxScavenges;
370
755
 
371
- test('perf-gate: detector validation (positive + negative controls)', async function () {
756
+ test(scenarios.length === 0
757
+ ? 'perf-gate: detector validation only (allowEmpty, 0 scenarios)'
758
+ : 'perf-gate: detector validation (positive + negative + large controls)', async function () {
372
759
  const pos = await measure(posCtrl, opts);
373
760
  _controlKeepAlive();
374
761
  const neg = await measure(negCtrl, opts);
375
- const v = validateDetector(pos, neg, maxScav);
762
+ // controlLarge is measured LAST in this detector test; its
763
+ // scheduled-scavenge residue (never drained -- draining makes it worse)
764
+ // is isolated by that ordering plus the node:test block boundary, so
765
+ // the scenario test blocks that follow are not poisoned (H-a,
766
+ // decisions/0003 "controlLarge residue").
767
+ const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
768
+ const v = validateDetector(pos, neg, large, maxScav);
376
769
  assert.ok(v.ok, 'DETECTOR VALIDATION FAILED: ' + v.reason +
377
- '\n ' + formatResult(pos) + '\n ' + formatResult(neg));
770
+ '\n ' + formatResult(pos) + '\n ' + formatResult(neg) + '\n ' + formatResult(large));
378
771
  });
379
772
 
380
773
  for (let i = 0; i < scenarios.length; i++) {
@@ -407,61 +800,87 @@ export function zgcSuite(config) {
407
800
  // ---------------------------------------------------------------------------
408
801
 
409
802
  /**
410
- * Run a gate check and print a human-readable report. Exit codes:
411
- * 0 = pass, 1 = allocation detected, 2 = detector validation failed.
803
+ * Run a gate check and print a human-readable report.
804
+ *
805
+ * Returns `{ passed, code, results }`. `code`: 0 pass, 1 scenario or
806
+ * must-fail failure, 2 detector validation failed; `passed === (code === 0)`.
807
+ * runGate never touches `process.exitCode` -- map `result.code` to your exit
808
+ * code: `process.exit((await runGate(cfg)).code)`.
412
809
  *
413
810
  * @param {object} config Same shape as zgcSuite.
414
- * @returns {Promise<{ passed: boolean, results: MeasureResult[] }>}
811
+ * @returns {Promise<{ passed: boolean, code: 0 | 1 | 2, results: MeasureResult[] }>}
415
812
  */
416
813
  export async function runGate(config) {
417
- const scenarios = config.scenarios;
418
- const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
814
+ const o = validateGate('runGate', config, null);
815
+ const cf = config || {};
816
+ const scenarios = cf.scenarios;
817
+ const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
419
818
  const thresholds = {
420
- maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
421
- maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
422
- counters: config.counters || null
819
+ maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
820
+ maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
821
+ maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
822
+ maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
823
+ counters: cf.counters || null
423
824
  };
424
- const posCtrl = config.positiveControl || controlPositive;
425
- const negCtrl = config.negativeControl || controlNegative;
426
- const mustFail = config.mustFail || [];
825
+ const posCtrl = cf.positiveControl || controlPositive;
826
+ const negCtrl = cf.negativeControl || controlNegative;
827
+ const largeCtrl = cf.largeControl || controlLarge;
828
+ const mustFail = cf.mustFail || [];
427
829
  const maxScav = thresholds.maxScavenges;
428
830
  const N = opts.N;
429
831
  const k = opts.k;
430
832
 
431
833
  console.log('Zero-GC gate \u2014 scavenge scaling (N=' + N + ', ' + k + 'N=' + (k * N) + ')\n');
432
834
 
835
+ // Measure every scavenge-gated pass FIRST, while the process is clean, and
836
+ // controlLarge LAST. controlLarge's retained external memory raises V8's
837
+ // scheduled-scavenge accounting, which poisons the NEXT measurement's minor
838
+ // count (H-a, decisions/0003 "controlLarge residue"); no gc drains it, so
839
+ // ordering is the fix -- nothing scavenge-gated may follow it. Detector
840
+ // validation therefore runs after measurement, still returning code 2 and
841
+ // discarding results if the instrument is broken.
433
842
  const pos = await measure(posCtrl, opts);
434
843
  const neg = await measure(negCtrl, opts);
844
+
845
+ const results = [];
846
+ for (let i = 0; i < scenarios.length; i++) {
847
+ results.push(await measure(scenarios[i], opts));
848
+ }
849
+
850
+ const mfResults = [];
851
+ for (let j = 0; j < mustFail.length; j++) {
852
+ mfResults.push(await measure(mustFail[j], opts));
853
+ }
854
+
855
+ const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
856
+ _controlKeepAlive();
857
+
435
858
  console.log('== controls ==');
436
859
  console.log(formatResult(pos));
437
860
  console.log(formatResult(neg));
438
- _controlKeepAlive();
861
+ console.log(formatResult(large));
439
862
 
440
- const dv = validateDetector(pos, neg, maxScav);
863
+ const dv = validateDetector(pos, neg, large, maxScav);
441
864
  if (!dv.ok) {
442
865
  console.log('\n!! DETECTOR VALIDATION FAILED: ' + dv.reason);
443
- return {passed: false, results: []};
866
+ return {passed: false, code: 2, results: []};
444
867
  }
445
868
  console.log('\ndetector validated: positive forced ' + pos.minorHi +
446
869
  ' scavenges at ' + k + 'N (' + pos.minorLo + ' at N, floor ' +
447
870
  CONTROL_FLOOR + '), negative forced ' + neg.minorHi + '.\n');
448
871
 
449
872
  console.log('== scenarios ==');
450
- const results = [];
451
- for (let i = 0; i < scenarios.length; i++) {
452
- const r = await measure(scenarios[i], opts);
453
- results.push(r);
454
- console.log(formatResult(r));
873
+ for (let i = 0; i < results.length; i++) {
874
+ console.log(formatResult(results[i]));
455
875
  }
456
876
 
457
877
  let mustFailOK = true;
458
- if (mustFail.length > 0) {
878
+ if (mfResults.length > 0) {
459
879
  console.log('\n== must-fail (gate self-test) ==');
460
- for (let j = 0; j < mustFail.length; j++) {
461
- const mf = await measure(mustFail[j], opts);
462
- const mfv = verdict(mf, thresholds);
880
+ for (let j = 0; j < mfResults.length; j++) {
881
+ const mfv = verdict(mfResults[j], thresholds);
463
882
  if (mfv.pass) mustFailOK = false;
464
- console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mf));
883
+ console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mfResults[j]));
465
884
  }
466
885
  }
467
886
 
@@ -481,7 +900,11 @@ export async function runGate(config) {
481
900
 
482
901
  const passed = failures.length === 0 && mustFailOK;
483
902
  if (passed) {
484
- console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
903
+ if (results.length === 0) {
904
+ console.log('\nZERO-GC GATE: PASS \u2014 detector only (allowEmpty, 0 scenarios gated).');
905
+ } else {
906
+ console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
907
+ }
485
908
  } else {
486
909
  const why = failures.map(function (f) {
487
910
  return f.name;
@@ -489,7 +912,8 @@ export async function runGate(config) {
489
912
  if (!mustFailOK) why.push('must-fail scenario was not caught');
490
913
  console.log('\nZERO-GC GATE: FAIL \u2014 ' + why.join('; '));
491
914
  }
492
- return {passed: passed, results: results};
915
+ const code = passed ? 0 : 1;
916
+ return {passed: passed, code: code, results: results};
493
917
  }
494
918
 
495
919
  // ---------------------------------------------------------------------------
@@ -511,19 +935,27 @@ export async function runGate(config) {
511
935
 
512
936
  const SPP_OP_CONT = 0x0F01;
513
937
 
514
- const SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
515
- const SUITE_SLOTS = {t: 1, a: 2, b: 3};
938
+ const SUITE_REDUCES = {__proto__: null, count: 1, sum: 1, max: 1, mean: 1, last: 1};
939
+ const SUITE_SLOTS = {__proto__: null, t: 1, a: 2, b: 3};
516
940
 
517
941
  function suiteMatcher(b, i) {
518
942
  if (b.packed !== undefined) {
519
943
  if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
520
944
  throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
521
945
  }
946
+ if ((b.packed & 0xFFFF) === SPP_OP_CONT) {
947
+ throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
948
+ 'CONT records are never budget targets (SPP v1)');
949
+ }
522
950
  return {exact: b.packed >>> 0, op: -1};
523
951
  }
524
952
  if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
525
953
  throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
526
954
  }
955
+ if (b.op === SPP_OP_CONT) {
956
+ throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
957
+ 'CONT records are never budget targets (SPP v1)');
958
+ }
527
959
  if (b.stream !== undefined) {
528
960
  if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
529
961
  throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
@@ -571,7 +1003,8 @@ export function suiteGate(config) {
571
1003
  const match = new Array(n);
572
1004
  const slot = new Array(n);
573
1005
  const reduce = new Array(n);
574
- const seen = {};
1006
+ const minCnt = new Array(n);
1007
+ const seen = Object.create(null);
575
1008
  for (let i = 0; i < n; i++) {
576
1009
  const b = budgets[i];
577
1010
  if (!b || typeof b.name !== 'string' || b.name.length === 0) {
@@ -590,9 +1023,18 @@ export function suiteGate(config) {
590
1023
  if (SUITE_REDUCES[rd] === undefined) {
591
1024
  throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
592
1025
  }
1026
+ let mc = 0;
1027
+ if (b.minCount !== undefined) {
1028
+ if (!Number.isInteger(b.minCount) || b.minCount < 0) {
1029
+ throw new RangeError('suiteGate: budget "' + b.name +
1030
+ '" minCount must be an integer >= 0 (got ' + String(b.minCount) + ')');
1031
+ }
1032
+ mc = b.minCount;
1033
+ }
593
1034
  match[i] = suiteMatcher(b, i);
594
1035
  slot[i] = SUITE_SLOTS[sl];
595
1036
  reduce[i] = rd;
1037
+ minCnt[i] = mc;
596
1038
  }
597
1039
 
598
1040
  const count = new Float64Array(n);
@@ -615,14 +1057,58 @@ export function suiteGate(config) {
615
1057
  }
616
1058
  }
617
1059
 
1060
+ // Cold thrower (D-A / D-A'): the loop and visitChecked hold the index, so
1061
+ // visit()'s body stays byte-identical to the pre-door build and pays no index tax.
1062
+ function badRecord(idx, p) {
1063
+ throw new RangeError('suiteGate: record ' + idx + ': packed ' + String(p) +
1064
+ ' is not a u32 -- an SPP record is 4 numbers [packed, t, a, b]');
1065
+ }
1066
+ // D-B (forEach lane, EVERY invocation): a slot that is not a number is a
1067
+ // corrupt record. A native Array/TypedArray forEach binds (value, index,
1068
+ // array) onto (packed, t, a, b); a non-native sink can also emit a non-
1069
+ // number slot at any index (null/'3'/true finitely coerce and pass a
1070
+ // budget silently -- 'null is not zero'). Checked on every record, not
1071
+ // just record 0.
1072
+ function badSlot(idx, t, a, b) {
1073
+ const bad = typeof t !== 'number' ? 't (' + typeof t + ')'
1074
+ : typeof a !== 'number' ? 'a (' + typeof a + ')'
1075
+ : 'b (' + typeof b + ')';
1076
+ throw new RangeError('suiteGate: record ' + idx + ': slot ' + bad +
1077
+ ' is not a number -- forEach must invoke cb(packed, t, a, b) with ' +
1078
+ 'four numbers (a native Array/TypedArray forEach binds ' +
1079
+ '(value, index, array) instead)');
1080
+ }
1081
+ let seenRec = 0;
1082
+ function visitChecked(packed, t, a, b) {
1083
+ if (packed !== packed >>> 0) badRecord(seenRec, packed);
1084
+ if (typeof t !== 'number' || typeof a !== 'number' || typeof b !== 'number') {
1085
+ badSlot(seenRec, t, a, b);
1086
+ }
1087
+ seenRec++;
1088
+ visit(packed, t, a, b);
1089
+ }
1090
+
1091
+ // D-C (cold dispatch): a typed-array view that is not a same-realm
1092
+ // Float64Array dies before one record is read, naming its constructor.
1093
+ // A cross-realm Float64Array fails `instanceof` and lands here too --
1094
+ // ArrayBuffer.isView is realm-agnostic, so it is caught, not routed to the
1095
+ // arity-collision forEach path.
1096
+ if (ArrayBuffer.isView(source) && !(source instanceof Float64Array)) {
1097
+ throw new RangeError('suiteGate: record 0: source is a ' +
1098
+ source.constructor.name + ' view from another realm, or a ' +
1099
+ 'non-Float64Array view -- pass a same-realm Float64Array SPP slab ' +
1100
+ '(a foreign or wrong-typed view forEach binds (value, index, array))');
1101
+ }
618
1102
  if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
619
- source.forEach(visit);
1103
+ source.forEach(visitChecked);
620
1104
  } else if (source instanceof Float64Array) {
621
1105
  if (source.length % 4 !== 0) {
622
1106
  throw new RangeError('suiteGate: slab length must be divisible by 4');
623
1107
  }
624
1108
  for (let r = 0; r < source.length; r += 4) {
625
- visit(source[r], source[r + 1], source[r + 2], source[r + 3]);
1109
+ const p = source[r];
1110
+ if (p !== p >>> 0) badRecord(r >> 2, p); // D-A, the measured door
1111
+ visit(p, source[r + 1], source[r + 2], source[r + 3]);
626
1112
  }
627
1113
  } else {
628
1114
  throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
@@ -640,19 +1126,36 @@ export function suiteGate(config) {
640
1126
  else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
641
1127
  else value = count[i] > 0 ? last[i] : 0;
642
1128
 
643
- const counters = {};
1129
+ const counters = {__proto__: null};
644
1130
  counters[b.name] = value;
645
- const ct = {};
1131
+ const ct = {__proto__: null};
646
1132
  ct[b.name] = b.max;
647
1133
  const v = verdict(
648
1134
  {name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
649
1135
  {counters: ct}
650
1136
  );
651
- if (!v.pass) pass = false;
652
- for (let ri = 0; ri < v.reasons.length; ri++) reasons.push(v.reasons[ri]);
1137
+ const rs = v.reasons.slice(); // cold; per budget, not per record
1138
+ let bPass = v.pass;
1139
+ const mc = minCnt[i];
1140
+ if (mc > 0) {
1141
+ // minCount is a MINIMUM on count; verdict() is max-only, so express
1142
+ // it as a maximum of 0 on the SHORTFALL (mc - count) in a SECOND,
1143
+ // separate verdict() call -- the fixed internal key 'shortfall'
1144
+ // lives in its own namespace and cannot alias a user budget name.
1145
+ const mv = verdict(
1146
+ {name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0,
1147
+ counters_hi: {__proto__: null, shortfall: mc - count[i]}},
1148
+ {counters: {__proto__: null, shortfall: 0}});
1149
+ if (!mv.pass) {
1150
+ bPass = false;
1151
+ rs.push(b.name + ': matched ' + count[i] + ' < minCount ' + mc);
1152
+ }
1153
+ }
1154
+ if (!bPass) pass = false;
1155
+ for (let ri = 0; ri < rs.length; ri++) reasons.push(rs[ri]);
653
1156
  perBudget.push({
654
1157
  name: b.name, value: value, count: count[i], max: b.max,
655
- pass: v.pass, reasons: v.reasons
1158
+ minCount: mc, pass: bPass, reasons: rs
656
1159
  });
657
1160
  }
658
1161
 
@@ -686,7 +1189,9 @@ function ndjsonMeasure(r, meta, lines) {
686
1189
  type: 'measure', name: r.name, N: r.N, k: r.k,
687
1190
  minorLo: r.minorLo, minorHi: r.minorHi,
688
1191
  majorLo: r.majorLo, majorHi: r.majorHi,
1192
+ oldGenLo: r.oldGenLo, oldGenHi: r.oldGenHi,
689
1193
  retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
1194
+ arrayBuffersKB_lo: r.arrayBuffersKB_lo, arrayBuffersKB_hi: r.arrayBuffersKB_hi,
690
1195
  counters_lo: r.counters_lo, counters_hi: r.counters_hi
691
1196
  })));
692
1197
  }