1use std::collections::HashMap;
8use std::sync::Arc;
9
10use arrow::array::{
11 Array, ArrayBuilder, ArrayRef, AsArray, StringArray, UInt64Array, UInt64Builder,
12};
13use arrow::datatypes::{DataType, Field, Schema};
14use arrow::record_batch::RecordBatch;
15use serde::Deserialize;
16
17use crate::context::Context;
18use crate::rng::Rng;
19
20#[derive(Debug, Deserialize)]
23pub struct PipelineConfig {
24 pub domain: String,
25 pub size: usize,
26 pub seed: u64,
27 pub difficulty: String,
28 pub output_format: String,
29 pub run_id: String,
30
31 pub entity_plans: Vec<EntityPlan>,
32 pub hard_neg_types: Vec<HnTypeConfig>,
33
34 pub hard_neg_ratio: f64,
35
36 pub noise_passes: usize,
41
42 pub graph_enabled: bool,
43 pub graph_format: String,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct EntityPlan {
48 pub name: String,
49 pub n_base: usize,
50 pub identifier_col: Option<String>,
51 pub columns_json: String,
52 pub noise_types: Vec<NoisePlanEntry>,
53 pub fk_remaps: Vec<FkRemapEntry>,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct NoisePlanEntry {
58 pub noise_type: String,
59 pub columns: Vec<String>,
60 pub count: usize,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct FkRemapEntry {
65 pub source_col: String,
66 pub target_entity: String,
67 #[serde(default = "default_fk_weight")]
71 pub fk_weight: f64,
72}
73
74fn default_fk_weight() -> f64 {
75 1.0
76}
77
78#[derive(Debug, Deserialize)]
79pub struct HnTypeConfig {
80 pub entity_type: String,
81 pub config_json: String,
82 pub count: usize,
83}
84
85#[derive(Debug)]
86pub struct PipelineOutput {
87 pub output_files: Vec<String>,
88 pub gt_file: String,
89 pub stats: PipelineStats,
90 pub nodes: Option<String>,
91 pub edges: Option<String>,
92}
93
94#[derive(Debug)]
95pub struct PipelineStats {
96 pub total_records: usize,
97 pub exact_dups: usize,
100 pub fuzzy_dups: usize,
103 pub hard_negs: usize,
104 pub uniques: usize,
105 pub masters: usize,
106}
107
108const RID_LEN: usize = 15; const PAD_LEN: usize = 13; #[inline]
123fn record_id_string(i: usize) -> String {
124 let mut buf = [0u8; RID_LEN];
125 buf[0] = b'R';
126 buf[1] = b'-';
127 let mut n = i;
128 for j in (2..RID_LEN).rev() {
129 buf[j] = b'0' + (n % 10) as u8;
130 n /= 10;
131 }
132 String::from_utf8(buf.to_vec()).expect("record_id_string: buffer is 'R', '-' and ASCII digits")
133}
134
135#[inline]
136fn pad_string(i: usize) -> String {
137 let mut buf = [0u8; PAD_LEN];
138 let mut n = i;
139 for j in (0..PAD_LEN).rev() {
140 buf[j] = b'0' + (n % 10) as u8;
141 n /= 10;
142 }
143 String::from_utf8(buf.to_vec()).expect("pad_string: buffer is always ASCII digits")
144}
145
146pub(crate) fn record_id_strs(range: std::ops::Range<usize>) -> Vec<String> {
147 range.map(record_id_string).collect()
148}
149
150fn distribute_by_weight(total: usize, weights: &[usize]) -> Vec<usize> {
158 let weight_sum: usize = weights.iter().sum();
159 if weight_sum == 0 || total == 0 {
160 return vec![0; weights.len()];
161 }
162 let raw: Vec<f64> = weights
163 .iter()
164 .map(|&w| total as f64 * w as f64 / weight_sum as f64)
165 .collect();
166 let mut counts: Vec<usize> = raw.iter().map(|&r| r as usize).collect();
167 let assigned: usize = counts.iter().sum();
168 let mut remainder = total - assigned;
169 let mut order: Vec<usize> = (0..counts.len()).collect();
170 order.sort_by(|&a, &b| {
171 let fa = raw[a] - counts[a] as f64;
172 let fb = raw[b] - counts[b] as f64;
173 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
174 });
175 for &i in &order {
176 if remainder == 0 {
177 break;
178 }
179 counts[i] += 1;
180 remainder -= 1;
181 }
182 counts
183}
184
185fn entity_prefix(entity_idx: usize) -> String {
194 format!("E{:05}", entity_idx)
195}
196
197type FkPoolMap = HashMap<String, RecordBatch>;
200
201struct HnPool {
202 batch: RecordBatch,
203 total_count: usize,
204 record_ids: Vec<String>,
207}
208
209const PERSON_NAME_WORDS: &[&str] = &[
216 "name",
217 "first_name",
225 "last_name",
226 "given",
227 "family",
228 "middle",
229 "operator",
230 "technician",
231 "inspector",
232 "claimant",
233 "consignee",
234 "investigator",
235 "collected_by",
236 "performed_by",
237 "assigned_to",
238];
239
240const COMPANY_NAME_WORDS: &[&str] = &[
246 "company",
247 "legal",
248 "trading",
249 "name",
250 "supplier",
251 "manufacturer",
252 "sponsor",
253 "institution",
254 "journal",
255 "funder",
256 "employer",
257 "law_firm",
258 "affiliation",
259 "reported_by",
260 "buyer",
261 "shipper",
262 "airline",
263];
264
265fn is_specifically_person_name(lower: &str) -> bool {
279 PERSON_NAME_WORDS
280 .iter()
281 .any(|&w| w != "name" && lower.contains(w))
282}
283
284fn is_specifically_company_name(lower: &str) -> bool {
287 COMPANY_NAME_WORDS
288 .iter()
289 .any(|&w| w != "name" && lower.contains(w))
290}
291
292pub(crate) fn noise_type_targets_column(noise_type: &str, col_name: &str) -> bool {
298 let lower = col_name.to_lowercase();
299 match noise_type {
300 "typo" | "typo_aggressive" | "typo_extreme" | "qwerty_azerty" | "visual" | "homoglyph"
301 | "unicode_pollution" | "ocr_errors" | "case_swap" | "char_dropout" | "language_mix"
302 | "blocking_fail" => {
303 if lower.contains("email") || lower.contains("ip_address") {
308 return false;
309 }
310 contains_any(&lower, &["address", "street", "city"])
315 || contains_any(&lower, PERSON_NAME_WORDS)
316 || contains_any(&lower, COMPANY_NAME_WORDS)
317 }
318 "names" | "nickname" | "initials" | "partial" | "name_compound" | "swap" | "full_swap" => {
319 contains_any(&lower, PERSON_NAME_WORDS) && !is_specifically_company_name(&lower)
325 }
326 "dates" | "date_error" | "date_chaotic" | "date_format_mix" | "age_impossible" => {
327 contains_any(&lower, &["date", "birth", "incorporation", "founding"])
328 }
329 "missing" | "missing_pattern" => {
330 contains_any(&lower, &["phone", "email", "mobile", "address", "street"])
331 }
332 "identifiers"
333 | "corrupt_email"
334 | "corrupt_phone"
335 | "corrupt_national_id"
336 | "corrupt_siren"
337 | "national_id_corrupt"
338 | "phone_corrupt"
339 | "email_corrupt"
340 | "siren_corrupt" => contains_any(
341 &lower,
342 &[
343 "email",
344 "phone",
345 "ssn",
346 "pan",
347 "passport",
348 "account",
349 "medicare",
350 "license",
351 "siren",
352 "national_id",
353 "registration",
360 ],
361 ),
362 "extra"
363 | "name_null"
364 | "dob_null"
365 | "blocking_fail_initial"
366 | "blocking_fail_partial"
367 | "fuzzy_match"
368 | "phonetic" => {
369 if lower.contains("email") || lower.contains("ip_address") {
375 return false;
376 }
377 contains_any(&lower, &["address", "street", "city", "note", "comment"])
378 || contains_any(&lower, PERSON_NAME_WORDS)
379 || contains_any(&lower, COMPANY_NAME_WORDS)
380 }
381 "companies" | "acronym" | "legal_form_drop" | "word_dropout" | "company_scramble" => {
382 contains_any(&lower, COMPANY_NAME_WORDS) && !is_specifically_person_name(&lower)
388 }
389 "addresses" | "address_scramble" | "postal_corrupt" => {
390 !lower.contains("ip_address")
406 && !lower.contains("email")
407 && contains_any(
408 &lower,
409 &["address", "street", "postal", "city", "state", "country"],
410 )
411 }
412 "exact" | "english_name" | "estonian_name" | "lithuanian_name" | "slovak_name"
413 | "serbian_name" | "norwegian_name" | "swedish_name" | "dutch_name" | "czech_name"
414 | "albanian_name" | "polish_name" | "romanian_name" | "hungarian_name" | "german_name"
415 | "italian_name" | "spanish_name" | "portuguese_name" | "combo_hard" | "combo_extreme"
416 | "combo_ultimate" | "french_address" => {
417 false
419 }
420 _ => true,
422 }
423}
424
425fn match_noise_columns(schema: &Schema, noise_type: &str, exclude_cols: &[String]) -> Vec<String> {
426 let mut matched: Vec<String> = Vec::new();
427 for field in schema.fields() {
428 if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
429 continue;
430 }
431 if exclude_cols.contains(field.name()) {
432 continue;
433 }
434 if noise_type_targets_column(noise_type, field.name()) {
435 matched.push(field.name().clone());
436 }
437 }
438 if matches!(noise_type, "swap" | "full_swap") && matched.len() >= 2 {
440 matched.truncate(2);
441 }
442 matched.sort();
443 matched.dedup();
444 matched
445}
446
447fn contains_any(s: &str, patterns: &[&str]) -> bool {
448 patterns.iter().any(|p| s.contains(p))
449}
450
451fn apply_noise_to_batch(
458 rb: &RecordBatch,
459 noise_type: &str,
460 plan_cols: &[String],
461 rng: &mut Rng,
462 exclude_cols: &[String],
463) -> Result<(RecordBatch, Vec<String>), String> {
464 let schema = rb.schema();
465 let n_cols = rb.num_columns();
466
467 let target_cols: Vec<String> = if plan_cols.is_empty() {
469 match_noise_columns(&schema, noise_type, exclude_cols)
470 } else {
471 plan_cols.to_vec()
472 };
473
474 let mut new_columns: Vec<Option<ArrayRef>> = (0..n_cols).map(|_| None).collect();
476
477 let get_col = |new_columns: &[Option<ArrayRef>], idx: usize, rb: &RecordBatch| -> ArrayRef {
478 new_columns[idx]
479 .clone()
480 .unwrap_or_else(|| rb.column(idx).clone())
481 };
482
483 match noise_type {
484 "blocking_fail" => {
485 for col_name in &target_cols {
486 let lower = col_name.to_lowercase();
487 if let Ok(idx) = schema.index_of(col_name) {
488 let col = get_col(&new_columns, idx, rb);
489 if lower.contains("first") || lower.contains("given") {
490 new_columns[idx] =
491 Some(crate::noise::extra::apply_blocking_initial(&*col, rng));
492 } else {
493 new_columns[idx] =
494 Some(crate::noise::extra::apply_blocking_partial(&*col, rng));
495 }
496 }
497 }
498 }
499 "swap" | "full_swap" => {
500 if target_cols.len() >= 2 {
501 let name_a = &target_cols[0];
502 let name_b = &target_cols[1];
503 if let (Ok(idx_a), Ok(idx_b)) = (schema.index_of(name_a), schema.index_of(name_b)) {
504 let a = get_col(&new_columns, idx_a, rb);
505 let b = get_col(&new_columns, idx_b, rb);
506 new_columns[idx_a] = Some(b);
507 new_columns[idx_b] = Some(a);
508 }
509 }
510 }
511 "missing_pattern" => {
512 let mut phone_idx: Option<usize> = None;
513 let mut email_idx: Option<usize> = None;
514 for col_name in &target_cols {
515 let lower = col_name.to_lowercase();
516 if let Ok(idx) = schema.index_of(col_name) {
517 if lower.contains("phone") || lower.contains("mobile") {
518 phone_idx = Some(idx);
519 } else if lower.contains("email") {
520 email_idx = Some(idx);
521 }
522 }
523 }
524 if let (Some(pi), Some(ei)) = (phone_idx, email_idx) {
525 let phone_arr = get_col(&new_columns, pi, rb);
526 let email_arr = get_col(&new_columns, ei, rb);
527 let phone_s = phone_arr.as_string::<i32>();
528 let email_s = email_arr.as_string::<i32>();
529 let n = phone_s.len();
530 let mut builder = arrow::array::StringBuilder::with_capacity(n, 32);
531 for i in 0..n {
532 let pv = if phone_s.is_null(i) {
533 ""
534 } else {
535 phone_s.value(i)
536 };
537 let ev = if email_s.is_null(i) {
538 ""
539 } else {
540 email_s.value(i)
541 };
542 if !ev.is_empty() && rng.next_usize(2) == 0 {
543 builder.append_value("");
544 } else {
545 builder.append_value(pv);
546 }
547 }
548 new_columns[pi] = Some(Arc::new(builder.finish()));
549 }
550 }
551 _ => {
552 for col_name in &target_cols {
553 if let Ok(col_idx) = schema.index_of(col_name) {
554 let col = get_col(&new_columns, col_idx, rb);
555 let result = crate::noise::apply_noise_to_column(&*col, noise_type, rng)
556 .map_err(|e| format!("noise '{noise_type}' on '{col_name}': {e}"))?;
557 new_columns[col_idx] = Some(result);
558 }
559 }
560 }
561 }
562
563 let final_columns: Vec<ArrayRef> = (0..n_cols)
565 .map(|i| {
566 new_columns[i]
567 .clone()
568 .unwrap_or_else(|| rb.column(i).clone())
569 })
570 .collect();
571
572 let batch =
573 RecordBatch::try_new(schema, final_columns).map_err(|e| format!("RecordBatch: {e}"))?;
574 Ok((batch, target_cols))
575}
576
577fn unchanged_row_mask(
588 orig: &RecordBatch,
589 noised: &RecordBatch,
590 target_cols: &[String],
591) -> Result<Vec<bool>, String> {
592 let n = orig.num_rows();
593 let mut changed = vec![false; n];
594 let schema = orig.schema();
595 for col_name in target_cols {
596 let Ok(idx) = schema.index_of(col_name) else {
597 continue;
598 };
599 let a = orig.column(idx).as_string::<i32>();
600 let b = noised.column(idx).as_string::<i32>();
601 for (i, is_changed) in changed.iter_mut().enumerate() {
602 if *is_changed {
603 continue;
604 }
605 let av = (!a.is_null(i)).then(|| a.value(i));
606 let bv = (!b.is_null(i)).then(|| b.value(i));
607 if av != bv {
608 *is_changed = true;
609 }
610 }
611 }
612 Ok(changed.into_iter().map(|c| !c).collect())
613}
614
615fn apply_noise_with_retry(
638 orig: &RecordBatch,
639 noise_type: &str,
640 plan_cols: &[String],
641 rng: &mut Rng,
642 exclude_cols: &[String],
643) -> Result<(RecordBatch, Vec<bool>, Vec<String>), String> {
644 const MAX_NOISE_ATTEMPTS: usize = 4;
645 let (mut noisy, target_cols) =
646 apply_noise_to_batch(orig, noise_type, plan_cols, rng, exclude_cols)?;
647 if target_cols.is_empty() {
648 return Ok((noisy, vec![true; orig.num_rows()], target_cols));
649 }
650 let schema = noisy.schema();
651 let target_idxs: Vec<usize> = target_cols
652 .iter()
653 .filter_map(|c| schema.index_of(c).ok())
654 .collect();
655 let mut unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
656 let mut attempt = 1;
657 while unchanged.iter().any(|&u| u) && attempt < MAX_NOISE_ATTEMPTS {
658 let mut retry_rng = Rng::new(rng.next_u64());
659 let (retried, _) =
660 apply_noise_to_batch(orig, noise_type, plan_cols, &mut retry_rng, exclude_cols)?;
661 let mask = arrow::array::BooleanArray::from(unchanged.clone());
662 let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
663 for &i in &target_idxs {
664 merged_cols[i] =
665 arrow::compute::kernels::zip::zip(&mask, retried.column(i), noisy.column(i))
666 .map_err(|e| format!("zip noise retry col {i}: {e}"))?;
667 }
668 noisy = RecordBatch::try_new(noisy.schema(), merged_cols)
669 .map_err(|e| format!("rebuild retried dup batch: {e}"))?;
670 unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
671 attempt += 1;
672 }
673 Ok((noisy, unchanged, target_cols))
674}
675
676fn apply_extra_pass_per_row(
703 noisy: &RecordBatch,
704 unchanged: &mut [bool],
705 all_types: &[&str],
706 rng: &mut Rng,
707 exclude_cols: &[String],
708) -> Result<RecordBatch, String> {
709 let n = noisy.num_rows();
710 if all_types.is_empty() || n == 0 {
711 return Ok(noisy.clone());
712 }
713 let per_row_type: Vec<usize> = (0..n).map(|_| rng.next_usize(all_types.len())).collect();
714
715 let mut groups: Vec<Vec<u32>> = vec![Vec::new(); all_types.len()];
726 for (i, &t) in per_row_type.iter().enumerate() {
727 groups[t].push(i as u32);
728 }
729
730 let schema = noisy.schema();
731 let mut pass_unchanged = vec![true; n];
732 let mut touched: HashMap<String, Vec<Option<String>>> = HashMap::new();
739
740 for (type_idx, idxs) in groups.into_iter().enumerate() {
741 if idxs.is_empty() {
742 continue;
743 }
744 let ttype = all_types[type_idx];
745 let idx_arr = UInt64Array::from_iter_values(idxs.iter().map(|&i| i as u64));
746 let subset = pick_rows(noisy, &idx_arr)?;
747 let (noised_subset, subset_unchanged, target_cols) =
748 apply_noise_with_retry(&subset, ttype, &[], rng, exclude_cols)?;
749 for col_name in &target_cols {
750 let Ok(col_idx) = schema.index_of(col_name) else {
751 continue;
752 };
753 let noised_col = noised_subset.column(col_idx).as_string::<i32>();
754 let buf = touched.entry(col_name.clone()).or_insert_with(|| {
755 let orig = noisy.column(col_idx).as_string::<i32>();
756 (0..n)
757 .map(|i| (!orig.is_null(i)).then(|| orig.value(i).to_string()))
758 .collect()
759 });
760 for (local_pos, &orig_i) in idxs.iter().enumerate() {
761 buf[orig_i as usize] = (!noised_col.is_null(local_pos))
762 .then(|| noised_col.value(local_pos).to_string());
763 }
764 }
765 for (local_pos, &orig_i) in idxs.iter().enumerate() {
766 pass_unchanged[orig_i as usize] = subset_unchanged[local_pos];
767 }
768 }
769
770 let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
771 for (col_name, buf) in touched {
772 if let Ok(col_idx) = schema.index_of(&col_name) {
773 merged_cols[col_idx] = Arc::new(StringArray::from(buf));
774 }
775 }
776 let current = RecordBatch::try_new(noisy.schema(), merged_cols)
777 .map_err(|e| format!("rebuild extra-pass batch: {e}"))?;
778
779 for (u, pu) in unchanged.iter_mut().zip(pass_unchanged.iter()) {
780 *u = *u && *pu;
781 }
782 Ok(current)
783}
784
785fn topological_sort(
789 plans: &[EntityPlan],
790 name_to_idx: &HashMap<&str, usize>,
791) -> Result<Vec<usize>, String> {
792 let n = plans.len();
793 let mut in_degree = vec![0usize; n];
794 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
795 for i in 0..n {
796 for remap in &plans[i].fk_remaps {
797 let Some(&target) = name_to_idx.get(remap.target_entity.as_str()) else {
798 continue;
799 };
800 if target == i {
801 continue;
802 }
803 adj[target].push(i); in_degree[i] += 1;
805 }
806 }
807 let mut queue: Vec<usize> = (0..n).filter(|i| in_degree[*i] == 0).collect();
808 let mut order = Vec::with_capacity(n);
809 let mut idx = 0;
810 while idx < queue.len() {
811 let v = queue[idx];
812 idx += 1;
813 order.push(v);
814 for &next in &adj[v] {
815 in_degree[next] -= 1;
816 if in_degree[next] == 0 {
817 queue.push(next);
818 }
819 }
820 }
821 if order.len() != n {
822 return Err("FK dependency cycle detected among entities".into());
823 }
824 Ok(order)
825}
826
827pub(crate) enum DatasetWriter {
831 Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
832 Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
833}
834
835impl DatasetWriter {
836 fn new(
837 output_format: &str,
838 path: &str,
839 schema: &Arc<Schema>,
840 metadata: &HashMap<String, String>,
841 ) -> Result<Self, String> {
842 let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
843 if output_format == "parquet" {
844 use parquet::basic::{Compression, ZstdLevel};
845 use parquet::file::properties::WriterProperties;
846 let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
847 let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
848 .iter()
849 .map(|(k, v)| parquet::file::metadata::KeyValue {
850 key: k.clone(),
851 value: Some(v.clone()),
852 })
853 .collect();
854 let props = WriterProperties::builder()
855 .set_compression(Compression::ZSTD(zstd))
856 .set_max_row_group_row_count(Some(1_000_000))
860 .set_key_value_metadata(Some(meta_kv))
861 .build();
862 let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
863 .map_err(|e| format!("ArrowWriter error: {e}"))?;
864 Ok(DatasetWriter::Parquet(Box::new(writer)))
865 } else {
866 let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
867 .map_err(|e| format!("IPC FileWriter error: {e}"))?;
868 Ok(DatasetWriter::Ipc(Box::new(writer)))
869 }
870 }
871
872 pub(crate) fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
873 match self {
874 DatasetWriter::Ipc(w) => w.write(batch).map_err(|e| format!("write ipc: {e}")),
875 DatasetWriter::Parquet(w) => w.write(batch).map_err(|e| format!("write parquet: {e}")),
876 }
877 }
878
879 fn finish(self) -> Result<(), String> {
880 match self {
881 DatasetWriter::Ipc(mut w) => w.finish().map_err(|e| format!("finish ipc: {e}")),
882 DatasetWriter::Parquet(w) => w
883 .close()
884 .map(|_| ())
885 .map_err(|e| format!("close parquet: {e}")),
886 }
887 }
888}
889
890pub fn run_pipeline(
893 ctx: &Context,
894 config: &PipelineConfig,
895 output_dir: &str,
896) -> Result<PipelineOutput, String> {
897 run_pipeline_with_progress(ctx, config, output_dir, None)
898}
899
900pub fn run_pipeline_with_progress(
907 ctx: &Context,
908 config: &PipelineConfig,
909 output_dir: &str,
910 mut progress: Option<&mut dyn FnMut(usize, usize)>,
911) -> Result<PipelineOutput, String> {
912 std::fs::create_dir_all(output_dir)
913 .map_err(|e| format!("create output directory {output_dir:?}: {e}"))?;
914 let t_start = std::time::Instant::now();
915
916 let t_alloc = t_start.elapsed().as_secs_f64();
917
918 let t1e = std::time::Instant::now();
922
923 let metadata = build_metadata_map(config);
929 let full_arc = Arc::new(build_full_schema(config, &metadata));
931 let dataset_ext = if config.output_format == "parquet" {
932 "parquet"
933 } else {
934 "ipc"
935 };
936 let dataset_path = format!("{}/{}.{}", output_dir, config.run_id, dataset_ext);
937 let mut writer =
938 DatasetWriter::new(&config.output_format, &dataset_path, &full_arc, &metadata)?;
939
940 let name_to_idx: HashMap<&str, usize> = config
942 .entity_plans
943 .iter()
944 .enumerate()
945 .map(|(i, p)| (p.name.as_str(), i))
946 .collect();
947 let entity_order = topological_sort(&config.entity_plans, &name_to_idx)?;
948
949 let mut fk_pools: FkPoolMap = HashMap::new();
951 let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
952
953 let gt_ext = if config.output_format == "parquet" {
957 "parquet"
958 } else {
959 "ipc"
960 };
961 let gt_path = format!("{}/{}_ground_truth.{}", output_dir, config.run_id, gt_ext);
962 let gt_draft_path = format!("{}/{}_gt_draft.ipc", output_dir, config.run_id);
963 let mut gt_acc = crate::gt::GtAccumulator::new(>_draft_path)?;
964
965 let mut global_rid_offset = 0usize;
966
967 const GRAPH_MAX_CLUSTER_EDGES: usize = 10_000;
969 let graph_fmt = crate::graph_gen::GraphFormat::from_str(&config.graph_format);
970 let mut node_writer: Option<crate::graph_gen::NodeWriter> = None;
971 let mut edge_writer: Option<crate::graph_gen::EdgeWriter> = None;
972 let mut graph_nodes_path: Option<String> = None;
973 let mut graph_edges_path: Option<String> = None;
974 if config.graph_enabled {
975 let nodes_ipc = format!("{}/{}_nodes.ipc", output_dir, config.run_id);
976 let edges_ipc = format!("{}/{}_edges.ipc", output_dir, config.run_id);
977 node_writer = Some(
978 crate::graph_gen::NodeWriter::new(&nodes_ipc, &full_arc, &metadata)
979 .map_err(|e| format!("init node writer: {e}"))?,
980 );
981 edge_writer = Some(
982 crate::graph_gen::EdgeWriter::new(&edges_ipc, &metadata)
983 .map_err(|e| format!("init edge writer: {e}"))?,
984 );
985 graph_nodes_path = Some(nodes_ipc);
986 graph_edges_path = Some(edges_ipc);
987 }
988
989 let mut _t_write = 0.0f64;
990 let mut _t_meta = 0.0f64;
991 let mut _t_dup = 0.0f64;
992 let mut _write_calls = 0u64;
993 let hn_pool_sizing: std::collections::HashMap<&str, usize> =
995 config
996 .hard_neg_types
997 .iter()
998 .fold(std::collections::HashMap::new(), |mut m, hn| {
999 let entry = m.entry(hn.entity_type.as_str()).or_insert(0);
1000 *entry = (*entry).max(hn.count);
1001 m
1002 });
1003 let hn_max_pool: usize = hn_pool_sizing.values().max().copied().unwrap_or(50_000) * 2 + 1_000;
1004 const FK_POOL_CAP: usize = 200_000;
1010
1011 let fk_targets: std::collections::HashSet<&str> = config
1016 .entity_plans
1017 .iter()
1018 .flat_map(|p| p.fk_remaps.iter().map(|r| r.target_entity.as_str()))
1019 .collect();
1020
1021 for &plan_idx in &entity_order {
1022 let plan = &config.entity_plans[plan_idx];
1023 if plan.n_base == 0 {
1024 continue;
1025 }
1026
1027 let prefix = entity_prefix(plan_idx);
1028 let col_json_str = &plan.columns_json;
1029
1030 let fk_targeted = fk_targets.contains(plan.name.as_str());
1032 let mut fk_builder = arrow::array::StringBuilder::new();
1033 let mut fk_rid_builder = arrow::array::StringBuilder::new();
1034 let mut fk_count: usize = 0;
1035 let hn_targeted = hn_pool_sizing.contains_key(plan.name.as_str());
1040 let mut hn_slices: Vec<RecordBatch> = Vec::new();
1041 let mut hn_slice_rids: Vec<Vec<String>> = Vec::new();
1042 let mut batch_rng = Rng::new(config.seed.wrapping_add(100));
1043 let mut fk_rng = Rng::new(config.seed.wrapping_add(42));
1044
1045 let batch_bounds: Vec<(usize, usize)> = (0..plan.n_base)
1051 .step_by(crate::entity_gen::BATCH_SIZE)
1052 .map(|offset| {
1053 (
1054 offset,
1055 (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE),
1056 )
1057 })
1058 .collect();
1059 let batch_weights: Vec<usize> = batch_bounds.iter().map(|&(_, bn)| bn).collect();
1060 let per_batch_noise_counts: Vec<Vec<usize>> = plan
1061 .noise_types
1062 .iter()
1063 .map(|n| distribute_by_weight(n.count, &batch_weights))
1064 .collect();
1065
1066 let mut col_lookup: Option<Vec<Option<usize>>> = None;
1068 let mut null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1070 let mut const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1071
1072 for (batch_idx, offset) in (0..plan.n_base)
1074 .step_by(crate::entity_gen::BATCH_SIZE)
1075 .enumerate()
1076 {
1077 let batch_n = (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE);
1078 let batch_seed = config.seed.wrapping_add(batch_idx as u64 * 1000 + 1000);
1079
1080 let request_json = format!(
1082 r#"{{"entity_name":"{}","n":{},"seed":{},"columns":{},"row_offset":{}}}"#,
1083 plan.name, batch_n, batch_seed, col_json_str, offset,
1084 );
1085 let rb = crate::entity_gen::generate_entity_batch(ctx, &request_json)?;
1086
1087 let mut fk_edges_by_remap: Vec<(String, Vec<String>, f64)> = Vec::new();
1089 let rb = if !plan.fk_remaps.is_empty() {
1090 let mut r = rb;
1091 for remap in &plan.fk_remaps {
1092 if let Some(pool) = fk_pools.get(&remap.target_entity) {
1093 let (remapped, target_rids) = crate::fk_remap::fk_remap_batch(
1094 &r,
1095 pool,
1096 &remap.source_col,
1097 &mut fk_rng,
1098 config.graph_enabled,
1099 )?;
1100 r = remapped;
1101 if config.graph_enabled
1102 && let Some(rids) = target_rids
1103 {
1104 fk_edges_by_remap.push((
1105 remap.source_col.clone(),
1106 rids,
1107 remap.fk_weight,
1108 ));
1109 }
1110 }
1111 }
1112 r
1113 } else {
1114 rb
1115 };
1116
1117 if fk_targeted
1120 && fk_count < FK_POOL_CAP
1121 && let Some(ref id_col) = plan.identifier_col
1122 && let Some(col) = rb.column_by_name(id_col)
1123 {
1124 let s = col.as_string::<i32>();
1125 for i in 0..s.len() {
1126 if fk_count >= FK_POOL_CAP {
1127 break;
1128 }
1129 if !s.is_null(i) {
1130 fk_builder.append_value(s.value(i));
1131 if config.graph_enabled {
1132 fk_rid_builder.append_value(record_id_string(global_rid_offset + i));
1133 }
1134 fk_count += 1;
1135 }
1136 }
1137 }
1138
1139 let hn_accum: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1141 if hn_targeted && hn_accum < hn_max_pool {
1142 let take = (hn_max_pool - hn_accum).min(batch_n);
1143 hn_slices.push(rb.slice(0, take));
1144 if config.graph_enabled {
1148 hn_slice_rids.push(record_id_strs(global_rid_offset..global_rid_offset + take));
1149 }
1150 }
1151
1152 if col_lookup.is_none() {
1154 let plan_schema = rb.schema();
1155 col_lookup = Some(
1156 full_arc
1157 .fields()
1158 .iter()
1159 .skip(4)
1160 .map(|f| plan_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1161 .collect(),
1162 );
1163 }
1164
1165 let batch_mids: Vec<String> = (offset..offset + batch_n)
1167 .map(|i| format!("{}-{}", prefix, pad_string(i)))
1168 .collect();
1169 let batch_mids_slice = &batch_mids[..];
1170
1171 let rid_slice = record_id_strs(global_rid_offset..global_rid_offset + batch_n);
1173 let t_m0 = std::time::Instant::now();
1174 let base_rb = add_metadata_and_align(
1175 &rb,
1176 &config.domain,
1177 &plan.name,
1178 &rid_slice,
1179 batch_mids_slice,
1180 &full_arc,
1181 col_lookup.as_ref().unwrap(),
1182 &mut null_cache,
1183 &mut const_arr_cache,
1184 )?;
1185 _t_meta += t_m0.elapsed().as_secs_f64();
1186
1187 if config.graph_enabled
1189 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1190 {
1191 nw.write_batch(&base_rb)
1192 .map_err(|e| format!("write node: {e}"))?;
1193 for i in 0..batch_n {
1194 for (subtype, target_rids, weight) in &fk_edges_by_remap {
1195 ew.push(&rid_slice[i], &target_rids[i], "fk", subtype, *weight)
1196 .map_err(|e| format!("push fk edge: {e}"))?;
1197 }
1198 }
1199 }
1200
1201 let t_w0 = std::time::Instant::now();
1202 writer
1203 .write(&base_rb)
1204 .map_err(|e| format!("write base: {e}"))?;
1205 _t_write += t_w0.elapsed().as_secs_f64();
1206 _write_calls += 1;
1207
1208 gt_acc.push_base_batch(base_rb.column(0), base_rb.column(2), base_rb.column(3))?;
1210
1211 global_rid_offset += batch_n;
1212 if let Some(cb) = &mut progress {
1213 cb(global_rid_offset, config.size);
1214 }
1215
1216 let has_dups: bool = per_batch_noise_counts
1224 .iter()
1225 .any(|counts| counts[batch_idx] > 0);
1226 if has_dups {
1227 let t_d0 = std::time::Instant::now();
1228 let mut dup_batches: Vec<RecordBatch> = Vec::new();
1229 let mut dup_mids_buf: Vec<String> = Vec::new();
1230
1231 let fk_exclude_cols: Vec<String> = plan
1233 .fk_remaps
1234 .iter()
1235 .map(|r| r.source_col.clone())
1236 .collect();
1237
1238 let ndata: Vec<(UInt64Array, u64, &str, &[String], usize)> = plan
1240 .noise_types
1241 .iter()
1242 .enumerate()
1243 .filter_map(|(ni, n)| {
1244 let cnt = per_batch_noise_counts[ni][batch_idx];
1245 if cnt == 0 {
1246 return None;
1247 }
1248 let mut b = UInt64Builder::with_capacity(cnt);
1249 for _ in 0..cnt {
1250 b.append_value(batch_rng.next_usize(batch_n) as u64);
1251 }
1252 Some((
1253 b.finish(),
1254 batch_rng.next_u64(),
1255 n.noise_type.as_str(),
1256 n.columns.as_slice(),
1257 cnt,
1258 ))
1259 })
1260 .collect();
1261
1262 let all_types: Vec<&str> = plan
1266 .noise_types
1267 .iter()
1268 .map(|n| n.noise_type.as_str())
1269 .collect();
1270 let all_types_ref = &all_types;
1271 let noise_passes = config.noise_passes.max(1);
1272
1273 type DupNoiseResult = Result<(RecordBatch, Vec<String>, Vec<bool>), String>;
1274 let mut results: Vec<DupNoiseResult> = Vec::with_capacity(ndata.len());
1275 let fk_exclude_ref = &fk_exclude_cols;
1276 let rb_ref = &rb;
1277 std::thread::scope(|s| {
1278 let mut handles = Vec::with_capacity(ndata.len());
1279 for (indices, seed, ntype, cols, cnt) in &ndata {
1280 let prefix_ref: &str = &prefix;
1288 handles.push(s.spawn(move || {
1289 let dup = pick_rows(rb_ref, indices)?;
1290 let mut rng = Rng::new(*seed);
1291
1292 let (mut noisy, mut unchanged, _) = apply_noise_with_retry(
1298 &dup,
1299 ntype,
1300 cols,
1301 &mut rng,
1302 fk_exclude_ref,
1303 )?;
1304
1305 for _ in 1..noise_passes {
1317 noisy = apply_extra_pass_per_row(
1318 &noisy,
1319 &mut unchanged,
1320 all_types_ref,
1321 &mut rng,
1322 fk_exclude_ref,
1323 )?;
1324 }
1325
1326 let mut mb = Vec::with_capacity(*cnt);
1327 for j in 0..*cnt {
1328 let global_idx = offset + indices.value(j) as usize;
1333 mb.push(format!("{}-{}", prefix_ref, pad_string(global_idx)));
1334 }
1335 Ok((noisy, mb, unchanged))
1336 }));
1337 }
1338 for h in handles {
1339 results.push(h.join().unwrap());
1340 }
1341 });
1342 let mut dup_is_identical_buf: Vec<bool> = Vec::new();
1343 for res in results {
1344 let (rb, mb, unchanged) = res?;
1345 dup_batches.push(rb);
1346 dup_mids_buf.extend(mb);
1347 dup_is_identical_buf.extend(unchanged);
1348 }
1349 _t_dup += t_d0.elapsed().as_secs_f64();
1350
1351 if !dup_batches.is_empty() {
1353 let dup_total = dup_mids_buf.len();
1354 let dup_rids = record_id_strs(global_rid_offset..global_rid_offset + dup_total);
1355
1356 let concated = if dup_batches.len() == 1 {
1357 dup_batches.into_iter().next().unwrap()
1358 } else {
1359 let schema = dup_batches[0].schema();
1360 let n_fields = dup_batches[0].num_columns();
1361 let mut concat_arrays = Vec::with_capacity(n_fields);
1362 for i in 0..n_fields {
1363 let refs: Vec<&dyn Array> =
1364 dup_batches.iter().map(|b| b.column(i).as_ref()).collect();
1365 concat_arrays.push(
1366 arrow::compute::concat(&refs)
1367 .map_err(|e| format!("concat dup col {i}: {e}"))?,
1368 );
1369 }
1370 RecordBatch::try_new(schema, concat_arrays)
1371 .map_err(|e| format!("concat dups: {e}"))?
1372 };
1373
1374 let dup_rb_full = add_metadata_and_align(
1375 &concated,
1376 &config.domain,
1377 &plan.name,
1378 &dup_rids,
1379 &dup_mids_buf,
1380 &full_arc,
1381 col_lookup.as_ref().unwrap(),
1382 &mut null_cache,
1383 &mut const_arr_cache,
1384 )?;
1385
1386 let t_wd = std::time::Instant::now();
1387 writer
1388 .write(&dup_rb_full)
1389 .map_err(|e| format!("write dups: {e}"))?;
1390 _t_write += t_wd.elapsed().as_secs_f64();
1391 _write_calls += 1;
1392
1393 if config.graph_enabled
1395 && let Some(nw) = node_writer.as_mut()
1396 {
1397 nw.write_batch(&dup_rb_full)
1398 .map_err(|e| format!("write dup node: {e}"))?;
1399 }
1400
1401 let is_identical_arr: ArrayRef =
1402 Arc::new(arrow::array::BooleanArray::from(dup_is_identical_buf));
1403 gt_acc.push_dup_batch(
1404 dup_rb_full.column(0),
1405 dup_rb_full.column(2),
1406 dup_rb_full.column(3),
1407 &is_identical_arr,
1408 )?;
1409 global_rid_offset += dup_total;
1410 }
1411 }
1412 }
1413
1414 if let Some(id_col) = &plan.identifier_col
1416 && fk_builder.len() > 0
1417 {
1418 let arr = Arc::new(fk_builder.finish()) as ArrayRef;
1419 if config.graph_enabled {
1420 let rid_arr = Arc::new(fk_rid_builder.finish()) as ArrayRef;
1424 let schema = Arc::new(Schema::new(vec![
1425 Field::new(id_col, DataType::Utf8, true),
1426 Field::new("record_id", DataType::Utf8, true),
1427 ]));
1428 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr, rid_arr]) {
1429 fk_pools.insert(plan.name.clone(), pool_rb);
1430 }
1431 } else {
1432 let schema = Arc::new(Schema::new(vec![Field::new(id_col, DataType::Utf8, true)]));
1433 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr]) {
1434 fk_pools.insert(plan.name.clone(), pool_rb);
1435 }
1436 }
1437 }
1438
1439 if !hn_slices.is_empty() {
1441 let total_count: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1442 let batch = if hn_slices.len() == 1 {
1443 let only = hn_slices.into_iter().next().unwrap();
1450 let idx = UInt64Array::from_iter_values(0..only.num_rows() as u64);
1451 pick_rows(&only, &idx)?
1452 } else {
1453 let schema = hn_slices[0].schema();
1454 let n_fields = schema.fields().len();
1455 let mut concat_arrays = Vec::with_capacity(n_fields);
1456 for i in 0..n_fields {
1457 let refs: Vec<&dyn Array> =
1458 hn_slices.iter().map(|pb| pb.column(i).as_ref()).collect();
1459 concat_arrays.push(
1460 arrow::compute::concat(&refs)
1461 .map_err(|e| format!("hn pool concat col {i}: {e}"))?,
1462 );
1463 }
1464 RecordBatch::try_new(schema, concat_arrays)
1465 .map_err(|e| format!("hn pool concat batch: {e}"))?
1466 };
1467 let record_ids = if config.graph_enabled {
1469 hn_slice_rids.into_iter().flatten().collect::<Vec<String>>()
1470 } else {
1471 Vec::new()
1472 };
1473 hn_pools.insert(
1474 plan.name.clone(),
1475 HnPool {
1476 batch,
1477 total_count,
1478 record_ids,
1479 },
1480 );
1481 }
1482 }
1483 let t1e_elapsed = t1e.elapsed().as_secs_f64();
1484 log::debug!("[sink_profile] write={_t_write:.3}s calls={_write_calls}");
1485
1486 let t2 = std::time::Instant::now();
1488 let mut hn_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1490 let mut hn_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1491 let mut hn_master_id_counter: u64 = 0;
1499 for hn_cfg in config.hard_neg_types.iter() {
1500 if hn_cfg.count == 0 {
1501 continue;
1502 }
1503 let Some(pool_data) = hn_pools.get(&hn_cfg.entity_type) else {
1504 continue;
1505 };
1506 let hn_total = pool_data.total_count;
1507
1508 let pool_rb = &pool_data.batch;
1509 let n_hn_max = hn_cfg.count.min(hn_total / 2);
1510 if n_hn_max == 0 {
1511 continue;
1512 }
1513
1514 let (hn_rb, hn_src) = crate::hn_common::generate_hard_negatives(
1515 pool_rb,
1516 &hn_cfg.config_json,
1517 n_hn_max,
1518 config.seed.wrapping_add(200),
1519 config.graph_enabled,
1520 )?;
1521
1522 let n_hn = hn_rb.num_rows();
1523 if n_hn == 0 {
1524 continue;
1525 }
1526
1527 let hn_rids = record_id_strs(global_rid_offset..global_rid_offset + n_hn);
1528
1529 let hn_mids: Vec<String> = (0..n_hn)
1530 .map(|_i| {
1531 let id = hn_master_id_counter;
1532 hn_master_id_counter += 1;
1533 format!("HN-{:09}", id)
1534 })
1535 .collect();
1536
1537 let hn_schema = hn_rb.schema();
1539 let hn_col_lookup: Vec<Option<usize>> = full_arc
1540 .fields()
1541 .iter()
1542 .skip(4)
1543 .map(|f| hn_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1544 .collect();
1545
1546 let hn_rb_full = add_metadata_and_align(
1547 &hn_rb,
1548 &config.domain,
1549 &hn_cfg.entity_type,
1550 &hn_rids,
1551 &hn_mids,
1552 &full_arc,
1553 &hn_col_lookup,
1554 &mut hn_null_cache,
1555 &mut hn_const_arr_cache,
1556 )?;
1557
1558 let t_wh = std::time::Instant::now();
1559 writer
1560 .write(&hn_rb_full)
1561 .map_err(|e| format!("write hn: {e}"))?;
1562 _t_write += t_wh.elapsed().as_secs_f64();
1563 _write_calls += 1;
1564
1565 if config.graph_enabled
1568 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1569 {
1570 nw.write_batch(&hn_rb_full)
1571 .map_err(|e| format!("write hn node: {e}"))?;
1572 if let Some((idx_a, pattern)) = hn_src {
1573 for i in 0..n_hn {
1574 let tgt = &pool_data.record_ids[idx_a[i]];
1575 ew.push(&hn_rids[i], tgt, "hard_neg", &pattern, 1.0)
1576 .map_err(|e| format!("push hn edge: {e}"))?;
1577 }
1578 }
1579 }
1580
1581 gt_acc.push_other_batch(
1583 hn_rb_full.column(0),
1584 hn_rb_full.column(2),
1585 hn_rb_full.column(3),
1586 )?;
1587
1588 global_rid_offset += n_hn;
1589 }
1590 let t2_elapsed = t2.elapsed().as_secs_f64();
1591
1592 {
1594 let mut canary_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1595 let mut canary_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1596 crate::canary::generate_all(
1597 ctx,
1598 config,
1599 &full_arc,
1600 &mut canary_null_cache,
1601 &mut canary_const_arr_cache,
1602 &mut global_rid_offset,
1603 &fk_pools,
1604 &mut writer,
1605 &mut node_writer,
1606 &mut gt_acc,
1607 )?;
1608 }
1609
1610 let t3 = std::time::Instant::now();
1612 writer
1613 .finish()
1614 .map_err(|e| format!("finish dataset writer: {e}"))?;
1615 let t3a_elapsed = t3.elapsed().as_secs_f64();
1616
1617 let t3b = std::time::Instant::now();
1618 let t_gt0 = std::time::Instant::now();
1619 let crate::gt::GtResult {
1620 n_exact_dup,
1621 n_fuzzy_dup,
1622 n_hard_neg,
1623 n_unique,
1624 n_masters,
1625 cluster_map,
1626 } = gt_acc.finish(
1627 config.difficulty.as_str(),
1628 &config.output_format,
1629 >_path,
1630 &metadata,
1631 )?;
1632 let _t_gt_compute = 0.0f64;
1633 let _t_gt_write = t_gt0.elapsed().as_secs_f64();
1634 let t3b_elapsed = t3b.elapsed().as_secs_f64();
1635 let _t3_elapsed = t3.elapsed().as_secs_f64();
1636
1637 if config.graph_enabled
1639 && let Some(ew) = edge_writer.as_mut()
1640 {
1641 crate::graph_gen::push_dup_clusters(ew, &cluster_map, GRAPH_MAX_CLUSTER_EDGES)
1642 .map_err(|e| format!("push dup clusters: {e}"))?;
1643 }
1644
1645 let duration = t_start.elapsed().as_secs_f64();
1646
1647 log::debug!(
1648 "[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",
1649 t_alloc,
1650 t1e_elapsed,
1651 t2_elapsed,
1652 t3a_elapsed,
1653 _t_meta,
1654 _t_dup,
1655 _t_write,
1656 _t_gt_compute,
1657 _t_gt_write,
1658 t3b_elapsed,
1659 duration,
1660 );
1661
1662 let stats = PipelineStats {
1663 total_records: global_rid_offset,
1664 exact_dups: n_exact_dup,
1665 fuzzy_dups: n_fuzzy_dup,
1666 hard_negs: n_hard_neg,
1667 uniques: n_unique,
1668 masters: n_masters,
1669 };
1670
1671 let mut graph_nodes_final: Option<String> = None;
1673 let mut graph_edges_final: Option<String> = None;
1674 if let (Some(nw), Some(ew)) = (node_writer, edge_writer) {
1675 nw.finish()
1676 .map_err(|e| format!("finish node writer: {e}"))?;
1677 ew.finish()
1678 .map_err(|e| format!("finish edge writer: {e}"))?;
1679 let mut np = graph_nodes_path.clone().unwrap();
1680 let mut ep = graph_edges_path.clone().unwrap();
1681 if graph_fmt == crate::graph_gen::GraphFormat::Parquet {
1682 let np_pq = format!("{}/{}_nodes.parquet", output_dir, config.run_id);
1683 let ep_pq = format!("{}/{}_edges.parquet", output_dir, config.run_id);
1684 convert_ipc_to_parquet(&np, &np_pq).map_err(|e| format!("graph nodes parquet: {e}"))?;
1685 convert_ipc_to_parquet(&ep, &ep_pq).map_err(|e| format!("graph edges parquet: {e}"))?;
1686 std::fs::remove_file(&np).ok();
1687 std::fs::remove_file(&ep).ok();
1688 np = np_pq;
1689 ep = ep_pq;
1690 }
1691 graph_nodes_final = Some(np);
1692 graph_edges_final = Some(ep);
1693 }
1694
1695 Ok(PipelineOutput {
1696 output_files: vec![dataset_path],
1697 gt_file: gt_path,
1698 stats,
1699 nodes: graph_nodes_final,
1700 edges: graph_edges_final,
1701 })
1702}
1703
1704fn convert_ipc_to_parquet(ipc_path: &str, parquet_path: &str) -> Result<(), String> {
1708 use arrow::ipc::reader::FileReader;
1709
1710 let ipc_file =
1711 std::fs::File::open(ipc_path).map_err(|e| format!("open IPC {ipc_path}: {e}"))?;
1712 let ipc_reader =
1713 FileReader::try_new(ipc_file, None).map_err(|e| format!("IPC reader {ipc_path}: {e}"))?;
1714 let schema = ipc_reader.schema();
1715
1716 let parquet_file =
1717 std::fs::File::create(parquet_path).map_err(|e| format!("create {parquet_path}: {e}"))?;
1718 let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1719 let meta_kv: Vec<parquet::file::metadata::KeyValue> = schema
1720 .metadata()
1721 .iter()
1722 .map(|(k, v)| parquet::file::metadata::KeyValue {
1723 key: k.clone(),
1724 value: Some(v.clone()),
1725 })
1726 .collect();
1727 let props = parquet::file::properties::WriterProperties::builder()
1728 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
1729 .set_max_row_group_row_count(Some(1_000_000))
1730 .set_key_value_metadata(Some(meta_kv))
1731 .build();
1732 let mut parquet_writer =
1733 parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
1734 .map_err(|e| format!("ArrowWriter {parquet_path}: {e}"))?;
1735
1736 for batch_result in ipc_reader {
1737 let batch = batch_result.map_err(|e| format!("IPC batch {ipc_path}: {e}"))?;
1738 parquet_writer
1739 .write(&batch)
1740 .map_err(|e| format!("write parquet {parquet_path}: {e}"))?;
1741 }
1742 parquet_writer
1743 .close()
1744 .map_err(|e| format!("close parquet {parquet_path}: {e}"))?;
1745 Ok(())
1746}
1747
1748fn build_metadata_map(config: &PipelineConfig) -> HashMap<String, String> {
1751 let ts = std::time::SystemTime::now()
1752 .duration_since(std::time::UNIX_EPOCH)
1753 .map(|d| d.as_secs().to_string())
1754 .unwrap_or_else(|_| "unknown".into());
1755 HashMap::from([
1756 (
1757 "dupehell.generator".into(),
1758 format!("DupeHell v{}", env!("CARGO_PKG_VERSION")),
1759 ),
1760 (
1761 "dupehell.provenance".into(),
1762 "dupehell-synthetic-data".into(),
1763 ),
1764 ("dupehell.license".into(), "MIT".into()),
1765 (
1766 "dupehell.purpose".into(),
1767 "Educational Use Only — Record Linkage Benchmarking".into(),
1768 ),
1769 (
1770 "dupehell.url".into(),
1771 "https://github.com/vntoinekaio/DupeHell".into(),
1772 ),
1773 ("dupehell.domain".into(), config.domain.clone()),
1774 ("dupehell.size".into(), config.size.to_string()),
1775 ("dupehell.seed".into(), config.seed.to_string()),
1776 ("dupehell.difficulty".into(), config.difficulty.clone()),
1777 ("dupehell.run_id".into(), config.run_id.clone()),
1778 ("dupehell.timestamp".into(), ts),
1779 (
1780 "dupehell.structural_key_columns".into(),
1781 structural_key_columns_note(config),
1782 ),
1783 ])
1784}
1785
1786fn structural_key_columns_note(config: &PipelineConfig) -> String {
1791 let cols: Vec<&str> = config
1792 .entity_plans
1793 .iter()
1794 .filter_map(|p| p.identifier_col.as_deref())
1795 .collect();
1796 format!(
1797 "{} — structural keys for joining child tables, not ER match attributes; use record_id as the row identifier instead",
1798 cols.join(", ")
1799 )
1800}
1801
1802fn build_full_schema(config: &PipelineConfig, metadata: &HashMap<String, String>) -> Schema {
1806 let mut field_map: Vec<(String, DataType, bool)> = Vec::new();
1807 let metadata_fields = ["record_id", "domain", "entity_type", "master_id"];
1808 for mf in &metadata_fields {
1809 field_map.push((mf.to_string(), DataType::Utf8, false));
1810 }
1811 for plan in &config.entity_plans {
1812 let cols: Vec<serde_json::Value> =
1813 serde_json::from_str(&plan.columns_json).unwrap_or_default();
1814 for col in &cols {
1815 let name = col["name"].as_str().unwrap_or("").to_string();
1816 if name.is_empty() || field_map.iter().any(|(n, _, _)| n == &name) {
1817 continue;
1818 }
1819 let col_type = col_type_from_request(col);
1820 field_map.push((name, col_type, true));
1821 }
1822 }
1823 let fields: Vec<Field> = field_map
1824 .into_iter()
1825 .map(|(n, dt, nullable)| Field::new(&n, dt, nullable))
1826 .collect();
1827 Schema::new(fields).with_metadata(metadata.clone())
1828}
1829
1830fn col_type_from_request(v: &serde_json::Value) -> DataType {
1832 match v["type"].as_str() {
1833 Some("int") => DataType::Int64,
1834 Some("float") => DataType::Float64,
1835 Some("boolean") => DataType::Boolean,
1836 Some("date") | Some("datetime") => DataType::Utf8,
1837 _ => DataType::Utf8,
1838 }
1839}
1840
1841#[allow(clippy::too_many_arguments)]
1844pub(crate) fn add_metadata_and_align(
1845 rb: &RecordBatch,
1846 domain: &str,
1847 entity_type: &str,
1848 record_ids: &[String],
1849 master_ids: &[String],
1850 full_arc: &Arc<Schema>,
1851 col_lookup: &[Option<usize>],
1852 null_cache: &mut HashMap<(DataType, usize), ArrayRef>,
1853 const_arr_cache: &mut HashMap<(String, usize), ArrayRef>,
1854) -> Result<RecordBatch, String> {
1855 let n = rb.num_rows();
1856 let domain_arr = const_arr_cache
1862 .entry((domain.to_string(), n))
1863 .or_insert_with(|| {
1864 Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1865 domain, n,
1866 ))) as ArrayRef
1867 })
1868 .clone();
1869 let et_arr = const_arr_cache
1870 .entry((entity_type.to_string(), n))
1871 .or_insert_with(|| {
1872 Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1873 entity_type,
1874 n,
1875 ))) as ArrayRef
1876 })
1877 .clone();
1878 let rid_arr = Arc::new(StringArray::from_iter_values(
1879 record_ids.iter().map(|s| s.as_str()),
1880 )) as ArrayRef;
1881 let mid_arr = Arc::new(StringArray::from_iter_values(
1882 master_ids.iter().map(|s| s.as_str()),
1883 )) as ArrayRef;
1884
1885 let mut all_arrays: Vec<ArrayRef> = Vec::with_capacity(4 + col_lookup.len());
1886 all_arrays.push(rid_arr);
1887 all_arrays.push(domain_arr);
1888 all_arrays.push(et_arr);
1889 all_arrays.push(mid_arr);
1890
1891 for (i, maybe_idx) in col_lookup.iter().enumerate() {
1894 match maybe_idx {
1895 Some(idx) => all_arrays.push(rb.column(*idx).clone()),
1896 None => {
1897 let dt = full_arc.field(i + 4).data_type();
1898 let arr = null_cache
1899 .entry((dt.clone(), n))
1900 .or_insert_with(|| arrow::array::new_null_array(dt, n));
1901 all_arrays.push(Arc::clone(arr));
1902 }
1903 }
1904 }
1905
1906 RecordBatch::try_new(Arc::clone(full_arc), all_arrays)
1908 .map_err(|e| format!("add_metadata_and_align RecordBatch: {e}"))
1909}
1910
1911fn pick_rows(rb: &RecordBatch, indices: &UInt64Array) -> Result<RecordBatch, String> {
1912 use arrow::compute::take;
1913 let new_columns: Vec<ArrayRef> = (0..rb.num_columns())
1914 .map(|i| take(rb.column(i), indices, None))
1915 .collect::<Result<Vec<_>, _>>()
1916 .map_err(|e| format!("take error: {e}"))?;
1917
1918 RecordBatch::try_new(rb.schema(), new_columns).map_err(|e| format!("RecordBatch: {e}"))
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923 use super::*;
1924 use crate::schema::{build_pipeline_config, load_schema};
1925
1926 #[test]
1933 fn test_no_person_company_name_cross_contamination() {
1934 for col in [
1935 "given_name",
1936 "family_name",
1937 "middle_name",
1938 "first_name",
1939 "last_name",
1940 ] {
1941 assert!(
1942 noise_type_targets_column("names", col),
1943 "{col} should still match 'names'"
1944 );
1945 assert!(
1946 !noise_type_targets_column("companies", col),
1947 "{col} should NOT match 'companies' (person name, not a company name)"
1948 );
1949 }
1950 for col in ["company_name", "legal_name", "trading_name"] {
1951 assert!(
1952 noise_type_targets_column("companies", col),
1953 "{col} should still match 'companies'"
1954 );
1955 assert!(
1956 !noise_type_targets_column("names", col),
1957 "{col} should NOT match 'names' (company name, not a person name)"
1958 );
1959 }
1960 assert!(
1961 !noise_type_targets_column("addresses", "email_address"),
1962 "email_address should NOT match 'addresses' (it's an email, not a postal address)"
1963 );
1964 assert!(
1965 noise_type_targets_column("identifiers", "email_address"),
1966 "email_address should still match 'identifiers' (corrupt_email)"
1967 );
1968 assert!(
1969 noise_type_targets_column("addresses", "residential_address_line1"),
1970 "a real postal address column should still match 'addresses'"
1971 );
1972 }
1973
1974 fn manifest_dir() -> std::path::PathBuf {
1975 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
1976 }
1977
1978 fn scratch_dir(name: &str) -> std::path::PathBuf {
1981 let dir = manifest_dir()
1982 .join("target")
1983 .join("pipeline_test_tmp")
1984 .join(format!("{name}_{}", std::process::id()));
1985 let _ = std::fs::remove_dir_all(&dir);
1986 std::fs::create_dir_all(&dir).expect("create scratch dir");
1987 dir
1988 }
1989
1990 fn run_kyc(
1991 output_dir: &std::path::Path,
1992 output_format: &str,
1993 graph_enabled: bool,
1994 ) -> PipelineOutput {
1995 let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
1996 let mut ctx = Context::new(
1997 "kyc",
1998 "en",
1999 &manifest_dir().join("assets/pools").to_string_lossy(),
2000 )
2001 .expect("load context");
2002 let config = build_pipeline_config(
2003 "kyc",
2004 200,
2005 42,
2006 "medium",
2007 0.1,
2008 0.3,
2009 &schema,
2010 "pipeline_test_run",
2011 output_format,
2012 graph_enabled,
2013 "parquet",
2014 )
2015 .expect("build config");
2016 ctx.enable_watermark(&config.domain, config.size, config.seed);
2017 run_pipeline(&ctx, &config, &output_dir.to_string_lossy()).expect("run_pipeline")
2018 }
2019
2020 #[test]
2021 fn test_run_pipeline_produces_dataset_and_gt() {
2022 let dir = scratch_dir("basic");
2023 let output = run_kyc(&dir, "parquet", false);
2024
2025 assert_eq!(output.output_files.len(), 1);
2026 for f in &output.output_files {
2027 assert!(std::path::Path::new(f).exists(), "missing dataset file {f}");
2028 }
2029 assert!(std::path::Path::new(&output.gt_file).exists());
2030 assert!(output.nodes.is_none());
2031 assert!(output.edges.is_none());
2032
2033 let s = &output.stats;
2038 assert!(s.total_records >= s.exact_dups + s.fuzzy_dups + s.hard_negs + s.uniques);
2039 assert!(s.total_records > 0);
2040 assert!(s.masters > 0);
2041
2042 let _ = std::fs::remove_dir_all(&dir);
2043 }
2044
2045 #[test]
2046 fn test_run_pipeline_ipc_format() {
2047 let dir = scratch_dir("ipc");
2048 let output = run_kyc(&dir, "ipc", false);
2049 assert!(output.output_files[0].ends_with(".ipc"));
2050 assert!(std::path::Path::new(&output.output_files[0]).exists());
2051 let _ = std::fs::remove_dir_all(&dir);
2052 }
2053
2054 #[test]
2055 fn test_run_pipeline_graph_output() {
2056 let dir = scratch_dir("graph");
2057 let output = run_kyc(&dir, "parquet", true);
2058 let nodes = output.nodes.expect("nodes path");
2059 let edges = output.edges.expect("edges path");
2060 assert!(std::path::Path::new(&nodes).exists());
2061 assert!(std::path::Path::new(&edges).exists());
2062 let _ = std::fs::remove_dir_all(&dir);
2063 }
2064
2065 #[test]
2066 fn test_fk_edge_weight_from_schema_and_default() {
2067 let dir = scratch_dir("fk_weight");
2074 let schema =
2075 load_schema("aviation", &manifest_dir().join("schemas")).expect("load aviation.json");
2076 let mut ctx = Context::new(
2077 "aviation",
2078 "en",
2079 &manifest_dir().join("assets/pools").to_string_lossy(),
2080 )
2081 .expect("load context");
2082 let config = build_pipeline_config(
2083 "aviation",
2084 300,
2085 42,
2086 "medium",
2087 0.1,
2088 0.3,
2089 &schema,
2090 "pipeline_test_fk_weight",
2091 "ipc",
2092 true,
2093 "ipc",
2094 )
2095 .expect("build config");
2096 ctx.enable_watermark(&config.domain, config.size, config.seed);
2097 let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
2098
2099 let edges_path = output.edges.expect("edges path");
2100 let file = std::fs::File::open(&edges_path).expect("open edges file");
2101 let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
2102
2103 let mut subtypes_seen: std::collections::HashMap<String, f64> =
2104 std::collections::HashMap::new();
2105 for batch in reader {
2106 let batch = batch.expect("read edges batch");
2107 let edge_type = batch
2108 .column_by_name("edge_type")
2109 .expect("edge_type col")
2110 .as_any()
2111 .downcast_ref::<arrow::array::StringArray>()
2112 .expect("edge_type as string");
2113 let subtype = batch
2114 .column_by_name("subtype")
2115 .expect("subtype col")
2116 .as_any()
2117 .downcast_ref::<arrow::array::StringArray>()
2118 .expect("subtype as string");
2119 let weight = batch
2120 .column_by_name("weight")
2121 .expect("weight col")
2122 .as_any()
2123 .downcast_ref::<arrow::array::Float64Array>()
2124 .expect("weight as f64");
2125 for i in 0..batch.num_rows() {
2126 if edge_type.value(i) == "fk" {
2127 subtypes_seen.insert(subtype.value(i).to_string(), weight.value(i));
2128 }
2129 }
2130 }
2131
2132 assert_eq!(
2133 subtypes_seen.get("aircraft_id"),
2134 Some(&0.5),
2135 "flight->aircraft fk_weight from schema not propagated to edge weight"
2136 );
2137 assert_eq!(
2138 subtypes_seen.get("airline_id"),
2139 Some(&1.0),
2140 "flight->airline fk_weight (schema default) not propagated to edge weight"
2141 );
2142
2143 let _ = std::fs::remove_dir_all(&dir);
2144 }
2145
2146 #[test]
2147 fn test_run_pipeline_deterministic_stats() {
2148 let dir_a = scratch_dir("det_a");
2149 let dir_b = scratch_dir("det_b");
2150 let a = run_kyc(&dir_a, "parquet", false);
2151 let b = run_kyc(&dir_b, "parquet", false);
2152 assert_eq!(a.stats.total_records, b.stats.total_records);
2153 assert_eq!(a.stats.exact_dups, b.stats.exact_dups);
2154 assert_eq!(a.stats.fuzzy_dups, b.stats.fuzzy_dups);
2155 assert_eq!(a.stats.hard_negs, b.stats.hard_negs);
2156 assert_eq!(a.stats.uniques, b.stats.uniques);
2157 assert_eq!(a.stats.masters, b.stats.masters);
2158 let _ = std::fs::remove_dir_all(&dir_a);
2159 let _ = std::fs::remove_dir_all(&dir_b);
2160 }
2161
2162 #[test]
2163 fn test_distribute_by_weight_sums_to_total_and_is_proportional() {
2164 let weights = [500_000usize, 216_666];
2172 let counts = distribute_by_weight(283_334, &weights);
2173 assert_eq!(counts.len(), 2);
2174 assert_eq!(counts.iter().sum::<usize>(), 283_334);
2175 assert!(counts[0] > 0, "first batch got zero duplicates");
2179 assert!(counts[1] > 0, "last batch got zero duplicates");
2180 assert!(counts[0] > counts[1]);
2181
2182 assert_eq!(distribute_by_weight(0, &weights), vec![0, 0]);
2184 assert_eq!(distribute_by_weight(100, &[0, 0]), vec![0, 0]);
2185 }
2186
2187 #[test]
2188 fn test_entity_prefix_unique_per_index() {
2189 let a = entity_prefix(0);
2190 let b = entity_prefix(1);
2191 assert_ne!(a, b);
2192 assert_eq!(a, "E00000");
2193 assert_eq!(entity_prefix(42), "E00042");
2194 }
2195
2196 #[test]
2197 fn test_run_pipeline_hell_multi_pass_touches_multiple_columns() {
2198 let dir = scratch_dir("hell_multipass");
2207 let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
2208 let mut ctx = Context::new(
2209 "kyc",
2210 "en",
2211 &manifest_dir().join("assets/pools").to_string_lossy(),
2212 )
2213 .expect("load context");
2214 let config = build_pipeline_config(
2215 "kyc",
2216 5000,
2217 7,
2218 "hell",
2219 0.1,
2220 0.3,
2221 &schema,
2222 "pipeline_test_hell_multipass",
2223 "ipc",
2224 false,
2225 "ipc",
2226 )
2227 .expect("build config");
2228 ctx.enable_watermark(&config.domain, config.size, config.seed);
2229 let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
2230
2231 let read_all = |path: &str| -> RecordBatch {
2232 let file = std::fs::File::open(path).unwrap();
2233 let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
2234 let batches: Vec<RecordBatch> = reader.map(|b| b.unwrap()).collect();
2235 let schema = batches[0].schema();
2236 let refs: Vec<Vec<&dyn Array>> = (0..schema.fields().len())
2237 .map(|i| batches.iter().map(|b| b.column(i).as_ref()).collect())
2238 .collect();
2239 let cols: Vec<ArrayRef> = refs
2240 .iter()
2241 .map(|r| arrow::compute::concat(r).unwrap())
2242 .collect();
2243 RecordBatch::try_new(schema, cols).unwrap()
2244 };
2245 let ds = read_all(&output.output_files[0]);
2246 let gt = read_all(&output.gt_file);
2247
2248 let ds_rid = ds.column_by_name("record_id").unwrap().as_string::<i32>();
2249 let ds_mid = ds.column_by_name("master_id").unwrap().as_string::<i32>();
2250 let gt_rid = gt.column_by_name("record_id").unwrap().as_string::<i32>();
2251 let gt_mt = gt.column_by_name("match_type").unwrap().as_string::<i32>();
2252
2253 let mut match_type_by_rid: HashMap<&str, &str> = HashMap::new();
2254 for i in 0..gt.num_rows() {
2255 match_type_by_rid.insert(gt_rid.value(i), gt_mt.value(i));
2256 }
2257
2258 let mut master_row_by_mid: HashMap<&str, usize> = HashMap::new();
2265 for i in 0..ds.num_rows() {
2266 master_row_by_mid.entry(ds_mid.value(i)).or_insert(i);
2267 }
2268
2269 let compare_cols: Vec<usize> = (0..ds.num_columns())
2270 .filter(|&i| {
2271 let field = ds.schema().field(i).clone();
2272 matches!(field.data_type(), DataType::Utf8)
2273 && !matches!(
2274 field.name().as_str(),
2275 "record_id" | "domain" | "entity_type" | "master_id"
2276 )
2277 && !field.name().ends_with("_id")
2278 })
2279 .collect();
2280
2281 let mut max_changed_cols = 0usize;
2282 for i in 0..ds.num_rows() {
2283 if match_type_by_rid.get(ds_rid.value(i)) != Some(&"fuzzy_dup") {
2284 continue;
2285 }
2286 let Some(&master_i) = master_row_by_mid.get(ds_mid.value(i)) else {
2287 continue;
2288 };
2289 let mut changed = 0usize;
2290 for &col_i in &compare_cols {
2291 let col = ds.column(col_i).as_string::<i32>();
2292 let a = (!col.is_null(i)).then(|| col.value(i));
2293 let b = (!col.is_null(master_i)).then(|| col.value(master_i));
2294 if a != b {
2295 changed += 1;
2296 }
2297 }
2298 max_changed_cols = max_changed_cols.max(changed);
2299 }
2300
2301 assert!(
2302 max_changed_cols >= 2,
2303 "expected at least one fuzzy_dup row to differ from its master on \
2304 2+ columns (evidence of multiple noise passes), got max {max_changed_cols}"
2305 );
2306
2307 let _ = std::fs::remove_dir_all(&dir);
2308 }
2309
2310 #[test]
2311 fn test_record_id_and_pad_string_roundtrip() {
2312 let rid = record_id_string(7);
2313 assert_eq!(rid, "R-0000000000007");
2314 let pad = pad_string(7);
2315 assert_eq!(pad, "0000000000007");
2316 let strs = record_id_strs(0..3);
2317 assert_eq!(strs.len(), 3);
2318 assert_eq!(strs[0], record_id_string(0));
2319 assert_eq!(strs[2], record_id_string(2));
2320 }
2321
2322 #[test]
2332 #[ignore]
2333 fn hunt_h1_zip_cost_vs_column_count() {
2334 use arrow::array::StringArray;
2335 use arrow::datatypes::{DataType, Field, Schema};
2336
2337 fn make_batch(n_rows: usize, n_cols: usize) -> RecordBatch {
2338 let fields: Vec<Field> = (0..n_cols)
2339 .map(|i| Field::new(format!("col{i}"), DataType::Utf8, false))
2340 .collect();
2341 let cols: Vec<ArrayRef> = (0..n_cols)
2342 .map(|_| {
2343 Arc::new(StringArray::from(
2344 (0..n_rows)
2345 .map(|_| "abcdefghijklmnop".to_string())
2346 .collect::<Vec<_>>(),
2347 )) as ArrayRef
2348 })
2349 .collect();
2350 RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
2351 }
2352
2353 const N_ROWS: usize = 100_000;
2354 const N_REPS: usize = 5;
2355 const EXTRA_PASSES: usize = 2;
2359
2360 eprintln!("\n[hunt_h1] N_ROWS={N_ROWS} N_REPS={N_REPS} EXTRA_PASSES={EXTRA_PASSES}");
2361 eprintln!("[hunt_h1] C=total columns, T=1 noised column (fixed) -- H1 predicts cost ∝ C");
2362
2363 for &n_cols in &[8usize, 15, 23] {
2364 let plan_cols = vec!["col0".to_string()];
2365 let all_types: Vec<&str> = vec!["typo"];
2366 let mut total = std::time::Duration::ZERO;
2367
2368 for rep in 0..N_REPS {
2369 let batch = make_batch(N_ROWS, n_cols);
2370 let mut rng = Rng::new(42 + rep as u64);
2371 let t0 = std::time::Instant::now();
2372
2373 let (noisy, mut unchanged, _) =
2374 apply_noise_with_retry(&batch, "typo", &plan_cols, &mut rng, &[]).unwrap();
2375 let mut current = noisy;
2376 for _ in 0..EXTRA_PASSES {
2377 current = apply_extra_pass_per_row(
2378 ¤t,
2379 &mut unchanged,
2380 &all_types,
2381 &mut rng,
2382 &[],
2383 )
2384 .unwrap();
2385 }
2386 std::hint::black_box(¤t);
2387 total += t0.elapsed();
2388 }
2389
2390 let avg = total / N_REPS as u32;
2391 eprintln!(
2392 "[hunt_h1] C={n_cols:2} avg={:>8.3}ms per_column={:>7.4}ms per_row_per_column={:>10.6}us",
2393 avg.as_secs_f64() * 1000.0,
2394 avg.as_secs_f64() * 1000.0 / n_cols as f64,
2395 avg.as_secs_f64() * 1_000_000.0 / (N_ROWS * n_cols) as f64,
2396 );
2397 }
2398 }
2399
2400 #[test]
2417 #[ignore]
2418 fn hunt_h1_residual_kyc_vs_nonprofit() {
2419 use arrow::array::StringArray;
2420 use arrow::datatypes::{DataType, Field, Schema};
2421
2422 fn make_batch(n_rows: usize, col_names: &[&str]) -> RecordBatch {
2423 let fields: Vec<Field> = col_names
2424 .iter()
2425 .map(|n| Field::new(*n, DataType::Utf8, true))
2426 .collect();
2427 let cols: Vec<ArrayRef> = col_names
2428 .iter()
2429 .map(|_| {
2430 Arc::new(StringArray::from(
2431 (0..n_rows)
2432 .map(|_| "abcdefghijklmnop testword".to_string())
2433 .collect::<Vec<_>>(),
2434 )) as ArrayRef
2435 })
2436 .collect();
2437 RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
2438 }
2439
2440 const N_ROWS: usize = 100_000;
2441 const N_REPS: usize = 5;
2442 const HELL_TYPES: &[&str] = &[
2443 "typo_aggressive",
2444 "visual",
2445 "unicode_pollution",
2446 "names",
2447 "dates",
2448 "identifiers",
2449 "addresses",
2450 "companies",
2451 "extra",
2452 ];
2453 const HELL_PASSES: usize = 8; let kyc_cols: &[&str] = &[
2456 "party_id",
2457 "given_name",
2458 "family_name",
2459 "middle_name",
2460 "birth_date",
2461 "birth_place",
2462 "nationality",
2463 "email_address",
2464 "mobile_phone",
2465 "personal_administrative_number",
2466 "tax_id",
2467 "residential_address_line1",
2468 "residential_city",
2469 "residential_postal_code",
2470 "residential_state",
2471 "residential_country",
2472 "occupation",
2473 "risk_score",
2474 "document_type",
2475 "document_number",
2476 "source_system",
2477 ];
2478 let nonprofit_cols: &[&str] = &[
2479 "donation_id",
2480 "donor_id",
2481 "campaign_id",
2482 "donation_date",
2483 "payment_method",
2484 "source_system",
2485 ];
2486
2487 eprintln!("\n[hunt_h1_residual] N_ROWS={N_ROWS} N_REPS={N_REPS} HELL_PASSES={HELL_PASSES}");
2488
2489 {
2494 let batch = make_batch(N_ROWS, kyc_cols);
2495 eprintln!("[hunt_h1_residual] per-category breakdown on kyc.natural_person:");
2496 for &ttype in HELL_TYPES {
2497 let mut rng = Rng::new(42);
2498 let t0 = std::time::Instant::now();
2499 let (_, _, tcols) =
2500 apply_noise_with_retry(&batch, ttype, &[], &mut rng, &[]).unwrap();
2501 eprintln!(
2502 "[hunt_h1_residual] {ttype:20} {:>8.3}ms target_cols={:?}",
2503 t0.elapsed().as_secs_f64() * 1000.0,
2504 tcols,
2505 );
2506 }
2507 }
2508
2509 for (label, col_names) in [
2510 ("kyc.natural_person", kyc_cols),
2511 ("nonprofit.donation", nonprofit_cols),
2512 ] {
2513 let mut total = std::time::Duration::ZERO;
2514 let mut total_target_hits = 0usize;
2515
2516 for rep in 0..N_REPS {
2517 let batch = make_batch(N_ROWS, col_names);
2518 let mut rng = Rng::new(42 + rep as u64);
2519 let t0 = std::time::Instant::now();
2520
2521 let (noisy, mut unchanged, t0cols) =
2524 apply_noise_with_retry(&batch, "identifiers", &[], &mut rng, &[]).unwrap();
2525 total_target_hits += t0cols.len();
2526 let mut current = noisy;
2527 for _ in 1..HELL_PASSES {
2528 current = apply_extra_pass_per_row(
2529 ¤t,
2530 &mut unchanged,
2531 HELL_TYPES,
2532 &mut rng,
2533 &[],
2534 )
2535 .unwrap();
2536 }
2537 std::hint::black_box(¤t);
2538 total += t0.elapsed();
2539 }
2540
2541 let avg = total / N_REPS as u32;
2542 eprintln!(
2543 "[hunt_h1_residual] {label:20} C={:2} avg={:>8.3}ms avg_first_pass_target_cols={:.1}",
2544 col_names.len(),
2545 avg.as_secs_f64() * 1000.0,
2546 total_target_hits as f64 / N_REPS as f64,
2547 );
2548 }
2549 }
2550}