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

jzombie / rust-triplets / 23521097611

25 Mar 2026 01:52AM UTC coverage: 94.141% (-0.7%) from 94.794%
23521097611

Pull #39

github

web-flow
Merge 59341a264 into 65addee9d
Pull Request #39: Prototype extended metrics reporting

106 of 263 new or added lines in 3 files covered. (40.3%)

2 existing lines in 1 file now uncovered.

20838 of 22135 relevant lines covered (94.14%)

99444.99 hits per line

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

83.52
/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
use std::time::Instant;
7

8
use cache_manager::CacheRoot;
9
use clap::{Parser, ValueEnum, error::ErrorKind};
10

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

28
type DynSource = Box<dyn DataSource + 'static>;
29

30
fn managed_demo_split_store_path() -> Result<PathBuf, String> {
×
31
    let cache_root = CacheRoot::from_discovery()
×
32
        .map_err(|err| format!("failed discovering managed cache root: {err}"))?;
×
33
    let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
×
34
    let dir = cache_root.ensure_group(&group).map_err(|err| {
×
35
        format!(
×
36
            "failed creating managed demo cache group '{}': {err}",
37
            group.display()
×
38
        )
39
    })?;
×
40
    Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
×
41
}
×
42

43
fn init_example_tracing() {
16✔
44
    static INIT: Once = Once::new();
45
    INIT.call_once(|| {
16✔
46
        let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
1✔
47
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("triplets=info"));
1✔
48
        let _ = tracing_subscriber::fmt()
1✔
49
            .with_env_filter(env_filter)
1✔
50
            .try_init();
1✔
51
    });
1✔
52
}
16✔
53

54
#[derive(Debug, Clone, Copy, ValueEnum)]
55
/// CLI split selector mapped onto `SplitLabel`.
56
enum SplitArg {
57
    Train,
58
    Validation,
59
    Test,
60
}
61

62
impl From<SplitArg> for SplitLabel {
63
    fn from(value: SplitArg) -> Self {
6✔
64
        match value {
6✔
65
            SplitArg::Train => SplitLabel::Train,
1✔
66
            SplitArg::Validation => SplitLabel::Validation,
4✔
67
            SplitArg::Test => SplitLabel::Test,
1✔
68
        }
69
    }
6✔
70
}
71

72
#[derive(Debug, Parser)]
73
#[command(
74
    name = "estimate_capacity",
75
    disable_help_subcommand = true,
76
    about = "Metadata-only capacity estimation",
77
    long_about = "Estimate record, pair, triplet, and text-sample capacity using source-reported counts only (no data refresh).",
78
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
79
)]
80
/// CLI arguments for metadata-only capacity estimation.
81
struct EstimateCapacityCli {
82
    #[arg(
83
        long,
84
        default_value_t = 99,
85
        help = "Deterministic seed used for split allocation"
86
    )]
87
    seed: u64,
88
    #[arg(
89
        long = "split-ratios",
90
        value_name = "TRAIN,VALIDATION,TEST",
91
        value_parser = parse_split_ratios_arg,
92
        default_value = "0.8,0.1,0.1",
93
        help = "Comma-separated split ratios that must sum to 1.0"
94
    )]
95
    split: SplitRatios,
96
    #[arg(
97
        long = "source-root",
98
        value_name = "PATH",
99
        help = "Optional source root override, repeat as needed in source order"
100
    )]
101
    source_roots: Vec<String>,
102
}
103

104
#[derive(Debug, Parser)]
105
#[command(
106
    name = "multi_source_demo",
107
    disable_help_subcommand = true,
108
    about = "Run sampled batches from multiple sources",
109
    long_about = "Sample triplet, pair, or text batches from multiple sources and persist split/epoch state.",
110
    after_help = "Source roots are optional and resolved in order by explicit arg, environment variables, then project defaults."
111
)]
112
/// CLI for `multi_source_demo`.
113
///
114
/// Common usage:
115
/// - Use managed cache-group default path (no flag)
116
/// - Set an explicit file path: `--split-store-path /tmp/split_store.bin`
117
/// - Repeat `--source-root <PATH>` to override source roots in order
118
struct MultiSourceDemoCli {
119
    #[arg(
120
        long = "text-recipes",
121
        help = "Emit a text batch instead of a triplet batch"
122
    )]
123
    show_text_samples: bool,
124
    #[arg(
125
        long = "pair-batch",
126
        help = "Emit a pair batch instead of a triplet batch"
127
    )]
128
    show_pair_samples: bool,
129
    #[arg(
130
        long = "list-text-recipes",
131
        help = "Print registered text recipes and exit"
132
    )]
133
    list_text_recipes: bool,
134
    #[arg(
135
        long = "batch-size",
136
        default_value_t = 4,
137
        value_parser = parse_positive_usize,
138
        help = "Batch size used for sampling"
139
    )]
140
    batch_size: usize,
141
    #[arg(long, help = "Optional deterministic seed override")]
142
    seed: Option<u64>,
143
    #[arg(long, value_enum, help = "Target split to sample from")]
144
    split: Option<SplitArg>,
145
    #[arg(
146
        long = "source-root",
147
        value_name = "PATH",
148
        help = "Optional source root override, repeat as needed in source order"
149
    )]
150
    source_roots: Vec<String>,
151
    #[arg(
152
        long = "split-store-path",
153
        value_name = "SPLIT_STORE_PATH",
154
        help = "Optional explicit path for persisted split/epoch state file"
155
    )]
156
    split_store_path: Option<PathBuf>,
157
    #[arg(
158
        long = "reset",
159
        help = "Delete the persisted split/epoch state before sampling, restarting from epoch 0"
160
    )]
161
    reset: bool,
162
    #[arg(
163
        long = "batches",
164
        value_name = "N",
165
        value_parser = parse_positive_usize,
166
        help = "Run N triplet batches in succession, printing a timing line per batch and (with --features extended-metrics) a per-source similarity summary at the end"
167
    )]
168
    batches: Option<usize>,
169
}
170

171
#[derive(Debug, Clone)]
172
/// Source-level inventory used by capacity estimation output.
173
struct SourceInventory {
174
    source_id: String,
175
    reported_records: u128,
176
    triplet_recipes: Vec<TripletRecipe>,
177
}
178

179
/// Run the capacity-estimation CLI with injectable root resolution/source builders.
180
///
181
/// `build_sources` is construction-only; sampler configuration is applied
182
/// centrally by this function before any source calls.
183
pub fn run_estimate_capacity<R, Resolve, Build, I>(
4✔
184
    args_iter: I,
4✔
185
    resolve_roots: Resolve,
4✔
186
    build_sources: Build,
4✔
187
) -> Result<(), Box<dyn Error>>
4✔
188
where
4✔
189
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
4✔
190
    Build: FnOnce(&R) -> Vec<DynSource>,
4✔
191
    I: Iterator<Item = String>,
4✔
192
{
193
    init_example_tracing();
4✔
194

195
    let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
4✔
196
        std::iter::once("estimate_capacity".to_string()).chain(args_iter),
4✔
197
    )?
×
198
    else {
199
        return Ok(());
×
200
    };
201

202
    let roots = resolve_roots(cli.source_roots)?;
4✔
203

204
    let config = SamplerConfig {
3✔
205
        seed: cli.seed,
3✔
206
        split: cli.split,
3✔
207
        ..SamplerConfig::default()
3✔
208
    };
3✔
209

210
    let sources = build_sources(&roots);
3✔
211

212
    let mut inventories = Vec::new();
3✔
213
    for source in &sources {
3✔
214
        let recipes = if config.recipes.is_empty() {
3✔
215
            source.default_triplet_recipes()
3✔
216
        } else {
217
            config.recipes.clone()
×
218
        };
219
        let reported_records = source.reported_record_count(&config).map_err(|err| {
3✔
220
            format!(
1✔
221
                "source '{}' failed to report exact record count: {err}",
222
                source.id()
1✔
223
            )
224
        })?;
1✔
225
        inventories.push(SourceInventory {
2✔
226
            source_id: source.id().to_string(),
2✔
227
            reported_records,
2✔
228
            triplet_recipes: recipes,
2✔
229
        });
2✔
230
    }
231

232
    let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
2✔
233
    let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
2✔
234

235
    for source in &inventories {
2✔
236
        let counts = split_counts_for_total(source.reported_records, cli.split);
2✔
237
        for (label, count) in counts {
6✔
238
            per_source_split_counts.insert((source.source_id.clone(), label), count);
6✔
239
            *split_record_counts.entry(label).or_insert(0) += count;
6✔
240
        }
6✔
241
    }
