• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / rust-triplets / 23966493447

03 Apr 2026 11:50PM UTC coverage: 95.484% (+0.02%) from 95.467%
23966493447

push

github

web-flow
Add KVP metadata to samples (#57)

92 of 93 new or added lines in 7 files covered. (98.92%)

17548 of 18378 relevant lines covered (95.48%)

4190.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.99
/src/example_apps.rs
1
// TODO: Consider extracting to a debug crate
2

3
use std::collections::HashMap;
4
use std::error::Error;
5
use std::path::PathBuf;
6
use std::sync::Arc;
7
use std::sync::Once;
8
#[cfg(test)]
9
use std::sync::OnceLock;
10
use std::time::Instant;
11
#[cfg(test)]
12
use tempfile::TempDir;
13

14
use cache_manager::CacheRoot;
15
use clap::{Parser, ValueEnum, error::ErrorKind};
16

17
use crate::config::{ChunkingStrategy, SamplerConfig, TripletRecipe};
18
use crate::constants::cache::{MULTI_SOURCE_DEMO_GROUP, MULTI_SOURCE_DEMO_STORE_FILENAME};
19
use crate::data::ChunkView;
20
use crate::heuristics::{
21
    CapacityTotals, EFFECTIVE_NEGATIVES_PER_ANCHOR, EFFECTIVE_POSITIVES_PER_ANCHOR,
22
    estimate_source_split_capacity_from_counts, format_replay_factor, format_u128_with_commas,
23
    resolve_text_recipes_for_source, split_counts_for_total,
24
};
25
use crate::metrics::{chunk_proximity_score, source_skew, window_chunk_distance};
26
use crate::sampler::chunk_weight;
27
use crate::source::DataSource;
28
use crate::splits::{FileSplitStore, SplitLabel, SplitRatios, SplitStore};
29
use crate::{
30
    RecordChunk, SampleBatch, Sampler, SamplerError, SourceId, TextBatch, TextRecipe, TripletBatch,
31
    TripletSampler,
32
};
33

34
type DynSource = Box<dyn DataSource + 'static>;
35

36
#[cfg(feature = "extended-metrics")]
37
type MetricEntry = (f32, f32, f32, f32, f32);
38

39
#[cfg(feature = "extended-metrics")]
40
type SourceMetricsMap = HashMap<String, Vec<MetricEntry>>;
41

42
fn managed_demo_split_store_path() -> Result<PathBuf, String> {
2✔
43
    #[cfg(test)]
44
    {
45
        static TEST_CACHE_ROOT: OnceLock<TempDir> = OnceLock::new();
46
        let root = TEST_CACHE_ROOT.get_or_init(|| {
2✔
47
            TempDir::new().expect("failed to create test demo split-store cache root")
1✔
48
        });
1✔
49
        let cache_root = CacheRoot::from_root(root.path());
2✔
50
        let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
2✔
51
        let dir = cache_root.ensure_group(&group).map_err(|err| {
2✔
52
            format!(
×
53
                "failed creating managed demo cache group '{}': {err}",
54
                group.display()
×
55
            )
56
        })?;
×
57
        Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
2✔
58
    }
59

60
    #[cfg(not(test))]
61
    {
62
        let cache_root = CacheRoot::from_discovery()
×
63
            .map_err(|err| format!("failed discovering managed cache root: {err}"))?;
×
64
        let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
×
65
        let dir = cache_root.ensure_group(&group).map_err(|err| {
×
66
            format!(
×
67
                "failed creating managed demo cache group '{}': {err}",
68
                group.display()
×
69
            )
70
        })?;
×
71
        Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
×
72
    }
73
}
2✔
74

75
fn init_example_tracing() {
35✔
76
    static INIT: Once = Once::new();
77
    INIT.call_once(|| {
35✔
78
        let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
1✔
79
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("triplets=info"));
1✔
80
        let _ = tracing_subscriber::fmt()
1✔
81
            .with_env_filter(env_filter)
1✔
82
            .try_init();
1✔
83
    });
1✔
84
}
35✔
85

86
#[derive(Debug, Clone, Copy, ValueEnum)]
87
/// CLI split selector mapped onto `SplitLabel`.
88
enum SplitArg {
89
    Train,
90
    Validation,
91
    Test,
92
}
93

94
impl From<SplitArg> for SplitLabel {
95
    fn from(value: SplitArg) -> Self {
6✔
96
        match value {
6✔
97
            SplitArg::Train => SplitLabel::Train,
1✔
98
            SplitArg::Validation => SplitLabel::Validation,
4✔
99
            SplitArg::Test => SplitLabel::Test,
1✔
100
        }
101
    }
6✔
102
}
103

104
#[derive(Debug, Parser)]
105
#[command(
106
    name = "estimate_capacity",
107
    disable_help_subcommand = true,
108
    about = "Metadata-only capacity estimation",
109
    long_about = "Estimate record, pair, triplet, and text-sample capacity using source-reported counts only (no data refresh).",
110
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
111
)]
112
/// CLI arguments for metadata-only capacity estimation.
113
struct EstimateCapacityCli {
114
    #[arg(
115
        long,
116
        default_value_t = 99,
117
        help = "Deterministic seed used for split allocation"
118
    )]
119
    seed: u64,
120
    #[arg(
121
        long = "split-ratios",
122
        value_name = "TRAIN,VALIDATION,TEST",
123
        value_parser = parse_split_ratios_arg,
124
        default_value = "0.8,0.1,0.1",
125
        help = "Comma-separated split ratios that must sum to 1.0"
126
    )]
127
    split: SplitRatios,
128
    #[arg(
129
        long = "source-root",
130
        value_name = "PATH",
131
        help = "Optional source root override, repeat as needed in source order"
132
    )]
133
    source_roots: Vec<String>,
134
}
135

136
#[derive(Debug, Parser)]
137
#[command(
138
    name = "multi_source_demo",
139
    disable_help_subcommand = true,
140
    about = "Run sampled batches from multiple sources",
141
    long_about = "Sample triplet, pair, or text batches from multiple sources and persist split/epoch state.",
142
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
143
)]
144
/// CLI for `multi_source_demo`.
145
///
146
/// Common usage:
147
/// - Use managed cache-group default path (no flag)
148
/// - Set an explicit file path: `--split-store-path /tmp/split_store.bin`
149
/// - Repeat `--source-root <PATH>` to override source roots in order
150
struct MultiSourceDemoCli {
151
    #[arg(
152
        long = "text-recipes",
153
        help = "Emit a text batch instead of a triplet batch"
154
    )]
155
    show_text_samples: bool,
156
    #[arg(
157
        long = "pair-batch",
158
        help = "Emit a pair batch instead of a triplet batch"
159
    )]
160
    show_pair_samples: bool,
161
    #[arg(
162
        long = "list-text-recipes",
163
        help = "Print registered text recipes and exit"
164
    )]
165
    list_text_recipes: bool,
166
    #[arg(
167
        long = "batch-size",
168
        default_value_t = 4,
169
        value_parser = parse_batch_size,
170
        help = "Batch size used for sampling"
171
    )]
172
    batch_size: usize,
173
    #[arg(
174
        long = "ingestion-max-records",
175
        default_value_t = default_ingestion_max_records(),
176
        value_parser = parse_ingestion_max_records,
177
        help = "Per-source ingestion buffer target used while refreshing records"
178
    )]
179
    ingestion_max_records: usize,
180
    #[arg(long, help = "Optional deterministic seed override")]
181
    seed: Option<u64>,
182
    #[arg(long, value_enum, help = "Target split to sample from")]
183
    split: Option<SplitArg>,
184
    #[arg(
185
        long = "source-root",
186
        value_name = "PATH",
187
        help = "Optional source root override, repeat as needed in source order"
188
    )]
189
    source_roots: Vec<String>,
190
    #[arg(
191
        long = "split-store-path",
192
        value_name = "SPLIT_STORE_PATH",
193
        help = "Optional explicit path for persisted split/epoch state file"
194
    )]
195
    split_store_path: Option<PathBuf>,
196
    #[arg(
197
        long = "reset",
198
        help = "Delete the persisted split/epoch state before sampling, restarting from epoch 0"
199
    )]
200
    reset: bool,
201
    #[arg(
202
        long = "batches",
203
        value_name = "N",
204
        value_parser = parse_batch_count,
205
        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"
206
    )]
207
    batches: Option<usize>,
208
}
209

210
#[derive(Debug, Clone)]
211
/// Source-level inventory used by capacity estimation output.
212
struct SourceInventory {
213
    source_id: String,
214
    reported_records: u128,
215
    triplet_recipes: Vec<TripletRecipe>,
216
}
217

218
/// Run the capacity-estimation CLI with injectable root resolution/source builders.
219
///
220
/// `build_sources` is construction-only; sampler configuration is applied
221
/// centrally by this function before any source calls.
222
pub fn run_estimate_capacity<R, Resolve, Build, I>(
7✔
223
    args_iter: I,
7✔
224
    resolve_roots: Resolve,
7✔
225
    build_sources: Build,
7✔
226
) -> Result<(), Box<dyn Error>>
7✔
227
where
7✔
228
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
7✔
229
    Build: FnOnce(&R) -> Vec<DynSource>,
7✔
230
    I: Iterator<Item = String>,
7✔
231
{
232
    init_example_tracing();
7✔
233

234
    let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
7✔
235
        std::iter::once("estimate_capacity".to_string()).chain(args_iter),
7✔
236
    )?
1✔
237
    else {
238
        return Ok(());
1✔
239
    };
240

241
    let roots = resolve_roots(cli.source_roots)?;
5✔
242

243
    let config = SamplerConfig {
4✔
244
        seed: cli.seed,
4✔
245
        split: cli.split,
4✔
246
        ..SamplerConfig::default()
4✔
247
    };
4✔
248

249
    let sources = build_sources(&roots);
4✔
250

251
    let mut inventories = Vec::new();
4✔
252
    for source in &sources {
4✔
253
        let recipes = if config.recipes.is_empty() {
3✔
254
            source.default_triplet_recipes()
3✔
255
        } else {
256
            config.recipes.clone()
×
257
        };
258
        let reported_records = source.reported_record_count(&config).map_err(|err| {
3✔
259
            format!(
1✔
260
                "source '{}' failed to report exact record count: {err}",
261
                source.id()
1✔
262
            )
263
        })?;
1✔
264
        inventories.push(SourceInventory {
2✔
265
            source_id: source.id().to_string(),
2✔
266
            reported_records,
2✔
267
            triplet_recipes: recipes,
2✔
268
        });
2✔
269
    }
270

271
    let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
3✔
272
    let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
3✔
273

274
    for source in &inventories {
3✔
275
        let counts = split_counts_for_total(source.reported_records, cli.split);
2✔
276
        for (label, count) in counts {
6✔
277
            per_source_split_counts.insert((source.source_id.clone(), label), count);
6✔
278
            *split_record_counts.entry(label).or_insert(0) += count;
6✔
279
        }
6✔
280
    }
281

282
    let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
3✔
283
    let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
3✔
284
        HashMap::new();
3✔
285

286
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
9✔
287
        let mut totals = CapacityTotals::default();
9✔
288

289
        for source in &inventories {
9✔
290
            let source_split_records = per_source_split_counts
6✔
291
                .get(&(source.source_id.clone(), split_label))
6✔
292
                .copied()
6✔
293
                .unwrap_or(0);
6✔
294

6✔
295
            let triplet_recipes = &source.triplet_recipes;
6✔
296
            let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
6✔
297

6✔
298
            let capacity = estimate_source_split_capacity_from_counts(
6✔
299
                source_split_records,
6✔
300
                triplet_recipes,
6✔
301
                &text_recipes,
6✔
302
            );
6✔
303

6✔
304
            totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
6✔
305

6✔
306
            totals.triplets += capacity.triplets;
6✔
307
            totals.effective_triplets += capacity.effective_triplets;
6✔
308
            totals.pairs += capacity.pairs;
6✔
309
            totals.text_samples += capacity.text_samples;
6✔
310
        }
6✔
311

312
        totals_by_split.insert(split_label, totals);
9✔
313
    }
314

315
    let min_nonzero_records_by_split: HashMap<SplitLabel, u128> =
