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

jzombie / rust-triplets / 22585665958

02 Mar 2026 04:37PM UTC coverage: 93.854% (+0.5%) from 93.384%
22585665958

push

github

web-flow
Bump tempfile from 3.25.0 to 3.26.0 (#19)

Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.25.0 to 3.26.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/commits/v3.26.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

16140 of 17197 relevant lines covered (93.85%)

2387.21 hits per line

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

93.53
/src/example_apps.rs
1
use std::collections::HashMap;
2
use std::error::Error;
3
use std::path::PathBuf;
4
use std::sync::Arc;
5
use std::sync::Once;
6

7
use clap::{Parser, ValueEnum, error::ErrorKind};
8

9
use crate::config::{ChunkingStrategy, SamplerConfig, TripletRecipe};
10
use crate::data::ChunkView;
11
use crate::heuristics::{
12
    CapacityTotals, EFFECTIVE_NEGATIVES_PER_ANCHOR, EFFECTIVE_POSITIVES_PER_ANCHOR,
13
    estimate_source_split_capacity_from_counts, format_replay_factor, format_u128_with_commas,
14
    resolve_text_recipes_for_source, split_counts_for_total,
15
};
16
use crate::metrics::source_skew;
17
use crate::sampler::chunk_weight;
18
use crate::source::DataSource;
19
use crate::splits::{FileSplitStore, SplitLabel, SplitRatios, SplitStore};
20
use crate::{
21
    RecordChunk, SampleBatch, Sampler, SamplerError, SourceId, TextBatch, TextRecipe, TripletBatch,
22
    TripletSampler,
23
};
24

25
type DynSource = Box<dyn DataSource + 'static>;
26

27
fn init_example_tracing() {
13✔
28
    static INIT: Once = Once::new();
29
    INIT.call_once(|| {
13✔
30
        let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
1✔
31
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("triplets=info"));
1✔
32
        let _ = tracing_subscriber::fmt()
1✔
33
            .with_env_filter(env_filter)
1✔
34
            .try_init();
1✔
35
    });
1✔
36
}
13✔
37

38
#[derive(Debug, Clone, Copy, ValueEnum)]
39
/// CLI split selector mapped onto `SplitLabel`.
40
enum SplitArg {
41
    Train,
42
    Validation,
43
    Test,
44
}
45

46
impl From<SplitArg> for SplitLabel {
47
    fn from(value: SplitArg) -> Self {
6✔
48
        match value {
6✔
49
            SplitArg::Train => SplitLabel::Train,
1✔
50
            SplitArg::Validation => SplitLabel::Validation,
4✔
51
            SplitArg::Test => SplitLabel::Test,
1✔
52
        }
53
    }
6✔
54
}
55

56
#[derive(Debug, Parser)]
57
#[command(
58
    name = "estimate_capacity",
59
    disable_help_subcommand = true,
60
    about = "Metadata-only capacity estimation",
61
    long_about = "Estimate record, pair, triplet, and text-sample capacity using source-reported counts only (no data refresh).",
62
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
63
)]
64
/// CLI arguments for metadata-only capacity estimation.
65
struct EstimateCapacityCli {
66
    #[arg(
67
        long,
68
        default_value_t = 99,
69
        help = "Deterministic seed used for split allocation"
70
    )]
71
    seed: u64,
72
    #[arg(
73
        long = "split-ratios",
74
        value_name = "TRAIN,VALIDATION,TEST",
75
        value_parser = parse_split_ratios_arg,
76
        default_value = "0.8,0.1,0.1",
77
        help = "Comma-separated split ratios that must sum to 1.0"
78
    )]
79
    split: SplitRatios,
80
    #[arg(
81
        long = "source-root",
82
        value_name = "PATH",
83
        help = "Optional source root override, repeat as needed in source order"
84
    )]
85
    source_roots: Vec<String>,
86
}
87

88
#[derive(Debug, Parser)]
89
#[command(
90
    name = "multi_source_demo",
91
    disable_help_subcommand = true,
92
    about = "Run sampled batches from multiple sources",
93
    long_about = "Sample triplet, pair, or text batches from multiple sources and persist split/epoch state.",
94
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
95
)]
96
/// CLI for `multi_source_demo`.
97
///
98
/// Common usage:
99
/// - Keep default persistence file location: `.sampler_store/split_store.bin`
100
/// - Set an explicit file path: `--split-store-path /tmp/split_store.bin`
101
/// - Set a custom directory and keep default filename: `--split-store-dir /tmp/sampler_store`
102
/// - Repeat `--source-root <PATH>` to override source roots in order
103
struct MultiSourceDemoCli {
104
    #[arg(
105
        long = "text-recipes",
106
        help = "Emit a text batch instead of a triplet batch"
107
    )]
108
    show_text_samples: bool,
109
    #[arg(
110
        long = "pair-batch",
111
        help = "Emit a pair batch instead of a triplet batch"
112
    )]
113
    show_pair_samples: bool,
114
    #[arg(
115
        long = "list-text-recipes",
116
        help = "Print registered text recipes and exit"
117
    )]
118
    list_text_recipes: bool,
119
    #[arg(
120
        long = "batch-size",
121
        default_value_t = 4,
122
        value_parser = parse_positive_usize,
123
        help = "Batch size used for sampling"
124
    )]
125
    batch_size: usize,
126
    #[arg(long, help = "Optional deterministic seed override")]
127
    seed: Option<u64>,
128
    #[arg(long, value_enum, help = "Target split to sample from")]
129
    split: Option<SplitArg>,
130
    #[arg(
131
        long = "source-root",
132
        value_name = "PATH",
133
        help = "Optional source root override, repeat as needed in source order"
134
    )]
135
    source_roots: Vec<String>,
136
    #[arg(
137
        long = "split-store-path",
138
        value_name = "SPLIT_STORE_PATH",
139
        help = "Optional path for persisted split/epoch state file"
140
    )]
141
    split_store_path: Option<PathBuf>,
142
    #[arg(
143
        long = "split-store-dir",
144
        value_name = "DIR",
145
        conflicts_with = "split_store_path",
146
        help = "Optional directory for persisted split/epoch state file (uses split_store.bin filename)"
147
    )]
148
    split_store_dir: Option<PathBuf>,
149
}
150

151
#[derive(Debug, Clone)]
152
/// Source-level inventory used by capacity estimation output.
153
struct SourceInventory {
154
    source_id: String,
155
    reported_records: u128,
156
    triplet_recipes: Vec<TripletRecipe>,
157
}
158

159
/// Run the capacity-estimation CLI with injectable root resolution/source builders.
160
///
161
/// `build_sources` is construction-only; sampler configuration is applied
162
/// centrally by this function before any source calls.
163
pub fn run_estimate_capacity<R, Resolve, Build, I>(
4✔
164
    args_iter: I,
4✔
165
    resolve_roots: Resolve,
4✔
166
    build_sources: Build,
4✔
167
) -> Result<(), Box<dyn Error>>
4✔
168
where
4✔
169
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
4✔
170
    Build: FnOnce(&R) -> Vec<DynSource>,
