Skip to main content

dupehell_core/
difficulty.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// No liability for misuse.
5
6use serde::Serialize;
7
8use crate::schema::{DomainSchema, build_pipeline_config};
9
10/// Noise destructiveness per column name pattern.
11fn contains_any(s: &str, patterns: &[&str]) -> bool {
12    patterns.iter().any(|p| s.contains(p))
13}
14
15/// How useful is this column for record linkage (0.0 = useless, 1.0 = excellent).
16fn match_utility(col_name: &str, col_type: &str) -> f64 {
17    match col_type {
18        "boolean" => 0.0,
19        "int" | "float" => 0.2,
20        _ => {
21            let lower = col_name.to_lowercase();
22            if contains_any(&lower, &["record_id", "row_id"]) {
23                0.0
24            } else if lower.ends_with("_id") {
25                0.3
26            } else if contains_any(&lower, &["name", "email", "address", "phone", "ssn", "tax"]) {
27                1.0
28            } else if contains_any(
29                &lower,
30                &["date", "birth", "city", "country", "state", "postal"],
31            ) {
32                0.7
33            } else if contains_any(&lower, &["company", "legal", "trading", "registration"]) {
34                0.8
35            } else {
36                // Unclassified generic/categorical columns (occupation,
37                // risk_score, document_type, source_system, ...) are
38                // descriptive, not identifying -- most duplicates of
39                // different people share the same value. Kept low
40                // (below the weak `_id`-suffix bucket) so a handful of
41                // them can't noisy-OR their way to near-certain recall
42                // when combined with genuinely informative columns.
43                0.1
44            }
45        }
46    }
47}
48
49/// Noise destructiveness per column name pattern (0.0 = never, 1.0 = always destroyed).
50fn base_noise_damage(col_name: &str, col_type: &str) -> f64 {
51    let lower = col_name.to_lowercase();
52    match col_type {
53        "boolean" => 0.0,
54        "int" | "float" => 0.0,
55        "date" | "datetime" => {
56            if contains_any(&lower, &["birth", "dob"]) {
57                0.3
58            } else {
59                0.2
60            }
61        }
62        _ => {
63            if contains_any(&lower, &["email", "ssn", "phone", "mobile", "telephone"]) {
64                0.8
65            } else if contains_any(
66                &lower,
67                &[
68                    "tax_id",
69                    "registration",
70                    "national_id",
71                    "passport",
72                    "account",
73                    "barcode",
74                    "pan",
75                    "medicare",
76                ],
77            ) {
78                0.6
79            } else if contains_any(
80                &lower,
81                &[
82                    "first_name",
83                    "last_name",
84                    "given_name",
85                    "family_name",
86                    "middle_name",
87                ],
88            ) {
89                0.4
90            } else if contains_any(
91                &lower,
92                &["address", "street", "city", "postal", "state", "country"],
93            ) {
94                0.5
95            } else if contains_any(&lower, &["date", "birth", "dob"]) {
96                0.3
97            } else {
98                // "company"/"legal"/"trading" and any other column fall back
99                // to the same default weight.
100                0.4
101            }
102        }
103    }
104}
105
106// ── HN column poisoning ──────────────────────────────────────────────────
107
108fn parse_hn_id_fields(config_json: &str) -> Vec<String> {
109    #[derive(serde::Deserialize)]
110    struct HnConfigLight {
111        #[serde(default)]
112        pattern: String,
113        #[serde(default)]
114        id_fields: Vec<String>,
115        #[serde(default)]
116        mix_field: String,
117        #[serde(default)]
118        first_name_col: String,
119        #[serde(default)]
120        last_name_col: String,
121        #[serde(default)]
122        dob_col: String,
123        #[serde(default)]
124        email_col: String,
125        #[serde(default)]
126        ssn_col: String,
127        #[serde(default)]
128        phone_col: String,
129        #[serde(default)]
130        address_fields: Vec<String>,
131    }
132
133    let cfg: HnConfigLight = serde_json::from_str(config_json).unwrap_or(HnConfigLight {
134        pattern: String::new(),
135        id_fields: vec![],
136        mix_field: String::new(),
137        first_name_col: String::new(),
138        last_name_col: String::new(),
139        dob_col: String::new(),
140        email_col: String::new(),
141        ssn_col: String::new(),
142        phone_col: String::new(),
143        address_fields: vec![],
144    });
145
146    match cfg.pattern.as_str() {
147        "same_field" => cfg.id_fields,
148        "mix_identifier" => {
149            if cfg.mix_field.is_empty() {
150                vec![]
151            } else {
152                vec![cfg.mix_field]
153            }
154        }
155        "same_name_different_everything" => vec![cfg.first_name_col, cfg.last_name_col],
156        "same_email" => vec![cfg.email_col],
157        "same_ssn" => vec![cfg.ssn_col],
158        "same_phone" => vec![cfg.phone_col],
159        "same_address" => {
160            if cfg.address_fields.is_empty() {
161                vec!["address_line1".into(), "postal_code".into()]
162            } else {
163                cfg.address_fields
164            }
165        }
166        "same_name_dob" => vec![cfg.first_name_col, cfg.last_name_col, cfg.dob_col],
167        _ => vec![],
168    }
169    .into_iter()
170    .filter(|s| !s.is_empty())
171    .collect()
172}
173
174// ── Column descriptors ──────────────────────────────────────────────────
175
176#[derive(Debug, Serialize)]
177pub struct ColReliability {
178    pub name: String,
179    pub col_type: String,
180    pub noise_damage: f64,
181    pub hn_risk: f64,
182    pub reliability: f64,
183}
184
185#[derive(Debug, Serialize)]
186pub struct EntityDifficulty {
187    pub name: String,
188    pub n_base: usize,
189    pub n_dup: usize,
190    pub true_pairs: usize,
191    pub hard_neg_pairs: usize,
192    pub guaranteed_fp: usize,
193    pub guaranteed_fn: usize,
194    pub columns: Vec<ColReliability>,
195}
196
197#[derive(Debug, Serialize)]
198pub struct DifficultyReport {
199    pub domain: String,
200    pub difficulty: String,
201    pub size: usize,
202    pub total_true_pairs: usize,
203    pub total_hard_neg_pairs: usize,
204    pub total_guaranteed_fp: usize,
205    pub total_guaranteed_fn: usize,
206    pub precision_max: f64,
207    pub recall_max: f64,
208    pub f1_max: f64,
209    pub entities: Vec<EntityDifficulty>,
210}
211
212// ── Estimator ────────────────────────────────────────────────────────────
213
214#[derive(serde::Deserialize)]
215struct ColDefLight {
216    name: String,
217    #[serde(rename = "type", default = "default_str")]
218    col_type: String,
219}
220
221fn default_str() -> String {
222    "string".to_string()
223}
224
225pub fn estimate_difficulty(
226    domain: &str,
227    size: usize,
228    seed: u64,
229    difficulty: &str,
230    hard_neg_ratio: f64,
231    schema: &DomainSchema,
232) -> Result<DifficultyReport, String> {
233    let singleton_master_fraction = crate::schema::default_singleton_master_fraction(difficulty);
234    // `run_id` is never used to name a file on this path (`--estimate` never
235    // writes output) — `locale` is passed as a fixed placeholder since it
236    // doesn't affect the column-level estimation model either way.
237    let run_id = crate::schema::deterministic_run_id(
238        domain,
239        size,
240        seed,
241        difficulty,
242        hard_neg_ratio,
243        singleton_master_fraction,
244        "en",
245        None,
246        None,
247    );
248    let config = build_pipeline_config(
249        domain,
250        size,
251        seed,
252        difficulty,
253        hard_neg_ratio,
254        singleton_master_fraction,
255        schema,
256        &run_id,
257        "ipc",
258        false,
259        "ipc",
260        None,
261    )?;
262
263    // Build a map: entity_name -> HN id_fields
264    let mut hn_id_fields: std::collections::HashMap<String, Vec<String>> =
265        std::collections::HashMap::new();
266    for hn in &schema.hn_types {
267        let fields = parse_hn_id_fields(&hn.config_json);
268        hn_id_fields
269            .entry(hn.entity_type.clone())
270            .or_default()
271            .extend(fields);
272    }
273
274    let mut entities = Vec::new();
275    let mut total_true_pairs = 0usize;
276    let mut total_hard_neg_pairs = 0usize;
277    let mut total_guaranteed_fp = 0usize;
278    let mut total_guaranteed_fn = 0usize;
279    // Float accumulators for the precision/recall/F1 calculation itself:
280    // summing per-entity `usize` counts first would floor sub-1 expected
281    // failures (e.g. 1416 pairs * 3e-4 fail chance = 0.42) to exactly 0,
282    // silently rounding f1_max up to a false 1.0 once enough columns are
283    // combined via noisy-OR. `guaranteed_fp`/`guaranteed_fn` stay `usize`
284    // in the report (display only); the ratios below use these instead.
285    let mut total_guaranteed_fp_f = 0.0f64;
286    let mut total_guaranteed_fn_f = 0.0f64;
287
288    for plan in &config.entity_plans {
289        let poisoned: std::collections::HashSet<String> = hn_id_fields
290            .get(&plan.name)
291            .map(|v| v.iter().cloned().collect())
292            .unwrap_or_default();
293
294        // Parse columns from columns_json
295        let cols: Vec<ColDefLight> = serde_json::from_str(&plan.columns_json)
296            .map_err(|e| format!("parse columns for '{}': {}", plan.name, e))?;
297
298        // Count HN pairs targeting this entity
299        let hn_pairs: usize = config
300            .hard_neg_types
301            .iter()
302            .filter(|h| h.entity_type == plan.name)
303            .map(|h| h.count)
304            .sum();
305
306        // True duplicate pairs per entity = n_dup / 2 (all duplicates are paired)
307        let n_dup: usize = plan.noise_types.iter().map(|n| n.count).sum();
308        let true_pairs = n_dup / 2;
309
310        // Column analysis: each duplicate is hit by `config.noise_passes`
311        // independent noise_type draws from `plan.noise_types` (see
312        // `pipeline::apply_noise_with_retry` and the multi-pass loop around
313        // it), so a column's chance of being touched *in a single pass* is
314        // the summed weight of the active types that actually target it —
315        // not a flat scalar. Reuses `pipeline::noise_type_targets_column`,
316        // the same predicate real generation uses, so this can't drift from
317        // reality. The chance of being touched at least once across all
318        // passes is `1 - (1 - p_single)^passes`: this — not the raw
319        // single-pass sum — is what actually differentiates a
320        // higher-`passes` tier (e.g. hell) from one with more noise_types
321        // categories active but fewer passes (e.g. medium). Adding
322        // categories to a single pass necessarily *dilutes* every
323        // category's weight (each entry's weight is `1 /
324        // noise_types.len()`), including whichever one happens to guard a
325        // domain's single most reliable column — so more categories alone
326        // can make a tier easier on some schemas. More passes can't: the
327        // union-of-independent-events probability strictly increases with
328        // `passes` regardless of how small any one category's weight is.
329        let n_dup_f = n_dup.max(1) as f64;
330        let passes = config.noise_passes.max(1) as i32;
331        let mut col_reliability = Vec::new();
332        // A real ER pipeline combines evidence across columns instead of
333        // relying on a single "best" one: blocking is typically OR'd
334        // across a few strong candidate keys, and matching then combines
335        // ALL informative surviving columns. We model both stages as
336        // noisy-OR combinations (complement of the product of individual
337        // failure probabilities) rather than a single max, and chain them:
338        // recall = recall_blocking * recall_matching. See the plan in
339        // `nifty-spinning-rocket.md` for the full rationale.
340        const BLOCKING_UTILITY_THRESHOLD: f64 = 0.7;
341        let mut blocking_fn_fail = 1.0f64;
342        let mut matching_fn_fail = 1.0f64;
343        let mut blocking_fp_fail = 1.0f64; // higher = more chance NO strong column catches the HN
344        let mut matching_fp_fail = 1.0f64;
345
346        for col in &cols {
347            let base_damage = base_noise_damage(&col.name, &col.col_type);
348            // A dictionary-encoded column is never
349            // actually reachable by any noise category, regardless of what
350            // `noise_type_targets_column`'s name-pattern predicate would
351            // say: `pipeline::match_noise_columns` filters candidates to
352            // `DataType::Utf8 | DataType::LargeUtf8` *before* consulting
353            // that predicate, and a `Dictionary(Int32, Utf8)` field fails
354            // it. `col.col_type` here is the schema JSON's `"string"`/etc
355            // string (this model has no notion of Arrow types otherwise),
356            // so this check has to be separate — without it, this model
357            // would silently drift from real generation on exactly the
358            // handful of dictionary-encoded columns (`suffix`,
359            // `currency`, `address_type`, ...), same failure mode the doc
360            // comment above already guards against for the noise-type
361            // predicate itself. `resolve_dict_encoded_column` is the single
362            // place this name resolution is written, shared with
363            // `pipeline::build_full_schema`.
364            let is_dict_encoded =
365                crate::fast_template::resolve_dict_encoded_column(&col.name).is_some();
366            let p_single: f64 = if is_dict_encoded {
367                0.0
368            } else {
369                plan.noise_types
370                    .iter()
371                    .filter(|n| {
372                        crate::pipeline::noise_type_targets_column(&n.noise_type, &col.name)
373                    })
374                    .map(|n| n.count as f64 / n_dup_f)
375                    .sum::<f64>()
376                    .min(1.0)
377            };
378            let p_touched = 1.0 - (1.0 - p_single).powi(passes);
379            let damage = base_damage * p_touched;
380            let util = match_utility(&col.name, &col.col_type);
381            let is_hn_id = poisoned.contains(&col.name);
382            let hn_risk = if is_hn_id { 1.0 } else { 0.0 };
383
384            // Reliability for finding TRUE matches: utility × noise survival
385            let rel_fn = util * (1.0 - damage);
386
387            // Reliability for AVOIDING false positives: utility × freedom from HN poisoning
388            let rel_fp = util * (1.0 - hn_risk) * (1.0 - damage);
389
390            if util > 0.0 {
391                matching_fn_fail *= 1.0 - rel_fn;
392                matching_fp_fail *= 1.0 - rel_fp;
393                if util >= BLOCKING_UTILITY_THRESHOLD {
394                    blocking_fn_fail *= 1.0 - rel_fn;
395                    blocking_fp_fail *= 1.0 - rel_fp;
396                }
397            }
398
399            col_reliability.push(ColReliability {
400                name: col.name.clone(),
401                col_type: col.col_type.clone(),
402                noise_damage: damage,
403                hn_risk,
404                reliability: rel_fn.min(rel_fp),
405            });
406        }
407
408        // No column reaches the blocking threshold on this entity: fall
409        // back to the matching-stage combination alone (no separate
410        // blocking gate to model).
411        let recall_blocking = if blocking_fn_fail < 1.0 {
412            1.0 - blocking_fn_fail
413        } else {
414            1.0
415        };
416        let precision_blocking = if blocking_fp_fail < 1.0 {
417            1.0 - blocking_fp_fail
418        } else {
419            1.0
420        };
421        let recall_combined = recall_blocking * (1.0 - matching_fn_fail);
422        let precision_combined = precision_blocking * (1.0 - matching_fp_fail);
423
424        let guaranteed_fp_f = if hn_pairs > 0 {
425            // FP guaranteed when NO combination of reliable columns catches the poison
426            hn_pairs as f64 * (1.0 - precision_combined)
427        } else {
428            0.0
429        };
430        let guaranteed_fn_f = true_pairs as f64 * (1.0 - recall_combined);
431        let guaranteed_fp = guaranteed_fp_f as usize;
432        let guaranteed_fn = guaranteed_fn_f as usize;
433
434        total_true_pairs += true_pairs;
435        total_hard_neg_pairs += hn_pairs;
436        total_guaranteed_fp += guaranteed_fp;
437        total_guaranteed_fn += guaranteed_fn;
438        total_guaranteed_fp_f += guaranteed_fp_f;
439        total_guaranteed_fn_f += guaranteed_fn_f;
440
441        entities.push(EntityDifficulty {
442            name: plan.name.clone(),
443            n_base: plan.n_base,
444            n_dup,
445            true_pairs,
446            hard_neg_pairs: hn_pairs,
447            guaranteed_fp,
448            guaranteed_fn,
449            columns: col_reliability,
450        });
451    }
452
453    let tp = total_true_pairs.max(1) as f64;
454    let fp = total_guaranteed_fp_f;
455    let fn_ = total_guaranteed_fn_f;
456
457    let precision_max = tp / (tp + fp);
458    let recall_max = tp / (tp + fn_);
459    let f1_max = 2.0 * precision_max * recall_max / (precision_max + recall_max);
460
461    Ok(DifficultyReport {
462        domain: domain.to_string(),
463        difficulty: difficulty.to_string(),
464        size,
465        total_true_pairs,
466        total_hard_neg_pairs,
467        total_guaranteed_fp,
468        total_guaranteed_fn,
469        precision_max,
470        recall_max,
471        f1_max,
472        entities,
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::schema::load_schema;
480
481    fn kyc_schema() -> DomainSchema {
482        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas");
483        load_schema("kyc", &dir).expect("load kyc.json")
484    }
485
486    #[test]
487    fn test_estimate_difficulty_basic_shape() {
488        let schema = kyc_schema();
489        let report = estimate_difficulty("kyc", 1000, 42, "medium", 0.1, &schema).unwrap();
490        assert_eq!(report.domain, "kyc");
491        assert_eq!(report.difficulty, "medium");
492        assert_eq!(report.entities.len(), 2);
493        for e in &report.entities {
494            assert!(!e.columns.is_empty());
495        }
496    }
497
498    #[test]
499    fn test_estimate_difficulty_f1_bounds() {
500        let schema = kyc_schema();
501        let report = estimate_difficulty("kyc", 5000, 42, "hell", 0.1, &schema).unwrap();
502        assert!(report.precision_max > 0.0 && report.precision_max <= 1.0);
503        assert!(report.recall_max > 0.0 && report.recall_max <= 1.0);
504        assert!(report.f1_max > 0.0 && report.f1_max <= 1.0);
505    }
506
507    #[test]
508    fn test_estimate_difficulty_hell_harder_than_light() {
509        // "hell" has more noise types active and a lower singleton fraction
510        // than "light" — its theoretical max F1 should never be easier.
511        let schema = kyc_schema();
512        let light = estimate_difficulty("kyc", 5000, 42, "light", 0.1, &schema).unwrap();
513        let hell = estimate_difficulty("kyc", 5000, 42, "hell", 0.1, &schema).unwrap();
514        assert!(hell.f1_max <= light.f1_max);
515    }
516
517    #[test]
518    fn test_estimate_difficulty_tiers_ordered() {
519        // The full light > medium > hell chain, not just the endpoints —
520        // this is the ordering "hard" used to violate before being folded
521        // into "hell" (see the comment on `DIFFICULTY_MAP` in schema.rs).
522        let schema = kyc_schema();
523        let light = estimate_difficulty("kyc", 5000, 42, "light", 0.1, &schema).unwrap();
524        let medium = estimate_difficulty("kyc", 5000, 42, "medium", 0.1, &schema).unwrap();
525        let hell = estimate_difficulty("kyc", 5000, 42, "hell", 0.1, &schema).unwrap();
526        assert!(medium.f1_max <= light.f1_max);
527        assert!(hell.f1_max <= medium.f1_max);
528    }
529
530    #[test]
531    fn test_estimate_difficulty_tiers_ordered_healthcare() {
532        // Same chain as `test_estimate_difficulty_tiers_ordered`, but on the
533        // domain that actually motivated the noise-passes redesign: with
534        // the old "more categories = harder" model (single pass, hell
535        // listing more noise_types than medium), healthcare's medium.f1_max
536        // (0.8971) came out *lower* than hell's (0.9114 with the old
537        // category-duplication patch, worse still without it) — hell
538        // looked easier than medium on this schema specifically, because
539        // healthcare's most reliable column across entities happens to be
540        // protected by a category ("dates") that got diluted by hell's
541        // larger category list. The compounding passes model must not
542        // regress this.
543        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas");
544        let schema = load_schema("healthcare", &dir).expect("load healthcare.json");
545        let light = estimate_difficulty("healthcare", 5000, 42, "light", 0.1, &schema).unwrap();
546        let medium = estimate_difficulty("healthcare", 5000, 42, "medium", 0.1, &schema).unwrap();
547        let hell = estimate_difficulty("healthcare", 5000, 42, "hell", 0.1, &schema).unwrap();
548        assert!(medium.f1_max <= light.f1_max);
549        assert!(hell.f1_max <= medium.f1_max);
550    }
551
552    #[test]
553    fn test_estimate_difficulty_tiers_ordered_aviation() {
554        // Aviation exposed a second, distinct root cause from the
555        // categories/passes one above: `n_hard_neg` used to be a flat
556        // `size * hard_neg_ratio * 0.05`, independent of tier, while
557        // `total_true_pairs` scales a lot with the tier's singleton/doublet
558        // split (light ~0.28*size, medium ~0.40*size, hell ~0.57*size).
559        // With guaranteed_fp roughly constant across tiers and true_pairs
560        // growing, `precision_max = tp/(tp+fp)` mechanically rose with
561        // duplication volume regardless of injected noise — enough to flip
562        // light (0.8280) below medium (0.8328) on this schema specifically.
563        // Fixed by scaling `n_hard_neg` off `n_duplicates` instead of raw
564        // `size` (`schema::build_pipeline_config`), which keeps the
565        // fp/tp ratio comparable across tiers.
566        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas");
567        let schema = load_schema("aviation", &dir).expect("load aviation.json");
568        let light = estimate_difficulty("aviation", 5000, 42, "light", 0.1, &schema).unwrap();
569        let medium = estimate_difficulty("aviation", 5000, 42, "medium", 0.1, &schema).unwrap();
570        let hell = estimate_difficulty("aviation", 5000, 42, "hell", 0.1, &schema).unwrap();
571        assert!(medium.f1_max <= light.f1_max);
572        assert!(hell.f1_max <= medium.f1_max);
573    }
574
575    #[test]
576    fn test_estimate_difficulty_tiers_ordered_all_domains() {
577        // The kyc/healthcare/aviation tests above each caught a real,
578        // distinct bug -- but each only covered one hand-picked domain.
579        // That gap is exactly what let 19/40 domains violate
580        // `medium <= light` (categories dilution: medium's 4 equally-
581        // weighted noise_types, at passes=1, diluted below light's 2)
582        // stay hidden until scripts/validate_all_domains.py swept all 40
583        // schemas. This test is the permanent, in-repo version of that
584        // sweep so a future schema/passes change can't reintroduce it
585        // without `cargo test` catching it immediately.
586        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas");
587        let eps = 1e-6;
588        let mut violations = Vec::new();
589        for entry in std::fs::read_dir(&dir).expect("read schemas dir") {
590            let path = entry.expect("dir entry").path();
591            if path.extension().and_then(|e| e.to_str()) != Some("json") {
592                continue;
593            }
594            let domain = path.file_stem().unwrap().to_str().unwrap().to_string();
595            let schema = load_schema(&domain, &dir).expect("load schema");
596            let light = estimate_difficulty(&domain, 5000, 42, "light", 0.1, &schema).unwrap();
597            let medium = estimate_difficulty(&domain, 5000, 42, "medium", 0.1, &schema).unwrap();
598            let hell = estimate_difficulty(&domain, 5000, 42, "hell", 0.1, &schema).unwrap();
599            if medium.f1_max > light.f1_max + eps {
600                violations.push(format!(
601                    "{domain}: medium ({:.4}) > light ({:.4})",
602                    medium.f1_max, light.f1_max
603                ));
604            }
605            if hell.f1_max > medium.f1_max + eps {
606                violations.push(format!(
607                    "{domain}: hell ({:.4}) > medium ({:.4})",
608                    hell.f1_max, medium.f1_max
609                ));
610            }
611        }
612        assert!(
613            violations.is_empty(),
614            "monotonicity violations:\n{}",
615            violations.join("\n")
616        );
617    }
618
619    #[test]
620    fn test_estimate_difficulty_deterministic() {
621        let schema = kyc_schema();
622        let a = estimate_difficulty("kyc", 1000, 42, "medium", 0.1, &schema).unwrap();
623        let b = estimate_difficulty("kyc", 1000, 42, "medium", 0.1, &schema).unwrap();
624        assert_eq!(a.total_true_pairs, b.total_true_pairs);
625        assert_eq!(a.f1_max, b.f1_max);
626    }
627
628    #[test]
629    fn test_estimate_difficulty_zero_hard_neg_ratio_no_fp() {
630        let schema = kyc_schema();
631        let report = estimate_difficulty("kyc", 1000, 42, "medium", 0.0, &schema).unwrap();
632        assert_eq!(report.total_hard_neg_pairs, 0);
633        assert_eq!(report.total_guaranteed_fp, 0);
634    }
635}