@crewhaus/tool-table 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Has the data moved since the baseline?
3
+ *
4
+ * Four things go wrong between yesterday's export and today's, and only one
5
+ * of them is visible in a row count: the schema changes, a column's
6
+ * distribution shifts, a column starts arriving empty, or a categorical
7
+ * column grows a value nothing downstream has ever seen. This compares a
8
+ * stored profile against today's and answers all four.
9
+ *
10
+ * THE RULE THAT MAKES ANY OF IT MEAN ANYTHING. A Population Stability Index
11
+ * is a comparison of two histograms over the SAME bins, and the bins have to
12
+ * be the reference's. Bin each side against its own quantiles and every
13
+ * column comes back with ten bins of ten percent on both sides — PSI near
14
+ * zero, however far the distribution actually moved. So the reference profile
15
+ * carries its edges, the current data is binned against those stored edges,
16
+ * and `compareDrift` REFUSES a column whose two sides were not binned
17
+ * identically rather than returning the plausible number.
18
+ *
19
+ * The statistics come from `@crewhaus/tool-math`'s kernel, which is where
20
+ * Mann-Whitney, PSI and the normal tail live for the whole repository. The
21
+ * chi-square below is the one piece that was not already there.
22
+ */
23
+ import { statsKernel } from "@crewhaus/tool-math";
24
+ /** A refusal: the data cannot answer the question asked of it. */
25
+ export class DriftError extends Error {
26
+ name = "DriftError";
27
+ }
28
+ // --- chi-square -------------------------------------------------------------
29
+ /** Lanczos g=7, n=9. Good to ~15 significant figures for the x >= 0.5 we use. */
30
+ const LANCZOS = [
31
+ 0.999_999_999_999_809_93, 676.520_368_121_885_1, -1259.139_216_722_402_8, 771.323_428_777_653_13,
32
+ -176.615_029_162_140_59, 12.507_343_278_686_905, -0.138_571_095_265_720_12,
33
+ 9.984_369_578_019_571_6e-6, 1.505_632_735_149_311_6e-7,
34
+ ];
35
+ /**
36
+ * ln Gamma(x) for x >= 0.5.
37
+ *
38
+ * The reflection formula for x < 0.5 is deliberately absent: the only caller
39
+ * is `chiSquareUpperTail` with a = df/2 and df >= 1, so x is never below 0.5.
40
+ * A branch with no caller is a branch with no test.
41
+ */
42
+ function lnGamma(x) {
43
+ const z = x - 1;
44
+ const t = z + 7.5;
45
+ let a = LANCZOS[0];
46
+ for (let i = 1; i < LANCZOS.length; i++)
47
+ a += LANCZOS[i] / (z + i);
48
+ return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a);
49
+ }
50
+ /**
51
+ * Lentz's guard: whenever a denominator collapses to zero it is replaced by
52
+ * this instead, so the recurrence carries on rather than producing Infinity.
53
+ * Well above the denormal floor, so the reciprocal below stays exact.
54
+ */
55
+ const FPMIN = 1e-300;
56
+ const ITERATIONS = 300;
57
+ /** Regularized lower incomplete gamma P(a, x) by its series. Converges fast for x < a+1. */
58
+ function gammaPSeries(a, x) {
59
+ let ap = a;
60
+ let sum = 1 / a;
61
+ let term = sum;
62
+ for (let i = 0; i < ITERATIONS; i++) {
63
+ ap += 1;
64
+ term *= x / ap;
65
+ sum += term;
66
+ if (Math.abs(term) < Math.abs(sum) * 1e-16)
67
+ break;
68
+ }
69
+ return sum * Math.exp(-x + a * Math.log(x) - lnGamma(a));
70
+ }
71
+ /** Regularized upper incomplete gamma Q(a, x) by the modified Lentz continued fraction. */
72
+ function gammaQContinued(a, x) {
73
+ let b = x + 1 - a;
74
+ let c = 1 / FPMIN;
75
+ let d = 1 / b;
76
+ let h = d;
77
+ for (let i = 1; i <= ITERATIONS; i++) {
78
+ const an = -i * (i - a);
79
+ b += 2;
80
+ d = an * d + b;
81
+ if (Math.abs(d) < FPMIN)
82
+ d = FPMIN;
83
+ c = b + an / c;
84
+ if (Math.abs(c) < FPMIN)
85
+ c = FPMIN;
86
+ d = 1 / d;
87
+ const delta = d * c;
88
+ h *= delta;
89
+ if (Math.abs(delta - 1) < 1e-16)
90
+ break;
91
+ }
92
+ return Math.exp(-x + a * Math.log(x) - lnGamma(a)) * h;
93
+ }
94
+ /**
95
+ * P(X > x) for a chi-square variate with `df` degrees of freedom — the upper
96
+ * tail, which is the p-value of every chi-square test.
97
+ *
98
+ * This is Q(df/2, x/2) of the regularized incomplete gamma function, split at
99
+ * `x < a + 1` between the series and the continued fraction because each one
100
+ * loses its digits in the other's region (Press et al., *Numerical Recipes*,
101
+ * §6.2). The kernel in `@crewhaus/tool-math` has the normal tail but not this
102
+ * one; `lib.test.ts` cross-checks the df=1 case against that normal tail,
103
+ * since Q(1/2, x/2) is exactly erfc(sqrt(x/2)) = 2 * P(Z > sqrt(x)).
104
+ */
105
+ export function chiSquareUpperTail(x, df) {
106
+ if (!Number.isFinite(x) || !Number.isFinite(df) || df < 1) {
107
+ throw new DriftError(`chi-square tail needs a finite statistic and df >= 1, got x=${x} df=${df}`);
108
+ }
109
+ if (x <= 0)
110
+ return 1;
111
+ const a = df / 2;
112
+ const y = x / 2;
113
+ return y < a + 1 ? 1 - gammaPSeries(a, y) : gammaQContinued(a, y);
114
+ }
115
+ /**
116
+ * Chi-square test of homogeneity on the 2 x k table of (reference, current)
117
+ * against category — "are these two samples drawn from the same distribution
118
+ * over these labels?".
119
+ *
120
+ * A category present on only one side stays in the table. It is the single
121
+ * most informative cell there is, and dropping it to avoid a zero is how a
122
+ * column that gained a whole new value tests as unchanged.
123
+ *
124
+ * `approximationValid` is Cochran's rule, and it is NOT advisory: the
125
+ * chi-square statistic's null distribution is only approximately chi-square,
126
+ * and with a handful of rare categories the approximation is off by enough to
127
+ * move a verdict. A caller gating on `p` should gate on this first.
128
+ */
129
+ export function chiSquareHomogeneity(reference, current) {
130
+ // A key recorded as zero on BOTH sides is dropped rather than counted: it
131
+ // would make its column total zero, so both its expected counts would be
132
+ // 0/0, and it would inflate df with a cell that holds no observation.
133
+ const categories = [...new Set([...Object.keys(reference), ...Object.keys(current)])]
134
+ .filter((key) => (reference[key] ?? 0) + (current[key] ?? 0) > 0)
135
+ .sort();
136
+ let referenceTotal = 0;
137
+ let currentTotal = 0;
138
+ for (const key of categories) {
139
+ referenceTotal += reference[key] ?? 0;
140
+ currentTotal += current[key] ?? 0;
141
+ }
142
+ const total = referenceTotal + currentTotal;
143
+ const base = {
144
+ categories: categories.length,
145
+ cells: categories.length * 2,
146
+ cellsBelowFive: 0,
147
+ cellsBelowOne: 0,
148
+ approximationValid: false,
149
+ };
150
+ if (referenceTotal === 0 || currentTotal === 0) {
151
+ return {
152
+ ...base,
153
+ chiSquare: 0,
154
+ df: Math.max(0, categories.length - 1),
155
+ p: null,
156
+ note: `one side has no observations (reference ${referenceTotal}, current ${currentTotal}); a test of homogeneity needs both`,
157
+ };
158
+ }
159
+ if (categories.length < 2) {
160
+ return {
161
+ ...base,
162
+ chiSquare: 0,
163
+ df: 0,
164
+ p: null,
165
+ note: categories.length === 1
166
+ ? `both sides hold only the value "${categories[0]}", so there is no distribution over labels to compare`
167
+ : "neither side has any values",
168
+ };
169
+ }
170
+ let chiSquare = 0;
171
+ let cellsBelowFive = 0;
172
+ let cellsBelowOne = 0;
173
+ for (const key of categories) {
174
+ const observedReference = reference[key] ?? 0;
175
+ const observedCurrent = current[key] ?? 0;
176
+ const columnTotal = observedReference + observedCurrent;
177
+ // A column total of zero cannot occur: the key is in the union because at
178
+ // least one side counted it. So neither expectation below is 0/0.
179
+ const expectedReference = (referenceTotal * columnTotal) / total;
180
+ const expectedCurrent = (currentTotal * columnTotal) / total;
181
+ for (const [observed, expected] of [
182
+ [observedReference, expectedReference],
183
+ [observedCurrent, expectedCurrent],
184
+ ]) {
185
+ if (expected < 5)
186
+ cellsBelowFive++;
187
+ if (expected < 1)
188
+ cellsBelowOne++;
189
+ chiSquare += ((observed - expected) * (observed - expected)) / expected;
190
+ }
191
+ }
192
+ const df = categories.length - 1;
193
+ const cells = categories.length * 2;
194
+ const approximationValid = cellsBelowOne === 0 && cellsBelowFive <= 0.2 * cells;
195
+ return {
196
+ chiSquare,
197
+ df,
198
+ p: chiSquareUpperTail(chiSquare, df),
199
+ categories: categories.length,
200
+ cells,
201
+ cellsBelowFive,
202
+ cellsBelowOne,
203
+ approximationValid,
204
+ note: approximationValid
205
+ ? `chi-square ${chiSquare.toFixed(4)} on ${df} df over ${categories.length} categories; Cochran's condition holds`
206
+ : `chi-square ${chiSquare.toFixed(4)} on ${df} df, but ${cellsBelowFive} of ${cells} expected cell counts are under 5 (${cellsBelowOne} under 1). The chi-square approximation is unreliable here — read p as a direction, not a rate, and pool the rare categories if you need a rate`,
207
+ };
208
+ }
209
+ const isNumeric = (d) => d?.kind === "numeric";
210
+ const isCategorical = (d) => d?.kind === "categorical";
211
+ const ratio = (reference, current) => reference === 0 ? null : current / reference;
212
+ /**
213
+ * What is wrong with a numeric capture's counts, or `null`.
214
+ *
215
+ * A stored profile is a file somebody may have edited, and the kernel's own
216
+ * guards fire from inside the sum — "reference has 5 bins and current has 12"
217
+ * with no column name attached. Catching it here buys the column name and the
218
+ * word "hand-edited", which is what tells a reader where to look.
219
+ */
220
+ function countsProblem(capture, side) {
221
+ if (capture.counts.length !== Math.max(0, capture.edges.length - 1)) {
222
+ return `the ${side} capture has ${capture.edges.length} bin edges but ${capture.counts.length} counts, so its bins no longer describe its data — it has been hand-edited`;
223
+ }
224
+ for (const count of [...capture.counts, capture.below, capture.above]) {
225
+ if (typeof count !== "number" || !Number.isFinite(count) || count < 0) {
226
+ return `the ${side} capture has a bin count of ${String(count)}, which is not a count — it has been hand-edited`;
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ /** Edges match only if every one of them does. A near-match is not a match. */
232
+ function sameEdges(a, b) {
233
+ return a.length === b.length && a.every((edge, i) => edge === b[i]);
234
+ }
235
+ /**
236
+ * PSI over the reference's stored bins and the current data's counts IN THOSE
237
+ * SAME BINS, with out-of-range values kept as their own two bins.
238
+ *
239
+ * Out-of-range handling is fixed at the kernel's "separate-bins" rather than
240
+ * exposed: a value beyond the reference's range is the loudest drift signal
241
+ * there is, and clamping it into the end bin is precisely how a distribution
242
+ * that walked off the edge of the chart reads as stable. Refusing outright
243
+ * would turn the most informative case into an error.
244
+ */
245
+ function psiFromCaptures(reference, current, epsilon) {
246
+ const referenceCounts = [reference.below, ...reference.counts, reference.above];
247
+ const currentCounts = [current.below, ...current.counts, current.above];
248
+ const edges = reference.edges;
249
+ const last = edges.length - 1;
250
+ const bounds = [
251
+ { lo: Number.NEGATIVE_INFINITY, hi: edges[0] },
252
+ ];
253
+ for (let i = 0; i < last; i++) {
254
+ // The final edge-interval is closed, matching the kernel's `binCounts`.
255
+ bounds.push({ lo: edges[i], hi: edges[i + 1], closed: i === last - 1 });
256
+ }
257
+ bounds.push({ lo: edges[last], hi: Number.POSITIVE_INFINITY });
258
+ return statsKernel.populationStabilityIndex(referenceCounts, currentCounts, { epsilon }, bounds);
259
+ }
260
+ /** The three bins that moved the index most, heaviest first. */
261
+ function topContributors(bins) {
262
+ return [...bins].sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution)).slice(0, 3);
263
+ }
264
+ function compareNumeric(column, reference, current, options, notes) {
265
+ let psi = null;
266
+ const malformed = countsProblem(reference, "reference") ?? countsProblem(current, "today's");
267
+ if (reference.unbinnable !== null) {
268
+ notes.push(`no PSI for "${column}": the reference column ${reference.unbinnable}`);
269
+ }
270
+ else if (malformed !== null) {
271
+ // Checked BEFORE the index is computed, not after: the kernel would throw
272
+ // a length-mismatch from three frames down, naming bin counts and no
273
+ // column, which is the error a reader cannot act on.
274
+ notes.push(`no PSI for "${column}": ${malformed}`);
275
+ }
276
+ else if (!sameEdges(reference.edges, current.edges)) {
277
+ // The whole point of storing edges. A PSI over two independently-derived
278
+ // edge sets compares each side against its own quantiles and returns
279
+ // roughly zero however far the data moved.
280
+ notes.push(`no PSI for "${column}": today's data was binned against different edges than the reference (reference [${reference.edges.join(", ")}], current [${current.edges.join(", ")}]). PSI over mismatched bins is a number with no meaning`);
281
+ }
282
+ else if (current.n === 0) {
283
+ notes.push(`no PSI for "${column}": today's column has no values that parse as numbers`);
284
+ }
285
+ else {
286
+ const result = psiFromCaptures(reference, current, options.epsilon);
287
+ psi = {
288
+ value: result.psi,
289
+ band: result.band,
290
+ epsilon: result.epsilon,
291
+ epsilonApplied: result.epsilonApplied,
292
+ binCount: result.bins.length,
293
+ outOfRange: { below: current.below, above: current.above },
294
+ bins: options.includeBins === true ? result.bins : topContributors(result.bins),
295
+ note: result.note,
296
+ };
297
+ }
298
+ if (current.unparsed > 0 || reference.unparsed > 0) {
299
+ notes.push(`"${column}" holds values that do not parse as numbers (${reference.unparsed} in the reference, ${current.unparsed} today); every number above speaks only for the ones that do`);
300
+ }
301
+ // Samples are checked here rather than left to the kernel for the same
302
+ // reason as the counts above: its message says `sample1[3]`, which names
303
+ // neither the column nor which of the two files it came from.
304
+ const usable = reference.sample.every((v) => Number.isFinite(v)) &&
305
+ current.sample.every((v) => Number.isFinite(v));
306
+ const mannWhitney = !usable || reference.sample.length === 0 || current.sample.length === 0
307
+ ? null
308
+ : statsKernel.mannWhitneyU(reference.sample, current.sample);
309
+ if (mannWhitney === null) {
310
+ notes.push(usable
311
+ ? `no rank test for "${column}": one side's value sample is empty`
312
+ : `no rank test for "${column}": a stored value sample holds something that is not a finite number`);
313
+ }
314
+ else {
315
+ if (reference.sampled || current.sampled) {
316
+ notes.push(`the rank test for "${column}" ran on samples (${reference.sample.length} of ${reference.n} reference values, ${current.sample.length} of ${current.n} today), so its p reflects the sample size, not the column's`);
317
+ }
318
+ if (!mannWhitney.normalApproximationValid) {
319
+ notes.push(`rank test for "${column}": ${mannWhitney.note}`);
320
+ }
321
+ }
322
+ return { psi, mannWhitney };
323
+ }
324
+ function compareCategorical(column, reference, current, notes) {
325
+ // With a capped category list there is no way to tell a genuinely new value
326
+ // from one that was always there and merely rare. Naming the wrong ones is
327
+ // worse than naming none: "three new payment methods appeared" is acted on.
328
+ if (reference.truncated || current.truncated) {
329
+ const which = reference.truncated
330
+ ? current.truncated
331
+ ? "both sides"
332
+ : "the reference"
333
+ : "today's data";
334
+ notes.push(`no category comparison for "${column}": ${which} had more distinct values than the capture's cap, so a value missing from the stored list may simply be one that did not make the cap. Re-profile with a higher maxCategories, or compare this column some other way`);
335
+ return { chiSquare: null, newCategories: null, droppedCategories: null };
336
+ }
337
+ const referenceKeys = new Set(Object.keys(reference.valueCounts));
338
+ const currentKeys = new Set(Object.keys(current.valueCounts));
339
+ const newCategories = [...currentKeys].filter((k) => !referenceKeys.has(k)).sort();
340
+ const droppedCategories = [...referenceKeys].filter((k) => !currentKeys.has(k)).sort();
341
+ const chiSquare = chiSquareHomogeneity(reference.valueCounts, current.valueCounts);
342
+ if (chiSquare.p !== null && !chiSquare.approximationValid)
343
+ notes.push(chiSquare.note);
344
+ return { chiSquare, newCategories, droppedCategories };
345
+ }
346
+ /**
347
+ * Compare a stored reference profile against today's.
348
+ *
349
+ * Both must carry a drift capture, and today's must have been captured
350
+ * against the reference's edges — `profileTable`'s `drift.edges` option is
351
+ * what does that. A column captured any other way is reported without a PSI
352
+ * and with the reason, which is the only honest thing to return.
353
+ */
354
+ export function compareDrift(reference, current, options) {
355
+ if (reference.driftCapture === undefined) {
356
+ throw new DriftError("the reference profile carries no drift capture: re-run TableProfile on the baseline with driftProfile true, which is what stores the bin edges this comparison needs");
357
+ }
358
+ if (current.driftCapture === undefined) {
359
+ throw new DriftError("today's profile carries no drift capture");
360
+ }
361
+ const notes = [];
362
+ const referenceColumns = new Map(reference.columns.map((c) => [c.name, c]));
363
+ const currentColumns = new Map(current.columns.map((c) => [c.name, c]));
364
+ // A header that repeats. Those maps keep the LAST column of each name, so
365
+ // both reference columns called "v" would be compared against today's
366
+ // second "v" — and nothing downstream can tell: the edges match (today's
367
+ // side was binned with them), so `sameEdges` passes, and two identical
368
+ // files come back reporting that "s" gained two categories and lost two.
369
+ // TableDiff reports a duplicate key rather than picking a row for exactly
370
+ // this reason: there is no fact about which one became which.
371
+ const duplicated = [
372
+ ...new Set([...repeatedNames(reference.columns), ...repeatedNames(current.columns)]),
373
+ ].sort();
374
+ if (duplicated.length > 0) {
375
+ notes.push(`no comparison for ${duplicated.map((n) => `"${n}"`).join(", ")}: that name is on more than one column, and a repeated header carries no fact about which column today corresponds to which column in the reference. Rename them upstream`);
376
+ }
377
+ const repeated = new Set(duplicated);
378
+ const added = current.columns.map((c) => c.name).filter((n) => !referenceColumns.has(n));
379
+ const removed = reference.columns.map((c) => c.name).filter((n) => !currentColumns.has(n));
380
+ const retyped = [];
381
+ const kindChanged = [];
382
+ const results = [];
383
+ for (const referenceColumn of reference.columns) {
384
+ if (repeated.has(referenceColumn.name))
385
+ continue;
386
+ const currentColumn = currentColumns.get(referenceColumn.name);
387
+ if (currentColumn === undefined)
388
+ continue;
389
+ if (referenceColumn.type !== currentColumn.type) {
390
+ retyped.push({
391
+ column: referenceColumn.name,
392
+ reference: referenceColumn.type,
393
+ current: currentColumn.type,
394
+ });
395
+ }
396
+ results.push(compareColumn(referenceColumn, currentColumn, options, kindChanged));
397
+ }
398
+ const gate = evaluate({ reference: reference.rows, current: current.rows }, { added, removed, retyped, duplicated }, results, options.thresholds);
399
+ if (reference.driftCapture.nullTokens.join("\u0000") !==
400
+ current.driftCapture.nullTokens.join("\u0000")) {
401
+ notes.push("the two profiles were taken under different null-token lists, so every null-rate figure below is partly a difference between those lists rather than a difference in the data");
402
+ }
403
+ if (reference.rows === 0 || current.rows === 0) {
404
+ notes.push(`one side has no rows (reference ${reference.rows}, today ${current.rows}); nothing below is a measurement`);
405
+ }
406
+ return {
407
+ rows: {
408
+ reference: reference.rows,
409
+ current: current.rows,
410
+ ratio: ratio(reference.rows, current.rows),
411
+ },
412
+ schema: { added, removed, retyped, kindChanged },
413
+ columns: results,
414
+ gate,
415
+ notes,
416
+ };
417
+ }
418
+ /** Names carried by more than one column, which is what makes them ambiguous. */
419
+ function repeatedNames(columns) {
420
+ const seen = new Set();
421
+ const twice = new Set();
422
+ for (const column of columns) {
423
+ if (seen.has(column.name))
424
+ twice.add(column.name);
425
+ seen.add(column.name);
426
+ }
427
+ return [...twice];
428
+ }
429
+ function compareColumn(referenceColumn, currentColumn, options, kindChanged) {
430
+ const column = referenceColumn.name;
431
+ const notes = [];
432
+ const shared = {
433
+ column,
434
+ nulls: {
435
+ reference: referenceColumn.nullFraction,
436
+ current: currentColumn.nullFraction,
437
+ increase: currentColumn.nullFraction - referenceColumn.nullFraction,
438
+ },
439
+ distinct: {
440
+ reference: referenceColumn.distinct,
441
+ current: currentColumn.distinct,
442
+ ratio: ratio(referenceColumn.distinct, currentColumn.distinct),
443
+ },
444
+ };
445
+ const referenceDrift = referenceColumn.drift;
446
+ const currentDrift = currentColumn.drift;
447
+ if (isNumeric(referenceDrift) && isNumeric(currentDrift)) {
448
+ const { psi, mannWhitney } = compareNumeric(column, referenceDrift, currentDrift, options, notes);
449
+ return {
450
+ kind: "numeric",
451
+ ...shared,
452
+ psi,
453
+ mannWhitney,
454
+ chiSquare: null,
455
+ newCategories: null,
456
+ droppedCategories: null,
457
+ notes,
458
+ };
459
+ }
460
+ if (isCategorical(referenceDrift) && isCategorical(currentDrift)) {
461
+ const { chiSquare, newCategories, droppedCategories } = compareCategorical(column, referenceDrift, currentDrift, notes);
462
+ return {
463
+ kind: "categorical",
464
+ ...shared,
465
+ psi: null,
466
+ mannWhitney: null,
467
+ chiSquare,
468
+ newCategories,
469
+ droppedCategories,
470
+ notes,
471
+ };
472
+ }
473
+ // One side is numbers and the other labels, or a capture is missing. There
474
+ // is no comparison to make: a distribution over numbers and a distribution
475
+ // over strings do not live on the same axis, and forcing one produces a
476
+ // statistic with no null hypothesis behind it.
477
+ const referenceKind = referenceDrift?.kind ?? "absent";
478
+ const currentKind = currentDrift?.kind ?? "absent";
479
+ if (referenceKind !== currentKind) {
480
+ kindChanged.push({ column, reference: referenceKind, current: currentKind });
481
+ notes.push(`"${column}" is compared as ${referenceKind} in the reference and ${currentKind} today, so no distribution test applies. The null rate and cardinality below still do`);
482
+ }
483
+ else {
484
+ notes.push(`"${column}" has no drift capture on either side, so only its null rate and cardinality are compared`);
485
+ }
486
+ return {
487
+ kind: "incomparable",
488
+ ...shared,
489
+ psi: null,
490
+ mannWhitney: null,
491
+ chiSquare: null,
492
+ newCategories: null,
493
+ droppedCategories: null,
494
+ notes,
495
+ };
496
+ }
497
+ /** The note that says why a measurement is missing, so the gate can quote it. */
498
+ function why(notes, marker) {
499
+ return (notes.find((note) => note.includes(marker)) ?? "the comparison produced no such measurement");
500
+ }
501
+ /**
502
+ * Turn the measurements into a pass or a fail.
503
+ *
504
+ * Every gate is opt-in, and with none configured `ok` is true — which is why
505
+ * it comes with a note saying so. "ok: true" reported for an unconfigured
506
+ * gate reads as "nothing drifted" to anyone skimming, and it means "nothing
507
+ * was checked".
508
+ *
509
+ * THE SAME MISTAKE ONE LEVEL DOWN, WHICH IS THE ONE THAT SHIPS. A configured
510
+ * threshold whose measurement was REFUSED used to compare nothing, add
511
+ * nothing to `failures`, and leave `ok` true under the note "every configured
512
+ * threshold held". So the upstream feed that starts sending `amount` as
513
+ * `"1,234.00"` — every value unparseable, the loudest possible break — takes
514
+ * `failOn.psi` straight through green, because PSI was refused rather than
515
+ * large. `rowCountRatio` already declined to pass a ratio it could not
516
+ * compute; `unchecked` is that same stance for every other threshold, and
517
+ * `ok` is false while it is non-empty. Fail closed: the gate did not look.
518
+ */
519
+ function evaluate(rows, schema, columns, thresholds) {
520
+ const configured = thresholds !== undefined && Object.keys(thresholds).length > 0;
521
+ if (!configured || thresholds === undefined) {
522
+ return {
523
+ configured: false,
524
+ ok: true,
525
+ failures: [],
526
+ unchecked: [],
527
+ note: "no failOn thresholds were given, so ok is true because nothing was checked — not because nothing drifted. Read the columns",
528
+ };
529
+ }
530
+ const failures = [];
531
+ const unchecked = [];
532
+ const perColumn = thresholds.psi !== undefined ||
533
+ thresholds.pValue !== undefined ||
534
+ thresholds.nullRateIncrease !== undefined ||
535
+ thresholds.cardinalityRatio !== undefined ||
536
+ thresholds.newCategories !== undefined;
537
+ if (perColumn) {
538
+ // Neither ever reaches the per-column loop below: a column that is gone
539
+ // gets no result, and an ambiguous one is excluded upstream. Without this
540
+ // they are per-column thresholds that quietly measured no column at all.
541
+ for (const name of schema.removed) {
542
+ unchecked.push(`"${name}": no per-column threshold was checked — the column is in the reference and not in today's data`);
543
+ }
544
+ for (const name of schema.duplicated) {
545
+ unchecked.push(`"${name}": no per-column threshold was checked — more than one column carries that name`);
546
+ }
547
+ }
548
+ if (thresholds.schemaDrift === true) {
549
+ for (const name of schema.added)
550
+ failures.push(`column "${name}" is new`);
551
+ for (const name of schema.removed)
552
+ failures.push(`column "${name}" is gone`);
553
+ for (const { column } of schema.retyped)
554
+ failures.push(`column "${column}" changed type`);
555
+ }
556
+ if (thresholds.rowCountRatio !== undefined) {
557
+ const r = ratio(rows.reference, rows.current);
558
+ if (r === null) {
559
+ unchecked.push("the row-count threshold was not checked — the reference has no rows to take a ratio against");
560
+ }
561
+ else if (r > thresholds.rowCountRatio || r < 1 / thresholds.rowCountRatio) {
562
+ failures.push(`row count went ${rows.reference} to ${rows.current} (ratio ${r.toFixed(3)}), outside [${(1 / thresholds.rowCountRatio).toFixed(3)}, ${thresholds.rowCountRatio}]`);
563
+ }
564
+ }
565
+ for (const column of columns) {
566
+ if (thresholds.psi !== undefined &&
567
+ column.psi?.value != null &&
568
+ column.psi.value >= thresholds.psi) {
569
+ failures.push(`"${column.column}" PSI ${column.psi.value.toFixed(4)} (${column.psi.band}) at or above ${thresholds.psi}${column.psi.epsilonApplied ? `, with the epsilon floor ${column.psi.epsilon} in the sum` : ""}`);
570
+ }
571
+ if (thresholds.pValue !== undefined) {
572
+ // A p from an approximation the kernel itself calls invalid is a rank
573
+ // ordering, not a rate. Gating on it is a coin flip wearing a decimal
574
+ // point, so an invalid p does not trip the gate — it is reported in the
575
+ // column's notes instead.
576
+ const mw = column.mannWhitney;
577
+ if (mw?.p != null && mw.normalApproximationValid && mw.p <= thresholds.pValue) {
578
+ failures.push(`"${column.column}" rank test p ${mw.p.toExponential(3)} at or below ${thresholds.pValue}`);
579
+ }
580
+ const cs = column.chiSquare;
581
+ if (cs?.p != null && cs.approximationValid && cs.p <= thresholds.pValue) {
582
+ failures.push(`"${column.column}" chi-square p ${cs.p.toExponential(3)} at or below ${thresholds.pValue}`);
583
+ }
584
+ }
585
+ if (thresholds.nullRateIncrease !== undefined &&
586
+ column.nulls.increase >= thresholds.nullRateIncrease) {
587
+ failures.push(`"${column.column}" null rate rose from ${(column.nulls.reference * 100).toFixed(1)}% to ${(column.nulls.current * 100).toFixed(1)}%`);
588
+ }
589
+ if (thresholds.cardinalityRatio !== undefined) {
590
+ const r = column.distinct.ratio;
591
+ if (r !== null && (r > thresholds.cardinalityRatio || r < 1 / thresholds.cardinalityRatio)) {
592
+ failures.push(`"${column.column}" distinct count went ${column.distinct.reference} to ${column.distinct.current} (ratio ${r.toFixed(3)})`);
593
+ }
594
+ }
595
+ if (thresholds.newCategories !== undefined &&
596
+ column.newCategories !== null &&
597
+ column.newCategories.length > thresholds.newCategories) {
598
+ failures.push(`"${column.column}" gained ${column.newCategories.length} categories: ${column.newCategories.slice(0, 10).join(", ")}`);
599
+ }
600
+ // A threshold is "unchecked" only where the column's KIND says the
601
+ // measurement should have existed. A PSI for a column of labels was never
602
+ // going to exist and is not a gap; a PSI for a numeric column that came
603
+ // back refused is exactly the gap this list is for.
604
+ if (thresholds.psi !== undefined &&
605
+ column.kind !== "categorical" &&
606
+ column.psi?.value == null) {
607
+ unchecked.push(`"${column.column}": the PSI threshold ${thresholds.psi} was not checked — ${why(column.notes, "no PSI for")}`);
608
+ }
609
+ if (thresholds.pValue !== undefined) {
610
+ const mw = column.mannWhitney;
611
+ const cs = column.chiSquare;
612
+ if (column.kind === "numeric" && (mw?.p == null || !mw.normalApproximationValid)) {
613
+ unchecked.push(`"${column.column}": the p-value threshold ${thresholds.pValue} was not checked against the rank test — ${mw?.p == null ? why(column.notes, "no rank test for") : mw.note}`);
614
+ }
615
+ else if (column.kind === "categorical" && (cs?.p == null || !cs.approximationValid)) {
616
+ unchecked.push(`"${column.column}": the p-value threshold ${thresholds.pValue} was not checked against the chi-square — ${cs?.note ?? why(column.notes, "no category comparison for")}`);
617
+ }
618
+ else if (column.kind === "incomparable") {
619
+ unchecked.push(`"${column.column}": the p-value threshold ${thresholds.pValue} was not checked — ${why(column.notes, column.column)}`);
620
+ }
621
+ }
622
+ if (thresholds.newCategories !== undefined &&
623
+ column.kind === "categorical" &&
624
+ column.newCategories === null) {
625
+ unchecked.push(`"${column.column}": the new-category threshold ${thresholds.newCategories} was not checked — ${why(column.notes, "no category comparison for")}`);
626
+ }
627
+ if (thresholds.cardinalityRatio !== undefined && column.distinct.ratio === null) {
628
+ unchecked.push(`"${column.column}": the cardinality threshold ${thresholds.cardinalityRatio} was not checked — the reference column had no distinct values to take a ratio against`);
629
+ }
630
+ }
631
+ const held = failures.length === 0 && unchecked.length === 0;
632
+ return {
633
+ configured: true,
634
+ ok: held,
635
+ failures,
636
+ unchecked,
637
+ note: held
638
+ ? "every configured threshold held"
639
+ : [
640
+ failures.length > 0 ? `${failures.length} configured threshold(s) tripped` : "",
641
+ unchecked.length > 0
642
+ ? `${unchecked.length} configured threshold(s) could not be checked at all, which is not the same as holding — ok is false because the gate never got to look`
643
+ : "",
644
+ ]
645
+ .filter((part) => part !== "")
646
+ .join("; "),
647
+ };
648
+ }