4✔
171
    I: Iterator<Item = String>,
4✔
172
{
173
    init_example_tracing();
4✔
174

175
    let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
4✔
176
        std::iter::once("estimate_capacity".to_string()).chain(args_iter),
4✔
177
    )?
×
178
    else {
179
        return Ok(());
×
180
    };
181

182
    let roots = resolve_roots(cli.source_roots)?;
4✔
183

184
    let config = SamplerConfig {
3✔
185
        seed: cli.seed,
3✔
186
        split: cli.split,
3✔
187
        ..SamplerConfig::default()
3✔
188
    };
3✔
189

190
    let sources = build_sources(&roots);
3✔
191

192
    let mut inventories = Vec::new();
3✔
193
    for source in &sources {
3✔
194
        let recipes = if config.recipes.is_empty() {
3✔
195
            source.default_triplet_recipes()
3✔
196
        } else {
197
            config.recipes.clone()
×
198
        };
199
        let reported_records = source.reported_record_count(&config).map_err(|err| {
3✔
200
            format!(
1✔
201
                "source '{}' failed to report exact record count: {err}",
202
                source.id()
1✔
203
            )
204
        })?;
1✔
205
        inventories.push(SourceInventory {
2✔
206
            source_id: source.id().to_string(),
2✔
207
            reported_records,
2✔
208
            triplet_recipes: recipes,
2✔
209
        });
2✔
210
    }
211

212
    let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
2✔
213
    let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
2✔
214

215
    for source in &inventories {
2✔
216
        let counts = split_counts_for_total(source.reported_records, cli.split);
2✔
217
        for (label, count) in counts {
6✔
218
            per_source_split_counts.insert((source.source_id.clone(), label), count);
6✔
219
            *split_record_counts.entry(label).or_insert(0) += count;
6✔
220
        }
6✔
221
    }
222

223
    let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
2✔
224
    let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
2✔
225
        HashMap::new();
2✔
226

227
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
228
        let mut totals = CapacityTotals::default();
6✔
229

230
        for source in &inventories {
6✔
231
            let source_split_records = per_source_split_counts
6✔
232
                .get(&(source.source_id.clone(), split_label))
6✔
233
                .copied()
6✔
234
                .unwrap_or(0);
6✔
235

6✔
236
            let triplet_recipes = &source.triplet_recipes;
6✔
237
            let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
6✔
238

6✔
239
            let capacity = estimate_source_split_capacity_from_counts(
6✔
240
                source_split_records,
6✔
241
                triplet_recipes,
6✔
242
                &text_recipes,
6✔
243
            );
6✔
244

6✔
245
            totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
6✔
246

6✔
247
            totals.triplets += capacity.triplets;
6✔
248
            totals.effective_triplets += capacity.effective_triplets;
6✔
249
            totals.pairs += capacity.pairs;
6✔
250
            totals.text_samples += capacity.text_samples;
6✔
251
        }
6✔
252

253
        totals_by_split.insert(split_label, totals);
6✔
254
    }
255

256
    let min_nonzero_records_by_split: HashMap<SplitLabel, u128> =
2✔
257
        [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
2✔
258
            .into_iter()
2✔
259
            .map(|split_label| {
6✔
260
                let min_nonzero = inventories
6✔
261
                    .iter()
6✔
262
                    .filter_map(|source| {
6✔
263
                        per_source_split_counts
6✔
264
                            .get(&(source.source_id.clone(), split_label))
6✔
265
                            .copied()
6✔
266
                    })
6✔
267
                    .filter(|&records| records > 0)
6✔
268
                    .min()
6✔
269
                    .unwrap_or(0);
6✔
270
                (split_label, min_nonzero)
6✔
271
            })
6✔
272
            .collect();
2✔
273

274
    let min_nonzero_records_all_splits = inventories
2✔
275
        .iter()
2✔
276
        .map(|source| source.reported_records)
2✔
277
        .filter(|&records| records > 0)
2✔
278
        .min()
2✔
279
        .unwrap_or(0);
2✔
280

281
    println!("=== capacity estimate (length-only) ===");
2✔
282
    println!("mode: metadata-only (no source.refresh calls)");
2✔
283
    println!("classification: heuristic approximation (not exact)");
2✔
284
    println!("split seed: {}", cli.seed);
2✔
285
    println!(
2✔
286
        "split ratios: train={:.4}, validation={:.4}, test={:.4}",
287
        cli.split.train, cli.split.validation, cli.split.test
288
    );
289
    println!();
2✔
290

291
    println!("[SOURCES]");
2✔
292
    for source in &inventories {
2✔
293
        println!(
2✔
294
            "  {} => reported records: {}",
2✔
295
            source.source_id,
2✔
296
            format_u128_with_commas(source.reported_records)
2✔
297
        );
2✔
298
    }
2✔
299
    println!();
2✔
300

301
    println!("[PER SOURCE BREAKDOWN]");
2✔
302
    for source in &inventories {
2✔
303
        println!("  {}", source.source_id);
2✔
304
        let mut source_grand = CapacityTotals::default();
2✔
305
        let mut source_total_records = 0u128;
2✔
306
        for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
307
            let split_records = per_source_split_counts
6✔
308
                .get(&(source.source_id.clone(), split_label))
6✔
309
                .copied()
6✔
310
                .unwrap_or(0);
6✔
311
            source_total_records = source_total_records.saturating_add(split_records);
6✔
312
            let split_longest_records = inventories
6✔
313
                .iter()
6✔
314
                .map(|candidate| {
6✔
315
                    per_source_split_counts
6✔
316
                        .get(&(candidate.source_id.clone(), split_label))
6✔
317
                        .copied()
6✔
318
                        .unwrap_or(0)
6✔
319
                })
6✔
320
                .max()
6✔
321
                .unwrap_or(0);
6✔
322
            let totals = totals_by_source_and_split
6✔
323
                .get(&(source.source_id.clone(), split_label))
6✔
324
                .copied()
6✔
325
                .unwrap_or_default();
6✔
326
            source_grand.triplets += totals.triplets;
6✔
327
            source_grand.effective_triplets += totals.effective_triplets;
6✔
328
            source_grand.pairs += totals.pairs;
6✔
329
            source_grand.text_samples += totals.text_samples;
6✔
330
            println!("    [{:?}]", split_label);
6✔
331
            println!("      records: {}", format_u128_with_commas(split_records));
6✔
332
            println!(
6✔
333
                "      triplet combinations: {}",
334
                format_u128_with_commas(totals.triplets)
6✔
335
            );
336
            println!(
6✔
337
                "      effective sampled triplets (p={}, k={}): {}",
338
                EFFECTIVE_POSITIVES_PER_ANCHOR,
339
                EFFECTIVE_NEGATIVES_PER_ANCHOR,
340
                format_u128_with_commas(totals.effective_triplets)
6✔
341
            );
342
            println!(
6✔
343
                "      pair combinations:    {}",
344
                format_u128_with_commas(totals.pairs)
6✔
345
            );
346
            println!(
6✔
347
                "      text samples:         {}",
348
                format_u128_with_commas(totals.text_samples)
6✔
349
            );
350
            println!(
6✔
351
                "      replay factor vs longest source: {}",
352
                format_replay_factor(split_longest_records, split_records)
6✔
353
            );
354
            println!(
6✔
355
                "      suggested proportional-size batch weight (0-1): {:.4}",
356
                suggested_balancing_weight(split_longest_records, split_records)
6✔
357
            );
358
            let split_smallest_nonzero = min_nonzero_records_by_split
6✔
359
                .get(&split_label)
6✔
360
                .copied()
6✔
361
                .unwrap_or(0);
6✔
362
            println!(
6✔
363
                "      suggested small-source-boost batch weight (0-1): {:.4}",
364
                suggested_oversampling_weight(split_smallest_nonzero, split_records)
6✔
365
            );
366
            println!();
6✔
367
        }
368
        let longest_source_total = inventories
2✔
369
            .iter()
2✔
370
            .map(|candidate| candidate.reported_records)
2✔
371
            .max()
2✔
372
            .unwrap_or(0);
2✔
373
        println!("    [ALL SPLITS FOR SOURCE]");
2✔
374
        println!(
2✔
375
            "      triplet combinations: {}",
376
            format_u128_with_commas(source_grand.triplets)
2✔
377
        );
378
        println!(
2✔
379
            "      effective sampled triplets (p={}, k={}): {}",
380
            EFFECTIVE_POSITIVES_PER_ANCHOR,
381
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
382
            format_u128_with_commas(source_grand.effective_triplets)
2✔
383
        );
384
        println!(
2✔
385
            "      pair combinations:    {}",
386
            format_u128_with_commas(source_grand.pairs)
2✔
387
        );
388
        println!(
2✔
389
            "      text samples:         {}",
390
            format_u128_with_commas(source_grand.text_samples)
2✔
391
        );
392
        println!(
2✔
393
            "      replay factor vs longest source: {}",
394
            format_replay_factor(longest_source_total, source_total_records)
2✔
395
        );
396
        println!(
2✔
397
            "      suggested proportional-size batch weight (0-1): {:.4}",
398
            suggested_balancing_weight(longest_source_total, source_total_records)
2✔
399
        );
400
        println!(
2✔
401
            "      suggested small-source-boost batch weight (0-1): {:.4}",
402
            suggested_oversampling_weight(min_nonzero_records_all_splits, source_total_records)
2✔
403
        );
404
        println!();
2✔
405
    }