242

243
    let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
2✔
244
    let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
2✔
245
        HashMap::new();
2✔
246

247
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
248
        let mut totals = CapacityTotals::default();
6✔
249

250
        for source in &inventories {
6✔
251
            let source_split_records = per_source_split_counts
6✔
252
                .get(&(source.source_id.clone(), split_label))
6✔
253
                .copied()
6✔
254
                .unwrap_or(0);
6✔
255

6✔
256
            let triplet_recipes = &source.triplet_recipes;
6✔
257
            let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
6✔
258

6✔
259
            let capacity = estimate_source_split_capacity_from_counts(
6✔
260
                source_split_records,
6✔
261
                triplet_recipes,
6✔
262
                &text_recipes,
6✔
263
            );
6✔
264

6✔
265
            totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
6✔
266

6✔
267
            totals.triplets += capacity.triplets;
6✔
268
            totals.effective_triplets += capacity.effective_triplets;
6✔
269
            totals.pairs += capacity.pairs;
6✔
270
            totals.text_samples += capacity.text_samples;
6✔
271
        }
6✔
272

273
        totals_by_split.insert(split_label, totals);
6✔
274
    }
275

276
    let min_nonzero_records_by_split: HashMap<SplitLabel, u128> =
2✔
277
        [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
2✔
278
            .into_iter()
2✔
279
            .map(|split_label| {
6✔
280
                let min_nonzero = inventories
6✔
281
                    .iter()
6✔
282
                    .filter_map(|source| {
6✔
283
                        per_source_split_counts
6✔
284
                            .get(&(source.source_id.clone(), split_label))
6✔
285
                            .copied()
6✔
286
                    })
6✔
287
                    .filter(|&records| records > 0)
6✔
288
                    .min()
6✔
289
                    .unwrap_or(0);
6✔
290
                (split_label, min_nonzero)
6✔
291
            })
6✔
292
            .collect();
2✔
293

294
    let min_nonzero_records_all_splits = inventories
2✔
295
        .iter()
2✔
296
        .map(|source| source.reported_records)
2✔
297
        .filter(|&records| records > 0)
2✔
298
        .min()
2✔
299
        .unwrap_or(0);
2✔
300

301
    println!("=== capacity estimate (length-only) ===");
2✔
302
    println!("mode: metadata-only (no source.refresh calls)");
2✔
303
    println!("classification: heuristic approximation (not exact)");
2✔
304
    println!("split seed: {}", cli.seed);
2✔
305
    println!(
2✔
306
        "split ratios: train={:.4}, validation={:.4}, test={:.4}",
307
        cli.split.train, cli.split.validation, cli.split.test
308
    );
309
    println!();
2✔
310

311
    println!("[SOURCES]");
2✔
312
    for source in &inventories {
2✔
313
        println!(
2✔
314
            "  {} => reported records: {}",
2✔
315
            source.source_id,
2✔
316
            format_u128_with_commas(source.reported_records)
2✔
317
        );
2✔
318
    }
2✔
319
    println!();
2✔
320

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

427
    let mut grand = CapacityTotals::default();
2✔
428
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
6✔
429
        let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
6✔
430
        let totals = totals_by_split
6✔
431
            .get(&split_label)
6✔
432
            .copied()
6✔
433
            .unwrap_or_default();
6✔
434

6✔
435
        grand.triplets += totals.triplets;
6✔
436
        grand.effective_triplets += totals.effective_triplets;
6✔
437
        grand.pairs += totals.pairs;
6✔
438
        grand.text_samples += totals.text_samples;
6✔
439

6✔
440
        println!("[{:?}]", split_label);
6✔
441
        println!("  records: {}", format_u128_with_commas(record_count));
6✔
442
        println!(
6✔
443
            "  triplet combinations: {}",
6✔
444
            format_u128_with_commas(totals.triplets)
6✔
445
        );
6✔
446
        println!(
6✔
447
            "  effective sampled triplets (p={}, k={}): {}",
6✔
448
            EFFECTIVE_POSITIVES_PER_ANCHOR,
6✔
449
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
6✔
450
            format_u128_with_commas(totals.effective_triplets)
6✔
451
        );
6✔
452
        println!(
6✔
453
            "  pair combinations:    {}",
6✔
454
            format_u128_with_commas(totals.pairs)
6✔
455
        );
6✔
456
        println!(
6✔
457
            "  text samples:         {}",
6✔
458
            format_u128_with_commas(totals.text_samples)
6✔
459
        );
6✔
460
        println!();
6✔
461
    }
6✔
462

463
    println!("[ALL SPLITS TOTAL]");
2✔
464
    println!(
2✔
465
        "  triplet combinations: {}",
466
        format_u128_with_commas(grand.triplets)
2✔
467
    );
468
    println!(
2✔
469
        "  effective sampled triplets (p={}, k={}): {}",
470
        EFFECTIVE_POSITIVES_PER_ANCHOR,
471
        EFFECTIVE_NEGATIVES_PER_ANCHOR,
472
        format_u128_with_commas(grand.effective_triplets)
2✔
473
    );
474
    println!(
2✔
475
        "  pair combinations:    {}",
476
        format_u128_with_commas(grand.pairs)
2✔
477
    );
478
    println!(
2✔
479
        "  text samples:         {}",
480
        format_u128_with_commas(grand.text_samples)
2✔
481
    );
482
    println!();
2✔
483
    println!(
2✔
484
        "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."
485
    );
486
    println!();
2✔
487
    println!(
2✔
488
        "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.",
489
        EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
490
    );
491
    println!();
2✔
492
    println!(
2✔
493
        "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."
494
    );
495
    println!();
2✔
496
    println!(
2✔
497
        "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."
498
    );
499
    println!();
2✔
500
    println!(
2✔
501
        "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."
502
    );
503
    println!();
2✔
504
    println!(
2✔
505
        "When passed to next_*_batch_with_weights, higher weight means that source is sampled more often relative to lower-weight sources."
506
    );
507

508
    Ok(())
2✔
509
}
4✔
510

511
/// Run the multi-source demo CLI with injectable root resolution/source builders.
512
///
513
/// `build_sources` is construction-only. Source sampler configuration is owned
514
/// by sampler registration (`TripletSampler::register_source`).
515
pub fn run_multi_source_demo<R, Resolve, Build, I>(
12✔
516
    args_iter: I,
12✔
517
    resolve_roots: Resolve,
12✔
518
    build_sources: Build,
12✔
519
) -> Result<(), Box<dyn Error>>
12✔
520
where
12✔
521
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
12✔
522
    Build: FnOnce(&R) -> Vec<DynSource>,
12✔
523
    I: Iterator<Item = String>,
12✔
524
{
525
    init_example_tracing();
12✔
526

527
    let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
12✔
528
        std::iter::once("multi_source_demo".to_string()).chain(args_iter),
12✔
529
    )?
×
530
    else {
531
        return Ok(());
×
532
    };
533

534
    let roots = resolve_roots(cli.source_roots)?;
12✔
535

536
    let mut config = SamplerConfig::default();
11✔
537
    config.seed = cli.seed.unwrap_or(config.seed);
11✔
538
    config.batch_size = cli.batch_size;
11✔
539
    config.chunking = Default::default();
11✔
540
    let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
11✔
541
    config.split = SplitRatios::default();
11✔
542
    config.allowed_splits = vec![selected_split];
11✔
543
    let chunking = config.chunking.clone();
11✔
544
    let config_snapshot = MultiSourceDemoConfigSnapshot {
11✔
545
        seed: config.seed,
11✔
546
        batch_size: config.batch_size,
11✔
547
        ingestion_max_records: config.ingestion_max_records,
11✔
548
        split: selected_split,
11✔
549
        split_ratios: config.split,
11✔
550
        max_window_tokens: config.chunking.max_window_tokens,
11✔
551
        overlap_tokens: config.chunking.overlap_tokens.clone(),
11✔
552
        summary_fallback_tokens: config.chunking.summary_fallback_tokens,
11✔
553
    };
11✔
554

555
    let split_store_path = if let Some(path) = cli.split_store_path {
11✔
556
        path
11✔
557
    } else {
558
        managed_demo_split_store_path().map_err(|err| {
×
559
            Box::<dyn Error>::from(format!("failed to resolve demo split-store path: {err}"))
×
560
        })?
×
561
    };
