@futux/whitebox-dedup 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ # @futux/whitebox-dedup (Interpretable Entity Deduplication in TypeScript)
2
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.
3
+ [![npm version](https://img.shields.io/npm/v/@futux/whitebox-dedup.svg?color=0B3541&logo=npm&logoColor=white)](https://www.npmjs.com/package/@futux/whitebox-dedup)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@futux/whitebox-dedup.svg)](https://www.npmjs.com/package/@futux/whitebox-dedup)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Live Web Demo](https://img.shields.io/badge/Interactive_Playground-Live_Online-0FBFF1?style=flat&logo=google-chrome&logoColor=white)](https://futuxagriconsult.github.io/whitebox-dedup-ml/)
7
+
8
+ > **Auditable, transparent entity deduplication and record linkage with Multivariate Adaptive Regression Splines (MARS) and Inductive Conformal Prediction (ICP).** High-performance, zero-native-dependency TypeScript library for Node.js, Next.js, and browser Web Workers.
9
+
10
+ ---
11
+
12
+ ### 🌐 Try the Interactive Web Playground
13
+
14
+ Test `@futux/whitebox-dedup` live directly in your browser:
15
+ 👉 **[Launch Live Web Demo (https://futuxagriconsult.github.io/whitebox-dedup-ml/)](https://futuxagriconsult.github.io/whitebox-dedup-ml/)**
16
+
17
+ ---
18
+
19
+ ## 🚀 Key Features
20
+
21
+ - **Auditable MARS Scoring:** No black-box neural networks. Outputs clear hinge-function math explaining precisely why two entities matched.
22
+ - **Three Conformal Statistical Tiers:**
23
+ - **Tier 1 (Auto-Merge, $\ge 0.85$):** Conformal $p$-value $\ge 0.95$; statistically safe for automated merging.
24
+ - **Tier 2 (Circuit Breaker / Review, $0.60 - 0.84$):** Ambiguous boundary cases routed to human adjudicators with exact contributing features.
25
+ - **Tier 3 (Auto-Reject, $< 0.60$):** Distinct records; kept separate.
26
+ - **Client & Server Compatible:** Runs in Node.js, Bun, Deno, Next.js API routes, and client-side Web Workers.
27
+ - **Zero Heavy Native Dependencies:** Fast cold-start, pure TypeScript, instant browser compilation.
28
+
29
+ ---
30
+
31
+ ## 📦 Installation
32
+
33
+ ```bash
34
+ # npm
35
+ npm install @futux/whitebox-dedup
36
+
37
+ # yarn
38
+ yarn add @futux/whitebox-dedup
39
+
40
+ # pnpm
41
+ pnpm add @futux/whitebox-dedup
42
+ ```
43
+
44
+ ---
45
+
46
+ ## 💡 Quick Start
47
+
48
+ ```typescript
49
+ import { DeduplicationPipeline } from "@futux/whitebox-dedup";
50
+
51
+ const records = [
52
+ { id: "1", name: "Ibrahim Musa", phone: "+254712345678" },
53
+ { id: "2", name: "Ibraheem Mussa", phone: "+254712345678" },
54
+ { id: "3", name: "Sarah Cherono", phone: "+254787654321" }
55
+ ];
56
+
57
+ const pipeline = new DeduplicationPipeline({
58
+ model: "text_phone_v1",
59
+ confidence: 0.95
60
+ });
61
+
62
+ const results = pipeline.run(records);
63
+
64
+ results.matches.forEach(match => {
65
+ console.log(`Pair: ${match.recordA.name} <-> ${match.recordB.name}`);
66
+ console.log(`Score: ${(match.score * 100).toFixed(1)}% | Tier: ${match.tier}`);
67
+ console.log(`Auditable Basis:`, match.explanation);
68
+ });
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 🔬 How the Math Works
74
+
75
+ The score is calculated via transparent piecewise linear splines:
76
+
77
+ ```text
78
+ Score = 1.0 / (1.0 + exp(-(
79
+ -3.8421
80
+ + 5.1204 * max(0, jaro_winkler - 0.7200)
81
+ - 2.8910 * max(0, 0.7200 - jaro_winkler)
82
+ + 1.8432 * max(0, token_sort_ratio - 0.6500)
83
+ + 0.9420 * exact_phone_match
84
+ )))
85
+ ```
86
+
87
+ Every prediction can be audited, reviewed, and justified in regulatory or administrative hearings.
88
+
89
+ ---
90
+
91
+ ## 🐍 Also Available in Python
92
+
93
+ For Python data pipelines and pandas integration, install the companion package:
94
+ ```bash
95
+ pip install whitebox-dedup
96
+ ```
97
+ See [GitHub Repository](https://github.com/futuxagriconsult/whitebox-dedup-ml) for CLI and calibration tools.
98
+
99
+ ---
100
+
101
+ ## 📄 License
102
+
103
+ MIT. Developed and maintained by **FutuX Agri-consult Limited** ([info@futuxconsult.com](mailto:info@futuxconsult.com)).
@@ -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.1",
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
+ }