406

407
    let mut grand = CapacityTotals::default();
2✔
408
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
409
        let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
6✔
410
        let totals = totals_by_split
6✔
411
            .get(&split_label)
6✔
412
            .copied()
6✔
413
            .unwrap_or_default();
6✔
414

6✔
415
        grand.triplets += totals.triplets;
6✔
416
        grand.effective_triplets += totals.effective_triplets;
6✔
417
        grand.pairs += totals.pairs;
6✔
418
        grand.text_samples += totals.text_samples;
6✔
419

6✔
420
        println!("[{:?}]", split_label);
6✔
421
        println!("  records: {}", format_u128_with_commas(record_count));
6✔
422
        println!(
6✔
423
            "  triplet combinations: {}",
6✔
424
            format_u128_with_commas(totals.triplets)
6✔
425
        );
6✔
426
        println!(
6✔
427
            "  effective sampled triplets (p={}, k={}): {}",
6✔
428
            EFFECTIVE_POSITIVES_PER_ANCHOR,
6✔
429
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
6✔
430
            format_u128_with_commas(totals.effective_triplets)
6✔
431
        );
6✔
432
        println!(
6✔
433
            "  pair combinations:    {}",
6✔
434
            format_u128_with_commas(totals.pairs)
6✔
435
        );
6✔
436
        println!(
6✔
437
            "  text samples:         {}",
6✔
438
            format_u128_with_commas(totals.text_samples)
6✔
439
        );
6✔
440
        println!();
6✔
441
    }
6✔
442

443
    println!("[ALL SPLITS TOTAL]");
2✔
444
    println!(
2✔
445
        "  triplet combinations: {}",
446
        format_u128_with_commas(grand.triplets)
2✔
447
    );
448
    println!(
2✔
449
        "  effective sampled triplets (p={}, k={}): {}",
450
        EFFECTIVE_POSITIVES_PER_ANCHOR,
451
        EFFECTIVE_NEGATIVES_PER_ANCHOR,
452
        format_u128_with_commas(grand.effective_triplets)
2✔
453
    );
454
    println!(
2✔
455
        "  pair combinations:    {}",
456
        format_u128_with_commas(grand.pairs)
2✔
457
    );
458
    println!(
2✔
459
        "  text samples:         {}",
460
        format_u128_with_commas(grand.text_samples)
2✔
461
    );
462
    println!();
2✔
463
    println!(
2✔
464
        "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."
465
    );
466
    println!();
2✔
467
    println!(
2✔
468
        "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.",
469
        EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
470
    );
471
    println!();
2✔
472
    println!(
2✔
473
        "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."
474
    );
475
    println!();
2✔
476
    println!(
2✔
477
        "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."
478
    );
479
    println!();
2✔
480
    println!(
2✔
481
        "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."
482
    );
483
    println!();
2✔
484
    println!(
2✔
485
        "When passed to next_*_batch_with_weights, higher weight means that source is sampled more often relative to lower-weight sources."
486
    );
487

488
    Ok(())
2✔
489
}
4✔
490

491
/// Run the multi-source demo CLI with injectable root resolution/source builders.
492
///
493
/// `build_sources` is construction-only. Source sampler configuration is owned
494
/// by sampler registration (`TripletSampler::register_source`).
495
pub fn run_multi_source_demo<R, Resolve, Build, I>(
9✔
496
    args_iter: I,
9✔
497
    resolve_roots: Resolve,
9✔
498
    build_sources: Build,
9✔
499
) -> Result<(), Box<dyn Error>>
9✔
500
where
9✔
501
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
9✔
502
    Build: FnOnce(&R) -> Vec<DynSource>,
9✔
503
    I: Iterator<Item = String>,
9✔
504
{
505
    init_example_tracing();
9✔
506

507
    let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
9✔
508
        std::iter::once("multi_source_demo".to_string()).chain(args_iter),
9✔
509
    )?
×
510
    else {
511
        return Ok(());
×
512
    };