562

563
    if cli.reset && split_store_path.exists() {
11✔
NEW
564
        std::fs::remove_file(&split_store_path).map_err(|err| {
×
NEW
565
            Box::<dyn Error>::from(format!(
×
NEW
566
                "failed to remove split store '{}': {err}",
×
NEW
567
                split_store_path.display()
×
NEW
568
            ))
×
NEW
569
        })?;
×
NEW
570
        println!("Reset: removed {}", split_store_path.display());
×
571
    }
11✔
572
    println!(
11✔
573
        "Persisting split assignments and epoch state to {}",
574
        split_store_path.display()
11✔
575
    );
576
    let sources = build_sources(&roots);
11✔
577
    let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
11✔
578
    let sampler = TripletSampler::new(config, split_store.clone());
11✔
579
    for source in sources {
11✔
580
        sampler.register_source(source);
11✔
581
    }
11✔
582

583
    if cli.show_pair_samples {
11✔
584
        match sampler.next_pair_batch(selected_split) {
3✔
585
            Ok(pair_batch) => {
×
586
                if pair_batch.pairs.is_empty() {
×
587
                    println!("Pair sampling produced no results.");
×
588
                } else {
×
589
                    print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
×
590
                }
×
591
                sampler.save_sampler_state(None)?;
×
592
            }
593
            Err(SamplerError::Exhausted(name)) => {
3✔
594
                eprintln!(
3✔
595
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
3✔
596
                    name
3✔
597
                );
3✔
598
            }
3✔
599
            Err(err) => return Err(err.into()),
×
600
        }
601
    } else if cli.show_text_samples {
8✔
602
        match sampler.next_text_batch(selected_split) {
3✔
603
            Ok(text_batch) => {
1✔
604
                if text_batch.samples.is_empty() {
1✔
605
                    println!(
×
606
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
607
                    );
×
608
                } else {
1✔
609
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
1✔
610
                }
1✔
611
                sampler.save_sampler_state(None)?;
1✔
612
            }
613
            Err(SamplerError::Exhausted(name)) => {
2✔
614
                eprintln!(
2✔
615
                    "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
2✔
616
                    name
2✔
617
                );
2✔
618
            }
2✔
619
            Err(err) => return Err(err.into()),
×
620
        }
621
    } else if cli.list_text_recipes {
5✔
622
        let recipes = sampler.text_recipes();
2✔
623
        if recipes.is_empty() {
2✔
624
            println!(
1✔
625
                "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
1✔
626
            );
1✔
627
        } else {
1✔
628
            print_text_recipes(&recipes);
1✔
629
        }
1✔
630
    } else if let Some(batch_count) = cli.batches {
3✔
NEW
631
        print_demo_config(&config_snapshot);
×
NEW
632
        println!("=== benchmark: {} triplet batches ===", batch_count);
×
633

634
        // source_id -> Vec<(pos_jaccard, pos_cosine, neg_jaccard, neg_cosine)>
635
        #[cfg(feature = "extended-metrics")]
NEW
636
        let mut source_metrics: HashMap<String, Vec<(f32, f32, f32, f32)>> = HashMap::new();
×
637

NEW
638
        for i in 0..batch_count {
×
NEW
639
            let t0 = Instant::now();
×
NEW
640
            match sampler.next_triplet_batch(selected_split) {
×
NEW
641
                Ok(batch) => {
×
NEW
642
                    let elapsed = t0.elapsed();
×
NEW
643
                    let n = batch.triplets.len();
×
NEW
644
                    println!(
×
645
                        "batch {:>4}  triplets={:<4}  elapsed={:>8.2}ms  per_triplet={:.2}ms",
NEW
646
                        i + 1,
×
647
                        n,
NEW
648
                        elapsed.as_secs_f64() * 1000.0,
×
NEW
649
                        if n > 0 {
×
NEW
650
                            elapsed.as_secs_f64() * 1000.0 / n as f64
×
651
                        } else {
NEW
652
                            0.0
×
653
                        },
654
                    );
655
                    #[cfg(feature = "extended-metrics")]
656
                    {
657
                        use crate::metrics::lexical_similarity_scores;
NEW
658
                        for triplet in &batch.triplets {
×
NEW
659
                            let (pj, pc) = lexical_similarity_scores(
×
NEW
660
                                &triplet.anchor.text,
×
NEW
661
                                &triplet.positive.text,
×
NEW
662
                            );
×
NEW
663
                            let (nj, nc) = lexical_similarity_scores(
×
NEW
664
                                &triplet.anchor.text,
×
NEW
665
                                &triplet.negative.text,
×
NEW
666
                            );
×
NEW
667
                            let source = extract_source(&triplet.anchor.record_id);
×
NEW
668
                            source_metrics
×
NEW
669
                                .entry(source)
×
NEW
670
                                .or_default()
×
NEW
671
                                .push((pj, pc, nj, nc));
×
NEW
672
                        }
×
673
                    }
674
                }
NEW
675
                Err(SamplerError::Exhausted(name)) => {
×
NEW
676
                    println!(
×
677
                        "batch {:>4}  exhausted recipe '{}' — stopping early",
NEW
678
                        i + 1,
×
679
                        name
680
                    );
NEW
681
                    break;
×
682
                }
NEW
683
                Err(err) => return Err(err.into()),
×
684
            }
685
        }
686

NEW
687
        sampler.save_sampler_state(None)?;
×
688

689
        #[cfg(feature = "extended-metrics")]
NEW
690
        if !source_metrics.is_empty() {
×
NEW
691
            println!();
×
NEW
692
            print_metric_summary(&source_metrics);
×
NEW
693
        }
×
694
    } else {
695
        match sampler.next_triplet_batch(selected_split) {
3✔
696
            Ok(triplet_batch) => {
×
697
                if triplet_batch.triplets.is_empty() {
×
698
                    println!(
×
699
                        "Triplet sampling produced no results. Ensure multiple records per source exist."
×
700
                    );
×
701
                } else {
×
702
                    print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
×
703
                }
×
704
                sampler.save_sampler_state(None)?;
×
705
            }
706
            Err(SamplerError::Exhausted(name)) => {
3✔
707
                eprintln!(
3✔
708
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
3✔
709
                    name
3✔
710
                );
3✔
711
            }
3✔
712
            Err(err) => return Err(err.into()),
×
713
        }
714
    }
715

716
    Ok(())
11✔
717
}
12✔
718

719
struct MultiSourceDemoConfigSnapshot {
720
    seed: u64,
721
    batch_size: usize,
722
    ingestion_max_records: usize,
723
    split: SplitLabel,
724
    split_ratios: SplitRatios,
725
    max_window_tokens: usize,
726
    overlap_tokens: Vec<usize>,
727
    summary_fallback_tokens: usize,
728
}
729

NEW
730
fn print_demo_config(cfg: &MultiSourceDemoConfigSnapshot) {
×
NEW
731
    let overlaps: Vec<String> = cfg.overlap_tokens.iter().map(|t| t.to_string()).collect();
×
NEW
732
    println!("=== sampler config ===");
×
NEW
733
    println!("seed                 : {}", cfg.seed);
×
NEW
734
    println!("batch_size           : {}", cfg.batch_size);
×
NEW
735
    println!("ingestion_max_records: {}", cfg.ingestion_max_records);
×
NEW
736
    println!("split                : {:?}", cfg.split);
×
NEW
737
    println!(
×
738
        "split_ratios         : train={:.2} val={:.2} test={:.2}",
739
        cfg.split_ratios.train, cfg.split_ratios.validation, cfg.split_ratios.test
740
    );
NEW
741
    println!("max_window_tokens    : {}", cfg.max_window_tokens);
×
NEW
742
    println!("overlap_tokens       : [{}]", overlaps.join(", "));
×
NEW
743
    println!(
×
744
        "summary_fallback     : {} tokens (0 = disabled)",
745
        cfg.summary_fallback_tokens
746
    );
NEW
747
    println!();
×
NEW
748
}
×
749

750
fn parse_positive_usize(raw: &str) -> Result<usize, String> {
17✔
751
    let parsed = raw.parse::<usize>().map_err(|_| {
17✔
752
        format!(
1✔
753
            "Could not parse --batch-size value '{}' as a positive integer",
754
            raw
755
        )
756
    })?;
1✔
757
    if parsed == 0 {
16✔
758
        return Err("--batch-size must be greater than zero".to_string());
2✔
759
    }
14✔
760
    Ok(parsed)
14✔
761
}
17✔
762

