@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,180 @@
|
|
|
1
|
+
export declare const COLUMN_TYPES: readonly ["integer", "decimal", "boolean", "date", "datetime", "string", "empty", "mixed"];
|
|
2
|
+
export type ColumnType = (typeof COLUMN_TYPES)[number];
|
|
3
|
+
/**
|
|
4
|
+
* The drift capture for a column compared as a distribution of numbers.
|
|
5
|
+
*
|
|
6
|
+
* `edges` is the whole reason this type exists. They are the REFERENCE's
|
|
7
|
+
* quantile edges, and a comparison must re-use them for the current data —
|
|
8
|
+
* see the module note. `counts` is over the full column; `sample` is a
|
|
9
|
+
* bounded, seeded reservoir, because a rank test needs values and a profile
|
|
10
|
+
* cannot carry two million of them.
|
|
11
|
+
*/
|
|
12
|
+
export type NumericDrift = {
|
|
13
|
+
readonly kind: "numeric";
|
|
14
|
+
/** Non-null cells that parsed as a finite number. */
|
|
15
|
+
readonly n: number;
|
|
16
|
+
/** Non-null cells that did NOT — the fraction this capture cannot speak for. */
|
|
17
|
+
readonly unparsed: number;
|
|
18
|
+
/** Strictly increasing, spanning [min, max]. Empty when `unbinnable`. */
|
|
19
|
+
readonly edges: ReadonlyArray<number>;
|
|
20
|
+
/** One count per interval between consecutive edges. Sums to `n`. */
|
|
21
|
+
readonly counts: ReadonlyArray<number>;
|
|
22
|
+
/**
|
|
23
|
+
* Zero by construction here, since the edges come from this very column.
|
|
24
|
+
* Carried anyway so the shape matches what the current side produces, and
|
|
25
|
+
* so a hand-edited profile whose edges no longer fit its counts is visible.
|
|
26
|
+
*/
|
|
27
|
+
readonly below: number;
|
|
28
|
+
readonly above: number;
|
|
29
|
+
/** A seeded reservoir of the column's numeric values, ascending. */
|
|
30
|
+
readonly sample: ReadonlyArray<number>;
|
|
31
|
+
/** True when the reservoir had to drop values, i.e. `sample.length < n`. */
|
|
32
|
+
readonly sampled: boolean;
|
|
33
|
+
/** Why no edges exist — a constant or empty column — or `null`. */
|
|
34
|
+
readonly unbinnable: string | null;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The drift capture for a column compared as a set of labels.
|
|
38
|
+
*
|
|
39
|
+
* `truncated` is load-bearing: with a capped list there is no way to tell a
|
|
40
|
+
* genuinely new category from one that was always there and merely rare, so a
|
|
41
|
+
* comparison must refuse to name new categories rather than name the wrong
|
|
42
|
+
* ones.
|
|
43
|
+
*/
|
|
44
|
+
export type CategoricalDrift = {
|
|
45
|
+
readonly kind: "categorical";
|
|
46
|
+
/** Non-null cells. */
|
|
47
|
+
readonly counted: number;
|
|
48
|
+
readonly distinct: number;
|
|
49
|
+
/** The commonest values and their counts. Capped. */
|
|
50
|
+
readonly valueCounts: Readonly<Record<string, number>>;
|
|
51
|
+
readonly truncated: boolean;
|
|
52
|
+
/** Cells whose value did not make the cap. */
|
|
53
|
+
readonly otherCount: number;
|
|
54
|
+
};
|
|
55
|
+
export type ColumnDrift = NumericDrift | CategoricalDrift;
|
|
56
|
+
/** The settings a drift capture was taken under, echoed so it is checkable. */
|
|
57
|
+
export type DriftCaptureHeader = {
|
|
58
|
+
readonly version: 1;
|
|
59
|
+
readonly binsRequested: number;
|
|
60
|
+
readonly maxCategories: number;
|
|
61
|
+
readonly sampleSize: number;
|
|
62
|
+
readonly seed: number;
|
|
63
|
+
/**
|
|
64
|
+
* The null tokens in force. A comparison MUST profile the current data with
|
|
65
|
+
* this same list: otherwise a null-rate jump measures the difference between
|
|
66
|
+
* two token lists rather than a difference in the data.
|
|
67
|
+
*/
|
|
68
|
+
readonly nullTokens: ReadonlyArray<string>;
|
|
69
|
+
};
|
|
70
|
+
export type ColumnProfile = {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly type: ColumnType;
|
|
73
|
+
/** Every type seen, with counts, so `mixed` is explicable. */
|
|
74
|
+
readonly types: Readonly<Record<string, number>>;
|
|
75
|
+
readonly nulls: number;
|
|
76
|
+
readonly nullFraction: number;
|
|
77
|
+
readonly distinct: number;
|
|
78
|
+
/** True when every non-null value is distinct — a candidate key. */
|
|
79
|
+
readonly unique: boolean;
|
|
80
|
+
readonly minLength: number;
|
|
81
|
+
readonly maxLength: number;
|
|
82
|
+
/** For numeric columns only. */
|
|
83
|
+
readonly min: number | null;
|
|
84
|
+
readonly max: number | null;
|
|
85
|
+
/** The commonest values, heaviest first. Capped. */
|
|
86
|
+
readonly top: ReadonlyArray<{
|
|
87
|
+
readonly value: string;
|
|
88
|
+
readonly count: number;
|
|
89
|
+
}>;
|
|
90
|
+
/** A few real values, for a reader who wants to see the shape. */
|
|
91
|
+
readonly samples: ReadonlyArray<string>;
|
|
92
|
+
/** Present only when a drift capture was asked for; see `ProfileOptions`. */
|
|
93
|
+
readonly drift?: ColumnDrift;
|
|
94
|
+
};
|
|
95
|
+
export type TableProfile = {
|
|
96
|
+
readonly rows: number;
|
|
97
|
+
readonly columns: ReadonlyArray<ColumnProfile>;
|
|
98
|
+
/** Columns whose values are unique across every row — candidate keys. */
|
|
99
|
+
readonly candidateKeys: ReadonlyArray<string>;
|
|
100
|
+
/** Rows identical to an earlier row, across every column. */
|
|
101
|
+
readonly duplicateRows: number;
|
|
102
|
+
readonly emptyColumns: ReadonlyArray<string>;
|
|
103
|
+
readonly constantColumns: ReadonlyArray<string>;
|
|
104
|
+
/** Present only when a drift capture was asked for. */
|
|
105
|
+
readonly driftCapture?: DriftCaptureHeader;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Classify one cell.
|
|
109
|
+
*
|
|
110
|
+
* `0` and `1` are reported as integers rather than booleans: a column of
|
|
111
|
+
* counts would otherwise be called boolean, and calling a quantity a flag is
|
|
112
|
+
* a worse error than the reverse. A column whose values are all `0`/`1` is
|
|
113
|
+
* visible in `top` either way.
|
|
114
|
+
*/
|
|
115
|
+
export declare function classifyCell(raw: string): ColumnType;
|
|
116
|
+
export type DriftCaptureOptions = {
|
|
117
|
+
/** Quantile bins to cut the reference into. Default 10. */
|
|
118
|
+
readonly bins?: number;
|
|
119
|
+
/** Cap on the category list. Default 128. */
|
|
120
|
+
readonly maxCategories?: number;
|
|
121
|
+
/** Cap on the reservoir of numeric values. Default 500. */
|
|
122
|
+
readonly sampleSize?: number;
|
|
123
|
+
/** Seeds the reservoir, so the same file always yields the same sample. */
|
|
124
|
+
readonly seed?: number;
|
|
125
|
+
/**
|
|
126
|
+
* Bin these columns against these edges instead of deriving fresh ones.
|
|
127
|
+
*
|
|
128
|
+
* This is how the current side of a drift check gets binned: with the
|
|
129
|
+
* REFERENCE's stored edges, which is the only binning under which a
|
|
130
|
+
* Population Stability Index means anything. A column named here whose
|
|
131
|
+
* edges are not strictly increasing is a caller error and throws.
|
|
132
|
+
*/
|
|
133
|
+
readonly edges?: Readonly<Record<string, ReadonlyArray<number>>>;
|
|
134
|
+
};
|
|
135
|
+
export declare const DRIFT_CAPTURE_DEFAULTS: Readonly<{
|
|
136
|
+
bins: 10;
|
|
137
|
+
maxCategories: 128;
|
|
138
|
+
sampleSize: 500;
|
|
139
|
+
/** Arbitrary, fixed, and stated: a seed nobody can see is not reproducible. */
|
|
140
|
+
seed: 1337;
|
|
141
|
+
}>;
|
|
142
|
+
export type ProfileOptions = {
|
|
143
|
+
/** Values treated as null in addition to the empty string. */
|
|
144
|
+
readonly nullTokens?: ReadonlyArray<string>;
|
|
145
|
+
readonly topValues?: number;
|
|
146
|
+
readonly samples?: number;
|
|
147
|
+
/**
|
|
148
|
+
* Capture what a later drift comparison needs. Off by default: the edges,
|
|
149
|
+
* counts and value sample are for a machine, and putting a 500-value
|
|
150
|
+
* reservoir in every profile makes the answer a person asked for worse.
|
|
151
|
+
*/
|
|
152
|
+
readonly drift?: DriftCaptureOptions;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Vitter's Algorithm R: every value has the same chance of ending up in the
|
|
156
|
+
* reservoir, whatever the length of the column.
|
|
157
|
+
*
|
|
158
|
+
* Taking the FIRST k values instead would be simpler and deterministic too,
|
|
159
|
+
* and would also be wrong on every export that arrives sorted by date or id —
|
|
160
|
+
* which is most of them. The first 500 rows of a sorted file are a sample of
|
|
161
|
+
* January, not of the year.
|
|
162
|
+
*/
|
|
163
|
+
export declare function reservoirSample(values: ReadonlyArray<number>, size: number, seed: number): number[];
|
|
164
|
+
/**
|
|
165
|
+
* Quantile bin edges over `values`, strictly increasing.
|
|
166
|
+
*
|
|
167
|
+
* Quantile edges rather than equal-width ones: equal-width bins on a skewed
|
|
168
|
+
* column put 99% of the reference in one bin and near-nothing in the rest, and
|
|
169
|
+
* PSI over near-empty reference bins is mostly noise. Equal-frequency bins
|
|
170
|
+
* give every reference bin the same weight, which is the shape the standard
|
|
171
|
+
* 0.1/0.25 bands were calibrated against.
|
|
172
|
+
*
|
|
173
|
+
* Ties collapse edges — a column that is 60% zeros has several identical
|
|
174
|
+
* quantiles — so duplicates are dropped and the SURVIVING count is reported.
|
|
175
|
+
* Silently keeping a zero-width bin would be worse: nothing can ever land in
|
|
176
|
+
* one under the half-open rule, so it would contribute an epsilon-against-
|
|
177
|
+
* epsilon term to every comparison forever.
|
|
178
|
+
*/
|
|
179
|
+
export declare function quantileEdges(values: ReadonlyArray<number>, bins: number): number[];
|
|
180
|
+
export declare function profileTable(headers: ReadonlyArray<string>, rows: ReadonlyArray<ReadonlyArray<string>>, options?: ProfileOptions): TableProfile;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is actually in this file.
|
|
3
|
+
*
|
|
4
|
+
* The first question about any export is always the same — how many rows,
|
|
5
|
+
* which columns, what types, how many nulls, is the key unique — and the
|
|
6
|
+
* usual way to answer it is to read the first fifty rows into a context
|
|
7
|
+
* window and guess from those. Fifty rows do not tell you that column 9 is
|
|
8
|
+
* empty in the last thousand, or that the id repeats.
|
|
9
|
+
*
|
|
10
|
+
* A profile can also be asked to carry a DRIFT CAPTURE: the bin edges, bin
|
|
11
|
+
* counts, category counts and a value sample that a later comparison needs.
|
|
12
|
+
* Those are opt-in because they are machine fodder, not an answer a person
|
|
13
|
+
* reads — but they have to be captured HERE, when the data is in hand.
|
|
14
|
+
* Re-deriving bin edges from tomorrow's data is the specific mistake that
|
|
15
|
+
* makes a Population Stability Index look fine and mean nothing: each side
|
|
16
|
+
* then gets binned against its own quantiles, and the index reads near zero
|
|
17
|
+
* no matter how far the distribution moved.
|
|
18
|
+
*/
|
|
19
|
+
import { statsKernel } from "@crewhaus/tool-math";
|
|
20
|
+
export const COLUMN_TYPES = [
|
|
21
|
+
"integer",
|
|
22
|
+
"decimal",
|
|
23
|
+
"boolean",
|
|
24
|
+
"date",
|
|
25
|
+
"datetime",
|
|
26
|
+
"string",
|
|
27
|
+
"empty",
|
|
28
|
+
"mixed",
|
|
29
|
+
];
|
|
30
|
+
const INTEGER = /^-?\d+$/;
|
|
31
|
+
const DECIMAL = /^-?\d*\.\d+(?:[eE][+-]?\d+)?$/;
|
|
32
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
33
|
+
const DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?/;
|
|
34
|
+
const BOOLEAN = /^(?:true|false|yes|no|y|n|t|f|0|1)$/i;
|
|
35
|
+
/**
|
|
36
|
+
* Classify one cell.
|
|
37
|
+
*
|
|
38
|
+
* `0` and `1` are reported as integers rather than booleans: a column of
|
|
39
|
+
* counts would otherwise be called boolean, and calling a quantity a flag is
|
|
40
|
+
* a worse error than the reverse. A column whose values are all `0`/`1` is
|
|
41
|
+
* visible in `top` either way.
|
|
42
|
+
*/
|
|
43
|
+
export function classifyCell(raw) {
|
|
44
|
+
const text = raw.trim();
|
|
45
|
+
if (text === "")
|
|
46
|
+
return "empty";
|
|
47
|
+
if (INTEGER.test(text))
|
|
48
|
+
return "integer";
|
|
49
|
+
if (DECIMAL.test(text))
|
|
50
|
+
return "decimal";
|
|
51
|
+
if (DATETIME.test(text))
|
|
52
|
+
return "datetime";
|
|
53
|
+
if (DATE.test(text))
|
|
54
|
+
return "date";
|
|
55
|
+
if (BOOLEAN.test(text))
|
|
56
|
+
return "boolean";
|
|
57
|
+
return "string";
|
|
58
|
+
}
|
|
59
|
+
export const DRIFT_CAPTURE_DEFAULTS = Object.freeze({
|
|
60
|
+
bins: 10,
|
|
61
|
+
maxCategories: 128,
|
|
62
|
+
sampleSize: 500,
|
|
63
|
+
/** Arbitrary, fixed, and stated: a seed nobody can see is not reproducible. */
|
|
64
|
+
seed: 1_337,
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* A small, fast, seeded PRNG (Tommy Ettinger's mulberry32). It is here so the
|
|
68
|
+
* reservoir below is reproducible: `Math.random` would make two profiles of
|
|
69
|
+
* the same unchanged file disagree, and a drift check against a moving
|
|
70
|
+
* baseline reports drift that is its own sampling noise.
|
|
71
|
+
*/
|
|
72
|
+
function mulberry32(seed) {
|
|
73
|
+
let a = seed >>> 0;
|
|
74
|
+
return () => {
|
|
75
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
76
|
+
let t = a;
|
|
77
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
78
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
79
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Vitter's Algorithm R: every value has the same chance of ending up in the
|
|
84
|
+
* reservoir, whatever the length of the column.
|
|
85
|
+
*
|
|
86
|
+
* Taking the FIRST k values instead would be simpler and deterministic too,
|
|
87
|
+
* and would also be wrong on every export that arrives sorted by date or id —
|
|
88
|
+
* which is most of them. The first 500 rows of a sorted file are a sample of
|
|
89
|
+
* January, not of the year.
|
|
90
|
+
*/
|
|
91
|
+
export function reservoirSample(values, size, seed) {
|
|
92
|
+
if (size <= 0)
|
|
93
|
+
return [];
|
|
94
|
+
// Always ascending, whether or not anything was dropped: a stored sample is
|
|
95
|
+
// an artifact somebody diffs against last week's, and row order is not a
|
|
96
|
+
// fact about the data.
|
|
97
|
+
if (values.length <= size)
|
|
98
|
+
return [...values].sort((a, b) => a - b);
|
|
99
|
+
const random = mulberry32(seed);
|
|
100
|
+
const reservoir = values.slice(0, size);
|
|
101
|
+
for (let i = size; i < values.length; i++) {
|
|
102
|
+
const j = Math.floor(random() * (i + 1));
|
|
103
|
+
if (j < size)
|
|
104
|
+
reservoir[j] = values[i];
|
|
105
|
+
}
|
|
106
|
+
return reservoir.sort((a, b) => a - b);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Quantile bin edges over `values`, strictly increasing.
|
|
110
|
+
*
|
|
111
|
+
* Quantile edges rather than equal-width ones: equal-width bins on a skewed
|
|
112
|
+
* column put 99% of the reference in one bin and near-nothing in the rest, and
|
|
113
|
+
* PSI over near-empty reference bins is mostly noise. Equal-frequency bins
|
|
114
|
+
* give every reference bin the same weight, which is the shape the standard
|
|
115
|
+
* 0.1/0.25 bands were calibrated against.
|
|
116
|
+
*
|
|
117
|
+
* Ties collapse edges — a column that is 60% zeros has several identical
|
|
118
|
+
* quantiles — so duplicates are dropped and the SURVIVING count is reported.
|
|
119
|
+
* Silently keeping a zero-width bin would be worse: nothing can ever land in
|
|
120
|
+
* one under the half-open rule, so it would contribute an epsilon-against-
|
|
121
|
+
* epsilon term to every comparison forever.
|
|
122
|
+
*/
|
|
123
|
+
export function quantileEdges(values, bins) {
|
|
124
|
+
if (values.length === 0 || bins < 1)
|
|
125
|
+
return [];
|
|
126
|
+
const edges = [];
|
|
127
|
+
for (let i = 0; i <= bins; i++) {
|
|
128
|
+
const q = statsKernel.quantile(values, i / bins);
|
|
129
|
+
if (q === null)
|
|
130
|
+
return [];
|
|
131
|
+
if (edges.length === 0 || q > edges[edges.length - 1])
|
|
132
|
+
edges.push(q);
|
|
133
|
+
}
|
|
134
|
+
return edges.length >= 2 ? edges : [];
|
|
135
|
+
}
|
|
136
|
+
export function profileTable(headers, rows, options = {}) {
|
|
137
|
+
const nullTokens = new Set((options.nullTokens ?? ["NULL", "null", "NA", "N/A", "-"]).map((t) => t.trim()));
|
|
138
|
+
const topLimit = options.topValues ?? 5;
|
|
139
|
+
const sampleLimit = options.samples ?? 3;
|
|
140
|
+
const capture = options.drift;
|
|
141
|
+
const bins = capture?.bins ?? DRIFT_CAPTURE_DEFAULTS.bins;
|
|
142
|
+
const maxCategories = capture?.maxCategories ?? DRIFT_CAPTURE_DEFAULTS.maxCategories;
|
|
143
|
+
const sampleSize = capture?.sampleSize ?? DRIFT_CAPTURE_DEFAULTS.sampleSize;
|
|
144
|
+
const seed = capture?.seed ?? DRIFT_CAPTURE_DEFAULTS.seed;
|
|
145
|
+
const columns = headers.map((name, index) => {
|
|
146
|
+
const counts = new Map();
|
|
147
|
+
const typeCounts = {};
|
|
148
|
+
let nulls = 0;
|
|
149
|
+
let minLength = Number.POSITIVE_INFINITY;
|
|
150
|
+
let maxLength = 0;
|
|
151
|
+
let min = null;
|
|
152
|
+
let max = null;
|
|
153
|
+
const samples = [];
|
|
154
|
+
// Filled only when a capture was asked for, and held for the duration of
|
|
155
|
+
// this ONE column — which is why the capture is built inside the map
|
|
156
|
+
// rather than after it. Twenty numeric columns of two million rows would
|
|
157
|
+
// otherwise all be resident at once.
|
|
158
|
+
const numericValues = [];
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const raw = (row[index] ?? "").trim();
|
|
161
|
+
const isNull = raw === "" || nullTokens.has(raw);
|
|
162
|
+
if (isNull) {
|
|
163
|
+
nulls++;
|
|
164
|
+
typeCounts["empty"] = (typeCounts["empty"] ?? 0) + 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const type = classifyCell(raw);
|
|
168
|
+
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
|
169
|
+
counts.set(raw, (counts.get(raw) ?? 0) + 1);
|
|
170
|
+
minLength = Math.min(minLength, raw.length);
|
|
171
|
+
maxLength = Math.max(maxLength, raw.length);
|
|
172
|
+
if (type === "integer" || type === "decimal") {
|
|
173
|
+
const value = Number(raw);
|
|
174
|
+
if (Number.isFinite(value)) {
|
|
175
|
+
min = min === null ? value : Math.min(min, value);
|
|
176
|
+
max = max === null ? value : Math.max(max, value);
|
|
177
|
+
if (capture !== undefined)
|
|
178
|
+
numericValues.push(value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (samples.length < sampleLimit)
|
|
182
|
+
samples.push(raw);
|
|
183
|
+
}
|
|
184
|
+
const present = Object.entries(typeCounts).filter(([t]) => t !== "empty");
|
|
185
|
+
const type = present.length === 0
|
|
186
|
+
? "empty"
|
|
187
|
+
: present.length === 1
|
|
188
|
+
? present[0]?.[0]
|
|
189
|
+
: "mixed";
|
|
190
|
+
const ordered = [...counts.entries()]
|
|
191
|
+
.map(([value, count]) => ({ value, count }))
|
|
192
|
+
.sort((a, b) => b.count - a.count || (a.value < b.value ? -1 : 1));
|
|
193
|
+
const top = ordered.slice(0, topLimit);
|
|
194
|
+
const drift = capture === undefined
|
|
195
|
+
? undefined
|
|
196
|
+
: captureColumn(numericValues, ordered, rows.length - nulls, {
|
|
197
|
+
bins,
|
|
198
|
+
maxCategories,
|
|
199
|
+
sampleSize,
|
|
200
|
+
seed,
|
|
201
|
+
// Own properties only: a column named "__proto__" otherwise reads
|
|
202
|
+
// back `Object.prototype`, which is not undefined, so the column is
|
|
203
|
+
// forced down the numeric path with a non-array "edges" whose
|
|
204
|
+
// `length` is undefined — `binCounts` then builds `new Array(NaN)`
|
|
205
|
+
// and dies with a RangeError naming nothing.
|
|
206
|
+
suppliedEdges: capture.edges !== undefined && Object.hasOwn(capture.edges, name)
|
|
207
|
+
? capture.edges[name]
|
|
208
|
+
: undefined,
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
name,
|
|
212
|
+
type,
|
|
213
|
+
types: typeCounts,
|
|
214
|
+
nulls,
|
|
215
|
+
nullFraction: rows.length === 0 ? 0 : nulls / rows.length,
|
|
216
|
+
distinct: counts.size,
|
|
217
|
+
// A column with one non-null value is technically unique; requiring
|
|
218
|
+
// more than one stops a nearly-empty column being offered as a key.
|
|
219
|
+
unique: counts.size > 1 && counts.size === rows.length - nulls,
|
|
220
|
+
minLength: minLength === Number.POSITIVE_INFINITY ? 0 : minLength,
|
|
221
|
+
maxLength,
|
|
222
|
+
min,
|
|
223
|
+
max,
|
|
224
|
+
top,
|
|
225
|
+
samples,
|
|
226
|
+
...(drift === undefined ? {} : { drift }),
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
let duplicateRows = 0;
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
const key = JSON.stringify(row);
|
|
233
|
+
if (seen.has(key))
|
|
234
|
+
duplicateRows++;
|
|
235
|
+
else
|
|
236
|
+
seen.add(key);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
rows: rows.length,
|
|
240
|
+
columns,
|
|
241
|
+
candidateKeys: columns.filter((c) => c.unique && c.nulls === 0).map((c) => c.name),
|
|
242
|
+
duplicateRows,
|
|
243
|
+
emptyColumns: columns.filter((c) => c.type === "empty").map((c) => c.name),
|
|
244
|
+
constantColumns: columns.filter((c) => c.distinct === 1 && c.nulls === 0).map((c) => c.name),
|
|
245
|
+
...(capture === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: {
|
|
248
|
+
driftCapture: {
|
|
249
|
+
version: 1,
|
|
250
|
+
binsRequested: bins,
|
|
251
|
+
maxCategories,
|
|
252
|
+
sampleSize,
|
|
253
|
+
seed,
|
|
254
|
+
nullTokens: [...nullTokens],
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Decide how a column should be compared, and capture what that comparison
|
|
261
|
+
* needs.
|
|
262
|
+
*
|
|
263
|
+
* A column is compared as NUMBERS when at least half its non-null cells parse
|
|
264
|
+
* as one. The alternative — treating a numeric column with a few stray
|
|
265
|
+
* "n/a"-style strings as categorical — turns every distinct number into its
|
|
266
|
+
* own category, so a column of prices reads as tens of thousands of
|
|
267
|
+
* categories and invents cardinality drift out of arithmetic. A tie goes to
|
|
268
|
+
* numeric, and `unparsed` names exactly how much of the column the numeric
|
|
269
|
+
* capture does not speak for.
|
|
270
|
+
*/
|
|
271
|
+
function captureColumn(numericValues, ordered, nonNull, settings) {
|
|
272
|
+
// Supplied edges force the numeric path even for a column that today is
|
|
273
|
+
// mostly unparseable text. The reference said this column is numbers; a bad
|
|
274
|
+
// day for the data should surface as `unparsed` and an out-of-range count,
|
|
275
|
+
// not as the column quietly switching axes and losing its comparison.
|
|
276
|
+
const numericByShare = nonNull > 0 && numericValues.length * 2 >= nonNull;
|
|
277
|
+
if (numericByShare || settings.suppliedEdges !== undefined) {
|
|
278
|
+
const edges = settings.suppliedEdges ?? quantileEdges(numericValues, settings.bins);
|
|
279
|
+
const sample = reservoirSample(numericValues, settings.sampleSize, settings.seed);
|
|
280
|
+
if (edges.length < 2) {
|
|
281
|
+
return {
|
|
282
|
+
kind: "numeric",
|
|
283
|
+
n: numericValues.length,
|
|
284
|
+
unparsed: nonNull - numericValues.length,
|
|
285
|
+
edges: [],
|
|
286
|
+
counts: [],
|
|
287
|
+
below: 0,
|
|
288
|
+
above: 0,
|
|
289
|
+
sample,
|
|
290
|
+
sampled: sample.length < numericValues.length,
|
|
291
|
+
unbinnable: numericValues.length === 0
|
|
292
|
+
? "holds no values that parse as numbers, so there is nothing to bin"
|
|
293
|
+
: `is constant at ${numericValues[0]}, so every quantile lands on the same edge and there is nothing to cut into bins`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const binned = statsKernel.binCounts(numericValues, edges);
|
|
297
|
+
return {
|
|
298
|
+
kind: "numeric",
|
|
299
|
+
n: numericValues.length,
|
|
300
|
+
unparsed: nonNull - numericValues.length,
|
|
301
|
+
edges,
|
|
302
|
+
counts: binned.counts,
|
|
303
|
+
below: binned.belowFirstEdge,
|
|
304
|
+
above: binned.aboveLastEdge,
|
|
305
|
+
sample,
|
|
306
|
+
sampled: sample.length < numericValues.length,
|
|
307
|
+
unbinnable: null,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const kept = ordered.slice(0, settings.maxCategories);
|
|
311
|
+
// A null prototype, not `{}`. These keys are CELL VALUES, and a cell holding
|
|
312
|
+
// the string "__proto__" assigns to Object.prototype's accessor on a plain
|
|
313
|
+
// object: the entry is silently dropped, `truncated` still says false and
|
|
314
|
+
// `otherCount` still says zero, so the capture claims a complete category
|
|
315
|
+
// list that is missing a category — and a value arriving for the first time
|
|
316
|
+
// today is never named as new. A null prototype makes it an ordinary key,
|
|
317
|
+
// and `JSON.stringify`/`JSON.parse` round-trip it intact.
|
|
318
|
+
const valueCounts = Object.create(null);
|
|
319
|
+
for (const { value, count } of kept)
|
|
320
|
+
valueCounts[value] = count;
|
|
321
|
+
let keptTotal = 0;
|
|
322
|
+
for (const { count } of kept)
|
|
323
|
+
keptTotal += count;
|
|
324
|
+
return {
|
|
325
|
+
kind: "categorical",
|
|
326
|
+
counted: nonNull,
|
|
327
|
+
distinct: ordered.length,
|
|
328
|
+
valueCounts,
|
|
329
|
+
truncated: ordered.length > kept.length,
|
|
330
|
+
otherCount: nonNull - keptTotal,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciling two exports, and matching records that do not share a key.
|
|
3
|
+
*
|
|
4
|
+
* "What changed between yesterday's file and today's" and "is this the same
|
|
5
|
+
* customer as that one" are the two questions that make a person open two
|
|
6
|
+
* spreadsheets side by side. Both are mechanical, and both are done badly by
|
|
7
|
+
* eye at any real size.
|
|
8
|
+
*/
|
|
9
|
+
export type Row = Readonly<Record<string, string>>;
|
|
10
|
+
export type TableDiff = {
|
|
11
|
+
readonly keyColumns: ReadonlyArray<string>;
|
|
12
|
+
readonly added: ReadonlyArray<Row>;
|
|
13
|
+
readonly removed: ReadonlyArray<Row>;
|
|
14
|
+
readonly changed: ReadonlyArray<{
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly changes: ReadonlyArray<{
|
|
17
|
+
readonly column: string;
|
|
18
|
+
readonly from: string;
|
|
19
|
+
readonly to: string;
|
|
20
|
+
}>;
|
|
21
|
+
}>;
|
|
22
|
+
readonly unchanged: number;
|
|
23
|
+
/** Keys appearing more than once on either side — the diff is unreliable. */
|
|
24
|
+
readonly duplicateKeys: ReadonlyArray<{
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly side: "before" | "after";
|
|
27
|
+
readonly count: number;
|
|
28
|
+
}>;
|
|
29
|
+
readonly columnsOnlyInBefore: ReadonlyArray<string>;
|
|
30
|
+
readonly columnsOnlyInAfter: ReadonlyArray<string>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile two tables by key.
|
|
34
|
+
*
|
|
35
|
+
* Duplicate keys are reported rather than silently resolved. With a
|
|
36
|
+
* duplicated key there is no fact of the matter about which row changed into
|
|
37
|
+
* which, and picking one produces a diff that looks authoritative and is
|
|
38
|
+
* arbitrary.
|
|
39
|
+
*/
|
|
40
|
+
export declare function diffTables(before: ReadonlyArray<Row>, after: ReadonlyArray<Row>, keyColumns: ReadonlyArray<string>, ignoreColumns?: ReadonlyArray<string>): TableDiff;
|
|
41
|
+
/** Normalized forms of the fields every join and dedupe depends on. */
|
|
42
|
+
export type NormalizedContact = {
|
|
43
|
+
readonly email: string;
|
|
44
|
+
readonly emailDomain: string;
|
|
45
|
+
readonly phone: string;
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly nameKey: string;
|
|
48
|
+
readonly company: string;
|
|
49
|
+
readonly notes: ReadonlyArray<string>;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Canonicalize a contact's identifying fields.
|
|
53
|
+
*
|
|
54
|
+
* Gmail's dots and `+tags` are folded ONLY for gmail-family domains, because
|
|
55
|
+
* they are a Gmail feature and not a rule of email: treating `a.b@other.com`
|
|
56
|
+
* as `ab@other.com` merges two different people. The original is never
|
|
57
|
+
* discarded — this returns a comparison key beside it, and any fold that
|
|
58
|
+
* happened is named in `notes`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function normalizeContact(input: {
|
|
61
|
+
email?: string;
|
|
62
|
+
phone?: string;
|
|
63
|
+
name?: string;
|
|
64
|
+
company?: string;
|
|
65
|
+
defaultCountryCode?: string;
|
|
66
|
+
}): NormalizedContact;
|
|
67
|
+
/** Levenshtein distance, bounded so a long pair cannot become quadratic cost. */
|
|
68
|
+
export declare function editDistance(a: string, b: string, max?: number): number;
|
|
69
|
+
/** 1 for identical, 0 for nothing in common. */
|
|
70
|
+
export declare function similarity(a: string, b: string): number;
|
|
71
|
+
export declare const FIELD_NORMALIZERS: readonly ["none", "name", "email", "phone", "company"];
|
|
72
|
+
export type FieldNormalizer = (typeof FIELD_NORMALIZERS)[number];
|
|
73
|
+
export type LinkageRule = {
|
|
74
|
+
readonly field: string;
|
|
75
|
+
/** `exact` contributes its full weight or nothing; `fuzzy` scales. */
|
|
76
|
+
readonly compare: "exact" | "fuzzy";
|
|
77
|
+
readonly weight: number;
|
|
78
|
+
/** `fuzzy` only: similarity below this contributes nothing. */
|
|
79
|
+
readonly threshold?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Canonicalize before comparing, with the same rules `normalizeContact`
|
|
82
|
+
* uses. Without this a rule compares raw columns, and "Dr Jane Smith" and
|
|
83
|
+
* "Smith Jane" score far apart on an edit distance while obviously being
|
|
84
|
+
* the same person — which is the case the tool exists for.
|
|
85
|
+
*/
|
|
86
|
+
readonly normalize?: FieldNormalizer;
|
|
87
|
+
};
|
|
88
|
+
/** Apply one field normalizer, reusing the contact rules. */
|
|
89
|
+
export declare function normalizeField(value: string, how: FieldNormalizer | undefined): string;
|
|
90
|
+
export type Match = {
|
|
91
|
+
readonly leftIndex: number;
|
|
92
|
+
readonly rightIndex: number;
|
|
93
|
+
readonly score: number;
|
|
94
|
+
readonly evidence: ReadonlyArray<{
|
|
95
|
+
readonly field: string;
|
|
96
|
+
readonly similarity: number;
|
|
97
|
+
readonly weight: number;
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
100
|
+
export type LinkageResult = {
|
|
101
|
+
readonly matched: ReadonlyArray<Match>;
|
|
102
|
+
readonly unmatchedLeft: ReadonlyArray<number>;
|
|
103
|
+
readonly unmatchedRight: ReadonlyArray<number>;
|
|
104
|
+
/** Pairs above the review floor but below the accept floor. */
|
|
105
|
+
readonly review: ReadonlyArray<Match>;
|
|
106
|
+
readonly comparisons: number;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Match records between two lists that share no key.
|
|
110
|
+
*
|
|
111
|
+
* Every accepted pair carries its evidence — which field matched and how
|
|
112
|
+
* well — because "these are the same customer" is a claim somebody will have
|
|
113
|
+
* to defend, and a bare score cannot be defended. Pairs between the review
|
|
114
|
+
* floor and the accept floor are returned separately rather than guessed at.
|
|
115
|
+
*
|
|
116
|
+
* Matching is greedy over the scored pairs, and one record is used once: a
|
|
117
|
+
* record matched to two others is not a match, it is a question.
|
|
118
|
+
*/
|
|
119
|
+
export declare function linkRecords(left: ReadonlyArray<Row>, right: ReadonlyArray<Row>, rules: ReadonlyArray<LinkageRule>, options?: {
|
|
120
|
+
accept?: number;
|
|
121
|
+
review?: number;
|
|
122
|
+
maxComparisons?: number;
|
|
123
|
+
}): LinkageResult;
|