Skip to main content

dupehell_core/
pipeline.rs

1// DupeHell -- MIT License . Educational Use Only
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// EDUCATIONAL AND RESEARCH PURPOSES ONLY -- see ETHICS.md for prohibited uses.
5// No liability for misuse.
6
7use 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// ── Config types ───────────────────────────────────────────────────────────
21
22#[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
88// ── Fixed-width ASCII IDs ────────────────────────────────────────────────
89
90// `record_id`/`pad` are pure functions of the row index — "R-" + 13 digits
91// and 13 digits respectively. Earlier this was a struct precomputing both
92// for every id up front (bounded per-2-big-allocations, better than one
93// `String` per id, but still O(total_ids) resident for the whole run — at
94// 200M+ ids that's several GB held for nothing). Computing on demand, only
95// for the batch currently being written, removes that fixed cost entirely;
96// the transient `Vec<String>` built per batch is bounded by `BATCH_SIZE`
97// and freed right after use, same as `batch_mids`/`dup_mids_buf`/`hn_mids`
98// elsewhere in this file.
99const RID_LEN: usize = 15; // "R-" + 13 digits
100const PAD_LEN: usize = 13; // 13 digits
101
102#[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
130// ── Entity prefix ──────────────────────────────────────────────────────────
131
132fn 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
139// ── FK / HN pool types ─────────────────────────────────────────────────────
140
141type FkPoolMap = HashMap<String, RecordBatch>;
142
143struct HnPool {
144    batch: RecordBatch,
145    total_count: usize,
146    // Global record_ids aligned positionally with `batch` rows (only populated
147    // when graph output is enabled). Used to emit `hard_neg` edges.
148    record_ids: Vec<String>,
149}
150
151// ── Noise column matching ─────────────────────────────────────────────────
152
153/// Column-name fragments for columns holding a person's name as free text,
154/// even when the column isn't literally called `*_name` (e.g. `operator`,
155/// `technician`, populated from the `first_name` pool across several
156/// domain schemas — see the 40-domain schema audit in the 2026-07 session).
157const 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
174/// Column-name fragments for columns holding a company/organization name,
175/// even when the column isn't literally called `*_name`/`company*` (e.g.
176/// `supplier`, `manufacturer`, populated from the `company` pool across
177/// several domain schemas — see the 40-domain schema audit in the 2026-07
178/// session).
179const 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
199/// Does this noise category ever target a column with this (lowercased) name?
200///
201/// Pure name-pattern predicate shared with `estimate_difficulty` (see
202/// `crate::difficulty`) so the theoretical column-reliability model can never
203/// silently drift from what real generation actually does to each column.
204pub(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            // Skip email-like columns — typo/visual noise destroys '@'
211            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            // Skip email-like columns — extra noise destroys '@'
258            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            // These noise types are handled inline or are no-ops
279            false
280        }
281        // Default: applies to all string columns (except FK columns)
282        _ => 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    // For swap, keep exactly 2 columns
300    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
312// ── Noise application ──────────────────────────────────────────────────────
313
314fn 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    // Fallback to heuristic when plan_cols is empty
325    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    // Start with None; only clone/modify targeted columns
332    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    // Fill remaining from original, unwrap all
421    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
432// ── Topological sort for FK dependency ordering ────────────────────────────
433
434/// Order entity plans so that FK target entities are processed before dependents.
435fn 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); // target → i means target must come before i
451            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
474// ── Main pipeline ──────────────────────────────────────────────────────────
475
476pub 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    // ── Phase 14: Streaming pipeline ─────────────────────────────────────
488    // Generates, extracts FK/HN, remaps, and sinks each batch per entity
489    // without storing all entity batches in memory.
490    let t1e = std::time::Instant::now();
491
492    // Metadata (including the `dupehell.timestamp` snapshot) is computed
493    // once here and reused for every output file (dataset, graph, GT,
494    // parquet conversions) — computing it per-file let `SystemTime::now()`
495    // drift across a Unix-second boundary on longer runs, making
496    // `dupehell.timestamp` inconsistent between files from the same run.
497    let metadata = build_metadata_map(config);
498    // Build the full schema once (union of all entity columns + metadata)
499    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    // Topological sort for FK dependency processing order
507    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    // Cross-entity storage (lightweight)
516    let mut fk_pools: FkPoolMap = HashMap::new();
517    let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
518
519    // GT streaming: fed one batch at a time as the dataset is generated
520    // (see `crate::gt::GtAccumulator`) instead of accumulating full-dataset
521    // arrays in RAM.
522    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(&gt_draft_path)?;
530
531    let mut global_rid_offset = 0usize;
532
533    // ── Graph output (opt-in via --graph) ───────────────────────────────
534    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    // Compute HN pool size: need at least 2× the max hn_cfg.count for any entity type
560    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    // FK remap (fk_remap.rs) samples uniformly at random from this pool via
571    // `take` — it has no need to see every identifier the entity ever
572    // produced, only a large-enough sample for the target diversity. Capping
573    // it (same idea as `hn_max_pool` above) keeps its RAM bounded instead of
574    // O(n_base) per entity with an identifier column.
575    const FK_POOL_CAP: usize = 200_000;
576
577    // Entities whose FK pool is ever read (`fk_pools.get(&remap.target_entity)`
578    // below, and the identical lookup in `canary::generate_all`, which reuses
579    // the same `fk_remaps`). Extracting identifiers for an entity outside
580    // this set is pure waste — the pool built for it is never consulted.
581    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        // Per-entity streaming state
597        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        // Only entities actually referenced by a `hard_neg_types` entry ever
602        // have their HN pool read (see `hn_pools.get(&hn_cfg.entity_type)`
603        // below); accumulating slices for the others just pins whole-batch
604        // Arrow buffers in `hn_pools` for the rest of the run for nothing.
605        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        // Build col_lookup from first batch (it has the entity's schema)
613        let mut col_lookup: Option<Vec<Option<usize>>> = None;
614        // Phase 12.3: null cache per entity plan
615        let mut null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
616        let mut const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
617
618        // Stream: generate → FK remap → FK extract → HN sample → IPC write → GT collect
619        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            // Generate batch
627            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            // FK remap (uses pools from previously processed entities)
634            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            // Extract FK identifiers for this entity's pool, capped at
660            // FK_POOL_CAP (see comment above).
661            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            // HN pool: accumulate slices up to max pool size
682            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                // Capture the global record_ids for these sliced rows so the
687                // HN pool can later resolve `hard_neg` edge targets. Use
688                // `global_rid_offset`, never the per-entity `offset`.
689                if config.graph_enabled {
690                    hn_slice_rids.push(record_id_strs(global_rid_offset..global_rid_offset + take));
691                }
692            }
693
694            // Build col_lookup from first batch's schema
695            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            // Master IDs for this batch
708            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            // Add metadata + IPC write
714            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            // Graph: write base nodes + FK edges (only when --graph)
730            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 collect
751            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        // Save FK pool for cross-entity remapping
758        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                // Graph mode: also persist the target record_ids (column 1)
764                // so FK edges can be emitted by later-source entities. Same
765                // bounds as the identifier column above (FK_POOL_CAP gated).
766                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        // Build HN pool from collected slices
783        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                // `rb.slice(0, take)` (pushed above) is zero-copy: it keeps
787                // the *entire* source batch's buffers alive (up to
788                // BATCH_SIZE=500_000 rows × all columns) for the rest of the
789                // run just to serve `take` rows. Re-materializing through the
790                // `take` kernel (same helper as `pick_rows` below) builds
791                // compact, right-sized buffers instead.
792                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            // `record_ids` aligns positionally with the concatenated pool rows.
807            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        // ── Dups (use last_batch) ──────────────────────────────────────────
823        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            // Collect FK columns to exclude from noise
830            let fk_exclude_cols: Vec<String> = plan
831                .fk_remaps
832                .iter()
833                .map(|r| r.source_col.clone())
834                .collect();
835
836            // Pre-generate indices + parallel noise (Phase 13c)
837            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                            // `idxs` are local to `last_rb` (0..last_n); the master_id
873                            // is a pure function of (prefix, global index) — same
874                            // formula as `batch_mids` above — so it's recomputed here
875                            // instead of being cloned out of a retained master_id_pool.
876                            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            // Write dups
894            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                // Graph: write duplicate nodes (edges emitted post-GT)
936                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    // ── Phase 2: Hard negatives ────────────────────────────────────────────
956    let t2 = std::time::Instant::now();
957    // Phase 12.3: separate null cache for HN section (different entity types)
958    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        // Build col lookup for the HN entity type schema
999        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        // Graph: write HN nodes + hard_neg edges (source = HN rid,
1027        // target = the pool row referenced by idx_a)
1028        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        // Phase 13: collect ArrayRefs instead of per-row StringBuilder
1043        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    // ── Canary records ────────────────────────────────────────────────────
1054    {
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    // ── Phase 3: Finalize + GT (streamed, see crate::gt::GtAccumulator) ────
1072    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        &gt_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    // ── Graph: emit duplicate-cluster edges from the post-GT cluster_map ──
1098    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    // ── Phase 3b: Materialize IPC → Parquet (Rust, no Polars) ───────────
1106    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            // An unbounded row group (previously `usize::MAX / 2`) never
1130            // flushes before `close()`: every compressed column chunk for
1131            // the whole dataset stays resident in `ArrowWriter`'s buffers
1132            // for the entire conversion. 1M rows/group (parquet-rs' own
1133            // default) bounds that to one row group's worth at a time —
1134            // same values on disk, different row-group boundaries.
1135            .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    // ── Finalize graph writers (base nodes + FK edges emitted so far) ──
1182    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
1214/// IPC → Parquet (ZSTD) conversion for graph files, mirroring the dataset
1215/// conversion above. The node IPC already carries the `dupehell.*` metadata,
1216/// which is forwarded to the Parquet key/value metadata.
1217fn 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
1258// ── Metadata injection ──────────────────────────────────────────────────────
1259
1260fn 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
1296/// Lists the `{entity}_id` columns that are structural join keys (linking an
1297/// entity's rows to its flattened child tables), not attributes to feed an
1298/// ER model — they never receive noise and stay identical across all
1299/// duplicates of the same master_id, unlike `record_id`.
1300fn 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
1312// ── Schema alignment ───────────────────────────────────────────────────────
1313
1314/// Build the union schema from all entity plans (all columns + metadata).
1315fn 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
1340/// Map a column's JSON type string to Arrow DataType.
1341fn 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// ── Helpers ─────────────────────────────────────────────────────────────────
1352
1353#[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    // `domain` is constant for the whole run and `entity_type` repeats
1367    // across every batch of a given entity (and often across HN/canary
1368    // sections too) — cache the built repeat-array per (value, n) instead
1369    // of rebuilding an n-length StringArray on every call, same idea as
1370    // `null_cache` above for the always-null columns.
1371    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    // P4: Vec<Option<usize>> eliminates HashMap lookup per field (just index)
1402    // Phase 12.3: cache null arrays per (DataType, n) — avoids new_null_array overhead per batch
1403    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    // P3: Arc::clone(full_arc) avoids full schema clone (just atomic increment)
1417    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}