Skip to main content

zer_compare/
comparator.rs

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
16/// Pairwise field comparator that applies similarity functions to produce a field-major `ComparisonBatch`.
17pub 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    /// Build a comparator for cross-schema linkage from an explicit field-mapping list.
41    ///
42    /// Field kinds are inferred from `a_schema` by looking up each `a_field`.
43    /// Fields not found in `a_schema` default to `FieldKind::Categorical`.
44    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    /// Compare a cross-schema pair using an explicit field-mapping list.
67    ///
68    /// For each mapping, looks up `a_field` in record `a` and `b_field` in
69    /// record `b`.  When a field is missing the `NullPolicy` decides the level:
70    /// `Skip` gives `Null` (EM ignores it), `PenaliseAbsence` gives `None` (hard fail).
71    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    /// Batch comparison for cross-schema linkage using explicit field mappings.
100    ///
101    /// Equivalent to calling `compare_pair_mapped` per pair then assembling the
102    /// field-major `ComparisonBatch`.  `n_fields = mappings.len()`.
103    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    /// Compare field `f` using the zero-alloc `similarity_str` hot path.
184    #[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    /// Pool-native batch comparison, the primary hot path.
197    ///
198    /// Reads `RecordPool` columns directly: zero HashMap lookups, no
199    /// `Record::clone()`.  Uses Rayon for parallel per-pair comparison
200    /// into a flat pair-major buffer (zero per-pair heap allocations), then
201    /// transposes to the field-major `ComparisonBatch` layout required by
202    /// all GPU EM kernels (CUDA/Vulkan/AVX2): `levels[f * n_pairs + p]`.
203    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        // Pre-compute pair IDs (cheap, serial).
217        let pair_ids: Vec<(u64, u64)> = indices
218            .iter()
219            .map(|&(i, j)| (pool.ids[i], pool.ids[j]))
220            .collect();
221
222        // Phase 1: parallel pair-major fill.  Each pair owns a contiguous
223        // n_fields-byte slice, no per-pair allocation.
224        // pair_major[p * n_fields + f] = level for pair p, field f.
225        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        // Phase 2: transpose pair-major → field-major.
236        // Output: levels[f * n_pairs + p]  (required by GPU EM kernels).
237        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        // All identical → all Exact
388        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}