Skip to main content

triplets_debug/
lib.rs

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