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 graph_enabled: bool,
37 pub graph_format: String,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct EntityPlan {
42 pub name: String,
43 pub n_base: usize,
44 pub identifier_col: Option<String>,
45 pub columns_json: String,
46 pub noise_types: Vec<NoisePlanEntry>,
47 pub fk_remaps: Vec<FkRemapEntry>,
48}
49
50#[derive(Debug, Deserialize)]
51pub struct NoisePlanEntry {
52 pub noise_type: String,
53 pub columns: Vec<String>,
54 pub count: usize,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct FkRemapEntry {
59 pub source_col: String,
60 pub target_entity: String,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct HnTypeConfig {
65 pub entity_type: String,
66 pub config_json: String,
67 pub count: usize,
68}
69
70#[derive(Debug)]
71pub struct PipelineOutput {
72 pub output_files: Vec<String>,
73 pub gt_file: String,
74 pub stats: PipelineStats,
75 pub nodes: Option<String>,
76 pub edges: Option<String>,
77}
78
79#[derive(Debug)]
80pub struct PipelineStats {
81 pub total_records: usize,
82 pub exact_dups: usize,
83 pub hard_negs: usize,
84 pub uniques: usize,
85 pub masters: usize,
86}
87
88const RID_LEN: usize = 15; const PAD_LEN: usize = 13; #[inline]
103fn record_id_string(i: usize) -> String {
104 let mut buf = [0u8; RID_LEN];
105 buf[0] = b'R';
106 buf[1] = b'-';
107 let mut n = i;
108 for j in (2..RID_LEN).rev() {
109 buf[j] = b'0' + (n % 10) as u8;
110 n /= 10;
111 }
112 String::from_utf8(buf.to_vec()).unwrap()
113}
114
115#[inline]
116fn pad_string(i: usize) -> String {
117 let mut buf = [0u8; PAD_LEN];
118 let mut n = i;
119 for j in (0..PAD_LEN).rev() {
120 buf[j] = b'0' + (n % 10) as u8;
121 n /= 10;
122 }
123 String::from_utf8(buf.to_vec()).unwrap()
124}
125
126pub(crate) fn record_id_strs(range: std::ops::Range<usize>) -> Vec<String> {
127 range.map(record_id_string).collect()
128}
129
130fn entity_prefix(name: &str) -> String {
133 let h: u64 = name
134 .bytes()
135 .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
136 format!("E{:05}", h % 100000)
137}
138
139type FkPoolMap = HashMap<String, RecordBatch>;
142
143struct HnPool {
144 batch: RecordBatch,
145 total_count: usize,
146 record_ids: Vec<String>,
149}
150
151const PERSON_NAME_WORDS: &[&str] = &[
158 "name",
159 "first",
160 "last",
161 "given",
162 "family",
163 "operator",
164 "technician",
165 "inspector",
166 "claimant",
167 "consignee",
168 "investigator",
169 "collected_by",
170 "performed_by",
171 "assigned_to",
172];
173
174const COMPANY_NAME_WORDS: &[&str] = &[
180 "company",
181 "legal",
182 "trading",
183 "name",
184 "supplier",
185 "manufacturer",
186 "sponsor",
187 "institution",
188 "journal",
189 "funder",
190 "employer",
191 "law_firm",
192 "affiliation",
193 "reported_by",
194 "buyer",
195 "shipper",
196 "airline",
197];
198
199pub(crate) fn noise_type_targets_column(noise_type: &str, col_name: &str) -> bool {
205 let lower = col_name.to_lowercase();
206 match noise_type {
207 "typo" | "typo_aggressive" | "typo_extreme" | "qwerty_azerty" | "visual" | "homoglyph"
208 | "unicode_pollution" | "ocr_errors" | "case_swap" | "char_dropout" | "language_mix"
209 | "blocking_fail" => {
210 if lower.contains("email") {
212 return false;
213 }
214 contains_any(&lower, &["address", "street", "city", "phone"])
215 || contains_any(&lower, PERSON_NAME_WORDS)
216 || contains_any(&lower, COMPANY_NAME_WORDS)
217 }
218 "names" | "nickname" | "initials" | "partial" | "name_compound" | "swap" | "full_swap" => {
219 contains_any(&lower, PERSON_NAME_WORDS)
220 }
221 "dates" | "date_error" | "date_chaotic" | "date_format_mix" | "age_impossible" => {
222 contains_any(&lower, &["date", "birth", "incorporation", "founding"])
223 }
224 "missing" | "missing_pattern" => {
225 contains_any(&lower, &["phone", "email", "mobile", "address", "street"])
226 }
227 "identifiers"
228 | "corrupt_email"
229 | "corrupt_phone"
230 | "corrupt_national_id"
231 | "corrupt_siren"
232 | "national_id_corrupt"
233 | "phone_corrupt"
234 | "email_corrupt"
235 | "siren_corrupt" => contains_any(
236 &lower,
237 &[
238 "email",
239 "phone",
240 "ssn",
241 "pan",
242 "passport",
243 "account",
244 "medicare",
245 "license",
246 "siren",
247 "national_id",
248 ],
249 ),
250 "extra"
251 | "name_null"
252 | "dob_null"
253 | "blocking_fail_initial"
254 | "blocking_fail_partial"
255 | "fuzzy_match"
256 | "phonetic" => {
257 if lower.contains("email") {
259 return false;
260 }
261 contains_any(
262 &lower,
263 &["phone", "address", "street", "city", "note", "comment"],
264 ) || contains_any(&lower, PERSON_NAME_WORDS)
265 || contains_any(&lower, COMPANY_NAME_WORDS)
266 }
267 "companies" | "acronym" | "legal_form_drop" | "word_dropout" | "company_scramble" => {
268 contains_any(&lower, COMPANY_NAME_WORDS)
269 }
270 "addresses" | "address_scramble" | "postal_corrupt" => {
271 contains_any(&lower, &["address", "street", "postal", "city"])
272 }
273 "exact" | "english_name" | "estonian_name" | "lithuanian_name" | "slovak_name"
274 | "serbian_name" | "norwegian_name" | "swedish_name" | "dutch_name" | "czech_name"
275 | "albanian_name" | "polish_name" | "romanian_name" | "hungarian_name" | "german_name"
276 | "italian_name" | "spanish_name" | "portuguese_name" | "combo_hard" | "combo_extreme"
277 | "combo_ultimate" | "french_address" => {
278 false
280 }
281 _ => true,
283 }
284}
285
286fn match_noise_columns(schema: &Schema, noise_type: &str, exclude_cols: &[String]) -> Vec<String> {
287 let mut matched: Vec<String> = Vec::new();
288 for field in schema.fields() {
289 if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
290 continue;
291 }
292 if exclude_cols.contains(field.name()) {
293 continue;
294 }
295 if noise_type_targets_column(noise_type, field.name()) {
296 matched.push(field.name().clone());
297 }
298 }
299 if matches!(noise_type, "swap" | "full_swap") && matched.len() >= 2 {
301 matched.truncate(2);
302 }
303 matched.sort();
304 matched.dedup();
305 matched
306}
307
308fn contains_any(s: &str, patterns: &[&str]) -> bool {
309 patterns.iter().any(|p| s.contains(p))
310}
311
312fn apply_noise_to_batch(
315 rb: &RecordBatch,
316 noise_type: &str,
317 plan_cols: &[String],
318 rng: &mut Rng,
319 exclude_cols: &[String],
320) -> Result<RecordBatch, String> {
321 let schema = rb.schema();
322 let n_cols = rb.num_columns();
323
324 let target_cols: Vec<String> = if plan_cols.is_empty() {
326 match_noise_columns(&schema, noise_type, exclude_cols)
327 } else {
328 plan_cols.to_vec()
329 };
330
331 let mut new_columns: Vec<Option<ArrayRef>> = (0..n_cols).map(|_| None).collect();
333
334 let get_col = |new_columns: &[Option<ArrayRef>], idx: usize, rb: &RecordBatch| -> ArrayRef {
335 new_columns[idx]
336 .clone()
337 .unwrap_or_else(|| rb.column(idx).clone())
338 };
339
340 match noise_type {
341 "blocking_fail" => {
342 for col_name in &target_cols {
343 let lower = col_name.to_lowercase();
344 if let Ok(idx) = schema.index_of(col_name) {
345 let col = get_col(&new_columns, idx, rb);
346 if lower.contains("first") || lower.contains("given") {
347 new_columns[idx] =
348 Some(crate::noise::extra::apply_blocking_initial(&*col, rng));
349 } else {
350 new_columns[idx] =
351 Some(crate::noise::extra::apply_blocking_partial(&*col, rng));
352 }
353 }
354 }
355 }
356 "swap" | "full_swap" => {
357 if target_cols.len() >= 2 {
358 let name_a = &target_cols[0];
359 let name_b = &target_cols[1];
360 if let (Ok(idx_a), Ok(idx_b)) = (schema.index_of(name_a), schema.index_of(name_b)) {
361 let a = get_col(&new_columns, idx_a, rb);
362 let b = get_col(&new_columns, idx_b, rb);
363 new_columns[idx_a] = Some(b);
364 new_columns[idx_b] = Some(a);
365 }
366 }
367 }
368 "missing_pattern" => {
369 let mut phone_idx: Option<usize> = None;
370 let mut email_idx: Option<usize> = None;
371 for col_name in &target_cols {
372 let lower = col_name.to_lowercase();
373 if let Ok(idx) = schema.index_of(col_name) {
374 if lower.contains("phone") || lower.contains("mobile") {
375 phone_idx = Some(idx);
376 } else if lower.contains("email") {
377 email_idx = Some(idx);
378 }
379 }
380 }
381 if let (Some(pi), Some(ei)) = (phone_idx, email_idx) {
382 let phone_arr = get_col(&new_columns, pi, rb);
383 let email_arr = get_col(&new_columns, ei, rb);
384 let phone_s = phone_arr.as_string::<i32>();
385 let email_s = email_arr.as_string::<i32>();
386 let n = phone_s.len();
387 let mut builder = arrow::array::StringBuilder::with_capacity(n, 32);
388 for i in 0..n {
389 let pv = if phone_s.is_null(i) {
390 ""
391 } else {
392 phone_s.value(i)
393 };
394 let ev = if email_s.is_null(i) {
395 ""
396 } else {
397 email_s.value(i)
398 };
399 if !ev.is_empty() && rng.next_usize(2) == 0 {
400 builder.append_value("");
401 } else {
402 builder.append_value(pv);
403 }
404 }
405 new_columns[pi] = Some(Arc::new(builder.finish()));
406 }
407 }
408 _ => {
409 for col_name in &target_cols {
410 if let Ok(col_idx) = schema.index_of(col_name) {
411 let col = get_col(&new_columns, col_idx, rb);
412 let result = crate::noise::apply_noise_to_column(&*col, noise_type, rng)
413 .map_err(|e| format!("noise '{noise_type}' on '{col_name}': {e}"))?;
414 new_columns[col_idx] = Some(result);
415 }
416 }
417 }
418 }
419
420 let final_columns: Vec<ArrayRef> = (0..n_cols)
422 .map(|i| {
423 new_columns[i]
424 .clone()
425 .unwrap_or_else(|| rb.column(i).clone())
426 })
427 .collect();
428
429 RecordBatch::try_new(schema, final_columns).map_err(|e| format!("RecordBatch: {e}"))
430}
431
432fn topological_sort(
436 plans: &[EntityPlan],
437 name_to_idx: &HashMap<&str, usize>,
438) -> Result<Vec<usize>, String> {
439 let n = plans.len();
440 let mut in_degree = vec![0usize; n];
441 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
442 for i in 0..n {
443 for remap in &plans[i].fk_remaps {
444 let Some(&target) = name_to_idx.get(remap.target_entity.as_str()) else {
445 continue;
446 };
447 if target == i {
448 continue;
449 }
450 adj[target].push(i); in_degree[i] += 1;
452 }
453 }
454 let mut queue: Vec<usize> = (0..n).filter(|i| in_degree[*i] == 0).collect();
455 let mut order = Vec::with_capacity(n);
456 let mut idx = 0;
457 while idx < queue.len() {
458 let v = queue[idx];
459 idx += 1;
460 order.push(v);
461 for &next in &adj[v] {
462 in_degree[next] -= 1;
463 if in_degree[next] == 0 {
464 queue.push(next);
465 }
466 }
467 }
468 if order.len() != n {
469 return Err("FK dependency cycle detected among entities".into());
470 }
471 Ok(order)
472}
473
474pub fn run_pipeline(
477 ctx: &Context,
478 config: &PipelineConfig,
479 output_dir: &str,
480) -> Result<PipelineOutput, String> {
481 std::fs::create_dir_all(output_dir)
482 .map_err(|e| format!("create output directory {output_dir:?}: {e}"))?;
483 let t_start = std::time::Instant::now();
484
485 let t_alloc = t_start.elapsed().as_secs_f64();
486
487 let t1e = std::time::Instant::now();
491
492 let metadata = build_metadata_map(config);
498 let full_arc = Arc::new(build_full_schema(config, &metadata));
500 let mut dataset_path = format!("{}/{}.ipc", output_dir, config.run_id);
501 let out_file =
502 std::fs::File::create(&dataset_path).map_err(|e| format!("create {dataset_path}: {e}"))?;
503 let mut writer = arrow::ipc::writer::FileWriter::try_new(out_file, &full_arc)
504 .map_err(|e| format!("IPC FileWriter error: {e}"))?;
505
506 let name_to_idx: HashMap<&str, usize> = config
508 .entity_plans
509 .iter()
510 .enumerate()
511 .map(|(i, p)| (p.name.as_str(), i))
512 .collect();
513 let entity_order = topological_sort(&config.entity_plans, &name_to_idx)?;
514
515 let mut fk_pools: FkPoolMap = HashMap::new();
517 let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
518
519 let gt_ext = if config.output_format == "parquet" {
523 "parquet"
524 } else {
525 "ipc"
526 };
527 let gt_path = format!("{}/{}_ground_truth.{}", output_dir, config.run_id, gt_ext);
528 let gt_draft_path = format!("{}/{}_gt_draft.ipc", output_dir, config.run_id);
529 let mut gt_acc = crate::gt::GtAccumulator::new(>_draft_path)?;
530
531 let mut global_rid_offset = 0usize;
532
533 const GRAPH_MAX_CLUSTER_EDGES: usize = 10_000;
535 let graph_fmt = crate::graph_gen::GraphFormat::from_str(&config.graph_format);
536 let mut node_writer: Option<crate::graph_gen::NodeWriter> = None;
537 let mut edge_writer: Option<crate::graph_gen::EdgeWriter> = None;
538 let mut graph_nodes_path: Option<String> = None;
539 let mut graph_edges_path: Option<String> = None;
540 if config.graph_enabled {
541 let nodes_ipc = format!("{}/{}_nodes.ipc", output_dir, config.run_id);
542 let edges_ipc = format!("{}/{}_edges.ipc", output_dir, config.run_id);
543 node_writer = Some(
544 crate::graph_gen::NodeWriter::new(&nodes_ipc, &full_arc, &metadata)
545 .map_err(|e| format!("init node writer: {e}"))?,
546 );
547 edge_writer = Some(
548 crate::graph_gen::EdgeWriter::new(&edges_ipc, &metadata)
549 .map_err(|e| format!("init edge writer: {e}"))?,
550 );
551 graph_nodes_path = Some(nodes_ipc);
552 graph_edges_path = Some(edges_ipc);
553 }
554
555 let mut _t_write = 0.0f64;
556 let mut _t_meta = 0.0f64;
557 let mut _t_dup = 0.0f64;
558 let mut _write_calls = 0u64;
559 let hn_pool_sizing: std::collections::HashMap<&str, usize> =
561 config
562 .hard_neg_types
563 .iter()
564 .fold(std::collections::HashMap::new(), |mut m, hn| {
565 let entry = m.entry(hn.entity_type.as_str()).or_insert(0);
566 *entry = (*entry).max(hn.count);
567 m
568 });
569 let hn_max_pool: usize = hn_pool_sizing.values().max().copied().unwrap_or(50_000) * 2 + 1_000;
570 const FK_POOL_CAP: usize = 200_000;
576
577 let fk_targets: std::collections::HashSet<&str> = config
582 .entity_plans
583 .iter()
584 .flat_map(|p| p.fk_remaps.iter().map(|r| r.target_entity.as_str()))
585 .collect();
586
587 for &plan_idx in &entity_order {
588 let plan = &config.entity_plans[plan_idx];
589 if plan.n_base == 0 {
590 continue;
591 }
592
593 let prefix = entity_prefix(&plan.name);
594 let col_json_str = &plan.columns_json;
595
596 let fk_targeted = fk_targets.contains(plan.name.as_str());
598 let mut fk_builder = arrow::array::StringBuilder::new();
599 let mut fk_rid_builder = arrow::array::StringBuilder::new();
600 let mut fk_count: usize = 0;
601 let hn_targeted = hn_pool_sizing.contains_key(plan.name.as_str());
606 let mut hn_slices: Vec<RecordBatch> = Vec::new();
607 let mut hn_slice_rids: Vec<Vec<String>> = Vec::new();
608 let mut last_batch: Option<(RecordBatch, usize, usize)> = None;
609 let mut batch_rng = Rng::new(config.seed.wrapping_add(100));
610 let mut fk_rng = Rng::new(config.seed.wrapping_add(42));
611
612 let mut col_lookup: Option<Vec<Option<usize>>> = None;
614 let mut null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
616 let mut const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
617
618 for (batch_idx, offset) in (0..plan.n_base)
620 .step_by(crate::entity_gen::BATCH_SIZE)
621 .enumerate()
622 {
623 let batch_n = (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE);
624 let batch_seed = config.seed.wrapping_add(batch_idx as u64 * 1000 + 1000);
625
626 let request_json = format!(
628 r#"{{"entity_name":"{}","n":{},"seed":{},"columns":{}}}"#,
629 plan.name, batch_n, batch_seed, col_json_str,
630 );
631 let rb = crate::entity_gen::generate_entity_batch(ctx, &request_json)?;
632
633 let mut fk_edges_by_remap: Vec<(String, Vec<String>)> = Vec::new();
635 let rb = if !plan.fk_remaps.is_empty() {
636 let mut r = rb;
637 for remap in &plan.fk_remaps {
638 if let Some(pool) = fk_pools.get(&remap.target_entity) {
639 let (remapped, target_rids) = crate::fk_remap::fk_remap_batch(
640 &r,
641 pool,
642 &remap.source_col,
643 &mut fk_rng,
644 config.graph_enabled,
645 )?;
646 r = remapped;
647 if config.graph_enabled
648 && let Some(rids) = target_rids
649 {
650 fk_edges_by_remap.push((remap.source_col.clone(), rids));
651 }
652 }
653 }
654 r
655 } else {
656 rb
657 };
658
659 if fk_targeted
662 && fk_count < FK_POOL_CAP
663 && let Some(ref id_col) = plan.identifier_col
664 && let Some(col) = rb.column_by_name(id_col)
665 {
666 let s = col.as_string::<i32>();
667 for i in 0..s.len() {
668 if fk_count >= FK_POOL_CAP {
669 break;
670 }
671 if !s.is_null(i) {
672 fk_builder.append_value(s.value(i));
673 if config.graph_enabled {
674 fk_rid_builder.append_value(record_id_string(global_rid_offset + i));
675 }
676 fk_count += 1;
677 }
678 }
679 }
680
681 let hn_accum: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
683 if hn_targeted && hn_accum < hn_max_pool {
684 let take = (hn_max_pool - hn_accum).min(batch_n);
685 hn_slices.push(rb.slice(0, take));
686 if config.graph_enabled {
690 hn_slice_rids.push(record_id_strs(global_rid_offset..global_rid_offset + take));
691 }
692 }
693
694 if col_lookup.is_none() {
696 let plan_schema = rb.schema();
697 col_lookup = Some(
698 full_arc
699 .fields()
700 .iter()
701 .skip(4)
702 .map(|f| plan_schema.column_with_name(f.name()).map(|(idx, _)| idx))
703 .collect(),
704 );
705 }
706
707 let batch_mids: Vec<String> = (offset..offset + batch_n)
709 .map(|i| format!("{}-{}", prefix, pad_string(i)))
710 .collect();
711 let batch_mids_slice = &batch_mids[..];
712
713 let rid_slice = record_id_strs(global_rid_offset..global_rid_offset + batch_n);
715 let t_m0 = std::time::Instant::now();
716 let base_rb = add_metadata_and_align(
717 &rb,
718 &config.domain,
719 &plan.name,
720 &rid_slice,
721 batch_mids_slice,
722 &full_arc,
723 col_lookup.as_ref().unwrap(),
724 &mut null_cache,
725 &mut const_arr_cache,
726 );
727 _t_meta += t_m0.elapsed().as_secs_f64();
728
729 if config.graph_enabled
731 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
732 {
733 nw.write_batch(&base_rb)
734 .map_err(|e| format!("write node: {e}"))?;
735 for i in 0..batch_n {
736 for (subtype, target_rids) in &fk_edges_by_remap {
737 ew.push(&rid_slice[i], &target_rids[i], "fk", subtype, 1.0)
738 .map_err(|e| format!("push fk edge: {e}"))?;
739 }
740 }
741 }
742
743 let t_w0 = std::time::Instant::now();
744 writer
745 .write(&base_rb)
746 .map_err(|e| format!("write base: {e}"))?;
747 _t_write += t_w0.elapsed().as_secs_f64();
748 _write_calls += 1;
749
750 gt_acc.push_base_batch(base_rb.column(0), base_rb.column(2), base_rb.column(3))?;
752
753 global_rid_offset += batch_n;
754 last_batch = Some((rb, batch_n, offset));
755 }
756
757 if let Some(id_col) = &plan.identifier_col
759 && fk_builder.len() > 0
760 {
761 let arr = Arc::new(fk_builder.finish()) as ArrayRef;
762 if config.graph_enabled {
763 let rid_arr = Arc::new(fk_rid_builder.finish()) as ArrayRef;
767 let schema = Arc::new(Schema::new(vec![
768 Field::new(id_col, DataType::Utf8, true),
769 Field::new("record_id", DataType::Utf8, true),
770 ]));
771 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr, rid_arr]) {
772 fk_pools.insert(plan.name.clone(), pool_rb);
773 }
774 } else {
775 let schema = Arc::new(Schema::new(vec![Field::new(id_col, DataType::Utf8, true)]));
776 if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr]) {
777 fk_pools.insert(plan.name.clone(), pool_rb);
778 }
779 }
780 }
781
782 if !hn_slices.is_empty() {
784 let total_count: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
785 let batch = if hn_slices.len() == 1 {
786 let only = hn_slices.into_iter().next().unwrap();
793 let idx = UInt64Array::from_iter_values(0..only.num_rows() as u64);
794 pick_rows(&only, &idx)?
795 } else {
796 let schema = hn_slices[0].schema();
797 let n_fields = schema.fields().len();
798 let mut concat_arrays = Vec::with_capacity(n_fields);
799 for i in 0..n_fields {
800 let refs: Vec<&dyn Array> =
801 hn_slices.iter().map(|pb| pb.column(i).as_ref()).collect();
802 concat_arrays.push(arrow::compute::concat(&refs).expect("hn pool concat col"));
803 }
804 RecordBatch::try_new(schema, concat_arrays).expect("hn pool concat batch")
805 };
806 let record_ids = if config.graph_enabled {
808 hn_slice_rids.into_iter().flatten().collect::<Vec<String>>()
809 } else {
810 Vec::new()
811 };
812 hn_pools.insert(
813 plan.name.clone(),
814 HnPool {
815 batch,
816 total_count,
817 record_ids,
818 },
819 );
820 }
821
822 let has_dups: bool = plan.noise_types.iter().any(|n| n.count > 0);
824 if has_dups && let Some((ref last_rb, last_n, last_offset)) = last_batch {
825 let t_d0 = std::time::Instant::now();
826 let mut dup_batches: Vec<RecordBatch> = Vec::new();
827 let mut dup_mids_buf: Vec<String> = Vec::new();
828
829 let fk_exclude_cols: Vec<String> = plan
831 .fk_remaps
832 .iter()
833 .map(|r| r.source_col.clone())
834 .collect();
835
836 let ndata: Vec<(UInt64Array, u64, &str, &[String], usize)> = plan
838 .noise_types
839 .iter()
840 .filter(|n| n.count > 0)
841 .map(|n| {
842 let mut b = UInt64Builder::with_capacity(n.count);
843 for _ in 0..n.count {
844 b.append_value(batch_rng.next_usize(last_n) as u64);
845 }
846 (
847 b.finish(),
848 batch_rng.next_u64(),
849 n.noise_type.as_str(),
850 n.columns.as_slice(),
851 n.count,
852 )
853 })
854 .collect();
855
856 let mut results: Vec<Result<(RecordBatch, Vec<String>), String>> =
857 Vec::with_capacity(ndata.len());
858 std::thread::scope(|s| {
859 let mut handles = Vec::with_capacity(ndata.len());
860 for (indices, seed, ntype, cols, cnt) in &ndata {
861 let rb = last_rb.clone();
862 let idxs = indices.clone();
863 let cols_v: Vec<String> = cols.to_vec();
864 let exclude = fk_exclude_cols.clone();
865 let prefix_ref: &str = &prefix;
866 handles.push(s.spawn(move || {
867 let dup = pick_rows(&rb, &idxs)?;
868 let mut rng = Rng::new(*seed);
869 let noisy = apply_noise_to_batch(&dup, ntype, &cols_v, &mut rng, &exclude)?;
870 let mut mb = Vec::with_capacity(*cnt);
871 for j in 0..*cnt {
872 let global_idx = last_offset + idxs.value(j) as usize;
877 mb.push(format!("{}-{}", prefix_ref, pad_string(global_idx)));
878 }
879 Ok((noisy, mb))
880 }));
881 }
882 for h in handles {
883 results.push(h.join().unwrap());
884 }
885 });
886 for res in results {
887 let (rb, mb) = res?;
888 dup_batches.push(rb);
889 dup_mids_buf.extend(mb);
890 }
891 _t_dup += t_d0.elapsed().as_secs_f64();
892
893 if !dup_batches.is_empty() {
895 let dup_total = dup_mids_buf.len();
896 let dup_rids = record_id_strs(global_rid_offset..global_rid_offset + dup_total);
897
898 let concated = if dup_batches.len() == 1 {
899 dup_batches.into_iter().next().unwrap()
900 } else {
901 let schema = dup_batches[0].schema();
902 let n_fields = dup_batches[0].num_columns();
903 let mut concat_arrays = Vec::with_capacity(n_fields);
904 for i in 0..n_fields {
905 let refs: Vec<&dyn Array> =
906 dup_batches.iter().map(|b| b.column(i).as_ref()).collect();
907 concat_arrays.push(
908 arrow::compute::concat(&refs)
909 .map_err(|e| format!("concat dup col {i}: {e}"))?,
910 );
911 }
912 RecordBatch::try_new(schema, concat_arrays)
913 .map_err(|e| format!("concat dups: {e}"))?
914 };
915
916 let dup_rb_full = add_metadata_and_align(
917 &concated,
918 &config.domain,
919 &plan.name,
920 &dup_rids,
921 &dup_mids_buf,
922 &full_arc,
923 col_lookup.as_ref().unwrap(),
924 &mut null_cache,
925 &mut const_arr_cache,
926 );
927
928 let t_wd = std::time::Instant::now();
929 writer
930 .write(&dup_rb_full)
931 .map_err(|e| format!("write dups: {e}"))?;
932 _t_write += t_wd.elapsed().as_secs_f64();
933 _write_calls += 1;
934
935 if config.graph_enabled
937 && let Some(nw) = node_writer.as_mut()
938 {
939 nw.write_batch(&dup_rb_full)
940 .map_err(|e| format!("write dup node: {e}"))?;
941 }
942
943 gt_acc.push_dup_batch(
944 dup_rb_full.column(0),
945 dup_rb_full.column(2),
946 dup_rb_full.column(3),
947 )?;
948 global_rid_offset += dup_total;
949 }
950 }
951 }
952 let t1e_elapsed = t1e.elapsed().as_secs_f64();
953 log::debug!("[sink_profile] write={_t_write:.3}s calls={_write_calls}");
954
955 let t2 = std::time::Instant::now();
957 let mut hn_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
959 let mut hn_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
960 for (hn_idx, hn_cfg) in config.hard_neg_types.iter().enumerate() {
961 if hn_cfg.count == 0 {
962 continue;
963 }
964 let Some(pool_data) = hn_pools.get(&hn_cfg.entity_type) else {
965 continue;
966 };
967 let hn_total = pool_data.total_count;
968
969 let pool_rb = &pool_data.batch;
970 let n_hn_max = hn_cfg.count.min(hn_total / 2);
971 if n_hn_max == 0 {
972 continue;
973 }
974
975 let (hn_rb, hn_src) = crate::hn_common::generate_hard_negatives(
976 pool_rb,
977 &hn_cfg.config_json,
978 n_hn_max,
979 config.seed.wrapping_add(200),
980 config.graph_enabled,
981 )?;
982
983 let n_hn = hn_rb.num_rows();
984 if n_hn == 0 {
985 continue;
986 }
987
988 let hn_rids = record_id_strs(global_rid_offset..global_rid_offset + n_hn);
989
990 let mut hn_mid_rng = Rng::new(config.seed.wrapping_add(300 + hn_idx as u64));
991 let hn_mids: Vec<String> = (0..n_hn)
992 .map(|_i| {
993 let pad = hn_mid_rng.next_usize(10_000_000);
994 format!("HN-{:07}", pad)
995 })
996 .collect();
997
998 let hn_schema = hn_rb.schema();
1000 let hn_col_lookup: Vec<Option<usize>> = full_arc
1001 .fields()
1002 .iter()
1003 .skip(4)
1004 .map(|f| hn_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1005 .collect();
1006
1007 let hn_rb_full = add_metadata_and_align(
1008 &hn_rb,
1009 &config.domain,
1010 &hn_cfg.entity_type,
1011 &hn_rids,
1012 &hn_mids,
1013 &full_arc,
1014 &hn_col_lookup,
1015 &mut hn_null_cache,
1016 &mut hn_const_arr_cache,
1017 );
1018
1019 let t_wh = std::time::Instant::now();
1020 writer
1021 .write(&hn_rb_full)
1022 .map_err(|e| format!("write hn: {e}"))?;
1023 _t_write += t_wh.elapsed().as_secs_f64();
1024 _write_calls += 1;
1025
1026 if config.graph_enabled
1029 && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1030 {
1031 nw.write_batch(&hn_rb_full)
1032 .map_err(|e| format!("write hn node: {e}"))?;
1033 if let Some((idx_a, pattern)) = hn_src {
1034 for i in 0..n_hn {
1035 let tgt = &pool_data.record_ids[idx_a[i]];
1036 ew.push(&hn_rids[i], tgt, "hard_neg", &pattern, 1.0)
1037 .map_err(|e| format!("push hn edge: {e}"))?;
1038 }
1039 }
1040 }
1041
1042 gt_acc.push_other_batch(
1044 hn_rb_full.column(0),
1045 hn_rb_full.column(2),
1046 hn_rb_full.column(3),
1047 )?;
1048
1049 global_rid_offset += n_hn;
1050 }
1051 let t2_elapsed = t2.elapsed().as_secs_f64();
1052
1053 {
1055 let mut canary_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1056 let mut canary_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1057 crate::canary::generate_all(
1058 ctx,
1059 config,
1060 &full_arc,
1061 &mut canary_null_cache,
1062 &mut canary_const_arr_cache,
1063 &mut global_rid_offset,
1064 &fk_pools,
1065 &mut writer,
1066 &mut node_writer,
1067 &mut gt_acc,
1068 )?;
1069 }
1070
1071 let t3 = std::time::Instant::now();
1073 writer
1074 .finish()
1075 .map_err(|e| format!("finish IPC writer: {e}"))?;
1076 let t3a_elapsed = t3.elapsed().as_secs_f64();
1077
1078 let t3b = std::time::Instant::now();
1079 let t_gt0 = std::time::Instant::now();
1080 let crate::gt::GtResult {
1081 n_exact_dup,
1082 n_hard_neg,
1083 n_unique,
1084 n_masters,
1085 cluster_map,
1086 } = gt_acc.finish(
1087 config.difficulty.as_str(),
1088 &config.output_format,
1089 >_path,
1090 &metadata,
1091 )?;
1092 let _t_gt_compute = 0.0f64;
1093 let _t_gt_write = t_gt0.elapsed().as_secs_f64();
1094 let t3b_elapsed = t3b.elapsed().as_secs_f64();
1095 let _t3_elapsed = t3.elapsed().as_secs_f64();
1096
1097 if config.graph_enabled
1099 && let Some(ew) = edge_writer.as_mut()
1100 {
1101 crate::graph_gen::push_dup_clusters(ew, &cluster_map, GRAPH_MAX_CLUSTER_EDGES)
1102 .map_err(|e| format!("push dup clusters: {e}"))?;
1103 }
1104
1105 if config.output_format == "parquet" {
1107 use arrow::ipc::reader::FileReader;
1108
1109 let parquet_path = dataset_path.replace(".ipc", ".parquet");
1110 let ipc_file = std::fs::File::open(&dataset_path)
1111 .map_err(|e| format!("open IPC for conversion: {e}"))?;
1112 let ipc_reader = FileReader::try_new(ipc_file, None)
1113 .map_err(|e| format!("IPC reader for conversion: {e}"))?;
1114 let schema = ipc_reader.schema();
1115
1116 let parquet_file = std::fs::File::create(&parquet_path)
1117 .map_err(|e| format!("create {parquet_path}: {e}"))?;
1118 let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1119 let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
1120 .clone()
1121 .into_iter()
1122 .map(|(k, v)| parquet::file::metadata::KeyValue {
1123 key: k,
1124 value: Some(v),
1125 })
1126 .collect();
1127 let props = parquet::file::properties::WriterProperties::builder()
1128 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
1129 .set_max_row_group_row_count(Some(1_000_000))
1136 .set_key_value_metadata(Some(meta_kv))
1137 .build();
1138 let mut parquet_writer =
1139 parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
1140 .map_err(|e| format!("ArrowWriter for conversion: {e}"))?;
1141
1142 for batch_result in ipc_reader {
1143 let batch = batch_result.map_err(|e| format!("IPC batch for conversion: {e}"))?;
1144 parquet_writer
1145 .write(&batch)
1146 .map_err(|e| format!("write parquet conversion: {e}"))?;
1147 }
1148 parquet_writer
1149 .close()
1150 .map_err(|e| format!("close parquet conversion: {e}"))?;
1151
1152 std::fs::remove_file(&dataset_path).map_err(|e| format!("remove IPC temp file: {e}"))?;
1153 dataset_path = parquet_path;
1154 }
1155
1156 let duration = t_start.elapsed().as_secs_f64();
1157
1158 log::debug!(
1159 "[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",
1160 t_alloc,
1161 t1e_elapsed,
1162 t2_elapsed,
1163 t3a_elapsed,
1164 _t_meta,
1165 _t_dup,
1166 _t_write,
1167 _t_gt_compute,
1168 _t_gt_write,
1169 t3b_elapsed,
1170 duration,
1171 );
1172
1173 let stats = PipelineStats {
1174 total_records: global_rid_offset,
1175 exact_dups: n_exact_dup,
1176 hard_negs: n_hard_neg,
1177 uniques: n_unique,
1178 masters: n_masters,
1179 };
1180
1181 let mut graph_nodes_final: Option<String> = None;
1183 let mut graph_edges_final: Option<String> = None;
1184 if let (Some(nw), Some(ew)) = (node_writer, edge_writer) {
1185 nw.finish()
1186 .map_err(|e| format!("finish node writer: {e}"))?;
1187 ew.finish()
1188 .map_err(|e| format!("finish edge writer: {e}"))?;
1189 let mut np = graph_nodes_path.clone().unwrap();
1190 let mut ep = graph_edges_path.clone().unwrap();
1191 if graph_fmt == crate::graph_gen::GraphFormat::Parquet {
1192 let np_pq = format!("{}/{}_nodes.parquet", output_dir, config.run_id);
1193 let ep_pq = format!("{}/{}_edges.parquet", output_dir, config.run_id);
1194 convert_ipc_to_parquet(&np, &np_pq).map_err(|e| format!("graph nodes parquet: {e}"))?;
1195 convert_ipc_to_parquet(&ep, &ep_pq).map_err(|e| format!("graph edges parquet: {e}"))?;
1196 std::fs::remove_file(&np).ok();
1197 std::fs::remove_file(&ep).ok();
1198 np = np_pq;
1199 ep = ep_pq;
1200 }
1201 graph_nodes_final = Some(np);
1202 graph_edges_final = Some(ep);
1203 }
1204
1205 Ok(PipelineOutput {
1206 output_files: vec![dataset_path],
1207 gt_file: gt_path,
1208 stats,
1209 nodes: graph_nodes_final,
1210 edges: graph_edges_final,
1211 })
1212}
1213
1214fn convert_ipc_to_parquet(ipc_path: &str, parquet_path: &str) -> Result<(), String> {
1218 use arrow::ipc::reader::FileReader;
1219
1220 let ipc_file =
1221 std::fs::File::open(ipc_path).map_err(|e| format!("open IPC {ipc_path}: {e}"))?;
1222 let ipc_reader =
1223 FileReader::try_new(ipc_file, None).map_err(|e| format!("IPC reader {ipc_path}: {e}"))?;
1224 let schema = ipc_reader.schema();
1225
1226 let parquet_file =
1227 std::fs::File::create(parquet_path).map_err(|e| format!("create {parquet_path}: {e}"))?;
1228 let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1229 let meta_kv: Vec<parquet::file::metadata::KeyValue> = schema
1230 .metadata()
1231 .iter()
1232 .map(|(k, v)| parquet::file::metadata::KeyValue {
1233 key: k.clone(),
1234 value: Some(v.clone()),
1235 })
1236 .collect();
1237 let props = parquet::file::properties::WriterProperties::builder()
1238 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
1239 .set_max_row_group_row_count(Some(1_000_000))
1240 .set_key_value_metadata(Some(meta_kv))
1241 .build();
1242 let mut parquet_writer =
1243 parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
1244 .map_err(|e| format!("ArrowWriter {parquet_path}: {e}"))?;
1245
1246 for batch_result in ipc_reader {
1247 let batch = batch_result.map_err(|e| format!("IPC batch {ipc_path}: {e}"))?;
1248 parquet_writer
1249 .write(&batch)
1250 .map_err(|e| format!("write parquet {parquet_path}: {e}"))?;
1251 }
1252 parquet_writer
1253 .close()
1254 .map_err(|e| format!("close parquet {parquet_path}: {e}"))?;
1255 Ok(())
1256}
1257
1258fn build_metadata_map(config: &PipelineConfig) -> HashMap<String, String> {
1261 let ts = std::time::SystemTime::now()
1262 .duration_since(std::time::UNIX_EPOCH)
1263 .map(|d| d.as_secs().to_string())
1264 .unwrap_or_else(|_| "unknown".into());
1265 HashMap::from([
1266 (
1267 "dupehell.generator".into(),
1268 format!("DupeHell v{}", env!("CARGO_PKG_VERSION")),
1269 ),
1270 (
1271 "dupehell.provenance".into(),
1272 "dupehell-synthetic-data".into(),
1273 ),
1274 ("dupehell.license".into(), "MIT".into()),
1275 (
1276 "dupehell.purpose".into(),
1277 "Educational Use Only — Record Linkage Benchmarking".into(),
1278 ),
1279 (
1280 "dupehell.url".into(),
1281 "https://github.com/vntoinekaio/DupeHell".into(),
1282 ),
1283 ("dupehell.domain".into(), config.domain.clone()),
1284 ("dupehell.size".into(), config.size.to_string()),
1285 ("dupehell.seed".into(), config.seed.to_string()),
1286 ("dupehell.difficulty".into(), config.difficulty.clone()),
1287 ("dupehell.run_id".into(), config.run_id.clone()),
1288 ("dupehell.timestamp".into(), ts),
1289 (
1290 "dupehell.structural_key_columns".into(),
1291 structural_key_columns_note(config),
1292 ),
1293 ])
1294}
1295
1296fn structural_key_columns_note(config: &PipelineConfig) -> String {
1301 let cols: Vec<&str> = config
1302 .entity_plans
1303 .iter()
1304 .filter_map(|p| p.identifier_col.as_deref())
1305 .collect();
1306 format!(
1307 "{} — structural keys for joining child tables, not ER match attributes; use record_id as the row identifier instead",
1308 cols.join(", ")
1309 )
1310}
1311
1312fn build_full_schema(config: &PipelineConfig, metadata: &HashMap<String, String>) -> Schema {
1316 let mut field_map: Vec<(String, DataType, bool)> = Vec::new();
1317 let metadata_fields = ["record_id", "domain", "entity_type", "master_id"];
1318 for mf in &metadata_fields {
1319 field_map.push((mf.to_string(), DataType::Utf8, false));
1320 }
1321 for plan in &config.entity_plans {
1322 let cols: Vec<serde_json::Value> =
1323 serde_json::from_str(&plan.columns_json).unwrap_or_default();
1324 for col in &cols {
1325 let name = col["name"].as_str().unwrap_or("").to_string();
1326 if name.is_empty() || field_map.iter().any(|(n, _, _)| n == &name) {
1327 continue;
1328 }
1329 let col_type = col_type_from_request(col);
1330 field_map.push((name, col_type, true));
1331 }
1332 }
1333 let fields: Vec<Field> = field_map
1334 .into_iter()
1335 .map(|(n, dt, nullable)| Field::new(&n, dt, nullable))
1336 .collect();
1337 Schema::new(fields).with_metadata(metadata.clone())
1338}
1339
1340fn col_type_from_request(v: &serde_json::Value) -> DataType {
1342 match v["type"].as_str() {
1343 Some("int") => DataType::Int64,
1344 Some("float") => DataType::Float64,
1345 Some("boolean") => DataType::Boolean,
1346 Some("date") | Some("datetime") => DataType::Utf8,
1347 _ => DataType::Utf8,
1348 }
1349}
1350
1351#[allow(clippy::too_many_arguments)]
1354pub(crate) fn add_metadata_and_align(
1355 rb: &RecordBatch,
1356 domain: &str,
1357 entity_type: &str,
1358 record_ids: &[String],
1359 master_ids: &[String],
1360 full_arc: &Arc<Schema>,
1361 col_lookup: &[Option<usize>],
1362 null_cache: &mut HashMap<(DataType, usize), ArrayRef>,
1363 const_arr_cache: &mut HashMap<(String, usize), ArrayRef>,
1364) -> RecordBatch {
1365 let n = rb.num_rows();
1366 let domain_arr = const_arr_cache
1372 .entry((domain.to_string(), n))
1373 .or_insert_with(|| {
1374 Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1375 domain, n,
1376 ))) as ArrayRef
1377 })
1378 .clone();
1379 let et_arr = const_arr_cache
1380 .entry((entity_type.to_string(), n))
1381 .or_insert_with(|| {
1382 Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1383 entity_type,
1384 n,
1385 ))) as ArrayRef
1386 })
1387 .clone();
1388 let rid_arr = Arc::new(StringArray::from_iter_values(
1389 record_ids.iter().map(|s| s.as_str()),
1390 )) as ArrayRef;
1391 let mid_arr = Arc::new(StringArray::from_iter_values(
1392 master_ids.iter().map(|s| s.as_str()),
1393 )) as ArrayRef;
1394
1395 let mut all_arrays: Vec<ArrayRef> = Vec::with_capacity(4 + col_lookup.len());
1396 all_arrays.push(rid_arr);
1397 all_arrays.push(domain_arr);
1398 all_arrays.push(et_arr);
1399 all_arrays.push(mid_arr);
1400
1401 for (i, maybe_idx) in col_lookup.iter().enumerate() {
1404 match maybe_idx {
1405 Some(idx) => all_arrays.push(rb.column(*idx).clone()),
1406 None => {
1407 let dt = full_arc.field(i + 4).data_type();
1408 let arr = null_cache
1409 .entry((dt.clone(), n))
1410 .or_insert_with(|| arrow::array::new_null_array(dt, n));
1411 all_arrays.push(Arc::clone(arr));
1412 }
1413 }
1414 }
1415
1416 RecordBatch::try_new(Arc::clone(full_arc), all_arrays)
1418 .expect("add_metadata_and_align RecordBatch")
1419}
1420
1421fn pick_rows(rb: &RecordBatch, indices: &UInt64Array) -> Result<RecordBatch, String> {
1422 use arrow::compute::take;
1423 let new_columns: Vec<ArrayRef> = (0..rb.num_columns())
1424 .map(|i| take(rb.column(i), indices, None))
1425 .collect::<Result<Vec<_>, _>>()
1426 .map_err(|e| format!("take error: {e}"))?;
1427
1428 RecordBatch::try_new(rb.schema(), new_columns).map_err(|e| format!("RecordBatch: {e}"))
1429}