3✔
316
        [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
3✔
317
            .into_iter()
3✔
318
            .map(|split_label| {
9✔
319
                let min_nonzero = inventories
9✔
320
                    .iter()
9✔
321
                    .filter_map(|source| {
9✔
322
                        per_source_split_counts
6✔
323
                            .get(&(source.source_id.clone(), split_label))
6✔
324
                            .copied()
6✔
325
                    })
6✔
326
                    .filter(|&records| records > 0)
9✔
327
                    .min()
9✔
328
                    .unwrap_or(0);
9✔
329
                (split_label, min_nonzero)
9✔
330
            })
9✔
331
            .collect();
3✔
332

333
    let min_nonzero_records_all_splits = inventories
3✔
334
        .iter()
3✔
335
        .map(|source| source.reported_records)
3✔
336
        .filter(|&records| records > 0)
3✔
337
        .min()
3✔
338
        .unwrap_or(0);
3✔
339

340
    println!("=== capacity estimate (length-only) ===");
3✔
341
    println!("mode: metadata-only (no source.refresh calls)");
3✔
342
    println!("classification: heuristic approximation (not exact)");
3✔
343
    println!("split seed: {}", cli.seed);
3✔
344
    println!(
3✔
345
        "split ratios: train={:.4}, validation={:.4}, test={:.4}",
346
        cli.split.train, cli.split.validation, cli.split.test
347
    );
348
    println!();
3✔
349

350
    println!("[SOURCES]");
3✔
351
    for source in &inventories {
3✔
352
        println!(
2✔
353
            "  {} => reported records: {}",
2✔
354
            source.source_id,
2✔
355
            format_u128_with_commas(source.reported_records)
2✔
356
        );
2✔
357
    }
2✔
358
    println!();
3✔
359

360
    println!("[PER SOURCE BREAKDOWN]");
3✔
361
    for source in &inventories {
3✔
362
        println!("  {}", source.source_id);
2✔
363
        let mut source_grand = CapacityTotals::default();
2✔
364
        let mut source_total_records = 0u128;
2✔
365
        for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
366
            let split_records = per_source_split_counts
6✔
367
                .get(&(source.source_id.clone(), split_label))
6✔
368
                .copied()
6✔
369
                .unwrap_or(0);
6✔
370
            source_total_records = source_total_records.saturating_add(split_records);
6✔
371
            let split_longest_records = inventories
6✔
372
                .iter()
6✔
373
                .map(|candidate| {
6✔
374
                    per_source_split_counts
6✔
375
                        .get(&(candidate.source_id.clone(), split_label))
6✔
376
                        .copied()
6✔
377
                        .unwrap_or(0)
6✔
378
                })
6✔
379
                .max()
6✔
380
                .unwrap_or(0);
6✔
381
            let totals = totals_by_source_and_split
6✔
382
                .get(&(source.source_id.clone(), split_label))
6✔
383
                .copied()
6✔
384
                .unwrap_or_default();
6✔
385
            source_grand.triplets += totals.triplets;
6✔
386
            source_grand.effective_triplets += totals.effective_triplets;
6✔
387
            source_grand.pairs += totals.pairs;
6✔
388
            source_grand.text_samples += totals.text_samples;
6✔
389
            println!("    [{:?}]", split_label);
6✔
390
            println!("      records: {}", format_u128_with_commas(split_records));
6✔
391
            println!(
6✔
392
                "      triplet combinations: {}",
393
                format_u128_with_commas(totals.triplets)
6✔
394
            );
395
            println!(
6✔
396
                "      effective sampled triplets (p={}, k={}): {}",
397
                EFFECTIVE_POSITIVES_PER_ANCHOR,
398
                EFFECTIVE_NEGATIVES_PER_ANCHOR,
399
                format_u128_with_commas(totals.effective_triplets)
6✔
400
            );
401
            println!(
6✔
402
                "      pair combinations:    {}",
403
                format_u128_with_commas(totals.pairs)
6✔
404
            );
405
            println!(
6✔
406
                "      text samples:         {}",
407
                format_u128_with_commas(totals.text_samples)
6✔
408
            );
409
            println!(
6✔
410
                "      replay factor vs longest source: {}",
411
                format_replay_factor(split_longest_records, split_records)
6✔
412
            );
413
            println!(
6✔
414
                "      suggested proportional-size batch weight (0-1): {:.4}",
415
                suggested_balancing_weight(split_longest_records, split_records)
6✔
416
            );
417
            let split_smallest_nonzero = min_nonzero_records_by_split
6✔
418
                .get(&split_label)
6✔
419
                .copied()
6✔
420
                .unwrap_or(0);
6✔
421
            println!(
6✔
422
                "      suggested small-source-boost batch weight (0-1): {:.4}",
423
                suggested_oversampling_weight(split_smallest_nonzero, split_records)
6✔
424
            );
425
            println!();
6✔
426
        }
427
        let longest_source_total = inventories
2✔
428
            .iter()
2✔
429
            .map(|candidate| candidate.reported_records)
2✔
430
            .max()
2✔
431
            .unwrap_or(0);
2✔
432
        println!("    [ALL SPLITS FOR SOURCE]");
2✔
433
        println!(
2✔
434
            "      triplet combinations: {}",
435
            format_u128_with_commas(source_grand.triplets)
2✔
436
        );
437
        println!(
2✔
438
            "      effective sampled triplets (p={}, k={}): {}",
439
            EFFECTIVE_POSITIVES_PER_ANCHOR,
440
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
441
            format_u128_with_commas(source_grand.effective_triplets)
2✔
442
        );
443
        println!(
2✔
444
            "      pair combinations:    {}",
445
            format_u128_with_commas(source_grand.pairs)
2✔
446
        );
447
        println!(
2✔
448
            "      text samples:         {}",
449
            format_u128_with_commas(source_grand.text_samples)
2✔
450
        );
451
        println!(
2✔
452
            "      replay factor vs longest source: {}",
453
            format_replay_factor(longest_source_total, source_total_records)
2✔
454
        );
455
        println!(
2✔
456
            "      suggested proportional-size batch weight (0-1): {:.4}",
457
            suggested_balancing_weight(longest_source_total, source_total_records)
2✔
458
        );
459
        println!(
2✔
460
            "      suggested small-source-boost batch weight (0-1): {:.4}",
461
            suggested_oversampling_weight(min_nonzero_records_all_splits, source_total_records)
2✔
462
        );
463
        println!();
2✔
464
    }
465

466
    let mut grand = CapacityTotals::default();
3✔
467
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
9✔
468
        let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
9✔
469
        let totals = totals_by_split
9✔
470
            .get(&split_label)
9✔
471
            .copied()
9✔
472
            .unwrap_or_default();
9✔
473

9✔
474
        grand.triplets += totals.triplets;
9✔
475
        grand.effective_triplets += totals.effective_triplets;
9✔
476
        grand.pairs += totals.pairs;
9✔
477
        grand.text_samples += totals.text_samples;
9✔
478

9✔
479
        println!("[{:?}]", split_label);
9✔
480
        println!("  records: {}", format_u128_with_commas(record_count));
9✔
481
        println!(
9✔
482
            "  triplet combinations: {}",
9✔
483
            format_u128_with_commas(totals.triplets)
9✔
484
        );
9✔
485
        println!(
9✔
486
            "  effective sampled triplets (p={}, k={}): {}",
9✔
487
            EFFECTIVE_POSITIVES_PER_ANCHOR,
9✔
488
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
9✔
489
            format_u128_with_commas(totals.effective_triplets)
9✔
490
        );
9✔
491
        println!(
9✔
492
            "  pair combinations:    {}",
9✔
493
            format_u128_with_commas(totals.pairs)
9✔
494
        );
9✔
495
        println!(
9✔
496
            "  text samples:         {}",
9✔
497
            format_u128_with_commas(totals.text_samples)
9✔
498
        );
9✔
499
        println!();
9✔
500
    }
9✔
501

502
    println!("[ALL SPLITS TOTAL]");
3✔
503
    println!(
3✔
504
        "  triplet combinations: {}",
505
        format_u128_with_commas(grand.triplets)
3✔
506
    );
507
    println!(
3✔
508
        "  effective sampled triplets (p={}, k={}): {}",
509
        EFFECTIVE_POSITIVES_PER_ANCHOR,
510
        EFFECTIVE_NEGATIVES_PER_ANCHOR,
511
        format_u128_with_commas(grand.effective_triplets)
3✔
512
    );
513
    println!(
3✔
514
        "  pair combinations:    {}",
515
        format_u128_with_commas(grand.pairs)
3✔
516
    );
517
    println!(
3✔
518
        "  text samples:         {}",
519
        format_u128_with_commas(grand.text_samples)
3✔
520
    );
521
    println!();
3✔
522
    println!(
3✔
523
        "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."
524
    );
525
    println!();
3✔
526
    println!(
3✔
527
        "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.",
528
        EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
529
    );
530
    println!();
3✔
531
    println!(
3✔
532
        "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."
533
    );
534
    println!();
3✔
535
    println!(
3✔
536
        "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."
537
    );
538
    println!();
3✔
539
    println!(
3✔
540
        "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."
541
    );
542
    println!();
3✔
543
    println!(
3✔
544
        "When passed to next_*_batch_with_weights, higher weight means that source is sampled more often relative to lower-weight sources."
545
    );
546

547
    Ok(())
3✔
548
}
7✔
549

550
/// Run the multi-source demo CLI with injectable root resolution/source builders.
551
///
552
/// `build_sources` is construction-only. Source sampler configuration is owned
553
/// by sampler registration (`TripletSampler::register_source`).
554
pub fn run_multi_source_demo<R, Resolve, Build, I>(
28✔
555
    args_iter: I,
28✔
556
    resolve_roots: Resolve,
28✔
557
    build_sources: Build,
28✔
558
) -> Result<(), Box<dyn Error>>
28✔
559
where
28✔
560
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
28✔
561
    Build: FnOnce(&R) -> Vec<DynSource>,
28✔
562
    I: Iterator<Item = String>,
28✔
563
{
564
    init_example_tracing();
28✔
565

566
    let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
28✔
567
        std::iter::once("multi_source_demo".to_string()).chain(args_iter),
28✔
568
    )?
1✔
569
    else {
570
        return Ok(());
1✔
571
    };
572

573
    let roots = resolve_roots(cli.source_roots)?;
26✔
574

575
    let mut config = SamplerConfig::default();
24✔
576
    config.seed = cli.seed.unwrap_or(config.seed);
24✔
577
    config.batch_size = cli.batch_size;
24✔
578
    config.ingestion_max_records = cli.ingestion_max_records;
24✔
579
    config.chunking = Default::default();
24✔
580
    let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
24✔
581
    config.split = SplitRatios::default();
24✔
582
    config.allowed_splits = vec![selected_split];
24✔
583
    let chunking = config.chunking.clone();
24✔
584
    let config_snapshot = MultiSourceDemoConfigSnapshot {
24✔
585
        seed: config.seed,
24✔
586
        batch_size: config.batch_size,
24✔
587
        ingestion_max_records: config.ingestion_max_records,
24✔
588
        split: selected_split,
24✔
589
        split_ratios: config.split,
24✔
590
        max_window_tokens: config.chunking.max_window_tokens,
24✔
591
        overlap_tokens: config.chunking.overlap_tokens.clone(),
24✔
592
        summary_fallback_tokens: config.chunking.summary_fallback_tokens,
24✔
593
    };
24✔
594

595
    let split_store_path = if let Some(path) = cli.split_store_path {
24✔
596
        path
23✔
597
    } else {
598
        managed_demo_split_store_path().map_err(|err| {
1✔
599
            Box::<dyn Error>::from(format!("failed to resolve demo split-store path: {err}"))
×
600
        })?
×
601
    };
602

603
    if cli.reset && split_store_path.exists() {
24✔
604
        std::fs::remove_file(&split_store_path).map_err(|err| {
2✔
605
            Box::<dyn Error>::from(format!(
1✔
606
                "failed to remove split store '{}': {err}",
1✔
607
                split_store_path.display()
1✔
608
            ))
1✔
609
        })?;
1✔
610
        println!("Reset: removed {}", split_store_path.display());
1✔
611
    }
22✔
612
    println!(
23✔
613
        "Persisting split assignments and epoch state to {}",
614
        split_store_path.display()
23✔
615
    );
616
    let sources = build_sources(&roots);
23✔
617
    let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
23✔
618
    let sampler = TripletSampler::new(config, split_store.clone());
23✔
619
    for source in sources {
23✔
620
        sampler.register_source(source);
22✔
621
    }
22✔
622

