Skip to main content

dupehell_core/
pipeline.rs

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