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

jzombie / rust-triplets / 22595342183

02 Mar 2026 08:56PM UTC coverage: 94.685% (+0.8%) from 93.854%
22595342183

push

github

web-flow
Integrate cache manager (#20)

355 of 361 new or added lines in 3 files covered. (98.34%)

6 existing lines in 2 files now uncovered.

16550 of 17479 relevant lines covered (94.69%)

2356.88 hits per line

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

96.33
/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() {
16✔
28
    static INIT: Once = Once::new();
29
    INIT.call_once(|| {
16✔
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
}
16✔
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>(
12✔
496
    args_iter: I,
12✔
497
    resolve_roots: Resolve,
12✔
498
    build_sources: Build,
12✔
499
) -> Result<(), Box<dyn Error>>
12✔
500
where
12✔
501
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
12✔
502
    Build: FnOnce(&R) -> Vec<DynSource>,
12✔
503
    I: Iterator<Item = String>,
12✔
504
{
505
    init_example_tracing();
12✔
506

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

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

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

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

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

544
    if cli.show_pair_samples {
11✔
545
        match sampler.next_pair_batch(selected_split) {
3✔
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)) => {
3✔
555
                eprintln!(
3✔
556
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
3✔
557
                    name
3✔
558
                );
3✔
559
            }
3✔
560
            Err(err) => return Err(err.into()),
×
561
        }
562
    } else if cli.show_text_samples {
8✔
563
        match sampler.next_text_batch(selected_split) {
3✔
564
            Ok(text_batch) => {
1✔
565
                if text_batch.samples.is_empty() {
1✔
566
                    println!(
×
567
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
568
                    );
×
569
                } else {
1✔
570
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
1✔
571
                }
1✔
572
                sampler.persist_state()?;
1✔
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 {
5✔
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) {
3✔
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)) => {
3✔
604
                eprintln!(
3✔
605
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
3✔
606
                    name
3✔
607
                );
3✔
608
            }
3✔
609
            Err(err) => return Err(err.into()),
×
610
        }
611
    }
612

613
    Ok(())
11✔
614
}
12✔
615

616
fn parse_positive_usize(raw: &str) -> Result<usize, String> {
17✔
617
    let parsed = raw.parse::<usize>().map_err(|_| {
17✔
618
        format!(
1✔
619
            "Could not parse --batch-size value '{}' as a positive integer",
620
            raw
621
        )
622
    })?;
1✔
623
    if parsed == 0 {
16✔
624
        return Err("--batch-size must be greater than zero".to_string());
2✔
625
    }
14✔
626
    Ok(parsed)
14✔
627
}
17✔
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>>
22✔
644
where
22✔
645
    T: Parser,
22✔
646
    I: IntoIterator,
22✔
647
    I::Item: Into<std::ffi::OsString> + Clone,
22✔
648
{
649
    match T::try_parse_from(args) {
22✔
650
        Ok(cli) => Ok(Some(cli)),
16✔
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
}
22✔
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) {
2✔
732
    println!("=== text batch ===");
2✔
733
    for (idx, sample) in batch.samples.iter().enumerate() {
5✔
734
        println!("--- sample #{} ---", idx);
5✔
735
        println!("recipe       : {}", sample.recipe);
5✔
736
        println!("sample_weight: {:.4}", sample.weight);
5✔
737
        if let Some(instr) = &sample.instruction {
5✔
738
            println!("instruction shown to model:\n{}\n", instr);
1✔
739
        }
4✔
740
        print_chunk_block("TEXT", &sample.chunk, strategy, split_store);
5✔
741
    }
742
    print_source_summary(
2✔
743
        "text samples",
2✔
744
        batch
2✔
745
            .samples
2✔
746
            .iter()
2✔
747
            .map(|sample| sample.chunk.record_id.as_str()),
5✔
748
    );
749
    print_recipe_context_by_source(
2✔
750
        "text recipes by source",
2✔
751
        batch
2✔
752
            .samples
2✔
753
            .iter()
2✔
754
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
5✔
755
    );
756
}
2✔
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 {
10✔
809
        match &self.view {
10✔
810
            ChunkView::Window {
811
                index,
8✔
812
                span,
8✔
813
                overlap,
8✔
814
                start_ratio,
8✔
815
            } => format!(
8✔
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
    }
10✔
824
}
825

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1133
    #[test]
1134
    fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1✔
1135
        let source = ConfigRequiredSource {
1✔
1136
            id: "cfg-source".to_string(),
1✔
1137
            expected_seed: 42,
1✔
1138
        };
1✔
1139

1140
        let refreshed = source
1✔
1141
            .refresh(&SamplerConfig::default(), None, None)
1✔
1142
            .unwrap();
1✔
1143
        assert!(refreshed.records.is_empty());
1✔
1144

1145
        let mismatched = source.reported_record_count(&SamplerConfig {
1✔
1146
            seed: 7,
1✔
1147
            ..SamplerConfig::default()
1✔
1148
        });
1✔
1149
        assert!(matches!(
1✔
1150
            mismatched,
1✔
1151
            Err(SamplerError::SourceInconsistent { .. })
1152
        ));
1153

1154
        assert!(source.default_triplet_recipes().is_empty());
1✔
1155
    }
1✔
1156

1157
    #[test]
1158
    fn run_multi_source_demo_exhausted_paths_return_ok() {
1✔
1159
        struct OneRecordSource;
1160

1161
        impl DataSource for OneRecordSource {
1162
            fn id(&self) -> &str {
26✔
1163
                "one_record"
26✔
1164
            }
26✔
1165

1166
            fn refresh(
11✔
1167
                &self,
11✔
1168
                _config: &SamplerConfig,
11✔
1169
                _cursor: Option<&SourceCursor>,
11✔
1170
                _limit: Option<usize>,
11✔
1171
            ) -> Result<SourceSnapshot, SamplerError> {
11✔
1172
                let now = Utc::now();
11✔
1173
                Ok(SourceSnapshot {
11✔
1174
                    records: vec![DataRecord {
11✔
1175
                        id: "one_record::r1".to_string(),
11✔
1176
                        source: "one_record".to_string(),
11✔
1177
                        created_at: now,
11✔
1178
                        updated_at: now,
11✔
1179
                        quality: QualityScore { trust: 1.0 },
11✔
1180
                        taxonomy: Vec::new(),
11✔
1181
                        sections: vec![
11✔
1182
                            RecordSection {
11✔
1183
                                role: SectionRole::Anchor,
11✔
1184
                                heading: Some("title".to_string()),
11✔
1185
                                text: "anchor".to_string(),
11✔
1186
                                sentences: vec!["anchor".to_string()],
11✔
1187
                            },
11✔
1188
                            RecordSection {
11✔
1189
                                role: SectionRole::Context,
11✔
1190
                                heading: Some("body".to_string()),
11✔
1191
                                text: "context".to_string(),
11✔
1192
                                sentences: vec!["context".to_string()],
11✔
1193
                            },
11✔
1194
                        ],
11✔
1195
                        meta_prefix: None,
11✔
1196
                    }],
11✔
1197
                    cursor: SourceCursor {
11✔
1198
                        last_seen: now,
11✔
1199
                        revision: 0,
11✔
1200
                    },
11✔
1201
                })
11✔
1202
            }
11✔
1203

NEW
1204
            fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
×
NEW
1205
                Ok(1)
×
NEW
1206
            }
×
1207

1208
            fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
3✔
1209
                vec![default_recipe("single_record_recipe")]
3✔
1210
            }
3✔
1211
        }
