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

jzombie / rust-triplets / 23622602544

26 Mar 2026 11:11PM UTC coverage: 95.162% (+0.04%) from 95.12%
23622602544

push

github

web-flow
Inner chunk proximity relevance (#41)

* Use proximity-based a/p scoring; negatives purely based on trust

* Add cache flow diagram

211 of 222 new or added lines in 5 files covered. (95.05%)

15991 of 16804 relevant lines covered (95.16%)

131350.43 hits per line

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

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

3
use std::collections::HashMap;
4
use std::error::Error;
5
use std::path::PathBuf;
6
use std::sync::Arc;
7
use std::sync::Once;
8
use std::time::Instant;
9

10
use cache_manager::CacheRoot;
11
use clap::{Parser, ValueEnum, error::ErrorKind};
12

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

30
type DynSource = Box<dyn DataSource + 'static>;
31

32
#[cfg(feature = "extended-metrics")]
33
type MetricEntry = (f32, f32, f32, f32, f32);
34

35
#[cfg(feature = "extended-metrics")]
36
type SourceMetricsMap = HashMap<String, Vec<MetricEntry>>;
37

38
fn managed_demo_split_store_path() -> Result<PathBuf, String> {
2✔
39
    let cache_root = CacheRoot::from_discovery()
2✔
40
        .map_err(|err| format!("failed discovering managed cache root: {err}"))?;
2✔
41
    let group = PathBuf::from(MULTI_SOURCE_DEMO_GROUP);
2✔
42
    let dir = cache_root.ensure_group(&group).map_err(|err| {
2✔
43
        format!(
×
44
            "failed creating managed demo cache group '{}': {err}",
45
            group.display()
×
46
        )
47
    })?;
×
48
    Ok(dir.join(MULTI_SOURCE_DEMO_STORE_FILENAME))
2✔
49
}
2✔
50

51
fn init_example_tracing() {
35✔
52
    static INIT: Once = Once::new();
53
    INIT.call_once(|| {
35✔
54
        let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
1✔
55
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("triplets=info"));
1✔
56
        let _ = tracing_subscriber::fmt()
1✔
57
            .with_env_filter(env_filter)
1✔
58
            .try_init();
1✔
59
    });
1✔
60
}
35✔
61

62
#[derive(Debug, Clone, Copy, ValueEnum)]
63
/// CLI split selector mapped onto `SplitLabel`.
64
enum SplitArg {
65
    Train,
66
    Validation,
67
    Test,
68
}
69

70
impl From<SplitArg> for SplitLabel {
71
    fn from(value: SplitArg) -> Self {
6✔
72
        match value {
6✔
73
            SplitArg::Train => SplitLabel::Train,
1✔
74
            SplitArg::Validation => SplitLabel::Validation,
4✔
75
            SplitArg::Test => SplitLabel::Test,
1✔
76
        }
77
    }
6✔
78
}
79

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

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

186
#[derive(Debug, Clone)]
187
/// Source-level inventory used by capacity estimation output.
188
struct SourceInventory {
189
    source_id: String,
190
    reported_records: u128,
191
    triplet_recipes: Vec<TripletRecipe>,
192
}
193

194
/// Run the capacity-estimation CLI with injectable root resolution/source builders.
195
///
196
/// `build_sources` is construction-only; sampler configuration is applied
197
/// centrally by this function before any source calls.
198
pub fn run_estimate_capacity<R, Resolve, Build, I>(
7✔
199
    args_iter: I,
7✔
200
    resolve_roots: Resolve,
7✔
201
    build_sources: Build,
7✔
202
) -> Result<(), Box<dyn Error>>
7✔
203
where
7✔
204
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
7✔
205
    Build: FnOnce(&R) -> Vec<DynSource>,
7✔
206
    I: Iterator<Item = String>,
7✔
207
{
208
    init_example_tracing();
7✔
209

210
    let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
7✔
211
        std::iter::once("estimate_capacity".to_string()).chain(args_iter),
7✔
212
    )?
1✔
213
    else {
214
        return Ok(());
1✔
215
    };
216

217
    let roots = resolve_roots(cli.source_roots)?;
5✔
218

219
    let config = SamplerConfig {
4✔
220
        seed: cli.seed,
4✔
221
        split: cli.split,
4✔
222
        ..SamplerConfig::default()
4✔
223
    };
4✔
224

225
    let sources = build_sources(&roots);
4✔
226

227
    let mut inventories = Vec::new();
4✔
228
    for source in &sources {
4✔
229
        let recipes = if config.recipes.is_empty() {
3✔
230
            source.default_triplet_recipes()
3✔
231
        } else {
232
            config.recipes.clone()
×
233
        };
234
        let reported_records = source.reported_record_count(&config).map_err(|err| {
3✔
235
            format!(
1✔
236
                "source '{}' failed to report exact record count: {err}",
237
                source.id()
1✔
238
            )
239
        })?;
1✔
240
        inventories.push(SourceInventory {
2✔
241
            source_id: source.id().to_string(),
2✔
242
            reported_records,
2✔
243
            triplet_recipes: recipes,
2✔
244
        });
2✔
245
    }
246

247
    let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
3✔
248
    let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
3✔
249

250
    for source in &inventories {
3✔
251
        let counts = split_counts_for_total(source.reported_records, cli.split);
2✔
252
        for (label, count) in counts {
6✔
253
            per_source_split_counts.insert((source.source_id.clone(), label), count);
6✔
254
            *split_record_counts.entry(label).or_insert(0) += count;
6✔
255
        }
6✔
256
    }
257

258
    let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
3✔
259
    let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
3✔
260
        HashMap::new();
3✔
261

262
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
9✔
263
        let mut totals = CapacityTotals::default();
9✔
264

265
        for source in &inventories {
9✔
266
            let source_split_records = per_source_split_counts
6✔
267
                .get(&(source.source_id.clone(), split_label))
6✔
268
                .copied()
6✔
269
                .unwrap_or(0);
6✔
270

6✔
271
            let triplet_recipes = &source.triplet_recipes;
6✔
272
            let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
6✔
273

6✔
274
            let capacity = estimate_source_split_capacity_from_counts(
6✔
275
                source_split_records,
6✔
276
                triplet_recipes,
6✔
277
                &text_recipes,
6✔
278
            );
6✔
279

6✔
280
            totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
6✔
281

6✔
282
            totals.triplets += capacity.triplets;
6✔
283
            totals.effective_triplets += capacity.effective_triplets;
6✔
284
            totals.pairs += capacity.pairs;
6✔
285
            totals.text_samples += capacity.text_samples;
6✔
286
        }
6✔
287

288
        totals_by_split.insert(split_label, totals);
9✔
289
    }
290

291
    let min_nonzero_records_by_split: HashMap<SplitLabel, u128> =
3✔
292
        [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
3✔
293
            .into_iter()
3✔
294
            .map(|split_label| {
9✔
295
                let min_nonzero = inventories
9✔
296
                    .iter()
9✔
297
                    .filter_map(|source| {
9✔
298
                        per_source_split_counts
6✔
299
                            .get(&(source.source_id.clone(), split_label))
6✔
300
                            .copied()
6✔
301
                    })
6✔
302
                    .filter(|&records| records > 0)
9✔
303
                    .min()
9✔
304
                    .unwrap_or(0);
9✔
305
                (split_label, min_nonzero)
9✔
306
            })
9✔
307
            .collect();
3✔
308

309
    let min_nonzero_records_all_splits = inventories
3✔
310
        .iter()
3✔
311
        .map(|source| source.reported_records)
3✔
312
        .filter(|&records| records > 0)
3✔
313
        .min()
3✔
314
        .unwrap_or(0);
3✔
315

316
    println!("=== capacity estimate (length-only) ===");
3✔
317
    println!("mode: metadata-only (no source.refresh calls)");
3✔
318
    println!("classification: heuristic approximation (not exact)");
3✔
319
    println!("split seed: {}", cli.seed);
3✔
320
    println!(
3✔
321
        "split ratios: train={:.4}, validation={:.4}, test={:.4}",
322
        cli.split.train, cli.split.validation, cli.split.test
323
    );
324
    println!();
3✔
325

326
    println!("[SOURCES]");
3✔
327
    for source in &inventories {
3✔
328
        println!(
2✔
329
            "  {} => reported records: {}",
2✔
330
            source.source_id,
2✔
331
            format_u128_with_commas(source.reported_records)
2✔
332
        );
2✔
333
    }
2✔
334
    println!();
3✔
335

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

442
    let mut grand = CapacityTotals::default();
3✔
443
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
9✔
444
        let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
9✔
445
        let totals = totals_by_split
9✔
446
            .get(&split_label)
9✔
447
            .copied()
9✔
448
            .unwrap_or_default();
9✔
449

9✔
450
        grand.triplets += totals.triplets;
9✔
451
        grand.effective_triplets += totals.effective_triplets;
9✔
452
        grand.pairs += totals.pairs;
9✔
453
        grand.text_samples += totals.text_samples;
9✔
454

9✔
455
        println!("[{:?}]", split_label);
9✔
456
        println!("  records: {}", format_u128_with_commas(record_count));
9✔
457
        println!(
9✔
458
            "  triplet combinations: {}",
9✔
459
            format_u128_with_commas(totals.triplets)
9✔
460
        );
9✔
461
        println!(
9✔
462
            "  effective sampled triplets (p={}, k={}): {}",
9✔
463
            EFFECTIVE_POSITIVES_PER_ANCHOR,
9✔
464
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
9✔
465
            format_u128_with_commas(totals.effective_triplets)
9✔
466
        );
9✔
467
        println!(
9✔
468
            "  pair combinations:    {}",
9✔
469
            format_u128_with_commas(totals.pairs)
9✔
470
        );
9✔
471
        println!(
9✔
472
            "  text samples:         {}",
9✔
473
            format_u128_with_commas(totals.text_samples)
9✔
474
        );
9✔
475
        println!();
9✔
476
    }
9✔
477

478
    println!("[ALL SPLITS TOTAL]");
3✔
479
    println!(
3✔
480
        "  triplet combinations: {}",
481
        format_u128_with_commas(grand.triplets)
3✔
482
    );
483
    println!(
3✔
484
        "  effective sampled triplets (p={}, k={}): {}",
485
        EFFECTIVE_POSITIVES_PER_ANCHOR,
486
        EFFECTIVE_NEGATIVES_PER_ANCHOR,
487
        format_u128_with_commas(grand.effective_triplets)
3✔
488
    );
489
    println!(
3✔
490
        "  pair combinations:    {}",
491
        format_u128_with_commas(grand.pairs)
3✔
492
    );
493
    println!(
3✔
494
        "  text samples:         {}",
495
        format_u128_with_commas(grand.text_samples)
3✔
496
    );
497
    println!();
3✔
498
    println!(
3✔
499
        "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."
500
    );
501
    println!();
3✔
502
    println!(
3✔
503
        "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.",
504
        EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
505
    );
506
    println!();
3✔
507
    println!(
3✔
508
        "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."
509
    );
510
    println!();
3✔
511
    println!(
3✔
512
        "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."
513
    );
514
    println!();
3✔
515
    println!(
3✔
516
        "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."
517
    );
518
    println!();
3✔
519
    println!(
3✔
520
        "When passed to next_*_batch_with_weights, higher weight means that source is sampled more often relative to lower-weight sources."
521
    );
522

523
    Ok(())
3✔
524
}
7✔
525

526
/// Run the multi-source demo CLI with injectable root resolution/source builders.
527
///
528
/// `build_sources` is construction-only. Source sampler configuration is owned
529
/// by sampler registration (`TripletSampler::register_source`).
530
pub fn run_multi_source_demo<R, Resolve, Build, I>(
28✔
531
    args_iter: I,
28✔
532
    resolve_roots: Resolve,
28✔
533
    build_sources: Build,
28✔
534
) -> Result<(), Box<dyn Error>>
28✔
535
where
28✔
536
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
28✔
537
    Build: FnOnce(&R) -> Vec<DynSource>,
28✔
538
    I: Iterator<Item = String>,
28✔
539
{
540
    init_example_tracing();
28✔
541

542
    let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
28✔
543
        std::iter::once("multi_source_demo".to_string()).chain(args_iter),
28✔
544
    )?
