Skip to main content

dupehell_core/
schema.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::path::Path;
8
9use crate::pipeline::PipelineConfig;
10
11#[derive(serde::Deserialize)]
12pub struct DomainSchema {
13    pub entities: Vec<EntitySchema>,
14    pub hn_types: Vec<HnSchema>,
15}
16
17fn default_entity_weight() -> f64 {
18    1.0
19}
20
21/// Infer an entity's identifier column: prefer `{entity_name}_id`, then
22/// `id`, then the first column ending in `_id`.
23fn infer_identifier_col(entity: &EntitySchema) -> Option<String> {
24    let entity_id_name = format!("{}_id", entity.name);
25    let col_names: Vec<&str> = entity
26        .columns
27        .iter()
28        .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
29        .collect();
30    if col_names.contains(&entity_id_name.as_str()) {
31        Some(entity_id_name)
32    } else if col_names.contains(&"id") {
33        Some("id".to_string())
34    } else {
35        col_names
36            .iter()
37            .find(|n| n.ends_with("_id"))
38            .map(|n| (*n).to_string())
39    }
40}
41
42#[derive(serde::Deserialize)]
43pub struct EntitySchema {
44    pub name: String,
45    pub columns: Vec<serde_json::Value>,
46    #[serde(default)]
47    pub fk_remaps: Vec<serde_json::Value>,
48    /// Relative population size of this entity within the domain, used to
49    /// split `total_unique` identities across entities (`build_pipeline_config`).
50    /// Defaults to `1.0` (every entity gets an equal share) when a schema
51    /// doesn't set it — every schema written before this field existed keeps
52    /// behaving exactly as before. Set explicit weights when entities aren't
53    /// realistically equal in population (e.g. aviation: far more
54    /// `passenger` identities than `airline` ones) — see `docs/weights.csv`
55    /// before picking numbers for a domain you don't know well; a wrong
56    /// weight is as misleading as the uniform default, just in the other
57    /// direction.
58    #[serde(default = "default_entity_weight")]
59    pub weight: f64,
60}
61
62#[derive(serde::Deserialize)]
63pub struct HnSchema {
64    pub entity_type: String,
65    pub config_json: String,
66}
67
68#[derive(Debug, Clone)]
69struct DifficultySettings {
70    singleton: f64,
71    doublet: f64,
72    /// Noise types active for this difficulty, weighted equally. Curated
73    /// explicitly per level (rather than derived from a truncated,
74    /// order-dependent countdown) so that "broad" noise types — `typo` and
75    /// `visual`, which both corrupt name+address+phone+company on the same
76    /// record at once (see `pipeline::noise_type_targets_column`) — never
77    /// end up a *larger* share of the mix at a lower difficulty than at a
78    /// higher one. A prior countdown-weight scheme caused exactly that:
79    /// medium (4 active types) put 70% of its noise budget on typo+visual,
80    /// vs. only ~42% for hell (8 active types), making medium duplicates
81    /// *more* likely to have every strong matching field wiped out at once
82    /// than hell duplicates, despite medium being meant as the easier tier.
83    noise_types: &'static [&'static str],
84    /// Number of *independent* noise passes applied to each duplicate copy
85    /// (see `pipeline::run_pipeline`'s dup-generation loop). Each additional
86    /// pass draws its own noise_type from `noise_types` and applies it on
87    /// top of the previous pass's result.
88    ///
89    /// This is the actual difficulty lever between tiers with different
90    /// `noise_types.len()` — not the category list itself. Each entry's
91    /// per-pass weight is `1 / noise_types.len()` (`build_pipeline_config`
92    /// below), so adding categories to make a tier "harder" *dilutes* every
93    /// existing category's weight, including whichever one happens to guard
94    /// a domain's single most reliable linkage column — a tier with more
95    /// categories active can paradoxically end up *easier* on some schemas
96    /// (measured: a "hard" tier here, folded into hell, kept coming out
97    /// easier than "hell" on several domains despite having fewer
98    /// categories; then a hand-picked category weighted twice fixed 2 of 6
99    /// domains tested but not the rest, since the dominant reliable column
100    /// isn't always the same category across schemas). Passes sidestep this
101    /// entirely: the probability a given column is touched at least once
102    /// across `P` independent passes is `1 - (1 - p)^P` where `p` is its
103    /// single-pass weight — strictly increasing in `P` regardless of how
104    /// small `p` is, so more passes can never make a tier easier the way
105    /// more categories can. `difficulty::estimate_difficulty` models this
106    /// exact same formula.
107    passes: usize,
108}
109
110const DIFFICULTY_MAP: [(&str, DifficultySettings); 3] = [
111    (
112        "light",
113        DifficultySettings {
114            singleton: 0.50,
115            doublet: 0.30,
116            noise_types: &["names", "dates"],
117            passes: 1,
118        },
119    ),
120    (
121        "medium",
122        DifficultySettings {
123            singleton: 0.30,
124            doublet: 0.40,
125            noise_types: &["typo", "names", "dates", "identifiers"],
126            // 3 passes: a column protected by exactly one of medium's 4
127            // equally-weighted categories (weight 1/4 -- e.g. a pure date
128            // column, only matched by "dates", not by "typo"'s broader
129            // predicate) is diluted well below light's worst case (2
130            // categories, weight 1/2). At passes=1 this let medium come out
131            // *easier* than light on 19/40 domains (found via
132            // scripts/validate_all_domains.py). 1 - (1 - 1/4)^3 ≈ 0.578 >
133            // light's single-pass 0.5, closing the gap for any category
134            // medium shares with light regardless of which column ends up
135            // being a given domain's escape hatch. Re-validated across all
136            // 40 domains with the same script after this change.
137            passes: 3,
138        },
139    ),
140    (
141        "hell",
142        DifficultySettings {
143            singleton: 0.10,
144            doublet: 0.20,
145            // `typo_aggressive` corrupts more of each string than plain
146            // `typo` (same target columns, more damage per hit — see
147            // `noise::typos`), and `unicode_pollution` gets its own bucket
148            // (rather than only ever showing up as one of `visual`'s five
149            // random sub-choices).
150            noise_types: &[
151                "typo_aggressive",
152                "visual",
153                "unicode_pollution",
154                "names",
155                "dates",
156                "identifiers",
157                "addresses",
158                "companies",
159                "extra",
160            ],
161            // 8 passes: medium was bumped from 1 to 3 passes to fix its own
162            // dilution against light (see the comment on medium's `passes`
163            // above), which raised medium's worst-case single-category
164            // touch probability to 1-(1-1/4)^3 ≈ 0.578. With 9
165            // equally-weighted categories (weight 1/9 each), hell needs
166            // 1-(1-1/9)^passes >= 0.578 to stay at least as hard as medium
167            // on any column protected by only one category -- solving gives
168            // passes >= 7.33, rounded up to 8 (1-(1-1/9)^8 ≈ 0.617).
169            // Re-validated across all 40 domains via
170            // scripts/validate_all_domains.py (0 crashes, 0 monotonicity
171            // violations) after this change.
172            passes: 8,
173        },
174    ),
175];
176
177pub fn default_singleton_master_fraction(difficulty: &str) -> f64 {
178    difficulty_settings(difficulty).singleton
179}
180
181fn difficulty_settings(difficulty: &str) -> DifficultySettings {
182    DIFFICULTY_MAP
183        .iter()
184        .find(|(name, _)| *name == difficulty)
185        .map(|(_, s)| s.clone())
186        .unwrap_or_else(|| DIFFICULTY_MAP[1].1.clone())
187}
188
189/// The exact set of `--difficulty` / Python `difficulty=` values accepted —
190/// derived from `DIFFICULTY_MAP` so there is one source of truth. The CLI
191/// (`main.rs`) already rejects anything else via `clap::PossibleValuesParser`;
192/// this exists so the PyO3 `generate()` binding (`lib.rs`) can reject an
193/// unrecognized value too instead of silently falling back to `medium` via
194/// `difficulty_settings`'s own fallback above.
195pub fn valid_difficulty_names() -> Vec<&'static str> {
196    DIFFICULTY_MAP.iter().map(|(name, _)| *name).collect()
197}
198
199/// Generate a domain-unique run ID based on the current Unix timestamp (hex).
200pub fn chrono_now() -> String {
201    let start = std::time::SystemTime::now()
202        .duration_since(std::time::UNIX_EPOCH)
203        .unwrap_or_default();
204    format!("{:x}", start.as_secs())
205}
206
207/// Deterministic run ID derived from generation parameters, so the same
208/// (domain, size, seed, difficulty, hard_neg_ratio, singleton_master_fraction,
209/// locale) always produces the same output filename regardless of output
210/// format (IPC vs Parquet) or how many times it's run — and, just as
211/// important, a *different* filename whenever any of these parameters
212/// differ, since they all affect the generated data. `singleton_master_fraction`
213/// and `locale` were missing from this list until this was flagged as a bug:
214/// two runs differing only in `--singleton-master-fraction`
215/// or `--locale` produced the exact same filename, so the second run silently
216/// overwrote the first's output.
217#[allow(clippy::too_many_arguments)]
218pub fn deterministic_run_id(
219    domain: &str,
220    size: usize,
221    seed: u64,
222    difficulty: &str,
223    hard_neg_ratio: f64,
224    singleton_master_fraction: f64,
225    locale: &str,
226    only_entity: Option<&str>,
227    chunk_size: Option<usize>,
228) -> String {
229    // SHA-256 over a fixed encoding, not `DefaultHasher`: std documents
230    // that hasher's algorithm as unspecified and free to change between
231    // Rust releases, which would silently rename every output file after a
232    // toolchain update. Each field is length- or width-delimited so no two
233    // parameter sets share an encoding. `--chunk-size` is part of it: it
234    // doesn't change what the dataset *is* but does change the per-chunk
235    // seeds actually used, so different chunkings must not collide on one
236    // filename.
237    use sha2::{Digest, Sha256};
238    let mut h = Sha256::new();
239    let put_str = |h: &mut Sha256, s: &str| {
240        h.update((s.len() as u64).to_le_bytes());
241        h.update(s.as_bytes());
242    };
243    put_str(&mut h, domain);
244    h.update((size as u64).to_le_bytes());
245    h.update(seed.to_le_bytes());
246    put_str(&mut h, difficulty);
247    h.update(hard_neg_ratio.to_bits().to_le_bytes());
248    h.update(singleton_master_fraction.to_bits().to_le_bytes());
249    put_str(&mut h, locale);
250    put_str(&mut h, only_entity.unwrap_or(""));
251    h.update((chunk_size.unwrap_or(0) as u64).to_le_bytes());
252    let digest = h.finalize();
253    let id = u64::from_be_bytes(digest[..8].try_into().expect("SHA-256 digest is 32 bytes"));
254    format!("{domain}_{id:016x}")
255}
256
257/// Load and parse a domain schema JSON file.
258///
259/// On failure, the error message includes the path attempted and a hint listing
260/// available domains found in the same directory.
261pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
262    let path = schemas_dir.join(format!("{domain}.json"));
263    // Case-sensitive exact match against the actual schema file names, so
264    // "KYC" is rejected the same way on every OS — on a case-insensitive
265    // filesystem (Windows), `read_to_string` alone would silently succeed
266    // for "KYC" via the "kyc.json" file, producing a different run hash
267    // than "kyc" for what the user intended to be the same domain.
268    let available = available_domain_names(schemas_dir);
269    if !available.iter().any(|d| d == domain) {
270        let hint = if available.is_empty() {
271            "no schemas found".to_string()
272        } else {
273            available.join(", ")
274        };
275        return Err(format!(
276            "schema file not found for domain '{domain}' at {path:?}. \
277             Available domains ({hint})"
278        ));
279    }
280    let data =
281        std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
282    serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
283}
284
285/// List available domain names (without .json extension) in a directory.
286fn available_domain_names(dir: &Path) -> Vec<String> {
287    match std::fs::read_dir(dir) {
288        Ok(entries) => entries
289            .filter_map(|e| e.ok())
290            .filter(|e| {
291                e.path()
292                    .extension()
293                    .map(|ext| ext == "json")
294                    .unwrap_or(false)
295            })
296            .filter_map(|e| {
297                e.path()
298                    .file_stem()
299                    .map(|s| s.to_string_lossy().to_string())
300            })
301            .collect(),
302        Err(_) => Vec::new(),
303    }
304}
305
306/// Build a `PipelineConfig` from CLI / Python parameters and a parsed schema.
307///
308/// Validates `size >= 10`, distributes singleton/doublet/triplet records
309/// across entities, and assigns noise weights per difficulty setting.
310#[allow(clippy::too_many_arguments)]
311pub fn build_pipeline_config(
312    domain: &str,
313    size: usize,
314    seed: u64,
315    difficulty: &str,
316    hard_neg_ratio: f64,
317    singleton_master_fraction: f64,
318    schema: &DomainSchema,
319    run_id: &str,
320    output_format: &str,
321    graph_enabled: bool,
322    graph_format: &str,
323    only_entity: Option<&str>,
324) -> Result<PipelineConfig, String> {
325    if size < 10 {
326        return Err(format!("size must be >= 10, got {size}"));
327    }
328    if schema.entities.is_empty() {
329        return Err(format!("schema for domain '{domain}' has no entities"));
330    }
331    if !(0.0..=1.0).contains(&singleton_master_fraction) {
332        return Err(format!(
333            "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
334        ));
335    }
336    if let Some(target) = only_entity
337        && !schema.entities.iter().any(|e| e.name == target)
338    {
339        let available: Vec<&str> = schema.entities.iter().map(|e| e.name.as_str()).collect();
340        return Err(format!(
341            "only_entity '{target}' not found in domain '{domain}'; available entities: {}",
342            available.join(", ")
343        ));
344    }
345    let ds = difficulty_settings(difficulty);
346    let total = size;
347
348    let n_singleton = (total as f64 * singleton_master_fraction) as usize;
349    let n_doublet_float = total as f64 * ds.doublet;
350    let mut n_doublet = n_doublet_float as usize;
351    if !n_doublet.is_multiple_of(2) {
352        n_doublet -= 1;
353    }
354    if n_singleton + n_doublet > total {
355        return Err(format!(
356            "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
357             difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
358            ds.doublet
359        ));
360    }
361    let mut n_triplet = total - n_singleton - n_doublet;
362    let r = n_triplet % 3;
363    if r != 0 {
364        n_triplet -= r;
365    }
366    let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
367    let n_duplicates = total.max(total_unique) - total_unique;
368
369    // `--only-entity`: the weight-based split below runs over this entity
370    // alone, so `total_unique`/`n_duplicates` land on it entirely instead of
371    // being shared out — no separate "recompute the ratio" step needed.
372    // Entities it FK-references get a small identifier-only pool appended
373    // after the main loop (see `pool_only` below); every other entity in
374    // the domain is simply absent from `entity_plans`.
375    let active_entities: Vec<&EntitySchema> = match only_entity {
376        Some(target) => schema
377            .entities
378            .iter()
379            .filter(|e| e.name == target)
380            .collect(),
381        None => schema.entities.iter().collect(),
382    };
383
384    let total_ratio: f64 = active_entities.iter().map(|e| e.weight).sum::<f64>();
385    let raw_floats: Vec<(&str, f64)> = active_entities
386        .iter()
387        .map(|e| {
388            (
389                e.name.as_str(),
390                total_unique as f64 * e.weight / total_ratio,
391            )
392        })
393        .collect();
394
395    // Every entity gets at least 2 identities when the budget allows it
396    // (`r.max(2.0)`), so even a low-weight entity can support a duplicate
397    // pair. But at small `--size` with many entities, `n_entities * 2` can
398    // exceed `total_unique` — the plain largest-remainder distribution below
399    // only ever tops UP to `total_unique` (`need = total_unique.max(floor_sum)
400    // - floor_sum`, clamped to 0), it never tops back down, so the guaranteed
401    // floor silently overshot the requested `--size` (confirmed: `sports`,
402    // 7 entities, `--size 20` produced 66 records instead of 20). Falling
403    // back to a plain `floor()` (no artificial minimum, some entities may
404    // land on 0 and simply produce no rows — already handled by
405    // `if plan.n_base == 0 { continue; }` in pipeline.rs) keeps
406    // `floor_sum <= total_unique` guaranteed in both branches, so the
407    // remainder distribution below still lands on exactly `total_unique`.
408    let n_entities = active_entities.len();
409    let mut floor_map: HashMap<&str, usize> = HashMap::new();
410    if total_unique >= 2 * n_entities {
411        for (name, r) in &raw_floats {
412            floor_map.insert(name, r.max(2.0) as usize);
413        }
414    } else {
415        for (name, r) in &raw_floats {
416            floor_map.insert(name, r.floor() as usize);
417        }
418    }
419    let floor_sum: usize = floor_map.values().sum();
420    let need = total_unique.max(floor_sum) - floor_sum;
421    if need > 0 {
422        let mut remainders: Vec<(&str, f64)> = raw_floats
423            .iter()
424            .map(|(n, r)| (*n, r - r.floor()))
425            .collect();
426        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
427        for (name, _) in remainders.iter().take(need) {
428            *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
429        }
430    }
431    // The 2-identity floor can still overshoot `total_unique` when the
432    // weights are skewed (e.g. 98:1:1 over 100 identities floors to
433    // 98+2+2): the top-up above only ever adds. Give the excess back from
434    // the largest allocations, never below 2 — always possible in that
435    // branch, since `total_unique >= 2 * n_entities`. Without this, a small
436    // --size produced up to ~40% more records than requested.
437    let mut excess = floor_sum.saturating_sub(total_unique);
438    while excess > 0 {
439        let Some((&top, _)) = floor_map
440            .iter()
441            .filter(|&(_, &v)| v > 2)
442            .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
443        else {
444            break;
445        };
446        *floor_map.get_mut(top).expect("key taken from the map") -= 1;
447        excess -= 1;
448    }
449
450    let dup_ratios: Vec<(&str, f64)> = active_entities
451        .iter()
452        .map(|e| {
453            (
454                e.name.as_str(),
455                *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
456            )
457        })
458        .collect();
459    let mut dup_floor: HashMap<&str, usize> = HashMap::new();
460    for (name, r) in &dup_ratios {
461        dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
462    }
463    let dup_sum: usize = dup_floor.values().sum();
464    let dup_need = n_duplicates.max(dup_sum) - dup_sum;
465    if dup_need > 0 {
466        let mut remainders: Vec<(&str, f64)> = dup_ratios
467            .iter()
468            .map(|(n, r)| (*n, r - r.floor()))
469            .collect();
470        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
471        for (name, _) in remainders.iter().take(dup_need) {
472            *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
473        }
474    }
475
476    let noise_count = ds.noise_types.len();
477    let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
478
479    let mut entity_plans = Vec::new();
480    for entity in &active_entities {
481        let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
482        let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
483
484        let mut noise_entries = Vec::new();
485        if n_dup > 0 {
486            let mut counts: Vec<usize> = noise_weights
487                .iter()
488                .map(|w| (w * n_dup as f64) as usize)
489                .collect();
490            let count_sum: usize = counts.iter().sum();
491            if count_sum < n_dup {
492                *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
493            }
494            for (i, noise_type) in ds.noise_types.iter().enumerate() {
495                if counts[i] == 0 {
496                    continue;
497                }
498                noise_entries.push(serde_json::json!({
499                    "noise_type": noise_type,
500                    "columns": [],
501                    "count": counts[i],
502                }));
503            }
504        }
505
506        let identifier_col = infer_identifier_col(entity);
507
508        let columns_json = serde_json::to_string(&entity.columns)
509            .map_err(|e| format!("serialize columns: {e}"))?;
510
511        entity_plans.push(serde_json::json!({
512            "name": entity.name,
513            "n_base": n_base,
514            "n_dup": n_dup,
515            "identifier_col": identifier_col,
516            "columns_json": columns_json,
517            "noise_types": noise_entries,
518            "fk_remaps": entity.fk_remaps,
519        }));
520    }
521
522    // `--only-entity`: entities the target directly references via
523    // `fk_remaps` still need to exist enough to give it something plausible
524    // to point at, without paying for a full-size, fully-noised generation
525    // of an entity the run never writes. Generate just an identifier pool,
526    // capped at `FK_POOL_CAP` (same cap the FK-remap sampler itself already
527    // enforces, so a larger pool would never be consulted anyway).
528    if let Some(target) = only_entity {
529        let target_schema = schema
530            .entities
531            .iter()
532            .find(|e| e.name == target)
533            .expect("only_entity validated to exist above");
534        let mut pool_target_names: Vec<&str> = target_schema
535            .fk_remaps
536            .iter()
537            .filter_map(|r| r.get("target_entity").and_then(|v| v.as_str()))
538            .filter(|&t| t != target)
539            .collect();
540        pool_target_names.sort_unstable();
541        pool_target_names.dedup();
542
543        let pool_n = crate::pipeline::FK_POOL_CAP.min(total_unique.max(2));
544        for name in pool_target_names {
545            let Some(entity) = schema.entities.iter().find(|e| e.name == name) else {
546                continue;
547            };
548            let identifier_col = infer_identifier_col(entity);
549            let columns_json = serde_json::to_string(&entity.columns)
550                .map_err(|e| format!("serialize columns: {e}"))?;
551            entity_plans.push(serde_json::json!({
552                "name": entity.name,
553                "n_base": pool_n,
554                "n_dup": 0,
555                "identifier_col": identifier_col,
556                "columns_json": columns_json,
557                "noise_types": [],
558                "fk_remaps": entity.fk_remaps,
559                "pool_only": true,
560            }));
561        }
562    }
563
564    // Hard negatives are per-entity (`HnSchema::entity_type`) — under
565    // `--only-entity` only the target's own hard-negative types apply; the
566    // rest belong to entities this run no longer writes.
567    let active_hn_types: Vec<&HnSchema> = schema
568        .hn_types
569        .iter()
570        .filter(|hn| only_entity.is_none_or(|t| hn.entity_type == t))
571        .collect();
572
573    // Scaled off `n_duplicates` (which itself scales with the tier's
574    // singleton/doublet fractions — light ~0.28*size, medium ~0.40*size,
575    // hell ~0.57*size) rather than off raw `size`. A flat `size`-based count
576    // makes `total_guaranteed_fp` roughly constant across tiers while
577    // `total_true_pairs` grows a lot with difficulty, which mechanically
578    // inflates `precision_max` for higher tiers regardless of how much
579    // noise they actually inject — enough to invert `light <= medium` on
580    // some schemas (e.g. aviation, where light/medium f1_max came out
581    // 0.828/0.833). The 0.125 constant keeps medium's hard-neg volume
582    // matching the old flat formula (0.40 * 0.125 = 0.05), so
583    // `hard_neg_ratio`'s documented "~1.5% of size at default 0.3" still
584    // holds at medium; light gets proportionally fewer, hell more.
585    let n_hard_neg = (n_duplicates as f64 * hard_neg_ratio * 0.125) as usize;
586    let hn_per_type = n_hard_neg / active_hn_types.len().max(1);
587    let hard_neg_types: Vec<serde_json::Value> = active_hn_types
588        .iter()
589        .map(|hn| {
590            serde_json::json!({
591                "entity_type": hn.entity_type,
592                "config_json": hn.config_json,
593                "count": hn_per_type,
594            })
595        })
596        .collect();
597
598    let config = serde_json::json!({
599        "domain": domain,
600        "size": size,
601        "seed": seed,
602        "difficulty": difficulty,
603        "output_format": output_format,
604        "run_id": run_id,
605        "entity_plans": entity_plans,
606        "hard_neg_types": hard_neg_types,
607        "hard_neg_ratio": hard_neg_ratio,
608        "noise_passes": ds.passes,
609        "graph_enabled": graph_enabled,
610        "graph_format": graph_format,
611    });
612
613    serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    fn schemas_dir() -> std::path::PathBuf {
621        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas")
622    }
623
624    fn kyc_schema() -> DomainSchema {
625        load_schema("kyc", &schemas_dir()).expect("load kyc.json")
626    }
627
628    fn aviation_schema() -> DomainSchema {
629        load_schema("aviation", &schemas_dir()).expect("load aviation.json")
630    }
631
632    fn sports_schema() -> DomainSchema {
633        load_schema("sports", &schemas_dir()).expect("load sports.json")
634    }
635
636    #[test]
637    fn test_valid_difficulty_names() {
638        assert_eq!(valid_difficulty_names(), vec!["light", "medium", "hell"]);
639    }
640
641    #[test]
642    fn test_load_schema_known_domain() {
643        let schema = kyc_schema();
644        assert_eq!(schema.entities.len(), 2);
645        assert!(schema.entities.iter().any(|e| e.name == "natural_person"));
646        assert!(!schema.hn_types.is_empty());
647    }
648
649    #[test]
650    fn test_load_schema_unknown_domain_lists_available() {
651        let err = load_schema("not-a-real-domain", &schemas_dir())
652            .err()
653            .unwrap();
654        assert!(err.contains("not-a-real-domain"));
655        assert!(err.contains("kyc"));
656    }
657
658    #[test]
659    fn test_load_schema_case_sensitive() {
660        // Windows filesystems are case-insensitive; load_schema must still
661        // reject "KYC" so run hashes stay consistent across OSes.
662        let err = load_schema("KYC", &schemas_dir()).err().unwrap();
663        assert!(err.contains("KYC"));
664    }
665
666    #[test]
667    fn test_build_pipeline_config_basic() {
668        let schema = kyc_schema();
669        let config = build_pipeline_config(
670            "kyc", 1000, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
671            None,
672        )
673        .expect("build config");
674        assert_eq!(config.domain, "kyc");
675        assert_eq!(config.size, 1000);
676        assert_eq!(config.entity_plans.len(), 2);
677        // Every entity must get at least the floor of 2 base records.
678        assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
679        assert!(!config.hard_neg_types.is_empty());
680    }
681
682    #[test]
683    fn test_build_pipeline_config_small_size_many_entities_does_not_overshoot() {
684        // Regression: `sports` has 7 entities. Before the fix, the
685        // per-entity floor of 2 (`max(2.0)`) was never scaled back down
686        // when `2 * n_entities > total_unique`, so a tiny `--size` produced
687        // far more base rows than requested (confirmed via real CLI run:
688        // `--size 20 --difficulty hell` generated 66 base+dup rows total —
689        // `total_unique` alone, the sum of n_base here, was already
690        // overshooting `size` before duplicates were even added).
691        let schema = sports_schema();
692        assert_eq!(
693            schema.entities.len(),
694            7,
695            "this test assumes sports has 7 entities"
696        );
697        let size = 20;
698        let config = build_pipeline_config(
699            "sports",
700            size,
701            42,
702            "hell",
703            0.1,
704            0.10,
705            &schema,
706            "sports_test",
707            "parquet",
708            false,
709            "parquet",
710            None,
711        )
712        .expect("build config");
713        let n_base_sum: usize = config.entity_plans.iter().map(|p| p.n_base).sum();
714        // `total_unique <= size` always holds by construction (see
715        // `build_pipeline_config`), and `n_base_sum` must equal
716        // `total_unique` exactly — never exceed `size`.
717        assert!(
718            n_base_sum <= size,
719            "n_base sum {n_base_sum} overshoots requested size {size}"
720        );
721    }
722
723    #[test]
724    fn test_build_pipeline_config_rows_match_size_on_every_schema() {
725        // Regression: skewed entity weights made the 2-identity floor
726        // overshoot `--size` at small sizes (e.g. sports --size 30 wrote 43
727        // base+duplicate rows).
728        let mut checked = 0;
729        for entry in std::fs::read_dir(schemas_dir()).unwrap() {
730            let path = entry.unwrap().path();
731            if path.extension().is_none_or(|e| e != "json") {
732                continue;
733            }
734            let domain = path.file_stem().unwrap().to_string_lossy().to_string();
735            let schema = load_schema(&domain, &schemas_dir()).unwrap();
736            for difficulty in valid_difficulty_names() {
737                for size in [10, 11, 20, 30, 57, 100, 333, 1000] {
738                    let config = build_pipeline_config(
739                        &domain,
740                        size,
741                        42,
742                        difficulty,
743                        0.3,
744                        default_singleton_master_fraction(difficulty),
745                        &schema,
746                        "t",
747                        "parquet",
748                        false,
749                        "parquet",
750                        None,
751                    )
752                    .unwrap();
753                    let rows: usize = config
754                        .entity_plans
755                        .iter()
756                        .map(|p| p.n_base + p.noise_types.iter().map(|n| n.count).sum::<usize>())
757                        .sum();
758                    assert_eq!(rows, size, "{domain} {difficulty} --size {size}");
759                    checked += 1;
760                }
761            }
762        }
763        assert!(checked > 0);
764    }
765
766    #[test]
767    fn test_build_pipeline_config_normal_size_still_floors_at_2() {
768        // Non-regression: at a realistic size, every entity should still
769        // get at least its floor of 2 base rows (unchanged from before).
770        let schema = sports_schema();
771        let config = build_pipeline_config(
772            "sports",
773            100_000,
774            42,
775            "hell",
776            0.1,
777            0.10,
778            &schema,
779            "sports_test2",
780            "parquet",
781            false,
782            "parquet",
783            None,
784        )
785        .expect("build config");
786        assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
787    }
788
789    #[test]
790    fn test_build_pipeline_config_rejects_size_below_10() {
791        let schema = kyc_schema();
792        let err = build_pipeline_config(
793            "kyc", 5, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
794            None,
795        )
796        .unwrap_err();
797        assert!(err.contains("size must be >= 10"));
798    }
799
800    #[test]
801    fn test_build_pipeline_config_rejects_invalid_singleton_fraction() {
802        let schema = kyc_schema();
803        let err = build_pipeline_config(
804            "kyc", 1000, 42, "medium", 0.1, 1.5, &schema, "kyc_test", "parquet", false, "parquet",
805            None,
806        )
807        .unwrap_err();
808        assert!(err.contains("singleton_master_fraction"));
809    }
810
811    #[test]
812    fn test_build_pipeline_config_deterministic() {
813        let schema = kyc_schema();
814        let build = || {
815            build_pipeline_config(
816                "kyc", 1000, 42, "hell", 0.1, 0.2, &schema, "kyc_test", "parquet", false,
817                "parquet", None,
818            )
819            .expect("build config")
820        };
821        let a = build();
822        let b = build();
823        assert_eq!(a.entity_plans.len(), b.entity_plans.len());
824        for (pa, pb) in a.entity_plans.iter().zip(b.entity_plans.iter()) {
825            assert_eq!(pa.n_base, pb.n_base);
826            assert_eq!(pa.noise_types.len(), pb.noise_types.len());
827        }
828    }
829
830    /// No-FK case (kyc's two entities
831    /// aren't FK-linked): `only_entity` should produce exactly one entity
832    /// plan, sized to the full `size`, with no pool-only entities appended.
833    #[test]
834    fn test_build_pipeline_config_only_entity_no_fk() {
835        let schema = kyc_schema();
836        let config = build_pipeline_config(
837            "kyc",
838            1000,
839            42,
840            "medium",
841            0.1,
842            0.3,
843            &schema,
844            "kyc_test",
845            "parquet",
846            false,
847            "parquet",
848            Some("natural_person"),
849        )
850        .expect("build config");
851        assert_eq!(config.entity_plans.len(), 1);
852        let plan = &config.entity_plans[0];
853        assert_eq!(plan.name, "natural_person");
854        assert!(!plan.pool_only);
855        // With only one active entity, all of `total_unique` (there's no
856        // weight split to share it with `legal_entity`) lands on it —
857        // comfortably more than the usual weight-shared slice would be.
858        assert!(plan.n_base > 100);
859        assert!(
860            config
861                .hard_neg_types
862                .iter()
863                .all(|hn| hn.entity_type == "natural_person")
864        );
865    }
866
867    /// FK case: aviation's `passenger` entity has no direct `fk_remaps` of
868    /// its own (verified against `schemas/aviation.json`), so `only_entity`
869    /// on it should still produce a single plan and no pool-only entities.
870    /// `flight`, which DOES reference `airline`/`aircraft`, is the case
871    /// that appends pool-only plans — covered by asserting the mechanism
872    /// generically below via `aircraft` (references `airline`).
873    #[test]
874    fn test_build_pipeline_config_only_entity_with_fk_pool() {
875        let schema = aviation_schema();
876        let config = build_pipeline_config(
877            "aviation",
878            10_000,
879            42,
880            "medium",
881            0.1,
882            0.3,
883            &schema,
884            "aviation_test",
885            "parquet",
886            false,
887            "parquet",
888            Some("aircraft"),
889        )
890        .expect("build config");
891        // aircraft (target, written) + airline (pool-only, FK target).
892        assert_eq!(config.entity_plans.len(), 2);
893        let target = config
894            .entity_plans
895            .iter()
896            .find(|p| p.name == "aircraft")
897            .expect("aircraft plan present");
898        assert!(!target.pool_only);
899        assert!(target.n_base > 1_000);
900        let pool = config
901            .entity_plans
902            .iter()
903            .find(|p| p.name == "airline")
904            .expect("airline pool-only plan present");
905        assert!(pool.pool_only);
906        assert!(pool.noise_types.is_empty());
907        // Pool-only entity is capped, not sized to the full run.
908        assert!(pool.n_base <= crate::pipeline::FK_POOL_CAP);
909    }
910
911    #[test]
912    fn test_build_pipeline_config_only_entity_unknown_name() {
913        let schema = kyc_schema();
914        let err = build_pipeline_config(
915            "kyc",
916            1000,
917            42,
918            "medium",
919            0.1,
920            0.3,
921            &schema,
922            "kyc_test",
923            "parquet",
924            false,
925            "parquet",
926            Some("not_a_real_entity"),
927        )
928        .unwrap_err();
929        assert!(err.contains("not_a_real_entity"));
930        assert!(err.contains("natural_person"));
931    }
932
933    #[test]
934    fn test_deterministic_run_id_stable_and_sensitive() {
935        let a = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
936        let b = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
937        assert_eq!(a, b);
938        let c = deterministic_run_id("kyc", 1000, 43, "medium", 0.1, 0.3, "en", None, None);
939        assert_ne!(a, c);
940    }
941
942    /// Regression: `singleton_master_fraction` and
943    /// `locale` weren't hashed into the run ID, so two runs differing only
944    /// in one of these parameters (but producing genuinely different data)
945    /// got the exact same output filename, silently overwriting each other.
946    #[test]
947    fn test_deterministic_run_id_sensitive_to_singleton_fraction_and_locale() {
948        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
949        let diff_fraction =
950            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.5, "en", None, None);
951        let diff_locale =
952            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "fr", None, None);
953        assert_ne!(base, diff_fraction);
954        assert_ne!(base, diff_locale);
955        assert_ne!(diff_fraction, diff_locale);
956    }
957
958    /// Regression guard for the `only_entity` hash input added alongside
959    /// `--only-entity`: two runs differing only in which entity is targeted
960    /// must not collide on the same run id (same class of bug as the
961    /// `singleton_master_fraction`/`locale` collision above).
962    #[test]
963    fn test_deterministic_run_id_sensitive_to_only_entity() {
964        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
965        let only_a = deterministic_run_id(
966            "kyc",
967            1000,
968            42,
969            "medium",
970            0.1,
971            0.3,
972            "en",
973            Some("natural_person"),
974            None,
975        );
976        let only_b = deterministic_run_id(
977            "kyc",
978            1000,
979            42,
980            "medium",
981            0.1,
982            0.3,
983            "en",
984            Some("legal_entity"),
985            None,
986        );
987        assert_ne!(base, only_a);
988        assert_ne!(base, only_b);
989        assert_ne!(only_a, only_b);
990    }
991
992    /// Regression guard for the `chunk_size` hash input added alongside
993    /// `--chunk-size`: two runs with the same total size but a different
994    /// chunking must not collide on the same run id, since the per-chunk
995    /// seed sequence actually used internally differs (same class of bug
996    /// as the `singleton_master_fraction`/`locale` collision above).
997    #[test]
998    fn test_deterministic_run_id_sensitive_to_chunk_size() {
999        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
1000        let chunked_a =
1001            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(500));
1002        let chunked_b =
1003            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(250));
1004        assert_ne!(base, chunked_a);
1005        assert_ne!(base, chunked_b);
1006        assert_ne!(chunked_a, chunked_b);
1007    }
1008
1009    #[test]
1010    fn test_default_singleton_master_fraction_known_and_unknown() {
1011        assert_eq!(default_singleton_master_fraction("light"), 0.50);
1012        assert_eq!(default_singleton_master_fraction("hell"), 0.10);
1013        // Unknown difficulty falls back to "medium".
1014        assert_eq!(
1015            default_singleton_master_fraction("bogus"),
1016            default_singleton_master_fraction("medium")
1017        );
1018    }
1019}