623
    if cli.show_pair_samples {
23✔
624
        match sampler.next_pair_batch(selected_split) {
7✔
625
            Ok(pair_batch) => {
2✔
626
                if pair_batch.pairs.is_empty() {
2✔
627
                    println!("Pair sampling produced no results.");
×
628
                } else {
2✔
629
                    print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
2✔
630
                }
2✔
631
                sampler.save_sampler_state(None)?;
2✔
632
            }
633
            Err(SamplerError::Exhausted(name)) => {
5✔
634
                eprintln!(
5✔
635
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
5✔
636
                    name
5✔
637
                );
5✔
638
            }
5✔
639
            Err(err) => return Err(err.into()),
×
640
        }
641
    } else if cli.show_text_samples {
16✔
642
        match sampler.next_text_batch(selected_split) {
4✔
643
            Ok(text_batch) => {
1✔
644
                if text_batch.samples.is_empty() {
1✔
645
                    println!(
×
646
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
647
                    );
×
648
                } else {
1✔
649
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
1✔
650
                }
1✔
651
                sampler.save_sampler_state(None)?;
1✔
652
            }
653
            Err(SamplerError::Exhausted(name)) => {
3✔
654
                eprintln!(
3✔
655
                    "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
3✔
656
                    name
3✔
657
                );
3✔
658
            }
3✔
659
            Err(err) => return Err(err.into()),
×
660
        }
661
    } else if cli.list_text_recipes {
12✔
662
        let recipes = sampler.text_recipes();
4✔
663
        if recipes.is_empty() {
4✔
664
            println!(
2✔
665
                "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
2✔
666
            );
2✔
667
        } else {
2✔
668
            print_text_recipes(&recipes);
2✔
669
        }
2✔
670
    } else if let Some(batch_count) = cli.batches {
8✔
671
        print_demo_config(&config_snapshot);
3✔
672
        println!("=== benchmark: {} triplet batches ===", batch_count);
3✔
673

674
        // source_id -> Vec<(pos_jaccard, pos_byte_cosine, neg_jaccard, neg_byte_cosine, pos_proximity)>
675
        #[cfg(feature = "extended-metrics")]
676
        let mut source_metrics: SourceMetricsMap = HashMap::new();
3✔
677

678
        for i in 0..batch_count {
4✔
679
            let t0 = Instant::now();
4✔
680
            match sampler.next_triplet_batch(selected_split) {
4✔
681
                Ok(batch) => {
2✔
682
                    let elapsed = t0.elapsed();
2✔
683
                    let n = batch.triplets.len();
2✔
684
                    println!(
2✔
685
                        "batch {:>4}  triplets={:<4}  elapsed={:>8.2}ms  per_triplet={:.2}ms",
686
                        i + 1,
2✔
687
                        n,
688
                        elapsed.as_secs_f64() * 1000.0,
2✔
689
                        if n > 0 {
2✔
690
                            elapsed.as_secs_f64() * 1000.0 / n as f64
2✔
691
                        } else {
692
                            0.0
×
693
                        },
694
                    );
695
                    #[cfg(feature = "extended-metrics")]
696
                    {
697
                        use crate::metrics::lexical_similarity_scores;
698
                        for triplet in &batch.triplets {
8✔
699
                            let (pj, pc) = lexical_similarity_scores(
8✔
700
                                &triplet.anchor.text,
8✔
701
                                &triplet.positive.text,
8✔
702
                            );
8✔
703
                            let (nj, nc) = lexical_similarity_scores(
8✔
704
                                &triplet.anchor.text,
8✔
705
                                &triplet.negative.text,
8✔
706
                            );
8✔
707
                            let proximity =
8✔
708
                                chunk_proximity_score(&triplet.anchor, &triplet.positive);
8✔
709
                            let source = extract_source(&triplet.anchor.record_id);
8✔
710
                            source_metrics
8✔
711
                                .entry(source)
8✔
712
                                .or_default()
8✔
713
                                .push((pj, pc, nj, nc, proximity));
8✔
714
                        }
8✔
715
                    }
716
                }
717
                Err(SamplerError::Exhausted(name)) => {
2✔
718
                    println!(
2✔
719
                        "batch {:>4}  exhausted recipe '{}' — stopping early",
720
                        i + 1,
2✔
721
                        name
722
                    );
723
                    break;
2✔
724
                }
725
                Err(err) => return Err(err.into()),
×
726
            }
727
        }
728

729
        sampler.save_sampler_state(None)?;
3✔
730

731
        #[cfg(feature = "extended-metrics")]
732
        if !source_metrics.is_empty() {
3✔
733
            println!();
1✔
734
            print_metric_summary(&source_metrics);
1✔
735
        }
2✔
736

737
        #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
738
        {
739
            let (fallback, total) = sampler.bm25_fallback_stats();
3✔
740
            if total > 0 {
3✔
741
                let pct = fallback as f64 / total as f64 * 100.0;
1✔
742
                println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
1✔
743
            }
2✔
744
        }
745
    } else {
746
        match sampler.next_triplet_batch(selected_split) {
5✔
747
            Ok(triplet_batch) => {
1✔
748
                if triplet_batch.triplets.is_empty() {
1✔
749
                    println!(
×
750
                        "Triplet sampling produced no results. Ensure multiple records per source exist."
×
751
                    );
×
752
                } else {
1✔
753
                    print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
1✔
754
                }
1✔
755
                sampler.save_sampler_state(None)?;
1✔
756
                #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
757
                {
758
                    let (fallback, total) = sampler.bm25_fallback_stats();
1✔
759
                    if total > 0 {
1✔
760
                        let pct = fallback as f64 / total as f64 * 100.0;
1✔
761
                        println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
1✔
762
                    }
1✔
763
                }
764
            }
765
            Err(SamplerError::Exhausted(name)) => {
4✔
766
                eprintln!(
4✔
767
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
4✔
768
                    name
4✔
769
                );
4✔
770
            }
4✔
771
            Err(err) => return Err(err.into()),
×
772
        }
773
    }
774

775
    Ok(())
23✔
776
}
28✔
777

778
struct MultiSourceDemoConfigSnapshot {
779
    seed: u64,
780
    batch_size: usize,
781
    ingestion_max_records: usize,
782
    split: SplitLabel,
783
    split_ratios: SplitRatios,
784
    max_window_tokens: usize,
785
    overlap_tokens: Vec<usize>,
786
    summary_fallback_tokens: usize,
787
}
788

789
fn print_demo_config(cfg: &MultiSourceDemoConfigSnapshot) {
3✔
790
    let overlaps: Vec<String> = cfg.overlap_tokens.iter().map(|t| t.to_string()).collect();
3✔
791
    println!("=== sampler config ===");
3✔
792
    println!("seed                 : {}", cfg.seed);
3✔
793
    println!("batch_size           : {}", cfg.batch_size);
3✔
794
    println!("ingestion_max_records: {}", cfg.ingestion_max_records);
3✔
795
    println!("split                : {:?}", cfg.split);
3✔
796
    println!(
3✔
797
        "split_ratios         : train={:.2} val={:.2} test={:.2}",
798
        cfg.split_ratios.train, cfg.split_ratios.validation, cfg.split_ratios.test
799
    );
800
    println!("max_window_tokens    : {}", cfg.max_window_tokens);
3✔
801
    println!("overlap_tokens       : [{}]", overlaps.join(", "));
3✔
802
    println!(
3✔
803
        "summary_fallback     : {} tokens (0 = disabled)",
804
        cfg.summary_fallback_tokens
805
    );
806
    println!();
3✔
807
}
3✔
808

809
fn default_ingestion_max_records() -> usize {
1✔
810
    SamplerConfig::default().ingestion_max_records
1✔
811
}
1✔
812

813
fn parse_positive_usize_flag(raw: &str, flag: &str) -> Result<usize, String> {
65✔
814
    let parsed = raw.parse::<usize>().map_err(|_| {
65✔
815
        format!(
1✔
816
            "Could not parse {} value '{}' as a positive integer",
817
            flag, raw
818
        )
819
    })?;
1✔
820
    if parsed == 0 {
64✔
821
        return Err(format!("{} must be greater than zero", flag));
5✔
822
    }
59✔
823
    Ok(parsed)
59✔
824
}
65✔
825

826
fn parse_batch_size(raw: &str) -> Result<usize, String> {
31✔
827
    parse_positive_usize_flag(raw, "--batch-size")
31✔
828
}
31✔
829

830
fn parse_ingestion_max_records(raw: &str) -> Result<usize, String> {
30✔
831
    parse_positive_usize_flag(raw, "--ingestion-max-records")
30✔
832
}
30✔
833

834
fn parse_batch_count(raw: &str) -> Result<usize, String> {
4✔
835
    parse_positive_usize_flag(raw, "--batches")
4✔
836
}
4✔
837

838
fn suggested_balancing_weight(max_baseline: u128, source_baseline: u128) -> f32 {
13✔
839
    if max_baseline == 0 || source_baseline == 0 {
13✔
840
        return 0.0;
4✔
841
    }
9✔
842
    (source_baseline as f64 / max_baseline as f64).clamp(0.0, 1.0) as f32
9✔
843
}
13✔
844

845
fn suggested_oversampling_weight(min_nonzero_baseline: u128, source_baseline: u128) -> f32 {
13✔
846
    if min_nonzero_baseline == 0 || source_baseline == 0 {
13✔
847
        return 0.0;
4✔
848
    }
9✔
849
    (min_nonzero_baseline as f64 / source_baseline as f64).clamp(0.0, 1.0) as f32
9✔
850
}
13✔
851

852
fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
42✔
853
where
42✔
854
    T: Parser,
42✔
855
    I: IntoIterator,
42✔
856
    I::Item: Into<std::ffi::OsString> + Clone,
42✔
857
{
858
    match T::try_parse_from(args) {
42✔
859
        Ok(cli) => Ok(Some(cli)),
32✔
860
        Err(err) => match err.kind() {
10✔
861
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
862
                err.print()?;
5✔
863
                Ok(None)
5✔
864
            }
865
            _ => Err(err.into()),
5✔
866
        },
867
    }
868
}
42✔
869

870
fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
12✔
871
    let parts: Vec<&str> = raw.split(',').collect();
12✔
872
    if parts.len() != 3 {
12✔
873
        return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
1✔
874
    }
11✔
875
    let train = parts[0]
11✔
876
        .trim()
11✔
877
        .parse::<f32>()
11✔
878
        .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
11✔
879
    let validation = parts[1].trim().parse::<f32>().map_err(|_| {
10✔
880
        format!(
1✔
881
            "invalid validation ratio '{}': must be a float",
882
            parts[1].trim()
1✔
883
        )
884
    })?;
1✔
885
    let test = parts[2]
9✔
886
        .trim()
9✔
887
        .parse::<f32>()
9✔
888
        .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
9✔
889
    let ratios = SplitRatios {
8✔
890
        train,
8✔
891
        validation,
8✔
892
        test,
8✔
893
    };
8✔
894
    let sum = ratios.train + ratios.validation + ratios.test;
8✔
895
    if (sum - 1.0).abs() > 1e-5 {
8✔
896
        return Err(format!(
1✔
897
            "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
1✔
898
            sum, ratios.train, ratios.validation, ratios.test
1✔
899
        ));
1✔
900
    }
7✔
901
    if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
7✔
902
        return Err("split ratios must be non-negative".to_string());
1✔
903
    }
6✔
904
    Ok(ratios)
6✔
905
}
12✔
906

907
fn print_triplet_batch(
2✔
908
    strategy: &ChunkingStrategy,
2✔
909
    batch: &TripletBatch,
2✔
910
    split_store: &impl SplitStore,
2✔
911
) {
2✔
912
    println!("=== triplet batch ===");
2✔
913
    for (idx, triplet) in batch.triplets.iter().enumerate() {
5✔
914
        println!("--- triplet #{} ---", idx);
5✔
915
        println!("recipe       : {}", triplet.recipe);
5✔
916
        println!("sample_weight: {:.4}", triplet.weight);
5✔
917
        if let Some(instr) = &triplet.instruction {
5✔
918
            println!("instruction shown to model:");
1✔
919
            println!("{instr}");
1✔
920
            println!();
1✔
921
        }
4✔
922
        let pos_proximity = chunk_proximity_score(&triplet.anchor, &triplet.positive);
5✔
923
        let pos_distance = window_chunk_distance(&triplet.anchor, &triplet.positive);
5✔
924
        #[cfg(feature = "extended-metrics")]
925
        let (pos_sim, neg_sim) = {
5✔
926
            use crate::metrics::lexical_similarity_scores;
927
            (
5✔
928
                Some(lexical_similarity_scores(
5✔
929
                    &triplet.anchor.text,
5✔
930
                    &triplet.positive.text,
5✔
931
                )),
5✔
932
                Some(lexical_similarity_scores(
5✔
933
                    &triplet.anchor.text,
5✔
934
                    &triplet.negative.text,
5✔
935
                )),
5✔
936
            )
5✔
937
        };
938
        #[cfg(not(feature = "extended-metrics"))]
939
        let (pos_sim, neg_sim): (Option<(f32, f32)>, Option<(f32, f32)>) = (None, None);
940
        print_chunk_block(
5✔
941
            "ANCHOR",
5✔
942
            &triplet.anchor,
5✔
943
            strategy,
5✔
944
            split_store,
5✔
945
            None,
5✔
946
            None,
5✔
947
            None,
5✔
948
        );
949
        print_chunk_block(
5✔
950
            "POSITIVE",
5✔
951
            &triplet.positive,
5✔
952
            strategy,
5✔
953
            split_store,
5✔
954
            pos_sim,
5✔
955
            Some(pos_proximity),
5✔
956
            pos_distance,
5✔
957
        );
958
        print_chunk_block(
5✔
959
            "NEGATIVE",
5✔
960
            &triplet.negative,
5✔
961
            strategy,
5✔
962
            split_store,
5✔
963
            neg_sim,
5✔
964
            None,
5✔
965
            None,
5✔
966
        );
967
    }
968
    print_source_summary(
2✔
969
        "triplet anchors",
2✔
970
        batch
2✔
971
            .triplets
2✔
972
            .iter()
2✔
973
            .map(|triplet| triplet.anchor.record_id.as_str()),
5✔
974
    );
975
    print_recipe_context_by_source(
2✔
976
        "triplet recipes by source",
2✔
977
        batch
2✔
978
            .triplets
2✔
979
            .iter()
2✔
980
            .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
5✔
981
    );
982
}
2✔
983

984
fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
2✔
985
    println!("=== text batch ===");
2✔
986
    for (idx, sample) in batch.samples.iter().enumerate() {
5✔
987
        println!("--- sample #{} ---", idx);
5✔
988
        println!("recipe       : {}", sample.recipe);
5✔
989
        println!("sample_weight: {:.4}", sample.weight);
5✔
990
        if let Some(instr) = &sample.instruction {
5✔
991
            println!("instruction shown to model:");
1✔
992
            println!("{instr}");
1✔
993
            println!();
1✔
994
        }
4✔
995
        print_chunk_block(
5✔
996
            "TEXT",
5✔
997
            &sample.chunk,
5✔
998
            strategy,
5✔
999
            split_store,
5✔
1000
            None,
5✔
1001
            None,
5✔
1002
            None,
5✔
1003
        );
1004
    }
1005
    print_source_summary(
2✔
1006
        "text samples",
2✔
1007
        batch
2✔
1008
            .samples
2✔
1009
            .iter()
2✔
1010
            .map(|sample| sample.chunk.record_id.as_str()),
5✔
1011
    );
