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