Skip to main content

dupehell_core/
gt.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// EDUCATIONAL AND RESEARCH PURPOSES ONLY -- see ETHICS.md for prohibited uses.
5// No liability for misuse.
6
7use arrow::array::{Array, ArrayRef, AsArray, BooleanArray, StringArray, StringBuilder};
8use arrow::datatypes::{DataType, Field, Schema};
9use arrow::record_batch::RecordBatch;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// Compressed-sparse-row representation of the duplicate-cluster
14/// membership previously held as `HashMap<String, Vec<(String, bool)>>`.
15/// Built once, in `GtAccumulator::finish`, from every `(master_id,
16/// record_id, is_identical)` triple belonging to a duplicated master.
17///
18/// Avoids one `String` allocation per `record_id` and one per distinct
19/// `master_id` (the dominant RAM cost of `cluster_map` at 200M+ records,
20/// see `project_csr_cluster_map_backlog`): `master_id`/`record_id` are
21/// packed into `u64`s (`pipeline::pack_master_key`/`parse_record_idx` —
22/// both are pure functions of the fixed-width ID shapes `pipeline.rs`
23/// already generates, so the packing/unpacking is lossless and exact) and
24/// stored in two flat, contiguous buffers instead of a hash map of
25/// per-cluster `Vec`s.
26///
27/// `master_id` itself is never needed downstream as a string — the only
28/// consumer (`graph_gen::push_dup_clusters`) only ever used it to get a
29/// deterministic cluster iteration order (`master_ids.sort()`), and
30/// numeric comparison of the packed key preserves that exact order (both
31/// segments of the original string are zero-padded to a fixed width, so
32/// lexicographic and numeric order agree).
33/// Bit-packed boolean buffer — 1 bit per entry instead of `Vec<bool>`'s 1
34/// byte, an 8x reduction on `ClusterCsr::is_identical`, which parallels
35/// `records` (potentially tens of millions of entries at 100M+ hell scale
36/// with `--graph`, a low singleton fraction). Found via a triage of
37/// reusable Rust perf patterns (hunt3007/H7, Linkars trick #22).
38struct Bitset {
39    words: Vec<u64>,
40    len: usize,
41}
42
43impl Bitset {
44    fn with_capacity(n: usize) -> Self {
45        Self {
46            words: vec![0u64; n.div_ceil(64).max(1)],
47            len: 0,
48        }
49    }
50
51    fn push(&mut self, val: bool) {
52        let word_idx = self.len / 64;
53        if word_idx >= self.words.len() {
54            self.words.push(0);
55        }
56        if val {
57            self.words[word_idx] |= 1u64 << (self.len % 64);
58        }
59        self.len += 1;
60    }
61
62    fn get(&self, i: usize) -> bool {
63        (self.words[i / 64] >> (i % 64)) & 1 != 0
64    }
65}
66
67/// View into a contiguous range of a [`Bitset`], returned per-cluster by
68/// [`ClusterCsr::groups`] — parallel to the `&[u64]` record-index slice for
69/// the same cluster.
70pub struct BitspanRef<'a> {
71    bits: &'a Bitset,
72    start: usize,
73    end: usize,
74}
75
76impl BitspanRef<'_> {
77    /// `i` is local to this span (`0..self.len()`), matching how callers
78    /// already index the parallel `&[u64]` records slice.
79    pub fn get(&self, i: usize) -> bool {
80        self.bits.get(self.start + i)
81    }
82
83    pub fn len(&self) -> usize {
84        self.end - self.start
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.start == self.end
89    }
90
91    #[cfg(test)]
92    fn to_vec(&self) -> Vec<bool> {
93        (0..self.len()).map(|i| self.get(i)).collect()
94    }
95}
96
97pub struct ClusterCsr {
98    /// `offsets.len() == n_clusters + 1`; cluster `k`'s members are
99    /// `records[offsets[k]..offsets[k+1]]` (and the parallel span of
100    /// `is_identical`). Sorted ascending by packed master key.
101    offsets: Vec<u32>,
102    /// Flat, cluster-grouped buffer of global record indices (see
103    /// `pipeline::record_id_string`/`parse_record_idx`), ascending within
104    /// each cluster.
105    records: Vec<u64>,
106    /// Parallel to `records`: whether that record is byte-identical to its
107    /// cluster's master.
108    is_identical: Bitset,
109}
110
111impl ClusterCsr {
112    /// Builds the CSR from an unordered flat list of `(packed_master_key,
113    /// record_idx, is_identical)` triples. Sorting once by `(master_key,
114    /// record_idx)` both groups every triple by cluster and orders each
115    /// cluster's members ascending, in one pass — no need to pre-count
116    /// group sizes or build the structure incrementally.
117    pub(crate) fn build(mut pairs: Vec<(u64, u64, bool)>) -> Self {
118        pairs.sort_unstable_by_key(|&(mk, ridx, _)| (mk, ridx));
119
120        let mut offsets = Vec::new();
121        let mut records = Vec::with_capacity(pairs.len());
122        let mut is_identical = Bitset::with_capacity(pairs.len());
123        let mut last_key: Option<u64> = None;
124
125        for (mk, ridx, ident) in pairs {
126            if last_key != Some(mk) {
127                offsets.push(records.len() as u32);
128                last_key = Some(mk);
129            }
130            records.push(ridx);
131            is_identical.push(ident);
132        }
133        offsets.push(records.len() as u32);
134
135        Self {
136            offsets,
137            records,
138            is_identical,
139        }
140    }
141
142    /// Iterates clusters in ascending packed-master-key order (matches the
143    /// previous `master_ids.sort()` determinism exactly), yielding each
144    /// cluster's `(record_indices, is_identical)` pair. A cluster's
145    /// `record_indices` are already ascending, so callers that used to sort
146    /// members themselves (`push_dup_clusters`) no longer need to.
147    pub fn groups(&self) -> impl Iterator<Item = (&[u64], BitspanRef<'_>)> {
148        self.offsets.windows(2).map(move |w| {
149            let (start, end) = (w[0] as usize, w[1] as usize);
150            (
151                &self.records[start..end],
152                BitspanRef {
153                    bits: &self.is_identical,
154                    start,
155                    end,
156                },
157            )
158        })
159    }
160
161    #[cfg(test)]
162    fn n_clusters(&self) -> usize {
163        self.offsets.len().saturating_sub(1)
164    }
165}
166
167/// Result of [`GtAccumulator::finish`].
168pub struct GtResult {
169    /// Cluster members that are genuinely byte-for-byte identical to their
170    /// master (the master itself, plus any duplicate copy whose assigned
171    /// noise ended up a no-op — see `pipeline::unchanged_row_mask`).
172    pub n_exact_dup: usize,
173    /// Cluster members that are duplicates of a master but differ from it
174    /// on at least one column (noise was actually applied). Distinguished
175    /// from `n_exact_dup` so a consumer can't mistake a fuzzy duplicate for
176    /// a trivial byte-for-byte match.
177    pub n_fuzzy_dup: usize,
178    pub n_hard_neg: usize,
179    pub n_unique: usize,
180    pub n_masters: usize,
181    /// Maps each duplicated `master_id` to the full set of `(record_id,
182    /// is_identical)` pairs in its cluster (base + duplicate copies, exact
183    /// and fuzzy alike) — `is_identical` mirrors that record's own
184    /// `exact_dup`/`fuzzy_dup` classification. Consumed by
185    /// `graph_gen::push_dup_clusters` to decide, per edge, whether the pair
186    /// it connects is `exact_dup` (both ends byte-identical to the master,
187    /// hence to each other) or `fuzzy_dup` (at least one end was noised).
188    pub cluster_map: ClusterCsr,
189}
190
191fn draft_schema() -> Arc<Schema> {
192    Arc::new(Schema::new(vec![
193        Field::new("record_id", DataType::Utf8, false),
194        Field::new("master_id", DataType::Utf8, false),
195        Field::new("entity_type", DataType::Utf8, false),
196        // `true` for duplicate-copy rows whose assigned noise ended up not
197        // changing anything visible; `false` for duplicate copies that were
198        // genuinely altered, and for base/master rows (a base row is never
199        // itself "a copy that happened to match" — see `is_base` below for
200        // how its match_type is actually resolved). Meaningless for
201        // hard_neg/canary rows (classified by master_id prefix regardless).
202        Field::new("is_identical", DataType::Boolean, false),
203        // `true` only for base/master rows (one per entity, written by
204        // `push_base_batch`); `false` for every duplicate copy and for
205        // hard_neg/canary rows. Lets `finish` tell a base row apart from a
206        // genuine fuzzy copy when both carry `is_identical = false` — without
207        // it, a duplicated master's own base row would be misclassified
208        // `exact_dup` unconditionally (it used to hardcode
209        // `is_identical = true` for every base row, regardless of whether
210        // any of its actual duplicate copies were byte-identical to it).
211        Field::new("is_base", DataType::Boolean, false),
212    ]))
213}
214
215/// Streaming, two-pass ground-truth builder.
216///
217/// Duplicate detection needs a genuinely global view of which masters are
218/// duplicated (a master can be duplicated anywhere in the record stream),
219/// but that's the *only* thing that needs to be global — everything else
220/// can be processed one batch at a time. Previously the whole run
221/// accumulated three full-dataset `Vec<ArrayRef>` (record_id/entity_type/
222/// master_id) in RAM to concat and classify at the very end; at 200M+
223/// records that's multiple GB held for the entire run just for GT
224/// bookkeeping.
225///
226/// This accumulator instead:
227/// 1. streams each batch straight to a small "draft" IPC file as it's
228///    generated. The pipeline already knows, at the point each batch is
229///    produced, whether its rows introduce brand-new masters
230///    (`push_base_batch`) or are duplicate copies of masters seen earlier
231///    (`push_dup_batch`) — that's how `pipeline::run_pipeline` builds
232///    `dup_mids_buf` today. So instead of *discovering* duplication by
233///    folding occurrence counts over every master ever seen (O(n_base) —
234///    one entry per base record, duplicated or not), this only records the
235///    (much smaller) set of masters that are *actually* duplicated —
236///    O(n_dup_masters), a fraction of the dataset determined by the
237///    doublet/triplet ratios, not the full base population. HN/CANARY rows
238///    (`push_other_batch`) need no bookkeeping at all: their prefix alone
239///    determines their classification.
240/// 2. on `finish`, re-reads that draft file batch by batch — now that the
241///    full duplicated-master set is known — to classify each row and
242///    stream the definitive ground-truth file (IPC or Parquet), same
243///    batch-by-batch pattern already used elsewhere in this codebase for
244///    the IPC→Parquet dataset conversion (see `pipeline::run_pipeline`).
245///
246/// Duplicate detection is keyed on the **full** master_id (entity prefix
247/// included, via `pipeline::pack_master_key` into a `u64`), not just a
248/// numeric suffix: master_ids are assigned per-entity-plan starting from
249/// index 0, so two unrelated entities of different types can share the
250/// same numeric suffix. Keying on the full identity avoids counting those
251/// as duplicates of each other.
252pub struct GtAccumulator {
253    draft_path: String,
254    writer: arrow::ipc::writer::FileWriter<std::fs::File>,
255    schema: Arc<Schema>,
256    /// Keyed on `pipeline::pack_master_key(mid)` rather than the raw
257    /// `String` — avoids a per-row string hash/allocation on a set queried
258    /// 2-3x per duplicated row (`push_dup_batch` + classification in
259    /// `finish`), which VTune hotspots (hunt3007) showed costing ~5% of
260    /// total CPU on its own. `push_dup_batch` never receives an
261    /// HN-/CANARY- master_id, so every key here is guaranteed
262    /// pack_master_key-compatible.
263    dup_masters: rustc_hash::FxHashSet<u64>,
264    /// Masters that have at least one duplicate copy whose assigned noise
265    /// ended up a genuine no-op (byte-identical to the base) — as opposed to
266    /// `dup_masters`, which just means "has any duplicate copy at all,
267    /// exact or fuzzy". Used at `finish` so a base row can tell whether it
268    /// actually has an identical twin instead of unconditionally claiming
269    /// one.
270    masters_with_exact_copy: rustc_hash::FxHashSet<u64>,
271    n_base_masters: usize,
272}
273
274impl GtAccumulator {
275    pub fn new(draft_path: &str) -> Result<Self, String> {
276        let schema = draft_schema();
277        let file = std::fs::File::create(draft_path)
278            .map_err(|e| format!("create gt draft {draft_path}: {e}"))?;
279        let writer = arrow::ipc::writer::FileWriter::try_new(file, &schema)
280            .map_err(|e| format!("gt draft writer: {e}"))?;
281        Ok(Self {
282            draft_path: draft_path.to_string(),
283            writer,
284            schema,
285            dup_masters: rustc_hash::FxHashSet::default(),
286            masters_with_exact_copy: rustc_hash::FxHashSet::default(),
287            n_base_masters: 0,
288        })
289    }
290
291    fn write_draft(
292        &mut self,
293        record_ids: &ArrayRef,
294        entity_types: &ArrayRef,
295        master_ids: &ArrayRef,
296        is_identical: &ArrayRef,
297        is_base: &ArrayRef,
298    ) -> Result<(), String> {
299        let batch = RecordBatch::try_new(
300            self.schema.clone(),
301            vec![
302                record_ids.clone(),
303                master_ids.clone(),
304                entity_types.clone(),
305                is_identical.clone(),
306                is_base.clone(),
307            ],
308        )
309        .map_err(|e| format!("build gt draft batch: {e}"))?;
310        self.writer
311            .write(&batch)
312            .map_err(|e| format!("write gt draft: {e}"))
313    }
314
315    /// Feed a batch of rows that each introduce a brand-new, not-yet-seen
316    /// master (base records — one master per row, never a duplicate copy).
317    pub fn push_base_batch(
318        &mut self,
319        record_ids: &ArrayRef,
320        entity_types: &ArrayRef,
321        master_ids: &ArrayRef,
322    ) -> Result<(), String> {
323        let mids = master_ids.as_string::<i32>();
324        for i in 0..mids.len() {
325            if !mids.is_null(i) {
326                self.n_base_masters += 1;
327            }
328        }
329        // `is_identical = false`: a base row is not itself a "copy that
330        // happened to match" — whether it should read as `exact_dup` is
331        // resolved at `finish` from `masters_with_exact_copy`, not hardcoded
332        // here (this used to be `true` unconditionally, which mislabeled
333        // every duplicated master's base row `exact_dup` even when all of
334        // its actual duplicate copies were fuzzy — none byte-identical).
335        let all_false: ArrayRef = Arc::new(BooleanArray::from(vec![false; mids.len()]));
336        let all_true: ArrayRef = Arc::new(BooleanArray::from(vec![true; mids.len()]));
337        self.write_draft(record_ids, entity_types, master_ids, &all_false, &all_true)
338    }
339
340    /// Feed a batch of duplicate-copy rows (each `master_id` matches an
341    /// existing base record already fed via `push_base_batch`). Records the
342    /// *set* of duplicated masters — every row sharing one of these
343    /// master_ids (the original base row included) belongs to a duplicated
344    /// cluster at `finish`, classified `exact_dup` or `fuzzy_dup` per-row
345    /// based on `is_identical` (`true` when the noise assigned to this copy
346    /// ended up a no-op — see `pipeline::unchanged_row_mask` — `false` when
347    /// it produced a real, visible change). Also records which masters have
348    /// at least one genuinely identical copy, for the base row's own
349    /// classification at `finish`.
350    pub fn push_dup_batch(
351        &mut self,
352        record_ids: &ArrayRef,
353        entity_types: &ArrayRef,
354        master_ids: &ArrayRef,
355        is_identical: &ArrayRef,
356    ) -> Result<(), String> {
357        let mids = master_ids.as_string::<i32>();
358        let idents = is_identical.as_boolean();
359        for i in 0..mids.len() {
360            if !mids.is_null(i) {
361                let mid = mids.value(i);
362                let key = crate::pipeline::pack_master_key(mid).unwrap_or_else(|| {
363                    panic!(
364                        "push_dup_batch: master_id {mid:?} isn't the fixed \
365                         \"{{entity_prefix}}-{{pad_string}}\" shape -- \
366                         push_dup_batch never receives an HN-/CANARY- master_id"
367                    )
368                });
369                self.dup_masters.insert(key);
370                if !idents.is_null(i) && idents.value(i) {
371                    self.masters_with_exact_copy.insert(key);
372                }
373            }
374        }
375        let all_false: ArrayRef = Arc::new(BooleanArray::from(vec![false; mids.len()]));
376        self.write_draft(
377            record_ids,
378            entity_types,
379            master_ids,
380            is_identical,
381            &all_false,
382        )
383    }
384
385    /// Feed a batch of rows whose classification is fully determined by
386    /// their `master_id` prefix (hard negatives `HN-...`, canaries
387    /// `CANARY-...`) — no bookkeeping needed beyond streaming to the draft.
388    pub fn push_other_batch(
389        &mut self,
390        record_ids: &ArrayRef,
391        entity_types: &ArrayRef,
392        master_ids: &ArrayRef,
393    ) -> Result<(), String> {
394        // `is_identical`/`is_base` are meaningless for hard_neg/canary rows
395        // (classified by master_id prefix regardless), so the values don't
396        // matter.
397        let all_false: ArrayRef = Arc::new(BooleanArray::from(vec![false; record_ids.len()]));
398        self.write_draft(record_ids, entity_types, master_ids, &all_false, &all_false)
399    }
400
401    /// Consumes the accumulator: closes the draft, re-reads it batch by
402    /// batch to classify each row now that the full duplicated-master set
403    /// is known, and streams the definitive ground-truth file (IPC or
404    /// Parquet). Returns a [`GtResult`].
405    pub fn finish(
406        self,
407        difficulty: &str,
408        output_format: &str,
409        final_path: &str,
410        metadata: &HashMap<String, String>,
411        track_clusters: bool,
412    ) -> Result<GtResult, String> {
413        let GtAccumulator {
414            draft_path,
415            mut writer,
416            dup_masters,
417            masters_with_exact_copy,
418            n_base_masters,
419            ..
420        } = self;
421        writer
422            .finish()
423            .map_err(|e| format!("finish gt draft: {e}"))?;
424        drop(writer);
425
426        let final_schema = Arc::new(
427            Schema::new(vec![
428                Field::new("record_id", DataType::Utf8, false),
429                Field::new("master_id", DataType::Utf8, false),
430                Field::new("entity_type", DataType::Utf8, false),
431                Field::new("match_type", DataType::Utf8, false),
432                Field::new("difficulty", DataType::Utf8, false),
433            ])
434            .with_metadata(metadata.clone()),
435        );
436
437        let draft_file = std::fs::File::open(&draft_path)
438            .map_err(|e| format!("reopen gt draft {draft_path}: {e}"))?;
439        let reader = arrow::ipc::reader::FileReader::try_new(draft_file, None)
440            .map_err(|e| format!("gt draft reader: {e}"))?;
441
442        let mut sink = GtSink::new(output_format, final_path, &final_schema, metadata)?;
443
444        let mut n_exact_dup = 0usize;
445        let mut n_fuzzy_dup = 0usize;
446        let mut n_hard_neg = 0usize;
447        let mut n_unique = 0usize;
448        // Cluster membership for duplicated masters (base + duplicate copies,
449        // exact and fuzzy alike), for emitting duplicate-cluster edges after
450        // this pass. Accumulated as a flat `Vec` of packed triples (see
451        // `ClusterCsr`) instead of a `HashMap<String, Vec<...>>` -- avoids a
452        // `String` allocation per record_id/master_id, the dominant RAM cost
453        // of this bookkeeping at 200M+ records.
454        let mut cluster_pairs: Vec<(u64, u64, bool)> = Vec::new();
455
456        for batch_result in reader {
457            let batch = batch_result.map_err(|e| format!("read gt draft batch: {e}"))?;
458            let n = batch.num_rows();
459            let mid_col = batch.column(1).as_string::<i32>();
460            let rid_col = batch.column(0).as_string::<i32>();
461            let ident_col = batch.column(3).as_boolean();
462            let base_col = batch.column(4).as_boolean();
463
464            let mut mt_builder = StringBuilder::with_capacity(n, n * 10);
465            for i in 0..n {
466                let mid = if mid_col.is_null(i) {
467                    ""
468                } else {
469                    mid_col.value(i)
470                };
471                let is_base = !base_col.is_null(i) && base_col.value(i);
472                let is_hn = mid.starts_with("HN-");
473                let is_canary = !is_hn && mid.starts_with("CANARY-");
474                // `dup_masters`/`masters_with_exact_copy` are keyed on
475                // `pack_master_key`, not the raw string (hunt3007/H3) --
476                // never `Some` for HN-/CANARY- ids, consistent with
477                // `push_dup_batch` never inserting those.
478                let master_key = if is_hn || is_canary {
479                    None
480                } else {
481                    crate::pipeline::pack_master_key(mid)
482                };
483                // Per-row, not per-cluster: a cluster can mix a base row, a
484                // copy the noise happened not to change, and a copy that's
485                // genuinely different. A base row has no noise of its own —
486                // it reads `exact_dup` only if the cluster actually contains
487                // a byte-identical copy (`masters_with_exact_copy`), never
488                // unconditionally (that used to mislabel every duplicated
489                // master's base row `exact_dup` even when all of its copies
490                // were fuzzy).
491                let is_identical = if is_base {
492                    master_key.is_some_and(|k| masters_with_exact_copy.contains(&k))
493                } else {
494                    !ident_col.is_null(i) && ident_col.value(i)
495                };
496                let is_dup_master = master_key.is_some_and(|k| dup_masters.contains(&k));
497                let mt = if is_hn {
498                    n_hard_neg += 1;
499                    "hard_neg"
500                } else if is_canary {
501                    "canary"
502                } else if is_dup_master {
503                    if is_identical {
504                        n_exact_dup += 1;
505                        "exact_dup"
506                    } else {
507                        n_fuzzy_dup += 1;
508                        "fuzzy_dup"
509                    }
510                } else {
511                    n_unique += 1;
512                    "unique"
513                };
514                mt_builder.append_value(mt);
515                // Every row of a duplicated master belongs to its cluster,
516                // tagged with its own identical/fuzzy status. Only tracked
517                // when `--graph` is enabled: `cluster_pairs` is exclusively
518                // consumed by `graph_gen::push_dup_clusters` to emit
519                // exact_dup/fuzzy_dup edges, so building it when there's no
520                // graph output to write is pure wasted RAM -- one entry per
521                // duplicate-cluster row (base + every copy), which at
522                // 100M+ records with a low singleton fraction (hell tier)
523                // can be the majority of the dataset.
524                if track_clusters && is_dup_master {
525                    let master_key = master_key.expect("is_dup_master implies master_key is Some");
526                    let record_idx = crate::pipeline::parse_record_idx(rid_col.value(i))
527                        .unwrap_or_else(|| {
528                            panic!(
529                                "cluster_map: record_id {:?} isn't the fixed \
530                                 \"R-\" + 13-digit shape produced by \
531                                 `record_id_string`",
532                                rid_col.value(i)
533                            )
534                        });
535                    cluster_pairs.push((master_key, record_idx, is_identical));
536                }
537            }
538            let mt_arr: ArrayRef = Arc::new(mt_builder.finish());
539            let diff_arr: ArrayRef = Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
540                difficulty, n,
541            )));
542
543            let final_batch = RecordBatch::try_new(
544                final_schema.clone(),
545                vec![
546                    batch.column(0).clone(),
547                    batch.column(1).clone(),
548                    batch.column(2).clone(),
549                    mt_arr,
550                    diff_arr,
551                ],
552            )
553            .map_err(|e| format!("build final gt batch: {e}"))?;
554
555            sink.write(&final_batch)?;
556        }
557
558        sink.finish()?;
559        std::fs::remove_file(&draft_path).ok();
560
561        Ok(GtResult {
562            n_exact_dup,
563            n_fuzzy_dup,
564            n_hard_neg,
565            n_unique,
566            n_masters: n_base_masters,
567            cluster_map: ClusterCsr::build(cluster_pairs),
568        })
569    }
570}
571
572enum GtSink {
573    Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
574    Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
575}
576
577impl GtSink {
578    fn new(
579        output_format: &str,
580        path: &str,
581        schema: &Arc<Schema>,
582        metadata: &HashMap<String, String>,
583    ) -> Result<Self, String> {
584        let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
585        if output_format == "parquet" {
586            use parquet::basic::{Compression, ZstdLevel};
587            use parquet::file::properties::WriterProperties;
588            let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
589            let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
590                .iter()
591                .map(|(k, v)| parquet::file::metadata::KeyValue {
592                    key: k.clone(),
593                    value: Some(v.clone()),
594                })
595                .collect();
596            let props = WriterProperties::builder()
597                .set_compression(Compression::ZSTD(zstd))
598                .set_data_page_size_limit(1_048_576)
599                .set_key_value_metadata(Some(meta_kv))
600                .build();
601            let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
602                .map_err(|e| format!("gt parquet writer: {e}"))?;
603            Ok(GtSink::Parquet(Box::new(writer)))
604        } else {
605            let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
606                .map_err(|e| format!("gt ipc writer: {e}"))?;
607            Ok(GtSink::Ipc(Box::new(writer)))
608        }
609    }
610
611    fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
612        match self {
613            GtSink::Ipc(w) => w.write(batch).map_err(|e| format!("write gt ipc: {e}")),
614            GtSink::Parquet(w) => w.write(batch).map_err(|e| format!("write gt parquet: {e}")),
615        }
616    }
617
618    fn finish(self) -> Result<(), String> {
619        match self {
620            GtSink::Ipc(mut w) => w.finish().map_err(|e| format!("finish gt ipc: {e}")),
621            GtSink::Parquet(w) => w
622                .close()
623                .map(|_| ())
624                .map_err(|e| format!("close gt parquet: {e}")),
625        }
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use arrow::array::StringArray;
633
634    fn tmp_path(name: &str) -> String {
635        let mut p = std::env::temp_dir();
636        p.push(format!(
637            "dupehell_gt_test_{name}_{}.ipc",
638            std::process::id()
639        ));
640        p.to_string_lossy().into_owned()
641    }
642
643    fn arr(values: Vec<&str>) -> ArrayRef {
644        Arc::new(StringArray::from(values))
645    }
646
647    /// Same as `arr`, but for computed (owned) strings -- used to build
648    /// `record_id`/`master_id` fixtures matching the exact fixed-width shape
649    /// `ClusterCsr`'s packing relies on (`pipeline::record_id_string`/
650    /// `entity_prefix`/`pad_string`), which a bare string literal like
651    /// `"R2"` doesn't.
652    fn arr_owned(values: Vec<String>) -> ArrayRef {
653        Arc::new(StringArray::from(values))
654    }
655
656    fn barr(values: Vec<bool>) -> ArrayRef {
657        Arc::new(BooleanArray::from(values))
658    }
659
660    /// Reads the final GT file into a `record_id -> match_type` map. A map
661    /// (rather than a positional `Vec`) keeps the assertions independent of
662    /// draft row order, which is caller-determined (base/dup/other batches
663    /// are pushed as separate, non-interleaved groups in the real pipeline).
664    fn read_match_types(final_path: &str) -> HashMap<String, String> {
665        let file = std::fs::File::open(final_path).unwrap();
666        let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
667        let mut out = HashMap::new();
668        for batch in reader {
669            let batch = batch.unwrap();
670            let rid = batch
671                .column_by_name("record_id")
672                .unwrap()
673                .as_string::<i32>();
674            let mt = batch
675                .column_by_name("match_type")
676                .unwrap()
677                .as_string::<i32>();
678            for i in 0..batch.num_rows() {
679                out.insert(rid.value(i).to_string(), mt.value(i).to_string());
680            }
681        }
682        out
683    }
684
685    /// Builds the fixed-width `"{entity_prefix}-{pad_string}"` master_id
686    /// shape `ClusterCsr`'s packing relies on -- all these fixture-building
687    /// tests use a single entity index (0).
688    fn mid(n: usize) -> String {
689        format!(
690            "{}-{}",
691            crate::pipeline::entity_prefix(0),
692            crate::pipeline::pad_string(n)
693        )
694    }
695
696    /// Alias for `pipeline::record_id_string`, for brevity in fixtures.
697    fn rid(i: usize) -> String {
698        crate::pipeline::record_id_string(i)
699    }
700
701    #[test]
702    fn test_gt_accumulator_basic() {
703        let draft = tmp_path("basic_draft");
704        let final_path = tmp_path("basic_final");
705
706        let mut acc = GtAccumulator::new(&draft).unwrap();
707        // Base rows: rid(1) (master mid(1), later duplicated), rid(3)
708        // (singleton mid(2)), rid(6) (singleton mid(5)).
709        acc.push_base_batch(
710            &arr_owned(vec![rid(1), rid(3), rid(6)]),
711            &arr(vec!["person", "person", "person"]),
712            &arr_owned(vec![mid(1), mid(2), mid(5)]),
713        )
714        .unwrap();
715        // Dup row: rid(2) duplicates mid(1), unchanged by its noise pass.
716        acc.push_dup_batch(
717            &arr_owned(vec![rid(2)]),
718            &arr(vec!["person"]),
719            &arr_owned(vec![mid(1)]),
720            &barr(vec![true]),
721        )
722        .unwrap();
723        // Hard negatives: rid(4), rid(5).
724        acc.push_other_batch(
725            &arr_owned(vec![rid(4), rid(5)]),
726            &arr(vec!["person", "person"]),
727            &arr(vec!["HN-0000003", "HN-0000004"]),
728        )
729        .unwrap();
730
731        let GtResult {
732            n_exact_dup: ed,
733            n_hard_neg: hn,
734            n_unique: un,
735            n_masters: masters,
736            ..
737        } = acc
738            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
739            .unwrap();
740        assert_eq!(ed, 2);
741        assert_eq!(hn, 2);
742        assert_eq!(un, 2);
743        assert_eq!(masters, 3); // mid(1), mid(2), mid(5)
744
745        let match_types = read_match_types(&final_path);
746        assert_eq!(match_types[&rid(1)], "exact_dup");
747        assert_eq!(match_types[&rid(2)], "exact_dup");
748        assert_eq!(match_types[&rid(3)], "unique");
749        assert_eq!(match_types[&rid(4)], "hard_neg");
750        assert_eq!(match_types[&rid(5)], "hard_neg");
751        assert_eq!(match_types[&rid(6)], "unique");
752
753        std::fs::remove_file(&final_path).ok();
754    }
755
756    /// A duplicate copy whose noise pass actually changed something
757    /// (`is_identical = false`) must be classified `fuzzy_dup`, not
758    /// `exact_dup` — the master itself, and any sibling copy that was left
759    /// unchanged, keep `exact_dup` independently, per row.
760    #[test]
761    fn test_gt_accumulator_fuzzy_dup() {
762        let draft = tmp_path("fuzzy_draft");
763        let final_path = tmp_path("fuzzy_final");
764
765        let mut acc = GtAccumulator::new(&draft).unwrap();
766        // Triplet: rid(1) is the master, rid(2) was left unchanged by its
767        // noise pass, rid(3) was genuinely altered.
768        acc.push_base_batch(
769            &arr_owned(vec![rid(1)]),
770            &arr(vec!["person"]),
771            &arr_owned(vec![mid(1)]),
772        )
773        .unwrap();
774        acc.push_dup_batch(
775            &arr_owned(vec![rid(2), rid(3)]),
776            &arr(vec!["person", "person"]),
777            &arr_owned(vec![mid(1), mid(1)]),
778            &barr(vec![true, false]),
779        )
780        .unwrap();
781
782        let GtResult {
783            n_exact_dup: ed,
784            n_fuzzy_dup: fd,
785            n_unique: un,
786            n_masters: masters,
787            ..
788        } = acc
789            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
790            .unwrap();
791        assert_eq!(ed, 2); // rid(1) (master) + rid(2) (unchanged copy)
792        assert_eq!(fd, 1); // rid(3) (genuinely noised copy)
793        assert_eq!(un, 0);
794        assert_eq!(masters, 1);
795
796        let match_types = read_match_types(&final_path);
797        assert_eq!(match_types[&rid(1)], "exact_dup");
798        assert_eq!(match_types[&rid(2)], "exact_dup");
799        assert_eq!(match_types[&rid(3)], "fuzzy_dup");
800
801        std::fs::remove_file(&final_path).ok();
802    }
803
804    #[test]
805    fn test_gt_accumulator_multi_batch() {
806        let draft = tmp_path("multi_draft");
807        let final_path = tmp_path("multi_final");
808
809        let mut acc = GtAccumulator::new(&draft).unwrap();
810        // Base batch: two masters, one of which (mid(1)) is duplicated in a
811        // later, separate dup batch — simulating a master's duplicate
812        // landing in a different batch than its base row.
813        acc.push_base_batch(
814            &arr_owned(vec![rid(1), rid(3)]),
815            &arr(vec!["person", "person"]),
816            &arr_owned(vec![mid(1), mid(2)]),
817        )
818        .unwrap();
819        acc.push_dup_batch(
820            &arr_owned(vec![rid(2)]),
821            &arr(vec!["person"]),
822            &arr_owned(vec![mid(1)]),
823            &barr(vec![true]),
824        )
825        .unwrap();
826
827        let GtResult {
828            n_exact_dup: ed,
829            n_hard_neg: hn,
830            n_unique: un,
831            n_masters: masters,
832            ..
833        } = acc
834            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
835            .unwrap();
836        assert_eq!(ed, 2); // both rows of M-0000001
837        assert_eq!(hn, 0);
838        assert_eq!(un, 1);
839        assert_eq!(masters, 2);
840
841        std::fs::remove_file(&final_path).ok();
842    }
843
844    /// Regression: two different entity types can produce master_ids that
845    /// share the same numeric suffix (each entity's index restarts at 0),
846    /// but must not be counted as duplicates of each other.
847    #[test]
848    fn test_no_cross_entity_suffix_collision() {
849        let draft = tmp_path("suffix_draft");
850        let final_path = tmp_path("suffix_final");
851
852        let mut acc = GtAccumulator::new(&draft).unwrap();
853        acc.push_base_batch(
854            &arr(vec!["R1", "R2"]),
855            &arr(vec!["person", "account"]),
856            &arr(vec!["PERSON-0000001", "ACCOUNT-0000001"]),
857        )
858        .unwrap();
859
860        let GtResult {
861            n_exact_dup: ed,
862            n_unique: un,
863            n_masters: masters,
864            ..
865        } = acc
866            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
867            .unwrap();
868        assert_eq!(ed, 0);
869        assert_eq!(un, 2);
870        assert_eq!(masters, 2);
871
872        std::fs::remove_file(&final_path).ok();
873    }
874
875    /// `finish()` returns a `cluster_map` mapping each duplicated master_id
876    /// to the full set of `(record_id, is_identical)` pairs in its cluster
877    /// (base + duplicate copies). This is the structure consumed by
878    /// `push_dup_clusters` to decide, per edge, `exact_dup` vs `fuzzy_dup`.
879    #[test]
880    fn test_cluster_map_contents() {
881        let draft = tmp_path("cm_draft");
882        let final_path = tmp_path("cm_final");
883
884        let mut acc = GtAccumulator::new(&draft).unwrap();
885        acc.push_base_batch(
886            &arr_owned(vec![rid(1), rid(3), rid(6)]),
887            &arr(vec!["person", "person", "person"]),
888            &arr_owned(vec![mid(1), mid(2), mid(5)]),
889        )
890        .unwrap();
891        // rid(2) (mid(1)) stayed identical; rid(7) (mid(5)) was genuinely noised.
892        acc.push_dup_batch(
893            &arr_owned(vec![rid(2), rid(7)]),
894            &arr(vec!["person", "person"]),
895            &arr_owned(vec![mid(1), mid(5)]),
896            &barr(vec![true, false]),
897        )
898        .unwrap();
899        acc.push_other_batch(
900            &arr_owned(vec![rid(4), rid(5)]),
901            &arr(vec!["person", "person"]),
902            &arr(vec!["HN-0000003", "HN-0000004"]),
903        )
904        .unwrap();
905
906        let GtResult {
907            cluster_map: cm, ..
908        } = acc
909            .finish("medium", "ipc", &final_path, &HashMap::new(), true)
910            .unwrap();
911
912        // Only duplicated masters appear; singletons (mid(2)) do not. Groups
913        // come out in ascending packed-master-key order, i.e. mid(1) (local
914        // index 1) before mid(5) (local index 5).
915        assert_eq!(cm.n_clusters(), 2);
916        let mut groups = cm.groups();
917        let (records1, idents1) = groups.next().unwrap();
918        assert_eq!(records1, &[1, 2]);
919        assert_eq!(idents1.to_vec(), vec![true, true]);
920        // rid(6) is the base of mid(5); its only copy (rid(7)) was genuinely
921        // noised, so the cluster has no byte-identical pair at all — rid(6)
922        // must NOT read as identical just because it's the base (that was
923        // the bug: base rows used to hardcode `is_identical = true`).
924        let (records5, idents5) = groups.next().unwrap();
925        assert_eq!(records5, &[6, 7]);
926        assert_eq!(idents5.to_vec(), vec![false, false]);
927        assert!(groups.next().is_none());
928
929        std::fs::remove_file(&final_path).ok();
930    }
931}