763
fn suggested_balancing_weight(max_baseline: u128, source_baseline: u128) -> f32 {
13✔
764
    if max_baseline == 0 || source_baseline == 0 {
13✔
765
        return 0.0;
4✔
766
    }
9✔
767
    (source_baseline as f64 / max_baseline as f64).clamp(0.0, 1.0) as f32
9✔
768
}
13✔
769

770
fn suggested_oversampling_weight(min_nonzero_baseline: u128, source_baseline: u128) -> f32 {
13✔
771
    if min_nonzero_baseline == 0 || source_baseline == 0 {
13✔
772
        return 0.0;
4✔
773
    }
9✔
774
    (min_nonzero_baseline as f64 / source_baseline as f64).clamp(0.0, 1.0) as f32
9✔
775
}
13✔
776

777
fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
22✔
778
where
22✔
779
    T: Parser,
22✔
780
    I: IntoIterator,
22✔
781
    I::Item: Into<std::ffi::OsString> + Clone,
22✔
782
{
783
    match T::try_parse_from(args) {
22✔
784
        Ok(cli) => Ok(Some(cli)),
17✔
785
        Err(err) => match err.kind() {
5✔
786
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
787
                err.print()?;
3✔
788
                Ok(None)
3✔
789
            }
790
            _ => Err(err.into()),
2✔
791
        },
792
    }
793
}
22✔
794

795
fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
11✔
796
    let parts: Vec<&str> = raw.split(',').collect();
11✔
797
    if parts.len() != 3 {
11✔
798
        return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
1✔
799
    }
10✔
800
    let train = parts[0]
10✔
801
        .trim()
10✔
802
        .parse::<f32>()
10✔
803
        .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
10✔
804
    let validation = parts[1].trim().parse::<f32>().map_err(|_| {
9✔
805
        format!(
1✔
806
            "invalid validation ratio '{}': must be a float",
807
            parts[1].trim()
1✔
808
        )
809
    })?;
1✔
810
    let test = parts[2]
8✔
811
        .trim()
8✔
812
        .parse::<f32>()
8✔
813
        .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
8✔
814
    let ratios = SplitRatios {
7✔
815
        train,
7✔
816
        validation,
7✔
817
        test,
7✔
818
    };
7✔
819
    let sum = ratios.train + ratios.validation + ratios.test;
7✔
820
    if (sum - 1.0).abs() > 1e-5 {
7✔
821
        return Err(format!(
1✔
822
            "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
1✔
823
            sum, ratios.train, ratios.validation, ratios.test
1✔
824
        ));
1✔
825
    }
6✔
826
    if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
6✔
827
        return Err("split ratios must be non-negative".to_string());
1✔
828
    }
5✔
829
    Ok(ratios)
5✔
830
}
11✔
831

832
fn print_triplet_batch(
1✔
833
    strategy: &ChunkingStrategy,
1✔
834
    batch: &TripletBatch,
1✔
835
    split_store: &impl SplitStore,
1✔
836
) {
1✔
837
    println!("=== triplet batch ===");
1✔
838
    for (idx, triplet) in batch.triplets.iter().enumerate() {
1✔
839
        println!("--- triplet #{} ---", idx);
1✔
840
        println!("recipe       : {}", triplet.recipe);
1✔
841
        println!("sample_weight: {:.4}", triplet.weight);
1✔
842
        if let Some(instr) = &triplet.instruction {
1✔
843
            println!("instruction shown to model:\n{}\n", instr);
1✔
844
        }
1✔
845
        #[cfg(feature = "extended-metrics")]
846
        let (pos_sim, neg_sim) = {
1✔
847
            use crate::metrics::lexical_similarity_scores;
848
            (
1✔
849
                Some(lexical_similarity_scores(
1✔
850
                    &triplet.anchor.text,
1✔
851
                    &triplet.positive.text,
1✔
852
                )),
1✔
853
                Some(lexical_similarity_scores(
1✔
854
                    &triplet.anchor.text,
1✔
855
                    &triplet.negative.text,
1✔
856
                )),
1✔
857
            )
1✔
858
        };
859
        #[cfg(not(feature = "extended-metrics"))]
860
        let (pos_sim, neg_sim): (Option<(f32, f32)>, Option<(f32, f32)>) = (None, None);
861
        print_chunk_block("ANCHOR", &triplet.anchor, strategy, split_store, None);
1✔
862
        print_chunk_block(
1✔
863
            "POSITIVE",
1✔
864
            &triplet.positive,
1✔
865
            strategy,
1✔
866
            split_store,
1✔
867
            pos_sim,
1✔
868
        );
869
        print_chunk_block(
1✔
870
            "NEGATIVE",
1✔
871
            &triplet.negative,
1✔
872
            strategy,
1✔
873
            split_store,
1✔
874
            neg_sim,
1✔
875
        );
876
    }
877
    print_source_summary(
1✔
878
        "triplet anchors",
1✔
879
        batch
1✔
880
            .triplets
1✔
881
            .iter()
1✔
882
            .map(|triplet| triplet.anchor.record_id.as_str()),
1✔
883
    );
884
    print_recipe_context_by_source(
1✔
885
        "triplet recipes by source",
1✔
886
        batch
1✔
887
            .triplets
1✔
888
            .iter()
1✔
889
            .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
1✔
890
    );
891
}
1✔
892

893
fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
2✔
894
    println!("=== text batch ===");
2✔
895
    for (idx, sample) in batch.samples.iter().enumerate() {
5✔
896
        println!("--- sample #{} ---", idx);
5✔
897
        println!("recipe       : {}", sample.recipe);
5✔
898
        println!("sample_weight: {:.4}", sample.weight);
5✔
899
        if let Some(instr) = &sample.instruction {
5✔
900
            println!("instruction shown to model:\n{}\n", instr);
1✔
901
        }
4✔
902
        print_chunk_block("TEXT", &sample.chunk, strategy, split_store, None);
5✔
903
    }
904
    print_source_summary(
2✔
905
        "text samples",
2✔
906
        batch
2✔
907
            .samples
2✔
908
            .iter()
2✔
909
            .map(|sample| sample.chunk.record_id.as_str()),
5✔
910
    );
911
    print_recipe_context_by_source(
2✔
912
        "text recipes by source",
2✔
913
        batch
2✔
914
            .samples
2✔
915
            .iter()
2✔
916
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
5✔
917
    );
918
}
2✔
919

920
fn print_pair_batch(
1✔
921
    strategy: &ChunkingStrategy,
1✔
922
    batch: &SampleBatch,
1✔
923
    split_store: &impl SplitStore,
1✔
924
) {
1✔
925
    println!("=== pair batch ===");
1✔
926
    for (idx, pair) in batch.pairs.iter().enumerate() {
1✔
927
        println!("--- pair #{} ---", idx);
1✔
928
        println!("recipe       : {}", pair.recipe);
1✔
929
        println!("label        : {:?}", pair.label);
1✔
930
        if let Some(reason) = &pair.reason {
1✔
931
            println!("reason       : {}", reason);
1✔
932
        }
1✔
933
        print_chunk_block("ANCHOR", &pair.anchor, strategy, split_store, None);
1✔
934
        print_chunk_block("OTHER", &pair.positive, strategy, split_store, None);
1✔
935
    }
936
    print_source_summary(
1✔
937
        "pair anchors",
1✔
938
        batch
1✔
939
            .pairs
1✔
940
            .iter()
1✔
941
            .map(|pair| pair.anchor.record_id.as_str()),
1✔
942
    );
943
    print_recipe_context_by_source(
1✔
944
        "pair recipes by source",
1✔
945
        batch
1✔
946
            .pairs
1✔
947
            .iter()
1✔
948
            .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
1✔
949
    );
950
}
1✔
951

952
fn print_text_recipes(recipes: &[TextRecipe]) {
2✔
953
    println!("=== available text recipes ===");
2✔
954
    for recipe in recipes {
4✔
955
        println!(
4✔
956
            "- {} (weight: {:.3}) selector={:?}",
957
            recipe.name, recipe.weight, recipe.selector
958
        );
959
        if let Some(instr) = &recipe.instruction {
4✔
960
            println!("  instruction: {}", instr);
1✔
961
        }
3✔
962
    }
963
}
2✔
964