1012
    print_recipe_context_by_source(
2✔
1013
        "text recipes by source",
2✔
1014
        batch
2✔
1015
            .samples
2✔
1016
            .iter()
2✔
1017
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
5✔
1018
    );
1019
}
2✔
1020

1021
fn print_pair_batch(
3✔
1022
    strategy: &ChunkingStrategy,
3✔
1023
    batch: &SampleBatch,
3✔
1024
    split_store: &impl SplitStore,
3✔
1025
) {
3✔
1026
    println!("=== pair batch ===");
3✔
1027
    for (idx, pair) in batch.pairs.iter().enumerate() {
9✔
1028
        println!("--- pair #{} ---", idx);
9✔
1029
        println!("recipe       : {}", pair.recipe);
9✔
1030
        println!("label        : {:?}", pair.label);
9✔
1031
        if let Some(reason) = &pair.reason {
9✔
1032
            println!("reason       : {}", reason);
5✔
1033
        }
5✔
1034
        print_chunk_block(
9✔
1035
            "ANCHOR",
9✔
1036
            &pair.anchor,
9✔
1037
            strategy,
9✔
1038
            split_store,
9✔
1039
            None,
9✔
1040
            None,
9✔
1041
            None,
9✔
1042
        );
1043
        print_chunk_block(
9✔
1044
            "OTHER",
9✔
1045
            &pair.positive,
9✔
1046
            strategy,
9✔
1047
            split_store,
9✔
1048
            None,
9✔
1049
            None,
9✔
1050
            None,
9✔
1051
        );
1052
    }
1053
    print_source_summary(
3✔
1054
        "pair anchors",
3✔
1055
        batch
3✔
1056
            .pairs
3✔
1057
            .iter()
3✔
1058
            .map(|pair| pair.anchor.record_id.as_str()),
9✔
1059
    );
1060
    print_recipe_context_by_source(
3✔
1061
        "pair recipes by source",
3✔
1062
        batch
3✔
1063
            .pairs
3✔
1064
            .iter()
3✔
1065
            .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
9✔
1066
    );
1067
}
3✔
1068

1069
fn print_text_recipes(recipes: &[TextRecipe]) {
3✔
1070
    println!("=== available text recipes ===");
3✔
1071
    for recipe in recipes {
7✔
1072
        println!(
7✔
1073
            "- {} (weight: {:.3}) selector={:?}",
1074
            recipe.name, recipe.weight, recipe.selector
1075
        );
1076
        if let Some(instr) = &recipe.instruction {
7✔
1077
            println!("  instruction: {}", instr);
1✔
1078
        }
6✔
1079
    }
1080
}
3✔
1081

1082
#[cfg(feature = "extended-metrics")]
1083
fn metric_mean_median(vals: &mut [f32]) -> (f32, f32) {
22✔
1084
    let mean = vals.iter().sum::<f32>() / vals.len() as f32;
22✔
1085
    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
95✔
1086
    let median = if vals.len() % 2 == 1 {
22✔
1087
        vals[vals.len() / 2]
1✔
1088
    } else {
1089
        (vals[vals.len() / 2 - 1] + vals[vals.len() / 2]) / 2.0
21✔
1090
    };
1091
    (mean, median)
22✔
1092
}
22✔
1093

1094
#[cfg(feature = "extended-metrics")]
1095
fn print_metric_summary(source_data: &SourceMetricsMap) {
2✔
1096
    let total: usize = source_data.values().map(|v| v.len()).sum();
3✔
1097
    let n_sources = source_data.len();
2✔
1098
    println!(
2✔
1099
        "=== extended metrics summary ({} triplets, {} {}) ===",
1100
        total,
1101
        n_sources,
1102
        if n_sources == 1 { "source" } else { "sources" }
2✔
1103
    );
1104

1105
    // Returns [pos, neg] as (mean, median) pairs for one metric across entries.
1106
    fn metric_pair(entries: &[MetricEntry], pos_idx: usize, neg_idx: usize) -> [(f32, f32); 2] {
8✔
1107
        let extract = |idx: usize| -> Vec<f32> {
16✔
1108
            entries
16✔
1109
                .iter()
16✔
1110
                .map(|e| match idx {
64✔
1111
                    0 => e.0,
16✔
1112
                    1 => e.1,
16✔
1113
                    2 => e.2,
16✔
1114
                    3 => e.3,
16✔
1115
                    _ => e.4,
×
1116
                })
64✔
1117
                .collect()
16✔
1118
        };
16✔
1119
        let mut pos_vals = extract(pos_idx);
8✔
1120
        let mut neg_vals = extract(neg_idx);
8✔
1121
        [
8✔
1122
            metric_mean_median(&mut pos_vals),
8✔
1123
            metric_mean_median(&mut neg_vals),
8✔
1124
        ]
8✔
1125
    }
8✔
1126

1127
    fn print_metric_section(
4✔
1128
        label: &str,
4✔
1129
        sources: &[&String],
4✔
1130
        source_data: &SourceMetricsMap,
4✔
1131
        pos_idx: usize,
4✔
1132
        neg_idx: usize,
4✔
1133
        total: usize,
4✔
1134
        n_sources: usize,
4✔
1135
    ) {
4✔
1136
        const SEP: usize = 83;
1137
        println!();
4✔
1138
        println!("[{}]", label);
4✔
1139
        println!(
4✔
1140
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1141
            "source", "n", "positive", "negative", "gap (pos\u{2212}neg)"
1142
        );
1143
        println!(
4✔
1144
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1145
            "", "", "mean / median", "mean / median", "mean / median"
1146
        );
1147
        println!("{}", "-".repeat(SEP));
4✔
1148
        for source in sources {
6✔
1149
            let entries = &source_data[*source];
6✔
1150
            let [pos, neg] = metric_pair(entries, pos_idx, neg_idx);
6✔
1151
            let gap_mean = pos.0 - neg.0;
6✔
1152
            let gap_med = pos.1 - neg.1;
6✔
1153
            println!(
6✔
1154
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
6✔
1155
                source,
6✔
1156
                entries.len(),
6✔
1157
                pos.0,
6✔
1158
                pos.1,
6✔
1159
                neg.0,
6✔
1160
                neg.1,
6✔
1161
                gap_mean,
6✔
1162
                gap_med,
6✔
1163
            );
6✔
1164
        }
6✔
1165
        if n_sources > 1 {
4✔
1166
            let all: Vec<MetricEntry> = source_data.values().flatten().copied().collect();
2✔
1167
            let [pos, neg] = metric_pair(&all, pos_idx, neg_idx);
2✔
1168
            let gap_mean = pos.0 - neg.0;
2✔
1169
            let gap_med = pos.1 - neg.1;
2✔
1170
            println!("{}", "-".repeat(SEP));
2✔
1171
            println!(
2✔
1172
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
2✔
1173
                "ALL", total, pos.0, pos.1, neg.0, neg.1, gap_mean, gap_med,
2✔
1174
            );
2✔
1175
        }
2✔
1176
    }
4✔
1177

1178
    fn print_single_metric_section(
2✔
1179
        label: &str,
2✔
1180
        sources: &[&String],
2✔
1181
        source_data: &SourceMetricsMap,
2✔
1182
        idx: usize,
2✔
1183
        total: usize,
2✔
1184
        n_sources: usize,
2✔
1185
    ) {
2✔
1186
        const SEP: usize = 58;
1187
        println!();
2✔
1188
        println!("[{}]", label);
2✔
1189
        println!("{:<24} {:>5}  {:<16}", "source", "n", "mean / median");
2✔
1190
        println!("{}", "-".repeat(SEP));
2✔
1191
        for source in sources {
3✔
1192
            let entries = &source_data[*source];
3✔
1193
            let mut vals: Vec<f32> = entries
3✔
1194
                .iter()
3✔
1195
                .map(|e| match idx {
12✔
1196
                    0 => e.0,
×
1197
                    1 => e.1,
×
1198
                    2 => e.2,
×
1199
                    3 => e.3,
×
1200
                    _ => e.4,
12✔
1201
                })
12✔
1202
                .collect();
3✔
1203
            let (mean, median) = metric_mean_median(&mut vals);
3✔
1204
            println!(
3✔
1205
                "{:<24} {:>5}  {:.3} / {:.3}",
1206
                source,
1207
                entries.len(),
3✔
1208
                mean,
1209
                median,
1210
            );
1211
        }
1212
        if n_sources > 1 {
2✔
1213
            let mut all: Vec<f32> = source_data
1✔
1214
                .values()
1✔
1215
                .flatten()
1✔
1216
                .map(|e| match idx {
4✔
1217
                    0 => e.0,
×
1218
                    1 => e.1,
×
1219
                    2 => e.2,
×
1220
                    3 => e.3,
×
1221
                    _ => e.4,
4✔
1222
                })
4✔
1223
                .collect();
1✔
1224
            let (mean, median) = metric_mean_median(&mut all);
1✔
1225
            println!("{}", "-".repeat(SEP));
1✔
1226
            println!("{:<24} {:>5}  {:.3} / {:.3}", "ALL", total, mean, median);
1✔
1227
        }
1✔
1228
    }
2✔
1229

1230
    let mut sources: Vec<&String> = source_data.keys().collect();
2✔
1231
    sources.sort();
2✔
1232

1233
    print_metric_section(
2✔
1234
        "jaccard \u{2194} anchor",
2✔
1235
        &sources,
2✔
1236
        source_data,
2✔
1237
        0,
1238
        2,
1239
        total,
2✔
1240
        n_sources,
2✔
1241
    );
1242
    print_metric_section(
2✔
1243
        "byte-cos \u{2194} anchor",
2✔
1244
        &sources,
2✔
1245
        source_data,
2✔
1246
        1,
1247
        3,
1248
        total,
2✔
1249
        n_sources,
2✔
1250
    );
1251
    print_single_metric_section(
2✔
1252
        "anchor-positive proximity",
2✔
1253
        &sources,
2✔
1254
        source_data,
2✔
1255
        4,
1256
        total,
2✔
1257
        n_sources,
2✔
1258
    );
1259
    println!();
2✔
1260
}
2✔
1261

1262
trait ChunkDebug {
1263
    fn view_name(&self) -> String;
1264
}
1265

1266
impl ChunkDebug for RecordChunk {
1267
    fn view_name(&self) -> String {
38✔
1268
        match &self.view {
38✔
1269
            ChunkView::Window {
1270
                index,
36✔
1271
                span,
36✔
1272
                overlap,
36✔
1273
            } => format!(
36✔
1274
                "window#index={} span={} overlap={} tokens={}",
1275
                index, span, overlap, self.tokens_estimate
1276
            ),
1277
            ChunkView::SummaryFallback { strategy, .. } => {
2✔
1278
                format!("summary:{} tokens={}", strategy, self.tokens_estimate)
2✔
1279
            }
1280
        }
1281
    }
38✔
1282
}
1283

1284
fn print_chunk_block(
38✔
1285
    title: &str,
38✔
1286
    chunk: &RecordChunk,
38✔
1287
    strategy: &ChunkingStrategy,
38✔
1288
    split_store: &impl SplitStore,
38✔
1289
    anchor_sim: Option<(f32, f32)>,
38✔
1290
    ap_proximity: Option<f32>,
38✔
1291
    ap_distance: Option<f32>,
38✔
1292
) {
38✔
1293
    let chunk_weight = chunk_weight(strategy, chunk);
38✔
1294
    let split = split_store
38✔
1295
        .label_for(&chunk.record_id)
38✔
1296
        .map(|label| format!("{:?}", label))
38✔
1297
        .unwrap_or_else(|| "Unknown".to_string());
38✔
1298
    println!("--- {} ---", title);
38✔
1299
    println!("split        : {}", split);
38✔
1300
    println!("view         : {}", chunk.view_name());
38✔
1301
    println!("chunk_weight : {:.4}", chunk_weight);
38✔
1302
    println!("record_id    : {}", chunk.record_id);
38✔
1303
    println!("section_idx  : {}", chunk.section_idx);
38✔
1304
    println!("token_est    : {}", chunk.tokens_estimate);
38✔
1305
    if let Some(proximity) = ap_proximity {
38✔
1306
        println!("a<->p proximity  : {:.4}", proximity);
5✔
1307
    }
33✔
1308
    if let Some(distance) = ap_distance {
38✔
1309
        println!("a<->p distance   : {:.4}", distance);
×
1310
    }
38✔
1311
    if let Some((j, c)) = anchor_sim {
38✔
1312
        println!("jaccard(↔a)  : {:.4}  byte-cos(↔a): {:.4}", j, c);
10✔
1313
    }
28✔
1314
    if !chunk.kvp_meta.is_empty() {
38✔
1315
        let mut kvp_keys: Vec<&String> = chunk.kvp_meta.keys().collect();
2✔
1316
        kvp_keys.sort();
2✔
1317
        println!("kvp_meta     :");
2✔
1318
        for k in kvp_keys {
2✔
1319
            let vals = &chunk.kvp_meta[k];
2✔
1320
            let display = if vals.len() > 1 {
2✔
1321
                format!("{} ({} variations)", vals[0], vals.len())
2✔
1322
            } else {
NEW
1323
                vals[0].clone()
×
1324
            };
1325
            println!("\t{}: {}", k, display);
2✔
1326
        }
1327
    }
36✔
1328
    println!("model_input (exact text sent to the model):");
38✔
1329
    println!("<<< BEGIN MODEL TEXT >>>");
38✔
1330
    println!("{}", chunk.text);
38✔
1331
    println!("<<< END MODEL TEXT >>>");
38✔
1332
    println!();
38✔
1333
}
38✔
1334