1✔
545
    else {
546
        return Ok(());
1✔
547
    };
548

549
    let roots = resolve_roots(cli.source_roots)?;
26✔
550

551
    let mut config = SamplerConfig::default();
24✔
552
    config.seed = cli.seed.unwrap_or(config.seed);
24✔
553
    config.batch_size = cli.batch_size;
24✔
554
    config.ingestion_max_records = cli.ingestion_max_records;
24✔
555
    config.chunking = Default::default();
24✔
556
    let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
24✔
557
    config.split = SplitRatios::default();
24✔
558
    config.allowed_splits = vec![selected_split];
24✔
559
    let chunking = config.chunking.clone();
24✔
560
    let config_snapshot = MultiSourceDemoConfigSnapshot {
24✔
561
        seed: config.seed,
24✔
562
        batch_size: config.batch_size,
24✔
563
        ingestion_max_records: config.ingestion_max_records,
24✔
564
        split: selected_split,
24✔
565
        split_ratios: config.split,
24✔
566
        max_window_tokens: config.chunking.max_window_tokens,
24✔
567
        overlap_tokens: config.chunking.overlap_tokens.clone(),
24✔
568
        summary_fallback_tokens: config.chunking.summary_fallback_tokens,
24✔
569
    };
24✔
570

571
    let split_store_path = if let Some(path) = cli.split_store_path {
24✔
572
        path
23✔
573
    } else {
574
        managed_demo_split_store_path().map_err(|err| {
1✔
575
            Box::<dyn Error>::from(format!("failed to resolve demo split-store path: {err}"))
×
576
        })?
×
577
    };
578

579
    if cli.reset && split_store_path.exists() {
24✔
580
        std::fs::remove_file(&split_store_path).map_err(|err| {
2✔
581
            Box::<dyn Error>::from(format!(
1✔
582
                "failed to remove split store '{}': {err}",
1✔
583
                split_store_path.display()
1✔
584
            ))
1✔
585
        })?;
1✔
586
        println!("Reset: removed {}", split_store_path.display());
1✔
587
    }
22✔
588
    println!(
23✔
589
        "Persisting split assignments and epoch state to {}",
590
        split_store_path.display()
23✔
591
    );
592
    let sources = build_sources(&roots);
23✔
593
    let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
23✔
594
    let sampler = TripletSampler::new(config, split_store.clone());
23✔
595
    for source in sources {
23✔
596
        sampler.register_source(source);
22✔
597
    }
22✔
598

599
    if cli.show_pair_samples {
23✔
600
        match sampler.next_pair_batch(selected_split) {
7✔
601
            Ok(pair_batch) => {
2✔
602
                if pair_batch.pairs.is_empty() {
2✔
603
                    println!("Pair sampling produced no results.");
×
604
                } else {
2✔
605
                    print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
2✔
606
                }
2✔
607
                sampler.save_sampler_state(None)?;
2✔
608
            }
609
            Err(SamplerError::Exhausted(name)) => {
5✔
610
                eprintln!(
5✔
611
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
5✔
612
                    name
5✔
613
                );
5✔
614
            }
5✔
615
            Err(err) => return Err(err.into()),
×
616
        }
617
    } else if cli.show_text_samples {
16✔
618
        match sampler.next_text_batch(selected_split) {
4✔
619
            Ok(text_batch) => {
1✔
620
                if text_batch.samples.is_empty() {
1✔
621
                    println!(
×
622
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
623
                    );
×
624
                } else {
1✔
625
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
1✔
626
                }
1✔
627
                sampler.save_sampler_state(None)?;
1✔
628
            }
629
            Err(SamplerError::Exhausted(name)) => {
3✔
630
                eprintln!(
3✔
631
                    "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
3✔
632
                    name
3✔
633
                );
3✔
634
            }
3✔
635
            Err(err) => return Err(err.into()),
×
636
        }
637
    } else if cli.list_text_recipes {
12✔
638
        let recipes = sampler.text_recipes();
4✔
639
        if recipes.is_empty() {
4✔
640
            println!(
2✔
641
                "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
2✔
642
            );
2✔
643
        } else {
2✔
644
            print_text_recipes(&recipes);
2✔
645
        }
2✔
646
    } else if let Some(batch_count) = cli.batches {
8✔
647
        print_demo_config(&config_snapshot);
3✔
648
        println!("=== benchmark: {} triplet batches ===", batch_count);
3✔
649

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

654
        for i in 0..batch_count {
4✔
655
            let t0 = Instant::now();
4✔
656
            match sampler.next_triplet_batch(selected_split) {
4✔
657
                Ok(batch) => {
2✔
658
                    let elapsed = t0.elapsed();
2✔
659
                    let n = batch.triplets.len();
2✔
660
                    println!(
2✔
661
                        "batch {:>4}  triplets={:<4}  elapsed={:>8.2}ms  per_triplet={:.2}ms",
662
                        i + 1,
2✔
663
                        n,
664
                        elapsed.as_secs_f64() * 1000.0,
2✔
665
                        if n > 0 {
2✔
666
                            elapsed.as_secs_f64() * 1000.0 / n as f64
2✔
667
                        } else {
668
                            0.0
×
669
                        },
670
                    );
671
                    #[cfg(feature = "extended-metrics")]
672
                    {
673
                        use crate::metrics::lexical_similarity_scores;
674
                        for triplet in &batch.triplets {
8✔
675
                            let (pj, pc) = lexical_similarity_scores(
8✔
676
                                &triplet.anchor.text,
8✔
677
                                &triplet.positive.text,
8✔
678
                            );
8✔
679
                            let (nj, nc) = lexical_similarity_scores(
8✔
680
                                &triplet.anchor.text,
8✔
681
                                &triplet.negative.text,
8✔
682
                            );
8✔
683
                            let proximity =
8✔
684
                                chunk_proximity_score(&triplet.anchor, &triplet.positive);
8✔
685
                            let source = extract_source(&triplet.anchor.record_id);
8✔
686
                            source_metrics
8✔
687
                                .entry(source)
8✔
688
                                .or_default()
8✔
689
                                .push((pj, pc, nj, nc, proximity));
8✔
690
                        }
8✔
691
                    }
692
                }
693
                Err(SamplerError::Exhausted(name)) => {
2✔
694
                    println!(
2✔
695
                        "batch {:>4}  exhausted recipe '{}' — stopping early",
696
                        i + 1,
2✔
697
                        name
698
                    );
699
                    break;
2✔
700
                }
701
                Err(err) => return Err(err.into()),
×
702
            }
703
        }
704

705
        sampler.save_sampler_state(None)?;
3✔
706

707
        #[cfg(feature = "extended-metrics")]
708
        if !source_metrics.is_empty() {
3✔
709
            println!();
1✔
710
            print_metric_summary(&source_metrics);
1✔
711
        }
2✔
712

713
        #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
714
        {
715
            let (fallback, total) = sampler.bm25_fallback_stats();
3✔
716
            if total > 0 {
3✔
717
                let pct = fallback as f64 / total as f64 * 100.0;
1✔
718
                println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
1✔
719
            }
2✔
720
        }
721
    } else {
722
        match sampler.next_triplet_batch(selected_split) {
5✔
723
            Ok(triplet_batch) => {
1✔
724
                if triplet_batch.triplets.is_empty() {
1✔
725
                    println!(
×
726
                        "Triplet sampling produced no results. Ensure multiple records per source exist."
×
727
                    );
×
728
                } else {
1✔
729
                    print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
1✔
730
                }
1✔
731
                sampler.save_sampler_state(None)?;
1✔
732
                #[cfg(all(feature = "extended-metrics", feature = "bm25-mining"))]
733
                {
734
                    let (fallback, total) = sampler.bm25_fallback_stats();
1✔
735
                    if total > 0 {
1✔
736
                        let pct = fallback as f64 / total as f64 * 100.0;
1✔
737
                        println!("bm25 fallback rate : {}/{} ({:.1}%)", fallback, total, pct);
1✔
738
                    }
1✔
739
                }
740
            }
741
            Err(SamplerError::Exhausted(name)) => {
4✔
742
                eprintln!(
4✔
743
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
4✔
744
                    name
4✔
745
                );
4✔
746
            }
4✔
747
            Err(err) => return Err(err.into()),
×
748
        }
749
    }
750

751
    Ok(())
23✔
752
}
28✔
753

754
struct MultiSourceDemoConfigSnapshot {
755
    seed: u64,
756
    batch_size: usize,
757
    ingestion_max_records: usize,
758
    split: SplitLabel,
759
    split_ratios: SplitRatios,
760
    max_window_tokens: usize,
761
    overlap_tokens: Vec<usize>,
762
    summary_fallback_tokens: usize,
763
}
764

765
fn print_demo_config(cfg: &MultiSourceDemoConfigSnapshot) {
3✔
766
    let overlaps: Vec<String> = cfg.overlap_tokens.iter().map(|t| t.to_string()).collect();
3✔
767
    println!("=== sampler config ===");
3✔
768
    println!("seed                 : {}", cfg.seed);
3✔
769
    println!("batch_size           : {}", cfg.batch_size);
3✔
770
    println!("ingestion_max_records: {}", cfg.ingestion_max_records);
3✔
771
    println!("split                : {:?}", cfg.split);
3✔
772
    println!(
3✔
773
        "split_ratios         : train={:.2} val={:.2} test={:.2}",
774
        cfg.split_ratios.train, cfg.split_ratios.validation, cfg.split_ratios.test
775
    );
776
    println!("max_window_tokens    : {}", cfg.max_window_tokens);
3✔
777
    println!("overlap_tokens       : [{}]", overlaps.join(", "));
3✔
778
    println!(
3✔
779
        "summary_fallback     : {} tokens (0 = disabled)",
780
        cfg.summary_fallback_tokens
781
    );
782
    println!();
3✔
783
}
3✔
784

785
fn default_ingestion_max_records() -> usize {
1✔
786
    SamplerConfig::default().ingestion_max_records
1✔
787
}
1✔
788

789
fn parse_positive_usize_flag(raw: &str, flag: &str) -> Result<usize, String> {
65✔
790
    let parsed = raw.parse::<usize>().map_err(|_| {
65✔
791
        format!(
1✔
792
            "Could not parse {} value '{}' as a positive integer",
793
            flag, raw
794
        )
795
    })?;
1✔
796
    if parsed == 0 {
64✔
797
        return Err(format!("{} must be greater than zero", flag));
5✔
798
    }
59✔
799
    Ok(parsed)
59✔
800
}
65✔
801

802
fn parse_batch_size(raw: &str) -> Result<usize, String> {
31✔
803
    parse_positive_usize_flag(raw, "--batch-size")
31✔
804
}
31✔
805

806
fn parse_ingestion_max_records(raw: &str) -> Result<usize, String> {
30✔
807
    parse_positive_usize_flag(raw, "--ingestion-max-records")
30✔
808
}
30✔
809

810
fn parse_batch_count(raw: &str) -> Result<usize, String> {
4✔
811
    parse_positive_usize_flag(raw, "--batches")
4✔
812
}
4✔
813

814
fn suggested_balancing_weight(max_baseline: u128, source_baseline: u128) -> f32 {
13✔
815
    if max_baseline == 0 || source_baseline == 0 {
13✔
816
        return 0.0;
4✔
817
    }
9✔
818
    (source_baseline as f64 / max_baseline as f64).clamp(0.0, 1.0) as f32
9✔
819
}
13✔
820

821
fn suggested_oversampling_weight(min_nonzero_baseline: u128, source_baseline: u128) -> f32 {
13✔
822
    if min_nonzero_baseline == 0 || source_baseline == 0 {
13✔
823
        return 0.0;
4✔
824
    }
9✔
825
    (min_nonzero_baseline as f64 / source_baseline as f64).clamp(0.0, 1.0) as f32
9✔
826
}
13✔
827

828
fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
42✔
829
where
42✔
830
    T: Parser,
42✔
831
    I: IntoIterator,
42✔
832
    I::Item: Into<std::ffi::OsString> + Clone,
42✔
833
{
834
    match T::try_parse_from(args) {
42✔
835
        Ok(cli) => Ok(Some(cli)),
32✔
836
        Err(err) => match err.kind() {
10✔
837
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
838
                err.print()?;
5✔
839
                Ok(None)
5✔
840
            }
841
            _ => Err(err.into()),
5✔
842
        },
843
    }
