@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/CHANGELOG.md CHANGED
@@ -1,5 +1,261 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-09-13
4
+
5
+ > One release, three roadmap sessions. The registry latest was 1.3.0;
6
+ > sessions P2, P3 and P4 were developed against interim targets 1.4.0,
7
+ > 1.5.0 and 1.6.0 -- none of which was ever published -- and ship
8
+ > together here as 1.4.0. Session records under decisions/ keep their
9
+ > original target numbers as history.
10
+
11
+ ### suiteGate record doors, inherited-key hardening, CONT rejection, minCount (session P4)
12
+
13
+ #### Added
14
+
15
+ - **`minCount`, the presence assertion (the ONE surface addition).** An
16
+ optional per-budget integer >= 0. Without it, a budget whose `op` is a
17
+ typo matches zero records, reduces to 0, and passes forever -- the
18
+ `count: 0` was reported and nothing ever looked at it. With
19
+ `minCount: n`, a budget that matched fewer than `n` records FAILS with
20
+ `'<name>: matched <count> < minCount <n>'`. It delegates through a
21
+ SECOND `verdict()` call carrying the shortfall (`minCount - count`) as a
22
+ maximum of 0 -- one comparison authority, and `verdict()` stays
23
+ max-only and unaware a minimum exists (decisions/0004 D-G).
24
+ `SuiteBudgetResult` gains `minCount` (0 when omitted); the NDJSON budget
25
+ line is deliberately UNCHANGED (schema frozen).
26
+ - **Back-compat, stated and pinned:** WITHOUT `minCount`, a zero-match
27
+ budget still reduces to 0, still reports `count: 0`, and still PASSES
28
+ (v1.1 behavior). `minCount: 0` is provably inert (result deep-equal to
29
+ omitting it). Presence assertions are opt-in because silence is
30
+ legitimately a pass for budgets like 'no gc pause over 8ms'.
31
+ - **Torture T4 activates: the suiteGate reduction gate.** `measureOps`
32
+ over `suiteGate` on a preallocated 1M-record slab with 8 budgets, gated
33
+ on per-record retained bytes (the 1M-vs-1K `bytesPerOp` difference),
34
+ `checkOps(maxBytesPerOp)`, `checkNoGc(maxMajor 0, maxArrayBuffersGrowth
35
+ 0)`, and an `nsPerRecord < 25` catastrophe ceiling. Runs BEFORE T5 so no
36
+ `measureOps` is ever in flight inside T5's `GcProfiler` window. A
37
+ `TORTURE_CONTROL=allocating-visit` lane (test-code only) drives a
38
+ forEach shim that RETAINS one object per record and MUST fail T4.
39
+
40
+ #### Changed
41
+
42
+ - **PG-06: wrong-shaped record sources now THROW, naming the record
43
+ index.** The same over-budget record used to reach three different
44
+ verdicts by container: FAIL as a `Float64Array` slab (correct), PASS as
45
+ a `Float32Array` (native `forEach` binds `(value, index, array)` ->
46
+ reduced value `-Infinity`, count 1), PASS as an array of tuples
47
+ (count 0). Now: a slab record whose packed header is not a u32 throws a
48
+ `RangeError` naming the record index (D-A, in the slab driver loop);
49
+ a non-`Float64Array` (or cross-realm) typed-array source throws at
50
+ dispatch naming its constructor (D-C); a `forEach` source whose ANY
51
+ invocation is not four numbers throws naming the invocation index and
52
+ the failing slot (D-B, checked on every record -- a `null`/`'3'`/`true`
53
+ slot at a later record would finitely coerce and pass a budget silently
54
+ otherwise). A NaN slot is a number and passes D-B by design: it fails
55
+ closed at D-D only when the reducer PROPAGATES it (`sum`/`mean`, or
56
+ `max`/`last` at the extremum); under `max`/`last`/`count` a NaN
57
+ coexisting with finite records is silently excluded and the budget can
58
+ pass (count inflated, minCount satisfied), identically on both lanes --
59
+ a documented residue (decisions/0004).
60
+ - **PG-06 / D-D: a reduced value that is not finite FAILS closed.**
61
+ `verdict()`'s counter lane predicate widened by ONE line from
62
+ `actual !== actual` to `!isFinite(actual)`, so `Infinity` and
63
+ `-Infinity` now read `'<name>: not a number (fail closed)'`. This closes
64
+ the `-Infinity` fail-open the P2 reviewer documented as
65
+ unreachable-until-P4. Public side effect: a `statsOf` counter delta of
66
+ `Infinity` now reads `'k: not a number (fail closed)'` instead of
67
+ `'k: Infinity > 0'` -- still a failure, only the wording changed. The
68
+ other four verdict lanes keep the NaN-only predicate (decisions/0004
69
+ D-D scope note).
70
+ - **PG-12: inherited prototype keys no longer corrupt validation.**
71
+ `SUITE_SLOTS` / `SUITE_REDUCES` are null-proto, so `slot: 'toString'`
72
+ throws `slot must be` and `reduce: 'constructor'` throws `reduce must
73
+ be`; the duplicate-name map (`seen`) and the per-budget `counters`/`ct`
74
+ objects are null-proto, so a budget legitimately NAMED `toString`,
75
+ `hasOwnProperty`, `__proto__`, or `constructor` is legal end to end,
76
+ through `toNDJSON` too (verified by round-trip, incl. a name with a
77
+ quote and a newline).
78
+ - **PG-13: a budget targeting CONT (0x0F01) now throws at config,** both
79
+ the `{op: 0x0F01}` and the packed low-16-bits forms:
80
+ `'CONT records are never budget targets (SPP v1)'`. `visit` skips CONT
81
+ by protocol, so such a budget passed vacuously forever. The v1.1
82
+ self-test that PINNED that vacuous pass is rewritten this release,
83
+ deliberately; the CONT RECORD-in-slab protocol behavior is unchanged.
84
+ - **`visit`'s body is byte-identical to v1.5.0.** The D-A per-record door
85
+ lives in the slab driver loop (`if (p !== p >>> 0) badRecord(r >> 2, p)`),
86
+ which already holds the index; the `forEach` lane gets a separate
87
+ `visitChecked` wrapper. `meterOnce`, `gc2`, `zgcSuite`, `runGate` are
88
+ unchanged; `verdict()` changed exactly one line.
89
+ - **T4 cost (darwin 25.6.0, Node v26.3.1, `--expose-gc
90
+ --max-semi-space-size=4`):** nsPerRecord p50 15.5310 (v1.5.0) ->
91
+ 15.7637 (v1.6.0), delta +0.2327 ns/record; min delta +0.0661. The
92
+ v1.5.0 back-to-back noise band was 15.3637 / 15.5310 / 18.0343
93
+ (min/p50/max), a 2.6706 ns spread -- the door's delta is far inside it,
94
+ so D-A ships per record. Per-record retained bytes measured 0.00000
95
+ (min, noise to 0.00123), well under the 0.01 gate (decisions/0004).
96
+
97
+ ### Bypass signals -- oldGen + arrayBuffers lanes, census-driven (session P3)
98
+
99
+ #### Added
100
+
101
+ - **Two new gate signals, chosen from a GC-kind census, close the
102
+ allocation bypasses (PG-02, PG-03).** The gate now measures FIVE
103
+ signals: scavenges, custom counters, retained heap, plus (4) **old-gen
104
+ activity** (`major + incremental`, threshold `maxOldGen` default 0) and
105
+ (5) **external/arrayBuffers growth** (`memoryUsage().arrayBuffers`
106
+ delta, threshold `maxArrayBuffersKB` default 64). Both are GATE signals,
107
+ not a profiler: each over-budget reason ends with "diagnose with
108
+ @zakkster/lite-gc-profiler". The boundary law holds -- no GC-kind
109
+ breakdown on results beyond the two composed counters.
110
+ - **The GC-kind census (decisions/0003).** Before any signal froze, a raw
111
+ `PerformanceObserver` census (`test/probes/census-0003.mjs`, repo-only)
112
+ logged kind + flags + timing across the bypass corpus, three corpus
113
+ runs plus a 100-rep x 2 ambient distribution. It falsified the naive
114
+ design: on Node v26.3.1, C1's 600KB-string LO churn (PG-02) fires
115
+ `oldGen=0` in-window and a flat settle, so it is a DOCUMENTED HOLE, not
116
+ a catch (Axis A4). C2's Float64Array churn fires `oldGen=2` (caught).
117
+ C3's 16MB pool grows `arrayBuffers` 16384KB while retained reads <64KB
118
+ (caught). Ambient old-gen and arrayBuffers were 0 across all 200 reps,
119
+ so the defaults (0 / 64) cause zero spurious failures.
120
+ - **`controlLarge` + external detector integrity.** A bounded, mask-gated
121
+ retained 64KB-ArrayBuffer pool (deterministic 832KB arrayBuffers at the
122
+ pinned k*N, 13x the gate) validates signal 5 on every `zgcSuite`/`runGate`
123
+ run, as the third argument to the ONE `validateDetector(pos, neg, large,
124
+ maxScav)` predicate (no fork). A `largeControl` config override and a
125
+ negative-control old-gen clause (the arithmetic control must fire 0
126
+ old-gen) complete the detector. Because a retained-external control
127
+ leaves a V8 scheduled-scavenge residue that poisons the next
128
+ scavenge-gated measurement (mechanism proven in decisions/0003, not
129
+ drainable), `controlLarge` is measured LAST in `runGate` and last in
130
+ `zgcSuite`'s detector block; nothing scavenge-gated follows it.
131
+ - **`MeasureResult` gains `oldGenLo/oldGenHi` and
132
+ `arrayBuffersKB_lo/arrayBuffersKB_hi`** (additive -- no existing key
133
+ changes). `toNDJSON`'s measure line carries them. `formatResult` shows
134
+ old-gen and arrayBuffers only when nonzero.
135
+ - **Torture T3** activates: the PG-02/PG-03 bypasses as permanent
136
+ bare-child fixtures at pinned windows, all judged at default thresholds,
137
+ plus a `TORTURE_CONTROL=zero-signal` lane (test-code only) that zeroes
138
+ the two new fields in-child and MUST make T3 fail.
139
+
140
+ #### Changed
141
+
142
+ - **New defaults CAN fail a previously-green suite** -- exactly when a hot
143
+ path fires an old-gen collection (`maxOldGen: 0`) or grows external
144
+ memory past 64KB (`maxArrayBuffersKB: 64`). That is the release: the
145
+ gate now sees PG-03a and PG-03b. The 0/200 ambient census reps are the
146
+ evidence a genuinely zero-alloc path trips neither.
147
+ - **PG-02 (600KB-string LO churn) is recorded as an uncatchable hole on
148
+ Node v26.3.1**, not silently "fixed": it fires no countable GC event any
149
+ lane can gate, and T3's t3a asserts its measured signature so a future
150
+ Node that closes the hole will flip the fixture.
151
+ - **`allowNoGc: true` with an explicit `maxArrayBuffersKB` throws** (new
152
+ `NO_ARRAYBUFFERS` door, mirroring `maxRetainedKB`); the arrayBuffers
153
+ lane is not applied at all when `retainedReliable === false`. The
154
+ old-gen lane comes from the observer, not `memoryUsage`, so it gates
155
+ even under `allowNoGc`.
156
+ - **The measurement window and `suiteGate` are byte-identical to v1.4.0.**
157
+ The observer callback gained two compare-and-increment branches
158
+ (incremental, weakcb -- outside the hot body); `incremental` is read
159
+ after `gcc.close()`, and the arrayBuffers captures reuse the existing
160
+ before/after `memoryUsage()` points. `gc2` and `suiteGate` are
161
+ untouched (P4 owns suiteGate).
162
+
163
+ ### Fail-closed doors -- thresholds, options, instrument, process contract (session P2)
164
+
165
+ #### Changed
166
+
167
+ - **Thresholds throw instead of passing everything (PG-04).**
168
+ `maxScavenges` and `maxRetainedKB` must be finite numbers >= 0 and
169
+ every `counters` maximum must be a finite number; NaN, Infinity,
170
+ negative counts and non-numbers now raise TypeError/RangeError at
171
+ config time, prefixed `lite-perf-gate: <where>: ` and naming the
172
+ field and the value. Reproduction inverted:
173
+ `verdict(r, {maxScavenges: NaN, maxRetainedKB: NaN})` used to pass a
174
+ result carrying 999 scavenges and 9999KB retained.
175
+ - **A NaN measurement fails instead of passing (PG-04).** A gated signal
176
+ that is not a finite number now produces
177
+ `'<signal>: not a number (fail closed)'` -- `scavenges`, `retained`,
178
+ or the counter's own key. A NaN measurement is evidence of a broken
179
+ instrument, never of a clean hot path.
180
+ - **A counter threshold that matches nothing fails (PG-05).** A typo'd
181
+ key produces `'<key>: no such counter measured (statsOf keys: ...)'`;
182
+ counter thresholds set against a scenario with no `statsOf` produce
183
+ one `'counters: ... (fail closed)'` reason. Previously
184
+ `{poolGrowth: 0}` against a measured `poolGrowths: 99` passed with
185
+ zero reasons, forever, silently.
186
+ - **Missing `--expose-gc` throws (PG-11).** `measure()` -- and so
187
+ `zgcSuite`/`runGate` -- refuses at entry when `globalThis.gc` is not
188
+ a function, with the exact run command in the message. Previously
189
+ `gc2()` no-opped and the retained signal judged uncollected garbage
190
+ (a plain-node run produced a spurious `retained: 2901KB > 64KB`).
191
+ - **Empty scenario lists throw (PG-08).** `zgcSuite`/`runGate` reject a
192
+ missing, non-array or empty `scenarios`; pass `allowEmpty: true` for a
193
+ controls-only detector smoke run. `runGate`'s old `PASS -- 0/0
194
+ scenarios` rubber stamp is now `detector only (allowEmpty, 0
195
+ scenarios gated)`, and zgcSuite's control test renames itself
196
+ `detector validation only (allowEmpty, 0 scenarios)`.
197
+ - **`measure()` options are validated, and `flushMs: 0` is honored
198
+ (PG-09).** `N` integer >= 1, `k` integer >= 2, `flushMs` finite >= 0,
199
+ `allowNoGc`/`allowEmpty` strictly boolean (a truthy non-`true` value
200
+ throws). Every `(options && options.X) || DEFAULT` resolution is now
201
+ `!== undefined`, so `flushMs: 0` reaches the sleep instead of
202
+ silently meaning 100ms -- the one backward-incompatible behavior
203
+ change in this release. `{N: -1}` used to skip every loop and make
204
+ the POSITIVE CONTROL report zero allocation; `{k: 0.5}` used to
205
+ invert the two-scale design; `{flushMs: -50}` used to collapse the
206
+ observer window to ~1ms with a Node TimeoutNegativeWarning.
207
+ - **Scenario shapes are validated at the same door.** `name` a non-empty
208
+ string, `setup`/`hot` functions, `statsOf`/`teardown` functions when
209
+ present -- across `scenarios[]`, `mustFail[]`, `positiveControl` and
210
+ `negativeControl`, each named by its position in the message.
211
+ - **`runGate`'s exit-code claim is corrected (PG-14).** The old
212
+ docstring -- and the 1.2.0 entry below -- promised "exit codes 0/1/2"
213
+ and set none. `runGate` never touches `process.exitCode`: runner
214
+ semantics stay in the runner layer. It now RETURNS `code` and the
215
+ docs say "map result.code to your exit code".
216
+
217
+ #### Added
218
+
219
+ - **`code: 0 | 1 | 2` on GateResult.** 0 pass, 1 a failing scenario or
220
+ an uncaught `mustFail`, 2 detector validation failed.
221
+ `passed === (code === 0)`, asserted by test. Additive: `passed` and
222
+ `results` are unchanged.
223
+ - **`allowNoGc`** (measure options + GateConfig). Runs without
224
+ `--expose-gc`; the result carries `retainedReliable: false`, the
225
+ retained rule is not applied at all (the default 64KB threshold
226
+ included), `retainedKB_lo/_hi` remain as diagnostics, and scavenges
227
+ and counters still gate. Combining it with an explicit
228
+ `maxRetainedKB` throws: retained is ungateable without the flag.
229
+ - **`allowEmpty`** (GateConfig). The only sanctioned empty gate: a
230
+ controls-only detector smoke run, which is exactly what the torture
231
+ suite's T1a child is.
232
+ - **`retainedReliable`** on MeasureResult. `false` only when `measure()`
233
+ ran without `globalThis.gc`; an absent field means reliable.
234
+ - **Torture tier T2 -- fail-closed doors** (`test/torture/t2-doors.mjs`):
235
+ sixteen door cases hit from OUTSIDE the library, each with its passing
236
+ twin one valid step away, plus two children spawned WITHOUT
237
+ `--expose-gc` (one asserting the throw carries the run command, one
238
+ asserting the `allowNoGc` retained behavior). Third
239
+ control-for-the-control: `TORTURE_CONTROL=no-doors npm run torture`
240
+ routes the NaN cases through a legacy fail-open shim and T2 MUST fail.
241
+ - **Six self-tests** (28 -> 34): threshold doors, NaN measurements,
242
+ unknown/absent counters, measure option doors including a timing proof
243
+ that `flushMs: 0` is honored, the empty-gate doors, and `runGate`
244
+ codes 0/1/2 in bare children with `process.exitCode` asserted
245
+ untouched in all three.
246
+ - **`decisions/0002-fail-closed.md`** -- the six policies, the rejected
247
+ `allowNoGc` shapes, and the standing rejection of an `inconclusive`
248
+ third verdict.
249
+
250
+ #### Notes
251
+
252
+ - Hot bodies are untouched: `meterOnce`'s measurement window (between
253
+ `makeGcCounter()` and `gcc.close()`) and `suiteGate`'s `visit` have a
254
+ byte-identical diff. Every door is config-time or verdict-time.
255
+ - suiteGate sources that reduce to NaN now fail their budget closed
256
+ instead of passing silently -- a down payment on PG-06; the record
257
+ doors themselves are P4.
258
+
3
259
  ## [1.3.0] - 2026-09-13
4
260
 
5
261
  ### Changed
package/PerfGate.d.ts CHANGED
@@ -38,12 +38,33 @@ export interface MeasureResult {
38
38
  minorHi: number;
39
39
  majorLo: number;
40
40
  majorHi: number;
41
+ /** Old-gen activity (major + incremental) at N. */
42
+ oldGenLo: number;
43
+ /** Old-gen activity (major + incremental) at k*N. GATE signal (default max 0). */
44
+ oldGenHi: number;
41
45
  retainedKB_lo: number;
42
46
  retainedKB_hi: number;
47
+ /** External/backing-store (memoryUsage().arrayBuffers) delta in KB at N. */
48
+ arrayBuffersKB_lo: number;
49
+ /** External delta in KB at k*N. GATE signal (default max 64). */
50
+ arrayBuffersKB_hi: number;
43
51
  /** Custom counter deltas at N, or null if no statsOf. */
44
52
  counters_lo: Record<string, number> | null;
45
53
  /** Custom counter deltas at k*N, or null if no statsOf. */
46
54
  counters_hi: Record<string, number> | null;
55
+ /** False when measured without --expose-gc (allowNoGc). Absent means reliable. */
56
+ retainedReliable?: boolean;
57
+ }
58
+
59
+ export interface MeasureOptions {
60
+ /** Iteration count (low). Integer >= 1. Default 200000. */
61
+ N?: number;
62
+ /** Scale factor (high = k*N). Integer >= 2. Default 8. */
63
+ k?: number;
64
+ /** Wait (ms) after the hot loop. Finite >= 0 (0 is legal and honored). Default 100. */
65
+ flushMs?: number;
66
+ /** Run without --expose-gc; retained is dropped and retainedReliable is false. */
67
+ allowNoGc?: boolean;
47
68
  }
48
69
 
49
70
  export interface Thresholds {
@@ -51,6 +72,16 @@ export interface Thresholds {
51
72
  maxScavenges?: number;
52
73
  /** Max retained heap growth in KB. Default 64. */
53
74
  maxRetainedKB?: number;
75
+ /**
76
+ * Max allowed old-gen activity (major + incremental) at k*N. Default 0.
77
+ * When it trips, diagnose the cause with @zakkster/lite-gc-profiler.
78
+ */
79
+ maxOldGen?: number;
80
+ /**
81
+ * Max allowed external/arrayBuffers growth in KB at k*N. Default 64.
82
+ * Ungated under allowNoGc; an explicit value with allowNoGc throws.
83
+ */
84
+ maxArrayBuffersKB?: number;
54
85
  /** Per-counter maximum allowed delta. */
55
86
  counters?: Record<string, number>;
56
87
  }
@@ -71,33 +102,55 @@ export interface GateConfig {
71
102
  maxScavenges?: number;
72
103
  /** Max retained heap growth in KB. Default 64. */
73
104
  maxRetainedKB?: number;
105
+ /** Max old-gen activity (major + incremental) at k*N. Default 0. */
106
+ maxOldGen?: number;
107
+ /** Max external/arrayBuffers growth in KB at k*N. Default 64 (undefined under allowNoGc). */
108
+ maxArrayBuffersKB?: number;
74
109
  /** Counter thresholds for statsOf deltas. */
75
110
  counters?: Record<string, number>;
76
111
  /** Override positive control. */
77
112
  positiveControl?: Scenario;
78
113
  /** Override negative control. */
79
114
  negativeControl?: Scenario;
115
+ /** Override the large/external detector control (controlLarge). */
116
+ largeControl?: Scenario;
80
117
  /** Scenarios that MUST trip the gate (injected allocation self-tests). */
81
118
  mustFail?: Scenario[];
82
119
  /**
83
120
  * Wait (ms) after the hot loop before reading the GC observer buffer.
84
121
  * Default 100 (or PERF_GATE_FLUSH_MS env var). Bump to 250-500 on
85
122
  * noisy CI runners where event-loop stalls > 100ms may drop entries.
123
+ * 0 is legal and honored.
86
124
  */
87
125
  flushMs?: number;
126
+ /**
127
+ * Permit an empty scenarios array: the ONLY sanctioned empty gate, a
128
+ * controls-only detector smoke run. Otherwise an empty/missing/non-array
129
+ * scenarios throws.
130
+ */
131
+ allowEmpty?: boolean;
132
+ /**
133
+ * Run without --expose-gc. The retained rule is not applied at all (the
134
+ * default 64KB threshold included) and combining it with an explicit
135
+ * maxRetainedKB throws. Scavenges and counters still gate.
136
+ */
137
+ allowNoGc?: boolean;
88
138
  }
89
139
 
90
140
  export interface GateResult {
91
141
  passed: boolean;
142
+ /** 0 pass, 1 scenario or must-fail failure, 2 detector validation failed. passed === (code === 0). */
143
+ code: 0 | 1 | 2;
92
144
  results: MeasureResult[];
93
145
  }
94
146
 
95
147
  /**
96
148
  * Measure a scenario at N and k*N iterations. Returns raw measurements.
149
+ * Bad options throw; missing --expose-gc throws unless allowNoGc is set.
97
150
  */
98
151
  export function measure(
99
152
  scenario: Scenario,
100
- options?: { N?: number; k?: number; flushMs?: number }
153
+ options?: MeasureOptions
101
154
  ): Promise<MeasureResult>;
102
155
 
103
156
  /**
@@ -114,6 +167,13 @@ export const controlPositive: Scenario;
114
167
  /** Built-in negative control (pure arithmetic, zero allocation). */
115
168
  export const controlNegative: Scenario;
116
169
 
170
+ /**
171
+ * Built-in large/external control: a bounded, mask-gated retained pool of 64KB
172
+ * ArrayBuffers that trips the arrayBuffers signal. Validates the external
173
+ * detector in zgcSuite/runGate (decisions/0003).
174
+ */
175
+ export const controlLarge: Scenario;
176
+
117
177
  /**
118
178
  * Register node:test cases: detector validation, scenario gating, and
119
179
  * must-fail self-tests. The primary public API.
@@ -123,7 +183,8 @@ export const controlNegative: Scenario;
123
183
  export function zgcSuite(config: GateConfig): void;
124
184
 
125
185
  /**
126
- * Standalone human-readable gate report. Returns pass/fail and raw results.
186
+ * Standalone human-readable gate report. Returns `{ passed, code, results }`;
187
+ * never touches process.exitCode -- map result.code to your exit code.
127
188
  */
128
189
  export function runGate(config: GateConfig): Promise<GateResult>;
129
190
 
@@ -153,6 +214,13 @@ export interface SuiteBudget {
153
214
  reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last';
154
215
  /** Inclusive budget: verdict() flags value > max. */
155
216
  max: number;
217
+ /**
218
+ * Optional presence assertion. Integer >= 0 (else RangeError at config).
219
+ * Fails the budget when fewer records matched than minCount, with reason
220
+ * '<name>: matched <count> < minCount <n>'. Omitted or 0 is inert: a
221
+ * zero-match budget still reduces to 0 and passes (v1.1 back-compat).
222
+ */
223
+ minCount?: number;
156
224
  }
157
225
 
158
226
  export interface SuiteBudgetResult {
@@ -160,6 +228,8 @@ export interface SuiteBudgetResult {
160
228
  value: number;
161
229
  count: number;
162
230
  max: number;
231
+ /** The effective minCount (0 when the budget omitted it). */
232
+ minCount: number;
163
233
  pass: boolean;
164
234
  reasons: string[];
165
235
  }
@@ -175,7 +245,20 @@ export interface SuiteGateResult {
175
245
  * Evaluate SPP stream records against numeric budgets. Pure reduction with
176
246
  * per-budget delegation to verdict(); no measurement, no process exit
177
247
  * codes, no record emission. Wide-record CONT payloads are not budget
178
- * targets in v1.1 (base-record t/a/b slots only).
248
+ * targets (base-record t/a/b slots only).
249
+ *
250
+ * The source contract (v1.4.0 record doors, decisions/0004):
251
+ * THROWS (data-integrity refusal): a slab record whose packed header is not
252
+ * a u32 (RangeError naming the record index); a non-Float64Array or
253
+ * cross-realm typed-array source (its forEach binds (value, index, array));
254
+ * a forEach source whose ANY invocation is not four numbers (checked every
255
+ * record, naming the invocation index and slot); slot/reduce
256
+ * inherited-prototype keys; a budget targeting CONT (0x0F01), op or packed
257
+ * form; a bad minCount.
258
+ * FAILS a budget (verdict): a reduced value that is not finite ->
259
+ * '<name>: not a number (fail closed)'; count < minCount ->
260
+ * '<name>: matched <count> < minCount <n>'. Budget names may be any non-empty
261
+ * string, including 'toString' / '__proto__' / 'constructor'.
179
262
  */
180
263
  export function suiteGate(config: {
181
264
  source: Float64Array | SppRecordSource;