goldenmatch 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/README.md +140 -0
- package/dist/cli.cjs +6079 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +6076 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/index.cjs +8449 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +1972 -0
- package/dist/core/index.d.ts +1972 -0
- package/dist/core/index.js +8318 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index.cjs +8449 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8318 -0
- package/dist/index.js.map +1 -0
- package/dist/node/backends/score-worker.cjs +934 -0
- package/dist/node/backends/score-worker.cjs.map +1 -0
- package/dist/node/backends/score-worker.d.cts +14 -0
- package/dist/node/backends/score-worker.d.ts +14 -0
- package/dist/node/backends/score-worker.js +932 -0
- package/dist/node/backends/score-worker.js.map +1 -0
- package/dist/node/index.cjs +11430 -0
- package/dist/node/index.cjs.map +1 -0
- package/dist/node/index.d.cts +554 -0
- package/dist/node/index.d.ts +554 -0
- package/dist/node/index.js +11277 -0
- package/dist/node/index.js.map +1 -0
- package/dist/types-DhUdX5Rc.d.cts +304 -0
- package/dist/types-DhUdX5Rc.d.ts +304 -0
- package/examples/01-basic-dedupe.ts +60 -0
- package/examples/02-match-two-datasets.ts +48 -0
- package/examples/03-csv-file-pipeline.ts +62 -0
- package/examples/04-string-scoring.ts +63 -0
- package/examples/05-custom-config.ts +94 -0
- package/examples/06-probabilistic-fs.ts +72 -0
- package/examples/07-pprl-privacy.ts +76 -0
- package/examples/08-streaming.ts +79 -0
- package/examples/09-llm-scorer.ts +79 -0
- package/examples/10-explain.ts +60 -0
- package/examples/11-evaluate.ts +61 -0
- package/examples/README.md +53 -0
- package/package.json +66 -0
- package/src/cli.ts +372 -0
- package/src/core/ann-blocker.ts +593 -0
- package/src/core/api.ts +220 -0
- package/src/core/autoconfig.ts +363 -0
- package/src/core/autofix.ts +102 -0
- package/src/core/blocker.ts +655 -0
- package/src/core/cluster.ts +699 -0
- package/src/core/compare-clusters.ts +176 -0
- package/src/core/config/loader.ts +869 -0
- package/src/core/cross-encoder.ts +614 -0
- package/src/core/data.ts +430 -0
- package/src/core/domain.ts +277 -0
- package/src/core/embedder.ts +562 -0
- package/src/core/evaluate.ts +156 -0
- package/src/core/explain.ts +352 -0
- package/src/core/golden.ts +524 -0
- package/src/core/graph-er.ts +371 -0
- package/src/core/index.ts +314 -0
- package/src/core/ingest.ts +112 -0
- package/src/core/learned-blocking.ts +305 -0
- package/src/core/lineage.ts +221 -0
- package/src/core/llm/budget.ts +258 -0
- package/src/core/llm/cluster.ts +542 -0
- package/src/core/llm/scorer.ts +396 -0
- package/src/core/match-one.ts +95 -0
- package/src/core/matchkey.ts +97 -0
- package/src/core/memory/corrections.ts +179 -0
- package/src/core/memory/learner.ts +218 -0
- package/src/core/memory/store.ts +114 -0
- package/src/core/pipeline.ts +366 -0
- package/src/core/pprl/protocol.ts +216 -0
- package/src/core/probabilistic.ts +511 -0
- package/src/core/profiler.ts +212 -0
- package/src/core/quality.ts +197 -0
- package/src/core/review-queue.ts +177 -0
- package/src/core/scorer.ts +855 -0
- package/src/core/sensitivity.ts +196 -0
- package/src/core/standardize.ts +279 -0
- package/src/core/streaming.ts +128 -0
- package/src/core/transforms.ts +599 -0
- package/src/core/types.ts +570 -0
- package/src/core/validate.ts +243 -0
- package/src/index.ts +8 -0
- package/src/node/a2a/server.ts +470 -0
- package/src/node/api/server.ts +412 -0
- package/src/node/backends/duckdb.ts +130 -0
- package/src/node/backends/score-worker.ts +41 -0
- package/src/node/backends/workers.ts +212 -0
- package/src/node/config-file.ts +66 -0
- package/src/node/connectors/base.ts +57 -0
- package/src/node/connectors/bigquery.ts +61 -0
- package/src/node/connectors/databricks.ts +69 -0
- package/src/node/connectors/file.ts +350 -0
- package/src/node/connectors/hubspot.ts +62 -0
- package/src/node/connectors/index.ts +43 -0
- package/src/node/connectors/salesforce.ts +93 -0
- package/src/node/connectors/snowflake.ts +73 -0
- package/src/node/db/postgres.ts +173 -0
- package/src/node/db/sync.ts +103 -0
- package/src/node/dedupe-file.ts +156 -0
- package/src/node/index.ts +89 -0
- package/src/node/mcp/server.ts +940 -0
- package/src/node/tui/app.ts +756 -0
- package/src/node/tui/index.ts +6 -0
- package/src/node/tui/widgets.ts +128 -0
- package/tests/parity/scorer-ground-truth.test.ts +118 -0
- package/tests/smoke.test.ts +46 -0
- package/tests/unit/a2a-server.test.ts +175 -0
- package/tests/unit/ann-blocker.test.ts +117 -0
- package/tests/unit/api-server.test.ts +239 -0
- package/tests/unit/api.test.ts +77 -0
- package/tests/unit/autoconfig.test.ts +103 -0
- package/tests/unit/autofix.test.ts +71 -0
- package/tests/unit/blocker.test.ts +164 -0
- package/tests/unit/buildBlocksAsync.test.ts +63 -0
- package/tests/unit/cluster.test.ts +213 -0
- package/tests/unit/compare-clusters.test.ts +42 -0
- package/tests/unit/config-loader.test.ts +301 -0
- package/tests/unit/connectors-base.test.ts +48 -0
- package/tests/unit/cross-encoder-model.test.ts +198 -0
- package/tests/unit/cross-encoder.test.ts +173 -0
- package/tests/unit/db-connectors.test.ts +37 -0
- package/tests/unit/domain.test.ts +80 -0
- package/tests/unit/embedder.test.ts +151 -0
- package/tests/unit/evaluate.test.ts +85 -0
- package/tests/unit/explain.test.ts +73 -0
- package/tests/unit/golden.test.ts +97 -0
- package/tests/unit/graph-er.test.ts +173 -0
- package/tests/unit/hnsw-ann.test.ts +283 -0
- package/tests/unit/hubspot-connector.test.ts +118 -0
- package/tests/unit/ingest.test.ts +97 -0
- package/tests/unit/learned-blocking.test.ts +134 -0
- package/tests/unit/lineage.test.ts +135 -0
- package/tests/unit/match-one.test.ts +129 -0
- package/tests/unit/matchkey.test.ts +97 -0
- package/tests/unit/mcp-server.test.ts +183 -0
- package/tests/unit/memory.test.ts +119 -0
- package/tests/unit/pipeline.test.ts +118 -0
- package/tests/unit/pprl-protocol.test.ts +381 -0
- package/tests/unit/probabilistic.test.ts +494 -0
- package/tests/unit/profiler.test.ts +68 -0
- package/tests/unit/review-queue.test.ts +68 -0
- package/tests/unit/salesforce-connector.test.ts +148 -0
- package/tests/unit/scorer.test.ts +301 -0
- package/tests/unit/sensitivity.test.ts +154 -0
- package/tests/unit/standardize.test.ts +84 -0
- package/tests/unit/streaming.test.ts +82 -0
- package/tests/unit/transforms.test.ts +208 -0
- package/tests/unit/tui-widgets.test.ts +42 -0
- package/tests/unit/tui.test.ts +24 -0
- package/tests/unit/validate.test.ts +145 -0
- package/tests/unit/workers-parallel.test.ts +99 -0
- package/tests/unit/workers.test.ts +74 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +37 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* explain.ts — Natural language explanations for match decisions.
|
|
3
|
+
* Ports `goldenmatch/core/explain.py` (+ parts of `explainer.py`).
|
|
4
|
+
*
|
|
5
|
+
* Template-based, zero LLM cost. Produces human-readable summaries of why
|
|
6
|
+
* two records matched, plus cluster-level summaries.
|
|
7
|
+
*
|
|
8
|
+
* Edge-safe: no `node:` imports.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
Row,
|
|
13
|
+
MatchkeyConfig,
|
|
14
|
+
MatchkeyField,
|
|
15
|
+
ClusterInfo,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import { scoreField, asString } from "./scorer.js";
|
|
18
|
+
import { pairKey } from "./cluster.js";
|
|
19
|
+
import { applyTransforms } from "./transforms.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Score descriptors
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const SCORE_DESCRIPTORS: ReadonlyArray<readonly [number, string]> = [
|
|
26
|
+
[0.95, "identical"],
|
|
27
|
+
[0.85, "very similar"],
|
|
28
|
+
[0.7, "similar"],
|
|
29
|
+
[0.5, "somewhat similar"],
|
|
30
|
+
[0.3, "weakly similar"],
|
|
31
|
+
[0.0, "different"],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const SCORER_NAMES: Readonly<Record<string, string>> = {
|
|
35
|
+
jaro_winkler: "string similarity",
|
|
36
|
+
levenshtein: "edit distance",
|
|
37
|
+
token_sort: "token similarity",
|
|
38
|
+
soundex_match: "phonetic match",
|
|
39
|
+
exact: "exact match",
|
|
40
|
+
ensemble: "best-of-multiple",
|
|
41
|
+
dice: "Dice coefficient",
|
|
42
|
+
jaccard: "Jaccard similarity",
|
|
43
|
+
embedding: "semantic similarity",
|
|
44
|
+
record_embedding: "record similarity",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function describeScore(score: number): string {
|
|
48
|
+
for (const [threshold, desc] of SCORE_DESCRIPTORS) {
|
|
49
|
+
if (score >= threshold) return desc;
|
|
50
|
+
}
|
|
51
|
+
return "different";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function describeScorer(name: string): string {
|
|
55
|
+
return SCORER_NAMES[name] ?? name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Public types
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export interface FieldScoreDetail {
|
|
63
|
+
readonly field: string;
|
|
64
|
+
readonly scorer: string;
|
|
65
|
+
readonly valueA: string | null;
|
|
66
|
+
readonly valueB: string | null;
|
|
67
|
+
readonly score: number | null;
|
|
68
|
+
readonly weight: number;
|
|
69
|
+
readonly diffType: "identical" | "similar" | "different" | "missing" | "unknown";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PairExplanation {
|
|
73
|
+
readonly score: number;
|
|
74
|
+
readonly fieldScores: Readonly<Record<string, number | null>>;
|
|
75
|
+
readonly explanation: string;
|
|
76
|
+
readonly confidence: "high" | "medium" | "low";
|
|
77
|
+
readonly reasoning: readonly string[];
|
|
78
|
+
readonly details: readonly FieldScoreDetail[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ClusterExplanation {
|
|
82
|
+
readonly clusterId: number;
|
|
83
|
+
readonly size: number;
|
|
84
|
+
readonly confidence: number;
|
|
85
|
+
readonly quality: string;
|
|
86
|
+
readonly summary: string;
|
|
87
|
+
readonly strongestField: string | null;
|
|
88
|
+
readonly weakestLink: readonly [number, number] | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Formatting helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function fmtVal(v: string | null): string {
|
|
96
|
+
if (v === null || v === undefined) return "[null]";
|
|
97
|
+
const s = String(v).trim();
|
|
98
|
+
if (s.length > 40) return s.slice(0, 37) + "...";
|
|
99
|
+
return s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function classifyDiff(
|
|
103
|
+
score: number | null,
|
|
104
|
+
): "identical" | "similar" | "different" | "missing" {
|
|
105
|
+
if (score === null) return "missing";
|
|
106
|
+
if (score >= 0.99) return "identical";
|
|
107
|
+
if (score >= 0.7) return "similar";
|
|
108
|
+
return "different";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function confidenceBand(score: number): "high" | "medium" | "low" {
|
|
112
|
+
if (score >= 0.9) return "high";
|
|
113
|
+
if (score >= 0.75) return "medium";
|
|
114
|
+
return "low";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Per-field scoring (used by both pair and cluster explanation)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function scoreFieldDetail(
|
|
122
|
+
rowA: Row,
|
|
123
|
+
rowB: Row,
|
|
124
|
+
field: MatchkeyField,
|
|
125
|
+
): FieldScoreDetail {
|
|
126
|
+
const rawA = asString(rowA[field.field]);
|
|
127
|
+
const rawB = asString(rowB[field.field]);
|
|
128
|
+
const valA = applyTransforms(rawA, field.transforms);
|
|
129
|
+
const valB = applyTransforms(rawB, field.transforms);
|
|
130
|
+
const score = scoreField(valA, valB, field.scorer);
|
|
131
|
+
return {
|
|
132
|
+
field: field.field,
|
|
133
|
+
scorer: field.scorer,
|
|
134
|
+
valueA: valA,
|
|
135
|
+
valueB: valB,
|
|
136
|
+
score,
|
|
137
|
+
weight: field.weight,
|
|
138
|
+
diffType: classifyDiff(score),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function aggregateScore(details: readonly FieldScoreDetail[]): number {
|
|
143
|
+
let weightedSum = 0;
|
|
144
|
+
let weightSum = 0;
|
|
145
|
+
for (const d of details) {
|
|
146
|
+
if (d.score === null) continue;
|
|
147
|
+
weightedSum += d.score * d.weight;
|
|
148
|
+
weightSum += d.weight;
|
|
149
|
+
}
|
|
150
|
+
return weightSum === 0 ? 0 : weightedSum / weightSum;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Public: explainPair
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Produce an NL explanation for why two rows match (or don't), using the
|
|
159
|
+
* scorers and weights defined by the matchkey config.
|
|
160
|
+
*/
|
|
161
|
+
export function explainPair(
|
|
162
|
+
rowA: Row,
|
|
163
|
+
rowB: Row,
|
|
164
|
+
mk: MatchkeyConfig,
|
|
165
|
+
): PairExplanation {
|
|
166
|
+
const details = mk.fields.map((f) => scoreFieldDetail(rowA, rowB, f));
|
|
167
|
+
const overall = aggregateScore(details);
|
|
168
|
+
|
|
169
|
+
// Sort by contribution (score * weight) descending.
|
|
170
|
+
const sorted = [...details].sort((a, b) => {
|
|
171
|
+
const aw = (a.score ?? 0) * a.weight;
|
|
172
|
+
const bw = (b.score ?? 0) * b.weight;
|
|
173
|
+
return bw - aw;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Build per-field phrases.
|
|
177
|
+
const reasoning: string[] = [];
|
|
178
|
+
let weakest: FieldScoreDetail | null = null;
|
|
179
|
+
let weakestScore = 1.0;
|
|
180
|
+
|
|
181
|
+
for (const d of sorted) {
|
|
182
|
+
if (d.score !== null && d.score < weakestScore) {
|
|
183
|
+
weakestScore = d.score;
|
|
184
|
+
weakest = d;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const scorerDesc = describeScorer(d.scorer);
|
|
188
|
+
if (d.diffType === "missing") {
|
|
189
|
+
reasoning.push(`${d.field} missing on one side`);
|
|
190
|
+
} else if (d.diffType === "identical" || (d.score ?? 0) >= 0.99) {
|
|
191
|
+
reasoning.push(`${d.field} match exactly (${fmtVal(d.valueA)})`);
|
|
192
|
+
} else if ((d.score ?? 0) >= 0.8) {
|
|
193
|
+
reasoning.push(
|
|
194
|
+
`${d.field} are ${describeScore(d.score!)} ` +
|
|
195
|
+
`(${fmtVal(d.valueA)} ~ ${fmtVal(d.valueB)}, ` +
|
|
196
|
+
`${scorerDesc} ${d.score!.toFixed(2)})`,
|
|
197
|
+
);
|
|
198
|
+
} else if ((d.score ?? 0) > 0) {
|
|
199
|
+
reasoning.push(
|
|
200
|
+
`${d.field} differ ` +
|
|
201
|
+
`(${fmtVal(d.valueA)} vs ${fmtVal(d.valueB)}, ` +
|
|
202
|
+
`${scorerDesc} ${d.score!.toFixed(2)})`,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
reasoning.push(
|
|
206
|
+
`${d.field} do not match ` +
|
|
207
|
+
`(${fmtVal(d.valueA)} vs ${fmtVal(d.valueB)})`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build top-line explanation.
|
|
213
|
+
const overallDesc = describeScore(overall);
|
|
214
|
+
const header = `Match (${overallDesc}, score ${overall.toFixed(2)}):`;
|
|
215
|
+
const body = reasoning.join("; ");
|
|
216
|
+
const weakestNote =
|
|
217
|
+
weakest && weakestScore < 0.8 ? ` Weakest signal: ${weakest.field}.` : "";
|
|
218
|
+
const explanation = `${header} ${body}.${weakestNote}`.replace(/\s+/g, " ").trim();
|
|
219
|
+
|
|
220
|
+
// Field scores map.
|
|
221
|
+
const fieldScores: Record<string, number | null> = {};
|
|
222
|
+
for (const d of details) fieldScores[d.field] = d.score;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
score: overall,
|
|
226
|
+
fieldScores,
|
|
227
|
+
explanation,
|
|
228
|
+
confidence: confidenceBand(overall),
|
|
229
|
+
reasoning,
|
|
230
|
+
details,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Public: explainCluster
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Produce a template summary for a cluster: size, confidence, weakest link.
|
|
240
|
+
* Mirrors `explain_cluster_nl` in Python.
|
|
241
|
+
*/
|
|
242
|
+
export function explainCluster(
|
|
243
|
+
clusterId: number,
|
|
244
|
+
cluster: ClusterInfo,
|
|
245
|
+
rows: readonly Row[],
|
|
246
|
+
mk: MatchkeyConfig,
|
|
247
|
+
): ClusterExplanation {
|
|
248
|
+
const size = cluster.size;
|
|
249
|
+
const confidence = cluster.confidence;
|
|
250
|
+
const pairScores = cluster.pairScores;
|
|
251
|
+
|
|
252
|
+
if (size <= 1) {
|
|
253
|
+
return {
|
|
254
|
+
clusterId,
|
|
255
|
+
size,
|
|
256
|
+
confidence,
|
|
257
|
+
quality: cluster.clusterQuality,
|
|
258
|
+
summary: "Singleton cluster with 1 record.",
|
|
259
|
+
strongestField: null,
|
|
260
|
+
weakestLink: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Score statistics.
|
|
265
|
+
const scores: number[] = [];
|
|
266
|
+
pairScores.forEach((s) => scores.push(s));
|
|
267
|
+
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
|
|
268
|
+
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
|
|
269
|
+
const avgScore =
|
|
270
|
+
scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
|
|
271
|
+
|
|
272
|
+
const parts: string[] = [];
|
|
273
|
+
parts.push(
|
|
274
|
+
`Cluster of ${size} records ` +
|
|
275
|
+
`(confidence ${confidence.toFixed(2)}, ` +
|
|
276
|
+
`scores ${minScore.toFixed(2)}-${maxScore.toFixed(2)}, ` +
|
|
277
|
+
`avg ${avgScore.toFixed(2)}).`,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (cluster.bottleneckPair !== null) {
|
|
281
|
+
const [a, b] = cluster.bottleneckPair;
|
|
282
|
+
const bpScore = pairScores.get(pairKey(a, b)) ?? 0;
|
|
283
|
+
parts.push(
|
|
284
|
+
`Weakest link: records ${a} and ${b} (score ${bpScore.toFixed(2)}).`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (cluster.oversized) {
|
|
289
|
+
parts.push("WARNING: cluster exceeds max size limit.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Identify the strongest field by averaging per-field scores across member pairs.
|
|
293
|
+
const strongestField = computeStrongestField(cluster, rows, mk);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
clusterId,
|
|
297
|
+
size,
|
|
298
|
+
confidence,
|
|
299
|
+
quality: cluster.clusterQuality,
|
|
300
|
+
summary: parts.join(" "),
|
|
301
|
+
strongestField,
|
|
302
|
+
weakestLink: cluster.bottleneckPair,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function computeStrongestField(
|
|
307
|
+
cluster: ClusterInfo,
|
|
308
|
+
rows: readonly Row[],
|
|
309
|
+
mk: MatchkeyConfig,
|
|
310
|
+
): string | null {
|
|
311
|
+
if (mk.fields.length === 0) return null;
|
|
312
|
+
|
|
313
|
+
const rowById = new Map<number, Row>();
|
|
314
|
+
for (const r of rows) {
|
|
315
|
+
const id = r["__row_id__"];
|
|
316
|
+
if (typeof id === "number") rowById.set(id, r);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const fieldSums: Record<string, { sum: number; count: number }> = {};
|
|
320
|
+
for (const f of mk.fields) {
|
|
321
|
+
fieldSums[f.field] = { sum: 0, count: 0 };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Sample every pair in the cluster.
|
|
325
|
+
const members = cluster.members;
|
|
326
|
+
for (let i = 0; i < members.length; i++) {
|
|
327
|
+
for (let j = i + 1; j < members.length; j++) {
|
|
328
|
+
const rowA = rowById.get(members[i]!);
|
|
329
|
+
const rowB = rowById.get(members[j]!);
|
|
330
|
+
if (!rowA || !rowB) continue;
|
|
331
|
+
for (const f of mk.fields) {
|
|
332
|
+
const d = scoreFieldDetail(rowA, rowB, f);
|
|
333
|
+
if (d.score === null) continue;
|
|
334
|
+
const entry = fieldSums[f.field]!;
|
|
335
|
+
entry.sum += d.score;
|
|
336
|
+
entry.count += 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let best: string | null = null;
|
|
342
|
+
let bestAvg = -1;
|
|
343
|
+
for (const [name, { sum, count }] of Object.entries(fieldSums)) {
|
|
344
|
+
if (count === 0) continue;
|
|
345
|
+
const avg = sum / count;
|
|
346
|
+
if (avg > bestAvg) {
|
|
347
|
+
bestAvg = avg;
|
|
348
|
+
best = name;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return best;
|
|
352
|
+
}
|