844
}
42✔
845

846
fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
12✔
847
    let parts: Vec<&str> = raw.split(',').collect();
12✔
848
    if parts.len() != 3 {
12✔
849
        return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
1✔
850
    }
11✔
851
    let train = parts[0]
11✔
852
        .trim()
11✔
853
        .parse::<f32>()
11✔
854
        .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
11✔
855
    let validation = parts[1].trim().parse::<f32>().map_err(|_| {
10✔
856
        format!(
1✔
857
            "invalid validation ratio '{}': must be a float",
858
            parts[1].trim()
1✔
859
        )
860
    })?;
1✔
861
    let test = parts[2]
9✔
862
        .trim()
9✔
863
        .parse::<f32>()
9✔
864
        .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
9✔
865
    let ratios = SplitRatios {
8✔
866
        train,
8✔
867
        validation,
8✔
868
        test,
8✔
869
    };
8✔
870
    let sum = ratios.train + ratios.validation + ratios.test;
8✔
871
    if (sum - 1.0).abs() > 1e-5 {
8✔
872
        return Err(format!(
1✔
873
            "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
1✔
874
            sum, ratios.train, ratios.validation, ratios.test
1✔
875
        ));
1✔
876
    }
7✔
877
    if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
7✔
878
        return Err("split ratios must be non-negative".to_string());
1✔
879
    }
6✔
880
    Ok(ratios)
6✔
881
}
12✔
882

883
fn print_triplet_batch(
2✔
884
    strategy: &ChunkingStrategy,
2✔
885
    batch: &TripletBatch,
2✔
886
    split_store: &impl SplitStore,
2✔
887
) {
2✔
888
    println!("=== triplet batch ===");
2✔
889
    for (idx, triplet) in batch.triplets.iter().enumerate() {
5✔
890
        println!("--- triplet #{} ---", idx);
5✔
891
        println!("recipe       : {}", triplet.recipe);
5✔
892
        println!("sample_weight: {:.4}", triplet.weight);
5✔
893
        if let Some(instr) = &triplet.instruction {
5✔
894
            println!("instruction shown to model:\n{}\n", instr);
1✔
895
        }
4✔
896
        let pos_proximity = chunk_proximity_score(&triplet.anchor, &triplet.positive);
5✔
897
        let pos_distance = window_chunk_distance(&triplet.anchor, &triplet.positive);
5✔
898
        #[cfg(feature = "extended-metrics")]
899
        let (pos_sim, neg_sim) = {
5✔
900
            use crate::metrics::lexical_similarity_scores;
901
            (
5✔
902
                Some(lexical_similarity_scores(
5✔
903
                    &triplet.anchor.text,
5✔
904
                    &triplet.positive.text,
5✔
905
                )),
5✔
906
                Some(lexical_similarity_scores(
5✔
907
                    &triplet.anchor.text,
5✔
908
                    &triplet.negative.text,
5✔
909
                )),
5✔
910
            )
5✔
911
        };
912
        #[cfg(not(feature = "extended-metrics"))]
913
        let (pos_sim, neg_sim): (Option<(f32, f32)>, Option<(f32, f32)>) = (None, None);
914
        print_chunk_block(
5✔
915
            "ANCHOR",
5✔
916
            &triplet.anchor,
5✔
917
            strategy,
5✔
918
            split_store,
5✔
919
            None,
5✔
920
            None,
5✔
921
            None,
5✔
922
        );
923
        print_chunk_block(
5✔
924
            "POSITIVE",
5✔
925
            &triplet.positive,
5✔
926
            strategy,
5✔
927
            split_store,
5✔
928
            pos_sim,
5✔
929
            Some(pos_proximity),
5✔
930
            pos_distance,
5✔
931
        );
932
        print_chunk_block(
5✔
933
            "NEGATIVE",
5✔
934
            &triplet.negative,
5✔
935
            strategy,
5✔
936
            split_store,
5✔
937
            neg_sim,
5✔
938
            None,
5✔
939
            None,
5✔
940
        );
941
    }
942
    print_source_summary(
2✔
943
        "triplet anchors",
2✔
944
        batch
2✔
945
            .triplets
2✔
946
            .iter()
2✔
947
            .map(|triplet| triplet.anchor.record_id.as_str()),
5✔
948
    );
949
    print_recipe_context_by_source(
2✔
950
        "triplet recipes by source",
2✔
951
        batch
2✔
952
            .triplets
2✔
953
            .iter()
2✔
954
            .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
5✔
955
    );
956
}
2✔
957

958
fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
2✔
959
    println!("=== text batch ===");
2✔
960
    for (idx, sample) in batch.samples.iter().enumerate() {
5✔
961
        println!("--- sample #{} ---", idx);
5✔
962
        println!("recipe       : {}", sample.recipe);
5✔
963
        println!("sample_weight: {:.4}", sample.weight);
5✔
964
        if let Some(instr) = &sample.instruction {
5✔
965
            println!("instruction shown to model:\n{}\n", instr);
1✔
966
        }
4✔
967
        print_chunk_block(
5✔
968
            "TEXT",
5✔
969
            &sample.chunk,
5✔
970
            strategy,
5✔
971
            split_store,
5✔
972
            None,
5✔
973
            None,
5✔
974
            None,
5✔
975
        );
976
    }
977
    print_source_summary(
2✔
978
        "text samples",
2✔
979
        batch
2✔
980
            .samples
2✔
981
            .iter()
2✔
982
            .map(|sample| sample.chunk.record_id.as_str()),
5✔
983
    );
984
    print_recipe_context_by_source(
2✔
985
        "text recipes by source",
2✔
986
        batch
2✔
987
            .samples
2✔
988
            .iter()
2✔
989
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
5✔
990
    );
991
}
2✔
992

993
fn print_pair_batch(
3✔
994
    strategy: &ChunkingStrategy,
3✔
995
    batch: &SampleBatch,
3✔
996
    split_store: &impl SplitStore,
3✔
997
) {
3✔
998
    println!("=== pair batch ===");
3✔
999
    for (idx, pair) in batch.pairs.iter().enumerate() {
9✔
1000
        println!("--- pair #{} ---", idx);
9✔
1001
        println!("recipe       : {}", pair.recipe);
9✔
1002
        println!("label        : {:?}", pair.label);
9✔
1003
        if let Some(reason) = &pair.reason {
9✔
1004
            println!("reason       : {}", reason);
5✔
1005
        }
5✔
1006
        print_chunk_block(
9✔
1007
            "ANCHOR",
9✔
1008
            &pair.anchor,
9✔
1009
            strategy,
9✔
1010
            split_store,
9✔
1011
            None,
9✔
1012
            None,
9✔
1013
            None,
9✔
1014
        );
1015
        print_chunk_block(
9✔
1016
            "OTHER",
9✔
1017
            &pair.positive,
9✔
1018
            strategy,
9✔
1019
            split_store,
9✔
1020
            None,
9✔
1021
            None,
9✔
1022
            None,
9✔
1023
        );
1024
    }
1025
    print_source_summary(
3✔
1026
        "pair anchors",
3✔
1027
        batch
3✔
1028
            .pairs
3✔
1029
            .iter()
3✔
1030
            .map(|pair| pair.anchor.record_id.as_str()),
9✔
1031
    );
1032
    print_recipe_context_by_source(
3✔
1033
        "pair recipes by source",
3✔
1034
        batch
3✔
1035
            .pairs
3✔
1036
            .iter()
3✔
1037
            .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
9✔
1038
    );
1039
}
3✔
1040

1041
fn print_text_recipes(recipes: &[TextRecipe]) {
3✔
1042
    println!("=== available text recipes ===");
3✔
1043
    for recipe in recipes {
7✔
1044
        println!(
7✔
1045
            "- {} (weight: {:.3}) selector={:?}",
1046
            recipe.name, recipe.weight, recipe.selector
1047
        );
1048
        if let Some(instr) = &recipe.instruction {
7✔
1049
            println!("  instruction: {}", instr);
1✔
1050
        }
6✔
1051
    }
1052
}
3✔
1053

1054
#[cfg(feature = "extended-metrics")]
1055
fn metric_mean_median(vals: &mut [f32]) -> (f32, f32) {
22✔
1056
    let mean = vals.iter().sum::<f32>() / vals.len() as f32;
22✔
1057
    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
107✔
1058
    let median = if vals.len() % 2 == 1 {
22✔
1059
        vals[vals.len() / 2]
1✔
1060
    } else {
1061
        (vals[vals.len() / 2 - 1] + vals[vals.len() / 2]) / 2.0
21✔
1062
    };
1063
    (mean, median)
22✔
1064
}
22✔
1065

