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