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 token_count: 0,
1876 },
1877 RecordSection {
1878 role: SectionRole::Context,
1879 heading: Some("body".to_string()),
1880 text: "context".to_string(),
1881 sentences: vec!["context".to_string()],
1882 token_count: 0,
1883 },
1884 ],
1885 meta_prefix: None,
1886 label: None,
1887 }],
1888 cursor: SourceCursor {
1889 last_seen: now,
1890 revision: 0,
1891 },
1892 })
1893 }
1894
1895 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1896 Ok(1)
1897 }
1898
1899 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1900 vec![default_recipe("single_record_recipe")]
1901 }
1902 }
1903
1904 let one = OneRecordSource;
1905 assert_eq!(
1906 one.reported_record_count(&SamplerConfig::default())
1907 .unwrap(),
1908 1
1909 );
1910 assert_eq!(one.default_triplet_recipes().len(), 1);
1911
1912 for mode in ["--pair-batch", "--text-recipes", ""] {
1913 let dir = tempdir().unwrap();
1914 let split_store_path = dir.path().join("split_store.bin");
1915 let mut args = vec![
1916 "--split-store-path".to_string(),
1917 split_store_path.to_string_lossy().to_string(),
1918 ];
1919 if !mode.is_empty() {
1920 args.push(mode.to_string());
1921 }
1922
1923 let result = run_multi_source_demo(
1924 args.into_iter(),
1925 |_| Ok(()),
1926 |_| vec![Box::new(OneRecordSource) as DynSource],
1927 );
1928 assert!(result.is_ok());
1929 }
1930 }
1931
1932 #[test]
1933 fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1934 let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1935 assert!(help.is_none());
1936
1937 let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1938 assert!(err.is_err());
1939
1940 let err = parse_cli::<MultiSourceDemoCli, _>([
1941 "multi_source_demo",
1942 "--ingestion-max-records",
1943 "0",
1944 ]);
1945 assert!(err.is_err());
1946
1947 let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1948 assert!(parsed.is_ok());
1949 }
1950
1951 #[test]
1952 fn run_debug_invalid_cli_args_return_errors() {
1953 let estimate = run_estimate_capacity(
1954 ["--unknown".to_string()].into_iter(),
1955 ok_unit_roots,
1956 empty_dyn_sources,
1957 );
1958 assert!(estimate.is_err());
1959
1960 let demo = run_multi_source_demo(
1961 ["--unknown".to_string()].into_iter(),
1962 ok_unit_roots,
1963 empty_dyn_sources,
1964 );
1965 assert!(demo.is_err());
1966 }
1967
1968 #[test]
1969 fn helper_and_error_refresh_source_methods_are_exercised() {
1970 assert!(ok_unit_roots(Vec::new()).is_ok());
1971 assert!(error_unit_roots(Vec::new()).is_err());
1972
1973 let source = ErrorRefreshSource {
1974 id: "error_refresh_source".to_string(),
1975 };
1976 assert_eq!(
1977 source
1978 .reported_record_count(&SamplerConfig::default())
1979 .unwrap(),
1980 1
1981 );
1982 assert_eq!(source.default_triplet_recipes().len(), 1);
1983 }
1984
1985 #[test]
1986 fn print_source_summary_handles_non_empty_ids() {
1987 let ids = [
1988 "source_a::r1",
1989 "source_a::r2",
1990 "source_b::r1",
1991 "source_without_delimiter",
1992 ];
1993 print_source_summary("non-empty summary", ids.into_iter());
1994 }
1995
1996 #[test]
1997 fn run_multi_source_demo_refresh_failures_degrade_to_exhausted_paths() {
1998 for mode in [
1999 vec!["--pair-batch".to_string()],
2000 vec!["--text-recipes".to_string()],
2001 vec!["--batches".to_string(), "1".to_string()],
2002 Vec::new(),
2003 ] {
2004 let dir = tempdir().unwrap();
2005 let split_store_path = dir.path().join("error_modes_split_store.bin");
2006 let mut args = mode;
2007 args.push("--split-store-path".to_string());
2008 args.push(split_store_path.to_string_lossy().to_string());
2009
2010 let result = run_multi_source_demo(
2011 args.into_iter(),
2012 |_| Ok(()),
2013 |_| {
2014 vec![Box::new(ErrorRefreshSource {
2015 id: "error_refresh_source".to_string(),
2016 }) as DynSource]
2017 },
2018 );
2019
2020 assert!(result.is_ok());
2021 }
2022 }
2023
2024 #[test]
2025 fn run_multi_source_demo_batches_exhausted_path_returns_ok() {
2026 let dir = tempdir().unwrap();
2027 let split_store_path = dir.path().join("batches_exhausted_split_store.bin");
2028 let args = vec![
2029 "--batches".to_string(),
2030 "3".to_string(),
2031 "--split-store-path".to_string(),
2032 split_store_path.to_string_lossy().to_string(),
2033 ];
2034
2035 let result = run_multi_source_demo(
2036 args.into_iter(),
2037 |_| Ok(()),
2038 |_| {
2039 vec![Box::new(FixtureSource {
2040 id: "batches_exhausted_source".into(),
2041 records: vec![fixture_record(
2042 "batches_exhausted_source",
2043 "r1",
2044 1,
2045 "Only one record",
2046 "Single record body",
2047 )],
2048 recipes: vec![default_recipe("batches_exhausted_recipe")],
2049 }) as DynSource]
2050 },
2051 );
2052
2053 assert!(result.is_ok());
2054 }
2055
2056 #[test]
2057 fn run_multi_source_demo_default_triplet_success_path_returns_ok() {
2058 let dir = tempdir().unwrap();
2059 let split_store_path = dir.path().join("default_triplet_success_split_store.bin");
2060 let args = vec![
2061 "--split-store-path".to_string(),
2062 split_store_path.to_string_lossy().to_string(),
2063 ];
2064
2065 let result = run_multi_source_demo(
2066 args.into_iter(),
2067 |_| Ok(()),
2068 |_| {
2069 vec![Box::new(FixtureSource {
2070 id: "default_triplet_success_source".into(),
2071 records: vec![
2072 fixture_record(
2073 "default_triplet_success_source",
2074 "r1",
2075 1,
2076 "Title one",
2077 "Body one",
2078 ),
2079 fixture_record(
2080 "default_triplet_success_source",
2081 "r2",
2082 2,
2083 "Title two",
2084 "Body two",
2085 ),
2086 fixture_record(
2087 "default_triplet_success_source",
2088 "r3",
2089 3,
2090 "Title three",
2091 "Body three",
2092 ),
2093 ],
2094 recipes: vec![default_recipe("default_triplet_success_recipe")],
2095 }) as DynSource]
2096 },
2097 );
2098
2099 assert!(result.is_ok());
2100 }
2101
2102 #[test]
2103 fn run_multi_source_demo_passes_ingestion_max_records_to_sources() {
2104 let dir = tempdir().unwrap();
2105 let split_store_path = dir.path().join("ingestion_config_split_store.bin");
2106 let expected = 7;
2107
2108 let result = run_multi_source_demo(
2109 [
2110 "--pair-batch".to_string(),
2111 "--ingestion-max-records".to_string(),
2112 expected.to_string(),
2113 "--split-store-path".to_string(),
2114 split_store_path.to_string_lossy().to_string(),
2115 ]
2116 .into_iter(),
2117 |_| Ok(()),
2118 |_| {
2119 vec![Box::new(IngestionConfigSource {
2120 expected_ingestion_max_records: expected,
2121 records: (1..=8)
2122 .map(|day| {
2123 fixture_record(
2124 "ingestion_config_source",
2125 &format!("r{day}"),
2126 day,
2127 &format!("Config headline {day}"),
2128 &format!("Config body {day}"),
2129 )
2130 })
2131 .collect(),
2132 }) as DynSource]
2133 },
2134 );
2135
2136 assert!(result.is_ok());
2137 }
2138
2139 #[test]
2140 fn parse_cli_handles_display_version_path() {
2141 #[derive(Debug, Parser)]
2142 #[command(name = "version_test", version = "1.0.0")]
2143 struct VersionCli {}
2144
2145 let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
2146 assert!(parsed.is_none());
2147 }
2148
2149 #[test]
2150 fn run_multi_source_demo_list_text_recipes_path_succeeds() {
2151 let dir = tempdir().unwrap();
2152 let split_store_path = dir.path().join("recipes_split_store.bin");
2153 let mut args = vec![
2154 "--list-text-recipes".to_string(),
2155 "--split-store-path".to_string(),
2156 split_store_path.to_string_lossy().to_string(),
2157 ];
2158 let result = run_multi_source_demo(
2159 args.drain(..),
2160 |_| Ok(()),
2161 |_| {
2162 vec![Box::new(TestSource {
2163 id: "source_for_recipes".into(),
2164 count: Some(10),
2165 recipes: vec![default_recipe("recipe_a")],
2166 }) as DynSource]
2167 },
2168 );
2169
2170 assert!(result.is_ok());
2171 }
2172
2173 #[test]
2174 fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
2175 let dir = tempdir().unwrap();
2176 let split_store_path = dir.path().join("custom_split_store.bin");
2177 let args = vec![
2178 "--list-text-recipes".to_string(),
2179 "--split-store-path".to_string(),
2180 split_store_path.to_string_lossy().to_string(),
2181 ];
2182
2183 let result = run_multi_source_demo(
2184 args.into_iter(),
2185 |_| Ok(()),
2186 |_| {
2187 vec![Box::new(TestSource {
2188 id: "source_without_text_recipes".into(),
2189 count: Some(1),
2190 recipes: Vec::new(),
2191 }) as DynSource]
2192 },
2193 );
2194
2195 assert!(result.is_ok());
2196 }
2197
2198 #[test]
2199 fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
2200 for mode in [
2201 vec!["--pair-batch".to_string()],
2202 vec!["--text-recipes".to_string()],
2203 vec![],
2204 ] {
2205 let dir = tempdir().unwrap();
2206 let split_store_path = dir.path().join("empty_sources_split_store.bin");
2207 let mut args = mode;
2208 args.push("--split-store-path".to_string());
2209 args.push(split_store_path.to_string_lossy().to_string());
2210 args.push("--split".to_string());
2211 args.push("validation".to_string());
2212
2213 let result = run_multi_source_demo(
2214 args.into_iter(),
2215 |_| Ok(()),
2216 |_| {
2217 vec![Box::new(TestSource {
2218 id: "source_empty".into(),
2219 count: Some(0),
2220 recipes: vec![default_recipe("recipe_empty")],
2221 }) as DynSource]
2222 },
2223 );
2224
2225 assert!(result.is_ok());
2226 }
2227 }
2228
2229 #[test]
2230 fn run_multi_source_demo_propagates_root_resolution_error() {
2231 let dir = tempdir().unwrap();
2232 let split_store_path = dir.path().join("root_resolution_error_store.bin");
2233 let result = run_multi_source_demo(
2234 [
2235 "--split-store-path".to_string(),
2236 split_store_path.to_string_lossy().to_string(),
2237 ]
2238 .into_iter(),
2239 |_| Err("demo root resolution failed".into()),
2240 empty_dyn_sources,
2241 );
2242
2243 let err = result.unwrap_err().to_string();
2244 assert!(err.contains("demo root resolution failed"));
2245 }
2246
2247 #[test]
2248 fn run_multi_source_demo_list_text_recipes_allows_empty_sources() {
2249 let dir = tempdir().unwrap();
2250 let split_store_path = dir.path().join("empty_source_list_recipes.bin");
2251 let result = run_multi_source_demo(
2252 [
2253 "--list-text-recipes".to_string(),
2254 "--split-store-path".to_string(),
2255 split_store_path.to_string_lossy().to_string(),
2256 ]
2257 .into_iter(),
2258 |_| Ok(()),
2259 empty_dyn_sources,
2260 );
2261
2262 assert!(result.is_ok());
2263 }
2264
2265 #[test]
2266 fn print_helpers_and_extract_source_cover_paths() {
2267 let split = SplitRatios::default();
2268 let store = DeterministicSplitStore::new(split, 42).unwrap();
2269 let strategy = ChunkingStrategy::default();
2270
2271 let anchor = RecordChunk {
2272 record_id: "source_a::rec1".to_string(),
2273 section_idx: 0,
2274 view: ChunkView::Window {
2275 index: 1,
2276 overlap: 2,
2277 span: 12,
2278 },
2279 text: "anchor text".to_string(),
2280 tokens_estimate: 8,
2281 quality: triplets_core::data::QualityScore { trust: 0.9 },
2282 kvp_meta: [(
2283 "date".to_string(),
2284 vec!["2025-01-01".to_string(), "Jan 1, 2025".to_string()],
2285 )]
2286 .into_iter()
2287 .collect(),
2288 };
2289 let positive = RecordChunk {
2290 record_id: "source_a::rec2".to_string(),
2291 section_idx: 1,
2292 view: ChunkView::SummaryFallback {
2293 strategy: "summary".to_string(),
2294 weight: 0.7,
2295 },
2296 text: "positive text".to_string(),
2297 tokens_estimate: 6,
2298 quality: triplets_core::data::QualityScore { trust: 0.8 },
2299 kvp_meta: Default::default(),
2300 };
2301 let negative = RecordChunk {
2302 record_id: "source_b::rec3".to_string(),
2303 section_idx: 2,
2304 view: ChunkView::Window {
2305 index: 0,
2306 overlap: 0,
2307 span: 16,
2308 },
2309 text: "negative text".to_string(),
2310 tokens_estimate: 7,
2311 quality: triplets_core::data::QualityScore { trust: 0.5 },
2312 kvp_meta: Default::default(),
2313 };
2314
2315 let triplet_batch = TripletBatch {
2316 triplets: vec![triplets_core::SampleTriplet {
2317 recipe: "triplet_recipe".to_string(),
2318 anchor: anchor.clone(),
2319 positive: positive.clone(),
2320 negative: negative.clone(),
2321 weight: 1.0,
2322 instruction: Some("triplet instruction".to_string()),
2323 }],
2324 };
2325 print_triplet_batch(&strategy, &triplet_batch, &store);
2326
2327 let pair_batch = SampleBatch {
2328 pairs: vec![triplets_core::SamplePair {
2329 recipe: "pair_recipe".to_string(),
2330 anchor: anchor.clone(),
2331 positive: positive.clone(),
2332 weight: 1.0,
2333 instruction: None,
2334 label: triplets_core::PairLabel::Positive,
2335 reason: Some("same topic".to_string()),
2336 }],
2337 };
2338 print_pair_batch(&strategy, &pair_batch, &store);
2339
2340 let text_batch = TextBatch {
2341 samples: vec![triplets_core::TextSample {
2342 recipe: "text_recipe".to_string(),
2343 chunk: negative,
2344 weight: 0.8,
2345 instruction: Some("text instruction".to_string()),
2346 }],
2347 };
2348 print_text_batch(&strategy, &text_batch, &store);
2349
2350 let recipes = vec![TextRecipe {
2351 name: "recipe_name".into(),
2352 selector: triplets_core::config::Selector::Role(SectionRole::Context),
2353 instruction: Some("instruction".into()),
2354 weight: 1.0,
2355 }];
2356 print_text_recipes(&recipes);
2357
2358 assert_eq!(extract_source("source_a::record"), "source_a");
2359 assert_eq!(extract_source("record-without-delimiter"), "unknown");
2360 }
2361
2362 #[test]
2363 fn split_arg_conversion_and_version_parse_paths_are_covered() {
2364 assert!(matches!(
2365 SplitLabel::from(SplitArg::Train),
2366 SplitLabel::Train
2367 ));
2368 assert!(matches!(
2369 SplitLabel::from(SplitArg::Validation),
2370 SplitLabel::Validation
2371 ));
2372 assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
2373 }
2374
2375 #[test]
2376 fn parse_split_ratios_reports_per_field_parse_errors() {
2377 assert!(
2378 parse_split_ratios_arg("x,0.1,0.9")
2379 .unwrap_err()
2380 .contains("invalid train ratio")
2381 );
2382 assert!(
2383 parse_split_ratios_arg("0.1,y,0.8")
2384 .unwrap_err()
2385 .contains("invalid validation ratio")
2386 );
2387 assert!(
2388 parse_split_ratios_arg("0.1,0.2,z")
2389 .unwrap_err()
2390 .contains("invalid test ratio")
2391 );
2392 }
2393
2394 #[test]
2395 fn run_multi_source_demo_exhausted_paths_are_handled() {
2396 for mode in [
2397 vec!["--pair-batch".to_string()],
2398 vec!["--text-recipes".to_string()],
2399 Vec::new(),
2400 ] {
2401 let dir = tempdir().unwrap();
2402 let split_store_path = dir.path().join("exhausted_split_store.bin");
2403 let mut args = mode;
2404 args.push("--split-store-path".to_string());
2405 args.push(split_store_path.to_string_lossy().to_string());
2406
2407 let result = run_multi_source_demo(
2408 args.into_iter(),
2409 |_| Ok(()),
2410 |_| {
2411 vec![Box::new(TestSource {
2412 id: "source_without_recipes".into(),
2413 count: Some(1),
2414 recipes: Vec::new(),
2415 }) as DynSource]
2416 },
2417 );
2418
2419 assert!(result.is_ok());
2420 }
2421 }
2422
2423 #[test]
2424 fn run_multi_source_demo_reset_recreates_split_store_and_samples() {
2425 let dir = tempdir().unwrap();
2426 let split_store_path = dir.path().join("reset_split_store.bin");
2427 std::fs::write(&split_store_path, b"stale-data").unwrap();
2428
2429 let args = vec![
2430 "--reset".to_string(),
2431 "--pair-batch".to_string(),
2432 "--split-store-path".to_string(),
2433 split_store_path.to_string_lossy().to_string(),
2434 ];
2435
2436 let result = run_multi_source_demo(
2437 args.into_iter(),
2438 |_| Ok(()),
2439 |_| {
2440 let recipes = vec![default_recipe("fixture_recipe")];
2441 let records: Vec<DataRecord> = (1..=8)
2442 .map(|day| {
2443 fixture_record(
2444 "fixture_source",
2445 &format!("r{day}"),
2446 day,
2447 &format!("Fixture headline {day}"),
2448 &format!("Fixture body content for day {day}."),
2449 )
2450 })
2451 .collect();
2452 vec![Box::new(FixtureSource {
2453 id: "fixture_source".into(),
2454 records,
2455 recipes,
2456 }) as DynSource]
2457 },
2458 );
2459
2460 assert!(result.is_ok());
2461 assert!(split_store_path.exists());
2462 let metadata = std::fs::metadata(&split_store_path).unwrap();
2463 assert!(metadata.len() > 0);
2464 }
2465
2466 #[test]
2467 fn run_multi_source_demo_batches_mode_executes_multiple_batches() {
2468 let dir = tempdir().unwrap();
2469 let split_store_path = dir.path().join("batches_split_store.bin");
2470 let args = vec![
2471 "--batches".to_string(),
2472 "2".to_string(),
2473 "--split-store-path".to_string(),
2474 split_store_path.to_string_lossy().to_string(),
2475 ];
2476
2477 let result = run_multi_source_demo(
2478 args.into_iter(),
2479 |_| Ok(()),
2480 |_| {
2481 let recipes = vec![default_recipe("batch_recipe")];
2482 vec![Box::new(FixtureSource {
2483 id: "batch_source".into(),
2484 records: vec![
2485 fixture_record(
2486 "batch_source",
2487 "r1",
2488 3,
2489 "Inflation cools in latest report",
2490 "Core inflation moderated compared with prior quarter.",
2491 ),
2492 fixture_record(
2493 "batch_source",
2494 "r2",
2495 4,
2496 "Labor market remains resilient",
2497 "Job openings remain elevated despite slower growth.",
2498 ),
2499 fixture_record(
2500 "batch_source",
2501 "r3",
2502 5,
2503 "Manufacturing sentiment stabilizes",
2504 "Survey data suggests output expectations are improving.",
2505 ),
2506 ],
2507 recipes,
2508 }) as DynSource]
2509 },
2510 );
2511
2512 assert!(result.is_ok());
2513 assert!(split_store_path.exists());
2514 }
2515
2516 #[test]
2517 fn managed_demo_split_store_path_resolves_under_cache_group() {
2518 let path = managed_demo_split_store_path().unwrap();
2519 assert!(path.ends_with(MULTI_SOURCE_DEMO_STORE_FILENAME));
2520 let parent = path
2521 .parent()
2522 .expect("managed split-store path should have a parent");
2523 assert!(parent.ends_with(PathBuf::from(MULTI_SOURCE_DEMO_GROUP)));
2524 }
2525
2526 #[test]
2527 fn run_multi_source_demo_help_returns_ok_without_work() {
2528 let no_help = run_multi_source_demo(
2529 std::iter::empty::<String>(),
2530 error_unit_roots,
2531 empty_dyn_sources,
2532 );
2533 assert!(
2534 no_help
2535 .expect_err("non-help path should attempt to resolve roots")
2536 .to_string()
2537 .contains("root-resolution-error")
2538 );
2539
2540 let result = run_multi_source_demo(
2541 ["--help".to_string()].into_iter(),
2542 ok_unit_roots,
2543 empty_dyn_sources,
2544 );
2545
2546 assert!(result.is_ok());
2547 }
2548
2549 #[test]
2550 fn run_estimate_capacity_help_returns_ok_without_work() {
2551 let result = run_estimate_capacity(
2552 ["--help".to_string()].into_iter(),
2553 ok_unit_roots,
2554 empty_dyn_sources,
2555 );
2556
2557 assert!(result.is_ok());
2558 }
2559
2560 #[test]
2561 fn run_multi_source_demo_pair_exhausted_branch_returns_ok() {
2562 let dir = tempdir().unwrap();
2563 let split_store_path = dir.path().join("pair_exhausted_split_store.bin");
2564 let args = vec![
2565 "--pair-batch".to_string(),
2566 "--split-store-path".to_string(),
2567 split_store_path.to_string_lossy().to_string(),
2568 ];
2569
2570 let result = run_multi_source_demo(
2571 args.into_iter(),
2572 |_| Ok(()),
2573 |_| {
2574 vec![Box::new(FixtureSource {
2575 id: "pair_exhausted_source".into(),
2576 records: vec![fixture_record(
2577 "pair_exhausted_source",
2578 "r1",
2579 1,
2580 "Single record title",
2581 "Single record body",
2582 )],
2583 recipes: vec![default_recipe("pair_exhausted_recipe")],
2584 }) as DynSource]
2585 },
2586 );
2587
2588 assert!(result.is_ok());
2589 }
2590
2591 #[test]
2592 fn run_multi_source_demo_uses_managed_split_store_path_when_not_provided() {
2593 let result = run_multi_source_demo(
2594 ["--list-text-recipes".to_string()].into_iter(),
2595 |_| Ok(()),
2596 |_| {
2597 vec![Box::new(TestSource {
2598 id: "managed_path_source".into(),
2599 count: Some(2),
2600 recipes: vec![default_recipe("managed_recipe")],
2601 }) as DynSource]
2602 },
2603 );
2604
2605 assert!(result.is_ok());
2606 }
2607
2608 #[test]
2609 fn run_multi_source_demo_reset_errors_when_target_is_directory() {
2610 let dir = tempdir().unwrap();
2611 let split_store_path = dir.path().join("split_store_dir");
2612 std::fs::create_dir(&split_store_path).unwrap();
2613
2614 let result = run_multi_source_demo(
2615 [
2616 "--reset".to_string(),
2617 "--split-store-path".to_string(),
2618 split_store_path.to_string_lossy().to_string(),
2619 ]
2620 .into_iter(),
2621 |_| Ok(()),
2622 empty_dyn_sources,
2623 );
2624
2625 let err = result.unwrap_err().to_string();
2626 assert!(err.contains("failed to remove split store"));
2627 }
2628
2629 #[test]
2630 fn print_summary_helpers_accept_empty_iterators() {
2631 print_source_summary("empty summary", std::iter::empty::<&str>());
2632 print_recipe_context_by_source("empty recipe context", std::iter::empty::<(&str, &str)>());
2633 }
2634
2635 #[cfg(feature = "extended-metrics")]
2636 #[test]
2637 fn metric_mean_median_handles_even_length_inputs() {
2638 let mut vals = [1.0, 4.0, 2.0, 3.0];
2639 let (mean, median) = metric_mean_median(&mut vals);
2640 assert!((mean - 2.5).abs() < 1e-6);
2641 assert!((median - 2.5).abs() < 1e-6);
2642 }
2643
2644 #[cfg(feature = "extended-metrics")]
2645 #[test]
2646 fn metric_mean_median_handles_odd_length_inputs() {
2647 let mut vals = [3.0, 1.0, 2.0];
2648 let (mean, median) = metric_mean_median(&mut vals);
2649 assert!((mean - 2.0).abs() < 1e-6);
2650 assert!((median - 2.0).abs() < 1e-6);
2651 }
2652
2653 #[cfg(feature = "extended-metrics")]
2654 #[test]
2655 fn print_metric_summary_includes_multi_source_aggregate() {
2656 let source_data = HashMap::from([
2657 (
2658 "source_a".to_string(),
2659 vec![(0.9, 0.8, 0.2, 0.1, 0.7), (0.8, 0.7, 0.3, 0.2, 0.8)],
2660 ),
2661 (
2662 "source_b".to_string(),
2663 vec![(0.7, 0.6, 0.4, 0.3, 0.5), (0.6, 0.5, 0.5, 0.4, 0.6)],
2664 ),
2665 ]);
2666
2667 print_metric_summary(&source_data);
2668 }
2669}