1066
#[cfg(feature = "extended-metrics")]
1067
fn print_metric_summary(source_data: &SourceMetricsMap) {
2✔
1068
    let total: usize = source_data.values().map(|v| v.len()).sum();
3✔
1069
    let n_sources = source_data.len();
2✔
1070
    println!(
2✔
1071
        "=== extended metrics summary ({} triplets, {} {}) ===",
1072
        total,
1073
        n_sources,
1074
        if n_sources == 1 { "source" } else { "sources" }
2✔
1075
    );
1076

1077
    // Returns [pos, neg] as (mean, median) pairs for one metric across entries.
1078
    fn metric_pair(entries: &[MetricEntry], pos_idx: usize, neg_idx: usize) -> [(f32, f32); 2] {
8✔
1079
        let extract = |idx: usize| -> Vec<f32> {
16✔
1080
            entries
16✔
1081
                .iter()
16✔
1082
                .map(|e| match idx {
64✔
1083
                    0 => e.0,
16✔
1084
                    1 => e.1,
16✔
1085
                    2 => e.2,
16✔
1086
                    3 => e.3,
16✔
NEW
1087
                    _ => e.4,
×
1088
                })
64✔
1089
                .collect()
16✔
1090
        };
16✔
1091
        let mut pos_vals = extract(pos_idx);
8✔
1092
        let mut neg_vals = extract(neg_idx);
8✔
1093
        [
8✔
1094
            metric_mean_median(&mut pos_vals),
8✔
1095
            metric_mean_median(&mut neg_vals),
8✔
1096
        ]
8✔
1097
    }
8✔
1098

1099
    fn print_metric_section(
4✔
1100
        label: &str,
4✔
1101
        sources: &[&String],
4✔
1102
        source_data: &SourceMetricsMap,
4✔
1103
        pos_idx: usize,
4✔
1104
        neg_idx: usize,
4✔
1105
        total: usize,
4✔
1106
        n_sources: usize,
4✔
1107
    ) {
4✔
1108
        const SEP: usize = 83;
1109
        println!();
4✔
1110
        println!("[{}]", label);
4✔
1111
        println!(
4✔
1112
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1113
            "source", "n", "positive", "negative", "gap (pos\u{2212}neg)"
1114
        );
1115
        println!(
4✔
1116
            "{:<24} {:>5}  {:<16} {:<16} {:<16}",
1117
            "", "", "mean / median", "mean / median", "mean / median"
1118
        );
1119
        println!("{}", "-".repeat(SEP));
4✔
1120
        for source in sources {
6✔
1121
            let entries = &source_data[*source];
6✔
1122
            let [pos, neg] = metric_pair(entries, pos_idx, neg_idx);
6✔
1123
            let gap_mean = pos.0 - neg.0;
6✔
1124
            let gap_med = pos.1 - neg.1;
6✔
1125
            println!(
6✔
1126
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
6✔
1127
                source,
6✔
1128
                entries.len(),
6✔
1129
                pos.0,
6✔
1130
                pos.1,
6✔
1131
                neg.0,
6✔
1132
                neg.1,
6✔
1133
                gap_mean,
6✔
1134
                gap_med,
6✔
1135
            );
6✔
1136
        }
6✔
1137
        if n_sources > 1 {
4✔
1138
            let all: Vec<MetricEntry> = source_data.values().flatten().copied().collect();
2✔
1139
            let [pos, neg] = metric_pair(&all, pos_idx, neg_idx);
2✔
1140
            let gap_mean = pos.0 - neg.0;
2✔
1141
            let gap_med = pos.1 - neg.1;
2✔
1142
            println!("{}", "-".repeat(SEP));
2✔
1143
            println!(
2✔
1144
                "{:<24} {:>5}  {:.3} / {:.3}     {:.3} / {:.3}     {:+.3} / {:+.3}",
2✔
1145
                "ALL", total, pos.0, pos.1, neg.0, neg.1, gap_mean, gap_med,
2✔
1146
            );
2✔
1147
        }
2✔
1148
    }
4✔
1149

1150
    fn print_single_metric_section(
2✔
1151
        label: &str,
2✔
1152
        sources: &[&String],
2✔
1153
        source_data: &SourceMetricsMap,
2✔
1154
        idx: usize,
2✔
1155
        total: usize,
2✔
1156
        n_sources: usize,
2✔
1157
    ) {
2✔
1158
        const SEP: usize = 58;
1159
        println!();
2✔
1160
        println!("[{}]", label);
2✔
1161
        println!("{:<24} {:>5}  {:<16}", "source", "n", "mean / median");
2✔
1162
        println!("{}", "-".repeat(SEP));
2✔
1163
        for source in sources {
3✔
1164
            let entries = &source_data[*source];
3✔
1165
            let mut vals: Vec<f32> = entries
3✔
1166
                .iter()
3✔
1167
                .map(|e| match idx {
12✔
NEW
1168
                    0 => e.0,
×
NEW
1169
                    1 => e.1,
×
NEW
1170
                    2 => e.2,
×
NEW
1171
                    3 => e.3,
×
1172
                    _ => e.4,
12✔
1173
                })
12✔
1174
                .collect();
3✔
1175
            let (mean, median) = metric_mean_median(&mut vals);
3✔
1176
            println!(
3✔
1177
                "{:<24} {:>5}  {:.3} / {:.3}",
1178
                source,
1179
                entries.len(),
3✔
1180
                mean,
1181
                median,
1182
            );
1183
        }
1184
        if n_sources > 1 {
2✔
1185
            let mut all: Vec<f32> = source_data
1✔
1186
                .values()
1✔
1187
                .flatten()
1✔
1188
                .map(|e| match idx {
4✔
NEW
1189
                    0 => e.0,
×
NEW
1190
                    1 => e.1,
×
NEW
1191
                    2 => e.2,
×
NEW
1192
                    3 => e.3,
×
1193
                    _ => e.4,
4✔
1194
                })
4✔
1195
                .collect();
1✔
1196
            let (mean, median) = metric_mean_median(&mut all);
1✔
1197
            println!("{}", "-".repeat(SEP));
1✔
1198
            println!("{:<24} {:>5}  {:.3} / {:.3}", "ALL", total, mean, median);
1✔
1199
        }
1✔
1200
    }
2✔
1201

1202
    let mut sources: Vec<&String> = source_data.keys().collect();
2✔
1203
    sources.sort();
2✔
1204

1205
    print_metric_section(
2✔
1206
        "jaccard \u{2194} anchor",
2✔
1207
        &sources,
2✔
1208
        source_data,
2✔
1209
        0,
1210
        2,
1211
        total,
2✔
1212
        n_sources,
2✔
1213
    );
1214
    print_metric_section(
2✔
1215
        "byte-cos \u{2194} anchor",
2✔
1216
        &sources,
2✔
1217
        source_data,
2✔
1218
        1,
1219
        3,
1220
        total,
2✔
1221
        n_sources,
2✔
1222
    );
1223
    print_single_metric_section(
2✔
1224
        "anchor-positive proximity",
2✔
1225
        &sources,
2✔
1226
        source_data,
2✔
1227
        4,
1228
        total,
2✔
1229
        n_sources,
2✔
1230
    );
1231
    println!();
2✔
1232
}
2✔
1233

1234
trait ChunkDebug {
1235
    fn view_name(&self) -> String;
1236
}
1237

1238
impl ChunkDebug for RecordChunk {
1239
    fn view_name(&self) -> String {
38✔
1240
        match &self.view {
38✔
1241
            ChunkView::Window {
1242
                index,
36✔
1243
                span,
36✔
1244
                overlap,
36✔
1245
            } => format!(
36✔
1246
                "window#index={} span={} overlap={} tokens={}",
1247
                index, span, overlap, self.tokens_estimate
1248
            ),
1249
            ChunkView::SummaryFallback { strategy, .. } => {
2✔
1250
                format!("summary:{} tokens={}", strategy, self.tokens_estimate)
2✔
1251
            }
1252
        }
1253
    }
38✔
1254
}
1255

1256
fn print_chunk_block(
38✔
1257
    title: &str,
38✔
1258
    chunk: &RecordChunk,
38✔
1259
    strategy: &ChunkingStrategy,
38✔
1260
    split_store: &impl SplitStore,
38✔
1261
    anchor_sim: Option<(f32, f32)>,
38✔
1262
    ap_proximity: Option<f32>,
38✔
1263
    ap_distance: Option<f32>,
38✔
1264
) {
38✔
1265
    let chunk_weight = chunk_weight(strategy, chunk);
38✔
1266
    let split = split_store
38✔
1267
        .label_for(&chunk.record_id)
38✔
1268
        .map(|label| format!("{:?}", label))
38✔
1269
        .unwrap_or_else(|| "Unknown".to_string());
38✔
1270
    println!("--- {} ---", title);
38✔
1271
    println!("split        : {}", split);
38✔
1272
    println!("view         : {}", chunk.view_name());
38✔
1273
    println!("chunk_weight : {:.4}", chunk_weight);
38✔
1274
    println!("record_id    : {}", chunk.record_id);
38✔
1275
    println!("section_idx  : {}", chunk.section_idx);
38✔
1276
    println!("token_est    : {}", chunk.tokens_estimate);
38✔
1277
    if let Some(proximity) = ap_proximity {
38✔
1278
        println!("a<->p proximity  : {:.4}", proximity);
5✔
1279
    }
33✔
1280
    if let Some(distance) = ap_distance {
38✔
NEW
1281
        println!("a<->p distance   : {:.4}", distance);
×
1282
    }
38✔
1283
    if let Some((j, c)) = anchor_sim {
38✔
1284
        println!("jaccard(↔a)  : {:.4}  byte-cos(↔a): {:.4}", j, c);
10✔
1285
    }
28✔
1286
    println!("model_input (exact text sent to the model):");
38✔
1287
    println!(
38✔
1288
        "<<< BEGIN MODEL TEXT >>>\n{}\n<<< END MODEL TEXT >>>\n",
1289
        chunk.text
1290
    );
1291
}
38✔
1292

1293
fn print_source_summary<'a, I>(label: &str, ids: I)
9✔
1294
where
9✔
1295
    I: Iterator<Item = &'a str>,
9✔
1296
{
1297
    let mut counts: HashMap<SourceId, usize> = HashMap::new();
9✔
1298
    for id in ids {
23✔
1299
        let source = extract_source(id);
23✔
1300
        *counts.entry(source).or_insert(0) += 1;
23✔
1301
    }
23✔
1302
    if counts.is_empty() {
9✔
1303
        return;
1✔
1304
    }
8✔
1305
    let skew = source_skew(&counts);
8✔
1306
    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
8✔
1307
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
8✔
1308
    println!("--- {} by source ---", label);
8✔
1309
    if let Some(skew) = skew {
8✔
1310
        for entry in &skew.per_source {
10✔
1311
            println!(
10✔
1312
                "{}: count={} share={:.2}",
10✔
1313
                entry.source, entry.count, entry.share
10✔
1314
            );
10✔
1315
        }
10✔
1316
        println!(
8✔
1317
            "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
1318
            skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
1319
        );
1320
    }
×
1321
}
9✔
1322

1323
fn print_recipe_context_by_source<'a, I>(label: &str, entries: I)
8✔
1324
where
8✔
1325
    I: Iterator<Item = (&'a str, &'a str)>,
8✔
1326
{
1327
    let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
8✔
1328
    for (record_id, recipe) in entries {
19✔
1329
        let source = extract_source(record_id);
19✔
1330
        let entry = counts
19✔
1331
            .entry(source)
19✔
1332
            .or_default()
19✔
1333
            .entry(recipe.to_string())
19✔
1334
            .or_insert(0);
19✔
1335
        *entry += 1;
19✔
1336
    }
19✔
1337
    if counts.is_empty() {
8✔
1338
        return;
1✔
1339
    }
7✔
1340
    let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
7✔
1341
    sources.sort_by(|a, b| a.0.cmp(&b.0));
7✔
1342
    println!("--- {} ---", label);
7✔
1343
    for (source, recipes) in sources {
7✔
1344
        println!("{source}");
7✔
1345
        let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
7✔
1346
        entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
7✔
1347
        for (recipe, count) in entries {
8✔
1348
            println!("  - {recipe}={count}");
8✔
1349
        }
8✔
1350
    }
1351
}
8✔
1352

1353
fn extract_source(record_id: &str) -> SourceId {
52✔
1354
    record_id
52✔
1355
        .split_once("::")
52✔
1356
        .map(|(source, _)| source.to_string())
52✔
1357
        .unwrap_or_else(|| "unknown".to_string())
52✔
1358
}
52✔
1359

1360
#[cfg(test)]
1361
mod tests {
1362
    use super::*;
1363
    use crate::DataRecord;
1364
    use crate::DeterministicSplitStore;
1365
    use crate::data::{QualityScore, RecordSection, SectionRole};
1366
    use crate::source::{SourceCursor, SourceSnapshot};
1367
    use crate::utils::make_section;
1368
    use chrono::{TimeZone, Utc};
1369
    use tempfile::tempdir;
1370

1371
    fn empty_dyn_sources(_: &()) -> Vec<DynSource> {
2✔
1372
        Vec::new()
2✔
1373
    }
2✔
1374

1375
    fn ok_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
1✔
1376
        Ok(())
1✔
1377
    }
1✔
1378

1379
    fn error_unit_roots(_: Vec<String>) -> Result<(), Box<dyn Error>> {
2✔
1380
        Err("root-resolution-error".into())
2✔
1381
    }
2✔
1382

1383
    struct ErrorRefreshSource {
1384
        id: String,
1385
    }
1386

1387
    impl DataSource for ErrorRefreshSource {
1388
        fn id(&self) -> &str {
84✔
1389
            &self.id
84✔
1390
        }
84✔
1391

1392
        fn refresh(
20✔
1393
            &self,
20✔
1394
            _config: &SamplerConfig,
20✔
1395
            _cursor: Option<&SourceCursor>,
20✔
1396
            _limit: Option<usize>,
20✔
1397
        ) -> Result<SourceSnapshot, SamplerError> {
20✔
1398
            Err(SamplerError::SourceUnavailable {
20✔
1399
                source_id: self.id.clone(),
20✔
1400
                reason: "simulated refresh failure".to_string(),
20✔
1401
            })
20✔
1402
        }
20✔
1403

1404
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1405
            Ok(1)
1✔
1406
        }
1✔
1407

1408
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
5✔
1409
            vec![default_recipe("error_refresh_recipe")]
5✔
1410
        }
5✔
1411
    }
1412

1413
    /// Minimal in-memory `DataSource` test double for example app tests.
1414
    struct TestSource {
1415
        id: String,
1416
        count: Option<u128>,
1417
        recipes: Vec<TripletRecipe>,
1418
    }
1419

1420
    impl DataSource for TestSource {
1421
        fn id(&self) -> &str {
131✔
1422
            &self.id
131✔
1423
        }
131✔
1424

1425
        fn refresh(
30✔
1426
            &self,
30✔
1427
            _config: &SamplerConfig,
30✔
1428
            _cursor: Option<&SourceCursor>,
30✔
1429
            _limit: Option<usize>,
30✔
1430
        ) -> Result<SourceSnapshot, SamplerError> {
30✔
1431
            Ok(SourceSnapshot {
30✔
1432
                records: Vec::new(),
30✔
1433
                cursor: SourceCursor {
30✔
1434
                    last_seen: Utc::now(),
30✔
1435
                    revision: 0,
30✔
1436
                },
30✔
1437
            })
30✔
1438
        }
30✔
1439

1440
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1441
            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
2✔
1442
                source_id: self.id.clone(),
1✔
1443
                details: "test source has no configured exact count".to_string(),
1✔
1444
            })