965
#[cfg(feature = "extended-metrics")]
NEW
966
fn metric_mean_median(vals: &mut Vec<f32>) -> (f32, f32) {
×
NEW
967
    let mean = vals.iter().sum::<f32>() / vals.len() as f32;
×
NEW
968
    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
×
NEW
969
    let median = if vals.len() % 2 == 1 {
×
NEW
970
        vals[vals.len() / 2]
×
971
    } else {
NEW
972
        (vals[vals.len() / 2 - 1] + vals[vals.len() / 2]) / 2.0
×
973
    };
NEW
974
    (mean, median)
×
NEW
975
}
×
976

977
#[cfg(feature = "extended-metrics")]
NEW
978
fn print_metric_summary(source_data: &HashMap<String, Vec<(f32, f32, f32, f32)>>) {
×
NEW
979
    let total: usize = source_data.values().map(|v| v.len()).sum();
×
NEW
980
    let n_sources = source_data.len();
×
NEW
981
    println!(
×
982
        "=== extended metrics summary ({} triplets, {} {}) ===",
983
        total,
984
        n_sources,
NEW
985
        if n_sources == 1 { "source" } else { "sources" }
×
986
    );
987

988
    // Returns [pos, neg] as (mean, median) pairs for one metric across entries.
NEW
989
    fn metric_pair(
×
NEW
990
        entries: &[(f32, f32, f32, f32)],
×
NEW
991
        pos_idx: usize,
×
NEW
992
        neg_idx: usize,
×
NEW
993
    ) -> [(f32, f32); 2] {
×
NEW
994
        let extract = |idx: usize| -> Vec<f32> {
×
NEW
995
            entries
×
NEW
996
                .iter()
×
NEW
997
                .map(|e| match idx {
×
NEW
998
                    0 => e.0,
×
NEW
999
                    1 => e.1,
×
NEW
1000
                    2 => e.2,
×
NEW
1001
                    _ => e.3,
×
NEW
1002
                })
×
NEW
1003
                .collect()
×
NEW
1004
        };
×
NEW
1005
        let mut pos_vals = extract(pos_idx);
×
NEW
1006
        let mut neg_vals = extract(neg_idx);
×
NEW
1007
        [
×
NEW
1008
            metric_mean_median(&mut pos_vals),
×
NEW
1009
            metric_mean_median(&mut neg_vals),
×
NEW
1010
        ]
×
NEW
1011
    }
×
1012

NEW
1013
    fn print_metric_section(
×
NEW
1014
        label: &str,
×
NEW
1015
        sources: &[&String],
×
NEW
1016
        source_data: &HashMap<String, Vec<(f32, f32, f32, f32)>>,
×
NEW
1017
        pos_idx: usize,
×
NEW
1018
        neg_idx: usize,
×
NEW
1019
        total: usize,
×
NEW
1020
        n_sources: usize,
×
NEW
1021
    ) {
×
1022
        const SEP: usize = 83;
NEW
1023
        println!();
×
NEW
1024
        println!("[{}]", label);
×
NEW
1025
        println!(
×
1026
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1027
            "source", "n", "positive", "negative", "gap (pos\u{2212}neg)"
1028
        );
NEW
1029
        println!(
×
1030
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1031
            "", "", "mean / median", "mean / median", "mean / median"
1032
        );
NEW
1033
        println!("{}", "-".repeat(SEP));
×
NEW
1034
        for source in sources {
×
NEW
1035
            let entries = &source_data[*source];
×
NEW
1036
            let [pos, neg] = metric_pair(entries, pos_idx, neg_idx);
×
NEW
1037
            let gap_mean = pos.0 - neg.0;
×
NEW
1038
            let gap_med = pos.1 - neg.1;
×
NEW
1039
            println!(
×
NEW
1040
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
×
NEW
1041
                source,
×
NEW
1042
                entries.len(),
×
NEW
1043
                pos.0,
×
NEW
1044
                pos.1,
×
NEW
1045
                neg.0,
×
NEW
1046
                neg.1,
×
NEW
1047
                gap_mean,
×
NEW
1048
                gap_med,
×
NEW
1049
            );
×
NEW
1050
        }
×
NEW
1051
        if n_sources > 1 {
×
NEW
1052
            let all: Vec<(f32, f32, f32, f32)> = source_data.values().flatten().copied().collect();
×
NEW
1053
            let [pos, neg] = metric_pair(&all, pos_idx, neg_idx);
×
NEW
1054
            let gap_mean = pos.0 - neg.0;
×
NEW
1055
            let gap_med = pos.1 - neg.1;
×
NEW
1056
            println!("{}", "-".repeat(SEP));
×
NEW
1057
            println!(
×
NEW
1058
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
×
NEW
1059
                "ALL", total, pos.0, pos.1, neg.0, neg.1, gap_mean, gap_med,
×
NEW
1060
            );
×
NEW
1061
        }
×
NEW
1062
    }
×
1063

NEW
1064
    let mut sources: Vec<&String> = source_data.keys().collect();
×
NEW
1065
    sources.sort();
×
1066

NEW
1067
    print_metric_section(
×
NEW
1068
        "jaccard \u{2194} anchor",
×
NEW
1069
        &sources,
×
NEW
1070
        source_data,
×
1071
        0,
1072
        2,
NEW
1073
        total,
×
NEW
1074
        n_sources,
×
1075
    );
NEW
1076
    print_metric_section(
×
NEW
1077
        "cosine  \u{2194} anchor",
×
NEW
1078
        &sources,
×
NEW
1079
        source_data,
×
1080
        1,
1081
        3,
NEW
1082
        total,
×
NEW
1083
        n_sources,
×
1084
    );
NEW
1085
    println!();
×
NEW
1086
}
×
1087

1088
trait ChunkDebug {
1089
    fn view_name(&self) -> String;
1090
}
1091

1092
impl ChunkDebug for RecordChunk {
1093
    fn view_name(&self) -> String {
10✔
1094
        match &self.view {
10✔
1095
            ChunkView::Window {
1096
                index,
8✔
1097
                span,
8✔
1098
                overlap,
8✔
1099
                start_ratio,
8✔
1100
            } => format!(
8✔
1101
                "window#index={} span={} overlap={} start_ratio={:.3} tokens={}",
1102
                index, span, overlap, start_ratio, self.tokens_estimate
1103
            ),
1104
            ChunkView::SummaryFallback { strategy, .. } => {
2✔
1105
                format!("summary:{} tokens={}", strategy, self.tokens_estimate)
2✔
1106
            }
1107
        }
1108
    }
10✔
1109
}
1110

1111
fn print_chunk_block(
10✔
1112
    title: &str,
10✔
1113
    chunk: &RecordChunk,
10✔
1114
    strategy: &ChunkingStrategy,
10✔
1115
    split_store: &impl SplitStore,
10✔
1116
    anchor_sim: Option<(f32, f32)>,
10✔
1117
) {
10✔
1118
    let chunk_weight = chunk_weight(strategy, chunk);
10✔
1119
    let split = split_store
10✔
1120
        .label_for(&chunk.record_id)
10✔
1121
        .map(|label| format!("{:?}", label))
10✔
1122
        .unwrap_or_else(|| "Unknown".to_string());
10✔
1123
    println!("--- {} ---", title);
10✔
1124
    println!("split        : {}", split);
10✔
1125
    println!("view         : {}", chunk.view_name());
10✔
1126
    println!("chunk_weight : {:.4}", chunk_weight);
10✔
1127
    println!("record_id    : {}", chunk.record_id);
10✔
1128
    println!("section_idx  : {}", chunk.section_idx);
10✔
1129
    println!("token_est    : {}", chunk.tokens_estimate);
10✔
1130
    if let Some((j, c)) = anchor_sim {
10✔
1131
        println!("jaccard(↔a)  : {:.4}  cosine(↔a)  : {:.4}", j, c);
2✔
1132
    }
8✔
1133
    println!("model_input (exact text sent to the model):");
10✔
1134
    println!(
10✔
1135
        "<<< BEGIN MODEL TEXT >>>\n{}\n<<< END MODEL TEXT >>>\n",
1136
        chunk.text
1137
    );
1138
}
10✔
1139

1140
fn print_source_summary<'a, I>(label: &str, ids: I)
4✔
1141
where
4✔
1142
    I: Iterator<Item = &'a str>,
