@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-table — making a messy export usable.
|
|
3
|
+
*
|
|
4
|
+
* The first questions about any data file are always the same: how many
|
|
5
|
+
* rows, which columns, what types, how many nulls, is the key unique, what
|
|
6
|
+
* changed since yesterday, is this the same customer as that one. The usual
|
|
7
|
+
* way to answer them is to read the first fifty rows into a context window
|
|
8
|
+
* and guess — and fifty rows do not tell you that column nine is empty in
|
|
9
|
+
* the last thousand, or that the id repeats.
|
|
10
|
+
*
|
|
11
|
+
* Files are read here rather than passed in. A table worth these tools is
|
|
12
|
+
* one too big to paste, which is the whole saving.
|
|
13
|
+
*
|
|
14
|
+
* CSV parsing comes from `@crewhaus/tool-data`: two readers in one
|
|
15
|
+
* repository would disagree about quoting and embedded newlines on the same
|
|
16
|
+
* file, in different tools, which is the worst way to disagree.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, statSync } from "node:fs";
|
|
19
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
20
|
+
import { parseCsv } from "@crewhaus/tool-data";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { DriftError, compareDrift } from "./lib/drift";
|
|
23
|
+
import { profileTable } from "./lib/profile";
|
|
24
|
+
import { FIELD_NORMALIZERS, diffTables, linkRecords, normalizeContact, } from "./lib/reconcile";
|
|
25
|
+
import { parseFixedWidth, shardRows, toLong, toWide } from "./lib/reshape";
|
|
26
|
+
import { resolveSafe } from "./paths";
|
|
27
|
+
const json = (value) => JSON.stringify(value);
|
|
28
|
+
const LIMITS = {
|
|
29
|
+
fileBytes: 512 * 1024 * 1024,
|
|
30
|
+
rows: 2_000_000,
|
|
31
|
+
columns: 4_096,
|
|
32
|
+
records: 100_000,
|
|
33
|
+
/** Past this, returning the shard bodies means returning the file again. */
|
|
34
|
+
returnedBytes: 8 * 1024 * 1024,
|
|
35
|
+
/** A stored profile is a summary; anything this big is the data by mistake. */
|
|
36
|
+
profileBytes: 64 * 1024 * 1024,
|
|
37
|
+
};
|
|
38
|
+
/** Read a delimited file into a header and rows of strings. */
|
|
39
|
+
function readTable(tool, file, options) {
|
|
40
|
+
const at = resolveSafe(tool, file);
|
|
41
|
+
const bytes = statSync(at.real).size;
|
|
42
|
+
if (bytes > LIMITS.fileBytes) {
|
|
43
|
+
throw new Error(`${at.rel} is ${bytes} bytes, over the ${LIMITS.fileBytes}-byte limit`);
|
|
44
|
+
}
|
|
45
|
+
const text = readFileSync(at.real, "utf-8");
|
|
46
|
+
// `parseCsv` returns every row including the header; splitting it here
|
|
47
|
+
// keeps one reader for the whole repository rather than two that disagree
|
|
48
|
+
// about quoting on the same file.
|
|
49
|
+
const parsed = parseCsv(text, {
|
|
50
|
+
delimiter: options.delimiter ?? (at.rel.endsWith(".tsv") ? "\t" : ","),
|
|
51
|
+
quote: '"',
|
|
52
|
+
skipEmptyLines: true,
|
|
53
|
+
trim: false,
|
|
54
|
+
maxRows: LIMITS.rows,
|
|
55
|
+
});
|
|
56
|
+
const headers = options.noHeader === true
|
|
57
|
+
? Array.from({ length: parsed.rows[0]?.length ?? 0 }, (_, i) => `column${i + 1}`)
|
|
58
|
+
: (parsed.rows[0] ?? []);
|
|
59
|
+
const rows = options.noHeader === true ? parsed.rows : parsed.rows.slice(1);
|
|
60
|
+
if (headers.length > LIMITS.columns) {
|
|
61
|
+
throw new Error(`${at.rel} has ${headers.length} columns, over the ${LIMITS.columns} limit`);
|
|
62
|
+
}
|
|
63
|
+
// Every tool in this package answers a question about the WHOLE file — the
|
|
64
|
+
// profile, the diff, the drift verdict. `parseCsv` stops at `maxRows` and
|
|
65
|
+
// reports it; ignoring that flag would answer about the first two million
|
|
66
|
+
// rows while claiming to answer about the file, and on the date-sorted
|
|
67
|
+
// export that is most of them the prefix is a different month's data.
|
|
68
|
+
if (parsed.truncated) {
|
|
69
|
+
throw new Error(`${at.rel} has more than ${LIMITS.rows} rows, so it was only read that far. Every one of these tools answers about the whole file, and an answer about its first ${LIMITS.rows} rows is an answer about different data — split the file and compare the parts`);
|
|
70
|
+
}
|
|
71
|
+
return { headers, rows, rel: at.rel, bytes };
|
|
72
|
+
}
|
|
73
|
+
const asRecords = (headers, rows) => rows.map((row) => Object.fromEntries(headers.map((h, i) => [h, row[i] ?? ""])));
|
|
74
|
+
const sourceFields = {
|
|
75
|
+
file: z.string().min(1).describe("workspace-relative path to a CSV or TSV"),
|
|
76
|
+
delimiter: z.string().length(1).optional().describe("inferred from the extension when omitted"),
|
|
77
|
+
noHeader: z.boolean().optional().describe("the first row is data, not a header"),
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* What a drift capture records. Shared by the tool that writes one
|
|
81
|
+
* (`TableProfile`) and the tool that can be asked to take one on the fly
|
|
82
|
+
* (`DataDriftCheck` with a reference FILE), so the two cannot offer different
|
|
83
|
+
* knobs for the same capture.
|
|
84
|
+
*/
|
|
85
|
+
const driftCaptureSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
bins: z
|
|
88
|
+
.number()
|
|
89
|
+
.int()
|
|
90
|
+
.min(2)
|
|
91
|
+
.max(100)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("quantile bins over the reference; default 10, which the PSI bands assume"),
|
|
94
|
+
maxCategories: z
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.min(2)
|
|
98
|
+
.max(10_000)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("cap on a column's stored category list; default 128"),
|
|
101
|
+
sampleSize: z
|
|
102
|
+
.number()
|
|
103
|
+
.int()
|
|
104
|
+
.min(0)
|
|
105
|
+
.max(50_000)
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("values kept per numeric column for the rank test; default 500"),
|
|
108
|
+
seed: z
|
|
109
|
+
.number()
|
|
110
|
+
.int()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("seeds the value sample so the same file gives the same profile; default 1337"),
|
|
113
|
+
})
|
|
114
|
+
.strict();
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
export const tableProfile = buildTool({
|
|
117
|
+
name: "TableProfile",
|
|
118
|
+
description: "Answer what is actually in a data file in one call: row and column counts, each column's type, null fraction, distinct count, range and commonest values, plus candidate keys, duplicate rows and columns that are empty or constant. Use it before writing any query against an unfamiliar export. Reading the first fifty rows instead cannot tell you that a column is empty in the last thousand, or that the id you were about to join on repeats.",
|
|
119
|
+
inputSchema: z
|
|
120
|
+
.object({
|
|
121
|
+
...sourceFields,
|
|
122
|
+
nullTokens: z
|
|
123
|
+
.array(z.string())
|
|
124
|
+
.max(64)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('default ["NULL","null","NA","N/A","-"]'),
|
|
127
|
+
topValues: z.number().int().positive().max(100).optional().describe("default 5"),
|
|
128
|
+
columns: z.array(z.string()).max(LIMITS.columns).optional().describe("profile only these"),
|
|
129
|
+
driftProfile: driftCaptureSchema
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("also capture the bin edges, category counts and value sample DataDriftCheck needs; pass {} for the defaults"),
|
|
132
|
+
})
|
|
133
|
+
.strict(),
|
|
134
|
+
readOnly: true,
|
|
135
|
+
concurrencySafe: true,
|
|
136
|
+
execute: async (input) => {
|
|
137
|
+
const { headers, rows, rel, bytes } = readTable("TableProfile", input.file, input);
|
|
138
|
+
const wanted = input.columns;
|
|
139
|
+
const keep = wanted === undefined ? headers : headers.filter((h) => wanted.includes(h));
|
|
140
|
+
if (wanted !== undefined) {
|
|
141
|
+
const missing = wanted.filter((c) => !headers.includes(c));
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
return `these columns are not in ${rel}: ${missing.join(", ")}. It has: ${headers.join(", ")}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const indexes = keep.map((h) => headers.indexOf(h));
|
|
147
|
+
const narrowed = wanted === undefined ? rows : rows.map((r) => indexes.map((i) => r[i] ?? ""));
|
|
148
|
+
const profile = profileTable(keep, narrowed, {
|
|
149
|
+
nullTokens: input.nullTokens,
|
|
150
|
+
topValues: input.topValues,
|
|
151
|
+
drift: input.driftProfile,
|
|
152
|
+
});
|
|
153
|
+
return json({ file: rel, bytes, ...profile });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
/**
|
|
157
|
+
* Read a stored profile back, or say what is wrong with it.
|
|
158
|
+
*
|
|
159
|
+
* A baseline is a file somebody wrote weeks ago and may have edited since, so
|
|
160
|
+
* this checks the parts the comparison actually indexes into rather than
|
|
161
|
+
* trusting the shape. Returning a string for a bad one — instead of throwing
|
|
162
|
+
* or, worse, letting an undefined through — is what turns "my drift check
|
|
163
|
+
* crashed" into "your baseline has no bin edges, re-run it like this".
|
|
164
|
+
*/
|
|
165
|
+
function loadReferenceProfile(file) {
|
|
166
|
+
const at = resolveSafe("DataDriftCheck", file);
|
|
167
|
+
const bytes = statSync(at.real).size;
|
|
168
|
+
if (bytes > LIMITS.profileBytes) {
|
|
169
|
+
return `${at.rel} is ${bytes} bytes, far larger than any profile — this is probably not a stored TableProfile`;
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(readFileSync(at.real, "utf-8"));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return `${at.rel} is not JSON: ${err.message}. referenceProfile takes a stored TableProfile result; for raw data use referenceFile`;
|
|
177
|
+
}
|
|
178
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
179
|
+
return `${at.rel} does not hold a TableProfile object`;
|
|
180
|
+
}
|
|
181
|
+
const candidate = parsed;
|
|
182
|
+
if (typeof candidate["rows"] !== "number" || !Array.isArray(candidate["columns"])) {
|
|
183
|
+
return `${at.rel} has no "rows" count and "columns" array, so it is not a TableProfile result`;
|
|
184
|
+
}
|
|
185
|
+
const capture = candidate["driftCapture"];
|
|
186
|
+
if (typeof capture !== "object" || capture === null) {
|
|
187
|
+
return `${at.rel} is a TableProfile result but carries no drift capture, so it has no bin edges. A PSI computed against edges derived fresh from today's data compares each side to its own quantiles and reads near zero however far the data moved — so this is refused rather than answered. Re-profile the baseline with driftProfile, or pass the baseline data as referenceFile`;
|
|
188
|
+
}
|
|
189
|
+
const header = capture;
|
|
190
|
+
if (header["version"] !== 1) {
|
|
191
|
+
return `${at.rel} carries a drift capture of version ${String(header["version"])}; this build reads version 1`;
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(header["nullTokens"]) || typeof header["sampleSize"] !== "number") {
|
|
194
|
+
return `${at.rel} has a drift capture with no nullTokens list or sampleSize; it has been edited by hand and cannot be used as a baseline`;
|
|
195
|
+
}
|
|
196
|
+
for (const column of candidate["columns"]) {
|
|
197
|
+
const bad = columnProblem(column);
|
|
198
|
+
if (bad !== null)
|
|
199
|
+
return `${at.rel} has an unusable column: ${bad}`;
|
|
200
|
+
}
|
|
201
|
+
return { profile: parsed, rel: at.rel };
|
|
202
|
+
}
|
|
203
|
+
/** `null` when the column is shaped like a profiled column, else what is wrong. */
|
|
204
|
+
function columnProblem(value) {
|
|
205
|
+
if (typeof value !== "object" || value === null)
|
|
206
|
+
return "a column entry is not an object";
|
|
207
|
+
const column = value;
|
|
208
|
+
if (typeof column["name"] !== "string")
|
|
209
|
+
return "a column has no name";
|
|
210
|
+
const label = JSON.stringify(column["name"]);
|
|
211
|
+
if (typeof column["nullFraction"] !== "number" || typeof column["distinct"] !== "number") {
|
|
212
|
+
return `${label} has no nullFraction or distinct count`;
|
|
213
|
+
}
|
|
214
|
+
const drift = column["drift"];
|
|
215
|
+
if (drift === undefined)
|
|
216
|
+
return null;
|
|
217
|
+
if (typeof drift !== "object" || drift === null)
|
|
218
|
+
return `${label} has a non-object drift capture`;
|
|
219
|
+
const capture = drift;
|
|
220
|
+
if (capture["kind"] === "numeric") {
|
|
221
|
+
// The edges are the one field the whole comparison rests on, so they are
|
|
222
|
+
// checked for the property `binCounts` will assert anyway — strictly
|
|
223
|
+
// increasing — here, where the message can name the column.
|
|
224
|
+
const edges = capture["edges"];
|
|
225
|
+
if (!Array.isArray(edges))
|
|
226
|
+
return `${label} has a numeric capture with no edges array`;
|
|
227
|
+
for (let i = 0; i < edges.length; i++) {
|
|
228
|
+
if (typeof edges[i] !== "number" || !Number.isFinite(edges[i])) {
|
|
229
|
+
return `${label} has a non-finite bin edge at position ${i}`;
|
|
230
|
+
}
|
|
231
|
+
if (i > 0 && edges[i] <= edges[i - 1]) {
|
|
232
|
+
return `${label} has bin edges that are not strictly increasing at position ${i}`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (edges.length > 0 && !Array.isArray(capture["counts"])) {
|
|
236
|
+
return `${label} has bin edges but no counts`;
|
|
237
|
+
}
|
|
238
|
+
if (!Array.isArray(capture["sample"]))
|
|
239
|
+
return `${label} has a numeric capture with no sample`;
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (capture["kind"] === "categorical") {
|
|
243
|
+
const counts = capture["valueCounts"];
|
|
244
|
+
if (typeof counts !== "object" || counts === null || Array.isArray(counts)) {
|
|
245
|
+
return `${label} has a categorical capture with no valueCounts map`;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return `${label} has a drift capture of unknown kind ${JSON.stringify(capture["kind"])}`;
|
|
250
|
+
}
|
|
251
|
+
/** The reference's bin edges, keyed by column — what today's data must be binned against. */
|
|
252
|
+
function edgesFromReference(profile) {
|
|
253
|
+
// Null prototype: the keys are COLUMN NAMES out of a CSV header. On a plain
|
|
254
|
+
// object `edges["__proto__"] = [...]` sets the map's prototype instead of an
|
|
255
|
+
// entry, and every other column name then resolves through that array.
|
|
256
|
+
const edges = Object.create(null);
|
|
257
|
+
for (const column of profile.columns) {
|
|
258
|
+
const drift = column.drift;
|
|
259
|
+
if (drift?.kind === "numeric" && drift.edges.length >= 2)
|
|
260
|
+
edges[column.name] = drift.edges;
|
|
261
|
+
}
|
|
262
|
+
return edges;
|
|
263
|
+
}
|
|
264
|
+
/** Narrow a profile to the named columns, for the `columns` input. */
|
|
265
|
+
function narrowProfile(profile, wanted) {
|
|
266
|
+
return { ...profile, columns: profile.columns.filter((c) => wanted.includes(c.name)) };
|
|
267
|
+
}
|
|
268
|
+
export const dataDriftCheck = buildTool({
|
|
269
|
+
name: "DataDriftCheck",
|
|
270
|
+
description: "Compare today's data file against a stored TableProfile baseline and report what moved: columns added, removed or retyped, distribution shift per column (PSI plus a Mann-Whitney rank test for numbers, a chi-square for labels), null-rate and cardinality jumps, categories that are new today, and the row-count ratio. Use it as the gate between an upstream feed and everything downstream of it. Today's numbers are binned against the BASELINE's stored bin edges, never freshly derived ones — a PSI over two independently binned samples reads near zero however far the data moved — and a column whose two sides were not binned identically comes back with the reason instead of a number. The epsilon that stands in for an empty bin is a required input, because at 1e-3, 1e-2 and 0.05 the same data reads significant, moderate and stable.",
|
|
271
|
+
inputSchema: z
|
|
272
|
+
.object({
|
|
273
|
+
file: z.string().min(1).describe("today's data: a workspace-relative CSV or TSV"),
|
|
274
|
+
referenceProfile: z
|
|
275
|
+
.string()
|
|
276
|
+
.min(1)
|
|
277
|
+
.optional()
|
|
278
|
+
.describe("a stored TableProfile result, taken with driftProfile"),
|
|
279
|
+
referenceFile: z
|
|
280
|
+
.string()
|
|
281
|
+
.min(1)
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("baseline data to profile now, instead of a stored profile"),
|
|
284
|
+
epsilon: z
|
|
285
|
+
.number()
|
|
286
|
+
.gt(0)
|
|
287
|
+
.lt(1)
|
|
288
|
+
.describe("REQUIRED share substituted for a bin empty on one side; it decides the verdict, so it is never defaulted. 1e-3 is strict, 1e-2 usual"),
|
|
289
|
+
delimiter: z.string().length(1).optional(),
|
|
290
|
+
noHeader: z.boolean().optional(),
|
|
291
|
+
nullTokens: z
|
|
292
|
+
.array(z.string())
|
|
293
|
+
.max(64)
|
|
294
|
+
.optional()
|
|
295
|
+
.describe("referenceFile only; a stored profile dictates its own, so both sides match"),
|
|
296
|
+
capture: driftCaptureSchema
|
|
297
|
+
.optional()
|
|
298
|
+
.describe("referenceFile only; a stored profile dictates its own capture settings"),
|
|
299
|
+
columns: z
|
|
300
|
+
.array(z.string())
|
|
301
|
+
.max(LIMITS.columns)
|
|
302
|
+
.optional()
|
|
303
|
+
.describe("compare only these columns; the schema report then covers only them too"),
|
|
304
|
+
includeBins: z
|
|
305
|
+
.boolean()
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("return every PSI bin rather than the three that moved the index most"),
|
|
308
|
+
failOn: z
|
|
309
|
+
.object({
|
|
310
|
+
psi: z.number().positive().optional().describe("0.25 is the usual significant line"),
|
|
311
|
+
pValue: z.number().gt(0).lt(1).optional().describe("only a VALID p can trip this"),
|
|
312
|
+
nullRateIncrease: z.number().gt(0).max(1).optional().describe("absolute, e.g. 0.05"),
|
|
313
|
+
cardinalityRatio: z.number().min(1).optional().describe("fails outside [1/r, r]"),
|
|
314
|
+
rowCountRatio: z.number().min(1).optional().describe("fails outside [1/r, r]"),
|
|
315
|
+
newCategories: z.number().int().nonnegative().optional(),
|
|
316
|
+
schemaDrift: z.boolean().optional(),
|
|
317
|
+
})
|
|
318
|
+
.strict()
|
|
319
|
+
.optional()
|
|
320
|
+
.describe("without this nothing is gated and gate.ok says only that nothing was checked"),
|
|
321
|
+
})
|
|
322
|
+
.strict()
|
|
323
|
+
.refine((v) => (v.referenceProfile === undefined) !== (v.referenceFile === undefined), {
|
|
324
|
+
message: "give exactly one of referenceProfile (a stored TableProfile) or referenceFile (baseline data to profile now)",
|
|
325
|
+
}),
|
|
326
|
+
readOnly: true,
|
|
327
|
+
concurrencySafe: true,
|
|
328
|
+
execute: async (input) => {
|
|
329
|
+
let reference;
|
|
330
|
+
let referenceRel;
|
|
331
|
+
if (input.referenceProfile !== undefined) {
|
|
332
|
+
const loaded = loadReferenceProfile(input.referenceProfile);
|
|
333
|
+
if (typeof loaded === "string")
|
|
334
|
+
return loaded;
|
|
335
|
+
reference = loaded.profile;
|
|
336
|
+
referenceRel = loaded.rel;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
const source = readTable("DataDriftCheck", input.referenceFile, input);
|
|
340
|
+
referenceRel = source.rel;
|
|
341
|
+
reference = profileTable(source.headers, source.rows, {
|
|
342
|
+
nullTokens: input.nullTokens,
|
|
343
|
+
drift: input.capture ?? {},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const capture = reference.driftCapture;
|
|
347
|
+
if (capture === undefined) {
|
|
348
|
+
return "the reference carries no drift capture, so it has no bin edges to compare against";
|
|
349
|
+
}
|
|
350
|
+
if (input.referenceProfile !== undefined && input.nullTokens !== undefined) {
|
|
351
|
+
// Two null-token lists make the null-rate comparison a comparison of the
|
|
352
|
+
// lists. The stored profile already records the list it was taken under,
|
|
353
|
+
// so the current side simply reuses it and there is nothing to override.
|
|
354
|
+
return `nullTokens applies only to referenceFile: the stored profile was taken with [${capture.nullTokens.join(", ")}] and today's data is read with that same list, so the null rates compare`;
|
|
355
|
+
}
|
|
356
|
+
if (input.referenceProfile !== undefined && input.capture !== undefined) {
|
|
357
|
+
return "capture applies only to referenceFile: a stored profile already fixes its bins, category cap, sample size and seed, and today's data is captured with those";
|
|
358
|
+
}
|
|
359
|
+
const today = readTable("DataDriftCheck", input.file, input);
|
|
360
|
+
let current = profileTable(today.headers, today.rows, {
|
|
361
|
+
nullTokens: capture.nullTokens,
|
|
362
|
+
drift: {
|
|
363
|
+
bins: capture.binsRequested,
|
|
364
|
+
maxCategories: capture.maxCategories,
|
|
365
|
+
sampleSize: capture.sampleSize,
|
|
366
|
+
seed: capture.seed,
|
|
367
|
+
// The one line this tool exists for.
|
|
368
|
+
edges: edgesFromReference(reference),
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const wanted = input.columns;
|
|
372
|
+
const extras = [];
|
|
373
|
+
if (wanted !== undefined) {
|
|
374
|
+
const known = new Set([
|
|
375
|
+
...reference.columns.map((c) => c.name),
|
|
376
|
+
...current.columns.map((c) => c.name),
|
|
377
|
+
]);
|
|
378
|
+
const missing = wanted.filter((c) => !known.has(c));
|
|
379
|
+
if (missing.length > 0) {
|
|
380
|
+
return `these columns are in neither the reference nor ${today.rel}: ${missing.join(", ")}`;
|
|
381
|
+
}
|
|
382
|
+
reference = narrowProfile(reference, wanted);
|
|
383
|
+
current = narrowProfile(current, wanted);
|
|
384
|
+
extras.push(`only ${wanted.length} column(s) were compared, so the schema report covers only those`);
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const report = compareDrift(reference, current, {
|
|
388
|
+
epsilon: input.epsilon,
|
|
389
|
+
thresholds: input.failOn,
|
|
390
|
+
includeBins: input.includeBins,
|
|
391
|
+
});
|
|
392
|
+
return json({
|
|
393
|
+
reference: referenceRel,
|
|
394
|
+
file: today.rel,
|
|
395
|
+
...report,
|
|
396
|
+
notes: [...report.notes, ...extras],
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
// A refusal from the comparison or the statistics kernel is an answer,
|
|
401
|
+
// not a crash: the caller gets the reason instead of a stack. Anything
|
|
402
|
+
// else is a bug here and is re-thrown.
|
|
403
|
+
//
|
|
404
|
+
// The kernel's `StatsError` is matched by NAME on purpose: it is not on
|
|
405
|
+
// `@crewhaus/tool-math`'s public surface, and reaching past the package
|
|
406
|
+
// entrypoint to import the class would be a second import path into
|
|
407
|
+
// another package's internals for the sake of one `instanceof`.
|
|
408
|
+
if (err instanceof DriftError)
|
|
409
|
+
return err.message;
|
|
410
|
+
if (err instanceof Error && err.name === "StatsError")
|
|
411
|
+
return err.message;
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
export const tableDiff = buildTool({
|
|
417
|
+
name: "TableDiff",
|
|
418
|
+
description: "Reconcile two exports by key and report what was added, removed and changed, cell by cell. Use it for yesterday-versus-today files, an API dump against a database dump, or a before-and-after migration check. A key that appears twice on either side is REPORTED rather than resolved: with a duplicate key there is no fact about which row became which, and picking one produces a diff that looks authoritative and is arbitrary. Columns present on only one side are named too.",
|
|
419
|
+
inputSchema: z
|
|
420
|
+
.object({
|
|
421
|
+
before: z.string().min(1),
|
|
422
|
+
after: z.string().min(1),
|
|
423
|
+
key: z.array(z.string().min(1)).min(1).max(16).describe("the column(s) identifying a row"),
|
|
424
|
+
ignore: z.array(z.string()).max(LIMITS.columns).optional().describe("columns not to compare"),
|
|
425
|
+
delimiter: z.string().length(1).optional(),
|
|
426
|
+
limit: z
|
|
427
|
+
.number()
|
|
428
|
+
.int()
|
|
429
|
+
.positive()
|
|
430
|
+
.max(100_000)
|
|
431
|
+
.optional()
|
|
432
|
+
.describe("cap each list; default 200"),
|
|
433
|
+
})
|
|
434
|
+
.strict(),
|
|
435
|
+
readOnly: true,
|
|
436
|
+
concurrencySafe: true,
|
|
437
|
+
execute: async (input) => {
|
|
438
|
+
const a = readTable("TableDiff", input.before, input);
|
|
439
|
+
const b = readTable("TableDiff", input.after, input);
|
|
440
|
+
const diff = diffTables(asRecords(a.headers, a.rows), asRecords(b.headers, b.rows), input.key, input.ignore ?? []);
|
|
441
|
+
const limit = input.limit ?? 200;
|
|
442
|
+
return json({
|
|
443
|
+
before: a.rel,
|
|
444
|
+
after: b.rel,
|
|
445
|
+
keyColumns: diff.keyColumns,
|
|
446
|
+
counts: {
|
|
447
|
+
added: diff.added.length,
|
|
448
|
+
removed: diff.removed.length,
|
|
449
|
+
changed: diff.changed.length,
|
|
450
|
+
unchanged: diff.unchanged,
|
|
451
|
+
},
|
|
452
|
+
duplicateKeys: diff.duplicateKeys.slice(0, limit),
|
|
453
|
+
columnsOnlyInBefore: diff.columnsOnlyInBefore,
|
|
454
|
+
columnsOnlyInAfter: diff.columnsOnlyInAfter,
|
|
455
|
+
added: diff.added.slice(0, limit),
|
|
456
|
+
removed: diff.removed.slice(0, limit),
|
|
457
|
+
changed: diff.changed.slice(0, limit),
|
|
458
|
+
truncated: diff.added.length > limit || diff.removed.length > limit || diff.changed.length > limit,
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
export const recordLinkage = buildTool({
|
|
463
|
+
name: "RecordLinkage",
|
|
464
|
+
description: "Match records between two lists that share no key, using weighted exact and fuzzy field comparisons. Use it to resolve the same customer, vendor or product across two systems. Every accepted pair carries its EVIDENCE — which field matched and how closely — because 'these are the same person' is a claim somebody will have to defend and a bare score cannot be defended. Pairs above the review floor but below the accept floor come back separately rather than being guessed at, and each record is used once: a record matching two others is a question, not a match.",
|
|
465
|
+
inputSchema: z
|
|
466
|
+
.object({
|
|
467
|
+
left: z.string().min(1).describe("workspace-relative path"),
|
|
468
|
+
right: z.string().min(1),
|
|
469
|
+
rules: z
|
|
470
|
+
.array(z.object({
|
|
471
|
+
field: z.string().min(1),
|
|
472
|
+
compare: z.enum(["exact", "fuzzy"]),
|
|
473
|
+
weight: z.number().positive(),
|
|
474
|
+
threshold: z.number().min(0).max(1).optional().describe("fuzzy only; default 0.8"),
|
|
475
|
+
normalize: z
|
|
476
|
+
.enum(FIELD_NORMALIZERS)
|
|
477
|
+
.optional()
|
|
478
|
+
.describe("canonicalize first, with ContactNormalize's rules"),
|
|
479
|
+
}))
|
|
480
|
+
.min(1)
|
|
481
|
+
.max(32),
|
|
482
|
+
accept: z.number().min(0).max(1).optional().describe("default 0.85"),
|
|
483
|
+
review: z.number().min(0).max(1).optional().describe("default 0.6"),
|
|
484
|
+
delimiter: z.string().length(1).optional(),
|
|
485
|
+
limit: z.number().int().positive().max(100_000).optional().describe("default 200"),
|
|
486
|
+
})
|
|
487
|
+
.strict(),
|
|
488
|
+
readOnly: true,
|
|
489
|
+
concurrencySafe: true,
|
|
490
|
+
execute: async (input) => {
|
|
491
|
+
const a = readTable("RecordLinkage", input.left, input);
|
|
492
|
+
const b = readTable("RecordLinkage", input.right, input);
|
|
493
|
+
const leftRecords = asRecords(a.headers, a.rows);
|
|
494
|
+
const rightRecords = asRecords(b.headers, b.rows);
|
|
495
|
+
for (const rule of input.rules) {
|
|
496
|
+
if (!a.headers.includes(rule.field) || !b.headers.includes(rule.field)) {
|
|
497
|
+
return `the rule field "${rule.field}" must be a column in both files; left has ${a.headers.join(", ")} and right has ${b.headers.join(", ")}`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const result = linkRecords(leftRecords, rightRecords, input.rules, {
|
|
501
|
+
accept: input.accept,
|
|
502
|
+
review: input.review,
|
|
503
|
+
});
|
|
504
|
+
const limit = input.limit ?? 200;
|
|
505
|
+
return json({
|
|
506
|
+
left: a.rel,
|
|
507
|
+
right: b.rel,
|
|
508
|
+
comparisons: result.comparisons,
|
|
509
|
+
counts: {
|
|
510
|
+
matched: result.matched.length,
|
|
511
|
+
review: result.review.length,
|
|
512
|
+
unmatchedLeft: result.unmatchedLeft.length,
|
|
513
|
+
unmatchedRight: result.unmatchedRight.length,
|
|
514
|
+
},
|
|
515
|
+
matched: result.matched.slice(0, limit),
|
|
516
|
+
review: result.review.slice(0, limit),
|
|
517
|
+
unmatchedLeft: result.unmatchedLeft.slice(0, limit),
|
|
518
|
+
unmatchedRight: result.unmatchedRight.slice(0, limit),
|
|
519
|
+
});
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
export const contactNormalize = buildTool({
|
|
523
|
+
name: "ContactNormalize",
|
|
524
|
+
description: "Canonicalize the fields every join and dedupe depends on — email, phone, person name, company name — and return a comparison key beside the original. Use it before matching or deduplicating contacts. Gmail's dots and +tags are folded ONLY for Gmail-family domains, because they are a Gmail feature and not a rule of email: treating a.b@other.com as ab@other.com merges two different people. A phone with no country code is reported as national rather than being given one, and every fold that happened is named.",
|
|
525
|
+
inputSchema: z
|
|
526
|
+
.object({
|
|
527
|
+
contacts: z
|
|
528
|
+
.array(z.object({
|
|
529
|
+
email: z.string().optional(),
|
|
530
|
+
phone: z.string().optional(),
|
|
531
|
+
name: z.string().optional(),
|
|
532
|
+
company: z.string().optional(),
|
|
533
|
+
}))
|
|
534
|
+
.min(1)
|
|
535
|
+
.max(LIMITS.records),
|
|
536
|
+
defaultCountryCode: z
|
|
537
|
+
.string()
|
|
538
|
+
.max(4)
|
|
539
|
+
.optional()
|
|
540
|
+
.describe("assumed for numbers with no + prefix; without it they stay national"),
|
|
541
|
+
})
|
|
542
|
+
.strict(),
|
|
543
|
+
readOnly: true,
|
|
544
|
+
concurrencySafe: true,
|
|
545
|
+
execute: async (input) => json({
|
|
546
|
+
count: input.contacts.length,
|
|
547
|
+
contacts: input.contacts.map((contact) => normalizeContact({ ...contact, defaultCountryCode: input.defaultCountryCode })),
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
export const tableReshape = buildTool({
|
|
551
|
+
name: "TableReshape",
|
|
552
|
+
description: "Pivot a table between wide and long form — the transform SQL cannot express without already knowing the distinct values. Use it to turn a column-per-month export into rows, or a long event log into a matrix. Going long to wide, a repeated (identifier, variable) pair is a COLLISION and is reported: the shape does not say which value wins, and silently taking the last makes a pivot that looks complete and is wrong in a way nothing downstream can detect.",
|
|
553
|
+
inputSchema: z
|
|
554
|
+
.object({
|
|
555
|
+
...sourceFields,
|
|
556
|
+
direction: z.enum(["long", "wide"]),
|
|
557
|
+
idColumns: z.array(z.string().min(1)).min(1).max(64),
|
|
558
|
+
valueColumns: z
|
|
559
|
+
.array(z.string().min(1))
|
|
560
|
+
.max(LIMITS.columns)
|
|
561
|
+
.optional()
|
|
562
|
+
.describe("wide→long: which to melt"),
|
|
563
|
+
variableColumn: z.string().optional().describe("long→wide: the column holding names"),
|
|
564
|
+
valueColumn: z.string().optional().describe("long→wide: the column holding values"),
|
|
565
|
+
variableName: z
|
|
566
|
+
.string()
|
|
567
|
+
.optional()
|
|
568
|
+
.describe("wide→long: output name column; default 'variable'"),
|
|
569
|
+
valueName: z.string().optional().describe("wide→long: output value column; default 'value'"),
|
|
570
|
+
fill: z.string().optional().describe("long→wide: value for absent cells"),
|
|
571
|
+
keepEmpty: z.boolean().optional().describe("wide→long: keep empty cells as rows"),
|
|
572
|
+
limit: z
|
|
573
|
+
.number()
|
|
574
|
+
.int()
|
|
575
|
+
.positive()
|
|
576
|
+
.max(100_000)
|
|
577
|
+
.optional()
|
|
578
|
+
.describe("default 500 rows returned"),
|
|
579
|
+
})
|
|
580
|
+
.strict(),
|
|
581
|
+
readOnly: true,
|
|
582
|
+
concurrencySafe: true,
|
|
583
|
+
execute: async (input) => {
|
|
584
|
+
const { headers, rows, rel } = readTable("TableReshape", input.file, input);
|
|
585
|
+
const records = asRecords(headers, rows);
|
|
586
|
+
const limit = input.limit ?? 500;
|
|
587
|
+
if (input.direction === "long") {
|
|
588
|
+
const valueColumns = input.valueColumns ?? headers.filter((h) => !input.idColumns.includes(h));
|
|
589
|
+
const out = toLong(records, input.idColumns, valueColumns, {
|
|
590
|
+
variableName: input.variableName,
|
|
591
|
+
valueName: input.valueName,
|
|
592
|
+
keepEmpty: input.keepEmpty,
|
|
593
|
+
});
|
|
594
|
+
return json({
|
|
595
|
+
file: rel,
|
|
596
|
+
direction: "long",
|
|
597
|
+
melted: valueColumns,
|
|
598
|
+
rowCount: out.length,
|
|
599
|
+
rows: out.slice(0, limit),
|
|
600
|
+
truncated: out.length > limit,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
if (input.variableColumn === undefined || input.valueColumn === undefined) {
|
|
604
|
+
return "reshaping to wide needs variableColumn and valueColumn — which column holds the names, and which holds the values";
|
|
605
|
+
}
|
|
606
|
+
const result = toWide(records, input.idColumns, input.variableColumn, input.valueColumn, {
|
|
607
|
+
fill: input.fill,
|
|
608
|
+
});
|
|
609
|
+
return json({
|
|
610
|
+
file: rel,
|
|
611
|
+
direction: "wide",
|
|
612
|
+
columns: result.columns,
|
|
613
|
+
rowCount: result.rows.length,
|
|
614
|
+
collisions: result.collisions.slice(0, limit),
|
|
615
|
+
rows: result.rows.slice(0, limit),
|
|
616
|
+
truncated: result.rows.length > limit,
|
|
617
|
+
});
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
export const tableShard = buildTool({
|
|
621
|
+
name: "TableShard",
|
|
622
|
+
description: "Split a large delimited file into shards by row count or byte size, each carrying the header. Use it to get a file that is too big for one pass into pieces that are not. Every shard repeats the header, so each is independently readable — a split that put it only on the first piece produces one usable file and the rest needing it grafted back on, which is how a column ends up shifted. It reports the plan and the shard bodies; writing them is the caller's to do.",
|
|
623
|
+
inputSchema: z
|
|
624
|
+
.object({
|
|
625
|
+
...sourceFields,
|
|
626
|
+
maxRows: z.number().int().positive().max(LIMITS.rows).optional(),
|
|
627
|
+
maxBytes: z.number().int().positive().optional(),
|
|
628
|
+
planOnly: z.boolean().optional().describe("return the shard sizes without their contents"),
|
|
629
|
+
})
|
|
630
|
+
.strict()
|
|
631
|
+
.refine((v) => v.maxRows !== undefined || v.maxBytes !== undefined, {
|
|
632
|
+
message: "give maxRows or maxBytes, or every row lands in one shard",
|
|
633
|
+
}),
|
|
634
|
+
readOnly: true,
|
|
635
|
+
concurrencySafe: true,
|
|
636
|
+
execute: async (input) => {
|
|
637
|
+
const { headers, rows, rel } = readTable("TableShard", input.file, input);
|
|
638
|
+
const { shards, bodies } = shardRows(headers, rows, {
|
|
639
|
+
maxRows: input.maxRows,
|
|
640
|
+
maxBytes: input.maxBytes,
|
|
641
|
+
});
|
|
642
|
+
const totalBytes = shards.reduce((sum, shard) => sum + shard.bytes, 0);
|
|
643
|
+
// Returning the bodies means returning the whole file as JSON. Past a
|
|
644
|
+
// few megabytes that is not a result, it is the file again — so the plan
|
|
645
|
+
// comes back and the caller asks for the pieces it wants.
|
|
646
|
+
const tooLarge = !input.planOnly && totalBytes > LIMITS.returnedBytes;
|
|
647
|
+
return json({
|
|
648
|
+
file: rel,
|
|
649
|
+
totalRows: rows.length,
|
|
650
|
+
shardCount: shards.length,
|
|
651
|
+
totalBytes,
|
|
652
|
+
shards,
|
|
653
|
+
...(input.planOnly || tooLarge ? {} : { bodies }),
|
|
654
|
+
...(tooLarge
|
|
655
|
+
? {
|
|
656
|
+
note: `the shards come to ${totalBytes} bytes, over the ${LIMITS.returnedBytes}-byte return limit — this is the plan; re-run with planOnly for just this, or shard smaller`,
|
|
657
|
+
}
|
|
658
|
+
: {}),
|
|
659
|
+
});
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
export const fixedWidthParse = buildTool({
|
|
663
|
+
name: "FixedWidthParse",
|
|
664
|
+
description: "Parse a fixed-width or mainframe extract from a positional layout into records. Use it for the exports that arrive as columns of padded text with no delimiter at all. Positions are 1-BASED AND INCLUSIVE, as every layout document and copybook states them — converting in your head is how a field ends up one character off down the whole file. A line shorter than the layout needs is reported rather than padded, since padding turns a truncated record into one with empty trailing fields that read as real data.",
|
|
665
|
+
inputSchema: z
|
|
666
|
+
.object({
|
|
667
|
+
file: z.string().min(1),
|
|
668
|
+
fields: z
|
|
669
|
+
.array(z.object({
|
|
670
|
+
name: z.string().min(1),
|
|
671
|
+
start: z.number().int().positive().describe("1-based, inclusive"),
|
|
672
|
+
length: z.number().int().positive(),
|
|
673
|
+
trim: z.boolean().optional().describe("default true"),
|
|
674
|
+
}))
|
|
675
|
+
.min(1)
|
|
676
|
+
.max(LIMITS.columns),
|
|
677
|
+
skipLines: z.number().int().nonnegative().max(1_000).optional(),
|
|
678
|
+
limit: z.number().int().positive().max(100_000).optional().describe("default 500"),
|
|
679
|
+
})
|
|
680
|
+
.strict(),
|
|
681
|
+
readOnly: true,
|
|
682
|
+
concurrencySafe: true,
|
|
683
|
+
execute: async (input) => {
|
|
684
|
+
const at = resolveSafe("FixedWidthParse", input.file);
|
|
685
|
+
const bytes = statSync(at.real).size;
|
|
686
|
+
if (bytes > LIMITS.fileBytes) {
|
|
687
|
+
throw new Error(`${at.rel} is ${bytes} bytes, over the ${LIMITS.fileBytes}-byte limit`);
|
|
688
|
+
}
|
|
689
|
+
const result = parseFixedWidth(readFileSync(at.real, "utf-8"), input.fields, {
|
|
690
|
+
skipLines: input.skipLines,
|
|
691
|
+
});
|
|
692
|
+
const limit = input.limit ?? 500;
|
|
693
|
+
return json({
|
|
694
|
+
file: at.rel,
|
|
695
|
+
rowCount: result.rows.length,
|
|
696
|
+
shortLines: result.shortLines.slice(0, limit),
|
|
697
|
+
rows: result.rows.slice(0, limit),
|
|
698
|
+
truncated: result.rows.length > limit,
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
703
|
+
export const TABLE_TOOLS = Object.freeze([
|
|
704
|
+
contactNormalize,
|
|
705
|
+
dataDriftCheck,
|
|
706
|
+
fixedWidthParse,
|
|
707
|
+
recordLinkage,
|
|
708
|
+
tableDiff,
|
|
709
|
+
tableProfile,
|
|
710
|
+
tableReshape,
|
|
711
|
+
tableShard,
|
|
712
|
+
]);
|