1✔
1445
        }
2✔
1446

1447
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
11✔
1448
            self.recipes.clone()
11✔
1449
        }
11✔
1450
    }
1451

1452
    struct ConfigRequiredSource {
1453
        id: String,
1454
        expected_seed: u64,
1455
    }
1456

1457
    impl DataSource for ConfigRequiredSource {
1458
        fn id(&self) -> &str {
1✔
1459
            &self.id
1✔
1460
        }
1✔
1461

1462
        fn refresh(
1✔
1463
            &self,
1✔
1464
            _config: &SamplerConfig,
1✔
1465
            _cursor: Option<&SourceCursor>,
1✔
1466
            _limit: Option<usize>,
1✔
1467
        ) -> Result<SourceSnapshot, SamplerError> {
1✔
1468
            Ok(SourceSnapshot {
1✔
1469
                records: Vec::new(),
1✔
1470
                cursor: SourceCursor {
1✔
1471
                    last_seen: Utc::now(),
1✔
1472
                    revision: 0,
1✔
1473
                },
1✔
1474
            })
1✔
1475
        }
1✔
1476

1477
        fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
1478
            if config.seed == self.expected_seed {
2✔
1479
                Ok(1)
1✔
1480
            } else {
1481
                Err(SamplerError::SourceInconsistent {
1✔
1482
                    source_id: self.id.clone(),
1✔
1483
                    details: format!(
1✔
1484
                        "expected sampler seed {} but got {}",
1✔
1485
                        self.expected_seed, config.seed
1✔
1486
                    ),
1✔
1487
                })
1✔
1488
            }
1489
        }
2✔
1490

1491
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
1492
            Vec::new()
2✔
1493
        }
2✔
1494
    }
1495

1496
    struct FixtureSource {
1497
        id: String,
1498
        records: Vec<DataRecord>,
1499
        recipes: Vec<TripletRecipe>,
1500
    }
1501

1502
    impl DataSource for FixtureSource {
1503
        fn id(&self) -> &str {
65✔
1504
            &self.id
65✔
1505
        }
65✔
1506

1507
        fn refresh(
15✔
1508
            &self,
15✔
1509
            _config: &SamplerConfig,
15✔
1510
            _cursor: Option<&SourceCursor>,
15✔
1511
            _limit: Option<usize>,
15✔
1512
        ) -> Result<SourceSnapshot, SamplerError> {
15✔
1513
            Ok(SourceSnapshot {
15✔
1514
                records: self.records.clone(),
15✔
1515
                cursor: SourceCursor {
15✔
1516
                    last_seen: Utc::now(),
15✔
1517
                    revision: 0,
15✔
1518
                },
15✔
1519
            })
15✔
1520
        }
15✔
1521

1522
        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1523
            Ok(self.records.len() as u128)
1✔
1524
        }
1✔
1525

1526
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
6✔
1527
            self.recipes.clone()
6✔
1528
        }
6✔
1529
    }
1530

1531
    struct IngestionConfigSource {
1532
        expected_ingestion_max_records: usize,
1533
        records: Vec<DataRecord>,
1534
    }
1535

1536
    impl DataSource for IngestionConfigSource {
1537
        fn id(&self) -> &str {
7✔
1538
            "ingestion_config_source"
7✔
1539
        }
7✔
1540

1541
        fn refresh(
3✔
1542
            &self,
3✔
1543
            config: &SamplerConfig,
3✔
1544
            _cursor: Option<&SourceCursor>,
3✔
1545
            _limit: Option<usize>,
3✔
1546
        ) -> Result<SourceSnapshot, SamplerError> {
3✔
1547
            if config.ingestion_max_records != self.expected_ingestion_max_records {
3✔
1548
                return Err(SamplerError::SourceInconsistent {
1✔
1549
                    source_id: self.id().to_string(),
1✔
1550
                    details: format!(
1✔
1551
                        "expected ingestion_max_records {} but got {}",
1✔
1552
                        self.expected_ingestion_max_records, config.ingestion_max_records
1✔
1553
                    ),
1✔
1554
                });
1✔
1555
            }
2✔
1556
            Ok(SourceSnapshot {
2✔
1557
                records: self.records.clone(),
2✔
1558
                cursor: SourceCursor {
2✔
1559
                    last_seen: Utc::now(),
2✔
1560
                    revision: 0,
2✔
1561
                },
2✔
1562
            })
2✔
1563
        }
3✔
1564

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

1569
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
1570
            vec![default_recipe("ingestion_config_recipe")]
2✔
1571
        }
2✔
1572
    }
1573

1574
    fn fixture_record(
25✔
1575
        source: &str,
25✔
1576
        id_suffix: &str,
25✔
1577
        day: u32,
25✔
1578
        title: &str,
25✔
1579
        body: &str,
25✔
1580
    ) -> DataRecord {
25✔
1581
        let now = Utc.with_ymd_and_hms(2025, 1, day, 12, 0, 0).unwrap();
25✔
1582
        DataRecord {
25✔
1583
            id: format!("{source}::{id_suffix}"),
25✔
1584
            source: source.to_string(),
25✔
1585
            created_at: now,
25✔
1586
            updated_at: now,
25✔
1587
            quality: QualityScore { trust: 1.0 },
25✔
1588
            taxonomy: Vec::new(),
25✔
1589
            sections: vec![
25✔
1590
                make_section(SectionRole::Anchor, Some("title"), title),
25✔
1591
                make_section(SectionRole::Context, Some("body"), body),
25✔
1592
            ],
25✔
1593
            meta_prefix: None,
25✔
1594
        }
25✔
1595
    }
25✔
1596

1597
    fn default_recipe(name: &str) -> TripletRecipe {
24✔
1598
        TripletRecipe {
24✔
1599
            name: name.to_string().into(),
24✔
1600
            anchor: crate::config::Selector::Role(SectionRole::Anchor),
24✔
1601
            positive_selector: crate::config::Selector::Role(SectionRole::Context),
24✔
1602
            negative_selector: crate::config::Selector::Role(SectionRole::Context),
24✔
1603
            negative_strategy: crate::config::NegativeStrategy::WrongArticle,
24✔
1604
            weight: 1.0,
24✔
1605
            instruction: None,
24✔
1606
            allow_same_anchor_positive: false,
24✔
1607
        }
24✔
1608
    }
24✔
1609

1610
    #[test]
1611
    fn parse_helpers_validate_inputs() {
1✔
1612
        assert_eq!(parse_batch_size("2").unwrap(), 2);
1✔
1613
        assert!(parse_batch_size("0").is_err());
1✔
1614
        assert!(parse_batch_size("abc").is_err());
1✔
1615
        assert_eq!(parse_ingestion_max_records("16").unwrap(), 16);
1✔
1616
        assert!(parse_ingestion_max_records("0").is_err());
1✔
1617
        assert!(parse_batch_count("0").is_err());
1✔
1618

1619
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
1620
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
1621
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
1622
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
1623
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
1624
    }
1✔
1625

1626
    #[test]
1627
    fn fixture_and_ingestion_sources_trait_methods_cover_paths() {
1✔
1628
        let records = vec![fixture_record("fixture_source", "r1", 1, "Title", "Body")];
1✔
1629
        let recipes = vec![default_recipe("fixture_recipe")];
1✔
1630
        let fixture = FixtureSource {
1✔
1631
            id: "fixture_source".into(),
1✔
1632
            records: records.clone(),
1✔
1633
            recipes: recipes.clone(),
1✔
1634
        };
1✔
1635

1636
        let snapshot = fixture
1✔
1637
            .refresh(&SamplerConfig::default(), None, None)
1✔
1638
            .expect("fixture refresh should succeed");
1✔
1639
        assert_eq!(snapshot.records.len(), 1);
1✔
1640
        assert_eq!(
1✔
1641
            fixture
1✔
1642
                .reported_record_count(&SamplerConfig::default())
1✔
1643
                .unwrap(),
1✔
1644
            1
1645
        );
1646
        assert_eq!(fixture.default_triplet_recipes().len(), 1);
1✔
1647

1648
        let source = IngestionConfigSource {
1✔
1649
            expected_ingestion_max_records: 7,
1✔
1650
            records,
1✔
1651
        };
1✔
1652
        let ok_cfg = SamplerConfig {
1✔
1653
            ingestion_max_records: 7,
1✔
1654
            ..SamplerConfig::default()
1✔
1655
        };
1✔
1656
        assert!(source.refresh(&ok_cfg, None, None).is_ok());
1✔
1657
        assert_eq!(source.reported_record_count(&ok_cfg).unwrap(), 1);
1✔
1658
        assert_eq!(source.default_triplet_recipes().len(), 1);
1✔
1659

1660
        let bad_cfg = SamplerConfig {
1✔
1661
            ingestion_max_records: 8,
1✔
1662
            ..SamplerConfig::default()
1✔
1663
        };
1✔
1664
        let err = source.refresh(&bad_cfg, None, None).unwrap_err();
1✔
1665
        assert!(matches!(err, SamplerError::SourceInconsistent { .. }));
1✔
1666
    }
1✔
1667

1668
    #[test]
