Skip to main content

dupehell_core/
gt.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// No liability for misuse.
5
6use arrow::array::{Array, ArrayRef, AsArray, BooleanArray};
7use arrow::buffer::BooleanBuffer;
8use arrow::datatypes::{DataType, Field, Schema};
9use arrow::record_batch::RecordBatch;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// Compressed-sparse-row representation of the duplicate-cluster
14/// membership previously held as `HashMap<String, Vec<(String, bool)>>`.
15/// Built once, in `GtAccumulator::finish`, from every `(master_id,
16/// record_id, is_identical)` triple belonging to a duplicated master.
17///
18/// Avoids one `String` allocation per `record_id` and one per distinct
19/// `master_id` (the dominant RAM cost of `cluster_map` at 200M+ records,
20/// see `project_csr_cluster_map_backlog`): `master_id`/`record_id` are
21/// packed into `u64`s (`pipeline::pack_master_key`/`parse_record_idx` —
22/// both are pure functions of the fixed-width ID shapes `pipeline.rs`
23/// already generates, so the packing/unpacking is lossless and exact) and
24/// stored in two flat, contiguous buffers instead of a hash map of
25/// per-cluster `Vec`s.
26///
27/// `master_id` itself is never needed downstream as a string — the only
28/// consumer (`graph_gen::push_dup_clusters`) only ever used it to get a
29/// deterministic cluster iteration order (`master_ids.sort()`), and
30/// numeric comparison of the packed key preserves that exact order (both
31/// segments of the original string are zero-padded to a fixed width, so
32/// lexicographic and numeric order agree).
33/// Bit-packed boolean buffer — 1 bit per entry instead of `Vec<bool>`'s 1
34/// byte, an 8x reduction on `ClusterCsr::is_identical`, which parallels
35/// `records` (potentially tens of millions of entries at 100M+ hell scale
36/// with `--graph`, a low singleton fraction). Found via a triage of
37/// reusable Rust perf patterns (hunt3007/H7).
38/// A `BooleanArray` of `n` copies of `value`, built directly from a
39/// `BooleanBuffer` (one bitmap allocation, `n/8` bytes) instead of
40/// `BooleanArray::from(vec![value; n])` (an `n`-byte `Vec<bool>` allocated
41/// and memset, then re-packed into a bitmap by `From`).
42fn const_bool_array(n: usize, value: bool) -> ArrayRef {
43    let buf = if value {
44        BooleanBuffer::new_set(n)
45    } else {
46        BooleanBuffer::new_unset(n)
47    };
48    Arc::new(BooleanArray::new(buf, None))
49}
50
51struct Bitset {
52    words: Vec<u64>,
53    len: usize,
54}
55
56impl Bitset {
57    fn with_capacity(n: usize) -> Self {
58        Self {
59            words: vec![0u64; n.div_ceil(64).max(1)],
60            len: 0,
61        }
62    }
63
64    fn push(&mut self, val: bool) {
65        let word_idx = self.len / 64;
66        if word_idx >= self.words.len() {
67            self.words.push(0);
68        }
69        if val {
70            self.words[word_idx] |= 1u64 << (self.len % 64);
71        }
72        self.len += 1;
73    }
74
75    fn get(&self, i: usize) -> bool {
76        (self.words[i / 64] >> (i % 64)) & 1 != 0
77    }
78}
79
80/// View into a contiguous range of a [`Bitset`], returned per-cluster by
81/// [`ClusterCsr::groups`] — parallel to the `&[u64]` record-index slice for
82/// the same cluster.
83pub struct BitspanRef<'a> {
84    bits: &'a Bitset,
85    start: usize,
86    end: usize,
87}
88
89impl BitspanRef<'_> {
90    /// `i` is local to this span (`0..self.len()`), matching how callers
91    /// already index the parallel `&[u64]` records slice.
92    pub fn get(&self, i: usize) -> bool {
93        self.bits.get(self.start + i)
94    }
95
96    pub fn len(&self) -> usize {
97        self.end - self.start
98    }
99
100    pub fn is_empty(&self) -> bool {
101        self.start == self.end
102    }
103
104    #[cfg(test)]
105    fn to_vec(&self) -> Vec<bool> {
106        (0..self.len()).map(|i| self.get(i)).collect()
107    }
108}
109
110pub struct ClusterCsr {
111    /// `offsets.len() == n_clusters + 1`; cluster `k`'s members are
112    /// `records[offsets[k]..offsets[k+1]]` (and the parallel span of
113    /// `is_identical`). Sorted ascending by packed master key.
114    offsets: Vec<u32>,
115    /// Flat, cluster-grouped buffer of global record indices (see
116    /// `pipeline::record_id_string`/`parse_record_idx`), ascending within
117    /// each cluster.
118    records: Vec<u64>,
119    /// Parallel to `records`: whether that record is byte-identical to its
120    /// cluster's master.
121    is_identical: Bitset,
122}
123
124/// `record_idx` (from `parse_record_idx`) is bounded by `PAD_LEN` (13)
125/// decimal digits — `< 10^13 ≈ 2^43.2` — so bit 63 is always free to carry
126/// `is_identical` alongside it in one `u64`, the same trick
127/// `pack_master_key` already uses for its own high bits (hunt1808/H-RAM3).
128/// Packing this into `ClusterCsr::build`'s transient pair list drops it
129/// from `(u64, u64, bool)` (24 bytes/entry — `bool` pads the tuple to the
130/// next 8-byte alignment boundary, measured on this machine) to `(u64,
131/// u64)` (16 bytes/entry, −33%), at potentially tens of millions of
132/// entries (every row of every duplicated cluster, `--graph` mode).
133const RIDX_IDENTICAL_BIT: u64 = 1 << 63;
134const RIDX_MASK: u64 = !RIDX_IDENTICAL_BIT;
135
136/// Packs `(record_idx, is_identical)` into `ClusterCsr::build`'s pair
137/// representation — see `RIDX_IDENTICAL_BIT`.
138pub(crate) fn pack_ridx_identical(record_idx: u64, is_identical: bool) -> u64 {
139    debug_assert_eq!(
140        record_idx & RIDX_IDENTICAL_BIT,
141        0,
142        "record_idx must fit in the low 63 bits"
143    );
144    record_idx | if is_identical { RIDX_IDENTICAL_BIT } else { 0 }
145}
146
147impl ClusterCsr {
148    /// Builds the CSR from an unordered flat list of `(packed_master_key,
149    /// packed_ridx_identical)` pairs (see `pack_ridx_identical`). Sorting
150    /// once by `(master_key, record_idx)` — masking off the packed
151    /// `is_identical` bit for the comparison, so it can't perturb member
152    /// order within a cluster — both groups every pair by cluster and
153    /// orders each cluster's members ascending, in one pass; no need to
154    /// pre-count group sizes or build the structure incrementally.
155    ///
156    /// Sorted in parallel (`rayon`, already a warm dependency by this point
157    /// in the run): the sort key `(master_key, record_idx)` is unique per
158    /// entry (one record belongs to exactly one cluster, and a
159    /// `record_idx` is unique across the whole run), so an unstable
160    /// parallel sort produces the exact same total order as the previous
161    /// single-threaded one — determinism doesn't depend on stability here.
162    pub(crate) fn build(mut pairs: Vec<(u64, u64)>) -> Self {
163        use rayon::slice::ParallelSliceMut;
164        pairs.par_sort_unstable_by_key(|&(mk, r)| (mk, r & RIDX_MASK));
165
166        let mut offsets = Vec::new();
167        let mut records = Vec::with_capacity(pairs.len());
168        let mut is_identical = Bitset::with_capacity(pairs.len());
169        let mut last_key: Option<u64> = None;
170
171        for (mk, r) in pairs {
172            if last_key != Some(mk) {
173                offsets.push(records.len() as u32);
174                last_key = Some(mk);
175            }
176            records.push(r & RIDX_MASK);
177            is_identical.push(r & RIDX_IDENTICAL_BIT != 0);
178        }
179        offsets.push(records.len() as u32);
180
181        Self {
182            offsets,
183            records,
184            is_identical,
185        }
186    }
187
188    /// Iterates clusters in ascending packed-master-key order (matches the
189    /// previous `master_ids.sort()` determinism exactly), yielding each
190    /// cluster's `(record_indices, is_identical)` pair. A cluster's
191    /// `record_indices` are already ascending, so callers that used to sort
192    /// members themselves (`push_dup_clusters`) no longer need to.
193    pub fn groups(&self) -> impl Iterator<Item = (&[u64], BitspanRef<'_>)> {
194        self.offsets.windows(2).map(move |w| {
195            let (start, end) = (w[0] as usize, w[1] as usize);
196            (
197                &self.records[start..end],
198                BitspanRef {
199                    bits: &self.is_identical,
200                    start,
201                    end,
202                },
203            )
204        })
205    }
206
207    #[cfg(test)]
208    fn n_clusters(&self) -> usize {
209        self.offsets.len().saturating_sub(1)
210    }
211}
212
213/// Result of [`GtAccumulator::finish`].
214pub struct GtResult {
215    /// Cluster members that are genuinely byte-for-byte identical to their
216    /// master (the master itself, plus any duplicate copy whose assigned
217    /// noise ended up a no-op — see `pipeline::unchanged_row_mask`).
218    pub n_exact_dup: usize,
219    /// Cluster members that are duplicates of a master but differ from it
220    /// on at least one column (noise was actually applied). Distinguished
221    /// from `n_exact_dup` so a consumer can't mistake a fuzzy duplicate for
222    /// a trivial byte-for-byte match.
223    pub n_fuzzy_dup: usize,
224    pub n_hard_neg: usize,
225    pub n_unique: usize,
226    pub n_masters: usize,
227    /// Maps each duplicated `master_id` to the full set of `(record_id,
228    /// is_identical)` pairs in its cluster (base + duplicate copies, exact
229    /// and fuzzy alike) — `is_identical` mirrors that record's own
230    /// `exact_dup`/`fuzzy_dup` classification. Consumed by
231    /// `graph_gen::push_dup_clusters` to decide, per edge, whether the pair
232    /// it connects is `exact_dup` (both ends byte-identical to the master,
233    /// hence to each other) or `fuzzy_dup` (at least one end was noised).
234    pub cluster_map: ClusterCsr,
235}
236
237/// `Dictionary(Int32, Utf8)` for `entity_type`/`match_type`/`difficulty` —
238/// hunt1808/H11, an explicit output-contract change (not bit-identical:
239/// the declared column type changes from `Utf8`), applied on explicit user
240/// request. `entity_type` here must match `pipeline::low_cardinality_dict_type`
241/// exactly — this column's data is `add_metadata_and_align`'s `et_arr`,
242/// pushed straight through by `push_*_batch`, so draft/final schema must
243/// declare the same type it's actually built as.
244fn low_cardinality_dict_type() -> DataType {
245    DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
246}
247
248fn draft_schema() -> Arc<Schema> {
249    Arc::new(Schema::new(vec![
250        Field::new("record_id", DataType::Utf8, false),
251        Field::new("master_id", DataType::Utf8, false),
252        Field::new("entity_type", low_cardinality_dict_type(), false),
253        // `true` for duplicate-copy rows whose assigned noise ended up not
254        // changing anything visible; `false` for duplicate copies that were
255        // genuinely altered, and for base/master rows (a base row is never
256        // itself "a copy that happened to match" — see `is_base` below for
257        // how its match_type is actually resolved). Meaningless for
258        // hard_neg/canary rows (classified by master_id prefix regardless).
259        Field::new("is_identical", DataType::Boolean, false),
260        // `true` only for base/master rows (one per entity, written by
261        // `push_base_batch`); `false` for every duplicate copy and for
262        // hard_neg/canary rows. Lets `finish` tell a base row apart from a
263        // genuine fuzzy copy when both carry `is_identical = false` — without
264        // it, a duplicated master's own base row would be misclassified
265        // `exact_dup` unconditionally (it used to hardcode
266        // `is_identical = true` for every base row, regardless of whether
267        // any of its actual duplicate copies were byte-identical to it).
268        Field::new("is_base", DataType::Boolean, false),
269    ]))
270}
271
272/// Streaming, two-pass ground-truth builder.
273///
274/// Duplicate detection needs a genuinely global view of which masters are
275/// duplicated (a master can be duplicated anywhere in the record stream),
276/// but that's the *only* thing that needs to be global — everything else
277/// can be processed one batch at a time. Previously the whole run
278/// accumulated three full-dataset `Vec<ArrayRef>` (record_id/entity_type/
279/// master_id) in RAM to concat and classify at the very end; at 200M+
280/// records that's multiple GB held for the entire run just for GT
281/// bookkeeping.
282///
283/// This accumulator instead:
284/// 1. streams each batch straight to a small "draft" IPC file as it's
285///    generated. The pipeline already knows, at the point each batch is
286///    produced, whether its rows introduce brand-new masters
287///    (`push_base_batch`) or are duplicate copies of masters seen earlier
288///    (`push_dup_batch`) — that's how `pipeline::run_pipeline` builds
289///    `dup_mids_buf` today. So instead of *discovering* duplication by
290///    folding occurrence counts over every master ever seen (O(n_base) —
291///    one entry per base record, duplicated or not), this only records the
292///    (much smaller) set of masters that are *actually* duplicated —
293///    O(n_dup_masters), a fraction of the dataset determined by the
294///    doublet/triplet ratios, not the full base population. HN/CANARY rows
295///    (`push_other_batch`) need no bookkeeping at all: their prefix alone
296///    determines their classification.
297/// 2. on `finish`, re-reads that draft file batch by batch — now that the
298///    full duplicated-master set is known — to classify each row and
299///    stream the definitive ground-truth file (IPC or Parquet), same
300///    batch-by-batch pattern already used elsewhere in this codebase for
301///    the IPC→Parquet dataset conversion (see `pipeline::run_pipeline`).
302///
303/// Duplicate detection is keyed on the **full** master_id (entity prefix
304/// included, via `pipeline::pack_master_key` into a `u64`), not just a
305/// numeric suffix: master_ids are assigned per-entity-plan starting from
306/// index 0, so two unrelated entities of different types can share the
307/// same numeric suffix. Keying on the full identity avoids counting those
308/// as duplicates of each other.
309pub struct GtAccumulator {
310    draft_path: String,
311    writer: arrow::ipc::writer::FileWriter<std::fs::File>,
312    schema: Arc<Schema>,
313    /// Keyed on `pipeline::pack_master_key(mid)` rather than the raw
314    /// `String` — avoids a per-row string hash/allocation on a set queried
315    /// 2-3x per duplicated row (`push_dup_batch` + classification in
316    /// `finish`), which VTune hotspots (hunt3007) showed costing ~5% of
317    /// total CPU on its own. `push_dup_batch` never receives an
318    /// HN-/CANARY- master_id, so every key here is guaranteed
319    /// pack_master_key-compatible.
320    dup_masters: rustc_hash::FxHashSet<u64>,
321    /// Masters that have at least one duplicate copy whose assigned noise
322    /// ended up a genuine no-op (byte-identical to the base) — as opposed to
323    /// `dup_masters`, which just means "has any duplicate copy at all,
324    /// exact or fuzzy". Used at `finish` so a base row can tell whether it
325    /// actually has an identical twin instead of unconditionally claiming
326    /// one.
327    masters_with_exact_copy: rustc_hash::FxHashSet<u64>,
328    n_base_masters: usize,
329}
330
331impl GtAccumulator {
332    /// Current size of `dup_masters` (hunt2808, RAM-first pass) -- lets
333    /// call sites log this cumulative-over-the-whole-run set's growth
334    /// directly, instead of inferring it from RSS deltas that also include
335    /// unrelated per-entity batch buffers.
336    pub(crate) fn dup_masters_len(&self) -> usize {
337        self.dup_masters.len()
338    }
339
340    /// Current size of `masters_with_exact_copy` (hunt2808, RAM-first
341    /// pass) -- see `dup_masters_len`.
342    pub(crate) fn masters_with_exact_copy_len(&self) -> usize {
343        self.masters_with_exact_copy.len()
344    }
345
346    pub fn new(draft_path: &str) -> Result<Self, String> {
347        let schema = draft_schema();
348        let file = std::fs::File::create(draft_path)
349            .map_err(|e| format!("create gt draft {draft_path}: {e}"))?;
350        let writer = arrow::ipc::writer::FileWriter::try_new(file, &schema)
351            .map_err(|e| format!("gt draft writer: {e}"))?;
352        Ok(Self {
353            draft_path: draft_path.to_string(),
354            writer,
355            schema,
356            dup_masters: rustc_hash::FxHashSet::default(),
357            masters_with_exact_copy: rustc_hash::FxHashSet::default(),
358            n_base_masters: 0,
359        })
360    }
361
362    fn write_draft(
363        &mut self,
364        record_ids: &ArrayRef,
365        entity_types: &ArrayRef,
366        master_ids: &ArrayRef,
367        is_identical: &ArrayRef,
368        is_base: &ArrayRef,
369    ) -> Result<(), String> {
370        let batch = RecordBatch::try_new(
371            self.schema.clone(),
372            vec![
373                record_ids.clone(),
374                master_ids.clone(),
375                entity_types.clone(),
376                is_identical.clone(),
377                is_base.clone(),
378            ],
379        )
380        .map_err(|e| format!("build gt draft batch: {e}"))?;
381        self.writer
382            .write(&batch)
383            .map_err(|e| format!("write gt draft: {e}"))
384    }
385
386    /// Feed a batch of rows that each introduce a brand-new, not-yet-seen
387    /// master (base records — one master per row, never a duplicate copy).
388    pub fn push_base_batch(
389        &mut self,
390        record_ids: &ArrayRef,
391        entity_types: &ArrayRef,
392        master_ids: &ArrayRef,
393    ) -> Result<(), String> {
394        let mids = master_ids.as_string::<i32>();
395        self.n_base_masters += mids.len() - mids.null_count();
396        // `is_identical = false`: a base row is not itself a "copy that
397        // happened to match" — whether it should read as `exact_dup` is
398        // resolved at `finish` from `masters_with_exact_copy`, not hardcoded
399        // here (this used to be `true` unconditionally, which mislabeled
400        // every duplicated master's base row `exact_dup` even when all of
401        // its actual duplicate copies were fuzzy — none byte-identical).
402        let all_false = const_bool_array(mids.len(), false);
403        let all_true = const_bool_array(mids.len(), true);
404        self.write_draft(record_ids, entity_types, master_ids, &all_false, &all_true)
405    }
406
407    /// Feed a batch of duplicate-copy rows (each `master_id` matches an
408    /// existing base record already fed via `push_base_batch`). Records the
409    /// *set* of duplicated masters — every row sharing one of these
410    /// master_ids (the original base row included) belongs to a duplicated
411    /// cluster at `finish`, classified `exact_dup` or `fuzzy_dup` per-row
412    /// based on `is_identical` (`true` when the noise assigned to this copy
413    /// ended up a no-op — see `pipeline::unchanged_row_mask` — `false` when
414    /// it produced a real, visible change). Also records which masters have
415    /// at least one genuinely identical copy, for the base row's own
416    /// classification at `finish`.
417    pub fn push_dup_batch(
418        &mut self,
419        record_ids: &ArrayRef,
420        entity_types: &ArrayRef,
421        master_ids: &ArrayRef,
422        is_identical: &ArrayRef,
423    ) -> Result<(), String> {
424        let mids = master_ids.as_string::<i32>();
425        let idents = is_identical.as_boolean();
426        for i in 0..mids.len() {
427            if !mids.is_null(i) {
428                let mid = mids.value(i);
429                let key = crate::pipeline::pack_master_key(mid).unwrap_or_else(|| {
430                    panic!(
431                        "push_dup_batch: master_id {mid:?} isn't the fixed \
432                         \"{{entity_prefix}}-{{pad_string}}\" shape -- \
433                         push_dup_batch never receives an HN-/CANARY- master_id"
434                    )
435                });
436                self.dup_masters.insert(key);
437                if !idents.is_null(i) && idents.value(i) {
438                    self.masters_with_exact_copy.insert(key);
439                }
440            }
441        }
442        let all_false = const_bool_array(mids.len(), false);
443        self.write_draft(
444            record_ids,
445            entity_types,
446            master_ids,
447            is_identical,
448            &all_false,
449        )
450    }
451
452    /// Feed a batch of rows whose classification is fully determined by
453    /// their `master_id` prefix (hard negatives `HN-...`, canaries
454    /// `CANARY-...`) — no bookkeeping needed beyond streaming to the draft.
455    pub fn push_other_batch(
456        &mut self,
457        record_ids: &ArrayRef,
458        entity_types: &ArrayRef,
459        master_ids: &ArrayRef,
460    ) -> Result<(), String> {
461        // `is_identical`/`is_base` are meaningless for hard_neg/canary rows
462        // (classified by master_id prefix regardless), so the values don't
463        // matter.
464        let all_false = const_bool_array(record_ids.len(), false);
465        self.write_draft(record_ids, entity_types, master_ids, &all_false, &all_false)
466    }
467
468    /// Consumes the accumulator: closes the draft, re-reads it batch by
469    /// batch to classify each row now that the full duplicated-master set
470    /// is known, and streams the definitive ground-truth file (IPC or
471    /// Parquet). Returns a [`GtResult`].
472    pub fn finish(
473        self,
474        difficulty: &str,
475        output_format: &str,
476        final_path: &str,
477        metadata: &HashMap<String, String>,
478        track_clusters: bool,
479    ) -> Result<GtResult, String> {
480        let GtAccumulator {
481            draft_path,
482            mut writer,
483            dup_masters,
484            masters_with_exact_copy,
485            n_base_masters,
486            ..
487        } = self;
488        writer
489            .finish()
490            .map_err(|e| format!("finish gt draft: {e}"))?;
491        drop(writer);
492
493        let final_schema = Arc::new(
494            Schema::new(vec![
495                Field::new("record_id", DataType::Utf8, false),
496                Field::new("master_id", DataType::Utf8, false),
497                Field::new("entity_type", low_cardinality_dict_type(), false),
498                Field::new("match_type", low_cardinality_dict_type(), false),
499                Field::new("difficulty", low_cardinality_dict_type(), false),
500            ])
501            .with_metadata(metadata.clone()),
502        );
503
504        let draft_file = std::fs::File::open(&draft_path)
505            .map_err(|e| format!("reopen gt draft {draft_path}: {e}"))?;
506        let reader = arrow::ipc::reader::FileReader::try_new(draft_file, None)
507            .map_err(|e| format!("gt draft reader: {e}"))?;
508
509        let mut sink = GtSink::new(output_format, final_path, &final_schema, metadata)?;
510
511        let mut n_exact_dup = 0usize;
512        let mut n_fuzzy_dup = 0usize;
513        let mut n_hard_neg = 0usize;
514        let mut n_unique = 0usize;
515        // Cluster membership for duplicated masters (base + duplicate copies,
516        // exact and fuzzy alike), for emitting duplicate-cluster edges after
517        // this pass. Accumulated as a flat `Vec` of packed triples (see
518        // `ClusterCsr`) instead of a `HashMap<String, Vec<...>>` -- avoids a
519        // `String` allocation per record_id/master_id, the dominant RAM cost
520        // of this bookkeeping at 200M+ records.
521        let mut cluster_pairs: Vec<(u64, u64)> = Vec::new();
522        // `difficulty` is constant for the whole run and `n` only ever takes
523        // two values across all batches (the draft's batch size, and the
524        // final remainder) — cache the built column by `n` instead of
525        // reallocating+refilling it every batch (same pattern as
526        // `pipeline.rs`'s `const_arr_cache`).
527        let mut diff_arr_cache: Option<(usize, ArrayRef)> = None;
528        // Fixed, fully-known value sets — built once, before any batch is
529        // written, and reused for every batch's `DictionaryArray` (hunt1808/
530        // H11; see `pipeline::DictValues`'s doc comment for why a *shared*
531        // dictionary is required for IPC output, not just a same-content
532        // one built fresh per batch).
533        let match_type_dict = crate::pipeline::DictValues::new([
534            "hard_neg",
535            "canary",
536            "exact_dup",
537            "fuzzy_dup",
538            "unique",
539        ]);
540        let difficulty_dict = crate::pipeline::DictValues::new([difficulty.to_string()]);
541        // Resolved once, not once per row (perf-hunt hunt0109/H10):
542        // `match_type_dict.key(mt)` hashes `mt` through a `HashMap<String,
543        // i32>` (SipHash), but `mt` is always one of these 5 literals —
544        // every row's classification branch below already knows which one
545        // before it ever calls `.key()`. Resolving the 5 keys up front lets
546        // the branch produce the `i32` directly, at the same place it used
547        // to produce the `&str`.
548        let k_hard_neg = match_type_dict.key("hard_neg");
549        let k_canary = match_type_dict.key("canary");
550        let k_exact_dup = match_type_dict.key("exact_dup");
551        let k_fuzzy_dup = match_type_dict.key("fuzzy_dup");
552        let k_unique = match_type_dict.key("unique");
553
554        for batch_result in reader {
555            let batch = batch_result.map_err(|e| format!("read gt draft batch: {e}"))?;
556            let n = batch.num_rows();
557            let mid_col = batch.column(1).as_string::<i32>();
558            let rid_col = batch.column(0).as_string::<i32>();
559            let ident_col = batch.column(3).as_boolean();
560            let base_col = batch.column(4).as_boolean();
561
562            let mut mt_keys = arrow::array::Int32Builder::with_capacity(n);
563            for i in 0..n {
564                let mid = if mid_col.is_null(i) {
565                    ""
566                } else {
567                    mid_col.value(i)
568                };
569                let is_base = !base_col.is_null(i) && base_col.value(i);
570                let is_hn = mid.starts_with("HN-");
571                let is_canary = !is_hn && mid.starts_with("CANARY-");
572                // `dup_masters`/`masters_with_exact_copy` are keyed on
573                // `pack_master_key`, not the raw string (hunt3007/H3) --
574                // never `Some` for HN-/CANARY- ids, consistent with
575                // `push_dup_batch` never inserting those.
576                let master_key = if is_hn || is_canary {
577                    None
578                } else {
579                    crate::pipeline::pack_master_key(mid)
580                };
581                // Per-row, not per-cluster: a cluster can mix a base row, a
582                // copy the noise happened not to change, and a copy that's
583                // genuinely different. A base row has no noise of its own —
584                // it reads `exact_dup` only if the cluster actually contains
585                // a byte-identical copy (`masters_with_exact_copy`), never
586                // unconditionally (that used to mislabel every duplicated
587                // master's base row `exact_dup` even when all of its copies
588                // were fuzzy).
589                let is_identical = if is_base {
590                    master_key.is_some_and(|k| masters_with_exact_copy.contains(&k))
591                } else {
592                    !ident_col.is_null(i) && ident_col.value(i)
593                };
594                let is_dup_master = master_key.is_some_and(|k| dup_masters.contains(&k));
595                let mt_key = if is_hn {
596                    n_hard_neg += 1;
597                    k_hard_neg
598                } else if is_canary {
599                    k_canary
600                } else if is_dup_master {
601                    if is_identical {
602                        n_exact_dup += 1;
603                        k_exact_dup
604                    } else {
605                        n_fuzzy_dup += 1;
606                        k_fuzzy_dup
607                    }
608                } else {
609                    n_unique += 1;
610                    k_unique
611                };
612                mt_keys.append_value(mt_key);
613                // Every row of a duplicated master belongs to its cluster,
614                // tagged with its own identical/fuzzy status. Only tracked
615                // when `--graph` is enabled: `cluster_pairs` is exclusively
616                // consumed by `graph_gen::push_dup_clusters` to emit
617                // exact_dup/fuzzy_dup edges, so building it when there's no
618                // graph output to write is pure wasted RAM -- one entry per
619                // duplicate-cluster row (base + every copy), which at
620                // 100M+ records with a low singleton fraction (hell tier)
621                // can be the majority of the dataset.
622                if track_clusters && is_dup_master {
623                    let master_key = master_key.expect("is_dup_master implies master_key is Some");
624                    let record_idx = crate::pipeline::parse_record_idx(rid_col.value(i))
625                        .unwrap_or_else(|| {
626                            panic!(
627                                "cluster_map: record_id {:?} isn't the fixed \
628                                 \"R-\" + 13-digit shape produced by \
629                                 `record_id_string`",
630                                rid_col.value(i)
631                            )
632                        });
633                    cluster_pairs.push((master_key, pack_ridx_identical(record_idx, is_identical)));
634                }
635            }
636            let mt_arr = match_type_dict.finish_keys(mt_keys.finish());
637            let diff_arr = match &diff_arr_cache {
638                Some((cached_n, arr)) if *cached_n == n => arr.clone(),
639                _ => {
640                    let arr = difficulty_dict.const_array(difficulty, n);
641                    diff_arr_cache = Some((n, arr.clone()));
642                    arr
643                }
644            };
645
646            let final_batch = RecordBatch::try_new(
647                final_schema.clone(),
648                vec![
649                    batch.column(0).clone(),
650                    batch.column(1).clone(),
651                    batch.column(2).clone(),
652                    mt_arr,
653                    diff_arr,
654                ],
655            )
656            .map_err(|e| format!("build final gt batch: {e}"))?;
657
658            sink.write(&final_batch)?;
659        }
660
661        sink.finish()?;
662        std::fs::remove_file(&draft_path).ok();
663
664        Ok(GtResult {
665            n_exact_dup,
666            n_fuzzy_dup,
667            n_hard_neg,
668            n_unique,
669            n_masters: n_base_masters,
670            cluster_map: ClusterCsr::build(cluster_pairs),
671        })
672    }
673}
674
675enum GtSink {
676    Ipc(Box<arrow::ipc::writer::FileWriter<std::fs::File>>),
677    Parquet(Box<parquet::arrow::ArrowWriter<std::fs::File>>),
678}
679
680impl GtSink {
681    fn new(
682        output_format: &str,
683        path: &str,
684        schema: &Arc<Schema>,
685        metadata: &HashMap<String, String>,
686    ) -> Result<Self, String> {
687        let file = std::fs::File::create(path).map_err(|e| format!("create {path}: {e}"))?;
688        if output_format == "parquet" {
689            use parquet::basic::{Compression, ZstdLevel};
690            use parquet::file::properties::WriterProperties;
691            let zstd = ZstdLevel::try_new(3).map_err(|e| format!("zstd: {e}"))?;
692            let meta_kv: Vec<parquet::file::metadata::KeyValue> = metadata
693                .iter()
694                .map(|(k, v)| parquet::file::metadata::KeyValue {
695                    key: k.clone(),
696                    value: Some(v.clone()),
697                })
698                .collect();
699            let props = WriterProperties::builder()
700                .set_compression(Compression::ZSTD(zstd))
701                .set_data_page_size_limit(1_048_576)
702                .set_key_value_metadata(Some(meta_kv))
703                .build();
704            let writer = parquet::arrow::ArrowWriter::try_new(file, schema.clone(), Some(props))
705                .map_err(|e| format!("gt parquet writer: {e}"))?;
706            Ok(GtSink::Parquet(Box::new(writer)))
707        } else {
708            let writer = arrow::ipc::writer::FileWriter::try_new(file, schema)
709                .map_err(|e| format!("gt ipc writer: {e}"))?;
710            Ok(GtSink::Ipc(Box::new(writer)))
711        }
712    }
713
714    fn write(&mut self, batch: &RecordBatch) -> Result<(), String> {
715        match self {
716            GtSink::Ipc(w) => w.write(batch).map_err(|e| format!("write gt ipc: {e}")),
717            GtSink::Parquet(w) => w.write(batch).map_err(|e| format!("write gt parquet: {e}")),
718        }
719    }
720
721    fn finish(self) -> Result<(), String> {
722        match self {
723            GtSink::Ipc(mut w) => w.finish().map_err(|e| format!("finish gt ipc: {e}")),
724            GtSink::Parquet(w) => w
725                .close()
726                .map(|_| ())
727                .map_err(|e| format!("close gt parquet: {e}")),
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use arrow::array::StringArray;
736
737    fn tmp_path(name: &str) -> String {
738        let mut p = std::env::temp_dir();
739        p.push(format!(
740            "dupehell_gt_test_{name}_{}.ipc",
741            std::process::id()
742        ));
743        p.to_string_lossy().into_owned()
744    }
745
746    fn arr(values: Vec<&str>) -> ArrayRef {
747        Arc::new(StringArray::from(values))
748    }
749
750    /// Same as `arr`, but for computed (owned) strings -- used to build
751    /// `record_id`/`master_id` fixtures matching the exact fixed-width shape
752    /// `ClusterCsr`'s packing relies on (`pipeline::record_id_string`/
753    /// `entity_prefix`/`pad_string`), which a bare string literal like
754    /// `"R2"` doesn't.
755    fn arr_owned(values: Vec<String>) -> ArrayRef {
756        Arc::new(StringArray::from(values))
757    }
758
759    fn barr(values: Vec<bool>) -> ArrayRef {
760        Arc::new(BooleanArray::from(values))
761    }
762
763    /// `entity_type` fixtures (hunt1808/H11): the real column is
764    /// `Dictionary(Int32, Utf8)`, built from `add_metadata_and_align`'s
765    /// `entity_type_dict` — these tests build the draft schema directly,
766    /// so they need to hand `push_*_batch` a matching dictionary array
767    /// rather than the plain `StringArray` `arr()` builds. `dict` must be
768    /// the SAME `DictValues` instance across every `dict_arr` call feeding
769    /// one draft file — a fresh dictionary per call is exactly the
770    /// "Dictionary replacement" bug this whole hunt/H11 fixes in the real
771    /// pipeline code, and IPC enforces it just as strictly in tests.
772    fn dict_arr(dict: &crate::pipeline::DictValues, values: Vec<&str>) -> ArrayRef {
773        let keys =
774            arrow::array::Int32Array::from(values.iter().map(|v| dict.key(v)).collect::<Vec<_>>());
775        dict.finish_keys(keys)
776    }
777
778    /// Reads the final GT file into a `record_id -> match_type` map. A map
779    /// (rather than a positional `Vec`) keeps the assertions independent of
780    /// draft row order, which is caller-determined (base/dup/other batches
781    /// are pushed as separate, non-interleaved groups in the real pipeline).
782    fn read_match_types(final_path: &str) -> HashMap<String, String> {
783        let file = std::fs::File::open(final_path).unwrap();
784        let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
785        let mut out = HashMap::new();
786        for batch in reader {
787            let batch = batch.unwrap();
788            let rid = batch
789                .column_by_name("record_id")
790                .unwrap()
791                .as_string::<i32>();
792            // `match_type` is `Dictionary(Int32, Utf8)` (hunt1808/H11) —
793            // cast back to plain `Utf8` for this test-only comparison.
794            let mt_col =
795                arrow::compute::cast(batch.column_by_name("match_type").unwrap(), &DataType::Utf8)
796                    .unwrap();
797            let mt = mt_col.as_string::<i32>();
798            for i in 0..batch.num_rows() {
799                out.insert(rid.value(i).to_string(), mt.value(i).to_string());
800            }
801        }
802        out
803    }
804
805    /// Builds the fixed-width `"{entity_prefix}-{pad_string}"` master_id
806    /// shape `ClusterCsr`'s packing relies on -- all these fixture-building
807    /// tests use a single entity index (0).
808    fn mid(n: usize) -> String {
809        format!(
810            "{}-{}",
811            crate::pipeline::entity_prefix(0),
812            crate::pipeline::pad_string(n)
813        )
814    }
815
816    /// Alias for `pipeline::record_id_string`, for brevity in fixtures.
817    fn rid(i: usize) -> String {
818        crate::pipeline::record_id_string(i)
819    }
820
821    #[test]
822    fn test_gt_accumulator_basic() {
823        let draft = tmp_path("basic_draft");
824        let final_path = tmp_path("basic_final");
825
826        let mut acc = GtAccumulator::new(&draft).unwrap();
827        let et_dict = crate::pipeline::DictValues::new(["person"]);
828        // Base rows: rid(1) (master mid(1), later duplicated), rid(3)
829        // (singleton mid(2)), rid(6) (singleton mid(5)).
830        acc.push_base_batch(
831            &arr_owned(vec![rid(1), rid(3), rid(6)]),
832            &dict_arr(&et_dict, vec!["person", "person", "person"]),
833            &arr_owned(vec![mid(1), mid(2), mid(5)]),
834        )
835        .unwrap();
836        // Dup row: rid(2) duplicates mid(1), unchanged by its noise pass.
837        acc.push_dup_batch(
838            &arr_owned(vec![rid(2)]),
839            &dict_arr(&et_dict, vec!["person"]),
840            &arr_owned(vec![mid(1)]),
841            &barr(vec![true]),
842        )
843        .unwrap();
844        // Hard negatives: rid(4), rid(5).
845        acc.push_other_batch(
846            &arr_owned(vec![rid(4), rid(5)]),
847            &dict_arr(&et_dict, vec!["person", "person"]),
848            &arr(vec!["HN-0000003", "HN-0000004"]),
849        )
850        .unwrap();
851
852        let GtResult {
853            n_exact_dup: ed,
854            n_hard_neg: hn,
855            n_unique: un,
856            n_masters: masters,
857            ..
858        } = acc
859            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
860            .unwrap();
861        assert_eq!(ed, 2);
862        assert_eq!(hn, 2);
863        assert_eq!(un, 2);
864        assert_eq!(masters, 3); // mid(1), mid(2), mid(5)
865
866        let match_types = read_match_types(&final_path);
867        assert_eq!(match_types[&rid(1)], "exact_dup");
868        assert_eq!(match_types[&rid(2)], "exact_dup");
869        assert_eq!(match_types[&rid(3)], "unique");
870        assert_eq!(match_types[&rid(4)], "hard_neg");
871        assert_eq!(match_types[&rid(5)], "hard_neg");
872        assert_eq!(match_types[&rid(6)], "unique");
873
874        std::fs::remove_file(&final_path).ok();
875    }
876
877    /// A duplicate copy whose noise pass actually changed something
878    /// (`is_identical = false`) must be classified `fuzzy_dup`, not
879    /// `exact_dup` — the master itself, and any sibling copy that was left
880    /// unchanged, keep `exact_dup` independently, per row.
881    #[test]
882    fn test_gt_accumulator_fuzzy_dup() {
883        let draft = tmp_path("fuzzy_draft");
884        let final_path = tmp_path("fuzzy_final");
885
886        let mut acc = GtAccumulator::new(&draft).unwrap();
887        let et_dict = crate::pipeline::DictValues::new(["person"]);
888        // Triplet: rid(1) is the master, rid(2) was left unchanged by its
889        // noise pass, rid(3) was genuinely altered.
890        acc.push_base_batch(
891            &arr_owned(vec![rid(1)]),
892            &dict_arr(&et_dict, vec!["person"]),
893            &arr_owned(vec![mid(1)]),
894        )
895        .unwrap();
896        acc.push_dup_batch(
897            &arr_owned(vec![rid(2), rid(3)]),
898            &dict_arr(&et_dict, vec!["person", "person"]),
899            &arr_owned(vec![mid(1), mid(1)]),
900            &barr(vec![true, false]),
901        )
902        .unwrap();
903
904        let GtResult {
905            n_exact_dup: ed,
906            n_fuzzy_dup: fd,
907            n_unique: un,
908            n_masters: masters,
909            ..
910        } = acc
911            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
912            .unwrap();
913        assert_eq!(ed, 2); // rid(1) (master) + rid(2) (unchanged copy)
914        assert_eq!(fd, 1); // rid(3) (genuinely noised copy)
915        assert_eq!(un, 0);
916        assert_eq!(masters, 1);
917
918        let match_types = read_match_types(&final_path);
919        assert_eq!(match_types[&rid(1)], "exact_dup");
920        assert_eq!(match_types[&rid(2)], "exact_dup");
921        assert_eq!(match_types[&rid(3)], "fuzzy_dup");
922
923        std::fs::remove_file(&final_path).ok();
924    }
925
926    #[test]
927    fn test_gt_accumulator_multi_batch() {
928        let draft = tmp_path("multi_draft");
929        let final_path = tmp_path("multi_final");
930
931        let mut acc = GtAccumulator::new(&draft).unwrap();
932        let et_dict = crate::pipeline::DictValues::new(["person"]);
933        // Base batch: two masters, one of which (mid(1)) is duplicated in a
934        // later, separate dup batch — simulating a master's duplicate
935        // landing in a different batch than its base row.
936        acc.push_base_batch(
937            &arr_owned(vec![rid(1), rid(3)]),
938            &dict_arr(&et_dict, vec!["person", "person"]),
939            &arr_owned(vec![mid(1), mid(2)]),
940        )
941        .unwrap();
942        acc.push_dup_batch(
943            &arr_owned(vec![rid(2)]),
944            &dict_arr(&et_dict, vec!["person"]),
945            &arr_owned(vec![mid(1)]),
946            &barr(vec![true]),
947        )
948        .unwrap();
949
950        let GtResult {
951            n_exact_dup: ed,
952            n_hard_neg: hn,
953            n_unique: un,
954            n_masters: masters,
955            ..
956        } = acc
957            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
958            .unwrap();
959        assert_eq!(ed, 2); // both rows of M-0000001
960        assert_eq!(hn, 0);
961        assert_eq!(un, 1);
962        assert_eq!(masters, 2);
963
964        std::fs::remove_file(&final_path).ok();
965    }
966
967    /// Regression: two different entity types can produce master_ids that
968    /// share the same numeric suffix (each entity's index restarts at 0),
969    /// but must not be counted as duplicates of each other.
970    #[test]
971    fn test_no_cross_entity_suffix_collision() {
972        let draft = tmp_path("suffix_draft");
973        let final_path = tmp_path("suffix_final");
974
975        let mut acc = GtAccumulator::new(&draft).unwrap();
976        let et_dict = crate::pipeline::DictValues::new(["person", "account"]);
977        acc.push_base_batch(
978            &arr(vec!["R1", "R2"]),
979            &dict_arr(&et_dict, vec!["person", "account"]),
980            &arr(vec!["PERSON-0000001", "ACCOUNT-0000001"]),
981        )
982        .unwrap();
983
984        let GtResult {
985            n_exact_dup: ed,
986            n_unique: un,
987            n_masters: masters,
988            ..
989        } = acc
990            .finish("medium", "ipc", &final_path, &HashMap::new(), false)
991            .unwrap();
992        assert_eq!(ed, 0);
993        assert_eq!(un, 2);
994        assert_eq!(masters, 2);
995
996        std::fs::remove_file(&final_path).ok();
997    }
998
999    /// `finish()` returns a `cluster_map` mapping each duplicated master_id
1000    /// to the full set of `(record_id, is_identical)` pairs in its cluster
1001    /// (base + duplicate copies). This is the structure consumed by
1002    /// `push_dup_clusters` to decide, per edge, `exact_dup` vs `fuzzy_dup`.
1003    #[test]
1004    fn test_cluster_map_contents() {
1005        let draft = tmp_path("cm_draft");
1006        let final_path = tmp_path("cm_final");
1007
1008        let mut acc = GtAccumulator::new(&draft).unwrap();
1009        let et_dict = crate::pipeline::DictValues::new(["person"]);
1010        acc.push_base_batch(
1011            &arr_owned(vec![rid(1), rid(3), rid(6)]),
1012            &dict_arr(&et_dict, vec!["person", "person", "person"]),
1013            &arr_owned(vec![mid(1), mid(2), mid(5)]),
1014        )
1015        .unwrap();
1016        // rid(2) (mid(1)) stayed identical; rid(7) (mid(5)) was genuinely noised.
1017        acc.push_dup_batch(
1018            &arr_owned(vec![rid(2), rid(7)]),
1019            &dict_arr(&et_dict, vec!["person", "person"]),
1020            &arr_owned(vec![mid(1), mid(5)]),
1021            &barr(vec![true, false]),
1022        )
1023        .unwrap();
1024        acc.push_other_batch(
1025            &arr_owned(vec![rid(4), rid(5)]),
1026            &dict_arr(&et_dict, vec!["person", "person"]),
1027            &arr(vec!["HN-0000003", "HN-0000004"]),
1028        )
1029        .unwrap();
1030
1031        let GtResult {
1032            cluster_map: cm, ..
1033        } = acc
1034            .finish("medium", "ipc", &final_path, &HashMap::new(), true)
1035            .unwrap();
1036
1037        // Only duplicated masters appear; singletons (mid(2)) do not. Groups
1038        // come out in ascending packed-master-key order, i.e. mid(1) (local
1039        // index 1) before mid(5) (local index 5).
1040        assert_eq!(cm.n_clusters(), 2);
1041        let mut groups = cm.groups();
1042        let (records1, idents1) = groups.next().unwrap();
1043        assert_eq!(records1, &[1, 2]);
1044        assert_eq!(idents1.to_vec(), vec![true, true]);
1045        // rid(6) is the base of mid(5); its only copy (rid(7)) was genuinely
1046        // noised, so the cluster has no byte-identical pair at all — rid(6)
1047        // must NOT read as identical just because it's the base (that was
1048        // the bug: base rows used to hardcode `is_identical = true`).
1049        let (records5, idents5) = groups.next().unwrap();
1050        assert_eq!(records5, &[6, 7]);
1051        assert_eq!(idents5.to_vec(), vec![false, false]);
1052        assert!(groups.next().is_none());
1053
1054        std::fs::remove_file(&final_path).ok();
1055    }
1056
1057    // ── perf-hunt RAM pass (hunt1708.md) ─────────────────────────────────
1058    //
1059    // Not a correctness test -- #[ignore]'d so it never runs in normal
1060    // `cargo test`. Isolated measurement (never a full pipeline run, per
1061    // perf-hunt rule 4): compares process RSS growth for two separate
1062    // `FxHashSet<u64>` (`dup_masters` + `masters_with_exact_copy` as they
1063    // exist today) against one combined `FxHashMap<u64, bool>`, at the
1064    // cardinality actually observed on a real run (aviation/hell/50M:
1065    // masters=21,666,666, uniques=5,855,236 -> dup_masters.len() ~=
1066    // 15,811,430; masters_with_exact_copy is a subset, sized here at 70%
1067    // of that as a representative estimate -- exact_dups=23,237,057 of
1068    // fuzzy+exact=44,144,764 total duplicate rows is ~53% at the row
1069    // level, but a master with ANY identical copy is more common than
1070    // that row-level fraction suggests, so 70% is a deliberately
1071    // generous (not cherry-picked-low) estimate for this comparison).
1072    // Run with:
1073    //   cargo test --release gt::tests::hunt_ram_dup_masters_vs_combined_map -- --ignored --nocapture
1074    #[test]
1075    #[ignore]
1076    fn hunt_ram_dup_masters_vs_combined_map() {
1077        fn rss_mb() -> f64 {
1078            let pid = sysinfo::Pid::from_u32(std::process::id());
1079            let mut sys = sysinfo::System::new();
1080            sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
1081            sys.process(pid)
1082                .map(|p| p.memory() as f64 / 1e6)
1083                .unwrap_or(0.0)
1084        }
1085
1086        const N_DUP_MASTERS: usize = 15_811_430;
1087        const N_EXACT_COPY: usize = (N_DUP_MASTERS as f64 * 0.70) as usize;
1088
1089        // Deterministic, well-spread u64 keys (not sequential -- sequential
1090        // keys would let the hash table's bucket layout be unrealistically
1091        // cache-friendly compared to the real `pack_master_key` output,
1092        // which mixes an entity_idx high-bit prefix with a local counter).
1093        // A cheap SplitMix64-style mix is enough to spread bits without
1094        // pulling in a new dependency for a throwaway measurement.
1095        fn mix(i: u64) -> u64 {
1096            let mut z = i.wrapping_add(0x9E3779B97F4A7C15);
1097            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
1098            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
1099            z ^ (z >> 31)
1100        }
1101
1102        // ── Scenario A: two separate FxHashSet<u64> (today's shape) ────────
1103        let rss0 = rss_mb();
1104        let mut dup_masters: rustc_hash::FxHashSet<u64> =
1105            rustc_hash::FxHashSet::with_capacity_and_hasher(N_DUP_MASTERS, Default::default());
1106        for i in 0..N_DUP_MASTERS as u64 {
1107            dup_masters.insert(mix(i));
1108        }
1109        let mut masters_with_exact_copy: rustc_hash::FxHashSet<u64> =
1110            rustc_hash::FxHashSet::with_capacity_and_hasher(N_EXACT_COPY, Default::default());
1111        for i in 0..N_EXACT_COPY as u64 {
1112            // Subset of the same key space as dup_masters (every master
1113            // with an exact copy is also in dup_masters), same mix.
1114            masters_with_exact_copy.insert(mix(i));
1115        }
1116        let rss_two_sets = rss_mb();
1117        println!(
1118            "[hunt_ram] two FxHashSet<u64> ({} + {} entries): rss delta = {:.1} Mo (rss0={:.1} -> {:.1})",
1119            dup_masters.len(),
1120            masters_with_exact_copy.len(),
1121            rss_two_sets - rss0,
1122            rss0,
1123            rss_two_sets
1124        );
1125        drop(dup_masters);
1126        drop(masters_with_exact_copy);
1127
1128        // ── Scenario B: one combined FxHashMap<u64, bool> ──────────────────
1129        let rss1 = rss_mb();
1130        let mut combined: rustc_hash::FxHashMap<u64, bool> =
1131            rustc_hash::FxHashMap::with_capacity_and_hasher(N_DUP_MASTERS, Default::default());
1132        for i in 0..N_DUP_MASTERS as u64 {
1133            combined.insert(mix(i), i < N_EXACT_COPY as u64);
1134        }
1135        let rss_combined = rss_mb();
1136        println!(
1137            "[hunt_ram] one FxHashMap<u64,bool> ({} entries): rss delta = {:.1} Mo (rss1={:.1} -> {:.1})",
1138            combined.len(),
1139            rss_combined - rss1,
1140            rss1,
1141            rss_combined
1142        );
1143        drop(combined);
1144
1145        let delta_two = rss_two_sets - rss0;
1146        let delta_one = rss_combined - rss1;
1147        println!(
1148            "[hunt_ram] combined map saves {:.1} Mo ({:.1}%) vs two separate sets, at this cardinality",
1149            delta_two - delta_one,
1150            100.0 * (delta_two - delta_one) / delta_two.max(1.0)
1151        );
1152    }
1153}