@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.
@@ -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>;
@@ -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
+ }
@@ -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
+ }
@@ -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;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.