shadowjoin 0.0.0-stage → 0.1.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/dist/format.js ADDED
@@ -0,0 +1,52 @@
1
+ const pad = (s, n) => s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
2
+ const lpad = (s, n) => s.length >= n ? s : " ".repeat(n - s.length) + s;
3
+ /** Render a report as plain text for a terminal. */
4
+ export function formatReport(report) {
5
+ const out = [];
6
+ const n = (v) => v.toLocaleString("en-US");
7
+ out.push("");
8
+ out.push(` left rows ${n(report.leftRows)} right rows ${n(report.rightRows)}`);
9
+ out.push("");
10
+ out.push(` exact join matches ${lpad(n(report.exactMatches), 9)} ${report.exactMatchRate}%`);
11
+ out.push(` recovered by shadow join ${lpad(n(report.recovered), 9)} ${report.lowerBoundLossPct}%`);
12
+ out.push(` still unexplained ${lpad(n(report.unexplained), 9)}`);
13
+ out.push(` reachable match rate ${lpad(report.reachableMatchRate + "%", 9)}`);
14
+ out.push("");
15
+ out.push(` LOWER BOUND ON LOST MATCHES: ${report.lowerBoundLossPct}% of left rows`);
16
+ if (report.precision !== null) {
17
+ out.push(` verified against ground truth: ${n(report.verified)} correct, ${n(report.contradicted)} wrong, ${n(report.ambiguous)} ambiguous (precision ${report.precision}%)`);
18
+ }
19
+ out.push("");
20
+ if (report.byRule.length) {
21
+ out.push(` ${pad("rule", 54)}${lpad("alone", 8)}${lpad("ok", 7)}${lpad("wrong", 7)}`);
22
+ out.push(" " + "-".repeat(76));
23
+ for (const r of report.byRule) {
24
+ out.push(` ${pad(r.label, 54)}${lpad(n(r.recovered), 8)}${lpad(n(r.verified), 7)}${lpad(n(r.contradicted), 7)}`);
25
+ }
26
+ out.push("");
27
+ }
28
+ if (report.stack.length) {
29
+ out.push(" rules worth adopting, in order (greedy by marginal recovery):");
30
+ out.push(` ${pad("", 4)}${pad("rule", 54)}${lpad("+rows", 9)}${lpad("running", 9)}`);
31
+ out.push(" " + "-".repeat(76));
32
+ report.stack.forEach((s2, i) => {
33
+ out.push(` ${pad(String(i + 1) + ".", 4)}${pad(s2.label, 54)}${lpad("+" + n(s2.marginal), 9)}${lpad(n(s2.cumulative), 9)}`);
34
+ });
35
+ out.push("");
36
+ }
37
+ if (report.samplePairs.length) {
38
+ out.push(" sample recoveries (verify these by hand before acting):");
39
+ const mark = { verified: "ok ", contradicted: "BAD", ambiguous: "?? ", unverifiable: " " };
40
+ for (const s of report.samplePairs.slice(0, 12)) {
41
+ const multi = s.candidates > 1 ? ` [${s.candidates} candidates]` : "";
42
+ out.push(` ${mark[s.verification]} ${s.leftKey}`);
43
+ out.push(` -> ${s.rightKey} (${s.rule})${multi}`);
44
+ }
45
+ out.push("");
46
+ }
47
+ for (const w of report.warnings)
48
+ out.push(` ! ${w}`);
49
+ if (report.warnings.length)
50
+ out.push("");
51
+ return out.join("\n");
52
+ }
@@ -0,0 +1,7 @@
1
+ export { scan } from "./scan.js";
2
+ export type { ScanOptions } from "./scan.js";
3
+ export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql } from "./normalize.js";
4
+ export { formatReport } from "./format.js";
5
+ export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
6
+ export type { CsvOptions } from "./adapters/csv.js";
7
+ export type { Report, Row, Rule, RuleContribution, SamplePair, Verification } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { scan } from "./scan.js";
2
+ export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql } from "./normalize.js";
3
+ export { formatReport } from "./format.js";
4
+ export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * shadowjoin MCP server.
4
+ *
5
+ * Two deliberate constraints, because this points at databases:
6
+ * 1. There is NO tool that executes arbitrary SQL. An agent can name a
7
+ * configured source plus a table and column, and nothing else.
8
+ * 2. No tool accepts a connection string. Credentials live in a local config
9
+ * file and never enter a tool call, because tool calls land in transcripts.
10
+ */
11
+ import { readFile } from "node:fs/promises";
12
+ import { resolve } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
17
+ import { scan } from "./scan.js";
18
+ import { ALL_RULE_IDS, rulesByIds, toSql } from "./normalize.js";
19
+ import { rowsFromCsv } from "./adapters/csv.js";
20
+ import { rowsFromJson } from "./adapters/json.js";
21
+ const CONFIG_PATH = resolve(process.env.SHADOWJOIN_CONFIG ?? "shadowjoin.config.json");
22
+ async function loadConfig() {
23
+ try {
24
+ const parsed = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
25
+ if (!parsed.sources || typeof parsed.sources !== "object")
26
+ throw new Error("missing `sources`");
27
+ return parsed;
28
+ }
29
+ catch (err) {
30
+ throw new Error(`Could not read ${CONFIG_PATH}. Create it with your named sources, e.g.\n` +
31
+ `{"sources":{"prod":{"type":"postgres","connectionString":"postgres://readonly@host/db"}}}\n` +
32
+ `(${err instanceof Error ? err.message : String(err)})`);
33
+ }
34
+ }
35
+ async function loadSide(side, config, limit) {
36
+ const def = config.sources[side.source];
37
+ if (!def) {
38
+ throw new Error(`Unknown source "${side.source}". Configured: ${Object.keys(config.sources).join(", ") || "(none)"}`);
39
+ }
40
+ const opts = { keyColumn: side.column, idColumn: side.idColumn, truthColumn: side.truthColumn };
41
+ if (def.type === "postgres") {
42
+ if (!side.table)
43
+ throw new Error(`Source "${side.source}" is a database; "table" is required.`);
44
+ const { rowsFromPostgres } = await import("./adapters/postgres.js");
45
+ return rowsFromPostgres({ connectionString: def.connectionString, table: side.table, limit, ...opts });
46
+ }
47
+ return def.type === "json" ? rowsFromJson(def.path, opts) : rowsFromCsv(def.path, opts);
48
+ }
49
+ const runs = new Map();
50
+ function summarize(runId, report) {
51
+ return {
52
+ runId,
53
+ leftRows: report.leftRows,
54
+ rightRows: report.rightRows,
55
+ exactMatches: report.exactMatches,
56
+ exactMatchRate: report.exactMatchRate,
57
+ recovered: report.recovered,
58
+ lowerBoundLossPct: report.lowerBoundLossPct,
59
+ reachableMatchRate: report.reachableMatchRate,
60
+ unexplained: report.unexplained,
61
+ precision: report.precision,
62
+ verified: report.verified,
63
+ contradicted: report.contradicted,
64
+ ambiguous: report.ambiguous,
65
+ topRules: report.stack.slice(0, 5).map((s) => ({ rule: s.rule, marginal: s.marginal })),
66
+ warnings: report.warnings,
67
+ note: "lowerBoundLossPct is a FLOOR, not an estimate: the shadow matcher's own recall is unknown, so the true loss is at least this large. Call explain_misses and show the user real pairs before acting on it.",
68
+ };
69
+ }
70
+ const SIDE_SCHEMA = {
71
+ type: "object",
72
+ properties: {
73
+ source: { type: "string", description: "Name of a source from shadowjoin.config.json. Not a connection string." },
74
+ table: { type: "string", description: "Table name (database sources only). Plain identifier; arbitrary SQL is rejected." },
75
+ column: { type: "string", description: "Column holding the join key." },
76
+ idColumn: { type: "string", description: "Column holding a stable row id." },
77
+ truthColumn: { type: "string", description: "Column holding ground truth. Supply on BOTH sides to get verified precision." },
78
+ },
79
+ required: ["source", "column"],
80
+ };
81
+ const TOOLS = [
82
+ {
83
+ name: "scan_join",
84
+ description: "Measure how many matches an exact join is silently dropping. Returns a compact summary including a FLOOR on lost matches and the rules worth adopting. Read-only. Call this before changing matching logic, and again after, to prove the change helped.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ left: SIDE_SCHEMA,
89
+ right: SIDE_SCHEMA,
90
+ rules: { type: "array", items: { type: "string" }, description: `Rule ids to enable. Default all: ${ALL_RULE_IDS.join(", ")}` },
91
+ limit: { type: "number", description: "Max rows per side. Default 100000." },
92
+ },
93
+ required: ["left", "right"],
94
+ },
95
+ },
96
+ {
97
+ name: "explain_misses",
98
+ description: "Return concrete example pairs from a previous scan, with the real key values and the rule that recovered each. Use this to show a human actual evidence instead of a percentage. May return key values, so prefer redact:true when the data is sensitive.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ runId: { type: "string" },
103
+ rule: { type: "string", description: "Only pairs attributed to this rule." },
104
+ limit: { type: "number", description: "Default 20." },
105
+ },
106
+ required: ["runId"],
107
+ },
108
+ },
109
+ {
110
+ name: "suggest_normalization",
111
+ description: "Turn a scan into a concrete fix: the rules worth adopting in order, each as a Postgres expression and a JavaScript function ready to paste into the matching pipeline.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: { runId: { type: "string" }, column: { type: "string", description: "Column name to build the SQL around. Default 'key'." } },
115
+ required: ["runId"],
116
+ },
117
+ },
118
+ {
119
+ name: "compare_runs",
120
+ description: "Compare two scans to prove whether a change to matching logic actually helped. Reports the change in recovered matches, reachable match rate and precision. Use after editing matching logic — never claim an improvement without this.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: { baselineRunId: { type: "string" }, candidateRunId: { type: "string" } },
124
+ required: ["baselineRunId", "candidateRunId"],
125
+ },
126
+ },
127
+ ];
128
+ const server = new Server({ name: "shadowjoin", version: "0.1.0" }, { capabilities: { tools: {} } });
129
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
130
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
131
+ const { name } = request.params;
132
+ const args = (request.params.arguments ?? {});
133
+ const ok = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] });
134
+ try {
135
+ if (name === "scan_join") {
136
+ const config = await loadConfig();
137
+ const limit = typeof args.limit === "number" ? args.limit : 100_000;
138
+ const [left, right] = await Promise.all([
139
+ loadSide(args.left, config, limit),
140
+ loadSide(args.right, config, limit),
141
+ ]);
142
+ const report = scan(left, right, {
143
+ rules: Array.isArray(args.rules) && args.rules.length ? args.rules : ALL_RULE_IDS,
144
+ maxSamplePairs: 50,
145
+ });
146
+ const runId = randomUUID().slice(0, 8);
147
+ runs.set(runId, { report, left: args.left, right: args.right });
148
+ return ok(summarize(runId, report));
149
+ }
150
+ if (name === "explain_misses") {
151
+ const run = runs.get(String(args.runId));
152
+ if (!run)
153
+ throw new Error(`Unknown runId "${args.runId}". Call scan_join first.`);
154
+ const limit = typeof args.limit === "number" ? args.limit : 20;
155
+ const pairs = run.report.samplePairs
156
+ .filter((p) => !args.rule || p.rule === args.rule)
157
+ .slice(0, limit);
158
+ return ok({
159
+ runId: args.runId,
160
+ pairs,
161
+ legend: {
162
+ verified: "ground truth confirms this pair",
163
+ contradicted: "ground truth says this pair is WRONG — the matcher over-merged",
164
+ ambiguous: "several right-hand rows collapsed onto one key; needs a tie-breaker, not a rule",
165
+ unverifiable: "no ground truth available for this pair",
166
+ },
167
+ });
168
+ }
169
+ if (name === "suggest_normalization") {
170
+ const run = runs.get(String(args.runId));
171
+ if (!run)
172
+ throw new Error(`Unknown runId "${args.runId}". Call scan_join first.`);
173
+ const column = typeof args.column === "string" ? args.column : "key";
174
+ const chosen = run.report.stack.map((s) => s.rule);
175
+ const steps = run.report.stack.map((step, i) => {
176
+ const upto = rulesByIds(chosen.slice(0, i + 1));
177
+ return { ...step, sql: toSql(column, upto) };
178
+ });
179
+ return ok({
180
+ runId: args.runId,
181
+ recommended: chosen,
182
+ steps,
183
+ fullSql: toSql(column, rulesByIds(chosen)),
184
+ javascript: "import { normalize, rulesByIds } from 'shadowjoin';\n" +
185
+ `const rules = rulesByIds(${JSON.stringify(chosen)});\n` +
186
+ "const key = (value) => normalize(value, rules);",
187
+ caution: "Adopting a rule widens matching and can merge records that should stay separate. Re-run scan_join afterwards and compare_runs to confirm precision did not fall.",
188
+ });
189
+ }
190
+ if (name === "compare_runs") {
191
+ const a = runs.get(String(args.baselineRunId));
192
+ const b = runs.get(String(args.candidateRunId));
193
+ if (!a || !b)
194
+ throw new Error("Both runIds must come from scan_join calls in this session.");
195
+ const delta = (x, y) => Number((y - x).toFixed(2));
196
+ return ok({
197
+ baseline: summarize(String(args.baselineRunId), a.report),
198
+ candidate: summarize(String(args.candidateRunId), b.report),
199
+ delta: {
200
+ exactMatches: delta(a.report.exactMatches, b.report.exactMatches),
201
+ recovered: delta(a.report.recovered, b.report.recovered),
202
+ reachableMatchRate: delta(a.report.reachableMatchRate, b.report.reachableMatchRate),
203
+ lowerBoundLossPct: delta(a.report.lowerBoundLossPct, b.report.lowerBoundLossPct),
204
+ precision: a.report.precision !== null && b.report.precision !== null ? delta(a.report.precision, b.report.precision) : null,
205
+ contradicted: delta(a.report.contradicted, b.report.contradicted),
206
+ },
207
+ verdict: b.report.exactMatchRate > a.report.exactMatchRate
208
+ ? "The exact join matches more rows than before."
209
+ : "The exact join does not match more rows than before. If you changed the matching logic, it did not take effect.",
210
+ });
211
+ }
212
+ throw new Error(`Unknown tool "${name}"`);
213
+ }
214
+ catch (err) {
215
+ return {
216
+ isError: true,
217
+ content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
218
+ };
219
+ }
220
+ });
221
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,19 @@
1
+ import type { Rule } from "./types.js";
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 declare const BASE_RULES: Rule[];
8
+ /**
9
+ * Each of these is measured INDEPENDENTLY on top of BASE_RULES, so the number
10
+ * next to a rule is what that rule alone buys you — not an artifact of the
11
+ * order rules happen to run in.
12
+ */
13
+ export declare const OPTIONAL_RULES: Rule[];
14
+ export declare const ALL_RULE_IDS: string[];
15
+ export declare function rulesByIds(ids: string[]): Rule[];
16
+ /** Apply BASE_RULES, then the supplied rules, in order. */
17
+ export declare function normalize(value: string, rules?: Rule[]): string;
18
+ /** Compose the enabled rules into one Postgres expression over a column. */
19
+ export declare function toSql(column: string, rules?: Rule[]): string;
@@ -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,17 @@
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
+ }
10
+ /**
11
+ * Compare an exact join against a shadow matcher and report what the exact
12
+ * join is losing.
13
+ *
14
+ * The figure this produces is a FLOOR. The shadow matcher has unknown recall of
15
+ * its own, so the true loss is at least this large and may be larger.
16
+ */
17
+ export declare function scan(left: Row[], right: Row[], options?: ScanOptions): Report;
package/dist/scan.js ADDED
@@ -0,0 +1,218 @@
1
+ import { ALL_RULE_IDS, normalize, rulesByIds } from "./normalize.js";
2
+ /** Tickers, SKUs and ids differ cosmetically across sources; compare them loosely. */
3
+ function canonicalTruth(value) {
4
+ return value.replace(/[^\p{L}\p{N}]+/gu, "").toUpperCase();
5
+ }
6
+ function redact(value) {
7
+ let h = 2166136261;
8
+ for (let i = 0; i < value.length; i++) {
9
+ h ^= value.charCodeAt(i);
10
+ h = Math.imul(h, 16777619);
11
+ }
12
+ return `#${(h >>> 0).toString(16).padStart(8, "0")}`;
13
+ }
14
+ function buildIndex(rows, rules) {
15
+ const index = new Map();
16
+ for (const row of rows) {
17
+ const key = rules === null ? row.key : normalize(row.key, rules);
18
+ if (!key)
19
+ continue;
20
+ const bucket = index.get(key);
21
+ if (bucket)
22
+ bucket.push(row);
23
+ else
24
+ index.set(key, [row]);
25
+ }
26
+ return index;
27
+ }
28
+ function verify(left, candidates) {
29
+ if (left.truth === undefined)
30
+ return "unverifiable";
31
+ const known = candidates.filter((c) => c.truth !== undefined);
32
+ if (known.length === 0)
33
+ return "unverifiable";
34
+ const target = canonicalTruth(left.truth);
35
+ const agreeing = known.filter((c) => canonicalTruth(c.truth) === target);
36
+ if (agreeing.length === 0)
37
+ return "contradicted";
38
+ if (agreeing.length === known.length)
39
+ return "verified";
40
+ return "ambiguous";
41
+ }
42
+ /**
43
+ * Compare an exact join against a shadow matcher and report what the exact
44
+ * join is losing.
45
+ *
46
+ * The figure this produces is a FLOOR. The shadow matcher has unknown recall of
47
+ * its own, so the true loss is at least this large and may be larger.
48
+ */
49
+ export function scan(left, right, options = {}) {
50
+ const ruleIds = options.rules ?? ALL_RULE_IDS;
51
+ const rules = rulesByIds(ruleIds);
52
+ const maxSamples = options.maxSamplePairs ?? 20;
53
+ const warnings = [];
54
+ if (left.length === 0 || right.length === 0) {
55
+ warnings.push("One side of the join is empty; there is nothing to measure.");
56
+ }
57
+ // 1. The baseline: the join exactly as it runs today.
58
+ const exactIndex = buildIndex(right, null);
59
+ const missed = [];
60
+ let exactMatches = 0;
61
+ for (const row of left) {
62
+ if (exactIndex.has(row.key))
63
+ exactMatches++;
64
+ else
65
+ missed.push(row);
66
+ }
67
+ // 2. Each rule measured independently against that baseline, so the number
68
+ // beside a rule is what the rule alone buys and not a product of ordering.
69
+ const attribution = new Map();
70
+ const byRule = rules.map((rule) => {
71
+ const index = buildIndex(right, [rule]);
72
+ let recovered = 0, verified = 0, contradicted = 0, ambiguous = 0;
73
+ for (const row of missed) {
74
+ const candidates = index.get(normalize(row.key, [rule]));
75
+ if (!candidates || candidates.length === 0)
76
+ continue;
77
+ recovered++;
78
+ const verdict = verify(row, candidates);
79
+ if (verdict === "verified")
80
+ verified++;
81
+ else if (verdict === "contradicted")
82
+ contradicted++;
83
+ else if (verdict === "ambiguous")
84
+ ambiguous++;
85
+ if (!attribution.has(row.id))
86
+ attribution.set(row.id, { rule: rule.id, candidates });
87
+ }
88
+ return { rule: rule.id, label: rule.label, recovered, verified, contradicted, ambiguous };
89
+ });
90
+ // 3. Greedy forward selection: which rules, in which order, actually pay.
91
+ // Most recoveries need a combination, so per-rule figures alone are not
92
+ // enough to act on.
93
+ const stack = [];
94
+ const covered = new Set();
95
+ const chosen = [];
96
+ let pool = [...rules];
97
+ while (pool.length > 0) {
98
+ let best = null;
99
+ let bestGain = 0;
100
+ let bestMatched = null;
101
+ for (const candidate of pool) {
102
+ const trial = [...chosen, candidate];
103
+ const index = buildIndex(right, trial);
104
+ const matched = new Set();
105
+ for (const row of missed) {
106
+ if (index.has(normalize(row.key, trial)))
107
+ matched.add(row.id);
108
+ }
109
+ let gain = 0;
110
+ for (const id of matched)
111
+ if (!covered.has(id))
112
+ gain++;
113
+ if (gain > bestGain) {
114
+ bestGain = gain;
115
+ best = candidate;
116
+ bestMatched = matched;
117
+ }
118
+ }
119
+ if (best === null || bestGain === 0)
120
+ break;
121
+ chosen.push(best);
122
+ for (const id of bestMatched)
123
+ covered.add(id);
124
+ stack.push({ rule: best.id, label: best.label, marginal: bestGain, cumulative: covered.size });
125
+ pool = pool.filter((r) => r !== best);
126
+ }
127
+ // 4. Attribute what only a COMBINATION explains. The greedy stack is tried
128
+ // first, because applying every rule can recover fewer rows than the best
129
+ // subset does — over-normalization merges keys that should stay apart.
130
+ const greedyIndex = buildIndex(right, chosen);
131
+ for (const row of missed) {
132
+ if (attribution.has(row.id))
133
+ continue;
134
+ const candidates = greedyIndex.get(normalize(row.key, chosen));
135
+ if (candidates && candidates.length > 0) {
136
+ attribution.set(row.id, { rule: "combined", candidates });
137
+ }
138
+ }
139
+ const fullIndex = buildIndex(right, rules);
140
+ let fullStackRecovered = 0;
141
+ for (const row of missed) {
142
+ const candidates = fullIndex.get(normalize(row.key, rules));
143
+ if (candidates && candidates.length > 0) {
144
+ fullStackRecovered++;
145
+ if (!attribution.has(row.id))
146
+ attribution.set(row.id, { rule: "combined", candidates });
147
+ }
148
+ }
149
+ // 5. Tally and collect examples.
150
+ let verified = 0, contradicted = 0, ambiguous = 0, unverifiable = 0;
151
+ const samplePairs = [];
152
+ const missedById = new Map(missed.map((r) => [r.id, r]));
153
+ for (const [leftId, { rule, candidates }] of attribution) {
154
+ const row = missedById.get(leftId);
155
+ const verdict = verify(row, candidates);
156
+ if (verdict === "verified")
157
+ verified++;
158
+ else if (verdict === "contradicted")
159
+ contradicted++;
160
+ else if (verdict === "ambiguous")
161
+ ambiguous++;
162
+ else
163
+ unverifiable++;
164
+ if (samplePairs.length < maxSamples) {
165
+ const best = candidates[0];
166
+ samplePairs.push({
167
+ rule,
168
+ leftId: row.id,
169
+ leftKey: options.redactSamples ? redact(row.key) : row.key,
170
+ rightId: best.id,
171
+ rightKey: options.redactSamples ? redact(best.key) : best.key,
172
+ candidates: candidates.length,
173
+ verification: verdict,
174
+ });
175
+ }
176
+ }
177
+ const recovered = attribution.size;
178
+ const unexplained = missed.length - recovered;
179
+ const pct = (n) => (left.length === 0 ? 0 : Number(((n / left.length) * 100).toFixed(2)));
180
+ if (rules.length > 1) {
181
+ warnings.push("Per-rule counts are measured independently against the exact baseline, so they overlap and will not sum to the total.");
182
+ }
183
+ if (chosen.length > 0 && chosen.length < rules.length && fullStackRecovered < covered.size) {
184
+ 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(",")}`);
185
+ }
186
+ if (contradicted > 0) {
187
+ warnings.push(`${contradicted} recovered match(es) are contradicted by ground truth — the shadow matcher is over-merging. Review samplePairs before acting.`);
188
+ }
189
+ if (ambiguous > 0) {
190
+ 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.`);
191
+ }
192
+ if (unverifiable > 0 && verified + contradicted === 0) {
193
+ 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.");
194
+ }
195
+ return {
196
+ generatedAt: new Date().toISOString(),
197
+ leftRows: left.length,
198
+ rightRows: right.length,
199
+ exactMatches,
200
+ exactMatchRate: pct(exactMatches),
201
+ recovered,
202
+ unexplained,
203
+ reachableMatchRate: pct(exactMatches + recovered),
204
+ lowerBoundLossPct: pct(recovered),
205
+ byRule: byRule.sort((a, b) => b.recovered - a.recovered),
206
+ stack,
207
+ verified,
208
+ contradicted,
209
+ ambiguous,
210
+ unverifiable,
211
+ precision: verified + contradicted === 0
212
+ ? null
213
+ : Number(((verified / (verified + contradicted)) * 100).toFixed(2)),
214
+ samplePairs,
215
+ warnings,
216
+ rulesApplied: ruleIds,
217
+ };
218
+ }