1669
    fn suggested_balancing_weight_is_longest_normalized_and_bounded() {
1✔
1670
        assert!((suggested_balancing_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1671
        assert!((suggested_balancing_weight(400, 100) - 0.25).abs() < 1e-6);
1✔
1672
        assert!((suggested_balancing_weight(400, 400) - 1.0).abs() < 1e-6);
1✔
1673
        assert_eq!(suggested_balancing_weight(0, 100), 0.0);
1✔
1674
        assert_eq!(suggested_balancing_weight(100, 0), 0.0);
1✔
1675
    }
1✔
1676

1677
    #[test]
1678
    fn suggested_oversampling_weight_is_inverse_in_unit_interval() {
1✔
1679
        assert!((suggested_oversampling_weight(100, 100) - 1.0).abs() < 1e-6);
1✔
1680
        assert!((suggested_oversampling_weight(100, 400) - 0.25).abs() < 1e-6);
1✔
1681
        assert!((suggested_oversampling_weight(100, 1000) - 0.1).abs() < 1e-6);
1✔
1682
        assert_eq!(suggested_oversampling_weight(0, 100), 0.0);
1✔
1683
        assert_eq!(suggested_oversampling_weight(100, 0), 0.0);
1✔
1684
    }
1✔
1685

1686
    #[test]
1687
    fn parse_cli_handles_help_and_invalid_args() {
1✔
1688
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
1689
        assert!(help.is_none());
1✔
1690

1691
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
1692
        assert!(err.is_err());
1✔
1693
    }
1✔
1694

1695
    #[test]
1696
    fn run_estimate_capacity_succeeds_with_reported_counts() {
1✔
1697
        let result = run_estimate_capacity(
1✔
1698
            std::iter::empty::<String>(),
1✔
1699
            |roots| {
1✔
1700
                assert!(roots.is_empty());
1✔
1701
                Ok(())
1✔
1702
            },
1✔
1703
            |_| {
1✔
1704
                vec![Box::new(TestSource {
1✔
1705
                    id: "source_a".into(),
1✔
1706
                    count: Some(12),
1✔
1707
                    recipes: vec![default_recipe("r1")],
1✔
1708
                }) as DynSource]
1✔
1709
            },
1✔
1710
        );
1711

1712
        assert!(result.is_ok());
1✔
1713
    }
1✔
1714

1715
    #[test]
1716
    fn run_estimate_capacity_errors_when_source_count_missing() {
1✔
1717
        let result = run_estimate_capacity(
1✔
1718
            std::iter::empty::<String>(),
1✔
1719
            |_| Ok(()),
1✔
1720
            |_| {
1✔
1721
                vec![Box::new(TestSource {
1✔
1722
                    id: "source_missing".into(),
1✔
1723
                    count: None,
1✔
1724
                    recipes: vec![default_recipe("r1")],
1✔
1725
                }) as DynSource]
1✔
1726
            },
1✔
1727
        );
1728

1729
        let err = result.unwrap_err().to_string();
1✔
1730
        assert!(err.contains("failed to report exact record count"));
1✔
1731
    }
1✔
1732

1733
    #[test]
1734
    fn run_estimate_capacity_propagates_root_resolution_error() {
1✔
1735
        let result = run_estimate_capacity(
1✔
1736
            std::iter::empty::<String>(),
1✔
1737
            |_| Err("root resolution failed".into()),
1✔
1738
            empty_dyn_sources,
1739
        );
1740

1741
        let err = result.unwrap_err().to_string();
1✔
1742
        assert!(err.contains("root resolution failed"));
1✔
1743
    }
1✔
1744

1745
    #[test]
1746
    fn run_estimate_capacity_allows_empty_source_list() {
1✔
1747
        let result =
1✔
1748
            run_estimate_capacity(std::iter::empty::<String>(), |_| Ok(()), empty_dyn_sources);
1✔
1749

1750
        assert!(result.is_ok());
1✔
1751
    }
1✔
1752

1753
    #[test]
1754
    fn run_estimate_capacity_configures_sources_centrally_before_counting() {
1✔
1755
        let result = run_estimate_capacity(
1✔
1756
            std::iter::empty::<String>(),
1✔
1757
            |_| Ok(()),
1✔
1758
            |_| {
1✔
1759
                vec![Box::new(ConfigRequiredSource {
1✔
1760
                    id: "requires_config".into(),
1✔
1761
                    expected_seed: 99,
1✔
1762
                }) as DynSource]
1✔
1763
            },
1✔
1764
        );
1765

1766
        assert!(result.is_ok());
1✔
1767
    }
1✔
1768

1769
    #[test]
1770
    fn config_required_source_refresh_and_seed_mismatch_are_exercised() {
1✔
1771
        let source = ConfigRequiredSource {
1✔
1772
            id: "cfg-source".to_string(),
1✔
1773
            expected_seed: 42,
1✔
1774
        };
1✔
1775

1776
        let refreshed = source
1✔
1777
            .refresh(&SamplerConfig::default(), None, None)
1✔
1778
            .unwrap();
1✔
1779
        assert!(refreshed.records.is_empty());
1✔
1780

1781
        let mismatched = source.reported_record_count(&SamplerConfig {
1✔
1782
            seed: 7,
1✔
1783
            ..SamplerConfig::default()
1✔
1784
        });
1✔
1785
        assert!(matches!(
1✔
1786
            mismatched,
1✔
1787
            Err(SamplerError::SourceInconsistent { .. })
1788
        ));
1789

1790
        assert!(source.default_triplet_recipes().is_empty());
1✔
1791
    }
1✔
1792

1793
    #[test]
1794
    fn run_multi_source_demo_exhausted_paths_return_ok() {
1✔
1795
        struct OneRecordSource;
1796

1797
        impl DataSource for OneRecordSource {
1798
            fn id(&self) -> &str {
48✔
1799
                "one_record"
48✔
1800
            }
48✔
1801

1802
            fn refresh(
11✔
1803
                &self,
11✔
1804
                _config: &SamplerConfig,
11✔
1805
                _cursor: Option<&SourceCursor>,
11✔
1806
                _limit: Option<usize>,
11✔
1807
            ) -> Result<SourceSnapshot, SamplerError> {
11✔
1808
                let now = Utc::now();
11✔
1809
                Ok(SourceSnapshot {
11✔
1810
                    records: vec![DataRecord {
11✔
1811
                        id: "one_record::r1".to_string(),
11✔
1812
                        source: "one_record".to_string(),
11✔
1813
                        created_at: now,
11✔
1814
                        updated_at: now,
11✔
1815
                        quality: QualityScore { trust: 1.0 },
11✔
1816
                        taxonomy: Vec::new(),
11✔
1817
                        sections: vec![
11✔
1818
                            RecordSection {
11✔
1819
                                role: SectionRole::Anchor,
11✔
1820
                                heading: Some("title".to_string()),
11✔
1821
                                text: "anchor".to_string(),
11✔
1822
                                sentences: vec!["anchor".to_string()],
11✔
1823
                            },
11✔
1824
                            RecordSection {
11✔
1825
                                role: SectionRole::Context,
11✔
1826
                                heading: Some("body".to_string()),
11✔
1827
                                text: "context".to_string(),
11✔
1828
                                sentences: vec!["context".to_string()],
11✔
1829
                            },
11✔
1830
                        ],
11✔
1831
                        meta_prefix: None,
11✔
1832
                    }],
11✔
1833
                    cursor: SourceCursor {
11✔
1834
                        last_seen: now,
11✔
1835
                        revision: 0,
11✔
1836
                    },
11✔
1837
                })
11✔
1838
            }
11✔
1839

1840
            fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
1841
                Ok(1)
1✔
1842
            }
1✔
1843

1844
            fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
4✔
1845
                vec![default_recipe("single_record_recipe")]
4✔
1846
            }
4✔
1847
        }
1848

1849
        let one = OneRecordSource;
1✔
1850
        assert_eq!(
1✔
1851
            one.reported_record_count(&SamplerConfig::default())
1✔
1852
                .unwrap(),
1✔
1853
            1
1854
        );
1855
        assert_eq!(one.default_triplet_recipes().len(), 1);
1✔
1856

1857
        for mode in ["--pair-batch", "--text-recipes", ""] {
3✔
1858
            let dir = tempdir().unwrap();
3✔
1859
            let split_store_path = dir.path().join("split_store.bin");
3✔
1860
            let mut args = vec![
3✔
1861
                "--split-store-path".to_string(),
3✔
1862
                split_store_path.to_string_lossy().to_string(),
3✔
1863
            ];
1864
            if !mode.is_empty() {
3✔
1865
                args.push(mode.to_string());
2✔
1866
            }
2✔
1867

1868
            let result = run_multi_source_demo(
3✔
1869
                args.into_iter(),
3✔
1870
                |_| Ok(()),
3✔
1871
                |_| vec![Box::new(OneRecordSource) as DynSource],
3✔
1872
            );
1873
            assert!(result.is_ok());
3✔
1874
        }
1875
    }
1✔
1876

1877
    #[test]
1878
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
1879
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
1880
        assert!(help.is_none());
1✔
1881

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

1885
        let err = parse_cli::<MultiSourceDemoCli, _>([
1✔
1886
            "multi_source_demo",
1✔
1887
            "--ingestion-max-records",
1✔
1888
            "0",
1✔
1889
        ]);
1✔
1890
        assert!(err.is_err());
1✔
1891

1892
        let parsed = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo"]);
1✔
1893
        assert!(parsed.is_ok());
1✔
1894
    }
1✔
1895

1896
    #[test]
1897
    fn run_example_apps_invalid_cli_args_return_errors() {
1✔
1898
        let estimate = run_estimate_capacity(
1✔
1899
            ["--unknown".to_string()].into_iter(),
1✔
1900
            ok_unit_roots,
1901
            empty_dyn_sources,
1902
        );
1903
        assert!(estimate.is_err());
1✔
1904

1905
        let demo = run_multi_source_demo(
1✔
1906
            ["--unknown".to_string()].into_iter(),
1✔
1907
            ok_unit_roots,
1908
            empty_dyn_sources,
1909
        );
1910
        assert!(demo.is_err());
1✔
1911
    }
1✔
1912

1913
    #[test]
1914
    fn helper_and_error_refresh_source_methods_are_exercised() {
1✔
1915
        assert!(ok_unit_roots(Vec::new()).is_ok());
1✔
1916
        assert!(error_unit_roots(Vec::new()).is_err());
1✔
1917

1918
        let source = ErrorRefreshSource {
1✔
1919
            id: "error_refresh_source".to_string(),
1✔
1920
        };
1✔
1921
        assert_eq!(
1✔
1922
            source
1✔
1923
                .reported_record_count(&SamplerConfig::default())
1✔
1924
                .unwrap(),
1✔
1925
            1
1926
        );
1927
        assert_eq!(source.default_triplet_recipes().len(), 1);
1✔
1928
    }
1✔
1929

1930
    #[test]
1931
    fn print_source_summary_handles_non_empty_ids() {
1✔
1932
        let ids = [
1✔
1933
            "source_a::r1",
1✔
1934
            "source_a::r2",
1✔
1935
            "source_b::r1",
1✔
1936
            "source_without_delimiter",
1✔
1937
        ];
1✔
1938
        print_source_summary("non-empty summary", ids.into_iter());
1✔
1939
    }
1✔
1940

1941
    #[test]
1942
    fn run_multi_source_demo_refresh_failures_degrade_to_exhausted_paths() {
1✔
1943
        for mode in [
4✔
1944
            vec!["--pair-batch".to_string()],
1✔
1945
            vec!["--text-recipes".to_string()],
1✔
1946
            vec!["--batches".to_string(), "1".to_string()],
1✔
1947
            Vec::new(),
1✔
1948
        ] {
1✔
1949
            let dir = tempdir().unwrap();
4✔
1950
            let split_store_path = dir.path().join("error_modes_split_store.bin");
4✔
1951
            let mut args = mode;
4✔
1952
            args.push("--split-store-path".to_string());
4✔
1953
            args.push(split_store_path.to_string_lossy().to_string());
4✔
1954

1955
            let result = run_multi_source_demo(
4✔
1956
                args.into_iter(),
4✔
1957
                |_| Ok(()),
4✔
1958
                |_| {
4✔
1959
                    vec![Box::new(ErrorRefreshSource {
4✔
1960
                        id: "error_refresh_source".to_string(),
4✔
1961
                    }) as DynSource]
4✔
1962
                },
4✔
1963
            );
1964

1965
            assert!(result.is_ok());
4✔
1966
        }
1967
    }
1✔
1968

1969
    #[test]
1970
    fn run_multi_source_demo_batches_exhausted_path_returns_ok() {
1✔
1971
        let dir = tempdir().unwrap();
1✔
1972
        let split_store_path = dir.path().join("batches_exhausted_split_store.bin");
1✔
1973
        let args = vec![
1✔
1974
            "--batches".to_string(),
1✔
1975
            "3".to_string(),
1✔
1976
            "--split-store-path".to_string(),
1✔
1977
            split_store_path.to_string_lossy().to_string(),
1✔
1978
        ];
1979

1980
        let result = run_multi_source_demo(
1✔
1981
            args.into_iter(),
1✔
1982
            |_| Ok(()),
1✔
1983
            |_| {
1✔
1984
                vec![Box::new(FixtureSource {
1✔
1985
                    id: "batches_exhausted_source".into(),
1✔
1986
                    records: vec![fixture_record(
1✔
1987
                        "batches_exhausted_source",
1✔
1988
                        "r1",
1✔
1989
                        1,
1✔
1990
                        "Only one record",
1✔
1991
                        "Single record body",
1✔
1992
                    )],
1✔
1993
                    recipes: vec![default_recipe("batches_exhausted_recipe")],
1✔
1994
                }) as DynSource]
1✔
1995
            },
1✔
1996
        );
1997

1998
        assert!(result.is_ok());
1✔
1999
    }
1✔
2000

2001
    #[test]
2002
    fn run_multi_source_demo_default_triplet_success_path_returns_ok() {
1✔
2003
        let dir = tempdir().unwrap();
1✔
2004
        let split_store_path = dir.path().join("default_triplet_success_split_store.bin");
1✔
2005
        let args = vec![
1✔
2006
            "--split-store-path".to_string(),
1✔
2007
            split_store_path.to_string_lossy().to_string(),
1✔
2008
        ];
2009

2010
        let result = run_multi_source_demo(
1✔
2011
            args.into_iter(),
1✔
2012
            |_| Ok(()),
1✔
2013
            |_| {
1✔
2014
                vec![Box::new(FixtureSource {
1✔
2015
                    id: "default_triplet_success_source".into(),
1✔
2016
                    records: vec![
1✔
2017
                        fixture_record(
1✔
2018
                            "default_triplet_success_source",
1✔
2019
                            "r1",
1✔
2020
                            1,
1✔
2021
                            "Title one",
1✔
2022
                            "Body one",
1✔
2023
                        ),
1✔
2024
                        fixture_record(
1✔
2025
                            "default_triplet_success_source",
1✔
2026
                            "r2",
1✔
2027
                            2,
1✔
2028
                            "Title two",
1✔
2029
                            "Body two",
1✔
2030
                        ),
1✔
2031
                        fixture_record(
1✔
2032
                            "default_triplet_success_source",
1✔
2033
                            "r3",
1✔
2034
                            3,
1✔
2035
                            "Title three",
1✔
2036
                            "Body three",
1✔
2037
                        ),
1✔
2038
                    ],
1✔
2039
                    recipes: vec![default_recipe("default_triplet_success_recipe")],
1✔
2040
                }) as DynSource]
1✔
2041
            },
1✔
2042
        );
2043

2044
        assert!(result.is_ok());
1✔
2045
    }
