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    /// Number of independent noise passes applied to each duplicate copy —
37    /// see the doc comment on `schema::DifficultySettings::passes` for why
38    /// this (not the noise_types category count) is the actual difficulty
39    /// lever between tiers.
40    pub noise_passes: usize,
41
42    pub graph_enabled: bool,
43    pub graph_format: String,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct EntityPlan {
48    pub name: String,
49    pub n_base: usize,
50    pub identifier_col: Option<String>,
51    pub columns_json: String,
52    pub noise_types: Vec<NoisePlanEntry>,
53    pub fk_remaps: Vec<FkRemapEntry>,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct NoisePlanEntry {
58    pub noise_type: String,
59    pub columns: Vec<String>,
60    pub count: usize,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct FkRemapEntry {
65    pub source_col: String,
66    pub target_entity: String,
67    /// Weight written to the `weight` column of `fk`-typed graph edges
68    /// produced from this relation (`--graph`). Defaults to 1.0 for schemas
69    /// that don't declare it, preserving prior behavior.
70    #[serde(default = "default_fk_weight")]
71    pub fk_weight: f64,
72}
73
74fn default_fk_weight() -> f64 {
75    1.0
76}
77
78#[derive(Debug, Deserialize)]
79pub struct HnTypeConfig {
80    pub entity_type: String,
81    pub config_json: String,
82    pub count: usize,
83}
84
85#[derive(Debug)]
86pub struct PipelineOutput {
87    pub output_files: Vec<String>,
88    pub gt_file: String,
89    pub stats: PipelineStats,
90    pub nodes: Option<String>,
91    pub edges: Option<String>,
92}
93
94#[derive(Debug)]
95pub struct PipelineStats {
96    pub total_records: usize,
97    /// Cluster members that are genuinely byte-for-byte identical to their
98    /// master. See `PipelineStats::fuzzy_dups` for the noised counterpart.
99    pub exact_dups: usize,
100    /// Cluster members that are duplicates of a master but differ from it on
101    /// at least one column (the assigned noise actually changed something).
102    pub fuzzy_dups: usize,
103    pub hard_negs: usize,
104    pub uniques: usize,
105    pub masters: usize,
106}
107
108// ── Fixed-width ASCII IDs ────────────────────────────────────────────────
109
110// `record_id`/`pad` are pure functions of the row index — "R-" + 13 digits
111// and 13 digits respectively. Earlier this was a struct precomputing both
112// for every id up front (bounded per-2-big-allocations, better than one
113// `String` per id, but still O(total_ids) resident for the whole run — at
114// 200M+ ids that's several GB held for nothing). Computing on demand, only
115// for the batch currently being written, removes that fixed cost entirely;
116// the transient `Vec<String>` built per batch is bounded by `BATCH_SIZE`
117// and freed right after use, same as `batch_mids`/`dup_mids_buf`/`hn_mids`
118// elsewhere in this file.
119const RID_LEN: usize = 15; // "R-" + 13 digits
120const PAD_LEN: usize = 13; // 13 digits
121
122#[inline]
123fn record_id_string(i: usize) -> String {
124    let mut buf = [0u8; RID_LEN];
125    buf[0] = b'R';
126    buf[1] = b'-';
127    let mut n = i;
128    for j in (2..RID_LEN).rev() {
129        buf[j] = b'0' + (n % 10) as u8;
130        n /= 10;
131    }
132    String::from_utf8(buf.to_vec()).expect("record_id_string: buffer is 'R', '-' and ASCII digits")
133}
134
135#[inline]
136fn pad_string(i: usize) -> String {
137    let mut buf = [0u8; PAD_LEN];
138    let mut n = i;
139    for j in (0..PAD_LEN).rev() {
140        buf[j] = b'0' + (n % 10) as u8;
141        n /= 10;
142    }
143    String::from_utf8(buf.to_vec()).expect("pad_string: buffer is always ASCII digits")
144}
145
146pub(crate) fn record_id_strs(range: std::ops::Range<usize>) -> Vec<String> {
147    range.map(record_id_string).collect()
148}
149
150/// Splits `total` proportionally across `weights` (largest-remainder
151/// method), so the sum of the result is always exactly `total`.
152///
153/// Used to spread each noise_type's planned duplicate count across every
154/// batch of masters in proportion to batch size, instead of assigning it
155/// all to a single batch — see the doc comment on the "Dups" section in
156/// `run_pipeline_with_progress` for why that matters.
157fn distribute_by_weight(total: usize, weights: &[usize]) -> Vec<usize> {
158    let weight_sum: usize = weights.iter().sum();
159    if weight_sum == 0 || total == 0 {
160        return vec![0; weights.len()];
161    }
162    let raw: Vec<f64> = weights
163        .iter()
164        .map(|&w| total as f64 * w as f64 / weight_sum as f64)
165        .collect();
166    let mut counts: Vec<usize> = raw.iter().map(|&r| r as usize).collect();
167    let assigned: usize = counts.iter().sum();
168    let mut remainder = total - assigned;
169    let mut order: Vec<usize> = (0..counts.len()).collect();
170    order.sort_by(|&a, &b| {
171        let fa = raw[a] - counts[a] as f64;
172        let fb = raw[b] - counts[b] as f64;
173        fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
174    });
175    for &i in &order {
176        if remainder == 0 {
177            break;
178        }
179        counts[i] += 1;
180        remainder -= 1;
181    }
182    counts
183}
184
185// ── Entity prefix ──────────────────────────────────────────────────────────
186
187/// Per-entity master_id prefix, derived from the entity's position in
188/// `config.entity_plans` rather than a hash of its name: a name hash mod
189/// 100000 had a real (if small) chance of two entities in the same domain
190/// colliding on the same prefix, which would make `gt.rs`'s master_id
191/// counting conflate masters from different entity types. An index is
192/// unique by construction — zero collision risk regardless of domain size.
193fn entity_prefix(entity_idx: usize) -> String {
194    format!("E{:05}", entity_idx)
195}
196
197// ── FK / HN pool types ─────────────────────────────────────────────────────
198
199type FkPoolMap = HashMap<String, RecordBatch>;
200
201struct HnPool {
202    batch: RecordBatch,
203    total_count: usize,
204    // Global record_ids aligned positionally with `batch` rows (only populated
205    // when graph output is enabled). Used to emit `hard_neg` edges.
206    record_ids: Vec<String>,
207}
208
209// ── Noise column matching ─────────────────────────────────────────────────
210
211/// Column-name fragments for columns holding a person's name as free text,
212/// even when the column isn't literally called `*_name` (e.g. `operator`,
213/// `technician`, populated from the `first_name` pool across several
214/// domain schemas — see the 40-domain schema audit in the 2026-07 session).
215const PERSON_NAME_WORDS: &[&str] = &[
216    "name",
217    // Not bare "first"/"last": those also matched non-name columns like
218    // `first_seen`/`last_active`/`last_login`/`last_updated`, sending them
219    // through char-level typo/homoglyph noise and injecting letters into
220    // what are actually timestamps (BUGS.md C13). Every legitimate name
221    // column across the 40 domain schemas is literally `*first_name*` /
222    // `*last_name*` (e.g. `agent_first_name`, `pat_first_name`), so this
223    // loses no real coverage.
224    "first_name",
225    "last_name",
226    "given",
227    "family",
228    "middle",
229    "operator",
230    "technician",
231    "inspector",
232    "claimant",
233    "consignee",
234    "investigator",
235    "collected_by",
236    "performed_by",
237    "assigned_to",
238];
239
240/// Column-name fragments for columns holding a company/organization name,
241/// even when the column isn't literally called `*_name`/`company*` (e.g.
242/// `supplier`, `manufacturer`, populated from the `company` pool across
243/// several domain schemas — see the 40-domain schema audit in the 2026-07
244/// session).
245const COMPANY_NAME_WORDS: &[&str] = &[
246    "company",
247    "legal",
248    "trading",
249    "name",
250    "supplier",
251    "manufacturer",
252    "sponsor",
253    "institution",
254    "journal",
255    "funder",
256    "employer",
257    "law_firm",
258    "affiliation",
259    "reported_by",
260    "buyer",
261    "shipper",
262    "airline",
263];
264
265/// Does `lower` match one of `PERSON_NAME_WORDS`' fragments *other than* the
266/// bare `"name"` catch-all — i.e. is it identifiable as a person-name column
267/// via a specific signal (`given`, `family`, `first_name`, `inspector`...),
268/// not just because its name happens to end in "name"?
269///
270/// Used to keep the `"name"` fragment both lists share (needed to catch
271/// columns like `judge_name`/`director_name` with no more specific word)
272/// from making `given_name`/`company_name` cross-match each other's noise
273/// category — see `is_specifically_company_name` and the `hunt2407.md`
274/// perf-hunt entry this fixes (kyc's `given_name`/`family_name`/
275/// `middle_name` were getting `companies` noise — `drop_legal_form` etc —
276/// applied to them, purely because `COMPANY_NAME_WORDS` also contains
277/// `"name"`).
278fn is_specifically_person_name(lower: &str) -> bool {
279    PERSON_NAME_WORDS
280        .iter()
281        .any(|&w| w != "name" && lower.contains(w))
282}
283
284/// Company-side mirror of `is_specifically_person_name`: matches a
285/// `COMPANY_NAME_WORDS` fragment other than the bare `"name"` catch-all.
286fn is_specifically_company_name(lower: &str) -> bool {
287    COMPANY_NAME_WORDS
288        .iter()
289        .any(|&w| w != "name" && lower.contains(w))
290}
291
292/// Does this noise category ever target a column with this (lowercased) name?
293///
294/// Pure name-pattern predicate shared with `estimate_difficulty` (see
295/// `crate::difficulty`) so the theoretical column-reliability model can never
296/// silently drift from what real generation actually does to each column.
297pub(crate) fn noise_type_targets_column(noise_type: &str, col_name: &str) -> bool {
298    let lower = col_name.to_lowercase();
299    match noise_type {
300        "typo" | "typo_aggressive" | "typo_extreme" | "qwerty_azerty" | "visual" | "homoglyph"
301        | "unicode_pollution" | "ocr_errors" | "case_swap" | "char_dropout" | "language_mix"
302        | "blocking_fail" => {
303            // Skip email-like columns — typo/visual noise destroys '@'.
304            // Skip `ip_address` too — it matches the "address" fragment
305            // below by name collision, but it isn't a postal address; this
306            // char-level noise injected letters into octets (BUGS.md C20).
307            if lower.contains("email") || lower.contains("ip_address") {
308                return false;
309            }
310            // Not "phone": that's digit-formatted data, not free text —
311            // char-level typo/homoglyph noise injected letters into phone
312            // numbers (BUGS.md C12). `identifiers`/`corrupt_phone` already
313            // corrupts phone columns digit-aware.
314            contains_any(&lower, &["address", "street", "city"])
315                || contains_any(&lower, PERSON_NAME_WORDS)
316                || contains_any(&lower, COMPANY_NAME_WORDS)
317        }
318        "names" | "nickname" | "initials" | "partial" | "name_compound" | "swap" | "full_swap" => {
319            // Exclude columns that are specifically identifiable as a
320            // company/organization name (e.g. `company_name`, `legal_name`)
321            // — the only reason they'd match PERSON_NAME_WORDS at all is the
322            // bare "name" fragment both lists share, not a real person-name
323            // signal. See `is_specifically_company_name`.
324            contains_any(&lower, PERSON_NAME_WORDS) && !is_specifically_company_name(&lower)
325        }
326        "dates" | "date_error" | "date_chaotic" | "date_format_mix" | "age_impossible" => {
327            contains_any(&lower, &["date", "birth", "incorporation", "founding"])
328        }
329        "missing" | "missing_pattern" => {
330            contains_any(&lower, &["phone", "email", "mobile", "address", "street"])
331        }
332        "identifiers"
333        | "corrupt_email"
334        | "corrupt_phone"
335        | "corrupt_national_id"
336        | "corrupt_siren"
337        | "national_id_corrupt"
338        | "phone_corrupt"
339        | "email_corrupt"
340        | "siren_corrupt" => contains_any(
341            &lower,
342            &[
343                "email",
344                "phone",
345                "ssn",
346                "pan",
347                "passport",
348                "account",
349                "medicare",
350                "license",
351                "siren",
352                "national_id",
353                // Added: `difficulty::base_noise_damage` already models a
354                // 0.6 damage weight for "registration"-matching columns
355                // (e.g. kyc's `registration_number`), but this predicate
356                // never actually targeted them -- a real column with zero
357                // noise coverage regardless of `passes`, floor-ing hell's
358                // f1_max well above its intended ceiling on that schema.
359                "registration",
360            ],
361        ),
362        "extra"
363        | "name_null"
364        | "dob_null"
365        | "blocking_fail_initial"
366        | "blocking_fail_partial"
367        | "fuzzy_match"
368        | "phonetic" => {
369            // Same exclusions as the typo/visual arm above (BUGS.md C12/C20):
370            // skip email (destroys '@') and `ip_address` (not a postal
371            // address despite the name collision), and don't target `phone`
372            // with this char-level noise — `identifiers` already handles it
373            // digit-aware.
374            if lower.contains("email") || lower.contains("ip_address") {
375                return false;
376            }
377            contains_any(&lower, &["address", "street", "city", "note", "comment"])
378                || contains_any(&lower, PERSON_NAME_WORDS)
379                || contains_any(&lower, COMPANY_NAME_WORDS)
380        }
381        "companies" | "acronym" | "legal_form_drop" | "word_dropout" | "company_scramble" => {
382            // Mirror exclusion: don't apply company-name noise (legal-form
383            // dropping, acronym-ing...) to a column that's specifically
384            // identifiable as a person's name (e.g. `given_name`,
385            // `family_name`) just because it also contains "name". See
386            // `is_specifically_person_name`.
387            contains_any(&lower, COMPANY_NAME_WORDS) && !is_specifically_person_name(&lower)
388        }
389        "addresses" | "address_scramble" | "postal_corrupt" => {
390            // `ip_address` matches "address" by name collision but isn't a
391            // postal address — scrambling/postal-corrupting it doesn't make
392            // sense (see the same exclusion on the typo/visual arm above,
393            // BUGS.md C20). `email_address` is the same collision: it
394            // contains "address" but is an email, not a postal address —
395            // `identifiers`/`corrupt_email` already handles it (perf-hunt
396            // hunt2407.md, confirmed empirically: kyc's `email_address` was
397            // getting postal `address_scramble`/`postal_corrupt` noise).
398            //
399            // "state"/"country" added: `difficulty::base_noise_damage`
400            // already models a 0.5 damage weight for them (same bucket as
401            // address/street/city/postal), but this predicate never
402            // actually targeted them -- e.g. kyc's `residential_state` /
403            // `residential_country` / `registered_country` had zero real
404            // noise coverage regardless of `passes`.
405            !lower.contains("ip_address")
406                && !lower.contains("email")
407                && contains_any(
408                    &lower,
409                    &["address", "street", "postal", "city", "state", "country"],
410                )
411        }
412        "exact" | "english_name" | "estonian_name" | "lithuanian_name" | "slovak_name"
413        | "serbian_name" | "norwegian_name" | "swedish_name" | "dutch_name" | "czech_name"
414        | "albanian_name" | "polish_name" | "romanian_name" | "hungarian_name" | "german_name"
415        | "italian_name" | "spanish_name" | "portuguese_name" | "combo_hard" | "combo_extreme"
416        | "combo_ultimate" | "french_address" => {
417            // These noise types are handled inline or are no-ops
418            false
419        }
420        // Default: applies to all string columns (except FK columns)
421        _ => true,
422    }
423}
424
425fn match_noise_columns(schema: &Schema, noise_type: &str, exclude_cols: &[String]) -> Vec<String> {
426    let mut matched: Vec<String> = Vec::new();
427    for field in schema.fields() {
428        if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
429            continue;
430        }
431        if exclude_cols.contains(field.name()) {
432            continue;
433        }
434        if noise_type_targets_column(noise_type, field.name()) {
435            matched.push(field.name().clone());
436        }
437    }
438    // For swap, keep exactly 2 columns
439    if matches!(noise_type, "swap" | "full_swap") && matched.len() >= 2 {
440        matched.truncate(2);
441    }
442    matched.sort();
443    matched.dedup();
444    matched
445}
446
447fn contains_any(s: &str, patterns: &[&str]) -> bool {
448    patterns.iter().any(|p| s.contains(p))
449}
450
451// ── Noise application ──────────────────────────────────────────────────────
452
453/// Returns the noised batch plus the list of columns it actually targeted
454/// (resolved from the heuristic when `plan_cols` is empty) — callers use
455/// the latter to verify the noise produced a real change, see
456/// `unchanged_row_mask`.
457fn apply_noise_to_batch(
458    rb: &RecordBatch,
459    noise_type: &str,
460    plan_cols: &[String],
461    rng: &mut Rng,
462    exclude_cols: &[String],
463) -> Result<(RecordBatch, Vec<String>), String> {
464    let schema = rb.schema();
465    let n_cols = rb.num_columns();
466
467    // Fallback to heuristic when plan_cols is empty
468    let target_cols: Vec<String> = if plan_cols.is_empty() {
469        match_noise_columns(&schema, noise_type, exclude_cols)
470    } else {
471        plan_cols.to_vec()
472    };
473
474    // Start with None; only clone/modify targeted columns
475    let mut new_columns: Vec<Option<ArrayRef>> = (0..n_cols).map(|_| None).collect();
476
477    let get_col = |new_columns: &[Option<ArrayRef>], idx: usize, rb: &RecordBatch| -> ArrayRef {
478        new_columns[idx]
479            .clone()
480            .unwrap_or_else(|| rb.column(idx).clone())
481    };
482
483    match noise_type {
484        "blocking_fail" => {
485            for col_name in &target_cols {
486                let lower = col_name.to_lowercase();
487                if let Ok(idx) = schema.index_of(col_name) {
488                    let col = get_col(&new_columns, idx, rb);
489                    if lower.contains("first") || lower.contains("given") {
490                        new_columns[idx] =
491                            Some(crate::noise::extra::apply_blocking_initial(&*col, rng));
492                    } else {
493                        new_columns[idx] =
494                            Some(crate::noise::extra::apply_blocking_partial(&*col, rng));
495                    }
496                }
497            }
498        }
499        "swap" | "full_swap" => {
500            if target_cols.len() >= 2 {
501                let name_a = &target_cols[0];
502                let name_b = &target_cols[1];
503                if let (Ok(idx_a), Ok(idx_b)) = (schema.index_of(name_a), schema.index_of(name_b)) {
504                    let a = get_col(&new_columns, idx_a, rb);
505                    let b = get_col(&new_columns, idx_b, rb);
506                    new_columns[idx_a] = Some(b);
507                    new_columns[idx_b] = Some(a);
508                }
509            }
510        }
511        "missing_pattern" => {
512            let mut phone_idx: Option<usize> = None;
513            let mut email_idx: Option<usize> = None;
514            for col_name in &target_cols {
515                let lower = col_name.to_lowercase();
516                if let Ok(idx) = schema.index_of(col_name) {
517                    if lower.contains("phone") || lower.contains("mobile") {
518                        phone_idx = Some(idx);
519                    } else if lower.contains("email") {
520                        email_idx = Some(idx);
521                    }
522                }
523            }
524            if let (Some(pi), Some(ei)) = (phone_idx, email_idx) {
525                let phone_arr = get_col(&new_columns, pi, rb);
526                let email_arr = get_col(&new_columns, ei, rb);
527                let phone_s = phone_arr.as_string::<i32>();
528                let email_s = email_arr.as_string::<i32>();
529                let n = phone_s.len();
530                let mut builder = arrow::array::StringBuilder::with_capacity(n, 32);
531                for i in 0..n {
532                    let pv = if phone_s.is_null(i) {
533                        ""
534                    } else {
535                        phone_s.value(i)
536                    };
537                    let ev = if email_s.is_null(i) {
538                        ""
539                    } else {
540                        email_s.value(i)
541                    };
542                    if !ev.is_empty() && rng.next_usize(2) == 0 {
543                        builder.append_value("");
544                    } else {
545                        builder.append_value(pv);
546                    }
547                }
548                new_columns[pi] = Some(Arc::new(builder.finish()));
549            }
550        }
551        _ => {
552            for col_name in &target_cols {
553                if let Ok(col_idx) = schema.index_of(col_name) {
554                    let col = get_col(&new_columns, col_idx, rb);
555                    let result = crate::noise::apply_noise_to_column(&*col, noise_type, rng)
556                        .map_err(|e| format!("noise '{noise_type}' on '{col_name}': {e}"))?;
557                    new_columns[col_idx] = Some(result);
558                }
559            }
560        }
561    }
562
563    // Fill remaining from original, unwrap all
564    let final_columns: Vec<ArrayRef> = (0..n_cols)
565        .map(|i| {
566            new_columns[i]
567                .clone()
568                .unwrap_or_else(|| rb.column(i).clone())
569        })
570        .collect();
571
572    let batch =
573        RecordBatch::try_new(schema, final_columns).map_err(|e| format!("RecordBatch: {e}"))?;
574    Ok((batch, target_cols))
575}
576
577/// Per-row mask (`true` = still unchanged): compares `orig` and `noised` on
578/// every column in `target_cols`, treating null == null as equal. A `true`
579/// row means the noise application was a complete no-op for it — either
580/// because its target columns were already null (a "corrupted" null stays
581/// null) or because the noise function's own internal randomness happened
582/// not to produce a visible variant (several functions in `noise/names.rs`
583/// and `noise/extra.rs` only mutate a row some fraction of the time).
584/// Consumed by the retry loop in the dup-generation closure to guarantee a
585/// duplicate copy that was assigned a noise type actually differs from its
586/// master, instead of silently staying a byte-for-byte duplicate.
587fn unchanged_row_mask(
588    orig: &RecordBatch,
589    noised: &RecordBatch,
590    target_cols: &[String],
591) -> Result<Vec<bool>, String> {
592    let n = orig.num_rows();
593    let mut changed = vec![false; n];
594    let schema = orig.schema();
595    for col_name in target_cols {
596        let Ok(idx) = schema.index_of(col_name) else {
597            continue;
598        };
599        let a = orig.column(idx).as_string::<i32>();
600        let b = noised.column(idx).as_string::<i32>();
601        for (i, is_changed) in changed.iter_mut().enumerate() {
602            if *is_changed {
603                continue;
604            }
605            let av = (!a.is_null(i)).then(|| a.value(i));
606            let bv = (!b.is_null(i)).then(|| b.value(i));
607            if av != bv {
608                *is_changed = true;
609            }
610        }
611    }
612    Ok(changed.into_iter().map(|c| !c).collect())
613}
614
615/// Apply `noise_type` to `orig`, retrying with fresh randomness (merging in
616/// only the still-unchanged rows via `zip`) up to `MAX_NOISE_ATTEMPTS` times,
617/// until every row's target columns differ from `orig` or attempts run out.
618/// Returns the resulting batch, the final per-row "unchanged" mask (`true` =
619/// still byte-identical to `orig` on this noise_type's target columns), and
620/// the `target_cols` this noise_type actually touched.
621///
622/// When `target_cols` comes back empty (no column in this entity's schema
623/// matches `noise_type` at all), every row is returned unchanged (`true`)
624/// with no retries attempted — retrying can't manufacture a matching column
625/// that doesn't exist; this is a schema/config-level gap, not a per-row
626/// randomness one.
627///
628/// The retry merge only `zip()`s columns in `target_cols` (perf-hunt H1,
629/// hunt2407.md): `apply_noise_to_batch` already guarantees every other
630/// column of `retried`/`noisy` is the same `Arc` as `orig`'s (untouched),
631/// so `zip`-ing them is a provably-no-op pass over the full column just to
632/// reconstruct a value that's already sitting there — wasted work that
633/// scales with the entity's *total* column count instead of the handful
634/// this noise_type actually targets. Confirmed empirically: for a 23-column
635/// entity (kyc-sized) vs an 8-column one, this loop alone cost ~1.7x more
636/// wall time despite touching the same single target column either way.
637fn apply_noise_with_retry(
638    orig: &RecordBatch,
639    noise_type: &str,
640    plan_cols: &[String],
641    rng: &mut Rng,
642    exclude_cols: &[String],
643) -> Result<(RecordBatch, Vec<bool>, Vec<String>), String> {
644    const MAX_NOISE_ATTEMPTS: usize = 4;
645    let (mut noisy, target_cols) =
646        apply_noise_to_batch(orig, noise_type, plan_cols, rng, exclude_cols)?;
647    if target_cols.is_empty() {
648        return Ok((noisy, vec![true; orig.num_rows()], target_cols));
649    }
650    let schema = noisy.schema();
651    let target_idxs: Vec<usize> = target_cols
652        .iter()
653        .filter_map(|c| schema.index_of(c).ok())
654        .collect();
655    let mut unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
656    let mut attempt = 1;
657    while unchanged.iter().any(|&u| u) && attempt < MAX_NOISE_ATTEMPTS {
658        let mut retry_rng = Rng::new(rng.next_u64());
659        let (retried, _) =
660            apply_noise_to_batch(orig, noise_type, plan_cols, &mut retry_rng, exclude_cols)?;
661        let mask = arrow::array::BooleanArray::from(unchanged.clone());
662        let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
663        for &i in &target_idxs {
664            merged_cols[i] =
665                arrow::compute::kernels::zip::zip(&mask, retried.column(i), noisy.column(i))
666                    .map_err(|e| format!("zip noise retry col {i}: {e}"))?;
667        }
668        noisy = RecordBatch::try_new(noisy.schema(), merged_cols)
669            .map_err(|e| format!("rebuild retried dup batch: {e}"))?;
670        unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
671        attempt += 1;
672    }
673    Ok((noisy, unchanged, target_cols))
674}
675
676/// Applies one additional noise pass, drawing an independent noise_type per
677/// ROW from `all_types` instead of one type for the entire batch.
678///
679/// The original implementation drew a single `extra_type` for the whole
680/// batch (`all_types[rng.next_usize(all_types.len())]`), applied once. If
681/// that one draw landed on a type with no matching column in this entity's
682/// schema at all (`noise_type_targets_column` false for every column —
683/// common: `dates`/`identifiers`/`addresses`/`companies` are frequently
684/// no-ops for entities that simply have no date/identifier/address/company
685/// column, e.g. an aviation `passenger` row), `apply_noise_with_retry`
686/// returns every row unchanged with *no retry attempted* (retrying can't
687/// manufacture a matching column that doesn't exist) — so the entire batch
688/// silently skipped this pass. With only 2-3 total passes, whether a batch's
689/// "still identical after all passes" fate landed on such a no-op type was
690/// close to a coin flip per pass, and that coin flip's odds shifted with
691/// unrelated RNG-stream position (traced empirically: the same aviation
692/// domain/seed/difficulty produced a real byte-identical-duplicate rate
693/// swinging from ~12% to ~0.003% between two dataset sizes differing by
694/// under 5%, tracking exactly when a per-entity batch boundary was crossed
695/// elsewhere in the run — same root-cause family as `distribute_by_weight`).
696///
697/// Per-row assignment means every batch always has a healthy mix of types,
698/// so a handful of schema-inapplicable categories can no longer determine
699/// the whole batch's fate — same fix class as `noise::apply_random_subtype`,
700/// applied one level up (noise *type* selection, not just sub-type selection
701/// within one already-chosen type).
702fn apply_extra_pass_per_row(
703    noisy: &RecordBatch,
704    unchanged: &mut [bool],
705    all_types: &[&str],
706    rng: &mut Rng,
707    exclude_cols: &[String],
708) -> Result<RecordBatch, String> {
709    let n = noisy.num_rows();
710    if all_types.is_empty() || n == 0 {
711        return Ok(noisy.clone());
712    }
713    let per_row_type: Vec<usize> = (0..n).map(|_| rng.next_usize(all_types.len())).collect();
714
715    // Partition rows by assigned type FIRST (perf-hunt H4, hunt2407.md),
716    // same technique as `noise::apply_random_subtype` — each row needs
717    // noise from exactly one type, but the previous implementation called
718    // `apply_noise_with_retry` on the FULL n-row batch once per active
719    // type and threw away the n - |group| rows that mask didn't select:
720    // O(len(all_types) * n) noise computation instead of O(n). On a schema
721    // where every category has real target columns (e.g. kyc, all 9 hell
722    // categories active — hunt2407.md), that's up to 9x redundant work per
723    // extra pass. Grouping first means each row's noise is computed once,
724    // on a batch sized to just its own group.
725    let mut groups: Vec<Vec<u32>> = vec![Vec::new(); all_types.len()];
726    for (i, &t) in per_row_type.iter().enumerate() {
727        groups[t].push(i as u32);
728    }
729
730    let schema = noisy.schema();
731    let mut pass_unchanged = vec![true; n];
732    // column name -> full-length scatter buffer, lazily seeded from
733    // `noisy`'s own values and overwritten in place per group that
734    // touches it. Only columns some active type actually targets get an
735    // entry — untouched columns are left as direct clones of `noisy`'s
736    // arrays at the end, same "don't rebuild what didn't change" principle
737    // as the H1 fix above.
738    let mut touched: HashMap<String, Vec<Option<String>>> = HashMap::new();
739
740    for (type_idx, idxs) in groups.into_iter().enumerate() {
741        if idxs.is_empty() {
742            continue;
743        }
744        let ttype = all_types[type_idx];
745        let idx_arr = UInt64Array::from_iter_values(idxs.iter().map(|&i| i as u64));
746        let subset = pick_rows(noisy, &idx_arr)?;
747        let (noised_subset, subset_unchanged, target_cols) =
748            apply_noise_with_retry(&subset, ttype, &[], rng, exclude_cols)?;
749        for col_name in &target_cols {
750            let Ok(col_idx) = schema.index_of(col_name) else {
751                continue;
752            };
753            let noised_col = noised_subset.column(col_idx).as_string::<i32>();
754            let buf = touched.entry(col_name.clone()).or_insert_with(|| {
755                let orig = noisy.column(col_idx).as_string::<i32>();
756                (0..n)
757                    .map(|i| (!orig.is_null(i)).then(|| orig.value(i).to_string()))
758                    .collect()
759            });
760            for (local_pos, &orig_i) in idxs.iter().enumerate() {
761                buf[orig_i as usize] = (!noised_col.is_null(local_pos))
762                    .then(|| noised_col.value(local_pos).to_string());
763            }
764        }
765        for (local_pos, &orig_i) in idxs.iter().enumerate() {
766            pass_unchanged[orig_i as usize] = subset_unchanged[local_pos];
767        }
768    }
769
770    let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
771    for (col_name, buf) in touched {
772        if let Ok(col_idx) = schema.index_of(&col_name) {
773            merged_cols[col_idx] = Arc::new(StringArray::from(buf));
774        }
775    }
776    let current = RecordBatch::try_new(noisy.schema(), merged_cols)
777        .map_err(|e| format!("rebuild extra-pass batch: {e}"))?;
778
779    for (u, pu) in unchanged.iter_mut().zip(pass_unchanged.iter()) {
780        *u = *u && *pu;
781    }
782    Ok(current)
783}
784
785// ── Topological sort for FK dependency ordering ────────────────────────────
786
787/// Order entity plans so that FK target entities are processed before dependents.
788fn topological_sort(
789    plans: &[EntityPlan],
790    name_to_idx: &HashMap<&str, usize>,
791) -> Result<Vec<usize>, String> {
792    let n = plans.len();
793    let mut in_degree = vec![0usize; n];
794    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
795    for i in 0..n {
796        for remap in &plans[i].fk_remaps {
797            let Some(&target) = name_to_idx.get(remap.target_entity.as_str()) else {
798                continue;
799            };
800            if target == i {
801                continue;
802            }
803            adj[target].push(i); // target → i means target must come before i
804            in_degree[i] += 1;
805        }
806    }
807    let mut queue: Vec<usize> = (0..n).filter(|i| in_degree[*i] == 0).collect();
808    let mut order = Vec::with_capacity(n);
809    let mut idx = 0;
810    while idx < queue.len() {
811        let v = queue[idx];
812        idx += 1;
813        order.push(v);
814        for &next in &adj[v] {
815            in_degree[next] -= 1;
816            if in_degree[next] == 0 {
817                queue.push(next);
818            }
819        }
820    }
821    if order.len() != n {
822        return Err("FK dependency cycle detected among entities".into());
823    }
824    Ok(order)
825}
826
827/// Dataset sink: writes directly in the requested output format, avoiding
828/// an uncompressed IPC intermediate + full reread when `parquet` is
829/// requested (mirrors the `GtSink` pattern in `gt.rs`).
830pub(crate) enum DatasetWriter {
831    Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
832    Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
833}
834
835impl DatasetWriter {
836    fn new(
837        output_format: &str,
838        path: &str,
839        schema: &Arc<Schema>,
840        metadata: &HashMap<String, String>,
841    ) -> Result<Self, String> {
842        let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
843        if output_format == "parquet" {
844            use parquet::basic::{Compression, ZstdLevel};
845            use parquet::file::properties::WriterProperties;
846            let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
847            let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
848                .iter()
849                .map(|(k, v)| parquet::file::metadata::KeyValue {
850                    key: k.clone(),
851                    value: Some(v.clone()),
852                })
853                .collect();
854            let props = WriterProperties::builder()
855                .set_compression(Compression::ZSTD(zstd))
856                // 1M rows/group (parquet-rs' own default) bounds the
857                // resident, uncompressed column-chunk buffers to one row
858                // group's worth at a time instead of the whole dataset.
859                .set_max_row_group_row_count(Some(1_000_000))
860                .set_key_value_metadata(Some(meta_kv))
861                .build();
862            let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
863                .map_err(|e| format!("ArrowWriter error: {e}"))?;
864            Ok(DatasetWriter::Parquet(Box::new(writer)))
865        } else {
866            let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
867                .map_err(|e| format!("IPC FileWriter error: {e}"))?;
868            Ok(DatasetWriter::Ipc(Box::new(writer)))
869        }
870    }
871
872    pub(crate) fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
873        match self {
874            DatasetWriter::Ipc(w) => w.write(batch).map_err(|e| format!("write ipc: {e}")),
875            DatasetWriter::Parquet(w) => w.write(batch).map_err(|e| format!("write parquet: {e}")),
876        }
877    }
878
879    fn finish(self) -> Result<(), String> {
880        match self {
881            DatasetWriter::Ipc(mut w) => w.finish().map_err(|e| format!("finish ipc: {e}")),
882            DatasetWriter::Parquet(w) => w
883                .close()
884                .map(|_| ())
885                .map_err(|e| format!("close parquet: {e}")),
886        }
887    }
888}
889
890// ── Main pipeline ──────────────────────────────────────────────────────────
891
892pub fn run_pipeline(
893    ctx: &Context,
894    config: &PipelineConfig,
895    output_dir: &str,
896) -> Result<PipelineOutput, String> {
897    run_pipeline_with_progress(ctx, config, output_dir, None)
898}
899
900/// Same as [`run_pipeline`], but invokes `progress(records_written_so_far,
901/// approx_total)` after every base-record batch is written to the dataset
902/// file — `approx_total` is `config.size` (the requested record count; the
903/// actual total including duplicates/HN/canary rows differs slightly, close
904/// enough for a progress display). Callers that don't need progress
905/// reporting should use [`run_pipeline`] instead.
906pub fn run_pipeline_with_progress(
907    ctx: &Context,
908    config: &PipelineConfig,
909    output_dir: &str,
910    mut progress: Option<&mut dyn FnMut(usize, usize)>,
911) -> Result<PipelineOutput, String> {
912    std::fs::create_dir_all(output_dir)
913        .map_err(|e| format!("create output directory {output_dir:?}: {e}"))?;
914    let t_start = std::time::Instant::now();
915
916    let t_alloc = t_start.elapsed().as_secs_f64();
917
918    // ── Phase 14: Streaming pipeline ─────────────────────────────────────
919    // Generates, extracts FK/HN, remaps, and sinks each batch per entity
920    // without storing all entity batches in memory.
921    let t1e = std::time::Instant::now();
922
923    // Metadata (including the `dupehell.timestamp` snapshot) is computed
924    // once here and reused for every output file (dataset, graph, GT,
925    // parquet conversions) — computing it per-file let `SystemTime::now()`
926    // drift across a Unix-second boundary on longer runs, making
927    // `dupehell.timestamp` inconsistent between files from the same run.
928    let metadata = build_metadata_map(config);
929    // Build the full schema once (union of all entity columns + metadata)
930    let full_arc = Arc::new(build_full_schema(config, &metadata));
931    let dataset_ext = if config.output_format == "parquet" {
932        "parquet"
933    } else {
934        "ipc"
935    };
936    let dataset_path = format!("{}/{}.{}", output_dir, config.run_id, dataset_ext);
937    let mut writer =
938        DatasetWriter::new(&config.output_format, &dataset_path, &full_arc, &metadata)?;
939
940    // Topological sort for FK dependency processing order
941    let name_to_idx: HashMap<&str, usize> = config
942        .entity_plans
943        .iter()
944        .enumerate()
945        .map(|(i, p)| (p.name.as_str(), i))
946        .collect();
947    let entity_order = topological_sort(&config.entity_plans, &name_to_idx)?;
948
949    // Cross-entity storage (lightweight)
950    let mut fk_pools: FkPoolMap = HashMap::new();
951    let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
952
953    // GT streaming: fed one batch at a time as the dataset is generated
954    // (see `crate::gt::GtAccumulator`) instead of accumulating full-dataset
955    // arrays in RAM.
956    let gt_ext = if config.output_format == "parquet" {
957        "parquet"
958    } else {
959        "ipc"
960    };
961    let gt_path = format!("{}/{}_ground_truth.{}", output_dir, config.run_id, gt_ext);
962    let gt_draft_path = format!("{}/{}_gt_draft.ipc", output_dir, config.run_id);
963    let mut gt_acc = crate::gt::GtAccumulator::new(&gt_draft_path)?;
964
965    let mut global_rid_offset = 0usize;
966
967    // ── Graph output (opt-in via --graph) ───────────────────────────────
968    const GRAPH_MAX_CLUSTER_EDGES: usize = 10_000;
969    let graph_fmt = crate::graph_gen::GraphFormat::from_str(&config.graph_format);
970    let mut node_writer: Option<crate::graph_gen::NodeWriter> = None;
971    let mut edge_writer: Option<crate::graph_gen::EdgeWriter> = None;
972    let mut graph_nodes_path: Option<String> = None;
973    let mut graph_edges_path: Option<String> = None;
974    if config.graph_enabled {
975        let nodes_ipc = format!("{}/{}_nodes.ipc", output_dir, config.run_id);
976        let edges_ipc = format!("{}/{}_edges.ipc", output_dir, config.run_id);
977        node_writer = Some(
978            crate::graph_gen::NodeWriter::new(&nodes_ipc, &full_arc, &metadata)
979                .map_err(|e| format!("init node writer: {e}"))?,
980        );
981        edge_writer = Some(
982            crate::graph_gen::EdgeWriter::new(&edges_ipc, &metadata)
983                .map_err(|e| format!("init edge writer: {e}"))?,
984        );
985        graph_nodes_path = Some(nodes_ipc);
986        graph_edges_path = Some(edges_ipc);
987    }
988
989    let mut _t_write = 0.0f64;
990    let mut _t_meta = 0.0f64;
991    let mut _t_dup = 0.0f64;
992    let mut _write_calls = 0u64;
993    // Compute HN pool size: need at least 2× the max hn_cfg.count for any entity type
994    let hn_pool_sizing: std::collections::HashMap<&str, usize> =
995        config
996            .hard_neg_types
997            .iter()
998            .fold(std::collections::HashMap::new(), |mut m, hn| {
999                let entry = m.entry(hn.entity_type.as_str()).or_insert(0);
1000                *entry = (*entry).max(hn.count);
1001                m
1002            });
1003    let hn_max_pool: usize = hn_pool_sizing.values().max().copied().unwrap_or(50_000) * 2 + 1_000;
1004    // FK remap (fk_remap.rs) samples uniformly at random from this pool via
1005    // `take` — it has no need to see every identifier the entity ever
1006    // produced, only a large-enough sample for the target diversity. Capping
1007    // it (same idea as `hn_max_pool` above) keeps its RAM bounded instead of
1008    // O(n_base) per entity with an identifier column.
1009    const FK_POOL_CAP: usize = 200_000;
1010
1011    // Entities whose FK pool is ever read (`fk_pools.get(&remap.target_entity)`
1012    // below, and the identical lookup in `canary::generate_all`, which reuses
1013    // the same `fk_remaps`). Extracting identifiers for an entity outside
1014    // this set is pure waste — the pool built for it is never consulted.
1015    let fk_targets: std::collections::HashSet<&str> = config
1016        .entity_plans
1017        .iter()
1018        .flat_map(|p| p.fk_remaps.iter().map(|r| r.target_entity.as_str()))
1019        .collect();
1020
1021    for &plan_idx in &entity_order {
1022        let plan = &config.entity_plans[plan_idx];
1023        if plan.n_base == 0 {
1024            continue;
1025        }
1026
1027        let prefix = entity_prefix(plan_idx);
1028        let col_json_str = &plan.columns_json;
1029
1030        // Per-entity streaming state
1031        let fk_targeted = fk_targets.contains(plan.name.as_str());
1032        let mut fk_builder = arrow::array::StringBuilder::new();
1033        let mut fk_rid_builder = arrow::array::StringBuilder::new();
1034        let mut fk_count: usize = 0;
1035        // Only entities actually referenced by a `hard_neg_types` entry ever
1036        // have their HN pool read (see `hn_pools.get(&hn_cfg.entity_type)`
1037        // below); accumulating slices for the others just pins whole-batch
1038        // Arrow buffers in `hn_pools` for the rest of the run for nothing.
1039        let hn_targeted = hn_pool_sizing.contains_key(plan.name.as_str());
1040        let mut hn_slices: Vec<RecordBatch> = Vec::new();
1041        let mut hn_slice_rids: Vec<Vec<String>> = Vec::new();
1042        let mut batch_rng = Rng::new(config.seed.wrapping_add(100));
1043        let mut fk_rng = Rng::new(config.seed.wrapping_add(42));
1044
1045        // Each noise_type's planned `count` is spread across every batch of
1046        // masters in proportion to batch size (largest-remainder method),
1047        // so duplicates land on masters throughout the whole entity instead
1048        // of being sampled only from whichever batch happens to run last —
1049        // see the "Dups" section below, at the end of each batch iteration.
1050        let batch_bounds: Vec<(usize, usize)> = (0..plan.n_base)
1051            .step_by(crate::entity_gen::BATCH_SIZE)
1052            .map(|offset| {
1053                (
1054                    offset,
1055                    (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE),
1056                )
1057            })
1058            .collect();
1059        let batch_weights: Vec<usize> = batch_bounds.iter().map(|&(_, bn)| bn).collect();
1060        let per_batch_noise_counts: Vec<Vec<usize>> = plan
1061            .noise_types
1062            .iter()
1063            .map(|n| distribute_by_weight(n.count, &batch_weights))
1064            .collect();
1065
1066        // Build col_lookup from first batch (it has the entity's schema)
1067        let mut col_lookup: Option<Vec<Option<usize>>> = None;
1068        // Phase 12.3: null cache per entity plan
1069        let mut null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1070        let mut const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1071
1072        // Stream: generate → FK remap → FK extract → HN sample → IPC write → GT collect
1073        for (batch_idx, offset) in (0..plan.n_base)
1074            .step_by(crate::entity_gen::BATCH_SIZE)
1075            .enumerate()
1076        {
1077            let batch_n = (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE);
1078            let batch_seed = config.seed.wrapping_add(batch_idx as u64 * 1000 + 1000);
1079
1080            // Generate batch
1081            let request_json = format!(
1082                r#"{{"entity_name":"{}","n":{},"seed":{},"columns":{},"row_offset":{}}}"#,
1083                plan.name, batch_n, batch_seed, col_json_str, offset,
1084            );
1085            let rb = crate::entity_gen::generate_entity_batch(ctx, &request_json)?;
1086
1087            // FK remap (uses pools from previously processed entities)
1088            let mut fk_edges_by_remap: Vec<(String, Vec<String>, f64)> = Vec::new();
1089            let rb = if !plan.fk_remaps.is_empty() {
1090                let mut r = rb;
1091                for remap in &plan.fk_remaps {
1092                    if let Some(pool) = fk_pools.get(&remap.target_entity) {
1093                        let (remapped, target_rids) = crate::fk_remap::fk_remap_batch(
1094                            &r,
1095                            pool,
1096                            &remap.source_col,
1097                            &mut fk_rng,
1098                            config.graph_enabled,
1099                        )?;
1100                        r = remapped;
1101                        if config.graph_enabled
1102                            && let Some(rids) = target_rids
1103                        {
1104                            fk_edges_by_remap.push((
1105                                remap.source_col.clone(),
1106                                rids,
1107                                remap.fk_weight,
1108                            ));
1109                        }
1110                    }
1111                }
1112                r
1113            } else {
1114                rb
1115            };
1116
1117            // Extract FK identifiers for this entity's pool, capped at
1118            // FK_POOL_CAP (see comment above).
1119            if fk_targeted
1120                && fk_count < FK_POOL_CAP
1121                && let Some(ref id_col) = plan.identifier_col
1122                && let Some(col) = rb.column_by_name(id_col)
1123            {
1124                let s = col.as_string::<i32>();
1125                for i in 0..s.len() {
1126                    if fk_count >= FK_POOL_CAP {
1127                        break;
1128                    }
1129                    if !s.is_null(i) {
1130                        fk_builder.append_value(s.value(i));
1131                        if config.graph_enabled {
1132                            fk_rid_builder.append_value(record_id_string(global_rid_offset + i));
1133                        }
1134                        fk_count += 1;
1135                    }
1136                }
1137            }
1138
1139            // HN pool: accumulate slices up to max pool size
1140            let hn_accum: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1141            if hn_targeted && hn_accum < hn_max_pool {
1142                let take = (hn_max_pool - hn_accum).min(batch_n);
1143                hn_slices.push(rb.slice(0, take));
1144                // Capture the global record_ids for these sliced rows so the
1145                // HN pool can later resolve `hard_neg` edge targets. Use
1146                // `global_rid_offset`, never the per-entity `offset`.
1147                if config.graph_enabled {
1148                    hn_slice_rids.push(record_id_strs(global_rid_offset..global_rid_offset + take));
1149                }
1150            }
1151
1152            // Build col_lookup from first batch's schema
1153            if col_lookup.is_none() {
1154                let plan_schema = rb.schema();
1155                col_lookup = Some(
1156                    full_arc
1157                        .fields()
1158                        .iter()
1159                        .skip(4)
1160                        .map(|f| plan_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1161                        .collect(),
1162                );
1163            }
1164
1165            // Master IDs for this batch
1166            let batch_mids: Vec<String> = (offset..offset + batch_n)
1167                .map(|i| format!("{}-{}", prefix, pad_string(i)))
1168                .collect();
1169            let batch_mids_slice = &batch_mids[..];
1170
1171            // Add metadata + IPC write
1172            let rid_slice = record_id_strs(global_rid_offset..global_rid_offset + batch_n);
1173            let t_m0 = std::time::Instant::now();
1174            let base_rb = add_metadata_and_align(
1175                &rb,
1176                &config.domain,
1177                &plan.name,
1178                &rid_slice,
1179                batch_mids_slice,
1180                &full_arc,
1181                col_lookup.as_ref().unwrap(),
1182                &mut null_cache,
1183                &mut const_arr_cache,
1184            )?;
1185            _t_meta += t_m0.elapsed().as_secs_f64();
1186
1187            // Graph: write base nodes + FK edges (only when --graph)
1188            if config.graph_enabled
1189                && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1190            {
1191                nw.write_batch(&base_rb)
1192                    .map_err(|e| format!("write node: {e}"))?;
1193                for i in 0..batch_n {
1194                    for (subtype, target_rids, weight) in &fk_edges_by_remap {
1195                        ew.push(&rid_slice[i], &target_rids[i], "fk", subtype, *weight)
1196                            .map_err(|e| format!("push fk edge: {e}"))?;
1197                    }
1198                }
1199            }
1200
1201            let t_w0 = std::time::Instant::now();
1202            writer
1203                .write(&base_rb)
1204                .map_err(|e| format!("write base: {e}"))?;
1205            _t_write += t_w0.elapsed().as_secs_f64();
1206            _write_calls += 1;
1207
1208            // GT collect
1209            gt_acc.push_base_batch(base_rb.column(0), base_rb.column(2), base_rb.column(3))?;
1210
1211            global_rid_offset += batch_n;
1212            if let Some(cb) = &mut progress {
1213                cb(global_rid_offset, config.size);
1214            }
1215
1216            // ── Dups ─────────────────────────────────────────────────────
1217            // Sampled from *this* batch's masters only (`rb`/`batch_n`/
1218            // `offset`), using this batch's share of each noise_type's
1219            // total count (`per_batch_noise_counts`, computed once before
1220            // the loop). Every batch of masters gets duplicates this way,
1221            // instead of concentrating all of them on whichever batch
1222            // happened to run last — see `distribute_by_weight`.
1223            let has_dups: bool = per_batch_noise_counts
1224                .iter()
1225                .any(|counts| counts[batch_idx] > 0);
1226            if has_dups {
1227                let t_d0 = std::time::Instant::now();
1228                let mut dup_batches: Vec<RecordBatch> = Vec::new();
1229                let mut dup_mids_buf: Vec<String> = Vec::new();
1230
1231                // Collect FK columns to exclude from noise
1232                let fk_exclude_cols: Vec<String> = plan
1233                    .fk_remaps
1234                    .iter()
1235                    .map(|r| r.source_col.clone())
1236                    .collect();
1237
1238                // Pre-generate indices + parallel noise (Phase 13c)
1239                let ndata: Vec<(UInt64Array, u64, &str, &[String], usize)> = plan
1240                    .noise_types
1241                    .iter()
1242                    .enumerate()
1243                    .filter_map(|(ni, n)| {
1244                        let cnt = per_batch_noise_counts[ni][batch_idx];
1245                        if cnt == 0 {
1246                            return None;
1247                        }
1248                        let mut b = UInt64Builder::with_capacity(cnt);
1249                        for _ in 0..cnt {
1250                            b.append_value(batch_rng.next_usize(batch_n) as u64);
1251                        }
1252                        Some((
1253                            b.finish(),
1254                            batch_rng.next_u64(),
1255                            n.noise_type.as_str(),
1256                            n.columns.as_slice(),
1257                            cnt,
1258                        ))
1259                    })
1260                    .collect();
1261
1262                // Distinct active noise_type names for this entity, used by
1263                // additional noise passes (below) to independently draw a type
1264                // to apply on top of the first pass's result.
1265                let all_types: Vec<&str> = plan
1266                    .noise_types
1267                    .iter()
1268                    .map(|n| n.noise_type.as_str())
1269                    .collect();
1270                let all_types_ref = &all_types;
1271                let noise_passes = config.noise_passes.max(1);
1272
1273                type DupNoiseResult = Result<(RecordBatch, Vec<String>, Vec<bool>), String>;
1274                let mut results: Vec<DupNoiseResult> = Vec::with_capacity(ndata.len());
1275                let fk_exclude_ref = &fk_exclude_cols;
1276                let rb_ref = &rb;
1277                std::thread::scope(|s| {
1278                    let mut handles = Vec::with_capacity(ndata.len());
1279                    for (indices, seed, ntype, cols, cnt) in &ndata {
1280                        // `thread::scope` lets each spawned closure borrow
1281                        // `rb`/`indices`/`cols`/`fk_exclude_cols` directly —
1282                        // the scope guarantees every thread joins before these
1283                        // borrows end, so the per-thread `.clone()` of the
1284                        // batch, index array, column list and exclude list
1285                        // (all cheap Arc/Vec clones, but still real allocations
1286                        // times the noise-type count) was unnecessary.
1287                        let prefix_ref: &str = &prefix;
1288                        handles.push(s.spawn(move || {
1289                            let dup = pick_rows(rb_ref, indices)?;
1290                            let mut rng = Rng::new(*seed);
1291
1292                            // First pass: the noise_type this bucket was
1293                            // assigned, with the retry-until-changed guarantee
1294                            // (see `apply_noise_with_retry`) so a "noised"
1295                            // duplicate can't silently stay a byte-for-byte
1296                            // copy of its master.
1297                            let (mut noisy, mut unchanged, _) = apply_noise_with_retry(
1298                                &dup,
1299                                ntype,
1300                                cols,
1301                                &mut rng,
1302                                fk_exclude_ref,
1303                            )?;
1304
1305                            // Additional independent passes (difficulty-
1306                            // controlled, see `schema::DifficultySettings::
1307                            // passes`): each draws its own noise_type from this
1308                            // entity's active list and applies it on top of the
1309                            // previous pass's result, compounding corruption.
1310                            // This — not adding more categories to
1311                            // `noise_types` — is what makes a tier reliably
1312                            // harder without diluting any single category's
1313                            // weight (see the doc comment on `passes`). A row
1314                            // is only "still identical" overall once every
1315                            // pass left it unchanged.
1316                            for _ in 1..noise_passes {
1317                                noisy = apply_extra_pass_per_row(
1318                                    &noisy,
1319                                    &mut unchanged,
1320                                    all_types_ref,
1321                                    &mut rng,
1322                                    fk_exclude_ref,
1323                                )?;
1324                            }
1325
1326                            let mut mb = Vec::with_capacity(*cnt);
1327                            for j in 0..*cnt {
1328                                // `indices` are local to `rb` (0..batch_n); the master_id
1329                                // is a pure function of (prefix, global index) — same
1330                                // formula as `batch_mids` above — so it's recomputed here
1331                                // instead of being cloned out of a retained master_id_pool.
1332                                let global_idx = offset + indices.value(j) as usize;
1333                                mb.push(format!("{}-{}", prefix_ref, pad_string(global_idx)));
1334                            }
1335                            Ok((noisy, mb, unchanged))
1336                        }));
1337                    }
1338                    for h in handles {
1339                        results.push(h.join().unwrap());
1340                    }
1341                });
1342                let mut dup_is_identical_buf: Vec<bool> = Vec::new();
1343                for res in results {
1344                    let (rb, mb, unchanged) = res?;
1345                    dup_batches.push(rb);
1346                    dup_mids_buf.extend(mb);
1347                    dup_is_identical_buf.extend(unchanged);
1348                }
1349                _t_dup += t_d0.elapsed().as_secs_f64();
1350
1351                // Write dups
1352                if !dup_batches.is_empty() {
1353                    let dup_total = dup_mids_buf.len();
1354                    let dup_rids = record_id_strs(global_rid_offset..global_rid_offset + dup_total);
1355
1356                    let concated = if dup_batches.len() == 1 {
1357                        dup_batches.into_iter().next().unwrap()
1358                    } else {
1359                        let schema = dup_batches[0].schema();
1360                        let n_fields = dup_batches[0].num_columns();
1361                        let mut concat_arrays = Vec::with_capacity(n_fields);
1362                        for i in 0..n_fields {
1363                            let refs: Vec<&dyn Array> =
1364                                dup_batches.iter().map(|b| b.column(i).as_ref()).collect();
1365                            concat_arrays.push(
1366                                arrow::compute::concat(&refs)
1367                                    .map_err(|e| format!("concat dup col {i}: {e}"))?,
1368                            );
1369                        }
1370                        RecordBatch::try_new(schema, concat_arrays)
1371                            .map_err(|e| format!("concat dups: {e}"))?
1372                    };
1373
1374                    let dup_rb_full = add_metadata_and_align(
1375                        &concated,
1376                        &config.domain,
1377                        &plan.name,
1378                        &dup_rids,
1379                        &dup_mids_buf,
1380                        &full_arc,
1381                        col_lookup.as_ref().unwrap(),
1382                        &mut null_cache,
1383                        &mut const_arr_cache,
1384                    )?;
1385
1386                    let t_wd = std::time::Instant::now();
1387                    writer
1388                        .write(&dup_rb_full)
1389                        .map_err(|e| format!("write dups: {e}"))?;
1390                    _t_write += t_wd.elapsed().as_secs_f64();
1391                    _write_calls += 1;
1392
1393                    // Graph: write duplicate nodes (edges emitted post-GT)
1394                    if config.graph_enabled
1395                        && let Some(nw) = node_writer.as_mut()
1396                    {
1397                        nw.write_batch(&dup_rb_full)
1398                            .map_err(|e| format!("write dup node: {e}"))?;
1399                    }
1400
1401                    let is_identical_arr: ArrayRef =
1402                        Arc::new(arrow::array::BooleanArray::from(dup_is_identical_buf));
1403                    gt_acc.push_dup_batch(
1404                        dup_rb_full.column(0),
1405                        dup_rb_full.column(2),
1406                        dup_rb_full.column(3),
1407                        &is_identical_arr,
1408                    )?;
1409                    global_rid_offset += dup_total;
1410                }
1411            }
1412        }
1413
1414        // Save FK pool for cross-entity remapping
1415        if let Some(id_col) = &plan.identifier_col
1416            && fk_builder.len() > 0
1417        {
1418            let arr = Arc::new(fk_builder.finish()) as ArrayRef;
1419            if config.graph_enabled {
1420                // Graph mode: also persist the target record_ids (column 1)
1421                // so FK edges can be emitted by later-source entities. Same
1422                // bounds as the identifier column above (FK_POOL_CAP gated).
1423                let rid_arr = Arc::new(fk_rid_builder.finish()) as ArrayRef;
1424                let schema = Arc::new(Schema::new(vec![
1425                    Field::new(id_col, DataType::Utf8, true),
1426                    Field::new("record_id", DataType::Utf8, true),
1427                ]));
1428                if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr, rid_arr]) {
1429                    fk_pools.insert(plan.name.clone(), pool_rb);
1430                }
1431            } else {
1432                let schema = Arc::new(Schema::new(vec![Field::new(id_col, DataType::Utf8, true)]));
1433                if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr]) {
1434                    fk_pools.insert(plan.name.clone(), pool_rb);
1435                }
1436            }
1437        }
1438
1439        // Build HN pool from collected slices
1440        if !hn_slices.is_empty() {
1441            let total_count: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1442            let batch = if hn_slices.len() == 1 {
1443                // `rb.slice(0, take)` (pushed above) is zero-copy: it keeps
1444                // the *entire* source batch's buffers alive (up to
1445                // BATCH_SIZE=500_000 rows × all columns) for the rest of the
1446                // run just to serve `take` rows. Re-materializing through the
1447                // `take` kernel (same helper as `pick_rows` below) builds
1448                // compact, right-sized buffers instead.
1449                let only = hn_slices.into_iter().next().unwrap();
1450                let idx = UInt64Array::from_iter_values(0..only.num_rows() as u64);
1451                pick_rows(&only, &idx)?
1452            } else {
1453                let schema = hn_slices[0].schema();
1454                let n_fields = schema.fields().len();
1455                let mut concat_arrays = Vec::with_capacity(n_fields);
1456                for i in 0..n_fields {
1457                    let refs: Vec<&dyn Array> =
1458                        hn_slices.iter().map(|pb| pb.column(i).as_ref()).collect();
1459                    concat_arrays.push(
1460                        arrow::compute::concat(&refs)
1461                            .map_err(|e| format!("hn pool concat col {i}: {e}"))?,
1462                    );
1463                }
1464                RecordBatch::try_new(schema, concat_arrays)
1465                    .map_err(|e| format!("hn pool concat batch: {e}"))?
1466            };
1467            // `record_ids` aligns positionally with the concatenated pool rows.
1468            let record_ids = if config.graph_enabled {
1469                hn_slice_rids.into_iter().flatten().collect::<Vec<String>>()
1470            } else {
1471                Vec::new()
1472            };
1473            hn_pools.insert(
1474                plan.name.clone(),
1475                HnPool {
1476                    batch,
1477                    total_count,
1478                    record_ids,
1479                },
1480            );
1481        }
1482    }
1483    let t1e_elapsed = t1e.elapsed().as_secs_f64();
1484    log::debug!("[sink_profile] write={_t_write:.3}s calls={_write_calls}");
1485
1486    // ── Phase 2: Hard negatives ────────────────────────────────────────────
1487    let t2 = std::time::Instant::now();
1488    // Phase 12.3: separate null cache for HN section (different entity types)
1489    let mut hn_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1490    let mut hn_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1491    // Global counter for HN master_ids (shared across every hard_neg_types
1492    // entry in this run) instead of a random draw from a fixed 10M space:
1493    // at tens/hundreds of thousands of HN rows, `next_usize(10_000_000)`
1494    // has a real birthday-paradox chance of two unrelated HN rows landing
1495    // on the same master_id, which `gt.rs` would then count as sharing a
1496    // master — silently mislabeling a hard negative as a match. A counter
1497    // is unique by construction, eliminating the collision entirely.
1498    let mut hn_master_id_counter: u64 = 0;
1499    for hn_cfg in config.hard_neg_types.iter() {
1500        if hn_cfg.count == 0 {
1501            continue;
1502        }
1503        let Some(pool_data) = hn_pools.get(&hn_cfg.entity_type) else {
1504            continue;
1505        };
1506        let hn_total = pool_data.total_count;
1507
1508        let pool_rb = &pool_data.batch;
1509        let n_hn_max = hn_cfg.count.min(hn_total / 2);
1510        if n_hn_max == 0 {
1511            continue;
1512        }
1513
1514        let (hn_rb, hn_src) = crate::hn_common::generate_hard_negatives(
1515            pool_rb,
1516            &hn_cfg.config_json,
1517            n_hn_max,
1518            config.seed.wrapping_add(200),
1519            config.graph_enabled,
1520        )?;
1521
1522        let n_hn = hn_rb.num_rows();
1523        if n_hn == 0 {
1524            continue;
1525        }
1526
1527        let hn_rids = record_id_strs(global_rid_offset..global_rid_offset + n_hn);
1528
1529        let hn_mids: Vec<String> = (0..n_hn)
1530            .map(|_i| {
1531                let id = hn_master_id_counter;
1532                hn_master_id_counter += 1;
1533                format!("HN-{:09}", id)
1534            })
1535            .collect();
1536
1537        // Build col lookup for the HN entity type schema
1538        let hn_schema = hn_rb.schema();
1539        let hn_col_lookup: Vec<Option<usize>> = full_arc
1540            .fields()
1541            .iter()
1542            .skip(4)
1543            .map(|f| hn_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1544            .collect();
1545
1546        let hn_rb_full = add_metadata_and_align(
1547            &hn_rb,
1548            &config.domain,
1549            &hn_cfg.entity_type,
1550            &hn_rids,
1551            &hn_mids,
1552            &full_arc,
1553            &hn_col_lookup,
1554            &mut hn_null_cache,
1555            &mut hn_const_arr_cache,
1556        )?;
1557
1558        let t_wh = std::time::Instant::now();
1559        writer
1560            .write(&hn_rb_full)
1561            .map_err(|e| format!("write hn: {e}"))?;
1562        _t_write += t_wh.elapsed().as_secs_f64();
1563        _write_calls += 1;
1564
1565        // Graph: write HN nodes + hard_neg edges (source = HN rid,
1566        // target = the pool row referenced by idx_a)
1567        if config.graph_enabled
1568            && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1569        {
1570            nw.write_batch(&hn_rb_full)
1571                .map_err(|e| format!("write hn node: {e}"))?;
1572            if let Some((idx_a, pattern)) = hn_src {
1573                for i in 0..n_hn {
1574                    let tgt = &pool_data.record_ids[idx_a[i]];
1575                    ew.push(&hn_rids[i], tgt, "hard_neg", &pattern, 1.0)
1576                        .map_err(|e| format!("push hn edge: {e}"))?;
1577                }
1578            }
1579        }
1580
1581        // Phase 13: collect ArrayRefs instead of per-row StringBuilder
1582        gt_acc.push_other_batch(
1583            hn_rb_full.column(0),
1584            hn_rb_full.column(2),
1585            hn_rb_full.column(3),
1586        )?;
1587
1588        global_rid_offset += n_hn;
1589    }
1590    let t2_elapsed = t2.elapsed().as_secs_f64();
1591
1592    // ── Canary records ────────────────────────────────────────────────────
1593    {
1594        let mut canary_null_cache: HashMap<(DataType, usize), ArrayRef> = HashMap::new();
1595        let mut canary_const_arr_cache: HashMap<(String, usize), ArrayRef> = HashMap::new();
1596        crate::canary::generate_all(
1597            ctx,
1598            config,
1599            &full_arc,
1600            &mut canary_null_cache,
1601            &mut canary_const_arr_cache,
1602            &mut global_rid_offset,
1603            &fk_pools,
1604            &mut writer,
1605            &mut node_writer,
1606            &mut gt_acc,
1607        )?;
1608    }
1609
1610    // ── Phase 3: Finalize + GT (streamed, see crate::gt::GtAccumulator) ────
1611    let t3 = std::time::Instant::now();
1612    writer
1613        .finish()
1614        .map_err(|e| format!("finish dataset writer: {e}"))?;
1615    let t3a_elapsed = t3.elapsed().as_secs_f64();
1616
1617    let t3b = std::time::Instant::now();
1618    let t_gt0 = std::time::Instant::now();
1619    let crate::gt::GtResult {
1620        n_exact_dup,
1621        n_fuzzy_dup,
1622        n_hard_neg,
1623        n_unique,
1624        n_masters,
1625        cluster_map,
1626    } = gt_acc.finish(
1627        config.difficulty.as_str(),
1628        &config.output_format,
1629        &gt_path,
1630        &metadata,
1631    )?;
1632    let _t_gt_compute = 0.0f64;
1633    let _t_gt_write = t_gt0.elapsed().as_secs_f64();
1634    let t3b_elapsed = t3b.elapsed().as_secs_f64();
1635    let _t3_elapsed = t3.elapsed().as_secs_f64();
1636
1637    // ── Graph: emit duplicate-cluster edges from the post-GT cluster_map ──
1638    if config.graph_enabled
1639        && let Some(ew) = edge_writer.as_mut()
1640    {
1641        crate::graph_gen::push_dup_clusters(ew, &cluster_map, GRAPH_MAX_CLUSTER_EDGES)
1642            .map_err(|e| format!("push dup clusters: {e}"))?;
1643    }
1644
1645    let duration = t_start.elapsed().as_secs_f64();
1646
1647    log::debug!(
1648        "[pipeline_timing] alloc={:.3}s  sink={:.3}s  hn={:.3}s  merge={:.3}s  meta={:.3}s/dup={:.3}s/write={:.3}s  gt(comp={:.3}s+write={:.3}s)={:.3}s  total={:.3}s",
1649        t_alloc,
1650        t1e_elapsed,
1651        t2_elapsed,
1652        t3a_elapsed,
1653        _t_meta,
1654        _t_dup,
1655        _t_write,
1656        _t_gt_compute,
1657        _t_gt_write,
1658        t3b_elapsed,
1659        duration,
1660    );
1661
1662    let stats = PipelineStats {
1663        total_records: global_rid_offset,
1664        exact_dups: n_exact_dup,
1665        fuzzy_dups: n_fuzzy_dup,
1666        hard_negs: n_hard_neg,
1667        uniques: n_unique,
1668        masters: n_masters,
1669    };
1670
1671    // ── Finalize graph writers (base nodes + FK edges emitted so far) ──
1672    let mut graph_nodes_final: Option<String> = None;
1673    let mut graph_edges_final: Option<String> = None;
1674    if let (Some(nw), Some(ew)) = (node_writer, edge_writer) {
1675        nw.finish()
1676            .map_err(|e| format!("finish node writer: {e}"))?;
1677        ew.finish()
1678            .map_err(|e| format!("finish edge writer: {e}"))?;
1679        let mut np = graph_nodes_path.clone().unwrap();
1680        let mut ep = graph_edges_path.clone().unwrap();
1681        if graph_fmt == crate::graph_gen::GraphFormat::Parquet {
1682            let np_pq = format!("{}/{}_nodes.parquet", output_dir, config.run_id);
1683            let ep_pq = format!("{}/{}_edges.parquet", output_dir, config.run_id);
1684            convert_ipc_to_parquet(&np, &np_pq).map_err(|e| format!("graph nodes parquet: {e}"))?;
1685            convert_ipc_to_parquet(&ep, &ep_pq).map_err(|e| format!("graph edges parquet: {e}"))?;
1686            std::fs::remove_file(&np).ok();
1687            std::fs::remove_file(&ep).ok();
1688            np = np_pq;
1689            ep = ep_pq;
1690        }
1691        graph_nodes_final = Some(np);
1692        graph_edges_final = Some(ep);
1693    }
1694
1695    Ok(PipelineOutput {
1696        output_files: vec![dataset_path],
1697        gt_file: gt_path,
1698        stats,
1699        nodes: graph_nodes_final,
1700        edges: graph_edges_final,
1701    })
1702}
1703
1704/// IPC → Parquet (ZSTD) conversion for graph files, mirroring the dataset
1705/// conversion above. The node IPC already carries the `dupehell.*` metadata,
1706/// which is forwarded to the Parquet key/value metadata.
1707fn convert_ipc_to_parquet(ipc_path: &str, parquet_path: &str) -> Result<(), String> {
1708    use arrow::ipc::reader::FileReader;
1709
1710    let ipc_file =
1711        std::fs::File::open(ipc_path).map_err(|e| format!("open IPC {ipc_path}: {e}"))?;
1712    let ipc_reader =
1713        FileReader::try_new(ipc_file, None).map_err(|e| format!("IPC reader {ipc_path}: {e}"))?;
1714    let schema = ipc_reader.schema();
1715
1716    let parquet_file =
1717        std::fs::File::create(parquet_path).map_err(|e| format!("create {parquet_path}: {e}"))?;
1718    let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1719    let meta_kv: Vec<parquet::file::metadata::KeyValue> = schema
1720        .metadata()
1721        .iter()
1722        .map(|(k, v)| parquet::file::metadata::KeyValue {
1723            key: k.clone(),
1724            value: Some(v.clone()),
1725        })
1726        .collect();
1727    let props = parquet::file::properties::WriterProperties::builder()
1728        .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
1729        .set_max_row_group_row_count(Some(1_000_000))
1730        .set_key_value_metadata(Some(meta_kv))
1731        .build();
1732    let mut parquet_writer =
1733        parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
1734            .map_err(|e| format!("ArrowWriter {parquet_path}: {e}"))?;
1735
1736    for batch_result in ipc_reader {
1737        let batch = batch_result.map_err(|e| format!("IPC batch {ipc_path}: {e}"))?;
1738        parquet_writer
1739            .write(&batch)
1740            .map_err(|e| format!("write parquet {parquet_path}: {e}"))?;
1741    }
1742    parquet_writer
1743        .close()
1744        .map_err(|e| format!("close parquet {parquet_path}: {e}"))?;
1745    Ok(())
1746}
1747
1748// ── Metadata injection ──────────────────────────────────────────────────────
1749
1750fn build_metadata_map(config: &PipelineConfig) -> HashMap<String, String> {
1751    let ts = std::time::SystemTime::now()
1752        .duration_since(std::time::UNIX_EPOCH)
1753        .map(|d| d.as_secs().to_string())
1754        .unwrap_or_else(|_| "unknown".into());
1755    HashMap::from([
1756        (
1757            "dupehell.generator".into(),
1758            format!("DupeHell v{}", env!("CARGO_PKG_VERSION")),
1759        ),
1760        (
1761            "dupehell.provenance".into(),
1762            "dupehell-synthetic-data".into(),
1763        ),
1764        ("dupehell.license".into(), "MIT".into()),
1765        (
1766            "dupehell.purpose".into(),
1767            "Educational Use Only — Record Linkage Benchmarking".into(),
1768        ),
1769        (
1770            "dupehell.url".into(),
1771            "https://github.com/vntoinekaio/DupeHell".into(),
1772        ),
1773        ("dupehell.domain".into(), config.domain.clone()),
1774        ("dupehell.size".into(), config.size.to_string()),
1775        ("dupehell.seed".into(), config.seed.to_string()),
1776        ("dupehell.difficulty".into(), config.difficulty.clone()),
1777        ("dupehell.run_id".into(), config.run_id.clone()),
1778        ("dupehell.timestamp".into(), ts),
1779        (
1780            "dupehell.structural_key_columns".into(),
1781            structural_key_columns_note(config),
1782        ),
1783    ])
1784}
1785
1786/// Lists the `{entity}_id` columns that are structural join keys (linking an
1787/// entity's rows to its flattened child tables), not attributes to feed an
1788/// ER model — they never receive noise and stay identical across all
1789/// duplicates of the same master_id, unlike `record_id`.
1790fn structural_key_columns_note(config: &PipelineConfig) -> String {
1791    let cols: Vec<&str> = config
1792        .entity_plans
1793        .iter()
1794        .filter_map(|p| p.identifier_col.as_deref())
1795        .collect();
1796    format!(
1797        "{} — structural keys for joining child tables, not ER match attributes; use record_id as the row identifier instead",
1798        cols.join(", ")
1799    )
1800}
1801
1802// ── Schema alignment ───────────────────────────────────────────────────────
1803
1804/// Build the union schema from all entity plans (all columns + metadata).
1805fn build_full_schema(config: &PipelineConfig, metadata: &HashMap<String, String>) -> Schema {
1806    let mut field_map: Vec<(String, DataType, bool)> = Vec::new();
1807    let metadata_fields = ["record_id", "domain", "entity_type", "master_id"];
1808    for mf in &metadata_fields {
1809        field_map.push((mf.to_string(), DataType::Utf8, false));
1810    }
1811    for plan in &config.entity_plans {
1812        let cols: Vec<serde_json::Value> =
1813            serde_json::from_str(&plan.columns_json).unwrap_or_default();
1814        for col in &cols {
1815            let name = col["name"].as_str().unwrap_or("").to_string();
1816            if name.is_empty() || field_map.iter().any(|(n, _, _)| n == &name) {
1817                continue;
1818            }
1819            let col_type = col_type_from_request(col);
1820            field_map.push((name, col_type, true));
1821        }
1822    }
1823    let fields: Vec<Field> = field_map
1824        .into_iter()
1825        .map(|(n, dt, nullable)| Field::new(&n, dt, nullable))
1826        .collect();
1827    Schema::new(fields).with_metadata(metadata.clone())
1828}
1829
1830/// Map a column's JSON type string to Arrow DataType.
1831fn col_type_from_request(v: &serde_json::Value) -> DataType {
1832    match v["type"].as_str() {
1833        Some("int") => DataType::Int64,
1834        Some("float") => DataType::Float64,
1835        Some("boolean") => DataType::Boolean,
1836        Some("date") | Some("datetime") => DataType::Utf8,
1837        _ => DataType::Utf8,
1838    }
1839}
1840
1841// ── Helpers ─────────────────────────────────────────────────────────────────
1842
1843#[allow(clippy::too_many_arguments)]
1844pub(crate) fn add_metadata_and_align(
1845    rb: &RecordBatch,
1846    domain: &str,
1847    entity_type: &str,
1848    record_ids: &[String],
1849    master_ids: &[String],
1850    full_arc: &Arc<Schema>,
1851    col_lookup: &[Option<usize>],
1852    null_cache: &mut HashMap<(DataType, usize), ArrayRef>,
1853    const_arr_cache: &mut HashMap<(String, usize), ArrayRef>,
1854) -> Result<RecordBatch, String> {
1855    let n = rb.num_rows();
1856    // `domain` is constant for the whole run and `entity_type` repeats
1857    // across every batch of a given entity (and often across HN/canary
1858    // sections too) — cache the built repeat-array per (value, n) instead
1859    // of rebuilding an n-length StringArray on every call, same idea as
1860    // `null_cache` above for the always-null columns.
1861    let domain_arr = const_arr_cache
1862        .entry((domain.to_string(), n))
1863        .or_insert_with(|| {
1864            Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1865                domain, n,
1866            ))) as ArrayRef
1867        })
1868        .clone();
1869    let et_arr = const_arr_cache
1870        .entry((entity_type.to_string(), n))
1871        .or_insert_with(|| {
1872            Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
1873                entity_type,
1874                n,
1875            ))) as ArrayRef
1876        })
1877        .clone();
1878    let rid_arr = Arc::new(StringArray::from_iter_values(
1879        record_ids.iter().map(|s| s.as_str()),
1880    )) as ArrayRef;
1881    let mid_arr = Arc::new(StringArray::from_iter_values(
1882        master_ids.iter().map(|s| s.as_str()),
1883    )) as ArrayRef;
1884
1885    let mut all_arrays: Vec<ArrayRef> = Vec::with_capacity(4 + col_lookup.len());
1886    all_arrays.push(rid_arr);
1887    all_arrays.push(domain_arr);
1888    all_arrays.push(et_arr);
1889    all_arrays.push(mid_arr);
1890
1891    // P4: Vec<Option<usize>> eliminates HashMap lookup per field (just index)
1892    // Phase 12.3: cache null arrays per (DataType, n) — avoids new_null_array overhead per batch
1893    for (i, maybe_idx) in col_lookup.iter().enumerate() {
1894        match maybe_idx {
1895            Some(idx) => all_arrays.push(rb.column(*idx).clone()),
1896            None => {
1897                let dt = full_arc.field(i + 4).data_type();
1898                let arr = null_cache
1899                    .entry((dt.clone(), n))
1900                    .or_insert_with(|| arrow::array::new_null_array(dt, n));
1901                all_arrays.push(Arc::clone(arr));
1902            }
1903        }
1904    }
1905
1906    // P3: Arc::clone(full_arc) avoids full schema clone (just atomic increment)
1907    RecordBatch::try_new(Arc::clone(full_arc), all_arrays)
1908        .map_err(|e| format!("add_metadata_and_align RecordBatch: {e}"))
1909}
1910
1911fn pick_rows(rb: &RecordBatch, indices: &UInt64Array) -> Result<RecordBatch, String> {
1912    use arrow::compute::take;
1913    let new_columns: Vec<ArrayRef> = (0..rb.num_columns())
1914        .map(|i| take(rb.column(i), indices, None))
1915        .collect::<Result<Vec<_>, _>>()
1916        .map_err(|e| format!("take error: {e}"))?;
1917
1918    RecordBatch::try_new(rb.schema(), new_columns).map_err(|e| format!("RecordBatch: {e}"))
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923    use super::*;
1924    use crate::schema::{build_pipeline_config, load_schema};
1925
1926    /// Regression for the perf-hunt cross-contamination bug (hunt2407.md):
1927    /// `COMPANY_NAME_WORDS` and `PERSON_NAME_WORDS` both contain the bare
1928    /// `"name"` fragment, which used to let `given_name`/`family_name`/
1929    /// `middle_name` match the `companies` noise category (and would
1930    /// symmetrically let `company_name`/`legal_name` match `names`), and let
1931    /// `email_address` match the `addresses` category via `"address"`.
1932    #[test]
1933    fn test_no_person_company_name_cross_contamination() {
1934        for col in [
1935            "given_name",
1936            "family_name",
1937            "middle_name",
1938            "first_name",
1939            "last_name",
1940        ] {
1941            assert!(
1942                noise_type_targets_column("names", col),
1943                "{col} should still match 'names'"
1944            );
1945            assert!(
1946                !noise_type_targets_column("companies", col),
1947                "{col} should NOT match 'companies' (person name, not a company name)"
1948            );
1949        }
1950        for col in ["company_name", "legal_name", "trading_name"] {
1951            assert!(
1952                noise_type_targets_column("companies", col),
1953                "{col} should still match 'companies'"
1954            );
1955            assert!(
1956                !noise_type_targets_column("names", col),
1957                "{col} should NOT match 'names' (company name, not a person name)"
1958            );
1959        }
1960        assert!(
1961            !noise_type_targets_column("addresses", "email_address"),
1962            "email_address should NOT match 'addresses' (it's an email, not a postal address)"
1963        );
1964        assert!(
1965            noise_type_targets_column("identifiers", "email_address"),
1966            "email_address should still match 'identifiers' (corrupt_email)"
1967        );
1968        assert!(
1969            noise_type_targets_column("addresses", "residential_address_line1"),
1970            "a real postal address column should still match 'addresses'"
1971        );
1972    }
1973
1974    fn manifest_dir() -> std::path::PathBuf {
1975        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
1976    }
1977
1978    /// Unique per-test scratch dir under `target/`, so parallel test runs
1979    /// never collide on output filenames and cleanup is trivial.
1980    fn scratch_dir(name: &str) -> std::path::PathBuf {
1981        let dir = manifest_dir()
1982            .join("target")
1983            .join("pipeline_test_tmp")
1984            .join(format!("{name}_{}", std::process::id()));
1985        let _ = std::fs::remove_dir_all(&dir);
1986        std::fs::create_dir_all(&dir).expect("create scratch dir");
1987        dir
1988    }
1989
1990    fn run_kyc(
1991        output_dir: &std::path::Path,
1992        output_format: &str,
1993        graph_enabled: bool,
1994    ) -> PipelineOutput {
1995        let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
1996        let mut ctx = Context::new(
1997            "kyc",
1998            "en",
1999            &manifest_dir().join("assets/pools").to_string_lossy(),
2000        )
2001        .expect("load context");
2002        let config = build_pipeline_config(
2003            "kyc",
2004            200,
2005            42,
2006            "medium",
2007            0.1,
2008            0.3,
2009            &schema,
2010            "pipeline_test_run",
2011            output_format,
2012            graph_enabled,
2013            "parquet",
2014        )
2015        .expect("build config");
2016        ctx.enable_watermark(&config.domain, config.size, config.seed);
2017        run_pipeline(&ctx, &config, &output_dir.to_string_lossy()).expect("run_pipeline")
2018    }
2019
2020    #[test]
2021    fn test_run_pipeline_produces_dataset_and_gt() {
2022        let dir = scratch_dir("basic");
2023        let output = run_kyc(&dir, "parquet", false);
2024
2025        assert_eq!(output.output_files.len(), 1);
2026        for f in &output.output_files {
2027            assert!(std::path::Path::new(f).exists(), "missing dataset file {f}");
2028        }
2029        assert!(std::path::Path::new(&output.gt_file).exists());
2030        assert!(output.nodes.is_none());
2031        assert!(output.edges.is_none());
2032
2033        // Stats must be internally consistent: exact_dup/fuzzy_dup/hard_neg/
2034        // unique are a partition of the classified records, which is <=
2035        // total_records (total_records also includes unclassified canary
2036        // watermark rows).
2037        let s = &output.stats;
2038        assert!(s.total_records >= s.exact_dups + s.fuzzy_dups + s.hard_negs + s.uniques);
2039        assert!(s.total_records > 0);
2040        assert!(s.masters > 0);
2041
2042        let _ = std::fs::remove_dir_all(&dir);
2043    }
2044
2045    #[test]
2046    fn test_run_pipeline_ipc_format() {
2047        let dir = scratch_dir("ipc");
2048        let output = run_kyc(&dir, "ipc", false);
2049        assert!(output.output_files[0].ends_with(".ipc"));
2050        assert!(std::path::Path::new(&output.output_files[0]).exists());
2051        let _ = std::fs::remove_dir_all(&dir);
2052    }
2053
2054    #[test]
2055    fn test_run_pipeline_graph_output() {
2056        let dir = scratch_dir("graph");
2057        let output = run_kyc(&dir, "parquet", true);
2058        let nodes = output.nodes.expect("nodes path");
2059        let edges = output.edges.expect("edges path");
2060        assert!(std::path::Path::new(&nodes).exists());
2061        assert!(std::path::Path::new(&edges).exists());
2062        let _ = std::fs::remove_dir_all(&dir);
2063    }
2064
2065    #[test]
2066    fn test_fk_edge_weight_from_schema_and_default() {
2067        // aviation's flight->aircraft fk_remap declares fk_weight=0.5
2068        // (schemas/aviation.json); flight->airline leaves the field at
2069        // its schema default of 1.0. Confirms `FkRemapEntry::fk_weight`
2070        // is threaded through to the `weight` column of `fk`-typed graph
2071        // edges instead of the old hardcoded 1.0 constant, and that
2072        // domains/relations without the field still default correctly.
2073        let dir = scratch_dir("fk_weight");
2074        let schema =
2075            load_schema("aviation", &manifest_dir().join("schemas")).expect("load aviation.json");
2076        let mut ctx = Context::new(
2077            "aviation",
2078            "en",
2079            &manifest_dir().join("assets/pools").to_string_lossy(),
2080        )
2081        .expect("load context");
2082        let config = build_pipeline_config(
2083            "aviation",
2084            300,
2085            42,
2086            "medium",
2087            0.1,
2088            0.3,
2089            &schema,
2090            "pipeline_test_fk_weight",
2091            "ipc",
2092            true,
2093            "ipc",
2094        )
2095        .expect("build config");
2096        ctx.enable_watermark(&config.domain, config.size, config.seed);
2097        let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
2098
2099        let edges_path = output.edges.expect("edges path");
2100        let file = std::fs::File::open(&edges_path).expect("open edges file");
2101        let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
2102
2103        let mut subtypes_seen: std::collections::HashMap<String, f64> =
2104            std::collections::HashMap::new();
2105        for batch in reader {
2106            let batch = batch.expect("read edges batch");
2107            let edge_type = batch
2108                .column_by_name("edge_type")
2109                .expect("edge_type col")
2110                .as_any()
2111                .downcast_ref::<arrow::array::StringArray>()
2112                .expect("edge_type as string");
2113            let subtype = batch
2114                .column_by_name("subtype")
2115                .expect("subtype col")
2116                .as_any()
2117                .downcast_ref::<arrow::array::StringArray>()
2118                .expect("subtype as string");
2119            let weight = batch
2120                .column_by_name("weight")
2121                .expect("weight col")
2122                .as_any()
2123                .downcast_ref::<arrow::array::Float64Array>()
2124                .expect("weight as f64");
2125            for i in 0..batch.num_rows() {
2126                if edge_type.value(i) == "fk" {
2127                    subtypes_seen.insert(subtype.value(i).to_string(), weight.value(i));
2128                }
2129            }
2130        }
2131
2132        assert_eq!(
2133            subtypes_seen.get("aircraft_id"),
2134            Some(&0.5),
2135            "flight->aircraft fk_weight from schema not propagated to edge weight"
2136        );
2137        assert_eq!(
2138            subtypes_seen.get("airline_id"),
2139            Some(&1.0),
2140            "flight->airline fk_weight (schema default) not propagated to edge weight"
2141        );
2142
2143        let _ = std::fs::remove_dir_all(&dir);
2144    }
2145
2146    #[test]
2147    fn test_run_pipeline_deterministic_stats() {
2148        let dir_a = scratch_dir("det_a");
2149        let dir_b = scratch_dir("det_b");
2150        let a = run_kyc(&dir_a, "parquet", false);
2151        let b = run_kyc(&dir_b, "parquet", false);
2152        assert_eq!(a.stats.total_records, b.stats.total_records);
2153        assert_eq!(a.stats.exact_dups, b.stats.exact_dups);
2154        assert_eq!(a.stats.fuzzy_dups, b.stats.fuzzy_dups);
2155        assert_eq!(a.stats.hard_negs, b.stats.hard_negs);
2156        assert_eq!(a.stats.uniques, b.stats.uniques);
2157        assert_eq!(a.stats.masters, b.stats.masters);
2158        let _ = std::fs::remove_dir_all(&dir_a);
2159        let _ = std::fs::remove_dir_all(&dir_b);
2160    }
2161
2162    #[test]
2163    fn test_distribute_by_weight_sums_to_total_and_is_proportional() {
2164        // Regression: duplicates used to be sampled only from the last
2165        // batch of masters (`last_batch`), so for any entity spanning 2+
2166        // batches, earlier batches never received a single duplicate —
2167        // the observed symptom was the overall duplicate rate silently
2168        // drifting down as dataset size grew past `BATCH_SIZE`. Fixed by
2169        // spreading each noise_type's count across every batch in
2170        // proportion to its size via this helper.
2171        let weights = [500_000usize, 216_666];
2172        let counts = distribute_by_weight(283_334, &weights);
2173        assert_eq!(counts.len(), 2);
2174        assert_eq!(counts.iter().sum::<usize>(), 283_334);
2175        // Every batch gets a non-zero share proportional to its size —
2176        // in particular the *first* batch (which the old code starved
2177        // entirely) must receive the majority share here.
2178        assert!(counts[0] > 0, "first batch got zero duplicates");
2179        assert!(counts[1] > 0, "last batch got zero duplicates");
2180        assert!(counts[0] > counts[1]);
2181
2182        // Edge cases: zero total, zero weights.
2183        assert_eq!(distribute_by_weight(0, &weights), vec![0, 0]);
2184        assert_eq!(distribute_by_weight(100, &[0, 0]), vec![0, 0]);
2185    }
2186
2187    #[test]
2188    fn test_entity_prefix_unique_per_index() {
2189        let a = entity_prefix(0);
2190        let b = entity_prefix(1);
2191        assert_ne!(a, b);
2192        assert_eq!(a, "E00000");
2193        assert_eq!(entity_prefix(42), "E00042");
2194    }
2195
2196    #[test]
2197    fn test_run_pipeline_hell_multi_pass_touches_multiple_columns() {
2198        // At `hell` (3 noise passes, see `schema::DifficultySettings::
2199        // passes`), at least one duplicate copy should differ from its
2200        // master on 2+ distinct columns — a single pass only ever touches
2201        // the columns its one chosen noise_type's predicate matches
2202        // (`noise_type_targets_column`), so seeing 2+ changed columns on
2203        // the same row is direct evidence that multiple independent passes
2204        // actually landed on it, not just a coincidence of one pass hitting
2205        // a multi-column noise_type like "typo".
2206        let dir = scratch_dir("hell_multipass");
2207        let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
2208        let mut ctx = Context::new(
2209            "kyc",
2210            "en",
2211            &manifest_dir().join("assets/pools").to_string_lossy(),
2212        )
2213        .expect("load context");
2214        let config = build_pipeline_config(
2215            "kyc",
2216            5000,
2217            7,
2218            "hell",
2219            0.1,
2220            0.3,
2221            &schema,
2222            "pipeline_test_hell_multipass",
2223            "ipc",
2224            false,
2225            "ipc",
2226        )
2227        .expect("build config");
2228        ctx.enable_watermark(&config.domain, config.size, config.seed);
2229        let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
2230
2231        let read_all = |path: &str| -> RecordBatch {
2232            let file = std::fs::File::open(path).unwrap();
2233            let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
2234            let batches: Vec<RecordBatch> = reader.map(|b| b.unwrap()).collect();
2235            let schema = batches[0].schema();
2236            let refs: Vec<Vec<&dyn Array>> = (0..schema.fields().len())
2237                .map(|i| batches.iter().map(|b| b.column(i).as_ref()).collect())
2238                .collect();
2239            let cols: Vec<ArrayRef> = refs
2240                .iter()
2241                .map(|r| arrow::compute::concat(r).unwrap())
2242                .collect();
2243            RecordBatch::try_new(schema, cols).unwrap()
2244        };
2245        let ds = read_all(&output.output_files[0]);
2246        let gt = read_all(&output.gt_file);
2247
2248        let ds_rid = ds.column_by_name("record_id").unwrap().as_string::<i32>();
2249        let ds_mid = ds.column_by_name("master_id").unwrap().as_string::<i32>();
2250        let gt_rid = gt.column_by_name("record_id").unwrap().as_string::<i32>();
2251        let gt_mt = gt.column_by_name("match_type").unwrap().as_string::<i32>();
2252
2253        let mut match_type_by_rid: HashMap<&str, &str> = HashMap::new();
2254        for i in 0..gt.num_rows() {
2255            match_type_by_rid.insert(gt_rid.value(i), gt_mt.value(i));
2256        }
2257
2258        // The true master row for a given master_id is whichever row was
2259        // written *first* (base rows are always written before any of their
2260        // duplicate copies, within a batch) — not "whichever row happens to
2261        // be labeled `exact_dup`": a cluster with only fuzzy copies has no
2262        // `exact_dup` row at all (a base row only reads `exact_dup` when the
2263        // cluster actually contains a byte-identical copy — see `gt.rs`).
2264        let mut master_row_by_mid: HashMap<&str, usize> = HashMap::new();
2265        for i in 0..ds.num_rows() {
2266            master_row_by_mid.entry(ds_mid.value(i)).or_insert(i);
2267        }
2268
2269        let compare_cols: Vec<usize> = (0..ds.num_columns())
2270            .filter(|&i| {
2271                let field = ds.schema().field(i).clone();
2272                matches!(field.data_type(), DataType::Utf8)
2273                    && !matches!(
2274                        field.name().as_str(),
2275                        "record_id" | "domain" | "entity_type" | "master_id"
2276                    )
2277                    && !field.name().ends_with("_id")
2278            })
2279            .collect();
2280
2281        let mut max_changed_cols = 0usize;
2282        for i in 0..ds.num_rows() {
2283            if match_type_by_rid.get(ds_rid.value(i)) != Some(&"fuzzy_dup") {
2284                continue;
2285            }
2286            let Some(&master_i) = master_row_by_mid.get(ds_mid.value(i)) else {
2287                continue;
2288            };
2289            let mut changed = 0usize;
2290            for &col_i in &compare_cols {
2291                let col = ds.column(col_i).as_string::<i32>();
2292                let a = (!col.is_null(i)).then(|| col.value(i));
2293                let b = (!col.is_null(master_i)).then(|| col.value(master_i));
2294                if a != b {
2295                    changed += 1;
2296                }
2297            }
2298            max_changed_cols = max_changed_cols.max(changed);
2299        }
2300
2301        assert!(
2302            max_changed_cols >= 2,
2303            "expected at least one fuzzy_dup row to differ from its master on \
2304             2+ columns (evidence of multiple noise passes), got max {max_changed_cols}"
2305        );
2306
2307        let _ = std::fs::remove_dir_all(&dir);
2308    }
2309
2310    #[test]
2311    fn test_record_id_and_pad_string_roundtrip() {
2312        let rid = record_id_string(7);
2313        assert_eq!(rid, "R-0000000000007");
2314        let pad = pad_string(7);
2315        assert_eq!(pad, "0000000000007");
2316        let strs = record_id_strs(0..3);
2317        assert_eq!(strs.len(), 3);
2318        assert_eq!(strs[0], record_id_string(0));
2319        assert_eq!(strs[2], record_id_string(2));
2320    }
2321
2322    // ── perf-hunt H1 isolated measurement (hunt2407.md) ─────────────────
2323    //
2324    // Not a correctness test -- #[ignore]'d so it never runs in normal
2325    // `cargo test`. Measures whether `apply_noise_with_retry`'s retry-zip
2326    // loop and `apply_extra_pass_per_row`'s pass-zip loop (both iterate
2327    // `0..num_columns()`, not just the noise_type's actual target_cols) cost
2328    // scales with TOTAL column count C, holding the number of noised
2329    // columns T=1 constant -- H1's hypothesis. Run with:
2330    //   cargo test --release pipeline::tests::hunt_h1_zip_cost_vs_column_count -- --ignored --nocapture
2331    #[test]
2332    #[ignore]
2333    fn hunt_h1_zip_cost_vs_column_count() {
2334        use arrow::array::StringArray;
2335        use arrow::datatypes::{DataType, Field, Schema};
2336
2337        fn make_batch(n_rows: usize, n_cols: usize) -> RecordBatch {
2338            let fields: Vec<Field> = (0..n_cols)
2339                .map(|i| Field::new(format!("col{i}"), DataType::Utf8, false))
2340                .collect();
2341            let cols: Vec<ArrayRef> = (0..n_cols)
2342                .map(|_| {
2343                    Arc::new(StringArray::from(
2344                        (0..n_rows)
2345                            .map(|_| "abcdefghijklmnop".to_string())
2346                            .collect::<Vec<_>>(),
2347                    )) as ArrayRef
2348                })
2349                .collect();
2350            RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
2351        }
2352
2353        const N_ROWS: usize = 100_000;
2354        const N_REPS: usize = 5;
2355        // 3 noise_passes total (hell tier): 1 first pass + 2 extra passes,
2356        // matching `apply_noise_with_retry` + `apply_extra_pass_per_row`
2357        // call pattern in `run_pipeline`'s dup-generation closure.
2358        const EXTRA_PASSES: usize = 2;
2359
2360        eprintln!("\n[hunt_h1] N_ROWS={N_ROWS} N_REPS={N_REPS} EXTRA_PASSES={EXTRA_PASSES}");
2361        eprintln!("[hunt_h1] C=total columns, T=1 noised column (fixed) -- H1 predicts cost ∝ C");
2362
2363        for &n_cols in &[8usize, 15, 23] {
2364            let plan_cols = vec!["col0".to_string()];
2365            let all_types: Vec<&str> = vec!["typo"];
2366            let mut total = std::time::Duration::ZERO;
2367
2368            for rep in 0..N_REPS {
2369                let batch = make_batch(N_ROWS, n_cols);
2370                let mut rng = Rng::new(42 + rep as u64);
2371                let t0 = std::time::Instant::now();
2372
2373                let (noisy, mut unchanged, _) =
2374                    apply_noise_with_retry(&batch, "typo", &plan_cols, &mut rng, &[]).unwrap();
2375                let mut current = noisy;
2376                for _ in 0..EXTRA_PASSES {
2377                    current = apply_extra_pass_per_row(
2378                        &current,
2379                        &mut unchanged,
2380                        &all_types,
2381                        &mut rng,
2382                        &[],
2383                    )
2384                    .unwrap();
2385                }
2386                std::hint::black_box(&current);
2387                total += t0.elapsed();
2388            }
2389
2390            let avg = total / N_REPS as u32;
2391            eprintln!(
2392                "[hunt_h1] C={n_cols:2}  avg={:>8.3}ms  per_column={:>7.4}ms  per_row_per_column={:>10.6}us",
2393                avg.as_secs_f64() * 1000.0,
2394                avg.as_secs_f64() * 1000.0 / n_cols as f64,
2395                avg.as_secs_f64() * 1_000_000.0 / (N_ROWS * n_cols) as f64,
2396            );
2397        }
2398    }
2399
2400    // ── perf-hunt kyc residual factor (hunt2407.md, corrected) ──────────
2401    //
2402    // The first `hunt_h1_*` measurement above got two things wrong when
2403    // trying to explain kyc/hell's real-world ~4-5x slowdown vs nonprofit:
2404    // (1) it assumed `noise_passes = 3` at `hell` -- the real value is
2405    // `DifficultySettings::passes = 8` (schema.rs); (2) it used generic
2406    // `col0..colN` column names with `plan_cols=[]` for the "extra pass"
2407    // calls, but `noise_type_targets_column` is name-based -- generic names
2408    // match none of hell's 9 categories, so those extra passes were
2409    // silently no-ops regardless of `EXTRA_PASSES`. This measurement uses
2410    // the REAL kyc.natural_person (23 cols) and nonprofit.donation (7 cols,
2411    // the weight-dominant nonprofit entity, weight=200) column NAMES loaded
2412    // from `schemas/*.json`, hell's real 9-category `noise_types` list, and
2413    // the real pass count (8) -- reproducing genuine per-category column
2414    // matching via `noise_type_targets_column`. Run with:
2415    //   cargo test --release pipeline::tests::hunt_h1_residual_kyc_vs_nonprofit -- --ignored --nocapture
2416    #[test]
2417    #[ignore]
2418    fn hunt_h1_residual_kyc_vs_nonprofit() {
2419        use arrow::array::StringArray;
2420        use arrow::datatypes::{DataType, Field, Schema};
2421
2422        fn make_batch(n_rows: usize, col_names: &[&str]) -> RecordBatch {
2423            let fields: Vec<Field> = col_names
2424                .iter()
2425                .map(|n| Field::new(*n, DataType::Utf8, true))
2426                .collect();
2427            let cols: Vec<ArrayRef> = col_names
2428                .iter()
2429                .map(|_| {
2430                    Arc::new(StringArray::from(
2431                        (0..n_rows)
2432                            .map(|_| "abcdefghijklmnop testword".to_string())
2433                            .collect::<Vec<_>>(),
2434                    )) as ArrayRef
2435                })
2436                .collect();
2437            RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
2438        }
2439
2440        const N_ROWS: usize = 100_000;
2441        const N_REPS: usize = 5;
2442        const HELL_TYPES: &[&str] = &[
2443            "typo_aggressive",
2444            "visual",
2445            "unicode_pollution",
2446            "names",
2447            "dates",
2448            "identifiers",
2449            "addresses",
2450            "companies",
2451            "extra",
2452        ];
2453        const HELL_PASSES: usize = 8; // schema.rs DifficultySettings::passes for "hell"
2454
2455        let kyc_cols: &[&str] = &[
2456            "party_id",
2457            "given_name",
2458            "family_name",
2459            "middle_name",
2460            "birth_date",
2461            "birth_place",
2462            "nationality",
2463            "email_address",
2464            "mobile_phone",
2465            "personal_administrative_number",
2466            "tax_id",
2467            "residential_address_line1",
2468            "residential_city",
2469            "residential_postal_code",
2470            "residential_state",
2471            "residential_country",
2472            "occupation",
2473            "risk_score",
2474            "document_type",
2475            "document_number",
2476            "source_system",
2477        ];
2478        let nonprofit_cols: &[&str] = &[
2479            "donation_id",
2480            "donor_id",
2481            "campaign_id",
2482            "donation_date",
2483            "payment_method",
2484            "source_system",
2485        ];
2486
2487        eprintln!("\n[hunt_h1_residual] N_ROWS={N_ROWS} N_REPS={N_REPS} HELL_PASSES={HELL_PASSES}");
2488
2489        // Per-category breakdown for kyc only (the slow one) -- one rep,
2490        // timing each of the 9 categories' apply_noise_with_retry call
2491        // individually (fresh copy of the batch each time, not chained) to
2492        // find which category(ies) dominate the 18s total.
2493        {
2494            let batch = make_batch(N_ROWS, kyc_cols);
2495            eprintln!("[hunt_h1_residual] per-category breakdown on kyc.natural_person:");
2496            for &ttype in HELL_TYPES {
2497                let mut rng = Rng::new(42);
2498                let t0 = std::time::Instant::now();
2499                let (_, _, tcols) =
2500                    apply_noise_with_retry(&batch, ttype, &[], &mut rng, &[]).unwrap();
2501                eprintln!(
2502                    "[hunt_h1_residual]   {ttype:20} {:>8.3}ms  target_cols={:?}",
2503                    t0.elapsed().as_secs_f64() * 1000.0,
2504                    tcols,
2505                );
2506            }
2507        }
2508
2509        for (label, col_names) in [
2510            ("kyc.natural_person", kyc_cols),
2511            ("nonprofit.donation", nonprofit_cols),
2512        ] {
2513            let mut total = std::time::Duration::ZERO;
2514            let mut total_target_hits = 0usize;
2515
2516            for rep in 0..N_REPS {
2517                let batch = make_batch(N_ROWS, col_names);
2518                let mut rng = Rng::new(42 + rep as u64);
2519                let t0 = std::time::Instant::now();
2520
2521                // First pass: same as real generation, one explicit
2522                // noise_type (heuristic-matched via plan_cols=[]).
2523                let (noisy, mut unchanged, t0cols) =
2524                    apply_noise_with_retry(&batch, "identifiers", &[], &mut rng, &[]).unwrap();
2525                total_target_hits += t0cols.len();
2526                let mut current = noisy;
2527                for _ in 1..HELL_PASSES {
2528                    current = apply_extra_pass_per_row(
2529                        &current,
2530                        &mut unchanged,
2531                        HELL_TYPES,
2532                        &mut rng,
2533                        &[],
2534                    )
2535                    .unwrap();
2536                }
2537                std::hint::black_box(&current);
2538                total += t0.elapsed();
2539            }
2540
2541            let avg = total / N_REPS as u32;
2542            eprintln!(
2543                "[hunt_h1_residual] {label:20} C={:2}  avg={:>8.3}ms  avg_first_pass_target_cols={:.1}",
2544                col_names.len(),
2545                avg.as_secs_f64() * 1000.0,
2546                total_target_hits as f64 / N_REPS as f64,
2547            );
2548        }
2549    }
2550}