1212

1213
        for mode in ["--pair-batch", "--text-recipes", ""] {
3✔
1214
            let dir = tempdir().unwrap();
3✔
1215
            let mut args = vec![
3✔
1216
                "--split-store-dir".to_string(),
3✔
1217
                dir.path().to_string_lossy().to_string(),
3✔
1218
            ];
1219
            if !mode.is_empty() {
3✔
1220
                args.push(mode.to_string());
2✔
1221
            }
2✔
1222

1223
            let result = run_multi_source_demo(
3✔
1224
                args.into_iter(),
3✔
1225
                |_| Ok(()),
3✔
1226
                |_| vec![Box::new(OneRecordSource) as DynSource],
3✔
1227
            );
1228
            assert!(result.is_ok());
3✔
1229
        }
1230
    }
1✔
1231

1232
    #[test]
1233
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
1234
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
1235
        assert!(help.is_none());
1✔
1236

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

1240
        let conflict = parse_cli::<MultiSourceDemoCli, _>([
1✔
1241
            "multi_source_demo",
1✔
1242
            "--split-store-dir",
1✔
1243
            "./a",
1✔
1244
            "--split-store-path",
1✔
1245
            "./b.bin",
1✔
1246
        ]);
1✔
1247
        assert!(conflict.is_err());
1✔
1248
    }
1✔
1249

1250
    #[test]
1251
    fn parse_cli_handles_display_version_path() {
1✔
1252
        #[derive(Debug, Parser)]
1253
        #[command(name = "version_test", version = "1.0.0")]
1254
        struct VersionCli {}
1255

1256
        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
1✔
1257
        assert!(parsed.is_none());
1✔
1258
    }
1✔
1259

1260
    #[test]
1261
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
1262
        let dir = tempdir().unwrap();
1✔
1263
        let mut args = vec![
1✔
1264
            "--list-text-recipes".to_string(),
1✔
1265
            "--split-store-dir".to_string(),
1✔
1266
            dir.path().to_string_lossy().to_string(),
1✔
1267
        ];
