@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,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;