4✔
1143
{
1144
    let mut counts: HashMap<SourceId, usize> = HashMap::new();
4✔
1145
    for id in ids {
7✔
1146
        let source = extract_source(id);
7✔
1147
        *counts.entry(source).or_insert(0) += 1;
7✔
1148
    }
7✔
1149
    if counts.is_empty() {
4✔
1150
        return;
×
1151
    }
4✔
1152
    let skew = source_skew(&counts);
4✔
1153
    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
4✔
1154
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4✔
1155
    println!("--- {} by source ---", label);
4✔
1156
    if let Some(skew) = skew {
4✔
1157
        for entry in &skew.per_source {
4✔
1158
            println!(
4✔
1159
                "{}: count={} share={:.2}",
4✔
1160
                entry.source, entry.count, entry.share
4✔
1161
            );
4✔
1162
        }
4✔
1163
        println!(
4✔
1164
            "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
1165
            skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
1166
        );
1167
    } else {
1168
        for (source, count) in &entries {
×
1169
            println!("{source}: count={count}");
×
1170
        }
×
1171
    }
1172
}
4✔
1173

1174
fn print_recipe_context_by_source<'a, I>(label: &str, entries: I)
4✔
1175
where
4✔
1176
    I: Iterator<Item = (&'a str, &'a str)>,
4✔
1177
{
1178
    let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
4✔
1179
    for (record_id, recipe) in entries {
7✔
1180
        let source = extract_source(record_id);
7✔
1181
        let entry = counts
7✔
1182
            .entry(source)
7✔
1183
            .or_default()
7✔
1184
            .entry(recipe.to_string())
7✔
1185
            .or_insert(0);
7✔
1186
        *entry += 1;
7✔
1187
    }
7✔
1188
    if counts.is_empty() {
4✔
1189
        return;
×
1190
    }
4✔
1191
    let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
4✔
1192
    sources.sort_by(|a, b| a.0.cmp(&b.0));
4✔
1193
    println!("--- {} ---", label);
4✔
1194
    for (source, recipes) in sources {
4✔
1195
        println!("{source}");
4✔
1196
        let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
4✔
1197
        entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4✔
1198
        for (recipe, count) in entries {
5✔
1199
            println!("  - {recipe}={count}");
5✔
1200
        }
5✔
1201
    }
1202
}
4✔
1203

1204
fn extract_source(record_id: &str) -> SourceId {
16✔
1205
    record_id
16✔
1206
        .split_once("::")
16✔
1207
        .map(|(source, _)| source.to_string())
16✔
1208
        .unwrap_or_else(|| "unknown".to_string())
16✔
1209
}
16✔
1210

1211
#[cfg(test)]
1212
mod tests {
1213
    use super::*;
1214
    use crate::DataRecord;
1215
    use crate::DeterministicSplitStore;
1216
    use crate::data::{QualityScore, RecordSection, SectionRole};
1217
    use crate::source::{SourceCursor, SourceSnapshot};
1218
    use chrono::Utc;
1219
    use tempfile::tempdir;
1220

1221
    /// Minimal in-memory `DataSource` test double for example app tests.
1222
    struct TestSource {
1223
        id: String,
1224
        count: Option<u128>,
1225
        recipes: Vec<TripletRecipe>,
1226
    }
1227

1228
    impl DataSource for TestSource {
1229
        fn id(&self) -> &str {
130✔
1230
            &self.id
130✔
1231
        }
130✔
1232

1233
        fn refresh(
30✔
1234
            &self,
30✔
1235
            _config: &SamplerConfig,
30✔
1236
            _cursor: Option<&SourceCursor>,
30✔
1237
            _limit: Option<usize>,
30✔
1238
        ) -> Result<SourceSnapshot, SamplerError> {
30✔
1239
            Ok(SourceSnapshot {
30✔
1240
                records: Vec::new(),
30✔
1241
                cursor: SourceCursor {
30✔
1242
                    last_seen: Utc::now(),
30✔
1243
                    revision: 0,
30✔
1244
                },
30✔
1245
            })
30✔
1246
        }
30✔
1247

1248
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1249
            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
2✔
1250
                source_id: self.id.clone(),
1✔
1251
                details: "test source has no configured exact count".to_string(),
1✔
1252
            })
1✔
1253
        }
2✔
1254

1255
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
10✔
1256
            self.recipes.clone()
10✔
1257
        }
10✔
1258
    }
1259

1260
    struct ConfigRequiredSource {
1261
        id: String,
1262
        expected_seed: u64,
1263
    }
1264

1265
    impl DataSource for ConfigRequiredSource {
1266
        fn id(&self) -> &str {
1✔
1267
            &self.id
1✔
1268
        }
1✔
1269

1270
        fn refresh(
1✔
1271
            &self,
1✔
1272
            _config: &SamplerConfig,
1✔
1273
            _cursor: Option<&SourceCursor>,
1✔
1274
            _limit: Option<usize>,
1✔
1275
        ) -> Result<SourceSnapshot, SamplerError> {
1✔
1276
            Ok(SourceSnapshot {
1✔
1277
                records: Vec::new(),
1✔
1278
                cursor: SourceCursor {
1✔
1279
                    last_seen: Utc::now(),
1✔
1280
                    revision: 0,
1✔
1281
                },
1✔
1282
            })
1✔
1283
        }
1✔
1284

1285
        fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1286
            if config.seed == self.expected_seed {
2✔
1287
                Ok(1)
1✔
1288
            } else {
1289
                Err(SamplerError::SourceInconsistent {
1✔
1290
                    source_id: self.id.clone(),
1✔
1291
                    details: format!(
1✔
1292
                        "expected sampler seed {} but got {}",
1✔
1293
                        self.expected_seed, config.seed
1✔
1294
                    ),
1✔
1295
                })
1✔
1296
            }
1297
        }
2✔
1298

1299
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
1300
            Vec::new()
2✔
1301
        }
2✔
1302
    }
1303

1304
    fn default_recipe(name: &str) -> TripletRecipe {
9✔
1305
        TripletRecipe {
9✔
1306
            name: name.to_string().into(),
9✔
1307
            anchor: crate::config::Selector::Role(SectionRole::Anchor),
9✔
1308
            positive_selector: crate::config::Selector::Role(SectionRole::Context),
9✔
1309
            negative_selector: crate::config::Selector::Role(SectionRole::Context),
9✔
1310
            negative_strategy: crate::config::NegativeStrategy::WrongArticle,
9✔
1311
            weight: 1.0,
9✔
1312
            instruction: None,
9✔
1313
            allow_same_anchor_positive: false,
9✔
1314
        }
9✔
1315
    }
9✔
1316

1317
    #[test]
1318
    fn parse_helpers_validate_inputs() {
1✔
1319
        assert_eq!(parse_positive_usize("2").unwrap(), 2);
1✔
1320
        assert!(parse_positive_usize("0").is_err());
1✔
1321
        assert!(parse_positive_usize("abc").is_err());
1✔
1322

1323
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
1324
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
1325
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
1326
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
1327
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
1328
    }
1✔
1329

1330
    #[test]
1331
    fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1✔
1332
        assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1333
        assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1✔
1334
        assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1✔
1335
        assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1✔
1336
        assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1✔
1337
    }
1✔
1338

1339
    #[test]
1340
    fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1✔
1341
        assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1342
        assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1✔
1343
        assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1✔
1344
        assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1✔
1345
        assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1✔
1346
    }
1✔
1347

1348
    #[test]
1349
    fn parse_cli_handles_help_and_invalid_args() {
1✔
1350
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
1351
        assert!(help.is_none());
1✔
1352

1353
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
1354
        assert!(err.is_err());
1✔
1355
    }
1✔
1356

1357
    #[test]
1358
    fn run_estimate_capacity_succeeds_with_reported_counts() {
1✔
1359
        let result = run_estimate_capacity(
1✔
1360
            std::iter::empty::<String>(),
1✔
1361
            |roots| {
1✔
1362
                assert!(roots.is_empty());
1✔
1363
                Ok(())
1✔
1364
            },
1✔
1365
            |_| {
1✔
1366
                vec![Box::new(TestSource {
1✔
1367
                    id: "source_a".into(),
1✔
1368
                    count: Some(12),
1✔
1369
                    recipes: vec![default_recipe("r1")],
1✔
1370
                }) as DynSource]
1✔
1371
            },
1✔
1372
        );
1373

1374
        assert!(result.is_ok());
1✔
1375
    }
1✔
1376

1377
    #[test]