1✔
2046

2047
    #[test]
2048
    fn run_multi_source_demo_passes_ingestion_max_records_to_sources() {
1✔
2049
        let dir = tempdir().unwrap();
1✔
2050
        let split_store_path = dir.path().join("ingestion_config_split_store.bin");
1✔
2051
        let expected = 7;
1✔
2052

2053
        let result = run_multi_source_demo(
1✔
2054
            [
1✔
2055
                "--pair-batch".to_string(),
1✔
2056
                "--ingestion-max-records".to_string(),
1✔
2057
                expected.to_string(),
1✔
2058
                "--split-store-path".to_string(),
1✔
2059
                split_store_path.to_string_lossy().to_string(),
1✔
2060
            ]
1✔
2061
            .into_iter(),
1✔
2062
            |_| Ok(()),
1✔
2063
            |_| {
1✔
2064
                vec![Box::new(IngestionConfigSource {
1✔
2065
                    expected_ingestion_max_records: expected,
1✔
2066
                    records: (1..=8)
1✔
2067
                        .map(|day| {
8✔
2068
                            fixture_record(
8✔
2069
                                "ingestion_config_source",
8✔
2070
                                &format!("r{day}"),
8✔
2071
                                day,
8✔
2072
                                &format!("Config headline {day}"),
8✔
2073
                                &format!("Config body {day}"),
8✔
2074
                            )
2075
                        })
8✔
2076
                        .collect(),
1✔
2077
                }) as DynSource]
1✔
2078
            },
1✔
2079
        );
2080

2081
        assert!(result.is_ok());
1✔
2082
    }
1✔
2083

2084
    #[test]
2085
    fn parse_cli_handles_display_version_path() {
1✔
2086
        #[derive(Debug, Parser)]
2087
        #[command(name = "version_test", version = "1.0.0")]
2088
        struct VersionCli {}
2089

2090
        let parsed = parse_cli::<VersionCli, _>(["version_test", "--version"]).unwrap();
1✔
2091
        assert!(parsed.is_none());
1✔
2092
    }
1✔
2093

2094
    #[test]
2095
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
2096
        let dir = tempdir().unwrap();
1✔
2097
        let split_store_path = dir.path().join("recipes_split_store.bin");
1✔
2098
        let mut args = vec![
1✔
2099
            "--list-text-recipes".to_string(),
1✔
2100
            "--split-store-path".to_string(),
1✔
2101
            split_store_path.to_string_lossy().to_string(),
1✔
2102
        ];
2103
        let result = run_multi_source_demo(
1✔
2104
            args.drain(..),
1✔
2105
            |_| Ok(()),
1✔
2106
            |_| {
1✔
2107
                vec![Box::new(TestSource {
1✔
2108
                    id: "source_for_recipes".into(),
1✔
2109
                    count: Some(10),
1✔
2110
                    recipes: vec![default_recipe("recipe_a")],
1✔
2111
                }) as DynSource]
1✔
2112
            },
1✔
2113
        );
2114

2115
        assert!(result.is_ok());
1✔
2116
    }
1✔
2117

2118
    #[test]
2119
    fn run_multi_source_demo_list_text_recipes_uses_explicit_split_store_path() {
1✔
2120
        let dir = tempdir().unwrap();
1✔
2121
        let split_store_path = dir.path().join("custom_split_store.bin");
1✔
2122
        let args = vec![
1✔
2123
            "--list-text-recipes".to_string(),
1✔
2124
            "--split-store-path".to_string(),
1✔
2125
            split_store_path.to_string_lossy().to_string(),
1✔
2126
        ];
2127

2128
        let result = run_multi_source_demo(
1✔
2129
            args.into_iter(),
1✔
2130
            |_| Ok(()),
1✔
2131
            |_| {
1✔
2132
                vec![Box::new(TestSource {
1✔
2133
                    id: "source_without_text_recipes".into(),
1✔
2134
                    count: Some(1),
1✔
2135
                    recipes: Vec::new(),
1✔
2136
                }) as DynSource]
1✔
2137
            },
1✔
2138
        );
2139

2140
        assert!(result.is_ok());
1✔
2141
    }
1✔
2142

2143
    #[test]
2144
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
2145
        for mode in [
3✔
2146
            vec!["--pair-batch".to_string()],
1✔
2147
            vec!["--text-recipes".to_string()],
1✔
2148
            vec![],
1✔
2149
        ] {
1✔
2150
            let dir = tempdir().unwrap();
3✔
2151
            let split_store_path = dir.path().join("empty_sources_split_store.bin");
3✔
2152
            let mut args = mode;
3✔
2153
            args.push("--split-store-path".to_string());
3✔
2154
            args.push(split_store_path.to_string_lossy().to_string());
3✔
2155
            args.push("--split".to_string());
3✔
2156
            args.push("validation".to_string());
3✔
2157

2158
            let result = run_multi_source_demo(
3✔
2159
                args.into_iter(),
3✔
2160
                |_| Ok(()),
3✔
2161
                |_| {
3✔
2162
                    vec![Box::new(TestSource {
3✔
2163
                        id: "source_empty".into(),
3✔
2164
                        count: Some(0),
3✔
2165
                        recipes: vec![default_recipe("recipe_empty")],
3✔
2166
                    }) as DynSource]
3✔
2167
                },
3✔
2168
            );
2169

2170
            assert!(result.is_ok());
3✔
2171
        }
2172
    }
1✔
2173

2174
    #[test]
2175
    fn run_multi_source_demo_propagates_root_resolution_error() {
1✔
2176
        let dir = tempdir().unwrap();
1✔
2177
        let split_store_path = dir.path().join("root_resolution_error_store.bin");
1✔
2178
        let result = run_multi_source_demo(
1✔
2179
            [
1✔
2180
                "--split-store-path".to_string(),
1✔
2181
                split_store_path.to_string_lossy().to_string(),
1✔
2182
            ]
1✔
2183
            .into_iter(),
1✔
2184
            |_| Err("demo root resolution failed".into()),
1✔
2185
            empty_dyn_sources,
2186
        );
2187

2188
        let err = result.unwrap_err().to_string();
1✔
2189
        assert!(err.contains("demo root resolution failed"));
1✔
2190
    }
1✔
2191

2192
    #[test]
2193
    fn run_multi_source_demo_list_text_recipes_allows_empty_sources() {
1✔
2194
        let dir = tempdir().unwrap();
1✔
2195
        let split_store_path = dir.path().join("empty_source_list_recipes.bin");
1✔
2196
        let result = run_multi_source_demo(
1✔
2197
            [
1✔
2198
                "--list-text-recipes".to_string(),
1✔
2199
                "--split-store-path".to_string(),
1✔
2200
                split_store_path.to_string_lossy().to_string(),
1✔
2201
            ]
1✔
2202
            .into_iter(),
1✔
2203
            |_| Ok(()),
1✔
2204
            empty_dyn_sources,
2205
        );
2206

2207
        assert!(result.is_ok());
1✔
2208
    }
1✔
2209

2210
    #[test]
2211
    fn print_helpers_and_extract_source_cover_paths() {
1✔
2212
        let split = SplitRatios::default();
1✔
2213
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
2214
        let strategy = ChunkingStrategy::default();
1✔
2215

2216
        let anchor = RecordChunk {
1✔
2217
            record_id: "source_a::rec1".to_string(),
1✔
2218
            section_idx: 0,
1✔
2219
            view: ChunkView::Window {
1✔
2220
                index: 1,
1✔
2221
                overlap: 2,
1✔
2222
                span: 12,
1✔
2223
            },
1✔
2224
            text: "anchor text".to_string(),
1✔
2225
            tokens_estimate: 8,
1✔
2226
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
2227
        };
1✔
2228
        let positive = RecordChunk {
1✔
2229
            record_id: "source_a::rec2".to_string(),
1✔
2230
            section_idx: 1,
1✔
2231
            view: ChunkView::SummaryFallback {
1✔
2232
                strategy: "summary".to_string(),
1✔
2233
                weight: 0.7,
1✔
2234
            },
1✔
2235
            text: "positive text".to_string(),
1✔
2236
            tokens_estimate: 6,
1✔
2237
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
2238
        };
1✔
2239
        let negative = RecordChunk {
1✔
2240
            record_id: "source_b::rec3".to_string(),
1✔
2241
            section_idx: 2,
1✔
2242
            view: ChunkView::Window {
1✔
2243
                index: 0,
1✔
2244
                overlap: 0,
1✔
2245
                span: 16,
1✔
2246
            },
1✔
2247
            text: "negative text".to_string(),
1✔
2248
            tokens_estimate: 7,
1✔
2249
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
2250
        };
1✔
2251

2252
        let triplet_batch = TripletBatch {
1✔
2253
            triplets: vec![crate::SampleTriplet {
1✔
2254
                recipe: "triplet_recipe".to_string(),
1✔
2255
                anchor: anchor.clone(),
1✔
2256
                positive: positive.clone(),
1✔
2257
                negative: negative.clone(),
1✔
2258
                weight: 1.0,
1✔
2259
                instruction: Some("triplet instruction".to_string()),
1✔
2260
            }],
1✔
2261
        };
1✔
2262
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
2263

2264
        let pair_batch = SampleBatch {
1✔
2265
            pairs: vec![crate::SamplePair {
1✔
2266
                recipe: "pair_recipe".to_string(),
1✔
2267
                anchor: anchor.clone(),
1✔
2268
                positive: positive.clone(),
1✔
2269
                weight: 1.0,
1✔
2270
                instruction: None,
1✔
2271
                label: crate::PairLabel::Positive,
1✔
2272
                reason: Some("same topic".to_string()),
1✔
2273
            }],
1✔
2274
        };
1✔
2275
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
2276

2277
        let text_batch = TextBatch {
1✔
2278
            samples: vec![crate::TextSample {
1✔
2279
                recipe: "text_recipe".to_string(),
1✔
2280
                chunk: negative,
1✔
2281
                weight: 0.8,
1✔
2282
                instruction: Some("text instruction".to_string()),
1✔
2283
            }],
1✔
2284
        };
1✔
2285
        print_text_batch(&strategy, &text_batch, &store);
1✔
2286

2287
        let recipes = vec![TextRecipe {
1✔
2288
            name: "recipe_name".into(),
1✔
2289
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
2290
            instruction: Some("instruction".into()),
1✔
2291
            weight: 1.0,
1✔
2292
        }];
1✔
2293
        print_text_recipes(&recipes);
1✔
2294

2295
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
2296
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
2297
    }
1✔
2298

2299
    #[test]
2300
    fn split_arg_conversion_and_version_parse_paths_are_covered() {
1✔
2301
        assert!(matches!(
1✔
2302
            SplitLabel::from(SplitArg::Train),
1✔
2303
            SplitLabel::Train
2304
        ));
2305
        assert!(matches!(
1✔
2306
            SplitLabel::from(SplitArg::Validation),
1✔
2307
            SplitLabel::Validation
2308
        ));
2309
        assert!(matches!(SplitLabel::from(SplitArg::Test), SplitLabel::Test));
1✔
2310
    }
1✔
2311

2312
    #[test]
2313
    fn parse_split_ratios_reports_per_field_parse_errors() {
1✔
2314
        assert!(
1✔
2315
            parse_split_ratios_arg("x,0.1,0.9")
1✔
2316
                .unwrap_err()
1✔
2317
                .contains("invalid train ratio")
1✔
2318
        );
2319
        assert!(
1✔
2320
            parse_split_ratios_arg("0.1,y,0.8")
1✔
2321
                .unwrap_err()
1✔
2322
                .contains("invalid validation ratio")
1✔
2323
        );
2324
        assert!(
1✔
2325
            parse_split_ratios_arg("0.1,0.2,z")
1✔
2326
                .unwrap_err()
1✔
2327
                .contains("invalid test ratio")
1✔
2328
        );
2329
    }
