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