@futux/whitebox-dedup 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/conformal.d.ts +12 -0
- package/dist/conformal.js +27 -0
- package/dist/features.d.ts +3 -0
- package/dist/features.js +100 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +34 -0
- package/dist/mars_scorer.d.ts +45 -0
- package/dist/mars_scorer.js +81 -0
- package/dist/pipeline.d.ts +24 -0
- package/dist/pipeline.js +66 -0
- package/dist/router.d.ts +26 -0
- package/dist/router.js +81 -0
- package/package.json +29 -4
- package/src/conformal.ts +31 -0
- package/src/features.ts +107 -0
- package/src/index.ts +9 -0
- package/src/mars_scorer.ts +108 -0
- package/src/pipeline.ts +81 -0
- package/src/router.ts +94 -0
- package/tsconfig.json +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ICPCalibrationData {
|
|
2
|
+
model_name: string;
|
|
3
|
+
alpha: number;
|
|
4
|
+
threshold_duplicate: number;
|
|
5
|
+
threshold_distinct: number;
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class ICPPredictor {
|
|
9
|
+
cal: ICPCalibrationData;
|
|
10
|
+
constructor(cal: ICPCalibrationData);
|
|
11
|
+
predictSet(probability: number): Set<number>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ICPPredictor = void 0;
|
|
4
|
+
class ICPPredictor {
|
|
5
|
+
cal;
|
|
6
|
+
constructor(cal) {
|
|
7
|
+
this.cal = cal;
|
|
8
|
+
}
|
|
9
|
+
predictSet(probability) {
|
|
10
|
+
const nonconformityDup = 1.0 - probability;
|
|
11
|
+
const nonconformityDist = probability;
|
|
12
|
+
const includeDup = nonconformityDup <= this.cal.threshold_duplicate;
|
|
13
|
+
const includeDist = nonconformityDist <= this.cal.threshold_distinct;
|
|
14
|
+
const set = new Set();
|
|
15
|
+
if (includeDist)
|
|
16
|
+
set.add(0);
|
|
17
|
+
if (includeDup)
|
|
18
|
+
set.add(1);
|
|
19
|
+
if (set.size === 0) {
|
|
20
|
+
// Empty set safeguard -> ambiguous
|
|
21
|
+
set.add(0);
|
|
22
|
+
set.add(1);
|
|
23
|
+
}
|
|
24
|
+
return set;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.ICPPredictor = ICPPredictor;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function jaroWinkler(s1: string, s2: string): number;
|
|
2
|
+
export declare function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
3
|
+
export declare function extractPairFeatures(a: Record<string, string | number | null | undefined>, b: Record<string, string | number | null | undefined>): Record<string, number | null>;
|
package/dist/features.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.jaroWinkler = jaroWinkler;
|
|
4
|
+
exports.haversineM = haversineM;
|
|
5
|
+
exports.extractPairFeatures = extractPairFeatures;
|
|
6
|
+
function jaroWinkler(s1, s2) {
|
|
7
|
+
if (s1 === s2)
|
|
8
|
+
return 1.0;
|
|
9
|
+
const len1 = s1.length;
|
|
10
|
+
const len2 = s2.length;
|
|
11
|
+
if (len1 === 0 || len2 === 0)
|
|
12
|
+
return 0.0;
|
|
13
|
+
const matchDistance = Math.floor(Math.max(len1, len2) / 2) - 1;
|
|
14
|
+
const s1Matches = new Array(len1).fill(false);
|
|
15
|
+
const s2Matches = new Array(len2).fill(false);
|
|
16
|
+
let matches = 0;
|
|
17
|
+
for (let i = 0; i < len1; i++) {
|
|
18
|
+
const start = Math.max(0, i - matchDistance);
|
|
19
|
+
const end = Math.min(i + matchDistance + 1, len2);
|
|
20
|
+
for (let j = start; j < end; j++) {
|
|
21
|
+
if (s2Matches[j] || s1[i] !== s2[j])
|
|
22
|
+
continue;
|
|
23
|
+
s1Matches[i] = true;
|
|
24
|
+
s2Matches[j] = true;
|
|
25
|
+
matches++;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (matches === 0)
|
|
30
|
+
return 0.0;
|
|
31
|
+
let transpositions = 0;
|
|
32
|
+
let k = 0;
|
|
33
|
+
for (let i = 0; i < len1; i++) {
|
|
34
|
+
if (!s1Matches[i])
|
|
35
|
+
continue;
|
|
36
|
+
while (!s2Matches[k])
|
|
37
|
+
k++;
|
|
38
|
+
if (s1[i] !== s2[k])
|
|
39
|
+
transpositions++;
|
|
40
|
+
k++;
|
|
41
|
+
}
|
|
42
|
+
const jaro = (matches / len1 + matches / len2 + (matches - transpositions / 2) / matches) / 3;
|
|
43
|
+
let prefix = 0;
|
|
44
|
+
for (let i = 0; i < Math.min(4, Math.min(len1, len2)); i++) {
|
|
45
|
+
if (s1[i] === s2[i])
|
|
46
|
+
prefix++;
|
|
47
|
+
else
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
51
|
+
}
|
|
52
|
+
function haversineM(lat1, lon1, lat2, lon2) {
|
|
53
|
+
const R = 6371000.0; // Earth radius in meters
|
|
54
|
+
const toRad = (deg) => (deg * Math.PI) / 180.0;
|
|
55
|
+
const dLat = toRad(lat2 - lat1);
|
|
56
|
+
const dLon = toRad(lon2 - lon1);
|
|
57
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
58
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
59
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
60
|
+
return R * c;
|
|
61
|
+
}
|
|
62
|
+
function extractPairFeatures(a, b) {
|
|
63
|
+
const feats = {};
|
|
64
|
+
// Name similarities
|
|
65
|
+
const nameA = String(a["full_name"] ?? a["name"] ?? "").trim().toLowerCase();
|
|
66
|
+
const nameB = String(b["full_name"] ?? b["name"] ?? "").trim().toLowerCase();
|
|
67
|
+
if (nameA && nameB) {
|
|
68
|
+
feats["jw_name_full"] = jaroWinkler(nameA, nameB);
|
|
69
|
+
const tokensA = nameA.split(/\s+/);
|
|
70
|
+
const tokensB = nameB.split(/\s+/);
|
|
71
|
+
feats["jw_name_first"] = jaroWinkler(tokensA[0] ?? "", tokensB[0] ?? "");
|
|
72
|
+
feats["jw_name_last"] = jaroWinkler(tokensA[tokensA.length - 1] ?? "", tokensB[tokensB.length - 1] ?? "");
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
feats["jw_name_full"] = null;
|
|
76
|
+
feats["jw_name_first"] = null;
|
|
77
|
+
feats["jw_name_last"] = null;
|
|
78
|
+
}
|
|
79
|
+
// Exact Phone Match
|
|
80
|
+
const phoneA = String(a["phone"] ?? a["phone_number"] ?? "").replace(/\D/g, "");
|
|
81
|
+
const phoneB = String(b["phone"] ?? b["phone_number"] ?? "").replace(/\D/g, "");
|
|
82
|
+
if (phoneA.length >= 7 && phoneB.length >= 7) {
|
|
83
|
+
feats["exact_phone"] = phoneA === phoneB ? 1.0 : 0.0;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
feats["exact_phone"] = null;
|
|
87
|
+
}
|
|
88
|
+
// Haversine GPS distance
|
|
89
|
+
const latA = Number(a["latitude"] ?? a["lat"]);
|
|
90
|
+
const lonA = Number(a["longitude"] ?? a["lon"]);
|
|
91
|
+
const latB = Number(b["latitude"] ?? b["lat"]);
|
|
92
|
+
const lonB = Number(b["longitude"] ?? b["lon"]);
|
|
93
|
+
if (!isNaN(latA) && !isNaN(lonA) && !isNaN(latB) && !isNaN(lonB)) {
|
|
94
|
+
feats["haversine_m"] = haversineM(latA, lonA, latB, lonB);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
feats["haversine_m"] = null;
|
|
98
|
+
}
|
|
99
|
+
return feats;
|
|
100
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { MARSScorer } from "./mars_scorer.js";
|
|
2
|
+
export * from "./mars_scorer.js";
|
|
3
|
+
export { ICPPredictor } from "./conformal.js";
|
|
4
|
+
export * from "./conformal.js";
|
|
5
|
+
export { DecisionRouter, Tier } from "./router.js";
|
|
6
|
+
export * from "./router.js";
|
|
7
|
+
export { DeduplicationPipeline } from "./pipeline.js";
|
|
8
|
+
export * from "./pipeline.js";
|
|
9
|
+
export { jaroWinkler, haversineM, extractPairFeatures } from "./features.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.extractPairFeatures = exports.haversineM = exports.jaroWinkler = exports.DeduplicationPipeline = exports.Tier = exports.DecisionRouter = exports.ICPPredictor = exports.MARSScorer = void 0;
|
|
18
|
+
var mars_scorer_js_1 = require("./mars_scorer.js");
|
|
19
|
+
Object.defineProperty(exports, "MARSScorer", { enumerable: true, get: function () { return mars_scorer_js_1.MARSScorer; } });
|
|
20
|
+
__exportStar(require("./mars_scorer.js"), exports);
|
|
21
|
+
var conformal_js_1 = require("./conformal.js");
|
|
22
|
+
Object.defineProperty(exports, "ICPPredictor", { enumerable: true, get: function () { return conformal_js_1.ICPPredictor; } });
|
|
23
|
+
__exportStar(require("./conformal.js"), exports);
|
|
24
|
+
var router_js_1 = require("./router.js");
|
|
25
|
+
Object.defineProperty(exports, "DecisionRouter", { enumerable: true, get: function () { return router_js_1.DecisionRouter; } });
|
|
26
|
+
Object.defineProperty(exports, "Tier", { enumerable: true, get: function () { return router_js_1.Tier; } });
|
|
27
|
+
__exportStar(require("./router.js"), exports);
|
|
28
|
+
var pipeline_js_1 = require("./pipeline.js");
|
|
29
|
+
Object.defineProperty(exports, "DeduplicationPipeline", { enumerable: true, get: function () { return pipeline_js_1.DeduplicationPipeline; } });
|
|
30
|
+
__exportStar(require("./pipeline.js"), exports);
|
|
31
|
+
var features_js_1 = require("./features.js");
|
|
32
|
+
Object.defineProperty(exports, "jaroWinkler", { enumerable: true, get: function () { return features_js_1.jaroWinkler; } });
|
|
33
|
+
Object.defineProperty(exports, "haversineM", { enumerable: true, get: function () { return features_js_1.haversineM; } });
|
|
34
|
+
Object.defineProperty(exports, "extractPairFeatures", { enumerable: true, get: function () { return features_js_1.extractPairFeatures; } });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface HingeDefinition {
|
|
2
|
+
feature: string;
|
|
3
|
+
coefficient: number;
|
|
4
|
+
knot: number;
|
|
5
|
+
reflected?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface MARSModelData {
|
|
8
|
+
model_name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
required_features: string[];
|
|
11
|
+
intercept: number;
|
|
12
|
+
hinges: HingeDefinition[];
|
|
13
|
+
}
|
|
14
|
+
export interface HingeContribution {
|
|
15
|
+
feature: string;
|
|
16
|
+
inputValue: number | null;
|
|
17
|
+
knot: number;
|
|
18
|
+
reflected: boolean;
|
|
19
|
+
coefficient: number;
|
|
20
|
+
hingeOutput: number;
|
|
21
|
+
}
|
|
22
|
+
export interface MARSTraceResult {
|
|
23
|
+
probability: number;
|
|
24
|
+
logit: number;
|
|
25
|
+
intercept: number;
|
|
26
|
+
hingeContributions: HingeContribution[];
|
|
27
|
+
}
|
|
28
|
+
export declare class HingeFunction {
|
|
29
|
+
feature: string;
|
|
30
|
+
coefficient: number;
|
|
31
|
+
knot: number;
|
|
32
|
+
reflected: boolean;
|
|
33
|
+
constructor(feature: string, coefficient: number, knot: number, reflected?: boolean);
|
|
34
|
+
evaluate(x: number): number;
|
|
35
|
+
}
|
|
36
|
+
export declare class MARSScorer {
|
|
37
|
+
modelName: string;
|
|
38
|
+
description: string;
|
|
39
|
+
intercept: number;
|
|
40
|
+
hinges: HingeFunction[];
|
|
41
|
+
requiredFeatures: string[];
|
|
42
|
+
constructor(data: MARSModelData);
|
|
43
|
+
score(features: Record<string, number | null | undefined>): number;
|
|
44
|
+
scoreWithTrace(features: Record<string, number | null | undefined>): MARSTraceResult;
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MARSScorer = exports.HingeFunction = void 0;
|
|
4
|
+
class HingeFunction {
|
|
5
|
+
feature;
|
|
6
|
+
coefficient;
|
|
7
|
+
knot;
|
|
8
|
+
reflected;
|
|
9
|
+
constructor(feature, coefficient, knot, reflected = false) {
|
|
10
|
+
this.feature = feature;
|
|
11
|
+
this.coefficient = coefficient;
|
|
12
|
+
this.knot = knot;
|
|
13
|
+
this.reflected = reflected;
|
|
14
|
+
}
|
|
15
|
+
evaluate(x) {
|
|
16
|
+
if (this.reflected) {
|
|
17
|
+
return this.coefficient * Math.max(0, this.knot - x);
|
|
18
|
+
}
|
|
19
|
+
return this.coefficient * Math.max(0, x - this.knot);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.HingeFunction = HingeFunction;
|
|
23
|
+
class MARSScorer {
|
|
24
|
+
modelName;
|
|
25
|
+
description;
|
|
26
|
+
intercept;
|
|
27
|
+
hinges;
|
|
28
|
+
requiredFeatures;
|
|
29
|
+
constructor(data) {
|
|
30
|
+
this.modelName = data.model_name;
|
|
31
|
+
this.description = data.description ?? "";
|
|
32
|
+
this.intercept = data.intercept;
|
|
33
|
+
this.requiredFeatures = data.required_features;
|
|
34
|
+
this.hinges = data.hinges.map((h) => new HingeFunction(h.feature, h.coefficient, h.knot, Boolean(h.reflected)));
|
|
35
|
+
}
|
|
36
|
+
score(features) {
|
|
37
|
+
let logit = this.intercept;
|
|
38
|
+
for (const hinge of this.hinges) {
|
|
39
|
+
const val = features[hinge.feature];
|
|
40
|
+
if (val !== null && val !== undefined) {
|
|
41
|
+
logit += hinge.evaluate(val);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (logit >= 50.0)
|
|
45
|
+
return 1.0;
|
|
46
|
+
if (logit <= -50.0)
|
|
47
|
+
return 0.0;
|
|
48
|
+
return 1.0 / (1.0 + Math.exp(-logit));
|
|
49
|
+
}
|
|
50
|
+
scoreWithTrace(features) {
|
|
51
|
+
let logit = this.intercept;
|
|
52
|
+
const contributions = [];
|
|
53
|
+
for (const hinge of this.hinges) {
|
|
54
|
+
const val = features[hinge.feature] ?? null;
|
|
55
|
+
const contribution = val !== null ? hinge.evaluate(val) : 0.0;
|
|
56
|
+
logit += contribution;
|
|
57
|
+
contributions.push({
|
|
58
|
+
feature: hinge.feature,
|
|
59
|
+
inputValue: val,
|
|
60
|
+
knot: hinge.knot,
|
|
61
|
+
reflected: hinge.reflected,
|
|
62
|
+
coefficient: hinge.coefficient,
|
|
63
|
+
hingeOutput: contribution,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
let prob = 0.0;
|
|
67
|
+
if (logit >= 50.0)
|
|
68
|
+
prob = 1.0;
|
|
69
|
+
else if (logit <= -50.0)
|
|
70
|
+
prob = 0.0;
|
|
71
|
+
else
|
|
72
|
+
prob = 1.0 / (1.0 + Math.exp(-logit));
|
|
73
|
+
return {
|
|
74
|
+
probability: Number(prob.toFixed(6)),
|
|
75
|
+
logit: Number(logit.toFixed(6)),
|
|
76
|
+
intercept: this.intercept,
|
|
77
|
+
hingeContributions: contributions,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.MARSScorer = MARSScorer;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MARSModelData } from "./mars_scorer.js";
|
|
2
|
+
import { ICPCalibrationData } from "./conformal.js";
|
|
3
|
+
import { DecisionRouter, RoutingDecision } from "./router.js";
|
|
4
|
+
export interface DeduplicationSummary {
|
|
5
|
+
totalRecords: number;
|
|
6
|
+
candidatePairsEvaluated: number;
|
|
7
|
+
tier1AutoMerge: number;
|
|
8
|
+
tier2HumanReview: number;
|
|
9
|
+
tier3AutoDistinct: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class DeduplicationPipeline {
|
|
12
|
+
records: Record<string, string>[];
|
|
13
|
+
router: DecisionRouter;
|
|
14
|
+
constructor(records: Record<string, string>[], router: DecisionRouter);
|
|
15
|
+
static fromCSV(csvContent: string, modelData: MARSModelData, calibrationData?: ICPCalibrationData): DeduplicationPipeline;
|
|
16
|
+
scorePair(recordA: Record<string, string | number | null | undefined>, recordB: Record<string, string | number | null | undefined>): RoutingDecision;
|
|
17
|
+
run(): {
|
|
18
|
+
summary: DeduplicationSummary;
|
|
19
|
+
results: RoutingDecision[];
|
|
20
|
+
autoMerge: RoutingDecision[];
|
|
21
|
+
humanReview: RoutingDecision[];
|
|
22
|
+
autoDistinct: RoutingDecision[];
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DeduplicationPipeline = void 0;
|
|
7
|
+
const mars_scorer_js_1 = require("./mars_scorer.js");
|
|
8
|
+
const conformal_js_1 = require("./conformal.js");
|
|
9
|
+
const router_js_1 = require("./router.js");
|
|
10
|
+
const features_js_1 = require("./features.js");
|
|
11
|
+
const papaparse_1 = __importDefault(require("papaparse"));
|
|
12
|
+
class DeduplicationPipeline {
|
|
13
|
+
records;
|
|
14
|
+
router;
|
|
15
|
+
constructor(records, router) {
|
|
16
|
+
this.records = records;
|
|
17
|
+
this.router = router;
|
|
18
|
+
}
|
|
19
|
+
static fromCSV(csvContent, modelData, calibrationData) {
|
|
20
|
+
const parsed = papaparse_1.default.parse(csvContent, {
|
|
21
|
+
header: true,
|
|
22
|
+
skipEmptyLines: true,
|
|
23
|
+
});
|
|
24
|
+
const scorer = new mars_scorer_js_1.MARSScorer(modelData);
|
|
25
|
+
const icp = calibrationData ? new conformal_js_1.ICPPredictor(calibrationData) : null;
|
|
26
|
+
const router = new router_js_1.DecisionRouter(scorer, icp);
|
|
27
|
+
return new DeduplicationPipeline(parsed.data, router);
|
|
28
|
+
}
|
|
29
|
+
scorePair(recordA, recordB) {
|
|
30
|
+
const feats = (0, features_js_1.extractPairFeatures)(recordA, recordB);
|
|
31
|
+
return this.router.route(feats);
|
|
32
|
+
}
|
|
33
|
+
run() {
|
|
34
|
+
const results = [];
|
|
35
|
+
const autoMerge = [];
|
|
36
|
+
const humanReview = [];
|
|
37
|
+
const autoDistinct = [];
|
|
38
|
+
const n = this.records.length;
|
|
39
|
+
for (let i = 0; i < n; i++) {
|
|
40
|
+
for (let j = i + 1; j < n; j++) {
|
|
41
|
+
const dec = this.scorePair(this.records[i], this.records[j]);
|
|
42
|
+
results.push(dec);
|
|
43
|
+
if (dec.tier === router_js_1.Tier.AUTO_MERGE)
|
|
44
|
+
autoMerge.push(dec);
|
|
45
|
+
else if (dec.tier === router_js_1.Tier.HUMAN_REVIEW)
|
|
46
|
+
humanReview.push(dec);
|
|
47
|
+
else
|
|
48
|
+
autoDistinct.push(dec);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
summary: {
|
|
53
|
+
totalRecords: n,
|
|
54
|
+
candidatePairsEvaluated: results.length,
|
|
55
|
+
tier1AutoMerge: autoMerge.length,
|
|
56
|
+
tier2HumanReview: humanReview.length,
|
|
57
|
+
tier3AutoDistinct: autoDistinct.length,
|
|
58
|
+
},
|
|
59
|
+
results,
|
|
60
|
+
autoMerge,
|
|
61
|
+
humanReview,
|
|
62
|
+
autoDistinct,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.DeduplicationPipeline = DeduplicationPipeline;
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MARSScorer, MARSTraceResult } from "./mars_scorer.js";
|
|
2
|
+
import { ICPPredictor } from "./conformal.js";
|
|
3
|
+
export declare enum Tier {
|
|
4
|
+
AUTO_MERGE = "auto_merge",
|
|
5
|
+
HUMAN_REVIEW = "human_review",
|
|
6
|
+
AUTO_DISTINCT = "auto_distinct"
|
|
7
|
+
}
|
|
8
|
+
export interface RoutingDecision {
|
|
9
|
+
tier: Tier;
|
|
10
|
+
probability: number;
|
|
11
|
+
predictionSet: number[];
|
|
12
|
+
reason: string;
|
|
13
|
+
features: Record<string, number | null>;
|
|
14
|
+
marsTrace?: MARSTraceResult;
|
|
15
|
+
}
|
|
16
|
+
export declare class DecisionRouter {
|
|
17
|
+
scorer: MARSScorer;
|
|
18
|
+
icp: ICPPredictor | null;
|
|
19
|
+
dupThreshold: number;
|
|
20
|
+
distThreshold: number;
|
|
21
|
+
constructor(scorer: MARSScorer, icp?: ICPPredictor | null, dupThreshold?: number, distThreshold?: number);
|
|
22
|
+
route(features: Record<string, number | null>, hardSignals?: {
|
|
23
|
+
isExactIdMatch?: boolean;
|
|
24
|
+
isExactIdMismatch?: boolean;
|
|
25
|
+
}): RoutingDecision;
|
|
26
|
+
}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DecisionRouter = exports.Tier = void 0;
|
|
4
|
+
var Tier;
|
|
5
|
+
(function (Tier) {
|
|
6
|
+
Tier["AUTO_MERGE"] = "auto_merge";
|
|
7
|
+
Tier["HUMAN_REVIEW"] = "human_review";
|
|
8
|
+
Tier["AUTO_DISTINCT"] = "auto_distinct";
|
|
9
|
+
})(Tier || (exports.Tier = Tier = {}));
|
|
10
|
+
class DecisionRouter {
|
|
11
|
+
scorer;
|
|
12
|
+
icp;
|
|
13
|
+
dupThreshold;
|
|
14
|
+
distThreshold;
|
|
15
|
+
constructor(scorer, icp = null, dupThreshold = 0.898, distThreshold = 0.491) {
|
|
16
|
+
this.scorer = scorer;
|
|
17
|
+
this.icp = icp;
|
|
18
|
+
this.dupThreshold = dupThreshold;
|
|
19
|
+
this.distThreshold = distThreshold;
|
|
20
|
+
}
|
|
21
|
+
route(features, hardSignals = {}) {
|
|
22
|
+
// 1. Hard signals override
|
|
23
|
+
if (hardSignals.isExactIdMatch) {
|
|
24
|
+
return {
|
|
25
|
+
tier: Tier.AUTO_MERGE,
|
|
26
|
+
probability: 1.0,
|
|
27
|
+
predictionSet: [1],
|
|
28
|
+
reason: "hard_signal:exact_id_match",
|
|
29
|
+
features,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (hardSignals.isExactIdMismatch) {
|
|
33
|
+
return {
|
|
34
|
+
tier: Tier.AUTO_DISTINCT,
|
|
35
|
+
probability: 0.0,
|
|
36
|
+
predictionSet: [0],
|
|
37
|
+
reason: "hard_signal:exact_id_mismatch",
|
|
38
|
+
features,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// 2. Probabilistic evaluation via MARS
|
|
42
|
+
const trace = this.scorer.scoreWithTrace(features);
|
|
43
|
+
const prob = trace.probability;
|
|
44
|
+
// 3. Conformal Prediction Set Check
|
|
45
|
+
let pset = [0, 1];
|
|
46
|
+
if (this.icp) {
|
|
47
|
+
const setObj = this.icp.predictSet(prob);
|
|
48
|
+
pset = Array.from(setObj);
|
|
49
|
+
}
|
|
50
|
+
if (pset.length === 1 && pset[0] === 1) {
|
|
51
|
+
return {
|
|
52
|
+
tier: Tier.AUTO_MERGE,
|
|
53
|
+
probability: prob,
|
|
54
|
+
predictionSet: pset,
|
|
55
|
+
reason: `mars:high_confidence_duplicate(p=${prob.toFixed(3)})`,
|
|
56
|
+
features,
|
|
57
|
+
marsTrace: trace,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (pset.length === 1 && pset[0] === 0) {
|
|
61
|
+
return {
|
|
62
|
+
tier: Tier.AUTO_DISTINCT,
|
|
63
|
+
probability: prob,
|
|
64
|
+
predictionSet: pset,
|
|
65
|
+
reason: `mars:high_confidence_distinct(p=${prob.toFixed(3)})`,
|
|
66
|
+
features,
|
|
67
|
+
marsTrace: trace,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Circuit Breaker triggered
|
|
71
|
+
return {
|
|
72
|
+
tier: Tier.HUMAN_REVIEW,
|
|
73
|
+
probability: prob,
|
|
74
|
+
predictionSet: pset,
|
|
75
|
+
reason: `circuit_breaker:ambiguous_region(p=${prob.toFixed(3)},set={0,1})`,
|
|
76
|
+
features,
|
|
77
|
+
marsTrace: trace,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.DecisionRouter = DecisionRouter;
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@futux/whitebox-dedup",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interpretable entity deduplication using auditable MARS equations and conformal prediction in TypeScript",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"deduplication",
|
|
14
|
+
"entity-resolution",
|
|
15
|
+
"mars",
|
|
16
|
+
"conformal-prediction",
|
|
17
|
+
"data-governance",
|
|
18
|
+
"dpi"
|
|
19
|
+
],
|
|
20
|
+
"author": "Daniel Olagunju <d.olagunju@futuxconsult.com>, Temitope Olarewaju <t.olarewaju@futuxconsult.com>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"papaparse": "^5.4.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.0.0",
|
|
27
|
+
"@types/papaparse": "^5.3.14",
|
|
28
|
+
"typescript": "^5.5.0",
|
|
29
|
+
"vitest": "^2.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/conformal.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface ICPCalibrationData {
|
|
2
|
+
model_name: string;
|
|
3
|
+
alpha: number;
|
|
4
|
+
threshold_duplicate: number;
|
|
5
|
+
threshold_distinct: number;
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ICPPredictor {
|
|
10
|
+
constructor(public cal: ICPCalibrationData) {}
|
|
11
|
+
|
|
12
|
+
predictSet(probability: number): Set<number> {
|
|
13
|
+
const nonconformityDup = 1.0 - probability;
|
|
14
|
+
const nonconformityDist = probability;
|
|
15
|
+
|
|
16
|
+
const includeDup = nonconformityDup <= this.cal.threshold_duplicate;
|
|
17
|
+
const includeDist = nonconformityDist <= this.cal.threshold_distinct;
|
|
18
|
+
|
|
19
|
+
const set = new Set<number>();
|
|
20
|
+
if (includeDist) set.add(0);
|
|
21
|
+
if (includeDup) set.add(1);
|
|
22
|
+
|
|
23
|
+
if (set.size === 0) {
|
|
24
|
+
// Empty set safeguard -> ambiguous
|
|
25
|
+
set.add(0);
|
|
26
|
+
set.add(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return set;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export function jaroWinkler(s1: string, s2: string): number {
|
|
2
|
+
if (s1 === s2) return 1.0;
|
|
3
|
+
const len1 = s1.length;
|
|
4
|
+
const len2 = s2.length;
|
|
5
|
+
if (len1 === 0 || len2 === 0) return 0.0;
|
|
6
|
+
|
|
7
|
+
const matchDistance = Math.floor(Math.max(len1, len2) / 2) - 1;
|
|
8
|
+
const s1Matches = new Array(len1).fill(false);
|
|
9
|
+
const s2Matches = new Array(len2).fill(false);
|
|
10
|
+
|
|
11
|
+
let matches = 0;
|
|
12
|
+
for (let i = 0; i < len1; i++) {
|
|
13
|
+
const start = Math.max(0, i - matchDistance);
|
|
14
|
+
const end = Math.min(i + matchDistance + 1, len2);
|
|
15
|
+
for (let j = start; j < end; j++) {
|
|
16
|
+
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
|
17
|
+
s1Matches[i] = true;
|
|
18
|
+
s2Matches[j] = true;
|
|
19
|
+
matches++;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (matches === 0) return 0.0;
|
|
25
|
+
|
|
26
|
+
let transpositions = 0;
|
|
27
|
+
let k = 0;
|
|
28
|
+
for (let i = 0; i < len1; i++) {
|
|
29
|
+
if (!s1Matches[i]) continue;
|
|
30
|
+
while (!s2Matches[k]) k++;
|
|
31
|
+
if (s1[i] !== s2[k]) transpositions++;
|
|
32
|
+
k++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const jaro = (matches / len1 + matches / len2 + (matches - transpositions / 2) / matches) / 3;
|
|
36
|
+
let prefix = 0;
|
|
37
|
+
for (let i = 0; i < Math.min(4, Math.min(len1, len2)); i++) {
|
|
38
|
+
if (s1[i] === s2[i]) prefix++;
|
|
39
|
+
else break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
46
|
+
const R = 6371000.0; // Earth radius in meters
|
|
47
|
+
const toRad = (deg: number) => (deg * Math.PI) / 180.0;
|
|
48
|
+
|
|
49
|
+
const dLat = toRad(lat2 - lat1);
|
|
50
|
+
const dLon = toRad(lon2 - lon1);
|
|
51
|
+
|
|
52
|
+
const a =
|
|
53
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
54
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
55
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
56
|
+
|
|
57
|
+
return R * c;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function extractPairFeatures(
|
|
61
|
+
a: Record<string, string | number | null | undefined>,
|
|
62
|
+
b: Record<string, string | number | null | undefined>
|
|
63
|
+
): Record<string, number | null> {
|
|
64
|
+
const feats: Record<string, number | null> = {};
|
|
65
|
+
|
|
66
|
+
// Name similarities
|
|
67
|
+
const nameA = String(a["full_name"] ?? a["name"] ?? "").trim().toLowerCase();
|
|
68
|
+
const nameB = String(b["full_name"] ?? b["name"] ?? "").trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (nameA && nameB) {
|
|
71
|
+
feats["jw_name_full"] = jaroWinkler(nameA, nameB);
|
|
72
|
+
const tokensA = nameA.split(/\s+/);
|
|
73
|
+
const tokensB = nameB.split(/\s+/);
|
|
74
|
+
feats["jw_name_first"] = jaroWinkler(tokensA[0] ?? "", tokensB[0] ?? "");
|
|
75
|
+
feats["jw_name_last"] = jaroWinkler(
|
|
76
|
+
tokensA[tokensA.length - 1] ?? "",
|
|
77
|
+
tokensB[tokensB.length - 1] ?? ""
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
feats["jw_name_full"] = null;
|
|
81
|
+
feats["jw_name_first"] = null;
|
|
82
|
+
feats["jw_name_last"] = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Exact Phone Match
|
|
86
|
+
const phoneA = String(a["phone"] ?? a["phone_number"] ?? "").replace(/\D/g, "");
|
|
87
|
+
const phoneB = String(b["phone"] ?? b["phone_number"] ?? "").replace(/\D/g, "");
|
|
88
|
+
if (phoneA.length >= 7 && phoneB.length >= 7) {
|
|
89
|
+
feats["exact_phone"] = phoneA === phoneB ? 1.0 : 0.0;
|
|
90
|
+
} else {
|
|
91
|
+
feats["exact_phone"] = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Haversine GPS distance
|
|
95
|
+
const latA = Number(a["latitude"] ?? a["lat"]);
|
|
96
|
+
const lonA = Number(a["longitude"] ?? a["lon"]);
|
|
97
|
+
const latB = Number(b["latitude"] ?? b["lat"]);
|
|
98
|
+
const lonB = Number(b["longitude"] ?? b["lon"]);
|
|
99
|
+
|
|
100
|
+
if (!isNaN(latA) && !isNaN(lonA) && !isNaN(latB) && !isNaN(lonB)) {
|
|
101
|
+
feats["haversine_m"] = haversineM(latA, lonA, latB, lonB);
|
|
102
|
+
} else {
|
|
103
|
+
feats["haversine_m"] = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return feats;
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { MARSScorer } from "./mars_scorer.js";
|
|
2
|
+
export * from "./mars_scorer.js";
|
|
3
|
+
export { ICPPredictor } from "./conformal.js";
|
|
4
|
+
export * from "./conformal.js";
|
|
5
|
+
export { DecisionRouter, Tier } from "./router.js";
|
|
6
|
+
export * from "./router.js";
|
|
7
|
+
export { DeduplicationPipeline } from "./pipeline.js";
|
|
8
|
+
export * from "./pipeline.js";
|
|
9
|
+
export { jaroWinkler, haversineM, extractPairFeatures } from "./features.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface HingeDefinition {
|
|
2
|
+
feature: string;
|
|
3
|
+
coefficient: number;
|
|
4
|
+
knot: number;
|
|
5
|
+
reflected?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface MARSModelData {
|
|
9
|
+
model_name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
required_features: string[];
|
|
12
|
+
intercept: number;
|
|
13
|
+
hinges: HingeDefinition[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HingeContribution {
|
|
17
|
+
feature: string;
|
|
18
|
+
inputValue: number | null;
|
|
19
|
+
knot: number;
|
|
20
|
+
reflected: boolean;
|
|
21
|
+
coefficient: number;
|
|
22
|
+
hingeOutput: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MARSTraceResult {
|
|
26
|
+
probability: number;
|
|
27
|
+
logit: number;
|
|
28
|
+
intercept: number;
|
|
29
|
+
hingeContributions: HingeContribution[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class HingeFunction {
|
|
33
|
+
constructor(
|
|
34
|
+
public feature: string,
|
|
35
|
+
public coefficient: number,
|
|
36
|
+
public knot: number,
|
|
37
|
+
public reflected: boolean = false
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
evaluate(x: number): number {
|
|
41
|
+
if (this.reflected) {
|
|
42
|
+
return this.coefficient * Math.max(0, this.knot - x);
|
|
43
|
+
}
|
|
44
|
+
return this.coefficient * Math.max(0, x - this.knot);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class MARSScorer {
|
|
49
|
+
public modelName: string;
|
|
50
|
+
public description: string;
|
|
51
|
+
public intercept: number;
|
|
52
|
+
public hinges: HingeFunction[];
|
|
53
|
+
public requiredFeatures: string[];
|
|
54
|
+
|
|
55
|
+
constructor(data: MARSModelData) {
|
|
56
|
+
this.modelName = data.model_name;
|
|
57
|
+
this.description = data.description ?? "";
|
|
58
|
+
this.intercept = data.intercept;
|
|
59
|
+
this.requiredFeatures = data.required_features;
|
|
60
|
+
this.hinges = data.hinges.map(
|
|
61
|
+
(h) => new HingeFunction(h.feature, h.coefficient, h.knot, Boolean(h.reflected))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
score(features: Record<string, number | null | undefined>): number {
|
|
66
|
+
let logit = this.intercept;
|
|
67
|
+
for (const hinge of this.hinges) {
|
|
68
|
+
const val = features[hinge.feature];
|
|
69
|
+
if (val !== null && val !== undefined) {
|
|
70
|
+
logit += hinge.evaluate(val);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (logit >= 50.0) return 1.0;
|
|
74
|
+
if (logit <= -50.0) return 0.0;
|
|
75
|
+
return 1.0 / (1.0 + Math.exp(-logit));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
scoreWithTrace(features: Record<string, number | null | undefined>): MARSTraceResult {
|
|
79
|
+
let logit = this.intercept;
|
|
80
|
+
const contributions: HingeContribution[] = [];
|
|
81
|
+
|
|
82
|
+
for (const hinge of this.hinges) {
|
|
83
|
+
const val = features[hinge.feature] ?? null;
|
|
84
|
+
const contribution = val !== null ? hinge.evaluate(val) : 0.0;
|
|
85
|
+
logit += contribution;
|
|
86
|
+
contributions.push({
|
|
87
|
+
feature: hinge.feature,
|
|
88
|
+
inputValue: val,
|
|
89
|
+
knot: hinge.knot,
|
|
90
|
+
reflected: hinge.reflected,
|
|
91
|
+
coefficient: hinge.coefficient,
|
|
92
|
+
hingeOutput: contribution,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let prob = 0.0;
|
|
97
|
+
if (logit >= 50.0) prob = 1.0;
|
|
98
|
+
else if (logit <= -50.0) prob = 0.0;
|
|
99
|
+
else prob = 1.0 / (1.0 + Math.exp(-logit));
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
probability: Number(prob.toFixed(6)),
|
|
103
|
+
logit: Number(logit.toFixed(6)),
|
|
104
|
+
intercept: this.intercept,
|
|
105
|
+
hingeContributions: contributions,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { MARSScorer, MARSModelData } from "./mars_scorer.js";
|
|
2
|
+
import { ICPPredictor, ICPCalibrationData } from "./conformal.js";
|
|
3
|
+
import { DecisionRouter, RoutingDecision, Tier } from "./router.js";
|
|
4
|
+
import { extractPairFeatures } from "./features.js";
|
|
5
|
+
import Papa from "papaparse";
|
|
6
|
+
|
|
7
|
+
export interface DeduplicationSummary {
|
|
8
|
+
totalRecords: number;
|
|
9
|
+
candidatePairsEvaluated: number;
|
|
10
|
+
tier1AutoMerge: number;
|
|
11
|
+
tier2HumanReview: number;
|
|
12
|
+
tier3AutoDistinct: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class DeduplicationPipeline {
|
|
16
|
+
constructor(
|
|
17
|
+
public records: Record<string, string>[],
|
|
18
|
+
public router: DecisionRouter
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
static fromCSV(
|
|
22
|
+
csvContent: string,
|
|
23
|
+
modelData: MARSModelData,
|
|
24
|
+
calibrationData?: ICPCalibrationData
|
|
25
|
+
): DeduplicationPipeline {
|
|
26
|
+
const parsed = Papa.parse<Record<string, string>>(csvContent, {
|
|
27
|
+
header: true,
|
|
28
|
+
skipEmptyLines: true,
|
|
29
|
+
});
|
|
30
|
+
const scorer = new MARSScorer(modelData);
|
|
31
|
+
const icp = calibrationData ? new ICPPredictor(calibrationData) : null;
|
|
32
|
+
const router = new DecisionRouter(scorer, icp);
|
|
33
|
+
return new DeduplicationPipeline(parsed.data, router);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
scorePair(
|
|
37
|
+
recordA: Record<string, string | number | null | undefined>,
|
|
38
|
+
recordB: Record<string, string | number | null | undefined>
|
|
39
|
+
): RoutingDecision {
|
|
40
|
+
const feats = extractPairFeatures(recordA, recordB);
|
|
41
|
+
return this.router.route(feats);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
run(): {
|
|
45
|
+
summary: DeduplicationSummary;
|
|
46
|
+
results: RoutingDecision[];
|
|
47
|
+
autoMerge: RoutingDecision[];
|
|
48
|
+
humanReview: RoutingDecision[];
|
|
49
|
+
autoDistinct: RoutingDecision[];
|
|
50
|
+
} {
|
|
51
|
+
const results: RoutingDecision[] = [];
|
|
52
|
+
const autoMerge: RoutingDecision[] = [];
|
|
53
|
+
const humanReview: RoutingDecision[] = [];
|
|
54
|
+
const autoDistinct: RoutingDecision[] = [];
|
|
55
|
+
|
|
56
|
+
const n = this.records.length;
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
for (let j = i + 1; j < n; j++) {
|
|
59
|
+
const dec = this.scorePair(this.records[i]!, this.records[j]!);
|
|
60
|
+
results.push(dec);
|
|
61
|
+
if (dec.tier === Tier.AUTO_MERGE) autoMerge.push(dec);
|
|
62
|
+
else if (dec.tier === Tier.HUMAN_REVIEW) humanReview.push(dec);
|
|
63
|
+
else autoDistinct.push(dec);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
summary: {
|
|
69
|
+
totalRecords: n,
|
|
70
|
+
candidatePairsEvaluated: results.length,
|
|
71
|
+
tier1AutoMerge: autoMerge.length,
|
|
72
|
+
tier2HumanReview: humanReview.length,
|
|
73
|
+
tier3AutoDistinct: autoDistinct.length,
|
|
74
|
+
},
|
|
75
|
+
results,
|
|
76
|
+
autoMerge,
|
|
77
|
+
humanReview,
|
|
78
|
+
autoDistinct,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { MARSScorer, MARSTraceResult } from "./mars_scorer.js";
|
|
2
|
+
import { ICPPredictor } from "./conformal.js";
|
|
3
|
+
|
|
4
|
+
export enum Tier {
|
|
5
|
+
AUTO_MERGE = "auto_merge",
|
|
6
|
+
HUMAN_REVIEW = "human_review",
|
|
7
|
+
AUTO_DISTINCT = "auto_distinct",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RoutingDecision {
|
|
11
|
+
tier: Tier;
|
|
12
|
+
probability: number;
|
|
13
|
+
predictionSet: number[];
|
|
14
|
+
reason: string;
|
|
15
|
+
features: Record<string, number | null>;
|
|
16
|
+
marsTrace?: MARSTraceResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DecisionRouter {
|
|
20
|
+
constructor(
|
|
21
|
+
public scorer: MARSScorer,
|
|
22
|
+
public icp: ICPPredictor | null = null,
|
|
23
|
+
public dupThreshold: number = 0.898,
|
|
24
|
+
public distThreshold: number = 0.491
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
route(
|
|
28
|
+
features: Record<string, number | null>,
|
|
29
|
+
hardSignals: { isExactIdMatch?: boolean; isExactIdMismatch?: boolean } = {}
|
|
30
|
+
): RoutingDecision {
|
|
31
|
+
// 1. Hard signals override
|
|
32
|
+
if (hardSignals.isExactIdMatch) {
|
|
33
|
+
return {
|
|
34
|
+
tier: Tier.AUTO_MERGE,
|
|
35
|
+
probability: 1.0,
|
|
36
|
+
predictionSet: [1],
|
|
37
|
+
reason: "hard_signal:exact_id_match",
|
|
38
|
+
features,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (hardSignals.isExactIdMismatch) {
|
|
42
|
+
return {
|
|
43
|
+
tier: Tier.AUTO_DISTINCT,
|
|
44
|
+
probability: 0.0,
|
|
45
|
+
predictionSet: [0],
|
|
46
|
+
reason: "hard_signal:exact_id_mismatch",
|
|
47
|
+
features,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Probabilistic evaluation via MARS
|
|
52
|
+
const trace = this.scorer.scoreWithTrace(features);
|
|
53
|
+
const prob = trace.probability;
|
|
54
|
+
|
|
55
|
+
// 3. Conformal Prediction Set Check
|
|
56
|
+
let pset = [0, 1];
|
|
57
|
+
if (this.icp) {
|
|
58
|
+
const setObj = this.icp.predictSet(prob);
|
|
59
|
+
pset = Array.from(setObj);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (pset.length === 1 && pset[0] === 1) {
|
|
63
|
+
return {
|
|
64
|
+
tier: Tier.AUTO_MERGE,
|
|
65
|
+
probability: prob,
|
|
66
|
+
predictionSet: pset,
|
|
67
|
+
reason: `mars:high_confidence_duplicate(p=${prob.toFixed(3)})`,
|
|
68
|
+
features,
|
|
69
|
+
marsTrace: trace,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (pset.length === 1 && pset[0] === 0) {
|
|
74
|
+
return {
|
|
75
|
+
tier: Tier.AUTO_DISTINCT,
|
|
76
|
+
probability: prob,
|
|
77
|
+
predictionSet: pset,
|
|
78
|
+
reason: `mars:high_confidence_distinct(p=${prob.toFixed(3)})`,
|
|
79
|
+
features,
|
|
80
|
+
marsTrace: trace,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Circuit Breaker triggered
|
|
85
|
+
return {
|
|
86
|
+
tier: Tier.HUMAN_REVIEW,
|
|
87
|
+
probability: prob,
|
|
88
|
+
predictionSet: pset,
|
|
89
|
+
reason: `circuit_breaker:ambiguous_region(p=${prob.toFixed(3)},set={0,1})`,
|
|
90
|
+
features,
|
|
91
|
+
marsTrace: trace,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noImplicitAny": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"]
|
|
16
|
+
}
|
package/README.md
DELETED