1378
    fn run_estimate_capacity_errors_when_source_count_missing() {
1✔
1379
        let result = run_estimate_capacity(
1✔
1380
            std::iter::empty::<String>(),
1✔
1381
            |_| Ok(()),
1✔
1382
            |_| {
1✔
1383
                vec![Box::new(TestSource {
1✔
1384
                    id: "source_missing".into(),
1✔
1385
                    count: None,
1✔
1386
                    recipes: vec![default_recipe("r1")],
1✔
1387
                }) as DynSource]
1✔
1388
            },
1✔
1389
        );
1390

1391
        let err = result.unwrap_err().to_string();
1✔
1392
        assert!(err.contains("failed to report exact record count"));
1✔
1393
    }
1✔
1394

1395
    #[test]
1396
    fn run_estimate_capacity_propagates_root_resolution_error() {
1✔
1397
        let result = run_estimate_capacity(
1✔
1398
            std::iter::empty::<String>(),
1✔
1399
            |_| Err("root resolution failed".into()),
1✔
1400
            |_: &()| Vec::<DynSource>::new(),
×
1401
        );
1402

1403
        let err = result.unwrap_err().to_string();
1✔
1404
        assert!(err.contains("root resolution failed"));
1✔
1405
    }
1✔
1406

1407
    #[test]
1408
    fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1✔
1409
        let result = run_estimate_capacity(
1✔
1410
            std::iter::empty::<String>(),
1✔
1411
            |_| Ok(()),
1✔
1412
            |_| {
1✔
1413
                vec![Box::new(ConfigRequiredSource {
1✔
1414
                    id: "requires_config".into(),
1✔
1415
                    expected_seed: 99,
1✔
1416
                }) as DynSource]
1✔
1417
            },
1✔
1418
        );
1419

1420
        assert!(result.is_ok());
1✔
1421
    }
1✔
1422

1423
    #[test]
1424
    fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1✔
1425
        let source = ConfigRequiredSource {
1✔
1426
            id: "cfg-source".to_string(),
1✔
1427
            expected_seed: 42,
1✔
1428
        };
1✔
1429

1430
        let refreshed = source
1✔
1431
            .refresh(&SamplerConfig::default(), None, None)
1✔
1432
            .unwrap();
1✔
1433
        assert!(refreshed.records.is_empty());
1✔
1434

1435
        let mismatched = source.reported_record_count(&SamplerConfig {
1✔
1436
            seed: 7,
1✔
1437
            ..SamplerConfig::default()
1✔
1438
        });
1✔
1439
        assert!(matches!(
1✔
1440
            mismatched,
1✔
1441
            Err(SamplerError::SourceInconsistent { .. })
1442
        ));
1443

1444
        assert!(source.default_triplet_recipes().is_empty());
1✔
1445
    }
1✔
1446

1447
    #[test]
1448
    fn run_multi_source_demo_exhausted_paths_return_ok() {
1✔
1449
        struct OneRecordSource;
1450

1451
        impl DataSource for OneRecordSource {
1452
            fn id(&self) -> &str {
48✔
1453
                "one_record"
48✔
1454
            }
48✔
1455

1456
            fn refresh(
11✔
1457
                &self,
11✔
1458
                _config: &SamplerConfig,
11✔
1459
                _cursor: Option<&SourceCursor>,
11✔
1460
                _limit: Option<usize>,
11✔
1461
            ) -> Result<SourceSnapshot, SamplerError> {
11✔
1462
                let now = Utc::now();
11✔
1463
                Ok(SourceSnapshot {
11✔
1464
                    records: vec![DataRecord {
11✔
1465
                        id: "one_record::r1".to_string(),
11✔
1466
                        source: "one_record".to_string(),
11✔
1467
                        created_at: now,
11✔
1468
                        updated_at: now,
11✔
1469
                        quality: QualityScore { trust: 1.0 },
11✔
1470
                        taxonomy: Vec::new(),
11✔
1471
                        sections: vec![
11✔
1472
                            RecordSection {
11✔
1473
                                role: SectionRole::Anchor,
11✔
1474
                                heading: Some("title".to_string()),
11✔
1475
                                text: "anchor".to_string(),
11✔
1476
                                sentences: vec!["anchor".to_string()],
11✔
1477
                            },
11✔
1478
                            RecordSection {
11✔
1479
                                role: SectionRole::Context,
11✔
1480
                                heading: Some("body".to_string()),
11✔
1481
                                text: "context".to_string(),
11✔
1482
                                sentences: vec!["context".to_string()],
11✔
1483
                            },
11✔
1484
                        ],
11✔
1485
                        meta_prefix: None,
11✔
1486
                    }],
11✔
1487
                    cursor: SourceCursor {
11✔
1488
                        last_seen: now,
11✔
1489
                        revision: 0,
11✔
1490
                    },
11✔
1491
                })
11✔
1492
            }
11✔
1493

1494
            fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
×
1495
                Ok(1)
×
1496
            }
×
1497

1498
            fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
3✔
1499
                vec![default_recipe("single_record_recipe")]
3✔
1500
            }
3✔
1501
        }
1502

1503
        for mode in ["--pair-batch", "--text-recipes", ""] {
3✔
1504
            let dir = tempdir().unwrap();
3✔
1505
            let split_store_path = dir.path().join("split_store.bin");
3✔
1506
            let mut args = vec![
3✔
1507
                "--split-store-path".to_string(),
3✔
1508
                split_store_path.to_string_lossy().to_string(),
3✔
1509
            ];
1510
            if !mode.is_empty() {
3✔
1511
                args.push(mode.to_string());
2✔
1512
            }
2✔
1513

1514
            let result = run_multi_source_demo(
3✔
1515
                args.into_iter(),
3✔
1516
                |_| Ok(()),
3✔
1517
                |_| vec![Box::new(OneRecordSource) as DynSource],
3✔
1518
            );
1519
            assert!(result.is_ok());
3✔
1520
        }
1521
    }
1✔
1522

1523
    #[test]
1524
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
1525
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
1526
        assert!(help.is_none());
1✔
1527

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

1531
        let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1✔
1532
        assert!(parsed.is_ok());
1✔
1533
    }
1✔
1534

1535
    #[test]
1536
    fn parse_cli_handles_display_version_path() {
1✔
1537
        #[derive(Debug, Parser)]
1538
        #[command(name = "version_test", version = "1.0.0")]
1539
        struct VersionCli {}
1540

1541
        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
1✔
1542
        assert!(parsed.is_none());
1✔
1543
    }
1✔
1544

1545
    #[test]
1546
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
1547
        let dir = tempdir().unwrap();
1✔
1548
        let split_store_path = dir.path().join("recipes_split_store.bin");
1✔
1549
        let mut args = vec![
1✔
1550
            "--list-text-recipes".to_string(),
1✔
1551
            "--split-store-path".to_string(),
1✔
1552
            split_store_path.to_string_lossy().to_string(),
1✔
1553
        ];
1554
        let result = run_multi_source_demo(
1✔
1555
            args.drain(..),
1✔
1556
            |_| Ok(()),
1✔
1557
            |_| {
1✔
1558
                vec![Box::new(TestSource {
1✔
1559
                    id: "source_for_recipes".into(),
1✔
1560
                    count: Some(10),
1✔
1561
                    recipes: vec![default_recipe("recipe_a")],
1✔
1562
                }) as DynSource]
1✔
1563
            },
1✔
1564
        );
1565

1566
        assert!(result.is_ok());
1✔
1567
    }
1✔
1568

1569
    #[test]
1570
    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
1✔
1571
        let dir = tempdir().unwrap();
1✔
1572
        let split_store_path = dir.path().join("custom_split_store.bin");
1✔
1573
        let args = vec![
1✔
1574
            "--list-text-recipes".to_string(),
1✔
1575
            "--split-store-path".to_string(),
1✔
1576
            split_store_path.to_string_lossy().to_string(),
1✔
1577
        ];
1578

1579
        let result = run_multi_source_demo(
1✔
1580
            args.into_iter(),
1✔
1581
            |_| Ok(()),
1✔
1582
            |_| {
1✔
1583
                vec![Box::new(TestSource {
1✔
1584
                    id: "source_without_text_recipes".into(),
1✔
1585
                    count: Some(1),
1✔
1586
                    recipes: Vec::new(),
1✔
1587
                }) as DynSource]
1✔
1588
            },
1✔
1589
        );
1590

1591
        assert!(result.is_ok());
