Skip to main content

dupehell_core/
gt.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// No liability for misuse.
5
6use arrow::array::{Array, ArrayRef, AsArray, Int32Array, Int32Builder};
7use arrow::datatypes::{DataType, Field, Schema};
8use arrow::record_batch::RecordBatch;
9use std::sync::Arc;
10
11/// Bit-packed boolean buffer — 1 bit per entry instead of `Vec<bool>`'s 1
12/// byte, an 8x reduction on `ClusterCsr::is_identical`, which parallels
13/// `records` (potentially tens of millions of entries at 100M+ hell scale
14/// with `--graph`, a low singleton fraction).
15struct 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
44/// View into a contiguous range of a [`Bitset`], returned per-cluster by
45/// [`ClusterCsr::groups`] — parallel to the `&[u64]` record-index slice for
46/// the same cluster.
47pub struct BitspanRef<'a> {
48    bits: &'a Bitset,
49    start: usize,
50    end: usize,
51}
52
53impl BitspanRef<'_> {
54    /// `i` is local to this span (`0..self.len()`), matching how callers
55    /// already index the parallel `&[u64]` records slice.
56    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
74/// Compressed-sparse-row representation of the duplicate-cluster
75/// membership. Built once, in `GtAccumulator::finish`, from every
76/// `(master, record, is_identical)` triple belonging to a duplicated master.
77///
78/// Avoids one `String` allocation per `record_id` and one per distinct
79/// `master_id` (the dominant RAM cost of a string-keyed cluster map at 200M+
80/// records): `master_id`/`record_id` are packed into `u64`s
81/// (`pipeline::pack_master_parts` / the global record index) and stored in
82/// two flat, contiguous buffers instead of a hash map of per-cluster `Vec`s.
83///
84/// `master_id` itself is never needed downstream as a string — the only
85/// consumer (`graph_gen::push_dup_clusters`) only ever used it to get a
86/// deterministic cluster iteration order, and numeric comparison of the
87/// packed key preserves the lexicographic order of the original string
88/// (both segments are zero-padded to a fixed width).
89pub struct ClusterCsr {
90    /// `offsets.len() == n_clusters + 1`; cluster `k`'s members are
91    /// `records[offsets[k]..offsets[k+1]]` (and the parallel span of
92    /// `is_identical`). Sorted ascending by packed master key.
93    offsets: Vec<u32>,
94    /// Flat, cluster-grouped buffer of global record indices (see
95    /// `pipeline::record_id_string`/`parse_record_idx`), ascending within
96    /// each cluster.
97    records: Vec<u64>,
98    /// Parallel to `records`: whether that record is byte-identical to its
99    /// cluster's master.
100    is_identical: Bitset,
101}
102
103/// `record_idx` is bounded by `PAD_LEN` (13) decimal digits — `< 10^13 ≈
104/// 2^43.2` — so bit 63 is always free to carry `is_identical` alongside it
105/// in one `u64`, the same trick `pack_master_parts` already uses for its own
106/// high bits. Packing this into `ClusterCsr::build`'s transient pair list
107/// drops it from `(u64, u64, bool)` (24 bytes/entry — `bool` pads the tuple
108/// to the next 8-byte alignment boundary) to `(u64, u64)` (16 bytes/entry,
109/// −33%), at potentially tens of millions of entries (every row of every
110/// duplicated cluster, `--graph` mode).
111const RIDX_IDENTICAL_BIT: u64 = 1 << 63;
112const RIDX_MASK: u64 = !RIDX_IDENTICAL_BIT;
113
114/// Packs `(record_idx, is_identical)` into `ClusterCsr::build`'s pair
115/// representation — see `RIDX_IDENTICAL_BIT`.
116pub(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
125/// `offsets` are `u32` (half the RAM of `usize` on a structure with one entry
126/// per duplicated master). `pipeline::check_capacity` refuses `--graph` runs
127/// that could exceed it up front; this is the backstop against silently
128/// wrapping around instead.
129fn 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    /// Builds the CSR from an unordered flat list of `(packed_master_key,
135    /// packed_ridx_identical)` pairs (see `pack_ridx_identical`). Sorting
136    /// once by `(master_key, record_idx)` — masking off the packed
137    /// `is_identical` bit for the comparison, so it can't perturb member
138    /// order within a cluster — both groups every pair by cluster and
139    /// orders each cluster's members ascending, in one pass; no need to
140    /// pre-count group sizes or build the structure incrementally.
141    ///
142    /// Sorted in parallel (`rayon`, already a warm dependency by this point
143    /// in the run): the sort key `(master_key, record_idx)` is unique per
144    /// entry (one record belongs to exactly one cluster, and a
145    /// `record_idx` is unique across the whole run), so an unstable
146    /// parallel sort produces the exact same total order as a
147    /// single-threaded one — determinism doesn't depend on stability here.
148    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    /// Iterates clusters in ascending packed-master-key order, yielding each
175    /// cluster's `(record_indices, is_identical)` pair. A cluster's
176    /// `record_indices` are already ascending, so callers
177    /// (`push_dup_clusters`) don't need to sort members themselves.
178    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
198/// Result of [`GtAccumulator::finish`].
199pub struct GtResult {
200    /// Cluster members that are genuinely byte-for-byte identical to their
201    /// master (the master itself, plus any duplicate copy whose assigned
202    /// noise ended up a no-op — see `pipeline::unchanged_row_mask`).
203    pub n_exact_dup: usize,
204    /// Cluster members that are duplicates of a master but differ from it
205    /// on at least one column (noise was actually applied). Distinguished
206    /// from `n_exact_dup` so a consumer can't mistake a fuzzy duplicate for
207    /// a trivial byte-for-byte match.
208    pub n_fuzzy_dup: usize,
209    pub n_hard_neg: usize,
210    pub n_unique: usize,
211    pub n_masters: usize,
212    /// Every duplicated master's full cluster (base + duplicate copies,
213    /// exact and fuzzy alike), each member tagged with its own
214    /// `exact_dup`/`fuzzy_dup` status. Consumed by
215    /// `graph_gen::push_dup_clusters` to decide, per edge, whether the pair
216    /// it connects is `exact_dup` (both ends byte-identical to the master,
217    /// hence to each other) or `fuzzy_dup` (at least one end was noised).
218    /// Only populated when cluster tracking is enabled (`--graph`).
219    pub cluster_map: ClusterCsr,
220}
221
222/// `Dictionary(Int32, Utf8)` for `entity_type`/`match_type`/`difficulty` —
223/// an explicit output-contract change (not bit-identical: the declared
224/// column type changes from `Utf8`). `entity_type` here must match
225/// `pipeline::low_cardinality_dict_type` exactly — this column's data is
226/// `add_metadata_and_align`'s `et_arr`, passed straight through, so the
227/// schema must declare the same type it's actually built as.
228fn low_cardinality_dict_type() -> DataType {
229    DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
230}
231
232/// One entity batch's base rows: every row introduces a brand-new master.
233/// Masters are contiguous — row `j`'s master is global index
234/// `first_master_idx + j` of entity `entity_idx`, and its record is global
235/// record index `first_record_idx + j` (the same numbers the pipeline
236/// formats into `master_id`/`record_id`).
237pub 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
246/// The duplicate copies generated from one [`BaseRows`] batch. Every copy's
247/// master belongs to that same batch (`pipeline.rs` samples a batch's
248/// duplicates exclusively from its own masters), which is what lets the
249/// whole batch be classified as soon as both are known.
250pub struct DupRows<'a> {
251    pub record_ids: &'a ArrayRef,
252    pub entity_types: &'a ArrayRef,
253    pub master_ids: &'a ArrayRef,
254    /// Global master index of each copy, within
255    /// `base.first_master_idx..base.first_master_idx + base.len()`.
256    pub master_idx: &'a [usize],
257    /// `true` when the noise assigned to that copy ended up a no-op (see
258    /// `pipeline::unchanged_row_mask`), `false` when it produced a real,
259    /// visible change.
260    pub is_identical: &'a [bool],
261    pub first_record_idx: u64,
262}
263
264/// Per-batch scratch bitset over a base batch's masters (`0..n` local
265/// indices), reused across batches instead of reallocated.
266#[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
286/// Streaming, single-pass ground-truth builder.
287///
288/// `pipeline.rs` draws every duplicate copy of a batch exclusively from that
289/// same batch's masters, and writes the copies right after the batch. So at
290/// the end of each batch, whether each of its masters is duplicated — and
291/// whether at least one copy is byte-identical to it — is already final: no
292/// later batch can add a copy of it. Each batch is therefore classified and
293/// streamed straight to the final ground-truth file (IPC or Parquet) as soon
294/// as its duplicates are known, with only two per-batch scratch bitsets as
295/// state. Hard negatives (`HN-…`) and canaries (`CANARY-…`) are never
296/// duplicated and are classified by kind.
297///
298/// This replaces an earlier two-pass design (draft file of every row +
299/// global duplicated-master hash sets + full re-read and reclassification
300/// at the end), which an isolated benchmark measured at ~4x the cost
301/// (`examples/profile_gt_single_pass.rs`, byte-identical output).
302pub 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    // `match_type` dictionary keys, resolved once instead of hashing the
309    // value string for every row.
310    k_hard_neg: i32,
311    k_canary: i32,
312    k_exact_dup: i32,
313    k_fuzzy_dup: i32,
314    k_unique: i32,
315    /// `difficulty` is constant for the whole run and batch sizes only take
316    /// a handful of values — cache the built column by length instead of
317    /// rebuilding it every batch.
318    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    /// Cluster membership for duplicated masters, only built when `--graph`
325    /// needs it (`graph_gen::push_dup_clusters` is its sole consumer) —
326    /// one entry per duplicate-cluster row, the majority of the dataset at
327    /// hell's low singleton fraction, so it's pure wasted RAM otherwise.
328    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        // Fixed, fully-known value sets — built once and reused for every
354        // batch's `DictionaryArray` (see `pipeline::DictValues`'s doc comment
355        // for why a *shared* dictionary is required for IPC output).
356        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    /// Classifies and writes one entity batch: its base rows first, then its
419    /// duplicate copies (`dups`, `None` when the batch has none) — the same
420    /// order they appear in the dataset.
421    ///
422    /// - A base row is `unique` if no copy of its master exists, otherwise
423    ///   `exact_dup` if at least one copy is byte-identical to it (a base row
424    ///   has no noise of its own: it reads as an exact duplicate only if the
425    ///   cluster genuinely contains an identical twin), else `fuzzy_dup`.
426    /// - A copy is `exact_dup` if its noise was a no-op, else `fuzzy_dup`.
427    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    /// Writes a batch of hard-negative rows (`HN-…` masters): always
539    /// `hard_neg`, never part of a duplicate cluster.
540    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    /// Writes a batch of canary rows (`CANARY-…` masters, see `canary.rs`):
554    /// always `canary`, not counted in any statistic.
555    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    /// Closes the ground-truth file and returns the run's statistics (plus
567    /// the duplicate-cluster map, when tracked).
568    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
581/// Debug-only guard that the numeric ids handed to `push_entity_batch` are
582/// the ones actually formatted into the first row's `master_id`/`record_id`
583/// strings — the classification and `cluster_map` keys rely on that
584/// correspondence instead of re-parsing every string.
585fn 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    /// `entity_type` fixtures: the real column is `Dictionary(Int32, Utf8)`,
689    /// built from `add_metadata_and_align`'s `entity_type_dict`. `dict` must
690    /// be the SAME `DictValues` instance across every `dict_arr` call feeding
691    /// one file — a fresh dictionary per call is exactly the "Dictionary
692    /// replacement" error IPC raises in the real pipeline too.
693    fn dict_arr(dict: &crate::pipeline::DictValues, value: &str, n: usize) -> ArrayRef {
694        dict.const_array(value, n)
695    }
696
697    /// Reads the GT file into a `record_id -> match_type` map.
698    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            // `match_type` is `Dictionary(Int32, Utf8)` — cast back to plain
709            // `Utf8` for this test-only comparison.
710            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    /// Fixed-width master_id of `entity`'s global master `n`, as the
722    /// pipeline formats it.
723    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    /// Contiguous base batch fixture: masters `first_master..first_master+n`
736    /// of `entity`, records `first_rid..first_rid+n`.
737    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    /// Duplicate-copies fixture: one copy per `(master, is_identical)`,
782    /// records contiguous from `first_rid`.
783    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        // Masters 0..3 (records 0..3); master 0 gets one unchanged copy
848        // (record 3); then 2 hard negatives (records 4, 5).
849        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    /// A copy whose noise actually changed something is `fuzzy_dup`; the
874    /// master and an unchanged sibling copy stay `exact_dup`, per row.
875    #[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); // master + unchanged copy
887        assert_eq!(res.n_fuzzy_dup, 1); // genuinely noised copy
888        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    /// A master whose only copies were all genuinely noised has no
899    /// byte-identical twin: its base row is `fuzzy_dup`, not `exact_dup`.
900    #[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    /// Several batches, offset masters/records, some without any duplicate:
918    /// classification only depends on each batch's own copies.
919    #[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        // Batch 1: masters 0..2 (records 0..2), master 1 copied (record 2).
925        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        // Batch 2: masters 2..4 (records 3..5), no duplicates at all.
929        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    /// Two entities share the same local master index (each restarts at
946    /// 0): duplicating one must not mark the other as duplicated, and their
947    /// clusters must stay separate.
948    #[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    /// A duplicate referencing a master outside its own base batch breaks
970    /// the single-pass invariant — rejected loudly instead of misclassified.
971    #[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]; // batch covers masters 10..12
979        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    /// `cluster_map` holds every duplicated master's members (base + copies)
988    /// with their own identical/fuzzy status, in ascending master order;
989    /// singletons and hard negatives don't appear.
990    #[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        // Masters 0..3 (records 0..3). Master 0: one unchanged copy (record
996        // 3). Master 2: one genuinely noised copy (record 4).
997        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        // Master 2's only copy was noised: no byte-identical pair, so its
1011        // base row must not read as identical either.
1012        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}