1335
fn print_source_summary<'a, I>(label: &str, ids: I)
9✔
1336
where
9✔
1337
    I: Iterator<Item = &'a str>,
9✔
1338
{
1339
    let mut counts: HashMap<SourceId, usize> = HashMap::new();
9✔
1340
    for id in ids {
23✔
1341
        let source = extract_source(id);
23✔
1342
        *counts.entry(source).or_insert(0) += 1;
23✔
1343
    }
23✔
1344
    if counts.is_empty() {
9✔
1345
        return;
1✔
1346
    }
8✔
1347
    let skew = source_skew(&counts);
8✔
1348
    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
8✔
1349
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
8✔
1350
    println!("--- {} by source ---", label);
8✔
1351
    if let Some(skew) = skew {
8✔
1352
        for entry in &skew.per_source {
10✔
1353
            println!(
10✔
1354
                "{}: count={} share={:.2}",
10✔
1355
                entry.source, entry.count, entry.share
10✔
1356
            );
10✔
1357
        }
10✔
1358
        println!(
8✔
1359
            "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
1360
            skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
1361
        );
1362
    }
×
1363
}
9✔
1364

1365
fn print_recipe_context_by_source<'a, I>(label: &str, entries: I)
8✔
1366
where
8✔
1367
    I: Iterator<Item = (&'a str, &'a str)>,
8✔
1368
{
1369
    let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
8✔
1370
    for (record_id, recipe) in entries {
19✔
1371
        let source = extract_source(record_id);
19✔
1372
        let entry = counts
19✔
1373
            .entry(source)
19✔
1374
            .or_default()
19✔
1375
            .entry(recipe.to_string())
19✔
1376
            .or_insert(0);
19✔
1377
        *entry += 1;
19✔
1378
    }
19✔
1379
    if counts.is_empty() {
8✔
1380
        return;
1✔
1381
    }
7✔
1382
    let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
7✔
1383
    sources.sort_by(|a, b| a.0.cmp(&b.0));
7✔
1384
    println!("--- {} ---", label);
7✔
1385
    for (source, recipes) in sources {
7✔
1386
        println!("{source}");
7✔
1387
        let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
7✔
1388
        entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
7✔
1389
        for (recipe, count) in entries {
8✔
1390
            println!("  - {recipe}={count}");
8✔
1391
        }
8✔
1392
    }
1393
}
8✔
1394

1395
fn extract_source(record_id: &str) -> SourceId {
52✔
1396
    record_id
52✔
1397
        .split_once("::")
52✔
1398
        .map(|(source, _)| source.to_string())
52✔
1399
        .unwrap_or_else(|| "unknown".to_string())
52✔
1400
}
52✔
1401

1402
#[cfg(test)]
1403
mod tests {
1404
    use super::*;
1405
    use crate::DataRecord;
1406
    use crate::DeterministicSplitStore;
1407
    use crate::data::{QualityScore, RecordSection, SectionRole};
1408
    use crate::source::{SourceCursor, SourceSnapshot};
1409
    use crate::utils::make_section;
1410
    use chrono::{TimeZone, Utc};
1411
    use tempfile::tempdir;
1412

1413
    fn empty_dyn_sources(_: &()) -> Vec<DynSource> {
2✔
1414
        Vec::new()
2✔
1415
    }
2✔
1416

1417
    fn ok_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
1✔
1418
        Ok(())
1✔
1419
    }
1✔
1420

1421
    fn error_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
2✔
1422
        Err("root-resolution-error".into())
2✔
1423
    }
2✔
1424

1425
    struct ErrorRefreshSource {
1426
        id: String,
1427
    }
1428

1429
    impl DataSource for ErrorRefreshSource {
1430
        fn id(&self) -> &str {
84✔
1431
            &self.id
84✔
1432
        }
84✔
1433

1434
        fn refresh(
20✔
1435
            &self,
20✔
1436
            _config: &SamplerConfig,
20✔
1437
            _cursor: Option<&SourceCursor>,
20✔
1438
            _limit: Option<usize>,
20✔
1439
        ) -> Result<SourceSnapshot, SamplerError> {
20✔
1440
            Err(SamplerError::SourceUnavailable {
20✔
1441
                source_id: self.id.clone(),
20✔
1442
                reason: "simulated refresh failure".to_string(),
20✔
1443
            })
20✔
1444
        }
20✔
1445

1446
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1447
            Ok(1)
1✔
1448
        }
1✔
1449

1450
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
5✔
1451
            vec![default_recipe("error_refresh_recipe")]
5✔
1452
        }
5✔
1453
    }
1454

1455
    /// Minimal in-memory `DataSource` test double for example app tests.
1456
    struct TestSource {
1457
        id: String,
1458
        count: Option<u128>,
1459
        recipes: Vec<TripletRecipe>,
1460
    }
1461

1462
    impl DataSource for TestSource {
1463
        fn id(&self) -> &str {
131✔
1464
            &self.id
131✔
1465
        }
131✔
1466

1467
        fn refresh(
30✔
1468
            &self,
30✔
1469
            _config: &SamplerConfig,
30✔
1470
            _cursor: Option<&SourceCursor>,
30✔
1471
            _limit: Option<usize>,
30✔
1472
        ) -> Result<SourceSnapshot, SamplerError> {
30✔
1473
            Ok(SourceSnapshot {
30✔
1474
                records: Vec::new(),
30✔
1475
                cursor: SourceCursor {
30✔
1476
                    last_seen: Utc::now(),
30✔
1477
                    revision: 0,
30✔
1478
                },
30✔
1479
            })
30✔
1480
        }
30✔
1481

1482
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1483
            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
2✔
1484
                source_id: self.id.clone(),
1✔
1485
                details: "test source has no configured exact count".to_string(),
1✔
1486
            })
1✔
1487
        }
2✔
1488

1489
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
11✔
1490
            self.recipes.clone()
11✔
1491
        }
11✔
1492
    }
1493

1494
    struct ConfigRequiredSource {
1495
        id: String,
1496
        expected_seed: u64,
1497
    }
1498

1499
    impl DataSource for ConfigRequiredSource {
1500
        fn id(&self) -> &str {
1✔
1501
            &self.id
1✔
1502
        }
1✔
1503

1504
        fn refresh(
1✔
1505
            &self,
1✔
1506
            _config: &SamplerConfig,
1✔
1507
            _cursor: Option<&SourceCursor>,
1✔
1508
            _limit: Option<usize>,
1✔
1509
        ) -> Result<SourceSnapshot, SamplerError> {
1✔
1510
            Ok(SourceSnapshot {
1✔
1511
                records: Vec::new(),
1✔
1512
                cursor: SourceCursor {
1✔
1513
                    last_seen: Utc::now(),
1✔
1514
                    revision: 0,
1✔
1515
                },
1✔
1516
            })
1✔
1517
        }
1✔
1518

1519
        fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1520
            if config.seed == self.expected_seed {
2✔
1521
                Ok(1)
1✔
1522
            } else {
1523
                Err(SamplerError::SourceInconsistent {
1✔
1524
                    source_id: self.id.clone(),
1✔
1525
                    details: format!(
1✔
1526
                        "expected sampler seed {} but got {}",
1✔
1527
                        self.expected_seed, config.seed
1✔
1528
                    ),
1✔
1529
                })
1✔
1530
            }
1531
        }
2✔
1532

1533
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
1534
            Vec::new()
2✔
1535
        }
2✔
1536
    }
1537

1538
    struct FixtureSource {
1539
        id: String,
1540
        records: Vec<DataRecord>,
1541
        recipes: Vec<TripletRecipe>,
1542
    }
1543

1544
    impl DataSource for FixtureSource {
1545
        fn id(&self) -> &str {
65✔
1546
            &self.id
65✔
1547
        }
65✔
1548

1549
        fn refresh(
15✔
1550
            &self,
15✔
1551
            _config: &SamplerConfig,
15✔
1552
            _cursor: Option<&SourceCursor>,
15✔
1553
            _limit: Option<usize>,
15✔
1554
        ) -> Result<SourceSnapshot, SamplerError> {
15✔
1555
            Ok(SourceSnapshot {
15✔
1556
                records: self.records.clone(),
15✔
1557
                cursor: SourceCursor {
15✔
1558
                    last_seen: Utc::now(),
15✔
1559
                    revision: 0,
15✔
1560
                },
15✔
1561
            })
15✔
1562
        }
15✔
1563

1564
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1565
            Ok(self.records.len() as u128)
1✔
1566
        }
1✔
1567

1568
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
6✔
1569
            self.recipes.clone()
6✔
1570
        }
6✔
1571
    }
1572

1573
    struct IngestionConfigSource {
1574
        expected_ingestion_max_records: usize,
1575
        records: Vec<DataRecord>,
1576
    }
1577

1578
    impl DataSource for IngestionConfigSource {
1579
        fn id(&self) -> &str {
7✔
1580
            "ingestion_config_source"
7✔
1581
        }
7✔
1582

1583
        fn refresh(
3✔
1584
            &self,
3✔
1585
            config: &SamplerConfig,
3✔
1586
            _cursor: Option<&SourceCursor>,
3✔
1587
            _limit: Option<usize>,
3✔
1588
        ) -> Result<SourceSnapshot, SamplerError> {
3✔
1589
            if config.ingestion_max_records != self.expected_ingestion_max_records {
3✔
1590
                return Err(SamplerError::SourceInconsistent {
1✔
1591
                    source_id: self.id().to_string(),
1✔
1592
                    details: format!(
1✔
1593
                        "expected ingestion_max_records {} but got {}",
1✔
1594
                        self.expected_ingestion_max_records, config.ingestion_max_records
1✔
1595
                    ),
1✔
1596
                });
1✔
1597
            }
2✔
1598
            Ok(SourceSnapshot {
2✔
1599
                records: self.records.clone(),
2✔
1600
                cursor: SourceCursor {
2✔
1601
                    last_seen: Utc::now(),
2✔
1602
                    revision: 0,
2✔
1603
                },
2✔
1604
            })
2✔
1605
        }
3✔
1606

1607
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1608
            Ok(self.records.len() as u128)
1✔
1609
        }
1✔
1610

1611
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
1612
            vec![default_recipe("ingestion_config_recipe")]
2✔
1613
        }
2✔
1614
    }
1615

1616
    fn fixture_record(
25✔
1617
        source: &str,
25✔
1618
        id_suffix: &str,
25✔
1619
        day: u32,
25✔
1620
        title: &str,
25✔
1621
        body: &str,
25✔
1622
    ) -> DataRecord {
25✔
1623
        let now = Utc.with_ymd_and_hms(2025, 1, day, 12, 0, 0).unwrap();
25✔
1624
        DataRecord {
25✔
1625
            id: format!("{source}::{id_suffix}"),
25✔
1626
            source: source.to_string(),
25✔
1627
            created_at: now,
25✔
1628
            updated_at: now,
25✔
1629
            quality: QualityScore { trust: 1.0 },
25✔
1630
            taxonomy: Vec::new(),
25✔
1631
            sections: vec![
25✔
1632
                make_section(SectionRole::Anchor, Some("title"), title),
25✔
1633
                make_section(SectionRole::Context, Some("body"), body),
25✔
1634
            ],
25✔
1635
            meta_prefix: None,
25✔
1636
        }
25✔
1637
    }
25✔
1638

1639
    fn default_recipe(name: &str) -> TripletRecipe {
24✔
1640
        TripletRecipe {
24✔
1641
            name: name.to_string().into(),
24✔
1642
            anchor: crate::config::Selector::Role(SectionRole::Anchor),
24✔
1643
            positive_selector: crate::config::Selector::Role(SectionRole::Context),
24✔
1644
            negative_selector: crate::config::Selector::Role(SectionRole::Context),
24✔
1645
            negative_strategy: crate::config::NegativeStrategy::WrongArticle,
24✔
1646
            weight: 1.0,
24✔
1647
            instruction: None,
24✔
1648
            allow_same_anchor_positive: false,
24✔
1649
        }
24✔
1650
    }
24✔
1651

1652
    #[test]
1653
    fn parse_helpers_validate_inputs() {
1✔
1654
        assert_eq!(parse_batch_size("2").unwrap(), 2);
1✔
1655
        assert!(parse_batch_size("0").is_err());
1✔
1656
        assert!(parse_batch_size("abc").is_err());
1✔
1657
        assert_eq!(parse_ingestion_max_records("16").unwrap(), 16);
1✔
1658
        assert!(parse_ingestion_max_records("0").is_err());
1✔
1659
        assert!(parse_batch_count("0").is_err());
1✔
1660

1661
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
1662
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
1663
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
1664
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
1665
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
1666
    }
1✔
1667

1668
    #[test]
