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
18#[derive(serde::Deserialize)]
19pub struct EntitySchema {
20 pub name: String,
21 pub columns: Vec<serde_json::Value>,
22 #[serde(default)]
23 pub fk_remaps: Vec<serde_json::Value>,
24}
25
26#[derive(serde::Deserialize)]
27pub struct HnSchema {
28 pub entity_type: String,
29 pub config_json: String,
30}
31
32#[derive(Debug, Clone)]
33struct DifficultySettings {
34 singleton: f64,
35 doublet: f64,
36 noise_types: &'static [&'static str],
48}
49
50const DIFFICULTY_MAP: [(&str, DifficultySettings); 4] = [
51 (
52 "light",
53 DifficultySettings {
54 singleton: 0.50,
55 doublet: 0.30,
56 noise_types: &["names", "dates"],
57 },
58 ),
59 (
60 "medium",
61 DifficultySettings {
62 singleton: 0.30,
63 doublet: 0.40,
64 noise_types: &["typo", "names", "dates", "identifiers"],
65 },
66 ),
67 (
68 "hard",
69 DifficultySettings {
70 singleton: 0.20,
71 doublet: 0.30,
72 noise_types: &[
73 "typo",
74 "visual",
75 "names",
76 "dates",
77 "identifiers",
78 "addresses",
79 "companies",
80 "extra",
81 ],
82 },
83 ),
84 (
85 "hell",
86 DifficultySettings {
87 singleton: 0.10,
88 doublet: 0.20,
89 noise_types: &[
90 "typo",
91 "visual",
92 "names",
93 "dates",
94 "identifiers",
95 "addresses",
96 "companies",
97 "extra",
98 ],
99 },
100 ),
101];
102
103pub fn default_singleton_master_fraction(difficulty: &str) -> f64 {
104 difficulty_settings(difficulty).singleton
105}
106
107fn difficulty_settings(difficulty: &str) -> DifficultySettings {
108 DIFFICULTY_MAP
109 .iter()
110 .find(|(name, _)| *name == difficulty)
111 .map(|(_, s)| s.clone())
112 .unwrap_or_else(|| DIFFICULTY_MAP[1].1.clone())
113}
114
115pub fn chrono_now() -> String {
117 let start = std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .unwrap_or_default();
120 format!("{:x}", start.as_secs())
121}
122
123pub fn deterministic_run_id(
128 domain: &str,
129 size: usize,
130 seed: u64,
131 difficulty: &str,
132 hard_neg_ratio: f64,
133) -> String {
134 use std::hash::{Hash, Hasher};
135 let mut hasher = std::collections::hash_map::DefaultHasher::new();
136 domain.hash(&mut hasher);
137 size.hash(&mut hasher);
138 seed.hash(&mut hasher);
139 difficulty.hash(&mut hasher);
140 hard_neg_ratio.to_bits().hash(&mut hasher);
141 format!("{}_{:x}", domain, hasher.finish())
142}
143
144pub fn load_schema(domain: &str, schemas_dir: &Path) -> Result<DomainSchema, String> {
149 let path = schemas_dir.join(format!("{domain}.json"));
150 let available = available_domain_names(schemas_dir);
156 if !available.iter().any(|d| d == domain) {
157 let hint = if available.is_empty() {
158 "no schemas found".to_string()
159 } else {
160 available.join(", ")
161 };
162 return Err(format!(
163 "schema file not found for domain '{domain}' at {path:?}. \
164 Available domains ({hint})"
165 ));
166 }
167 let data =
168 std::fs::read_to_string(&path).map_err(|e| format!("cannot read schema {path:?}: {e}"))?;
169 serde_json::from_str(&data).map_err(|e| format!("cannot parse schema {domain}.json: {e}"))
170}
171
172fn available_domain_names(dir: &Path) -> Vec<String> {
174 match std::fs::read_dir(dir) {
175 Ok(entries) => entries
176 .filter_map(|e| e.ok())
177 .filter(|e| {
178 e.path()
179 .extension()
180 .map(|ext| ext == "json")
181 .unwrap_or(false)
182 })
183 .filter_map(|e| {
184 e.path()
185 .file_stem()
186 .map(|s| s.to_string_lossy().to_string())
187 })
188 .collect(),
189 Err(_) => Vec::new(),
190 }
191}
192
193#[allow(clippy::too_many_arguments)]
198pub fn build_pipeline_config(
199 domain: &str,
200 size: usize,
201 seed: u64,
202 difficulty: &str,
203 hard_neg_ratio: f64,
204 singleton_master_fraction: f64,
205 schema: &DomainSchema,
206 run_id: &str,
207 output_format: &str,
208 graph_enabled: bool,
209 graph_format: &str,
210) -> Result<PipelineConfig, String> {
211 if size < 10 {
212 return Err(format!("size must be >= 10, got {size}"));
213 }
214 if schema.entities.is_empty() {
215 return Err(format!("schema for domain '{domain}' has no entities"));
216 }
217 if !(0.0..=1.0).contains(&singleton_master_fraction) {
218 return Err(format!(
219 "singleton_master_fraction must be in [0.0, 1.0], got {singleton_master_fraction}"
220 ));
221 }
222 let ds = difficulty_settings(difficulty);
223 let total = size;
224
225 let n_singleton = (total as f64 * singleton_master_fraction) as usize;
226 let n_doublet_float = total as f64 * ds.doublet;
227 let mut n_doublet = n_doublet_float as usize;
228 if !n_doublet.is_multiple_of(2) {
229 n_doublet -= 1;
230 }
231 if n_singleton + n_doublet > total {
232 return Err(format!(
233 "singleton_master_fraction {singleton_master_fraction} leaves no room for this \
234 difficulty's doublet share ({:.2}); reduce singleton_master_fraction",
235 ds.doublet
236 ));
237 }
238 let mut n_triplet = total - n_singleton - n_doublet;
239 let r = n_triplet % 3;
240 if r != 0 {
241 n_triplet -= r;
242 }
243 let total_unique = n_singleton + n_doublet / 2 + n_triplet / 3;
244 let n_duplicates = total.max(total_unique) - total_unique;
245
246 let total_ratio: f64 = schema.entities.iter().map(|_| 1.0).sum::<f64>();
247 let raw_floats: Vec<(&str, f64)> = schema
248 .entities
249 .iter()
250 .map(|e| (e.name.as_str(), total_unique as f64 / total_ratio))
251 .collect();
252
253 let mut floor_map: HashMap<&str, usize> = HashMap::new();
254 for (name, r) in &raw_floats {
255 floor_map.insert(name, r.max(2.0) as usize);
256 }
257 let floor_sum: usize = floor_map.values().sum();
258 let need = total_unique.max(floor_sum) - floor_sum;
259 if need > 0 {
260 let mut remainders: Vec<(&str, f64)> = raw_floats
261 .iter()
262 .map(|(n, r)| (*n, r - r.floor()))
263 .collect();
264 remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
265 for (name, _) in remainders.iter().take(need) {
266 *floor_map.get_mut(name).unwrap_or(&mut 0) += 1;
267 }
268 }
269
270 let dup_ratios: Vec<(&str, f64)> = schema
271 .entities
272 .iter()
273 .map(|e| {
274 (
275 e.name.as_str(),
276 *floor_map.get(e.name.as_str()).unwrap_or(&2) as f64 / total_unique as f64,
277 )
278 })
279 .collect();
280 let mut dup_floor: HashMap<&str, usize> = HashMap::new();
281 for (name, r) in &dup_ratios {
282 dup_floor.insert(name, (n_duplicates as f64 * r) as usize);
283 }
284 let dup_sum: usize = dup_floor.values().sum();
285 let dup_need = n_duplicates.max(dup_sum) - dup_sum;
286 if dup_need > 0 {
287 let mut remainders: Vec<(&str, f64)> = dup_ratios
288 .iter()
289 .map(|(n, r)| (*n, r - r.floor()))
290 .collect();
291 remainders.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
292 for (name, _) in remainders.iter().take(dup_need) {
293 *dup_floor.get_mut(name).unwrap_or(&mut 0) += 1;
294 }
295 }
296
297 let noise_count = ds.noise_types.len();
298 let noise_weights: Vec<f64> = vec![1.0 / noise_count as f64; noise_count];
299
300 let mut entity_plans = Vec::new();
301 for entity in &schema.entities {
302 let n_base = *floor_map.get(entity.name.as_str()).unwrap_or(&2);
303 let n_dup = *dup_floor.get(entity.name.as_str()).unwrap_or(&0);
304
305 let mut noise_entries = Vec::new();
306 if n_dup > 0 {
307 let mut counts: Vec<usize> = noise_weights
308 .iter()
309 .map(|w| (w * n_dup as f64) as usize)
310 .collect();
311 let count_sum: usize = counts.iter().sum();
312 if count_sum < n_dup {
313 *counts.last_mut().unwrap_or(&mut 0) += n_dup - count_sum;
314 }
315 for (i, noise_type) in ds.noise_types.iter().enumerate() {
316 if counts[i] == 0 {
317 continue;
318 }
319 noise_entries.push(serde_json::json!({
320 "noise_type": noise_type,
321 "columns": [],
322 "count": counts[i],
323 }));
324 }
325 }
326
327 let identifier_col: Option<String> = {
329 let entity_id_name = format!("{}_id", entity.name);
330 let col_names: Vec<&str> = entity
331 .columns
332 .iter()
333 .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
334 .collect();
335 if col_names.contains(&entity_id_name.as_str()) {
336 Some(entity_id_name)
337 } else if col_names.contains(&"id") {
338 Some("id".to_string())
339 } else {
340 col_names
341 .iter()
342 .find(|n| n.ends_with("_id"))
343 .map(|n| (*n).to_string())
344 }
345 };
346
347 let columns_json = serde_json::to_string(&entity.columns)
348 .map_err(|e| format!("serialize columns: {e}"))?;
349
350 entity_plans.push(serde_json::json!({
351 "name": entity.name,
352 "n_base": n_base,
353 "n_dup": n_dup,
354 "identifier_col": identifier_col,
355 "columns_json": columns_json,
356 "noise_types": noise_entries,
357 "fk_remaps": entity.fk_remaps,
358 }));
359 }
360
361 let n_hard_neg = (size as f64 * hard_neg_ratio * 0.05) as usize;
362 let hn_per_type = n_hard_neg / schema.hn_types.len().max(1);
363 let hard_neg_types: Vec<serde_json::Value> = schema
364 .hn_types
365 .iter()
366 .map(|hn| {
367 serde_json::json!({
368 "entity_type": hn.entity_type,
369 "config_json": hn.config_json,
370 "count": hn_per_type,
371 })
372 })
373 .collect();
374
375 let config = serde_json::json!({
376 "domain": domain,
377 "size": size,
378 "seed": seed,
379 "difficulty": difficulty,
380 "output_format": output_format,
381 "run_id": run_id,
382 "entity_plans": entity_plans,
383 "hard_neg_types": hard_neg_types,
384 "hard_neg_ratio": hard_neg_ratio,
385 "graph_enabled": graph_enabled,
386 "graph_format": graph_format,
387 });
388
389 serde_json::from_value(config).map_err(|e| format!("build PipelineConfig: {e}"))
390}