513

514
    let roots = resolve_roots(cli.source_roots)?;
9✔
515

516
    let mut config = SamplerConfig::default();
8✔
517
    config.seed = cli.seed.unwrap_or(config.seed);
8✔
518
    config.batch_size = cli.batch_size;
8✔
519
    config.chunking = Default::default();
8✔
520
    let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
8✔
521
    config.split = SplitRatios::default();
8✔
522
    config.allowed_splits = vec![selected_split];
8✔
523
    let chunking = config.chunking.clone();
8✔
524

525
    let split_store_path = if let Some(path) = cli.split_store_path {
8✔
526
        path
1✔
527
    } else if let Some(dir) = cli.split_store_dir {
7✔
528
        FileSplitStore::default_path_in_dir(dir)
7✔
529
    } else {
530
        FileSplitStore::default_path()
×
531
    };
532

533
    println!(
8✔
534
        "Persisting split assignments and epoch state to {}",
535
        split_store_path.display()
8✔
536
    );
537
    let sources = build_sources(&roots);
8✔
538
    let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
8✔
539
    let sampler = TripletSampler::new(config, split_store.clone());
8✔
540
    for source in sources {
8✔
541
        sampler.register_source(source);
8✔
542
    }
8✔
543

544
    if cli.show_pair_samples {
8✔
545
        match sampler.next_pair_batch(selected_split) {
2✔
546
            Ok(pair_batch) => {
×
547
                if pair_batch.pairs.is_empty() {
×
548
                    println!("Pair sampling produced no results.");
×
549
                } else {
×
550
                    print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
×
551
                }
×
552
                sampler.persist_state()?;
×
553
            }
554
            Err(SamplerError::Exhausted(name)) => {
2✔
555
                eprintln!(
2✔
556
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
2✔
557
                    name
2✔
558
                );
2✔
559
            }
2✔
560
            Err(err) => return Err(err.into()),
×
561
        }
562
    } else if cli.show_text_samples {
6✔
563
        match sampler.next_text_batch(selected_split) {
2✔
564
            Ok(text_batch) => {
×
565
                if text_batch.samples.is_empty() {
×
566
                    println!(
×
567
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
568
                    );
×
569
                } else {
×
570
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
×
571
                }
×
572
                sampler.persist_state()?;
×
573
            }
574
            Err(SamplerError::Exhausted(name)) => {
2✔
575
                eprintln!(
2✔
576
                    "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
2✔
577
                    name
2✔
578
                );
2✔
579
            }
2✔
580
            Err(err) => return Err(err.into()),
×
581
        }
582
    } else if cli.list_text_recipes {
4✔
583
        let recipes = sampler.text_recipes();
2✔
584
        if recipes.is_empty() {
2✔
585
            println!(
1✔
586
                "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
1✔
587
            );
1✔
588
        } else {
1✔
589
            print_text_recipes(&recipes);
1✔
590
        }
1✔
591
    } else {
592
        match sampler.next_triplet_batch(selected_split) {
2✔
593
            Ok(triplet_batch) => {
×
594
                if triplet_batch.triplets.is_empty() {
×
595
                    println!(
×
596
                        "Triplet sampling produced no results. Ensure multiple records per source exist."
×
597
                    );
×
598
                } else {
×
599
                    print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
×
600
                }
×
601
                sampler.persist_state()?;
×
602
            }
603
            Err(SamplerError::Exhausted(name)) => {
2✔
604
                eprintln!(
2✔
605
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
2✔
606
                    name
2✔
607
                );
2✔
608
            }
2✔
609
            Err(err) => return Err(err.into()),
×
610
        }
611
    }
612

613
    Ok(())
8✔
614
}
9✔
615

616
fn parse_positive_usize(raw: &str) -> Result<usize, String> {
14✔
617
    let parsed = raw.parse::<usize>().map_err(|_| {
14✔
618
        format!(
1✔
619
            "Could not parse --batch-size value '{}' as a positive integer",
620
            raw
621
        )
622
    })?;
1✔
623
    if parsed == 0 {
13✔
624
        return Err("--batch-size must be greater than zero".to_string());
2✔
625
    }
11✔
626
    Ok(parsed)
11✔
627
}
14✔
628

629
fn suggested_balancing_weight(max_baseline: u128, source_baseline: u128) -> f32 {
13✔
630
    if max_baseline == 0 || source_baseline == 0 {
13✔
631
        return 0.0;
4✔
632
    }
9✔
633
    (source_baseline as f64 / max_baseline as f64).clamp(0.0, 1.0) as f32
9✔
634
}
13✔
635

636
fn suggested_oversampling_weight(min_nonzero_baseline: u128, source_baseline: u128) -> f32 {
13✔
637
    if min_nonzero_baseline == 0 || source_baseline == 0 {
13✔
638
        return 0.0;
4✔
639
    }
9✔
640
    (min_nonzero_baseline as f64 / source_baseline as f64).clamp(0.0, 1.0) as f32
9✔
641
}
13✔
642

643
fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
19✔
644
where
19✔
645
    T: Parser,
19✔
646
    I: IntoIterator,
19✔
647
    I::Item: Into<std::ffi::OsString> + Clone,
19✔
648
{
649
    match T::try_parse_from(args) {
19✔
650
        Ok(cli) => Ok(Some(cli)),
13✔
651
        Err(err) => match err.kind() {
6✔
652
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
653
                err.print()?;
3✔
654
                Ok(None)
3✔
655
            }
656
            _ => Err(err.into()),
3✔
657
        },
658
    }
659
}
19✔
660

661
fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
11✔
662
    let parts: Vec<&str> = raw.split(',').collect();
11✔
663
    if parts.len() != 3 {
11✔
664
        return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
1✔
665
    }
10✔
666
    let train = parts[0]
10✔
667
        .trim()
10✔
668
        .parse::<f32>()
10✔
669
        .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
10✔
670
    let validation = parts[1].trim().parse::<f32>().map_err(|_| {
9✔
671
        format!(
1✔
672
            "invalid validation ratio '{}': must be a float",
673
            parts[1].trim()
1✔
674
        )
675
    })?;
1✔
676
    let test = parts[2]
8✔
677
        .trim()
8✔
678
        .parse::<f32>()
8✔
679
        .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
8✔
680
    let ratios = SplitRatios {
7✔
681
        train,
7✔
682
        validation,
7✔
683
        test,
7✔
684
    };
7✔
685
    let sum = ratios.train + ratios.validation + ratios.test;
7✔
686
    if (sum - 1.0).abs() > 1e-5 {
7✔
687
        return Err(format!(
1✔
688
            "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
1✔
689
            sum, ratios.train, ratios.validation, ratios.test
1✔
690
        ));
1✔
691
    }
6✔
692
    if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
6✔
693
        return Err("split ratios must be non-negative".to_string());
1✔
694
    }
