1use rayon::prelude::*;
2use zer_core::{
3 comparison::{ComparisonBatch, ComparisonLevel, ComparisonVector},
4 field_mapping::{FieldMapping, NullPolicy},
5 record::Record,
6 record_pool::RecordPool,
7 schema::{FieldKind, Schema},
8 traits::Comparator,
9};
10
11use crate::{
12 discretize::LevelThresholds,
13 similarity::{default_fns_for, SimilarityFn},
14};
15
16pub struct FieldComparator {
18 field_fns: Vec<Vec<Box<dyn SimilarityFn>>>,
19 thresholds: Vec<LevelThresholds>,
20}
21
22impl FieldComparator {
23 pub fn from_schema(schema: &Schema) -> Self {
24 let field_fns = schema
25 .fields
26 .iter()
27 .map(|f| default_fns_for(f.kind))
28 .collect();
29 let thresholds = schema
30 .fields
31 .iter()
32 .map(|f| LevelThresholds::for_kind(f.kind))
33 .collect();
34 Self {
35 field_fns,
36 thresholds,
37 }
38 }
39
40 pub fn from_mapping(mappings: &[FieldMapping], a_schema: &Schema) -> Self {
45 let kind_of = |name: &str| {
46 a_schema
47 .fields
48 .iter()
49 .find(|f| f.name == name)
50 .map(|f| f.kind)
51 .unwrap_or(FieldKind::Categorical)
52 };
53 let (field_fns, thresholds): (Vec<_>, Vec<_>) = mappings
54 .iter()
55 .map(|m| {
56 let k = kind_of(&m.a_field);
57 (default_fns_for(k), LevelThresholds::for_kind(k))
58 })
59 .unzip();
60 Self {
61 field_fns,
62 thresholds,
63 }
64 }
65
66 pub fn compare_pair_mapped(
72 &self,
73 a: &Record,
74 b: &Record,
75 mappings: &[FieldMapping],
76 ) -> ComparisonVector {
77 let levels: Vec<ComparisonLevel> = mappings
78 .iter()
79 .enumerate()
80 .map(|(i, m)| {
81 let va = a.fields.get(&m.a_field);
82 let vb = b.fields.get(&m.b_field);
83 match (va, vb, &m.null_policy) {
84 (Some(va), Some(vb), _) => {
85 let sim = self.field_fns[i]
86 .iter()
87 .map(|f| f.similarity(va, vb))
88 .fold(0.0_f32, f32::max);
89 self.thresholds[i].apply(sim)
90 }
91 (_, _, NullPolicy::PenaliseAbsence) => ComparisonLevel::None,
92 (_, _, NullPolicy::Skip) => ComparisonLevel::Null,
93 }
94 })
95 .collect();
96 ComparisonVector::new(a.id, b.id, levels)
97 }
98
99 pub fn compare_batch_mapped(
104 &self,
105 records: &[Record],
106 indices: &[(usize, usize)],
107 mappings: &[FieldMapping],
108 ) -> ComparisonBatch {
109 let n_pairs = indices.len();
110 let n_fields = mappings.len();
111
112 if n_pairs == 0 {
113 return ComparisonBatch::new(0, n_fields, vec![]);
114 }
115
116 let pair_ids_and_levels: Vec<((u64, u64), Vec<u8>)> = indices
117 .par_iter()
118 .map(|&(i, j)| {
119 let ids = (records[i].id, records[j].id);
120 let cv = self.compare_pair_mapped(&records[i], &records[j], mappings);
121 let levels = cv.levels.iter().map(|&l| l as u8).collect();
122 (ids, levels)
123 })
124 .collect();
125
126 Self::scatter_to_batch(n_pairs, n_fields, pair_ids_and_levels)
127 }
128
129 fn scatter_to_batch(
130 n_pairs: usize,
131 n_fields: usize,
132 pair_ids_and_levels: Vec<((u64, u64), Vec<u8>)>,
133 ) -> ComparisonBatch {
134 let pair_ids: Vec<(u64, u64)> = pair_ids_and_levels.iter().map(|(ids, _)| *ids).collect();
135 let mut levels = vec![0u8; n_fields * n_pairs];
136 for f in 0..n_fields {
137 let field_slice = &mut levels[f * n_pairs..(f + 1) * n_pairs];
138 for (p, (_, pair_lvls)) in pair_ids_and_levels.iter().enumerate() {
139 field_slice[p] = pair_lvls[f];
140 }
141 }
142 ComparisonBatch {
143 n_pairs,
144 n_fields,
145 pair_ids,
146 levels,
147 }
148 }
149
150 pub fn with_thresholds(mut self, field_idx: usize, thresholds: LevelThresholds) -> Self {
151 self.thresholds[field_idx] = thresholds;
152 self
153 }
154
155 pub fn with_fns(mut self, field_idx: usize, fns: Vec<Box<dyn SimilarityFn>>) -> Self {
156 self.field_fns[field_idx] = fns;
157 self
158 }
159
160 fn compare_pair(&self, a: &Record, b: &Record, schema: &Schema) -> ComparisonVector {
161 let levels: Vec<ComparisonLevel> = schema
162 .fields
163 .iter()
164 .enumerate()
165 .map(|(i, field)| {
166 let va = a.fields.get(&field.name);
167 let vb = b.fields.get(&field.name);
168 match (va, vb) {
169 (Some(va), Some(vb)) => {
170 let sim = self.field_fns[i]
171 .iter()
172 .map(|f| f.similarity(va, vb))
173 .fold(0.0_f32, f32::max);
174 self.thresholds[i].apply(sim)
175 }
176 _ => ComparisonLevel::None,
177 }
178 })
179 .collect();
180 ComparisonVector::new(a.id, b.id, levels)
181 }
182
183 #[inline]
185 fn compare_pool_field(&self, f: usize, a_str: &str, b_str: &str) -> u8 {
186 if a_str.is_empty() || b_str.is_empty() {
187 return ComparisonLevel::None as u8;
188 }
189 let sim = self.field_fns[f]
190 .iter()
191 .map(|fn_| fn_.similarity_str(a_str, b_str))
192 .fold(0.0_f32, f32::max);
193 self.thresholds[f].apply(sim) as u8
194 }
195
196 pub fn compare_batch_from_pool(
204 &self,
205 pool: &RecordPool,
206 indices: &[(usize, usize)],
207 schema: &Schema,
208 ) -> ComparisonBatch {
209 let n_pairs = indices.len();
210 let n_fields = schema.fields.len();
211
212 if n_pairs == 0 {
213 return ComparisonBatch::new(0, n_fields, vec![]);
214 }
215
216 let pair_ids: Vec<(u64, u64)> = indices
218 .iter()
219 .map(|&(i, j)| (pool.ids[i], pool.ids[j]))
220 .collect();
221
222 let mut pair_major = vec![0u8; n_pairs * n_fields];
226 pair_major
227 .par_chunks_mut(n_fields)
228 .zip(indices.par_iter())
229 .for_each(|(chunk, &(i, j))| {
230 for (f, item) in chunk.iter_mut().enumerate() {
231 *item = self.compare_pool_field(f, pool.get(f, i), pool.get(f, j));
232 }
233 });
234
235 let mut levels = vec![0u8; n_fields * n_pairs];
238 for (p, chunk) in pair_major.chunks_exact(n_fields).enumerate() {
239 for (f, &lvl) in chunk.iter().enumerate() {
240 levels[f * n_pairs + p] = lvl;
241 }
242 }
243
244 ComparisonBatch {
245 n_pairs,
246 n_fields,
247 pair_ids,
248 levels,
249 }
250 }
251}
252
253impl Comparator for FieldComparator {
254 fn compare(&self, a: &Record, b: &Record, schema: &Schema) -> ComparisonVector {
255 self.compare_pair(a, b, schema)
256 }
257
258 fn compare_batch_from_pool(
259 &self,
260 pool: &RecordPool,
261 indices: &[(usize, usize)],
262 schema: &Schema,
263 ) -> ComparisonBatch {
264 self.compare_batch_from_pool(pool, indices, schema)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use zer_core::{
271 comparison::ComparisonLevel,
272 record::FieldValue,
273 record_pool::RecordPool,
274 schema::{FieldKind, SchemaBuilder},
275 };
276
277 use super::*;
278
279 fn person_schema() -> Schema {
280 SchemaBuilder::new()
281 .field("voornamen", FieldKind::Name)
282 .field("achternaam", FieldKind::Name)
283 .field("geboortedatum", FieldKind::Date)
284 .field("postcode", FieldKind::Id)
285 .build()
286 .unwrap()
287 }
288
289 fn make_record(
290 id: u64,
291 voornamen: &str,
292 achternaam: &str,
293 dob: &str,
294 postcode: &str,
295 ) -> Record {
296 Record::new(id)
297 .insert("voornamen", FieldValue::Text(voornamen.into()))
298 .insert("achternaam", FieldValue::Text(achternaam.into()))
299 .insert("geboortedatum", FieldValue::Text(dob.into()))
300 .insert("postcode", FieldValue::Text(postcode.into()))
301 }
302
303 #[test]
304 fn compare_returns_correct_field_count() {
305 let schema = person_schema();
306 let cmp = FieldComparator::from_schema(&schema);
307 let a = make_record(1, "Jan", "Jansen", "1990-06-15", "1011AB");
308 let b = make_record(2, "Jan", "Jansen", "1990-06-15", "1011AB");
309 let cv = cmp.compare(&a, &b, &schema);
310 assert_eq!(cv.levels.len(), schema.len());
311 }
312
313 #[test]
314 fn identical_records_score_exact_on_all_fields() {
315 let schema = person_schema();
316 let cmp = FieldComparator::from_schema(&schema);
317 let a = make_record(1, "Jan", "Jansen", "1990-06-15", "1011AB");
318 let b = make_record(2, "Jan", "Jansen", "1990-06-15", "1011AB");
319 let cv = cmp.compare(&a, &b, &schema);
320 assert!(
321 cv.levels.iter().all(|&l| l == ComparisonLevel::Exact),
322 "identical records should have all Exact levels: {:?}",
323 cv.levels
324 );
325 }
326
327 #[test]
328 fn completely_different_records_score_none_or_low() {
329 let schema = person_schema();
330 let cmp = FieldComparator::from_schema(&schema);
331 let a = make_record(1, "Jan", "Jansen", "1990-06-15", "1011AB");
332 let b = make_record(2, "Maria", "Bakker", "1955-12-01", "3001XY");
333 let cv = cmp.compare(&a, &b, &schema);
334 let n_none = cv
335 .levels
336 .iter()
337 .filter(|&&l| l == ComparisonLevel::None)
338 .count();
339 assert!(
340 n_none >= 2,
341 "dissimilar records should have several None levels: {:?}",
342 cv.levels
343 );
344 }
345
346 #[test]
347 fn missing_field_produces_none() {
348 let schema = person_schema();
349 let cmp = FieldComparator::from_schema(&schema);
350 let a = make_record(1, "Jan", "Jansen", "1990-06-15", "1011AB");
351 let b = Record::new(2)
352 .insert("voornamen", FieldValue::Text("Jan".into()))
353 .insert("achternaam", FieldValue::Text("Jansen".into()))
354 .insert("geboortedatum", FieldValue::Text("1990-06-15".into()));
355 let cv = cmp.compare(&a, &b, &schema);
356 assert_eq!(
357 cv.levels[3],
358 ComparisonLevel::None,
359 "missing postcode should yield None, got {:?}",
360 cv.levels[3]
361 );
362 }
363
364 #[test]
365 fn compare_batch_field_major_layout() {
366 let schema = person_schema();
367 let cmp = FieldComparator::from_schema(&schema);
368 let n_fields = schema.len();
369
370 let records: Vec<Record> = (0..5)
371 .flat_map(|i| {
372 vec![
373 make_record(i * 2, "Jan", "Jansen", "1990-06-15", "1011AB"),
374 make_record(i * 2 + 1, "Jan", "Jansen", "1990-06-15", "1011AB"),
375 ]
376 })
377 .collect();
378 let pool = RecordPool::from_records(&records, &schema);
379 let indices: Vec<(usize, usize)> = (0..5).map(|i| (i * 2, i * 2 + 1)).collect();
380
381 let batch = cmp.compare_batch_from_pool(&pool, &indices, &schema);
382
383 assert_eq!(batch.n_pairs, 5);
384 assert_eq!(batch.n_fields, n_fields);
385 assert_eq!(batch.levels.len(), n_fields * 5);
386
387 for f in 0..n_fields {
389 for p in 0..5 {
390 assert_eq!(
391 batch.level(f, p),
392 ComparisonLevel::Exact,
393 "field {f} pair {p} should be Exact"
394 );
395 }
396 }
397 }
398
399 #[test]
400 fn compare_batch_from_pool_matches_individual_compare() {
401 let schema = person_schema();
402 let cmp = FieldComparator::from_schema(&schema);
403 let records: Vec<Record> = (0..20)
404 .flat_map(|i| {
405 vec![
406 make_record(i * 2, "Jan", "Jansen", "1990-06-15", "1011AB"),
407 make_record(i * 2 + 1, "Jan", "Jansen", "1990-06-15", "1011AB"),
408 ]
409 })
410 .collect();
411 let pool = RecordPool::from_records(&records, &schema);
412 let indices: Vec<(usize, usize)> = (0..20).map(|i| (i * 2, i * 2 + 1)).collect();
413
414 let batch = cmp.compare_batch_from_pool(&pool, &indices, &schema);
415 for (p, &(i, j)) in indices.iter().enumerate() {
416 let single = cmp.compare(&records[i], &records[j], &schema);
417 for (f, &expected) in single.levels.iter().enumerate() {
418 assert_eq!(
419 batch.level(f, p),
420 expected,
421 "batch and individual disagree at field {f} pair {p}"
422 );
423 }
424 }
425 }
426
427 #[test]
428 fn empty_batch_is_valid() {
429 let schema = person_schema();
430 let cmp = FieldComparator::from_schema(&schema);
431 let pool = RecordPool::new(schema.fields.len());
432 let batch = cmp.compare_batch_from_pool(&pool, &[], &schema);
433 assert_eq!(batch.n_pairs, 0);
434 assert!(batch.levels.is_empty());
435 }
436}