1268
        let result = run_multi_source_demo(
1✔
1269
            args.drain(..),
1✔
1270
            |_| Ok(()),
1✔
1271
            |_| {
1✔
1272
                vec![Box::new(TestSource {
1✔
1273
                    id: "source_for_recipes".into(),
1✔
1274
                    count: Some(10),
1✔
1275
                    recipes: vec![default_recipe("recipe_a")],
1✔
1276
                }) as DynSource]
1✔
1277
            },
1✔
1278
        );
1279

1280
        assert!(result.is_ok());
1✔
1281
    }
1✔
1282

1283
    #[test]
1284
    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
1✔
1285
        let dir = tempdir().unwrap();
1✔
1286
        let split_store_path = dir.path().join("custom_split_store.bin");
1✔
1287
        let args = vec![
1✔
1288
            "--list-text-recipes".to_string(),
1✔
1289
            "--split-store-path".to_string(),
1✔
1290
            split_store_path.to_string_lossy().to_string(),
1✔
1291
        ];
1292

1293
        let result = run_multi_source_demo(
1✔
1294
            args.into_iter(),
1✔
1295
            |_| Ok(()),
1✔
1296
            |_| {
1✔
1297
                vec![Box::new(TestSource {
1✔
1298
                    id: "source_without_text_recipes".into(),
1✔
1299
                    count: Some(1),
1✔
1300
                    recipes: Vec::new(),
1✔
1301
                }) as DynSource]
1✔
1302
            },
1✔
1303
        );
1304

1305
        assert!(result.is_ok());
1✔
1306
    }
1✔
1307

1308
    #[test]
1309
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
1310
        for mode in [
3✔
1311
            vec!["--pair-batch".to_string()],
1✔
1312
            vec!["--text-recipes".to_string()],
1✔
1313
            vec![],
1✔
1314
        ] {
1✔
1315
            let dir = tempdir().unwrap();
3✔
1316
            let mut args = mode;
3✔
1317
            args.push("--split-store-dir".to_string());
3✔
1318
            args.push(dir.path().to_string_lossy().to_string());
3✔
1319
            args.push("--split".to_string());
3✔
1320
            args.push("validation".to_string());
3✔
1321

1322
            let result = run_multi_source_demo(
3✔
1323
                args.into_iter(),
3✔
1324
                |_| Ok(()),
3✔
1325
                |_| {
3✔
1326
                    vec![Box::new(TestSource {
3✔
1327
                        id: "source_empty".into(),
3✔
1328
                        count: Some(0),
3✔
1329
                        recipes: vec![default_recipe("recipe_empty")],
3✔
1330
                    }) as DynSource]
3✔
1331
                },
3✔
1332
            );
1333

1334
            assert!(result.is_ok());
3✔
1335
        }
1336
    }
1✔
1337

1338
    #[test]
1339
    fn run_multi_source_demo_propagates_root_resolution_error() {
1✔
1340
        let result = run_multi_source_demo(
1✔
1341
            std::iter::empty::<String>(),
1✔
1342
            |_| Err("demo root resolution failed".into()),
1✔
1343
            |_: &()| Vec::<DynSource>::new(),
×
1344
        );
1345

1346
        let err = result.unwrap_err().to_string();
1✔
1347
        assert!(err.contains("demo root resolution failed"));
1✔
1348
    }
1✔
1349

1350
    #[test]
