1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::collections::HashMap;
5use std::error::Error;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::Once;
9#[cfg(test)]
10use std::sync::OnceLock;
11use std::time::Instant;
12#[cfg(test)]
13use tempfile::TempDir;
14
15use cache_manager::CacheRoot;
16use clap::{Parser, ValueEnum, error::ErrorKind};
17
18use triplets_core::config::{ChunkingStrategy, SamplerConfig, TripletRecipe};
19use triplets_core::constants::cache::{MULTI_SOURCE_DEMO_GROUP, MULTI_SOURCE_DEMO_STORE_FILENAME};
20use triplets_core::data::ChunkView;
21use triplets_core::heuristics::{
22 CapacityTotals, EFFECTIVE_NEGATIVES_PER_ANCHOR, EFFECTIVE_POSITIVES_PER_ANCHOR,
23 estimate_source_split_capacity_from_counts, format_replay_factor, format_u128_with_commas,
24 resolve_text_recipes_for_source, split_counts_for_total,
25};
26use triplets_core::metrics::{chunk_proximity_score, source_skew, window_chunk_distance};
27use triplets_core::sampler::chunk_weight;
28use triplets_core::source::DataSource;
29use triplets_core::splits::{FileSplitStore, SplitLabel, SplitRatios, SplitStore};
30use triplets_core::{
31 RecordChunk, SampleBatch, Sampler, SamplerError, SourceId, TextBatch, TextRecipe, TripletBatch,
32 TripletSampler,
33};
34
35type DynSource = Box<dyn DataSource + 'static>;
36
37#[cfg(feature = "extended-metrics")]
38type MetricEntry = (f32, f32, f32, f32, f32);
39
40#[cfg(feature = "extended-metrics")]
41type SourceMetricsMap = HashMap<String, Vec<MetricEntry>>;
42
43fn managed_demo_split_store_path() -> Result<PathBuf, String> {
44 #[cfg(test)]
45 {
46 static TEST_CACHE_ROOT: OnceLock<TempDir> = OnceLock::new();
47 let root = TEST_CACHE_ROOT.get_or_init(|| {
48 TempDir::new().expect("failed to create test demo split-store cache root")
49 });
50 let cache_root = CacheRoot::from_root(root.path());
51 let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
52 let dir = cache_root.ensure_group(&group).map_err(|err| {
53 format!(
54 "failed creating managed demo cache group '{}': {err}",
55 group.display()
56 )
57 })?;
58 Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
59 }
60
61 #[cfg(not(test))]
62 {
63 let cache_root = CacheRoot::from_discovery()
64 .map_err(|err| format!("failed discovering managed cache root: {err}"))?;
65 let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
66 let dir = cache_root.ensure_group(&group).map_err(|err| {
67 format!(
68 "failed creating managed demo cache group '{}': {err}",
69 group.display()
70 )
71 })?;
72 Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
73 }
74}
75
76fn init_example_tracing() {
77 static INIT: Once = Once::new();
78 INIT.call_once(|| {
79 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
80 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("triplets=info"));
81 let _ = tracing_subscriber::fmt()
82 .with_env_filter(env_filter)
83 .try_init();
84 });
85}
86
87#[derive(Debug, Clone, Copy, ValueEnum)]
88enum SplitArg {
90 Train,
91 Validation,
92 Test,
93}
94
95impl From<SplitArg> for SplitLabel {
96 fn from(value: SplitArg) -> Self {
97 match value {
98 SplitArg::Train => SplitLabel::Train,
99 SplitArg::Validation => SplitLabel::Validation,
100 SplitArg::Test => SplitLabel::Test,
101 }
102 }
103}
104
105#[derive(Debug, Parser)]
106#[command(
107 name = "estimate_capacity",
108 disable_help_subcommand = true,
109 about = "Metadata-only capacity estimation",
110 long_about = "Estimate record, pair, triplet, and text-sample capacity using source-reported counts only (no data refresh).",
111 after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
112)]
113struct EstimateCapacityCli {
115 #[arg(
116 long,
117 default_value_t = 99,
118 help = "Deterministic seed used for split allocation"
119 )]
120 seed: u64,
121 #[arg(
122 long = "split-ratios",
123 value_name = "TRAIN,VALIDATION,TEST",
124 value_parser = parse_split_ratios_arg,
125 default_value = "0.8,0.1,0.1",
126 help = "Comma-separated split ratios that must sum to 1.0"
127 )]
128 split: SplitRatios,
129 #[arg(
130 long = "source-root",
131 value_name = "PATH",
132 help = "Optional source root override, repeat as needed in source order"
133 )]
134 source_roots: Vec<String>,
135}
136
137#[derive(Debug, Parser)]
138#[command(
139 name = "multi_source_demo",
140 disable_help_subcommand = true,
141 about = "Run sampled batches from multiple sources",
142 long_about = "Sample triplet, pair, or text batches from multiple sources and persist split/epoch state.",
143 after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
144)]
145struct MultiSourceDemoCli {
152 #[arg(
153 long = "text-recipes",
154 help = "Emit a text batch instead of a triplet batch"
155 )]
156 show_text_samples: bool,
157 #[arg(
158 long = "pair-batch",
159 help = "Emit a pair batch instead of a triplet batch"
160 )]
161 show_pair_samples: bool,
162 #[arg(
163 long = "list-text-recipes",
164 help = "Print registered text recipes and exit"
165 )]
166 list_text_recipes: bool,
167 #[arg(
168 long = "batch-size",
169 default_value_t = 4,
170 value_parser = parse_batch_size,
171 help = "Batch size used for sampling"
172 )]
173 batch_size: usize,
174 #[arg(
175 long = "ingestion-max-records",
176 default_value_t = default_ingestion_max_records(),
177 value_parser = parse_ingestion_max_records,
178 help = "Per-source ingestion buffer target used while refreshing records"
179 )]
180 ingestion_max_records: usize,
181 #[arg(long, help = "Optional deterministic seed override")]
182 seed: Option<u64>,
183 #[arg(long, value_enum, help = "Target split to sample from")]
184 split: Option<SplitArg>,
185 #[arg(
186 long = "source-root",
187 value_name = "PATH",
188 help = "Optional source root override, repeat as needed in source order"
189 )]
190 source_roots: Vec<String>,
191 #[arg(
192 long = "split-store-path",
193 value_name = "SPLIT_STORE_PATH",
194 help = "Optional explicit path for persisted split/epoch state file"
195 )]
196 split_store_path: Option<PathBuf>,
197 #[arg(
198 long = "reset",
199 help = "Delete the persisted split/epoch state before sampling, restarting from epoch 0"
200 )]
201 reset: bool,
202 #[arg(
203 long = "batches",
204 value_name = "N",
205 value_parser = parse_batch_count,
206 help = "Run N triplet batches in succession, printing a timing line per batch and (with --features extended-metrics) a per-source similarity summary at the end"
207 )]
208 batches: Option<usize>,
209}
210
211#[derive(Debug, Clone)]
212struct SourceInventory {
214 source_id: String,
215 reported_records: u128,
216 triplet_recipes: Vec<TripletRecipe>,
217}
218
219pub fn run_estimate_capacity<R, Resolve, Build, I>(
224 args_iter: I,
225 resolve_roots: Resolve,
226 build_sources: Build,
227) -> Result<(), Box<dyn Error>>
228where
229 Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
230 Build: FnOnce(&R) -> Vec<DynSource>,
231 I: Iterator<Item = String>,
232{
233 init_example_tracing();
234
235 let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
236 std::iter::once("estimate_capacity".to_string()).chain(args_iter),
237 )?
238 else {
239 return Ok(());
240 };
241
242 let roots = resolve_roots(cli.source_roots)?;
243
244 let config = SamplerConfig {
245 seed: cli.seed,
246 split: cli.split,
247 ..SamplerConfig::default()
248 };
249
250 let sources = build_sources(&roots);
251
252 let mut inventories = Vec::new();
253 for source in &sources {
254 let recipes = if config.recipes.is_empty() {
255 source.default_triplet_recipes()
256 } else {
257 config.recipes.clone()
258 };
259 let reported_records = source.reported_record_count(&config).map_err(|err| {
260 format!(
261 "source '{}' failed to report exact record count: {err}",
262 source.id()
263 )
264 })?;
265 inventories.push(SourceInventory {
266 source_id: source.id().to_string(),
267 reported_records,
268 triplet_recipes: recipes,
269 });
270 }
271
272 let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
273 let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
274
275 for source in &inventories {
276 let counts = split_counts_for_total(source.reported_records, cli.split);
277 for (label, count) in counts {
278 per_source_split_counts.insert((source.source_id.clone(), label), count);
279 *split_record_counts.entry(label).or_insert(0) += count;
280 }
281 }
282
283 let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
284 let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
285 HashMap::new();
286
287 for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
288 let mut totals = CapacityTotals::default();
289
290 for source in &inventories {
291 let source_split_records = per_source_split_counts
292 .get(&(source.source_id.clone(), split_label))
293 .copied()
294 .unwrap_or(0);
295
296 let triplet_recipes = &source.triplet_recipes;
297 let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
298
299 let capacity = estimate_source_split_capacity_from_counts(
300 source_split_records,
301 triplet_recipes,
302 &text_recipes,
303 );
304
305 totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
306
307 totals.triplets += capacity.triplets;
308 totals.effective_triplets += capacity.effective_triplets;
309 totals.pairs += capacity.pairs;
310 totals.text_samples += capacity.text_samples;
311 }
312
313 totals_by_split.insert(split_label, totals);
314 }
315
316 let min_nonzero_records_by_split: HashMap<SplitLabel, u128> =
317 [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
318 .into_iter()
319 .map(|split_label| {
320 let min_nonzero = inventories
321 .iter()
322 .filter_map(|source| {
323 per_source_split_counts
324 .get(&(source.source_id.clone(), split_label))
325 .copied()
326 })
327 .filter(|&records| records > 0)
328 .min()
329 .unwrap_or(0);
330 (split_label, min_nonzero)
331 })
332 .collect();
333
334 let min_nonzero_records_all_splits = inventories
335 .iter()
336 .map(|source| source.reported_records)
337 .filter(|&records| records > 0)
338 .min()
339 .unwrap_or(0);
340
341 println!("=== capacity estimate (length-only) ===");
342 println!("mode: metadata-only (no source.refresh calls)");
343 println!("classification: heuristic approximation (not exact)");
344 println!("split seed: {}", cli.seed);
345 println!(
346 "split ratios: train={:.4}, validation={:.4}, test={:.4}",
347 cli.split.train, cli.split.validation, cli.split.test
348 );
349 println!();
350
351 println!("[SOURCES]");
352 for source in &inventories {
353 println!(
354 " {} => reported records: {}",
355 source.source_id,
356 format_u128_with_commas(source.reported_records)
357 );
358 }
359 println!();
360
361 println!("[PER SOURCE BREAKDOWN]");
362 for source in &inventories {
363 println!(" {}", source.source_id);
364 let mut source_grand = CapacityTotals::default();
365 let mut source_total_records = 0u128;
366 for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
367 let split_records = per_source_split_counts
368 .get(&(source.source_id.clone(), split_label))
369 .copied()
370 .unwrap_or(0);
371 source_total_records = source_total_records.saturating_add(split_records);
372 let split_longest_records = inventories
373 .iter()
374 .map(|candidate| {
375 per_source_split_counts
376 .get(&(candidate.source_id.clone(), split_label))
377 .copied()
378 .unwrap_or(0)
379 })
380 .max()
381 .unwrap_or(0);
382 let totals = totals_by_source_and_split
383 .get(&(source.source_id.clone(), split_label))
384 .copied()
385 .unwrap_or_default();
386 source_grand.triplets += totals.triplets;
387 source_grand.effective_triplets += totals.effective_triplets;
388 source_grand.pairs += totals.pairs;
389 source_grand.text_samples += totals.text_samples;
390 println!(" [{:?}]", split_label);
391 println!(" records: {}", format_u128_with_commas(split_records));
392 println!(
393 " triplet combinations: {}",
394 format_u128_with_commas(totals.triplets)
395 );
396 println!(
397 " effective sampled triplets (p={}, k={}): {}",
398 EFFECTIVE_POSITIVES_PER_ANCHOR,
399 EFFECTIVE_NEGATIVES_PER_ANCHOR,
400 format_u128_with_commas(totals.effective_triplets)
401 );
402 println!(
403 " pair combinations: {}",
404 format_u128_with_commas(totals.pairs)
405 );
406 println!(
407 " text samples: {}",
408 format_u128_with_commas(totals.text_samples)
409 );
410 println!(
411 " replay factor vs longest source: {}",
412 format_replay_factor(split_longest_records, split_records)
413 );
414 println!(
415 " suggested proportional-size batch weight (0-1): {:.4}",
416 suggested_balancing_weight(split_longest_records, split_records)
417 );
418 let split_smallest_nonzero = min_nonzero_records_by_split
419 .get(&split_label)
420 .copied()
421 .unwrap_or(0);
422 println!(
423 " suggested small-source-boost batch weight (0-1): {:.4}",
424 suggested_oversampling_weight(split_smallest_nonzero, split_records)
425 );
426 println!();
427 }
428 let longest_source_total = inventories
429 .iter()
430 .map(|candidate| candidate.reported_records)
431 .max()
432 .unwrap_or(0);
433 println!(" [ALL SPLITS FOR SOURCE]");
434 println!(
435 " triplet combinations: {}",
436 format_u128_with_commas(source_grand.triplets)
437 );
438 println!(
439 " effective sampled triplets (p={}, k={}): {}",
440 EFFECTIVE_POSITIVES_PER_ANCHOR,
441 EFFECTIVE_NEGATIVES_PER_ANCHOR,
442 format_u128_with_commas(source_grand.effective_triplets)
443 );
444 println!(
445 " pair combinations: {}",
446 format_u128_with_commas(source_grand.pairs)
447 );
448 println!(
449 " text samples: {}",
450 format_u128_with_commas(source_grand.text_samples)
451 );
452 println!(
453 " replay factor vs longest source: {}",
454 format_replay_factor(longest_source_total, source_total_records)
455 );
456 println!(
457 " suggested proportional-size batch weight (0-1): {:.4}",
458 suggested_balancing_weight(longest_source_total, source_total_records)
459 );
460 println!(
461 " suggested small-source-boost batch weight (0-1): {:.4}",
462 suggested_oversampling_weight(min_nonzero_records_all_splits, source_total_records)
463 );
464 println!();
465 }
466
467 let mut grand = CapacityTotals::default();
468 for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
469 let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
470 let totals = totals_by_split
471 .get(&split_label)
472 .copied()
473 .unwrap_or_default();
474
475 grand.triplets += totals.triplets;
476 grand.effective_triplets += totals.effective_triplets;
477 grand.pairs += totals.pairs;
478 grand.text_samples += totals.text_samples;
479
480 println!("[{:?}]", split_label);
481 println!(" records: {}", format_u128_with_commas(record_count));
482 println!(
483 " triplet combinations: {}",
484 format_u128_with_commas(totals.triplets)
485 );
486 println!(
487 " effective sampled triplets (p={}, k={}): {}",
488 EFFECTIVE_POSITIVES_PER_ANCHOR,
489 EFFECTIVE_NEGATIVES_PER_ANCHOR,
490 format_u128_with_commas(totals.effective_triplets)
491 );
492 println!(
493 " pair combinations: {}",
494 format_u128_with_commas(totals.pairs)
495 );
496 println!(
497 " text samples: {}",
498 format_u128_with_commas(totals.text_samples)
499 );
500 println!();
501 }
502
503 println!("[ALL SPLITS TOTAL]");
504 println!(
505 " triplet combinations: {}",
506 format_u128_with_commas(grand.triplets)
507 );
508 println!(
509 " effective sampled triplets (p={}, k={}): {}",
510 EFFECTIVE_POSITIVES_PER_ANCHOR,
511 EFFECTIVE_NEGATIVES_PER_ANCHOR,
512 format_u128_with_commas(grand.effective_triplets)
513 );
514 println!(
515 " pair combinations: {}",
516 format_u128_with_commas(grand.pairs)
517 );
518 println!(
519 " text samples: {}",
520 format_u128_with_commas(grand.text_samples)
521 );
522 println!();
523 println!(
524 "Note: counts are heuristic, length-based estimates from source-reported totals and recipe structure. They are approximate, not exact, and assume anchor-positive pairs=records (one positive per anchor by default), negatives=source_records_in_split-1 (anchor excluded as its own negative), and at most one chunk/window realization per sample. In real-world chunked sampling, practical combinations are often higher, so treat this as a floor-like baseline."
525 );
526 println!();
527 println!(
528 "Effective sampled triplets apply a bounded training assumption: effective_triplets = records * p * k per triplet recipe, with defaults p={} positives per anchor and k={} negatives per anchor.",
529 EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
530 );
531 println!();
532 println!(
533 "Oversample loops are not inferred from this static report. To measure true oversampling (how many times sampling loops through the combination space), use observed sampled draw counts from an actual run."
534 );
535 println!();
536 println!(
537 "Suggested proportional-size batch weight (0-1) is source/max_source by record count: 1.0 for the largest source in scope, smaller values for smaller sources."
538 );
539 println!();
540 println!(
541 "Suggested small-source-boost batch weight (0-1) is min_nonzero_source/source by record count: 1.0 for the smallest non-zero source in scope, smaller values for larger sources."
542 );
543 println!();
544 println!(
545 "When passed to next_*_batch_with_weights, higher weight means that source is sampled more often relative to lower-weight sources."
546 );
547
548 Ok(())
549}
550
551pub fn run_multi_source_demo<R, Resolve, Build, I>(
556 args_iter: I,
557 resolve_roots: Resolve,
558 build_sources: Build,
559) -> Result<(), Box<dyn Error>>
560where
561 Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
562 Build: FnOnce(&R) -> Vec<DynSource>,
563 I: Iterator<Item = String>,
564{
565 init_example_tracing();
566
567 let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
568 std::iter::once("multi_source_demo".to_string()).chain(args_iter),
569 )?
570 else {
571 return Ok(());
572 };
573
574 let roots = resolve_roots(cli.source_roots)?;
575
576 let mut config = SamplerConfig::default();
577 config.seed = cli.seed.unwrap_or(config.seed);
578 config.batch_size = cli.batch_size;
579 config.ingestion_max_records = cli.ingestion_max_records;
580 config.chunking = Default::default();
581 let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
582 config.split = SplitRatios::default();
583 config.allowed_splits = vec![selected_split];
584 let chunking = config.chunking.clone();
585 let config_snapshot = MultiSourceDemoConfigSnapshot {
586 seed: config.seed,
587 batch_size: config.batch_size,
588 ingestion_max_records: config.ingestion_max_records,
589 split: selected_split,
590 split_ratios: config.split,
591 max_window_tokens: config.chunking.max_window_tokens,
592 overlap_tokens: config.chunking.overlap_tokens.clone(),
593 summary_fallback_tokens: config.chunking.summary_fallback_tokens,
594 };
595
596 let split_store_path = if let Some(path) = cli.split_store_path {
597 path
598 } else {
599 managed_demo_split_store_path().map_err(|err| {
600 Box::<dyn Error>::from(format!("failed to resolve demo split-store path: {err}"))
601 })?
602 };
603
604 if cli.reset && split_store_path.exists() {
605 std::fs::remove_file(&split_store_path).map_err(|err| {
606 Box::<dyn Error>::from(format!(
607 "failed to remove split store '{}': {err}",
608 split_store_path.display()
609 ))
610 })?;
611 println!("Reset: removed {}", split_store_path.display());
612 }
613 println!(
614 "Persisting split assignments and epoch state to {}",
615 split_store_path.display()
616 );
617 let sources = build_sources(&roots);
618 let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
619 let sampler = TripletSampler::new(config, split_store.clone());
620 for source in sources {
621 sampler
622 .register_source(source)
623 .map_err(|e| Box::new(e) as Box<dyn Error>)?;
624 }
625
626 if cli.show_pair_samples {
627 match sampler.next_pair_batch_with_weights(selected_split, &HashMap::<SourceId, f32>::new())
628 {
629 Ok(pair_batch) => {
630 if pair_batch.pairs.is_empty() {
631 println!("Pair sampling produced no results.");
632 } else {
633 print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
634 }
635 sampler.save_sampler_state(None)?;
636 }
637 Err(SamplerError::Exhausted(name)) => {
638 eprintln!(
639 "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
640 name
641 );
642 }
643 Err(err) => return Err(err.into()),
644 }
645 } else if cli.show_text_samples {
646 match sampler.next_text_batch_with_weights(selected_split, &HashMap::<SourceId, f32>::new())
647 {
648 Ok(text_batch) => {
649 if text_batch.samples.is_empty() {
650 println!(
651 "Text sampling produced no results. Ensure each source has eligible sections."
652 );
653 } else {
654 print_text_batch(&chunking, &text_batch, split_store.as_ref());
655 }
656 sampler.save_sampler_state(None)?;
657 }
658 Err(SamplerError::Exhausted(name)) => {
659 eprintln!(
660 "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
661 name
662 );
663 }
664 Err(err) => return Err(err.into()),
665 }
666 } else if cli.list_text_recipes {
667 let recipes = sampler.text_recipes();
668 if recipes.is_empty() {
669 println!(
670 "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
671 );
672 } else {
673 print_text_recipes(&recipes);
674 }
675 } else if let Some(batch_count) = cli.batches {
676 print_demo_config(&config_snapshot);
677 println!("=== benchmark: {} triplet batches ===", batch_count);
678
679 #[cfg(feature = "extended-metrics")]
681 let mut source_metrics: SourceMetricsMap = HashMap::new();
682
683 for i in 0..batch_count {
684 let t0 = Instant::now();
685 match sampler
686 .next_triplet_batch_with_weights(selected_split, &HashMap::<SourceId, f32>::new())
687 {
688 Ok(batch) => {
689 let elapsed = t0.elapsed();
690 let n = batch.triplets.len();
691 println!(
692 "batch {:>4} triplets={:<4} elapsed={:>8.2}ms per_triplet={:.2}ms",
693 i + 1,
694 n,
695 elapsed.as_secs_f64() * 1000.0,
696 if n > 0 {
697 elapsed.as_secs_f64() * 1000.0 / n as f64
698 } else {
699 0.0
700 },
701 );
702 #[cfg(feature = "extended-metrics")]
703 {
704 use triplets_core::metrics::lexical_similarity_scores;
705 for triplet in &batch.triplets {
706 let (pj, pc) = lexical_similarity_scores(
707 &triplet.anchor.text,
708 &triplet.positive.text,
709 );
710 let (nj, nc) = lexical_similarity_scores(
711 &triplet.anchor.text,
712 &triplet.negative.text,
713 );
714 let proximity =
715 chunk_proximity_score(&triplet.anchor, &triplet.positive);
716 let source = extract_source(&triplet.anchor.record_id);
717 source_metrics
718 .entry(source)
719 .or_default()
720 .push((pj, pc, nj, nc, proximity));
721 }
722 }
723 }
724 Err(SamplerError::Exhausted(name)) => {
725 println!(
726 "batch {:>4} exhausted recipe '{}' — stopping early",
727 i + 1,
728 name
729 );
730 break;
731 }
732 Err(err) => return Err(err.into()),
733 }
734 }
735
736 sampler.save_sampler_state(None)?;
737
738 #[cfg(feature = "extended-metrics")]
739 if !source_metrics.is_empty() {
740 println!();
741 print_metric_summary(&source_metrics);
742 }
743
744 #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
745 {
746 let (fallback, total) = sampler.bm25_fallback_stats();
747 if total > 0 {
748 let pct = fallback as f64 / total as f64 * 100.0;
749 println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
750 }
751 }
752 } else {
753 match sampler
754 .next_triplet_batch_with_weights(selected_split, &HashMap::<SourceId, f32>::new())
755 {
756 Ok(triplet_batch) => {
757 if triplet_batch.triplets.is_empty() {
758 println!(
759 "Triplet sampling produced no results. Ensure multiple records per source exist."
760 );
761 } else {
762 print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
763 }
764 sampler.save_sampler_state(None)?;
765 #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
766 {
767 let (fallback, total) = sampler.bm25_fallback_stats();
768 if total > 0 {
769 let pct = fallback as f64 / total as f64 * 100.0;
770 println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
771 }
772 }
773 }
774 Err(SamplerError::Exhausted(name)) => {
775 eprintln!(
776 "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
777 name
778 );
779 }
780 Err(err) => return Err(err.into()),
781 }
782 }
783
784 Ok(())
785}
786
787struct MultiSourceDemoConfigSnapshot {
788 seed: u64,
789 batch_size: usize,
790 ingestion_max_records: usize,
791 split: SplitLabel,
792 split_ratios: SplitRatios,
793 max_window_tokens: usize,
794 overlap_tokens: Vec<usize>,
795 summary_fallback_tokens: usize,
796}
797
798fn print_demo_config(cfg: &MultiSourceDemoConfigSnapshot) {
799 let overlaps: Vec<String> = cfg.overlap_tokens.iter().map(|t| t.to_string()).collect();
800 println!("=== sampler config ===");
801 println!("seed : {}", cfg.seed);
802 println!("batch_size : {}", cfg.batch_size);
803 println!("ingestion_max_records: {}", cfg.ingestion_max_records);
804 println!("split : {:?}", cfg.split);
805 println!(
806 "split_ratios : train={:.2} val={:.2} test={:.2}",
807 cfg.split_ratios.train, cfg.split_ratios.validation, cfg.split_ratios.test
808 );
809 println!("max_window_tokens : {}", cfg.max_window_tokens);
810 println!("overlap_tokens : [{}]", overlaps.join(", "));
811 println!(
812 "summary_fallback : {} tokens (0 = disabled)",
813 cfg.summary_fallback_tokens
814 );
815 println!();
816}
817
818fn default_ingestion_max_records() -> usize {
819 SamplerConfig::default().ingestion_max_records
820}
821
822fn parse_positive_usize_flag(raw: &str, flag: &str) -> Result<usize, String> {
823 let parsed = raw.parse::<usize>().map_err(|_| {
824 format!(
825 "Could not parse {} value '{}' as a positive integer",
826 flag, raw
827 )
828 })?;
829 if parsed == 0 {
830 return Err(format!("{} must be greater than zero", flag));
831 }
832 Ok(parsed)
833}
834
835fn parse_batch_size(raw: &str) -> Result<usize, String> {
836 parse_positive_usize_flag(raw, "--batch-size")
837}
838
839fn parse_ingestion_max_records(raw: &str) -> Result<usize, String> {
840 parse_positive_usize_flag(raw, "--ingestion-max-records")
841}
842
843fn parse_batch_count(raw: &str) -> Result<usize, String> {
844 parse_positive_usize_flag(raw, "--batches")
845}
846
847fn suggested_balancing_weight(max_baseline: u128, source_baseline: u128) -> f32 {
848 if max_baseline == 0 || source_baseline == 0 {
849 return 0.0;
850 }
851 (source_baseline as f64 / max_baseline as f64).clamp(0.0, 1.0) as f32
852}
853
854fn suggested_oversampling_weight(min_nonzero_baseline: u128, source_baseline: u128) -> f32 {
855 if min_nonzero_baseline == 0 || source_baseline == 0 {
856 return 0.0;
857 }
858 (min_nonzero_baseline as f64 / source_baseline as f64).clamp(0.0, 1.0) as f32
859}
860
861fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
862where
863 T: Parser,
864 I: IntoIterator,
865 I::Item: Into<std::ffi::OsString> + Clone,
866{
867 match T::try_parse_from(args) {
868 Ok(cli) => Ok(Some(cli)),
869 Err(err) => match err.kind() {
870 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
871 err.print()?;
872 Ok(None)
873 }
874 _ => Err(err.into()),
875 },
876 }
877}
878
879fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
880 let parts: Vec<&str> = raw.split(',').collect();
881 if parts.len() != 3 {
882 return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
883 }
884 let train = parts[0]
885 .trim()
886 .parse::<f32>()
887 .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
888 let validation = parts[1].trim().parse::<f32>().map_err(|_| {
889 format!(
890 "invalid validation ratio '{}': must be a float",
891 parts[1].trim()
892 )
893 })?;
894 let test = parts[2]
895 .trim()
896 .parse::<f32>()
897 .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
898 let ratios = SplitRatios {
899 train,
900 validation,
901 test,
902 };
903 let sum = ratios.train + ratios.validation + ratios.test;
904 if (sum - 1.0).abs() > 1e-5 {
905 return Err(format!(
906 "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
907 sum, ratios.train, ratios.validation, ratios.test
908 ));
909 }
910 if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
911 return Err("split ratios must be non-negative".to_string());
912 }
913 Ok(ratios)
914}
915
916fn print_triplet_batch(
917 strategy: &ChunkingStrategy,
918 batch: &TripletBatch,
919 split_store: &impl SplitStore,
920) {
921 println!("=== triplet batch ===");
922 for (idx, triplet) in batch.triplets.iter().enumerate() {
923 println!("--- triplet #{} ---", idx);
924 println!("recipe : {}", triplet.recipe);
925 println!("sample_weight: {:.4}", triplet.weight);
926 if let Some(instr) = &triplet.instruction {
927 println!("instruction shown to model:");
928 println!("{instr}");
929 println!();
930 }
931 let pos_proximity = chunk_proximity_score(&triplet.anchor, &triplet.positive);
932 let pos_distance = window_chunk_distance(&triplet.anchor, &triplet.positive);
933 #[cfg(feature = "extended-metrics")]
934 let (pos_sim, neg_sim) = {
935 use triplets_core::metrics::lexical_similarity_scores;
936 (
937 Some(lexical_similarity_scores(
938 &triplet.anchor.text,
939 &triplet.positive.text,
940 )),
941 Some(lexical_similarity_scores(
942 &triplet.anchor.text,
943 &triplet.negative.text,
944 )),
945 )
946 };
947 #[cfg(not(feature = "extended-metrics"))]
948 let (pos_sim, neg_sim): (Option<(f32, f32)>, Option<(f32, f32)>) = (None, None);
949 print_chunk_block(
950 "ANCHOR",
951 &triplet.anchor,
952 strategy,
953 split_store,
954 None,
955 None,
956 None,
957 );
958 print_chunk_block(
959 "POSITIVE",
960 &triplet.positive,
961 strategy,
962 split_store,
963 pos_sim,
964 Some(pos_proximity),
965 pos_distance,
966 );
967 print_chunk_block(
968 "NEGATIVE",
969 &triplet.negative,
970 strategy,
971 split_store,
972 neg_sim,
973 None,
974 None,
975 );
976 }
977 print_source_summary(
978 "triplet anchors",
979 batch
980 .triplets
981 .iter()
982 .map(|triplet| triplet.anchor.record_id.as_str()),
983 );
984 print_recipe_context_by_source(
985 "triplet recipes by source",
986 batch
987 .triplets
988 .iter()
989 .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
990 );
991}
992
993fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
994 println!("=== text batch ===");
995 for (idx, sample) in batch.samples.iter().enumerate() {
996 println!("--- sample #{} ---", idx);
997 println!("recipe : {}", sample.recipe);
998 println!("sample_weight: {:.4}", sample.weight);
999 if let Some(instr) = &sample.instruction {
1000 println!("instruction shown to model:");
1001 println!("{instr}");
1002 println!();
1003 }
1004 print_chunk_block(
1005 "TEXT",
1006 &sample.chunk,
1007 strategy,
1008 split_store,
1009 None,
1010 None,
1011 None,
1012 );
1013 }
1014 print_source_summary(
1015 "text samples",
1016 batch
1017 .samples
1018 .iter()
1019 .map(|sample| sample.chunk.record_id.as_str()),
1020 );
1021 print_recipe_context_by_source(
1022 "text recipes by source",
1023 batch
1024 .samples
1025 .iter()
1026 .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
1027 );
1028}
1029
1030fn print_pair_batch(
1031 strategy: &ChunkingStrategy,
1032 batch: &SampleBatch,
1033 split_store: &impl SplitStore,
1034) {
1035 println!("=== pair batch ===");
1036 for (idx, pair) in batch.pairs.iter().enumerate() {
1037 println!("--- pair #{} ---", idx);
1038 println!("recipe : {}", pair.recipe);
1039 println!("label : {:?}", pair.label);
1040 if let Some(reason) = &pair.reason {
1041 println!("reason : {}", reason);
1042 }
1043 print_chunk_block(
1044 "ANCHOR",
1045 &pair.anchor,
1046 strategy,
1047 split_store,
1048 None,
1049 None,
1050 None,
1051 );
1052 print_chunk_block(
1053 "OTHER",
1054 &pair.positive,
1055 strategy,
1056 split_store,
1057 None,
1058 None,
1059 None,
1060 );
1061 }
1062 print_source_summary(
1063 "pair anchors",
1064 batch
1065 .pairs
1066 .iter()
1067 .map(|pair| pair.anchor.record_id.as_str()),
1068 );
1069 print_recipe_context_by_source(
1070 "pair recipes by source",
1071 batch
1072 .pairs
1073 .iter()
1074 .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
1075 );
1076}
1077
1078fn print_text_recipes(recipes: &[TextRecipe]) {
1079 println!("=== available text recipes ===");
1080 for recipe in recipes {
1081 println!(
1082 "- {} (weight: {:.3}) selector={:?}",
1083 recipe.name, recipe.weight, recipe.selector
1084 );
1085 if let Some(instr) = &recipe.instruction {
1086 println!(" instruction: {}", instr);
1087 }
1088 }
1089}
1090
1091#[cfg(feature = "extended-metrics")]
1092fn metric_mean_median(vals: &mut [f32]) -> (f32, f32) {
1093 let mean = vals.iter().sum::<f32>() / vals.len() as f32;
1094 vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1095 let median = if vals.len() % 2 == 1 {
1096 vals[vals.len() / 2]
1097 } else {
1098 (vals[vals.len() / 2 - 1] + vals[vals.len() / 2]) / 2.0
1099 };
1100 (mean, median)
1101}
1102
1103#[cfg(feature = "extended-metrics")]
1104fn print_metric_summary(source_data: &SourceMetricsMap) {
1105 let total: usize = source_data.values().map(|v| v.len()).sum();
1106 let n_sources = source_data.len();
1107 println!(
1108 "=== extended metrics summary ({} triplets, {} {}) ===",
1109 total,
1110 n_sources,
1111 if n_sources == 1 { "source" } else { "sources" }
1112 );
1113
1114 fn metric_pair(entries: &[MetricEntry], pos_idx: usize, neg_idx: usize) -> [(f32, f32); 2] {
1116 let extract = |idx: usize| -> Vec<f32> {
1117 entries
1118 .iter()
1119 .map(|e| match idx {
1120 0 => e.0,
1121 1 => e.1,
1122 2 => e.2,
1123 3 => e.3,
1124 _ => e.4,
1125 })
1126 .collect()
1127 };
1128 let mut pos_vals = extract(pos_idx);
1129 let mut neg_vals = extract(neg_idx);
1130 [
1131 metric_mean_median(&mut pos_vals),
1132 metric_mean_median(&mut neg_vals),
1133 ]
1134 }
1135
1136 fn print_metric_section(
1137 label: &str,
1138 sources: &[&String],
1139 source_data: &SourceMetricsMap,
1140 pos_idx: usize,
1141 neg_idx: usize,
1142 total: usize,
1143 n_sources: usize,
1144 ) {
1145 const SEP: usize = 83;
1146 println!();
1147 println!("[{}]", label);
1148 println!(
1149 "{:<24} {:>5} {:<16} {:<16} {:<16}",
1150 "source", "n", "positive", "negative", "gap (pos\u{2212}neg)"
1151 );
1152 println!(
1153 "{:<24} {:>5} {:<16} {:<16} {:<16}",
1154 "", "", "mean / median", "mean / median", "mean / median"
1155 );
1156 println!("{}", "-".repeat(SEP));
1157 for source in sources {
1158 let entries = &source_data[*source];
1159 let [pos, neg] = metric_pair(entries, pos_idx, neg_idx);
1160 let gap_mean = pos.0 - neg.0;
1161 let gap_med = pos.1 - neg.1;
1162 println!(
1163 "{:<24} {:>5} {:.3} / {:.3} {:.3} / {:.3} {:+.3} / {:+.3}",
1164 source,
1165 entries.len(),
1166 pos.0,
1167 pos.1,
1168 neg.0,
1169 neg.1,
1170 gap_mean,
1171 gap_med,
1172 );
1173 }
1174 if n_sources > 1 {
1175 let all: Vec<MetricEntry> = source_data.values().flatten().copied().collect();
1176 let [pos, neg] = metric_pair(&all, pos_idx, neg_idx);
1177 let gap_mean = pos.0 - neg.0;
1178 let gap_med = pos.1 - neg.1;
1179 println!("{}", "-".repeat(SEP));
1180 println!(
1181 "{:<24} {:>5} {:.3} / {:.3} {:.3} / {:.3} {:+.3} / {:+.3}",
1182 "ALL", total, pos.0, pos.1, neg.0, neg.1, gap_mean, gap_med,
1183 );
1184 }
1185 }
1186
1187 fn print_single_metric_section(
1188 label: &str,
1189 sources: &[&String],
1190 source_data: &SourceMetricsMap,
1191 idx: usize,
1192 total: usize,
1193 n_sources: usize,
1194 ) {
1195 const SEP: usize = 58;
1196 println!();
1197 println!("[{}]", label);
1198 println!("{:<24} {:>5} {:<16}", "source", "n", "mean / median");
1199 println!("{}", "-".repeat(SEP));
1200 for source in sources {
1201 let entries = &source_data[*source];
1202 let mut vals: Vec<f32> = entries
1203 .iter()
1204 .map(|e| match idx {
1205 0 => e.0,
1206 1 => e.1,
1207 2 => e.2,
1208 3 => e.3,
1209 _ => e.4,
1210 })
1211 .collect();
1212 let (mean, median) = metric_mean_median(&mut vals);
1213 println!(
1214 "{:<24} {:>5} {:.3} / {:.3}",
1215 source,
1216 entries.len(),
1217 mean,
1218 median,
1219 );
1220 }
1221 if n_sources > 1 {
1222 let mut all: Vec<f32> = source_data
1223 .values()
1224 .flatten()
1225 .map(|e| match idx {
1226 0 => e.0,
1227 1 => e.1,
1228 2 => e.2,
1229 3 => e.3,
1230 _ => e.4,
1231 })
1232 .collect();
1233 let (mean, median) = metric_mean_median(&mut all);
1234 println!("{}", "-".repeat(SEP));
1235 println!("{:<24} {:>5} {:.3} / {:.3}", "ALL", total, mean, median);
1236 }
1237 }
1238
1239 let mut sources: Vec<&String> = source_data.keys().collect();
1240 sources.sort();
1241
1242 print_metric_section(
1243 "jaccard \u{2194} anchor",
1244 &sources,
1245 source_data,
1246 0,
1247 2,
1248 total,
1249 n_sources,
1250 );
1251 print_metric_section(
1252 "byte-cos \u{2194} anchor",
1253 &sources,
1254 source_data,
1255 1,
1256 3,
1257 total,
1258 n_sources,
1259 );
1260 print_single_metric_section(
1261 "anchor-positive proximity",
1262 &sources,
1263 source_data,
1264 4,
1265 total,
1266 n_sources,
1267 );
1268 println!();
1269}
1270
1271trait ChunkDebug {
1272 fn view_name(&self) -> String;
1273}
1274
1275impl ChunkDebug for RecordChunk {
1276 fn view_name(&self) -> String {
1277 match &self.view {
1278 ChunkView::Window {
1279 index,
1280 span,
1281 overlap,
1282 } => format!(
1283 "window#index={} span={} overlap={} tokens={}",
1284 index, span, overlap, self.tokens_estimate
1285 ),
1286 ChunkView::SummaryFallback { strategy, .. } => {
1287 format!("summary:{} tokens={}", strategy, self.tokens_estimate)
1288 }
1289 }
1290 }
1291}
1292
1293fn print_chunk_block(
1294 title: &str,
1295 chunk: &RecordChunk,
1296 strategy: &ChunkingStrategy,
1297 split_store: &impl SplitStore,
1298 anchor_sim: Option<(f32, f32)>,
1299 ap_proximity: Option<f32>,
1300 ap_distance: Option<f32>,
1301) {
1302 let chunk_weight = chunk_weight(strategy, chunk);
1303 let split = split_store
1304 .label_for(&chunk.record_id)
1305 .map(|label| format!("{:?}", label))
1306 .unwrap_or_else(|| "Unknown".to_string());
1307 println!("--- {} ---", title);
1308 println!("split : {}", split);
1309 println!("view : {}", chunk.view_name());
1310 println!("chunk_weight : {:.4}", chunk_weight);
1311 println!("record_id : {}", chunk.record_id);
1312 println!("section_idx : {}", chunk.section_idx);
1313 println!("token_est : {}", chunk.tokens_estimate);
1314 if let Some(proximity) = ap_proximity {
1315 println!("a<->p proximity : {:.4}", proximity);
1316 }
1317 if let Some(distance) = ap_distance {
1318 println!("a<->p distance : {:.4}", distance);
1319 }
1320 if let Some((j, c)) = anchor_sim {
1321 println!("jaccard(↔a) : {:.4} byte-cos(↔a): {:.4}", j, c);
1322 }
1323 if !chunk.kvp_meta.is_empty() {
1324 let mut kvp_keys: Vec<&String> = chunk.kvp_meta.keys().collect();
1325 kvp_keys.sort();
1326 println!("kvp_meta :");
1327 for k in kvp_keys {
1328 let vals = &chunk.kvp_meta[k];
1329 let display = if vals.len() > 1 {
1330 format!("{} ({} variations)", vals[0], vals.len())
1331 } else {
1332 vals[0].clone()
1333 };
1334 println!("\t{}: {}", k, display);
1335 }
1336 }
1337 println!("model_input (exact text sent to the model):");
1338 println!("<<< BEGIN MODEL TEXT >>>");
1339 println!("{}", chunk.text);
1340 println!("<<< END MODEL TEXT >>>");
1341 println!();
1342}
1343
1344fn print_source_summary<'a, I>(label: &str, ids: I)
1345where
1346 I: Iterator<Item = &'a str>,
1347{
1348 let mut counts: HashMap<SourceId, usize> = HashMap::new();
1349 for id in ids {
1350 let source = extract_source(id);
1351 *counts.entry(source).or_insert(0) += 1;
1352 }
1353 if counts.is_empty() {
1354 return;
1355 }
1356 let skew = source_skew(&counts);
1357 let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
1358 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1359 println!("--- {} by source ---", label);
1360 if let Some(skew) = skew {
1361 for entry in &skew.per_source {
1362 println!(
1363 "{}: count={} share={:.2}",
1364 entry.source, entry.count, entry.share
1365 );
1366 }
1367 println!(
1368 "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
1369 skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
1370 );
1371 }
1372}
1373
1374fn print_recipe_context_by_source<'a, I>(label: &str, entries: I)
1375where
1376 I: Iterator<Item = (&'a str, &'a str)>,
1377{
1378 let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
1379 for (record_id, recipe) in entries {
1380 let source = extract_source(record_id);
1381 let entry = counts
1382 .entry(source)
1383 .or_default()
1384 .entry(recipe.to_string())
1385 .or_insert(0);
1386 *entry += 1;
1387 }
1388 if counts.is_empty() {
1389 return;
1390 }
1391 let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
1392 sources.sort_by(|a, b| a.0.cmp(&b.0));
1393 println!("--- {} ---", label);
1394 for (source, recipes) in sources {
1395 println!("{source}");
1396 let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
1397 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1398 for (recipe, count) in entries {
1399 println!(" - {recipe}={count}");
1400 }
1401 }
1402}
1403
1404fn extract_source(record_id: &str) -> SourceId {
1405 record_id
1406 .split_once("::")
1407 .map(|(source, _)| source.to_string())
1408 .unwrap_or_else(|| "unknown".to_string())
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use super::*;
1414 use chrono::{TimeZone, Utc};
1415 use tempfile::tempdir;
1416 use triplets_core::DataRecord;
1417 use triplets_core::DeterministicSplitStore;
1418 use triplets_core::data::{QualityScore, RecordSection, SectionRole};
1419 use triplets_core::source::{SourceCursor, SourceSnapshot};
1420 use triplets_core::utils::make_section;
1421
1422 fn empty_dyn_sources(_: &()) -> Vec<DynSource> {
1423 Vec::new()
1424 }
1425
1426 fn ok_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
1427 Ok(())
1428 }
1429
1430 fn error_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
1431 Err("root-resolution-error".into())
1432 }
1433
1434 struct ErrorRefreshSource {
1435 id: String,
1436 }
1437
1438 impl DataSource for ErrorRefreshSource {
1439 fn id(&self) -> &str {
1440 &self.id
1441 }
1442
1443 fn refresh(
1444 &self,
1445 _config: &SamplerConfig,
1446 _cursor: Option<&SourceCursor>,
1447 _limit: Option<usize>,
1448 ) -> Result<SourceSnapshot, SamplerError> {
1449 Err(SamplerError::SourceUnavailable {
1450 source_id: self.id.clone(),
1451 reason: "simulated refresh failure".to_string(),
1452 })
1453 }
1454
1455 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1456 Ok(1)
1457 }
1458
1459 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1460 vec![default_recipe("error_refresh_recipe")]
1461 }
1462 }
1463
1464 struct TestSource {
1466 id: String,
1467 count: Option<u128>,
1468 recipes: Vec<TripletRecipe>,
1469 }
1470
1471 impl DataSource for TestSource {
1472 fn id(&self) -> &str {
1473 &self.id
1474 }
1475
1476 fn refresh(
1477 &self,
1478 _config: &SamplerConfig,
1479 _cursor: Option<&SourceCursor>,
1480 _limit: Option<usize>,
1481 ) -> Result<SourceSnapshot, SamplerError> {
1482 Ok(SourceSnapshot {
1483 records: Vec::new(),
1484 cursor: SourceCursor {
1485 last_seen: Utc::now(),
1486 revision: 0,
1487 },
1488 })
1489 }
1490
1491 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1492 self.count.ok_or_else(|| SamplerError::SourceInconsistent {
1493 source_id: self.id.clone(),
1494 details: "test source has no configured exact count".to_string(),
1495 })
1496 }
1497
1498 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1499 self.recipes.clone()
1500 }
1501 }
1502
1503 struct ConfigRequiredSource {
1504 id: String,
1505 expected_seed: u64,
1506 }
1507
1508 impl DataSource for ConfigRequiredSource {
1509 fn id(&self) -> &str {
1510 &self.id
1511 }
1512
1513 fn refresh(
1514 &self,
1515 _config: &SamplerConfig,
1516 _cursor: Option<&SourceCursor>,
1517 _limit: Option<usize>,
1518 ) -> Result<SourceSnapshot, SamplerError> {
1519 Ok(SourceSnapshot {
1520 records: Vec::new(),
1521 cursor: SourceCursor {
1522 last_seen: Utc::now(),
1523 revision: 0,
1524 },
1525 })
1526 }
1527
1528 fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
1529 if config.seed == self.expected_seed {
1530 Ok(1)
1531 } else {
1532 Err(SamplerError::SourceInconsistent {
1533 source_id: self.id.clone(),
1534 details: format!(
1535 "expected sampler seed {} but got {}",
1536 self.expected_seed, config.seed
1537 ),
1538 })
1539 }
1540 }
1541
1542 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1543 Vec::new()
1544 }
1545 }
1546
1547 struct FixtureSource {
1548 id: String,
1549 records: Vec<DataRecord>,
1550 recipes: Vec<TripletRecipe>,
1551 }
1552
1553 impl DataSource for FixtureSource {
1554 fn id(&self) -> &str {
1555 &self.id
1556 }
1557
1558 fn refresh(
1559 &self,
1560 _config: &SamplerConfig,
1561 _cursor: Option<&SourceCursor>,
1562 _limit: Option<usize>,
1563 ) -> Result<SourceSnapshot, SamplerError> {
1564 Ok(SourceSnapshot {
1565 records: self.records.clone(),
1566 cursor: SourceCursor {
1567 last_seen: Utc::now(),
1568 revision: 0,
1569 },
1570 })
1571 }
1572
1573 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1574 Ok(self.records.len() as u128)
1575 }
1576
1577 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1578 self.recipes.clone()
1579 }
1580 }
1581
1582 struct IngestionConfigSource {
1583 expected_ingestion_max_records: usize,
1584 records: Vec<DataRecord>,
1585 }
1586
1587 impl DataSource for IngestionConfigSource {
1588 fn id(&self) -> &str {
1589 "ingestion_config_source"
1590 }
1591
1592 fn refresh(
1593 &self,
1594 config: &SamplerConfig,
1595 _cursor: Option<&SourceCursor>,
1596 _limit: Option<usize>,
1597 ) -> Result<SourceSnapshot, SamplerError> {
1598 if config.ingestion_max_records != self.expected_ingestion_max_records {
1599 return Err(SamplerError::SourceInconsistent {
1600 source_id: self.id().to_string(),
1601 details: format!(
1602 "expected ingestion_max_records {} but got {}",
1603 self.expected_ingestion_max_records, config.ingestion_max_records
1604 ),
1605 });
1606 }
1607 Ok(SourceSnapshot {
1608 records: self.records.clone(),
1609 cursor: SourceCursor {
1610 last_seen: Utc::now(),
1611 revision: 0,
1612 },
1613 })
1614 }
1615
1616 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1617 Ok(self.records.len() as u128)
1618 }
1619
1620 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1621 vec![default_recipe("ingestion_config_recipe")]
1622 }
1623 }
1624
1625 fn fixture_record(
1626 source: &str,
1627 id_suffix: &str,
1628 day: u32,
1629 title: &str,
1630 body: &str,
1631 ) -> DataRecord {
1632 let now = Utc.with_ymd_and_hms(2025, 1, day, 12, 0, 0).unwrap();
1633 DataRecord {
1634 id: format!("{source}::{id_suffix}"),
1635 source: source.to_string(),
1636 created_at: now,
1637 updated_at: now,
1638 quality: QualityScore { trust: 1.0 },
1639 taxonomy: Vec::new(),
1640 sections: vec![
1641 make_section(SectionRole::Anchor, Some("title"), title),
1642 make_section(SectionRole::Context, Some("body"), body),
1643 ],
1644 meta_prefix: None,
1645 label: None,
1646 }
1647 }
1648
1649 fn default_recipe(name: &str) -> TripletRecipe {
1650 TripletRecipe {
1651 name: name.to_string().into(),
1652 anchor: triplets_core::config::Selector::Role(SectionRole::Anchor),
1653 positive_selector: triplets_core::config::Selector::Role(SectionRole::Context),
1654 negative_selector: triplets_core::config::Selector::Role(SectionRole::Context),
1655 negative_strategy: triplets_core::config::NegativeStrategy::WrongArticle,
1656 weight: 1.0,
1657 instruction: None,
1658 allow_same_anchor_positive: false,
1659 }
1660 }
1661
1662 #[test]
1663 fn parse_helpers_validate_inputs() {
1664 assert_eq!(parse_batch_size("2").unwrap(), 2);
1665 assert!(parse_batch_size("0").is_err());
1666 assert!(parse_batch_size("abc").is_err());
1667 assert_eq!(parse_ingestion_max_records("16").unwrap(), 16);
1668 assert!(parse_ingestion_max_records("0").is_err());
1669 assert!(parse_batch_count("0").is_err());
1670
1671 let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1672 assert!((split.train - 0.8).abs() < 1e-6);
1673 assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1674 assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1675 assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1676 }
1677
1678 #[test]
1679 fn fixture_and_ingestion_sources_trait_methods_cover_paths() {
1680 let records = vec![fixture_record("fixture_source", "r1", 1, "Title", "Body")];
1681 let recipes = vec![default_recipe("fixture_recipe")];
1682 let fixture = FixtureSource {
1683 id: "fixture_source".into(),
1684 records: records.clone(),
1685 recipes: recipes.clone(),
1686 };
1687
1688 let snapshot = fixture
1689 .refresh(&SamplerConfig::default(), None, None)
1690 .expect("fixture refresh should succeed");
1691 assert_eq!(snapshot.records.len(), 1);
1692 assert_eq!(
1693 fixture
1694 .reported_record_count(&SamplerConfig::default())
1695 .unwrap(),
1696 1
1697 );
1698 assert_eq!(fixture.default_triplet_recipes().len(), 1);
1699
1700 let source = IngestionConfigSource {
1701 expected_ingestion_max_records: 7,
1702 records,
1703 };
1704 let ok_cfg = SamplerConfig {
1705 ingestion_max_records: 7,
1706 ..SamplerConfig::default()
1707 };
1708 assert!(source.refresh(&ok_cfg, None, None).is_ok());
1709 assert_eq!(source.reported_record_count(&ok_cfg).unwrap(), 1);
1710 assert_eq!(source.default_triplet_recipes().len(), 1);
1711
1712 let bad_cfg = SamplerConfig {
1713 ingestion_max_records: 8,
1714 ..SamplerConfig::default()
1715 };
1716 let err = source.refresh(&bad_cfg, None, None).unwrap_err();
1717 assert!(matches!(err, SamplerError::SourceInconsistent { .. }));
1718 }
1719
1720 #[test]
1721 fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1722 assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1723 assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1724 assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1725 assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1726 assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1727 }
1728
1729 #[test]
1730 fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1731 assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1732 assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1733 assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1734 assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1735 assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1736 }
1737
1738 #[test]
1739 fn parse_cli_handles_help_and_invalid_args() {
1740 let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1741 assert!(help.is_none());
1742
1743 let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1744 assert!(err.is_err());
1745 }
1746
1747 #[test]
1748 fn run_estimate_capacity_succeeds_with_reported_counts() {
1749 let result = run_estimate_capacity(
1750 std::iter::empty::<String>(),
1751 |roots| {
1752 assert!(roots.is_empty());
1753 Ok(())
1754 },
1755 |_| {
1756 vec![Box::new(TestSource {
1757 id: "source_a".into(),
1758 count: Some(12),
1759 recipes: vec![default_recipe("r1")],
1760 }) as DynSource]
1761 },
1762 );
1763
1764 assert!(result.is_ok());
1765 }
1766
1767 #[test]
1768 fn run_estimate_capacity_errors_when_source_count_missing() {
1769 let result = run_estimate_capacity(
1770 std::iter::empty::<String>(),
1771 |_| Ok(()),
1772 |_| {
1773 vec![Box::new(TestSource {
1774 id: "source_missing".into(),
1775 count: None,
1776 recipes: vec![default_recipe("r1")],
1777 }) as DynSource]
1778 },
1779 );
1780
1781 let err = result.unwrap_err().to_string();
1782 assert!(err.contains("failed to report exact record count"));
1783 }
1784
1785 #[test]
1786 fn run_estimate_capacity_propagates_root_resolution_error() {
1787 let result = run_estimate_capacity(
1788 std::iter::empty::<String>(),
1789 |_| Err("root resolution failed".into()),
1790 empty_dyn_sources,
1791 );
1792
1793 let err = result.unwrap_err().to_string();
1794 assert!(err.contains("root resolution failed"));
1795 }
1796
1797 #[test]
1798 fn run_estimate_capacity_allows_empty_source_list() {
1799 let result =
1800 run_estimate_capacity(std::iter::empty::<String>(), |_| Ok(()), empty_dyn_sources);
1801
1802 assert!(result.is_ok());
1803 }
1804
1805 #[test]
1806 fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1807 let result = run_estimate_capacity(
1808 std::iter::empty::<String>(),
1809 |_| Ok(()),
1810 |_| {
1811 vec![Box::new(ConfigRequiredSource {
1812 id: "requires_config".into(),
1813 expected_seed: 99,
1814 }) as DynSource]
1815 },
1816 );
1817
1818 assert!(result.is_ok());
1819 }
1820
1821 #[test]
1822 fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1823 let source = ConfigRequiredSource {
1824 id: "cfg-source".to_string(),
1825 expected_seed: 42,
1826 };
1827
1828 let refreshed = source
1829 .refresh(&SamplerConfig::default(), None, None)
1830 .unwrap();
1831 assert!(refreshed.records.is_empty());
1832
1833 let mismatched = source.reported_record_count(&SamplerConfig {
1834 seed: 7,
1835 ..SamplerConfig::default()
1836 });
1837 assert!(matches!(
1838 mismatched,
1839 Err(SamplerError::SourceInconsistent { .. })
1840 ));
1841
1842 assert!(source.default_triplet_recipes().is_empty());
1843 }
1844
1845 #[test]
1846 fn run_multi_source_demo_exhausted_paths_return_ok() {
1847 struct OneRecordSource;
1848
1849 impl DataSource for OneRecordSource {
1850 fn id(&self) -> &str {
1851 "one_record"
1852 }
1853
1854 fn refresh(
1855 &self,
1856 _config: &SamplerConfig,
1857 _cursor: Option<&SourceCursor>,
1858 _limit: Option<usize>,
1859 ) -> Result<SourceSnapshot, SamplerError> {
1860 let now = Utc::now();
1861 Ok(SourceSnapshot {
1862 records: vec![DataRecord {
1863 id: "one_record::r1".to_string(),
1864 source: "one_record".to_string(),
1865 created_at: now,
1866 updated_at: now,
1867 quality: QualityScore { trust: 1.0 },
1868 taxonomy: Vec::new(),
1869 sections: vec![
1870 RecordSection {
1871 role: SectionRole::Anchor,
1872 heading: Some("title".to_string()),
1873 text: "anchor".to_string(),
1874 sentences: vec!["anchor".to_string()],
1875 },
1876 RecordSection {
1877 role: SectionRole::Context,
1878 heading: Some("body".to_string()),
1879 text: "context".to_string(),
1880 sentences: vec!["context".to_string()],
1881 },
1882 ],
1883 meta_prefix: None,
1884 label: None,
1885 }],
1886 cursor: SourceCursor {
1887 last_seen: now,
1888 revision: 0,
1889 },
1890 })
1891 }
1892
1893 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1894 Ok(1)
1895 }
1896
1897 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1898 vec![default_recipe("single_record_recipe")]
1899 }
1900 }
1901
1902 let one = OneRecordSource;
1903 assert_eq!(
1904 one.reported_record_count(&SamplerConfig::default())
1905 .unwrap(),
1906 1
1907 );
1908 assert_eq!(one.default_triplet_recipes().len(), 1);
1909
1910 for mode in ["--pair-batch", "--text-recipes", ""] {
1911 let dir = tempdir().unwrap();
1912 let split_store_path = dir.path().join("split_store.bin");
1913 let mut args = vec![
1914 "--split-store-path".to_string(),
1915 split_store_path.to_string_lossy().to_string(),
1916 ];
1917 if !mode.is_empty() {
1918 args.push(mode.to_string());
1919 }
1920
1921 let result = run_multi_source_demo(
1922 args.into_iter(),
1923 |_| Ok(()),
1924 |_| vec![Box::new(OneRecordSource) as DynSource],
1925 );
1926 assert!(result.is_ok());
1927 }
1928 }
1929
1930 #[test]
1931 fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1932 let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1933 assert!(help.is_none());
1934
1935 let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1936 assert!(err.is_err());
1937
1938 let err = parse_cli::<MultiSourceDemoCli, _>([
1939 "multi_source_demo",
1940 "--ingestion-max-records",
1941 "0",
1942 ]);
1943 assert!(err.is_err());
1944
1945 let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1946 assert!(parsed.is_ok());
1947 }
1948
1949 #[test]
1950 fn run_debug_invalid_cli_args_return_errors() {
1951 let estimate = run_estimate_capacity(
1952 ["--unknown".to_string()].into_iter(),
1953 ok_unit_roots,
1954 empty_dyn_sources,
1955 );
1956 assert!(estimate.is_err());
1957
1958 let demo = run_multi_source_demo(
1959 ["--unknown".to_string()].into_iter(),
1960 ok_unit_roots,
1961 empty_dyn_sources,
1962 );
1963 assert!(demo.is_err());
1964 }
1965
1966 #[test]
1967 fn helper_and_error_refresh_source_methods_are_exercised() {
1968 assert!(ok_unit_roots(Vec::new()).is_ok());
1969 assert!(error_unit_roots(Vec::new()).is_err());
1970
1971 let source = ErrorRefreshSource {
1972 id: "error_refresh_source".to_string(),
1973 };
1974 assert_eq!(
1975 source
1976 .reported_record_count(&SamplerConfig::default())
1977 .unwrap(),
1978 1
1979 );
1980 assert_eq!(source.default_triplet_recipes().len(), 1);
1981 }
1982
1983 #[test]
1984 fn print_source_summary_handles_non_empty_ids() {
1985 let ids = [
1986 "source_a::r1",
1987 "source_a::r2",
1988 "source_b::r1",
1989 "source_without_delimiter",
1990 ];
1991 print_source_summary("non-empty summary", ids.into_iter());
1992 }
1993
1994 #[test]
1995 fn run_multi_source_demo_refresh_failures_degrade_to_exhausted_paths() {
1996 for mode in [
1997 vec!["--pair-batch".to_string()],
1998 vec!["--text-recipes".to_string()],
1999 vec!["--batches".to_string(), "1".to_string()],
2000 Vec::new(),
2001 ] {
2002 let dir = tempdir().unwrap();
2003 let split_store_path = dir.path().join("error_modes_split_store.bin");
2004 let mut args = mode;
2005 args.push("--split-store-path".to_string());
2006 args.push(split_store_path.to_string_lossy().to_string());
2007
2008 let result = run_multi_source_demo(
2009 args.into_iter(),
2010 |_| Ok(()),
2011 |_| {
2012 vec![Box::new(ErrorRefreshSource {
2013 id: "error_refresh_source".to_string(),
2014 }) as DynSource]
2015 },
2016 );
2017
2018 assert!(result.is_ok());
2019 }
2020 }
2021
2022 #[test]
2023 fn run_multi_source_demo_batches_exhausted_path_returns_ok() {
2024 let dir = tempdir().unwrap();
2025 let split_store_path = dir.path().join("batches_exhausted_split_store.bin");
2026 let args = vec![
2027 "--batches".to_string(),
2028 "3".to_string(),
2029 "--split-store-path".to_string(),
2030 split_store_path.to_string_lossy().to_string(),
2031 ];
2032
2033 let result = run_multi_source_demo(
2034 args.into_iter(),
2035 |_| Ok(()),
2036 |_| {
2037 vec![Box::new(FixtureSource {
2038 id: "batches_exhausted_source".into(),
2039 records: vec![fixture_record(
2040 "batches_exhausted_source",
2041 "r1",
2042 1,
2043 "Only one record",
2044 "Single record body",
2045 )],
2046 recipes: vec![default_recipe("batches_exhausted_recipe")],
2047 }) as DynSource]
2048 },
2049 );
2050
2051 assert!(result.is_ok());
2052 }
2053
2054 #[test]
2055 fn run_multi_source_demo_default_triplet_success_path_returns_ok() {
2056 let dir = tempdir().unwrap();
2057 let split_store_path = dir.path().join("default_triplet_success_split_store.bin");
2058 let args = vec![
2059 "--split-store-path".to_string(),
2060 split_store_path.to_string_lossy().to_string(),
2061 ];
2062
2063 let result = run_multi_source_demo(
2064 args.into_iter(),
2065 |_| Ok(()),
2066 |_| {
2067 vec![Box::new(FixtureSource {
2068 id: "default_triplet_success_source".into(),
2069 records: vec![
2070 fixture_record(
2071 "default_triplet_success_source",
2072 "r1",
2073 1,
2074 "Title one",
2075 "Body one",
2076 ),
2077 fixture_record(
2078 "default_triplet_success_source",
2079 "r2",
2080 2,
2081 "Title two",
2082 "Body two",
2083 ),
2084 fixture_record(
2085 "default_triplet_success_source",
2086 "r3",
2087 3,
2088 "Title three",
2089 "Body three",
2090 ),
2091 ],
2092 recipes: vec![default_recipe("default_triplet_success_recipe")],
2093 }) as DynSource]
2094 },
2095 );
2096
2097 assert!(result.is_ok());
2098 }
2099
2100 #[test]
2101 fn run_multi_source_demo_passes_ingestion_max_records_to_sources() {
2102 let dir = tempdir().unwrap();
2103 let split_store_path = dir.path().join("ingestion_config_split_store.bin");
2104 let expected = 7;
2105
2106 let result = run_multi_source_demo(
2107 [
2108 "--pair-batch".to_string(),
2109 "--ingestion-max-records".to_string(),
2110 expected.to_string(),
2111 "--split-store-path".to_string(),
2112 split_store_path.to_string_lossy().to_string(),
2113 ]
2114 .into_iter(),
2115 |_| Ok(()),
2116 |_| {
2117 vec![Box::new(IngestionConfigSource {
2118 expected_ingestion_max_records: expected,
2119 records: (1..=8)
2120 .map(|day| {
2121 fixture_record(
2122 "ingestion_config_source",
2123 &format!("r{day}"),
2124 day,
2125 &format!("Config headline {day}"),
2126 &format!("Config body {day}"),
2127 )
2128 })
2129 .collect(),
2130 }) as DynSource]
2131 },
2132 );
2133
2134 assert!(result.is_ok());
2135 }
2136
2137 #[test]
2138 fn parse_cli_handles_display_version_path() {
2139 #[derive(Debug, Parser)]
2140 #[command(name = "version_test", version = "1.0.0")]
2141 struct VersionCli {}
2142
2143 let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
2144 assert!(parsed.is_none());
2145 }
2146
2147 #[test]
2148 fn run_multi_source_demo_list_text_recipes_path_succeeds() {
2149 let dir = tempdir().unwrap();
2150 let split_store_path = dir.path().join("recipes_split_store.bin");
2151 let mut args = vec![
2152 "--list-text-recipes".to_string(),
2153 "--split-store-path".to_string(),
2154 split_store_path.to_string_lossy().to_string(),
2155 ];
2156 let result = run_multi_source_demo(
2157 args.drain(..),
2158 |_| Ok(()),
2159 |_| {
2160 vec![Box::new(TestSource {
2161 id: "source_for_recipes".into(),
2162 count: Some(10),
2163 recipes: vec![default_recipe("recipe_a")],
2164 }) as DynSource]
2165 },
2166 );
2167
2168 assert!(result.is_ok());
2169 }
2170
2171 #[test]
2172 fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
2173 let dir = tempdir().unwrap();
2174 let split_store_path = dir.path().join("custom_split_store.bin");
2175 let args = vec![
2176 "--list-text-recipes".to_string(),
2177 "--split-store-path".to_string(),
2178 split_store_path.to_string_lossy().to_string(),
2179 ];
2180
2181 let result = run_multi_source_demo(
2182 args.into_iter(),
2183 |_| Ok(()),
2184 |_| {
2185 vec![Box::new(TestSource {
2186 id: "source_without_text_recipes".into(),
2187 count: Some(1),
2188 recipes: Vec::new(),
2189 }) as DynSource]
2190 },
2191 );
2192
2193 assert!(result.is_ok());
2194 }
2195
2196 #[test]
2197 fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
2198 for mode in [
2199 vec!["--pair-batch".to_string()],
2200 vec!["--text-recipes".to_string()],
2201 vec![],
2202 ] {
2203 let dir = tempdir().unwrap();
2204 let split_store_path = dir.path().join("empty_sources_split_store.bin");
2205 let mut args = mode;
2206 args.push("--split-store-path".to_string());
2207 args.push(split_store_path.to_string_lossy().to_string());
2208 args.push("--split".to_string());
2209 args.push("validation".to_string());
2210
2211 let result = run_multi_source_demo(
2212 args.into_iter(),
2213 |_| Ok(()),
2214 |_| {
2215 vec![Box::new(TestSource {
2216 id: "source_empty".into(),
2217 count: Some(0),
2218 recipes: vec![default_recipe("recipe_empty")],
2219 }) as DynSource]
2220 },
2221 );
2222
2223 assert!(result.is_ok());
2224 }
2225 }
2226
2227 #[test]
2228 fn run_multi_source_demo_propagates_root_resolution_error() {
2229 let dir = tempdir().unwrap();
2230 let split_store_path = dir.path().join("root_resolution_error_store.bin");
2231 let result = run_multi_source_demo(
2232 [
2233 "--split-store-path".to_string(),
2234 split_store_path.to_string_lossy().to_string(),
2235 ]
2236 .into_iter(),
2237 |_| Err("demo root resolution failed".into()),
2238 empty_dyn_sources,
2239 );
2240
2241 let err = result.unwrap_err().to_string();
2242 assert!(err.contains("demo root resolution failed"));
2243 }
2244
2245 #[test]
2246 fn run_multi_source_demo_list_text_recipes_allows_empty_sources() {
2247 let dir = tempdir().unwrap();
2248 let split_store_path = dir.path().join("empty_source_list_recipes.bin");
2249 let result = run_multi_source_demo(
2250 [
2251 "--list-text-recipes".to_string(),
2252 "--split-store-path".to_string(),
2253 split_store_path.to_string_lossy().to_string(),
2254 ]
2255 .into_iter(),
2256 |_| Ok(()),
2257 empty_dyn_sources,
2258 );
2259
2260 assert!(result.is_ok());
2261 }
2262
2263 #[test]
2264 fn print_helpers_and_extract_source_cover_paths() {
2265 let split = SplitRatios::default();
2266 let store = DeterministicSplitStore::new(split, 42).unwrap();
2267 let strategy = ChunkingStrategy::default();
2268
2269 let anchor = RecordChunk {
2270 record_id: "source_a::rec1".to_string(),
2271 section_idx: 0,
2272 view: ChunkView::Window {
2273 index: 1,
2274 overlap: 2,
2275 span: 12,
2276 },
2277 text: "anchor text".to_string(),
2278 tokens_estimate: 8,
2279 quality: triplets_core::data::QualityScore { trust: 0.9 },
2280 kvp_meta: [(
2281 "date".to_string(),
2282 vec!["2025-01-01".to_string(), "Jan 1, 2025".to_string()],
2283 )]
2284 .into_iter()
2285 .collect(),
2286 };
2287 let positive = RecordChunk {
2288 record_id: "source_a::rec2".to_string(),
2289 section_idx: 1,
2290 view: ChunkView::SummaryFallback {
2291 strategy: "summary".to_string(),
2292 weight: 0.7,
2293 },
2294 text: "positive text".to_string(),
2295 tokens_estimate: 6,
2296 quality: triplets_core::data::QualityScore { trust: 0.8 },
2297 kvp_meta: Default::default(),
2298 };
2299 let negative = RecordChunk {
2300 record_id: "source_b::rec3".to_string(),
2301 section_idx: 2,
2302 view: ChunkView::Window {
2303 index: 0,
2304 overlap: 0,
2305 span: 16,
2306 },
2307 text: "negative text".to_string(),
2308 tokens_estimate: 7,
2309 quality: triplets_core::data::QualityScore { trust: 0.5 },
2310 kvp_meta: Default::default(),
2311 };
2312
2313 let triplet_batch = TripletBatch {
2314 triplets: vec![triplets_core::SampleTriplet {
2315 recipe: "triplet_recipe".to_string(),
2316 anchor: anchor.clone(),
2317 positive: positive.clone(),
2318 negative: negative.clone(),
2319 weight: 1.0,
2320 instruction: Some("triplet instruction".to_string()),
2321 }],
2322 };
2323 print_triplet_batch(&strategy, &triplet_batch, &store);
2324
2325 let pair_batch = SampleBatch {
2326 pairs: vec![triplets_core::SamplePair {
2327 recipe: "pair_recipe".to_string(),
2328 anchor: anchor.clone(),
2329 positive: positive.clone(),
2330 weight: 1.0,
2331 instruction: None,
2332 label: triplets_core::PairLabel::Positive,
2333 reason: Some("same topic".to_string()),
2334 }],
2335 };
2336 print_pair_batch(&strategy, &pair_batch, &store);
2337
2338 let text_batch = TextBatch {
2339 samples: vec![triplets_core::TextSample {
2340 recipe: "text_recipe".to_string(),
2341 chunk: negative,
2342 weight: 0.8,
2343 instruction: Some("text instruction".to_string()),
2344 }],
2345 };
2346 print_text_batch(&strategy, &text_batch, &store);
2347
2348 let recipes = vec![TextRecipe {
2349 name: "recipe_name".into(),
2350 selector: triplets_core::config::Selector::Role(SectionRole::Context),
2351 instruction: Some("instruction".into()),
2352 weight: 1.0,
2353 }];
2354 print_text_recipes(&recipes);
2355
2356 assert_eq!(extract_source("source_a::record"), "source_a");
2357 assert_eq!(extract_source("record-without-delimiter"), "unknown");
2358 }
2359
2360 #[test]
2361 fn split_arg_conversion_and_version_parse_paths_are_covered() {
2362 assert!(matches!(
2363 SplitLabel::from(SplitArg::Train),
2364 SplitLabel::Train
2365 ));
2366 assert!(matches!(
2367 SplitLabel::from(SplitArg::Validation),
2368 SplitLabel::Validation
2369 ));
2370 assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
2371 }
2372
2373 #[test]
2374 fn parse_split_ratios_reports_per_field_parse_errors() {
2375 assert!(
2376 parse_split_ratios_arg("x,0.1,0.9")
2377 .unwrap_err()
2378 .contains("invalid train ratio")
2379 );
2380 assert!(
2381 parse_split_ratios_arg("0.1,y,0.8")
2382 .unwrap_err()
2383 .contains("invalid validation ratio")
2384 );
2385 assert!(
2386 parse_split_ratios_arg("0.1,0.2,z")
2387 .unwrap_err()
2388 .contains("invalid test ratio")
2389 );
2390 }
2391
2392 #[test]
2393 fn run_multi_source_demo_exhausted_paths_are_handled() {
2394 for mode in [
2395 vec!["--pair-batch".to_string()],
2396 vec!["--text-recipes".to_string()],
2397 Vec::new(),
2398 ] {
2399 let dir = tempdir().unwrap();
2400 let split_store_path = dir.path().join("exhausted_split_store.bin");
2401 let mut args = mode;
2402 args.push("--split-store-path".to_string());
2403 args.push(split_store_path.to_string_lossy().to_string());
2404
2405 let result = run_multi_source_demo(
2406 args.into_iter(),
2407 |_| Ok(()),
2408 |_| {
2409 vec![Box::new(TestSource {
2410 id: "source_without_recipes".into(),
2411 count: Some(1),
2412 recipes: Vec::new(),
2413 }) as DynSource]
2414 },
2415 );
2416
2417 assert!(result.is_ok());
2418 }
2419 }
2420
2421 #[test]
2422 fn run_multi_source_demo_reset_recreates_split_store_and_samples() {
2423 let dir = tempdir().unwrap();
2424 let split_store_path = dir.path().join("reset_split_store.bin");
2425 std::fs::write(&split_store_path, b"stale-data").unwrap();
2426
2427 let args = vec![
2428 "--reset".to_string(),
2429 "--pair-batch".to_string(),
2430 "--split-store-path".to_string(),
2431 split_store_path.to_string_lossy().to_string(),
2432 ];
2433
2434 let result = run_multi_source_demo(
2435 args.into_iter(),
2436 |_| Ok(()),
2437 |_| {
2438 let recipes = vec![default_recipe("fixture_recipe")];
2439 let records: Vec<DataRecord> = (1..=8)
2440 .map(|day| {
2441 fixture_record(
2442 "fixture_source",
2443 &format!("r{day}"),
2444 day,
2445 &format!("Fixture headline {day}"),
2446 &format!("Fixture body content for day {day}."),
2447 )
2448 })
2449 .collect();
2450 vec![Box::new(FixtureSource {
2451 id: "fixture_source".into(),
2452 records,
2453 recipes,
2454 }) as DynSource]
2455 },
2456 );
2457
2458 assert!(result.is_ok());
2459 assert!(split_store_path.exists());
2460 let metadata = std::fs::metadata(&split_store_path).unwrap();
2461 assert!(metadata.len() > 0);
2462 }
2463
2464 #[test]
2465 fn run_multi_source_demo_batches_mode_executes_multiple_batches() {
2466 let dir = tempdir().unwrap();
2467 let split_store_path = dir.path().join("batches_split_store.bin");
2468 let args = vec![
2469 "--batches".to_string(),
2470 "2".to_string(),
2471 "--split-store-path".to_string(),
2472 split_store_path.to_string_lossy().to_string(),
2473 ];
2474
2475 let result = run_multi_source_demo(
2476 args.into_iter(),
2477 |_| Ok(()),
2478 |_| {
2479 let recipes = vec![default_recipe("batch_recipe")];
2480 vec![Box::new(FixtureSource {
2481 id: "batch_source".into(),
2482 records: vec![
2483 fixture_record(
2484 "batch_source",
2485 "r1",
2486 3,
2487 "Inflation cools in latest report",
2488 "Core inflation moderated compared with prior quarter.",
2489 ),
2490 fixture_record(
2491 "batch_source",
2492 "r2",
2493 4,
2494 "Labor market remains resilient",
2495 "Job openings remain elevated despite slower growth.",
2496 ),
2497 fixture_record(
2498 "batch_source",
2499 "r3",
2500 5,
2501 "Manufacturing sentiment stabilizes",
2502 "Survey data suggests output expectations are improving.",
2503 ),
2504 ],
2505 recipes,
2506 }) as DynSource]
2507 },
2508 );
2509
2510 assert!(result.is_ok());
2511 assert!(split_store_path.exists());
2512 }
2513
2514 #[test]
2515 fn managed_demo_split_store_path_resolves_under_cache_group() {
2516 let path = managed_demo_split_store_path().unwrap();
2517 assert!(path.ends_with(MULTI_SOURCE_DEMO_STORE_FILENAME));
2518 let parent = path
2519 .parent()
2520 .expect("managed split-store path should have a parent");
2521 assert!(parent.ends_with(PathBuf::from(MULTI_SOURCE_DEMO_GROUP)));
2522 }
2523
2524 #[test]
2525 fn run_multi_source_demo_help_returns_ok_without_work() {
2526 let no_help = run_multi_source_demo(
2527 std::iter::empty::<String>(),
2528 error_unit_roots,
2529 empty_dyn_sources,
2530 );
2531 assert!(
2532 no_help
2533 .expect_err("non-help path should attempt to resolve roots")
2534 .to_string()
2535 .contains("root-resolution-error")
2536 );
2537
2538 let result = run_multi_source_demo(
2539 ["--help".to_string()].into_iter(),
2540 ok_unit_roots,
2541 empty_dyn_sources,
2542 );
2543
2544 assert!(result.is_ok());
2545 }
2546
2547 #[test]
2548 fn run_estimate_capacity_help_returns_ok_without_work() {
2549 let result = run_estimate_capacity(
2550 ["--help".to_string()].into_iter(),
2551 ok_unit_roots,
2552 empty_dyn_sources,
2553 );
2554
2555 assert!(result.is_ok());
2556 }
2557
2558 #[test]
2559 fn run_multi_source_demo_pair_exhausted_branch_returns_ok() {
2560 let dir = tempdir().unwrap();
2561 let split_store_path = dir.path().join("pair_exhausted_split_store.bin");
2562 let args = vec![
2563 "--pair-batch".to_string(),
2564 "--split-store-path".to_string(),
2565 split_store_path.to_string_lossy().to_string(),
2566 ];
2567
2568 let result = run_multi_source_demo(
2569 args.into_iter(),
2570 |_| Ok(()),
2571 |_| {
2572 vec![Box::new(FixtureSource {
2573 id: "pair_exhausted_source".into(),
2574 records: vec![fixture_record(
2575 "pair_exhausted_source",
2576 "r1",
2577 1,
2578 "Single record title",
2579 "Single record body",
2580 )],
2581 recipes: vec![default_recipe("pair_exhausted_recipe")],
2582 }) as DynSource]
2583 },
2584 );
2585
2586 assert!(result.is_ok());
2587 }
2588
2589 #[test]
2590 fn run_multi_source_demo_uses_managed_split_store_path_when_not_provided() {
2591 let result = run_multi_source_demo(
2592 ["--list-text-recipes".to_string()].into_iter(),
2593 |_| Ok(()),
2594 |_| {
2595 vec![Box::new(TestSource {
2596 id: "managed_path_source".into(),
2597 count: Some(2),
2598 recipes: vec![default_recipe("managed_recipe")],
2599 }) as DynSource]
2600 },
2601 );
2602
2603 assert!(result.is_ok());
2604 }
2605
2606 #[test]
2607 fn run_multi_source_demo_reset_errors_when_target_is_directory() {
2608 let dir = tempdir().unwrap();
2609 let split_store_path = dir.path().join("split_store_dir");
2610 std::fs::create_dir(&split_store_path).unwrap();
2611
2612 let result = run_multi_source_demo(
2613 [
2614 "--reset".to_string(),
2615 "--split-store-path".to_string(),
2616 split_store_path.to_string_lossy().to_string(),
2617 ]
2618 .into_iter(),
2619 |_| Ok(()),
2620 empty_dyn_sources,
2621 );
2622
2623 let err = result.unwrap_err().to_string();
2624 assert!(err.contains("failed to remove split store"));
2625 }
2626
2627 #[test]
2628 fn print_summary_helpers_accept_empty_iterators() {
2629 print_source_summary("empty summary", std::iter::empty::<&str>());
2630 print_recipe_context_by_source("empty recipe context", std::iter::empty::<(&str, &str)>());
2631 }
2632
2633 #[cfg(feature = "extended-metrics")]
2634 #[test]
2635 fn metric_mean_median_handles_even_length_inputs() {
2636 let mut vals = [1.0, 4.0, 2.0, 3.0];
2637 let (mean, median) = metric_mean_median(&mut vals);
2638 assert!((mean - 2.5).abs() < 1e-6);
2639 assert!((median - 2.5).abs() < 1e-6);
2640 }
2641
2642 #[cfg(feature = "extended-metrics")]
2643 #[test]
2644 fn metric_mean_median_handles_odd_length_inputs() {
2645 let mut vals = [3.0, 1.0, 2.0];
2646 let (mean, median) = metric_mean_median(&mut vals);
2647 assert!((mean - 2.0).abs() < 1e-6);
2648 assert!((median - 2.0).abs() < 1e-6);
2649 }
2650
2651 #[cfg(feature = "extended-metrics")]
2652 #[test]
2653 fn print_metric_summary_includes_multi_source_aggregate() {
2654 let source_data = HashMap::from([
2655 (
2656 "source_a".to_string(),
2657 vec![(0.9, 0.8, 0.2, 0.1, 0.7), (0.8, 0.7, 0.3, 0.2, 0.8)],
2658 ),
2659 (
2660 "source_b".to_string(),
2661 vec![(0.7, 0.6, 0.4, 0.3, 0.5), (0.6, 0.5, 0.5, 0.4, 0.6)],
2662 ),
2663 ]);
2664
2665 print_metric_summary(&source_data);
2666 }
2667}