5✔
695
    Ok(ratios)
5✔
696
}
11✔
697

698
fn print_triplet_batch(
1✔
699
    strategy: &ChunkingStrategy,
1✔
700
    batch: &TripletBatch,
1✔
701
    split_store: &impl SplitStore,
1✔
702
) {
1✔
703
    println!("=== triplet batch ===");
1✔
704
    for (idx, triplet) in batch.triplets.iter().enumerate() {
1✔
705
        println!("--- triplet #{} ---", idx);
1✔
706
        println!("recipe       : {}", triplet.recipe);
1✔
707
        println!("sample_weight: {:.4}", triplet.weight);
1✔
708
        if let Some(instr) = &triplet.instruction {
1✔
709
            println!("instruction shown to model:\n{}\n", instr);
1✔
710
        }
1✔
711
        print_chunk_block("ANCHOR", &triplet.anchor, strategy, split_store);
1✔
712
        print_chunk_block("POSITIVE", &triplet.positive, strategy, split_store);
1✔
713
        print_chunk_block("NEGATIVE", &triplet.negative, strategy, split_store);
1✔
714
    }
715
    print_source_summary(
1✔
716
        "triplet anchors",
1✔
717
        batch
1✔
718
            .triplets
1✔
719
            .iter()
1✔
720
            .map(|triplet| triplet.anchor.record_id.as_str()),
1✔
721
    );
722
    print_recipe_context_by_source(
1✔
723
        "triplet recipes by source",
1✔
724
        batch
1✔
725
            .triplets
1✔
726
            .iter()
1✔
727
            .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
1✔
728
    );
729
}
1✔
730

731
fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
1✔
732
    println!("=== text batch ===");
1✔
733
    for (idx, sample) in batch.samples.iter().enumerate() {
1✔
734
        println!("--- sample #{} ---", idx);
1✔
735
        println!("recipe       : {}", sample.recipe);
1✔
736
        println!("sample_weight: {:.4}", sample.weight);
1✔
737
        if let Some(instr) = &sample.instruction {
1✔
738
            println!("instruction shown to model:\n{}\n", instr);
1✔
739
        }
1✔
740
        print_chunk_block("TEXT", &sample.chunk, strategy, split_store);
1✔
741
    }
742
    print_source_summary(
1✔
743
        "text samples",
1✔
744
        batch
1✔
745
            .samples
1✔
746
            .iter()
1✔
747
            .map(|sample| sample.chunk.record_id.as_str()),
1✔
748
    );
749
    print_recipe_context_by_source(
1✔
750
        "text recipes by source",
1✔
751
        batch
1✔
752
            .samples
1✔
753
            .iter()
1✔
754
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
1✔
755
    );
756
}
1✔
757

758
fn print_pair_batch(
1✔
759
    strategy: &ChunkingStrategy,
1✔
760
    batch: &SampleBatch,
1✔
761
    split_store: &impl SplitStore,
1✔
762
) {
1✔
763
    println!("=== pair batch ===");
1✔
764
    for (idx, pair) in batch.pairs.iter().enumerate() {
1✔
765
        println!("--- pair #{} ---", idx);
1✔
766
        println!("recipe       : {}", pair.recipe);
1✔
767
        println!("label        : {:?}", pair.label);
1✔
768
        if let Some(reason) = &pair.reason {
1✔
769
            println!("reason       : {}", reason);
1✔
770
        }
1✔
771
        print_chunk_block("ANCHOR", &pair.anchor, strategy, split_store);
1✔
772
        print_chunk_block("OTHER", &pair.positive, strategy, split_store);
1✔
773
    }
774
    print_source_summary(
1✔
775
        "pair anchors",
1✔
776
        batch
1✔
777
            .pairs
1✔
778
            .iter()
1✔
779
            .map(|pair| pair.anchor.record_id.as_str()),
1✔
780
    );
781
    print_recipe_context_by_source(
1✔
782
        "pair recipes by source",
1✔
783
        batch
1✔
784
            .pairs
1✔
785
            .iter()
1✔
786
            .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
1✔
787
    );
788
}
1✔
789

790
fn print_text_recipes(recipes: &[TextRecipe]) {
2✔
791
    println!("=== available text recipes ===");
2✔
792
    for recipe in recipes {
4✔
793
        println!(
4✔
794
            "- {} (weight: {:.3}) selector={:?}",
795
            recipe.name, recipe.weight, recipe.selector
796
        );
797
        if let Some(instr) = &recipe.instruction {
4✔
798
            println!("  instruction: {}", instr);
1✔
799
        }
3✔
800
    }
801
}
2✔
802

803
trait ChunkDebug {
804
    fn view_name(&self) -> String;
805
}
806

807
impl ChunkDebug for RecordChunk {
808
    fn view_name(&self) -> String {
6✔
809
        match &self.view {
6✔
810
            ChunkView::Window {
811
                index,
4✔
812
                span,
4✔
813
                overlap,
4✔
814
                start_ratio,
4✔
815
            } => format!(
4✔
816
                "window#index={} span={} overlap={} start_ratio={:.3} tokens={}",
817
                index, span, overlap, start_ratio, self.tokens_estimate
818
            ),
819
            ChunkView::SummaryFallback { strategy, .. } => {
2✔
820
                format!("summary:{} tokens={}", strategy, self.tokens_estimate)
2✔
821
            }
822
        }
823
    }
6✔
824
}
825

826
fn print_chunk_block(
6✔
827
    title: &str,
6✔
828
    chunk: &RecordChunk,
6✔
829
    strategy: &ChunkingStrategy,
6✔
830
    split_store: &impl SplitStore,
6✔
831
) {
6✔
832
    let chunk_weight = chunk_weight(strategy, chunk);
6✔
833
    let split = split_store
6✔
834
        .label_for(&chunk.record_id)
6✔
835
        .map(|label| format!("{:?}", label))
6✔
836
        .unwrap_or_else(|| "Unknown".to_string());
6✔
837
    println!("--- {} ---", title);
6✔
838
    println!("split        : {}", split);
6✔
839
    println!("view         : {}", chunk.view_name());
6✔
840
    println!("chunk_weight : {:.4}", chunk_weight);
6✔
841
    println!("record_id    : {}", chunk.record_id);
6✔
842
    println!("section_idx  : {}", chunk.section_idx);
6✔
843
    println!("token_est    : {}", chunk.tokens_estimate);
6✔
844
    println!("model_input (exact text sent to the model):");
6✔
845
    println!(
6✔
846
        "<<< BEGIN MODEL TEXT >>>\n{}\n<<< END MODEL TEXT >>>\n",
847
        chunk.text
848
    );
849
}
6✔
850

851
fn print_source_summary<'a, I>(label: &str, ids: I)
3✔
852
where
3✔
853
    I: Iterator<Item = &'a str>,
