@zakkster/lite-perf-gate 1.0.0 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-07-07
4
+
5
+ - **`toNDJSON(x, meta?)`** -- NDJSON verdict output for CI artifacts.
6
+ Accepts a `suiteGate()` result, a `measure()` result, or an array of
7
+ either; emits `budget` lines before their `suite-gate` summary line and
8
+ `measure` lines, with optional per-run `meta` fields merged into every
9
+ line. Otherwise the API is frozen, per the suite roadmap.
10
+
11
+ ## [1.1.0] - 2026-07-07
12
+
13
+ - **`suiteGate(config)`** -- SPP stream-fed budgets. Consumes a Float64Array
14
+ slab or any forEach record source (a lite-scope memory sink qualifies),
15
+ reduces per-budget metrics (count/sum/max/mean/last over slot t/a/b,
16
+ matched by packed header, stream+op, or op-only), and delegates every
17
+ threshold comparison to `verdict()` -- one comparison authority,
18
+ per-budget structured results. No package import: lite-perf-gate speaks
19
+ the Scope Probe Protocol (SPP v1), it does not depend on lite-scope.
20
+ suiteGate never touches process exit codes; runner semantics (0/1/3 in
21
+ the VersionMatrix scripts, 0/1/2 in `runGate`) stay in the runner layer.
22
+ CONT continuation records are never budget targets in v1.1.
23
+ - **Version discipline fix**: `VERSION` now reads `1.2.0` and the self-test
24
+ asserts it against `package.json` (the published 1.0.1 tarball shipped
25
+ `VERSION = '1.0.0'` because the old test pinned a literal instead of the
26
+ manifest -- that class of slip is now structurally impossible).
27
+
3
28
  ## [1.0.0] - 2026-07-07
4
29
 
5
30
  Initial release. Generalized from the `@zakkster/lite-signal` zero-GC gate
@@ -22,6 +47,18 @@ Three signals: scavenge count (transient allocation), custom counters
22
47
  Scaling verdict: measure at N and k*N. Detector self-validation: the gate
23
48
  refuses to judge if the controls misbehave.
24
49
 
50
+ ### CI tuning
51
+
52
+ - `flushMs` option (default 100ms; env var `PERF_GATE_FLUSH_MS`) controls
53
+ how long the harness waits after the hot loop before reading the
54
+ perf_hooks GC observer buffer. Bump to 250-500ms on noisy CI runners
55
+ where event-loop stalls can drop GC entries.
56
+ - `gc2()` yields a `setImmediate` tick between its two `globalThis.gc()`
57
+ passes so any `FinalizationRegistry` cleanups scheduled by the first
58
+ pass run before the second. Prevents WeakRef/finalizer-driven teardown
59
+ (used in `lite-cleanup`, `lite-observe`, `lite-floating`) from leaving
60
+ the heap in an intermediate state and inflating retained deltas.
61
+
25
62
  ### Tests
26
63
 
27
64
  12 self-tests covering controls, verdict logic, counter deltas, and
package/PerfGate.d.ts CHANGED
@@ -79,6 +79,12 @@ export interface GateConfig {
79
79
  negativeControl?: Scenario;
80
80
  /** Scenarios that MUST trip the gate (injected allocation self-tests). */
81
81
  mustFail?: Scenario[];
82
+ /**
83
+ * Wait (ms) after the hot loop before reading the GC observer buffer.
84
+ * Default 100 (or PERF_GATE_FLUSH_MS env var). Bump to 250-500 on
85
+ * noisy CI runners where event-loop stalls > 100ms may drop entries.
86
+ */
87
+ flushMs?: number;
82
88
  }
83
89
 