1✔
1592
    }
1✔
1593

1594
    #[test]
1595
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
1596
        for mode in [
3✔
1597
            vec!["--pair-batch".to_string()],
1✔
1598
            vec!["--text-recipes".to_string()],
1✔
1599
            vec![],
1✔
1600
        ] {
1✔
1601
            let dir = tempdir().unwrap();
3✔
1602
            let split_store_path = dir.path().join("empty_sources_split_store.bin");
3✔
1603
            let mut args = mode;
3✔
1604
            args.push("--split-store-path".to_string());
3✔
1605
            args.push(split_store_path.to_string_lossy().to_string());
3✔
1606
            args.push("--split".to_string());
3✔
1607
            args.push("validation".to_string());
3✔
1608

1609
            let result = run_multi_source_demo(
3✔
1610
                args.into_iter(),
3✔
1611
                |_| Ok(()),
3✔
1612
                |_| {
3✔
1613
                    vec![Box::new(TestSource {
3✔
1614
                        id: "source_empty".into(),
3✔
1615
                        count: Some(0),
3✔
1616
                        recipes: vec![default_recipe("recipe_empty")],
3✔
1617
                    }) as DynSource]
3✔
1618
                },
3✔
1619
            );
1620

1621
            assert!(result.is_ok());
3✔
1622
        }
1623
    }
1✔
1624

1625
    #[test]
1626
    fn run_multi_source_demo_propagates_root_resolution_error() {
1✔
1627
        let dir = tempdir().unwrap();
1✔
1628
        let split_store_path = dir.path().join("root_resolution_error_store.bin");
1✔
1629
        let result = run_multi_source_demo(
1✔
1630
            [
1✔
1631
                "--split-store-path".to_string(),
1✔
1632
                split_store_path.to_string_lossy().to_string(),
1✔
1633
            ]
1✔
1634
            .into_iter(),
1✔
1635
            |_| Err("demo root resolution failed".into()),
1✔
1636
            |_: &()| Vec::<DynSource>::new(),
×
1637
        );
1638

1639
        let err = result.unwrap_err().to_string();
1✔
1640
        assert!(err.contains("demo root resolution failed"));
1✔
1641
    }
1✔
1642

1643
    #[test]
1644
    fn print_helpers_and_extract_source_cover_paths() {
1✔
1645
        let split = SplitRatios::default();
1✔
1646
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
1647
        let strategy = ChunkingStrategy::default();
1✔
1648

1649
        let anchor = RecordChunk {
1✔
1650
            record_id: "source_a::rec1".to_string(),
1✔
1651
            section_idx: 0,
1✔
1652
            view: ChunkView::Window {
1✔
1653
                index: 1,
1✔
1654
                overlap: 2,
1✔
1655
                span: 12,
1✔
1656
                start_ratio: 0.25,
1✔
1657
            },
1✔
1658
            text: "anchor text".to_string(),
1✔
1659
            tokens_estimate: 8,
1✔
1660
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
1661
        };
1✔
1662
        let positive = RecordChunk {
1✔
1663
            record_id: "source_a::rec2".to_string(),
1✔
1664
            section_idx: 1,
1✔
1665
            view: ChunkView::SummaryFallback {
1✔
1666
                strategy: "summary".to_string(),
1✔
1667
                weight: 0.7,
1✔
1668
            },
1✔
1669
            text: "positive text".to_string(),
1✔
1670
            tokens_estimate: 6,
1✔
1671
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
1672
        };
1✔
1673
        let negative = RecordChunk {
1✔
1674
            record_id: "source_b::rec3".to_string(),
1✔
1675
            section_idx: 2,
1✔
1676
            view: ChunkView::Window {
1✔
1677
                index: 0,
1✔
1678
                overlap: 0,
1✔
1679
                span: 16,
1✔
1680
                start_ratio: 0.0,
1✔
1681
            },
1✔
1682
            text: "negative text".to_string(),
1✔
1683
            tokens_estimate: 7,
1✔
1684
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
1685
        };
1✔
1686

1687
        let triplet_batch = TripletBatch {
1✔
1688
            triplets: vec![crate::SampleTriplet {
1✔
1689
                recipe: "triplet_recipe".to_string(),
1✔
1690
                anchor: anchor.clone(),
1✔
1691
                positive: positive.clone(),
1✔
1692
                negative: negative.clone(),
1✔
1693
                weight: 1.0,
1✔
1694
                instruction: Some("triplet instruction".to_string()),
1✔
1695
            }],
1✔
1696
        };
1✔
1697
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
1698

1699
        let pair_batch = SampleBatch {
1✔
1700
            pairs: vec![crate::SamplePair {
1✔
1701
                recipe: "pair_recipe".to_string(),
1✔
1702
                anchor: anchor.clone(),
1✔
1703
                positive: positive.clone(),
1✔
1704
                weight: 1.0,
1✔
1705
                instruction: None,
1✔
1706
                label: crate::PairLabel::Positive,
1✔
1707
                reason: Some("same topic".to_string()),
1✔
1708
            }],
1✔
1709
        };
1✔
1710
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
1711

1712
        let text_batch = TextBatch {
1✔
1713
            samples: vec![crate::TextSample {
1✔
1714
                recipe: "text_recipe".to_string(),
1✔
1715
                chunk: negative,
1✔
1716
                weight: 0.8,
1✔
1717
                instruction: Some("text instruction".to_string()),
1✔
1718
            }],
1✔
1719
        };
1✔
1720
        print_text_batch(&strategy, &text_batch, &store);
1✔
1721

1722
        let recipes = vec![TextRecipe {
1✔
1723
            name: "recipe_name".into(),
1✔
1724
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
1725
            instruction: Some("instruction".into()),
1✔
1726
            weight: 1.0,
1✔
1727
        }];
1✔
1728
        print_text_recipes(&recipes);
1✔
1729

1730
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
1731
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
1732
    }
1✔
1733

1734
    #[test]
1735
    fn split_arg_conversion_and_version_parse_paths_are_covered() {
1✔
1736
        assert!(matches!(
1✔
1737
            SplitLabel::from(SplitArg::Train),
1✔
1738
            SplitLabel::Train
1739
        ));
1740
        assert!(matches!(
1✔
1741
            SplitLabel::from(SplitArg::Validation),
1✔
1742
            SplitLabel::Validation
1743
        ));
1744
        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
1✔
1745
    }
1✔
1746

1747
    #[test]
1748
    fn parse_split_ratios_reports_per_field_parse_errors() {
1✔
1749
        assert!(
1✔
1750
            parse_split_ratios_arg("x,0.1,0.9")
1✔
1751
                .unwrap_err()
1✔
1752
                .contains("invalid train ratio")
1✔
1753
        );
1754
        assert!(
1✔
1755
            parse_split_ratios_arg("0.1,y,0.8")
1✔
1756
                .unwrap_err()
1✔
1757
                .contains("invalid validation ratio")
1✔
1758
        );
1759
        assert!(
1✔
1760
            parse_split_ratios_arg("0.1,0.2,z")
1✔
1761
                .unwrap_err()
1✔
1762
                .contains("invalid test ratio")
1✔
1763
        );
1764
    }
1✔
1765

1766
    #[test]
1767
    fn run_multi_source_demo_exhausted_paths_are_handled() {
1✔
1768
        for mode in [
3✔
1769
            vec!["--pair-batch".to_string()],
1✔
1770
            vec!["--text-recipes".to_string()],
1✔
1771
            Vec::new(),
1✔
1772
        ] {
1✔
1773
            let dir = tempdir().unwrap();
3✔
1774
            let split_store_path = dir.path().join("exhausted_split_store.bin");
3✔
1775
            let mut args = mode;
3✔
1776
            args.push("--split-store-path".to_string());
3✔
1777
            args.push(split_store_path.to_string_lossy().to_string());
3✔
1778

1779
            let result = run_multi_source_demo(
3✔
1780
                args.into_iter(),
3✔
1781
                |_| Ok(()),
3✔
1782
                |_| {
3✔
1783
                    vec![Box::new(TestSource {
3✔
1784
                        id: "source_without_recipes".into(),
3✔
1785
                        count: Some(1),
3✔
1786
                        recipes: Vec::new(),
3✔
1787
                    }) as DynSource]
3✔
1788
                },
3✔
1789
            );
1790

1791
            assert!(result.is_ok());
3✔
1792
        }
1793
    }
1✔
1794
}
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