@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.
- package/README.md +187 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +712 -0
- package/dist/lib/drift.d.ts +185 -0
- package/dist/lib/drift.js +648 -0
- package/dist/lib/profile.d.ts +180 -0
- package/dist/lib/profile.js +332 -0
- package/dist/lib/reconcile.d.ts +123 -0
- package/dist/lib/reconcile.js +336 -0
- package/dist/lib/reshape.d.ts +79 -0
- package/dist/lib/reshape.js +176 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +47 -0
|
@@ -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
|
+
}
|