1use serde::Serialize;
8
9use crate::schema::{DomainSchema, build_pipeline_config};
10
11fn contains_any(s: &str, patterns: &[&str]) -> bool {
13 patterns.iter().any(|p| s.contains(p))
14}
15
16fn 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
43fn 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 0.4
95 }
96 }
97 }
98}
99
100fn 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#[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#[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 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 let cols: Vec<ColDefLight> = serde_json::from_str(&plan.columns_json)
269 .map_err(|e| format!("parse columns for '{}': {}", plan.name, e))?;
270
271 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 let n_dup: usize = plan.noise_types.iter().map(|n| n.count).sum();
281 let true_pairs = n_dup / 2;
282
283 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; 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 let rel_fn = util * (1.0 - damage);
309
310 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 (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}