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