@mailwoman/match 7.2.0 → 7.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/em.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Unsupervised parameter estimation for the Fellegi-Sunter model — the part that makes the matcher
7
+ * work with no labeled data.
8
+ *
9
+ * The paradox: to estimate `m`/`u` you need to know which pairs match, but finding matches is the
10
+ * whole problem. EM (Winkler 1988) breaks it by treating the match/non-match status as a hidden
11
+ * variable and iterating:
12
+ *
13
+ * - **E-step** — under the current parameters, compute each pair's posterior responsibility `g =
14
+ * P(match | its agreement pattern)`.
15
+ * - **M-step** — re-estimate `λ`, and each level's `m`/`u`, as `g`-weighted (resp. `(1-g)`-weighted)
16
+ * fractions of the pairs landing in that level.
17
+ *
18
+ * It converges because true matches agree on most fields and non-matches don't, so the two classes
19
+ * pull apart. Assumes conditional independence of the comparisons given match status (the
20
+ * standard F-S assumption). Caveat from the literature: EM can land in a local optimum when the
21
+ * true match rate is very low — seed from sensible `m`/`u` (the model's existing levels do this)
22
+ * and sanity- check that the recovered `m` exceeds `u` on the top agreement level.
23
+ */
24
+
25
+ import type { Comparison, FellegiSunterModel } from "./fellegi-sunter.ts"
26
+
27
+ /**
28
+ * Tiny floor mixed into the M-step so an unobserved level never produces a zero (→ infinite weight).
29
+ */
30
+ const EPSILON = 1e-9
31
+
32
+ /** Reduce a record pair to its agreement pattern — the per-comparison level index (`-1` = missing). */
33
+ export function agreementPattern<R>(comparisons: Comparison<R>[], a: R, b: R): number[] {
34
+ return comparisons.map((comparison) => comparison.assess(a, b))
35
+ }
36
+
37
+ /** Options for {@link estimateParameters}. */
38
+ export interface EmOptions {
39
+ /** Hard iteration cap. Default 100. */
40
+ maxIterations?: number
41
+ /** Convergence tolerance on the largest parameter change between iterations. Default 1e-6. */
42
+ tolerance?: number
43
+ /** Starting prior match rate. Defaults to the model's `lambda`. */
44
+ initialLambda?: number
45
+ }
46
+
47
+ /** The fitted model plus convergence diagnostics. */
48
+ export interface EmResult<R> {
49
+ /** The input model with every level's `m`/`u` and the prior `lambda` re-estimated. */
50
+ model: FellegiSunterModel<R>
51
+ /** The estimated prior match rate. */
52
+ lambda: number
53
+ iterations: number
54
+ converged: boolean
55
+ }
56
+
57
+ /**
58
+ * Estimate `m`/`u` and the prior `λ` from unlabeled agreement patterns via EM. The patterns are per-comparison level
59
+ * indices (as produced by {@link agreementPattern}); a `-1` (missing) field contributes no evidence to either class. The
60
+ * model's existing level `m`/`u` seed the iteration.
61
+ */
62
+ export function estimateParameters<R>(
63
+ model: FellegiSunterModel<R>,
64
+ patterns: number[][],
65
+ opts: EmOptions = {}
66
+ ): EmResult<R> {
67
+ const maxIterations = opts.maxIterations ?? 100
68
+ const tolerance = opts.tolerance ?? 1e-6
69
+ const comparisons = model.comparisons
70
+ const levelCounts = comparisons.map((c) => c.levels.length)
71
+
72
+ // Per-comparison, per-level m/u, seeded from the model's current levels.
73
+ const m = comparisons.map((c) => c.levels.map((l) => l.m))
74
+ const u = comparisons.map((c) => c.levels.map((l) => l.u))
75
+ let lambda = opts.initialLambda ?? model.lambda
76
+
77
+ let iterations = 0
78
+ let converged = false
79
+
80
+ if (patterns.length === 0) {
81
+ return { model, lambda, iterations, converged }
82
+ }
83
+
84
+ for (; iterations < maxIterations; iterations++) {
85
+ const mNumerator = comparisons.map((_, i) => new Array<number>(levelCounts[i]!).fill(0))
86
+ const uNumerator = comparisons.map((_, i) => new Array<number>(levelCounts[i]!).fill(0))
87
+ const mDenominator = comparisons.map(() => 0)
88
+ const uDenominator = comparisons.map(() => 0)
89
+ let responsibilitySum = 0
90
+
91
+ // E-step: posterior P(match | pattern) for each pair.
92
+ for (const pattern of patterns) {
93
+ let matchLikelihood = lambda
94
+ let nonMatchLikelihood = 1 - lambda
95
+
96
+ for (let i = 0; i < comparisons.length; i++) {
97
+ const level = pattern[i]!
98
+
99
+ if (level < 0) continue
100
+ matchLikelihood *= m[i]![level]!
101
+ nonMatchLikelihood *= u[i]![level]!
102
+ }
103
+ const total = matchLikelihood + nonMatchLikelihood
104
+ const g = total > 0 ? matchLikelihood / total : 0
105
+ responsibilitySum += g
106
+
107
+ for (let i = 0; i < comparisons.length; i++) {
108
+ const level = pattern[i]!
109
+
110
+ if (level < 0) continue
111
+ mNumerator[i]![level]! += g
112
+ uNumerator[i]![level]! += 1 - g
113
+ mDenominator[i]! += g
114
+ uDenominator[i]! += 1 - g
115
+ }
116
+ }
117
+
118
+ // M-step: re-estimate λ and each level's m/u as (1-)g-weighted fractions.
119
+ const newLambda = responsibilitySum / patterns.length
120
+ let maxDelta = Math.abs(newLambda - lambda)
121
+ lambda = newLambda
122
+
123
+ for (let i = 0; i < comparisons.length; i++) {
124
+ const levels = levelCounts[i]!
125
+
126
+ for (let l = 0; l < levels; l++) {
127
+ const newM =
128
+ mDenominator[i]! > 0 ? (mNumerator[i]![l]! + EPSILON) / (mDenominator[i]! + EPSILON * levels) : m[i]![l]!
129
+ const newU =
130
+ uDenominator[i]! > 0 ? (uNumerator[i]![l]! + EPSILON) / (uDenominator[i]! + EPSILON * levels) : u[i]![l]!
131
+ maxDelta = Math.max(maxDelta, Math.abs(newM - m[i]![l]!), Math.abs(newU - u[i]![l]!))
132
+ m[i]![l] = newM
133
+ u[i]![l] = newU
134
+ }
135
+ }
136
+
137
+ if (maxDelta < tolerance) {
138
+ converged = true
139
+ iterations++
140
+ break
141
+ }
142
+ }
143
+
144
+ const fittedComparisons = comparisons.map((c, i) => ({
145
+ ...c,
146
+ levels: c.levels.map((level, j) => ({ ...level, m: m[i]![j]!, u: u[i]![j]! })),
147
+ }))
148
+
149
+ return { model: { comparisons: fittedComparisons, lambda }, lambda, iterations, converged }
150
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The Fellegi-Sunter scorer — the matcher's decision layer.
7
+ *
8
+ * Each field comparison lands a record pair in a discrete _agreement level_ (exact / high / low /
9
+ * different / missing). Each level carries two probabilities: `m` = P(this level | the pair
10
+ * really matches) and `u` = P(this level | it doesn't). Their ratio is a Bayes factor, and its
11
+ * log is the level's contribution to the total match weight in bits:
12
+ *
13
+ * ```
14
+ * M = log2(λ / (1 - λ)) + Σ_fields log2(m_level / u_level)
15
+ * ```
16
+ *
17
+ * — a prior (how likely any two random records match) plus an additive, per-field-attributable
18
+ * stack of evidence. Convert `M` to a probability and threshold it: above the upper bound is a
19
+ * link, below the lower bound a non-link, and the band between is _clerical review_ — the
20
+ * calibrated abstain zone the whole design leans on.
21
+ *
22
+ * The `m`/`u` numbers here are NOT universal constants. They are estimated from the data — by EM,
23
+ * unsupervised (the next increment) — and the term-frequency adjustment that makes a rare-name
24
+ * agreement count more than a common one layers on top. This module is the deterministic core
25
+ * those build on: given the levels, it produces the weights, the probability, and the decision.
26
+ */
27
+
28
+ import { nameSimilarity } from "./comparators.ts"
29
+
30
+ /** One agreement level of a comparison, with its match / non-match probabilities. */
31
+ export interface ComparisonLevel {
32
+ /** Human-readable label for debugging (`exact`, `high`, `different`). */
33
+ label: string
34
+ /** P(a pair lands in this level | it is a true match). A measure of data quality. */
35
+ m: number
36
+ /** P(a pair lands in this level | it is NOT a match). A measure of coincidence / cardinality. */
37
+ u: number
38
+ /** For similarity-driven comparisons: the minimum similarity (inclusive) to qualify. */
39
+ minSimilarity?: number
40
+ /** For distance-driven comparisons: the maximum distance in km (inclusive) to qualify. */
41
+ maxKm?: number
42
+ }
43
+
44
+ /** A per-field comparison: pull a value from each record and assign an agreement level. */
45
+ export interface Comparison<R> {
46
+ /** Field name, for attribution. */
47
+ name: string
48
+ /** Levels ordered highest agreement → lowest (`exact` first, `different` last). */
49
+ levels: ComparisonLevel[]
50
+ /** Index into {@link levels}, or `-1` when either value is missing (no evidence → weight 0). */
51
+ assess(a: R, b: R): number
52
+ /**
53
+ * Optional term-frequency adjustment: on the levels it names, replace the level's average `u` with the agreeing
54
+ * value's actual frequency, so agreement on a rare value (`Vijayan`) outweighs agreement on a common one (`Smith`).
55
+ * See `withTermFrequency`.
56
+ */
57
+ termFrequency?: TermFrequencyAdjustment<R>
58
+ }
59
+
60
+ /**
61
+ * Per-value term-frequency adjustment for a comparison (the Splink/Winkler mechanism). `m` is unchanged; on an
62
+ * agreement level the effective `u` becomes the value's own frequency, adding `log2(u_level / frequency)` to the weight
63
+ * — large and positive for rare values, negative for common ones. Floored at
64
+ * {@link TermFrequencyAdjustment.minimumFrequency} so an ultra-rare value can't produce an unbounded boost.
65
+ */
66
+ export interface TermFrequencyAdjustment<R> {
67
+ /** Relative frequency of a value in the data, in (0, 1]. Typically computed on-the-fly. */
68
+ frequency(value: string): number
69
+ /** The level indices the adjustment applies to (typically just the exact level). */
70
+ levels: ReadonlySet<number>
71
+ /** The agreeing value to look up for a pair (a normalized field value), or null to skip. */
72
+ value(a: R, b: R): string | null | undefined
73
+ /** Scale the adjustment in [0, 1]. Default 1. */
74
+ weight?: number
75
+ /** Floor for the looked-up frequency, bounding the boost on ultra-rare values. Default 1e-4. */
76
+ minimumFrequency?: number
77
+ }
78
+
79
+ /** A Fellegi-Sunter model: the field comparisons plus the prior match rate `λ`. */
80
+ export interface FellegiSunterModel<R> {
81
+ comparisons: Comparison<R>[]
82
+ /** Prior probability that two records drawn at random are a match. */
83
+ lambda: number
84
+ }
85
+
86
+ /** The scored outcome for one record pair. */
87
+ export interface PairScore {
88
+ /** Total match weight in bits (`log2` odds). */
89
+ weight: number
90
+ /** Match probability in [0, 1]. */
91
+ probability: number
92
+ /** Per-field breakdown — what drove the score. */
93
+ contributions: Array<{ name: string; level: string | null; weight: number }>
94
+ }
95
+
96
+ /** The terminal decision for a pair under upper / lower match-weight thresholds. */
97
+ export type MatchDecision = "match" | "review" | "non-match"
98
+
99
+ /** The Bayes-factor weight of a single level, in bits: `log2(m / u)`. */
100
+ export function levelWeight(level: ComparisonLevel): number {
101
+ if (level.u <= 0) return level.m > 0 ? Infinity : 0
102
+
103
+ return Math.log2(level.m / level.u)
104
+ }
105
+
106
+ /** The prior match weight in bits: `log2(λ / (1 - λ))`. */
107
+ export function priorWeight(lambda: number): number {
108
+ if (lambda <= 0) return -Infinity
109
+
110
+ if (lambda >= 1) return Infinity
111
+
112
+ return Math.log2(lambda / (1 - lambda))
113
+ }
114
+
115
+ /** Convert a total match weight (bits) to a probability, numerically stable for extreme weights. */
116
+ export function probabilityFromWeight(weight: number): number {
117
+ return 1 / (1 + 2 ** -weight)
118
+ }
119
+
120
+ /**
121
+ * A comparison driven by a similarity function and a tier of `minSimilarity` thresholds (the StatCan/Splink recipe).
122
+ * Levels must be ordered highest → lowest similarity, the last acting as the `different` catch-all (`minSimilarity` 0).
123
+ * A missing value on either side yields no evidence.
124
+ */
125
+ export function similarityComparison<R>(config: {
126
+ name: string
127
+ extract: (record: R) => string | null | undefined
128
+ /** Defaults to {@link nameSimilarity}. */
129
+ similarity?: (a: string, b: string) => number
130
+ levels: ComparisonLevel[]
131
+ }): Comparison<R> {
132
+ const similarity = config.similarity ?? nameSimilarity
133
+
134
+ return {
135
+ name: config.name,
136
+ levels: config.levels,
137
+ assess(a, b) {
138
+ const va = config.extract(a)
139
+ const vb = config.extract(b)
140
+
141
+ if (!va || !vb || !va.trim() || !vb.trim()) return -1
142
+
143
+ const sim = similarity(va, vb)
144
+
145
+ for (let i = 0; i < config.levels.length; i++) {
146
+ if (sim >= (config.levels[i]!.minSimilarity ?? 0)) return i
147
+ }
148
+
149
+ return config.levels.length - 1
150
+ },
151
+ }
152
+ }
153
+
154
+ /** Score a record pair: total match weight, probability, and the per-field contributions. */
155
+ export function scorePair<R>(model: FellegiSunterModel<R>, a: R, b: R): PairScore {
156
+ let weight = priorWeight(model.lambda)
157
+ const contributions: PairScore["contributions"] = []
158
+
159
+ for (const comparison of model.comparisons) {
160
+ const index = comparison.assess(a, b)
161
+
162
+ if (index < 0) {
163
+ contributions.push({ name: comparison.name, level: null, weight: 0 })
164
+ continue
165
+ }
166
+ const level = comparison.levels[index]!
167
+ let w = levelWeight(level)
168
+
169
+ // Term-frequency adjustment: swap the level's average u for the agreeing value's own frequency.
170
+ const tf = comparison.termFrequency
171
+
172
+ if (tf && tf.levels.has(index) && level.u > 0) {
173
+ const value = tf.value(a, b)
174
+
175
+ if (value) {
176
+ const frequency = Math.max(tf.frequency(value), tf.minimumFrequency ?? 1e-4)
177
+
178
+ if (frequency > 0) {
179
+ w += Math.log2(level.u / frequency) * (tf.weight ?? 1)
180
+ }
181
+ }
182
+ }
183
+
184
+ weight += w
185
+ contributions.push({ name: comparison.name, level: level.label, weight: w })
186
+ }
187
+
188
+ return { weight, probability: probabilityFromWeight(weight), contributions }
189
+ }
190
+
191
+ /**
192
+ * Classify a score against upper / lower match-weight thresholds (in bits): at or above `upper` is a link, at or below
193
+ * `lower` a non-link, and the band between is clerical review (abstain).
194
+ */
195
+ export function decide(score: PairScore, thresholds: { upper: number; lower: number }): MatchDecision {
196
+ if (score.weight >= thresholds.upper) return "match"
197
+
198
+ if (score.weight <= thresholds.lower) return "non-match"
199
+
200
+ return "review"
201
+ }
package/gbt.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Gradient-boosted shallow regression trees (logistic loss), pure-Node — the learned scorer #603
7
+ * names: an offline-trained model (this trainer, or XGBoost/LightGBM exported to the same
8
+ * {@link GBT} shape) plus a trivial evaluator, no new runtime dependency. It sits behind the
9
+ * matcher's `scorer` hook to replace the Fellegi-Sunter link weight where labels (or a held-out
10
+ * truth like an NPI) let a tree learn the over-merge signature the hand-weights miss.
11
+ *
12
+ * This module is feature-agnostic: feature vectors are caller-defined `number[]` (the record
13
+ * matcher builds them in `@mailwoman/registry`'s learned-scorer module — one-hot agreement
14
+ * levels
15
+ *
16
+ * - Interaction terms + corpus statistics). It only fits ({@link trainGBT}) and scores
17
+ * ({@link gbtScore}). The trained {@link GBT} is plain JSON (`{trees, lr, base}`), so a model
18
+ * trains offline once and ships as a data file.
19
+ */
20
+
21
+ /** A trained tree: an internal split (feature `f` ≤ `thr` → `lo`, else `hi`) or a `leaf` value. */
22
+ export type TreeNode = { leaf: number } | { f: number; thr: number; lo: TreeNode; hi: TreeNode }
23
+
24
+ const sigmoid = (z: number): number => 1 / (1 + Math.exp(-Math.max(-30, Math.min(30, z))))
25
+
26
+ /**
27
+ * Per-feature candidate split thresholds: midpoints for few-valued/binary features, quantiles for continuous.
28
+ */
29
+ export function buildThresholds(X: number[][]): number[][] {
30
+ const dim = X[0]?.length ?? 0
31
+ const out: number[][] = []
32
+
33
+ for (let f = 0; f < dim; f++) {
34
+ const vals = X.map((r) => r[f]!)
35
+ const uniq = [...new Set(vals)].sort((p, q) => p - q)
36
+
37
+ if (uniq.length <= 1) {
38
+ out.push([])
39
+ } else if (uniq.length <= 5) {
40
+ const t: number[] = []
41
+
42
+ for (let k = 0; k < uniq.length - 1; k++) {
43
+ t.push((uniq[k]! + uniq[k + 1]!) / 2)
44
+ }
45
+ out.push(t)
46
+ } else {
47
+ const sorted = [...vals].sort((p, q) => p - q)
48
+ const t: number[] = []
49
+
50
+ for (let q = 1; q <= 6; q++) {
51
+ t.push(sorted[Math.floor((q / 7) * (sorted.length - 1))]!)
52
+ }
53
+ out.push([...new Set(t)])
54
+ }
55
+ }
56
+
57
+ return out
58
+ }
59
+
60
+ /** Weighted SSE of target `g` over `rows` around their weighted mean. */
61
+ function nodeSSE(rows: number[], g: number[], w: number[]): number {
62
+ let wsum = 0
63
+ let wg = 0
64
+
65
+ for (const i of rows) {
66
+ wsum += w[i]!
67
+ wg += w[i]! * g[i]!
68
+ }
69
+ const mean = wsum > 0 ? wg / wsum : 0
70
+ let sse = 0
71
+
72
+ for (const i of rows) {
73
+ const d = g[i]! - mean
74
+ sse += w[i]! * d * d
75
+ }
76
+
77
+ return sse
78
+ }
79
+
80
+ /** Greedy depth-limited weighted regression tree on target `g` (the boosting residual). */
81
+ function fitRegTree(
82
+ rows: number[],
83
+ X: number[][],
84
+ g: number[],
85
+ w: number[],
86
+ thresholds: number[][],
87
+ depth: number,
88
+ minLeaf: number
89
+ ): TreeNode {
90
+ let wsum = 0
91
+ let wg = 0
92
+
93
+ for (const i of rows) {
94
+ wsum += w[i]!
95
+ wg += w[i]! * g[i]!
96
+ }
97
+ const leaf = wsum > 0 ? wg / wsum : 0
98
+
99
+ if (depth === 0 || rows.length < 2 * minLeaf) return { leaf }
100
+ const parentSSE = nodeSSE(rows, g, w)
101
+ let bestGain = 1e-12
102
+ let bestF = -1
103
+ let bestThr = 0
104
+ let bestLo: number[] = []
105
+ let bestHi: number[] = []
106
+
107
+ for (let f = 0; f < thresholds.length; f++) {
108
+ for (const thr of thresholds[f]!) {
109
+ const lo: number[] = []
110
+ const hi: number[] = []
111
+
112
+ for (const i of rows) {
113
+ ;(X[i]![f]! <= thr ? lo : hi).push(i)
114
+ }
115
+
116
+ if (lo.length < minLeaf || hi.length < minLeaf) continue
117
+ const gain = parentSSE - (nodeSSE(lo, g, w) + nodeSSE(hi, g, w))
118
+
119
+ if (gain > bestGain) {
120
+ bestGain = gain
121
+ bestF = f
122
+ bestThr = thr
123
+ bestLo = lo
124
+ bestHi = hi
125
+ }
126
+ }
127
+ }
128
+
129
+ if (bestF < 0) return { leaf }
130
+
131
+ return {
132
+ f: bestF,
133
+ thr: bestThr,
134
+ lo: fitRegTree(bestLo, X, g, w, thresholds, depth - 1, minLeaf),
135
+ hi: fitRegTree(bestHi, X, g, w, thresholds, depth - 1, minLeaf),
136
+ }
137
+ }
138
+
139
+ function predictTree(t: TreeNode, x: number[]): number {
140
+ let n = t
141
+
142
+ while ("f" in n) {
143
+ n = x[n.f]! <= n.thr ? n.lo : n.hi
144
+ }
145
+
146
+ return n.leaf
147
+ }
148
+
149
+ /** A trained gradient-boosted-tree model: an additive ensemble over a base log-odds. Plain JSON. */
150
+ export interface GBT {
151
+ trees: TreeNode[]
152
+ lr: number
153
+ base: number
154
+ }
155
+
156
+ /** Hyperparameters for {@link trainGBT}. */
157
+ export interface GBTOpts {
158
+ rounds: number
159
+ depth: number
160
+ lr: number
161
+ minLeaf: number
162
+ }
163
+
164
+ /** Gradient-boosted regression trees on logistic loss, with per-sample class weights `w`. */
165
+ export function trainGBT(X: number[][], y: number[], w: number[], opts: GBTOpts): GBT {
166
+ const N = X.length
167
+ const thresholds = buildThresholds(X)
168
+ const rowsAll = Array.from({ length: N }, (_, i) => i)
169
+ let wpos = 0
170
+ let wtot = 0
171
+
172
+ for (let i = 0; i < N; i++) {
173
+ wtot += w[i]!
174
+
175
+ if (y[i] === 1) {
176
+ wpos += w[i]!
177
+ }
178
+ }
179
+ const base = Math.log((wpos + 1) / (wtot - wpos + 1)) // weighted base log-odds
180
+ const F = new Array<number>(N).fill(base)
181
+ const trees: TreeNode[] = []
182
+
183
+ for (let m = 0; m < opts.rounds; m++) {
184
+ const g = new Array<number>(N)
185
+
186
+ for (let i = 0; i < N; i++) {
187
+ g[i] = y[i]! - sigmoid(F[i]!)
188
+ } // negative gradient of logistic loss
189
+ const tree = fitRegTree(rowsAll, X, g, w, thresholds, opts.depth, opts.minLeaf)
190
+
191
+ for (let i = 0; i < N; i++) {
192
+ F[i]! += opts.lr * predictTree(tree, X[i]!)
193
+ }
194
+ trees.push(tree)
195
+ }
196
+
197
+ return { trees, lr: opts.lr, base }
198
+ }
199
+
200
+ /** GBT score (logit) for one feature vector. Threshold-comparable like the FS weight. */
201
+ export function gbtScore(m: GBT, x: number[]): number {
202
+ let f = m.base
203
+
204
+ for (const t of m.trees) {
205
+ f += m.lr * predictTree(t, x)
206
+ }
207
+
208
+ return f
209
+ }
package/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/match` — the geocode-first record matcher: block → score → cluster.
7
+ *
8
+ * The full three-stage pipeline:
9
+ *
10
+ * 1. {@link block Block} — geo-first candidate generation (a spatial-cell union of cheap, high-recall
11
+ * keys), so two records at the same place meet regardless of address spelling.
12
+ * 2. **Score** — string {@link jaroWinkler comparators} → the {@link scorePair Fellegi-Sunter} weight
13
+ * model (agreement levels → `log2(m/u)` weights → probability → link / review / non-link),
14
+ * with `m`/`u` learned label-free by {@link estimateParameters EM} and rare-value agreement
15
+ * up-weighted by {@link withTermFrequency term frequency}.
16
+ * 3. {@link cluster Cluster} — resolve the non-transitive pairwise link graph into canonical entities.
17
+ */
18
+
19
+ export * from "./blocking.ts"
20
+ export * from "./clustering.ts"
21
+ export * from "./comparators.ts"
22
+ export * from "./distance.ts"
23
+ export * from "./em.ts"
24
+ export * from "./fellegi-sunter.ts"
25
+ export * from "./gbt.ts"
26
+ export * from "./tf.ts"