1use 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
13pub 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 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
33pub 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 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 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 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 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 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 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 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 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 acc.push_dup_batch(
392 &arr(vec!["R2"]),
393 &arr(vec!["person"]),
394 &arr(vec!["M-0000001"]),
395 )
396 .unwrap();
397 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); 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 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); 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 #[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 #[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 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}