Skip to main content

dupehell_core/
difficulty.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 serde::Serialize;
8
9use crate::schema::{DomainSchema, build_pipeline_config};
10
11/// Noise destructiveness per column name pattern.
12fn contains_any(s: &str, patterns: &[&str]) -> bool {
13    patterns.iter().any(|p| s.contains(p))
14}
15
16/// How useful is this column for record linkage (0.0 = useless, 1.0 = excellent).
17fn match_utility(col_name: &str, col_type: &str) -> f64 {
18    match col_type {
19        "boolean" => 0.0,
20        "int" | "float" => 0.2,
21        _ => {
22            let lower = col_name.to_lowercase();
23            if contains_any(&lower, &["record_id", "row_id"]) {
24                0.0
25            } else if lower.ends_with("_id") {
26                0.3
27            } else if contains_any(&lower, &["name", "email", "address", "phone", "ssn", "tax"]) {
28                1.0
29            } else if contains_any(
30                &lower,
31                &["date", "birth", "city", "country", "state", "postal"],
32            ) {
33                0.7
34            } else if contains_any(&lower, &["company", "legal", "trading", "registration"]) {
35                0.8
36            } else {
37                0.5
38            }
39        }
40    }
41}
42
43/// Noise destructiveness per column name pattern (0.0 = never, 1.0 = always destroyed).
44fn base_noise_damage(col_name: &str, col_type: &str) -> f64 {
45    let lower = col_name.to_lowercase();
46    match col_type {
47        "boolean" => 0.0,
48        "int" | "float" => 0.0,
49        "date" | "datetime" => {
50            if contains_any(&lower, &["birth", "dob"]) {
51                0.3
52            } else {
53                0.2
54            }
55        }
56        _ => {
57            if contains_any(&lower, &["email", "ssn", "phone", "mobile", "telephone"]) {
58                0.8
59            } else if contains_any(
60                &lower,
61                &[
62                    "tax_id",
63                    "registration",
64                    "national_id",
65                    "passport",
66                    "account",
67                    "barcode",
68                    "pan",
69                    "medicare",
70                ],
71            ) {
72                0.6
73            } else if contains_any(
74                &lower,
75                &[
76                    "first_name",
77                    "last_name",
78                    "given_name",
79                    "family_name",
80                    "middle_name",
81                ],
82            ) {
83                0.4
84            } else if contains_any(
85                &lower,
86                &["address", "street", "city", "postal", "state", "country"],
87            ) {
88                0.5
89            } else if contains_any(&lower, &["date", "birth", "dob"]) {
90                0.3
91            } else {
92                // "company"/"legal"/"trading" and any other column fall back
93                // to the same default weight.
94                0.4
95            }
96        }
97    }
98}
99
100// ── HN column poisoning ──────────────────────────────────────────────────
101
102fn parse_hn_id_fields(config_json: &str) -> Vec<String> {
103    #[derive(serde::Deserialize)]
104    struct HnConfigLight {
105        #[serde(default)]
106        pattern: String,
107        #[serde(default)]
108        id_fields: Vec<String>,
109        #[serde(default)]
110        mix_field: String,
111        #[serde(default)]
112        first_name_col: String,
113        #[serde(default)]
114        last_name_col: String,
115        #[serde(default)]
116        dob_col: String,
117        #[serde(default)]
118        email_col: String,
119        #[serde(default)]
120        ssn_col: String,
121        #[serde(default)]
122        phone_col: String,
123        #[serde(default)]
124        address_fields: Vec<String>,
125    }
126
127    let cfg: HnConfigLight = serde_json::from_str(config_json).unwrap_or(HnConfigLight {
128        pattern: String::new(),
129        id_fields: vec![],
130        mix_field: String::new(),
131        first_name_col: String::new(),
132        last_name_col: String::new(),
133        dob_col: String::new(),
134        email_col: String::new(),
135        ssn_col: String::new(),
136        phone_col: String::new(),
137        address_fields: vec![],
138    });
139
140    match cfg.pattern.as_str() {
141        "same_field" => cfg.id_fields,
142        "mix_identifier" => {
143            if cfg.mix_field.is_empty() {
144                vec![]
145            } else {
146                vec![cfg.mix_field]
147            }
148        }
149        "same_name_different_everything" => vec![cfg.first_name_col, cfg.last_name_col],
150        "same_email" => vec![cfg.email_col],
151        "same_ssn" => vec![cfg.ssn_col],
152        "same_phone" => vec![cfg.phone_col],
153        "same_address" => {
154            if cfg.address_fields.is_empty() {
155                vec!["address_line1".into(), "postal_code".into()]
156            } else {
157                cfg.address_fields
158            }
159        }
160        "same_name_dob" => vec![cfg.first_name_col, cfg.last_name_col, cfg.dob_col],
161        _ => vec![],
162    }
163    .into_iter()
164    .filter(|s| !s.is_empty())
165    .collect()
166}
167
168// ── Column descriptors ──────────────────────────────────────────────────
169
170#[derive(Debug, Serialize)]
171pub struct ColReliability {
172    pub name: String,
173    pub col_type: String,
174    pub noise_damage: f64,
175    pub hn_risk: f64,
176    pub reliability: f64,
177}
178
179#[derive(Debug, Serialize)]
180pub struct EntityDifficulty {
181    pub name: String,
182    pub n_base: usize,
183    pub n_dup: usize,
184    pub true_pairs: usize,
185    pub hard_neg_pairs: usize,
186    pub guaranteed_fp: usize,
187    pub guaranteed_fn: usize,
188    pub columns: Vec<ColReliability>,
189}
190
191#[derive(Debug, Serialize)]
192pub struct DifficultyReport {
193    pub domain: String,
194    pub difficulty: String,
195    pub size: usize,
196    pub total_true_pairs: usize,
197    pub total_hard_neg_pairs: usize,
198    pub total_guaranteed_fp: usize,
199    pub total_guaranteed_fn: usize,
200    pub precision_max: f64,
201    pub recall_max: f64,
202    pub f1_max: f64,
203    pub entities: Vec<EntityDifficulty>,
204}
205
206// ── Estimator ────────────────────────────────────────────────────────────
207
208#[derive(serde::Deserialize)]
209struct ColDefLight {
210    name: String,
211    #[serde(rename = "type", default = "default_str")]
212    col_type: String,
213}
214
215fn default_str() -> String {
216    "string".to_string()
217}
218
219pub fn estimate_difficulty(
220    domain: &str,
221    size: usize,
222    seed: u64,
223    difficulty: &str,
224    hard_neg_ratio: f64,
225    schema: &DomainSchema,
226) -> Result<DifficultyReport, String> {
227    let run_id =
228        crate::schema::deterministic_run_id(domain, size, seed, difficulty, hard_neg_ratio);
229    let singleton_master_fraction = crate::schema::default_singleton_master_fraction(difficulty);
230    let config = build_pipeline_config(
231        domain,
232        size,
233        seed,
234        difficulty,
235        hard_neg_ratio,
236        singleton_master_fraction,
237        schema,
238        &run_id,
239        "ipc",
240        false,
241        "ipc",
242    )?;
243
244    // Build a map: entity_name -> HN id_fields
245    let mut hn_id_fields: std::collections::HashMap<String, Vec<String>> =
246        std::collections::HashMap::new();
247    for hn in &schema.hn_types {
248        let fields = parse_hn_id_fields(&hn.config_json);
249        hn_id_fields
250            .entry(hn.entity_type.clone())
251            .or_default()
252            .extend(fields);
253    }
254
255    let mut entities = Vec::new();
256    let mut total_true_pairs = 0usize;
257    let mut total_hard_neg_pairs = 0usize;
258    let mut total_guaranteed_fp = 0usize;
259    let mut total_guaranteed_fn = 0usize;
260
261    for plan in &config.entity_plans {
262        let poisoned: std::collections::HashSet<String> = hn_id_fields
263            .get(&plan.name)
264            .map(|v| v.iter().cloned().collect())
265            .unwrap_or_default();
266
267        // Parse columns from columns_json
268        let cols: Vec<ColDefLight> = serde_json::from_str(&plan.columns_json)
269            .map_err(|e| format!("parse columns for '{}': {}", plan.name, e))?;
270
271        // Count HN pairs targeting this entity
272        let hn_pairs: usize = config
273            .hard_neg_types
274            .iter()
275            .filter(|h| h.entity_type == plan.name)
276            .map(|h| h.count)
277            .sum();
278
279        // True duplicate pairs per entity = n_dup / 2 (all duplicates are paired)
280        let n_dup: usize = plan.noise_types.iter().map(|n| n.count).sum();
281        let true_pairs = n_dup / 2;
282
283        // Column analysis: each duplicate is hit by exactly one of `plan.noise_types`
284        // (see `pipeline::apply_noise_to_batch`), so a column's chance of being
285        // touched is the summed weight of the active types that actually target it
286        // — not a flat scalar. Reuses `pipeline::noise_type_targets_column`, the
287        // same predicate real generation uses, so this can't drift from reality.
288        let n_dup_f = n_dup.max(1) as f64;
289        let mut col_reliability = Vec::new();
290        let mut best_fn_reliability = 0.0f64;
291        let mut best_fp_reliability = 0.0f64; // higher = more FP-safe
292
293        for col in &cols {
294            let base_damage = base_noise_damage(&col.name, &col.col_type);
295            let p_touched: f64 = plan
296                .noise_types
297                .iter()
298                .filter(|n| crate::pipeline::noise_type_targets_column(&n.noise_type, &col.name))
299                .map(|n| n.count as f64 / n_dup_f)
300                .sum::<f64>()
301                .min(1.0);
302            let damage = base_damage * p_touched;
303            let util = match_utility(&col.name, &col.col_type);
304            let is_hn_id = poisoned.contains(&col.name);
305            let hn_risk = if is_hn_id { 1.0 } else { 0.0 };
306
307            // Reliability for finding TRUE matches: utility × noise survival
308            let rel_fn = util * (1.0 - damage);
309
310            // Reliability for AVOIDING false positives: utility × freedom from HN poisoning
311            let rel_fp = util * (1.0 - hn_risk) * (1.0 - damage);
312
313            if rel_fn > best_fn_reliability {
314                best_fn_reliability = rel_fn;
315            }
316            if rel_fp > best_fp_reliability {
317                best_fp_reliability = rel_fp;
318            }
319
320            col_reliability.push(ColReliability {
321                name: col.name.clone(),
322                col_type: col.col_type.clone(),
323                noise_damage: damage,
324                hn_risk,
325                reliability: rel_fn.min(rel_fp),
326            });
327        }
328
329        let guaranteed_fp = if hn_pairs > 0 {
330            // FP guaranteed when even the best non-HN-safe column is unreliable
331            (hn_pairs as f64 * (1.0 - best_fp_reliability)) as usize
332        } else {
333            0
334        };
335
336        let guaranteed_fn = (true_pairs as f64 * (1.0 - best_fn_reliability)) as usize;
337
338        total_true_pairs += true_pairs;
339        total_hard_neg_pairs += hn_pairs;
340        total_guaranteed_fp += guaranteed_fp;
341        total_guaranteed_fn += guaranteed_fn;
342
343        entities.push(EntityDifficulty {
344            name: plan.name.clone(),
345            n_base: plan.n_base,
346            n_dup,
347            true_pairs,
348            hard_neg_pairs: hn_pairs,
349            guaranteed_fp,
350            guaranteed_fn,
351            columns: col_reliability,
352        });
353    }
354
355    let tp = total_true_pairs.max(1) as f64;
356    let fp = total_guaranteed_fp as f64;
357    let fn_ = total_guaranteed_fn as f64;
358
359    let precision_max = tp / (tp + fp);
360    let recall_max = tp / (tp + fn_);
361    let f1_max = 2.0 * precision_max * recall_max / (precision_max + recall_max);
362
363    Ok(DifficultyReport {
364        domain: domain.to_string(),
365        difficulty: difficulty.to_string(),
366        size,
367        total_true_pairs,
368        total_hard_neg_pairs,
369        total_guaranteed_fp,
370        total_guaranteed_fn,
371        precision_max,
372        recall_max,
373        f1_max,
374        entities,
375    })
376}