1351
    fn print_helpers_and_extract_source_cover_paths() {
1✔
1352
        let split = SplitRatios::default();
1✔
1353
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
1354
        let strategy = ChunkingStrategy::default();
1✔
1355

1356
        let anchor = RecordChunk {
1✔
1357
            record_id: "source_a::rec1".to_string(),
1✔
1358
            section_idx: 0,
1✔
1359
            view: ChunkView::Window {
1✔
1360
                index: 1,
1✔
1361
                overlap: 2,
1✔
1362
                span: 12,
1✔
1363
                start_ratio: 0.25,
1✔
1364
            },
1✔
1365
            text: "anchor text".to_string(),
1✔
1366
            tokens_estimate: 8,
1✔
1367
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
1368
        };
1✔
1369
        let positive = RecordChunk {
1✔
1370
            record_id: "source_a::rec2".to_string(),
1✔
1371
            section_idx: 1,
1✔
1372
            view: ChunkView::SummaryFallback {
1✔
1373
                strategy: "summary".to_string(),
1✔
1374
                weight: 0.7,
1✔
1375
            },
1✔
1376
            text: "positive text".to_string(),
1✔
1377
            tokens_estimate: 6,
1✔
1378
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
1379
        };
1✔
1380
        let negative = RecordChunk {
1✔
1381
            record_id: "source_b::rec3".to_string(),
1✔
1382
            section_idx: 2,
1✔
1383
            view: ChunkView::Window {
1✔
1384
                index: 0,
1✔
1385
                overlap: 0,
1✔
1386
                span: 16,
1✔
1387
                start_ratio: 0.0,
1✔
1388
            },
1✔
1389
            text: "negative text".to_string(),
1✔
1390
            tokens_estimate: 7,
1✔
1391
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
1392
        };
1✔
1393

1394
        let triplet_batch = TripletBatch {
1✔
1395
            triplets: vec![crate::SampleTriplet {
1✔
1396
                recipe: "triplet_recipe".to_string(),
1✔
1397
                anchor: anchor.clone(),
1✔
1398
                positive: positive.clone(),
1✔
1399
                negative: negative.clone(),
1✔
1400
                weight: 1.0,
1✔
1401
                instruction: Some("triplet instruction".to_string()),
1✔
1402
            }],
1✔
1403
        };
1✔
1404
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
1405

1406
        let pair_batch = SampleBatch {
1✔
1407
            pairs: vec![crate::SamplePair {
1✔
1408
                recipe: "pair_recipe".to_string(),
1✔
1409
                anchor: anchor.clone(),
1✔
1410
                positive: positive.clone(),
1✔
1411
                weight: 1.0,
1✔
1412
                instruction: None,
1✔
1413
                label: crate::PairLabel::Positive,
1✔
1414
                reason: Some("same topic".to_string()),
1✔
1415
            }],
1✔
1416
        };
1✔
1417
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
1418

1419
        let text_batch = TextBatch {
1✔
1420
            samples: vec![crate::TextSample {
1✔
1421
                recipe: "text_recipe".to_string(),
1✔
1422
                chunk: negative,
1✔
1423
                weight: 0.8,
1✔
1424
                instruction: Some("text instruction".to_string()),
1✔
1425
            }],
1✔
1426
        };
1✔
1427
        print_text_batch(&strategy, &text_batch, &store);
1✔
1428

1429
        let recipes = vec![TextRecipe {
1✔
1430
            name: "recipe_name".into(),
1✔
1431
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
1432
            instruction: Some("instruction".into()),
1✔
1433
            weight: 1.0,
1✔
1434
        }];
1✔
1435
        print_text_recipes(&recipes);
1✔
1436

1437
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
1438
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
1439
    }
1✔
1440

1441
    #[test]
1442
    fn split_arg_conversion_and_version_parse_paths_are_covered() {
1✔
1443
        assert!(matches!(
1✔
1444
            SplitLabel::from(SplitArg::Train),
1✔
1445
            SplitLabel::Train
1446
        ));
1447
        assert!(matches!(
1✔
1448
            SplitLabel::from(SplitArg::Validation),
1✔
1449
            SplitLabel::Validation
1450
        ));
1451
        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
1✔
1452
    }
1✔
1453

1454
    #[test]
1455
    fn parse_split_ratios_reports_per_field_parse_errors() {
1✔
1456
        assert!(
1✔
1457
            parse_split_ratios_arg("x,0.1,0.9")
1✔
1458
                .unwrap_err()
1✔
1459
                .contains("invalid train ratio")
1✔
1460
        );
1461
        assert!(
1✔
1462
            parse_split_ratios_arg("0.1,y,0.8")
1✔
1463
                .unwrap_err()
1✔
1464
                .contains("invalid validation ratio")
1✔
1465
        );
1466
        assert!(
1✔
1467
            parse_split_ratios_arg("0.1,0.2,z")
1✔
1468
                .unwrap_err()
1✔
1469
                .contains("invalid test ratio")
1✔
1470
        );
1471
    }
1✔
1472

1473
    #[test]
1474
    fn run_multi_source_demo_exhausted_paths_are_handled() {
1✔
1475
        for mode in [
3✔
1476
            vec!["--pair-batch".to_string()],
1✔
1477
            vec!["--text-recipes".to_string()],
1✔
1478
            Vec::new(),
1✔
1479
        ] {
1✔
1480
            let dir = tempdir().unwrap();
3✔
1481
            let mut args = mode;
3✔
1482
            args.push("--split-store-dir".to_string());
3✔
1483
            args.push(dir.path().to_string_lossy().to_string());
3✔
1484

1485
            let result = run_multi_source_demo(
3✔
1486
                args.into_iter(),
3✔
1487
                |_| Ok(()),
3✔
1488
                |_| {
3✔
1489
                    vec![Box::new(TestSource {
3✔
1490
                        id: "source_without_recipes".into(),
3✔
1491
                        count: Some(1),
3✔
1492
                        recipes: Vec::new(),
3✔
1493
                    }) as DynSource]
3✔
1494
                },
3✔
1495
            );
1496

1497
            assert!(result.is_ok());
3✔
1498
        }
1499
    }
1✔
1500
}
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