3✔
854
{
855
    let mut counts: HashMap<SourceId, usize> = HashMap::new();
3✔
856
    for id in ids {
3✔
857
        let source = extract_source(id);
3✔
858
        *counts.entry(source).or_insert(0) += 1;
3✔
859
    }
3✔
860
    if counts.is_empty() {
3✔
861
        return;
×
862
    }
3✔
863
    let skew = source_skew(&counts);
3✔
864
    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
3✔
865
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
3✔
866
    println!("--- {} by source ---", label);
3✔
867
    if let Some(skew) = skew {
3✔
868
        for entry in &skew.per_source {
3✔
869
            println!(
3✔
870
                "{}: count={} share={:.2}",
3✔
871
                entry.source, entry.count, entry.share
3✔
872
            );
3✔
873
        }
3✔
874
        println!(
3✔
875
            "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
876
            skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
877
        );
878
    } else {
879
        for (source, count) in &entries {
×
880
            println!("{source}: count={count}");
×
881
        }
×
882
    }
883
}
3✔
884

885
fn print_recipe_context_by_source<'a, I>(label: &str, entries: I)
3✔
886
where
3✔
887
    I: Iterator<Item = (&'a str, &'a str)>,
3✔
888
{
889
    let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
3✔
890
    for (record_id, recipe) in entries {
3✔
891
        let source = extract_source(record_id);
3✔
892
        let entry = counts
3✔
893
            .entry(source)
3✔
894
            .or_default()
3✔
895
            .entry(recipe.to_string())
3✔
896
            .or_insert(0);
3✔
897
        *entry += 1;
3✔
898
    }
3✔
899
    if counts.is_empty() {
3✔
900
        return;
×
901
    }
3✔
902
    let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
3✔
903
    sources.sort_by(|a, b| a.0.cmp(&b.0));
3✔
904
    println!("--- {} ---", label);
3✔
905
    for (source, recipes) in sources {
3✔
906
        println!("{source}");
3✔
907
        let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
3✔
908
        entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
3✔
909
        for (recipe, count) in entries {
3✔
910
            println!("  - {recipe}={count}");
3✔
911
        }
3✔
912
    }
913
}
3✔
914

915
fn extract_source(record_id: &str) -> SourceId {
8✔
916
    record_id
8✔
917
        .split_once("::")
8✔
918
        .map(|(source, _)| source.to_string())
8✔
919
        .unwrap_or_else(|| "unknown".to_string())
8✔
920
}
8✔
921

922
#[cfg(test)]
923
mod tests {
924
    use super::*;
925
    use crate::DeterministicSplitStore;
926
    use crate::data::SectionRole;
927
    use crate::source::{SourceCursor, SourceSnapshot};
928
    use chrono::Utc;
929
    use tempfile::tempdir;
930

931
    /// Minimal in-memory `DataSource` test double for example app tests.
932
    struct TestSource {
933
        id: String,
934
        count: Option<u128>,
935
        recipes: Vec<TripletRecipe>,
936
    }
937

938
    impl DataSource for TestSource {
939
        fn id(&self) -> &str {
70✔
940
            &self.id
70✔
941
        }
70✔
942

943
        fn refresh(
30✔
944
            &self,
30✔
945
            _config: &SamplerConfig,
30✔
946
            _cursor: Option<&SourceCursor>,
30✔
947
            _limit: Option<usize>,
30✔
948
        ) -> Result<SourceSnapshot, SamplerError> {
30✔
949
            Ok(SourceSnapshot {
30✔
950
                records: Vec::new(),
30✔
951
                cursor: SourceCursor {
30✔
952
                    last_seen: Utc::now(),
30✔
953
                    revision: 0,
30✔
954
                },
30✔
955
            })
30✔
956
        }
30✔
957

958
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
959
            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
2✔
960
                source_id: self.id.clone(),
1✔
961
                details: "test source has no configured exact count".to_string(),
1✔
962
            })
1✔
963
        }
2✔
964

965
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
10✔
966
            self.recipes.clone()
10✔
967
        }
10✔
968
    }
969

970
    struct ConfigRequiredSource {
971
        id: String,
972
        expected_seed: u64,
973
    }
974

975
    impl DataSource for ConfigRequiredSource {
976
        fn id(&self) -> &str {
1✔
977
            &self.id
1✔
978
        }
1✔
979

980
        fn refresh(
×
981
            &self,
×
982
            _config: &SamplerConfig,
×
983
            _cursor: Option<&SourceCursor>,
×
984
            _limit: Option<usize>,
×
985
        ) -> Result<SourceSnapshot, SamplerError> {
×
986
            Ok(SourceSnapshot {
×
987
                records: Vec::new(),
×
988
                cursor: SourceCursor {
×
989
                    last_seen: Utc::now(),
×
990
                    revision: 0,
×
991
                },
×
992
            })
×
993
        }
×
994

995
        fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
996
            if config.seed == self.expected_seed {
1✔
997
                Ok(1)
1✔
998
            } else {
999
                Err(SamplerError::SourceInconsistent {
×
1000
                    source_id: self.id.clone(),
×
1001
                    details: format!(
×
1002
                        "expected sampler seed {} but got {}",
×
1003
                        self.expected_seed, config.seed
×
1004
                    ),
×
1005
                })
×
1006
            }
1007
        }
1✔
1008

1009
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1✔
1010
            Vec::new()
1✔
1011
        }
1✔
1012
    }
1013

1014
    fn default_recipe(name: &str) -> TripletRecipe {
6✔
1015
        TripletRecipe {
6✔
1016
            name: name.to_string().into(),
6✔
1017
            anchor: crate::config::Selector::Role(SectionRole::Anchor),
6✔
1018
            positive_selector: crate::config::Selector::Role(SectionRole::Context),
6✔
1019
            negative_selector: crate::config::Selector::Role(SectionRole::Context),
6✔
1020
            negative_strategy: crate::config::NegativeStrategy::WrongArticle,
6✔
1021
            weight: 1.0,
6✔
1022
            instruction: None,
6✔
1023
        }
6✔
1024
    }
6✔
1025

1026
    #[test]
1027
    fn parse_helpers_validate_inputs() {
1✔
1028
        assert_eq!(parse_positive_usize("2").unwrap(), 2);
1✔
1029
        assert!(parse_positive_usize("0").is_err());
1✔
1030
        assert!(parse_positive_usize("abc").is_err());
1✔
1031

1032
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
1033
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
1034
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
1035
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
1036
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
1037
    }
1✔
1038

1039
    #[test]
1040
    fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1✔
1041
        assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1042
        assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1✔
1043
        assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1✔
1044
        assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1✔
1045
        assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1✔
1046
    }
1✔
1047

1048
    #[test]
1049
    fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1✔
1050
        assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1051
        assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1✔
1052
        assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1✔
1053
        assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1✔
