Skip to main content

dupehell_core/
pipeline.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// No liability for misuse.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use arrow::array::{
10    Array, ArrayBuilder, ArrayRef, AsArray, StringArray, StringBuilder, UInt64Array, UInt64Builder,
11};
12use arrow::datatypes::{DataType, Field, Schema};
13use arrow::record_batch::RecordBatch;
14use rayon::prelude::*;
15use rustc_hash::FxHashMap;
16use serde::Deserialize;
17
18use crate::context::Context;
19use crate::rng::Rng;
20
21// ── Config types ───────────────────────────────────────────────────────────
22
23#[derive(Debug, Deserialize)]
24pub struct PipelineConfig {
25    pub domain: String,
26    pub size: usize,
27    pub seed: u64,
28    pub difficulty: String,
29    pub output_format: String,
30    pub run_id: String,
31
32    pub entity_plans: Vec<EntityPlan>,
33    pub hard_neg_types: Vec<HnTypeConfig>,
34
35    pub hard_neg_ratio: f64,
36
37    /// Number of independent noise passes applied to each duplicate copy —
38    /// see the doc comment on `schema::DifficultySettings::passes` for why
39    /// this (not the noise_types category count) is the actual difficulty
40    /// lever between tiers.
41    pub noise_passes: usize,
42
43    pub graph_enabled: bool,
44    pub graph_format: String,
45}
46
47#[derive(Debug, Deserialize)]
48pub struct EntityPlan {
49    pub name: String,
50    pub n_base: usize,
51    pub identifier_col: Option<String>,
52    pub columns_json: String,
53    pub noise_types: Vec<NoisePlanEntry>,
54    pub fk_remaps: Vec<FkRemapEntry>,
55    /// Set by `schema::build_pipeline_config` for the FK-target entities
56    /// added when `--only-entity` is active (see `FK_POOL_CAP`): this
57    /// entity's rows are generated only far enough to build its identifier
58    /// pool for other entities' FK remaps, never noised, written to the
59    /// dataset/graph output, or fed to ground truth. Always `false` on the
60    /// normal (no `--only-entity`) path.
61    #[serde(default)]
62    pub pool_only: bool,
63}
64
65/// Cap on how many identifiers of an entity are retained as a pool for other
66/// entities' FK remaps to sample from (`fk_remap.rs` samples uniformly via
67/// `take`, so it never needs to see every identifier the entity produced).
68/// Also used by `schema::build_pipeline_config` to size the pool-only
69/// entities it synthesizes for `--only-entity` (no need to generate more
70/// rows of an FK target than this pool will ever draw from).
71pub(crate) const FK_POOL_CAP: usize = 200_000;
72
73#[derive(Debug, Deserialize)]
74pub struct NoisePlanEntry {
75    pub noise_type: String,
76    pub columns: Vec<String>,
77    pub count: usize,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct FkRemapEntry {
82    pub source_col: String,
83    pub target_entity: String,
84    /// Weight written to the `weight` column of `fk`-typed graph edges
85    /// produced from this relation (`--graph`). Defaults to 1.0 for schemas
86    /// that don't declare it, preserving prior behavior.
87    #[serde(default = "default_fk_weight")]
88    pub fk_weight: f64,
89}
90
91fn default_fk_weight() -> f64 {
92    1.0
93}
94
95#[derive(Debug, Deserialize)]
96pub struct HnTypeConfig {
97    pub entity_type: String,
98    pub config_json: String,
99    pub count: usize,
100}
101
102#[derive(Debug)]
103pub struct PipelineOutput {
104    pub output_files: Vec<String>,
105    pub gt_file: String,
106    pub stats: PipelineStats,
107    pub nodes: Option<String>,
108    pub edges: Option<String>,
109}
110
111#[derive(Debug)]
112pub struct PipelineStats {
113    pub total_records: usize,
114    /// Cluster members that are genuinely byte-for-byte identical to their
115    /// master. See `PipelineStats::fuzzy_dups` for the noised counterpart.
116    pub exact_dups: usize,
117    /// Cluster members that are duplicates of a master but differ from it on
118    /// at least one column (the assigned noise actually changed something).
119    pub fuzzy_dups: usize,
120    pub hard_negs: usize,
121    pub uniques: usize,
122    pub masters: usize,
123}
124
125/// Carries the running counters that `record_id`/`master_id` generation
126/// depend on across a sequence of chunked runs (`--chunk-size`), so chunk
127/// N+1 continues exactly where chunk N left off instead of restarting at
128/// 0 — genuinely contiguous, global identifiers over the whole logical
129/// dataset, with no shard-tag hack needed on top. Defaults (all zero) give
130/// the single-run behavior, unchanged.
131#[derive(Debug, Clone, Default)]
132pub struct ChunkOffsets {
133    /// Next `record_id` index to hand out (see `record_id_string`).
134    pub rid_offset: usize,
135    /// Next local `master_id` index per entity, indexed by the entity's
136    /// position in `PipelineConfig::entity_plans` (see `entity_prefix`).
137    /// Entities absent from a shorter previous chunk's plan (shouldn't
138    /// happen — the same schema/`--only-entity` produces the same plan
139    /// shape every chunk) simply default to 0 via `master_base`.
140    pub master_local: Vec<usize>,
141    /// Next `HN-{:09}` counter value (see `hn_master_id_counter`).
142    pub hn_master_counter: u64,
143}
144
145impl ChunkOffsets {
146    fn master_base(&self, entity_idx: usize) -> usize {
147        self.master_local.get(entity_idx).copied().unwrap_or(0)
148    }
149}
150
151// ── Fixed-width ASCII IDs ────────────────────────────────────────────────
152
153// `record_id`/`pad` are pure functions of the row index — "R-" + 13 digits
154// and 13 digits respectively. Earlier this was a struct precomputing both
155// for every id up front (bounded per-2-big-allocations, better than one
156// `String` per id, but still O(total_ids) resident for the whole run — at
157// 200M+ ids that's several GB held for nothing). Computing on demand, only
158// for the batch currently being written, removes that fixed cost entirely;
159// the transient `Vec<String>` built per batch is bounded by `BATCH_SIZE`
160// and freed right after use, same as `batch_mids`/`dup_mids_buf`/`hn_mids`
161// elsewhere in this file.
162pub(crate) const RID_LEN: usize = 15; // "R-" + 13 digits
163pub(crate) const PAD_LEN: usize = 13; // 13 digits
164
165// Every real (non-test) code path now uses `record_id_array`/
166// `master_id_array_*` (hunt1808/H1) or, on scalar (one-id-at-a-time) call
167// sites, `append_record_id` (hunt0109/H7+H9) — a buffer-reusing sibling
168// with the same output, no per-id `String` allocation. `record_id_string`
169// itself survives only as a `#[cfg(test)]` fixture helper now — kept
170// `#[cfg(test)]` rather than deleted since test fixtures across
171// `graph_gen.rs`/`gt.rs` still build expected ids with it.
172#[cfg(test)]
173#[inline]
174pub(crate) fn record_id_string(i: usize) -> String {
175    let mut buf = [0u8; RID_LEN];
176    buf[0] = b'R';
177    buf[1] = b'-';
178    let mut n = i;
179    for j in (2..RID_LEN).rev() {
180        buf[j] = b'0' + (n % 10) as u8;
181        n /= 10;
182    }
183    String::from_utf8(buf.to_vec()).expect("record_id_string: buffer is 'R', '-' and ASCII digits")
184}
185#[cfg(test)]
186#[inline]
187pub(crate) fn pad_string(i: usize) -> String {
188    let mut buf = [0u8; PAD_LEN];
189    let mut n = i;
190    for j in (0..PAD_LEN).rev() {
191        buf[j] = b'0' + (n % 10) as u8;
192        n /= 10;
193    }
194    String::from_utf8(buf.to_vec()).expect("pad_string: buffer is always ASCII digits")
195}
196
197#[cfg(test)]
198pub(crate) fn record_id_strs(range: std::ops::Range<usize>) -> Vec<String> {
199    range.map(record_id_string).collect()
200}
201
202/// Writes `val` right-aligned, zero-padded to `width` ASCII digits, at the
203/// end of `buf` (grows `buf` by exactly `width` bytes). Shared digit-writing
204/// core for `record_id_array`/`master_id_array_*`/`hn_master_id_array` —
205/// same arithmetic as `record_id_string`/`pad_string`, just writing into a
206/// caller-owned buffer instead of allocating its own.
207#[inline]
208pub(crate) fn write_digits(buf: &mut Vec<u8>, val: u64, width: usize) {
209    let start = buf.len();
210    buf.resize(start + width, b'0');
211    let mut n = val;
212    for j in (0..width).rev() {
213        buf[start + j] = b'0' + (n % 10) as u8;
214        n /= 10;
215    }
216}
217
218/// Appends `"R-" + idx` zero-padded to `PAD_LEN` digits onto the end of
219/// `buf` — the exact same bytes `record_id_string(idx)` produces, without
220/// allocating a `String` (perf-hunt hunt0109/H7+H9). Callers writing many
221/// ids in a loop should reuse one `buf` across rows (`buf.clear()` between
222/// calls, same convention as `buf_gen::build_string_array`'s row buffer).
223#[inline]
224pub(crate) fn append_record_id(buf: &mut Vec<u8>, idx: u64) {
225    buf.push(b'R');
226    buf.push(b'-');
227    write_digits(buf, idx, PAD_LEN);
228}
229
230/// Builds a `record_id` `StringArray` for `range` directly (one shared
231/// buffer via `buf_gen::build_string_array`, no per-row `String`) instead of
232/// collecting `Vec<String>` and recopying through `StringArray::
233/// from_iter_values` — same bytes, same order, see hunt1808/H1.
234pub(crate) fn record_id_array(range: std::ops::Range<usize>) -> ArrayRef {
235    let n = range.len();
236    let mut i = range.start as u64;
237    crate::buf_gen::build_string_array(n, RID_LEN, |buf| {
238        buf.push(b'R');
239        buf.push(b'-');
240        write_digits(buf, i, PAD_LEN);
241        i += 1;
242    })
243}
244
245/// Builds a `master_id` `StringArray` for the contiguous global-index range
246/// `start..start+n` (base entity batches: every row in the batch gets the
247/// next master index in order) — see hunt1808/H1.
248fn master_id_array_range(prefix: &str, start: usize, n: usize) -> ArrayRef {
249    let width = prefix.len() + 1 + PAD_LEN;
250    let mut i = start as u64;
251    crate::buf_gen::build_string_array(n, width, |buf| {
252        buf.extend_from_slice(prefix.as_bytes());
253        buf.push(b'-');
254        write_digits(buf, i, PAD_LEN);
255        i += 1;
256    })
257}
258
259/// Builds a `master_id` `StringArray` from an arbitrary (non-contiguous)
260/// sequence of global indices — duplicate-copy rows, whose master is
261/// whichever base row `indices` sampled, not a running counter. `indices`
262/// must yield exactly `n` values. See hunt1808/H1.
263fn master_id_array_from_indices(
264    prefix: &str,
265    mut indices: impl Iterator<Item = usize>,
266    n: usize,
267) -> ArrayRef {
268    let width = prefix.len() + 1 + PAD_LEN;
269    crate::buf_gen::build_string_array(n, width, |buf| {
270        let idx = indices
271            .next()
272            .expect("master_id_array_from_indices: fewer than n indices");
273        buf.extend_from_slice(prefix.as_bytes());
274        buf.push(b'-');
275        write_digits(buf, idx as u64, PAD_LEN);
276    })
277}
278
279/// Builds an `"HN-{:09}"` `master_id` `StringArray` for `n` rows, counting
280/// up from `start` — mirrors the previous per-row `format!("HN-{:09}", id)`
281/// loop, same values, same order. See hunt1808/H1.
282fn hn_master_id_array(start: u64, n: usize) -> ArrayRef {
283    const HN_WIDTH: usize = 12; // "HN-" + 9 digits
284    let mut id = start;
285    crate::buf_gen::build_string_array(n, HN_WIDTH, |buf| {
286        buf.extend_from_slice(b"HN-");
287        write_digits(buf, id, 9);
288        id += 1;
289    })
290}
291
292/// Inverse of `record_id_string`: parses `"R-0000000000007"` back to `7`.
293/// Returns `None` if `rid` isn't exactly `RID_LEN` bytes starting with
294/// `"R-"` followed by `PAD_LEN` ASCII digits (never expected in practice —
295/// every `record_id` in a duplicated cluster is produced by
296/// `record_id_string`/`record_id_strs`, never hand-built).
297pub(crate) fn parse_record_idx(rid: &str) -> Option<u64> {
298    if rid.len() != RID_LEN || !rid.starts_with("R-") {
299        return None;
300    }
301    rid[2..].parse().ok()
302}
303
304/// Packs a `master_id` of the fixed shape `"{entity_prefix}-{pad_string}"`
305/// (e.g. `"E00042-0000000001234"`) into a single `u64`: `entity_idx` in the
306/// high bits, `local_idx` in the low 44 bits. `entity_idx` is at most 5
307/// digits (`entity_prefix`'s format), so it fits comfortably above bit 44;
308/// `local_idx` is at most `PAD_LEN` (13) digits, which fits in 44 bits
309/// (2^44 ≈ 1.76e13). Preserves the same ordering as a lexicographic
310/// comparison of the original fixed-width string, since both segments are
311/// zero-padded to a constant width — callers that need the old
312/// `master_ids.sort()` determinism can just sort/compare the packed `u64`.
313/// Returns `None` if `mid` isn't `"E" + 5 digits + "-" + PAD_LEN digits`
314/// (never expected for a genuine duplicated master_id — see
315/// `gt::GtAccumulator::push_dup_batch`, the only source of `cluster_map`
316/// keys, which always writes this exact shape).
317pub(crate) fn pack_master_key(mid: &str) -> Option<u64> {
318    let bytes = mid.as_bytes();
319    if bytes.len() != 1 + 5 + 1 + PAD_LEN || bytes[0] != b'E' || bytes[6] != b'-' {
320        return None;
321    }
322    let entity_idx: u64 = mid[1..6].parse().ok()?;
323    let local_idx: u64 = mid[7..7 + PAD_LEN].parse().ok()?;
324    Some((entity_idx << 44) | local_idx)
325}
326
327/// Splits `total` proportionally across `weights` (largest-remainder
328/// method), so the sum of the result is always exactly `total`.
329///
330/// Used to spread each noise_type's planned duplicate count across every
331/// batch of masters in proportion to batch size, instead of assigning it
332/// all to a single batch — see the doc comment on the "Dups" section in
333/// `run_pipeline_with_progress` for why that matters.
334fn distribute_by_weight(total: usize, weights: &[usize]) -> Vec<usize> {
335    let weight_sum: usize = weights.iter().sum();
336    if weight_sum == 0 || total == 0 {
337        return vec![0; weights.len()];
338    }
339    let raw: Vec<f64> = weights
340        .iter()
341        .map(|&w| total as f64 * w as f64 / weight_sum as f64)
342        .collect();
343    let mut counts: Vec<usize> = raw.iter().map(|&r| r as usize).collect();
344    let assigned: usize = counts.iter().sum();
345    let mut remainder = total - assigned;
346    let mut order: Vec<usize> = (0..counts.len()).collect();
347    order.sort_by(|&a, &b| {
348        let fa = raw[a] - counts[a] as f64;
349        let fb = raw[b] - counts[b] as f64;
350        fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
351    });
352    for &i in &order {
353        if remainder == 0 {
354            break;
355        }
356        counts[i] += 1;
357        remainder -= 1;
358    }
359    counts
360}
361
362// ── Entity prefix ──────────────────────────────────────────────────────────
363
364/// Per-entity master_id prefix, derived from the entity's position in
365/// `config.entity_plans` rather than a hash of its name: a name hash mod
366/// 100000 had a real (if small) chance of two entities in the same domain
367/// colliding on the same prefix, which would make `gt.rs`'s master_id
368/// counting conflate masters from different entity types. An index is
369/// unique by construction — zero collision risk regardless of domain size.
370pub(crate) fn entity_prefix(entity_idx: usize) -> String {
371    format!("E{:05}", entity_idx)
372}
373
374// ── FK / HN pool types ─────────────────────────────────────────────────────
375
376type FkPoolMap = HashMap<String, RecordBatch>;
377
378struct HnPool {
379    batch: RecordBatch,
380    total_count: usize,
381    // Global record_ids aligned positionally with `batch` rows (only populated
382    // when graph output is enabled). Used to emit `hard_neg` edges.
383    record_ids: ArrayRef,
384}
385
386// ── Noise column matching ─────────────────────────────────────────────────
387
388/// Column-name fragments for columns holding a person's name as free text,
389/// even when the column isn't literally called `*_name` (e.g. `operator`,
390/// `technician`, populated from the `first_name` pool across several
391/// domain schemas — see the 40-domain schema audit in the 2026-07 session).
392const PERSON_NAME_WORDS: &[&str] = &[
393    "name",
394    // Not bare "first"/"last": those also matched non-name columns like
395    // `first_seen`/`last_active`/`last_login`/`last_updated`, sending them
396    // through char-level typo/homoglyph noise and injecting letters into
397    // what are actually timestamps (BUGS.md C13). Every legitimate name
398    // column across the 40 domain schemas is literally `*first_name*` /
399    // `*last_name*` (e.g. `agent_first_name`, `pat_first_name`), so this
400    // loses no real coverage.
401    "first_name",
402    "last_name",
403    "given",
404    "family",
405    "middle",
406    "operator",
407    "technician",
408    "inspector",
409    "claimant",
410    "consignee",
411    "investigator",
412    "collected_by",
413    "performed_by",
414    "assigned_to",
415];
416
417/// Column-name fragments for columns holding a company/organization name,
418/// even when the column isn't literally called `*_name`/`company*` (e.g.
419/// `supplier`, `manufacturer`, populated from the `company` pool across
420/// several domain schemas — see the 40-domain schema audit in the 2026-07
421/// session).
422const COMPANY_NAME_WORDS: &[&str] = &[
423    "company",
424    "legal",
425    "trading",
426    "name",
427    "supplier",
428    "manufacturer",
429    "sponsor",
430    "institution",
431    "journal",
432    "funder",
433    "employer",
434    "law_firm",
435    "affiliation",
436    "reported_by",
437    "buyer",
438    "shipper",
439    "airline",
440];
441
442/// Does `lower` match one of `PERSON_NAME_WORDS`' fragments *other than* the
443/// bare `"name"` catch-all — i.e. is it identifiable as a person-name column
444/// via a specific signal (`given`, `family`, `first_name`, `inspector`...),
445/// not just because its name happens to end in "name"?
446///
447/// Used to keep the `"name"` fragment both lists share (needed to catch
448/// columns like `judge_name`/`director_name` with no more specific word)
449/// from making `given_name`/`company_name` cross-match each other's noise
450/// category — see `is_specifically_company_name` and the `hunt2407.md`
451/// perf-hunt entry this fixes (kyc's `given_name`/`family_name`/
452/// `middle_name` were getting `companies` noise — `drop_legal_form` etc —
453/// applied to them, purely because `COMPANY_NAME_WORDS` also contains
454/// `"name"`).
455fn is_specifically_person_name(lower: &str) -> bool {
456    PERSON_NAME_WORDS
457        .iter()
458        .any(|&w| w != "name" && lower.contains(w))
459}
460
461/// Company-side mirror of `is_specifically_person_name`: matches a
462/// `COMPANY_NAME_WORDS` fragment other than the bare `"name"` catch-all.
463fn is_specifically_company_name(lower: &str) -> bool {
464    COMPANY_NAME_WORDS
465        .iter()
466        .any(|&w| w != "name" && lower.contains(w))
467}
468
469/// Does this noise category ever target a column with this (lowercased) name?
470///
471/// Pure name-pattern predicate shared with `estimate_difficulty` (see
472/// `crate::difficulty`) so the theoretical column-reliability model can never
473/// silently drift from what real generation actually does to each column.
474pub(crate) fn noise_type_targets_column(noise_type: &str, col_name: &str) -> bool {
475    let lower = col_name.to_lowercase();
476    match noise_type {
477        "typo" | "typo_aggressive" | "typo_extreme" | "qwerty_azerty" | "visual" | "homoglyph"
478        | "unicode_pollution" | "ocr_errors" | "case_swap" | "char_dropout" | "language_mix"
479        | "blocking_fail" => {
480            // Skip email-like columns — typo/visual noise destroys '@'.
481            // Skip `ip_address` too — it matches the "address" fragment
482            // below by name collision, but it isn't a postal address; this
483            // char-level noise injected letters into octets (BUGS.md C20).
484            if lower.contains("email") || lower.contains("ip_address") {
485                return false;
486            }
487            // Not "phone": that's digit-formatted data, not free text —
488            // char-level typo/homoglyph noise injected letters into phone
489            // numbers (BUGS.md C12). `identifiers`/`corrupt_phone` already
490            // corrupts phone columns digit-aware.
491            contains_any(&lower, &["address", "street", "city"])
492                || contains_any(&lower, PERSON_NAME_WORDS)
493                || contains_any(&lower, COMPANY_NAME_WORDS)
494        }
495        "names" | "nickname" | "initials" | "partial" | "name_compound" | "swap" | "full_swap" => {
496            // Exclude columns that are specifically identifiable as a
497            // company/organization name (e.g. `company_name`, `legal_name`)
498            // — the only reason they'd match PERSON_NAME_WORDS at all is the
499            // bare "name" fragment both lists share, not a real person-name
500            // signal. See `is_specifically_company_name`.
501            contains_any(&lower, PERSON_NAME_WORDS) && !is_specifically_company_name(&lower)
502        }
503        "dates" | "date_error" | "date_chaotic" | "date_format_mix" | "age_impossible" => {
504            // "time"/"since"/"period" added (hunt2808.md): the schema's
505            // `date`/`datetime` column type already tells the generator a
506            // column is a date, but this predicate only ever looked at the
507            // *name* -- `departure_time`/`arrival_time` (aviation, travel),
508            // `member_since` (hospitality), `pay_period_start`/`_end`
509            // (hr), `admission_time`/`discharge_time` (healthcare),
510            // `period_start`/`_end` (energy) all carry a real `date`/
511            // `datetime` schema type but none contain "date"/"birth"/
512            // "incorporation"/"founding" -- silently zero noise coverage
513            // from this category regardless of `passes`, on every
514            // difficulty tier. Checked against all 40 domains' column
515            // names for false-positive collisions before adding (none:
516            // the few non-Utf8 hits, e.g. `lifetime_stays`/`lead_time_days`/
517            // `on_time_rate`, are ints/floats already excluded upstream by
518            // `match_noise_columns`'s Utf8-only filter).
519            contains_any(
520                &lower,
521                &[
522                    "date",
523                    "birth",
524                    "incorporation",
525                    "founding",
526                    "time",
527                    "since",
528                    "period",
529                ],
530            )
531        }
532        "missing" | "missing_pattern" => {
533            contains_any(&lower, &["phone", "email", "mobile", "address", "street"])
534        }
535        "identifiers"
536        | "corrupt_email"
537        | "corrupt_phone"
538        | "corrupt_national_id"
539        | "corrupt_siren"
540        | "national_id_corrupt"
541        | "phone_corrupt"
542        | "email_corrupt"
543        | "siren_corrupt" => contains_any(
544            &lower,
545            &[
546                "email",
547                "phone",
548                "ssn",
549                "pan",
550                "passport",
551                "account",
552                "medicare",
553                "license",
554                "siren",
555                "national_id",
556                // Added: `difficulty::base_noise_damage` already models a
557                // 0.6 damage weight for "registration"-matching columns
558                // (e.g. kyc's `registration_number`), but this predicate
559                // never actually targeted them -- a real column with zero
560                // noise coverage regardless of `passes`, floor-ing hell's
561                // f1_max well above its intended ceiling on that schema.
562                "registration",
563                // "sku"/"barcode"/"imei"/"imsi"/"iccid" added (hunt2808.md):
564                // real sector-specific identifiers this fixed keyword list
565                // didn't recognize -- `sku` (ecommerce/fashion/retail/
566                // supplychain), `barcode`/`package_barcode` (ecommerce/
567                // biotech/logistics), `imei`/`imsi`/`iccid` (telecom
568                // `device`, standard cellular identifiers) all had zero
569                // noise coverage from this category. Checked against all
570                // 40 domains for false-positive collisions before adding
571                // (none found).
572                "sku",
573                "barcode",
574                "imei",
575                "imsi",
576                "iccid",
577            ],
578        ),
579        "extra"
580        | "name_null"
581        | "dob_null"
582        | "blocking_fail_initial"
583        | "blocking_fail_partial"
584        | "fuzzy_match"
585        | "phonetic" => {
586            // Same exclusions as the typo/visual arm above (BUGS.md C12/C20):
587            // skip email (destroys '@') and `ip_address` (not a postal
588            // address despite the name collision), and don't target `phone`
589            // with this char-level noise — `identifiers` already handles it
590            // digit-aware.
591            if lower.contains("email") || lower.contains("ip_address") {
592                return false;
593            }
594            // "bio" added (hunt2808.md): free-text fields named `bio`/
595            // `biography` (social_media, publishing) are exactly the kind
596            // of column this category targets, just not covered by the
597            // existing "note"/"comment" keywords.
598            contains_any(
599                &lower,
600                &["address", "street", "city", "note", "comment", "bio"],
601            ) || contains_any(&lower, PERSON_NAME_WORDS)
602                || contains_any(&lower, COMPANY_NAME_WORDS)
603        }
604        "companies" | "acronym" | "legal_form_drop" | "word_dropout" | "company_scramble" => {
605            // Mirror exclusion: don't apply company-name noise (legal-form
606            // dropping, acronym-ing...) to a column that's specifically
607            // identifiable as a person's name (e.g. `given_name`,
608            // `family_name`) just because it also contains "name". See
609            // `is_specifically_person_name`.
610            contains_any(&lower, COMPANY_NAME_WORDS) && !is_specifically_person_name(&lower)
611        }
612        "addresses" | "address_scramble" | "postal_corrupt" => {
613            // `ip_address` matches "address" by name collision but isn't a
614            // postal address — scrambling/postal-corrupting it doesn't make
615            // sense (see the same exclusion on the typo/visual arm above,
616            // BUGS.md C20). `email_address` is the same collision: it
617            // contains "address" but is an email, not a postal address —
618            // `identifiers`/`corrupt_email` already handles it (perf-hunt
619            // hunt2407.md, confirmed empirically: kyc's `email_address` was
620            // getting postal `address_scramble`/`postal_corrupt` noise).
621            //
622            // "state"/"country" added: `difficulty::base_noise_damage`
623            // already models a 0.5 damage weight for them (same bucket as
624            // address/street/city/postal), but this predicate never
625            // actually targeted them -- e.g. kyc's `residential_state` /
626            // `residential_country` / `registered_country` had zero real
627            // noise coverage regardless of `passes`.
628            // "location" added (hunt2808.md): `social_media::profile`'s
629            // `location` field is semantically an address/place field but
630            // wasn't covered by any existing keyword. Checked against all
631            // 40 domains for false-positive substring collisions before
632            // adding (none — the only "location"-containing columns are
633            // genuinely location-like: `work_location`, `branch_location`,
634            // `shelf_location`).
635            !lower.contains("ip_address")
636                && !lower.contains("email")
637                && contains_any(
638                    &lower,
639                    &[
640                        "address", "street", "postal", "city", "state", "country", "location",
641                    ],
642                )
643        }
644        "exact" | "english_name" | "estonian_name" | "lithuanian_name" | "slovak_name"
645        | "serbian_name" | "norwegian_name" | "swedish_name" | "dutch_name" | "czech_name"
646        | "albanian_name" | "polish_name" | "romanian_name" | "hungarian_name" | "german_name"
647        | "italian_name" | "spanish_name" | "portuguese_name" | "combo_hard" | "combo_extreme"
648        | "combo_ultimate" | "french_address" => {
649            // These noise types are handled inline or are no-ops
650            false
651        }
652        // Default: applies to all string columns (except FK columns)
653        _ => true,
654    }
655}
656
657fn match_noise_columns(schema: &Schema, noise_type: &str, exclude_cols: &[String]) -> Vec<String> {
658    let mut matched: Vec<String> = Vec::new();
659    for field in schema.fields() {
660        if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
661            continue;
662        }
663        if exclude_cols.contains(field.name()) {
664            continue;
665        }
666        if noise_type_targets_column(noise_type, field.name()) {
667            matched.push(field.name().clone());
668        }
669    }
670    // For swap, keep exactly 2 columns
671    if matches!(noise_type, "swap" | "full_swap") && matched.len() >= 2 {
672        matched.truncate(2);
673    }
674    matched.sort();
675    matched.dedup();
676    matched
677}
678
679fn contains_any(s: &str, patterns: &[&str]) -> bool {
680    patterns.iter().any(|p| s.contains(p))
681}
682
683// ── Noise application ──────────────────────────────────────────────────────
684
685/// Returns the noised batch plus the list of columns it actually targeted
686/// (resolved from the heuristic when `plan_cols` is empty) — callers use
687/// the latter to verify the noise produced a real change, see
688/// `unchanged_row_mask`.
689fn apply_noise_to_batch(
690    rb: &RecordBatch,
691    noise_type: &str,
692    plan_cols: &[String],
693    rng: &mut Rng,
694    exclude_cols: &[String],
695) -> Result<(RecordBatch, Vec<String>), String> {
696    let schema = rb.schema();
697    let n_cols = rb.num_columns();
698
699    // Fallback to heuristic when plan_cols is empty
700    let target_cols: Vec<String> = if plan_cols.is_empty() {
701        match_noise_columns(&schema, noise_type, exclude_cols)
702    } else {
703        plan_cols.to_vec()
704    };
705
706    // Start with None; only clone/modify targeted columns
707    let mut new_columns: Vec<Option<ArrayRef>> = (0..n_cols).map(|_| None).collect();
708
709    let get_col = |new_columns: &[Option<ArrayRef>], idx: usize, rb: &RecordBatch| -> ArrayRef {
710        new_columns[idx]
711            .clone()
712            .unwrap_or_else(|| rb.column(idx).clone())
713    };
714
715    match noise_type {
716        "blocking_fail" => {
717            for col_name in &target_cols {
718                let lower = col_name.to_lowercase();
719                if let Ok(idx) = schema.index_of(col_name) {
720                    let col = get_col(&new_columns, idx, rb);
721                    if lower.contains("first") || lower.contains("given") {
722                        new_columns[idx] =
723                            Some(crate::noise::extra::apply_blocking_initial(&*col, rng));
724                    } else {
725                        new_columns[idx] =
726                            Some(crate::noise::extra::apply_blocking_partial(&*col, rng));
727                    }
728                }
729            }
730        }
731        "swap" | "full_swap" => {
732            if target_cols.len() >= 2 {
733                let name_a = &target_cols[0];
734                let name_b = &target_cols[1];
735                if let (Ok(idx_a), Ok(idx_b)) = (schema.index_of(name_a), schema.index_of(name_b)) {
736                    let a = get_col(&new_columns, idx_a, rb);
737                    let b = get_col(&new_columns, idx_b, rb);
738                    new_columns[idx_a] = Some(b);
739                    new_columns[idx_b] = Some(a);
740                }
741            }
742        }
743        "missing_pattern" => {
744            let mut phone_idx: Option<usize> = None;
745            let mut email_idx: Option<usize> = None;
746            for col_name in &target_cols {
747                let lower = col_name.to_lowercase();
748                if let Ok(idx) = schema.index_of(col_name) {
749                    if lower.contains("phone") || lower.contains("mobile") {
750                        phone_idx = Some(idx);
751                    } else if lower.contains("email") {
752                        email_idx = Some(idx);
753                    }
754                }
755            }
756            if let (Some(pi), Some(ei)) = (phone_idx, email_idx) {
757                let phone_arr = get_col(&new_columns, pi, rb);
758                let email_arr = get_col(&new_columns, ei, rb);
759                let phone_s = phone_arr.as_string::<i32>();
760                let email_s = email_arr.as_string::<i32>();
761                let n = phone_s.len();
762                let mut builder = arrow::array::StringBuilder::with_capacity(n, 32);
763                for i in 0..n {
764                    let pv = if phone_s.is_null(i) {
765                        ""
766                    } else {
767                        phone_s.value(i)
768                    };
769                    let ev = if email_s.is_null(i) {
770                        ""
771                    } else {
772                        email_s.value(i)
773                    };
774                    if !ev.is_empty() && rng.next_usize(2) == 0 {
775                        builder.append_value("");
776                    } else {
777                        builder.append_value(pv);
778                    }
779                }
780                new_columns[pi] = Some(Arc::new(builder.finish()));
781            }
782        }
783        _ => {
784            for col_name in &target_cols {
785                if let Ok(col_idx) = schema.index_of(col_name) {
786                    let col = get_col(&new_columns, col_idx, rb);
787                    let result = crate::noise::apply_noise_to_column(&*col, noise_type, rng)
788                        .map_err(|e| format!("noise '{noise_type}' on '{col_name}': {e}"))?;
789                    new_columns[col_idx] = Some(result);
790                }
791            }
792        }
793    }
794
795    // Fill remaining from original, unwrap all
796    let final_columns: Vec<ArrayRef> = (0..n_cols)
797        .map(|i| {
798            new_columns[i]
799                .clone()
800                .unwrap_or_else(|| rb.column(i).clone())
801        })
802        .collect();
803
804    let batch =
805        RecordBatch::try_new(schema, final_columns).map_err(|e| format!("RecordBatch: {e}"))?;
806    Ok((batch, target_cols))
807}
808
809/// Per-row mask (`true` = still unchanged): compares `orig` and `noised` on
810/// every column in `target_cols`, treating null == null as equal. A `true`
811/// row means the noise application was a complete no-op for it — either
812/// because its target columns were already null (a "corrupted" null stays
813/// null) or because the noise function's own internal randomness happened
814/// not to produce a visible variant (several functions in `noise/names.rs`
815/// and `noise/extra.rs` only mutate a row some fraction of the time).
816/// Consumed by the retry loop in the dup-generation closure to guarantee a
817/// duplicate copy that was assigned a noise type actually differs from its
818/// master, instead of silently staying a byte-for-byte duplicate.
819fn unchanged_row_mask(
820    orig: &RecordBatch,
821    noised: &RecordBatch,
822    target_cols: &[String],
823) -> Result<Vec<bool>, String> {
824    let n = orig.num_rows();
825    unchanged_row_mask_within(orig, noised, target_cols, &vec![true; n])
826}
827
828/// Same as [`unchanged_row_mask`], but only re-tests rows where `only[i]`
829/// is `true` — every other row keeps its input value unconditionally
830/// (perf-hunt hunt0109/H6). Used by `apply_noise_with_retry`'s retry loop:
831/// after a retry, `zip(mask, retried, noisy)` only ever overwrites rows
832/// where `unchanged[i]` was `true` going in, so for any row where it was
833/// `false`, `noisy` coming out of that `zip` is byte-for-byte the same
834/// `noisy` that went in on every target column — re-comparing it to `orig`
835/// is guaranteed to reproduce the same `false` verdict. The previous
836/// implementation re-scanned all `n` rows on every attempt regardless,
837/// including rows already proven changed on attempt 1.
838fn unchanged_row_mask_within(
839    orig: &RecordBatch,
840    noised: &RecordBatch,
841    target_cols: &[String],
842    only: &[bool],
843) -> Result<Vec<bool>, String> {
844    let n = orig.num_rows();
845    // Rows outside `only` are not re-tested: they keep `false` (already
846    // known changed), matching the invariant documented above.
847    let mut changed: Vec<bool> = only.iter().map(|&keep| !keep).collect();
848    let schema = orig.schema();
849    for col_name in target_cols {
850        let Ok(idx) = schema.index_of(col_name) else {
851            continue;
852        };
853        let a = orig.column(idx).as_string::<i32>();
854        let b = noised.column(idx).as_string::<i32>();
855        // `i` indexes `a`/`b` (Arrow arrays, not iterable by `.iter_mut()`
856        // alongside `changed`) and `changed` in lockstep.
857        #[allow(clippy::needless_range_loop)]
858        for i in 0..n {
859            if changed[i] {
860                continue;
861            }
862            let av = (!a.is_null(i)).then(|| a.value(i));
863            let bv = (!b.is_null(i)).then(|| b.value(i));
864            if av != bv {
865                changed[i] = true;
866            }
867        }
868    }
869    for c in changed.iter_mut() {
870        *c = !*c;
871    }
872    Ok(changed)
873}
874
875/// Apply `noise_type` to `orig`, retrying with fresh randomness (merging in
876/// only the still-unchanged rows via `zip`) up to `MAX_NOISE_ATTEMPTS` times,
877/// until every row's target columns differ from `orig` or attempts run out.
878/// Returns the resulting batch, the final per-row "unchanged" mask (`true` =
879/// still byte-identical to `orig` on this noise_type's target columns), and
880/// the `target_cols` this noise_type actually touched.
881///
882/// When `target_cols` comes back empty (no column in this entity's schema
883/// matches `noise_type` at all), every row is returned unchanged (`true`)
884/// with no retries attempted — retrying can't manufacture a matching column
885/// that doesn't exist; this is a schema/config-level gap, not a per-row
886/// randomness one.
887///
888/// The retry merge only `zip()`s columns in `target_cols` (perf-hunt H1,
889/// hunt2407.md): `apply_noise_to_batch` already guarantees every other
890/// column of `retried`/`noisy` is the same `Arc` as `orig`'s (untouched),
891/// so `zip`-ing them is a provably-no-op pass over the full column just to
892/// reconstruct a value that's already sitting there — wasted work that
893/// scales with the entity's *total* column count instead of the handful
894/// this noise_type actually targets. Confirmed empirically: for a 23-column
895/// entity (kyc-sized) vs an 8-column one, this loop alone cost ~1.7x more
896/// wall time despite touching the same single target column either way.
897fn apply_noise_with_retry(
898    orig: &RecordBatch,
899    noise_type: &str,
900    plan_cols: &[String],
901    rng: &mut Rng,
902    exclude_cols: &[String],
903) -> Result<(RecordBatch, Vec<bool>, Vec<String>), String> {
904    const MAX_NOISE_ATTEMPTS: usize = 4;
905    let (mut noisy, target_cols) =
906        apply_noise_to_batch(orig, noise_type, plan_cols, rng, exclude_cols)?;
907    if target_cols.is_empty() {
908        return Ok((noisy, vec![true; orig.num_rows()], target_cols));
909    }
910    let schema = noisy.schema();
911    let target_idxs: Vec<usize> = target_cols
912        .iter()
913        .filter_map(|c| schema.index_of(c).ok())
914        .collect();
915    let mut unchanged = unchanged_row_mask(orig, &noisy, &target_cols)?;
916    let mut attempt = 1;
917    while unchanged.iter().any(|&u| u) && attempt < MAX_NOISE_ATTEMPTS {
918        let mut retry_rng = Rng::new(rng.next_u64());
919        let (retried, _) =
920            apply_noise_to_batch(orig, noise_type, plan_cols, &mut retry_rng, exclude_cols)?;
921        let mask = arrow::array::BooleanArray::from_iter(unchanged.iter().map(|&u| Some(u)));
922        let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
923        for &i in &target_idxs {
924            merged_cols[i] =
925                arrow::compute::kernels::zip::zip(&mask, retried.column(i), noisy.column(i))
926                    .map_err(|e| format!("zip noise retry col {i}: {e}"))?;
927        }
928        noisy = RecordBatch::try_new(noisy.schema(), merged_cols)
929            .map_err(|e| format!("rebuild retried dup batch: {e}"))?;
930        // Only re-test rows the zip above could actually have touched
931        // (perf-hunt hunt0109/H6) — every row with `unchanged[i] == false`
932        // is provably still `false` after this merge, see
933        // `unchanged_row_mask_within`'s doc comment.
934        unchanged = unchanged_row_mask_within(orig, &noisy, &target_cols, &unchanged)?;
935        attempt += 1;
936    }
937    Ok((noisy, unchanged, target_cols))
938}
939
940/// Applies one additional noise pass, drawing an independent noise_type per
941/// ROW from `all_types` instead of one type for the entire batch.
942///
943/// The original implementation drew a single `extra_type` for the whole
944/// batch (`all_types[rng.next_usize(all_types.len())]`), applied once. If
945/// that one draw landed on a type with no matching column in this entity's
946/// schema at all (`noise_type_targets_column` false for every column —
947/// common: `dates`/`identifiers`/`addresses`/`companies` are frequently
948/// no-ops for entities that simply have no date/identifier/address/company
949/// column, e.g. an aviation `passenger` row), `apply_noise_with_retry`
950/// returns every row unchanged with *no retry attempted* (retrying can't
951/// manufacture a matching column that doesn't exist) — so the entire batch
952/// silently skipped this pass. With only 2-3 total passes, whether a batch's
953/// "still identical after all passes" fate landed on such a no-op type was
954/// close to a coin flip per pass, and that coin flip's odds shifted with
955/// unrelated RNG-stream position (traced empirically: the same aviation
956/// domain/seed/difficulty produced a real byte-identical-duplicate rate
957/// swinging from ~12% to ~0.003% between two dataset sizes differing by
958/// under 5%, tracking exactly when a per-entity batch boundary was crossed
959/// elsewhere in the run — same root-cause family as `distribute_by_weight`).
960///
961/// Per-row assignment means every batch always has a healthy mix of types,
962/// so a handful of schema-inapplicable categories can no longer determine
963/// the whole batch's fate — same fix class as `noise::apply_random_subtype`,
964/// applied one level up (noise *type* selection, not just sub-type selection
965/// within one already-chosen type).
966fn apply_extra_pass_per_row(
967    noisy: &RecordBatch,
968    unchanged: &mut [bool],
969    all_types: &[&str],
970    rng: &mut Rng,
971    exclude_cols: &[String],
972) -> Result<RecordBatch, String> {
973    let n = noisy.num_rows();
974    if all_types.is_empty() || n == 0 {
975        return Ok(noisy.clone());
976    }
977    let per_row_type: Vec<usize> = (0..n).map(|_| rng.next_usize(all_types.len())).collect();
978
979    // Partition rows by assigned type FIRST (perf-hunt H4, hunt2407.md),
980    // same technique as `noise::apply_random_subtype` — each row needs
981    // noise from exactly one type, but the previous implementation called
982    // `apply_noise_with_retry` on the FULL n-row batch once per active
983    // type and threw away the n - |group| rows that mask didn't select:
984    // O(len(all_types) * n) noise computation instead of O(n). On a schema
985    // where every category has real target columns (e.g. kyc, all 9 hell
986    // categories active — hunt2407.md), that's up to 9x redundant work per
987    // extra pass. Grouping first means each row's noise is computed once,
988    // on a batch sized to just its own group.
989    // `row_group`/`row_pos` record, per original row, which type-group it
990    // landed in and its position within that group's `idxs` (built in the
991    // same pass as `groups`) — same technique as
992    // `noise::apply_random_subtype` (hunt1808/H2, transposing that fix one
993    // level up): every noised value is read straight out of its group's
994    // Arrow buffer at scatter time below, instead of round-tripping through
995    // a `HashMap<String, Vec<Option<String>>>` of owned `String`s that a
996    // final `StringArray::from` would then copy AGAIN.
997    let mut groups: Vec<Vec<u32>> = vec![Vec::new(); all_types.len()];
998    let mut row_group: Vec<u32> = Vec::with_capacity(n);
999    let mut row_pos: Vec<u32> = Vec::with_capacity(n);
1000    for (i, &t) in per_row_type.iter().enumerate() {
1001        row_pos.push(groups[t].len() as u32);
1002        row_group.push(t as u32);
1003        groups[t].push(i as u32);
1004    }
1005
1006    let schema = noisy.schema();
1007    let mut pass_unchanged = vec![true; n];
1008    let mut noised_by_group: Vec<Option<RecordBatch>> = vec![None; all_types.len()];
1009    // Per group, the column indices its noise_type actually targeted —
1010    // needed at scatter time to tell "this row's group touched this
1011    // column" (read `noised_by_group`) from "this row's group left this
1012    // column alone" (read `noisy`, unchanged) — different types touch
1013    // different column sets.
1014    let mut group_target_idxs: Vec<Vec<usize>> = vec![Vec::new(); all_types.len()];
1015    let mut touched_cols: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
1016
1017    for (type_idx, idxs) in groups.into_iter().enumerate() {
1018        if idxs.is_empty() {
1019            continue;
1020        }
1021        let ttype = all_types[type_idx];
1022        let idx_arr = UInt64Array::from_iter_values(idxs.iter().map(|&i| i as u64));
1023        let subset = pick_rows(noisy, &idx_arr)?;
1024        let (noised_subset, subset_unchanged, target_cols) =
1025            apply_noise_with_retry(&subset, ttype, &[], rng, exclude_cols)?;
1026        let target_idxs: Vec<usize> = target_cols
1027            .iter()
1028            .filter_map(|c| schema.index_of(c).ok())
1029            .collect();
1030        touched_cols.extend(target_idxs.iter().copied());
1031        group_target_idxs[type_idx] = target_idxs;
1032        for (local_pos, &orig_i) in idxs.iter().enumerate() {
1033            pass_unchanged[orig_i as usize] = subset_unchanged[local_pos];
1034        }
1035        noised_by_group[type_idx] = Some(noised_subset);
1036    }
1037
1038    let mut merged_cols: Vec<ArrayRef> = noisy.columns().to_vec();
1039    for col_idx in touched_cols {
1040        let orig_col = noisy.column(col_idx).as_string::<i32>();
1041        // Capacity sized to the column's actual existing value-buffer width
1042        // instead of a flat 16-byte guess (perf-hunt hunt0109/H3, same
1043        // motif as `pool_lookup::pool_values`) — `orig_col.value_data()` is
1044        // the whole values buffer already sitting in memory for this
1045        // column, so its length is an exact, free-to-read bound.
1046        let cap_bytes = orig_col.value_data().len().max(n);
1047        let mut builder = StringBuilder::with_capacity(n, cap_bytes);
1048        // Per-column, not per-row (perf-hunt hunt0109/H5): whether this
1049        // group even touches `col_idx`, and which noised `StringArray` to
1050        // read from it if so, depends only on `(g, col_idx)` — at most
1051        // `all_types.len()` distinct outcomes — but the previous loop
1052        // re-evaluated a linear `Vec::contains` scan AND an Arrow
1053        // `as_string::<i32>()` downcast (a dynamic type check) on every one
1054        // of the `n` rows. Precomputing per group here turns the inner loop
1055        // into a plain indexed lookup + branch.
1056        let per_group: Vec<Option<&arrow::array::StringArray>> = (0..group_target_idxs.len())
1057            .map(|g| {
1058                if group_target_idxs[g].contains(&col_idx) {
1059                    noised_by_group[g]
1060                        .as_ref()
1061                        .map(|b| b.column(col_idx).as_string::<i32>())
1062                } else {
1063                    None
1064                }
1065            })
1066            .collect();
1067        for i in 0..n {
1068            let g = row_group[i] as usize;
1069            match per_group[g] {
1070                Some(noised_col) => {
1071                    let pos = row_pos[i] as usize;
1072                    if noised_col.is_null(pos) {
1073                        builder.append_null();
1074                    } else {
1075                        builder.append_value(noised_col.value(pos));
1076                    }
1077                }
1078                None => {
1079                    if orig_col.is_null(i) {
1080                        builder.append_null();
1081                    } else {
1082                        builder.append_value(orig_col.value(i));
1083                    }
1084                }
1085            }
1086        }
1087        merged_cols[col_idx] = Arc::new(builder.finish());
1088    }
1089    let current = RecordBatch::try_new(noisy.schema(), merged_cols)
1090        .map_err(|e| format!("rebuild extra-pass batch: {e}"))?;
1091
1092    for (u, pu) in unchanged.iter_mut().zip(pass_unchanged.iter()) {
1093        *u = *u && *pu;
1094    }
1095    Ok(current)
1096}
1097
1098// ── Topological sort for FK dependency ordering ────────────────────────────
1099
1100/// Order entity plans so that FK target entities are processed before dependents.
1101fn topological_sort(
1102    plans: &[EntityPlan],
1103    name_to_idx: &HashMap<&str, usize>,
1104) -> Result<Vec<usize>, String> {
1105    let n = plans.len();
1106    let mut in_degree = vec![0usize; n];
1107    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
1108    for i in 0..n {
1109        for remap in &plans[i].fk_remaps {
1110            let Some(&target) = name_to_idx.get(remap.target_entity.as_str()) else {
1111                continue;
1112            };
1113            if target == i {
1114                continue;
1115            }
1116            adj[target].push(i); // target → i means target must come before i
1117            in_degree[i] += 1;
1118        }
1119    }
1120    let mut queue: Vec<usize> = (0..n).filter(|i| in_degree[*i] == 0).collect();
1121    let mut order = Vec::with_capacity(n);
1122    let mut idx = 0;
1123    while idx < queue.len() {
1124        let v = queue[idx];
1125        idx += 1;
1126        order.push(v);
1127        for &next in &adj[v] {
1128            in_degree[next] -= 1;
1129            if in_degree[next] == 0 {
1130                queue.push(next);
1131            }
1132        }
1133    }
1134    if order.len() != n {
1135        return Err("FK dependency cycle detected among entities".into());
1136    }
1137    Ok(order)
1138}
1139
1140/// Dataset sink: writes directly in the requested output format, avoiding
1141/// an uncompressed IPC intermediate + full reread when `parquet` is
1142/// requested (mirrors the `GtSink` pattern in `gt.rs`).
1143pub(crate) enum DatasetWriter {
1144    Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
1145    Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
1146}
1147
1148impl DatasetWriter {
1149    fn new(
1150        output_format: &str,
1151        path: &str,
1152        schema: &Arc<Schema>,
1153        metadata: &HashMap<String, String>,
1154    ) -> Result<Self, String> {
1155        let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
1156        if output_format == "parquet" {
1157            use parquet::basic::{Compression, ZstdLevel};
1158            use parquet::file::properties::WriterProperties;
1159            let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
1160            let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
1161                .iter()
1162                .map(|(k, v)| parquet::file::metadata::KeyValue {
1163                    key: k.clone(),
1164                    value: Some(v.clone()),
1165                })
1166                .collect();
1167            let props = WriterProperties::builder()
1168                .set_compression(Compression::ZSTD(zstd))
1169                // 1M rows/group (parquet-rs' own default) bounds the
1170                // resident, uncompressed column-chunk buffers to one row
1171                // group's worth at a time instead of the whole dataset.
1172                .set_max_row_group_row_count(Some(1_000_000))
1173                .set_key_value_metadata(Some(meta_kv))
1174                .build();
1175            let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
1176                .map_err(|e| format!("ArrowWriter error: {e}"))?;
1177            Ok(DatasetWriter::Parquet(Box::new(writer)))
1178        } else {
1179            let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
1180                .map_err(|e| format!("IPC FileWriter error: {e}"))?;
1181            Ok(DatasetWriter::Ipc(Box::new(writer)))
1182        }
1183    }
1184
1185    pub(crate) fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
1186        match self {
1187            DatasetWriter::Ipc(w) => w.write(batch).map_err(|e| format!("write ipc: {e}")),
1188            DatasetWriter::Parquet(w) => w.write(batch).map_err(|e| format!("write parquet: {e}")),
1189        }
1190    }
1191
1192    fn finish(self) -> Result<(), String> {
1193        match self {
1194            DatasetWriter::Ipc(mut w) => w.finish().map_err(|e| format!("finish ipc: {e}")),
1195            DatasetWriter::Parquet(w) => w
1196                .close()
1197                .map(|_| ())
1198                .map_err(|e| format!("close parquet: {e}")),
1199        }
1200    }
1201}
1202
1203// ── perf-hunt RSS-by-phase instrumentation (hunt1708.md, RAM pass) ─────────
1204//
1205// Temporary, non-invasive: only samples/prints when `RUST_LOG=debug` (or
1206// lower) is enabled -- default runs pay one `log::log_enabled!` check per
1207// call site and nothing else. Reuses `sysinfo` (already a normal
1208// dependency, already used for the same purpose in `main.rs`'s
1209// `warn_if_memory_tight`) instead of adding a new one for a temporary
1210// probe. Not a substitute for real allocation-site attribution (no VTune
1211// `memory-consumption` support on this machine/CPU, see hunt1708.md) --
1212// just phase-boundary RSS deltas to localize which part of the pipeline
1213// dominates peak RAM before writing an isolated micro-bench for it.
1214fn log_rss(label: &str) {
1215    if !log::log_enabled!(log::Level::Debug) {
1216        return;
1217    }
1218    let pid = sysinfo::Pid::from_u32(std::process::id());
1219    let mut sys = sysinfo::System::new();
1220    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
1221    if let Some(p) = sys.process(pid) {
1222        log::debug!("[hunt_rss] {label}: rss={:.1} Mo", p.memory() as f64 / 1e6);
1223    }
1224}
1225
1226// ── Main pipeline ──────────────────────────────────────────────────────────
1227
1228pub fn run_pipeline(
1229    ctx: &Context,
1230    config: &PipelineConfig,
1231    output_dir: &str,
1232) -> Result<PipelineOutput, String> {
1233    run_pipeline_with_progress(ctx, config, output_dir, None)
1234}
1235
1236/// Same as [`run_pipeline`], but invokes `progress(records_written_so_far,
1237/// approx_total)` after every base-record batch is written to the dataset
1238/// file — `approx_total` is `config.size` (the requested record count; the
1239/// actual total including duplicates/HN/canary rows differs slightly, close
1240/// enough for a progress display). Callers that don't need progress
1241/// reporting should use [`run_pipeline`] instead.
1242pub fn run_pipeline_with_progress(
1243    ctx: &Context,
1244    config: &PipelineConfig,
1245    output_dir: &str,
1246    progress: Option<&mut dyn FnMut(usize, usize)>,
1247) -> Result<PipelineOutput, String> {
1248    run_pipeline_chunked(ctx, config, output_dir, progress, None).map(|(out, _)| out)
1249}
1250
1251/// Same as [`run_pipeline_with_progress`], but threads `record_id`/
1252/// `master_id` counters through `offsets` (`None` == all-zero, identical to
1253/// a normal single run) and returns the counters' final values alongside
1254/// the usual output — the mechanism `--chunk-size` orchestration uses to
1255/// keep IDs globally contiguous across a sequence of otherwise-independent
1256/// chunk runs. See `ChunkOffsets` for why this exists.
1257pub fn run_pipeline_chunked(
1258    ctx: &Context,
1259    config: &PipelineConfig,
1260    output_dir: &str,
1261    mut progress: Option<&mut dyn FnMut(usize, usize)>,
1262    offsets: Option<&ChunkOffsets>,
1263) -> Result<(PipelineOutput, ChunkOffsets), String> {
1264    let offsets = offsets.cloned().unwrap_or_default();
1265    std::fs::create_dir_all(output_dir)
1266        .map_err(|e| format!("create output directory {output_dir:?}: {e}"))?;
1267    let t_start = std::time::Instant::now();
1268
1269    let t_alloc = t_start.elapsed().as_secs_f64();
1270    log_rss("start");
1271
1272    // ── Phase 14: Streaming pipeline ─────────────────────────────────────
1273    // Generates, extracts FK/HN, remaps, and sinks each batch per entity
1274    // without storing all entity batches in memory.
1275    let t1e = std::time::Instant::now();
1276
1277    // Metadata (including the `dupehell.timestamp` snapshot) is computed
1278    // once here and reused for every output file (dataset, graph, GT,
1279    // parquet conversions) — computing it per-file let `SystemTime::now()`
1280    // drift across a Unix-second boundary on longer runs, making
1281    // `dupehell.timestamp` inconsistent between files from the same run.
1282    let metadata = build_metadata_map(config);
1283    // Build the full schema once (union of all entity columns + metadata)
1284    let full_arc = Arc::new(build_full_schema(config, &metadata));
1285    // One shared dictionary per low-cardinality column, built from the full
1286    // known value set before any batch is written (hunt1808/H11) — every
1287    // `domain`/`entity_type` array for the rest of this file reuses these
1288    // same `values`, required for IPC dictionary consistency (see
1289    // `DictValues`). `entity_type`'s value set is exactly the written
1290    // (non-pool-only) entity plans' names — base/dup/HN/canary rows never
1291    // carry any other entity_type value.
1292    let domain_dict = DictValues::new([config.domain.clone()]);
1293    let entity_type_dict = DictValues::new(
1294        config
1295            .entity_plans
1296            .iter()
1297            .filter(|p| !p.pool_only)
1298            .map(|p| p.name.clone()),
1299    );
1300    let dataset_ext = if config.output_format == "parquet" {
1301        "parquet"
1302    } else {
1303        "ipc"
1304    };
1305    let dataset_path = format!("{}/{}.{}", output_dir, config.run_id, dataset_ext);
1306    let mut writer =
1307        DatasetWriter::new(&config.output_format, &dataset_path, &full_arc, &metadata)?;
1308
1309    // Topological sort for FK dependency processing order
1310    let name_to_idx: HashMap<&str, usize> = config
1311        .entity_plans
1312        .iter()
1313        .enumerate()
1314        .map(|(i, p)| (p.name.as_str(), i))
1315        .collect();
1316    let entity_order = topological_sort(&config.entity_plans, &name_to_idx)?;
1317
1318    // Cross-entity storage (lightweight)
1319    let mut fk_pools: FkPoolMap = HashMap::new();
1320    let mut hn_pools: HashMap<String, HnPool> = HashMap::new();
1321
1322    // GT streaming: fed one batch at a time as the dataset is generated
1323    // (see `crate::gt::GtAccumulator`) instead of accumulating full-dataset
1324    // arrays in RAM.
1325    let gt_ext = if config.output_format == "parquet" {
1326        "parquet"
1327    } else {
1328        "ipc"
1329    };
1330    let gt_path = format!("{}/{}_ground_truth.{}", output_dir, config.run_id, gt_ext);
1331    let gt_draft_path = format!("{}/{}_gt_draft.ipc", output_dir, config.run_id);
1332    let mut gt_acc = crate::gt::GtAccumulator::new(&gt_draft_path)?;
1333
1334    let mut global_rid_offset = offsets.rid_offset;
1335
1336    // ── Graph output (opt-in via --graph) ───────────────────────────────
1337    const GRAPH_MAX_CLUSTER_EDGES: usize = 10_000;
1338    let graph_fmt = crate::graph_gen::GraphFormat::from_str(&config.graph_format);
1339    let mut node_writer: Option<crate::graph_gen::NodeWriter> = None;
1340    let mut edge_writer: Option<crate::graph_gen::EdgeWriter> = None;
1341    let mut graph_nodes_path: Option<String> = None;
1342    let mut graph_edges_path: Option<String> = None;
1343    if config.graph_enabled {
1344        let nodes_ipc = format!("{}/{}_nodes.ipc", output_dir, config.run_id);
1345        let edges_ipc = format!("{}/{}_edges.ipc", output_dir, config.run_id);
1346        node_writer = Some(
1347            crate::graph_gen::NodeWriter::new(&nodes_ipc, &full_arc, &metadata)
1348                .map_err(|e| format!("init node writer: {e}"))?,
1349        );
1350        edge_writer = Some(
1351            crate::graph_gen::EdgeWriter::new(&edges_ipc, &metadata, subtype_dict(config))
1352                .map_err(|e| format!("init edge writer: {e}"))?,
1353        );
1354        graph_nodes_path = Some(nodes_ipc);
1355        graph_edges_path = Some(edges_ipc);
1356    }
1357
1358    let mut _t_write = 0.0f64;
1359    let mut _t_meta = 0.0f64;
1360    let mut _t_dup = 0.0f64;
1361    let mut _write_calls = 0u64;
1362    // Compute HN pool size: need at least 2× the max hn_cfg.count for any entity type
1363    let hn_pool_sizing: std::collections::HashMap<&str, usize> =
1364        config
1365            .hard_neg_types
1366            .iter()
1367            .fold(std::collections::HashMap::new(), |mut m, hn| {
1368                let entry = m.entry(hn.entity_type.as_str()).or_insert(0);
1369                *entry = (*entry).max(hn.count);
1370                m
1371            });
1372    let hn_max_pool: usize = hn_pool_sizing.values().max().copied().unwrap_or(50_000) * 2 + 1_000;
1373    // FK remap (fk_remap.rs) samples uniformly at random from this pool via
1374    // `take` — it has no need to see every identifier the entity ever
1375    // produced, only a large-enough sample for the target diversity. Capping
1376    // it (same idea as `hn_max_pool` above) keeps its RAM bounded instead of
1377    // O(n_base) per entity with an identifier column. (`FK_POOL_CAP` is now
1378    // module-level, see its doc comment.)
1379
1380    // Entities whose FK pool is ever read (`fk_pools.get(&remap.target_entity)`
1381    // below, and the identical lookup in `canary::generate_all`, which reuses
1382    // the same `fk_remaps`). Extracting identifiers for an entity outside
1383    // this set is pure waste — the pool built for it is never consulted.
1384    // Entities present only as an FK identifier pool (`--only-entity`),
1385    // never written as graph nodes. A `fk`-typed edge pointing at one of
1386    // these would dangle (target node absent from the nodes output), so
1387    // graph edges are suppressed for remaps into this set below.
1388    let pool_only_names: std::collections::HashSet<&str> = config
1389        .entity_plans
1390        .iter()
1391        .filter(|p| p.pool_only)
1392        .map(|p| p.name.as_str())
1393        .collect();
1394
1395    let fk_targets: std::collections::HashSet<&str> = config
1396        .entity_plans
1397        .iter()
1398        .flat_map(|p| p.fk_remaps.iter().map(|r| r.target_entity.as_str()))
1399        .collect();
1400
1401    for &plan_idx in &entity_order {
1402        let plan = &config.entity_plans[plan_idx];
1403        if plan.n_base == 0 {
1404            continue;
1405        }
1406
1407        let prefix = entity_prefix(plan_idx);
1408        let master_base = offsets.master_base(plan_idx);
1409        // Parsed once per entity, not once per batch (hunt1808/H8) — see
1410        // `entity_gen::parse_columns`'s doc comment.
1411        let parsed_columns = crate::entity_gen::parse_columns(&plan.columns_json)?;
1412
1413        // Per-entity streaming state
1414        let fk_targeted = fk_targets.contains(plan.name.as_str());
1415        let mut fk_builder = arrow::array::StringBuilder::new();
1416        let mut fk_rid_builder = arrow::array::StringBuilder::new();
1417        // Reused across every row of this entity's FK-pool extraction
1418        // (perf-hunt hunt0109/H7) instead of one `String` allocation per
1419        // row via `record_id_string` — see `append_record_id`.
1420        let mut fk_rid_buf: Vec<u8> = Vec::with_capacity(RID_LEN);
1421        let mut fk_count: usize = 0;
1422        // Only entities actually referenced by a `hard_neg_types` entry ever
1423        // have their HN pool read (see `hn_pools.get(&hn_cfg.entity_type)`
1424        // below); accumulating slices for the others just pins whole-batch
1425        // Arrow buffers in `hn_pools` for the rest of the run for nothing.
1426        let hn_targeted = hn_pool_sizing.contains_key(plan.name.as_str());
1427        let mut hn_slices: Vec<RecordBatch> = Vec::new();
1428        let mut hn_slice_rids: Vec<ArrayRef> = Vec::new();
1429        let mut batch_rng = Rng::new(config.seed.wrapping_add(100));
1430        let mut fk_rng = Rng::new(config.seed.wrapping_add(42));
1431
1432        // Each noise_type's planned `count` is spread across every batch of
1433        // masters in proportion to batch size (largest-remainder method),
1434        // so duplicates land on masters throughout the whole entity instead
1435        // of being sampled only from whichever batch happens to run last —
1436        // see the "Dups" section below, at the end of each batch iteration.
1437        let batch_bounds: Vec<(usize, usize)> = (0..plan.n_base)
1438            .step_by(crate::entity_gen::BATCH_SIZE)
1439            .map(|offset| {
1440                (
1441                    offset,
1442                    (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE),
1443                )
1444            })
1445            .collect();
1446        let batch_weights: Vec<usize> = batch_bounds.iter().map(|&(_, bn)| bn).collect();
1447        let per_batch_noise_counts: Vec<Vec<usize>> = plan
1448            .noise_types
1449            .iter()
1450            .map(|n| distribute_by_weight(n.count, &batch_weights))
1451            .collect();
1452        // Both invariant per entity, not per batch (perf-hunt hunt0109/H8)
1453        // — hoisted out of the "Dups" section below, alongside this
1454        // entity's other precomputed invariants (`batch_bounds`,
1455        // `per_batch_noise_counts`), instead of being reconstructed inside
1456        // `if has_dups { … }` on every batch that has duplicates.
1457        let fk_exclude_cols: Vec<String> = plan
1458            .fk_remaps
1459            .iter()
1460            .map(|r| r.source_col.clone())
1461            .collect();
1462        let all_types: Vec<&str> = plan
1463            .noise_types
1464            .iter()
1465            .map(|n| n.noise_type.as_str())
1466            .collect();
1467
1468        // Build col_lookup from first batch (it has the entity's schema)
1469        let mut col_lookup: Option<Vec<Option<usize>>> = None;
1470        // Phase 12.3: null cache per entity plan
1471        let mut null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
1472        let mut const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
1473
1474        // Stream: generate → FK remap → FK extract → HN sample → IPC write → GT collect
1475        for (batch_idx, offset) in (0..plan.n_base)
1476            .step_by(crate::entity_gen::BATCH_SIZE)
1477            .enumerate()
1478        {
1479            let batch_n = (plan.n_base - offset).min(crate::entity_gen::BATCH_SIZE);
1480            let batch_seed = config.seed.wrapping_add(batch_idx as u64 * 1000 + 1000);
1481
1482            // Generate batch
1483            let rb = crate::entity_gen::generate_entity_batch_parsed(
1484                ctx,
1485                &parsed_columns,
1486                batch_n,
1487                batch_seed,
1488                offset,
1489            )?;
1490
1491            // FK remap (uses pools from previously processed entities)
1492            let mut fk_edges_by_remap: Vec<(String, ArrayRef, f64)> = Vec::new();
1493            let rb = if !plan.fk_remaps.is_empty() {
1494                let mut r = rb;
1495                for remap in &plan.fk_remaps {
1496                    if let Some(pool) = fk_pools.get(&remap.target_entity) {
1497                        // Suppress edge emission into a pool-only target —
1498                        // its rows were never written as graph nodes, so an
1499                        // edge to them would dangle (see `pool_only_names`).
1500                        let want_edge = config.graph_enabled
1501                            && !pool_only_names.contains(remap.target_entity.as_str());
1502                        let (remapped, target_rids) = crate::fk_remap::fk_remap_batch(
1503                            &r,
1504                            pool,
1505                            &remap.source_col,
1506                            &mut fk_rng,
1507                            want_edge,
1508                        )?;
1509                        r = remapped;
1510                        if want_edge && let Some(rids) = target_rids {
1511                            fk_edges_by_remap.push((
1512                                remap.source_col.clone(),
1513                                rids,
1514                                remap.fk_weight,
1515                            ));
1516                        }
1517                    }
1518                }
1519                r
1520            } else {
1521                rb
1522            };
1523
1524            // Extract FK identifiers for this entity's pool, capped at
1525            // FK_POOL_CAP (see comment above).
1526            if fk_targeted
1527                && fk_count < FK_POOL_CAP
1528                && let Some(ref id_col) = plan.identifier_col
1529                && let Some(col) = rb.column_by_name(id_col)
1530            {
1531                let s = col.as_string::<i32>();
1532                for i in 0..s.len() {
1533                    if fk_count >= FK_POOL_CAP {
1534                        break;
1535                    }
1536                    if !s.is_null(i) {
1537                        fk_builder.append_value(s.value(i));
1538                        if config.graph_enabled {
1539                            fk_rid_buf.clear();
1540                            append_record_id(&mut fk_rid_buf, (global_rid_offset + i) as u64);
1541                            fk_rid_builder.append_value(
1542                                std::str::from_utf8(&fk_rid_buf)
1543                                    .expect("append_record_id writes only ASCII digits"),
1544                            );
1545                        }
1546                        fk_count += 1;
1547                    }
1548                }
1549            }
1550
1551            // Pool-only entity (`--only-entity`): its rows exist solely to
1552            // seed `fk_pools` (extracted just above) — no noise, no writing,
1553            // no GT, no graph nodes, and `global_rid_offset` does not
1554            // advance for it (record_ids stay contiguous across the rows
1555            // that ARE written).
1556            if plan.pool_only {
1557                continue;
1558            }
1559
1560            // HN pool: accumulate slices up to max pool size
1561            let hn_accum: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1562            if hn_targeted && hn_accum < hn_max_pool {
1563                let take = (hn_max_pool - hn_accum).min(batch_n);
1564                hn_slices.push(rb.slice(0, take));
1565                // Capture the global record_ids for these sliced rows so the
1566                // HN pool can later resolve `hard_neg` edge targets. Use
1567                // `global_rid_offset`, never the per-entity `offset`.
1568                if config.graph_enabled {
1569                    hn_slice_rids
1570                        .push(record_id_array(global_rid_offset..global_rid_offset + take));
1571                }
1572            }
1573
1574            // Build col_lookup from first batch's schema
1575            if col_lookup.is_none() {
1576                let plan_schema = rb.schema();
1577                col_lookup = Some(
1578                    full_arc
1579                        .fields()
1580                        .iter()
1581                        .skip(4)
1582                        .map(|f| plan_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1583                        .collect(),
1584                );
1585            }
1586
1587            // Master IDs for this batch
1588            let batch_mid_arr = master_id_array_range(&prefix, master_base + offset, batch_n);
1589
1590            // Add metadata + IPC write
1591            let rid_arr = record_id_array(global_rid_offset..global_rid_offset + batch_n);
1592            let t_m0 = std::time::Instant::now();
1593            let base_rb = add_metadata_and_align(
1594                &rb,
1595                &config.domain,
1596                &plan.name,
1597                &domain_dict,
1598                &entity_type_dict,
1599                &rid_arr,
1600                &batch_mid_arr,
1601                &full_arc,
1602                col_lookup.as_ref().unwrap(),
1603                &mut null_cache,
1604                &mut const_arr_cache,
1605            )?;
1606            _t_meta += t_m0.elapsed().as_secs_f64();
1607
1608            // Graph: write base nodes + FK edges (only when --graph)
1609            if config.graph_enabled
1610                && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
1611            {
1612                nw.write_batch(&base_rb)
1613                    .map_err(|e| format!("write node: {e}"))?;
1614                // hunt3108_graph/H2: `rid_arr` and each remap's `target_rids`
1615                // are already whole `StringArray`s in row order -- push them
1616                // as a single batch (one `Arc` clone each) instead of the
1617                // previous `for i in 0..batch_n` scalar loop that
1618                // re-extracted `.value(i)` and re-appended through a
1619                // `StringBuilder` plus a `HashMap` lookup per row.
1620                for (subtype, target_rids, weight) in &fk_edges_by_remap {
1621                    ew.push_batch(&rid_arr, target_rids, "fk", subtype, *weight)
1622                        .map_err(|e| format!("push fk edge batch: {e}"))?;
1623                }
1624            }
1625
1626            let t_w0 = std::time::Instant::now();
1627            writer
1628                .write(&base_rb)
1629                .map_err(|e| format!("write base: {e}"))?;
1630            _t_write += t_w0.elapsed().as_secs_f64();
1631            _write_calls += 1;
1632
1633            // GT collect
1634            gt_acc.push_base_batch(base_rb.column(0), base_rb.column(2), base_rb.column(3))?;
1635
1636            global_rid_offset += batch_n;
1637            if let Some(cb) = &mut progress {
1638                cb(global_rid_offset, config.size);
1639            }
1640
1641            // ── Dups ─────────────────────────────────────────────────────
1642            // Sampled from *this* batch's masters only (`rb`/`batch_n`/
1643            // `offset`), using this batch's share of each noise_type's
1644            // total count (`per_batch_noise_counts`, computed once before
1645            // the loop). Every batch of masters gets duplicates this way,
1646            // instead of concentrating all of them on whichever batch
1647            // happened to run last — see `distribute_by_weight`.
1648            let has_dups: bool = per_batch_noise_counts
1649                .iter()
1650                .any(|counts| counts[batch_idx] > 0);
1651            if has_dups {
1652                let t_d0 = std::time::Instant::now();
1653                let mut dup_batches: Vec<RecordBatch> = Vec::new();
1654                let mut dup_master_idx_buf: Vec<usize> = Vec::new();
1655
1656                // Pre-generate indices + parallel noise (Phase 13c)
1657                let ndata: Vec<(UInt64Array, u64, &str, &[String], usize)> = plan
1658                    .noise_types
1659                    .iter()
1660                    .enumerate()
1661                    .filter_map(|(ni, n)| {
1662                        let cnt = per_batch_noise_counts[ni][batch_idx];
1663                        if cnt == 0 {
1664                            return None;
1665                        }
1666                        let mut b = UInt64Builder::with_capacity(cnt);
1667                        for _ in 0..cnt {
1668                            b.append_value(batch_rng.next_usize(batch_n) as u64);
1669                        }
1670                        Some((
1671                            b.finish(),
1672                            batch_rng.next_u64(),
1673                            n.noise_type.as_str(),
1674                            n.columns.as_slice(),
1675                            cnt,
1676                        ))
1677                    })
1678                    .collect();
1679
1680                // Distinct active noise_type names for this entity (hoisted
1681                // above, see the H8 comment), used by additional noise
1682                // passes (below) to independently draw a type to apply on
1683                // top of the first pass's result.
1684                let all_types_ref = &all_types;
1685                let noise_passes = config.noise_passes.max(1);
1686
1687                type DupNoiseResult = Result<(RecordBatch, Vec<usize>, Vec<bool>), String>;
1688                let fk_exclude_ref = &fk_exclude_cols;
1689                let rb_ref = &rb;
1690                // `rayon::scope`'s `par_iter().map(...).collect()` runs each
1691                // noise-type bucket on Rayon's already-warm global pool
1692                // instead of spawning a fresh OS thread per bucket per batch
1693                // (the previous `std::thread::scope` pattern) — an isolated
1694                // micro-benchmark (hunt3007/H5, `examples/profile_thread_spawn.rs`)
1695                // measured raw OS thread spawn/join at 12-19x the cost of the
1696                // pooled equivalent at this spawn count/workload shape.
1697                // `par_iter` preserves input order in its output (unlike a
1698                // channel), so `results` ends up in the same `ndata` order as
1699                // before — required downstream since `dup_mids_buf`/
1700                // `global_rid_offset` assignment depends on that order, not on
1701                // which bucket happens to finish first.
1702                let results: Vec<DupNoiseResult> = ndata
1703                    .par_iter()
1704                    .map(|(indices, seed, ntype, cols, cnt)| {
1705                        let dup = pick_rows(rb_ref, indices)?;
1706                        let mut rng = Rng::new(*seed);
1707
1708                        // First pass: the noise_type this bucket was
1709                        // assigned, with the retry-until-changed guarantee
1710                        // (see `apply_noise_with_retry`) so a "noised"
1711                        // duplicate can't silently stay a byte-for-byte
1712                        // copy of its master.
1713                        let (mut noisy, mut unchanged, _) =
1714                            apply_noise_with_retry(&dup, ntype, cols, &mut rng, fk_exclude_ref)?;
1715
1716                        // Additional independent passes (difficulty-
1717                        // controlled, see `schema::DifficultySettings::
1718                        // passes`): each draws its own noise_type from this
1719                        // entity's active list and applies it on top of the
1720                        // previous pass's result, compounding corruption.
1721                        // This — not adding more categories to
1722                        // `noise_types` — is what makes a tier reliably
1723                        // harder without diluting any single category's
1724                        // weight (see the doc comment on `passes`). A row
1725                        // is only "still identical" overall once every
1726                        // pass left it unchanged.
1727                        for _ in 1..noise_passes {
1728                            noisy = apply_extra_pass_per_row(
1729                                &noisy,
1730                                &mut unchanged,
1731                                all_types_ref,
1732                                &mut rng,
1733                                fk_exclude_ref,
1734                            )?;
1735                        }
1736
1737                        let mut mb = Vec::with_capacity(*cnt);
1738                        for j in 0..*cnt {
1739                            // `indices` are local to `rb` (0..batch_n); the master_id
1740                            // is a pure function of (prefix, global index) — same
1741                            // formula as `batch_mids` above — so it's recomputed here
1742                            // instead of being cloned out of a retained master_id_pool.
1743                            let global_idx = master_base + offset + indices.value(j) as usize;
1744                            mb.push(global_idx);
1745                        }
1746                        Ok((noisy, mb, unchanged))
1747                    })
1748                    .collect();
1749                let mut dup_is_identical_buf: Vec<bool> = Vec::new();
1750                for res in results {
1751                    let (rb, mb, unchanged) = res?;
1752                    dup_batches.push(rb);
1753                    dup_master_idx_buf.extend(mb);
1754                    dup_is_identical_buf.extend(unchanged);
1755                }
1756                _t_dup += t_d0.elapsed().as_secs_f64();
1757
1758                // Write dups
1759                if !dup_batches.is_empty() {
1760                    let dup_total = dup_master_idx_buf.len();
1761                    let dup_rid_arr =
1762                        record_id_array(global_rid_offset..global_rid_offset + dup_total);
1763                    let dup_mid_arr = master_id_array_from_indices(
1764                        &prefix,
1765                        dup_master_idx_buf.iter().copied(),
1766                        dup_total,
1767                    );
1768
1769                    let concated = if dup_batches.len() == 1 {
1770                        dup_batches.into_iter().next().unwrap()
1771                    } else {
1772                        let schema = dup_batches[0].schema();
1773                        let n_fields = dup_batches[0].num_columns();
1774                        let mut concat_arrays = Vec::with_capacity(n_fields);
1775                        for i in 0..n_fields {
1776                            let refs: Vec<&dyn Array> =
1777                                dup_batches.iter().map(|b| b.column(i).as_ref()).collect();
1778                            concat_arrays.push(
1779                                arrow::compute::concat(&refs)
1780                                    .map_err(|e| format!("concat dup col {i}: {e}"))?,
1781                            );
1782                        }
1783                        RecordBatch::try_new(schema, concat_arrays)
1784                            .map_err(|e| format!("concat dups: {e}"))?
1785                    };
1786
1787                    let dup_rb_full = add_metadata_and_align(
1788                        &concated,
1789                        &config.domain,
1790                        &plan.name,
1791                        &domain_dict,
1792                        &entity_type_dict,
1793                        &dup_rid_arr,
1794                        &dup_mid_arr,
1795                        &full_arc,
1796                        col_lookup.as_ref().unwrap(),
1797                        &mut null_cache,
1798                        &mut const_arr_cache,
1799                    )?;
1800
1801                    let t_wd = std::time::Instant::now();
1802                    writer
1803                        .write(&dup_rb_full)
1804                        .map_err(|e| format!("write dups: {e}"))?;
1805                    _t_write += t_wd.elapsed().as_secs_f64();
1806                    _write_calls += 1;
1807
1808                    // Graph: write duplicate nodes (edges emitted post-GT)
1809                    if config.graph_enabled
1810                        && let Some(nw) = node_writer.as_mut()
1811                    {
1812                        nw.write_batch(&dup_rb_full)
1813                            .map_err(|e| format!("write dup node: {e}"))?;
1814                    }
1815
1816                    let is_identical_arr: ArrayRef =
1817                        Arc::new(arrow::array::BooleanArray::from(dup_is_identical_buf));
1818                    gt_acc.push_dup_batch(
1819                        dup_rb_full.column(0),
1820                        dup_rb_full.column(2),
1821                        dup_rb_full.column(3),
1822                        &is_identical_arr,
1823                    )?;
1824                    global_rid_offset += dup_total;
1825                }
1826            }
1827        }
1828
1829        // Save FK pool for cross-entity remapping
1830        if let Some(id_col) = &plan.identifier_col
1831            && fk_builder.len() > 0
1832        {
1833            let arr = Arc::new(fk_builder.finish()) as ArrayRef;
1834            if config.graph_enabled {
1835                // Graph mode: also persist the target record_ids (column 1)
1836                // so FK edges can be emitted by later-source entities. Same
1837                // bounds as the identifier column above (FK_POOL_CAP gated).
1838                let rid_arr = Arc::new(fk_rid_builder.finish()) as ArrayRef;
1839                let schema = Arc::new(Schema::new(vec![
1840                    Field::new(id_col, DataType::Utf8, true),
1841                    Field::new("record_id", DataType::Utf8, true),
1842                ]));
1843                if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr, rid_arr]) {
1844                    fk_pools.insert(plan.name.clone(), pool_rb);
1845                }
1846            } else {
1847                let schema = Arc::new(Schema::new(vec![Field::new(id_col, DataType::Utf8, true)]));
1848                if let Ok(pool_rb) = RecordBatch::try_new(schema, vec![arr]) {
1849                    fk_pools.insert(plan.name.clone(), pool_rb);
1850                }
1851            }
1852        }
1853
1854        // Build HN pool from collected slices
1855        if !hn_slices.is_empty() {
1856            let total_count: usize = hn_slices.iter().map(|s| s.num_rows()).sum();
1857            let batch = if hn_slices.len() == 1 {
1858                // `rb.slice(0, take)` (pushed above) is zero-copy: it keeps
1859                // the *entire* source batch's buffers alive (up to
1860                // BATCH_SIZE=500_000 rows × all columns) for the rest of the
1861                // run just to serve `take` rows. Re-materializing through the
1862                // `take` kernel (same helper as `pick_rows` below) builds
1863                // compact, right-sized buffers instead.
1864                let only = hn_slices.into_iter().next().unwrap();
1865                let idx = UInt64Array::from_iter_values(0..only.num_rows() as u64);
1866                pick_rows(&only, &idx)?
1867            } else {
1868                let schema = hn_slices[0].schema();
1869                let n_fields = schema.fields().len();
1870                let mut concat_arrays = Vec::with_capacity(n_fields);
1871                for i in 0..n_fields {
1872                    let refs: Vec<&dyn Array> =
1873                        hn_slices.iter().map(|pb| pb.column(i).as_ref()).collect();
1874                    concat_arrays.push(
1875                        arrow::compute::concat(&refs)
1876                            .map_err(|e| format!("hn pool concat col {i}: {e}"))?,
1877                    );
1878                }
1879                RecordBatch::try_new(schema, concat_arrays)
1880                    .map_err(|e| format!("hn pool concat batch: {e}"))?
1881            };
1882            // `record_ids` aligns positionally with the concatenated pool rows.
1883            let record_ids: ArrayRef = if config.graph_enabled {
1884                let refs: Vec<&dyn Array> = hn_slice_rids.iter().map(|a| a.as_ref()).collect();
1885                arrow::compute::concat(&refs).map_err(|e| format!("hn rid concat: {e}"))?
1886            } else {
1887                Arc::new(StringArray::new_null(0))
1888            };
1889            hn_pools.insert(
1890                plan.name.clone(),
1891                HnPool {
1892                    batch,
1893                    total_count,
1894                    record_ids,
1895                },
1896            );
1897        }
1898
1899        // Per-entity RSS checkpoint (hunt2808, RAM-first pass): the
1900        // aggregate "after entity batches" checkpoint below only samples
1901        // once for the whole domain, which can't tell apart an entity that
1902        // dominates RAM from one that's negligible when a domain has
1903        // several entities. `dup_masters_len`/`masters_with_exact_copy_len`
1904        // are read straight off `gt_acc` so H1 (cumulative GT bookkeeping
1905        // growing with total duplicated masters seen so far) can be
1906        // confirmed/refuted directly instead of inferred from RSS deltas
1907        // that also include this entity's own transient batch buffers.
1908        log::debug!(
1909            "[hunt_gt_sets] after entity '{}' ({} base rows): dup_masters={} masters_with_exact_copy={}",
1910            plan.name,
1911            plan.n_base,
1912            gt_acc.dup_masters_len(),
1913            gt_acc.masters_with_exact_copy_len(),
1914        );
1915        log_rss(&format!(
1916            "after entity '{}' ({} base rows)",
1917            plan.name, plan.n_base
1918        ));
1919    }
1920    let t1e_elapsed = t1e.elapsed().as_secs_f64();
1921    log::debug!("[sink_profile] write={_t_write:.3}s calls={_write_calls}");
1922    log_rss("after entity batches (fk_pools+hn_pools built)");
1923
1924    // ── Phase 2: Hard negatives ────────────────────────────────────────────
1925    let t2 = std::time::Instant::now();
1926    // Phase 12.3: separate null cache for HN section (different entity types)
1927    let mut hn_null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
1928    let mut hn_const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
1929    // Global counter for HN master_ids (shared across every hard_neg_types
1930    // entry in this run) instead of a random draw from a fixed 10M space:
1931    // at tens/hundreds of thousands of HN rows, `next_usize(10_000_000)`
1932    // has a real birthday-paradox chance of two unrelated HN rows landing
1933    // on the same master_id, which `gt.rs` would then count as sharing a
1934    // master — silently mislabeling a hard negative as a match. A counter
1935    // is unique by construction, eliminating the collision entirely.
1936    let mut hn_master_id_counter: u64 = offsets.hn_master_counter;
1937    for hn_cfg in config.hard_neg_types.iter() {
1938        if hn_cfg.count == 0 {
1939            continue;
1940        }
1941        let Some(pool_data) = hn_pools.get(&hn_cfg.entity_type) else {
1942            continue;
1943        };
1944        let hn_total = pool_data.total_count;
1945
1946        let pool_rb = &pool_data.batch;
1947        let n_hn_max = hn_cfg.count.min(hn_total / 2);
1948        if n_hn_max == 0 {
1949            continue;
1950        }
1951
1952        let (hn_rb, hn_src) = crate::hn_common::generate_hard_negatives(
1953            pool_rb,
1954            &hn_cfg.config_json,
1955            n_hn_max,
1956            config.seed.wrapping_add(200),
1957            config.graph_enabled,
1958        )?;
1959
1960        let n_hn = hn_rb.num_rows();
1961        if n_hn == 0 {
1962            continue;
1963        }
1964
1965        let hn_rid_arr = record_id_array(global_rid_offset..global_rid_offset + n_hn);
1966        let hn_mid_arr = hn_master_id_array(hn_master_id_counter, n_hn);
1967        hn_master_id_counter += n_hn as u64;
1968
1969        // Build col lookup for the HN entity type schema
1970        let hn_schema = hn_rb.schema();
1971        let hn_col_lookup: Vec<Option<usize>> = full_arc
1972            .fields()
1973            .iter()
1974            .skip(4)
1975            .map(|f| hn_schema.column_with_name(f.name()).map(|(idx, _)| idx))
1976            .collect();
1977
1978        let hn_rb_full = add_metadata_and_align(
1979            &hn_rb,
1980            &config.domain,
1981            &hn_cfg.entity_type,
1982            &domain_dict,
1983            &entity_type_dict,
1984            &hn_rid_arr,
1985            &hn_mid_arr,
1986            &full_arc,
1987            &hn_col_lookup,
1988            &mut hn_null_cache,
1989            &mut hn_const_arr_cache,
1990        )?;
1991
1992        let t_wh = std::time::Instant::now();
1993        writer
1994            .write(&hn_rb_full)
1995            .map_err(|e| format!("write hn: {e}"))?;
1996        _t_write += t_wh.elapsed().as_secs_f64();
1997        _write_calls += 1;
1998
1999        // Graph: write HN nodes + hard_neg edges (source = HN rid,
2000        // target = the pool row referenced by idx_a)
2001        if config.graph_enabled
2002            && let (Some(nw), Some(ew)) = (node_writer.as_mut(), edge_writer.as_mut())
2003        {
2004            nw.write_batch(&hn_rb_full)
2005                .map_err(|e| format!("write hn node: {e}"))?;
2006            if let Some((idx_a, pattern)) = hn_src {
2007                // hunt3108_graph/H2: gather the target ids with the `take`
2008                // kernel (vectorized) instead of a scalar
2009                // `for i in .. { pool_rid_arr.value(idx_a[i]) }` loop, then
2010                // push the whole edge batch in one call -- same reasoning as
2011                // the FK-edge site above.
2012                let take_n = n_hn.min(idx_a.len());
2013                let idx_arr = arrow::array::UInt64Array::from_iter_values(
2014                    idx_a.iter().take(take_n).map(|&i| i as u64),
2015                );
2016                let tgt_arr = arrow::compute::take(&pool_data.record_ids, &idx_arr, None)
2017                    .map_err(|e| format!("take hn target ids: {e}"))?;
2018                let src_arr = hn_rid_arr.slice(0, take_n);
2019                ew.push_batch(&src_arr, &tgt_arr, "hard_neg", &pattern, 1.0)
2020                    .map_err(|e| format!("push hn edge batch: {e}"))?;
2021            }
2022        }
2023
2024        // Phase 13: collect ArrayRefs instead of per-row StringBuilder
2025        gt_acc.push_other_batch(
2026            hn_rb_full.column(0),
2027            hn_rb_full.column(2),
2028            hn_rb_full.column(3),
2029        )?;
2030
2031        global_rid_offset += n_hn;
2032    }
2033    let t2_elapsed = t2.elapsed().as_secs_f64();
2034    // `hn_pools` is never read again past this point, but its natural scope
2035    // end is the whole function — dropping it explicitly here frees its RAM
2036    // (measured 125.1 Mo on a 50M-record aviation/hell run, hunt1808/H-RAM2)
2037    // as soon as it's actually done being useful instead of letting it sit
2038    // resident through canary generation, the dataset writer's finish, and
2039    // GT accumulation.
2040    drop(hn_pools);
2041    log_rss("after hard negatives");
2042
2043    // ── Canary records ────────────────────────────────────────────────────
2044    {
2045        let mut canary_null_cache: FxHashMap<(DataType, usize), ArrayRef> = FxHashMap::default();
2046        let mut canary_const_arr_cache: FxHashMap<(String, usize), ArrayRef> = FxHashMap::default();
2047        crate::canary::generate_all(
2048            ctx,
2049            config,
2050            &full_arc,
2051            &domain_dict,
2052            &entity_type_dict,
2053            &mut canary_null_cache,
2054            &mut canary_const_arr_cache,
2055            &mut global_rid_offset,
2056            &fk_pools,
2057            &mut writer,
2058            &mut node_writer,
2059            &mut gt_acc,
2060        )?;
2061    }
2062    log_rss("after canary records");
2063
2064    // ── Phase 3: Finalize + GT (streamed, see crate::gt::GtAccumulator) ────
2065    let t3 = std::time::Instant::now();
2066    writer
2067        .finish()
2068        .map_err(|e| format!("finish dataset writer: {e}"))?;
2069    let t3a_elapsed = t3.elapsed().as_secs_f64();
2070    log_rss("after dataset writer finish");
2071
2072    let t3b = std::time::Instant::now();
2073    let t_gt0 = std::time::Instant::now();
2074    let crate::gt::GtResult {
2075        n_exact_dup,
2076        n_fuzzy_dup,
2077        n_hard_neg,
2078        n_unique,
2079        n_masters,
2080        cluster_map,
2081    } = gt_acc.finish(
2082        config.difficulty.as_str(),
2083        &config.output_format,
2084        &gt_path,
2085        &metadata,
2086        config.graph_enabled,
2087    )?;
2088    let _t_gt_compute = 0.0f64;
2089    let _t_gt_write = t_gt0.elapsed().as_secs_f64();
2090    let t3b_elapsed = t3b.elapsed().as_secs_f64();
2091    let _t3_elapsed = t3.elapsed().as_secs_f64();
2092    log_rss("after GT finish (cluster_map built)");
2093
2094    // ── Graph: emit duplicate-cluster edges from the post-GT cluster_map ──
2095    if config.graph_enabled
2096        && let Some(ew) = edge_writer.as_mut()
2097    {
2098        crate::graph_gen::push_dup_clusters(ew, &cluster_map, GRAPH_MAX_CLUSTER_EDGES)
2099            .map_err(|e| format!("push dup clusters: {e}"))?;
2100    }
2101
2102    let duration = t_start.elapsed().as_secs_f64();
2103
2104    log::debug!(
2105        "[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",
2106        t_alloc,
2107        t1e_elapsed,
2108        t2_elapsed,
2109        t3a_elapsed,
2110        _t_meta,
2111        _t_dup,
2112        _t_write,
2113        _t_gt_compute,
2114        _t_gt_write,
2115        t3b_elapsed,
2116        duration,
2117    );
2118
2119    let stats = PipelineStats {
2120        total_records: global_rid_offset,
2121        exact_dups: n_exact_dup,
2122        fuzzy_dups: n_fuzzy_dup,
2123        hard_negs: n_hard_neg,
2124        uniques: n_unique,
2125        masters: n_masters,
2126    };
2127
2128    // ── Finalize graph writers (base nodes + FK edges emitted so far) ──
2129    let mut graph_nodes_final: Option<String> = None;
2130    let mut graph_edges_final: Option<String> = None;
2131    if let (Some(nw), Some(ew)) = (node_writer, edge_writer) {
2132        nw.finish()
2133            .map_err(|e| format!("finish node writer: {e}"))?;
2134        ew.finish()
2135            .map_err(|e| format!("finish edge writer: {e}"))?;
2136        let mut np = graph_nodes_path.clone().unwrap();
2137        let mut ep = graph_edges_path.clone().unwrap();
2138        if graph_fmt == crate::graph_gen::GraphFormat::Parquet {
2139            let np_pq = format!("{}/{}_nodes.parquet", output_dir, config.run_id);
2140            let ep_pq = format!("{}/{}_edges.parquet", output_dir, config.run_id);
2141            convert_ipc_to_parquet(&np, &np_pq).map_err(|e| format!("graph nodes parquet: {e}"))?;
2142            convert_ipc_to_parquet(&ep, &ep_pq).map_err(|e| format!("graph edges parquet: {e}"))?;
2143            std::fs::remove_file(&np).ok();
2144            std::fs::remove_file(&ep).ok();
2145            np = np_pq;
2146            ep = ep_pq;
2147        }
2148        graph_nodes_final = Some(np);
2149        graph_edges_final = Some(ep);
2150    }
2151    log_rss("end (after graph finalize)");
2152
2153    // Carry each entity's final local master_id counter forward for the
2154    // next chunk (`--chunk-size`) — pool_only entities never assign a
2155    // master_id (see the `continue` above), so their slot is left as-is.
2156    let mut master_local = offsets.master_local.clone();
2157    if master_local.len() < config.entity_plans.len() {
2158        master_local.resize(config.entity_plans.len(), 0);
2159    }
2160    for (idx, plan) in config.entity_plans.iter().enumerate() {
2161        if !plan.pool_only {
2162            master_local[idx] = offsets.master_base(idx) + plan.n_base;
2163        }
2164    }
2165    let next_offsets = ChunkOffsets {
2166        rid_offset: global_rid_offset,
2167        master_local,
2168        hn_master_counter: hn_master_id_counter,
2169    };
2170
2171    Ok((
2172        PipelineOutput {
2173            output_files: vec![dataset_path],
2174            gt_file: gt_path,
2175            stats,
2176            nodes: graph_nodes_final,
2177            edges: graph_edges_final,
2178        },
2179        next_offsets,
2180    ))
2181}
2182
2183/// IPC → Parquet (ZSTD) conversion for graph files, mirroring the dataset
2184/// conversion above. The node IPC already carries the `dupehell.*` metadata,
2185/// which is forwarded to the Parquet key/value metadata.
2186fn convert_ipc_to_parquet(ipc_path: &str, parquet_path: &str) -> Result<(), String> {
2187    use arrow::ipc::reader::FileReader;
2188
2189    let ipc_file =
2190        std::fs::File::open(ipc_path).map_err(|e| format!("open IPC {ipc_path}: {e}"))?;
2191    let ipc_reader =
2192        FileReader::try_new(ipc_file, None).map_err(|e| format!("IPC reader {ipc_path}: {e}"))?;
2193    let schema = ipc_reader.schema();
2194
2195    let parquet_file =
2196        std::fs::File::create(parquet_path).map_err(|e| format!("create {parquet_path}: {e}"))?;
2197    let zstd_level = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
2198    let meta_kv: Vec<parquet::file::metadata::KeyValue> = schema
2199        .metadata()
2200        .iter()
2201        .map(|(k, v)| parquet::file::metadata::KeyValue {
2202            key: k.clone(),
2203            value: Some(v.clone()),
2204        })
2205        .collect();
2206    let props = parquet::file::properties::WriterProperties::builder()
2207        .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
2208        .set_max_row_group_row_count(Some(1_000_000))
2209        .set_key_value_metadata(Some(meta_kv))
2210        .build();
2211    let mut parquet_writer =
2212        parquet::arrow::ArrowWriter::try_new(parquet_file, schema, Some(props))
2213            .map_err(|e| format!("ArrowWriter {parquet_path}: {e}"))?;
2214
2215    for batch_result in ipc_reader {
2216        let batch = batch_result.map_err(|e| format!("IPC batch {ipc_path}: {e}"))?;
2217        parquet_writer
2218            .write(&batch)
2219            .map_err(|e| format!("write parquet {parquet_path}: {e}"))?;
2220    }
2221    parquet_writer
2222        .close()
2223        .map_err(|e| format!("close parquet {parquet_path}: {e}"))?;
2224    Ok(())
2225}
2226
2227// ── Chunked generation (`--chunk-size`) ─────────────────────────────────────
2228
2229/// Streams every batch (IPC) or row group (Parquet) of each file in
2230/// `in_paths` — all sharing the same schema, as chunk outputs from
2231/// `run_chunked` always do — into a single `out_path`, never holding more
2232/// than one batch/row-group resident at a time. Used to assemble
2233/// `--chunk-size` chunk outputs (dataset/GT/graph files) into the one final
2234/// file a normal (non-chunked) run would have produced.
2235fn concat_dataset_files(
2236    output_format: &str,
2237    in_paths: &[String],
2238    out_path: &str,
2239) -> Result<(), String> {
2240    if in_paths.is_empty() {
2241        return Err("concat_dataset_files: no chunk files to concatenate".into());
2242    }
2243    if output_format == "parquet" {
2244        concat_parquet_files(in_paths, out_path)
2245    } else {
2246        concat_ipc_files(in_paths, out_path)
2247    }
2248}
2249
2250fn concat_ipc_files(in_paths: &[String], out_path: &str) -> Result<(), String> {
2251    use arrow::ipc::reader::FileReader;
2252
2253    let first_file =
2254        std::fs::File::open(&in_paths[0]).map_err(|e| format!("open {}: {e}", in_paths[0]))?;
2255    let schema = FileReader::try_new(first_file, None)
2256        .map_err(|e| format!("ipc reader {}: {e}", in_paths[0]))?
2257        .schema();
2258
2259    let out_file =
2260        std::fs::File::create(out_path).map_err(|e| format!("create {out_path}: {e}"))?;
2261    let mut writer = arrow::ipc::writer::FileWriter::try_new(out_file, &schema)
2262        .map_err(|e| format!("ipc writer {out_path}: {e}"))?;
2263
2264    for path in in_paths {
2265        let file = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
2266        let reader =
2267            FileReader::try_new(file, None).map_err(|e| format!("ipc reader {path}: {e}"))?;
2268        for batch_result in reader {
2269            let batch = batch_result.map_err(|e| format!("read {path}: {e}"))?;
2270            writer
2271                .write(&batch)
2272                .map_err(|e| format!("write {out_path}: {e}"))?;
2273        }
2274    }
2275    writer
2276        .finish()
2277        .map_err(|e| format!("finish {out_path}: {e}"))
2278}
2279
2280fn concat_parquet_files(in_paths: &[String], out_path: &str) -> Result<(), String> {
2281    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
2282
2283    let first_file =
2284        std::fs::File::open(&in_paths[0]).map_err(|e| format!("open {}: {e}", in_paths[0]))?;
2285    let first_builder = ParquetRecordBatchReaderBuilder::try_new(first_file)
2286        .map_err(|e| format!("parquet reader {}: {e}", in_paths[0]))?;
2287    let schema = first_builder.schema().clone();
2288    let meta_kv: Vec<parquet::file::metadata::KeyValue> = first_builder
2289        .metadata()
2290        .file_metadata()
2291        .key_value_metadata()
2292        .cloned()
2293        .unwrap_or_default();
2294
2295    let out_file =
2296        std::fs::File::create(out_path).map_err(|e| format!("create {out_path}: {e}"))?;
2297    let zstd = parquet::basic::ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
2298    let props = parquet::file::properties::WriterProperties::builder()
2299        .set_compression(parquet::basic::Compression::ZSTD(zstd))
2300        .set_max_row_group_row_count(Some(1_000_000))
2301        .set_key_value_metadata(Some(meta_kv))
2302        .build();
2303    let mut writer = parquet::arrow::ArrowWriter::try_new(out_file, schema, Some(props))
2304        .map_err(|e| format!("arrow writer {out_path}: {e}"))?;
2305
2306    for path in in_paths {
2307        let file = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
2308        let reader = ParquetRecordBatchReaderBuilder::try_new(file)
2309            .map_err(|e| format!("parquet reader {path}: {e}"))?
2310            .build()
2311            .map_err(|e| format!("build parquet reader {path}: {e}"))?;
2312        for batch_result in reader {
2313            let batch = batch_result.map_err(|e| format!("read {path}: {e}"))?;
2314            writer
2315                .write(&batch)
2316                .map_err(|e| format!("write {out_path}: {e}"))?;
2317        }
2318    }
2319    writer
2320        .close()
2321        .map(|_| ())
2322        .map_err(|e| format!("close {out_path}: {e}"))
2323}
2324
2325/// Generates `total_size` records by internally looping over
2326/// `ceil(total_size / chunk_size)` sequential, independently-seeded chunk
2327/// runs (`seed + chunk_idx`), keeping `record_id`/`master_id` globally
2328/// contiguous across chunks (see `ChunkOffsets`) instead of the shard-tag
2329/// hack an external concat script would need, then assembling the chunk
2330/// outputs into one final dataset/GT/graph file via `concat_dataset_files`.
2331/// RAM stays bounded to roughly one `--size chunk_size` run at a time — each
2332/// chunk is a fully independent pipeline run, only the identifier counters
2333/// and (for the final assembly step) the output files carry over.
2334///
2335/// If `chunk_size == 0` or `chunk_size >= total_size`, this is exactly one
2336/// chunk — delegates straight to `run_pipeline_with_progress`, byte-for-byte
2337/// identical to calling it directly (no `--chunk-size` given).
2338#[allow(clippy::too_many_arguments)]
2339pub fn run_chunked(
2340    ctx: &Context,
2341    domain: &str,
2342    total_size: usize,
2343    chunk_size: usize,
2344    seed: u64,
2345    difficulty: &str,
2346    hard_neg_ratio: f64,
2347    singleton_master_fraction: f64,
2348    schema: &crate::schema::DomainSchema,
2349    final_run_id: &str,
2350    output_format: &str,
2351    graph_enabled: bool,
2352    graph_format: &str,
2353    only_entity: Option<&str>,
2354    output_dir: &str,
2355    mut progress: Option<&mut dyn FnMut(usize, usize)>,
2356) -> Result<PipelineOutput, String> {
2357    if chunk_size == 0 || chunk_size >= total_size {
2358        let config = crate::schema::build_pipeline_config(
2359            domain,
2360            total_size,
2361            seed,
2362            difficulty,
2363            hard_neg_ratio,
2364            singleton_master_fraction,
2365            schema,
2366            final_run_id,
2367            output_format,
2368            graph_enabled,
2369            graph_format,
2370            only_entity,
2371        )?;
2372        return run_pipeline_with_progress(ctx, &config, output_dir, progress);
2373    }
2374
2375    let n_chunks = total_size.div_ceil(chunk_size);
2376    let tmp_dir = format!("{output_dir}/.dupehell_chunks_{final_run_id}");
2377    std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("create {tmp_dir:?}: {e}"))?;
2378
2379    let mut offsets = ChunkOffsets::default();
2380    let mut dataset_chunks: Vec<String> = Vec::new();
2381    let mut gt_chunks: Vec<String> = Vec::new();
2382    let mut node_chunks: Vec<String> = Vec::new();
2383    let mut edge_chunks: Vec<String> = Vec::new();
2384    let mut agg = PipelineStats {
2385        total_records: 0,
2386        exact_dups: 0,
2387        fuzzy_dups: 0,
2388        hard_negs: 0,
2389        uniques: 0,
2390        masters: 0,
2391    };
2392
2393    for chunk_idx in 0..n_chunks {
2394        let this_size = chunk_size.min(total_size - chunk_idx * chunk_size);
2395        let chunk_seed = seed.wrapping_add(chunk_idx as u64);
2396        let chunk_run_id = format!("{final_run_id}__chunk{chunk_idx:04}");
2397        let chunk_config = crate::schema::build_pipeline_config(
2398            domain,
2399            this_size,
2400            chunk_seed,
2401            difficulty,
2402            hard_neg_ratio,
2403            singleton_master_fraction,
2404            schema,
2405            &chunk_run_id,
2406            output_format,
2407            graph_enabled,
2408            graph_format,
2409            only_entity,
2410        )?;
2411
2412        let already_written = agg.total_records;
2413        let mut chunk_cb = |done: usize, _total: usize| {
2414            if let Some(cb) = &mut progress {
2415                cb(already_written + done, total_size);
2416            }
2417        };
2418        let (out, next_offsets) = run_pipeline_chunked(
2419            ctx,
2420            &chunk_config,
2421            &tmp_dir,
2422            Some(&mut chunk_cb),
2423            Some(&offsets),
2424        )?;
2425        offsets = next_offsets;
2426
2427        // `out.stats.total_records` is `global_rid_offset` at the end of
2428        // the chunk, which started from `offsets.rid_offset` (the previous
2429        // chunk's running total) — it's already the running grand total,
2430        // not a per-chunk delta.
2431        agg.total_records = out.stats.total_records;
2432        agg.exact_dups += out.stats.exact_dups;
2433        agg.fuzzy_dups += out.stats.fuzzy_dups;
2434        agg.hard_negs += out.stats.hard_negs;
2435        agg.uniques += out.stats.uniques;
2436        agg.masters += out.stats.masters;
2437
2438        dataset_chunks.push(out.output_files[0].clone());
2439        gt_chunks.push(out.gt_file.clone());
2440        if let Some(n) = out.nodes {
2441            node_chunks.push(n);
2442        }
2443        if let Some(e) = out.edges {
2444            edge_chunks.push(e);
2445        }
2446    }
2447
2448    let dataset_ext = if output_format == "parquet" {
2449        "parquet"
2450    } else {
2451        "ipc"
2452    };
2453    let final_dataset = format!("{output_dir}/{final_run_id}.{dataset_ext}");
2454    concat_dataset_files(output_format, &dataset_chunks, &final_dataset)?;
2455
2456    let final_gt = format!("{output_dir}/{final_run_id}_ground_truth.{dataset_ext}");
2457    concat_dataset_files(output_format, &gt_chunks, &final_gt)?;
2458
2459    let graph_ext = if graph_format == "parquet" {
2460        "parquet"
2461    } else {
2462        "ipc"
2463    };
2464    let final_nodes = if !node_chunks.is_empty() {
2465        let p = format!("{output_dir}/{final_run_id}_nodes.{graph_ext}");
2466        concat_dataset_files(graph_format, &node_chunks, &p)?;
2467        Some(p)
2468    } else {
2469        None
2470    };
2471    let final_edges = if !edge_chunks.is_empty() {
2472        let p = format!("{output_dir}/{final_run_id}_edges.{graph_ext}");
2473        concat_dataset_files(graph_format, &edge_chunks, &p)?;
2474        Some(p)
2475    } else {
2476        None
2477    };
2478
2479    std::fs::remove_dir_all(&tmp_dir).ok();
2480
2481    Ok(PipelineOutput {
2482        output_files: vec![final_dataset],
2483        gt_file: final_gt,
2484        stats: agg,
2485        nodes: final_nodes,
2486        edges: final_edges,
2487    })
2488}
2489
2490// ── Metadata injection ──────────────────────────────────────────────────────
2491
2492fn build_metadata_map(config: &PipelineConfig) -> HashMap<String, String> {
2493    let ts = std::time::SystemTime::now()
2494        .duration_since(std::time::UNIX_EPOCH)
2495        .map(|d| d.as_secs().to_string())
2496        .unwrap_or_else(|_| "unknown".into());
2497    HashMap::from([
2498        (
2499            "dupehell.generator".into(),
2500            format!("DupeHell v{}", env!("CARGO_PKG_VERSION")),
2501        ),
2502        (
2503            "dupehell.provenance".into(),
2504            "dupehell-synthetic-data".into(),
2505        ),
2506        ("dupehell.license".into(), "MIT".into()),
2507        (
2508            "dupehell.purpose".into(),
2509            "Educational Use Only — Record Linkage Benchmarking".into(),
2510        ),
2511        (
2512            "dupehell.url".into(),
2513            "https://github.com/vntoinekaio/DupeHell".into(),
2514        ),
2515        ("dupehell.domain".into(), config.domain.clone()),
2516        ("dupehell.size".into(), config.size.to_string()),
2517        ("dupehell.seed".into(), config.seed.to_string()),
2518        ("dupehell.difficulty".into(), config.difficulty.clone()),
2519        ("dupehell.run_id".into(), config.run_id.clone()),
2520        ("dupehell.timestamp".into(), ts),
2521        (
2522            "dupehell.structural_key_columns".into(),
2523            structural_key_columns_note(config),
2524        ),
2525    ])
2526}
2527
2528/// Lists the `{entity}_id` columns that are structural join keys (linking an
2529/// entity's rows to its flattened child tables), not attributes to feed an
2530/// ER model — they never receive noise and stay identical across all
2531/// duplicates of the same master_id, unlike `record_id`.
2532fn structural_key_columns_note(config: &PipelineConfig) -> String {
2533    let cols: Vec<&str> = config
2534        .entity_plans
2535        .iter()
2536        .filter_map(|p| p.identifier_col.as_deref())
2537        .collect();
2538    format!(
2539        "{} — structural keys for joining child tables, not ER match attributes; use record_id as the row identifier instead",
2540        cols.join(", ")
2541    )
2542}
2543
2544// ── Schema alignment ───────────────────────────────────────────────────────
2545
2546/// Build the union schema from all entity plans (all columns + metadata).
2547/// `Dictionary(Int32, Utf8)` for the near-constant-cardinality metadata
2548/// columns (`domain`/`entity_type`/`match_type`/`difficulty`/`edge_type`/
2549/// `subtype`) — hunt1808/H11, an explicit output-contract change (not
2550/// bit-identical: the declared column type changes from `Utf8`), applied
2551/// on explicit user request.
2552pub(crate) fn low_cardinality_dict_type() -> DataType {
2553    DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
2554}
2555
2556/// A single dictionary (`values` + a `value -> key` index) shared by every
2557/// `DictionaryArray` built for one column across an entire output file.
2558///
2559/// Arrow's IPC `FileWriter` requires exactly one dictionary per field for
2560/// the whole file — writing a batch whose dictionary *content* differs from
2561/// a previously-written one for that field is a hard error ("Dictionary
2562/// replacement detected"), not just wasted work. A fresh
2563/// `StringDictionaryBuilder` per batch (the naive approach) builds a new,
2564/// batch-scoped dictionary every time — even when every batch happens to
2565/// use the exact same logical value set, the *order*/*subset* actually
2566/// registered can differ batch to batch, which is a real content
2567/// difference. `DictValues` fixes the dictionary's content once (built
2568/// from the full known value set for this column *before* any batch is
2569/// written) and every batch's `DictionaryArray` reuses the same `values`
2570/// `Arc` — trivially identical content by construction, for the life of
2571/// one output file.
2572pub(crate) struct DictValues {
2573    values: ArrayRef,
2574    index: HashMap<String, i32>,
2575}
2576
2577impl DictValues {
2578    pub(crate) fn new<I, S>(values: I) -> Self
2579    where
2580        I: IntoIterator<Item = S>,
2581        S: Into<String>,
2582    {
2583        let owned: Vec<String> = values.into_iter().map(Into::into).collect();
2584        let index: HashMap<String, i32> = owned
2585            .iter()
2586            .enumerate()
2587            .map(|(i, v)| (v.clone(), i as i32))
2588            .collect();
2589        Self {
2590            values: Arc::new(StringArray::from(owned)),
2591            index,
2592        }
2593    }
2594
2595    fn idx(&self, value: &str) -> i32 {
2596        *self.index.get(value).unwrap_or_else(|| {
2597            panic!("DictValues: value {value:?} was not in the pre-built dictionary")
2598        })
2599    }
2600
2601    fn build(&self, keys: arrow::array::Int32Array) -> ArrayRef {
2602        Arc::new(
2603            arrow::array::DictionaryArray::<arrow::datatypes::Int32Type>::try_new(
2604                keys,
2605                self.values.clone(),
2606            )
2607            .expect("DictValues::build: key array out of range for this dictionary"),
2608        )
2609    }
2610
2611    /// `n` copies of `value` (must already be in the dictionary).
2612    pub(crate) fn const_array(&self, value: &str, n: usize) -> ArrayRef {
2613        self.build(arrow::array::Int32Array::from(vec![self.idx(value); n]))
2614    }
2615
2616    /// Exposed for callers that build a `DictionaryArray` row by row (e.g.
2617    /// `gt.rs`'s `match_type` column): the key for `value`, to push into an
2618    /// `Int32Builder`, plus `finish_keys` to wrap the finished key array in
2619    /// this dictionary's `values`.
2620    pub(crate) fn key(&self, value: &str) -> i32 {
2621        self.idx(value)
2622    }
2623
2624    pub(crate) fn finish_keys(&self, keys: arrow::array::Int32Array) -> ArrayRef {
2625        self.build(keys)
2626    }
2627
2628    /// `n` all-null entries sharing this dictionary's `values` (perf-hunt
2629    /// hunt0109/H4): used when a batch's own schema is missing a
2630    /// dictionary-encoded column entirely (e.g. an HN pattern whose config
2631    /// never references it) — `arrow::array::new_null_array` would build a
2632    /// *different*, freshly-constructed dictionary values array for that
2633    /// batch, which IPC then rejects as "Dictionary replacement detected"
2634    /// against every other batch of the same field that uses the real
2635    /// shared dictionary. All-null keys map to no value regardless of which
2636    /// `values` array they're paired with, so sharing this one costs
2637    /// nothing and keeps every batch of the field on the same dictionary.
2638    pub(crate) fn null_array(&self, n: usize) -> ArrayRef {
2639        self.build(arrow::array::Int32Array::new_null(n))
2640    }
2641}
2642
2643/// Full known `subtype` value set for `_edges` output (hunt1808/H11): every
2644/// FK remap's `source_col` (the `"fk"`-edge subtype, see the `push()` call
2645/// in the base-batch FK-edge loop above), every hard-negative pattern kind
2646/// actually configured (the `"hard_neg"`-edge subtype — `config_json`'s
2647/// `"pattern"` field, e.g. `"same_field"`, NOT the schema's per-type name;
2648/// see `hn_common::generate_hard_negatives`), and the two literal dup-
2649/// cluster edge subtypes (`graph_gen::push_dup_clusters`). Built once,
2650/// before any edge is written, so `EdgeWriter` can hold one dictionary for
2651/// the whole `_edges` file's lifetime.
2652fn subtype_dict(config: &PipelineConfig) -> DictValues {
2653    let mut values: Vec<String> = vec!["complete".to_string(), "spanning_tree".to_string()];
2654    for plan in &config.entity_plans {
2655        for remap in &plan.fk_remaps {
2656            values.push(remap.source_col.clone());
2657        }
2658    }
2659    for hn in &config.hard_neg_types {
2660        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&hn.config_json)
2661            && let Some(pattern) = v.get("pattern").and_then(|p| p.as_str())
2662        {
2663            values.push(pattern.to_string());
2664        }
2665    }
2666    values.sort_unstable();
2667    values.dedup();
2668    DictValues::new(values)
2669}
2670
2671fn build_full_schema(config: &PipelineConfig, metadata: &HashMap<String, String>) -> Schema {
2672    let mut field_map: Vec<(String, DataType, bool)> = vec![
2673        ("record_id".to_string(), DataType::Utf8, false),
2674        ("domain".to_string(), low_cardinality_dict_type(), false),
2675        (
2676            "entity_type".to_string(),
2677            low_cardinality_dict_type(),
2678            false,
2679        ),
2680        ("master_id".to_string(), DataType::Utf8, false),
2681    ];
2682    for plan in &config.entity_plans {
2683        // Pool-only entities (`--only-entity`) are never written to output —
2684        // excluding their columns here is what gives `--only-entity` its
2685        // narrow, single-entity schema for free, with no separate filtering
2686        // step downstream.
2687        if plan.pool_only {
2688            continue;
2689        }
2690        let cols: Vec<serde_json::Value> =
2691            serde_json::from_str(&plan.columns_json).unwrap_or_default();
2692        for col in &cols {
2693            let name = col["name"].as_str().unwrap_or("").to_string();
2694            if name.is_empty() || field_map.iter().any(|(n, _, _)| n == &name) {
2695                continue;
2696            }
2697            // The declared schema type must match what `generate_column`
2698            // actually builds (perf-hunt hunt0109/H4), or `RecordBatch::
2699            // try_new` below hard-errors on a type mismatch. A handful of
2700            // templates produce `Dictionary(Int32, Utf8)` instead of plain
2701            // `Utf8` — `resolve_dict_encoded_column` is the single place
2702            // that name resolution is written, shared with
2703            // `difficulty::estimate_difficulty`'s own model of the same
2704            // fact.
2705            let is_dict = crate::fast_template::resolve_dict_encoded_column(&name).is_some();
2706            let col_type = if is_dict {
2707                low_cardinality_dict_type()
2708            } else {
2709                col_type_from_request(col)
2710            };
2711            field_map.push((name, col_type, true));
2712        }
2713    }
2714    let fields: Vec<Field> = field_map
2715        .into_iter()
2716        .map(|(n, dt, nullable)| Field::new(&n, dt, nullable))
2717        .collect();
2718    Schema::new(fields).with_metadata(metadata.clone())
2719}
2720
2721/// Map a column's JSON type string to Arrow DataType.
2722fn col_type_from_request(v: &serde_json::Value) -> DataType {
2723    match v["type"].as_str() {
2724        Some("int") => DataType::Int64,
2725        Some("float") => DataType::Float64,
2726        Some("boolean") => DataType::Boolean,
2727        Some("date") | Some("datetime") => DataType::Utf8,
2728        _ => DataType::Utf8,
2729    }
2730}
2731
2732// ── Helpers ─────────────────────────────────────────────────────────────────
2733
2734#[allow(clippy::too_many_arguments)]
2735pub(crate) fn add_metadata_and_align(
2736    rb: &RecordBatch,
2737    domain: &str,
2738    entity_type: &str,
2739    domain_dict: &DictValues,
2740    entity_type_dict: &DictValues,
2741    record_ids: &ArrayRef,
2742    master_ids: &ArrayRef,
2743    full_arc: &Arc<Schema>,
2744    col_lookup: &[Option<usize>],
2745    null_cache: &mut FxHashMap<(DataType, usize), ArrayRef>,
2746    const_arr_cache: &mut FxHashMap<(String, usize), ArrayRef>,
2747) -> Result<RecordBatch, String> {
2748    let n = rb.num_rows();
2749    // `domain` is constant for the whole run and `entity_type` repeats
2750    // across every batch of a given entity (and often across HN/canary
2751    // sections too) — cache the built repeat-array per (value, n) instead
2752    // of rebuilding an n-length array on every call, same idea as
2753    // `null_cache` above for the always-null columns. Built as
2754    // `Dictionary(Int32, Utf8)` against the run-wide `domain_dict`/
2755    // `entity_type_dict` (hunt1808/H11) — every batch's array reuses the
2756    // exact same dictionary `values`, required for IPC output (see
2757    // `DictValues`'s doc comment).
2758    let domain_arr = const_arr_cache
2759        .entry((domain.to_string(), n))
2760        .or_insert_with(|| domain_dict.const_array(domain, n))
2761        .clone();
2762    let et_arr = const_arr_cache
2763        .entry((entity_type.to_string(), n))
2764        .or_insert_with(|| entity_type_dict.const_array(entity_type, n))
2765        .clone();
2766    let rid_arr = record_ids.clone();
2767    let mid_arr = master_ids.clone();
2768
2769    let mut all_arrays: Vec<ArrayRef> = Vec::with_capacity(4 + col_lookup.len());
2770    all_arrays.push(rid_arr);
2771    all_arrays.push(domain_arr);
2772    all_arrays.push(et_arr);
2773    all_arrays.push(mid_arr);
2774
2775    // P4: Vec<Option<usize>> eliminates HashMap lookup per field (just index)
2776    // Phase 12.3: cache null arrays per (DataType, n) — avoids new_null_array overhead per batch
2777    for (i, maybe_idx) in col_lookup.iter().enumerate() {
2778        match maybe_idx {
2779            Some(idx) => all_arrays.push(rb.column(*idx).clone()),
2780            None => {
2781                let field = full_arc.field(i + 4);
2782                // A dictionary-encoded column (hunt0109/H4) needs its
2783                // dedicated shared dictionary even when null, not the
2784                // general `(DataType, n)` cache below: two different
2785                // dict-encoded fields (e.g. `suffix` and `grade`) share the
2786                // exact same `DataType::Dictionary(Int32, Utf8)`, so
2787                // caching by `DataType` alone would hand one field's null
2788                // array to the other — wrong dictionary content, same
2789                // "Dictionary replacement" IPC error this fallback exists
2790                // to avoid in the first place.
2791                if let Some(arr) = crate::fast_template::dict_encoded_null_array(field.name(), n) {
2792                    all_arrays.push(arr);
2793                    continue;
2794                }
2795                let dt = field.data_type();
2796                let arr = null_cache
2797                    .entry((dt.clone(), n))
2798                    .or_insert_with(|| arrow::array::new_null_array(dt, n));
2799                all_arrays.push(Arc::clone(arr));
2800            }
2801        }
2802    }
2803
2804    // P3: Arc::clone(full_arc) avoids full schema clone (just atomic increment)
2805    RecordBatch::try_new(Arc::clone(full_arc), all_arrays)
2806        .map_err(|e| format!("add_metadata_and_align RecordBatch: {e}"))
2807}
2808
2809fn pick_rows(rb: &RecordBatch, indices: &UInt64Array) -> Result<RecordBatch, String> {
2810    use arrow::compute::take;
2811    let new_columns: Vec<ArrayRef> = (0..rb.num_columns())
2812        .map(|i| take(rb.column(i), indices, None))
2813        .collect::<Result<Vec<_>, _>>()
2814        .map_err(|e| format!("take error: {e}"))?;
2815
2816    RecordBatch::try_new(rb.schema(), new_columns).map_err(|e| format!("RecordBatch: {e}"))
2817}
2818
2819#[cfg(test)]
2820mod tests {
2821    use super::*;
2822    use crate::schema::{build_pipeline_config, load_schema};
2823
2824    /// Diagnostic scan (hunt2808.md, RAM-first pass that turned up a noise
2825    /// coverage gap instead): for every entity of every domain schema,
2826    /// checks whether AT LEAST ONE of `hell`'s 9 noise categories has any
2827    /// target column at all. An entity with zero matching columns across
2828    /// all 9 categories gets `target_cols.is_empty()` on every attempt
2829    /// (`apply_noise_with_retry`, line ~811) -- its duplicates stay
2830    /// byte-identical to their master forever, regardless of `passes`, on
2831    /// every difficulty tier (not just hell, since hell's category list is
2832    /// the broadest; an entity failing all 9 fails every subset too).
2833    /// Confirmed root cause for aviation's `flight` entity (0/9
2834    /// categories match `flight_number`/`origin_airport`/
2835    /// `destination_airport`/`departure_time`/`arrival_time`/`status`/
2836    /// `source_system` -- `departure_time`/`arrival_time` are `datetime`
2837    /// columns but the `dates` category predicate only matches
2838    /// `"date"`/`"birth"`/`"incorporation"`/`"founding"`, never `"time"`).
2839    ///
2840    /// Deliberately reimplements just enough of `col_type_from_str`'s
2841    /// Utf8-vs-not split (only int/float/boolean are excluded; string,
2842    /// date, and datetime all end up `DataType::Utf8` per
2843    /// `entity_gen::col_type_to_arrow`) rather than importing it, since
2844    /// that function is private to `entity_gen` -- diagnostic-only code,
2845    /// not meant to become a permanent dependency.
2846    ///
2847    /// This is a report, not an assertion: intentionally does NOT fail the
2848    /// suite (a coverage gap is a data-quality finding for the hunt file,
2849    /// not a regression this specific test should gate) -- run with
2850    /// `cargo test --release test_hunt2808_noise_coverage_scan -- --nocapture --ignored`
2851    /// to see the full per-domain/per-entity table.
2852    #[test]
2853    #[ignore = "hunt2808 diagnostic scan, not a pass/fail regression test -- run explicitly with --ignored --nocapture"]
2854    fn test_hunt2808_noise_coverage_scan() {
2855        const HELL_NOISE_TYPES: &[&str] = &[
2856            "typo_aggressive",
2857            "visual",
2858            "unicode_pollution",
2859            "names",
2860            "dates",
2861            "identifiers",
2862            "addresses",
2863            "companies",
2864            "extra",
2865        ];
2866
2867        let mut zero_coverage: Vec<(String, String, usize)> = Vec::new();
2868        let mut total_entities = 0usize;
2869        let mut schema_files: Vec<_> = std::fs::read_dir("schemas")
2870            .expect("read schemas/ dir")
2871            .filter_map(|e| e.ok())
2872            .map(|e| e.path())
2873            .filter(|p| p.extension().is_some_and(|e| e == "json"))
2874            .collect();
2875        schema_files.sort();
2876
2877        for path in &schema_files {
2878            let domain = path.file_stem().unwrap().to_string_lossy().to_string();
2879            let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
2880            let json: serde_json::Value =
2881                serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path:?}: {e}"));
2882            let Some(entities) = json.get("entities").and_then(|v| v.as_array()) else {
2883                continue;
2884            };
2885            for entity in entities {
2886                total_entities += 1;
2887                let ename = entity
2888                    .get("name")
2889                    .and_then(|v| v.as_str())
2890                    .unwrap_or("?")
2891                    .to_string();
2892                let fk_exclude: std::collections::HashSet<&str> = entity
2893                    .get("fk_remaps")
2894                    .and_then(|v| v.as_array())
2895                    .map(|arr| {
2896                        arr.iter()
2897                            .filter_map(|r| r.get("source_col").and_then(|v| v.as_str()))
2898                            .collect()
2899                    })
2900                    .unwrap_or_default();
2901                let cols = entity
2902                    .get("columns")
2903                    .and_then(|v| v.as_array())
2904                    .cloned()
2905                    .unwrap_or_default();
2906
2907                let mut n_matched_categories = 0usize;
2908                for &ntype in HELL_NOISE_TYPES {
2909                    let has_match = cols.iter().any(|c| {
2910                        let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
2911                        let ctype = c.get("type").and_then(|v| v.as_str()).unwrap_or("string");
2912                        let is_utf8 = !matches!(ctype, "int" | "float" | "boolean");
2913                        is_utf8
2914                            && !fk_exclude.contains(name)
2915                            && noise_type_targets_column(ntype, name)
2916                    });
2917                    if has_match {
2918                        n_matched_categories += 1;
2919                    }
2920                }
2921                if n_matched_categories == 0 {
2922                    zero_coverage.push((domain.clone(), ename, cols.len()));
2923                }
2924            }
2925        }
2926
2927        eprintln!(
2928            "\n[hunt2808_coverage] {total_entities} entities scanned across {} domains",
2929            schema_files.len()
2930        );
2931        eprintln!(
2932            "[hunt2808_coverage] {} entities with ZERO matching hell noise category:",
2933            zero_coverage.len()
2934        );
2935        for (domain, entity, ncols) in &zero_coverage {
2936            eprintln!("[hunt2808_coverage]   {domain}::{entity} ({ncols} columns)");
2937        }
2938    }
2939
2940    /// Regression for the perf-hunt cross-contamination bug (hunt2407.md):
2941    /// `COMPANY_NAME_WORDS` and `PERSON_NAME_WORDS` both contain the bare
2942    /// `"name"` fragment, which used to let `given_name`/`family_name`/
2943    /// `middle_name` match the `companies` noise category (and would
2944    /// symmetrically let `company_name`/`legal_name` match `names`), and let
2945    /// `email_address` match the `addresses` category via `"address"`.
2946    #[test]
2947    fn test_no_person_company_name_cross_contamination() {
2948        for col in [
2949            "given_name",
2950            "family_name",
2951            "middle_name",
2952            "first_name",
2953            "last_name",
2954        ] {
2955            assert!(
2956                noise_type_targets_column("names", col),
2957                "{col} should still match 'names'"
2958            );
2959            assert!(
2960                !noise_type_targets_column("companies", col),
2961                "{col} should NOT match 'companies' (person name, not a company name)"
2962            );
2963        }
2964        for col in ["company_name", "legal_name", "trading_name"] {
2965            assert!(
2966                noise_type_targets_column("companies", col),
2967                "{col} should still match 'companies'"
2968            );
2969            assert!(
2970                !noise_type_targets_column("names", col),
2971                "{col} should NOT match 'names' (company name, not a person name)"
2972            );
2973        }
2974        assert!(
2975            !noise_type_targets_column("addresses", "email_address"),
2976            "email_address should NOT match 'addresses' (it's an email, not a postal address)"
2977        );
2978        assert!(
2979            noise_type_targets_column("identifiers", "email_address"),
2980            "email_address should still match 'identifiers' (corrupt_email)"
2981        );
2982        assert!(
2983            noise_type_targets_column("addresses", "residential_address_line1"),
2984            "a real postal address column should still match 'addresses'"
2985        );
2986    }
2987
2988    fn manifest_dir() -> std::path::PathBuf {
2989        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
2990    }
2991
2992    /// Unique per-test scratch dir under `target/`, so parallel test runs
2993    /// never collide on output filenames and cleanup is trivial.
2994    fn scratch_dir(name: &str) -> std::path::PathBuf {
2995        let dir = manifest_dir()
2996            .join("target")
2997            .join("pipeline_test_tmp")
2998            .join(format!("{name}_{}", std::process::id()));
2999        let _ = std::fs::remove_dir_all(&dir);
3000        std::fs::create_dir_all(&dir).expect("create scratch dir");
3001        dir
3002    }
3003
3004    fn run_kyc(
3005        output_dir: &std::path::Path,
3006        output_format: &str,
3007        graph_enabled: bool,
3008    ) -> PipelineOutput {
3009        let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3010        let mut ctx = Context::new(
3011            "kyc",
3012            "en",
3013            &manifest_dir().join("assets/pools").to_string_lossy(),
3014        )
3015        .expect("load context");
3016        let config = build_pipeline_config(
3017            "kyc",
3018            200,
3019            42,
3020            "medium",
3021            0.1,
3022            0.3,
3023            &schema,
3024            "pipeline_test_run",
3025            output_format,
3026            graph_enabled,
3027            "parquet",
3028            None,
3029        )
3030        .expect("build config");
3031        ctx.enable_watermark(&config.domain, config.size, config.seed);
3032        run_pipeline(&ctx, &config, &output_dir.to_string_lossy()).expect("run_pipeline")
3033    }
3034
3035    #[test]
3036    fn test_run_pipeline_produces_dataset_and_gt() {
3037        let dir = scratch_dir("basic");
3038        let output = run_kyc(&dir, "parquet", false);
3039
3040        assert_eq!(output.output_files.len(), 1);
3041        for f in &output.output_files {
3042            assert!(std::path::Path::new(f).exists(), "missing dataset file {f}");
3043        }
3044        assert!(std::path::Path::new(&output.gt_file).exists());
3045        assert!(output.nodes.is_none());
3046        assert!(output.edges.is_none());
3047
3048        // Stats must be internally consistent: exact_dup/fuzzy_dup/hard_neg/
3049        // unique are a partition of the classified records, which is <=
3050        // total_records (total_records also includes unclassified canary
3051        // watermark rows).
3052        let s = &output.stats;
3053        assert!(s.total_records >= s.exact_dups + s.fuzzy_dups + s.hard_negs + s.uniques);
3054        assert!(s.total_records > 0);
3055        assert!(s.masters > 0);
3056
3057        let _ = std::fs::remove_dir_all(&dir);
3058    }
3059
3060    #[test]
3061    fn test_run_pipeline_ipc_format() {
3062        let dir = scratch_dir("ipc");
3063        let output = run_kyc(&dir, "ipc", false);
3064        assert!(output.output_files[0].ends_with(".ipc"));
3065        assert!(std::path::Path::new(&output.output_files[0]).exists());
3066        let _ = std::fs::remove_dir_all(&dir);
3067    }
3068
3069    #[test]
3070    fn test_run_pipeline_graph_output() {
3071        let dir = scratch_dir("graph");
3072        let output = run_kyc(&dir, "parquet", true);
3073        let nodes = output.nodes.expect("nodes path");
3074        let edges = output.edges.expect("edges path");
3075        assert!(std::path::Path::new(&nodes).exists());
3076        assert!(std::path::Path::new(&edges).exists());
3077        let _ = std::fs::remove_dir_all(&dir);
3078    }
3079
3080    #[test]
3081    fn test_fk_edge_weight_from_schema_and_default() {
3082        // aviation's flight->aircraft fk_remap declares fk_weight=0.5
3083        // (schemas/aviation.json); flight->airline leaves the field at
3084        // its schema default of 1.0. Confirms `FkRemapEntry::fk_weight`
3085        // is threaded through to the `weight` column of `fk`-typed graph
3086        // edges instead of the old hardcoded 1.0 constant, and that
3087        // domains/relations without the field still default correctly.
3088        let dir = scratch_dir("fk_weight");
3089        let schema =
3090            load_schema("aviation", &manifest_dir().join("schemas")).expect("load aviation.json");
3091        let mut ctx = Context::new(
3092            "aviation",
3093            "en",
3094            &manifest_dir().join("assets/pools").to_string_lossy(),
3095        )
3096        .expect("load context");
3097        let config = build_pipeline_config(
3098            "aviation",
3099            300,
3100            42,
3101            "medium",
3102            0.1,
3103            0.3,
3104            &schema,
3105            "pipeline_test_fk_weight",
3106            "ipc",
3107            true,
3108            "ipc",
3109            None,
3110        )
3111        .expect("build config");
3112        ctx.enable_watermark(&config.domain, config.size, config.seed);
3113        let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3114
3115        let edges_path = output.edges.expect("edges path");
3116        let file = std::fs::File::open(&edges_path).expect("open edges file");
3117        let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
3118
3119        let mut subtypes_seen: std::collections::HashMap<String, f64> =
3120            std::collections::HashMap::new();
3121        for batch in reader {
3122            let batch = batch.expect("read edges batch");
3123            // `edge_type`/`subtype` are `Dictionary(Int32, Utf8)`
3124            // (hunt1808/H11) — cast back to plain `Utf8` for this test-only
3125            // comparison.
3126            let edge_type_col = arrow::compute::cast(
3127                batch.column_by_name("edge_type").expect("edge_type col"),
3128                &DataType::Utf8,
3129            )
3130            .expect("cast edge_type to Utf8");
3131            let edge_type = edge_type_col.as_string::<i32>();
3132            let subtype_col = arrow::compute::cast(
3133                batch.column_by_name("subtype").expect("subtype col"),
3134                &DataType::Utf8,
3135            )
3136            .expect("cast subtype to Utf8");
3137            let subtype = subtype_col.as_string::<i32>();
3138            let weight = batch
3139                .column_by_name("weight")
3140                .expect("weight col")
3141                .as_any()
3142                .downcast_ref::<arrow::array::Float64Array>()
3143                .expect("weight as f64");
3144            for i in 0..batch.num_rows() {
3145                if edge_type.value(i) == "fk" {
3146                    subtypes_seen.insert(subtype.value(i).to_string(), weight.value(i));
3147                }
3148            }
3149        }
3150
3151        assert_eq!(
3152            subtypes_seen.get("aircraft_id"),
3153            Some(&0.5),
3154            "flight->aircraft fk_weight from schema not propagated to edge weight"
3155        );
3156        assert_eq!(
3157            subtypes_seen.get("airline_id"),
3158            Some(&1.0),
3159            "flight->airline fk_weight (schema default) not propagated to edge weight"
3160        );
3161
3162        let _ = std::fs::remove_dir_all(&dir);
3163    }
3164
3165    /// End-to-end regression for the `ClusterCsr` rewrite of `cluster_map`
3166    /// (`project_csr_cluster_map_backlog`): drives the real pipeline
3167    /// (`gt::GtAccumulator::finish` -> `ClusterCsr::build` ->
3168    /// `graph_gen::push_dup_clusters`) instead of testing either module in
3169    /// isolation, and checks the emitted `exact_dup`/`fuzzy_dup` edges are
3170    /// well-formed and consistent with the run's own GT stats.
3171    #[test]
3172    fn test_run_pipeline_dup_cluster_edges_well_formed() {
3173        let dir = scratch_dir("dup_cluster_edges");
3174        // `run_kyc` always requests parquet-format graph output regardless
3175        // of its `output_format` arg, so build the config directly here to
3176        // get IPC edges (matches `test_fk_edge_weight_from_schema_and_default`'s
3177        // approach for the same reason).
3178        let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3179        let mut ctx = Context::new(
3180            "kyc",
3181            "en",
3182            &manifest_dir().join("assets/pools").to_string_lossy(),
3183        )
3184        .expect("load context");
3185        let config = build_pipeline_config(
3186            "kyc",
3187            200,
3188            42,
3189            "medium",
3190            0.1,
3191            0.3,
3192            &schema,
3193            "pipeline_test_dup_cluster_edges",
3194            "ipc",
3195            true,
3196            "ipc",
3197            None,
3198        )
3199        .expect("build config");
3200        ctx.enable_watermark(&config.domain, config.size, config.seed);
3201        let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3202
3203        let edges_path = output.edges.expect("edges path");
3204        let file = std::fs::File::open(&edges_path).expect("open edges file");
3205        let reader = arrow::ipc::reader::FileReader::try_new(file, None).expect("edges reader");
3206
3207        let mut n_exact = 0usize;
3208        let mut n_fuzzy = 0usize;
3209        for batch in reader {
3210            let batch = batch.expect("read edges batch");
3211            let src = batch
3212                .column_by_name("source_node_id")
3213                .expect("source_node_id col")
3214                .as_any()
3215                .downcast_ref::<arrow::array::StringArray>()
3216                .expect("source_node_id as string");
3217            let tgt = batch
3218                .column_by_name("target_node_id")
3219                .expect("target_node_id col")
3220                .as_any()
3221                .downcast_ref::<arrow::array::StringArray>()
3222                .expect("target_node_id as string");
3223            // `edge_type` is `Dictionary(Int32, Utf8)` (hunt1808/H11) —
3224            // cast back to plain `Utf8` for this test-only comparison.
3225            let edge_type_col = arrow::compute::cast(
3226                batch.column_by_name("edge_type").expect("edge_type col"),
3227                &DataType::Utf8,
3228            )
3229            .expect("cast edge_type to Utf8");
3230            let edge_type = edge_type_col.as_string::<i32>();
3231            let weight = batch
3232                .column_by_name("weight")
3233                .expect("weight col")
3234                .as_any()
3235                .downcast_ref::<arrow::array::Float64Array>()
3236                .expect("weight as f64");
3237
3238            for i in 0..batch.num_rows() {
3239                match edge_type.value(i) {
3240                    "exact_dup" => n_exact += 1,
3241                    "fuzzy_dup" => n_fuzzy += 1,
3242                    _ => continue,
3243                }
3244                // Every endpoint must round-trip through `parse_record_idx`
3245                // -- i.e. `ClusterCsr`'s packed indices were correctly
3246                // unpacked back into real record_ids by `push_dup_clusters`,
3247                // not garbage or truncated.
3248                assert!(
3249                    parse_record_idx(src.value(i)).is_some(),
3250                    "malformed source_node_id {:?} on a dup-cluster edge",
3251                    src.value(i)
3252                );
3253                assert!(
3254                    parse_record_idx(tgt.value(i)).is_some(),
3255                    "malformed target_node_id {:?} on a dup-cluster edge",
3256                    tgt.value(i)
3257                );
3258                assert!((weight.value(i) - 1.0).abs() < 1e-9);
3259            }
3260        }
3261
3262        // kyc/medium/seed 42/size 200 always produces some duplicates (see
3263        // `output.stats.exact_dups`/`fuzzy_dups`), so the cluster-edge pass
3264        // must actually have emitted something -- an empty result here would
3265        // mean `ClusterCsr` silently dropped every cluster.
3266        assert!(n_exact + n_fuzzy > 0, "no dup-cluster edges emitted");
3267        assert!(output.stats.exact_dups + output.stats.fuzzy_dups > 0);
3268
3269        let _ = std::fs::remove_dir_all(&dir);
3270    }
3271
3272    #[test]
3273    fn test_run_pipeline_deterministic_stats() {
3274        let dir_a = scratch_dir("det_a");
3275        let dir_b = scratch_dir("det_b");
3276        let a = run_kyc(&dir_a, "parquet", false);
3277        let b = run_kyc(&dir_b, "parquet", false);
3278        assert_eq!(a.stats.total_records, b.stats.total_records);
3279        assert_eq!(a.stats.exact_dups, b.stats.exact_dups);
3280        assert_eq!(a.stats.fuzzy_dups, b.stats.fuzzy_dups);
3281        assert_eq!(a.stats.hard_negs, b.stats.hard_negs);
3282        assert_eq!(a.stats.uniques, b.stats.uniques);
3283        assert_eq!(a.stats.masters, b.stats.masters);
3284        let _ = std::fs::remove_dir_all(&dir_a);
3285        let _ = std::fs::remove_dir_all(&dir_b);
3286    }
3287
3288    #[test]
3289    fn test_distribute_by_weight_sums_to_total_and_is_proportional() {
3290        // Regression: duplicates used to be sampled only from the last
3291        // batch of masters (`last_batch`), so for any entity spanning 2+
3292        // batches, earlier batches never received a single duplicate —
3293        // the observed symptom was the overall duplicate rate silently
3294        // drifting down as dataset size grew past `BATCH_SIZE`. Fixed by
3295        // spreading each noise_type's count across every batch in
3296        // proportion to its size via this helper.
3297        let weights = [500_000usize, 216_666];
3298        let counts = distribute_by_weight(283_334, &weights);
3299        assert_eq!(counts.len(), 2);
3300        assert_eq!(counts.iter().sum::<usize>(), 283_334);
3301        // Every batch gets a non-zero share proportional to its size —
3302        // in particular the *first* batch (which the old code starved
3303        // entirely) must receive the majority share here.
3304        assert!(counts[0] > 0, "first batch got zero duplicates");
3305        assert!(counts[1] > 0, "last batch got zero duplicates");
3306        assert!(counts[0] > counts[1]);
3307
3308        // Edge cases: zero total, zero weights.
3309        assert_eq!(distribute_by_weight(0, &weights), vec![0, 0]);
3310        assert_eq!(distribute_by_weight(100, &[0, 0]), vec![0, 0]);
3311    }
3312
3313    #[test]
3314    fn test_entity_prefix_unique_per_index() {
3315        let a = entity_prefix(0);
3316        let b = entity_prefix(1);
3317        assert_ne!(a, b);
3318        assert_eq!(a, "E00000");
3319        assert_eq!(entity_prefix(42), "E00042");
3320    }
3321
3322    #[test]
3323    fn test_run_pipeline_hell_multi_pass_touches_multiple_columns() {
3324        // At `hell` (3 noise passes, see `schema::DifficultySettings::
3325        // passes`), at least one duplicate copy should differ from its
3326        // master on 2+ distinct columns — a single pass only ever touches
3327        // the columns its one chosen noise_type's predicate matches
3328        // (`noise_type_targets_column`), so seeing 2+ changed columns on
3329        // the same row is direct evidence that multiple independent passes
3330        // actually landed on it, not just a coincidence of one pass hitting
3331        // a multi-column noise_type like "typo".
3332        let dir = scratch_dir("hell_multipass");
3333        let schema = load_schema("kyc", &manifest_dir().join("schemas")).expect("load kyc.json");
3334        let mut ctx = Context::new(
3335            "kyc",
3336            "en",
3337            &manifest_dir().join("assets/pools").to_string_lossy(),
3338        )
3339        .expect("load context");
3340        let config = build_pipeline_config(
3341            "kyc",
3342            5000,
3343            7,
3344            "hell",
3345            0.1,
3346            0.3,
3347            &schema,
3348            "pipeline_test_hell_multipass",
3349            "ipc",
3350            false,
3351            "ipc",
3352            None,
3353        )
3354        .expect("build config");
3355        ctx.enable_watermark(&config.domain, config.size, config.seed);
3356        let output = run_pipeline(&ctx, &config, &dir.to_string_lossy()).expect("run_pipeline");
3357
3358        let read_all = |path: &str| -> RecordBatch {
3359            let file = std::fs::File::open(path).unwrap();
3360            let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
3361            let batches: Vec<RecordBatch> = reader.map(|b| b.unwrap()).collect();
3362            let schema = batches[0].schema();
3363            let refs: Vec<Vec<&dyn Array>> = (0..schema.fields().len())
3364                .map(|i| batches.iter().map(|b| b.column(i).as_ref()).collect())
3365                .collect();
3366            let cols: Vec<ArrayRef> = refs
3367                .iter()
3368                .map(|r| arrow::compute::concat(r).unwrap())
3369                .collect();
3370            RecordBatch::try_new(schema, cols).unwrap()
3371        };
3372        let ds = read_all(&output.output_files[0]);
3373        let gt = read_all(&output.gt_file);
3374
3375        let ds_rid = ds.column_by_name("record_id").unwrap().as_string::<i32>();
3376        let ds_mid = ds.column_by_name("master_id").unwrap().as_string::<i32>();
3377        let gt_rid = gt.column_by_name("record_id").unwrap().as_string::<i32>();
3378        // `match_type` is `Dictionary(Int32, Utf8)` (hunt1808/H11) — cast
3379        // back to plain `Utf8` for this test-only comparison.
3380        let gt_mt_col =
3381            arrow::compute::cast(gt.column_by_name("match_type").unwrap(), &DataType::Utf8)
3382                .unwrap();
3383        let gt_mt = gt_mt_col.as_string::<i32>();
3384
3385        let mut match_type_by_rid: HashMap<&str, &str> = HashMap::new();
3386        for i in 0..gt.num_rows() {
3387            match_type_by_rid.insert(gt_rid.value(i), gt_mt.value(i));
3388        }
3389
3390        // The true master row for a given master_id is whichever row was
3391        // written *first* (base rows are always written before any of their
3392        // duplicate copies, within a batch) — not "whichever row happens to
3393        // be labeled `exact_dup`": a cluster with only fuzzy copies has no
3394        // `exact_dup` row at all (a base row only reads `exact_dup` when the
3395        // cluster actually contains a byte-identical copy — see `gt.rs`).
3396        let mut master_row_by_mid: HashMap<&str, usize> = HashMap::new();
3397        for i in 0..ds.num_rows() {
3398            master_row_by_mid.entry(ds_mid.value(i)).or_insert(i);
3399        }
3400
3401        let compare_cols: Vec<usize> = (0..ds.num_columns())
3402            .filter(|&i| {
3403                let field = ds.schema().field(i).clone();
3404                matches!(field.data_type(), DataType::Utf8)
3405                    && !matches!(
3406                        field.name().as_str(),
3407                        "record_id" | "domain" | "entity_type" | "master_id"
3408                    )
3409                    && !field.name().ends_with("_id")
3410            })
3411            .collect();
3412
3413        let mut max_changed_cols = 0usize;
3414        for i in 0..ds.num_rows() {
3415            if match_type_by_rid.get(ds_rid.value(i)) != Some(&"fuzzy_dup") {
3416                continue;
3417            }
3418            let Some(&master_i) = master_row_by_mid.get(ds_mid.value(i)) else {
3419                continue;
3420            };
3421            let mut changed = 0usize;
3422            for &col_i in &compare_cols {
3423                let col = ds.column(col_i).as_string::<i32>();
3424                let a = (!col.is_null(i)).then(|| col.value(i));
3425                let b = (!col.is_null(master_i)).then(|| col.value(master_i));
3426                if a != b {
3427                    changed += 1;
3428                }
3429            }
3430            max_changed_cols = max_changed_cols.max(changed);
3431        }
3432
3433        assert!(
3434            max_changed_cols >= 2,
3435            "expected at least one fuzzy_dup row to differ from its master on \
3436             2+ columns (evidence of multiple noise passes), got max {max_changed_cols}"
3437        );
3438
3439        let _ = std::fs::remove_dir_all(&dir);
3440    }
3441
3442    #[test]
3443    fn test_record_id_and_pad_string_roundtrip() {
3444        let rid = record_id_string(7);
3445        assert_eq!(rid, "R-0000000000007");
3446        let pad = pad_string(7);
3447        assert_eq!(pad, "0000000000007");
3448        let strs = record_id_strs(0..3);
3449        assert_eq!(strs.len(), 3);
3450        assert_eq!(strs[0], record_id_string(0));
3451        assert_eq!(strs[2], record_id_string(2));
3452    }
3453
3454    #[test]
3455    fn test_parse_record_idx_roundtrip() {
3456        for i in [0usize, 7, 1_234_567, 300_000_000] {
3457            assert_eq!(parse_record_idx(&record_id_string(i)), Some(i as u64));
3458        }
3459        assert_eq!(parse_record_idx("garbage"), None);
3460        assert_eq!(parse_record_idx("R-1"), None);
3461        assert_eq!(parse_record_idx("X-0000000000007"), None);
3462    }
3463
3464    #[test]
3465    fn test_pack_master_key_roundtrip_and_ordering() {
3466        let mid = |e: usize, i: usize| format!("{}-{}", entity_prefix(e), pad_string(i));
3467        assert!(pack_master_key(&mid(0, 0)).is_some());
3468        // Same entity, ascending local index -> ascending packed key.
3469        assert!(pack_master_key(&mid(0, 1)).unwrap() > pack_master_key(&mid(0, 0)).unwrap());
3470        // Different entity always outranks any local index within a lower
3471        // entity, exactly like comparing the original fixed-width strings.
3472        assert!(
3473            pack_master_key(&mid(1, 0)).unwrap()
3474                > pack_master_key(&mid(0, 9_999_999_999_999)).unwrap()
3475        );
3476        assert_eq!(pack_master_key("garbage"), None);
3477        assert_eq!(pack_master_key("HN-0000000000007"), None);
3478        assert_eq!(pack_master_key("CANARY-000-0-abc"), None);
3479    }
3480
3481    // ── perf-hunt H1 isolated measurement (hunt2407.md) ─────────────────
3482    //
3483    // Not a correctness test -- #[ignore]'d so it never runs in normal
3484    // `cargo test`. Measures whether `apply_noise_with_retry`'s retry-zip
3485    // loop and `apply_extra_pass_per_row`'s pass-zip loop (both iterate
3486    // `0..num_columns()`, not just the noise_type's actual target_cols) cost
3487    // scales with TOTAL column count C, holding the number of noised
3488    // columns T=1 constant -- H1's hypothesis. Run with:
3489    //   cargo test --release pipeline::tests::hunt_h1_zip_cost_vs_column_count -- --ignored --nocapture
3490    #[test]
3491    #[ignore]
3492    fn hunt_h1_zip_cost_vs_column_count() {
3493        use arrow::array::StringArray;
3494        use arrow::datatypes::{DataType, Field, Schema};
3495
3496        fn make_batch(n_rows: usize, n_cols: usize) -> RecordBatch {
3497            let fields: Vec<Field> = (0..n_cols)
3498                .map(|i| Field::new(format!("col{i}"), DataType::Utf8, false))
3499                .collect();
3500            let cols: Vec<ArrayRef> = (0..n_cols)
3501                .map(|_| {
3502                    Arc::new(StringArray::from(
3503                        (0..n_rows)
3504                            .map(|_| "abcdefghijklmnop".to_string())
3505                            .collect::<Vec<_>>(),
3506                    )) as ArrayRef
3507                })
3508                .collect();
3509            RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
3510        }
3511
3512        const N_ROWS: usize = 100_000;
3513        const N_REPS: usize = 5;
3514        // 3 noise_passes total (hell tier): 1 first pass + 2 extra passes,
3515        // matching `apply_noise_with_retry` + `apply_extra_pass_per_row`
3516        // call pattern in `run_pipeline`'s dup-generation closure.
3517        const EXTRA_PASSES: usize = 2;
3518
3519        eprintln!("\n[hunt_h1] N_ROWS={N_ROWS} N_REPS={N_REPS} EXTRA_PASSES={EXTRA_PASSES}");
3520        eprintln!("[hunt_h1] C=total columns, T=1 noised column (fixed) -- H1 predicts cost ∝ C");
3521
3522        for &n_cols in &[8usize, 15, 23] {
3523            let plan_cols = vec!["col0".to_string()];
3524            let all_types: Vec<&str> = vec!["typo"];
3525            let mut total = std::time::Duration::ZERO;
3526
3527            for rep in 0..N_REPS {
3528                let batch = make_batch(N_ROWS, n_cols);
3529                let mut rng = Rng::new(42 + rep as u64);
3530                let t0 = std::time::Instant::now();
3531
3532                let (noisy, mut unchanged, _) =
3533                    apply_noise_with_retry(&batch, "typo", &plan_cols, &mut rng, &[]).unwrap();
3534                let mut current = noisy;
3535                for _ in 0..EXTRA_PASSES {
3536                    current = apply_extra_pass_per_row(
3537                        &current,
3538                        &mut unchanged,
3539                        &all_types,
3540                        &mut rng,
3541                        &[],
3542                    )
3543                    .unwrap();
3544                }
3545                std::hint::black_box(&current);
3546                total += t0.elapsed();
3547            }
3548
3549            let avg = total / N_REPS as u32;
3550            eprintln!(
3551                "[hunt_h1] C={n_cols:2}  avg={:>8.3}ms  per_column={:>7.4}ms  per_row_per_column={:>10.6}us",
3552                avg.as_secs_f64() * 1000.0,
3553                avg.as_secs_f64() * 1000.0 / n_cols as f64,
3554                avg.as_secs_f64() * 1_000_000.0 / (N_ROWS * n_cols) as f64,
3555            );
3556        }
3557    }
3558
3559    // ── perf-hunt kyc residual factor (hunt2407.md, corrected) ──────────
3560    //
3561    // The first `hunt_h1_*` measurement above got two things wrong when
3562    // trying to explain kyc/hell's real-world ~4-5x slowdown vs nonprofit:
3563    // (1) it assumed `noise_passes = 3` at `hell` -- the real value is
3564    // `DifficultySettings::passes = 8` (schema.rs); (2) it used generic
3565    // `col0..colN` column names with `plan_cols=[]` for the "extra pass"
3566    // calls, but `noise_type_targets_column` is name-based -- generic names
3567    // match none of hell's 9 categories, so those extra passes were
3568    // silently no-ops regardless of `EXTRA_PASSES`. This measurement uses
3569    // the REAL kyc.natural_person (23 cols) and nonprofit.donation (7 cols,
3570    // the weight-dominant nonprofit entity, weight=200) column NAMES loaded
3571    // from `schemas/*.json`, hell's real 9-category `noise_types` list, and
3572    // the real pass count (8) -- reproducing genuine per-category column
3573    // matching via `noise_type_targets_column`. Run with:
3574    //   cargo test --release pipeline::tests::hunt_h1_residual_kyc_vs_nonprofit -- --ignored --nocapture
3575    #[test]
3576    #[ignore]
3577    fn hunt_h1_residual_kyc_vs_nonprofit() {
3578        use arrow::array::StringArray;
3579        use arrow::datatypes::{DataType, Field, Schema};
3580
3581        fn make_batch(n_rows: usize, col_names: &[&str]) -> RecordBatch {
3582            let fields: Vec<Field> = col_names
3583                .iter()
3584                .map(|n| Field::new(*n, DataType::Utf8, true))
3585                .collect();
3586            let cols: Vec<ArrayRef> = col_names
3587                .iter()
3588                .map(|_| {
3589                    Arc::new(StringArray::from(
3590                        (0..n_rows)
3591                            .map(|_| "abcdefghijklmnop testword".to_string())
3592                            .collect::<Vec<_>>(),
3593                    )) as ArrayRef
3594                })
3595                .collect();
3596            RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
3597        }
3598
3599        const N_ROWS: usize = 100_000;
3600        const N_REPS: usize = 5;
3601        const HELL_TYPES: &[&str] = &[
3602            "typo_aggressive",
3603            "visual",
3604            "unicode_pollution",
3605            "names",
3606            "dates",
3607            "identifiers",
3608            "addresses",
3609            "companies",
3610            "extra",
3611        ];
3612        const HELL_PASSES: usize = 8; // schema.rs DifficultySettings::passes for "hell"
3613
3614        let kyc_cols: &[&str] = &[
3615            "party_id",
3616            "given_name",
3617            "family_name",
3618            "middle_name",
3619            "birth_date",
3620            "birth_place",
3621            "nationality",
3622            "email_address",
3623            "mobile_phone",
3624            "personal_administrative_number",
3625            "tax_id",
3626            "residential_address_line1",
3627            "residential_city",
3628            "residential_postal_code",
3629            "residential_state",
3630            "residential_country",
3631            "occupation",
3632            "risk_score",
3633            "document_type",
3634            "document_number",
3635            "source_system",
3636        ];
3637        let nonprofit_cols: &[&str] = &[
3638            "donation_id",
3639            "donor_id",
3640            "campaign_id",
3641            "donation_date",
3642            "payment_method",
3643            "source_system",
3644        ];
3645
3646        eprintln!("\n[hunt_h1_residual] N_ROWS={N_ROWS} N_REPS={N_REPS} HELL_PASSES={HELL_PASSES}");
3647
3648        // Per-category breakdown for kyc only (the slow one) -- one rep,
3649        // timing each of the 9 categories' apply_noise_with_retry call
3650        // individually (fresh copy of the batch each time, not chained) to
3651        // find which category(ies) dominate the 18s total.
3652        {
3653            let batch = make_batch(N_ROWS, kyc_cols);
3654            eprintln!("[hunt_h1_residual] per-category breakdown on kyc.natural_person:");
3655            for &ttype in HELL_TYPES {
3656                let mut rng = Rng::new(42);
3657                let t0 = std::time::Instant::now();
3658                let (_, _, tcols) =
3659                    apply_noise_with_retry(&batch, ttype, &[], &mut rng, &[]).unwrap();
3660                eprintln!(
3661                    "[hunt_h1_residual]   {ttype:20} {:>8.3}ms  target_cols={:?}",
3662                    t0.elapsed().as_secs_f64() * 1000.0,
3663                    tcols,
3664                );
3665            }
3666        }
3667
3668        for (label, col_names) in [
3669            ("kyc.natural_person", kyc_cols),
3670            ("nonprofit.donation", nonprofit_cols),
3671        ] {
3672            let mut total = std::time::Duration::ZERO;
3673            let mut total_target_hits = 0usize;
3674
3675            for rep in 0..N_REPS {
3676                let batch = make_batch(N_ROWS, col_names);
3677                let mut rng = Rng::new(42 + rep as u64);
3678                let t0 = std::time::Instant::now();
3679
3680                // First pass: same as real generation, one explicit
3681                // noise_type (heuristic-matched via plan_cols=[]).
3682                let (noisy, mut unchanged, t0cols) =
3683                    apply_noise_with_retry(&batch, "identifiers", &[], &mut rng, &[]).unwrap();
3684                total_target_hits += t0cols.len();
3685                let mut current = noisy;
3686                for _ in 1..HELL_PASSES {
3687                    current = apply_extra_pass_per_row(
3688                        &current,
3689                        &mut unchanged,
3690                        HELL_TYPES,
3691                        &mut rng,
3692                        &[],
3693                    )
3694                    .unwrap();
3695                }
3696                std::hint::black_box(&current);
3697                total += t0.elapsed();
3698            }
3699
3700            let avg = total / N_REPS as u32;
3701            eprintln!(
3702                "[hunt_h1_residual] {label:20} C={:2}  avg={:>8.3}ms  avg_first_pass_target_cols={:.1}",
3703                col_names.len(),
3704                avg.as_secs_f64() * 1000.0,
3705                total_target_hits as f64 / N_REPS as f64,
3706            );
3707        }
3708    }
3709}