whitebox-dedup 0.1.0__py3-none-any.whl

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,27 @@
1
+ """
2
+ whitebox-dedup: Interpretable entity deduplication with auditable MARS equations
3
+ and Inductive Conformal Prediction.
4
+
5
+ Developed by FutuX Agri-consult Limited.
6
+
7
+ Key exports:
8
+ - DeduplicationPipeline: end-to-end deduplication runner
9
+ - EntitySchema: declares the semantic role of each column
10
+ - MARSScorer: zero-dependency MARS equation evaluator
11
+ - DeduplicationResult: structured output from pipeline.run()
12
+ """
13
+ from .pipeline import DeduplicationPipeline, DeduplicationResult
14
+ from .schema import EntitySchema
15
+ from .mars_scorer import MARSScorer
16
+ from .trainer import MARSTrainer
17
+
18
+ __all__ = [
19
+ "DeduplicationPipeline",
20
+ "EntitySchema",
21
+ "MARSScorer",
22
+ "MARSTrainer",
23
+ "DeduplicationResult",
24
+ ]
25
+
26
+ __version__ = "0.1.0"
27
+ __author__ = "Daniel Olagunju, Temitope Olarewaju"
@@ -0,0 +1,5 @@
1
+ """Package entry point for `python -m whitebox_dedup`."""
2
+ from whitebox_dedup.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,115 @@
1
+ """
2
+ Blocking strategies to generate candidate duplicate pairs efficiently.
3
+
4
+ Without blocking, comparing every pair in N records requires N*(N-1)/2 comparisons.
5
+ Blocking groups records into buckets where duplicates are likely to be found,
6
+ then only compares records within the same bucket.
7
+ """
8
+ import math
9
+ from typing import Iterator
10
+ import pandas as pd
11
+ from .schema import EntitySchema, FieldRole
12
+
13
+
14
+ def generate_candidate_pairs(
15
+ df: pd.DataFrame,
16
+ schema: EntitySchema,
17
+ geo_radius_m: float = 50.0,
18
+ ) -> list[tuple[int, int]]:
19
+ """
20
+ Generate candidate duplicate pairs using available blocking strategies.
21
+ Returns list of (row_index_a, row_index_b) tuples.
22
+ Deduplicates pairs (always returns (i, j) where i < j).
23
+ """
24
+ pairs: set[tuple[int, int]] = set()
25
+
26
+ # Strategy 1: First-letter blocking on name fields
27
+ name_cols = schema.fields_by_role(FieldRole.NAME)
28
+ if name_cols:
29
+ pairs.update(_name_block(df, name_cols[0]))
30
+
31
+ # Strategy 2: Exact phone blocking
32
+ phone_cols = schema.fields_by_role(FieldRole.PHONE)
33
+ if phone_cols:
34
+ pairs.update(_exact_block(df, phone_cols[0]))
35
+
36
+ # Strategy 3: Exact ID blocking (to detect hard duplicates)
37
+ id_cols = schema.fields_by_role(FieldRole.ID)
38
+ if id_cols:
39
+ pairs.update(_exact_block(df, id_cols[0]))
40
+
41
+ # Strategy 4: Spatial blocking (haversine <= geo_radius_m)
42
+ lat_cols = schema.fields_by_role(FieldRole.LATITUDE)
43
+ lon_cols = schema.fields_by_role(FieldRole.LONGITUDE)
44
+ if lat_cols and lon_cols:
45
+ pairs.update(_geo_block(df, lat_cols[0], lon_cols[0], geo_radius_m))
46
+
47
+ return sorted(pairs)
48
+
49
+
50
+ def _name_block(df: pd.DataFrame, col: str) -> set[tuple[int, int]]:
51
+ """Block by first 2 characters of name field."""
52
+ if col not in df.columns:
53
+ return set()
54
+ buckets: dict[str, list[int]] = {}
55
+ for idx, val in df[col].items():
56
+ key = str(val or "")[:2].lower()
57
+ if key:
58
+ buckets.setdefault(key, []).append(idx)
59
+ return _pairs_from_buckets(buckets)
60
+
61
+
62
+ def _exact_block(df: pd.DataFrame, col: str) -> set[tuple[int, int]]:
63
+ """Block by exact value match (detects hard duplicates)."""
64
+ if col not in df.columns:
65
+ return set()
66
+ buckets: dict[str, list[int]] = {}
67
+ for idx, val in df[col].items():
68
+ key = str(val or "").strip().lower()
69
+ if key:
70
+ buckets.setdefault(key, []).append(idx)
71
+ # Only keep buckets with > 1 record (actual duplicates possible)
72
+ return _pairs_from_buckets({k: v for k, v in buckets.items() if len(v) > 1})
73
+
74
+
75
+ def _geo_block(df: pd.DataFrame, lat_col: str, lon_col: str, radius_m: float) -> set[tuple[int, int]]:
76
+ """
77
+ Spatial blocking: pair records within radius_m metres of each other.
78
+ Uses a simple grid cell approach: approximately 0.001 degree = 111m resolution.
79
+ """
80
+ if lat_col not in df.columns or lon_col not in df.columns:
81
+ return set()
82
+
83
+ cell_deg = radius_m / 111_000.0
84
+ buckets: dict[tuple[int, int], list[int]] = {}
85
+ for idx, row in df[[lat_col, lon_col]].iterrows():
86
+ try:
87
+ lat, lon = float(row[lat_col]), float(row[lon_col])
88
+ cell = (int(lat / cell_deg), int(lon / cell_deg))
89
+ buckets.setdefault(cell, []).append(idx)
90
+ except (ValueError, TypeError):
91
+ continue
92
+
93
+ # Also check adjacent cells
94
+ pairs: set[tuple[int, int]] = set()
95
+ cells = list(buckets.keys())
96
+ for i, cell in enumerate(cells):
97
+ members = buckets[cell]
98
+ for di in [-1, 0, 1]:
99
+ for dj in [-1, 0, 1]:
100
+ neighbor = (cell[0] + di, cell[1] + dj)
101
+ if neighbor in buckets:
102
+ for a in members:
103
+ for b in buckets[neighbor]:
104
+ if a < b:
105
+ pairs.add((a, b))
106
+ return pairs
107
+
108
+
109
+ def _pairs_from_buckets(buckets: dict) -> set[tuple[int, int]]:
110
+ pairs: set[tuple[int, int]] = set()
111
+ for members in buckets.values():
112
+ for i in range(len(members)):
113
+ for j in range(i + 1, len(members)):
114
+ pairs.add((min(members[i], members[j]), max(members[i], members[j])))
115
+ return pairs
whitebox_dedup/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ CLI for whitebox-dedup.
3
+
4
+ Commands:
5
+ wdedup deduplicate --input FILE [--schema YAML] [--output JSON]
6
+ wdedup score-pair [--schema YAML]
7
+ wdedup show-equation [--model MODEL_NAME]
8
+ """
9
+ from __future__ import annotations
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ def _print_err(msg: str) -> None:
16
+ print(f"[error] {msg}", file=sys.stderr)
17
+
18
+
19
+ def _deduplicate_cmd(args) -> int:
20
+ """Run full deduplication on a CSV file."""
21
+ from .pipeline import DeduplicationPipeline
22
+ from .schema import EntitySchema
23
+
24
+ input_path = Path(args.input)
25
+ if not input_path.exists():
26
+ _print_err(f"Input file not found: {input_path}")
27
+ return 1
28
+
29
+ schema = None
30
+ if args.schema:
31
+ schema_path = Path(args.schema)
32
+ if not schema_path.exists():
33
+ _print_err(f"Schema file not found: {schema_path}")
34
+ return 1
35
+ schema = EntitySchema.from_yaml(schema_path)
36
+
37
+ print(f"Loading data from: {input_path}")
38
+ pipeline = DeduplicationPipeline.from_csv(input_path, schema=schema)
39
+ print(f"Schema active features: {sorted(pipeline.schema.active_features())}")
40
+ print(f"Model: {pipeline.scorer.model_name}")
41
+ print("Running deduplication...")
42
+
43
+ result = pipeline.run()
44
+ summary = result.summary()
45
+
46
+ print("\nResults:")
47
+ print(f" Records processed: {summary['total_records']}")
48
+ print(f" Candidate pairs: {summary['candidate_pairs_evaluated']}")
49
+ print(f" Tier 1 (auto-merge): {summary['tier1_auto_merge']}")
50
+ print(f" Tier 2 (human review): {summary['tier2_human_review']}")
51
+ print(f" Tier 3 (auto-distinct): {summary['tier3_auto_distinct']}")
52
+
53
+ if args.output:
54
+ output_path = Path(args.output)
55
+ output_data = {
56
+ "summary": summary,
57
+ "tier1_auto_merge": result.tier1_auto_merge,
58
+ "tier2_human_review": result.tier2_human_review,
59
+ "tier3_auto_distinct": result.tier3_auto_distinct,
60
+ }
61
+ with open(output_path, "w") as f:
62
+ json.dump(output_data, f, indent=2, default=str)
63
+ print(f"\nFull results written to: {output_path}")
64
+
65
+ return 0
66
+
67
+
68
+ def _score_pair_cmd(args) -> int:
69
+ """Interactively score a single pair of records."""
70
+ from .pipeline import DeduplicationPipeline
71
+ from .schema import EntitySchema
72
+
73
+ schema = None
74
+ if args.schema:
75
+ schema_path = Path(args.schema)
76
+ if not schema_path.exists():
77
+ _print_err(f"Schema file not found: {schema_path}")
78
+ return 1
79
+ schema = EntitySchema.from_yaml(schema_path)
80
+ else:
81
+ print("No schema provided. Using default agricultural schema.")
82
+ from .schema import EntityField, FieldRole
83
+ schema = EntitySchema(fields={
84
+ "full_name": EntityField("full_name", FieldRole.NAME),
85
+ "phone": EntityField("phone", FieldRole.PHONE),
86
+ "state": EntityField("state", FieldRole.ADMIN_1),
87
+ })
88
+
89
+ print("Enter record A as JSON (e.g. {\"full_name\": \"Aminu Bello\", \"phone\": \"08012345678\"}):")
90
+ try:
91
+ line_a = input("> ").strip()
92
+ row_a = json.loads(line_a)
93
+ except (json.JSONDecodeError, EOFError) as exc:
94
+ _print_err(f"Invalid JSON for record A: {exc}")
95
+ return 1
96
+
97
+ print("Enter record B as JSON:")
98
+ try:
99
+ line_b = input("> ").strip()
100
+ row_b = json.loads(line_b)
101
+ except (json.JSONDecodeError, EOFError) as exc:
102
+ _print_err(f"Invalid JSON for record B: {exc}")
103
+ return 1
104
+
105
+ from .mars_scorer import MARSScorer
106
+ from .router import DecisionRouter
107
+
108
+ scorer = DeduplicationPipeline._load_best_model(schema)
109
+ try:
110
+ from .conformal import ICPPredictor
111
+ icp = ICPPredictor.load_pretrained(scorer.model_name)
112
+ except FileNotFoundError:
113
+ icp = None
114
+
115
+ router = DecisionRouter(scorer, icp)
116
+ from .features import extract_all
117
+ feats = extract_all(row_a, row_b, schema)
118
+ routing = router.route(feats, schema)
119
+
120
+ print(f"\nTier: {routing.tier.value}")
121
+ print(f"Probability: {routing.probability:.4f}")
122
+ print(f"Reason: {routing.reason}")
123
+ print(f"Features: {json.dumps({k: round(v, 4) if v is not None else None for k, v in feats.items()}, indent=2)}")
124
+
125
+ if routing.mars_trace:
126
+ print("\nMARS trace:")
127
+ for contrib in routing.mars_trace["hinge_contributions"]:
128
+ print(f" {contrib['feature']} hinge at {contrib['knot']}: {contrib['hinge_output']:.4f}")
129
+
130
+ return 0
131
+
132
+
133
+ def _show_equation_cmd(args) -> int:
134
+ """Print the MARS equation for a given model."""
135
+ from .mars_scorer import MARSScorer
136
+
137
+ model_name = args.model or "text_only_v1"
138
+ try:
139
+ scorer = MARSScorer.load_pretrained(model_name)
140
+ except FileNotFoundError as exc:
141
+ _print_err(str(exc))
142
+ return 1
143
+
144
+ print(f"Model: {scorer.model_name}")
145
+ if scorer.description:
146
+ print(f"Description: {scorer.description}")
147
+ print()
148
+ print(scorer.equation_text())
149
+ return 0
150
+
151
+
152
+ def main() -> None:
153
+ """Entry point for the wdedup CLI."""
154
+ import argparse
155
+
156
+ parser = argparse.ArgumentParser(
157
+ prog="wdedup",
158
+ description="whitebox-dedup: interpretable entity deduplication",
159
+ )
160
+ subparsers = parser.add_subparsers(dest="command", required=True)
161
+
162
+ # deduplicate command
163
+ dedup_parser = subparsers.add_parser(
164
+ "deduplicate",
165
+ help="Deduplicate a CSV file",
166
+ )
167
+ dedup_parser.add_argument("--input", required=True, help="Path to input CSV file")
168
+ dedup_parser.add_argument("--schema", default=None, help="Path to schema YAML file (optional)")
169
+ dedup_parser.add_argument("--output", default=None, help="Path to output JSON file (optional)")
170
+
171
+ # score-pair command
172
+ score_parser = subparsers.add_parser(
173
+ "score-pair",
174
+ help="Interactively score a single pair of records",
175
+ )
176
+ score_parser.add_argument("--schema", default=None, help="Path to schema YAML file (optional)")
177
+
178
+ # show-equation command
179
+ eq_parser = subparsers.add_parser(
180
+ "show-equation",
181
+ help="Print the MARS equation for a model",
182
+ )
183
+ eq_parser.add_argument("--model", default="text_only_v1", help="Model name (default: text_only_v1)")
184
+
185
+ args = parser.parse_args()
186
+
187
+ if args.command == "deduplicate":
188
+ sys.exit(_deduplicate_cmd(args))
189
+ elif args.command == "score-pair":
190
+ sys.exit(_score_pair_cmd(args))
191
+ elif args.command == "show-equation":
192
+ sys.exit(_show_equation_cmd(args))
193
+ else:
194
+ parser.print_help()
195
+ sys.exit(1)
196
+
197
+
198
+ if __name__ == "__main__":
199
+ main()
@@ -0,0 +1,96 @@
1
+ """
2
+ Inductive Conformal Prediction (ICP) for deduplication.
3
+
4
+ ICP wraps the MARS scorer to produce a prediction SET {0}, {1}, or {0,1}
5
+ at a user-specified coverage level (e.g., 95%).
6
+
7
+ When the prediction set is {0,1} (ambiguous), the circuit breaker fires:
8
+ the system cannot make an automated decision and diverts to human review.
9
+
10
+ Coverage stratified by Jaro-Winkler similarity shows that coverage collapses
11
+ in the ambiguous zone (JW >= 0.491), establishing an automated statutory trigger.
12
+ """
13
+ from __future__ import annotations
14
+ import json
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+
20
+ @dataclass
21
+ class ICPCalibration:
22
+ """Pre-computed nonconformity score thresholds."""
23
+ alpha: float # significance level (e.g., 0.05 for 95% coverage)
24
+ threshold_duplicate: float # nonconformity threshold for class 1 (duplicate)
25
+ threshold_distinct: float # nonconformity threshold for class 0 (distinct)
26
+ model_name: str = ""
27
+
28
+ @classmethod
29
+ def load(cls, path: str | Path) -> "ICPCalibration":
30
+ with open(path) as f:
31
+ data = json.load(f)
32
+ return cls(
33
+ alpha=data["alpha"],
34
+ threshold_duplicate=data["threshold_duplicate"],
35
+ threshold_distinct=data["threshold_distinct"],
36
+ model_name=data.get("model_name", ""),
37
+ )
38
+
39
+
40
+ class ICPPredictor:
41
+ """
42
+ Produces conformal prediction sets given a MARS probability score.
43
+
44
+ A prediction set of {1} means "definitely duplicate at the stated coverage level".
45
+ A prediction set of {0} means "definitely distinct".
46
+ A prediction set of {0, 1} means "ambiguous: circuit breaker fires".
47
+ """
48
+
49
+ def __init__(self, calibration: ICPCalibration):
50
+ self.cal = calibration
51
+
52
+ @classmethod
53
+ def load_pretrained(cls, model_name: str) -> "ICPPredictor":
54
+ cal_dir = Path(__file__).parent.parent / "models" / "calibration"
55
+ path = cal_dir / f"{model_name}_icp.json"
56
+ if not path.exists():
57
+ raise FileNotFoundError(f"ICP calibration not found: {path}")
58
+ return cls(ICPCalibration.load(path))
59
+
60
+ def predict_set(self, probability: float) -> frozenset[int]:
61
+ """
62
+ Return the conformal prediction set for a given duplicate probability.
63
+
64
+ The nonconformity score for class 1 is (1 - p), for class 0 is p.
65
+ A label y is included in the prediction set if its nonconformity
66
+ score <= the calibration threshold.
67
+ """
68
+ nc_1 = 1.0 - probability # nonconformity for "duplicate"
69
+ nc_0 = probability # nonconformity for "distinct"
70
+
71
+ include_1 = nc_1 <= self.cal.threshold_duplicate
72
+ include_0 = nc_0 <= self.cal.threshold_distinct
73
+
74
+ result = set()
75
+ if include_1:
76
+ result.add(1)
77
+ if include_0:
78
+ result.add(0)
79
+ if not result:
80
+ result = {0, 1} # always return non-empty set
81
+ return frozenset(result)
82
+
83
+ def is_ambiguous(self, probability: float) -> bool:
84
+ return self.predict_set(probability) == frozenset({0, 1})
85
+
86
+ def coverage_zone(self, jw_score: float) -> str:
87
+ """
88
+ Classify coverage zone from JW similarity.
89
+ Based on empirical finding: coverage collapses to 77.93% when JW >= 0.491.
90
+ """
91
+ if jw_score < 0.491:
92
+ return "high_coverage" # >= 95% empirical coverage
93
+ elif jw_score >= 0.898:
94
+ return "high_coverage" # very high similarity -> clear duplicate
95
+ else:
96
+ return "ambiguous_zone" # coverage 77.93% -> circuit breaker
@@ -0,0 +1,166 @@
1
+ """
2
+ Pairwise feature extraction for entity deduplication.
3
+
4
+ All features take two record dicts (row_a, row_b) and an EntitySchema,
5
+ and return a float value. Missing columns return None (feature skipped).
6
+ """
7
+ import math
8
+ import re
9
+ from typing import Optional
10
+ import jellyfish
11
+
12
+
13
+ def jw_name_full(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
14
+ """Jaro-Winkler similarity on concatenated name tokens."""
15
+ if not name_cols:
16
+ return None
17
+ a = " ".join(str(row_a.get(c, "") or "").strip() for c in name_cols).lower()
18
+ b = " ".join(str(row_b.get(c, "") or "").strip() for c in name_cols).lower()
19
+ if not a or not b:
20
+ return None
21
+ return jellyfish.jaro_winkler_similarity(a, b)
22
+
23
+
24
+ def jw_name_first(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
25
+ """Jaro-Winkler on first token of primary name field."""
26
+ if not name_cols:
27
+ return None
28
+ a_tokens = str(row_a.get(name_cols[0], "") or "").split()
29
+ b_tokens = str(row_b.get(name_cols[0], "") or "").split()
30
+ if not a_tokens or not b_tokens:
31
+ return None
32
+ return jellyfish.jaro_winkler_similarity(a_tokens[0].lower(), b_tokens[0].lower())
33
+
34
+
35
+ def jw_name_last(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
36
+ """Jaro-Winkler on last token of primary name field."""
37
+ if not name_cols:
38
+ return None
39
+ a_tokens = str(row_a.get(name_cols[0], "") or "").split()
40
+ b_tokens = str(row_b.get(name_cols[0], "") or "").split()
41
+ if not a_tokens or not b_tokens:
42
+ return None
43
+ return jellyfish.jaro_winkler_similarity(a_tokens[-1].lower(), b_tokens[-1].lower())
44
+
45
+
46
+ def haversine_m(row_a: dict, row_b: dict, lat_col: str, lon_col: str) -> Optional[float]:
47
+ """Great-circle distance in metres between two GPS coordinates."""
48
+ try:
49
+ lat1, lon1 = float(row_a[lat_col]), float(row_a[lon_col])
50
+ lat2, lon2 = float(row_b[lat_col]), float(row_b[lon_col])
51
+ except (KeyError, TypeError, ValueError):
52
+ return None
53
+ R = 6_371_000.0
54
+ phi1, phi2 = math.radians(lat1), math.radians(lat2)
55
+ dphi = math.radians(lat2 - lat1)
56
+ dlambda = math.radians(lon2 - lon1)
57
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
58
+ return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
59
+
60
+
61
+ def _normalize_phone(phone: str) -> str:
62
+ return re.sub(r"[\s\-().+]", "", str(phone or ""))
63
+
64
+
65
+ def exact_phone(row_a: dict, row_b: dict, phone_col: str) -> Optional[float]:
66
+ """1.0 if phone numbers match after normalisation, 0.0 otherwise."""
67
+ a = _normalize_phone(row_a.get(phone_col, ""))
68
+ b = _normalize_phone(row_b.get(phone_col, ""))
69
+ if not a or not b:
70
+ return None
71
+ return 1.0 if a == b else 0.0
72
+
73
+
74
+ def exact_id(row_a: dict, row_b: dict, id_col: str) -> Optional[float]:
75
+ """1.0 if unique ID values match, 0.0 if both non-null and different."""
76
+ a = str(row_a.get(id_col, "") or "").strip()
77
+ b = str(row_b.get(id_col, "") or "").strip()
78
+ if not a or not b:
79
+ return None
80
+ return 1.0 if a.lower() == b.lower() else 0.0
81
+
82
+
83
+ def same_admin(row_a: dict, row_b: dict, col: str) -> Optional[float]:
84
+ """1.0 if same administrative unit, 0.0 otherwise."""
85
+ a = str(row_a.get(col, "") or "").strip().lower()
86
+ b = str(row_b.get(col, "") or "").strip().lower()
87
+ if not a or not b:
88
+ return None
89
+ return 1.0 if a == b else 0.0
90
+
91
+
92
+ def dob_match(row_a: dict, row_b: dict, dob_col: str) -> Optional[float]:
93
+ """1.0 if date of birth strings match exactly (after stripping)."""
94
+ a = str(row_a.get(dob_col, "") or "").strip()
95
+ b = str(row_b.get(dob_col, "") or "").strip()
96
+ if not a or not b:
97
+ return None
98
+ return 1.0 if a == b else 0.0
99
+
100
+
101
+ def same_gender(row_a: dict, row_b: dict, gender_col: str) -> Optional[float]:
102
+ """1.0 if gender values match (normalised to first letter)."""
103
+ a = str(row_a.get(gender_col, "") or "")[:1].upper()
104
+ b = str(row_b.get(gender_col, "") or "")[:1].upper()
105
+ if not a or not b:
106
+ return None
107
+ return 1.0 if a == b else 0.0
108
+
109
+
110
+ def same_enumerator(row_a: dict, row_b: dict, enum_col: str) -> Optional[float]:
111
+ """1.0 if records were collected by the same field agent."""
112
+ a = str(row_a.get(enum_col, "") or "").strip()
113
+ b = str(row_b.get(enum_col, "") or "").strip()
114
+ if not a or not b:
115
+ return None
116
+ return 1.0 if a == b else 0.0
117
+
118
+
119
+ def extract_all(row_a: dict, row_b: dict, schema) -> dict[str, Optional[float]]:
120
+ """
121
+ Extract all available features for a pair given the EntitySchema.
122
+ Returns a dict of feature_name -> value (None if feature unavailable).
123
+ """
124
+ from .schema import FieldRole
125
+ result: dict[str, Optional[float]] = {}
126
+ active = schema.active_features()
127
+
128
+ name_cols = schema.fields_by_role(FieldRole.NAME)
129
+ if "jw_name_full" in active: result["jw_name_full"] = jw_name_full(row_a, row_b, name_cols)
130
+ if "jw_name_first" in active: result["jw_name_first"] = jw_name_first(row_a, row_b, name_cols)
131
+ if "jw_name_last" in active: result["jw_name_last"] = jw_name_last(row_a, row_b, name_cols)
132
+
133
+ id_cols = schema.fields_by_role(FieldRole.ID)
134
+ if "exact_id" in active and id_cols:
135
+ result["exact_id"] = exact_id(row_a, row_b, id_cols[0])
136
+
137
+ phone_cols = schema.fields_by_role(FieldRole.PHONE)
138
+ if "exact_phone" in active and phone_cols:
139
+ result["exact_phone"] = exact_phone(row_a, row_b, phone_cols[0])
140
+
141
+ lat_cols = schema.fields_by_role(FieldRole.LATITUDE)
142
+ lon_cols = schema.fields_by_role(FieldRole.LONGITUDE)
143
+ if "haversine_m" in active and lat_cols and lon_cols:
144
+ result["haversine_m"] = haversine_m(row_a, row_b, lat_cols[0], lon_cols[0])
145
+
146
+ a1_cols = schema.fields_by_role(FieldRole.ADMIN_1)
147
+ if "same_admin_1" in active and a1_cols:
148
+ result["same_admin_1"] = same_admin(row_a, row_b, a1_cols[0])
149
+
150
+ a2_cols = schema.fields_by_role(FieldRole.ADMIN_2)
151
+ if "same_admin_2" in active and a2_cols:
152
+ result["same_admin_2"] = same_admin(row_a, row_b, a2_cols[0])
153
+
154
+ dob_cols = schema.fields_by_role(FieldRole.DATE_OF_BIRTH)
155
+ if "dob_match" in active and dob_cols:
156
+ result["dob_match"] = dob_match(row_a, row_b, dob_cols[0])
157
+
158
+ gender_cols = schema.fields_by_role(FieldRole.GENDER)
159
+ if "same_gender" in active and gender_cols:
160
+ result["same_gender"] = same_gender(row_a, row_b, gender_cols[0])
161
+
162
+ enum_cols = schema.fields_by_role(FieldRole.ENUMERATOR)
163
+ if "same_enumerator" in active and enum_cols:
164
+ result["same_enumerator"] = same_enumerator(row_a, row_b, enum_cols[0])
165
+
166
+ return result