shadowjoin 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +324 -2
- package/SKILL.md +66 -0
- package/dist/adapters/csv.d.ts +13 -0
- package/dist/adapters/csv.js +73 -0
- package/dist/adapters/json.d.ts +4 -0
- package/dist/adapters/json.js +25 -0
- package/dist/adapters/mysql.d.ts +21 -0
- package/dist/adapters/mysql.js +72 -0
- package/dist/adapters/postgres.d.ts +28 -0
- package/dist/adapters/postgres.js +72 -0
- package/dist/adapters/sqlite.d.ts +18 -0
- package/dist/adapters/sqlite.js +49 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +145 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +63 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +229 -0
- package/dist/normalize.d.ts +19 -0
- package/dist/normalize.js +102 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +260 -0
- package/dist/similarity.d.ts +34 -0
- package/dist/similarity.js +81 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.js +80 -0
- package/package.json +81 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const strip = (re) => (v) => v.replace(re, " ");
|
|
2
|
+
/**
|
|
3
|
+
* Applied to every candidate key before any optional rule. These are the
|
|
4
|
+
* transforms that are safe on essentially any text: they never merge values
|
|
5
|
+
* that a human would read as different entities.
|
|
6
|
+
*/
|
|
7
|
+
export const BASE_RULES = [
|
|
8
|
+
{ id: "casefold", label: "lowercase", apply: (v) => v.toLowerCase(), sql: (e) => `lower(${e})` },
|
|
9
|
+
{
|
|
10
|
+
id: "deaccent",
|
|
11
|
+
label: "unicode NFKD + strip diacritics",
|
|
12
|
+
apply: (v) => v.normalize("NFKD").replace(/\p{M}+/gu, ""),
|
|
13
|
+
sql: (e) => `unaccent(${e})`,
|
|
14
|
+
},
|
|
15
|
+
{ id: "trim", label: "trim and collapse whitespace", apply: (v) => v.replace(/\s+/g, " ").trim(), sql: (e) => `btrim(regexp_replace(${e}, '\\s+', ' ', 'g'))` },
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Each of these is measured INDEPENDENTLY on top of BASE_RULES, so the number
|
|
19
|
+
* next to a rule is what that rule alone buys you — not an artifact of the
|
|
20
|
+
* order rules happen to run in.
|
|
21
|
+
*/
|
|
22
|
+
export const OPTIONAL_RULES = [
|
|
23
|
+
{
|
|
24
|
+
id: "punctuation",
|
|
25
|
+
label: "strip punctuation",
|
|
26
|
+
apply: strip(/[^\p{L}\p{N}\s]+/gu),
|
|
27
|
+
sql: (e) => `regexp_replace(${e}, '[^[:alnum:][:space:]]+', ' ', 'g')`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "ampersand",
|
|
31
|
+
label: "& -> and",
|
|
32
|
+
apply: (v) => v.replace(/&/g, " and "),
|
|
33
|
+
sql: (e) => `replace(${e}, '&', ' and ')`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "leading-zeros",
|
|
37
|
+
label: "strip leading zeros from numeric tokens",
|
|
38
|
+
apply: (v) => v.replace(/\b0+(\d)/g, "$1"),
|
|
39
|
+
sql: (e) => `regexp_replace(${e}, '\\m0+(\\d)', '\\1', 'g')`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "article",
|
|
43
|
+
label: "strip a leading article",
|
|
44
|
+
apply: (v) => v.replace(/^\s*(the|a|an)\s+/i, " "),
|
|
45
|
+
sql: (e) => `regexp_replace(${e}, '^\\s*(the|a|an)\\s+', ' ', 'i')`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "corp-suffix",
|
|
49
|
+
label: "strip corporate suffixes (inc, corp, ltd, plc, gmbh ...)",
|
|
50
|
+
apply: strip(/\b(incorporated|inc|corporation|corp|company|co|limited|ltd|llc|llp|lp|plc|holdings?|group|gmbh|ag|sa|nv|bv|oyj|ab|as|pte|pty)\b\.?/gi),
|
|
51
|
+
sql: (e) => `regexp_replace(${e}, '\\m(incorporated|inc|corporation|corp|company|co|limited|ltd|llc|llp|lp|plc|holdings?|group|gmbh|ag|sa|nv|bv|oyj|ab|as|pte|pty)\\M\\.?', ' ', 'gi')`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "security-boilerplate",
|
|
55
|
+
label: "strip security/listing boilerplate (common stock, ADS, class A ...)",
|
|
56
|
+
apply: strip(/\b(common stock|common shares?|ordinary shares?|american depositary (shares?|receipts?)|depositary shares?|class [a-z]\b|series [a-z]\b|when issued|new)\b/gi),
|
|
57
|
+
sql: (e) => `regexp_replace(${e}, '\\m(common stock|common shares?|ordinary shares?|american depositary (shares?|receipts?)|depositary shares?|class [a-z]\\M|series [a-z]\\M|when issued|new)\\M', ' ', 'gi')`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "sort-tokens",
|
|
61
|
+
label: "sort tokens (order-insensitive match)",
|
|
62
|
+
apply: (v) => v.split(/\s+/).filter(Boolean).sort().join(" "),
|
|
63
|
+
sql: (e) => `(SELECT string_agg(t, ' ' ORDER BY t) FROM unnest(string_to_array(${e}, ' ')) AS t WHERE t <> '')`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "despace",
|
|
67
|
+
label: "remove all whitespace",
|
|
68
|
+
apply: (v) => v.replace(/\s+/g, ""),
|
|
69
|
+
sql: (e) => `regexp_replace(${e}, '\\s+', '', 'g')`,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
export const ALL_RULE_IDS = OPTIONAL_RULES.map((r) => r.id);
|
|
73
|
+
export function rulesByIds(ids) {
|
|
74
|
+
const index = new Map(OPTIONAL_RULES.map((r) => [r.id, r]));
|
|
75
|
+
return ids.map((id) => {
|
|
76
|
+
const rule = index.get(id);
|
|
77
|
+
if (!rule) {
|
|
78
|
+
throw new Error(`Unknown rule "${id}". Known rules: ${ALL_RULE_IDS.join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
return rule;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Apply BASE_RULES, then the supplied rules, in order. */
|
|
84
|
+
export function normalize(value, rules = []) {
|
|
85
|
+
let out = value;
|
|
86
|
+
for (const rule of BASE_RULES)
|
|
87
|
+
out = rule.apply(out);
|
|
88
|
+
for (const rule of rules)
|
|
89
|
+
out = rule.apply(out);
|
|
90
|
+
// Rules insert spaces where they remove things; tidy up so "3m company" and
|
|
91
|
+
// "3m company" do not end up as different keys.
|
|
92
|
+
return out.replace(/\s+/g, " ").trim();
|
|
93
|
+
}
|
|
94
|
+
/** Compose the enabled rules into one Postgres expression over a column. */
|
|
95
|
+
export function toSql(column, rules = []) {
|
|
96
|
+
let expr = column;
|
|
97
|
+
for (const rule of BASE_RULES)
|
|
98
|
+
expr = rule.sql(expr);
|
|
99
|
+
for (const rule of rules)
|
|
100
|
+
expr = rule.sql(expr);
|
|
101
|
+
return expr;
|
|
102
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Report, Row } from "./types.js";
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
/** Rule ids to enable. Defaults to every rule. */
|
|
4
|
+
rules?: string[];
|
|
5
|
+
/** How many example recoveries to include for human review. Default 20. */
|
|
6
|
+
maxSamplePairs?: number;
|
|
7
|
+
/** Replace key values in samples with a short hash. Counts are unaffected. */
|
|
8
|
+
redactSamples?: boolean;
|
|
9
|
+
/** Look for trigram neighbours among rows no rule explains. Default true. */
|
|
10
|
+
nearMisses?: boolean;
|
|
11
|
+
/** Minimum Dice score for a near miss. Default 0.45. */
|
|
12
|
+
nearMissThreshold?: number;
|
|
13
|
+
/** Cap on near misses reported. Default 25. */
|
|
14
|
+
maxNearMisses?: number;
|
|
15
|
+
/** Called as each phase begins, so a CLI can show progress. */
|
|
16
|
+
onPhase?: (label: string) => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compare an exact join against a shadow matcher and report what the exact
|
|
20
|
+
* join is losing.
|
|
21
|
+
*
|
|
22
|
+
* The figure this produces is a FLOOR. The shadow matcher has unknown recall of
|
|
23
|
+
* its own, so the true loss is at least this large and may be larger.
|
|
24
|
+
*/
|
|
25
|
+
export declare function scan(left: Row[], right: Row[], options?: ScanOptions): Report;
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { ALL_RULE_IDS, normalize, rulesByIds } from "./normalize.js";
|
|
2
|
+
import { TrigramIndex } from "./similarity.js";
|
|
3
|
+
/** Tickers, SKUs and ids differ cosmetically across sources; compare them loosely. */
|
|
4
|
+
function canonicalTruth(value) {
|
|
5
|
+
return value.replace(/[^\p{L}\p{N}]+/gu, "").toUpperCase();
|
|
6
|
+
}
|
|
7
|
+
function redact(value) {
|
|
8
|
+
let h = 2166136261;
|
|
9
|
+
for (let i = 0; i < value.length; i++) {
|
|
10
|
+
h ^= value.charCodeAt(i);
|
|
11
|
+
h = Math.imul(h, 16777619);
|
|
12
|
+
}
|
|
13
|
+
return `#${(h >>> 0).toString(16).padStart(8, "0")}`;
|
|
14
|
+
}
|
|
15
|
+
function buildIndex(rows, rules) {
|
|
16
|
+
const index = new Map();
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
const key = rules === null ? row.key : normalize(row.key, rules);
|
|
19
|
+
if (!key)
|
|
20
|
+
continue;
|
|
21
|
+
const bucket = index.get(key);
|
|
22
|
+
if (bucket)
|
|
23
|
+
bucket.push(row);
|
|
24
|
+
else
|
|
25
|
+
index.set(key, [row]);
|
|
26
|
+
}
|
|
27
|
+
return index;
|
|
28
|
+
}
|
|
29
|
+
function verify(left, candidates) {
|
|
30
|
+
if (left.truth === undefined)
|
|
31
|
+
return "unverifiable";
|
|
32
|
+
const known = candidates.filter((c) => c.truth !== undefined);
|
|
33
|
+
if (known.length === 0)
|
|
34
|
+
return "unverifiable";
|
|
35
|
+
const target = canonicalTruth(left.truth);
|
|
36
|
+
const agreeing = known.filter((c) => canonicalTruth(c.truth) === target);
|
|
37
|
+
if (agreeing.length === 0)
|
|
38
|
+
return "contradicted";
|
|
39
|
+
if (agreeing.length === known.length)
|
|
40
|
+
return "verified";
|
|
41
|
+
return "ambiguous";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Compare an exact join against a shadow matcher and report what the exact
|
|
45
|
+
* join is losing.
|
|
46
|
+
*
|
|
47
|
+
* The figure this produces is a FLOOR. The shadow matcher has unknown recall of
|
|
48
|
+
* its own, so the true loss is at least this large and may be larger.
|
|
49
|
+
*/
|
|
50
|
+
export function scan(left, right, options = {}) {
|
|
51
|
+
const ruleIds = options.rules ?? ALL_RULE_IDS;
|
|
52
|
+
const rules = rulesByIds(ruleIds);
|
|
53
|
+
const maxSamples = options.maxSamplePairs ?? 20;
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (left.length === 0 || right.length === 0) {
|
|
56
|
+
warnings.push("One side of the join is empty; there is nothing to measure.");
|
|
57
|
+
}
|
|
58
|
+
const phase = options.onPhase ?? (() => { });
|
|
59
|
+
// 1. The baseline: the join exactly as it runs today.
|
|
60
|
+
phase("measuring the join as it runs today");
|
|
61
|
+
const exactIndex = buildIndex(right, null);
|
|
62
|
+
const missed = [];
|
|
63
|
+
let exactMatches = 0;
|
|
64
|
+
for (const row of left) {
|
|
65
|
+
if (exactIndex.has(row.key))
|
|
66
|
+
exactMatches++;
|
|
67
|
+
else
|
|
68
|
+
missed.push(row);
|
|
69
|
+
}
|
|
70
|
+
// 2. Each rule measured independently against that baseline, so the number
|
|
71
|
+
// beside a rule is what the rule alone buys and not a product of ordering.
|
|
72
|
+
phase(`testing ${rules.length} rules against that baseline`);
|
|
73
|
+
const attribution = new Map();
|
|
74
|
+
const byRule = rules.map((rule) => {
|
|
75
|
+
const index = buildIndex(right, [rule]);
|
|
76
|
+
let recovered = 0, verified = 0, contradicted = 0, ambiguous = 0;
|
|
77
|
+
for (const row of missed) {
|
|
78
|
+
const candidates = index.get(normalize(row.key, [rule]));
|
|
79
|
+
if (!candidates || candidates.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
recovered++;
|
|
82
|
+
const verdict = verify(row, candidates);
|
|
83
|
+
if (verdict === "verified")
|
|
84
|
+
verified++;
|
|
85
|
+
else if (verdict === "contradicted")
|
|
86
|
+
contradicted++;
|
|
87
|
+
else if (verdict === "ambiguous")
|
|
88
|
+
ambiguous++;
|
|
89
|
+
if (!attribution.has(row.id))
|
|
90
|
+
attribution.set(row.id, { rule: rule.id, candidates });
|
|
91
|
+
}
|
|
92
|
+
return { rule: rule.id, label: rule.label, recovered, verified, contradicted, ambiguous };
|
|
93
|
+
});
|
|
94
|
+
// 3. Greedy forward selection: which rules, in which order, actually pay.
|
|
95
|
+
// Most recoveries need a combination, so per-rule figures alone are not
|
|
96
|
+
// enough to act on.
|
|
97
|
+
phase("selecting the rules worth adopting");
|
|
98
|
+
const stack = [];
|
|
99
|
+
const covered = new Set();
|
|
100
|
+
const chosen = [];
|
|
101
|
+
let pool = [...rules];
|
|
102
|
+
while (pool.length > 0) {
|
|
103
|
+
let best = null;
|
|
104
|
+
let bestGain = 0;
|
|
105
|
+
let bestMatched = null;
|
|
106
|
+
for (const candidate of pool) {
|
|
107
|
+
const trial = [...chosen, candidate];
|
|
108
|
+
const index = buildIndex(right, trial);
|
|
109
|
+
const matched = new Set();
|
|
110
|
+
for (const row of missed) {
|
|
111
|
+
if (index.has(normalize(row.key, trial)))
|
|
112
|
+
matched.add(row.id);
|
|
113
|
+
}
|
|
114
|
+
let gain = 0;
|
|
115
|
+
for (const id of matched)
|
|
116
|
+
if (!covered.has(id))
|
|
117
|
+
gain++;
|
|
118
|
+
if (gain > bestGain) {
|
|
119
|
+
bestGain = gain;
|
|
120
|
+
best = candidate;
|
|
121
|
+
bestMatched = matched;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (best === null || bestGain === 0)
|
|
125
|
+
break;
|
|
126
|
+
chosen.push(best);
|
|
127
|
+
for (const id of bestMatched)
|
|
128
|
+
covered.add(id);
|
|
129
|
+
stack.push({ rule: best.id, label: best.label, marginal: bestGain, cumulative: covered.size });
|
|
130
|
+
pool = pool.filter((r) => r !== best);
|
|
131
|
+
}
|
|
132
|
+
// 4. Attribute what only a COMBINATION explains. The greedy stack is tried
|
|
133
|
+
// first, because applying every rule can recover fewer rows than the best
|
|
134
|
+
// subset does — over-normalization merges keys that should stay apart.
|
|
135
|
+
const greedyIndex = buildIndex(right, chosen);
|
|
136
|
+
for (const row of missed) {
|
|
137
|
+
if (attribution.has(row.id))
|
|
138
|
+
continue;
|
|
139
|
+
const candidates = greedyIndex.get(normalize(row.key, chosen));
|
|
140
|
+
if (candidates && candidates.length > 0) {
|
|
141
|
+
attribution.set(row.id, { rule: "combined", candidates });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const fullIndex = buildIndex(right, rules);
|
|
145
|
+
let fullStackRecovered = 0;
|
|
146
|
+
for (const row of missed) {
|
|
147
|
+
const candidates = fullIndex.get(normalize(row.key, rules));
|
|
148
|
+
if (candidates && candidates.length > 0) {
|
|
149
|
+
fullStackRecovered++;
|
|
150
|
+
if (!attribution.has(row.id))
|
|
151
|
+
attribution.set(row.id, { rule: "combined", candidates });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// 5. Tally and collect examples.
|
|
155
|
+
let verified = 0, contradicted = 0, ambiguous = 0, unverifiable = 0;
|
|
156
|
+
const samplePairs = [];
|
|
157
|
+
const missedById = new Map(missed.map((r) => [r.id, r]));
|
|
158
|
+
for (const [leftId, { rule, candidates }] of attribution) {
|
|
159
|
+
const row = missedById.get(leftId);
|
|
160
|
+
const verdict = verify(row, candidates);
|
|
161
|
+
if (verdict === "verified")
|
|
162
|
+
verified++;
|
|
163
|
+
else if (verdict === "contradicted")
|
|
164
|
+
contradicted++;
|
|
165
|
+
else if (verdict === "ambiguous")
|
|
166
|
+
ambiguous++;
|
|
167
|
+
else
|
|
168
|
+
unverifiable++;
|
|
169
|
+
if (samplePairs.length < maxSamples) {
|
|
170
|
+
const best = candidates[0];
|
|
171
|
+
samplePairs.push({
|
|
172
|
+
rule,
|
|
173
|
+
leftId: row.id,
|
|
174
|
+
leftKey: options.redactSamples ? redact(row.key) : row.key,
|
|
175
|
+
rightId: best.id,
|
|
176
|
+
rightKey: options.redactSamples ? redact(best.key) : best.key,
|
|
177
|
+
candidates: candidates.length,
|
|
178
|
+
verification: verdict,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const recovered = attribution.size;
|
|
183
|
+
const unexplained = missed.length - recovered;
|
|
184
|
+
// 6. The residue. No normalization rule explains these, so look for the
|
|
185
|
+
// closest trigram neighbour instead. Reported as advice, never counted:
|
|
186
|
+
// a suggestion that has not cleared the rule bar must not move the floor.
|
|
187
|
+
const nearMisses = [];
|
|
188
|
+
let noCandidate = 0;
|
|
189
|
+
if (options.nearMisses !== false && unexplained > 0) {
|
|
190
|
+
phase(`analysing the ${unexplained} rows no rule explains`);
|
|
191
|
+
const index = new TrigramIndex(right);
|
|
192
|
+
const maxNear = options.maxNearMisses ?? 25;
|
|
193
|
+
for (const row of missed) {
|
|
194
|
+
if (attribution.has(row.id))
|
|
195
|
+
continue;
|
|
196
|
+
const hit = index.best(row.key, { threshold: options.nearMissThreshold ?? 0.45 });
|
|
197
|
+
if (!hit) {
|
|
198
|
+
noCandidate++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (nearMisses.length < maxNear) {
|
|
202
|
+
nearMisses.push({
|
|
203
|
+
leftId: row.id,
|
|
204
|
+
leftKey: options.redactSamples ? redact(row.key) : row.key,
|
|
205
|
+
rightId: hit.row.id,
|
|
206
|
+
rightKey: options.redactSamples ? redact(hit.row.key) : hit.row.key,
|
|
207
|
+
similarity: hit.similarity,
|
|
208
|
+
verification: verify(row, [hit.row]),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const pct = (n) => (left.length === 0 ? 0 : Number(((n / left.length) * 100).toFixed(2)));
|
|
214
|
+
if (rules.length > 1) {
|
|
215
|
+
warnings.push("Per-rule counts are measured independently against the exact baseline, so they overlap and will not sum to the total.");
|
|
216
|
+
}
|
|
217
|
+
if (chosen.length > 0 && chosen.length < rules.length && fullStackRecovered < covered.size) {
|
|
218
|
+
warnings.push(`Applying every rule recovers ${covered.size - fullStackRecovered} fewer match(es) than the best subset. Over-normalization is costing you matches — rerun with --rules ${chosen.map((r) => r.id).join(",")}`);
|
|
219
|
+
}
|
|
220
|
+
if (nearMisses.length > 0) {
|
|
221
|
+
warnings.push(`${nearMisses.length} unexplained row(s) have a close trigram neighbour. These are suggestions, NOT counted in the floor — confirm them before adding an alias.`);
|
|
222
|
+
}
|
|
223
|
+
if (noCandidate > 0) {
|
|
224
|
+
warnings.push(`${noCandidate} unexplained row(s) have no string similarity to anything on the right (e.g. "Vatican City" vs "Holy See"). No normalization rule will ever recover those; they need an alias table.`);
|
|
225
|
+
}
|
|
226
|
+
if (contradicted > 0) {
|
|
227
|
+
warnings.push(`${contradicted} recovered match(es) are contradicted by ground truth — the shadow matcher is over-merging. Review samplePairs before acting.`);
|
|
228
|
+
}
|
|
229
|
+
if (ambiguous > 0) {
|
|
230
|
+
warnings.push(`${ambiguous} recovered match(es) collapsed onto a key shared by several right-hand rows (e.g. share classes or product variants). These need a tie-breaker, not a normalization rule.`);
|
|
231
|
+
}
|
|
232
|
+
if (unverifiable > 0 && verified + contradicted === 0) {
|
|
233
|
+
warnings.push("No ground truth was supplied, so recoveries are unverified candidates. Supply a `truth` field on both sides to turn this figure into a verified floor.");
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
generatedAt: new Date().toISOString(),
|
|
237
|
+
leftRows: left.length,
|
|
238
|
+
rightRows: right.length,
|
|
239
|
+
exactMatches,
|
|
240
|
+
exactMatchRate: pct(exactMatches),
|
|
241
|
+
recovered,
|
|
242
|
+
unexplained,
|
|
243
|
+
reachableMatchRate: pct(exactMatches + recovered),
|
|
244
|
+
lowerBoundLossPct: pct(recovered),
|
|
245
|
+
byRule: byRule.sort((a, b) => b.recovered - a.recovered),
|
|
246
|
+
stack,
|
|
247
|
+
verified,
|
|
248
|
+
contradicted,
|
|
249
|
+
ambiguous,
|
|
250
|
+
unverifiable,
|
|
251
|
+
precision: verified + contradicted === 0
|
|
252
|
+
? null
|
|
253
|
+
: Number(((verified / (verified + contradicted)) * 100).toFixed(2)),
|
|
254
|
+
samplePairs,
|
|
255
|
+
nearMisses,
|
|
256
|
+
noCandidate,
|
|
257
|
+
warnings,
|
|
258
|
+
rulesApplied: ruleIds,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Row } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trigram similarity, for the rows no normalization rule explains.
|
|
4
|
+
*
|
|
5
|
+
* Implemented in-process rather than delegating to Postgres `pg_trgm`, so the
|
|
6
|
+
* same residue analysis runs on CSVs, on JSON and on databases that have no
|
|
7
|
+
* such extension.
|
|
8
|
+
*/
|
|
9
|
+
/** Postgres-style padding so short strings and word starts are weighted sensibly. */
|
|
10
|
+
export declare function trigrams(value: string): Set<string>;
|
|
11
|
+
/** Sørensen–Dice: 2|A∩B| / (|A|+|B|). 1 is identical, 0 shares nothing. */
|
|
12
|
+
export declare function dice(a: Set<string>, b: Set<string>): number;
|
|
13
|
+
export interface NearMissOptions {
|
|
14
|
+
/** Minimum Dice score to report. Default 0.45. */
|
|
15
|
+
threshold?: number;
|
|
16
|
+
/** Cap on candidates scored per unexplained row. Default 200. */
|
|
17
|
+
maxCandidates?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface NearMissHit {
|
|
20
|
+
row: Row;
|
|
21
|
+
similarity: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An inverted trigram index over the right-hand side. Built once, queried per
|
|
25
|
+
* unexplained row, so cost grows with the residue rather than with every pair.
|
|
26
|
+
*/
|
|
27
|
+
export declare class TrigramIndex {
|
|
28
|
+
private readonly rows;
|
|
29
|
+
private readonly postings;
|
|
30
|
+
private readonly grams;
|
|
31
|
+
constructor(rows: Row[]);
|
|
32
|
+
/** Best scoring right-hand row for `key`, or null if nothing clears the bar. */
|
|
33
|
+
best(key: string, options?: NearMissOptions): NearMissHit | null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigram similarity, for the rows no normalization rule explains.
|
|
3
|
+
*
|
|
4
|
+
* Implemented in-process rather than delegating to Postgres `pg_trgm`, so the
|
|
5
|
+
* same residue analysis runs on CSVs, on JSON and on databases that have no
|
|
6
|
+
* such extension.
|
|
7
|
+
*/
|
|
8
|
+
/** Postgres-style padding so short strings and word starts are weighted sensibly. */
|
|
9
|
+
export function trigrams(value) {
|
|
10
|
+
const padded = ` ${value.trim().toLowerCase()} `;
|
|
11
|
+
const out = new Set();
|
|
12
|
+
for (let i = 0; i + 3 <= padded.length; i++)
|
|
13
|
+
out.add(padded.slice(i, i + 3));
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/** Sørensen–Dice: 2|A∩B| / (|A|+|B|). 1 is identical, 0 shares nothing. */
|
|
17
|
+
export function dice(a, b) {
|
|
18
|
+
if (a.size === 0 || b.size === 0)
|
|
19
|
+
return 0;
|
|
20
|
+
let shared = 0;
|
|
21
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
22
|
+
for (const g of small)
|
|
23
|
+
if (large.has(g))
|
|
24
|
+
shared++;
|
|
25
|
+
return (2 * shared) / (a.size + b.size);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* An inverted trigram index over the right-hand side. Built once, queried per
|
|
29
|
+
* unexplained row, so cost grows with the residue rather than with every pair.
|
|
30
|
+
*/
|
|
31
|
+
export class TrigramIndex {
|
|
32
|
+
rows;
|
|
33
|
+
postings = new Map();
|
|
34
|
+
grams = [];
|
|
35
|
+
constructor(rows) {
|
|
36
|
+
this.rows = rows;
|
|
37
|
+
rows.forEach((row, i) => {
|
|
38
|
+
const g = trigrams(row.key);
|
|
39
|
+
this.grams.push(g);
|
|
40
|
+
for (const gram of g) {
|
|
41
|
+
const bucket = this.postings.get(gram);
|
|
42
|
+
if (bucket)
|
|
43
|
+
bucket.push(i);
|
|
44
|
+
else
|
|
45
|
+
this.postings.set(gram, [i]);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Best scoring right-hand row for `key`, or null if nothing clears the bar. */
|
|
50
|
+
best(key, options = {}) {
|
|
51
|
+
const threshold = options.threshold ?? 0.45;
|
|
52
|
+
const maxCandidates = options.maxCandidates ?? 200;
|
|
53
|
+
const query = trigrams(key);
|
|
54
|
+
// Skipping near-universal trigrams keeps large indexes fast, but on a small
|
|
55
|
+
// right-hand side every bucket looks universal and the filter would discard
|
|
56
|
+
// everything. Only apply it once there are enough rows for it to mean something.
|
|
57
|
+
const universal = this.rows.length >= 20 ? this.rows.length / 2 : Infinity;
|
|
58
|
+
const overlap = new Map();
|
|
59
|
+
for (const gram of query) {
|
|
60
|
+
const bucket = this.postings.get(gram);
|
|
61
|
+
if (!bucket || bucket.length > universal)
|
|
62
|
+
continue;
|
|
63
|
+
for (const i of bucket)
|
|
64
|
+
overlap.set(i, (overlap.get(i) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
if (overlap.size === 0)
|
|
67
|
+
return null;
|
|
68
|
+
// Score the most promising candidates only; shared-gram count bounds Dice.
|
|
69
|
+
const ranked = [...overlap.entries()]
|
|
70
|
+
.sort((a, b) => b[1] - a[1])
|
|
71
|
+
.slice(0, maxCandidates);
|
|
72
|
+
let best = null;
|
|
73
|
+
for (const [i] of ranked) {
|
|
74
|
+
const score = dice(query, this.grams[i]);
|
|
75
|
+
if (score >= threshold && (best === null || score > best.similarity)) {
|
|
76
|
+
best = { row: this.rows[i], similarity: Number(score.toFixed(3)) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** A single record on one side of the join. */
|
|
2
|
+
export interface Row {
|
|
3
|
+
/** Stable identifier for this row (primary key, line number — anything unique). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** The value actually used as the join key today. */
|
|
6
|
+
key: string;
|
|
7
|
+
/**
|
|
8
|
+
* Optional ground truth. When both sides carry it, every recovered match is
|
|
9
|
+
* verified rather than asserted, and the report gains a precision figure.
|
|
10
|
+
* In the bundled example this is the ticker symbol.
|
|
11
|
+
*/
|
|
12
|
+
truth?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A reversible-in-spirit string transform used to build a looser blocking key. */
|
|
15
|
+
export interface Rule {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
apply(value: string): string;
|
|
19
|
+
/** The same transform as a Postgres expression, so it can be pasted into a pipeline. */
|
|
20
|
+
sql(expr: string): string;
|
|
21
|
+
}
|
|
22
|
+
export type Verification = "verified" | "contradicted" | "ambiguous" | "unverifiable";
|
|
23
|
+
export interface RuleContribution {
|
|
24
|
+
rule: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Rows recovered by BASE + this rule alone, measured against the exact-match baseline. */
|
|
27
|
+
recovered: number;
|
|
28
|
+
verified: number;
|
|
29
|
+
contradicted: number;
|
|
30
|
+
ambiguous: number;
|
|
31
|
+
}
|
|
32
|
+
/** One step of the greedy stack: the rule, and what adding it actually bought. */
|
|
33
|
+
export interface StackStep {
|
|
34
|
+
rule: string;
|
|
35
|
+
label: string;
|
|
36
|
+
/** Rows recovered by adding this rule that the preceding ones did not already cover. */
|
|
37
|
+
marginal: number;
|
|
38
|
+
/** Total rows recovered by this rule plus everything before it. */
|
|
39
|
+
cumulative: number;
|
|
40
|
+
}
|
|
41
|
+
export interface SamplePair {
|
|
42
|
+
/** Which rule (or "combined") first explained this recovery. */
|
|
43
|
+
rule: string;
|
|
44
|
+
leftId: string;
|
|
45
|
+
leftKey: string;
|
|
46
|
+
rightId: string;
|
|
47
|
+
rightKey: string;
|
|
48
|
+
/** Number of right-hand rows that collapsed onto the same normalized key. */
|
|
49
|
+
candidates: number;
|
|
50
|
+
verification: Verification;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A row no rule explains, paired with its closest trigram neighbour.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT counted in `recovered` or `lowerBoundLossPct`: these are
|
|
56
|
+
* unverified suggestions below the confidence bar, and letting them inflate the
|
|
57
|
+
* headline would turn a defensible floor into a guess.
|
|
58
|
+
*/
|
|
59
|
+
export interface NearMiss {
|
|
60
|
+
leftId: string;
|
|
61
|
+
leftKey: string;
|
|
62
|
+
rightId: string;
|
|
63
|
+
rightKey: string;
|
|
64
|
+
/** Sørensen-Dice over character trigrams. 1 is identical. */
|
|
65
|
+
similarity: number;
|
|
66
|
+
verification: Verification;
|
|
67
|
+
}
|
|
68
|
+
export interface Report {
|
|
69
|
+
generatedAt: string;
|
|
70
|
+
leftRows: number;
|
|
71
|
+
rightRows: number;
|
|
72
|
+
/** Rows matched by the join exactly as it runs today. */
|
|
73
|
+
exactMatches: number;
|
|
74
|
+
exactMatchRate: number;
|
|
75
|
+
/** Rows the shadow matcher recovered on top of the exact baseline. */
|
|
76
|
+
recovered: number;
|
|
77
|
+
/** Recovered rows with no candidate under any enabled rule. */
|
|
78
|
+
unexplained: number;
|
|
79
|
+
/** exactMatches + recovered, over leftRows. */
|
|
80
|
+
reachableMatchRate: number;
|
|
81
|
+
/**
|
|
82
|
+
* Lower bound on the share of left rows whose match the exact join is losing.
|
|
83
|
+
* A FLOOR, not an estimate: the shadow matcher's own recall is unknown, so the
|
|
84
|
+
* true loss can only be larger.
|
|
85
|
+
*/
|
|
86
|
+
lowerBoundLossPct: number;
|
|
87
|
+
byRule: RuleContribution[];
|
|
88
|
+
/**
|
|
89
|
+
* Rules in the order worth adopting, chosen greedily by marginal recovery.
|
|
90
|
+
* This is the actionable half of the report: most recoveries need a
|
|
91
|
+
* combination, so the individual figures in `byRule` under-state them.
|
|
92
|
+
*/
|
|
93
|
+
stack: StackStep[];
|
|
94
|
+
verified: number;
|
|
95
|
+
contradicted: number;
|
|
96
|
+
ambiguous: number;
|
|
97
|
+
unverifiable: number;
|
|
98
|
+
/** verified / (verified + contradicted), or null when no ground truth was supplied. */
|
|
99
|
+
precision: number | null;
|
|
100
|
+
samplePairs: SamplePair[];
|
|
101
|
+
/** Closest trigram neighbours for rows no rule explains. Advisory, not counted. */
|
|
102
|
+
nearMisses: NearMiss[];
|
|
103
|
+
/** Unexplained rows with no trigram neighbour at all — these need an alias table. */
|
|
104
|
+
noCandidate: number;
|
|
105
|
+
warnings: string[];
|
|
106
|
+
rulesApplied: string[];
|
|
107
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const isTty: () => boolean;
|
|
2
|
+
export declare function banner(version: string): string;
|
|
3
|
+
export declare function footer(version: string): string;
|
|
4
|
+
/** A spinner that degrades to nothing when it isn't being watched by a human. */
|
|
5
|
+
export declare class Progress {
|
|
6
|
+
private timer;
|
|
7
|
+
private frame;
|
|
8
|
+
private label;
|
|
9
|
+
private readonly started;
|
|
10
|
+
private readonly done;
|
|
11
|
+
start(label: string): void;
|
|
12
|
+
/** Mark the current step finished and begin the next one. */
|
|
13
|
+
step(label: string): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
/** Clear the spinner without claiming the step succeeded. */
|
|
16
|
+
abort(): void;
|
|
17
|
+
private clear;
|
|
18
|
+
private paint;
|
|
19
|
+
}
|