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/` or ask
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/// (BUGS.md C14/C15): 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    use std::hash::{Hash, Hasher};
230    let mut hasher = std::collections::hash_map::DefaultHasher::new();
231    domain.hash(&mut hasher);
232    size.hash(&mut hasher);
233    seed.hash(&mut hasher);
234    difficulty.hash(&mut hasher);
235    hard_neg_ratio.to_bits().hash(&mut hasher);
236    singleton_master_fraction.to_bits().hash(&mut hasher);
237    locale.hash(&mut hasher);
238    // BUGS.md C14/C15: every parameter that changes the output must be
239    // hashed in, or two runs differing only in this one collide on the same
240    // filename and silently overwrite each other.
241    only_entity.unwrap_or("").hash(&mut hasher);
242    // `--chunk-size` doesn't change the final dataset's *content* (chunked
243    // and non-chunked runs of the same total size are meant to be
244    // equivalent data, modulo per-chunk RNG independence) but it DOES
245    // change the sequence of per-chunk seeds actually used internally
246    // (`seed + chunk_idx` per chunk vs a single unbroken `seed`), so two
247    // runs with the same total size/seed but a different chunking must
248    // never collide on the same output filename.
249    chunk_size.unwrap_or(0).hash(&mut hasher);
250    format!("{}_{:x}", domain, hasher.finish())
251}
252
253/// Load and parse a domain schema JSON file.
254///
255/// On failure, the error message includes the path attempted and a hint listing
256/// available domains found in the same directory.
257pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
258    let path = schemas_dir.join(format!("{domain}.json"));
259    // Case-sensitive exact match against the actual schema file names, so
260    // "KYC" is rejected the same way on every OS — on a case-insensitive
261    // filesystem (Windows), `read_to_string` alone would silently succeed
262    // for "KYC" via the "kyc.json" file, producing a different run hash
263    // than "kyc" for what the user intended to be the same domain.
264    let available = available_domain_names(schemas_dir);
265    if !available.iter().any(|d| d == domain) {
266        let hint = if available.is_empty() {
267            "no schemas found".to_string()
268        } else {
269            available.join(", ")
270        };
271        return Err(format!(
272            "schema file not found for domain '{domain}' at {path:?}. \
273             Available domains ({hint})"
274        ));
275    }
276    let data =
277        std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
278    serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
279}
280
281/// List available domain names (without .json extension) in a directory.
282fn available_domain_names(dir: &Path) -> Vec<String> {
283    match std::fs::read_dir(dir) {
284        Ok(entries) => entries
285            .filter_map(|e| e.ok())
286            .filter(|e| {
287                e.path()
288                    .extension()
289                    .map(|ext| ext == "json")
290                    .unwrap_or(false)
291            })
292            .filter_map(|e| {
293                e.path()
294                    .file_stem()
295                    .map(|s| s.to_string_lossy().to_string())
296            })
297            .collect(),
298        Err(_) => Vec::new(),
299    }
300}
301
302/// Build a `PipelineConfig` from CLI / Python parameters and a parsed schema.
303///
304/// Validates `size >= 10`, distributes singleton/doublet/triplet records
305/// across entities, and assigns noise weights per difficulty setting.
306#[allow(clippy::too_many_arguments)]
307pub fn build_pipeline_config(
308    domain: &str,
309    size: usize,
310    seed: u64,
311    difficulty: &str,
312    hard_neg_ratio: f64,
313    singleton_master_fraction: f64,
314    schema: &DomainSchema,
315    run_id: &str,
316    output_format: &str,
317    graph_enabled: bool,
318    graph_format: &str,
319    only_entity: Option<&str>,
320) -> Result<PipelineConfig, String> {
321    if size < 10 {
322        return Err(format!("size must be >= 10, got {size}"));
323    }
324    if schema.entities.is_empty() {
325        return Err(format!("schema for domain '{domain}' has no entities"));
326    }
327    if !(0.0..=1.0).contains(&singleton_master_fraction) {
328        return Err(format!(
329            "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
330        ));
331    }
332    if let Some(target) = only_entity
333        && !schema.entities.iter().any(|e| e.name == target)
334    {
335        let available: Vec<&str> = schema.entities.iter().map(|e| e.name.as_str()).collect();
336        return Err(format!(
337            "only_entity '{target}' not found in domain '{domain}'; available entities: {}",
338            available.join(", ")
339        ));
340    }
341    let ds = difficulty_settings(difficulty);
342    let total = size;
343
344    let n_singleton = (total as f64 * singleton_master_fraction) as usize;
345    let n_doublet_float = total as f64 * ds.doublet;
346    let mut n_doublet = n_doublet_float as usize;
347    if !n_doublet.is_multiple_of(2) {
348        n_doublet -= 1;
349    }
350    if n_singleton + n_doublet > total {
351        return Err(format!(
352            "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
353             difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
354            ds.doublet
355        ));
356    }
357    let mut n_triplet = total - n_singleton - n_doublet;
358    let r = n_triplet % 3;
359    if r != 0 {
360        n_triplet -= r;
361    }
362    let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
363    let n_duplicates = total.max(total_unique) - total_unique;
364
365    // `--only-entity`: the weight-based split below runs over this entity
366    // alone, so `total_unique`/`n_duplicates` land on it entirely instead of
367    // being shared out — no separate "recompute the ratio" step needed.
368    // Entities it FK-references get a small identifier-only pool appended
369    // after the main loop (see `pool_only` below); every other entity in
370    // the domain is simply absent from `entity_plans`.
371    let active_entities: Vec<&EntitySchema> = match only_entity {
372        Some(target) => schema
373            .entities
374            .iter()
375            .filter(|e| e.name == target)
376            .collect(),
377        None => schema.entities.iter().collect(),
378    };
379
380    let total_ratio: f64 = active_entities.iter().map(|e| e.weight).sum::<f64>();
381    let raw_floats: Vec<(&str, f64)> = active_entities
382        .iter()
383        .map(|e| {
384            (
385                e.name.as_str(),
386                total_unique as f64 * e.weight / total_ratio,
387            )
388        })
389        .collect();
390
391    // Every entity gets at least 2 identities when the budget allows it
392    // (`r.max(2.0)`), so even a low-weight entity can support a duplicate
393    // pair. But at small `--size` with many entities, `n_entities * 2` can
394    // exceed `total_unique` — the plain largest-remainder distribution below
395    // only ever tops UP to `total_unique` (`need = total_unique.max(floor_sum)
396    // - floor_sum`, clamped to 0), it never tops back down, so the guaranteed
397    // floor silently overshot the requested `--size` (confirmed: `sports`,
398    // 7 entities, `--size 20` produced 66 records instead of 20). Falling
399    // back to a plain `floor()` (no artificial minimum, some entities may
400    // land on 0 and simply produce no rows — already handled by
401    // `if plan.n_base == 0 { continue; }` in pipeline.rs) keeps
402    // `floor_sum <= total_unique` guaranteed in both branches, so the
403    // remainder distribution below still lands on exactly `total_unique`.
404    let n_entities = active_entities.len();
405    let mut floor_map: HashMap<&str, usize> = HashMap::new();
406    if total_unique >= 2 * n_entities {
407        for (name, r) in &raw_floats {
408            floor_map.insert(name, r.max(2.0) as usize);
409        }
410    } else {
411        for (name, r) in &raw_floats {
412            floor_map.insert(name, r.floor() as usize);
413        }
414    }
415    let floor_sum: usize = floor_map.values().sum();
416    let need = total_unique.max(floor_sum) - floor_sum;
417    if need > 0 {
418        let mut remainders: Vec<(&str, f64)> = raw_floats
419            .iter()
420            .map(|(n, r)| (*n, r - r.floor()))
421            .collect();
422        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
423        for (name, _) in remainders.iter().take(need) {
424            *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
425        }
426    }
427
428    let dup_ratios: Vec<(&str, f64)> = active_entities
429        .iter()
430        .map(|e| {
431            (
432                e.name.as_str(),
433                *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
434            )
435        })
436        .collect();
437    let mut dup_floor: HashMap<&str, usize> = HashMap::new();
438    for (name, r) in &dup_ratios {
439        dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
440    }
441    let dup_sum: usize = dup_floor.values().sum();
442    let dup_need = n_duplicates.max(dup_sum) - dup_sum;
443    if dup_need > 0 {
444        let mut remainders: Vec<(&str, f64)> = dup_ratios
445            .iter()
446            .map(|(n, r)| (*n, r - r.floor()))
447            .collect();
448        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
449        for (name, _) in remainders.iter().take(dup_need) {
450            *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
451        }
452    }
453
454    let noise_count = ds.noise_types.len();
455    let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
456
457    let mut entity_plans = Vec::new();
458    for entity in &active_entities {
459        let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
460        let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
461
462        let mut noise_entries = Vec::new();
463        if n_dup > 0 {
464            let mut counts: Vec<usize> = noise_weights
465                .iter()
466                .map(|w| (w * n_dup as f64) as usize)
467                .collect();
468            let count_sum: usize = counts.iter().sum();
469            if count_sum < n_dup {
470                *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
471            }
472            for (i, noise_type) in ds.noise_types.iter().enumerate() {
473                if counts[i] == 0 {
474                    continue;
475                }
476                noise_entries.push(serde_json::json!({
477                    "noise_type": noise_type,
478                    "columns": [],
479                    "count": counts[i],
480                }));
481            }
482        }
483
484        let identifier_col = infer_identifier_col(entity);
485
486        let columns_json = serde_json::to_string(&entity.columns)
487            .map_err(|e| format!("serialize columns: {e}"))?;
488
489        entity_plans.push(serde_json::json!({
490            "name": entity.name,
491            "n_base": n_base,
492            "n_dup": n_dup,
493            "identifier_col": identifier_col,
494            "columns_json": columns_json,
495            "noise_types": noise_entries,
496            "fk_remaps": entity.fk_remaps,
497        }));
498    }
499
500    // `--only-entity`: entities the target directly references via
501    // `fk_remaps` still need to exist enough to give it something plausible
502    // to point at, without paying for a full-size, fully-noised generation
503    // of an entity the run never writes. Generate just an identifier pool,
504    // capped at `FK_POOL_CAP` (same cap the FK-remap sampler itself already
505    // enforces, so a larger pool would never be consulted anyway).
506    if let Some(target) = only_entity {
507        let target_schema = schema
508            .entities
509            .iter()
510            .find(|e| e.name == target)
511            .expect("only_entity validated to exist above");
512        let mut pool_target_names: Vec<&str> = target_schema
513            .fk_remaps
514            .iter()
515            .filter_map(|r| r.get("target_entity").and_then(|v| v.as_str()))
516            .filter(|&t| t != target)
517            .collect();
518        pool_target_names.sort_unstable();
519        pool_target_names.dedup();
520
521        let pool_n = crate::pipeline::FK_POOL_CAP.min(total_unique.max(2));
522        for name in pool_target_names {
523            let Some(entity) = schema.entities.iter().find(|e| e.name == name) else {
524                continue;
525            };
526            let identifier_col = infer_identifier_col(entity);
527            let columns_json = serde_json::to_string(&entity.columns)
528                .map_err(|e| format!("serialize columns: {e}"))?;
529            entity_plans.push(serde_json::json!({
530                "name": entity.name,
531                "n_base": pool_n,
532                "n_dup": 0,
533                "identifier_col": identifier_col,
534                "columns_json": columns_json,
535                "noise_types": [],
536                "fk_remaps": entity.fk_remaps,
537                "pool_only": true,
538            }));
539        }
540    }
541
542    // Hard negatives are per-entity (`HnSchema::entity_type`) — under
543    // `--only-entity` only the target's own hard-negative types apply; the
544    // rest belong to entities this run no longer writes.
545    let active_hn_types: Vec<&HnSchema> = schema
546        .hn_types
547        .iter()
548        .filter(|hn| only_entity.is_none_or(|t| hn.entity_type == t))
549        .collect();
550
551    // Scaled off `n_duplicates` (which itself scales with the tier's
552    // singleton/doublet fractions — light ~0.28*size, medium ~0.40*size,
553    // hell ~0.57*size) rather than off raw `size`. A flat `size`-based count
554    // makes `total_guaranteed_fp` roughly constant across tiers while
555    // `total_true_pairs` grows a lot with difficulty, which mechanically
556    // inflates `precision_max` for higher tiers regardless of how much
557    // noise they actually inject — enough to invert `light <= medium` on
558    // some schemas (e.g. aviation, where light/medium f1_max came out
559    // 0.828/0.833). The 0.125 constant keeps medium's hard-neg volume
560    // matching the old flat formula (0.40 * 0.125 = 0.05), so
561    // `hard_neg_ratio`'s documented "~1.5% of size at default 0.3" still
562    // holds at medium; light gets proportionally fewer, hell more.
563    let n_hard_neg = (n_duplicates as f64 * hard_neg_ratio * 0.125) as usize;
564    let hn_per_type = n_hard_neg / active_hn_types.len().max(1);
565    let hard_neg_types: Vec<serde_json::Value> = active_hn_types
566        .iter()
567        .map(|hn| {
568            serde_json::json!({
569                "entity_type": hn.entity_type,
570                "config_json": hn.config_json,
571                "count": hn_per_type,
572            })
573        })
574        .collect();
575
576    let config = serde_json::json!({
577        "domain": domain,
578        "size": size,
579        "seed": seed,
580        "difficulty": difficulty,
581        "output_format": output_format,
582        "run_id": run_id,
583        "entity_plans": entity_plans,
584        "hard_neg_types": hard_neg_types,
585        "hard_neg_ratio": hard_neg_ratio,
586        "noise_passes": ds.passes,
587        "graph_enabled": graph_enabled,
588        "graph_format": graph_format,
589    });
590
591    serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn schemas_dir() -> std::path::PathBuf {
599        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas")
600    }
601
602    fn kyc_schema() -> DomainSchema {
603        load_schema("kyc", &schemas_dir()).expect("load kyc.json")
604    }
605
606    fn aviation_schema() -> DomainSchema {
607        load_schema("aviation", &schemas_dir()).expect("load aviation.json")
608    }
609
610    fn sports_schema() -> DomainSchema {
611        load_schema("sports", &schemas_dir()).expect("load sports.json")
612    }
613
614    #[test]
615    fn test_valid_difficulty_names() {
616        assert_eq!(valid_difficulty_names(), vec!["light", "medium", "hell"]);
617    }
618
619    #[test]
620    fn test_load_schema_known_domain() {
621        let schema = kyc_schema();
622        assert_eq!(schema.entities.len(), 2);
623        assert!(schema.entities.iter().any(|e| e.name == "natural_person"));
624        assert!(!schema.hn_types.is_empty());
625    }
626
627    #[test]
628    fn test_load_schema_unknown_domain_lists_available() {
629        let err = load_schema("not-a-real-domain", &schemas_dir())
630            .err()
631            .unwrap();
632        assert!(err.contains("not-a-real-domain"));
633        assert!(err.contains("kyc"));
634    }
635
636    #[test]
637    fn test_load_schema_case_sensitive() {
638        // Windows filesystems are case-insensitive; load_schema must still
639        // reject "KYC" so run hashes stay consistent across OSes.
640        let err = load_schema("KYC", &schemas_dir()).err().unwrap();
641        assert!(err.contains("KYC"));
642    }
643
644    #[test]
645    fn test_build_pipeline_config_basic() {
646        let schema = kyc_schema();
647        let config = build_pipeline_config(
648            "kyc", 1000, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
649            None,
650        )
651        .expect("build config");
652        assert_eq!(config.domain, "kyc");
653        assert_eq!(config.size, 1000);
654        assert_eq!(config.entity_plans.len(), 2);
655        // Every entity must get at least the floor of 2 base records.
656        assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
657        assert!(!config.hard_neg_types.is_empty());
658    }
659
660    #[test]
661    fn test_build_pipeline_config_small_size_many_entities_does_not_overshoot() {
662        // Regression: `sports` has 7 entities. Before the fix, the
663        // per-entity floor of 2 (`max(2.0)`) was never scaled back down
664        // when `2 * n_entities > total_unique`, so a tiny `--size` produced
665        // far more base rows than requested (confirmed via real CLI run:
666        // `--size 20 --difficulty hell` generated 66 base+dup rows total —
667        // `total_unique` alone, the sum of n_base here, was already
668        // overshooting `size` before duplicates were even added).
669        let schema = sports_schema();
670        assert_eq!(
671            schema.entities.len(),
672            7,
673            "this test assumes sports has 7 entities"
674        );
675        let size = 20;
676        let config = build_pipeline_config(
677            "sports",
678            size,
679            42,
680            "hell",
681            0.1,
682            0.10,
683            &schema,
684            "sports_test",
685            "parquet",
686            false,
687            "parquet",
688            None,
689        )
690        .expect("build config");
691        let n_base_sum: usize = config.entity_plans.iter().map(|p| p.n_base).sum();
692        // `total_unique <= size` always holds by construction (see
693        // `build_pipeline_config`), and `n_base_sum` must equal
694        // `total_unique` exactly — never exceed `size`.
695        assert!(
696            n_base_sum <= size,
697            "n_base sum {n_base_sum} overshoots requested size {size}"
698        );
699    }
700
701    #[test]
702    fn test_build_pipeline_config_normal_size_still_floors_at_2() {
703        // Non-regression: at a realistic size, every entity should still
704        // get at least its floor of 2 base rows (unchanged from before).
705        let schema = sports_schema();
706        let config = build_pipeline_config(
707            "sports",
708            100_000,
709            42,
710            "hell",
711            0.1,
712            0.10,
713            &schema,
714            "sports_test2",
715            "parquet",
716            false,
717            "parquet",
718            None,
719        )
720        .expect("build config");
721        assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
722    }
723
724    #[test]
725    fn test_build_pipeline_config_rejects_size_below_10() {
726        let schema = kyc_schema();
727        let err = build_pipeline_config(
728            "kyc", 5, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
729            None,
730        )
731        .unwrap_err();
732        assert!(err.contains("size must be >= 10"));
733    }
734
735    #[test]
736    fn test_build_pipeline_config_rejects_invalid_singleton_fraction() {
737        let schema = kyc_schema();
738        let err = build_pipeline_config(
739            "kyc", 1000, 42, "medium", 0.1, 1.5, &schema, "kyc_test", "parquet", false, "parquet",
740            None,
741        )
742        .unwrap_err();
743        assert!(err.contains("singleton_master_fraction"));
744    }
745
746    #[test]
747    fn test_build_pipeline_config_deterministic() {
748        let schema = kyc_schema();
749        let build = || {
750            build_pipeline_config(
751                "kyc", 1000, 42, "hell", 0.1, 0.2, &schema, "kyc_test", "parquet", false,
752                "parquet", None,
753            )
754            .expect("build config")
755        };
756        let a = build();
757        let b = build();
758        assert_eq!(a.entity_plans.len(), b.entity_plans.len());
759        for (pa, pb) in a.entity_plans.iter().zip(b.entity_plans.iter()) {
760            assert_eq!(pa.n_base, pb.n_base);
761            assert_eq!(pa.noise_types.len(), pb.noise_types.len());
762        }
763    }
764
765    /// No-FK case (BUGS.md-style regression target, kyc's two entities
766    /// aren't FK-linked): `only_entity` should produce exactly one entity
767    /// plan, sized to the full `size`, with no pool-only entities appended.
768    #[test]
769    fn test_build_pipeline_config_only_entity_no_fk() {
770        let schema = kyc_schema();
771        let config = build_pipeline_config(
772            "kyc",
773            1000,
774            42,
775            "medium",
776            0.1,
777            0.3,
778            &schema,
779            "kyc_test",
780            "parquet",
781            false,
782            "parquet",
783            Some("natural_person"),
784        )
785        .expect("build config");
786        assert_eq!(config.entity_plans.len(), 1);
787        let plan = &config.entity_plans[0];
788        assert_eq!(plan.name, "natural_person");
789        assert!(!plan.pool_only);
790        // With only one active entity, all of `total_unique` (there's no
791        // weight split to share it with `legal_entity`) lands on it —
792        // comfortably more than the usual weight-shared slice would be.
793        assert!(plan.n_base > 100);
794        assert!(
795            config
796                .hard_neg_types
797                .iter()
798                .all(|hn| hn.entity_type == "natural_person")
799        );
800    }
801
802    /// FK case: aviation's `passenger` entity has no direct `fk_remaps` of
803    /// its own (verified against `schemas/aviation.json`), so `only_entity`
804    /// on it should still produce a single plan and no pool-only entities.
805    /// `flight`, which DOES reference `airline`/`aircraft`, is the case
806    /// that appends pool-only plans — covered by asserting the mechanism
807    /// generically below via `aircraft` (references `airline`).
808    #[test]
809    fn test_build_pipeline_config_only_entity_with_fk_pool() {
810        let schema = aviation_schema();
811        let config = build_pipeline_config(
812            "aviation",
813            10_000,
814            42,
815            "medium",
816            0.1,
817            0.3,
818            &schema,
819            "aviation_test",
820            "parquet",
821            false,
822            "parquet",
823            Some("aircraft"),
824        )
825        .expect("build config");
826        // aircraft (target, written) + airline (pool-only, FK target).
827        assert_eq!(config.entity_plans.len(), 2);
828        let target = config
829            .entity_plans
830            .iter()
831            .find(|p| p.name == "aircraft")
832            .expect("aircraft plan present");
833        assert!(!target.pool_only);
834        assert!(target.n_base > 1_000);
835        let pool = config
836            .entity_plans
837            .iter()
838            .find(|p| p.name == "airline")
839            .expect("airline pool-only plan present");
840        assert!(pool.pool_only);
841        assert!(pool.noise_types.is_empty());
842        // Pool-only entity is capped, not sized to the full run.
843        assert!(pool.n_base <= crate::pipeline::FK_POOL_CAP);
844    }
845
846    #[test]
847    fn test_build_pipeline_config_only_entity_unknown_name() {
848        let schema = kyc_schema();
849        let err = build_pipeline_config(
850            "kyc",
851            1000,
852            42,
853            "medium",
854            0.1,
855            0.3,
856            &schema,
857            "kyc_test",
858            "parquet",
859            false,
860            "parquet",
861            Some("not_a_real_entity"),
862        )
863        .unwrap_err();
864        assert!(err.contains("not_a_real_entity"));
865        assert!(err.contains("natural_person"));
866    }
867
868    #[test]
869    fn test_deterministic_run_id_stable_and_sensitive() {
870        let a = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
871        let b = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
872        assert_eq!(a, b);
873        let c = deterministic_run_id("kyc", 1000, 43, "medium", 0.1, 0.3, "en", None, None);
874        assert_ne!(a, c);
875    }
876
877    /// Regression: BUGS.md C14/C15 — `singleton_master_fraction` and
878    /// `locale` weren't hashed into the run ID, so two runs differing only
879    /// in one of these parameters (but producing genuinely different data)
880    /// got the exact same output filename, silently overwriting each other.
881    #[test]
882    fn test_deterministic_run_id_sensitive_to_singleton_fraction_and_locale() {
883        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
884        let diff_fraction =
885            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.5, "en", None, None);
886        let diff_locale =
887            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "fr", None, None);
888        assert_ne!(base, diff_fraction);
889        assert_ne!(base, diff_locale);
890        assert_ne!(diff_fraction, diff_locale);
891    }
892
893    /// Regression guard for the `only_entity` hash input added alongside
894    /// `--only-entity`: two runs differing only in which entity is targeted
895    /// must not collide on the same run id (same class of bug as C14/C15).
896    #[test]
897    fn test_deterministic_run_id_sensitive_to_only_entity() {
898        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
899        let only_a = deterministic_run_id(
900            "kyc",
901            1000,
902            42,
903            "medium",
904            0.1,
905            0.3,
906            "en",
907            Some("natural_person"),
908            None,
909        );
910        let only_b = deterministic_run_id(
911            "kyc",
912            1000,
913            42,
914            "medium",
915            0.1,
916            0.3,
917            "en",
918            Some("legal_entity"),
919            None,
920        );
921        assert_ne!(base, only_a);
922        assert_ne!(base, only_b);
923        assert_ne!(only_a, only_b);
924    }
925
926    /// Regression guard for the `chunk_size` hash input added alongside
927    /// `--chunk-size`: two runs with the same total size but a different
928    /// chunking must not collide on the same run id, since the per-chunk
929    /// seed sequence actually used internally differs (same class of bug
930    /// as C14/C15).
931    #[test]
932    fn test_deterministic_run_id_sensitive_to_chunk_size() {
933        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
934        let chunked_a =
935            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(500));
936        let chunked_b =
937            deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(250));
938        assert_ne!(base, chunked_a);
939        assert_ne!(base, chunked_b);
940        assert_ne!(chunked_a, chunked_b);
941    }
942
943    #[test]
944    fn test_default_singleton_master_fraction_known_and_unknown() {
945        assert_eq!(default_singleton_master_fraction("light"), 0.50);
946        assert_eq!(default_singleton_master_fraction("hell"), 0.10);
947        // Unknown difficulty falls back to "medium".
948        assert_eq!(
949            default_singleton_master_fraction("bogus"),
950            default_singleton_master_fraction("medium")
951        );
952    }
953}