1use std::collections::HashMap;
7use std::sync::Arc;
8
9use arrow::array::{
10 Array, ArrayRef, AsArray, StringArray, StringBuilder, UInt64Array, UInt64Builder,
11};
12use arrow::datatypes::{DataType, Field, Schema};
13use arrow::record_batch::RecordBatch;
14use rayon::prelude::*;
15use rustc_hash::FxHashMap;
16use serde::Deserialize;
17
18use crate::context::Context;
19use crate::rng::Rng;
20
21#[derive(Debug, Deserialize)]
24pub struct PipelineConfig {
25 pub domain: String,
26 pub size: usize,
27 pub seed: u64,
28 pub difficulty: String,
29 pub output_format: String,
30 pub run_id: String,
31
32 pub entity_plans: Vec<EntityPlan>,
33 pub hard_neg_types: Vec<HnTypeConfig>,
34
35 pub hard_neg_ratio: f64,
36
37 pub noise_passes: usize,
42
43 pub graph_enabled: bool,
44 pub graph_format: String,
45
46 #[serde(default)]
57 pub skip_ground_truth: bool,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct EntityPlan {
62 pub name: String,
63 pub n_base: usize,
64 pub identifier_col: Option<String>,
65 pub columns_json: String,
66 pub noise_types: Vec<NoisePlanEntry>,
67 pub fk_remaps: Vec<FkRemapEntry>,
68 #[serde(default)]
75 pub pool_only: bool,
76}
77
78pub(crate) const FK_POOL_CAP: usize = 200_000;
85
86#[derive(Debug, Deserialize)]
87pub struct NoisePlanEntry {
88 pub noise_type: String,
89 pub columns: Vec<String>,
90 pub count: usize,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct FkRemapEntry {
95 pub source_col: String,
96 pub target_entity: String,
97 #[serde(default = "default_fk_weight")]
101 pub fk_weight: f64,
102}
103
104fn default_fk_weight() -> f64 {
105 1.0
106}
107
108#[derive(Debug, Deserialize)]
109pub struct HnTypeConfig {
110 pub entity_type: String,
111 pub config_json: String,
112 pub count: usize,
113}
114
115#[derive(Debug)]
116pub struct PipelineOutput {
117 pub output_files: Vec<String>,
118 pub gt_file: String,
119 pub stats: PipelineStats,
120 pub nodes: Option<String>,
121 pub edges: Option<String>,
122}
123
124#[derive(Debug)]
125pub struct PipelineStats {
126 pub total_records: usize,
127 pub exact_dups: usize,
130 pub fuzzy_dups: usize,
133 pub hard_negs: usize,
134 pub uniques: usize,
135 pub masters: usize,
136}
137
138#[derive(Debug, Clone, Default)]
145pub struct ChunkOffsets {
146 pub rid_offset: usize,
148 pub master_local: Vec<usize>,
154 pub hn_master_counter: u64,
156 pub id_local: Vec<usize>,
163}
164
165impl ChunkOffsets {
166 fn master_base(&self, entity_idx: usize) -> usize {
167 self.master_local.get(entity_idx).copied().unwrap_or(0)
168 }
169
170 pub(crate) fn id_base(&self, entity_idx: usize) -> usize {
171 self.id_local.get(entity_idx).copied().unwrap_or(0)
172 }
173}
174
175pub(crate) const RID_LEN: usize = 15; pub(crate) const PAD_LEN: usize = 13; #[cfg(test)]
197#[inline]
198pub(crate) fn record_id_string(i: usize) -> String {
199 let mut buf = [0u8; RID_LEN];
200 buf[0] = b'R';
201 buf[1] = b'-';
202 let mut n = i;
203 for j in (2..RID_LEN).rev() {
204 buf[j] = b'0' + (n % 10) as u8;
205 n /= 10;
206 }
207 String::from_utf8(buf.to_vec()).expect("record_id_string: buffer is 'R', '-' and ASCII digits")
208}
209#[cfg(test)]
210#[inline]
211pub(crate) fn pad_string(i: usize) -> String {
212 let mut buf = [0u8; PAD_LEN];
213 let mut n = i;
214 for j in (0..PAD_LEN).rev() {
215 buf[j] = b'0' + (n % 10) as u8;
216 n /= 10;
217 }
218 String::from_utf8(buf.to_vec()).expect("pad_string: buffer is always ASCII digits")
219}
220
221#[cfg(test)]
222pub(crate) fn record_id_strs(range: std::ops::Range<usize>) -> Vec<String> {
223 range.map(record_id_string).collect()
224}
225
226#[inline]
232pub(crate) fn write_digits(buf: &mut Vec<u8>, val: u64, width: usize) {
233 let start = buf.len();
234 buf.resize(start + width, b'0');
235 let mut n = val;
236 for j in (0..width).rev() {
237 buf[start + j] = b'0' + (n % 10) as u8;
238 n /= 10;
239 }
240}
241
242#[inline]
248pub(crate) fn append_record_id(buf: &mut Vec<u8>, idx: u64) {
249 buf.push(b'R');
250 buf.push(b'-');
251 write_digits(buf, idx, PAD_LEN);
252}
253
254pub(crate) fn record_id_array(range: std::ops::Range<usize>) -> ArrayRef {
259 let n = range.len();
260 let mut i = range.start as u64;
261 crate::buf_gen::build_string_array(n, RID_LEN, |buf| {
262 buf.push(b'R');
263 buf.push(b'-');
264 write_digits(buf, i, PAD_LEN);
265 i += 1;
266 })
267}
268
269fn master_id_array_range(prefix: &str, start: usize, n: usize) -> ArrayRef {
273 let width = prefix.len() + 1 + PAD_LEN;
274 let mut i = start as u64;
275 crate::buf_gen::build_string_array(n, width, |buf| {
276 buf.extend_from_slice(prefix.as_bytes());
277 buf.push(b'-');
278 write_digits(buf, i, PAD_LEN);
279 i += 1;
280 })
281}
282
283fn master_id_array_from_indices(
288 prefix: &str,
289 mut indices: impl Iterator<Item = usize>,
290 n: usize,
291) -> ArrayRef {
292 let width = prefix.len() + 1 + PAD_LEN;
293 crate::buf_gen::build_string_array(n, width, |buf| {
294 let idx = indices
295 .next()
296 .expect("master_id_array_from_indices: fewer than n indices");
297 buf.extend_from_slice(prefix.as_bytes());
298 buf.push(b'-');
299 write_digits(buf, idx as u64, PAD_LEN);
300 })
301}
302
303fn hn_master_id_array(start: u64, n: usize) -> ArrayRef {
307 let mut id = start;
308 crate::buf_gen::build_string_array(n, 3 + HN_ID_DIGITS, |buf| {
309 buf.extend_from_slice(b"HN-");
310 write_digits(buf, id, HN_ID_DIGITS);
311 id += 1;
312 })
313}
314
315pub(crate) fn parse_record_idx(rid: &str) -> Option<u64> {
321 if rid.len() != RID_LEN || !rid.starts_with("R-") {
322 return None;
323 }
324 rid[2..].parse().ok()
325}
326
327pub(crate) fn pack_master_key(mid: &str) -> Option<u64> {
340 let bytes = mid.as_bytes();
341 if bytes.len() != 1 + 5 + 1 + PAD_LEN || bytes[0] != b'E' || bytes[6] != b'-' {
342 return None;
343 }
344 let entity_idx: u64 = mid[1..6].parse().ok()?;
345 let local_idx: u64 = mid[7..7 + PAD_LEN].parse().ok()?;
346 Some(pack_master_parts(entity_idx, local_idx))
347}
348
349pub(crate) fn pack_master_parts(entity_idx: u64, local_idx: u64) -> u64 {
353 (entity_idx << 44) | local_idx
354}
355
356fn distribute_by_weight(total: usize, weights: &[usize]) -> Vec<usize> {
364 let weight_sum: usize = weights.iter().sum();
365 if weight_sum == 0 || total == 0 {
366 return vec![0; weights.len()];
367 }
368 let raw: Vec<f64> = weights
369 .iter()
370 .map(|&w| total as f64 * w as f64 / weight_sum as f64)
371 .collect();
372 let mut counts: Vec<usize> = raw.iter().map(|&r| r as usize).collect();
373 let assigned: usize = counts.iter().sum();
374 let mut remainder = total - assigned;
375 let mut order: Vec<usize> = (0..counts.len()).collect();
376 order.sort_by(|&a, &b| {
377 let fa = raw[a] - counts[a] as f64;
378 let fb = raw[b] - counts[b] as f64;
379 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
380 });
381 for &i in &order {
382 if remainder == 0 {
383 break;
384 }
385 counts[i] += 1;
386 remainder -= 1;
387 }
388 counts
389}
390
391pub(crate) fn entity_prefix(entity_idx: usize) -> String {
400 format!("E{:05}", entity_idx)
401}
402
403type FkPoolMap = HashMap<String, RecordBatch>;
406
407struct HnPool {
408 batch: RecordBatch,
409 total_count: usize,
410 record_ids: ArrayRef,
413}
414
415const PERSON_NAME_WORDS: &[&str] = &[
422 "name",
423 "first_name",
431 "last_name",
432 "given",
433 "family",
434 "middle",
435 "operator",
436 "technician",
437 "inspector",
438 "claimant",
439 "consignee",
440 "investigator",
441 "collected_by",
442 "performed_by",
443 "assigned_to",
444];
445
446const COMPANY_NAME_WORDS: &[&str] = &[
451 "company",
452 "legal",
453 "trading",
454 "name",
455 "supplier",
456 "manufacturer",
457 "sponsor",
458 "institution",
459 "journal",
460 "funder",
461 "employer",
462 "law_firm",
463 "affiliation",
464 "reported_by",
465 "buyer",
466 "shipper",
467 "airline",
468];
469
470fn is_specifically_person_name(lower: &str) -> bool {
483 PERSON_NAME_WORDS
484 .iter()
485 .any(|&w| w != "name" && lower.contains(w))
486}
487
488fn is_specifically_company_name(lower: &str) -> bool {
491 COMPANY_NAME_WORDS
492 .iter()
493 .any(|&w| w != "name" && lower.contains(w))
494}
495
496pub(crate) fn noise_type_targets_column(noise_type: &str, col_name: &str) -> bool {
502 let lower = col_name.to_lowercase();
503 match noise_type {
504 "typo" | "typo_aggressive" | "typo_extreme" | "qwerty_azerty" | "visual" | "homoglyph"
505 | "unicode_pollution" | "ocr_errors" | "case_swap" | "char_dropout" | "language_mix"
506 | "blocking_fail" => {
507 if lower.contains("email") || lower.contains("ip_address") {
512 return false;
513 }
514 contains_any(&lower, &["address", "street", "city"])
519 || contains_any(&lower, PERSON_NAME_WORDS)
520 || contains_any(&lower, COMPANY_NAME_WORDS)
521 }
522 "names" | "nickname" | "initials" | "partial" | "name_compound" | "swap" | "full_swap" => {
523 contains_any(&lower, PERSON_NAME_WORDS) && !is_specifically_company_name(&lower)
529 }
530 "dates" | "date_error" | "date_chaotic" | "date_format_mix" | "age_impossible" => {
531 contains_any(
547 &lower,
548 &[
549 "date",
550 "birth",
551 "incorporation",
552 "founding",
553 "time",
554 "since",
555 "period",
556 ],
557 )
558 }
559 "missing" | "missing_pattern" => {
560 contains_any(&lower, &["phone", "email", "mobile", "address", "street"])
561 }
562 "identifiers"
563 | "corrupt_email"
564 | "corrupt_phone"
565 | "corrupt_national_id"
566 | "corrupt_siren"
567 | "national_id_corrupt"
568 | "phone_corrupt"
569 | "email_corrupt"
570 | "siren_corrupt" => contains_any(
571 &lower,
572 &[
573 "email",
574 "phone",
575 "ssn",
576 "pan",
577 "passport",
578 "account",
579 "medicare",
580 "license",
581 "siren",
582 "national_id",
583 "registration",
590 "sku",
600 "barcode",
601 "imei",
602 "imsi",
603 "iccid",
604 ],
605 ),
606 "extra"
607 | "name_null"
608 | "dob_null"
609 | "blocking_fail_initial"
610 | "blocking_fail_partial"
611 | "fuzzy_match"
612 | "phonetic" => {
613 if lower.contains("email") || lower.contains("ip_address") {
619 return false;
620 }
621 contains_any(
626 &lower,
627 &["address", "street", "city", "note", "comment", "bio"],
628 ) || contains_any(&lower, PERSON_NAME_WORDS)
629 || contains_any(&lower, COMPANY_NAME_WORDS)
630 }
631 "companies" | "acronym" | "legal_form_drop" | "word_dropout" | "company_scramble" => {
632 contains_any(&lower, COMPANY_NAME_WORDS) && !is_specifically_person_name(&lower)
638 }
639 "addresses" | "address_scramble" | "postal_corrupt" => {
640 !lower.contains("ip_address")
663 && !lower.contains("email")
664 && contains_any(
665 &lower,
666 &[
667 "address", "street", "postal", "city", "state", "country", "location",
668 ],
669 )
670 }
671 "exact" | "english_name" | "estonian_name" | "lithuanian_name" | "slovak_name"
672 | "serbian_name" | "norwegian_name" | "swedish_name" | "dutch_name" | "czech_name"
673 | "albanian_name" | "polish_name" | "romanian_name" | "hungarian_name" | "german_name"
674 | "italian_name" | "spanish_name" | "portuguese_name" | "combo_hard" | "combo_extreme"
675 | "combo_ultimate" | "french_address" => {
676 false
678 }
679 _ => true,
681 }
682}
683
684fn match_noise_columns(schema: &Schema, noise_type: &str, exclude_cols: &[String]) -> Vec<String> {
685 let mut matched: Vec<String> = Vec::new();
686 for field in schema.fields() {
687 if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
688 continue;
689 }
690 if exclude_cols.contains(field.name()) {
691 continue;
692 }
693 if noise_type_targets_column(noise_type, field.name()) {
694 matched.push(field.name().clone());
695 }
696 }
697 if matches!(noise_type, "swap" | "full_swap") && matched.len() >= 2 {
699 matched.truncate(2);
700 }
701 matched.sort();
702 matched.dedup();
703 matched
704}
705
706fn contains_any(s: &str, patterns: &[&str]) -> bool {
707 patterns.iter().any(|p| s.contains(p))
708}
709
710fn apply_noise_to_batch(
717 rb: &RecordBatch,
718 noise_type: &str,
719 plan_cols: &[String],
720 rng: &mut Rng,
721 exclude_cols: &[String],
722) -> Result<(RecordBatch, Vec<String>), String> {
723 let schema = rb.schema();
724 let n_cols = rb.num_columns();
725
726 let target_cols: Vec<String> = if plan_cols.is_empty() {
728 match_noise_columns(&schema, noise_type, exclude_cols)
729 } else {
730 plan_cols.to_vec()
731 };
732
733 let mut new_columns: Vec<Option<ArrayRef>> = (0..n_cols).map(|_| None).collect();
735
736 let get_col = |new_columns: &[Option<ArrayRef>], idx: usize, rb: &RecordBatch| -> ArrayRef {
737 new_columns[idx]
738 .clone()
739 .unwrap_or_else(|| rb.column(idx).clone())
740 };
741
742 match noise_type {
743 "blocking_fail" => {
744 for col_name in &target_cols {
745 let lower = col_name.to_lowercase();
746 if let Ok(idx) = schema.index_of(col_name) {
747 let col = get_col(&new_columns, idx, rb);
748 if lower.contains("first") || lower.contains("given") {
749 new_columns[idx] =
750 Some(crate::noise::extra::apply_blocking_initial(&*col, rng));
751 } else {
752 new_columns[idx] =
753 Some(crate::noise::extra::apply_blocking_partial(&*col, rng));
754 }
755 }
756 }
757 }
758 "swap" | "full_swap" => {
759 if target_cols.len() >= 2 {
760 let name_a = &target_cols[0];
761 let name_b = &target_cols[1];
762 if let (Ok(idx_a), Ok(idx_b)) = (schema.index_of(name_a), schema.index_of(name_b)) {
763 let a = get_col(&new_columns, idx_a, rb);
764 let b = get_col(&new_columns, idx_b, rb);
765 new_columns[idx_a] = Some(b);
766 new_columns[idx_b] = Some(a);
767 }
768 }
769 }
770 "missing_pattern" => {
771 let mut phone_idx: Option<usize> = None;
772 let mut email_idx: Option<usize> = None;
773 for col_name in &target_cols {
774 let lower = col_name.to_lowercase();
775 if let Ok(idx) = schema.index_of(col_name) {
776 if lower.contains("phone") || lower.contains("mobile") {
777 phone_idx = Some(idx);
778 } else if lower.contains("email") {
779 email_idx = Some(idx);
780 }
781 }
782 }
783 if let (Some(pi), Some(ei)) = (phone_idx, email_idx) {
784 let phone_arr = get_col(&new_columns, pi, rb);
785 let email_arr = get_col(&new_columns, ei, rb);
786 let phone_s = phone_arr.as_string::<i32>();
787 let email_s = email_arr.as_string::<i32>();
788 let n = phone_s.len();
789 let mut builder = arrow::array::StringBuilder::with_capacity(n, 32);
790 for i in 0..n {
791 let pv = if phone_s.is_null(i) {
792 ""
793 } else {
794 phone_s.value(i)
795 };
796 let ev = if email_s.is_null(i) {
797 ""
798 } else {
799 email_s.value(i)
800 };
801 if !ev.is_empty() && rng.next_usize(2) == 0 {
802 builder.append_value("");
803 } else {
804 builder.append_value(pv);
805 }
806 }
807 new_columns[pi] = Some(Arc::new(builder.finish()));
808 }
809 }
810 _ => {
811 for col_name in &target_cols {
812 if let Ok(col_idx) = schema.index_of(col_name) {
813 let col = get_col(&new_columns, col_idx, rb);
814 let result = crate::noise::apply_noise_to_column(&*col, noise_type, rng)
815 .map_err(|e| format!("noise '{noise_type}' on '{col_name}': {e}"))?;
816 new_columns[col_idx] = Some(result);
817 }
818 }
819 }
820 }
821
822 let final_columns: Vec<ArrayRef> = (0..n_cols)
824 .map(|i| {
825 new_columns[i]
826 .clone()
827 .unwrap_or_else(|| rb.column(i).clone())
828 })
829 .collect();
830
831 let batch =
832 RecordBatch::try_new(schema, final_columns).map_err(|e| format!("RecordBatch: {e}"))?;
833 Ok((batch, target_cols))
834}
835
836fn unchanged_row_mask(
847 orig: &RecordBatch,
848 noised: &RecordBatch,
849 target_cols: &[String],
850) -> Result<Vec<bool>, String> {
851 let n = orig.num_rows();
852 unchanged_row_mask_within(orig, noised, target_cols, &vec![true; n])
853}
854
855fn unchanged_row_mask_within(
866 orig: &RecordBatch,
867 noised: &RecordBatch,
868 target_cols: &[String],
869 only: &[bool],
870) -> Result<Vec<bool>, String> {
871 let n = orig.num_rows();
872 let mut changed: Vec<bool> = only.iter().map(|&keep| !keep).collect();
875 let schema = orig.schema();
876 for col_name in target_cols {
877 let Ok(idx) = schema.index_of(col_name) else {
878 continue;
879 };
880 let a = orig.column(idx).as_string::<i32>();
881 let b = noised.column(idx).as_string::<i32>();
882 #[allow(clippy::needless_range_loop)]
885 for i in 0..n {
886 if changed[i] {
887 continue;
888 }
889 let av = (!a.is_null(i)).then(|| a.value(i));
890 let bv = (!b.is_null(i)).then(|| b.value(i));
891 if av != bv {
892 changed[i] = true;
893 }
894 }
895 }
896 for c in changed.iter_mut() {
897 *c = !*c;
898 }
899 Ok(changed)
900}
901
902fn apply_noise_with_retry(
925 orig: &RecordBatch,
926 noise_type: &str,
927 plan_cols: &[String],
928 rng: &mut Rng,
929 exclude_cols: &[String],
930) -> Result<(RecordBatch, Vec<bool>, Vec<String>), String> {
931 const MAX_NOISE_ATTEMPTS: usize = 4;
932 let (mut noisy, target_cols) =
933 apply_noise_to_batch(orig, noise_type, plan_cols, rng, exclude_cols)?;
934 if target_cols.is_empty() {
935 return Ok((noisy, vec![true; orig.num_rows()], target_cols));
936 }
937 let schema = noisy.schema();
938 let target_idxs: Vec<usize> = target_cols
939 .iter()
940 .filter_map(|c| schema.index_of(c).ok())
941 .collect();
942 let mut unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
943 let mut attempt = 1;
944 while unchanged.iter().any(|&u| u) && attempt < MAX_NOISE_ATTEMPTS {
945 let mut retry_rng = Rng::new(rng.next_u64());
946 let (retried, _) =
947 apply_noise_to_batch(orig, noise_type, plan_cols, &mut retry_rng, exclude_cols)?;
948 let mask = arrow::array::BooleanArray::from_iter(unchanged.iter().map(|&u| Some(u)));
949 let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
950 for &i in &target_idxs {
951 merged_cols[i] =
952 arrow::compute::kernels::zip::zip(&mask, retried.column(i), noisy.column(i))
953 .map_err(|e| format!("zip noise retry col {i}: {e}"))?;
954 }
955 noisy = RecordBatch::try_new(noisy.schema(), merged_cols)
956 .map_err(|e| format!("rebuild retried dup batch: {e}"))?;
957 unchanged = unchanged_row_mask_within(orig, &noisy, &target_cols, &unchanged)?;
962 attempt += 1;
963 }
964 Ok((noisy, unchanged, target_cols))
965}
966
967fn apply_extra_pass_per_row(
994 noisy: &RecordBatch,
995 unchanged: &mut [bool],
996 all_types: &[&str],
997 rng: &mut Rng,
998 exclude_cols: &[String],
999) -> Result<RecordBatch, String> {
1000 let n = noisy.num_rows();
1001 if all_types.is_empty() || n == 0 {
1002 return Ok(noisy.clone());
1003 }
1004 let per_row_type: Vec<usize> = (0..n).map(|_| rng.next_usize(all_types.len())).collect();
1005
1006 let mut groups: Vec<Vec<u32>> = vec![Vec::new(); all_types.len()];
1024 let mut row_group: Vec<u32> = Vec::with_capacity(n);
1025 let mut row_pos: Vec<u32> = Vec::with_capacity(n);
1026 for (i, &t) in per_row_type.iter().enumerate() {
1027 row_pos.push(groups[t].len() as u32);
1028 row_group.push(t as u32);
1029 groups[t].push(i as u32);
1030 }
1031
1032 let schema = noisy.schema();
1033 let mut pass_unchanged = vec![true; n];
1034 let mut noised_by_group: Vec<Option<RecordBatch>> = vec![None; all_types.len()];
1035 let mut group_target_idxs: Vec<Vec<usize>> = vec![Vec::new(); all_types.len()];
1041 let mut touched_cols: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
1042
1043 for (type_idx, idxs) in groups.into_iter().enumerate() {
1044 if idxs.is_empty() {
1045 continue;
1046 }
1047 let ttype = all_types[type_idx];
1048 let idx_arr = UInt64Array::from_iter_values(idxs.iter().map(|&i| i as u64));
1049 let subset = pick_rows(noisy, &idx_arr)?;
1050 let (noised_subset, subset_unchanged, target_cols) =
1051 apply_noise_with_retry(&subset, ttype, &[], rng, exclude_cols)?;
1052 let target_idxs: Vec<usize> = target_cols
1053 .iter()
1054 .filter_map(|c| schema.index_of(c).ok())
1055 .collect();
1056 touched_cols.extend(target_idxs.iter().copied());
1057 group_target_idxs[type_idx] = target_idxs;
1058 for (local_pos, &orig_i) in idxs.iter().enumerate() {
1059 pass_unchanged[orig_i as usize] = subset_unchanged[local_pos];
1060 }
1061 noised_by_group[type_idx] = Some(noised_subset);
1062 }
1063
1064 let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
1065 for col_idx in touched_cols {
1066 let orig_col = noisy.column(col_idx).as_string::<i32>();
1067 let cap_bytes = orig_col.value_data().len().max(n);
1073 let mut builder = StringBuilder::with_capacity(n, cap_bytes);
1074 let per_group: Vec<Option<&arrow::array::StringArray>> = (0..group_target_idxs.len())
1083 .map(|g| {
1084 if group_target_idxs[g].contains(&col_idx) {
1085 noised_by_group[g]
1086 .as_ref()
1087 .map(|b| b.column(col_idx).as_string::<i32>())
1088 } else {
1089 None
1090 }
1091 })
1092 .collect();
1093 for i in 0..n {
1094 let g = row_group[i] as usize;
1095 match per_group[g] {
1096 Some(noised_col) => {
1097 let pos = row_pos[i] as usize;
1098 if noised_col.is_null(pos) {
1099 builder.append_null();
1100 } else {
1101 builder.append_value(noised_col.value(pos));
1102 }
1103 }
1104 None => {
1105 if orig_col.is_null(i) {
1106 builder.append_null();
1107 } else {
1108 builder.append_value(orig_col.value(i));
1109 }
1110 }
1111 }
1112 }
1113 merged_cols[col_idx] = Arc::new(builder.finish());
1114 }
1115 let current = RecordBatch::try_new(noisy.schema(), merged_cols)
1116 .map_err(|e| format!("rebuild extra-pass batch: {e}"))?;
1117
1118 for (u, pu) in unchanged.iter_mut().zip(pass_unchanged.iter()) {
1119 *u = *u && *pu;
1120 }
1121 Ok(current)
1122}
1123
1124fn topological_sort(
1128 plans: &[EntityPlan],
1129 name_to_idx: &HashMap<&str, usize>,
1130) -> Result<Vec<usize>, String> {
1131 let n = plans.len();
1132 let mut in_degree = vec![0usize; n];
1133 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
1134 for i in 0..n {
1135 for remap in &plans[i].fk_remaps {
1136 let Some(&target) = name_to_idx.get(remap.target_entity.as_str()) else {
1137 continue;
1138 };
1139 if target == i {
1140 continue;
1141 }
1142 adj[target].push(i); in_degree[i] += 1;
1144 }
1145 }
1146 let mut queue: Vec<usize> = (0..n).filter(|i| in_degree[*i] == 0).collect();
1147 let mut order = Vec::with_capacity(n);
1148 let mut idx = 0;
1149 while idx < queue.len() {
1150 let v = queue[idx];
1151 idx += 1;
1152 order.push(v);
1153 for &next in &adj[v] {
1154 in_degree[next] -= 1;
1155 if in_degree[next] == 0 {
1156 queue.push(next);
1157 }
1158 }
1159 }
1160 if order.len() != n {
1161 return Err("FK dependency cycle detected among entities".into());
1162 }
1163 Ok(order)
1164}
1165
1166pub(crate) enum DatasetWriter {
1170 Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
1171 Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
1172}
1173
1174impl DatasetWriter {
1175 fn new(
1176 output_format: &str,
1177 path: &str,
1178 schema: &Arc<Schema>,
1179 metadata: &RunMetadata,
1180 ) -> Result<Self, String> {
1181 let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
1182 if output_format == "parquet" {
1183 use parquet::basic::{Compression, ZstdLevel};
1184 use parquet::file::properties::WriterProperties;
1185 let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1186 let meta_kv = parquet_kv(metadata);
1187 let props = WriterProperties::builder()
1188 .set_compression(Compression::ZSTD(zstd))
1189 .set_max_row_group_row_count(Some(1_000_000))
1193 .set_key_value_metadata(Some(meta_kv))
1194 .build();
1195 let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
1196 .map_err(|e| format!("ArrowWriter error: {e}"))?;
1197 Ok(DatasetWriter::Parquet(Box::new(writer)))
1198 } else {
1199 let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
1200 .map_err(|e| format!("IPC FileWriter error: {e}"))?;
1201 Ok(DatasetWriter::Ipc(Box::new(writer)))
1202 }
1203 }
1204
1205 pub(crate) fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
1206 match self {
1207 DatasetWriter::Ipc(w) => w.write(batch).map_err(|e| format!("write ipc: {e}")),
1208 DatasetWriter::Parquet(w) => w.write(batch).map_err(|e| format!("write parquet: {e}")),
1209 }
1210 }
1211
1212 fn finish(self) -> Result<(), String> {
1213 match self {
1214 DatasetWriter::Ipc(mut w) => w.finish().map_err(|e| format!("finish ipc: {e}")),
1215 DatasetWriter::Parquet(w) => w
1216 .close()
1217 .map(|_| ())
1218 .map_err(|e| format!("close parquet: {e}")),
1219 }
1220 }
1221}
1222
1223fn log_rss(label: &str) {
1234 if !log::log_enabled!(log::Level::Debug) {
1235 return;
1236 }
1237 let pid = sysinfo::Pid::from_u32(std::process::id());
1238 let Ok(mut sys) = sysinfo::System::new() else {
1239 return;
1240 };
1241 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
1242 if let Some(p) = sys.process(pid) {
1243 log::debug!("[rss] {label}: rss={:.1} MB", p.memory() as f64 / 1e6);
1244 }
1245}
1246
1247pub fn run_pipeline(
1250 ctx: &Context,
1251 config: &PipelineConfig,
1252 output_dir: &str,
1253) -> Result<PipelineOutput, String> {
1254 run_pipeline_with_progress(ctx, config, output_dir, None)
1255}
1256
1257pub fn run_pipeline_with_progress(
1264 ctx: &Context,
1265 config: &PipelineConfig,
1266 output_dir: &str,
1267 progress: Option<&mut dyn FnMut(usize, usize)>,
1268) -> Result<PipelineOutput, String> {
1269 run_pipeline_chunked(ctx, config, output_dir, progress, None).map(|(out, _)| out)
1270}
1271
1272pub fn run_pipeline_chunked(
1279 ctx: &Context,
1280 config: &PipelineConfig,
1281 output_dir: &str,
1282 mut progress: Option<&mut dyn FnMut(usize, usize)>,
1283 offsets: Option<&ChunkOffsets>,
1284) -> Result<(PipelineOutput, ChunkOffsets), String> {
1285 let offsets = offsets.cloned().unwrap_or_default();
1286 std::fs::create_dir_all(output_dir)
1287 .map_err(|e| format!("create output directory {output_dir:?}: {e}"))?;
1288 let t_start = std::time::Instant::now();
1289
1290 let t_alloc = t_start.elapsed().as_secs_f64();
1291 log_rss("start");
1292
1293 let t1e = std::time::Instant::now();
1297
1298 let metadata = build_metadata_map(config);
1304 let full_arc = Arc::new(build_full_schema(config, &metadata));
1306 let domain_dict = DictValues::new([config.domain.clone()]);
1314 let entity_type_dict = DictValues::new(
1315 config
1316 .entity_plans
1317 .iter()
1318 .filter(|p| !p.pool_only)
1319 .map(|p| p.name.clone()),
1320 );
1321 let dataset_ext = if config.output_format == "parquet" {
1322 "parquet"
1323 } else {
1324 "ipc"
1325 };
1326 let dataset_path = format!("{}/{}.{}", output_dir, config.run_id, dataset_ext);
1327 let mut writer =
1328 DatasetWriter::new(&config.output_format, &dataset_path, &full_arc, &metadata)?;
1329
1330 let name_to_idx: HashMap<&str, usize> = config
1332 .entity_plans
1333 .iter()
1334 .enumerate()
1335 .map(|(i, p)| (p.name.as_str(), i))
1336 .collect();
1337 let entity_order = topological_sort(&config.entity_plans, &name_to_idx)?;
1338
1339 let mut fk_pools: FkPoolMap = HashMap::new();
1341 let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
1342
1343 let gt_ext = if config.output_format == "parquet" {
1347 "parquet"
1348 } else {
1349 "ipc"
1350 };
1351 let gt_path = format!("{}/{}_ground_truth.{}", output_dir, config.run_id, gt_ext);
1352 let mut gt_acc: Option<crate::gt::GtAccumulator> = if config.skip_ground_truth {
1353 None
1354 } else {
1355 Some(crate::gt::GtAccumulator::new(
1356 config.difficulty.as_str(),
1357 &config.output_format,
1358 >_path,
1359 &metadata,
1360 config.graph_enabled,
1361 )?)
1362 };
1363
1364 let mut global_rid_offset = offsets.rid_offset;
1365
1366 let mut disk_guard = crate::disk::DiskGuard::new(std::path::Path::new(output_dir));
1370
1371 const GRAPH_MAX_CLUSTER_EDGES: usize = 10_000;
1373 let graph_fmt = crate::graph_gen::GraphFormat::from_str(&config.graph_format);
1374 let mut node_writer: Option<crate::graph_gen::NodeWriter> = None;
1375 let mut edge_writer: Option<crate::graph_gen::EdgeWriter> = None;
1376 let mut graph_nodes_path: Option<String> = None;
1377 let mut graph_edges_path: Option<String> = None;
1378 if config.graph_enabled {
1379 let nodes_ipc = format!("{}/{}_nodes.ipc", output_dir, config.run_id);
1380 let edges_ipc = format!("{}/{}_edges.ipc", output_dir, config.run_id);
1381 node_writer = Some(
1382 crate::graph_gen::NodeWriter::new(&nodes_ipc, &full_arc, &metadata)
1383 .map_err(|e| format!("init node writer: {e}"))?,
1384 );
1385 edge_writer = Some(
1386 crate::graph_gen::EdgeWriter::new(&edges_ipc, &metadata, subtype_dict(config))
1387 .map_err(|e| format!("init edge writer: {e}"))?,
1388 );
1389 graph_nodes_path = Some(nodes_ipc);
1390 graph_edges_path = Some(edges_ipc);
1391 }
1392
1393 let mut _t_write = 0.0f64;
1394 let mut _t_meta = 0.0f64;
1395 let mut _t_dup = 0.0f64;
1396 let mut _write_calls = 0u64;
1397 let hn_pool_sizing: std::collections::HashMap<&str, usize> =
1399 config
1400 .hard_neg_types
1401 .iter()
1402 .fold(std::collections::HashMap::new(), |mut m, hn| {
1403 let entry = m.entry(hn.entity_type.as_str()).or_insert(0);
1404 *entry = (*entry).max(hn.count);
1405 m
1406 });
1407 let hn_max_pool: usize = hn_pool_sizing.values().max().copied().unwrap_or(50_000) * 2 + 1_000;
1408 let pool_only_names: std::collections::HashSet<&str> = config
1424 .entity_plans
1425 .iter()
1426 .filter(|p| p.pool_only)
1427 .map(|p| p.name.as_str())
1428 .collect();
1429
1430 let fk_targets: std::collections::HashSet<&str> = config
1431 .entity_plans
1432 .iter()
1433 .flat_map(|p| p.fk_remaps.iter().map(|r| r.target_entity.as_str()))
1434 .collect();
1435
1436 for &plan_idx in &entity_order {
1437 let plan = &config.entity_plans[plan_idx];
1438 if plan.n_base == 0 {
1439 continue;
1440 }
1441
1442 let prefix = entity_prefix(plan_idx);
1443 let master_base = offsets.master_base(plan_idx);
1444 let id_base = offsets.id_base(plan_idx);
1445 let parsed_columns = crate::entity_gen::parse_columns(&plan.columns_json)?;
1448
1449 let fk_targeted = fk_targets.contains(plan.name.as_str());
1451 let mut fk_ids: Vec<String> = Vec::new();
1457 let mut fk_rids: Vec<u64> = Vec::new();
1458 let mut fk_seen: usize = 0;
1459 let mut fk_pool_rng = Rng::new(crate::rng::derive_seed(
1460 config.seed,
1461 &[
1462 crate::rng::stream::FK_POOL,
1463 crate::rng::name_key(&plan.name),
1464 ],
1465 ));
1466 let hn_targeted = hn_pool_sizing.contains_key(plan.name.as_str());
1471 let mut hn_slices: Vec<RecordBatch> = Vec::new();
1472 let mut hn_slice_rids: Vec<ArrayRef> = Vec::new();
1473 let entity_key = crate::rng::name_key(&plan.name);
1476 let mut batch_rng = Rng::new(crate::rng::derive_seed(
1477 config.seed,
1478 &[crate::rng::stream::DUPLICATES, entity_key],
1479 ));
1480 let mut fk_rng = Rng::new(crate::rng::derive_seed(
1481 config.seed,
1482 &[crate::rng::stream::FK_REMAP, entity_key],
1483 ));
1484
1485 let batch_bounds: Vec<(usize, usize)> = (0..plan.n_base)
1491 .step_by(crate::entity_gen::BATCH_SIZE)
1492 .map(|offset| {
1493 (
1494 offset,
1495 (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE),
1496 )
1497 })
1498 .collect();
1499 let batch_weights: Vec<usize> = batch_bounds.iter().map(|&(_, bn)| bn).collect();
1500 let per_batch_noise_counts: Vec<Vec<usize>> = plan
1501 .noise_types
1502 .iter()
1503 .map(|n| distribute_by_weight(n.count, &batch_weights))
1504 .collect();
1505 let fk_exclude_cols: Vec<String> = plan
1511 .fk_remaps
1512 .iter()
1513 .map(|r| r.source_col.clone())
1514 .collect();
1515 let all_types: Vec<&str> = plan
1516 .noise_types
1517 .iter()
1518 .map(|n| n.noise_type.as_str())
1519 .collect();
1520
1521 let mut col_lookup: Option<Vec<Option<usize>>> = None;
1523 let mut null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
1525 let mut const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
1526
1527 for (batch_idx, offset) in (0..plan.n_base)
1529 .step_by(crate::entity_gen::BATCH_SIZE)
1530 .enumerate()
1531 {
1532 let batch_n = (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE);
1533 let batch_seed = crate::rng::derive_seed(
1534 config.seed,
1535 &[
1536 crate::rng::stream::ENTITY_BATCH,
1537 entity_key,
1538 batch_idx as u64,
1539 ],
1540 );
1541
1542 let rb = crate::entity_gen::generate_entity_batch_parsed(
1544 ctx,
1545 &parsed_columns,
1546 batch_n,
1547 batch_seed,
1548 id_base + offset,
1549 )?;
1550
1551 let mut fk_edges_by_remap: Vec<(String, ArrayRef, f64)> = Vec::new();
1553 let rb = if !plan.fk_remaps.is_empty() {
1554 let mut r = rb;
1555 for remap in &plan.fk_remaps {
1556 if let Some(pool) = fk_pools.get(&remap.target_entity) {
1557 let want_edge = config.graph_enabled
1561 && !pool_only_names.contains(remap.target_entity.as_str());
1562 let (remapped, target_rids) = crate::fk_remap::fk_remap_batch(
1563 &r,
1564 pool,
1565 &remap.source_col,
1566 &mut fk_rng,
1567 want_edge,
1568 )?;
1569 r = remapped;
1570 if want_edge && let Some(rids) = target_rids {
1571 fk_edges_by_remap.push((
1572 remap.source_col.clone(),
1573 rids,
1574 remap.fk_weight,
1575 ));
1576 }
1577 }
1578 }
1579 r
1580 } else {
1581 rb
1582 };
1583
1584 if fk_targeted
1587 && let Some(ref id_col) = plan.identifier_col
1588 && let Some(col) = rb.column_by_name(id_col)
1589 {
1590 let s = col.as_string::<i32>();
1591 for i in 0..s.len() {
1592 if s.is_null(i) {
1593 continue;
1594 }
1595 let rid = (global_rid_offset + i) as u64;
1596 if fk_seen < FK_POOL_CAP {
1597 fk_ids.push(s.value(i).to_string());
1598 fk_rids.push(rid);
1599 } else {
1600 let j = fk_pool_rng.next_usize(fk_seen + 1);
1601 if j < FK_POOL_CAP {
1602 fk_ids[j].clear();
1603 fk_ids[j].push_str(s.value(i));
1604 fk_rids[j] = rid;
1605 }
1606 }
1607 fk_seen += 1;
1608 }
1609 }
1610
1611 if plan.pool_only {
1617 continue;
1618 }
1619
1620 let hn_accum: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1622 if hn_targeted && hn_accum < hn_max_pool {
1623 let take = (hn_max_pool - hn_accum).min(batch_n);
1624 hn_slices.push(rb.slice(0, take));
1625 if config.graph_enabled {
1629 hn_slice_rids
1630 .push(record_id_array(global_rid_offset..global_rid_offset + take));
1631 }
1632 }
1633
1634 if col_lookup.is_none() {
1636 let plan_schema = rb.schema();
1637 col_lookup = Some(
1638 full_arc
1639 .fields()
1640 .iter()
1641 .skip(4)
1642 .map(|f| plan_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1643 .collect(),
1644 );
1645 }
1646
1647 let batch_mid_arr = master_id_array_range(&prefix, master_base + offset, batch_n);
1649
1650 let rid_arr = record_id_array(global_rid_offset..global_rid_offset + batch_n);
1652 let t_m0 = std::time::Instant::now();
1653 let base_rb = add_metadata_and_align(
1654 &rb,
1655 &config.domain,
1656 &plan.name,
1657 &domain_dict,
1658 &entity_type_dict,
1659 &rid_arr,
1660 &batch_mid_arr,
1661 &full_arc,
1662 col_lookup.as_ref().unwrap(),
1663 &mut null_cache,
1664 &mut const_arr_cache,
1665 )?;
1666 _t_meta += t_m0.elapsed().as_secs_f64();
1667
1668 if config.graph_enabled
1670 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1671 {
1672 nw.write_batch(&base_rb)
1673 .map_err(|e| format!("write node: {e}"))?;
1674 for (subtype, target_rids, weight) in &fk_edges_by_remap {
1681 ew.push_batch(&rid_arr, target_rids, "fk", subtype, *weight)
1682 .map_err(|e| format!("push fk edge batch: {e}"))?;
1683 }
1684 }
1685
1686 let t_w0 = std::time::Instant::now();
1687 writer
1688 .write(&base_rb)
1689 .map_err(|e| format!("write base: {e}"))?;
1690 _t_write += t_w0.elapsed().as_secs_f64();
1691 _write_calls += 1;
1692
1693 let base_first_rid = global_rid_offset;
1696 let mut dup_gt: Option<(RecordBatch, Vec<usize>, Vec<bool>, usize)> = None;
1699
1700 global_rid_offset += batch_n;
1701 if let Some(cb) = &mut progress {
1702 cb(global_rid_offset, config.size);
1703 }
1704
1705 let has_dups: bool = per_batch_noise_counts
1713 .iter()
1714 .any(|counts| counts[batch_idx] > 0);
1715 if has_dups {
1716 let t_d0 = std::time::Instant::now();
1717 let mut dup_batches: Vec<RecordBatch> = Vec::new();
1718 let mut dup_master_idx_buf: Vec<usize> = Vec::new();
1719
1720 let ndata: Vec<(UInt64Array, u64, &str, &[String], usize)> = plan
1722 .noise_types
1723 .iter()
1724 .enumerate()
1725 .filter_map(|(ni, n)| {
1726 let cnt = per_batch_noise_counts[ni][batch_idx];
1727 if cnt == 0 {
1728 return None;
1729 }
1730 let mut b = UInt64Builder::with_capacity(cnt);
1731 for _ in 0..cnt {
1732 b.append_value(batch_rng.next_usize(batch_n) as u64);
1733 }
1734 Some((
1735 b.finish(),
1736 batch_rng.next_u64(),
1737 n.noise_type.as_str(),
1738 n.columns.as_slice(),
1739 cnt,
1740 ))
1741 })
1742 .collect();
1743
1744 let all_types_ref = &all_types;
1749 let noise_passes = config.noise_passes.max(1);
1750
1751 type DupNoiseResult = Result<(RecordBatch, Vec<usize>, Vec<bool>), String>;
1752 let fk_exclude_ref = &fk_exclude_cols;
1753 let rb_ref = &rb;
1754 let results: Vec<DupNoiseResult> = ndata
1767 .par_iter()
1768 .map(|(indices, seed, ntype, cols, cnt)| {
1769 let dup = pick_rows(rb_ref, indices)?;
1770 let mut rng = Rng::new(*seed);
1771
1772 let (mut noisy, mut unchanged, _) =
1778 apply_noise_with_retry(&dup, ntype, cols, &mut rng, fk_exclude_ref)?;
1779
1780 for _ in 1..noise_passes {
1792 noisy = apply_extra_pass_per_row(
1793 &noisy,
1794 &mut unchanged,
1795 all_types_ref,
1796 &mut rng,
1797 fk_exclude_ref,
1798 )?;
1799 }
1800
1801 let mut mb = Vec::with_capacity(*cnt);
1802 for j in 0..*cnt {
1803 let global_idx = master_base + offset + indices.value(j) as usize;
1808 mb.push(global_idx);
1809 }
1810 Ok((noisy, mb, unchanged))
1811 })
1812 .collect();
1813 let mut dup_is_identical_buf: Vec<bool> = Vec::new();
1814 for res in results {
1815 let (rb, mb, unchanged) = res?;
1816 dup_batches.push(rb);
1817 dup_master_idx_buf.extend(mb);
1818 dup_is_identical_buf.extend(unchanged);
1819 }
1820 _t_dup += t_d0.elapsed().as_secs_f64();
1821
1822 if !dup_batches.is_empty() {
1824 let dup_total = dup_master_idx_buf.len();
1825 let dup_rid_arr =
1826 record_id_array(global_rid_offset..global_rid_offset + dup_total);
1827 let dup_mid_arr = master_id_array_from_indices(
1828 &prefix,
1829 dup_master_idx_buf.iter().copied(),
1830 dup_total,
1831 );
1832
1833 let concated = if dup_batches.len() == 1 {
1834 dup_batches.into_iter().next().unwrap()
1835 } else {
1836 let schema = dup_batches[0].schema();
1837 let n_fields = dup_batches[0].num_columns();
1838 let mut concat_arrays = Vec::with_capacity(n_fields);
1839 for i in 0..n_fields {
1840 let refs: Vec<&dyn Array> =
1841 dup_batches.iter().map(|b| b.column(i).as_ref()).collect();
1842 concat_arrays.push(
1843 arrow::compute::concat(&refs)
1844 .map_err(|e| format!("concat dup col {i}: {e}"))?,
1845 );
1846 }
1847 RecordBatch::try_new(schema, concat_arrays)
1848 .map_err(|e| format!("concat dups: {e}"))?
1849 };
1850
1851 let dup_rb_full = add_metadata_and_align(
1852 &concated,
1853 &config.domain,
1854 &plan.name,
1855 &domain_dict,
1856 &entity_type_dict,
1857 &dup_rid_arr,
1858 &dup_mid_arr,
1859 &full_arc,
1860 col_lookup.as_ref().unwrap(),
1861 &mut null_cache,
1862 &mut const_arr_cache,
1863 )?;
1864
1865 let t_wd = std::time::Instant::now();
1866 writer
1867 .write(&dup_rb_full)
1868 .map_err(|e| format!("write dups: {e}"))?;
1869 _t_write += t_wd.elapsed().as_secs_f64();
1870 _write_calls += 1;
1871
1872 if config.graph_enabled
1874 && let Some(nw) = node_writer.as_mut()
1875 {
1876 nw.write_batch(&dup_rb_full)
1877 .map_err(|e| format!("write dup node: {e}"))?;
1878 }
1879
1880 if gt_acc.is_some() {
1881 dup_gt = Some((
1882 dup_rb_full,
1883 dup_master_idx_buf,
1884 dup_is_identical_buf,
1885 global_rid_offset,
1886 ));
1887 }
1888 global_rid_offset += dup_total;
1889 }
1890 }
1891
1892 if let Some(acc) = gt_acc.as_mut() {
1893 let base = crate::gt::BaseRows {
1894 record_ids: base_rb.column(0),
1895 entity_types: base_rb.column(2),
1896 master_ids: base_rb.column(3),
1897 entity_idx: plan_idx as u64,
1898 first_master_idx: (master_base + offset) as u64,
1899 first_record_idx: base_first_rid as u64,
1900 };
1901 let dups =
1902 dup_gt
1903 .as_ref()
1904 .map(|(rb, master_idx, ident, first_rid)| crate::gt::DupRows {
1905 record_ids: rb.column(0),
1906 entity_types: rb.column(2),
1907 master_ids: rb.column(3),
1908 master_idx,
1909 is_identical: ident,
1910 first_record_idx: *first_rid as u64,
1911 });
1912 acc.push_entity_batch(&base, dups.as_ref())?;
1913 }
1914 if let Some(g) = disk_guard.as_mut() {
1915 g.check()?;
1916 }
1917 }
1918
1919 if let Some(id_col) = &plan.identifier_col
1921 && !fk_ids.is_empty()
1922 {
1923 let arr = Arc::new(arrow::array::StringArray::from_iter_values(&fk_ids)) as ArrayRef;
1924 if config.graph_enabled {
1925 let mut rid_builder = arrow::array::StringBuilder::with_capacity(
1928 fk_rids.len(),
1929 fk_rids.len() * RID_LEN,
1930 );
1931 let mut rid_buf: Vec<u8> = Vec::with_capacity(RID_LEN);
1932 for &rid in &fk_rids {
1933 rid_buf.clear();
1934 append_record_id(&mut rid_buf, rid);
1935 rid_builder.append_value(
1936 std::str::from_utf8(&rid_buf)
1937 .expect("append_record_id writes only ASCII digits"),
1938 );
1939 }
1940 let rid_arr = Arc::new(rid_builder.finish()) as ArrayRef;
1941 let schema = Arc::new(Schema::new(vec![
1942 Field::new(id_col, DataType::Utf8, true),
1943 Field::new("record_id", DataType::Utf8, true),
1944 ]));
1945 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr, rid_arr]) {
1946 fk_pools.insert(plan.name.clone(), pool_rb);
1947 }
1948 } else {
1949 let schema = Arc::new(Schema::new(vec![Field::new(id_col, DataType::Utf8, true)]));
1950 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr]) {
1951 fk_pools.insert(plan.name.clone(), pool_rb);
1952 }
1953 }
1954 }
1955
1956 if !hn_slices.is_empty() {
1958 let total_count: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1959 let batch = if hn_slices.len() == 1 {
1960 let only = hn_slices.into_iter().next().unwrap();
1967 let idx = UInt64Array::from_iter_values(0..only.num_rows() as u64);
1968 pick_rows(&only, &idx)?
1969 } else {
1970 let schema = hn_slices[0].schema();
1971 let n_fields = schema.fields().len();
1972 let mut concat_arrays = Vec::with_capacity(n_fields);
1973 for i in 0..n_fields {
1974 let refs: Vec<&dyn Array> =
1975 hn_slices.iter().map(|pb| pb.column(i).as_ref()).collect();
1976 concat_arrays.push(
1977 arrow::compute::concat(&refs)
1978 .map_err(|e| format!("hn pool concat col {i}: {e}"))?,
1979 );
1980 }
1981 RecordBatch::try_new(schema, concat_arrays)
1982 .map_err(|e| format!("hn pool concat batch: {e}"))?
1983 };
1984 let record_ids: ArrayRef = if config.graph_enabled {
1986 let refs: Vec<&dyn Array> = hn_slice_rids.iter().map(|a| a.as_ref()).collect();
1987 arrow::compute::concat(&refs).map_err(|e| format!("hn rid concat: {e}"))?
1988 } else {
1989 Arc::new(StringArray::new_null(0))
1990 };
1991 hn_pools.insert(
1992 plan.name.clone(),
1993 HnPool {
1994 batch,
1995 total_count,
1996 record_ids,
1997 },
1998 );
1999 }
2000
2001 log_rss(&format!(
2006 "after entity '{}' ({} base rows)",
2007 plan.name, plan.n_base
2008 ));
2009 }
2010 let t1e_elapsed = t1e.elapsed().as_secs_f64();
2011 log::debug!("[sink_profile] write={_t_write:.3}s calls={_write_calls}");
2012 log_rss("after entity batches (fk_pools+hn_pools built)");
2013
2014 let t2 = std::time::Instant::now();
2016 let mut hn_null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
2018 let mut hn_const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
2019 let mut hn_master_id_counter: u64 = offsets.hn_master_counter;
2027 for (hn_idx, hn_cfg) in config.hard_neg_types.iter().enumerate() {
2028 if hn_cfg.count == 0 {
2029 continue;
2030 }
2031 let Some(pool_data) = hn_pools.get(&hn_cfg.entity_type) else {
2032 continue;
2033 };
2034 let hn_total = pool_data.total_count;
2035
2036 let pool_rb = &pool_data.batch;
2037 let n_hn_max = hn_cfg.count.min(hn_total / 2);
2038 if n_hn_max == 0 {
2039 continue;
2040 }
2041
2042 let (hn_rb, hn_src) = crate::hn_common::generate_hard_negatives(
2043 pool_rb,
2044 &hn_cfg.config_json,
2045 n_hn_max,
2046 crate::rng::derive_seed(config.seed, &[crate::rng::stream::HARD_NEG, hn_idx as u64]),
2049 config.graph_enabled,
2050 )?;
2051
2052 let n_hn = hn_rb.num_rows();
2053 if n_hn == 0 {
2054 continue;
2055 }
2056
2057 let hn_rid_arr = record_id_array(global_rid_offset..global_rid_offset + n_hn);
2058 let hn_mid_arr = hn_master_id_array(hn_master_id_counter, n_hn);
2059 hn_master_id_counter += n_hn as u64;
2060
2061 let hn_schema = hn_rb.schema();
2063 let hn_col_lookup: Vec<Option<usize>> = full_arc
2064 .fields()
2065 .iter()
2066 .skip(4)
2067 .map(|f| hn_schema.column_with_name(f.name()).map(|(idx, _)| idx))
2068 .collect();
2069
2070 let hn_rb_full = add_metadata_and_align(
2071 &hn_rb,
2072 &config.domain,
2073 &hn_cfg.entity_type,
2074 &domain_dict,
2075 &entity_type_dict,
2076 &hn_rid_arr,
2077 &hn_mid_arr,
2078 &full_arc,
2079 &hn_col_lookup,
2080 &mut hn_null_cache,
2081 &mut hn_const_arr_cache,
2082 )?;
2083
2084 let t_wh = std::time::Instant::now();
2085 writer
2086 .write(&hn_rb_full)
2087 .map_err(|e| format!("write hn: {e}"))?;
2088 _t_write += t_wh.elapsed().as_secs_f64();
2089 _write_calls += 1;
2090
2091 if config.graph_enabled
2094 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
2095 {
2096 nw.write_batch(&hn_rb_full)
2097 .map_err(|e| format!("write hn node: {e}"))?;
2098 if let Some((idx_a, pattern)) = hn_src {
2099 let take_n = n_hn.min(idx_a.len());
2105 let idx_arr = arrow::array::UInt64Array::from_iter_values(
2106 idx_a.iter().take(take_n).map(|&i| i as u64),
2107 );
2108 let tgt_arr = arrow::compute::take(&pool_data.record_ids, &idx_arr, None)
2109 .map_err(|e| format!("take hn target ids: {e}"))?;
2110 let src_arr = hn_rid_arr.slice(0, take_n);
2111 ew.push_batch(&src_arr, &tgt_arr, "hard_neg", &pattern, 1.0)
2112 .map_err(|e| format!("push hn edge batch: {e}"))?;
2113 }
2114 }
2115
2116 if let Some(acc) = gt_acc.as_mut() {
2117 acc.push_hard_neg_batch(
2118 hn_rb_full.column(0),
2119 hn_rb_full.column(2),
2120 hn_rb_full.column(3),
2121 )?;
2122 }
2123
2124 global_rid_offset += n_hn;
2125 }
2126 let t2_elapsed = t2.elapsed().as_secs_f64();
2127 drop(hn_pools);
2134 log_rss("after hard negatives");
2135
2136 {
2138 let mut canary_null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
2139 let mut canary_const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
2140 crate::canary::generate_all(
2141 ctx,
2142 config,
2143 &full_arc,
2144 &domain_dict,
2145 &entity_type_dict,
2146 &mut canary_null_cache,
2147 &mut canary_const_arr_cache,
2148 &mut global_rid_offset,
2149 &fk_pools,
2150 &offsets,
2151 &mut writer,
2152 &mut node_writer,
2153 gt_acc.as_mut(),
2154 )?;
2155 }
2156 log_rss("after canary records");
2157
2158 let t3 = std::time::Instant::now();
2160 writer
2161 .finish()
2162 .map_err(|e| format!("finish dataset writer: {e}"))?;
2163 let t3a_elapsed = t3.elapsed().as_secs_f64();
2164 log_rss("after dataset writer finish");
2165
2166 let t3b = std::time::Instant::now();
2167 let t_gt0 = std::time::Instant::now();
2168 let crate::gt::GtResult {
2169 n_exact_dup,
2170 n_fuzzy_dup,
2171 n_hard_neg,
2172 n_unique,
2173 n_masters,
2174 cluster_map,
2175 } = match gt_acc {
2176 Some(acc) => acc.finish()?,
2177 None => crate::gt::GtResult {
2182 n_exact_dup: 0,
2183 n_fuzzy_dup: 0,
2184 n_hard_neg: 0,
2185 n_unique: 0,
2186 n_masters: 0,
2187 cluster_map: crate::gt::ClusterCsr::build(Vec::new()),
2188 },
2189 };
2190 let _t_gt_compute = 0.0f64;
2191 let _t_gt_write = t_gt0.elapsed().as_secs_f64();
2192 let t3b_elapsed = t3b.elapsed().as_secs_f64();
2193 let _t3_elapsed = t3.elapsed().as_secs_f64();
2194 log_rss("after GT finish (cluster_map built)");
2195
2196 if config.graph_enabled
2198 && let Some(ew) = edge_writer.as_mut()
2199 {
2200 crate::graph_gen::push_dup_clusters(ew, &cluster_map, GRAPH_MAX_CLUSTER_EDGES)
2201 .map_err(|e| format!("push dup clusters: {e}"))?;
2202 }
2203
2204 let duration = t_start.elapsed().as_secs_f64();
2205
2206 log::debug!(
2207 "[pipeline_timing] alloc={:.3}s sink={:.3}s hn={:.3}s merge={:.3}s meta={:.3}s/dup={:.3}s/write={:.3}s gt(comp={:.3}s+write={:.3}s)={:.3}s total={:.3}s",
2208 t_alloc,
2209 t1e_elapsed,
2210 t2_elapsed,
2211 t3a_elapsed,
2212 _t_meta,
2213 _t_dup,
2214 _t_write,
2215 _t_gt_compute,
2216 _t_gt_write,
2217 t3b_elapsed,
2218 duration,
2219 );
2220
2221 let stats = PipelineStats {
2222 total_records: global_rid_offset,
2223 exact_dups: n_exact_dup,
2224 fuzzy_dups: n_fuzzy_dup,
2225 hard_negs: n_hard_neg,
2226 uniques: n_unique,
2227 masters: n_masters,
2228 };
2229
2230 let mut graph_nodes_final: Option<String> = None;
2232 let mut graph_edges_final: Option<String> = None;
2233 if let (Some(nw), Some(ew)) = (node_writer, edge_writer) {
2234 nw.finish()
2235 .map_err(|e| format!("finish node writer: {e}"))?;
2236 ew.finish()
2237 .map_err(|e| format!("finish edge writer: {e}"))?;
2238 let mut np = graph_nodes_path.clone().unwrap();
2239 let mut ep = graph_edges_path.clone().unwrap();
2240 if graph_fmt == crate::graph_gen::GraphFormat::Parquet {
2241 let np_pq = format!("{}/{}_nodes.parquet", output_dir, config.run_id);
2242 let ep_pq = format!("{}/{}_edges.parquet", output_dir, config.run_id);
2243 convert_ipc_to_parquet(&np, &np_pq).map_err(|e| format!("graph nodes parquet: {e}"))?;
2244 convert_ipc_to_parquet(&ep, &ep_pq).map_err(|e| format!("graph edges parquet: {e}"))?;
2245 std::fs::remove_file(&np).ok();
2246 std::fs::remove_file(&ep).ok();
2247 np = np_pq;
2248 ep = ep_pq;
2249 }
2250 graph_nodes_final = Some(np);
2251 graph_edges_final = Some(ep);
2252 }
2253 log_rss("end (after graph finalize)");
2254
2255 let mut master_local = offsets.master_local.clone();
2259 if master_local.len() < config.entity_plans.len() {
2260 master_local.resize(config.entity_plans.len(), 0);
2261 }
2262 for (idx, plan) in config.entity_plans.iter().enumerate() {
2263 if !plan.pool_only {
2264 master_local[idx] = offsets.master_base(idx) + plan.n_base;
2265 }
2266 }
2267 let id_local = config
2269 .entity_plans
2270 .iter()
2271 .enumerate()
2272 .map(|(idx, plan)| {
2273 let canaries = if plan.pool_only {
2274 0
2275 } else {
2276 crate::canary::CANARY_COUNT
2277 };
2278 offsets.id_base(idx) + plan.n_base + canaries
2279 })
2280 .collect();
2281 let next_offsets = ChunkOffsets {
2282 rid_offset: global_rid_offset,
2283 master_local,
2284 hn_master_counter: hn_master_id_counter,
2285 id_local,
2286 };
2287
2288 Ok((
2289 PipelineOutput {
2290 output_files: vec![dataset_path],
2291 gt_file: if config.skip_ground_truth {
2294 String::new()
2295 } else {
2296 gt_path
2297 },
2298 stats,
2299 nodes: graph_nodes_final,
2300 edges: graph_edges_final,
2301 },
2302 next_offsets,
2303 ))
2304}
2305
2306fn convert_ipc_to_parquet(ipc_path: &str, parquet_path: &str) -> Result<(), String> {
2310 use arrow::ipc::reader::FileReader;
2311
2312 let ipc_file =
2313 std::fs::File::open(ipc_path).map_err(|e| format!("open IPC {ipc_path}: {e}"))?;
2314 let ipc_reader =
2315 FileReader::try_new(ipc_file, None).map_err(|e| format!("IPC reader {ipc_path}: {e}"))?;
2316 let schema = ipc_reader.schema();
2317
2318 let parquet_file =
2319 std::fs::File::create(parquet_path).map_err(|e| format!("create {parquet_path}: {e}"))?;
2320 let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
2321 let meta_kv = parquet_kv(&schema.metadata().clone().into_iter().collect());
2323 let props = parquet::file::properties::WriterProperties::builder()
2324 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
2325 .set_max_row_group_row_count(Some(1_000_000))
2326 .set_key_value_metadata(Some(meta_kv))
2327 .build();
2328 let mut parquet_writer =
2329 parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
2330 .map_err(|e| format!("ArrowWriter {parquet_path}: {e}"))?;
2331
2332 for batch_result in ipc_reader {
2333 let batch = batch_result.map_err(|e| format!("IPC batch {ipc_path}: {e}"))?;
2334 parquet_writer
2335 .write(&batch)
2336 .map_err(|e| format!("write parquet {parquet_path}: {e}"))?;
2337 }
2338 parquet_writer
2339 .close()
2340 .map_err(|e| format!("close parquet {parquet_path}: {e}"))?;
2341 Ok(())
2342}
2343
2344fn concat_dataset_files(
2353 output_format: &str,
2354 in_paths: &[String],
2355 out_path: &str,
2356) -> Result<(), String> {
2357 if in_paths.is_empty() {
2358 return Err("concat_dataset_files: no chunk files to concatenate".into());
2359 }
2360 if output_format == "parquet" {
2361 concat_parquet_files(in_paths, out_path)
2362 } else {
2363 concat_ipc_files(in_paths, out_path)
2364 }
2365}
2366
2367fn concat_ipc_files(in_paths: &[String], out_path: &str) -> Result<(), String> {
2368 use arrow::ipc::reader::FileReader;
2369
2370 let first_file =
2371 std::fs::File::open(&in_paths[0]).map_err(|e| format!("open {}: {e}", in_paths[0]))?;
2372 let schema = FileReader::try_new(first_file, None)
2373 .map_err(|e| format!("ipc reader {}: {e}", in_paths[0]))?
2374 .schema();
2375
2376 let out_file =
2377 std::fs::File::create(out_path).map_err(|e| format!("create {out_path}: {e}"))?;
2378 let mut writer = arrow::ipc::writer::FileWriter::try_new(out_file, &schema)
2379 .map_err(|e| format!("ipc writer {out_path}: {e}"))?;
2380
2381 for path in in_paths {
2382 let file = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
2383 let reader =
2384 FileReader::try_new(file, None).map_err(|e| format!("ipc reader {path}: {e}"))?;
2385 for batch_result in reader {
2386 let batch = batch_result.map_err(|e| format!("read {path}: {e}"))?;
2387 writer
2388 .write(&batch)
2389 .map_err(|e| format!("write {out_path}: {e}"))?;
2390 }
2391 }
2392 writer
2393 .finish()
2394 .map_err(|e| format!("finish {out_path}: {e}"))
2395}
2396
2397fn concat_parquet_files(in_paths: &[String], out_path: &str) -> Result<(), String> {
2398 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
2399
2400 let first_file =
2401 std::fs::File::open(&in_paths[0]).map_err(|e| format!("open {}: {e}", in_paths[0]))?;
2402 let first_builder = ParquetRecordBatchReaderBuilder::try_new(first_file)
2403 .map_err(|e| format!("parquet reader {}: {e}", in_paths[0]))?;
2404 let schema = first_builder.schema().clone();
2405 let meta_kv: Vec<parquet::file::metadata::KeyValue> = first_builder
2406 .metadata()
2407 .file_metadata()
2408 .key_value_metadata()
2409 .cloned()
2410 .unwrap_or_default();
2411
2412 let out_file =
2413 std::fs::File::create(out_path).map_err(|e| format!("create {out_path}: {e}"))?;
2414 let zstd = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
2415 let props = parquet::file::properties::WriterProperties::builder()
2416 .set_compression(parquet::basic::Compression::ZSTD(zstd))
2417 .set_max_row_group_row_count(Some(1_000_000))
2418 .set_key_value_metadata(Some(meta_kv))
2419 .build();
2420 let mut writer = parquet::arrow::ArrowWriter::try_new(out_file, schema, Some(props))
2421 .map_err(|e| format!("arrow writer {out_path}: {e}"))?;
2422
2423 for path in in_paths {
2424 let file = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
2425 let reader = ParquetRecordBatchReaderBuilder::try_new(file)
2426 .map_err(|e| format!("parquet reader {path}: {e}"))?
2427 .build()
2428 .map_err(|e| format!("build parquet reader {path}: {e}"))?;
2429 for batch_result in reader {
2430 let batch = batch_result.map_err(|e| format!("read {path}: {e}"))?;
2431 writer
2432 .write(&batch)
2433 .map_err(|e| format!("write {out_path}: {e}"))?;
2434 }
2435 }
2436 writer
2437 .close()
2438 .map(|_| ())
2439 .map_err(|e| format!("close {out_path}: {e}"))
2440}
2441
2442pub const MIN_RUN_SIZE: usize = 10;
2458
2459pub fn chunk_sizes(total: usize, chunk_size: usize) -> Vec<usize> {
2465 let mut sizes = vec![chunk_size; total / chunk_size];
2466 let rem = total % chunk_size;
2467 match sizes.last_mut() {
2468 Some(last) if rem < MIN_RUN_SIZE => *last += rem,
2469 _ if rem > 0 => sizes.push(rem),
2470 _ => {}
2471 }
2472 sizes
2473}
2474
2475struct RemoveDirOnDrop<'a>(&'a str);
2476
2477impl Drop for RemoveDirOnDrop<'_> {
2478 fn drop(&mut self) {
2479 let _ = std::fs::remove_dir_all(self.0);
2480 }
2481}
2482
2483#[allow(clippy::too_many_arguments)]
2484pub fn run_chunked(
2485 ctx: &Context,
2486 domain: &str,
2487 total_size: usize,
2488 chunk_size: usize,
2489 seed: u64,
2490 difficulty: &str,
2491 hard_neg_ratio: f64,
2492 singleton_master_fraction: f64,
2493 schema: &crate::schema::DomainSchema,
2494 final_run_id: &str,
2495 output_format: &str,
2496 graph_enabled: bool,
2497 graph_format: &str,
2498 only_entity: Option<&str>,
2499 output_dir: &str,
2500 mut progress: Option<&mut dyn FnMut(usize, usize)>,
2501 skip_ground_truth: bool,
2502) -> Result<PipelineOutput, String> {
2503 if chunk_size == 0 || chunk_size >= total_size {
2504 let mut config = crate::schema::build_pipeline_config(
2505 domain,
2506 total_size,
2507 seed,
2508 difficulty,
2509 hard_neg_ratio,
2510 singleton_master_fraction,
2511 schema,
2512 final_run_id,
2513 output_format,
2514 graph_enabled,
2515 graph_format,
2516 only_entity,
2517 )?;
2518 config.skip_ground_truth = skip_ground_truth;
2519 return run_pipeline_with_progress(ctx, &config, output_dir, progress);
2520 }
2521
2522 if chunk_size < MIN_RUN_SIZE {
2523 return Err(format!(
2524 "--chunk-size must be >= {MIN_RUN_SIZE} (each chunk is a full run), got {chunk_size}"
2525 ));
2526 }
2527 let sizes = chunk_sizes(total_size, chunk_size);
2528 let tmp_dir = format!("{output_dir}/.dupehell_chunks_{final_run_id}");
2529 std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("create {tmp_dir:?}: {e}"))?;
2530 let _tmp_guard = RemoveDirOnDrop(&tmp_dir);
2533
2534 let mut offsets = ChunkOffsets::default();
2535 let mut dataset_chunks: Vec<String> = Vec::new();
2536 let mut gt_chunks: Vec<String> = Vec::new();
2537 let mut node_chunks: Vec<String> = Vec::new();
2538 let mut edge_chunks: Vec<String> = Vec::new();
2539 let mut agg = PipelineStats {
2540 total_records: 0,
2541 exact_dups: 0,
2542 fuzzy_dups: 0,
2543 hard_negs: 0,
2544 uniques: 0,
2545 masters: 0,
2546 };
2547
2548 for (chunk_idx, &this_size) in sizes.iter().enumerate() {
2549 let chunk_seed =
2552 crate::rng::derive_seed(seed, &[crate::rng::stream::CHUNK, chunk_idx as u64]);
2553 let chunk_run_id = format!("{final_run_id}__chunk{chunk_idx:04}");
2554 let mut chunk_config = crate::schema::build_pipeline_config(
2555 domain,
2556 this_size,
2557 chunk_seed,
2558 difficulty,
2559 hard_neg_ratio,
2560 singleton_master_fraction,
2561 schema,
2562 &chunk_run_id,
2563 output_format,
2564 graph_enabled,
2565 graph_format,
2566 only_entity,
2567 )?;
2568 chunk_config.skip_ground_truth = skip_ground_truth;
2569
2570 let already_written = agg.total_records;
2571 let mut chunk_cb = |done: usize, _total: usize| {
2572 if let Some(cb) = &mut progress {
2573 cb(already_written + done, total_size);
2574 }
2575 };
2576 let (out, next_offsets) = run_pipeline_chunked(
2577 ctx,
2578 &chunk_config,
2579 &tmp_dir,
2580 Some(&mut chunk_cb),
2581 Some(&offsets),
2582 )?;
2583 offsets = next_offsets;
2584
2585 agg.total_records = out.stats.total_records;
2590 agg.exact_dups += out.stats.exact_dups;
2591 agg.fuzzy_dups += out.stats.fuzzy_dups;
2592 agg.hard_negs += out.stats.hard_negs;
2593 agg.uniques += out.stats.uniques;
2594 agg.masters += out.stats.masters;
2595
2596 dataset_chunks.push(out.output_files[0].clone());
2597 if !skip_ground_truth {
2598 gt_chunks.push(out.gt_file.clone());
2599 }
2600 if let Some(n) = out.nodes {
2601 node_chunks.push(n);
2602 }
2603 if let Some(e) = out.edges {
2604 edge_chunks.push(e);
2605 }
2606 }
2607
2608 let dataset_ext = if output_format == "parquet" {
2609 "parquet"
2610 } else {
2611 "ipc"
2612 };
2613 let final_dataset = format!("{output_dir}/{final_run_id}.{dataset_ext}");
2614 concat_dataset_files(output_format, &dataset_chunks, &final_dataset)?;
2615
2616 let final_gt = if skip_ground_truth {
2617 String::new()
2618 } else {
2619 let p = format!("{output_dir}/{final_run_id}_ground_truth.{dataset_ext}");
2620 concat_dataset_files(output_format, >_chunks, &p)?;
2621 p
2622 };
2623
2624 let graph_ext = if graph_format == "parquet" {
2625 "parquet"
2626 } else {
2627 "ipc"
2628 };
2629 let final_nodes = if !node_chunks.is_empty() {
2630 let p = format!("{output_dir}/{final_run_id}_nodes.{graph_ext}");
2631 concat_dataset_files(graph_format, &node_chunks, &p)?;
2632 Some(p)
2633 } else {
2634 None
2635 };
2636 let final_edges = if !edge_chunks.is_empty() {
2637 let p = format!("{output_dir}/{final_run_id}_edges.{graph_ext}");
2638 concat_dataset_files(graph_format, &edge_chunks, &p)?;
2639 Some(p)
2640 } else {
2641 None
2642 };
2643
2644 Ok(PipelineOutput {
2645 output_files: vec![final_dataset],
2646 gt_file: final_gt,
2647 stats: agg,
2648 nodes: final_nodes,
2649 edges: final_edges,
2650 })
2651}
2652
2653pub(crate) const HN_ID_DIGITS: usize = 9;
2658
2659fn plan_rows(plan: &EntityPlan) -> u64 {
2661 plan.n_base as u64 + plan.noise_types.iter().map(|n| n.count as u64).sum::<u64>()
2662}
2663
2664pub fn max_cluster_rows(config: &PipelineConfig) -> u64 {
2668 config
2669 .entity_plans
2670 .iter()
2671 .filter(|p| !p.pool_only)
2672 .map(plan_rows)
2673 .sum()
2674}
2675
2676pub fn check_capacity(config: &PipelineConfig) -> Result<(), String> {
2683 let entity_id_limit = 10u64.pow(crate::column_gen::ENTITY_ID_DIGITS as u32);
2684 for plan in config.entity_plans.iter().filter(|p| !p.pool_only) {
2685 if plan.n_base as u64 + 3 >= entity_id_limit {
2687 return Err(format!(
2688 "entity '{}' would get {} base rows, more than its {}-digit `_id` \
2689 values can number uniquely ({entity_id_limit}) — use a smaller --size",
2690 plan.name,
2691 plan.n_base,
2692 crate::column_gen::ENTITY_ID_DIGITS
2693 ));
2694 }
2695 }
2696 let hn_limit = 10u64.pow(HN_ID_DIGITS as u32);
2697 let n_hn: u64 = config.hard_neg_types.iter().map(|h| h.count as u64).sum();
2698 if n_hn >= hn_limit {
2699 return Err(format!(
2700 "this run would generate {n_hn} hard negatives, more than their {HN_ID_DIGITS}-digit \
2701 master_id can number uniquely ({hn_limit}) — use a smaller --size or --hard-neg-ratio"
2702 ));
2703 }
2704 if config.graph_enabled {
2705 let rows = max_cluster_rows(config);
2708 if rows > u32::MAX as u64 {
2709 return Err(format!(
2710 "--graph supports at most {} duplicate-cluster rows; this run could have up \
2711 to {rows} — use a smaller --size, or drop --graph",
2712 u32::MAX
2713 ));
2714 }
2715 }
2716 Ok(())
2717}
2718
2719const IPC_FILLED_STR: f64 = 10.8;
2726const IPC_NULL_STR: f64 = 5.4;
2727const IPC_FIXED_WIDTH: f64 = 8.0;
2728const IPC_DICT: f64 = 4.0;
2729const IPC_BOOL: f64 = 0.25;
2730const IPC_ROW_CONST: f64 = 66.5;
2731const PARQUET_RATIO: f64 = 0.15;
2734const GT_IPC_ROW: f64 = 56.0;
2736const GT_PARQUET_ROW: f64 = 3.5;
2737const EDGES_IPC_ROW: f64 = 90.0;
2740
2741pub fn estimate_output_bytes(config: &PipelineConfig, chunked: bool) -> u64 {
2747 let schema = build_full_schema(config, &RunMetadata::new());
2748 let fields = &schema.fields()[4..]; let mut rows_by_entity: HashMap<&str, f64> = config
2750 .entity_plans
2751 .iter()
2752 .filter(|p| !p.pool_only)
2753 .map(|p| (p.name.as_str(), plan_rows(p) as f64))
2754 .collect();
2755 for hn in &config.hard_neg_types {
2756 if let Some(r) = rows_by_entity.get_mut(hn.entity_type.as_str()) {
2757 *r += hn.count as f64;
2758 }
2759 }
2760
2761 let mut dataset_ipc = 0.0;
2762 let mut total_rows = 0.0;
2763 for plan in config.entity_plans.iter().filter(|p| !p.pool_only) {
2764 let rows = rows_by_entity[plan.name.as_str()];
2765 let own: std::collections::HashSet<String> =
2766 serde_json::from_str::<Vec<serde_json::Value>>(&plan.columns_json)
2767 .unwrap_or_default()
2768 .iter()
2769 .filter_map(|c| c["name"].as_str().map(str::to_string))
2770 .collect();
2771 let row_bytes: f64 = IPC_ROW_CONST
2772 + fields
2773 .iter()
2774 .map(|f| match f.data_type() {
2775 DataType::Utf8 if own.contains(f.name()) => IPC_FILLED_STR,
2776 DataType::Utf8 => IPC_NULL_STR,
2777 DataType::Dictionary(_, _) => IPC_DICT,
2778 DataType::Boolean => IPC_BOOL,
2779 _ => IPC_FIXED_WIDTH,
2780 })
2781 .sum::<f64>();
2782 dataset_ipc += rows * row_bytes;
2783 total_rows += rows;
2784 }
2785
2786 let parquet = config.output_format == "parquet";
2787 let mut bytes = if parquet {
2788 dataset_ipc * PARQUET_RATIO
2789 } else {
2790 dataset_ipc
2791 };
2792 if !config.skip_ground_truth {
2793 bytes += total_rows * if parquet { GT_PARQUET_ROW } else { GT_IPC_ROW };
2794 }
2795 if config.graph_enabled {
2796 let graph_ipc = dataset_ipc + total_rows * EDGES_IPC_ROW;
2797 bytes += graph_ipc;
2798 if config.graph_format == "parquet" {
2799 bytes += graph_ipc * PARQUET_RATIO;
2800 }
2801 }
2802 if chunked {
2803 bytes *= 2.0;
2804 }
2805 bytes as u64
2806}
2807
2808pub(crate) type RunMetadata = std::collections::BTreeMap<String, String>;
2816
2817pub(crate) fn arrow_metadata(metadata: &RunMetadata) -> HashMap<String, String> {
2820 metadata
2821 .iter()
2822 .map(|(k, v)| (k.clone(), v.clone()))
2823 .collect()
2824}
2825
2826pub(crate) fn parquet_kv(metadata: &RunMetadata) -> Vec<parquet::file::metadata::KeyValue> {
2828 metadata
2829 .iter()
2830 .map(|(k, v)| parquet::file::metadata::KeyValue {
2831 key: k.clone(),
2832 value: Some(v.clone()),
2833 })
2834 .collect()
2835}
2836
2837pub fn source_date_epoch() -> Result<Option<u64>, String> {
2845 match std::env::var("SOURCE_DATE_EPOCH") {
2846 Err(_) => Ok(None),
2847 Ok(s) => s.trim().parse::<u64>().map(Some).map_err(|_| {
2848 format!("SOURCE_DATE_EPOCH must be a Unix timestamp (non-negative integer), got {s:?}")
2849 }),
2850 }
2851}
2852
2853fn build_metadata_map(config: &PipelineConfig) -> RunMetadata {
2854 let ts = match source_date_epoch() {
2855 Ok(Some(epoch)) => epoch.to_string(),
2856 _ => std::time::SystemTime::now()
2857 .duration_since(std::time::UNIX_EPOCH)
2858 .map(|d| d.as_secs().to_string())
2859 .unwrap_or_else(|_| "unknown".into()),
2860 };
2861 RunMetadata::from([
2862 (
2863 "dupehell.generator".into(),
2864 format!("DupeHell v{}", env!("CARGO_PKG_VERSION")),
2865 ),
2866 (
2867 "dupehell.provenance".into(),
2868 "dupehell-synthetic-data".into(),
2869 ),
2870 ("dupehell.license".into(), "MIT".into()),
2871 (
2872 "dupehell.purpose".into(),
2873 "Educational Use Only — Record Linkage Benchmarking".into(),
2874 ),
2875 (
2876 "dupehell.url".into(),
2877 "https://github.com/vntoinekaio/DupeHell".into(),
2878 ),
2879 ("dupehell.domain".into(), config.domain.clone()),
2880 ("dupehell.size".into(), config.size.to_string()),
2881 ("dupehell.seed".into(), config.seed.to_string()),
2882 ("dupehell.difficulty".into(), config.difficulty.clone()),
2883 ("dupehell.run_id".into(), config.run_id.clone()),
2884 ("dupehell.timestamp".into(), ts),
2885 (
2886 "dupehell.structural_key_columns".into(),
2887 structural_key_columns_note(config),
2888 ),
2889 ])
2890}
2891
2892fn structural_key_columns_note(config: &PipelineConfig) -> String {
2897 let cols: Vec<&str> = config
2898 .entity_plans
2899 .iter()
2900 .filter_map(|p| p.identifier_col.as_deref())
2901 .collect();
2902 format!(
2903 "{} — structural keys for joining child tables, not ER match attributes; use record_id as the row identifier instead",
2904 cols.join(", ")
2905 )
2906}
2907
2908pub(crate) fn low_cardinality_dict_type() -> DataType {
2916 DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
2917}
2918
2919pub(crate) struct DictValues {
2936 values: ArrayRef,
2937 index: HashMap<String, i32>,
2938}
2939
2940impl DictValues {
2941 pub(crate) fn new<I, S>(values: I) -> Self
2942 where
2943 I: IntoIterator<Item = S>,
2944 S: Into<String>,
2945 {
2946 let owned: Vec<String> = values.into_iter().map(Into::into).collect();
2947 let index: HashMap<String, i32> = owned
2948 .iter()
2949 .enumerate()
2950 .map(|(i, v)| (v.clone(), i as i32))
2951 .collect();
2952 Self {
2953 values: Arc::new(StringArray::from(owned)),
2954 index,
2955 }
2956 }
2957
2958 fn idx(&self, value: &str) -> i32 {
2959 *self.index.get(value).unwrap_or_else(|| {
2960 panic!("DictValues: value {value:?} was not in the pre-built dictionary")
2961 })
2962 }
2963
2964 fn build(&self, keys: arrow::array::Int32Array) -> ArrayRef {
2965 Arc::new(
2966 arrow::array::DictionaryArray::<arrow::datatypes::Int32Type>::try_new(
2967 keys,
2968 self.values.clone(),
2969 )
2970 .expect("DictValues::build: key array out of range for this dictionary"),
2971 )
2972 }
2973
2974 pub(crate) fn const_array(&self, value: &str, n: usize) -> ArrayRef {
2976 self.build(arrow::array::Int32Array::from(vec![self.idx(value); n]))
2977 }
2978
2979 pub(crate) fn key(&self, value: &str) -> i32 {
2984 self.idx(value)
2985 }
2986
2987 pub(crate) fn finish_keys(&self, keys: arrow::array::Int32Array) -> ArrayRef {
2988 self.build(keys)
2989 }
2990
2991 pub(crate) fn null_array(&self, n: usize) -> ArrayRef {
3001 self.build(arrow::array::Int32Array::new_null(n))
3002 }
3003}
3004
3005fn subtype_dict(config: &PipelineConfig) -> DictValues {
3015 let mut values: Vec<String> = vec!["complete".to_string(), "spanning_tree".to_string()];
3016 for plan in &config.entity_plans {
3017 for remap in &plan.fk_remaps {
3018 values.push(remap.source_col.clone());
3019 }
3020 }
3021 for hn in &config.hard_neg_types {
3022 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&hn.config_json)
3023 && let Some(pattern) = v.get("pattern").and_then(|p| p.as_str())
3024 {
3025 values.push(pattern.to_string());
3026 }
3027 }
3028 values.sort_unstable();
3029 values.dedup();
3030 DictValues::new(values)
3031}
3032
3033fn build_full_schema(config: &PipelineConfig, metadata: &RunMetadata) -> Schema {
3034 let mut field_map: Vec<(String, DataType, bool)> = vec![
3035 ("record_id".to_string(), DataType::Utf8, false),
3036 ("domain".to_string(), low_cardinality_dict_type(), false),
3037 (
3038 "entity_type".to_string(),
3039 low_cardinality_dict_type(),
3040 false,
3041 ),
3042 ("master_id".to_string(), DataType::Utf8, false),
3043 ];
3044 for plan in &config.entity_plans {
3045 if plan.pool_only {
3050 continue;
3051 }
3052 let cols: Vec<serde_json::Value> =
3053 serde_json::from_str(&plan.columns_json).unwrap_or_default();
3054 for col in &cols {
3055 let name = col["name"].as_str().unwrap_or("").to_string();
3056 if name.is_empty() || field_map.iter().any(|(n, _, _)| n == &name) {
3057 continue;
3058 }
3059 let is_dict = crate::fast_template::resolve_dict_encoded_column(&name).is_some();
3068 let col_type = if is_dict {
3069 low_cardinality_dict_type()
3070 } else {
3071 col_type_from_request(col)
3072 };
3073 field_map.push((name, col_type, true));
3074 }
3075 }
3076 let fields: Vec<Field> = field_map
3077 .into_iter()
3078 .map(|(n, dt, nullable)| Field::new(&n, dt, nullable))
3079 .collect();
3080 Schema::new(fields).with_metadata(arrow_metadata(metadata))
3081}
3082
3083fn col_type_from_request(v: &serde_json::Value) -> DataType {
3085 match v["type"].as_str() {
3086 Some("int") => DataType::Int64,
3087 Some("float") => DataType::Float64,
3088 Some("boolean") => DataType::Boolean,
3089 Some("date") | Some("datetime") => DataType::Utf8,
3090 _ => DataType::Utf8,
3091 }
3092}
3093
3094#[allow(clippy::too_many_arguments)]
3097pub(crate) fn add_metadata_and_align(
3098 rb: &RecordBatch,
3099 domain: &str,
3100 entity_type: &str,
3101 domain_dict: &DictValues,
3102 entity_type_dict: &DictValues,
3103 record_ids: &ArrayRef,
3104 master_ids: &ArrayRef,
3105 full_arc: &Arc<Schema>,
3106 col_lookup: &[Option<usize>],
3107 null_cache: &mut FxHashMap<(DataType, usize), ArrayRef>,
3108 const_arr_cache: &mut FxHashMap<(String, usize), ArrayRef>,
3109) -> Result<RecordBatch, String> {
3110 let n = rb.num_rows();
3111 let domain_arr = const_arr_cache
3121 .entry((domain.to_string(), n))
3122 .or_insert_with(|| domain_dict.const_array(domain, n))
3123 .clone();
3124 let et_arr = const_arr_cache
3125 .entry((entity_type.to_string(), n))
3126 .or_insert_with(|| entity_type_dict.const_array(entity_type, n))
3127 .clone();
3128 let rid_arr = record_ids.clone();
3129 let mid_arr = master_ids.clone();
3130
3131 let mut all_arrays: Vec<ArrayRef> = Vec::with_capacity(4 + col_lookup.len());
3132 all_arrays.push(rid_arr);
3133 all_arrays.push(domain_arr);
3134 all_arrays.push(et_arr);
3135 all_arrays.push(mid_arr);
3136
3137 for (i, maybe_idx) in col_lookup.iter().enumerate() {
3140 match maybe_idx {
3141 Some(idx) => all_arrays.push(rb.column(*idx).clone()),
3142 None => {
3143 let field = full_arc.field(i + 4);
3144 if let Some(arr) = crate::fast_template::dict_encoded_null_array(field.name(), n) {
3154 all_arrays.push(arr);
3155 continue;
3156 }
3157 let dt = field.data_type();
3158 let arr = null_cache
3159 .entry((dt.clone(), n))
3160 .or_insert_with(|| arrow::array::new_null_array(dt, n));
3161 all_arrays.push(Arc::clone(arr));
3162 }
3163 }
3164 }
3165
3166 RecordBatch::try_new(Arc::clone(full_arc), all_arrays)
3168 .map_err(|e| format!("add_metadata_and_align RecordBatch: {e}"))
3169}
3170
3171fn pick_rows(rb: &RecordBatch, indices: &UInt64Array) -> Result<RecordBatch, String> {
3172 use arrow::compute::take;
3173 let new_columns: Vec<ArrayRef> = (0..rb.num_columns())
3174 .map(|i| take(rb.column(i), indices, None))
3175 .collect::<Result<Vec<_>, _>>()
3176 .map_err(|e| format!("take error: {e}"))?;
3177
3178 RecordBatch::try_new(rb.schema(), new_columns).map_err(|e| format!("RecordBatch: {e}"))
3179}
3180
3181#[cfg(test)]
3182mod tests {
3183 use super::*;
3184 use crate::schema::{build_pipeline_config, load_schema};
3185
3186 #[test]
3214 #[ignore = "diagnostic scan, not a pass/fail regression test -- run explicitly with --ignored --nocapture"]
3215 fn diag_noise_coverage_scan() {
3216 const HELL_NOISE_TYPES: &[&str] = &[
3217 "typo_aggressive",
3218 "visual",
3219 "unicode_pollution",
3220 "names",
3221 "dates",
3222 "identifiers",
3223 "addresses",
3224 "companies",
3225 "extra",
3226 ];
3227
3228 let mut zero_coverage: Vec<(String, String, usize)> = Vec::new();
3229 let mut total_entities = 0usize;
3230 let mut schema_files: Vec<_> = std::fs::read_dir("schemas")
3231 .expect("read schemas/ dir")
3232 .filter_map(|e| e.ok())
3233 .map(|e| e.path())
3234 .filter(|p| p.extension().is_some_and(|e| e == "json"))
3235 .collect();
3236 schema_files.sort();
3237
3238 for path in &schema_files {
3239 let domain = path.file_stem().unwrap().to_string_lossy().to_string();
3240 let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
3241 let json: serde_json::Value =
3242 serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path:?}: {e}"));
3243 let Some(entities) = json.get("entities").and_then(|v| v.as_array()) else {
3244 continue;
3245 };
3246 for entity in entities {
3247 total_entities += 1;
3248 let ename = entity
3249 .get("name")
3250 .and_then(|v| v.as_str())
3251 .unwrap_or("?")
3252 .to_string();
3253 let fk_exclude: std::collections::HashSet<&str> = entity
3254 .get("fk_remaps")
3255 .and_then(|v| v.as_array())
3256 .map(|arr| {
3257 arr.iter()
3258 .filter_map(|r| r.get("source_col").and_then(|v| v.as_str()))
3259 .collect()
3260 })
3261 .unwrap_or_default();
3262 let cols = entity
3263 .get("columns")
3264 .and_then(|v| v.as_array())
3265 .cloned()
3266 .unwrap_or_default();
3267
3268 let mut n_matched_categories = 0usize;
3269 for &ntype in HELL_NOISE_TYPES {
3270 let has_match = cols.iter().any(|c| {
3271 let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
3272 let ctype = c.get("type").and_then(|v| v.as_str()).unwrap_or("string");
3273 let is_utf8 = !matches!(ctype, "int" | "float" | "boolean");
3274 is_utf8
3275 && !fk_exclude.contains(name)
3276 && noise_type_targets_column(ntype, name)
3277 });
3278 if has_match {
3279 n_matched_categories += 1;
3280 }
3281 }
3282 if n_matched_categories == 0 {
3283 zero_coverage.push((domain.clone(), ename, cols.len()));
3284 }
3285 }
3286 }
3287
3288 eprintln!(
3289 "\n[noise_coverage] {total_entities} entities scanned across {} domains",
3290 schema_files.len()
3291 );
3292 eprintln!(
3293 "[noise_coverage] {} entities with ZERO matching hell noise category:",
3294 zero_coverage.len()
3295 );
3296 for (domain, entity, ncols) in &zero_coverage {
3297 eprintln!("[noise_coverage] {domain}::{entity} ({ncols} columns)");
3298 }
3299 }
3300
3301 #[test]
3308 fn test_no_person_company_name_cross_contamination() {
3309 for col in [
3310 "given_name",
3311 "family_name",
3312 "middle_name",
3313 "first_name",
3314 "last_name",
3315 ] {
3316 assert!(
3317 noise_type_targets_column("names", col),
3318 "{col} should still match 'names'"
3319 );
3320 assert!(
3321 !noise_type_targets_column("companies", col),
3322 "{col} should NOT match 'companies' (person name, not a company name)"
3323 );
3324 }
3325 for col in ["company_name", "legal_name", "trading_name"] {
3326 assert!(
3327 noise_type_targets_column("companies", col),
3328 "{col} should still match 'companies'"
3329 );
3330 assert!(
3331 !noise_type_targets_column("names", col),
3332 "{col} should NOT match 'names' (company name, not a person name)"
3333 );
3334 }
3335 assert!(
3336 !noise_type_targets_column("addresses", "email_address"),
3337 "email_address should NOT match 'addresses' (it's an email, not a postal address)"
3338 );
3339 assert!(
3340 noise_type_targets_column("identifiers", "email_address"),
3341 "email_address should still match 'identifiers' (corrupt_email)"
3342 );
3343 assert!(
3344 noise_type_targets_column("addresses", "residential_address_line1"),
3345 "a real postal address column should still match 'addresses'"
3346 );
3347 }
3348
3349 fn manifest_dir() -> std::path::PathBuf {
3350 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
3351 }
3352
3353 fn scratch_dir(name: &str) -> std::path::PathBuf {
3356 let dir = manifest_dir()
3357 .join("target")
3358 .join("pipeline_test_tmp")
3359 .join(format!("{name}_{}", std::process::id()));
3360 let _ = std::fs::remove_dir_all(&dir);
3361 std::fs::create_dir_all(&dir).expect("create scratch dir");
3362 dir
3363 }
3364
3365 fn run_kyc(
3366 output_dir: &std::path::Path,
3367 output_format: &str,
3368 graph_enabled: bool,
3369 ) -> PipelineOutput {
3370 let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3371 let mut ctx = Context::new(
3372 "kyc",
3373 "en",
3374 &manifest_dir().join("assets/pools").to_string_lossy(),
3375 )
3376 .expect("load context");
3377 let config = build_pipeline_config(
3378 "kyc",
3379 200,
3380 42,
3381 "medium",
3382 0.1,
3383 0.3,
3384 &schema,
3385 "pipeline_test_run",
3386 output_format,
3387 graph_enabled,
3388 "parquet",
3389 None,
3390 )
3391 .expect("build config");
3392 ctx.enable_watermark(&config.domain, config.size, config.seed);
3393 run_pipeline(&ctx, &config, &output_dir.to_string_lossy()).expect("run_pipeline")
3394 }
3395
3396 #[test]
3397 fn test_run_pipeline_produces_dataset_and_gt() {
3398 let dir = scratch_dir("basic");
3399 let output = run_kyc(&dir, "parquet", false);
3400
3401 assert_eq!(output.output_files.len(), 1);
3402 for f in &output.output_files {
3403 assert!(std::path::Path::new(f).exists(), "missing dataset file {f}");
3404 }
3405 assert!(std::path::Path::new(&output.gt_file).exists());
3406 assert!(output.nodes.is_none());
3407 assert!(output.edges.is_none());
3408
3409 let s = &output.stats;
3414 assert!(s.total_records >= s.exact_dups + s.fuzzy_dups + s.hard_negs + s.uniques);
3415 assert!(s.total_records > 0);
3416 assert!(s.masters > 0);
3417
3418 let _ = std::fs::remove_dir_all(&dir);
3419 }
3420
3421 #[test]
3422 fn test_run_pipeline_ipc_format() {
3423 let dir = scratch_dir("ipc");
3424 let output = run_kyc(&dir, "ipc", false);
3425 assert!(output.output_files[0].ends_with(".ipc"));
3426 assert!(std::path::Path::new(&output.output_files[0]).exists());
3427 let _ = std::fs::remove_dir_all(&dir);
3428 }
3429
3430 #[test]
3431 fn test_run_pipeline_graph_output() {
3432 let dir = scratch_dir("graph");
3433 let output = run_kyc(&dir, "parquet", true);
3434 let nodes = output.nodes.expect("nodes path");
3435 let edges = output.edges.expect("edges path");
3436 assert!(std::path::Path::new(&nodes).exists());
3437 assert!(std::path::Path::new(&edges).exists());
3438 let _ = std::fs::remove_dir_all(&dir);
3439 }
3440
3441 #[test]
3442 fn test_fk_edge_weight_from_schema_and_default() {
3443 let dir = scratch_dir("fk_weight");
3450 let schema =
3451 load_schema("aviation", &manifest_dir().join("schemas")).expect("load aviation.json");
3452 let mut ctx = Context::new(
3453 "aviation",
3454 "en",
3455 &manifest_dir().join("assets/pools").to_string_lossy(),
3456 )
3457 .expect("load context");
3458 let config = build_pipeline_config(
3459 "aviation",
3460 300,
3461 42,
3462 "medium",
3463 0.1,
3464 0.3,
3465 &schema,
3466 "pipeline_test_fk_weight",
3467 "ipc",
3468 true,
3469 "ipc",
3470 None,
3471 )
3472 .expect("build config");
3473 ctx.enable_watermark(&config.domain, config.size, config.seed);
3474 let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3475
3476 let edges_path = output.edges.expect("edges path");
3477 let file = std::fs::File::open(&edges_path).expect("open edges file");
3478 let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
3479
3480 let mut subtypes_seen: std::collections::HashMap<String, f64> =
3481 std::collections::HashMap::new();
3482 for batch in reader {
3483 let batch = batch.expect("read edges batch");
3484 let edge_type_col = arrow::compute::cast(
3488 batch.column_by_name("edge_type").expect("edge_type col"),
3489 &DataType::Utf8,
3490 )
3491 .expect("cast edge_type to Utf8");
3492 let edge_type = edge_type_col.as_string::<i32>();
3493 let subtype_col = arrow::compute::cast(
3494 batch.column_by_name("subtype").expect("subtype col"),
3495 &DataType::Utf8,
3496 )
3497 .expect("cast subtype to Utf8");
3498 let subtype = subtype_col.as_string::<i32>();
3499 let weight = batch
3500 .column_by_name("weight")
3501 .expect("weight col")
3502 .as_any()
3503 .downcast_ref::<arrow::array::Float64Array>()
3504 .expect("weight as f64");
3505 for i in 0..batch.num_rows() {
3506 if edge_type.value(i) == "fk" {
3507 subtypes_seen.insert(subtype.value(i).to_string(), weight.value(i));
3508 }
3509 }
3510 }
3511
3512 assert_eq!(
3513 subtypes_seen.get("aircraft_id"),
3514 Some(&0.5),
3515 "flight->aircraft fk_weight from schema not propagated to edge weight"
3516 );
3517 assert_eq!(
3518 subtypes_seen.get("airline_id"),
3519 Some(&1.0),
3520 "flight->airline fk_weight (schema default) not propagated to edge weight"
3521 );
3522
3523 let _ = std::fs::remove_dir_all(&dir);
3524 }
3525
3526 #[test]
3533 fn test_run_pipeline_dup_cluster_edges_well_formed() {
3534 let dir = scratch_dir("dup_cluster_edges");
3535 let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3540 let mut ctx = Context::new(
3541 "kyc",
3542 "en",
3543 &manifest_dir().join("assets/pools").to_string_lossy(),
3544 )
3545 .expect("load context");
3546 let config = build_pipeline_config(
3547 "kyc",
3548 200,
3549 42,
3550 "medium",
3551 0.1,
3552 0.3,
3553 &schema,
3554 "pipeline_test_dup_cluster_edges",
3555 "ipc",
3556 true,
3557 "ipc",
3558 None,
3559 )
3560 .expect("build config");
3561 ctx.enable_watermark(&config.domain, config.size, config.seed);
3562 let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3563
3564 let edges_path = output.edges.expect("edges path");
3565 let file = std::fs::File::open(&edges_path).expect("open edges file");
3566 let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
3567
3568 let mut n_exact = 0usize;
3569 let mut n_fuzzy = 0usize;
3570 for batch in reader {
3571 let batch = batch.expect("read edges batch");
3572 let src = batch
3573 .column_by_name("source_node_id")
3574 .expect("source_node_id col")
3575 .as_any()
3576 .downcast_ref::<arrow::array::StringArray>()
3577 .expect("source_node_id as string");
3578 let tgt = batch
3579 .column_by_name("target_node_id")
3580 .expect("target_node_id col")
3581 .as_any()
3582 .downcast_ref::<arrow::array::StringArray>()
3583 .expect("target_node_id as string");
3584 let edge_type_col = arrow::compute::cast(
3587 batch.column_by_name("edge_type").expect("edge_type col"),
3588 &DataType::Utf8,
3589 )
3590 .expect("cast edge_type to Utf8");
3591 let edge_type = edge_type_col.as_string::<i32>();
3592 let weight = batch
3593 .column_by_name("weight")
3594 .expect("weight col")
3595 .as_any()
3596 .downcast_ref::<arrow::array::Float64Array>()
3597 .expect("weight as f64");
3598
3599 for i in 0..batch.num_rows() {
3600 match edge_type.value(i) {
3601 "exact_dup" => n_exact += 1,
3602 "fuzzy_dup" => n_fuzzy += 1,
3603 _ => continue,
3604 }
3605 assert!(
3610 parse_record_idx(src.value(i)).is_some(),
3611 "malformed source_node_id {:?} on a dup-cluster edge",
3612 src.value(i)
3613 );
3614 assert!(
3615 parse_record_idx(tgt.value(i)).is_some(),
3616 "malformed target_node_id {:?} on a dup-cluster edge",
3617 tgt.value(i)
3618 );
3619 assert!((weight.value(i) - 1.0).abs() < 1e-9);
3620 }
3621 }
3622
3623 assert!(n_exact + n_fuzzy > 0, "no dup-cluster edges emitted");
3628 assert!(output.stats.exact_dups + output.stats.fuzzy_dups > 0);
3629
3630 let _ = std::fs::remove_dir_all(&dir);
3631 }
3632
3633 #[test]
3634 fn test_run_pipeline_deterministic_stats() {
3635 let dir_a = scratch_dir("det_a");
3636 let dir_b = scratch_dir("det_b");
3637 let a = run_kyc(&dir_a, "parquet", false);
3638 let b = run_kyc(&dir_b, "parquet", false);
3639 assert_eq!(a.stats.total_records, b.stats.total_records);
3640 assert_eq!(a.stats.exact_dups, b.stats.exact_dups);
3641 assert_eq!(a.stats.fuzzy_dups, b.stats.fuzzy_dups);
3642 assert_eq!(a.stats.hard_negs, b.stats.hard_negs);
3643 assert_eq!(a.stats.uniques, b.stats.uniques);
3644 assert_eq!(a.stats.masters, b.stats.masters);
3645 let _ = std::fs::remove_dir_all(&dir_a);
3646 let _ = std::fs::remove_dir_all(&dir_b);
3647 }
3648
3649 #[test]
3650 fn test_distribute_by_weight_sums_to_total_and_is_proportional() {
3651 let weights = [500_000usize, 216_666];
3659 let counts = distribute_by_weight(283_334, &weights);
3660 assert_eq!(counts.len(), 2);
3661 assert_eq!(counts.iter().sum::<usize>(), 283_334);
3662 assert!(counts[0] > 0, "first batch got zero duplicates");
3666 assert!(counts[1] > 0, "last batch got zero duplicates");
3667 assert!(counts[0] > counts[1]);
3668
3669 assert_eq!(distribute_by_weight(0, &weights), vec![0, 0]);
3671 assert_eq!(distribute_by_weight(100, &[0, 0]), vec![0, 0]);
3672 }
3673
3674 #[test]
3675 fn test_entity_prefix_unique_per_index() {
3676 let a = entity_prefix(0);
3677 let b = entity_prefix(1);
3678 assert_ne!(a, b);
3679 assert_eq!(a, "E00000");
3680 assert_eq!(entity_prefix(42), "E00042");
3681 }
3682
3683 #[test]
3684 fn test_run_pipeline_hell_multi_pass_touches_multiple_columns() {
3685 let dir = scratch_dir("hell_multipass");
3694 let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3695 let mut ctx = Context::new(
3696 "kyc",
3697 "en",
3698 &manifest_dir().join("assets/pools").to_string_lossy(),
3699 )
3700 .expect("load context");
3701 let config = build_pipeline_config(
3702 "kyc",
3703 5000,
3704 7,
3705 "hell",
3706 0.1,
3707 0.3,
3708 &schema,
3709 "pipeline_test_hell_multipass",
3710 "ipc",
3711 false,
3712 "ipc",
3713 None,
3714 )
3715 .expect("build config");
3716 ctx.enable_watermark(&config.domain, config.size, config.seed);
3717 let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3718
3719 let read_all = |path: &str| -> RecordBatch {
3720 let file = std::fs::File::open(path).unwrap();
3721 let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
3722 let batches: Vec<RecordBatch> = reader.map(|b| b.unwrap()).collect();
3723 let schema = batches[0].schema();
3724 let refs: Vec<Vec<&dyn Array>> = (0..schema.fields().len())
3725 .map(|i| batches.iter().map(|b| b.column(i).as_ref()).collect())
3726 .collect();
3727 let cols: Vec<ArrayRef> = refs
3728 .iter()
3729 .map(|r| arrow::compute::concat(r).unwrap())
3730 .collect();
3731 RecordBatch::try_new(schema, cols).unwrap()
3732 };
3733 let ds = read_all(&output.output_files[0]);
3734 let gt = read_all(&output.gt_file);
3735
3736 let ds_rid = ds.column_by_name("record_id").unwrap().as_string::<i32>();
3737 let ds_mid = ds.column_by_name("master_id").unwrap().as_string::<i32>();
3738 let gt_rid = gt.column_by_name("record_id").unwrap().as_string::<i32>();
3739 let gt_mt_col =
3742 arrow::compute::cast(gt.column_by_name("match_type").unwrap(), &DataType::Utf8)
3743 .unwrap();
3744 let gt_mt = gt_mt_col.as_string::<i32>();
3745
3746 let mut match_type_by_rid: HashMap<&str, &str> = HashMap::new();
3747 for i in 0..gt.num_rows() {
3748 match_type_by_rid.insert(gt_rid.value(i), gt_mt.value(i));
3749 }
3750
3751 let mut master_row_by_mid: HashMap<&str, usize> = HashMap::new();
3758 for i in 0..ds.num_rows() {
3759 master_row_by_mid.entry(ds_mid.value(i)).or_insert(i);
3760 }
3761
3762 let compare_cols: Vec<usize> = (0..ds.num_columns())
3763 .filter(|&i| {
3764 let field = ds.schema().field(i).clone();
3765 matches!(field.data_type(), DataType::Utf8)
3766 && !matches!(
3767 field.name().as_str(),
3768 "record_id" | "domain" | "entity_type" | "master_id"
3769 )
3770 && !field.name().ends_with("_id")
3771 })
3772 .collect();
3773
3774 let mut max_changed_cols = 0usize;
3775 for i in 0..ds.num_rows() {
3776 if match_type_by_rid.get(ds_rid.value(i)) != Some(&"fuzzy_dup") {
3777 continue;
3778 }
3779 let Some(&master_i) = master_row_by_mid.get(ds_mid.value(i)) else {
3780 continue;
3781 };
3782 let mut changed = 0usize;
3783 for &col_i in &compare_cols {
3784 let col = ds.column(col_i).as_string::<i32>();
3785 let a = (!col.is_null(i)).then(|| col.value(i));
3786 let b = (!col.is_null(master_i)).then(|| col.value(master_i));
3787 if a != b {
3788 changed += 1;
3789 }
3790 }
3791 max_changed_cols = max_changed_cols.max(changed);
3792 }
3793
3794 assert!(
3795 max_changed_cols >= 2,
3796 "expected at least one fuzzy_dup row to differ from its master on \
3797 2+ columns (evidence of multiple noise passes), got max {max_changed_cols}"
3798 );
3799
3800 let _ = std::fs::remove_dir_all(&dir);
3801 }
3802
3803 #[test]
3804 fn test_record_id_and_pad_string_roundtrip() {
3805 let rid = record_id_string(7);
3806 assert_eq!(rid, "R-0000000000007");
3807 let pad = pad_string(7);
3808 assert_eq!(pad, "0000000000007");
3809 let strs = record_id_strs(0..3);
3810 assert_eq!(strs.len(), 3);
3811 assert_eq!(strs[0], record_id_string(0));
3812 assert_eq!(strs[2], record_id_string(2));
3813 }
3814
3815 #[test]
3816 fn test_parse_record_idx_roundtrip() {
3817 for i in [0usize, 7, 1_234_567, 300_000_000] {
3818 assert_eq!(parse_record_idx(&record_id_string(i)), Some(i as u64));
3819 }
3820 assert_eq!(parse_record_idx("garbage"), None);
3821 assert_eq!(parse_record_idx("R-1"), None);
3822 assert_eq!(parse_record_idx("X-0000000000007"), None);
3823 }
3824
3825 #[test]
3826 fn test_pack_master_key_roundtrip_and_ordering() {
3827 let mid = |e: usize, i: usize| format!("{}-{}", entity_prefix(e), pad_string(i));
3828 assert!(pack_master_key(&mid(0, 0)).is_some());
3829 assert!(pack_master_key(&mid(0, 1)).unwrap() > pack_master_key(&mid(0, 0)).unwrap());
3831 assert!(
3834 pack_master_key(&mid(1, 0)).unwrap()
3835 > pack_master_key(&mid(0, 9_999_999_999_999)).unwrap()
3836 );
3837 assert_eq!(pack_master_key("garbage"), None);
3838 assert_eq!(pack_master_key("HN-0000000000007"), None);
3839 assert_eq!(pack_master_key("CANARY-000-0-abc"), None);
3840 }
3841
3842 #[test]
3852 #[ignore]
3853 fn bench_zip_cost_vs_column_count() {
3854 use arrow::array::StringArray;
3855 use arrow::datatypes::{DataType, Field, Schema};
3856
3857 fn make_batch(n_rows: usize, n_cols: usize) -> RecordBatch {
3858 let fields: Vec<Field> = (0..n_cols)
3859 .map(|i| Field::new(format!("col{i}"), DataType::Utf8, false))
3860 .collect();
3861 let cols: Vec<ArrayRef> = (0..n_cols)
3862 .map(|_| {
3863 Arc::new(StringArray::from(
3864 (0..n_rows)
3865 .map(|_| "abcdefghijklmnop".to_string())
3866 .collect::<Vec<_>>(),
3867 )) as ArrayRef
3868 })
3869 .collect();
3870 RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
3871 }
3872
3873 const N_ROWS: usize = 100_000;
3874 const N_REPS: usize = 5;
3875 const EXTRA_PASSES: usize = 2;
3879
3880 eprintln!("\n[bench_zip] N_ROWS={N_ROWS} N_REPS={N_REPS} EXTRA_PASSES={EXTRA_PASSES}");
3881 eprintln!("[bench_zip] C=total columns, T=1 noised column (fixed) -- hypothesis: cost ∝ C");
3882
3883 for &n_cols in &[8usize, 15, 23] {
3884 let plan_cols = vec!["col0".to_string()];
3885 let all_types: Vec<&str> = vec!["typo"];
3886 let mut total = std::time::Duration::ZERO;
3887
3888 for rep in 0..N_REPS {
3889 let batch = make_batch(N_ROWS, n_cols);
3890 let mut rng = Rng::new(42 + rep as u64);
3891 let t0 = std::time::Instant::now();
3892
3893 let (noisy, mut unchanged, _) =
3894 apply_noise_with_retry(&batch, "typo", &plan_cols, &mut rng, &[]).unwrap();
3895 let mut current = noisy;
3896 for _ in 0..EXTRA_PASSES {
3897 current = apply_extra_pass_per_row(
3898 ¤t,
3899 &mut unchanged,
3900 &all_types,
3901 &mut rng,
3902 &[],
3903 )
3904 .unwrap();
3905 }
3906 std::hint::black_box(¤t);
3907 total += t0.elapsed();
3908 }
3909
3910 let avg = total / N_REPS as u32;
3911 eprintln!(
3912 "[bench_zip] C={n_cols:2} avg={:>8.3}ms per_column={:>7.4}ms per_row_per_column={:>10.6}us",
3913 avg.as_secs_f64() * 1000.0,
3914 avg.as_secs_f64() * 1000.0 / n_cols as f64,
3915 avg.as_secs_f64() * 1_000_000.0 / (N_ROWS * n_cols) as f64,
3916 );
3917 }
3918 }
3919
3920 #[test]
3937 #[ignore]
3938 fn bench_residual_kyc_vs_nonprofit() {
3939 use arrow::array::StringArray;
3940 use arrow::datatypes::{DataType, Field, Schema};
3941
3942 fn make_batch(n_rows: usize, col_names: &[&str]) -> RecordBatch {
3943 let fields: Vec<Field> = col_names
3944 .iter()
3945 .map(|n| Field::new(*n, DataType::Utf8, true))
3946 .collect();
3947 let cols: Vec<ArrayRef> = col_names
3948 .iter()
3949 .map(|_| {
3950 Arc::new(StringArray::from(
3951 (0..n_rows)
3952 .map(|_| "abcdefghijklmnop testword".to_string())
3953 .collect::<Vec<_>>(),
3954 )) as ArrayRef
3955 })
3956 .collect();
3957 RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
3958 }
3959
3960 const N_ROWS: usize = 100_000;
3961 const N_REPS: usize = 5;
3962 const HELL_TYPES: &[&str] = &[
3963 "typo_aggressive",
3964 "visual",
3965 "unicode_pollution",
3966 "names",
3967 "dates",
3968 "identifiers",
3969 "addresses",
3970 "companies",
3971 "extra",
3972 ];
3973 const HELL_PASSES: usize = 8; let kyc_cols: &[&str] = &[
3976 "party_id",
3977 "given_name",
3978 "family_name",
3979 "middle_name",
3980 "birth_date",
3981 "birth_place",
3982 "nationality",
3983 "email_address",
3984 "mobile_phone",
3985 "personal_administrative_number",
3986 "tax_id",
3987 "residential_address_line1",
3988 "residential_city",
3989 "residential_postal_code",
3990 "residential_state",
3991 "residential_country",
3992 "occupation",
3993 "risk_score",
3994 "document_type",
3995 "document_number",
3996 "source_system",
3997 ];
3998 let nonprofit_cols: &[&str] = &[
3999 "donation_id",
4000 "donor_id",
4001 "campaign_id",
4002 "donation_date",
4003 "payment_method",
4004 "source_system",
4005 ];
4006
4007 eprintln!("\n[bench_residual] N_ROWS={N_ROWS} N_REPS={N_REPS} HELL_PASSES={HELL_PASSES}");
4008
4009 {
4014 let batch = make_batch(N_ROWS, kyc_cols);
4015 eprintln!("[bench_residual] per-category breakdown on kyc.natural_person:");
4016 for &ttype in HELL_TYPES {
4017 let mut rng = Rng::new(42);
4018 let t0 = std::time::Instant::now();
4019 let (_, _, tcols) =
4020 apply_noise_with_retry(&batch, ttype, &[], &mut rng, &[]).unwrap();
4021 eprintln!(
4022 "[bench_residual] {ttype:20} {:>8.3}ms target_cols={:?}",
4023 t0.elapsed().as_secs_f64() * 1000.0,
4024 tcols,
4025 );
4026 }
4027 }
4028
4029 for (label, col_names) in [
4030 ("kyc.natural_person", kyc_cols),
4031 ("nonprofit.donation", nonprofit_cols),
4032 ] {
4033 let mut total = std::time::Duration::ZERO;
4034 let mut total_target_hits = 0usize;
4035
4036 for rep in 0..N_REPS {
4037 let batch = make_batch(N_ROWS, col_names);
4038 let mut rng = Rng::new(42 + rep as u64);
4039 let t0 = std::time::Instant::now();
4040
4041 let (noisy, mut unchanged, t0cols) =
4044 apply_noise_with_retry(&batch, "identifiers", &[], &mut rng, &[]).unwrap();
4045 total_target_hits += t0cols.len();
4046 let mut current = noisy;
4047 for _ in 1..HELL_PASSES {
4048 current = apply_extra_pass_per_row(
4049 ¤t,
4050 &mut unchanged,
4051 HELL_TYPES,
4052 &mut rng,
4053 &[],
4054 )
4055 .unwrap();
4056 }
4057 std::hint::black_box(¤t);
4058 total += t0.elapsed();
4059 }
4060
4061 let avg = total / N_REPS as u32;
4062 eprintln!(
4063 "[bench_residual] {label:20} C={:2} avg={:>8.3}ms avg_first_pass_target_cols={:.1}",
4064 col_names.len(),
4065 avg.as_secs_f64() * 1000.0,
4066 total_target_hits as f64 / N_REPS as f64,
4067 );
4068 }
4069 }
4070
4071 #[test]
4072 fn test_chunk_sizes_fold_small_remainder() {
4073 assert_eq!(chunk_sizes(1000, 500), vec![500, 500]);
4074 assert_eq!(chunk_sizes(1005, 500), vec![500, 505]);
4075 assert_eq!(chunk_sizes(1010, 500), vec![500, 500, 10]);
4076 assert_eq!(chunk_sizes(1234, 400), vec![400, 400, 400, 34]);
4077 for (total, cs) in [(1005, 500), (99, 10), (10_009, 1000), (25, 12)] {
4078 let sizes = chunk_sizes(total, cs);
4079 assert_eq!(sizes.iter().sum::<usize>(), total);
4080 assert!(sizes.iter().all(|&s| s >= MIN_RUN_SIZE), "{sizes:?}");
4081 }
4082 }
4083}