Skip to main content

dupehell_core/
schema.rs

1// DupeHell -- MIT License . Educational Use Only
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// EDUCATIONAL AND RESEARCH PURPOSES ONLY -- see ETHICS.md for prohibited uses.
5// No liability for misuse.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use crate::pipeline::PipelineConfig;
11
12#[derive(serde::Deserialize)]
13pub struct DomainSchema {
14    pub entities: Vec<EntitySchema>,
15    pub hn_types: Vec<HnSchema>,
16}
17
18fn default_entity_weight() -> f64 {
19    1.0
20}
21
22#[derive(serde::Deserialize)]
23pub struct EntitySchema {
24    pub name: String,
25    pub columns: Vec<serde_json::Value>,
26    #[serde(default)]
27    pub fk_remaps: Vec<serde_json::Value>,
28    /// Relative population size of this entity within the domain, used to
29    /// split `total_unique` identities across entities (`build_pipeline_config`).
30    /// Defaults to `1.0` (every entity gets an equal share) when a schema
31    /// doesn't set it — every schema written before this field existed keeps
32    /// behaving exactly as before. Set explicit weights when entities aren't
33    /// realistically equal in population (e.g. aviation: far more
34    /// `passenger` identities than `airline` ones) — see `docs/` or ask
35    /// before picking numbers for a domain you don't know well; a wrong
36    /// weight is as misleading as the uniform default, just in the other
37    /// direction.
38    #[serde(default = "default_entity_weight")]
39    pub weight: f64,
40}
41
42#[derive(serde::Deserialize)]
43pub struct HnSchema {
44    pub entity_type: String,
45    pub config_json: String,
46}
47
48#[derive(Debug, Clone)]
49struct DifficultySettings {
50    singleton: f64,
51    doublet: f64,
52    /// Noise types active for this difficulty, weighted equally. Curated
53    /// explicitly per level (rather than derived from a truncated,
54    /// order-dependent countdown) so that "broad" noise types — `typo` and
55    /// `visual`, which both corrupt name+address+phone+company on the same
56    /// record at once (see `pipeline::noise_type_targets_column`) — never
57    /// end up a *larger* share of the mix at a lower difficulty than at a
58    /// higher one. A prior countdown-weight scheme caused exactly that:
59    /// medium (4 active types) put 70% of its noise budget on typo+visual,
60    /// vs. only ~42% for hell (8 active types), making medium duplicates
61    /// *more* likely to have every strong matching field wiped out at once
62    /// than hell duplicates, despite medium being meant as the easier tier.
63    noise_types: &'static [&'static str],
64    /// Number of *independent* noise passes applied to each duplicate copy
65    /// (see `pipeline::run_pipeline`'s dup-generation loop). Each additional
66    /// pass draws its own noise_type from `noise_types` and applies it on
67    /// top of the previous pass's result.
68    ///
69    /// This is the actual difficulty lever between tiers with different
70    /// `noise_types.len()` — not the category list itself. Each entry's
71    /// per-pass weight is `1 / noise_types.len()` (`build_pipeline_config`
72    /// below), so adding categories to make a tier "harder" *dilutes* every
73    /// existing category's weight, including whichever one happens to guard
74    /// a domain's single most reliable linkage column — a tier with more
75    /// categories active can paradoxically end up *easier* on some schemas
76    /// (measured: a "hard" tier here, folded into hell, kept coming out
77    /// easier than "hell" on several domains despite having fewer
78    /// categories; then a hand-picked category weighted twice fixed 2 of 6
79    /// domains tested but not the rest, since the dominant reliable column
80    /// isn't always the same category across schemas). Passes sidestep this
81    /// entirely: the probability a given column is touched at least once
82    /// across `P` independent passes is `1 - (1 - p)^P` where `p` is its
83    /// single-pass weight — strictly increasing in `P` regardless of how
84    /// small `p` is, so more passes can never make a tier easier the way
85    /// more categories can. `difficulty::estimate_difficulty` models this
86    /// exact same formula.
87    passes: usize,
88}
89
90const DIFFICULTY_MAP: [(&str, DifficultySettings); 3] = [
91    (
92        "light",
93        DifficultySettings {
94            singleton: 0.50,
95            doublet: 0.30,
96            noise_types: &["names", "dates"],
97            passes: 1,
98        },
99    ),
100    (
101        "medium",
102        DifficultySettings {
103            singleton: 0.30,
104            doublet: 0.40,
105            noise_types: &["typo", "names", "dates", "identifiers"],
106            // 3 passes: a column protected by exactly one of medium's 4
107            // equally-weighted categories (weight 1/4 -- e.g. a pure date
108            // column, only matched by "dates", not by "typo"'s broader
109            // predicate) is diluted well below light's worst case (2
110            // categories, weight 1/2). At passes=1 this let medium come out
111            // *easier* than light on 19/40 domains (found via
112            // scripts/validate_all_domains.py). 1 - (1 - 1/4)^3 ≈ 0.578 >
113            // light's single-pass 0.5, closing the gap for any category
114            // medium shares with light regardless of which column ends up
115            // being a given domain's escape hatch. Re-validated across all
116            // 40 domains with the same script after this change.
117            passes: 3,
118        },
119    ),
120    (
121        "hell",
122        DifficultySettings {
123            singleton: 0.10,
124            doublet: 0.20,
125            // `typo_aggressive` corrupts more of each string than plain
126            // `typo` (same target columns, more damage per hit — see
127            // `noise::typos`), and `unicode_pollution` gets its own bucket
128            // (rather than only ever showing up as one of `visual`'s five
129            // random sub-choices).
130            noise_types: &[
131                "typo_aggressive",
132                "visual",
133                "unicode_pollution",
134                "names",
135                "dates",
136                "identifiers",
137                "addresses",
138                "companies",
139                "extra",
140            ],
141            // 8 passes: medium was bumped from 1 to 3 passes to fix its own
142            // dilution against light (see the comment on medium's `passes`
143            // above), which raised medium's worst-case single-category
144            // touch probability to 1-(1-1/4)^3 ≈ 0.578. With 9
145            // equally-weighted categories (weight 1/9 each), hell needs
146            // 1-(1-1/9)^passes >= 0.578 to stay at least as hard as medium
147            // on any column protected by only one category -- solving gives
148            // passes >= 7.33, rounded up to 8 (1-(1-1/9)^8 ≈ 0.617).
149            // Re-validated across all 40 domains via
150            // scripts/validate_all_domains.py (0 crashes, 0 monotonicity
151            // violations) after this change.
152            passes: 8,
153        },
154    ),
155];
156
157pub fn default_singleton_master_fraction(difficulty: &str) -> f64 {
158    difficulty_settings(difficulty).singleton
159}
160
161fn difficulty_settings(difficulty: &str) -> DifficultySettings {
162    DIFFICULTY_MAP
163        .iter()
164        .find(|(name, _)| *name == difficulty)
165        .map(|(_, s)| s.clone())
166        .unwrap_or_else(|| DIFFICULTY_MAP[1].1.clone())
167}
168
169/// Generate a domain-unique run ID based on the current Unix timestamp (hex).
170pub fn chrono_now() -> String {
171    let start = std::time::SystemTime::now()
172        .duration_since(std::time::UNIX_EPOCH)
173        .unwrap_or_default();
174    format!("{:x}", start.as_secs())
175}
176
177/// Deterministic run ID derived from generation parameters, so the same
178/// (domain, size, seed, difficulty, hard_neg_ratio, singleton_master_fraction,
179/// locale) always produces the same output filename regardless of output
180/// format (IPC vs Parquet) or how many times it's run — and, just as
181/// important, a *different* filename whenever any of these parameters
182/// differ, since they all affect the generated data. `singleton_master_fraction`
183/// and `locale` were missing from this list until this was flagged as a bug
184/// (BUGS.md C14/C15): two runs differing only in `--singleton-master-fraction`
185/// or `--locale` produced the exact same filename, so the second run silently
186/// overwrote the first's output.
187#[allow(clippy::too_many_arguments)]
188pub fn deterministic_run_id(
189    domain: &str,
190    size: usize,
191    seed: u64,
192    difficulty: &str,
193    hard_neg_ratio: f64,
194    singleton_master_fraction: f64,
195    locale: &str,
196) -> String {
197    use std::hash::{Hash, Hasher};
198    let mut hasher = std::collections::hash_map::DefaultHasher::new();
199    domain.hash(&mut hasher);
200    size.hash(&mut hasher);
201    seed.hash(&mut hasher);
202    difficulty.hash(&mut hasher);
203    hard_neg_ratio.to_bits().hash(&mut hasher);
204    singleton_master_fraction.to_bits().hash(&mut hasher);
205    locale.hash(&mut hasher);
206    format!("{}_{:x}", domain, hasher.finish())
207}
208
209/// Load and parse a domain schema JSON file.
210///
211/// On failure, the error message includes the path attempted and a hint listing
212/// available domains found in the same directory.
213pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
214    let path = schemas_dir.join(format!("{domain}.json"));
215    // Case-sensitive exact match against the actual schema file names, so
216    // "KYC" is rejected the same way on every OS — on a case-insensitive
217    // filesystem (Windows), `read_to_string` alone would silently succeed
218    // for "KYC" via the "kyc.json" file, producing a different run hash
219    // than "kyc" for what the user intended to be the same domain.
220    let available = available_domain_names(schemas_dir);
221    if !available.iter().any(|d| d == domain) {
222        let hint = if available.is_empty() {
223            "no schemas found".to_string()
224        } else {
225            available.join(", ")
226        };
227        return Err(format!(
228            "schema file not found for domain '{domain}' at {path:?}. \
229             Available domains ({hint})"
230        ));
231    }
232    let data =
233        std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
234    serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
235}
236
237/// List available domain names (without .json extension) in a directory.
238fn available_domain_names(dir: &Path) -> Vec<String> {
239    match std::fs::read_dir(dir) {
240        Ok(entries) => entries
241            .filter_map(|e| e.ok())
242            .filter(|e| {
243                e.path()
244                    .extension()
245                    .map(|ext| ext == "json")
246                    .unwrap_or(false)
247            })
248            .filter_map(|e| {
249                e.path()
250                    .file_stem()
251                    .map(|s| s.to_string_lossy().to_string())
252            })
253            .collect(),
254        Err(_) => Vec::new(),
255    }
256}
257
258/// Build a `PipelineConfig` from CLI / Python parameters and a parsed schema.
259///
260/// Validates `size >= 10`, distributes singleton/doublet/triplet records
261/// across entities, and assigns noise weights per difficulty setting.
262#[allow(clippy::too_many_arguments)]
263pub fn build_pipeline_config(
264    domain: &str,
265    size: usize,
266    seed: u64,
267    difficulty: &str,
268    hard_neg_ratio: f64,
269    singleton_master_fraction: f64,
270    schema: &DomainSchema,
271    run_id: &str,
272    output_format: &str,
273    graph_enabled: bool,
274    graph_format: &str,
275) -> Result<PipelineConfig, String> {
276    if size < 10 {
277        return Err(format!("size must be >= 10, got {size}"));
278    }
279    if schema.entities.is_empty() {
280        return Err(format!("schema for domain '{domain}' has no entities"));
281    }
282    if !(0.0..=1.0).contains(&singleton_master_fraction) {
283        return Err(format!(
284            "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
285        ));
286    }
287    let ds = difficulty_settings(difficulty);
288    let total = size;
289
290    let n_singleton = (total as f64 * singleton_master_fraction) as usize;
291    let n_doublet_float = total as f64 * ds.doublet;
292    let mut n_doublet = n_doublet_float as usize;
293    if !n_doublet.is_multiple_of(2) {
294        n_doublet -= 1;
295    }
296    if n_singleton + n_doublet > total {
297        return Err(format!(
298            "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
299             difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
300            ds.doublet
301        ));
302    }
303    let mut n_triplet = total - n_singleton - n_doublet;
304    let r = n_triplet % 3;
305    if r != 0 {
306        n_triplet -= r;
307    }
308    let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
309    let n_duplicates = total.max(total_unique) - total_unique;
310
311    let total_ratio: f64 = schema.entities.iter().map(|e| e.weight).sum::<f64>();
312    let raw_floats: Vec<(&str, f64)> = schema
313        .entities
314        .iter()
315        .map(|e| {
316            (
317                e.name.as_str(),
318                total_unique as f64 * e.weight / total_ratio,
319            )
320        })
321        .collect();
322
323    let mut floor_map: HashMap<&str, usize> = HashMap::new();
324    for (name, r) in &raw_floats {
325        floor_map.insert(name, r.max(2.0) as usize);
326    }
327    let floor_sum: usize = floor_map.values().sum();
328    let need = total_unique.max(floor_sum) - floor_sum;
329    if need > 0 {
330        let mut remainders: Vec<(&str, f64)> = raw_floats
331            .iter()
332            .map(|(n, r)| (*n, r - r.floor()))
333            .collect();
334        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
335        for (name, _) in remainders.iter().take(need) {
336            *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
337        }
338    }
339
340    let dup_ratios: Vec<(&str, f64)> = schema
341        .entities
342        .iter()
343        .map(|e| {
344            (
345                e.name.as_str(),
346                *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
347            )
348        })
349        .collect();
350    let mut dup_floor: HashMap<&str, usize> = HashMap::new();
351    for (name, r) in &dup_ratios {
352        dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
353    }
354    let dup_sum: usize = dup_floor.values().sum();
355    let dup_need = n_duplicates.max(dup_sum) - dup_sum;
356    if dup_need > 0 {
357        let mut remainders: Vec<(&str, f64)> = dup_ratios
358            .iter()
359            .map(|(n, r)| (*n, r - r.floor()))
360            .collect();
361        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
362        for (name, _) in remainders.iter().take(dup_need) {
363            *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
364        }
365    }
366
367    let noise_count = ds.noise_types.len();
368    let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
369
370    let mut entity_plans = Vec::new();
371    for entity in &schema.entities {
372        let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
373        let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
374
375        let mut noise_entries = Vec::new();
376        if n_dup > 0 {
377            let mut counts: Vec<usize> = noise_weights
378                .iter()
379                .map(|w| (w * n_dup as f64) as usize)
380                .collect();
381            let count_sum: usize = counts.iter().sum();
382            if count_sum < n_dup {
383                *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
384            }
385            for (i, noise_type) in ds.noise_types.iter().enumerate() {
386                if counts[i] == 0 {
387                    continue;
388                }
389                noise_entries.push(serde_json::json!({
390                    "noise_type": noise_type,
391                    "columns": [],
392                    "count": counts[i],
393                }));
394            }
395        }
396
397        // Infer identifier column: prefer {entity_name}_id, then id, then first _id column
398        let identifier_col: Option<String> = {
399            let entity_id_name = format!("{}_id", entity.name);
400            let col_names: Vec<&str> = entity
401                .columns
402                .iter()
403                .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
404                .collect();
405            if col_names.contains(&entity_id_name.as_str()) {
406                Some(entity_id_name)
407            } else if col_names.contains(&"id") {
408                Some("id".to_string())
409            } else {
410                col_names
411                    .iter()
412                    .find(|n| n.ends_with("_id"))
413                    .map(|n| (*n).to_string())
414            }
415        };
416
417        let columns_json = serde_json::to_string(&entity.columns)
418            .map_err(|e| format!("serialize columns: {e}"))?;
419
420        entity_plans.push(serde_json::json!({
421            "name": entity.name,
422            "n_base": n_base,
423            "n_dup": n_dup,
424            "identifier_col": identifier_col,
425            "columns_json": columns_json,
426            "noise_types": noise_entries,
427            "fk_remaps": entity.fk_remaps,
428        }));
429    }
430
431    // Scaled off `n_duplicates` (which itself scales with the tier's
432    // singleton/doublet fractions — light ~0.28*size, medium ~0.40*size,
433    // hell ~0.57*size) rather than off raw `size`. A flat `size`-based count
434    // makes `total_guaranteed_fp` roughly constant across tiers while
435    // `total_true_pairs` grows a lot with difficulty, which mechanically
436    // inflates `precision_max` for higher tiers regardless of how much
437    // noise they actually inject — enough to invert `light <= medium` on
438    // some schemas (e.g. aviation, where light/medium f1_max came out
439    // 0.828/0.833). The 0.125 constant keeps medium's hard-neg volume
440    // matching the old flat formula (0.40 * 0.125 = 0.05), so
441    // `hard_neg_ratio`'s documented "~1.5% of size at default 0.3" still
442    // holds at medium; light gets proportionally fewer, hell more.
443    let n_hard_neg = (n_duplicates as f64 * hard_neg_ratio * 0.125) as usize;
444    let hn_per_type = n_hard_neg / schema.hn_types.len().max(1);
445    let hard_neg_types: Vec<serde_json::Value> = schema
446        .hn_types
447        .iter()
448        .map(|hn| {
449            serde_json::json!({
450                "entity_type": hn.entity_type,
451                "config_json": hn.config_json,
452                "count": hn_per_type,
453            })
454        })
455        .collect();
456
457    let config = serde_json::json!({
458        "domain": domain,
459        "size": size,
460        "seed": seed,
461        "difficulty": difficulty,
462        "output_format": output_format,
463        "run_id": run_id,
464        "entity_plans": entity_plans,
465        "hard_neg_types": hard_neg_types,
466        "hard_neg_ratio": hard_neg_ratio,
467        "noise_passes": ds.passes,
468        "graph_enabled": graph_enabled,
469        "graph_format": graph_format,
470    });
471
472    serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn schemas_dir() -> std::path::PathBuf {
480        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas")
481    }
482
483    fn kyc_schema() -> DomainSchema {
484        load_schema("kyc", &schemas_dir()).expect("load kyc.json")
485    }
486
487    #[test]
488    fn test_load_schema_known_domain() {
489        let schema = kyc_schema();
490        assert_eq!(schema.entities.len(), 2);
491        assert!(schema.entities.iter().any(|e| e.name == "natural_person"));
492        assert!(!schema.hn_types.is_empty());
493    }
494
495    #[test]
496    fn test_load_schema_unknown_domain_lists_available() {
497        let err = load_schema("not-a-real-domain", &schemas_dir())
498            .err()
499            .unwrap();
500        assert!(err.contains("not-a-real-domain"));
501        assert!(err.contains("kyc"));
502    }
503
504    #[test]
505    fn test_load_schema_case_sensitive() {
506        // Windows filesystems are case-insensitive; load_schema must still
507        // reject "KYC" so run hashes stay consistent across OSes.
508        let err = load_schema("KYC", &schemas_dir()).err().unwrap();
509        assert!(err.contains("KYC"));
510    }
511
512    #[test]
513    fn test_build_pipeline_config_basic() {
514        let schema = kyc_schema();
515        let config = build_pipeline_config(
516            "kyc", 1000, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
517        )
518        .expect("build config");
519        assert_eq!(config.domain, "kyc");
520        assert_eq!(config.size, 1000);
521        assert_eq!(config.entity_plans.len(), 2);
522        // Every entity must get at least the floor of 2 base records.
523        assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
524        assert!(!config.hard_neg_types.is_empty());
525    }
526
527    #[test]
528    fn test_build_pipeline_config_rejects_size_below_10() {
529        let schema = kyc_schema();
530        let err = build_pipeline_config(
531            "kyc", 5, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
532        )
533        .unwrap_err();
534        assert!(err.contains("size must be >= 10"));
535    }
536
537    #[test]
538    fn test_build_pipeline_config_rejects_invalid_singleton_fraction() {
539        let schema = kyc_schema();
540        let err = build_pipeline_config(
541            "kyc", 1000, 42, "medium", 0.1, 1.5, &schema, "kyc_test", "parquet", false, "parquet",
542        )
543        .unwrap_err();
544        assert!(err.contains("singleton_master_fraction"));
545    }
546
547    #[test]
548    fn test_build_pipeline_config_deterministic() {
549        let schema = kyc_schema();
550        let build = || {
551            build_pipeline_config(
552                "kyc", 1000, 42, "hell", 0.1, 0.2, &schema, "kyc_test", "parquet", false, "parquet",
553            )
554            .expect("build config")
555        };
556        let a = build();
557        let b = build();
558        assert_eq!(a.entity_plans.len(), b.entity_plans.len());
559        for (pa, pb) in a.entity_plans.iter().zip(b.entity_plans.iter()) {
560            assert_eq!(pa.n_base, pb.n_base);
561            assert_eq!(pa.noise_types.len(), pb.noise_types.len());
562        }
563    }
564
565    #[test]
566    fn test_deterministic_run_id_stable_and_sensitive() {
567        let a = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en");
568        let b = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en");
569        assert_eq!(a, b);
570        let c = deterministic_run_id("kyc", 1000, 43, "medium", 0.1, 0.3, "en");
571        assert_ne!(a, c);
572    }
573
574    /// Regression: BUGS.md C14/C15 — `singleton_master_fraction` and
575    /// `locale` weren't hashed into the run ID, so two runs differing only
576    /// in one of these parameters (but producing genuinely different data)
577    /// got the exact same output filename, silently overwriting each other.
578    #[test]
579    fn test_deterministic_run_id_sensitive_to_singleton_fraction_and_locale() {
580        let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en");
581        let diff_fraction = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.5, "en");
582        let diff_locale = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "fr");
583        assert_ne!(base, diff_fraction);
584        assert_ne!(base, diff_locale);
585        assert_ne!(diff_fraction, diff_locale);
586    }
587
588    #[test]
589    fn test_default_singleton_master_fraction_known_and_unknown() {
590        assert_eq!(default_singleton_master_fraction("light"), 0.50);
591        assert_eq!(default_singleton_master_fraction("hell"), 0.10);
592        // Unknown difficulty falls back to "medium".
593        assert_eq!(
594            default_singleton_master_fraction("bogus"),
595            default_singleton_master_fraction("medium")
596        );
597    }
598}