1669
    fn fixture_and_ingestion_sources_trait_methods_cover_paths() {
1✔
1670
        let records = vec![fixture_record("fixture_source", "r1", 1, "Title", "Body")];
1✔
1671
        let recipes = vec![default_recipe("fixture_recipe")];
1✔
1672
        let fixture = FixtureSource {
1✔
1673
            id: "fixture_source".into(),
1✔
1674
            records: records.clone(),
1✔
1675
            recipes: recipes.clone(),
1✔
1676
        };
1✔
1677

1678
        let snapshot = fixture
1✔
1679
            .refresh(&SamplerConfig::default(), None, None)
1✔
1680
            .expect("fixture refresh should succeed");
1✔
1681
        assert_eq!(snapshot.records.len(), 1);
1✔
1682
        assert_eq!(
1✔
1683
            fixture
1✔
1684
                .reported_record_count(&SamplerConfig::default())
1✔
1685
                .unwrap(),
1✔
1686
            1
1687
        );
1688
        assert_eq!(fixture.default_triplet_recipes().len(), 1);
1✔
1689

1690
        let source = IngestionConfigSource {
1✔
1691
            expected_ingestion_max_records: 7,
1✔
1692
            records,
1✔
1693
        };
1✔
1694
        let ok_cfg = SamplerConfig {
1✔
1695
            ingestion_max_records: 7,
1✔
1696
            ..SamplerConfig::default()
1✔
1697
        };
1✔
1698
        assert!(source.refresh(&ok_cfg, None, None).is_ok());
1✔
1699
        assert_eq!(source.reported_record_count(&ok_cfg).unwrap(), 1);
1✔
1700
        assert_eq!(source.default_triplet_recipes().len(), 1);
1✔
1701

1702
        let bad_cfg = SamplerConfig {
1✔
1703
            ingestion_max_records: 8,
1✔
1704
            ..SamplerConfig::default()
1✔
1705
        };
1✔
1706
        let err = source.refresh(&bad_cfg, None, None).unwrap_err();
1✔
1707
        assert!(matches!(err, SamplerError::SourceInconsistent { .. }));
1✔
1708
    }
1✔
1709

1710
    #[test]
1711
    fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1✔
1712
        assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1713
        assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1✔
1714
        assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1✔
1715
        assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1✔
1716
        assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1✔
1717
    }
1✔
1718

1719
    #[test]
1720
    fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1✔
1721
        assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1722
        assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1✔
1723
        assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1✔
1724
        assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1✔
1725
        assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1✔
1726
    }
1✔
1727

1728
    #[test]
1729
    fn parse_cli_handles_help_and_invalid_args() {
1✔
1730
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
1731
        assert!(help.is_none());
1✔
1732

1733
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
1734
        assert!(err.is_err());
1✔
1735
    }
1✔
1736

1737
    #[test]
1738
    fn run_estimate_capacity_succeeds_with_reported_counts() {
1✔
1739
        let result = run_estimate_capacity(
1✔
1740
            std::iter::empty::<String>(),
1✔
1741
            |roots| {
1✔
1742
                assert!(roots.is_empty());
1✔
1743
                Ok(())
1✔
1744
            },
1✔
1745
            |_| {
1✔
1746
                vec![Box::new(TestSource {
1✔
1747
                    id: "source_a".into(),
1✔
1748
                    count: Some(12),
1✔
1749
                    recipes: vec![default_recipe("r1")],
1✔
1750
                }) as DynSource]
1✔
1751
            },
1✔
1752
        );
1753

1754
        assert!(result.is_ok());
1✔
1755
    }
1✔
1756

1757
    #[test]
1758
    fn run_estimate_capacity_errors_when_source_count_missing() {
1✔
1759
        let result = run_estimate_capacity(
1✔
1760
            std::iter::empty::<String>(),
1✔
1761
            |_| Ok(()),
1✔
1762
            |_| {
1✔
1763
                vec![Box::new(TestSource {
1✔
1764
                    id: "source_missing".into(),
1✔
1765
                    count: None,
1✔
1766
                    recipes: vec![default_recipe("r1")],
1✔
1767
                }) as DynSource]
1✔
1768
            },
1✔
1769
        );
1770

1771
        let err = result.unwrap_err().to_string();
1✔
1772
        assert!(err.contains("failed to report exact record count"));
1✔
1773
    }
1✔
1774

1775
    #[test]
1776
    fn run_estimate_capacity_propagates_root_resolution_error() {
1✔
1777
        let result = run_estimate_capacity(
1✔
1778
            std::iter::empty::<String>(),
1✔
1779
            |_| Err("root resolution failed".into()),
1✔
1780
            empty_dyn_sources,
1781
        );
1782

1783
        let err = result.unwrap_err().to_string();
1✔
1784
        assert!(err.contains("root resolution failed"));
1✔
1785
    }
1✔
1786

1787
    #[test]
1788
    fn run_estimate_capacity_allows_empty_source_list() {
1✔
1789
        let result =
1✔
1790
            run_estimate_capacity(std::iter::empty::<String>(), |_| Ok(()), empty_dyn_sources);
1✔
1791

1792
        assert!(result.is_ok());
1✔
1793
    }
1✔
1794

1795
    #[test]
1796
    fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1✔
1797
        let result = run_estimate_capacity(
1✔
1798
            std::iter::empty::<String>(),
1✔
1799
            |_| Ok(()),
1✔
1800
            |_| {
1✔
1801
                vec![Box::new(ConfigRequiredSource {
1✔
1802
                    id: "requires_config".into(),
1✔
1803
                    expected_seed: 99,
1✔
1804
                }) as DynSource]
1✔
1805
            },
1✔
1806
        );
1807

1808
        assert!(result.is_ok());
1✔
1809
    }
1✔
1810

1811
    #[test]
1812
    fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1✔
1813
        let source = ConfigRequiredSource {
1✔
1814
            id: "cfg-source".to_string(),
1✔
1815
            expected_seed: 42,
1✔
1816
        };
1✔
1817

1818
        let refreshed = source
1✔
1819
            .refresh(&SamplerConfig::default(), None, None)
1✔
1820
            .unwrap();
1✔
1821
        assert!(refreshed.records.is_empty());
1✔
1822

1823
        let mismatched = source.reported_record_count(&SamplerConfig {
1✔
1824
            seed: 7,
1✔
1825
            ..SamplerConfig::default()
1✔
1826
        });
1✔
1827
        assert!(matches!(
1✔
1828
            mismatched,
1✔
1829
            Err(SamplerError::SourceInconsistent { .. })
1830
        ));
1831

1832
        assert!(source.default_triplet_recipes().is_empty());
1✔
1833
    }
1✔
1834

1835
    #[test]
1836
    fn run_multi_source_demo_exhausted_paths_return_ok() {
1✔
1837
        struct OneRecordSource;
1838

1839
        impl DataSource for OneRecordSource {
1840
            fn id(&self) -> &str {
48✔
1841
                "one_record"
48✔
1842
            }
48✔
1843

1844
            fn refresh(
11✔
1845
                &self,
11✔
1846
                _config: &SamplerConfig,
11✔
1847
                _cursor: Option<&SourceCursor>,
11✔
1848
                _limit: Option<usize>,
11✔
1849
            ) -> Result<SourceSnapshot, SamplerError> {
11✔
1850
                let now = Utc::now();
11✔
1851
                Ok(SourceSnapshot {
11✔
1852
                    records: vec![DataRecord {
11✔
1853
                        id: "one_record::r1".to_string(),
11✔
1854
                        source: "one_record".to_string(),
11✔
1855
                        created_at: now,
11✔
1856
                        updated_at: now,
11✔
1857
                        quality: QualityScore { trust: 1.0 },
11✔
1858
                        taxonomy: Vec::new(),
11✔
1859
                        sections: vec![
11✔
1860
                            RecordSection {
11✔
1861
                                role: SectionRole::Anchor,
11✔
1862
                                heading: Some("title".to_string()),
11✔
1863
                                text: "anchor".to_string(),
11✔
1864
                                sentences: vec!["anchor".to_string()],
11✔
1865
                            },
11✔
1866
                            RecordSection {
11✔
1867
                                role: SectionRole::Context,
11✔
1868
                                heading: Some("body".to_string()),
11✔
1869
                                text: "context".to_string(),
11✔
1870
                                sentences: vec!["context".to_string()],
11✔
1871
                            },
11✔
1872
                        ],
11✔
1873
                        meta_prefix: None,
11✔
1874
                    }],
11✔
1875
                    cursor: SourceCursor {
11✔
1876
                        last_seen: now,
11✔
1877
                        revision: 0,
11✔
1878
                    },
11✔
1879
                })
11✔
1880
            }
11✔
1881

1882
            fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1883
                Ok(1)
1✔
1884
            }
1✔
1885

1886
            fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
4✔
1887
                vec![default_recipe("single_record_recipe")]
4✔
1888
            }
4✔
1889
        }
1890

1891
        let one = OneRecordSource;
1✔
1892
        assert_eq!(
1✔
1893
            one.reported_record_count(&SamplerConfig::default())
1✔
1894
                .unwrap(),
1✔
1895
            1
1896
        );
1897
        assert_eq!(one.default_triplet_recipes().len(), 1);
1✔
1898

1899
        for mode in ["--pair-batch", "--text-recipes", ""] {
3✔
1900
            let dir = tempdir().unwrap();
3✔
1901
            let split_store_path = dir.path().join("split_store.bin");
3✔
1902
            let mut args = vec![
3✔
1903
                "--split-store-path".to_string(),
3✔
1904
                split_store_path.to_string_lossy().to_string(),
3✔
1905
            ];
1906
            if !mode.is_empty() {
3✔
1907
                args.push(mode.to_string());
2✔
1908
            }
2✔
1909

1910
            let result = run_multi_source_demo(
3✔
1911
                args.into_iter(),
3✔
1912
                |_| Ok(()),
3✔
1913
                |_| vec![Box::new(OneRecordSource) as DynSource],
3✔
1914
            );
1915
            assert!(result.is_ok());
3✔
1916
        }
1917
    }
1✔
1918

1919
    #[test]
1920
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
1921
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
1922
        assert!(help.is_none());
1✔
1923

1924
        let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1✔
1925
        assert!(err.is_err());
1✔
1926

1927
        let err = parse_cli::<MultiSourceDemoCli, _>([
1✔
1928
            "multi_source_demo",
1✔
1929
            "--ingestion-max-records",
1✔
1930
            "0",
1✔
1931
        ]);
1✔
1932
        assert!(err.is_err());
1✔
1933

1934
        let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1✔
1935
        assert!(parsed.is_ok());
1✔
1936
    }
1✔
1937

1938
    #[test]
1939
    fn run_example_apps_invalid_cli_args_return_errors() {
1✔
1940
        let estimate = run_estimate_capacity(
1✔
1941
            ["--unknown".to_string()].into_iter(),
1✔
1942
            ok_unit_roots,
1943
            empty_dyn_sources,
1944
        );
1945
        assert!(estimate.is_err());
1✔
1946

1947
        let demo = run_multi_source_demo(
1✔
1948
            ["--unknown".to_string()].into_iter(),
1✔
1949
            ok_unit_roots,
1950
            empty_dyn_sources,
1951
        );
1952
        assert!(demo.is_err());
1✔
1953
    }
1✔
1954

1955
    #[test]
1956
    fn helper_and_error_refresh_source_methods_are_exercised() {
1✔
1957
        assert!(ok_unit_roots(Vec::new()).is_ok());
1✔
1958
        assert!(error_unit_roots(Vec::new()).is_err());
1✔
1959

1960
        let source = ErrorRefreshSource {
1✔
1961
            id: "error_refresh_source".to_string(),
1✔
1962
        };
1✔
1963
        assert_eq!(
1✔
1964
            source
1✔
1965
                .reported_record_count(&SamplerConfig::default())
1✔
1966
                .unwrap(),
1✔
1967
            1
1968
        );
1969
        assert_eq!(source.default_triplet_recipes().len(), 1);
1✔
1970
    }
1✔
1971

1972
    #[test]
1973
    fn print_source_summary_handles_non_empty_ids() {
1✔
1974
        let ids = [
1✔
1975
            "source_a::r1",
1✔
1976
            "source_a::r2",
1✔
1977
            "source_b::r1",
1✔
1978
            "source_without_delimiter",
1✔
1979
        ];
1✔
1980
        print_source_summary("non-empty summary", ids.into_iter());
1✔
1981
    }
1✔
1982

1983
    #[test]
1984
    fn run_multi_source_demo_refresh_failures_degrade_to_exhausted_paths() {
1✔
1985
        for mode in [
4✔
1986
            vec!["--pair-batch".to_string()],
1✔
1987
            vec!["--text-recipes".to_string()],
1✔
1988
            vec!["--batches".to_string(), "1".to_string()],
1✔
1989
            Vec::new(),
1✔
1990
        ] {
1✔
1991
            let dir = tempdir().unwrap();
4✔
1992
            let split_store_path = dir.path().join("error_modes_split_store.bin");
4✔
1993
            let mut args = mode;
4✔
1994
            args.push("--split-store-path".to_string());
4✔
1995
            args.push(split_store_path.to_string_lossy().to_string());
4✔
1996

1997
            let result = run_multi_source_demo(
4✔
1998
                args.into_iter(),
4✔
1999
                |_| Ok(()),
4✔
2000
                |_| {
4✔
2001
                    vec![Box::new(ErrorRefreshSource {
4✔
2002
                        id: "error_refresh_source".to_string(),
4✔
2003
                    }) as DynSource]
4✔
2004
                },
4✔
2005
            );
2006

2007
            assert!(result.is_ok());
4✔
2008
        }
2009
    }
1✔
2010

2011
    #[test]