84
90
  export interface GateResult {
@@ -91,7 +97,7 @@ export interface GateResult {
91
97
  */
92
98
  export function measure(
93
99
  scenario: Scenario,
94
- options?: { N?: number; k?: number }
100
+ options?: { N?: number; k?: number; flushMs?: number }
95
101
  ): Promise<MeasureResult>;
96
102
 
97
103
  /**
@@ -123,3 +129,67 @@ export function runGate(config: GateConfig): Promise<GateResult>;
123
129
 
124
130
  /** @internal */
125
131
  export function _controlKeepAlive(): number;
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // suiteGate (v1.1) + toNDJSON (v1.2)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** forEach-style SPP record source (e.g. a lite-scope memory sink). */
138
+ export interface SppRecordSource {
139
+ forEach(cb: (packed: number, t: number, a: number, b: number) => void): void;
140
+ }
141
+
142
+ export interface SuiteBudget {
143
+ name: string;
144
+ /** Exact packed header (streamId << 16 | opcode). */
145
+ packed?: number;
146
+ /** Stream id; combine with op for an exact match. */
147
+ stream?: number;
148
+ /** Opcode; alone it matches the op on any stream. */
149
+ op?: number;
150
+ /** Record slot to read. Default 'a'. */
151
+ slot?: 't' | 'a' | 'b';
152
+ /** Reduction over matching records. Default 'max'. */
153
+ reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last';
154
+ /** Inclusive budget: verdict() flags value > max. */
155
+ max: number;
156
+ }
157
+
158
+ export interface SuiteBudgetResult {
159
+ name: string;
160
+ value: number;
161
+ count: number;
162
+ max: number;
163
+ pass: boolean;
164
+ reasons: string[];
165
+ }
166
+
167
+ export interface SuiteGateResult {
168
+ name: string;
169
+ pass: boolean;
170
+ reasons: string[];
171
+ budgets: SuiteBudgetResult[];
172
+ }
173
+
174
+ /**
175
+ * Evaluate SPP stream records against numeric budgets. Pure reduction with
176
+ * per-budget delegation to verdict(); no measurement, no process exit
177
+ * codes, no record emission. Wide-record CONT payloads are not budget
178
+ * targets in v1.1 (base-record t/a/b slots only).
179
+ */
180
+ export function suiteGate(config: {
181
+ source: Float64Array | SppRecordSource;
182
+ budgets: SuiteBudget[];
183
+ name?: string;
184
+ }): SuiteGateResult;
185
+
186
+ /**
187
+ * Serialize suiteGate() and/or measure() results as NDJSON for CI
188
+ * artifacts: one JSON object per line, budget lines before their
189
+ * suite-gate summary, trailing newline. `meta` fields merge into every
190
+ * line; core fields win on collision.
191
+ */
192
+ export function toNDJSON(
193
+ x: SuiteGateResult | MeasureResult | Array<SuiteGateResult | MeasureResult>,
194
+ meta?: Record<string, unknown>
195
+ ): string;
package/PerfGate.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * MIT License
18
18
  */
19
19
 
20
- export const VERSION = '1.0.0';
20
+ export const VERSION = '1.2.0';
21
21
 
22
22
  import {PerformanceObserver, constants} from 'node:perf_hooks';
23
23
  import {setTimeout as sleep} from 'node:timers/promises';
@@ -48,33 +48,53 @@ function makeGcCounter() {
48
48
  };
49
49
  }
50
50
 
51
- function gc2() {
51
+ async function gc2() {
52
52
  if (typeof globalThis.gc === 'function') {
53
53
  globalThis.gc();
54
+ // Yield one task tick so any FinalizationRegistry cleanup callbacks
55
+ // registered from the first gc() get a chance to run before the
56
+ // second pass. Without this, WeakRef/FinalizationRegistry-driven
57
+ // teardown (used across the ecosystem in lite-cleanup / lite-observe
58
+ // / lite-floating) can leave the heap in an intermediate state
59
+ // between the two collections, inflating the retained delta.
60
+ await new Promise(function (r) { setImmediate(r); });
54
61
  globalThis.gc();
55
62
  }
56
63
  }
57
64
 
65
+ // Default post-hot sleep before reading the GC observer buffer. The Node
66
+ // perf_hooks 'gc' entries are delivered asynchronously, so we need to yield
67
+ // long enough for the observer callback to fire. 100ms is a safe floor on
68
+ // most systems; noisy CI runners (shared GitHub Actions, Docker under
69
+ // contention) can stall the event loop for tens of milliseconds and drop
70
+ // entries with a shorter wait. Override per-measurement via the `flushMs`
71
+ // option, or globally via the PERF_GATE_FLUSH_MS environment variable.
72
+ var DEFAULT_FLUSH_MS = 100;
73
+ if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH_MS) {
74
+ var envMs = parseInt(process.env.PERF_GATE_FLUSH_MS, 10);
75
+ if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
76
+ }
77
+
58
78
  // ---------------------------------------------------------------------------
59
79
  // Core measurement
60
80
  // ---------------------------------------------------------------------------
61
81
 
62
- async function meterOnce(scenario, iters) {
82
+ async function meterOnce(scenario, iters, flushMs) {
63
83
  const state = scenario.setup();
64
84
  scenario.hot(state, Math.min(iters, 20000));
65
- gc2();
85
+ await gc2();
66
86
 
67
87
  const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
68
88
  const heapBefore = process.memoryUsage().heapUsed;
69
89
 
70
90
  const gcc = makeGcCounter();
71
91
  scenario.hot(state, iters);
72
- await sleep(40);
92
+ await sleep(flushMs);
73
93
  const minor = gcc.c.minor;
74
94
  const major = gcc.c.major;
75
95
  gcc.close();
76
96
 
77
- gc2();
97
+ await gc2();
78
98
  const heapAfter = process.memoryUsage().heapUsed;
79
99
  const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
80
100
  if (scenario.teardown) scenario.teardown(state);
@@ -114,14 +134,19 @@ async function meterOnce(scenario, iters) {
114
134
  * allocation via scavenge scaling.
115
135
  *
116
136
  * @param {Scenario} scenario
117
- * @param {{ N?: number, k?: number }} [options]
137
+ * @param {{ N?: number, k?: number, flushMs?: number }} [options]
138
+ * flushMs: how long to wait after the hot loop before reading the GC
139
+ * observer buffer. Default 100ms (or PERF_GATE_FLUSH_MS env var).
140
+ * Bump if you see zero scavenges on scenarios that should allocate --
141
+ * noisy CI runners may need 250-500ms.
118
142
  * @returns {Promise<MeasureResult>}
119
143
  */
120
144
  export async function measure(scenario, options) {
121
145
  const N = (options && options.N) || 200000;
122
146
  const k = (options && options.k) || 8;
123
- const lo = await meterOnce(scenario, N);
124
- const hi = await meterOnce(scenario, k * N);
147
+ const flushMs = (options && options.flushMs) || DEFAULT_FLUSH_MS;
148
+ const lo = await meterOnce(scenario, N, flushMs);
149
+ const hi = await meterOnce(scenario, k * N, flushMs);
125
150
  return {
126
151
  name: scenario.name, N: N, k: k,
127
152
  minorLo: lo.minor, minorHi: hi.minor,
@@ -264,7 +289,7 @@ export function formatResult(r) {
264
289
  */
265
290
  export function zgcSuite(config) {
266
291
  const scenarios = config.scenarios;
267
- const opts = {N: config.N || 200000, k: config.k || 8};
292
+ const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
268
293
  const thresholds = {
269
294
  maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
270
295
  maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
@@ -327,7 +352,7 @@ export function zgcSuite(config) {
327
352
  */
328
353
  export async function runGate(config) {
329
354
  const scenarios = config.scenarios;
330
- const opts = {N: config.N || 200000, k: config.k || 8};
355
+ const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
331
356
  const thresholds = {
332
357
  maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
333
358
  maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
@@ -403,3 +428,230 @@ export async function runGate(config) {
403
428
  }
404
429
  return {passed: passed, results: results};
405
430
  }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // suiteGate -- SPP stream-fed budgets (v1.1)
434
+ // ---------------------------------------------------------------------------
435
+ // lite-perf-gate does NOT import @zakkster/lite-scope. Probes, gates, and
436
+ // consumers are coupled by the Scope Probe Protocol (SPP v1 -- PROTOCOL.md
437
+ // in lite-scope), never by packages. The two protocol facts used here:
438
+ // - a record is 4 numbers [packed, t, a, b], where
439
+ // packed = (streamId << 16 | opcode) >>> 0, both u16;
440
+ // - opcode 0x0F01 (CONT) is a wide-record continuation and is never a
441
+ // budget target. Budgets therefore see the base record's t/a/b slots;
442
+ // extended CONT payload slots are out of scope for v1.1 (a lite-scope
443
+ // side bridge can pre-reduce wide records if that ever gates).
444
+ // suiteGate never touches process exit codes: runner semantics (0 pass /
445
+ // 1 regression / 3 recapture in the VersionMatrix scripts, 0/1/2 in
446
+ // runGate) stay in the runner layer. All threshold comparison is delegated
447
+ // to verdict() -- one comparison authority, per-budget.
448
+
449
+ var SPP_OP_CONT = 0x0F01;
450
+
451
+ var SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
452
+ var SUITE_SLOTS = {t: 1, a: 2, b: 3};
453
+
454
+ function suiteMatcher(b, i) {
455
+ if (b.packed !== undefined) {
456
+ if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
457
+ throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
458
+ }
459
+ return {exact: b.packed >>> 0, op: -1};
460
+ }
461
+ if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
462
+ throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
463
+ }
464
+ if (b.stream !== undefined) {
465
+ if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
466
+ throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
467
+ }
468
+ return {exact: ((b.stream << 16) | b.op) >>> 0, op: -1};
469
+ }
470
+ return {exact: -1, op: b.op}; // op-only: matches the op on any stream
471
+ }
472
+
473
+ /**
474
+ * Evaluate SPP stream records against numeric budgets. Pure reduction plus
475
+ * per-budget delegation to verdict(); no measurement, no exit codes, no
476
+ * record emission (callers bridge verdicts to GATE_VERDICT meta records).
477
+ *
478
+ * @param {object} config
479
+ * @param {Float64Array | { forEach: (cb: (packed: number, t: number, a: number, b: number) => void) => void }} config.source
480
+ * A contiguous SPP slab (length divisible by 4) or any forEach-style
481
+ * record source (e.g. a lite-scope memory sink).
482
+ * @param {Array<{
483
+ * name: string,
484
+ * packed?: number, stream?: number, op?: number,
485
+ * slot?: 't' | 'a' | 'b',
486
+ * reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last',
487
+ * max: number
488
+ * }>} config.budgets
489
+ * slot defaults to 'a', reduce to 'max'. Budgets that match zero records
490
+ * reduce to 0 (count 0 is reported so callers can tell silence from luck).
491
+ * @param {string} [config.name='suite-gate']
492
+ * @returns {{
493
+ * name: string, pass: boolean, reasons: string[],
494
+ * budgets: Array<{ name: string, value: number, count: number, max: number, pass: boolean, reasons: string[] }>
495
+ * }}
496
+ */
497
+ export function suiteGate(config) {
498
+ if (!config || typeof config !== 'object') {
499
+ throw new TypeError('suiteGate: expects a config object');
500
+ }
501
+ const source = config.source;
502
+ const budgets = config.budgets;
503
+ if (!Array.isArray(budgets) || budgets.length === 0) {
504
+ throw new TypeError('suiteGate: budgets must be a non-empty array');
505
+ }
506
+
507
+ const n = budgets.length;
508
+ const match = new Array(n);
509
+ const slot = new Array(n);
510
+ const reduce = new Array(n);
511
+ const seen = {};
512
+ for (let i = 0; i < n; i++) {
513
+ const b = budgets[i];
514
+ if (!b || typeof b.name !== 'string' || b.name.length === 0) {
515
+ throw new TypeError('suiteGate: budget[' + i + '] needs a non-empty name');
516
+ }
517
+ if (seen[b.name]) throw new RangeError('suiteGate: duplicate budget name "' + b.name + '"');
518
+ seen[b.name] = 1;
519
+ if (typeof b.max !== 'number' || !isFinite(b.max)) {
520
+ throw new RangeError('suiteGate: budget "' + b.name + '" needs a finite numeric max');
521
+ }
522
+ const sl = b.slot === undefined ? 'a' : b.slot;
523
+ if (SUITE_SLOTS[sl] === undefined) {
524
+ throw new RangeError('suiteGate: budget "' + b.name + '" slot must be t, a, or b');
525
+ }
526
+ const rd = b.reduce === undefined ? 'max' : b.reduce;
527
+ if (SUITE_REDUCES[rd] === undefined) {
528
+ throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
529
+ }
530
+ match[i] = suiteMatcher(b, i);
531
+ slot[i] = SUITE_SLOTS[sl];
532
+ reduce[i] = rd;
533
+ }
534
+
535
+ const count = new Float64Array(n);
536
+ const sum = new Float64Array(n);
537
+ const maxv = new Float64Array(n);
538
+ const last = new Float64Array(n);
539
+ maxv.fill(-Infinity);
540
+
541
+ function visit(packed, t, a, b) {
542
+ const op = packed & 0xFFFF;
543
+ if (op === SPP_OP_CONT) return;
544
+ for (let i = 0; i < n; i++) {
545
+ const m = match[i];
546
+ if (m.exact >= 0 ? (packed >>> 0) !== m.exact : op !== m.op) continue;
547
+ const val = slot[i] === 1 ? t : slot[i] === 2 ? a : b;
548
+ count[i] += 1;
549
+ sum[i] += val;
550
+ if (val > maxv[i]) maxv[i] = val;
551
+ last[i] = val;
552
+ }
553
+ }
554
+
555
+ if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
556
+ source.forEach(visit);
557
+ } else if (source instanceof Float64Array) {
558
+ if (source.length % 4 !== 0) {
559
+ throw new RangeError('suiteGate: slab length must be divisible by 4');
560
+ }
561
+ for (let r = 0; r < source.length; r += 4) {
562
+ visit(source[r], source[r + 1], source[r + 2], source[r + 3]);
563
+ }
564
+ } else {
565
+ throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
566
+ }
567
+
568
+ const perBudget = [];
569
+ const reasons = [];
570
+ let pass = true;
571
+ for (let i = 0; i < n; i++) {
572
+ const b = budgets[i];
573
+ let value;
574
+ if (reduce[i] === 'count') value = count[i];
575
+ else if (reduce[i] === 'sum') value = sum[i];
576
+ else if (reduce[i] === 'max') value = count[i] > 0 ? maxv[i] : 0;
577
+ else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
578
+ else value = count[i] > 0 ? last[i] : 0;
579
+
580
+ const counters = {};
581
+ counters[b.name] = value;
582
+ const ct = {};
583
+ ct[b.name] = b.max;
584
+ const v = verdict(
585
+ {name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
586
+ {counters: ct}
587
+ );
588
+ if (!v.pass) pass = false;
589
+ for (let ri = 0; ri < v.reasons.length; ri++) reasons.push(v.reasons[ri]);
590
+ perBudget.push({
591
+ name: b.name, value: value, count: count[i], max: b.max,
592
+ pass: v.pass, reasons: v.reasons
593
+ });
594
+ }
595
+
596
+ return {
597
+ name: typeof config.name === 'string' ? config.name : 'suite-gate',
598
+ pass: pass,
599
+ reasons: reasons,
600
+ budgets: perBudget
601
+ };
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // toNDJSON -- verdict output for CI artifacts (v1.2)
606
+ // ---------------------------------------------------------------------------
607
+
608
+ function ndjsonSuiteGate(r, meta, lines) {
609
+ for (let i = 0; i < r.budgets.length; i++) {
610
+ const b = r.budgets[i];
611
+ lines.push(JSON.stringify(Object.assign({}, meta, {
612
+ type: 'budget', gate: r.name, name: b.name,
613
+ value: b.value, count: b.count, max: b.max, pass: b.pass
614
+ })));
615
+ }
616
+ lines.push(JSON.stringify(Object.assign({}, meta, {
617
+ type: 'suite-gate', name: r.name, pass: r.pass, reasons: r.reasons
618
+ })));
619
+ }
620
+
621
+ function ndjsonMeasure(r, meta, lines) {
622
+ lines.push(JSON.stringify(Object.assign({}, meta, {
623
+ type: 'measure', name: r.name, N: r.N, k: r.k,
624
+ minorLo: r.minorLo, minorHi: r.minorHi,
625
+ majorLo: r.majorLo, majorHi: r.majorHi,
626
+ retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
627
+ counters_lo: r.counters_lo, counters_hi: r.counters_hi
628
+ })));
629
+ }
630
+
631
+ /**
632
+ * Serialize gate output as NDJSON for CI artifacts: one JSON object per
633
+ * line, budget lines before their suite-gate summary line, trailing
634
+ * newline. Accepts a suiteGate() result, a measure() result, or an array
635
+ * mixing both. `meta` fields (run id, package, version, ...) are merged
636
+ * into every line; core fields win on collision.
637
+ *
638
+ * @param {object | object[]} x
639
+ * @param {object} [meta]
640
+ * @returns {string}
641
+ */
642
+ export function toNDJSON(x, meta) {
643
+ const rows = Array.isArray(x) ? x : [x];
644
+ const m = meta && typeof meta === 'object' ? meta : {};
645
+ const lines = [];
646
+ for (let i = 0; i < rows.length; i++) {
647
+ const r = rows[i];
648
+ if (r && Array.isArray(r.budgets) && typeof r.pass === 'boolean') {
649
+ ndjsonSuiteGate(r, m, lines);
650
+ } else if (r && typeof r.minorHi === 'number' && typeof r.N === 'number') {
651
+ ndjsonMeasure(r, m, lines);
652
+ } else {
653
+ throw new TypeError('toNDJSON: row ' + i + ' is neither a suiteGate result nor a measure result');
654
+ }
655
+ }
656
+ return lines.join('\n') + '\n';
657
+ }
package/README.md CHANGED
@@ -24,6 +24,44 @@ npm install @zakkster/lite-perf-gate
24
24
 
25
25
  Scavenge-counting is the only reliable detector of transient allocation. This library packages the methodology into a `node:test`-native harness that validates its own detector on every run.
26
26
 
27
+
28
+ ## suiteGate -- SPP stream-fed budgets (v1.1)
29
+
30
+ Gate live probe streams, not just measured scenarios. `suiteGate()` reads
31
+ SPP records (a `Float64Array` slab or any `forEach(cb(packed, t, a, b))`
32
+ source -- a `@zakkster/lite-scope` memory sink qualifies), reduces each
33
+ budget's metric, and delegates every comparison to `verdict()`:
34
+
35
+ ```js
36
+ import { suiteGate, toNDJSON } from '@zakkster/lite-perf-gate';
37
+
38
+ const gate = suiteGate({
39
+ name: 'ci-gate',
40
+ source: sink.toSlab(), // or the sink itself (forEach source)
41
+ budgets: [
42
+ { name: 'gc.pause.max', stream: 2, op: 0x0201, slot: 'a', reduce: 'max', max: 8 },
43
+ { name: 'leak.orphans', op: 0x0801, reduce: 'count', max: 0 },
44
+ { name: 'inp.worst', op: 0x0601, slot: 'a', reduce: 'max', max: 200 }
45
+ ]
46
+ });
47
+
48
+ process.stdout.write(toNDJSON(gate, { pkg: 'my-lib', run: process.env.CI_RUN }));
49
+ if (!gate.pass) process.exitCode = 1; // exit codes stay in YOUR runner
50
+ ```
51
+
52
+ No package coupling: lite-perf-gate speaks the Scope Probe Protocol
53
+ (SPP v1, `PROTOCOL.md` in lite-scope) and never imports lite-scope.
54
+ CONT continuation records are never budget targets (base-record slots
55
+ only in v1.1). `suiteGate` returns structured per-budget verdicts and
56
+ leaves exit codes to the runner layer.
57
+
58
+ ## toNDJSON -- CI artifacts (v1.2)
59
+
60
+ One JSON object per line: `budget` lines, then the `suite-gate` summary;
61
+ `measure()` results serialize as `measure` lines. Optional `meta` fields
62
+ (package, run id, versions) merge into every line. Pipe to a file in your
63
+ gate script and attach it as a CI artifact.
64
+
27
65
  ## Quick start
28
66
 
29
67
  ```js
@@ -134,6 +172,18 @@ Two V8 gotchas the controls handle:
134
172
 
135
173
  The default V8 semi-space is 16MB. At 200k iterations of 64-byte objects, you produce ~12MB -- possibly fitting without a single scavenge. `--max-semi-space-size=4` shrinks the young generation to 4MB, forcing scavenges on smaller cumulative allocation and sharpening sensitivity. The four-field positive control (`{x,y,z,w}`) provides margin even without this flag, but the flag is recommended for production gates.
136
174
 
175
+ ## CI tuning
176
+
177
+ Node's `perf_hooks` delivers GC entries to the observer asynchronously. After the hot loop the harness waits before reading the entry count so the observer has time to flush. Default wait is **100ms**, which is safe on a dev machine but can be marginal on saturated CI runners.
178
+
179
+ If you see intermittent zero-scavenge reports on scenarios that clearly allocate:
180
+
181
+ - Bump globally via env var: `PERF_GATE_FLUSH_MS=300 node --test ...`
182
+ - Per-suite: `zgcSuite({ scenarios, flushMs: 300 })`
183
+ - Per-call: `measure(scenario, { flushMs: 500 })`
184
+
185
+ `gc2()` (the harness's forced-collection helper) does one `gc()`, yields a `setImmediate` tick to let `FinalizationRegistry` cleanups run, then does a second `gc()`. Without the yield, WeakRef/finalizer-driven teardown (used across the ecosystem in `lite-cleanup`, `lite-observe`, `lite-floating`) can leave the heap in an intermediate state between the two collections and inflate the retained-heap delta.
186
+
137
187
  ## License
138
188
 
139
189
  MIT (c) Zahary Shinikchiev
package/llms.txt CHANGED
@@ -21,8 +21,9 @@ zgcSuite(config) -- register node:test cases.
21
21
  config.counters: Record<string, number> -- per-counter max delta.
22
22
  config.mustFail: Scenario[] -- must trip the gate (self-test).
23
23
  config.N: number (default 200000), config.k: number (default 8).
24
+ config.flushMs: number (default 100, or PERF_GATE_FLUSH_MS env var).
24
25
 
25
- measure(scenario, {N, k}) -- raw MeasureResult.
26
+ measure(scenario, {N, k, flushMs}) -- raw MeasureResult.
26
27
  verdict(result, thresholds) -- { pass: boolean, reasons: string[] }.
27
28
  runGate(config) -- standalone report, same config as zgcSuite.
28
29
 
@@ -39,6 +40,19 @@ node --expose-gc --max-semi-space-size=4 --test your.test.mjs
39
40
 
40
41
  --expose-gc is required. --max-semi-space-size=4 sharpens sensitivity.
41
42
 
43
+ ## CI tuning
44
+
45
+ perf_hooks delivers GC entries asynchronously. Default 100ms wait after
46
+ hot loop is safe on dev machines but can be marginal on saturated CI
47
+ runners. If scenarios that should allocate report zero scavenges, bump
48
+ flushMs (via option) or PERF_GATE_FLUSH_MS env var to 250-500ms.
49
+
50
+ gc2() yields a setImmediate tick between its two gc() calls so any
51
+ FinalizationRegistry cleanups scheduled by the first run before the
52
+ second. Essential for ecosystems using WeakRef/FinalizationRegistry
53
+ (lite-cleanup / lite-observe / lite-floating) -- without it the two-pass
54
+ GC can leave the heap in an intermediate state and inflate retained deltas.
55
+
42
56
  ## Why scavenge counting
43
57
 
44
58
  Retained-heap delta misses transient garbage (freed before snapshot).
@@ -49,3 +63,14 @@ is the only reliable detector of transient allocation in V8.
49
63
 
50
64
  Single-file ESM. Imports only node:perf_hooks, node:timers/promises,
51
65
  node:test, node:assert/strict.
66
+
67
+ v1.1 suiteGate(config): SPP stream-fed budgets. config.source = Float64Array
68
+ slab or { forEach(cb(packed,t,a,b)) } (lite-scope memory sink). budgets[] =
69
+ { name, packed? | stream?+op | op, slot: t|a|b (default a), reduce:
70
+ count|sum|max|mean|last (default max), max }. Delegates every comparison to
71
+ verdict(); returns { name, pass, reasons, budgets: [{name, value, count,
72
+ max, pass, reasons}] }. Never sets exit codes; CONT records (op 0x0F01) are
73
+ never budget targets. No lite-scope import: coupled by SPP v1 protocol only.
74
+ v1.2 toNDJSON(x, meta?): NDJSON for CI artifacts; accepts suiteGate result,
75
+ measure result, or array; budget lines then suite-gate summary; meta merged
76
+ per line; trailing newline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-perf-gate",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
5
  "description": "Zero-GC and performance regression gate for node:test. Scavenge-counting, scaling verdict, detector self-validation. Proves your hot path allocates nothing -- or names what did.",
6
6
  "type": "module",