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
18#[derive(serde::Deserialize)]
19pub struct EntitySchema {
20    pub name: String,
21    pub columns: Vec<serde_json::Value>,
22    #[serde(default)]
23    pub fk_remaps: Vec<serde_json::Value>,
24}
25
26#[derive(serde::Deserialize)]
27pub struct HnSchema {
28    pub entity_type: String,
29    pub config_json: String,
30}
31
32#[derive(Debug, Clone)]
33struct DifficultySettings {
34    singleton: f64,
35    doublet: f64,
36    /// Noise types active for this difficulty, weighted equally. Curated
37    /// explicitly per level (rather than derived from a truncated,
38    /// order-dependent countdown) so that "broad" noise types — `typo` and
39    /// `visual`, which both corrupt name+address+phone+company on the same
40    /// record at once (see `pipeline::noise_type_targets_column`) — never
41    /// end up a *larger* share of the mix at a lower difficulty than at a
42    /// higher one. A prior countdown-weight scheme caused exactly that:
43    /// medium (4 active types) put 70% of its noise budget on typo+visual,
44    /// vs. only ~42% for hell (8 active types), making medium duplicates
45    /// *more* likely to have every strong matching field wiped out at once
46    /// than hell duplicates, despite medium being meant as the easier tier.
47    noise_types: &'static [&'static str],
48}
49
50const DIFFICULTY_MAP: [(&str, DifficultySettings); 4] = [
51    (
52        "light",
53        DifficultySettings {
54            singleton: 0.50,
55            doublet: 0.30,
56            noise_types: &["names", "dates"],
57        },
58    ),
59    (
60        "medium",
61        DifficultySettings {
62            singleton: 0.30,
63            doublet: 0.40,
64            noise_types: &["typo", "names", "dates", "identifiers"],
65        },
66    ),
67    (
68        "hard",
69        DifficultySettings {
70            singleton: 0.20,
71            doublet: 0.30,
72            noise_types: &[
73                "typo",
74                "visual",
75                "names",
76                "dates",
77                "identifiers",
78                "addresses",
79                "companies",
80                "extra",
81            ],
82        },
83    ),
84    (
85        "hell",
86        DifficultySettings {
87            singleton: 0.10,
88            doublet: 0.20,
89            noise_types: &[
90                "typo",
91                "visual",
92                "names",
93                "dates",
94                "identifiers",
95                "addresses",
96                "companies",
97                "extra",
98            ],
99        },
100    ),
101];
102
103pub fn default_singleton_master_fraction(difficulty: &str) -> f64 {
104    difficulty_settings(difficulty).singleton
105}
106
107fn difficulty_settings(difficulty: &str) -> DifficultySettings {
108    DIFFICULTY_MAP
109        .iter()
110        .find(|(name, _)| *name == difficulty)
111        .map(|(_, s)| s.clone())
112        .unwrap_or_else(|| DIFFICULTY_MAP[1].1.clone())
113}
114
115/// Generate a domain-unique run ID based on the current Unix timestamp (hex).
116pub fn chrono_now() -> String {
117    let start = std::time::SystemTime::now()
118        .duration_since(std::time::UNIX_EPOCH)
119        .unwrap_or_default();
120    format!("{:x}", start.as_secs())
121}
122
123/// Deterministic run ID derived from generation parameters, so the same
124/// (domain, size, seed, difficulty, hard_neg_ratio) always produces the same
125/// output filename regardless of output format (IPC vs Parquet) or how many
126/// times it's run.
127pub fn deterministic_run_id(
128    domain: &str,
129    size: usize,
130    seed: u64,
131    difficulty: &str,
132    hard_neg_ratio: f64,
133) -> String {
134    use std::hash::{Hash, Hasher};
135    let mut hasher = std::collections::hash_map::DefaultHasher::new();
136    domain.hash(&mut hasher);
137    size.hash(&mut hasher);
138    seed.hash(&mut hasher);
139    difficulty.hash(&mut hasher);
140    hard_neg_ratio.to_bits().hash(&mut hasher);
141    format!("{}_{:x}", domain, hasher.finish())
142}
143
144/// Load and parse a domain schema JSON file.
145///
146/// On failure, the error message includes the path attempted and a hint listing
147/// available domains found in the same directory.
148pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
149    let path = schemas_dir.join(format!("{domain}.json"));
150    // Case-sensitive exact match against the actual schema file names, so
151    // "KYC" is rejected the same way on every OS — on a case-insensitive
152    // filesystem (Windows), `read_to_string` alone would silently succeed
153    // for "KYC" via the "kyc.json" file, producing a different run hash
154    // than "kyc" for what the user intended to be the same domain.
155    let available = available_domain_names(schemas_dir);
156    if !available.iter().any(|d| d == domain) {
157        let hint = if available.is_empty() {
158            "no schemas found".to_string()
159        } else {
160            available.join(", ")
161        };
162        return Err(format!(
163            "schema file not found for domain '{domain}' at {path:?}. \
164             Available domains ({hint})"
165        ));
166    }
167    let data =
168        std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
169    serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
170}
171
172/// List available domain names (without .json extension) in a directory.
173fn available_domain_names(dir: &Path) -> Vec<String> {
174    match std::fs::read_dir(dir) {
175        Ok(entries) => entries
176            .filter_map(|e| e.ok())
177            .filter(|e| {
178                e.path()
179                    .extension()
180                    .map(|ext| ext == "json")
181                    .unwrap_or(false)
182            })
183            .filter_map(|e| {
184                e.path()
185                    .file_stem()
186                    .map(|s| s.to_string_lossy().to_string())
187            })
188            .collect(),
189        Err(_) => Vec::new(),
190    }
191}
192
193/// Build a `PipelineConfig` from CLI / Python parameters and a parsed schema.
194///
195/// Validates `size >= 10`, distributes singleton/doublet/triplet records
196/// across entities, and assigns noise weights per difficulty setting.
197#[allow(clippy::too_many_arguments)]
198pub fn build_pipeline_config(
199    domain: &str,
200    size: usize,
201    seed: u64,
202    difficulty: &str,
203    hard_neg_ratio: f64,
204    singleton_master_fraction: f64,
205    schema: &DomainSchema,
206    run_id: &str,
207    output_format: &str,
208    graph_enabled: bool,
209    graph_format: &str,
210) -> Result<PipelineConfig, String> {
211    if size < 10 {
212        return Err(format!("size must be >= 10, got {size}"));
213    }
214    if schema.entities.is_empty() {
215        return Err(format!("schema for domain '{domain}' has no entities"));
216    }
217    if !(0.0..=1.0).contains(&singleton_master_fraction) {
218        return Err(format!(
219            "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
220        ));
221    }
222    let ds = difficulty_settings(difficulty);
223    let total = size;
224
225    let n_singleton = (total as f64 * singleton_master_fraction) as usize;
226    let n_doublet_float = total as f64 * ds.doublet;
227    let mut n_doublet = n_doublet_float as usize;
228    if !n_doublet.is_multiple_of(2) {
229        n_doublet -= 1;
230    }
231    if n_singleton + n_doublet > total {
232        return Err(format!(
233            "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
234             difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
235            ds.doublet
236        ));
237    }
238    let mut n_triplet = total - n_singleton - n_doublet;
239    let r = n_triplet % 3;
240    if r != 0 {
241        n_triplet -= r;
242    }
243    let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
244    let n_duplicates = total.max(total_unique) - total_unique;
245
246    let total_ratio: f64 = schema.entities.iter().map(|_| 1.0).sum::<f64>();
247    let raw_floats: Vec<(&str, f64)> = schema
248        .entities
249        .iter()
250        .map(|e| (e.name.as_str(), total_unique as f64 / total_ratio))
251        .collect();
252
253    let mut floor_map: HashMap<&str, usize> = HashMap::new();
254    for (name, r) in &raw_floats {
255        floor_map.insert(name, r.max(2.0) as usize);
256    }
257    let floor_sum: usize = floor_map.values().sum();
258    let need = total_unique.max(floor_sum) - floor_sum;
259    if need > 0 {
260        let mut remainders: Vec<(&str, f64)> = raw_floats
261            .iter()
262            .map(|(n, r)| (*n, r - r.floor()))
263            .collect();
264        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
265        for (name, _) in remainders.iter().take(need) {
266            *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
267        }
268    }
269
270    let dup_ratios: Vec<(&str, f64)> = schema
271        .entities
272        .iter()
273        .map(|e| {
274            (
275                e.name.as_str(),
276                *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
277            )
278        })
279        .collect();
280    let mut dup_floor: HashMap<&str, usize> = HashMap::new();
281    for (name, r) in &dup_ratios {
282        dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
283    }
284    let dup_sum: usize = dup_floor.values().sum();
285    let dup_need = n_duplicates.max(dup_sum) - dup_sum;
286    if dup_need > 0 {
287        let mut remainders: Vec<(&str, f64)> = dup_ratios
288            .iter()
289            .map(|(n, r)| (*n, r - r.floor()))
290            .collect();
291        remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
292        for (name, _) in remainders.iter().take(dup_need) {
293            *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
294        }
295    }
296
297    let noise_count = ds.noise_types.len();
298    let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
299
300    let mut entity_plans = Vec::new();
301    for entity in &schema.entities {
302        let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
303        let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
304
305        let mut noise_entries = Vec::new();
306        if n_dup > 0 {
307            let mut counts: Vec<usize> = noise_weights
308                .iter()
309                .map(|w| (w * n_dup as f64) as usize)
310                .collect();
311            let count_sum: usize = counts.iter().sum();
312            if count_sum < n_dup {
313                *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
314            }
315            for (i, noise_type) in ds.noise_types.iter().enumerate() {
316                if counts[i] == 0 {
317                    continue;
318                }
319                noise_entries.push(serde_json::json!({
320                    "noise_type": noise_type,
321                    "columns": [],
322                    "count": counts[i],
323                }));
324            }
325        }
326
327        // Infer identifier column: prefer {entity_name}_id, then id, then first _id column
328        let identifier_col: Option<String> = {
329            let entity_id_name = format!("{}_id", entity.name);
330            let col_names: Vec<&str> = entity
331                .columns
332                .iter()
333                .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
334                .collect();
335            if col_names.contains(&entity_id_name.as_str()) {
336                Some(entity_id_name)
337            } else if col_names.contains(&"id") {
338                Some("id".to_string())
339            } else {
340                col_names
341                    .iter()
342                    .find(|n| n.ends_with("_id"))
343                    .map(|n| (*n).to_string())
344            }
345        };
346
347        let columns_json = serde_json::to_string(&entity.columns)
348            .map_err(|e| format!("serialize columns: {e}"))?;
349
350        entity_plans.push(serde_json::json!({
351            "name": entity.name,
352            "n_base": n_base,
353            "n_dup": n_dup,
354            "identifier_col": identifier_col,
355            "columns_json": columns_json,
356            "noise_types": noise_entries,
357            "fk_remaps": entity.fk_remaps,
358        }));
359    }
360
361    let n_hard_neg = (size as f64 * hard_neg_ratio * 0.05) as usize;
362    let hn_per_type = n_hard_neg / schema.hn_types.len().max(1);
363    let hard_neg_types: Vec<serde_json::Value> = schema
364        .hn_types
365        .iter()
366        .map(|hn| {
367            serde_json::json!({
368                "entity_type": hn.entity_type,
369                "config_json": hn.config_json,
370                "count": hn_per_type,
371            })
372        })
373        .collect();
374
375    let config = serde_json::json!({
376        "domain": domain,
377        "size": size,
378        "seed": seed,
379        "difficulty": difficulty,
380        "output_format": output_format,
381        "run_id": run_id,
382        "entity_plans": entity_plans,
383        "hard_neg_types": hard_neg_types,
384        "hard_neg_ratio": hard_neg_ratio,
385        "graph_enabled": graph_enabled,
386        "graph_format": graph_format,
387    });
388
389    serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
390}