Skip to main content

triplets_debug/
lib.rs

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)]
88/// CLI split selector mapped onto `SplitLabel`.
89enum 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)]
113/// CLI arguments for metadata-only capacity estimation.
114struct 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)]
145/// CLI for `multi_source_demo`.
146///
147/// Common usage:
148/// - Use managed cache-group default path (no flag)
149/// - Set an explicit file path: `--split-store-path /tmp/split_store.bin`
150/// - Repeat `--source-root <PATH>` to override source roots in order
151struct 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)]
212/// Source-level inventory used by capacity estimation output.
213struct SourceInventory {
214    source_id: String,
215    reported_records: u128,
216    triplet_recipes: Vec<TripletRecipe>,
217}
218
219/// Run the capacity-estimation CLI with injectable root resolution/source builders.
220///
221/// `build_sources` is construction-only; sampler configuration is applied
222/// centrally by this function before any source calls.
223pub 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
551/// Run the multi-source demo CLI with injectable root resolution/source builders.
552///
553/// `build_sources` is construction-only. Source sampler configuration is owned
554/// by sampler registration (`TripletSampler::register_source`).
555pub 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        // source_id -> Vec<(pos_jaccard, pos_byte_cosine, neg_jaccard, neg_byte_cosine, pos_proximity)>
680        #[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    // Returns [pos, neg] as (mean, median) pairs for one metric across entries.
1115    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    /// Minimal in-memory `DataSource` test double for example app tests.
1465    struct TestSource {
1466        id: String,
1467        count: Option<u128>,
1468        recipes: Vec<TripletRecipe>,
1469    }
1470
1471    impl DataSource for TestSource {
1472        fn id(&self) -> &str {
1473            &self.id
1474        }
1475
1476        fn refresh(
1477            &self,
1478            _config: &SamplerConfig,
1479            _cursor: Option<&SourceCursor>,
1480            _limit: Option<usize>,
1481        ) -> Result<SourceSnapshot, SamplerError> {
1482            Ok(SourceSnapshot {
1483                records: Vec::new(),
1484                cursor: SourceCursor {
1485                    last_seen: Utc::now(),
1486                    revision: 0,
1487                },
1488            })
1489        }
1490
1491        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1492            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
1493                source_id: self.id.clone(),
1494                details: "test source has no configured exact count".to_string(),
1495            })
1496        }
1497
1498        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1499            self.recipes.clone()
1500        }
1501    }
1502
1503    struct ConfigRequiredSource {
1504        id: String,
1505        expected_seed: u64,
1506    }
1507
1508    impl DataSource for ConfigRequiredSource {
1509        fn id(&self) -> &str {
1510            &self.id
1511        }
1512
1513        fn refresh(
1514            &self,
1515            _config: &SamplerConfig,
1516            _cursor: Option<&SourceCursor>,
1517            _limit: Option<usize>,
1518        ) -> Result<SourceSnapshot, SamplerError> {
1519            Ok(SourceSnapshot {
1520                records: Vec::new(),
1521                cursor: SourceCursor {
1522                    last_seen: Utc::now(),
1523                    revision: 0,
1524                },
1525            })
1526        }
1527
1528        fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
1529            if config.seed == self.expected_seed {
1530                Ok(1)
1531            } else {
1532                Err(SamplerError::SourceInconsistent {
1533                    source_id: self.id.clone(),
1534                    details: format!(
1535                        "expected sampler seed {} but got {}",
1536                        self.expected_seed, config.seed
1537                    ),
1538                })
1539            }
1540        }
1541
1542        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1543            Vec::new()
1544        }
1545    }
1546
1547    struct FixtureSource {
1548        id: String,
1549        records: Vec<DataRecord>,
1550        recipes: Vec<TripletRecipe>,
1551    }
1552
1553    impl DataSource for FixtureSource {
1554        fn id(&self) -> &str {
1555            &self.id
1556        }
1557
1558        fn refresh(
1559            &self,
1560            _config: &SamplerConfig,
1561            _cursor: Option<&SourceCursor>,
1562            _limit: Option<usize>,
1563        ) -> Result<SourceSnapshot, SamplerError> {
1564            Ok(SourceSnapshot {
1565                records: self.records.clone(),
1566                cursor: SourceCursor {
1567                    last_seen: Utc::now(),
1568                    revision: 0,
1569                },
1570            })
1571        }
1572
1573        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1574            Ok(self.records.len() as u128)
1575        }
1576
1577        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1578            self.recipes.clone()
1579        }
1580    }
1581
1582    struct IngestionConfigSource {
1583        expected_ingestion_max_records: usize,
1584        records: Vec<DataRecord>,
1585    }
1586
1587    impl DataSource for IngestionConfigSource {
1588        fn id(&self) -> &str {
1589            "ingestion_config_source"
1590        }
1591
1592        fn refresh(
1593            &self,
1594            config: &SamplerConfig,
1595            _cursor: Option<&SourceCursor>,
1596            _limit: Option<usize>,
1597        ) -> Result<SourceSnapshot, SamplerError> {
1598            if config.ingestion_max_records != self.expected_ingestion_max_records {
1599                return Err(SamplerError::SourceInconsistent {
1600                    source_id: self.id().to_string(),
1601                    details: format!(
1602                        "expected ingestion_max_records {} but got {}",
1603                        self.expected_ingestion_max_records, config.ingestion_max_records
1604                    ),
1605                });
1606            }
1607            Ok(SourceSnapshot {
1608                records: self.records.clone(),
1609                cursor: SourceCursor {
1610                    last_seen: Utc::now(),
1611                    revision: 0,
1612                },
1613            })
1614        }
1615
1616        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1617            Ok(self.records.len() as u128)
1618        }
1619
1620        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1621            vec![default_recipe("ingestion_config_recipe")]
1622        }
1623    }
1624
1625    fn fixture_record(
1626        source: &str,
1627        id_suffix: &str,
1628        day: u32,
1629        title: &str,
1630        body: &str,
1631    ) -> DataRecord {
1632        let now = Utc.with_ymd_and_hms(2025, 1, day, 12, 0, 0).unwrap();
1633        DataRecord {
1634            id: format!("{source}::{id_suffix}"),
1635            source: source.to_string(),
1636            created_at: now,
1637            updated_at: now,
1638            quality: QualityScore { trust: 1.0 },
1639            taxonomy: Vec::new(),
1640            sections: vec![
1641                make_section(SectionRole::Anchor, Some("title"), title),
1642                make_section(SectionRole::Context, Some("body"), body),
1643            ],
1644            meta_prefix: None,
1645            label: None,
1646        }
1647    }
1648
1649    fn default_recipe(name: &str) -> TripletRecipe {
1650        TripletRecipe {
1651            name: name.to_string().into(),
1652            anchor: triplets_core::config::Selector::Role(SectionRole::Anchor),
1653            positive_selector: triplets_core::config::Selector::Role(SectionRole::Context),
1654            negative_selector: triplets_core::config::Selector::Role(SectionRole::Context),
1655            negative_strategy: triplets_core::config::NegativeStrategy::WrongArticle,
1656            weight: 1.0,
1657            instruction: None,
1658            allow_same_anchor_positive: false,
1659        }
1660    }
1661
1662    #[test]
1663    fn parse_helpers_validate_inputs() {
1664        assert_eq!(parse_batch_size("2").unwrap(), 2);
1665        assert!(parse_batch_size("0").is_err());
1666        assert!(parse_batch_size("abc").is_err());
1667        assert_eq!(parse_ingestion_max_records("16").unwrap(), 16);
1668        assert!(parse_ingestion_max_records("0").is_err());
1669        assert!(parse_batch_count("0").is_err());
1670
1671        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1672        assert!((split.train - 0.8).abs() < 1e-6);
1673        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1674        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1675        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1676    }
1677
1678    #[test]
1679    fn fixture_and_ingestion_sources_trait_methods_cover_paths() {
1680        let records = vec![fixture_record("fixture_source", "r1", 1, "Title", "Body")];
1681        let recipes = vec![default_recipe("fixture_recipe")];
1682        let fixture = FixtureSource {
1683            id: "fixture_source".into(),
1684            records: records.clone(),
1685            recipes: recipes.clone(),
1686        };
1687
1688        let snapshot = fixture
1689            .refresh(&SamplerConfig::default(), None, None)
1690            .expect("fixture refresh should succeed");
1691        assert_eq!(snapshot.records.len(), 1);
1692        assert_eq!(
1693            fixture
1694                .reported_record_count(&SamplerConfig::default())
1695                .unwrap(),
1696            1
1697        );
1698        assert_eq!(fixture.default_triplet_recipes().len(), 1);
1699
1700        let source = IngestionConfigSource {
1701            expected_ingestion_max_records: 7,
1702            records,
1703        };
1704        let ok_cfg = SamplerConfig {
1705            ingestion_max_records: 7,
1706            ..SamplerConfig::default()
1707        };
1708        assert!(source.refresh(&ok_cfg, None, None).is_ok());
1709        assert_eq!(source.reported_record_count(&ok_cfg).unwrap(), 1);
1710        assert_eq!(source.default_triplet_recipes().len(), 1);
1711
1712        let bad_cfg = SamplerConfig {
1713            ingestion_max_records: 8,
1714            ..SamplerConfig::default()
1715        };
1716        let err = source.refresh(&bad_cfg, None, None).unwrap_err();
1717        assert!(matches!(err, SamplerError::SourceInconsistent { .. }));
1718    }
1719
1720    #[test]
1721    fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1722        assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1723        assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1724        assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1725        assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1726        assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1727    }
1728
1729    #[test]
1730    fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1731        assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1732        assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1733        assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1734        assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1735        assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1736    }
1737
1738    #[test]
1739    fn parse_cli_handles_help_and_invalid_args() {
1740        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1741        assert!(help.is_none());
1742
1743        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1744        assert!(err.is_err());
1745    }
1746
1747    #[test]
1748    fn run_estimate_capacity_succeeds_with_reported_counts() {
1749        let result = run_estimate_capacity(
1750            std::iter::empty::<String>(),
1751            |roots| {
1752                assert!(roots.is_empty());
1753                Ok(())
1754            },
1755            |_| {
1756                vec![Box::new(TestSource {
1757                    id: "source_a".into(),
1758                    count: Some(12),
1759                    recipes: vec![default_recipe("r1")],
1760                }) as DynSource]
1761            },
1762        );
1763
1764        assert!(result.is_ok());
1765    }
1766
1767    #[test]
1768    fn run_estimate_capacity_errors_when_source_count_missing() {
1769        let result = run_estimate_capacity(
1770            std::iter::empty::<String>(),
1771            |_| Ok(()),
1772            |_| {
1773                vec![Box::new(TestSource {
1774                    id: "source_missing".into(),
1775                    count: None,
1776                    recipes: vec![default_recipe("r1")],
1777                }) as DynSource]
1778            },
1779        );
1780
1781        let err = result.unwrap_err().to_string();
1782        assert!(err.contains("failed to report exact record count"));
1783    }
1784
1785    #[test]
1786    fn run_estimate_capacity_propagates_root_resolution_error() {
1787        let result = run_estimate_capacity(
1788            std::iter::empty::<String>(),
1789            |_| Err("root resolution failed".into()),
1790            empty_dyn_sources,
1791        );
1792
1793        let err = result.unwrap_err().to_string();
1794        assert!(err.contains("root resolution failed"));
1795    }
1796
1797    #[test]
1798    fn run_estimate_capacity_allows_empty_source_list() {
1799        let result =
1800            run_estimate_capacity(std::iter::empty::<String>(), |_| Ok(()), empty_dyn_sources);
1801
1802        assert!(result.is_ok());
1803    }
1804
1805    #[test]
1806    fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1807        let result = run_estimate_capacity(
1808            std::iter::empty::<String>(),
1809            |_| Ok(()),
1810            |_| {
1811                vec![Box::new(ConfigRequiredSource {
1812                    id: "requires_config".into(),
1813                    expected_seed: 99,
1814                }) as DynSource]
1815            },
1816        );
1817
1818        assert!(result.is_ok());
1819    }
1820
1821    #[test]
1822    fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1823        let source = ConfigRequiredSource {
1824            id: "cfg-source".to_string(),
1825            expected_seed: 42,
1826        };
1827
1828        let refreshed = source
1829            .refresh(&SamplerConfig::default(), None, None)
1830            .unwrap();
1831        assert!(refreshed.records.is_empty());
1832
1833        let mismatched = source.reported_record_count(&SamplerConfig {
1834            seed: 7,
1835            ..SamplerConfig::default()
1836        });
1837        assert!(matches!(
1838            mismatched,
1839            Err(SamplerError::SourceInconsistent { .. })
1840        ));
1841
1842        assert!(source.default_triplet_recipes().is_empty());
1843    }
1844
1845    #[test]
1846    fn run_multi_source_demo_exhausted_paths_return_ok() {
1847        struct OneRecordSource;
1848
1849        impl DataSource for OneRecordSource {
1850            fn id(&self) -> &str {
1851                "one_record"
1852            }
1853
1854            fn refresh(
1855                &self,
1856                _config: &SamplerConfig,
1857                _cursor: Option<&SourceCursor>,
1858                _limit: Option<usize>,
1859            ) -> Result<SourceSnapshot, SamplerError> {
1860                let now = Utc::now();
1861                Ok(SourceSnapshot {
1862                    records: vec![DataRecord {
1863                        id: "one_record::r1".to_string(),
1864                        source: "one_record".to_string(),
1865                        created_at: now,
1866                        updated_at: now,
1867                        quality: QualityScore { trust: 1.0 },
1868                        taxonomy: Vec::new(),
1869                        sections: vec![
1870                            RecordSection {
1871                                role: SectionRole::Anchor,
1872                                heading: Some("title".to_string()),
1873                                text: "anchor".to_string(),
1874                                sentences: vec!["anchor".to_string()],
1875                            },
1876                            RecordSection {
1877                                role: SectionRole::Context,
1878                                heading: Some("body".to_string()),
1879                                text: "context".to_string(),
1880                                sentences: vec!["context".to_string()],
1881                            },
1882                        ],
1883                        meta_prefix: None,
1884                        label: None,
1885                    }],
1886                    cursor: SourceCursor {
1887                        last_seen: now,
1888                        revision: 0,
1889                    },
1890                })
1891            }
1892
1893            fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1894                Ok(1)
1895            }
1896
1897            fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1898                vec![default_recipe("single_record_recipe")]
1899            }
1900        }
1901
1902        let one = OneRecordSource;
1903        assert_eq!(
1904            one.reported_record_count(&SamplerConfig::default())
1905                .unwrap(),
1906            1
1907        );
1908        assert_eq!(one.default_triplet_recipes().len(), 1);
1909
1910        for mode in ["--pair-batch", "--text-recipes", ""] {
1911            let dir = tempdir().unwrap();
1912            let split_store_path = dir.path().join("split_store.bin");
1913            let mut args = vec![
1914                "--split-store-path".to_string(),
1915                split_store_path.to_string_lossy().to_string(),
1916            ];
1917            if !mode.is_empty() {
1918                args.push(mode.to_string());
1919            }
1920
1921            let result = run_multi_source_demo(
1922                args.into_iter(),
1923                |_| Ok(()),
1924                |_| vec![Box::new(OneRecordSource) as DynSource],
1925            );
1926            assert!(result.is_ok());
1927        }
1928    }
1929
1930    #[test]
1931    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1932        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1933        assert!(help.is_none());
1934
1935        let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1936        assert!(err.is_err());
1937
1938        let err = parse_cli::<MultiSourceDemoCli, _>([
1939            "multi_source_demo",
1940            "--ingestion-max-records",
1941            "0",
1942        ]);
1943        assert!(err.is_err());
1944
1945        let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1946        assert!(parsed.is_ok());
1947    }
1948
1949    #[test]
1950    fn run_debug_invalid_cli_args_return_errors() {
1951        let estimate = run_estimate_capacity(
1952            ["--unknown".to_string()].into_iter(),
1953            ok_unit_roots,
1954            empty_dyn_sources,
1955        );
1956        assert!(estimate.is_err());
1957
1958        let demo = run_multi_source_demo(
1959            ["--unknown".to_string()].into_iter(),
1960            ok_unit_roots,
1961            empty_dyn_sources,
1962        );
1963        assert!(demo.is_err());
1964    }
1965
1966    #[test]
1967    fn helper_and_error_refresh_source_methods_are_exercised() {
1968        assert!(ok_unit_roots(Vec::new()).is_ok());
1969        assert!(error_unit_roots(Vec::new()).is_err());
1970
1971        let source = ErrorRefreshSource {
1972            id: "error_refresh_source".to_string(),
1973        };
1974        assert_eq!(
1975            source
1976                .reported_record_count(&SamplerConfig::default())
1977                .unwrap(),
1978            1
1979        );
1980        assert_eq!(source.default_triplet_recipes().len(), 1);
1981    }
1982
1983    #[test]
1984    fn print_source_summary_handles_non_empty_ids() {
1985        let ids = [
1986            "source_a::r1",
1987            "source_a::r2",
1988            "source_b::r1",
1989            "source_without_delimiter",
1990        ];
1991        print_source_summary("non-empty summary", ids.into_iter());
1992    }
1993
1994    #[test]
1995    fn run_multi_source_demo_refresh_failures_degrade_to_exhausted_paths() {
1996        for mode in [
1997            vec!["--pair-batch".to_string()],
1998            vec!["--text-recipes".to_string()],
1999            vec!["--batches".to_string(), "1".to_string()],
2000            Vec::new(),
2001        ] {
2002            let dir = tempdir().unwrap();
2003            let split_store_path = dir.path().join("error_modes_split_store.bin");
2004            let mut args = mode;
2005            args.push("--split-store-path".to_string());
2006            args.push(split_store_path.to_string_lossy().to_string());
2007
2008            let result = run_multi_source_demo(
2009                args.into_iter(),
2010                |_| Ok(()),
2011                |_| {
2012                    vec![Box::new(ErrorRefreshSource {
2013                        id: "error_refresh_source".to_string(),
2014                    }) as DynSource]
2015                },
2016            );
2017
2018            assert!(result.is_ok());
2019        }
2020    }
2021
2022    #[test]
2023    fn run_multi_source_demo_batches_exhausted_path_returns_ok() {
2024        let dir = tempdir().unwrap();
2025        let split_store_path = dir.path().join("batches_exhausted_split_store.bin");
2026        let args = vec![
2027            "--batches".to_string(),
2028            "3".to_string(),
2029            "--split-store-path".to_string(),
2030            split_store_path.to_string_lossy().to_string(),
2031        ];
2032
2033        let result = run_multi_source_demo(
2034            args.into_iter(),
2035            |_| Ok(()),
2036            |_| {
2037                vec![Box::new(FixtureSource {
2038                    id: "batches_exhausted_source".into(),
2039                    records: vec![fixture_record(
2040                        "batches_exhausted_source",
2041                        "r1",
2042                        1,
2043                        "Only one record",
2044                        "Single record body",
2045                    )],
2046                    recipes: vec![default_recipe("batches_exhausted_recipe")],
2047                }) as DynSource]
2048            },
2049        );
2050
2051        assert!(result.is_ok());
2052    }
2053
2054    #[test]
2055    fn run_multi_source_demo_default_triplet_success_path_returns_ok() {
2056        let dir = tempdir().unwrap();
2057        let split_store_path = dir.path().join("default_triplet_success_split_store.bin");
2058        let args = vec![
2059            "--split-store-path".to_string(),
2060            split_store_path.to_string_lossy().to_string(),
2061        ];
2062
2063        let result = run_multi_source_demo(
2064            args.into_iter(),
2065            |_| Ok(()),
2066            |_| {
2067                vec![Box::new(FixtureSource {
2068                    id: "default_triplet_success_source".into(),
2069                    records: vec![
2070                        fixture_record(
2071                            "default_triplet_success_source",
2072                            "r1",
2073                            1,
2074                            "Title one",
2075                            "Body one",
2076                        ),
2077                        fixture_record(
2078                            "default_triplet_success_source",
2079                            "r2",
2080                            2,
2081                            "Title two",
2082                            "Body two",
2083                        ),
2084                        fixture_record(
2085                            "default_triplet_success_source",
2086                            "r3",
2087                            3,
2088                            "Title three",
2089                            "Body three",
2090                        ),
2091                    ],
2092                    recipes: vec![default_recipe("default_triplet_success_recipe")],
2093                }) as DynSource]
2094            },
2095        );
2096
2097        assert!(result.is_ok());
2098    }
2099
2100    #[test]
2101    fn run_multi_source_demo_passes_ingestion_max_records_to_sources() {
2102        let dir = tempdir().unwrap();
2103        let split_store_path = dir.path().join("ingestion_config_split_store.bin");
2104        let expected = 7;
2105
2106        let result = run_multi_source_demo(
2107            [
2108                "--pair-batch".to_string(),
2109                "--ingestion-max-records".to_string(),
2110                expected.to_string(),
2111                "--split-store-path".to_string(),
2112                split_store_path.to_string_lossy().to_string(),
2113            ]
2114            .into_iter(),
2115            |_| Ok(()),
2116            |_| {
2117                vec![Box::new(IngestionConfigSource {
2118                    expected_ingestion_max_records: expected,
2119                    records: (1..=8)
2120                        .map(|day| {
2121                            fixture_record(
2122                                "ingestion_config_source",
2123                                &format!("r{day}"),
2124                                day,
2125                                &format!("Config headline {day}"),
2126                                &format!("Config body {day}"),
2127                            )
2128                        })
2129                        .collect(),
2130                }) as DynSource]
2131            },
2132        );
2133
2134        assert!(result.is_ok());
2135    }
2136
2137    #[test]
2138    fn parse_cli_handles_display_version_path() {
2139        #[derive(Debug, Parser)]
2140        #[command(name = "version_test", version = "1.0.0")]
2141        struct VersionCli {}
2142
2143        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
2144        assert!(parsed.is_none());
2145    }
2146
2147    #[test]
2148    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
2149        let dir = tempdir().unwrap();
2150        let split_store_path = dir.path().join("recipes_split_store.bin");
2151        let mut args = vec![
2152            "--list-text-recipes".to_string(),
2153            "--split-store-path".to_string(),
2154            split_store_path.to_string_lossy().to_string(),
2155        ];
2156        let result = run_multi_source_demo(
2157            args.drain(..),
2158            |_| Ok(()),
2159            |_| {
2160                vec![Box::new(TestSource {
2161                    id: "source_for_recipes".into(),
2162                    count: Some(10),
2163                    recipes: vec![default_recipe("recipe_a")],
2164                }) as DynSource]
2165            },
2166        );
2167
2168        assert!(result.is_ok());
2169    }
2170
2171    #[test]
2172    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
2173        let dir = tempdir().unwrap();
2174        let split_store_path = dir.path().join("custom_split_store.bin");
2175        let args = vec![
2176            "--list-text-recipes".to_string(),
2177            "--split-store-path".to_string(),
2178            split_store_path.to_string_lossy().to_string(),
2179        ];
2180
2181        let result = run_multi_source_demo(
2182            args.into_iter(),
2183            |_| Ok(()),
2184            |_| {
2185                vec![Box::new(TestSource {
2186                    id: "source_without_text_recipes".into(),
2187                    count: Some(1),
2188                    recipes: Vec::new(),
2189                }) as DynSource]
2190            },
2191        );
2192
2193        assert!(result.is_ok());
2194    }
2195
2196    #[test]
2197    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
2198        for mode in [
2199            vec!["--pair-batch".to_string()],
2200            vec!["--text-recipes".to_string()],
2201            vec![],
2202        ] {
2203            let dir = tempdir().unwrap();
2204            let split_store_path = dir.path().join("empty_sources_split_store.bin");
2205            let mut args = mode;
2206            args.push("--split-store-path".to_string());
2207            args.push(split_store_path.to_string_lossy().to_string());
2208            args.push("--split".to_string());
2209            args.push("validation".to_string());
2210
2211            let result = run_multi_source_demo(
2212                args.into_iter(),
2213                |_| Ok(()),
2214                |_| {
2215                    vec![Box::new(TestSource {
2216                        id: "source_empty".into(),
2217                        count: Some(0),
2218                        recipes: vec![default_recipe("recipe_empty")],
2219                    }) as DynSource]
2220                },
2221            );
2222
2223            assert!(result.is_ok());
2224        }
2225    }
2226
2227    #[test]
2228    fn run_multi_source_demo_propagates_root_resolution_error() {
2229        let dir = tempdir().unwrap();
2230        let split_store_path = dir.path().join("root_resolution_error_store.bin");
2231        let result = run_multi_source_demo(
2232            [
2233                "--split-store-path".to_string(),
2234                split_store_path.to_string_lossy().to_string(),
2235            ]
2236            .into_iter(),
2237            |_| Err("demo root resolution failed".into()),
2238            empty_dyn_sources,
2239        );
2240
2241        let err = result.unwrap_err().to_string();
2242        assert!(err.contains("demo root resolution failed"));
2243    }
2244
2245    #[test]
2246    fn run_multi_source_demo_list_text_recipes_allows_empty_sources() {
2247        let dir = tempdir().unwrap();
2248        let split_store_path = dir.path().join("empty_source_list_recipes.bin");
2249        let result = run_multi_source_demo(
2250            [
2251                "--list-text-recipes".to_string(),
2252                "--split-store-path".to_string(),
2253                split_store_path.to_string_lossy().to_string(),
2254            ]
2255            .into_iter(),
2256            |_| Ok(()),
2257            empty_dyn_sources,
2258        );
2259
2260        assert!(result.is_ok());
2261    }
2262
2263    #[test]
2264    fn print_helpers_and_extract_source_cover_paths() {
2265        let split = SplitRatios::default();
2266        let store = DeterministicSplitStore::new(split, 42).unwrap();
2267        let strategy = ChunkingStrategy::default();
2268
2269        let anchor = RecordChunk {
2270            record_id: "source_a::rec1".to_string(),
2271            section_idx: 0,
2272            view: ChunkView::Window {
2273                index: 1,
2274                overlap: 2,
2275                span: 12,
2276            },
2277            text: "anchor text".to_string(),
2278            tokens_estimate: 8,
2279            quality: triplets_core::data::QualityScore { trust: 0.9 },
2280            kvp_meta: [(
2281                "date".to_string(),
2282                vec!["2025-01-01".to_string(), "Jan 1, 2025".to_string()],
2283            )]
2284            .into_iter()
2285            .collect(),
2286        };
2287        let positive = RecordChunk {
2288            record_id: "source_a::rec2".to_string(),
2289            section_idx: 1,
2290            view: ChunkView::SummaryFallback {
2291                strategy: "summary".to_string(),
2292                weight: 0.7,
2293            },
2294            text: "positive text".to_string(),
2295            tokens_estimate: 6,
2296            quality: triplets_core::data::QualityScore { trust: 0.8 },
2297            kvp_meta: Default::default(),
2298        };
2299        let negative = RecordChunk {
2300            record_id: "source_b::rec3".to_string(),
2301            section_idx: 2,
2302            view: ChunkView::Window {
2303                index: 0,
2304                overlap: 0,
2305                span: 16,
2306            },
2307            text: "negative text".to_string(),
2308            tokens_estimate: 7,
2309            quality: triplets_core::data::QualityScore { trust: 0.5 },
2310            kvp_meta: Default::default(),
2311        };
2312
2313        let triplet_batch = TripletBatch {
2314            triplets: vec![triplets_core::SampleTriplet {
2315                recipe: "triplet_recipe".to_string(),
2316                anchor: anchor.clone(),
2317                positive: positive.clone(),
2318                negative: negative.clone(),
2319                weight: 1.0,
2320                instruction: Some("triplet instruction".to_string()),
2321            }],
2322        };
2323        print_triplet_batch(&strategy, &triplet_batch, &store);
2324
2325        let pair_batch = SampleBatch {
2326            pairs: vec![triplets_core::SamplePair {
2327                recipe: "pair_recipe".to_string(),
2328                anchor: anchor.clone(),
2329                positive: positive.clone(),
2330                weight: 1.0,
2331                instruction: None,
2332                label: triplets_core::PairLabel::Positive,
2333                reason: Some("same topic".to_string()),
2334            }],
2335        };
2336        print_pair_batch(&strategy, &pair_batch, &store);
2337
2338        let text_batch = TextBatch {
2339            samples: vec![triplets_core::TextSample {
2340                recipe: "text_recipe".to_string(),
2341                chunk: negative,
2342                weight: 0.8,
2343                instruction: Some("text instruction".to_string()),
2344            }],
2345        };
2346        print_text_batch(&strategy, &text_batch, &store);
2347
2348        let recipes = vec![TextRecipe {
2349            name: "recipe_name".into(),
2350            selector: triplets_core::config::Selector::Role(SectionRole::Context),
2351            instruction: Some("instruction".into()),
2352            weight: 1.0,
2353        }];
2354        print_text_recipes(&recipes);
2355
2356        assert_eq!(extract_source("source_a::record"), "source_a");
2357        assert_eq!(extract_source("record-without-delimiter"), "unknown");
2358    }
2359
2360    #[test]
2361    fn split_arg_conversion_and_version_parse_paths_are_covered() {
2362        assert!(matches!(
2363            SplitLabel::from(SplitArg::Train),
2364            SplitLabel::Train
2365        ));
2366        assert!(matches!(
2367            SplitLabel::from(SplitArg::Validation),
2368            SplitLabel::Validation
2369        ));
2370        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
2371    }
2372
2373    #[test]
2374    fn parse_split_ratios_reports_per_field_parse_errors() {
2375        assert!(
2376            parse_split_ratios_arg("x,0.1,0.9")
2377                .unwrap_err()
2378                .contains("invalid train ratio")
2379        );
2380        assert!(
2381            parse_split_ratios_arg("0.1,y,0.8")
2382                .unwrap_err()
2383                .contains("invalid validation ratio")
2384        );
2385        assert!(
2386            parse_split_ratios_arg("0.1,0.2,z")
2387                .unwrap_err()
2388                .contains("invalid test ratio")
2389        );
2390    }
2391
2392    #[test]
2393    fn run_multi_source_demo_exhausted_paths_are_handled() {
2394        for mode in [
2395            vec!["--pair-batch".to_string()],
2396            vec!["--text-recipes".to_string()],
2397            Vec::new(),
2398        ] {
2399            let dir = tempdir().unwrap();
2400            let split_store_path = dir.path().join("exhausted_split_store.bin");
2401            let mut args = mode;
2402            args.push("--split-store-path".to_string());
2403            args.push(split_store_path.to_string_lossy().to_string());
2404
2405            let result = run_multi_source_demo(
2406                args.into_iter(),
2407                |_| Ok(()),
2408                |_| {
2409                    vec![Box::new(TestSource {
2410                        id: "source_without_recipes".into(),
2411                        count: Some(1),
2412                        recipes: Vec::new(),
2413                    }) as DynSource]
2414                },
2415            );
2416
2417            assert!(result.is_ok());
2418        }
2419    }
2420
2421    #[test]
2422    fn run_multi_source_demo_reset_recreates_split_store_and_samples() {
2423        let dir = tempdir().unwrap();
2424        let split_store_path = dir.path().join("reset_split_store.bin");
2425        std::fs::write(&split_store_path, b"stale-data").unwrap();
2426
2427        let args = vec![
2428            "--reset".to_string(),
2429            "--pair-batch".to_string(),
2430            "--split-store-path".to_string(),
2431            split_store_path.to_string_lossy().to_string(),
2432        ];
2433
2434        let result = run_multi_source_demo(
2435            args.into_iter(),
2436            |_| Ok(()),
2437            |_| {
2438                let recipes = vec![default_recipe("fixture_recipe")];
2439                let records: Vec<DataRecord> = (1..=8)
2440                    .map(|day| {
2441                        fixture_record(
2442                            "fixture_source",
2443                            &format!("r{day}"),
2444                            day,
2445                            &format!("Fixture headline {day}"),
2446                            &format!("Fixture body content for day {day}."),
2447                        )
2448                    })
2449                    .collect();
2450                vec![Box::new(FixtureSource {
2451                    id: "fixture_source".into(),
2452                    records,
2453                    recipes,
2454                }) as DynSource]
2455            },
2456        );
2457
2458        assert!(result.is_ok());
2459        assert!(split_store_path.exists());
2460        let metadata = std::fs::metadata(&split_store_path).unwrap();
2461        assert!(metadata.len() > 0);
2462    }
2463
2464    #[test]
2465    fn run_multi_source_demo_batches_mode_executes_multiple_batches() {
2466        let dir = tempdir().unwrap();
2467        let split_store_path = dir.path().join("batches_split_store.bin");
2468        let args = vec![
2469            "--batches".to_string(),
2470            "2".to_string(),
2471            "--split-store-path".to_string(),
2472            split_store_path.to_string_lossy().to_string(),
2473        ];
2474
2475        let result = run_multi_source_demo(
2476            args.into_iter(),
2477            |_| Ok(()),
2478            |_| {
2479                let recipes = vec![default_recipe("batch_recipe")];
2480                vec![Box::new(FixtureSource {
2481                    id: "batch_source".into(),
2482                    records: vec![
2483                        fixture_record(
2484                            "batch_source",
2485                            "r1",
2486                            3,
2487                            "Inflation cools in latest report",
2488                            "Core inflation moderated compared with prior quarter.",
2489                        ),
2490                        fixture_record(
2491                            "batch_source",
2492                            "r2",
2493                            4,
2494                            "Labor market remains resilient",
2495                            "Job openings remain elevated despite slower growth.",
2496                        ),
2497                        fixture_record(
2498                            "batch_source",
2499                            "r3",
2500                            5,
2501                            "Manufacturing sentiment stabilizes",
2502                            "Survey data suggests output expectations are improving.",
2503                        ),
2504                    ],
2505                    recipes,
2506                }) as DynSource]
2507            },
2508        );
2509
2510        assert!(result.is_ok());
2511        assert!(split_store_path.exists());
2512    }
2513
2514    #[test]
2515    fn managed_demo_split_store_path_resolves_under_cache_group() {
2516        let path = managed_demo_split_store_path().unwrap();
2517        assert!(path.ends_with(MULTI_SOURCE_DEMO_STORE_FILENAME));
2518        let parent = path
2519            .parent()
2520            .expect("managed split-store path should have a parent");
2521        assert!(parent.ends_with(PathBuf::from(MULTI_SOURCE_DEMO_GROUP)));
2522    }
2523
2524    #[test]
2525    fn run_multi_source_demo_help_returns_ok_without_work() {
2526        let no_help = run_multi_source_demo(
2527            std::iter::empty::<String>(),
2528            error_unit_roots,
2529            empty_dyn_sources,
2530        );
2531        assert!(
2532            no_help
2533                .expect_err("non-help path should attempt to resolve roots")
2534                .to_string()
2535                .contains("root-resolution-error")
2536        );
2537
2538        let result = run_multi_source_demo(
2539            ["--help".to_string()].into_iter(),
2540            ok_unit_roots,
2541            empty_dyn_sources,
2542        );
2543
2544        assert!(result.is_ok());
2545    }
2546
2547    #[test]
2548    fn run_estimate_capacity_help_returns_ok_without_work() {
2549        let result = run_estimate_capacity(
2550            ["--help".to_string()].into_iter(),
2551            ok_unit_roots,
2552            empty_dyn_sources,
2553        );
2554
2555        assert!(result.is_ok());
2556    }
2557
2558    #[test]
2559    fn run_multi_source_demo_pair_exhausted_branch_returns_ok() {
2560        let dir = tempdir().unwrap();
2561        let split_store_path = dir.path().join("pair_exhausted_split_store.bin");
2562        let args = vec![
2563            "--pair-batch".to_string(),
2564            "--split-store-path".to_string(),
2565            split_store_path.to_string_lossy().to_string(),
2566        ];
2567
2568        let result = run_multi_source_demo(
2569            args.into_iter(),
2570            |_| Ok(()),
2571            |_| {
2572                vec![Box::new(FixtureSource {
2573                    id: "pair_exhausted_source".into(),
2574                    records: vec![fixture_record(
2575                        "pair_exhausted_source",
2576                        "r1",
2577                        1,
2578                        "Single record title",
2579                        "Single record body",
2580                    )],
2581                    recipes: vec![default_recipe("pair_exhausted_recipe")],
2582                }) as DynSource]
2583            },
2584        );
2585
2586        assert!(result.is_ok());
2587    }
2588
2589    #[test]
2590    fn run_multi_source_demo_uses_managed_split_store_path_when_not_provided() {
2591        let result = run_multi_source_demo(
2592            ["--list-text-recipes".to_string()].into_iter(),
2593            |_| Ok(()),
2594            |_| {
2595                vec![Box::new(TestSource {
2596                    id: "managed_path_source".into(),
2597                    count: Some(2),
2598                    recipes: vec![default_recipe("managed_recipe")],
2599                }) as DynSource]
2600            },
2601        );
2602
2603        assert!(result.is_ok());
2604    }
2605
2606    #[test]
2607    fn run_multi_source_demo_reset_errors_when_target_is_directory() {
2608        let dir = tempdir().unwrap();
2609        let split_store_path = dir.path().join("split_store_dir");
2610        std::fs::create_dir(&split_store_path).unwrap();
2611
2612        let result = run_multi_source_demo(
2613            [
2614                "--reset".to_string(),
2615                "--split-store-path".to_string(),
2616                split_store_path.to_string_lossy().to_string(),
2617            ]
2618            .into_iter(),
2619            |_| Ok(()),
2620            empty_dyn_sources,
2621        );
2622
2623        let err = result.unwrap_err().to_string();
2624        assert!(err.contains("failed to remove split store"));
2625    }
2626
2627    #[test]
2628    fn print_summary_helpers_accept_empty_iterators() {
2629        print_source_summary("empty summary", std::iter::empty::<&str>());
2630        print_recipe_context_by_source("empty recipe context", std::iter::empty::<(&str, &str)>());
2631    }
2632
2633    #[cfg(feature = "extended-metrics")]
2634    #[test]
2635    fn metric_mean_median_handles_even_length_inputs() {
2636        let mut vals = [1.0, 4.0, 2.0, 3.0];
2637        let (mean, median) = metric_mean_median(&mut vals);
2638        assert!((mean - 2.5).abs() < 1e-6);
2639        assert!((median - 2.5).abs() < 1e-6);
2640    }
2641
2642    #[cfg(feature = "extended-metrics")]
2643    #[test]
2644    fn metric_mean_median_handles_odd_length_inputs() {
2645        let mut vals = [3.0, 1.0, 2.0];
2646        let (mean, median) = metric_mean_median(&mut vals);
2647        assert!((mean - 2.0).abs() < 1e-6);
2648        assert!((median - 2.0).abs() < 1e-6);
2649    }
2650
2651    #[cfg(feature = "extended-metrics")]
2652    #[test]
2653    fn print_metric_summary_includes_multi_source_aggregate() {
2654        let source_data = HashMap::from([
2655            (
2656                "source_a".to_string(),
2657                vec![(0.9, 0.8, 0.2, 0.1, 0.7), (0.8, 0.7, 0.3, 0.2, 0.8)],
2658            ),
2659            (
2660                "source_b".to_string(),
2661                vec![(0.7, 0.6, 0.4, 0.3, 0.5), (0.6, 0.5, 0.5, 0.4, 0.6)],
2662            ),
2663        ]);
2664
2665        print_metric_summary(&source_data);
2666    }
2667}