1054
        assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1✔
1055
    }
1✔
1056

1057
    #[test]
1058
    fn parse_cli_handles_help_and_invalid_args() {
1✔
1059
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
1060
        assert!(help.is_none());
1✔
1061

1062
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
1063
        assert!(err.is_err());
1✔
1064
    }
1✔
1065

1066
    #[test]
1067
    fn run_estimate_capacity_succeeds_with_reported_counts() {
1✔
1068
        let result = run_estimate_capacity(
1✔
1069
            std::iter::empty::<String>(),
1✔
1070
            |roots| {
1✔
1071
                assert!(roots.is_empty());
1✔
1072
                Ok(())
1✔
1073
            },
1✔
1074
            |_| {
1✔
1075
                vec![Box::new(TestSource {
1✔
1076
                    id: "source_a".into(),
1✔
1077
                    count: Some(12),
1✔
1078
                    recipes: vec![default_recipe("r1")],
1✔
1079
                }) as DynSource]
1✔
1080
            },
1✔
1081
        );
1082

1083
        assert!(result.is_ok());
1✔
1084
    }
1✔
1085

1086
    #[test]
1087
    fn run_estimate_capacity_errors_when_source_count_missing() {
1✔
1088
        let result = run_estimate_capacity(
1✔
1089
            std::iter::empty::<String>(),
1✔
1090
            |_| Ok(()),
1✔
1091
            |_| {
1✔
1092
                vec![Box::new(TestSource {
1✔
1093
                    id: "source_missing".into(),
1✔
1094
                    count: None,
1✔
1095
                    recipes: vec![default_recipe("r1")],
1✔
1096
                }) as DynSource]
1✔
1097
            },
1✔
1098
        );
1099

1100
        let err = result.unwrap_err().to_string();
1✔
1101
        assert!(err.contains("failed to report exact record count"));
1✔
1102
    }
1✔
1103

1104
    #[test]
1105
    fn run_estimate_capacity_propagates_root_resolution_error() {
1✔
1106
        let result = run_estimate_capacity(
1✔
1107
            std::iter::empty::<String>(),
1✔
1108
            |_| Err("root resolution failed".into()),
1✔
1109
            |_: &()| Vec::<DynSource>::new(),
×
1110
        );
1111

1112
        let err = result.unwrap_err().to_string();
1✔
1113
        assert!(err.contains("root resolution failed"));
1✔
1114
    }
1✔
1115

1116
    #[test]
1117
    fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1✔
1118
        let result = run_estimate_capacity(
1✔
1119
            std::iter::empty::<String>(),
1✔
1120
            |_| Ok(()),
1✔
1121
            |_| {
1✔
1122
                vec![Box::new(ConfigRequiredSource {
1✔
1123
                    id: "requires_config".into(),
1✔
1124
                    expected_seed: 99,
1✔
1125
                }) as DynSource]
1✔
1126
            },
1✔
1127
        );
1128

1129
        assert!(result.is_ok());
1✔
1130
    }
1✔
1131

1132
    #[test]
1133
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
1134
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
1135
        assert!(help.is_none());
1✔
1136

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

1140
        let conflict = parse_cli::<MultiSourceDemoCli, _>([
1✔
1141
            "multi_source_demo",
1✔
1142
            "--split-store-dir",
1✔
1143
            "./a",
1✔
1144
            "--split-store-path",
1✔
1145
            "./b.bin",
1✔
1146
        ]);
1✔
1147
        assert!(conflict.is_err());
1✔
1148
    }
1✔
1149

1150
    #[test]
1151
    fn parse_cli_handles_display_version_path() {
1✔
1152
        #[derive(Debug, Parser)]
1153
        #[command(name = "version_test", version = "1.0.0")]
1154
        struct VersionCli {}
1155

1156
        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
1✔
1157
        assert!(parsed.is_none());
1✔
1158
    }
1✔
1159

1160
    #[test]
1161
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
1162
        let dir = tempdir().unwrap();
1✔
1163
        let mut args = vec![
1✔
1164
            "--list-text-recipes".to_string(),
1✔
1165
            "--split-store-dir".to_string(),
1✔
1166
            dir.path().to_string_lossy().to_string(),
1✔
1167
        ];
1168
        let result = run_multi_source_demo(
1✔
1169
            args.drain(..),
1✔
1170
            |_| Ok(()),
1✔
1171
            |_| {
1✔
1172
                vec![Box::new(TestSource {
1✔
1173
                    id: "source_for_recipes".into(),
1✔
1174
                    count: Some(10),
1✔
1175
                    recipes: vec![default_recipe("recipe_a")],
1✔
1176
                }) as DynSource]
1✔
1177
            },
1✔
1178
        );
1179

1180
        assert!(result.is_ok());
1✔
1181
    }
1✔
1182

1183
    #[test]
1184
    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
1✔
1185
        let dir = tempdir().unwrap();
1✔
1186
        let split_store_path = dir.path().join("custom_split_store.bin");
1✔
1187
        let args = vec![
1✔
1188
            "--list-text-recipes".to_string(),
1✔
1189
            "--split-store-path".to_string(),
1✔
1190
            split_store_path.to_string_lossy().to_string(),
1✔
1191
        ];
1192

1193
        let result = run_multi_source_demo(
1✔
1194
            args.into_iter(),
1✔
1195
            |_| Ok(()),
1✔
1196
            |_| {
1✔
1197
                vec![Box::new(TestSource {
1✔
1198
                    id: "source_without_text_recipes".into(),
1✔
1199
                    count: Some(1),
1✔
1200
                    recipes: Vec::new(),
1✔
1201
                }) as DynSource]
1✔
1202
            },
1✔
1203
        );
1204

1205
        assert!(result.is_ok());
1✔
1206
    }
1✔
1207

1208
    #[test]
1209
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
1210
        for mode in [
3✔
1211
            vec!["--pair-batch".to_string()],
1✔
1212
            vec!["--text-recipes".to_string()],
1✔
1213
            vec![],
1✔
1214
        ] {
1✔
1215
            let dir = tempdir().unwrap();
3✔
1216
            let mut args = mode;
3✔
1217
            args.push("--split-store-dir".to_string());
3✔
1218
            args.push(dir.path().to_string_lossy().to_string());
3✔
1219
            args.push("--split".to_string());
3✔
1220
            args.push("validation".to_string());
3✔
1221

1222
            let result = run_multi_source_demo(
3✔
1223
                args.into_iter(),
3✔
1224
                |_| Ok(()),
3✔
1225
                |_| {
3✔
1226
                    vec![Box::new(TestSource {
3✔
1227
                        id: "source_empty".into(),
3✔
1228
                        count: Some(0),
3✔
1229
                        recipes: vec![default_recipe("recipe_empty")],
3✔
1230
                    }) as DynSource]
3✔
1231
                },
3✔
1232
            );
1233

1234
            assert!(result.is_ok());
3✔
1235
        }
