@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 ADDED
@@ -0,0 +1,187 @@
1
+ # @crewhaus/tool-table
2
+
3
+ Making a messy export usable.
4
+
5
+ | Tool | Answers |
6
+ |---|---|
7
+ | `TableProfile` | what is actually in this file |
8
+ | `DataDriftCheck` | has today's feed moved away from the baseline |
9
+ | `TableDiff` | what changed since yesterday's export |
10
+ | `RecordLinkage` | is this the same customer as that one |
11
+ | `ContactNormalize` | what do these identifiers look like canonically |
12
+ | `TableReshape` | pivot between wide and long |
13
+ | `TableShard` | split a file too big for one pass |
14
+ | `FixedWidthParse` | read a positional layout with no delimiter |
15
+
16
+ The first questions about any data file are always the same, and the usual
17
+ way to answer them is to read the first fifty rows into a context window and
18
+ guess. Fifty rows do not tell you that column nine is empty in the last
19
+ thousand, or that the id you were about to join on repeats.
20
+
21
+ Files are read here rather than passed in: a table worth these tools is one
22
+ too big to paste, which is the whole saving. CSV parsing comes from
23
+ `@crewhaus/tool-data` — two readers in one repository would disagree about
24
+ quoting and embedded newlines on the same file, in different tools.
25
+
26
+ ## It refuses to guess where guessing is invisible
27
+
28
+ Each of these produces output that looks right when it is wrong, so each is
29
+ reported instead:
30
+
31
+ - **A duplicate key in a diff.** With a key that appears twice there is no
32
+ fact about which row became which. Picking one produces a reconciliation
33
+ that looks authoritative and is arbitrary.
34
+ - **A pivot collision.** Going long to wide, a repeated (identifier,
35
+ variable) pair does not say which value wins. Silently taking the last
36
+ makes a pivot that looks complete and is wrong in a way nothing downstream
37
+ can detect.
38
+ - **A short line in a fixed-width file.** Padding it turns a truncated record
39
+ into one with empty trailing fields, which read as real data.
40
+ - **A record matching two others.** That is a question, not two matches, so
41
+ each record is used once and the leftovers are listed.
42
+
43
+ A candidate key must be unique **and** complete: a column with distinct
44
+ values and some nulls cannot key a join, and offering it would be worse than
45
+ offering nothing.
46
+
47
+ ## Matching carries its evidence
48
+
49
+ `RecordLinkage` returns, for every accepted pair, which field matched and how
50
+ closely. "These are the same person" is a claim somebody will have to defend,
51
+ and a bare score cannot be defended. Pairs above the review floor but below
52
+ the accept floor come back separately rather than being decided.
53
+
54
+ Rules can normalize before comparing, with the same canonicalization
55
+ `ContactNormalize` applies — without it, "Dr Jane Smith" and "Smith Jane"
56
+ score far apart on an edit distance while obviously being the same person.
57
+
58
+ Gmail's dots and `+tags` fold **only** for Gmail-family domains, because they
59
+ are a Gmail feature and not a rule of email: treating `a.b@other.com` as
60
+ `ab@other.com` merges two different people. A phone number with no country
61
+ code stays national and says so rather than being given one. Every fold is
62
+ named, and the original is never discarded.
63
+
64
+ ## Bounds are on the work, not on a proxy for it
65
+
66
+ Edit distance is quadratic in the length of what it compares, so the real
67
+ cost of a linkage run is comparisons **times** length squared. A cap on the
68
+ number of comparisons alone bounds the wrong quantity: thirty records against
69
+ thirty, with three-thousand-character values, is only nine hundred
70
+ comparisons and took forty-five seconds. Fuzzy comparison looks at the first
71
+ 256 characters, which is far more than the names, companies and addresses
72
+ this exists to match, and the same run now takes four milliseconds.
73
+
74
+ Pivoting on a high-cardinality column is refused rather than answered — an id
75
+ or a timestamp gives a column per row, which is a table nobody can read
76
+ rather than an error anybody notices. And `TableShard` returns the plan
77
+ without the shard bodies once they exceed a few megabytes, because returning
78
+ them at that size is not a result, it is the file again.
79
+
80
+ ## Positions are 1-based and inclusive
81
+
82
+ `FixedWidthParse` takes the positions exactly as a layout document, copybook
83
+ or mainframe spec states them. Converting in your head is how a field ends up
84
+ one character off down the whole file.
85
+
86
+ ## Drift, and the one thing a baseline has to carry
87
+
88
+ `DataDriftCheck` compares today's file against a `TableProfile` taken earlier
89
+ and reports schema drift, distribution shift, null-rate and cardinality jumps,
90
+ categories that are new today, and the row-count ratio.
91
+
92
+ The Population Stability Index at the centre of it is only meaningful when
93
+ today's numbers are binned against the **baseline's** bin edges. Bin each side
94
+ against its own quantiles and both come back as ten deciles of ten percent, so
95
+ the index reads near zero however far the data moved — and that is not an edge
96
+ case, it is what happens every time. In this package's own tests, a column
97
+ moved bodily from 0-100 to 500-600 scores **11.46** against the stored edges
98
+ and **exactly 0** against fresh ones.
99
+
100
+ So the edges are captured when the baseline is taken, and cannot be recovered
101
+ afterwards:
102
+
103
+ ```
104
+ TableProfile { file: "month.csv", driftProfile: {} } # stores edges, category
105
+ # counts and a value sample
106
+ DataDriftCheck { referenceProfile: "month.json", file: "today.csv", epsilon: 1e-3 }
107
+ ```
108
+
109
+ A baseline profiled without `driftProfile` is **refused**, with the command
110
+ that fixes it, rather than answered from edges derived on the spot. So is a
111
+ column whose two sides were somehow binned differently.
112
+
113
+ `driftProfile` is opt-in because the capture is machine fodder — a 500-value
114
+ sample per numeric column makes the answer a person asked for worse. A profile
115
+ taken without it is byte-for-byte what it always was.
116
+
117
+ ## Epsilon is an input, not a constant
118
+
119
+ An empty bin makes the PSI log term infinite, so an empty bin's share is
120
+ floored at `epsilon`. That floor decides the verdict. For a ten-bin baseline
121
+ where one bin empties out:
122
+
123
+ | epsilon | PSI | band |
124
+ |---|---|---|
125
+ | 0.001 | 0.4664 | significant |
126
+ | 0.01 | 0.2178 | moderate |
127
+ | 0.05 | 0.0452 | stable |
128
+
129
+ Three ship decisions from one dataset. `epsilon` is therefore a **required**
130
+ input with no default anywhere in the stack, it is echoed in the result, and a
131
+ PSI that leaned on the floor says so. Put it in the same config as the
132
+ threshold it is compared against.
133
+
134
+ Values beyond the baseline's range get their own two bins rather than being
135
+ clamped into the end ones: a distribution that walked off the edge of the chart
136
+ is the loudest drift signal there is, and clamping is how it reads as stable.
137
+
138
+ ## What the drift check refuses to say
139
+
140
+ - **New categories, when the stored list was capped.** With a truncated list
141
+ there is no telling a genuinely new value from one that was always there and
142
+ merely rare. "Three new payment methods appeared" is a sentence somebody
143
+ acts on, so a wrong one is worse than none.
144
+ - **A p-value the approximation does not support.** The rank test carries the
145
+ kernel's own `normalApproximationValid`, and an invalid p cannot trip the
146
+ `pValue` gate — below eight values a side it is a rank ordering, not a rate.
147
+ The chi-square carries Cochran's condition for the same reason.
148
+ - **A distribution comparison across a type flip.** A column that arrives as
149
+ numbers and returns as labels is reported as `kindChanged`; its null rate and
150
+ cardinality are still compared, because those still mean something.
151
+ - **Anything, when `failOn` is absent.** `gate.ok` is then true because nothing
152
+ was checked, and it says so in as many words.
153
+ - **A comparison across a repeated header.** Two columns with one name give no
154
+ fact about which is which, so they are named in the notes and left
155
+ uncompared rather than matched to whichever came last.
156
+
157
+ A refusal is not a pass. Every threshold above that could not be evaluated —
158
+ PSI on a column that stopped parsing, new categories on a capped list, a p the
159
+ approximation does not support, any per-column gate on a column that vanished
160
+ or whose name repeats — lands in `gate.unchecked`, and `gate.ok` is **false**
161
+ while that list is non-empty. `failures` still means "measured and breached",
162
+ so the two are told apart; `unchecked` means the gate never got to look, which
163
+ is the one thing "ok" must never be allowed to mean.
164
+
165
+ Statistics come from `@crewhaus/tool-math`'s kernel — Mann-Whitney, PSI and the
166
+ normal tail live there for the whole repository. The chi-square test of
167
+ homogeneity and its incomplete-gamma tail are the one piece that was not
168
+ already there; the df=1 case is pinned against that kernel's normal tail, which
169
+ is an exact identity and so an independent oracle.
170
+
171
+ One JSON wrinkle worth knowing: the out-of-range bins are bounded by infinity,
172
+ and `JSON.stringify` turns those into `null`. The bin's `label` (`"(-inf, 0)"`,
173
+ `"[99, +inf)"`) is the field to read.
174
+
175
+ ## What it does not do
176
+
177
+ - **It does not write.** Everything here reads; `TableShard` returns the
178
+ shard bodies and the caller writes them.
179
+ - **It does not query.** For selection, aggregation and joins there are
180
+ `jsonQuery`, `tableQuery` and `tableAggregate` in `@crewhaus/tool-data`,
181
+ and `sqlQuery` in `@crewhaus/tool-sql`.
182
+ - **It does not read Parquet, ORC, Avro or Excel.** Delimited text and fixed
183
+ width only; `xlsxRead` in `@crewhaus/tool-docs` handles spreadsheets.
184
+ - **It does not clean data.** Nothing here corrects a value, fills a null or
185
+ resolves an inconsistency — it tells you they are there.
186
+ - **It does not explain drift.** `DataDriftCheck` says which column moved and
187
+ by how much. Why it moved is upstream of here.
@@ -0,0 +1,11 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ export declare const tableProfile: RegisteredTool;
3
+ export declare const dataDriftCheck: RegisteredTool;
4
+ export declare const tableDiff: RegisteredTool;
5
+ export declare const recordLinkage: RegisteredTool;
6
+ export declare const contactNormalize: RegisteredTool;
7
+ export declare const tableReshape: RegisteredTool;
8
+ export declare const tableShard: RegisteredTool;
9
+ export declare const fixedWidthParse: RegisteredTool;
10
+ /** Every tool this package registers, in the order a catalog should list them. */
11
+ export declare const TABLE_TOOLS: ReadonlyArray<RegisteredTool>;