2012
    fn run_multi_source_demo_batches_exhausted_path_returns_ok() {
1✔
2013
        let dir = tempdir().unwrap();
1✔
2014
        let split_store_path = dir.path().join("batches_exhausted_split_store.bin");
1✔
2015
        let args = vec![
1✔
2016
            "--batches".to_string(),
1✔
2017
            "3".to_string(),
1✔
2018
            "--split-store-path".to_string(),
1✔
2019
            split_store_path.to_string_lossy().to_string(),
1✔
2020
        ];
2021

2022
        let result = run_multi_source_demo(
1✔
2023
            args.into_iter(),
1✔
2024
            |_| Ok(()),
1✔
2025
            |_| {
1✔
2026
                vec![Box::new(FixtureSource {
1✔
2027
                    id: "batches_exhausted_source".into(),
1✔
2028
                    records: vec![fixture_record(
1✔
2029
                        "batches_exhausted_source",
1✔
2030
                        "r1",
1✔
2031
                        1,
1✔
2032
                        "Only one record",
1✔
2033
                        "Single record body",
1✔
2034
                    )],
1✔
2035
                    recipes: vec![default_recipe("batches_exhausted_recipe")],
1✔
2036
                }) as DynSource]
1✔
2037
            },
1✔
2038
        );
2039

2040
        assert!(result.is_ok());
1✔
2041
    }
1✔
2042

2043
    #[test]
2044
    fn run_multi_source_demo_default_triplet_success_path_returns_ok() {
1✔
2045
        let dir = tempdir().unwrap();
1✔
2046
        let split_store_path = dir.path().join("default_triplet_success_split_store.bin");
1✔
2047
        let args = vec![
1✔
2048
            "--split-store-path".to_string(),
1✔
2049
            split_store_path.to_string_lossy().to_string(),
1✔
2050
        ];
2051

2052
        let result = run_multi_source_demo(
1✔
2053
            args.into_iter(),
1✔
2054
            |_| Ok(()),
1✔
2055
            |_| {
1✔
2056
                vec![Box::new(FixtureSource {
1✔
2057
                    id: "default_triplet_success_source".into(),
1✔
2058
                    records: vec![
1✔
2059
                        fixture_record(
1✔
2060
                            "default_triplet_success_source",
1✔
2061
                            "r1",
1✔
2062
                            1,
1✔
2063
                            "Title one",
1✔
2064
                            "Body one",
1✔
2065
                        ),
1✔
2066
                        fixture_record(
1✔
2067
                            "default_triplet_success_source",
1✔
2068
                            "r2",
1✔
2069
                            2,
1✔
2070
                            "Title two",
1✔
2071
                            "Body two",
1✔
2072
                        ),
1✔
2073
                        fixture_record(
1✔
2074
                            "default_triplet_success_source",
1✔
2075
                            "r3",
1✔
2076
                            3,
1✔
2077
                            "Title three",
1✔
2078
                            "Body three",
1✔
2079
                        ),
1✔
2080
                    ],
1✔
2081
                    recipes: vec![default_recipe("default_triplet_success_recipe")],
1✔
2082
                }) as DynSource]
1✔
2083
            },
1✔
2084
        );
2085

2086
        assert!(result.is_ok());
1✔
2087
    }
1✔
2088

2089
    #[test]
2090
    fn run_multi_source_demo_passes_ingestion_max_records_to_sources() {
1✔
2091
        let dir = tempdir().unwrap();
1✔
2092
        let split_store_path = dir.path().join("ingestion_config_split_store.bin");
1✔
2093
        let expected = 7;
1✔
2094

2095
        let result = run_multi_source_demo(
1✔
2096
            [
1✔
2097
                "--pair-batch".to_string(),
1✔
2098
                "--ingestion-max-records".to_string(),
1✔
2099
                expected.to_string(),
1✔
2100
                "--split-store-path".to_string(),
1✔
2101
                split_store_path.to_string_lossy().to_string(),
1✔
2102
            ]
1✔
2103
            .into_iter(),
1✔
2104
            |_| Ok(()),
1✔
2105
            |_| {
1✔
2106
                vec![Box::new(IngestionConfigSource {
1✔
2107
                    expected_ingestion_max_records: expected,
1✔
2108
                    records: (1..=8)
1✔
2109
                        .map(|day| {
8✔
2110
                            fixture_record(
8✔
2111
                                "ingestion_config_source",
8✔
2112
                                &format!("r{day}"),
8✔
2113
                                day,
8✔
2114
                                &format!("Config headline {day}"),
8✔
2115
                                &format!("Config body {day}"),
8✔
2116
                            )
2117
                        })
8✔
2118
                        .collect(),
1✔
2119
                }) as DynSource]
1✔
2120
            },
1✔
2121
        );
2122

2123
        assert!(result.is_ok());
1✔
2124
    }
1✔
2125

2126
    #[test]
2127
    fn parse_cli_handles_display_version_path() {
1✔
2128
        #[derive(Debug, Parser)]
2129
        #[command(name = "version_test", version = "1.0.0")]
2130
        struct VersionCli {}
2131

2132
        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
1✔
2133
        assert!(parsed.is_none());
1✔
2134
    }
1✔
2135

2136
    #[test]
2137
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
2138
        let dir = tempdir().unwrap();
1✔
2139
        let split_store_path = dir.path().join("recipes_split_store.bin");
1✔
2140
        let mut args = vec![
1✔
2141
            "--list-text-recipes".to_string(),
1✔
2142
            "--split-store-path".to_string(),
1✔
2143
            split_store_path.to_string_lossy().to_string(),
1✔
2144
        ];
2145
        let result = run_multi_source_demo(
1✔
2146
            args.drain(..),
1✔
2147
            |_| Ok(()),
1✔
2148
            |_| {
1✔
2149
                vec![Box::new(TestSource {
1✔
2150
                    id: "source_for_recipes".into(),
1✔
2151
                    count: Some(10),
1✔
2152
                    recipes: vec![default_recipe("recipe_a")],
1✔
2153
                }) as DynSource]
1✔
2154
            },
1✔
2155
        );
2156

2157
        assert!(result.is_ok());
1✔
2158
    }
1✔
2159

2160
    #[test]
2161
    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
1✔
2162
        let dir = tempdir().unwrap();
1✔
2163
        let split_store_path = dir.path().join("custom_split_store.bin");
1✔
2164
        let args = vec![
1✔
2165
            "--list-text-recipes".to_string(),
1✔
2166
            "--split-store-path".to_string(),
1✔
2167
            split_store_path.to_string_lossy().to_string(),
1✔
2168
        ];
2169

2170
        let result = run_multi_source_demo(
1✔
2171
            args.into_iter(),
1✔
2172
            |_| Ok(()),
1✔
2173
            |_| {
1✔
2174
                vec![Box::new(TestSource {
1✔
2175
                    id: "source_without_text_recipes".into(),
1✔
2176
                    count: Some(1),
1✔
2177
                    recipes: Vec::new(),
1✔
2178
                }) as DynSource]
1✔
2179
            },
1✔
2180
        );
2181

2182
        assert!(result.is_ok());
1✔
2183
    }
1✔
2184

2185
    #[test]
2186
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
2187
        for mode in [
3✔
2188
            vec!["--pair-batch".to_string()],
1✔
2189
            vec!["--text-recipes".to_string()],
1✔
2190
            vec![],
1✔
2191
        ] {
1✔
2192
            let dir = tempdir().unwrap();
3✔
2193
            let split_store_path = dir.path().join("empty_sources_split_store.bin");
3✔
2194
            let mut args = mode;
3✔
2195
            args.push("--split-store-path".to_string());
3✔
2196
            args.push(split_store_path.to_string_lossy().to_string());
3✔
2197
            args.push("--split".to_string());
3✔
2198
            args.push("validation".to_string());
3✔
2199

2200
            let result = run_multi_source_demo(
3✔
2201
                args.into_iter(),
3✔
2202
                |_| Ok(()),
3✔
2203
                |_| {
3✔
2204
                    vec![Box::new(TestSource {
3✔
2205
                        id: "source_empty".into(),
3✔
2206
                        count: Some(0),
3✔
2207
                        recipes: vec![default_recipe("recipe_empty")],
3✔
2208
                    }) as DynSource]
3✔
2209
                },
3✔
2210
            );
2211

2212
            assert!(result.is_ok());
3✔
2213
        }
2214
    }
1✔
2215

2216
    #[test]
2217
    fn run_multi_source_demo_propagates_root_resolution_error() {
1✔
2218
        let dir = tempdir().unwrap();
1✔
2219
        let split_store_path = dir.path().join("root_resolution_error_store.bin");
1✔
2220
        let result = run_multi_source_demo(
1✔
2221
            [
1✔
2222
                "--split-store-path".to_string(),
1✔
2223
                split_store_path.to_string_lossy().to_string(),
1✔
2224
            ]
1✔
2225
            .into_iter(),
1✔
2226
            |_| Err("demo root resolution failed".into()),
1✔
2227
            empty_dyn_sources,
2228
        );
2229

2230
        let err = result.unwrap_err().to_string();
1✔
2231
        assert!(err.contains("demo root resolution failed"));
1✔
2232
    }
1✔
2233

2234
    #[test]
2235
    fn run_multi_source_demo_list_text_recipes_allows_empty_sources() {
1✔
2236
        let dir = tempdir().unwrap();
1✔
2237
        let split_store_path = dir.path().join("empty_source_list_recipes.bin");
1✔
2238
        let result = run_multi_source_demo(
1✔
2239
            [
1✔
2240
                "--list-text-recipes".to_string(),
1✔
2241
                "--split-store-path".to_string(),
1✔
2242
                split_store_path.to_string_lossy().to_string(),
1✔
2243
            ]
1✔
2244
            .into_iter(),
1✔
2245
            |_| Ok(()),
1✔
2246
            empty_dyn_sources,
2247
        );
2248

2249
        assert!(result.is_ok());
1✔
2250
    }
1✔
2251

2252
    #[test]
2253
    fn print_helpers_and_extract_source_cover_paths() {
1✔
2254
        let split = SplitRatios::default();
1✔
2255
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
2256
        let strategy = ChunkingStrategy::default();
1✔
2257

2258
        let anchor = RecordChunk {
1✔
2259
            record_id: "source_a::rec1".to_string(),
1✔
2260
            section_idx: 0,
1✔
2261
            view: ChunkView::Window {
1✔
2262
                index: 1,
1✔
2263
                overlap: 2,
1✔
2264
                span: 12,
1✔
2265
            },
1✔
2266
            text: "anchor text".to_string(),
1✔
2267
            tokens_estimate: 8,
1✔
2268
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
2269
            kvp_meta: [(
1✔
2270
                "date".to_string(),
1✔
2271
                vec!["2025-01-01".to_string(), "Jan 1, 2025".to_string()],
1✔
2272
            )]
1✔
2273
            .into_iter()
1✔
2274
            .collect(),
1✔
2275
        };
1✔
2276
        let positive = RecordChunk {
1✔
2277
            record_id: "source_a::rec2".to_string(),
1✔
2278
            section_idx: 1,
1✔
2279
            view: ChunkView::SummaryFallback {
1✔
2280
                strategy: "summary".to_string(),
1✔
2281
                weight: 0.7,
1✔
2282
            },
1✔
2283
            text: "positive text".to_string(),
1✔
2284
            tokens_estimate: 6,
1✔
2285
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
2286
            kvp_meta: Default::default(),
1✔
2287
        };
1✔
2288
        let negative = RecordChunk {
1✔
2289
            record_id: "source_b::rec3".to_string(),
1✔
2290
            section_idx: 2,
1✔
2291
            view: ChunkView::Window {
1✔
2292
                index: 0,
1✔
2293
                overlap: 0,
1✔
2294
                span: 16,
1✔
2295
            },
1✔
2296
            text: "negative text".to_string(),
1✔
2297
            tokens_estimate: 7,
1✔
2298
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
2299
            kvp_meta: Default::default(),
1✔
2300
        };
1✔
2301

2302
        let triplet_batch = TripletBatch {
1✔
2303
            triplets: vec![crate::SampleTriplet {
1✔
2304
                recipe: "triplet_recipe".to_string(),
1✔
2305
                anchor: anchor.clone(),
1✔
2306
                positive: positive.clone(),
1✔
2307
                negative: negative.clone(),
1✔
2308
                weight: 1.0,
1✔
2309
                instruction: Some("triplet instruction".to_string()),
1✔
2310
            }],
1✔
2311
        };
1✔
2312
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
2313

2314
        let pair_batch = SampleBatch {
1✔
2315
            pairs: vec![crate::SamplePair {
1✔
2316
                recipe: "pair_recipe".to_string(),
1✔
2317
                anchor: anchor.clone(),
1✔
2318
                positive: positive.clone(),
1✔
2319
                weight: 1.0,
1✔
2320
                instruction: None,
1✔
2321
                label: crate::PairLabel::Positive,
1✔
2322
                reason: Some("same topic".to_string()),
1✔
2323
            }],
1✔
2324
        };
1✔
2325
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
2326

2327
        let text_batch = TextBatch {
1✔
2328
            samples: vec![crate::TextSample {
1✔
2329
                recipe: "text_recipe".to_string(),
1✔
2330
                chunk: negative,
1✔
2331
                weight: 0.8,
1✔
2332
                instruction: Some("text instruction".to_string()),
1✔
2333
            }],
1✔
2334
        };
1✔
2335
        print_text_batch(&strategy, &text_batch, &store);
1✔
2336

2337
        let recipes = vec![TextRecipe {
1✔
2338
            name: "recipe_name".into(),
1✔
2339
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
2340
            instruction: Some("instruction".into()),
1✔
2341
            weight: 1.0,
1✔
2342
        }];
1✔
2343
        print_text_recipes(&recipes);
1✔
2344

2345
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
2346
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
2347
    }
1✔
2348