1236
    }
1✔
1237

1238
    #[test]
1239
    fn run_multi_source_demo_propagates_root_resolution_error() {
1✔
1240
        let result = run_multi_source_demo(
1✔
1241
            std::iter::empty::<String>(),
1✔
1242
            |_| Err("demo root resolution failed".into()),
1✔
1243
            |_: &()| Vec::<DynSource>::new(),
×
1244
        );
1245

1246
        let err = result.unwrap_err().to_string();
1✔
1247
        assert!(err.contains("demo root resolution failed"));
1✔
1248
    }
1✔
1249

1250
    #[test]
1251
    fn print_helpers_and_extract_source_cover_paths() {
1✔
1252
        let split = SplitRatios::default();
1✔
1253
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
1254
        let strategy = ChunkingStrategy::default();
1✔
1255

1256
        let anchor = RecordChunk {
1✔
1257
            record_id: "source_a::rec1".to_string(),
1✔
1258
            section_idx: 0,
1✔
1259
            view: ChunkView::Window {
1✔
1260
                index: 1,
1✔
1261
                overlap: 2,
1✔
1262
                span: 12,
1✔
1263
                start_ratio: 0.25,
1✔
1264
            },
1✔
1265
            text: "anchor text".to_string(),
1✔
1266
            tokens_estimate: 8,
1✔
1267
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
1268
        };
1✔
1269
        let positive = RecordChunk {
1✔
1270
            record_id: "source_a::rec2".to_string(),
1✔
1271
            section_idx: 1,
1✔
1272
            view: ChunkView::SummaryFallback {
1✔
1273
                strategy: "summary".to_string(),
1✔
1274
                weight: 0.7,
1✔
1275
            },
1✔
1276
            text: "positive text".to_string(),
1✔
1277
            tokens_estimate: 6,
1✔
1278
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
1279
        };
1✔
1280
        let negative = RecordChunk {
1✔
1281
            record_id: "source_b::rec3".to_string(),
1✔
1282
            section_idx: 2,
1✔
1283
            view: ChunkView::Window {
1✔
1284
                index: 0,
1✔
1285
                overlap: 0,
1✔
1286
                span: 16,
1✔
1287
                start_ratio: 0.0,
1✔
1288
            },
1✔
1289
            text: "negative text".to_string(),
1✔
1290
            tokens_estimate: 7,
1✔
1291
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
1292
        };
1✔
1293

1294
        let triplet_batch = TripletBatch {
1✔
1295
            triplets: vec![crate::SampleTriplet {
1✔
1296
                recipe: "triplet_recipe".to_string(),
1✔
1297
                anchor: anchor.clone(),
1✔
1298
                positive: positive.clone(),
1✔
1299
                negative: negative.clone(),
1✔
1300
                weight: 1.0,
1✔
1301
                instruction: Some("triplet instruction".to_string()),
1✔
1302
            }],
1✔
1303
        };
1✔
1304
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
1305

1306
        let pair_batch = SampleBatch {
1✔
1307
            pairs: vec![crate::SamplePair {
1✔
1308
                recipe: "pair_recipe".to_string(),
1✔
1309
                anchor: anchor.clone(),
1✔
1310
                positive: positive.clone(),
1✔
1311
                weight: 1.0,
1✔
1312
                instruction: None,
1✔
1313
                label: crate::PairLabel::Positive,
1✔
1314
                reason: Some("same topic".to_string()),
1✔
1315
            }],
1✔
1316
        };
1✔
1317
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
1318

1319
        let text_batch = TextBatch {
1✔
1320
            samples: vec![crate::TextSample {
1✔
1321
                recipe: "text_recipe".to_string(),
1✔
1322
                chunk: negative,
1✔
1323
                weight: 0.8,
1✔
1324
                instruction: Some("text instruction".to_string()),
1✔
1325
            }],
1✔
1326
        };
1✔
1327
        print_text_batch(&strategy, &text_batch, &store);
1✔
1328

1329
        let recipes = vec![TextRecipe {
1✔
1330
            name: "recipe_name".into(),
1✔
1331
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
1332
            instruction: Some("instruction".into()),
1✔
1333
            weight: 1.0,
1✔
1334
        }];
1✔
1335
        print_text_recipes(&recipes);
1✔
1336

1337
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
1338
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
1339
    }
1✔
1340

1341
    #[test]
1342
    fn split_arg_conversion_and_version_parse_paths_are_covered() {
1✔
1343
        assert!(matches!(
1✔
1344
            SplitLabel::from(SplitArg::Train),
1✔
1345
            SplitLabel::Train
1346
        ));
1347
        assert!(matches!(
1✔
1348
            SplitLabel::from(SplitArg::Validation),
1✔
1349
            SplitLabel::Validation
1350
        ));
1351
        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
1✔
1352
    }
1✔
1353

1354
    #[test]
1355
    fn parse_split_ratios_reports_per_field_parse_errors() {
1✔
1356
        assert!(
1✔
1357
            parse_split_ratios_arg("x,0.1,0.9")
1✔
1358
                .unwrap_err()
1✔
1359
                .contains("invalid train ratio")
1✔
1360
        );
1361
        assert!(
1✔
1362
            parse_split_ratios_arg("0.1,y,0.8")
1✔
1363
                .unwrap_err()
1✔
1364
                .contains("invalid validation ratio")
1✔
1365
        );
1366
        assert!(
1✔
1367
            parse_split_ratios_arg("0.1,0.2,z")
1✔
1368
                .unwrap_err()
1✔
1369
                .contains("invalid test ratio")
1✔
1370
        );
1371
    }
1✔
1372

1373
    #[test]
1374
    fn run_multi_source_demo_exhausted_paths_are_handled() {
1✔
1375
        for mode in [
3✔
1376
            vec!["--pair-batch".to_string()],
1✔
1377
            vec!["--text-recipes".to_string()],
1✔
1378
            Vec::new(),
1✔
1379
        ] {
1✔
1380
            let dir = tempdir().unwrap();
3✔
1381
            let mut args = mode;
3✔
1382
            args.push("--split-store-dir".to_string());
3✔
1383
            args.push(dir.path().to_string_lossy().to_string());
3✔
1384

1385
            let result = run_multi_source_demo(
3✔
1386
                args.into_iter(),
3✔
1387
                |_| Ok(()),
3✔
1388
                |_| {
3✔
1389
                    vec![Box::new(TestSource {
3✔
1390
                        id: "source_without_recipes".into(),
3✔
1391
                        count: Some(1),
3✔
1392
                        recipes: Vec::new(),
3✔
1393
                    }) as DynSource]
3✔
1394
                },
3✔
1395
            );
1396

1397
            assert!(result.is_ok());
3✔
1398
        }
1399
    }
1✔
1400
}
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