Skip to main content

dupehell_core/
gt.rs

1// DupeHell -- MIT License . Educational Use Only
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, StringArray, StringBuilder};
8use arrow::datatypes::{DataType, Field, Schema};
9use arrow::record_batch::RecordBatch;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// Result of [`GtAccumulator::finish`].
14pub struct GtResult {
15    pub n_exact_dup: usize,
16    pub n_hard_neg: usize,
17    pub n_unique: usize,
18    pub n_masters: usize,
19    /// Maps each duplicated `master_id` to the full set of `record_id`s in
20    /// its cluster (base + duplicate copies). Consumed by
21    /// `graph_gen::push_dup_clusters` to emit `exact_dup` edges.
22    pub cluster_map: HashMap<String, Vec<String>>,
23}
24
25fn draft_schema() -> Arc<Schema> {
26    Arc::new(Schema::new(vec![
27        Field::new("record_id", DataType::Utf8, false),
28        Field::new("master_id", DataType::Utf8, false),
29        Field::new("entity_type", DataType::Utf8, false),
30    ]))
31}
32
33/// Streaming, two-pass ground-truth builder.
34///
35/// Duplicate detection needs a genuinely global view of which masters are
36/// duplicated (a master can be duplicated anywhere in the record stream),
37/// but that's the *only* thing that needs to be global — everything else
38/// can be processed one batch at a time. Previously the whole run
39/// accumulated three full-dataset `Vec<ArrayRef>` (record_id/entity_type/
40/// master_id) in RAM to concat and classify at the very end; at 200M+
41/// records that's multiple GB held for the entire run just for GT
42/// bookkeeping.
43///
44/// This accumulator instead:
45/// 1. streams each batch straight to a small "draft" IPC file as it's
46///    generated. The pipeline already knows, at the point each batch is
47///    produced, whether its rows introduce brand-new masters
48///    (`push_base_batch`) or are duplicate copies of masters seen earlier
49///    (`push_dup_batch`) — that's how `pipeline::run_pipeline` builds
50///    `dup_mids_buf` today. So instead of *discovering* duplication by
51///    folding occurrence counts over every master ever seen (O(n_base) —
52///    one entry per base record, duplicated or not), this only records the
53///    (much smaller) set of masters that are *actually* duplicated —
54///    O(n_dup_masters), a fraction of the dataset determined by the
55///    doublet/triplet ratios, not the full base population. HN/CANARY rows
56///    (`push_other_batch`) need no bookkeeping at all: their prefix alone
57///    determines their classification.
58/// 2. on `finish`, re-reads that draft file batch by batch — now that the
59///    full duplicated-master set is known — to classify each row and
60///    stream the definitive ground-truth file (IPC or Parquet), same
61///    batch-by-batch pattern already used elsewhere in this codebase for
62///    the IPC→Parquet dataset conversion (see `pipeline::run_pipeline`).
63///
64/// Duplicate detection is keyed on the **full** master_id string (entity
65/// prefix included), not just a numeric suffix: master_ids are assigned
66/// per-entity-plan starting from index 0, so two unrelated entities of
67/// different types can share the same numeric suffix. Keying on the full
68/// string avoids counting those as duplicates of each other.
69pub struct GtAccumulator {
70    draft_path: String,
71    writer: arrow::ipc::writer::FileWriter<std::fs::File>,
72    schema: Arc<Schema>,
73    dup_masters: rustc_hash::FxHashSet<String>,
74    n_base_masters: usize,
75}
76
77impl GtAccumulator {
78    pub fn new(draft_path: &str) -> Result<Self, String> {
79        let schema = draft_schema();
80        let file = std::fs::File::create(draft_path)
81            .map_err(|e| format!("create gt draft {draft_path}: {e}"))?;
82        let writer = arrow::ipc::writer::FileWriter::try_new(file, &schema)
83            .map_err(|e| format!("gt draft writer: {e}"))?;
84        Ok(Self {
85            draft_path: draft_path.to_string(),
86            writer,
87            schema,
88            dup_masters: rustc_hash::FxHashSet::default(),
89            n_base_masters: 0,
90        })
91    }
92
93    fn write_draft(
94        &mut self,
95        record_ids: &ArrayRef,
96        entity_types: &ArrayRef,
97        master_ids: &ArrayRef,
98    ) -> Result<(), String> {
99        let batch = RecordBatch::try_new(
100            self.schema.clone(),
101            vec![record_ids.clone(), master_ids.clone(), entity_types.clone()],
102        )
103        .map_err(|e| format!("build gt draft batch: {e}"))?;
104        self.writer
105            .write(&batch)
106            .map_err(|e| format!("write gt draft: {e}"))
107    }
108
109    /// Feed a batch of rows that each introduce a brand-new, not-yet-seen
110    /// master (base records — one master per row, never a duplicate copy).
111    pub fn push_base_batch(
112        &mut self,
113        record_ids: &ArrayRef,
114        entity_types: &ArrayRef,
115        master_ids: &ArrayRef,
116    ) -> Result<(), String> {
117        let mids = master_ids.as_string::<i32>();
118        for i in 0..mids.len() {
119            if !mids.is_null(i) {
120                self.n_base_masters += 1;
121            }
122        }
123        self.write_draft(record_ids, entity_types, master_ids)
124    }
125
126    /// Feed a batch of duplicate-copy rows (each `master_id` matches an
127    /// existing base record already fed via `push_base_batch`). Records the
128    /// *set* of duplicated masters — every row sharing one of these
129    /// master_ids (the original base row included) is classified
130    /// `exact_dup` at `finish`.
131    pub fn push_dup_batch(
132        &mut self,
133        record_ids: &ArrayRef,
134        entity_types: &ArrayRef,
135        master_ids: &ArrayRef,
136    ) -> Result<(), String> {
137        let mids = master_ids.as_string::<i32>();
138        for i in 0..mids.len() {
139            if !mids.is_null(i) {
140                let mid = mids.value(i);
141                if !self.dup_masters.contains(mid) {
142                    self.dup_masters.insert(mid.to_string());
143                }
144            }
145        }
146        self.write_draft(record_ids, entity_types, master_ids)
147    }
148
149    /// Feed a batch of rows whose classification is fully determined by
150    /// their `master_id` prefix (hard negatives `HN-...`, canaries
151    /// `CANARY-...`) — no bookkeeping needed beyond streaming to the draft.
152    pub fn push_other_batch(
153        &mut self,
154        record_ids: &ArrayRef,
155        entity_types: &ArrayRef,
156        master_ids: &ArrayRef,
157    ) -> Result<(), String> {
158        self.write_draft(record_ids, entity_types, master_ids)
159    }
160
161    /// Consumes the accumulator: closes the draft, re-reads it batch by
162    /// batch to classify each row now that the full duplicated-master set
163    /// is known, and streams the definitive ground-truth file (IPC or
164    /// Parquet). Returns a [`GtResult`].
165    pub fn finish(
166        self,
167        difficulty: &str,
168        output_format: &str,
169        final_path: &str,
170        metadata: &HashMap<String, String>,
171    ) -> Result<GtResult, String> {
172        let GtAccumulator {
173            draft_path,
174            mut writer,
175            dup_masters,
176            n_base_masters,
177            ..
178        } = self;
179        writer
180            .finish()
181            .map_err(|e| format!("finish gt draft: {e}"))?;
182        drop(writer);
183
184        let final_schema = Arc::new(
185            Schema::new(vec![
186                Field::new("record_id", DataType::Utf8, false),
187                Field::new("master_id", DataType::Utf8, false),
188                Field::new("entity_type", DataType::Utf8, false),
189                Field::new("match_type", DataType::Utf8, false),
190                Field::new("difficulty", DataType::Utf8, false),
191            ])
192            .with_metadata(metadata.clone()),
193        );
194
195        let draft_file = std::fs::File::open(&draft_path)
196            .map_err(|e| format!("reopen gt draft {draft_path}: {e}"))?;
197        let reader = arrow::ipc::reader::FileReader::try_new(draft_file, None)
198            .map_err(|e| format!("gt draft reader: {e}"))?;
199
200        let mut sink = GtSink::new(output_format, final_path, &final_schema, metadata)?;
201
202        let mut n_exact_dup = 0usize;
203        let mut n_hard_neg = 0usize;
204        let mut n_unique = 0usize;
205        // Cluster membership for duplicated masters (base + duplicate copies),
206        // for emitting `exact_dup` edges after this pass.
207        let mut cluster_map: HashMap<String, Vec<String>> = HashMap::new();
208
209        for batch_result in reader {
210            let batch = batch_result.map_err(|e| format!("read gt draft batch: {e}"))?;
211            let n = batch.num_rows();
212            let mid_col = batch.column(1).as_string::<i32>();
213            let rid_col = batch.column(0).as_string::<i32>();
214
215            let mut mt_builder = StringBuilder::with_capacity(n, n * 10);
216            for i in 0..n {
217                let mid = if mid_col.is_null(i) {
218                    ""
219                } else {
220                    mid_col.value(i)
221                };
222                let mt = if mid.starts_with("HN-") {
223                    n_hard_neg += 1;
224                    "hard_neg"
225                } else if mid.starts_with("CANARY-") {
226                    "canary"
227                } else if dup_masters.contains(mid) {
228                    n_exact_dup += 1;
229                    "exact_dup"
230                } else {
231                    n_unique += 1;
232                    "unique"
233                };
234                mt_builder.append_value(mt);
235                // Every row of a duplicated master belongs to its cluster.
236                if dup_masters.contains(mid) {
237                    cluster_map
238                        .entry(mid.to_string())
239                        .or_default()
240                        .push(rid_col.value(i).to_string());
241                }
242            }
243            let mt_arr: ArrayRef = Arc::new(mt_builder.finish());
244            let diff_arr: ArrayRef = Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
245                difficulty, n,
246            )));
247
248            let final_batch = RecordBatch::try_new(
249                final_schema.clone(),
250                vec![
251                    batch.column(0).clone(),
252                    batch.column(1).clone(),
253                    batch.column(2).clone(),
254                    mt_arr,
255                    diff_arr,
256                ],
257            )
258            .map_err(|e| format!("build final gt batch: {e}"))?;
259
260            sink.write(&final_batch)?;
261        }
262
263        sink.finish()?;
264        std::fs::remove_file(&draft_path).ok();
265
266        Ok(GtResult {
267            n_exact_dup,
268            n_hard_neg,
269            n_unique,
270            n_masters: n_base_masters,
271            cluster_map,
272        })
273    }
274}
275
276enum GtSink {
277    Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
278    Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
279}
280
281impl GtSink {
282    fn new(
283        output_format: &str,
284        path: &str,
285        schema: &Arc<Schema>,
286        metadata: &HashMap<String, String>,
287    ) -> Result<Self, String> {
288        let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
289        if output_format == "parquet" {
290            use parquet::basic::{Compression, ZstdLevel};
291            use parquet::file::properties::WriterProperties;
292            let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
293            let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
294                .iter()
295                .map(|(k, v)| parquet::file::metadata::KeyValue {
296                    key: k.clone(),
297                    value: Some(v.clone()),
298                })
299                .collect();
300            let props = WriterProperties::builder()
301                .set_compression(Compression::ZSTD(zstd))
302                .set_data_page_size_limit(1_048_576)
303                .set_key_value_metadata(Some(meta_kv))
304                .build();
305            let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
306                .map_err(|e| format!("gt parquet writer: {e}"))?;
307            Ok(GtSink::Parquet(Box::new(writer)))
308        } else {
309            let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
310                .map_err(|e| format!("gt ipc writer: {e}"))?;
311            Ok(GtSink::Ipc(Box::new(writer)))
312        }
313    }
314
315    fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
316        match self {
317            GtSink::Ipc(w) => w.write(batch).map_err(|e| format!("write gt ipc: {e}")),
318            GtSink::Parquet(w) => w.write(batch).map_err(|e| format!("write gt parquet: {e}")),
319        }
320    }
321
322    fn finish(self) -> Result<(), String> {
323        match self {
324            GtSink::Ipc(mut w) => w.finish().map_err(|e| format!("finish gt ipc: {e}")),
325            GtSink::Parquet(w) => w
326                .close()
327                .map(|_| ())
328                .map_err(|e| format!("close gt parquet: {e}")),
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use arrow::array::StringArray;
337
338    fn tmp_path(name: &str) -> String {
339        let mut p = std::env::temp_dir();
340        p.push(format!(
341            "dupehell_gt_test_{name}_{}.ipc",
342            std::process::id()
343        ));
344        p.to_string_lossy().into_owned()
345    }
346
347    fn arr(values: Vec<&str>) -> ArrayRef {
348        Arc::new(StringArray::from(values))
349    }
350
351    /// Reads the final GT file into a `record_id -> match_type` map. A map
352    /// (rather than a positional `Vec`) keeps the assertions independent of
353    /// draft row order, which is caller-determined (base/dup/other batches
354    /// are pushed as separate, non-interleaved groups in the real pipeline).
355    fn read_match_types(final_path: &str) -> HashMap<String, String> {
356        let file = std::fs::File::open(final_path).unwrap();
357        let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
358        let mut out = HashMap::new();
359        for batch in reader {
360            let batch = batch.unwrap();
361            let rid = batch
362                .column_by_name("record_id")
363                .unwrap()
364                .as_string::<i32>();
365            let mt = batch
366                .column_by_name("match_type")
367                .unwrap()
368                .as_string::<i32>();
369            for i in 0..batch.num_rows() {
370                out.insert(rid.value(i).to_string(), mt.value(i).to_string());
371            }
372        }
373        out
374    }
375
376    #[test]
377    fn test_gt_accumulator_basic() {
378        let draft = tmp_path("basic_draft");
379        let final_path = tmp_path("basic_final");
380
381        let mut acc = GtAccumulator::new(&draft).unwrap();
382        // Base rows: R1 (master M-0000001, later duplicated), R3 (singleton
383        // M-0000002), R6 (singleton M-0000005).
384        acc.push_base_batch(
385            &arr(vec!["R1", "R3", "R6"]),
386            &arr(vec!["person", "person", "person"]),
387            &arr(vec!["M-0000001", "M-0000002", "M-0000005"]),
388        )
389        .unwrap();
390        // Dup row: R2 duplicates M-0000001.
391        acc.push_dup_batch(
392            &arr(vec!["R2"]),
393            &arr(vec!["person"]),
394            &arr(vec!["M-0000001"]),
395        )
396        .unwrap();
397        // Hard negatives: R4, R5.
398        acc.push_other_batch(
399            &arr(vec!["R4", "R5"]),
400            &arr(vec!["person", "person"]),
401            &arr(vec!["HN-0000003", "HN-0000004"]),
402        )
403        .unwrap();
404
405        let GtResult {
406            n_exact_dup: ed,
407            n_hard_neg: hn,
408            n_unique: un,
409            n_masters: masters,
410            ..
411        } = acc
412            .finish("medium", "ipc", &final_path, &HashMap::new())
413            .unwrap();
414        assert_eq!(ed, 2);
415        assert_eq!(hn, 2);
416        assert_eq!(un, 2);
417        assert_eq!(masters, 3); // M-0000001, M-0000002, M-0000005
418
419        let match_types = read_match_types(&final_path);
420        assert_eq!(match_types["R1"], "exact_dup");
421        assert_eq!(match_types["R2"], "exact_dup");
422        assert_eq!(match_types["R3"], "unique");
423        assert_eq!(match_types["R4"], "hard_neg");
424        assert_eq!(match_types["R5"], "hard_neg");
425        assert_eq!(match_types["R6"], "unique");
426
427        std::fs::remove_file(&final_path).ok();
428    }
429
430    #[test]
431    fn test_gt_accumulator_multi_batch() {
432        let draft = tmp_path("multi_draft");
433        let final_path = tmp_path("multi_final");
434
435        let mut acc = GtAccumulator::new(&draft).unwrap();
436        // Base batch: two masters, one of which (M-0000001) is duplicated
437        // in a later, separate dup batch — simulating a master's duplicate
438        // landing in a different batch than its base row.
439        acc.push_base_batch(
440            &arr(vec!["R1", "R3"]),
441            &arr(vec!["person", "person"]),
442            &arr(vec!["M-0000001", "M-0000002"]),
443        )
444        .unwrap();
445        acc.push_dup_batch(
446            &arr(vec!["R2"]),
447            &arr(vec!["person"]),
448            &arr(vec!["M-0000001"]),
449        )
450        .unwrap();
451
452        let GtResult {
453            n_exact_dup: ed,
454            n_hard_neg: hn,
455            n_unique: un,
456            n_masters: masters,
457            ..
458        } = acc
459            .finish("medium", "ipc", &final_path, &HashMap::new())
460            .unwrap();
461        assert_eq!(ed, 2); // both rows of M-0000001
462        assert_eq!(hn, 0);
463        assert_eq!(un, 1);
464        assert_eq!(masters, 2);
465
466        std::fs::remove_file(&final_path).ok();
467    }
468
469    /// Regression: two different entity types can produce master_ids that
470    /// share the same numeric suffix (each entity's index restarts at 0),
471    /// but must not be counted as duplicates of each other.
472    #[test]
473    fn test_no_cross_entity_suffix_collision() {
474        let draft = tmp_path("suffix_draft");
475        let final_path = tmp_path("suffix_final");
476
477        let mut acc = GtAccumulator::new(&draft).unwrap();
478        acc.push_base_batch(
479            &arr(vec!["R1", "R2"]),
480            &arr(vec!["person", "account"]),
481            &arr(vec!["PERSON-0000001", "ACCOUNT-0000001"]),
482        )
483        .unwrap();
484
485        let GtResult {
486            n_exact_dup: ed,
487            n_unique: un,
488            n_masters: masters,
489            ..
490        } = acc
491            .finish("medium", "ipc", &final_path, &HashMap::new())
492            .unwrap();
493        assert_eq!(ed, 0);
494        assert_eq!(un, 2);
495        assert_eq!(masters, 2);
496
497        std::fs::remove_file(&final_path).ok();
498    }
499
500    /// `finish()` returns a `cluster_map` mapping each duplicated master_id to
501    /// the full set of its record_ids (base + duplicate copies). This is the
502    /// structure consumed by `push_dup_clusters` to emit `exact_dup` edges.
503    #[test]
504    fn test_cluster_map_contents() {
505        let draft = tmp_path("cm_draft");
506        let final_path = tmp_path("cm_final");
507
508        let mut acc = GtAccumulator::new(&draft).unwrap();
509        acc.push_base_batch(
510            &arr(vec!["R1", "R3", "R6"]),
511            &arr(vec!["person", "person", "person"]),
512            &arr(vec!["M-0000001", "M-0000002", "M-0000005"]),
513        )
514        .unwrap();
515        acc.push_dup_batch(
516            &arr(vec!["R2", "R7"]),
517            &arr(vec!["person", "person"]),
518            &arr(vec!["M-0000001", "M-0000005"]),
519        )
520        .unwrap();
521        acc.push_other_batch(
522            &arr(vec!["R4", "R5"]),
523            &arr(vec!["person", "person"]),
524            &arr(vec!["HN-0000003", "HN-0000004"]),
525        )
526        .unwrap();
527
528        let GtResult {
529            cluster_map: cm, ..
530        } = acc
531            .finish("medium", "ipc", &final_path, &HashMap::new())
532            .unwrap();
533
534        // Only duplicated masters appear; singletons (M-0000002) do not.
535        assert_eq!(cm.len(), 2);
536        let mut m1 = cm.get("M-0000001").unwrap().clone();
537        m1.sort();
538        assert_eq!(m1, vec!["R1".to_string(), "R2".to_string()]);
539        let mut m5 = cm.get("M-0000005").unwrap().clone();
540        m5.sort();
541        assert_eq!(m5, vec!["R6".to_string(), "R7".to_string()]);
542        assert!(!cm.contains_key("M-0000002"));
543
544        std::fs::remove_file(&final_path).ok();
545    }
546}