2349
    #[test]
2350
    fn split_arg_conversion_and_version_parse_paths_are_covered() {
1✔
2351
        assert!(matches!(
1✔
2352
            SplitLabel::from(SplitArg::Train),
1✔
2353
            SplitLabel::Train
2354
        ));
2355
        assert!(matches!(
1✔
2356
            SplitLabel::from(SplitArg::Validation),
1✔
2357
            SplitLabel::Validation
2358
        ));
2359
        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
1✔
2360
    }
1✔
2361

2362
    #[test]
2363
    fn parse_split_ratios_reports_per_field_parse_errors() {
1✔
2364
        assert!(
1✔
2365
            parse_split_ratios_arg("x,0.1,0.9")
1✔
2366
                .unwrap_err()
1✔
2367
                .contains("invalid train ratio")
1✔
2368
        );
2369
        assert!(
1✔
2370
            parse_split_ratios_arg("0.1,y,0.8")
1✔
2371
                .unwrap_err()
1✔
2372
                .contains("invalid validation ratio")
1✔
2373
        );
2374
        assert!(
1✔
2375
            parse_split_ratios_arg("0.1,0.2,z")
1✔
2376
                .unwrap_err()
1✔
2377
                .contains("invalid test ratio")
1✔
2378
        );
2379
    }
1✔
2380

2381
    #[test]
2382
    fn run_multi_source_demo_exhausted_paths_are_handled() {
1✔
2383
        for mode in [
3✔
2384
            vec!["--pair-batch".to_string()],
1✔
2385
            vec!["--text-recipes".to_string()],
1✔
2386
            Vec::new(),
1✔
2387
        ] {
1✔
2388
            let dir = tempdir().unwrap();
3✔
2389
            let split_store_path = dir.path().join("exhausted_split_store.bin");
3✔
2390
            let mut args = mode;
3✔
2391
            args.push("--split-store-path".to_string());
3✔
2392
            args.push(split_store_path.to_string_lossy().to_string());
3✔
2393

2394
            let result = run_multi_source_demo(
3✔
2395
                args.into_iter(),
3✔
2396
                |_| Ok(()),
3✔
2397
                |_| {
3✔
2398
                    vec![Box::new(TestSource {
3✔
2399
                        id: "source_without_recipes".into(),
3✔
2400
                        count: Some(1),
3✔
2401
                        recipes: Vec::new(),
3✔
2402
                    }) as DynSource]
3✔
2403
                },
3✔
2404
            );
2405

2406
            assert!(result.is_ok());
3✔
2407
        }
2408
    }
1✔
2409

2410
    #[test]
2411
    fn run_multi_source_demo_reset_recreates_split_store_and_samples() {
1✔
2412
        let dir = tempdir().unwrap();
1✔
2413
        let split_store_path = dir.path().join("reset_split_store.bin");
1✔
2414
        std::fs::write(&split_store_path, b"stale-data").unwrap();
1✔
2415

2416
        let args = vec![
1✔
2417
            "--reset".to_string(),
1✔
2418
            "--pair-batch".to_string(),
1✔
2419
            "--split-store-path".to_string(),
1✔
2420
            split_store_path.to_string_lossy().to_string(),
1✔
2421
        ];
2422

2423
        let result = run_multi_source_demo(
1✔
2424
            args.into_iter(),
1✔
2425
            |_| Ok(()),
1✔
2426
            |_| {
1✔
2427
                let recipes = vec![default_recipe("fixture_recipe")];
1✔
2428
                let records: Vec<DataRecord> = (1..=8)
1✔
2429
                    .map(|day| {
8✔
2430
                        fixture_record(
8✔
2431
                            "fixture_source",
8✔
2432
                            &format!("r{day}"),
8✔
2433
                            day,
8✔
2434
                            &format!("Fixture headline {day}"),
8✔
2435
                            &format!("Fixture body content for day {day}."),
8✔
2436
                        )
2437
                    })
8✔
2438
                    .collect();
1✔
2439
                vec![Box::new(FixtureSource {
1✔
2440
                    id: "fixture_source".into(),
1✔
2441
                    records,
1✔
2442
                    recipes,
1✔
2443
                }) as DynSource]
1✔
2444
            },
1✔
2445
        );
2446

2447
        assert!(result.is_ok());
1✔
2448
        assert!(split_store_path.exists());
1✔
2449
        let metadata = std::fs::metadata(&split_store_path).unwrap();
1✔
2450
        assert!(metadata.len() > 0);
1✔
2451
    }
1✔
2452

2453
    #[test]
2454
    fn run_multi_source_demo_batches_mode_executes_multiple_batches() {
1✔
2455
        let dir = tempdir().unwrap();
1✔
2456
        let split_store_path = dir.path().join("batches_split_store.bin");
1✔
2457
        let args = vec![
1✔
2458
            "--batches".to_string(),
1✔
2459
            "2".to_string(),
1✔
2460
            "--split-store-path".to_string(),
1✔
2461
            split_store_path.to_string_lossy().to_string(),
1✔
2462
        ];
2463

2464
        let result = run_multi_source_demo(
1✔
2465
            args.into_iter(),
1✔
2466
            |_| Ok(()),
1✔
2467
            |_| {
1✔
2468
                let recipes = vec![default_recipe("batch_recipe")];
1✔
2469
                vec![Box::new(FixtureSource {
1✔
2470
                    id: "batch_source".into(),
1✔
2471
                    records: vec![
1✔
2472
                        fixture_record(
1✔
2473
                            "batch_source",
1✔
2474
                            "r1",
1✔
2475
                            3,
1✔
2476
                            "Inflation cools in latest report",
1✔
2477
                            "Core inflation moderated compared with prior quarter.",
1✔
2478
                        ),
1✔
2479
                        fixture_record(
1✔
2480
                            "batch_source",
1✔
2481
                            "r2",
1✔
2482
                            4,
1✔
2483
                            "Labor market remains resilient",
1✔
2484
                            "Job openings remain elevated despite slower growth.",
1✔
2485
                        ),
1✔
2486
                        fixture_record(
1✔
2487
                            "batch_source",
1✔
2488
                            "r3",
1✔
2489
                            5,
1✔
2490
                            "Manufacturing sentiment stabilizes",
1✔
2491
                            "Survey data suggests output expectations are improving.",
1✔
2492
                        ),
1✔
2493
                    ],
1✔
2494
                    recipes,
1✔
2495
                }) as DynSource]
1✔
2496
            },
1✔
2497
        );
2498

2499
        assert!(result.is_ok());
1✔
2500
        assert!(split_store_path.exists());
1✔
2501
    }
1✔
2502

2503
    #[test]
2504
    fn managed_demo_split_store_path_resolves_under_cache_group() {
1✔
2505
        let path = managed_demo_split_store_path().unwrap();
1✔
2506
        assert!(path.ends_with(MULTI_SOURCE_DEMO_STORE_FILENAME));
1✔
2507
        let parent = path
1✔
2508
            .parent()
1✔
2509
            .expect("managed split-store path should have a parent");
1✔
2510
        assert!(parent.ends_with(PathBuf::from(MULTI_SOURCE_DEMO_GROUP)));
1✔
2511
    }
1✔
2512

2513
    #[test]
2514
    fn run_multi_source_demo_help_returns_ok_without_work() {
1✔
2515
        let no_help = run_multi_source_demo(
1✔
2516
            std::iter::empty::<String>(),
1✔
2517
            error_unit_roots,
2518
            empty_dyn_sources,
2519
        );
2520
        assert!(
1✔
2521
            no_help
1✔
2522
                .expect_err("non-help path should attempt to resolve roots")
1✔
2523
                .to_string()
1✔
2524
                .contains("root-resolution-error")
1✔
2525
        );
2526

2527
        let result = run_multi_source_demo(
1✔
2528
            ["--help".to_string()].into_iter(),
1✔
2529
            ok_unit_roots,
2530
            empty_dyn_sources,
2531
        );
2532

2533
        assert!(result.is_ok());
1✔
2534
    }
1✔
2535

2536
    #[test]
2537
    fn run_estimate_capacity_help_returns_ok_without_work() {
1✔
2538
        let result = run_estimate_capacity(
1✔
2539
            ["--help".to_string()].into_iter(),
1✔
2540
            ok_unit_roots,
2541
            empty_dyn_sources,
2542
        );
2543

2544
        assert!(result.is_ok());
1✔
2545
    }
1✔
2546

2547
    #[test]
2548
    fn run_multi_source_demo_pair_exhausted_branch_returns_ok() {
1✔
2549
        let dir = tempdir().unwrap();
1✔
2550
        let split_store_path = dir.path().join("pair_exhausted_split_store.bin");
1✔
2551
        let args = vec![
1✔
2552
            "--pair-batch".to_string(),
1✔
2553
            "--split-store-path".to_string(),
1✔
2554
            split_store_path.to_string_lossy().to_string(),
1✔
2555
        ];
2556

2557
        let result = run_multi_source_demo(
1✔
2558
            args.into_iter(),
1✔
2559
            |_| Ok(()),
1✔
2560
            |_| {
1✔
2561
                vec![Box::new(FixtureSource {
1✔
2562
                    id: "pair_exhausted_source".into(),
1✔
2563
                    records: vec![fixture_record(
1✔
2564
                        "pair_exhausted_source",
1✔
2565
                        "r1",
1✔
2566
                        1,
1✔
2567
                        "Single record title",
1✔
2568
                        "Single record body",
1✔
2569
                    )],
1✔
2570
                    recipes: vec![default_recipe("pair_exhausted_recipe")],
1✔
2571
                }) as DynSource]
1✔
2572
            },
1✔
2573
        );
2574

2575
        assert!(result.is_ok());
1✔
2576
    }
1✔
2577

2578
    #[test]
2579
    fn run_multi_source_demo_uses_managed_split_store_path_when_not_provided() {
1✔
2580
        let result = run_multi_source_demo(
1✔
2581
            ["--list-text-recipes".to_string()].into_iter(),
1✔
2582
            |_| Ok(()),
1✔
2583
            |_| {
1✔
2584
                vec![Box::new(TestSource {
1✔
2585
                    id: "managed_path_source".into(),
1✔
2586
                    count: Some(2),
1✔
2587
                    recipes: vec![default_recipe("managed_recipe")],
1✔
2588
                }) as DynSource]
1✔
2589
            },
1✔
2590
        );
2591

2592
        assert!(result.is_ok());
1✔
2593
    }
1✔
2594

2595
    #[test]
2596
    fn run_multi_source_demo_reset_errors_when_target_is_directory() {
1✔
2597
        let dir = tempdir().unwrap();
1✔
2598
        let split_store_path = dir.path().join("split_store_dir");
1✔
2599
        std::fs::create_dir(&split_store_path).unwrap();
1✔
2600

2601
        let result = run_multi_source_demo(
1✔
2602
            [
1✔
2603
                "--reset".to_string(),
1✔
2604
                "--split-store-path".to_string(),
1✔
2605
                split_store_path.to_string_lossy().to_string(),
1✔
2606
            ]
1✔
2607
            .into_iter(),
1✔
2608
            |_| Ok(()),
1✔
2609
            empty_dyn_sources,
2610
        );
2611

2612
        let err = result.unwrap_err().to_string();
1✔
2613
        assert!(err.contains("failed to remove split store"));
1✔
2614
    }
1✔
2615

2616
    #[test]
2617
    fn print_summary_helpers_accept_empty_iterators() {
1✔
2618
        print_source_summary("empty summary", std::iter::empty::<&str>());
1✔
2619
        print_recipe_context_by_source("empty recipe context", std::iter::empty::<(&str, &str)>());
1✔
2620
    }
1✔
2621

2622
    #[cfg(feature = "extended-metrics")]
2623
    #[test]
2624
    fn metric_mean_median_handles_even_length_inputs() {
1✔
2625
        let mut vals = [1.0, 4.0, 2.0, 3.0];
1✔
2626
        let (mean, median) = metric_mean_median(&mut vals);
1✔
2627
        assert!((mean - 2.5).abs() < 1e-6);
1✔
2628
        assert!((median - 2.5).abs() < 1e-6);
1✔
2629
    }
1✔
2630

2631
    #[cfg(feature = "extended-metrics")]
2632
    #[test]
2633
    fn metric_mean_median_handles_odd_length_inputs() {
1✔
2634
        let mut vals = [3.0, 1.0, 2.0];
1✔
2635
        let (mean, median) = metric_mean_median(&mut vals);
1✔
2636
        assert!((mean - 2.0).abs() < 1e-6);
1✔
2637
        assert!((median - 2.0).abs() < 1e-6);
1✔
2638
    }
1✔
2639

2640
    #[cfg(feature = "extended-metrics")]
2641
    #[test]
2642
    fn print_metric_summary_includes_multi_source_aggregate() {
1✔
2643
        let source_data = HashMap::from([
1✔
2644
            (
1✔
2645
                "source_a".to_string(),
1✔
2646
                vec![(0.9, 0.8, 0.2, 0.1, 0.7), (0.8, 0.7, 0.3, 0.2, 0.8)],
1✔
2647
            ),
1✔
2648
            (
1✔
2649
                "source_b".to_string(),
1✔
2650
                vec![(0.7, 0.6, 0.4, 0.3, 0.5), (0.6, 0.5, 0.5, 0.4, 0.6)],
1✔
2651
            ),
1✔
2652
        ]);
1✔
2653

2654
        print_metric_summary(&source_data);
1✔
2655
    }
1✔
2656
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc