1use 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 #[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: &'static [&'static str],
64 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 passes: 3,
118 },
119 ),
120 (
121 "hell",
122 DifficultySettings {
123 singleton: 0.10,
124 doublet: 0.20,
125 noise_types: &[
131 "typo_aggressive",
132 "visual",
133 "unicode_pollution",
134 "names",
135 "dates",
136 "identifiers",
137 "addresses",
138 "companies",
139 "extra",
140 ],
141 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
169pub 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#[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
209pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
214 let path = schemas_dir.join(format!("{domain}.json"));
215 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
237fn 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#[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 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 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 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 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 #[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 assert_eq!(
594 default_singleton_master_fraction("bogus"),
595 default_singleton_master_fraction("medium")
596 );
597 }
598}