1use arrow::array::{Array, ArrayRef, AsArray, Int32Array, Int32Builder};
7use arrow::datatypes::{DataType, Field, Schema};
8use arrow::record_batch::RecordBatch;
9use std::sync::Arc;
10
11struct Bitset {
16 words: Vec<u64>,
17 len: usize,
18}
19
20impl Bitset {
21 fn with_capacity(n: usize) -> Self {
22 Self {
23 words: vec![0u64; n.div_ceil(64).max(1)],
24 len: 0,
25 }
26 }
27
28 fn push(&mut self, val: bool) {
29 let word_idx = self.len / 64;
30 if word_idx >= self.words.len() {
31 self.words.push(0);
32 }
33 if val {
34 self.words[word_idx] |= 1u64 << (self.len % 64);
35 }
36 self.len += 1;
37 }
38
39 fn get(&self, i: usize) -> bool {
40 (self.words[i / 64] >> (i % 64)) & 1 != 0
41 }
42}
43
44pub struct BitspanRef<'a> {
48 bits: &'a Bitset,
49 start: usize,
50 end: usize,
51}
52
53impl BitspanRef<'_> {
54 pub fn get(&self, i: usize) -> bool {
57 self.bits.get(self.start + i)
58 }
59
60 pub fn len(&self) -> usize {
61 self.end - self.start
62 }
63
64 pub fn is_empty(&self) -> bool {
65 self.start == self.end
66 }
67
68 #[cfg(test)]
69 fn to_vec(&self) -> Vec<bool> {
70 (0..self.len()).map(|i| self.get(i)).collect()
71 }
72}
73
74pub struct ClusterCsr {
90 offsets: Vec<u32>,
94 records: Vec<u64>,
98 is_identical: Bitset,
101}
102
103const RIDX_IDENTICAL_BIT: u64 = 1 << 63;
112const RIDX_MASK: u64 = !RIDX_IDENTICAL_BIT;
113
114pub(crate) fn pack_ridx_identical(record_idx: u64, is_identical: bool) -> u64 {
117 debug_assert_eq!(
118 record_idx & RIDX_IDENTICAL_BIT,
119 0,
120 "record_idx must fit in the low 63 bits"
121 );
122 record_idx | if is_identical { RIDX_IDENTICAL_BIT } else { 0 }
123}
124
125fn csr_offset(n: usize) -> u32 {
130 u32::try_from(n).expect("cluster_map exceeds u32::MAX rows (pipeline::check_capacity)")
131}
132
133impl ClusterCsr {
134 pub(crate) fn build(mut pairs: Vec<(u64, u64)>) -> Self {
149 use rayon::slice::ParallelSliceMut;
150 pairs.par_sort_unstable_by_key(|&(mk, r)| (mk, r & RIDX_MASK));
151
152 let mut offsets = Vec::new();
153 let mut records = Vec::with_capacity(pairs.len());
154 let mut is_identical = Bitset::with_capacity(pairs.len());
155 let mut last_key: Option<u64> = None;
156
157 for (mk, r) in pairs {
158 if last_key != Some(mk) {
159 offsets.push(csr_offset(records.len()));
160 last_key = Some(mk);
161 }
162 records.push(r & RIDX_MASK);
163 is_identical.push(r & RIDX_IDENTICAL_BIT != 0);
164 }
165 offsets.push(csr_offset(records.len()));
166
167 Self {
168 offsets,
169 records,
170 is_identical,
171 }
172 }
173
174 pub fn groups(&self) -> impl Iterator<Item = (&[u64], BitspanRef<'_>)> {
179 self.offsets.windows(2).map(move |w| {
180 let (start, end) = (w[0] as usize, w[1] as usize);
181 (
182 &self.records[start..end],
183 BitspanRef {
184 bits: &self.is_identical,
185 start,
186 end,
187 },
188 )
189 })
190 }
191
192 #[cfg(test)]
193 fn n_clusters(&self) -> usize {
194 self.offsets.len().saturating_sub(1)
195 }
196}
197
198pub struct GtResult {
200 pub n_exact_dup: usize,
204 pub n_fuzzy_dup: usize,
209 pub n_hard_neg: usize,
210 pub n_unique: usize,
211 pub n_masters: usize,
212 pub cluster_map: ClusterCsr,
220}
221
222fn low_cardinality_dict_type() -> DataType {
229 DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
230}
231
232pub struct BaseRows<'a> {
238 pub record_ids: &'a ArrayRef,
239 pub entity_types: &'a ArrayRef,
240 pub master_ids: &'a ArrayRef,
241 pub entity_idx: u64,
242 pub first_master_idx: u64,
243 pub first_record_idx: u64,
244}
245
246pub struct DupRows<'a> {
251 pub record_ids: &'a ArrayRef,
252 pub entity_types: &'a ArrayRef,
253 pub master_ids: &'a ArrayRef,
254 pub master_idx: &'a [usize],
257 pub is_identical: &'a [bool],
261 pub first_record_idx: u64,
262}
263
264#[derive(Default)]
267struct LocalBits {
268 words: Vec<u64>,
269}
270
271impl LocalBits {
272 fn reset(&mut self, n: usize) {
273 self.words.clear();
274 self.words.resize(n.div_ceil(64), 0);
275 }
276
277 fn set(&mut self, i: usize) {
278 self.words[i / 64] |= 1u64 << (i % 64);
279 }
280
281 fn get(&self, i: usize) -> bool {
282 (self.words[i / 64] >> (i % 64)) & 1 != 0
283 }
284}
285
286pub struct GtAccumulator {
303 sink: GtSink,
304 schema: Arc<Schema>,
305 match_type_dict: crate::pipeline::DictValues,
306 difficulty_dict: crate::pipeline::DictValues,
307 difficulty: String,
308 k_hard_neg: i32,
311 k_canary: i32,
312 k_exact_dup: i32,
313 k_fuzzy_dup: i32,
314 k_unique: i32,
315 diff_arr_cache: Option<(usize, ArrayRef)>,
319 n_exact_dup: usize,
320 n_fuzzy_dup: usize,
321 n_hard_neg: usize,
322 n_unique: usize,
323 n_masters: usize,
324 track_clusters: bool,
329 cluster_pairs: Vec<(u64, u64)>,
330 is_dup: LocalBits,
331 has_exact_copy: LocalBits,
332}
333
334impl GtAccumulator {
335 pub fn new(
336 difficulty: &str,
337 output_format: &str,
338 final_path: &str,
339 metadata: &crate::pipeline::RunMetadata,
340 track_clusters: bool,
341 ) -> Result<Self, String> {
342 let schema = Arc::new(
343 Schema::new(vec![
344 Field::new("record_id", DataType::Utf8, false),
345 Field::new("master_id", DataType::Utf8, false),
346 Field::new("entity_type", low_cardinality_dict_type(), false),
347 Field::new("match_type", low_cardinality_dict_type(), false),
348 Field::new("difficulty", low_cardinality_dict_type(), false),
349 ])
350 .with_metadata(crate::pipeline::arrow_metadata(metadata)),
351 );
352 let sink = GtSink::new(output_format, final_path, &schema, metadata)?;
353 let match_type_dict = crate::pipeline::DictValues::new([
357 "hard_neg",
358 "canary",
359 "exact_dup",
360 "fuzzy_dup",
361 "unique",
362 ]);
363 let difficulty_dict = crate::pipeline::DictValues::new([difficulty.to_string()]);
364 Ok(Self {
365 sink,
366 schema,
367 k_hard_neg: match_type_dict.key("hard_neg"),
368 k_canary: match_type_dict.key("canary"),
369 k_exact_dup: match_type_dict.key("exact_dup"),
370 k_fuzzy_dup: match_type_dict.key("fuzzy_dup"),
371 k_unique: match_type_dict.key("unique"),
372 match_type_dict,
373 difficulty_dict,
374 difficulty: difficulty.to_string(),
375 diff_arr_cache: None,
376 n_exact_dup: 0,
377 n_fuzzy_dup: 0,
378 n_hard_neg: 0,
379 n_unique: 0,
380 n_masters: 0,
381 track_clusters,
382 cluster_pairs: Vec::new(),
383 is_dup: LocalBits::default(),
384 has_exact_copy: LocalBits::default(),
385 })
386 }
387
388 fn write(
389 &mut self,
390 record_ids: &ArrayRef,
391 master_ids: &ArrayRef,
392 entity_types: &ArrayRef,
393 match_type_keys: Int32Array,
394 ) -> Result<(), String> {
395 let n = record_ids.len();
396 let diff_arr = match &self.diff_arr_cache {
397 Some((cached_n, arr)) if *cached_n == n => arr.clone(),
398 _ => {
399 let arr = self.difficulty_dict.const_array(&self.difficulty, n);
400 self.diff_arr_cache = Some((n, arr.clone()));
401 arr
402 }
403 };
404 let batch = RecordBatch::try_new(
405 self.schema.clone(),
406 vec![
407 record_ids.clone(),
408 master_ids.clone(),
409 entity_types.clone(),
410 self.match_type_dict.finish_keys(match_type_keys),
411 diff_arr,
412 ],
413 )
414 .map_err(|e| format!("build gt batch: {e}"))?;
415 self.sink.write(&batch)
416 }
417
418 pub fn push_entity_batch(
428 &mut self,
429 base: &BaseRows,
430 dups: Option<&DupRows>,
431 ) -> Result<(), String> {
432 let n = base.record_ids.len();
433 debug_assert_ids(
434 base.master_ids,
435 base.record_ids,
436 base.entity_idx,
437 base.first_master_idx,
438 base.first_record_idx,
439 );
440 self.n_masters += n;
441 self.is_dup.reset(n);
442 self.has_exact_copy.reset(n);
443 if let Some(d) = dups {
444 if d.master_idx.len() != d.record_ids.len()
445 || d.is_identical.len() != d.record_ids.len()
446 {
447 return Err(format!(
448 "gt: duplicate batch length mismatch (rows={}, master_idx={}, is_identical={})",
449 d.record_ids.len(),
450 d.master_idx.len(),
451 d.is_identical.len()
452 ));
453 }
454 for (i, (&g, &ident)) in d.master_idx.iter().zip(d.is_identical).enumerate() {
455 let local = (g as u64)
456 .checked_sub(base.first_master_idx)
457 .filter(|&l| l < n as u64)
458 .ok_or_else(|| {
459 format!(
460 "gt: duplicate row {i} references master {g}, outside its base \
461 batch [{}, {}) — duplicates must come from their own batch",
462 base.first_master_idx,
463 base.first_master_idx + n as u64
464 )
465 })? as usize;
466 self.is_dup.set(local);
467 if ident {
468 self.has_exact_copy.set(local);
469 }
470 }
471 }
472
473 let mut keys = Int32Builder::with_capacity(n);
474 for j in 0..n {
475 let key = if !self.is_dup.get(j) {
476 self.n_unique += 1;
477 self.k_unique
478 } else {
479 let ident = self.has_exact_copy.get(j);
480 if self.track_clusters {
481 self.cluster_pairs.push((
482 crate::pipeline::pack_master_parts(
483 base.entity_idx,
484 base.first_master_idx + j as u64,
485 ),
486 pack_ridx_identical(base.first_record_idx + j as u64, ident),
487 ));
488 }
489 if ident {
490 self.n_exact_dup += 1;
491 self.k_exact_dup
492 } else {
493 self.n_fuzzy_dup += 1;
494 self.k_fuzzy_dup
495 }
496 };
497 keys.append_value(key);
498 }
499 self.write(
500 base.record_ids,
501 base.master_ids,
502 base.entity_types,
503 keys.finish(),
504 )?;
505
506 if let Some(d) = dups {
507 let nd = d.record_ids.len();
508 if nd > 0 {
509 debug_assert_ids(
510 d.master_ids,
511 d.record_ids,
512 base.entity_idx,
513 d.master_idx[0] as u64,
514 d.first_record_idx,
515 );
516 }
517 let mut keys = Int32Builder::with_capacity(nd);
518 for (i, (&g, &ident)) in d.master_idx.iter().zip(d.is_identical).enumerate() {
519 if self.track_clusters {
520 self.cluster_pairs.push((
521 crate::pipeline::pack_master_parts(base.entity_idx, g as u64),
522 pack_ridx_identical(d.first_record_idx + i as u64, ident),
523 ));
524 }
525 keys.append_value(if ident {
526 self.n_exact_dup += 1;
527 self.k_exact_dup
528 } else {
529 self.n_fuzzy_dup += 1;
530 self.k_fuzzy_dup
531 });
532 }
533 self.write(d.record_ids, d.master_ids, d.entity_types, keys.finish())?;
534 }
535 Ok(())
536 }
537
538 pub fn push_hard_neg_batch(
541 &mut self,
542 record_ids: &ArrayRef,
543 entity_types: &ArrayRef,
544 master_ids: &ArrayRef,
545 ) -> Result<(), String> {
546 debug_assert_prefix(master_ids, "HN-");
547 let n = record_ids.len();
548 self.n_hard_neg += n;
549 let keys = Int32Array::from(vec![self.k_hard_neg; n]);
550 self.write(record_ids, master_ids, entity_types, keys)
551 }
552
553 pub fn push_canary_batch(
556 &mut self,
557 record_ids: &ArrayRef,
558 entity_types: &ArrayRef,
559 master_ids: &ArrayRef,
560 ) -> Result<(), String> {
561 debug_assert_prefix(master_ids, "CANARY-");
562 let keys = Int32Array::from(vec![self.k_canary; record_ids.len()]);
563 self.write(record_ids, master_ids, entity_types, keys)
564 }
565
566 pub fn finish(self) -> Result<GtResult, String> {
569 self.sink.finish()?;
570 Ok(GtResult {
571 n_exact_dup: self.n_exact_dup,
572 n_fuzzy_dup: self.n_fuzzy_dup,
573 n_hard_neg: self.n_hard_neg,
574 n_unique: self.n_unique,
575 n_masters: self.n_masters,
576 cluster_map: ClusterCsr::build(self.cluster_pairs),
577 })
578 }
579}
580
581fn debug_assert_ids(
586 master_ids: &ArrayRef,
587 record_ids: &ArrayRef,
588 entity_idx: u64,
589 master_idx: u64,
590 record_idx: u64,
591) {
592 if cfg!(debug_assertions) && !master_ids.is_empty() {
593 let mid = master_ids.as_string::<i32>().value(0);
594 let rid = record_ids.as_string::<i32>().value(0);
595 assert_eq!(
596 crate::pipeline::pack_master_key(mid),
597 Some(crate::pipeline::pack_master_parts(entity_idx, master_idx)),
598 "gt: master_id {mid:?} doesn't match entity {entity_idx} / master {master_idx}"
599 );
600 assert_eq!(
601 crate::pipeline::parse_record_idx(rid),
602 Some(record_idx),
603 "gt: record_id {rid:?} doesn't match record index {record_idx}"
604 );
605 }
606}
607
608fn debug_assert_prefix(master_ids: &ArrayRef, prefix: &str) {
609 if cfg!(debug_assertions) && !master_ids.is_empty() {
610 let mid = master_ids.as_string::<i32>().value(0);
611 assert!(
612 mid.starts_with(prefix),
613 "gt: master_id {mid:?} should start with {prefix:?}"
614 );
615 }
616}
617
618enum GtSink {
619 Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
620 Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
621}
622
623impl GtSink {
624 fn new(
625 output_format: &str,
626 path: &str,
627 schema: &Arc<Schema>,
628 metadata: &crate::pipeline::RunMetadata,
629 ) -> Result<Self, String> {
630 let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
631 if output_format == "parquet" {
632 use parquet::basic::{Compression, ZstdLevel};
633 use parquet::file::properties::WriterProperties;
634 let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
635 let meta_kv = crate::pipeline::parquet_kv(metadata);
636 let props = WriterProperties::builder()
637 .set_compression(Compression::ZSTD(zstd))
638 .set_data_page_size_limit(1_048_576)
639 .set_key_value_metadata(Some(meta_kv))
640 .build();
641 let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
642 .map_err(|e| format!("gt parquet writer: {e}"))?;
643 Ok(GtSink::Parquet(Box::new(writer)))
644 } else {
645 let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
646 .map_err(|e| format!("gt ipc writer: {e}"))?;
647 Ok(GtSink::Ipc(Box::new(writer)))
648 }
649 }
650
651 fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
652 match self {
653 GtSink::Ipc(w) => w.write(batch).map_err(|e| format!("write gt ipc: {e}")),
654 GtSink::Parquet(w) => w.write(batch).map_err(|e| format!("write gt parquet: {e}")),
655 }
656 }
657
658 fn finish(self) -> Result<(), String> {
659 match self {
660 GtSink::Ipc(mut w) => w.finish().map_err(|e| format!("finish gt ipc: {e}")),
661 GtSink::Parquet(w) => w
662 .close()
663 .map(|_| ())
664 .map_err(|e| format!("close gt parquet: {e}")),
665 }
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use arrow::array::StringArray;
673 use std::collections::HashMap;
674
675 fn tmp_path(name: &str) -> String {
676 let mut p = std::env::temp_dir();
677 p.push(format!(
678 "dupehell_gt_test_{name}_{}.ipc",
679 std::process::id()
680 ));
681 p.to_string_lossy().into_owned()
682 }
683
684 fn arr_owned(values: Vec<String>) -> ArrayRef {
685 Arc::new(StringArray::from(values))
686 }
687
688 fn dict_arr(dict: &crate::pipeline::DictValues, value: &str, n: usize) -> ArrayRef {
694 dict.const_array(value, n)
695 }
696
697 fn read_match_types(final_path: &str) -> HashMap<String, String> {
699 let file = std::fs::File::open(final_path).unwrap();
700 let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
701 let mut out = HashMap::new();
702 for batch in reader {
703 let batch = batch.unwrap();
704 let rid = batch
705 .column_by_name("record_id")
706 .unwrap()
707 .as_string::<i32>();
708 let mt_col =
711 arrow::compute::cast(batch.column_by_name("match_type").unwrap(), &DataType::Utf8)
712 .unwrap();
713 let mt = mt_col.as_string::<i32>();
714 for i in 0..batch.num_rows() {
715 out.insert(rid.value(i).to_string(), mt.value(i).to_string());
716 }
717 }
718 out
719 }
720
721 fn mid(entity: usize, n: usize) -> String {
724 format!(
725 "{}-{}",
726 crate::pipeline::entity_prefix(entity),
727 crate::pipeline::pad_string(n)
728 )
729 }
730
731 fn rid(i: usize) -> String {
732 crate::pipeline::record_id_string(i)
733 }
734
735 struct Base {
738 rids: ArrayRef,
739 ets: ArrayRef,
740 mids: ArrayRef,
741 entity: u64,
742 first_master: u64,
743 first_rid: u64,
744 }
745
746 impl Base {
747 fn new(
748 et: &crate::pipeline::DictValues,
749 name: &str,
750 entity: usize,
751 first_master: usize,
752 first_rid: usize,
753 n: usize,
754 ) -> Self {
755 Self {
756 rids: arr_owned((first_rid..first_rid + n).map(rid).collect()),
757 ets: dict_arr(et, name, n),
758 mids: arr_owned(
759 (first_master..first_master + n)
760 .map(|m| mid(entity, m))
761 .collect(),
762 ),
763 entity: entity as u64,
764 first_master: first_master as u64,
765 first_rid: first_rid as u64,
766 }
767 }
768
769 fn rows(&self) -> BaseRows<'_> {
770 BaseRows {
771 record_ids: &self.rids,
772 entity_types: &self.ets,
773 master_ids: &self.mids,
774 entity_idx: self.entity,
775 first_master_idx: self.first_master,
776 first_record_idx: self.first_rid,
777 }
778 }
779 }
780
781 struct Dups {
784 rids: ArrayRef,
785 ets: ArrayRef,
786 mids: ArrayRef,
787 master_idx: Vec<usize>,
788 ident: Vec<bool>,
789 first_rid: u64,
790 }
791
792 impl Dups {
793 fn new(
794 et: &crate::pipeline::DictValues,
795 name: &str,
796 entity: usize,
797 first_rid: usize,
798 copies: &[(usize, bool)],
799 ) -> Self {
800 let n = copies.len();
801 Self {
802 rids: arr_owned((first_rid..first_rid + n).map(rid).collect()),
803 ets: dict_arr(et, name, n),
804 mids: arr_owned(copies.iter().map(|&(m, _)| mid(entity, m)).collect()),
805 master_idx: copies.iter().map(|&(m, _)| m).collect(),
806 ident: copies.iter().map(|&(_, i)| i).collect(),
807 first_rid: first_rid as u64,
808 }
809 }
810
811 fn rows(&self) -> DupRows<'_> {
812 DupRows {
813 record_ids: &self.rids,
814 entity_types: &self.ets,
815 master_ids: &self.mids,
816 master_idx: &self.master_idx,
817 is_identical: &self.ident,
818 first_record_idx: self.first_rid,
819 }
820 }
821 }
822
823 fn hn(et: &crate::pipeline::DictValues, first_rid: usize, n: usize) -> [ArrayRef; 3] {
824 [
825 arr_owned((first_rid..first_rid + n).map(rid).collect()),
826 dict_arr(et, "person", n),
827 arr_owned((0..n).map(|i| format!("HN-{i:09}")).collect()),
828 ]
829 }
830
831 fn acc(path: &str, track_clusters: bool) -> GtAccumulator {
832 GtAccumulator::new(
833 "medium",
834 "ipc",
835 path,
836 &crate::pipeline::RunMetadata::new(),
837 track_clusters,
838 )
839 .unwrap()
840 }
841
842 #[test]
843 fn test_gt_accumulator_basic() {
844 let path = tmp_path("basic");
845 let et = crate::pipeline::DictValues::new(["person"]);
846 let mut acc = acc(&path, false);
847 let base = Base::new(&et, "person", 0, 0, 0, 3);
850 let dups = Dups::new(&et, "person", 0, 3, &[(0, true)]);
851 acc.push_entity_batch(&base.rows(), Some(&dups.rows()))
852 .unwrap();
853 let [r, e, m] = hn(&et, 4, 2);
854 acc.push_hard_neg_batch(&r, &e, &m).unwrap();
855
856 let res = acc.finish().unwrap();
857 assert_eq!(res.n_exact_dup, 2);
858 assert_eq!(res.n_fuzzy_dup, 0);
859 assert_eq!(res.n_hard_neg, 2);
860 assert_eq!(res.n_unique, 2);
861 assert_eq!(res.n_masters, 3);
862
863 let mt = read_match_types(&path);
864 assert_eq!(mt[&rid(0)], "exact_dup");
865 assert_eq!(mt[&rid(1)], "unique");
866 assert_eq!(mt[&rid(2)], "unique");
867 assert_eq!(mt[&rid(3)], "exact_dup");
868 assert_eq!(mt[&rid(4)], "hard_neg");
869 assert_eq!(mt[&rid(5)], "hard_neg");
870 std::fs::remove_file(&path).ok();
871 }
872
873 #[test]
876 fn test_gt_accumulator_fuzzy_dup() {
877 let path = tmp_path("fuzzy");
878 let et = crate::pipeline::DictValues::new(["person"]);
879 let mut acc = acc(&path, false);
880 let base = Base::new(&et, "person", 0, 0, 0, 1);
881 let dups = Dups::new(&et, "person", 0, 1, &[(0, true), (0, false)]);
882 acc.push_entity_batch(&base.rows(), Some(&dups.rows()))
883 .unwrap();
884
885 let res = acc.finish().unwrap();
886 assert_eq!(res.n_exact_dup, 2); assert_eq!(res.n_fuzzy_dup, 1); assert_eq!(res.n_unique, 0);
889 assert_eq!(res.n_masters, 1);
890
891 let mt = read_match_types(&path);
892 assert_eq!(mt[&rid(0)], "exact_dup");
893 assert_eq!(mt[&rid(1)], "exact_dup");
894 assert_eq!(mt[&rid(2)], "fuzzy_dup");
895 std::fs::remove_file(&path).ok();
896 }
897
898 #[test]
901 fn test_base_row_without_identical_copy_is_fuzzy() {
902 let path = tmp_path("base_fuzzy");
903 let et = crate::pipeline::DictValues::new(["person"]);
904 let mut acc = acc(&path, false);
905 let base = Base::new(&et, "person", 0, 0, 0, 1);
906 let dups = Dups::new(&et, "person", 0, 1, &[(0, false), (0, false)]);
907 acc.push_entity_batch(&base.rows(), Some(&dups.rows()))
908 .unwrap();
909
910 let res = acc.finish().unwrap();
911 assert_eq!(res.n_exact_dup, 0);
912 assert_eq!(res.n_fuzzy_dup, 3);
913 assert_eq!(read_match_types(&path)[&rid(0)], "fuzzy_dup");
914 std::fs::remove_file(&path).ok();
915 }
916
917 #[test]
920 fn test_gt_accumulator_multi_batch() {
921 let path = tmp_path("multi");
922 let et = crate::pipeline::DictValues::new(["person"]);
923 let mut acc = acc(&path, false);
924 let b1 = Base::new(&et, "person", 0, 0, 0, 2);
926 let d1 = Dups::new(&et, "person", 0, 2, &[(1, false)]);
927 acc.push_entity_batch(&b1.rows(), Some(&d1.rows())).unwrap();
928 let b2 = Base::new(&et, "person", 0, 2, 3, 2);
930 acc.push_entity_batch(&b2.rows(), None).unwrap();
931
932 let res = acc.finish().unwrap();
933 assert_eq!(res.n_masters, 4);
934 assert_eq!(res.n_unique, 3);
935 assert_eq!(res.n_fuzzy_dup, 2);
936 let mt = read_match_types(&path);
937 assert_eq!(mt[&rid(0)], "unique");
938 assert_eq!(mt[&rid(1)], "fuzzy_dup");
939 assert_eq!(mt[&rid(2)], "fuzzy_dup");
940 assert_eq!(mt[&rid(3)], "unique");
941 assert_eq!(mt[&rid(4)], "unique");
942 std::fs::remove_file(&path).ok();
943 }
944
945 #[test]
949 fn test_no_cross_entity_suffix_collision() {
950 let path = tmp_path("suffix");
951 let et = crate::pipeline::DictValues::new(["person", "account"]);
952 let mut acc = acc(&path, true);
953 let person = Base::new(&et, "person", 0, 0, 0, 1);
954 let person_dups = Dups::new(&et, "person", 0, 1, &[(0, true)]);
955 acc.push_entity_batch(&person.rows(), Some(&person_dups.rows()))
956 .unwrap();
957 let account = Base::new(&et, "account", 1, 0, 2, 1);
958 acc.push_entity_batch(&account.rows(), None).unwrap();
959
960 let res = acc.finish().unwrap();
961 assert_eq!(res.n_exact_dup, 2);
962 assert_eq!(res.n_unique, 1);
963 assert_eq!(res.n_masters, 2);
964 assert_eq!(res.cluster_map.n_clusters(), 1);
965 assert_eq!(read_match_types(&path)[&rid(2)], "unique");
966 std::fs::remove_file(&path).ok();
967 }
968
969 #[test]
972 fn test_duplicate_outside_its_batch_is_rejected() {
973 let path = tmp_path("outside");
974 let et = crate::pipeline::DictValues::new(["person"]);
975 let mut acc = acc(&path, false);
976 let base = Base::new(&et, "person", 0, 10, 0, 2);
977 let mut dups = Dups::new(&et, "person", 0, 2, &[(10, true)]);
978 dups.master_idx = vec![12]; let err = acc
980 .push_entity_batch(&base.rows(), Some(&dups.rows()))
981 .unwrap_err();
982 assert!(err.contains("outside its base batch"), "{err}");
983 drop(acc);
984 std::fs::remove_file(&path).ok();
985 }
986
987 #[test]
991 fn test_cluster_map_contents() {
992 let path = tmp_path("cm");
993 let et = crate::pipeline::DictValues::new(["person"]);
994 let mut acc = acc(&path, true);
995 let base = Base::new(&et, "person", 0, 0, 0, 3);
998 let dups = Dups::new(&et, "person", 0, 3, &[(2, false), (0, true)]);
999 acc.push_entity_batch(&base.rows(), Some(&dups.rows()))
1000 .unwrap();
1001 let [r, e, m] = hn(&et, 5, 2);
1002 acc.push_hard_neg_batch(&r, &e, &m).unwrap();
1003
1004 let cm = acc.finish().unwrap().cluster_map;
1005 assert_eq!(cm.n_clusters(), 2);
1006 let mut groups = cm.groups();
1007 let (records0, idents0) = groups.next().unwrap();
1008 assert_eq!(records0, &[0, 4]);
1009 assert_eq!(idents0.to_vec(), vec![true, true]);
1010 let (records2, idents2) = groups.next().unwrap();
1013 assert_eq!(records2, &[2, 3]);
1014 assert_eq!(idents2.to_vec(), vec![false, false]);
1015 assert!(groups.next().is_none());
1016 std::fs::remove_file(&path).ok();
1017 }
1018}