1✔
2330

2331
    #[test]
2332
    fn run_multi_source_demo_exhausted_paths_are_handled() {
1✔
2333
        for mode in [
3✔
2334
            vec!["--pair-batch".to_string()],
1✔
2335
            vec!["--text-recipes".to_string()],
1✔
2336
            Vec::new(),
1✔
2337
        ] {
1✔
2338
            let dir = tempdir().unwrap();
3✔
2339
            let split_store_path = dir.path().join("exhausted_split_store.bin");
3✔
2340
            let mut args = mode;
3✔
2341
            args.push("--split-store-path".to_string());
3✔
2342
            args.push(split_store_path.to_string_lossy().to_string());
3✔
2343

2344
            let result = run_multi_source_demo(
3✔
2345
                args.into_iter(),
3✔
2346
                |_| Ok(()),
3✔
2347
                |_| {
3✔
2348
                    vec![Box::new(TestSource {
3✔
2349
                        id: "source_without_recipes".into(),
3✔
2350
                        count: Some(1),
3✔
2351
                        recipes: Vec::new(),
3✔
2352
                    }) as DynSource]
3✔
2353
                },
3✔
2354
            );
2355

2356
            assert!(result.is_ok());
3✔
2357
        }
2358
    }
1✔
2359

2360
    #[test]
2361
    fn run_multi_source_demo_reset_recreates_split_store_and_samples() {
1✔
2362
        let dir = tempdir().unwrap();
1✔
2363
        let split_store_path = dir.path().join("reset_split_store.bin");
1✔
2364
        std::fs::write(&split_store_path, b"stale-data").unwrap();
1✔
2365

2366
        let args = vec![
1✔
2367
            "--reset".to_string(),
1✔
2368
            "--pair-batch".to_string(),
1✔
2369
            "--split-store-path".to_string(),
1✔
2370
            split_store_path.to_string_lossy().to_string(),
1✔
2371
        ];
2372

2373
        let result = run_multi_source_demo(
1✔
2374
            args.into_iter(),
1✔
2375
            |_| Ok(()),
1✔
2376
            |_| {
1✔
2377
                let recipes = vec![default_recipe("fixture_recipe")];
1✔
2378
                let records: Vec<DataRecord> = (1..=8)
1✔
2379
                    .map(|day| {
8✔
2380
                        fixture_record(
8✔
2381
                            "fixture_source",
8✔
2382
                            &format!("r{day}"),
8✔
2383
                            day,
8✔
2384
                            &format!("Fixture headline {day}"),
8✔
2385
                            &format!("Fixture body content for day {day}."),
8✔
2386
                        )
2387
                    })
8✔
2388
                    .collect();
1✔
2389
                vec![Box::new(FixtureSource {
1✔
2390
                    id: "fixture_source".into(),
1✔
2391
                    records,
1✔
2392
                    recipes,
1✔
2393
                }) as DynSource]
1✔
2394
            },
1✔
2395
        );
2396

2397
        assert!(result.is_ok());
1✔
2398
        assert!(split_store_path.exists());
1✔
2399
        let metadata = std::fs::metadata(&split_store_path).unwrap();
1✔
2400
        assert!(metadata.len() > 0);
1✔
2401
    }
1✔
2402

2403
    #[test]
2404
    fn run_multi_source_demo_batches_mode_executes_multiple_batches() {
1✔
2405
        let dir = tempdir().unwrap();
1✔
2406
        let split_store_path = dir.path().join("batches_split_store.bin");
1✔
2407
        let args = vec![
1✔
2408
            "--batches".to_string(),
1✔
2409
            "2".to_string(),
1✔
2410
            "--split-store-path".to_string(),
1✔
2411
            split_store_path.to_string_lossy().to_string(),
1✔
2412
        ];
2413

2414
        let result = run_multi_source_demo(
1✔
2415
            args.into_iter(),
1✔
2416
            |_| Ok(()),
1✔
2417
            |_| {
1✔
2418
                let recipes = vec![default_recipe("batch_recipe")];
1✔
2419
                vec![Box::new(FixtureSource {
1✔
2420
                    id: "batch_source".into(),
1✔
2421
                    records: vec![
1✔
2422
                        fixture_record(
1✔
2423
                            "batch_source",
1✔
2424
                            "r1",
1✔
2425
                            3,
1✔
2426
                            "Inflation cools in latest report",
1✔
2427
                            "Core inflation moderated compared with prior quarter.",
1✔
2428
                        ),
1✔
2429
                        fixture_record(
1✔
2430
                            "batch_source",
1✔
2431
                            "r2",
1✔
2432
                            4,
1✔
2433
                            "Labor market remains resilient",
1✔
2434
                            "Job openings remain elevated despite slower growth.",
1✔
2435
                        ),
1✔
2436
                        fixture_record(
1✔
2437
                            "batch_source",
1✔
2438
                            "r3",
1✔
2439
                            5,
1✔
2440
                            "Manufacturing sentiment stabilizes",
1✔
2441
                            "Survey data suggests output expectations are improving.",
1✔
2442
                        ),
1✔
2443
                    ],
1✔
2444
                    recipes,
1✔
2445
                }) as DynSource]
1✔
2446
            },
1✔
2447
        );
2448

2449
        assert!(result.is_ok());
1✔
2450
        assert!(split_store_path.exists());
1✔
2451
    }
1✔
2452

2453
    #[test]
2454
    fn managed_demo_split_store_path_resolves_under_cache_group() {
1✔
2455
        let path = managed_demo_split_store_path().unwrap();
1✔
2456
        assert!(path.ends_with(MULTI_SOURCE_DEMO_STORE_FILENAME));
1✔
2457
        let parent = path
1✔
2458
            .parent()
1✔
2459
            .expect("managed split-store path should have a parent");
1✔
2460
        assert!(parent.ends_with(PathBuf::from(MULTI_SOURCE_DEMO_GROUP)));
1✔
2461
    }
1✔
2462

2463
    #[test]
2464
    fn run_multi_source_demo_help_returns_ok_without_work() {
1✔
2465
        let no_help = run_multi_source_demo(
1✔
2466
            std::iter::empty::<String>(),
1✔
2467
            error_unit_roots,
2468
            empty_dyn_sources,
2469
        );
2470
        assert!(
1✔
2471
            no_help
1✔
2472
                .expect_err("non-help path should attempt to resolve roots")
1✔
2473
                .to_string()
1✔
2474
                .contains("root-resolution-error")
1✔
2475
        );
2476

2477
        let result = run_multi_source_demo(
1✔
2478
            ["--help".to_string()].into_iter(),
1✔
2479
            ok_unit_roots,
2480
            empty_dyn_sources,
2481
        );
2482

2483
        assert!(result.is_ok());
1✔
2484
    }
1✔
2485

2486
    #[test]
2487
    fn run_estimate_capacity_help_returns_ok_without_work() {
1✔
2488
        let result = run_estimate_capacity(
1✔
2489
            ["--help".to_string()].into_iter(),
1✔
2490
            ok_unit_roots,
2491
            empty_dyn_sources,
2492
        );
2493

2494
        assert!(result.is_ok());
1✔
2495
    }
1✔
2496

2497
    #[test]
2498
    fn run_multi_source_demo_pair_exhausted_branch_returns_ok() {
1✔
2499
        let dir = tempdir().unwrap();
1✔
2500
        let split_store_path = dir.path().join("pair_exhausted_split_store.bin");
1✔
2501
        let args = vec![
1✔
2502
            "--pair-batch".to_string(),
1✔
2503
            "--split-store-path".to_string(),
1✔
2504
            split_store_path.to_string_lossy().to_string(),
1✔
2505
        ];
2506

2507
        let result = run_multi_source_demo(
1✔
2508
            args.into_iter(),
1✔
2509
            |_| Ok(()),
1✔
2510
            |_| {
1✔
2511
                vec![Box::new(FixtureSource {
1✔
2512
                    id: "pair_exhausted_source".into(),
1✔
2513
                    records: vec![fixture_record(
1✔
2514
                        "pair_exhausted_source",
1✔
2515
                        "r1",
1✔
2516
                        1,
1✔
2517
                        "Single record title",
1✔
2518
                        "Single record body",
1✔
2519
                    )],
1✔
2520
                    recipes: vec![default_recipe("pair_exhausted_recipe")],
1✔
2521
                }) as DynSource]
1✔
2522
            },
1✔
2523
        );
2524

2525
        assert!(result.is_ok());
1✔
2526
    }
1✔
2527

2528
    #[test]
2529
    fn run_multi_source_demo_uses_managed_split_store_path_when_not_provided() {
1✔
2530
        let result = run_multi_source_demo(
1✔
2531
            ["--list-text-recipes".to_string()].into_iter(),
1✔
2532
            |_| Ok(()),
1✔
2533
            |_| {
1✔
2534
                vec![Box::new(TestSource {
1✔
2535
                    id: "managed_path_source".into(),
1✔
2536
                    count: Some(2),
1✔
2537
                    recipes: vec![default_recipe("managed_recipe")],
1✔
2538
                }) as DynSource]
1✔
2539
            },
1✔
2540
        );
2541

2542
        assert!(result.is_ok());
1✔
2543
    }
1✔
2544

2545
    #[test]
2546
    fn run_multi_source_demo_reset_errors_when_target_is_directory() {
1✔
2547
        let dir = tempdir().unwrap();
1✔
2548
        let split_store_path = dir.path().join("split_store_dir");
1✔
2549
        std::fs::create_dir(&split_store_path).unwrap();
1✔
2550

2551
        let result = run_multi_source_demo(
1✔
2552
            [
1✔
2553
                "--reset".to_string(),
1✔
2554
                "--split-store-path".to_string(),
1✔
2555
                split_store_path.to_string_lossy().to_string(),
1✔
2556
            ]
1✔
2557
            .into_iter(),
1✔
2558
            |_| Ok(()),
1✔
2559
            empty_dyn_sources,
2560
        );
2561

2562
        let err = result.unwrap_err().to_string();
1✔
2563
        assert!(err.contains("failed to remove split store"));
1✔
2564
    }
1✔
2565

2566
    #[test]
2567
    fn print_summary_helpers_accept_empty_iterators() {
1✔
2568
        print_source_summary("empty summary", std::iter::empty::<&str>());
1✔
2569
        print_recipe_context_by_source("empty recipe context", std::iter::empty::<(&str, &str)>());
1✔
2570
    }
1✔
2571

2572
    #[cfg(feature = "extended-metrics")]
2573
    #[test]
2574
    fn metric_mean_median_handles_even_length_inputs() {
1✔
2575
        let mut vals = [1.0, 4.0, 2.0, 3.0];
1✔
2576
        let (mean, median) = metric_mean_median(&mut vals);
1✔
2577
        assert!((mean - 2.5).abs() < 1e-6);
1✔
2578
        assert!((median - 2.5).abs() < 1e-6);
1✔
2579
    }
1✔
2580

2581
    #[cfg(feature = "extended-metrics")]
2582
    #[test]
2583
    fn metric_mean_median_handles_odd_length_inputs() {
1✔
2584
        let mut vals = [3.0, 1.0, 2.0];
1✔
2585
        let (mean, median) = metric_mean_median(&mut vals);
1✔
2586
        assert!((mean - 2.0).abs() < 1e-6);
1✔
2587
        assert!((median - 2.0).abs() < 1e-6);
1✔
2588
    }
1✔
2589

2590
    #[cfg(feature = "extended-metrics")]
2591
    #[test]
2592
    fn print_metric_summary_includes_multi_source_aggregate() {
1✔
2593
        let source_data = HashMap::from([
1✔
2594
            (
1✔
2595
                "source_a".to_string(),
1✔
2596
                vec![(0.9, 0.8, 0.2, 0.1, 0.7), (0.8, 0.7, 0.3, 0.2, 0.8)],
1✔
2597
            ),
1✔
2598
            (
1✔
2599
                "source_b".to_string(),
1✔
2600
                vec![(0.7, 0.6, 0.4, 0.3, 0.5), (0.6, 0.5, 0.5, 0.4, 0.6)],
1✔
2601
            ),
1✔
2602
        ]);
1✔
2603

2604
        print_metric_summary(&source_data);
1✔
2605
    }
1✔
2606
}
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