1use std::collections::HashMap;
7use std::path::Path;
8
9use crate::pipeline::PipelineConfig;
10
11#[derive(serde::Deserialize)]
12pub struct DomainSchema {
13 pub entities: Vec<EntitySchema>,
14 pub hn_types: Vec<HnSchema>,
15}
16
17fn default_entity_weight() -> f64 {
18 1.0
19}
20
21fn infer_identifier_col(entity: &EntitySchema) -> Option<String> {
24 let entity_id_name = format!("{}_id", entity.name);
25 let col_names: Vec<&str> = entity
26 .columns
27 .iter()
28 .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
29 .collect();
30 if col_names.contains(&entity_id_name.as_str()) {
31 Some(entity_id_name)
32 } else if col_names.contains(&"id") {
33 Some("id".to_string())
34 } else {
35 col_names
36 .iter()
37 .find(|n| n.ends_with("_id"))
38 .map(|n| (*n).to_string())
39 }
40}
41
42#[derive(serde::Deserialize)]
43pub struct EntitySchema {
44 pub name: String,
45 pub columns: Vec<serde_json::Value>,
46 #[serde(default)]
47 pub fk_remaps: Vec<serde_json::Value>,
48 #[serde(default = "default_entity_weight")]
59 pub weight: f64,
60}
61
62#[derive(serde::Deserialize)]
63pub struct HnSchema {
64 pub entity_type: String,
65 pub config_json: String,
66}
67
68#[derive(Debug, Clone)]
69struct DifficultySettings {
70 singleton: f64,
71 doublet: f64,
72 noise_types: &'static [&'static str],
84 passes: usize,
108}
109
110const DIFFICULTY_MAP: [(&str, DifficultySettings); 3] = [
111 (
112 "light",
113 DifficultySettings {
114 singleton: 0.50,
115 doublet: 0.30,
116 noise_types: &["names", "dates"],
117 passes: 1,
118 },
119 ),
120 (
121 "medium",
122 DifficultySettings {
123 singleton: 0.30,
124 doublet: 0.40,
125 noise_types: &["typo", "names", "dates", "identifiers"],
126 passes: 3,
138 },
139 ),
140 (
141 "hell",
142 DifficultySettings {
143 singleton: 0.10,
144 doublet: 0.20,
145 noise_types: &[
151 "typo_aggressive",
152 "visual",
153 "unicode_pollution",
154 "names",
155 "dates",
156 "identifiers",
157 "addresses",
158 "companies",
159 "extra",
160 ],
161 passes: 8,
173 },
174 ),
175];
176
177pub fn default_singleton_master_fraction(difficulty: &str) -> f64 {
178 difficulty_settings(difficulty).singleton
179}
180
181fn difficulty_settings(difficulty: &str) -> DifficultySettings {
182 DIFFICULTY_MAP
183 .iter()
184 .find(|(name, _)| *name == difficulty)
185 .map(|(_, s)| s.clone())
186 .unwrap_or_else(|| DIFFICULTY_MAP[1].1.clone())
187}
188
189pub fn valid_difficulty_names() -> Vec<&'static str> {
196 DIFFICULTY_MAP.iter().map(|(name, _)| *name).collect()
197}
198
199pub fn chrono_now() -> String {
201 let start = std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .unwrap_or_default();
204 format!("{:x}", start.as_secs())
205}
206
207#[allow(clippy::too_many_arguments)]
218pub fn deterministic_run_id(
219 domain: &str,
220 size: usize,
221 seed: u64,
222 difficulty: &str,
223 hard_neg_ratio: f64,
224 singleton_master_fraction: f64,
225 locale: &str,
226 only_entity: Option<&str>,
227 chunk_size: Option<usize>,
228) -> String {
229 use sha2::{Digest, Sha256};
238 let mut h = Sha256::new();
239 let put_str = |h: &mut Sha256, s: &str| {
240 h.update((s.len() as u64).to_le_bytes());
241 h.update(s.as_bytes());
242 };
243 put_str(&mut h, domain);
244 h.update((size as u64).to_le_bytes());
245 h.update(seed.to_le_bytes());
246 put_str(&mut h, difficulty);
247 h.update(hard_neg_ratio.to_bits().to_le_bytes());
248 h.update(singleton_master_fraction.to_bits().to_le_bytes());
249 put_str(&mut h, locale);
250 put_str(&mut h, only_entity.unwrap_or(""));
251 h.update((chunk_size.unwrap_or(0) as u64).to_le_bytes());
252 let digest = h.finalize();
253 let id = u64::from_be_bytes(digest[..8].try_into().expect("SHA-256 digest is 32 bytes"));
254 format!("{domain}_{id:016x}")
255}
256
257pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
262 let path = schemas_dir.join(format!("{domain}.json"));
263 let available = available_domain_names(schemas_dir);
269 if !available.iter().any(|d| d == domain) {
270 let hint = if available.is_empty() {
271 "no schemas found".to_string()
272 } else {
273 available.join(", ")
274 };
275 return Err(format!(
276 "schema file not found for domain '{domain}' at {path:?}. \
277 Available domains ({hint})"
278 ));
279 }
280 let data =
281 std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
282 serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
283}
284
285fn available_domain_names(dir: &Path) -> Vec<String> {
287 match std::fs::read_dir(dir) {
288 Ok(entries) => entries
289 .filter_map(|e| e.ok())
290 .filter(|e| {
291 e.path()
292 .extension()
293 .map(|ext| ext == "json")
294 .unwrap_or(false)
295 })
296 .filter_map(|e| {
297 e.path()
298 .file_stem()
299 .map(|s| s.to_string_lossy().to_string())
300 })
301 .collect(),
302 Err(_) => Vec::new(),
303 }
304}
305
306#[allow(clippy::too_many_arguments)]
311pub fn build_pipeline_config(
312 domain: &str,
313 size: usize,
314 seed: u64,
315 difficulty: &str,
316 hard_neg_ratio: f64,
317 singleton_master_fraction: f64,
318 schema: &DomainSchema,
319 run_id: &str,
320 output_format: &str,
321 graph_enabled: bool,
322 graph_format: &str,
323 only_entity: Option<&str>,
324) -> Result<PipelineConfig, String> {
325 if size < 10 {
326 return Err(format!("size must be >= 10, got {size}"));
327 }
328 if schema.entities.is_empty() {
329 return Err(format!("schema for domain '{domain}' has no entities"));
330 }
331 if !(0.0..=1.0).contains(&singleton_master_fraction) {
332 return Err(format!(
333 "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
334 ));
335 }
336 if let Some(target) = only_entity
337 && !schema.entities.iter().any(|e| e.name == target)
338 {
339 let available: Vec<&str> = schema.entities.iter().map(|e| e.name.as_str()).collect();
340 return Err(format!(
341 "only_entity '{target}' not found in domain '{domain}'; available entities: {}",
342 available.join(", ")
343 ));
344 }
345 let ds = difficulty_settings(difficulty);
346 let total = size;
347
348 let n_singleton = (total as f64 * singleton_master_fraction) as usize;
349 let n_doublet_float = total as f64 * ds.doublet;
350 let mut n_doublet = n_doublet_float as usize;
351 if !n_doublet.is_multiple_of(2) {
352 n_doublet -= 1;
353 }
354 if n_singleton + n_doublet > total {
355 return Err(format!(
356 "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
357 difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
358 ds.doublet
359 ));
360 }
361 let mut n_triplet = total - n_singleton - n_doublet;
362 let r = n_triplet % 3;
363 if r != 0 {
364 n_triplet -= r;
365 }
366 let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
367 let n_duplicates = total.max(total_unique) - total_unique;
368
369 let active_entities: Vec<&EntitySchema> = match only_entity {
376 Some(target) => schema
377 .entities
378 .iter()
379 .filter(|e| e.name == target)
380 .collect(),
381 None => schema.entities.iter().collect(),
382 };
383
384 let total_ratio: f64 = active_entities.iter().map(|e| e.weight).sum::<f64>();
385 let raw_floats: Vec<(&str, f64)> = active_entities
386 .iter()
387 .map(|e| {
388 (
389 e.name.as_str(),
390 total_unique as f64 * e.weight / total_ratio,
391 )
392 })
393 .collect();
394
395 let n_entities = active_entities.len();
409 let mut floor_map: HashMap<&str, usize> = HashMap::new();
410 if total_unique >= 2 * n_entities {
411 for (name, r) in &raw_floats {
412 floor_map.insert(name, r.max(2.0) as usize);
413 }
414 } else {
415 for (name, r) in &raw_floats {
416 floor_map.insert(name, r.floor() as usize);
417 }
418 }
419 let floor_sum: usize = floor_map.values().sum();
420 let need = total_unique.max(floor_sum) - floor_sum;
421 if need > 0 {
422 let mut remainders: Vec<(&str, f64)> = raw_floats
423 .iter()
424 .map(|(n, r)| (*n, r - r.floor()))
425 .collect();
426 remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
427 for (name, _) in remainders.iter().take(need) {
428 *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
429 }
430 }
431 let mut excess = floor_sum.saturating_sub(total_unique);
438 while excess > 0 {
439 let Some((&top, _)) = floor_map
440 .iter()
441 .filter(|&(_, &v)| v > 2)
442 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
443 else {
444 break;
445 };
446 *floor_map.get_mut(top).expect("key taken from the map") -= 1;
447 excess -= 1;
448 }
449
450 let dup_ratios: Vec<(&str, f64)> = active_entities
451 .iter()
452 .map(|e| {
453 (
454 e.name.as_str(),
455 *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
456 )
457 })
458 .collect();
459 let mut dup_floor: HashMap<&str, usize> = HashMap::new();
460 for (name, r) in &dup_ratios {
461 dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
462 }
463 let dup_sum: usize = dup_floor.values().sum();
464 let dup_need = n_duplicates.max(dup_sum) - dup_sum;
465 if dup_need > 0 {
466 let mut remainders: Vec<(&str, f64)> = dup_ratios
467 .iter()
468 .map(|(n, r)| (*n, r - r.floor()))
469 .collect();
470 remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
471 for (name, _) in remainders.iter().take(dup_need) {
472 *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
473 }
474 }
475
476 let noise_count = ds.noise_types.len();
477 let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
478
479 let mut entity_plans = Vec::new();
480 for entity in &active_entities {
481 let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
482 let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
483
484 let mut noise_entries = Vec::new();
485 if n_dup > 0 {
486 let mut counts: Vec<usize> = noise_weights
487 .iter()
488 .map(|w| (w * n_dup as f64) as usize)
489 .collect();
490 let count_sum: usize = counts.iter().sum();
491 if count_sum < n_dup {
492 *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
493 }
494 for (i, noise_type) in ds.noise_types.iter().enumerate() {
495 if counts[i] == 0 {
496 continue;
497 }
498 noise_entries.push(serde_json::json!({
499 "noise_type": noise_type,
500 "columns": [],
501 "count": counts[i],
502 }));
503 }
504 }
505
506 let identifier_col = infer_identifier_col(entity);
507
508 let columns_json = serde_json::to_string(&entity.columns)
509 .map_err(|e| format!("serialize columns: {e}"))?;
510
511 entity_plans.push(serde_json::json!({
512 "name": entity.name,
513 "n_base": n_base,
514 "n_dup": n_dup,
515 "identifier_col": identifier_col,
516 "columns_json": columns_json,
517 "noise_types": noise_entries,
518 "fk_remaps": entity.fk_remaps,
519 }));
520 }
521
522 if let Some(target) = only_entity {
529 let target_schema = schema
530 .entities
531 .iter()
532 .find(|e| e.name == target)
533 .expect("only_entity validated to exist above");
534 let mut pool_target_names: Vec<&str> = target_schema
535 .fk_remaps
536 .iter()
537 .filter_map(|r| r.get("target_entity").and_then(|v| v.as_str()))
538 .filter(|&t| t != target)
539 .collect();
540 pool_target_names.sort_unstable();
541 pool_target_names.dedup();
542
543 let pool_n = crate::pipeline::FK_POOL_CAP.min(total_unique.max(2));
544 for name in pool_target_names {
545 let Some(entity) = schema.entities.iter().find(|e| e.name == name) else {
546 continue;
547 };
548 let identifier_col = infer_identifier_col(entity);
549 let columns_json = serde_json::to_string(&entity.columns)
550 .map_err(|e| format!("serialize columns: {e}"))?;
551 entity_plans.push(serde_json::json!({
552 "name": entity.name,
553 "n_base": pool_n,
554 "n_dup": 0,
555 "identifier_col": identifier_col,
556 "columns_json": columns_json,
557 "noise_types": [],
558 "fk_remaps": entity.fk_remaps,
559 "pool_only": true,
560 }));
561 }
562 }
563
564 let active_hn_types: Vec<&HnSchema> = schema
568 .hn_types
569 .iter()
570 .filter(|hn| only_entity.is_none_or(|t| hn.entity_type == t))
571 .collect();
572
573 let n_hard_neg = (n_duplicates as f64 * hard_neg_ratio * 0.125) as usize;
586 let hn_per_type = n_hard_neg / active_hn_types.len().max(1);
587 let hard_neg_types: Vec<serde_json::Value> = active_hn_types
588 .iter()
589 .map(|hn| {
590 serde_json::json!({
591 "entity_type": hn.entity_type,
592 "config_json": hn.config_json,
593 "count": hn_per_type,
594 })
595 })
596 .collect();
597
598 let config = serde_json::json!({
599 "domain": domain,
600 "size": size,
601 "seed": seed,
602 "difficulty": difficulty,
603 "output_format": output_format,
604 "run_id": run_id,
605 "entity_plans": entity_plans,
606 "hard_neg_types": hard_neg_types,
607 "hard_neg_ratio": hard_neg_ratio,
608 "noise_passes": ds.passes,
609 "graph_enabled": graph_enabled,
610 "graph_format": graph_format,
611 });
612
613 serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn schemas_dir() -> std::path::PathBuf {
621 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas")
622 }
623
624 fn kyc_schema() -> DomainSchema {
625 load_schema("kyc", &schemas_dir()).expect("load kyc.json")
626 }
627
628 fn aviation_schema() -> DomainSchema {
629 load_schema("aviation", &schemas_dir()).expect("load aviation.json")
630 }
631
632 fn sports_schema() -> DomainSchema {
633 load_schema("sports", &schemas_dir()).expect("load sports.json")
634 }
635
636 #[test]
637 fn test_valid_difficulty_names() {
638 assert_eq!(valid_difficulty_names(), vec!["light", "medium", "hell"]);
639 }
640
641 #[test]
642 fn test_load_schema_known_domain() {
643 let schema = kyc_schema();
644 assert_eq!(schema.entities.len(), 2);
645 assert!(schema.entities.iter().any(|e| e.name == "natural_person"));
646 assert!(!schema.hn_types.is_empty());
647 }
648
649 #[test]
650 fn test_load_schema_unknown_domain_lists_available() {
651 let err = load_schema("not-a-real-domain", &schemas_dir())
652 .err()
653 .unwrap();
654 assert!(err.contains("not-a-real-domain"));
655 assert!(err.contains("kyc"));
656 }
657
658 #[test]
659 fn test_load_schema_case_sensitive() {
660 let err = load_schema("KYC", &schemas_dir()).err().unwrap();
663 assert!(err.contains("KYC"));
664 }
665
666 #[test]
667 fn test_build_pipeline_config_basic() {
668 let schema = kyc_schema();
669 let config = build_pipeline_config(
670 "kyc", 1000, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
671 None,
672 )
673 .expect("build config");
674 assert_eq!(config.domain, "kyc");
675 assert_eq!(config.size, 1000);
676 assert_eq!(config.entity_plans.len(), 2);
677 assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
679 assert!(!config.hard_neg_types.is_empty());
680 }
681
682 #[test]
683 fn test_build_pipeline_config_small_size_many_entities_does_not_overshoot() {
684 let schema = sports_schema();
692 assert_eq!(
693 schema.entities.len(),
694 7,
695 "this test assumes sports has 7 entities"
696 );
697 let size = 20;
698 let config = build_pipeline_config(
699 "sports",
700 size,
701 42,
702 "hell",
703 0.1,
704 0.10,
705 &schema,
706 "sports_test",
707 "parquet",
708 false,
709 "parquet",
710 None,
711 )
712 .expect("build config");
713 let n_base_sum: usize = config.entity_plans.iter().map(|p| p.n_base).sum();
714 assert!(
718 n_base_sum <= size,
719 "n_base sum {n_base_sum} overshoots requested size {size}"
720 );
721 }
722
723 #[test]
724 fn test_build_pipeline_config_rows_match_size_on_every_schema() {
725 let mut checked = 0;
729 for entry in std::fs::read_dir(schemas_dir()).unwrap() {
730 let path = entry.unwrap().path();
731 if path.extension().is_none_or(|e| e != "json") {
732 continue;
733 }
734 let domain = path.file_stem().unwrap().to_string_lossy().to_string();
735 let schema = load_schema(&domain, &schemas_dir()).unwrap();
736 for difficulty in valid_difficulty_names() {
737 for size in [10, 11, 20, 30, 57, 100, 333, 1000] {
738 let config = build_pipeline_config(
739 &domain,
740 size,
741 42,
742 difficulty,
743 0.3,
744 default_singleton_master_fraction(difficulty),
745 &schema,
746 "t",
747 "parquet",
748 false,
749 "parquet",
750 None,
751 )
752 .unwrap();
753 let rows: usize = config
754 .entity_plans
755 .iter()
756 .map(|p| p.n_base + p.noise_types.iter().map(|n| n.count).sum::<usize>())
757 .sum();
758 assert_eq!(rows, size, "{domain} {difficulty} --size {size}");
759 checked += 1;
760 }
761 }
762 }
763 assert!(checked > 0);
764 }
765
766 #[test]
767 fn test_build_pipeline_config_normal_size_still_floors_at_2() {
768 let schema = sports_schema();
771 let config = build_pipeline_config(
772 "sports",
773 100_000,
774 42,
775 "hell",
776 0.1,
777 0.10,
778 &schema,
779 "sports_test2",
780 "parquet",
781 false,
782 "parquet",
783 None,
784 )
785 .expect("build config");
786 assert!(config.entity_plans.iter().all(|p| p.n_base >= 2));
787 }
788
789 #[test]
790 fn test_build_pipeline_config_rejects_size_below_10() {
791 let schema = kyc_schema();
792 let err = build_pipeline_config(
793 "kyc", 5, 42, "medium", 0.1, 0.3, &schema, "kyc_test", "parquet", false, "parquet",
794 None,
795 )
796 .unwrap_err();
797 assert!(err.contains("size must be >= 10"));
798 }
799
800 #[test]
801 fn test_build_pipeline_config_rejects_invalid_singleton_fraction() {
802 let schema = kyc_schema();
803 let err = build_pipeline_config(
804 "kyc", 1000, 42, "medium", 0.1, 1.5, &schema, "kyc_test", "parquet", false, "parquet",
805 None,
806 )
807 .unwrap_err();
808 assert!(err.contains("singleton_master_fraction"));
809 }
810
811 #[test]
812 fn test_build_pipeline_config_deterministic() {
813 let schema = kyc_schema();
814 let build = || {
815 build_pipeline_config(
816 "kyc", 1000, 42, "hell", 0.1, 0.2, &schema, "kyc_test", "parquet", false,
817 "parquet", None,
818 )
819 .expect("build config")
820 };
821 let a = build();
822 let b = build();
823 assert_eq!(a.entity_plans.len(), b.entity_plans.len());
824 for (pa, pb) in a.entity_plans.iter().zip(b.entity_plans.iter()) {
825 assert_eq!(pa.n_base, pb.n_base);
826 assert_eq!(pa.noise_types.len(), pb.noise_types.len());
827 }
828 }
829
830 #[test]
834 fn test_build_pipeline_config_only_entity_no_fk() {
835 let schema = kyc_schema();
836 let config = build_pipeline_config(
837 "kyc",
838 1000,
839 42,
840 "medium",
841 0.1,
842 0.3,
843 &schema,
844 "kyc_test",
845 "parquet",
846 false,
847 "parquet",
848 Some("natural_person"),
849 )
850 .expect("build config");
851 assert_eq!(config.entity_plans.len(), 1);
852 let plan = &config.entity_plans[0];
853 assert_eq!(plan.name, "natural_person");
854 assert!(!plan.pool_only);
855 assert!(plan.n_base > 100);
859 assert!(
860 config
861 .hard_neg_types
862 .iter()
863 .all(|hn| hn.entity_type == "natural_person")
864 );
865 }
866
867 #[test]
874 fn test_build_pipeline_config_only_entity_with_fk_pool() {
875 let schema = aviation_schema();
876 let config = build_pipeline_config(
877 "aviation",
878 10_000,
879 42,
880 "medium",
881 0.1,
882 0.3,
883 &schema,
884 "aviation_test",
885 "parquet",
886 false,
887 "parquet",
888 Some("aircraft"),
889 )
890 .expect("build config");
891 assert_eq!(config.entity_plans.len(), 2);
893 let target = config
894 .entity_plans
895 .iter()
896 .find(|p| p.name == "aircraft")
897 .expect("aircraft plan present");
898 assert!(!target.pool_only);
899 assert!(target.n_base > 1_000);
900 let pool = config
901 .entity_plans
902 .iter()
903 .find(|p| p.name == "airline")
904 .expect("airline pool-only plan present");
905 assert!(pool.pool_only);
906 assert!(pool.noise_types.is_empty());
907 assert!(pool.n_base <= crate::pipeline::FK_POOL_CAP);
909 }
910
911 #[test]
912 fn test_build_pipeline_config_only_entity_unknown_name() {
913 let schema = kyc_schema();
914 let err = build_pipeline_config(
915 "kyc",
916 1000,
917 42,
918 "medium",
919 0.1,
920 0.3,
921 &schema,
922 "kyc_test",
923 "parquet",
924 false,
925 "parquet",
926 Some("not_a_real_entity"),
927 )
928 .unwrap_err();
929 assert!(err.contains("not_a_real_entity"));
930 assert!(err.contains("natural_person"));
931 }
932
933 #[test]
934 fn test_deterministic_run_id_stable_and_sensitive() {
935 let a = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
936 let b = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
937 assert_eq!(a, b);
938 let c = deterministic_run_id("kyc", 1000, 43, "medium", 0.1, 0.3, "en", None, None);
939 assert_ne!(a, c);
940 }
941
942 #[test]
947 fn test_deterministic_run_id_sensitive_to_singleton_fraction_and_locale() {
948 let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
949 let diff_fraction =
950 deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.5, "en", None, None);
951 let diff_locale =
952 deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "fr", None, None);
953 assert_ne!(base, diff_fraction);
954 assert_ne!(base, diff_locale);
955 assert_ne!(diff_fraction, diff_locale);
956 }
957
958 #[test]
963 fn test_deterministic_run_id_sensitive_to_only_entity() {
964 let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
965 let only_a = deterministic_run_id(
966 "kyc",
967 1000,
968 42,
969 "medium",
970 0.1,
971 0.3,
972 "en",
973 Some("natural_person"),
974 None,
975 );
976 let only_b = deterministic_run_id(
977 "kyc",
978 1000,
979 42,
980 "medium",
981 0.1,
982 0.3,
983 "en",
984 Some("legal_entity"),
985 None,
986 );
987 assert_ne!(base, only_a);
988 assert_ne!(base, only_b);
989 assert_ne!(only_a, only_b);
990 }
991
992 #[test]
998 fn test_deterministic_run_id_sensitive_to_chunk_size() {
999 let base = deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, None);
1000 let chunked_a =
1001 deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(500));
1002 let chunked_b =
1003 deterministic_run_id("kyc", 1000, 42, "medium", 0.1, 0.3, "en", None, Some(250));
1004 assert_ne!(base, chunked_a);
1005 assert_ne!(base, chunked_b);
1006 assert_ne!(chunked_a, chunked_b);
1007 }
1008
1009 #[test]
1010 fn test_default_singleton_master_fraction_known_and_unknown() {
1011 assert_eq!(default_singleton_master_fraction("light"), 0.50);
1012 assert_eq!(default_singleton_master_fraction("hell"), 0.10);
1013 assert_eq!(
1015 default_singleton_master_fraction("bogus"),
1016 default_singleton_master_fraction("medium")
1017 );
1018 }
1019}