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

jzombie / rust-triplets / 22337622218

24 Feb 2026 05:13AM UTC coverage: 91.641% (-1.0%) from 92.675%
22337622218

Pull #7

github

web-flow
Merge ed641cc4f into 980559192
Pull Request #7: Add HF source

3602 of 4094 new or added lines in 6 files covered. (87.98%)

99 existing lines in 3 files now uncovered.

13057 of 14248 relevant lines covered (91.64%)

2770.99 hits per line

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

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

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

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

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

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

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

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

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

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

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

159
/// Run the capacity-estimation CLI with injectable root resolution/source builders.
160
pub fn run_estimate_capacity<R, Resolve, Build, I>(
2✔
161
    args_iter: I,
2✔
162
    resolve_roots: Resolve,
2✔
163
    build_sources: Build,
2✔
164
) -> Result<(), Box<dyn Error>>
2✔
165
where
2✔
166
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
2✔
167
    Build: FnOnce(&R) -> Vec<DynSource>,
2✔
168
    I: Iterator<Item = String>,
2✔
169
{
170
    init_example_tracing();
2✔
171

172
    let Some(cli) = parse_cli::<EstimateCapacityCli, _>(
2✔
173
        std::iter::once("estimate_capacity".to_string()).chain(args_iter),
2✔
174
    )?
×
175
    else {
UNCOV
176
        return Ok(());
×
177
    };
178

179
    let roots = resolve_roots(cli.source_roots)?;
2✔
180

181
    let config = SamplerConfig {
2✔
182
        seed: cli.seed,
2✔
183
        split: cli.split,
2✔
184
        ..SamplerConfig::default()
2✔
185
    };
2✔
186

187
    let sources = build_sources(&roots);
2✔
188

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

209
    let mut per_source_split_counts: HashMap<(String, SplitLabel), u128> = HashMap::new();
1✔
210
    let mut split_record_counts: HashMap<SplitLabel, u128> = HashMap::new();
1✔
211

212
    for source in &inventories {
1✔
213
        let counts = split_counts_for_total(source.reported_records, cli.split);
1✔
214
        for (label, count) in counts {
3✔
215
            per_source_split_counts.insert((source.source_id.clone(), label), count);
3✔
216
            *split_record_counts.entry(label).or_insert(0) += count;
3✔
217
        }
3✔
218
    }
219

220
    let mut totals_by_split: HashMap<SplitLabel, CapacityTotals> = HashMap::new();
1✔
221
    let mut totals_by_source_and_split: HashMap<(String, SplitLabel), CapacityTotals> =
1✔
222
        HashMap::new();
1✔
223

224
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
3✔
225
        let mut totals = CapacityTotals::default();
3✔
226

227
        for source in &inventories {
3✔
228
            let source_split_records = per_source_split_counts
3✔
229
                .get(&(source.source_id.clone(), split_label))
3✔
230
                .copied()
3✔
231
                .unwrap_or(0);
3✔
232

3✔
233
            let triplet_recipes = &source.triplet_recipes;
3✔
234
            let text_recipes = resolve_text_recipes_for_source(&config, triplet_recipes);
3✔
235

3✔
236
            let capacity = estimate_source_split_capacity_from_counts(
3✔
237
                source_split_records,
3✔
238
                triplet_recipes,
3✔
239
                &text_recipes,
3✔
240
            );
3✔
241

3✔
242
            totals_by_source_and_split.insert((source.source_id.clone(), split_label), capacity);
3✔
243

3✔
244
            totals.triplets += capacity.triplets;
3✔
245
            totals.effective_triplets += capacity.effective_triplets;
3✔
246
            totals.pairs += capacity.pairs;
3✔
247
            totals.text_samples += capacity.text_samples;
3✔
248
        }
3✔
249

250
        totals_by_split.insert(split_label, totals);
3✔
251
    }
252

253
    println!("=== capacity estimate (length-only) ===");
1✔
254
    println!("mode: metadata-only (no source.refresh calls)");
1✔
255
    println!("classification: heuristic approximation (not exact)");
1✔
256
    println!("split seed: {}", cli.seed);
1✔
257
    println!(
1✔
258
        "split ratios: train={:.4}, validation={:.4}, test={:.4}",
259
        cli.split.train, cli.split.validation, cli.split.test
260
    );
261
    println!();
1✔
262

263
    println!("[SOURCES]");
1✔
264
    for source in &inventories {
1✔
265
        println!(
1✔
266
            "  {} => reported records: {}",
1✔
267
            source.source_id,
1✔
268
            format_u128_with_commas(source.reported_records)
1✔
269
        );
1✔
270
    }
1✔
271
    println!();
1✔
272

273
    println!("[PER SOURCE BREAKDOWN]");
1✔
274
    for source in &inventories {
1✔
275
        println!("  {}", source.source_id);
1✔
276
        let mut source_grand = CapacityTotals::default();
1✔
277
        let mut source_total_records = 0u128;
1✔
278
        for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
3✔
279
            let split_records = per_source_split_counts
3✔
280
                .get(&(source.source_id.clone(), split_label))
3✔
281
                .copied()
3✔
282
                .unwrap_or(0);
3✔
283
            source_total_records = source_total_records.saturating_add(split_records);
3✔
284
            let split_longest_records = inventories
3✔
285
                .iter()
3✔
286
                .map(|candidate| {
3✔
287
                    per_source_split_counts
3✔
288
                        .get(&(candidate.source_id.clone(), split_label))
3✔
289
                        .copied()
3✔
290
                        .unwrap_or(0)
3✔
291
                })
3✔
292
                .max()
3✔
293
                .unwrap_or(0);
3✔
294
            let totals = totals_by_source_and_split
3✔
295
                .get(&(source.source_id.clone(), split_label))
3✔
296
                .copied()
3✔
297
                .unwrap_or_default();
3✔
298
            source_grand.triplets += totals.triplets;
3✔
299
            source_grand.effective_triplets += totals.effective_triplets;
3✔
300
            source_grand.pairs += totals.pairs;
3✔
301
            source_grand.text_samples += totals.text_samples;
3✔
302
            println!("    [{:?}]", split_label);
3✔
303
            println!("      records: {}", format_u128_with_commas(split_records));
3✔
304
            println!(
3✔
305
                "      triplet combinations: {}",
306
                format_u128_with_commas(totals.triplets)
3✔
307
            );
308
            println!(
3✔
309
                "      effective sampled triplets (p={}, k={}): {}",
310
                EFFECTIVE_POSITIVES_PER_ANCHOR,
311
                EFFECTIVE_NEGATIVES_PER_ANCHOR,
312
                format_u128_with_commas(totals.effective_triplets)
3✔
313
            );
314
            println!(
3✔
315
                "      pair combinations:    {}",
316
                format_u128_with_commas(totals.pairs)
3✔
317
            );
318
            println!(
3✔
319
                "      text samples:         {}",
320
                format_u128_with_commas(totals.text_samples)
3✔
321
            );
322
            println!(
3✔
323
                "      replay factor vs longest source: {}",
324
                format_replay_factor(split_longest_records, split_records)
3✔
325
            );
326
        }
327
        let longest_source_total = inventories
1✔
328
            .iter()
1✔
329
            .map(|candidate| candidate.reported_records)
1✔
330
            .max()
1✔
331
            .unwrap_or(0);
1✔
332
        println!("    [ALL SPLITS FOR SOURCE]");
1✔
333
        println!(
1✔
334
            "      triplet combinations: {}",
335
            format_u128_with_commas(source_grand.triplets)
1✔
336
        );
337
        println!(
1✔
338
            "      effective sampled triplets (p={}, k={}): {}",
339
            EFFECTIVE_POSITIVES_PER_ANCHOR,
340
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
341
            format_u128_with_commas(source_grand.effective_triplets)
1✔
342
        );
343
        println!(
1✔
344
            "      pair combinations:    {}",
345
            format_u128_with_commas(source_grand.pairs)
1✔
346
        );
347
        println!(
1✔
348
            "      text samples:         {}",
349
            format_u128_with_commas(source_grand.text_samples)
1✔
350
        );
351
        println!(
1✔
352
            "      replay factor vs longest source: {}",
353
            format_replay_factor(longest_source_total, source_total_records)
1✔
354
        );
355
        println!();
1✔
356
    }
357

358
    let mut grand = CapacityTotals::default();
1✔
359
    for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
3✔
360
        let record_count = split_record_counts.get(&split_label).copied().unwrap_or(0);
3✔
361
        let totals = totals_by_split
3✔
362
            .get(&split_label)
3✔
363
            .copied()
3✔
364
            .unwrap_or_default();
3✔
365

3✔
366
        grand.triplets += totals.triplets;
3✔
367
        grand.effective_triplets += totals.effective_triplets;
3✔
368
        grand.pairs += totals.pairs;
3✔
369
        grand.text_samples += totals.text_samples;
3✔
370

3✔
371
        println!("[{:?}]", split_label);
3✔
372
        println!("  records: {}", format_u128_with_commas(record_count));
3✔
373
        println!(
3✔
374
            "  triplet combinations: {}",
3✔
375
            format_u128_with_commas(totals.triplets)
3✔
376
        );
3✔
377
        println!(
3✔
378
            "  effective sampled triplets (p={}, k={}): {}",
3✔
379
            EFFECTIVE_POSITIVES_PER_ANCHOR,
3✔
380
            EFFECTIVE_NEGATIVES_PER_ANCHOR,
3✔
381
            format_u128_with_commas(totals.effective_triplets)
3✔
382
        );
3✔
383
        println!(
3✔
384
            "  pair combinations:    {}",
3✔
385
            format_u128_with_commas(totals.pairs)
3✔
386
        );
3✔
387
        println!(
3✔
388
            "  text samples:         {}",
3✔
389
            format_u128_with_commas(totals.text_samples)
3✔
390
        );
3✔
391
        println!();
3✔
392
    }
3✔
393

394
    println!("[ALL SPLITS TOTAL]");
1✔
395
    println!(
1✔
396
        "  triplet combinations: {}",
397
        format_u128_with_commas(grand.triplets)
1✔
398
    );
399
    println!(
1✔
400
        "  effective sampled triplets (p={}, k={}): {}",
401
        EFFECTIVE_POSITIVES_PER_ANCHOR,
402
        EFFECTIVE_NEGATIVES_PER_ANCHOR,
403
        format_u128_with_commas(grand.effective_triplets)
1✔
404
    );
405
    println!(
1✔
406
        "  pair combinations:    {}",
407
        format_u128_with_commas(grand.pairs)
1✔
408
    );
409
    println!(
1✔
410
        "  text samples:         {}",
411
        format_u128_with_commas(grand.text_samples)
1✔
412
    );
413
    println!();
1✔
414
    println!(
1✔
415
        "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."
416
    );
417
    println!(
1✔
418
        "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.",
419
        EFFECTIVE_POSITIVES_PER_ANCHOR, EFFECTIVE_NEGATIVES_PER_ANCHOR
420
    );
421
    println!(
1✔
422
        "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."
423
    );
424

425
    Ok(())
1✔
426
}
2✔
427

428
/// Run the multi-source demo CLI with injectable root resolution/source builders.
429
pub fn run_multi_source_demo<R, Resolve, Build, I>(
4✔
430
    args_iter: I,
4✔
431
    resolve_roots: Resolve,
4✔
432
    build_sources: Build,
4✔
433
) -> Result<(), Box<dyn Error>>
4✔
434
where
4✔
435
    Resolve: FnOnce(Vec<String>) -> Result<R, Box<dyn Error>>,
4✔
436
    Build: FnOnce(&R) -> Vec<DynSource>,
4✔
437
    I: Iterator<Item = String>,
4✔
438
{
439
    init_example_tracing();
4✔
440

441
    let Some(cli) = parse_cli::<MultiSourceDemoCli, _>(
4✔
442
        std::iter::once("multi_source_demo".to_string()).chain(args_iter),
4✔
UNCOV
443
    )?
×
444
    else {
445
        return Ok(());
×
446
    };
447

448
    let roots = resolve_roots(cli.source_roots)?;
4✔
449

450
    let mut config = SamplerConfig::default();
4✔
451
    config.seed = cli.seed.unwrap_or(config.seed);
4✔
452
    config.batch_size = cli.batch_size;
4✔
453
    config.chunking = Default::default();
4✔
454
    let selected_split = cli.split.map(Into::into).unwrap_or(SplitLabel::Train);
4✔
455
    config.split = SplitRatios::default();
4✔
456
    config.allowed_splits = vec![selected_split];
4✔
457
    let chunking = config.chunking.clone();
4✔
458

459
    let split_store_path = if let Some(path) = cli.split_store_path {
4✔
UNCOV
460
        path
×
461
    } else if let Some(dir) = cli.split_store_dir {
4✔
462
        FileSplitStore::default_path_in_dir(dir)
4✔
463
    } else {
UNCOV
464
        FileSplitStore::default_path()
×
465
    };
466

467
    println!(
4✔
468
        "Persisting split assignments and epoch state to {}",
469
        split_store_path.display()
4✔
470
    );
471
    let split_store = Arc::new(FileSplitStore::open(&split_store_path, config.split, 99)?);
4✔
472
    let sampler = PairSampler::new(config, split_store.clone());
4✔
473
    for source in build_sources(&roots) {
4✔
474
        sampler.register_source(source);
4✔
475
    }
4✔
476

477
    if cli.show_pair_samples {
4✔
478
        match sampler.next_pair_batch(selected_split) {
1✔
479
            Ok(pair_batch) => {
×
480
                if pair_batch.pairs.is_empty() {
×
UNCOV
481
                    println!("Pair sampling produced no results.");
×
UNCOV
482
                } else {
×
UNCOV
483
                    print_pair_batch(&chunking, &pair_batch, split_store.as_ref());
×
UNCOV
484
                }
×
UNCOV
485
                sampler.persist_state()?;
×
486
            }
487
            Err(SamplerError::Exhausted(name)) => {
1✔
488
                eprintln!(
1✔
489
                    "Pair sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
1✔
490
                    name
1✔
491
                );
1✔
492
            }
1✔
UNCOV
493
            Err(err) => return Err(err.into()),
×
494
        }
495
    } else if cli.show_text_samples {
3✔
496
        match sampler.next_text_batch(selected_split) {
1✔
UNCOV
497
            Ok(text_batch) => {
×
UNCOV
498
                if text_batch.samples.is_empty() {
×
UNCOV
499
                    println!(
×
500
                        "Text sampling produced no results. Ensure each source has eligible sections."
×
UNCOV
501
                    );
×
UNCOV
502
                } else {
×
UNCOV
503
                    print_text_batch(&chunking, &text_batch, split_store.as_ref());
×
504
                }
×
505
                sampler.persist_state()?;
×
506
            }
507
            Err(SamplerError::Exhausted(name)) => {
1✔
508
                eprintln!(
1✔
509
                    "Text sampler exhausted selector '{}'. Ensure matching sections exist.",
1✔
510
                    name
1✔
511
                );
1✔
512
            }
1✔
UNCOV
513
            Err(err) => return Err(err.into()),
×
514
        }
515
    } else if cli.list_text_recipes {
2✔
516
        let recipes = sampler.text_recipes();
1✔
517
        if recipes.is_empty() {
1✔
UNCOV
518
            println!(
×
UNCOV
519
                "No text recipes registered. Ensure your sources expose triplet selectors or configure text_recipes explicitly."
×
520
            );
×
521
        } else {
1✔
522
            print_text_recipes(&recipes);
1✔
523
        }
1✔
524
    } else {
525
        match sampler.next_triplet_batch(selected_split) {
1✔
526
            Ok(triplet_batch) => {
×
527
                if triplet_batch.triplets.is_empty() {
×
UNCOV
528
                    println!(
×
UNCOV
529
                        "Triplet sampling produced no results. Ensure multiple records per source exist."
×
UNCOV
530
                    );
×
UNCOV
531
                } else {
×
UNCOV
532
                    print_triplet_batch(&chunking, &triplet_batch, split_store.as_ref());
×
533
                }
×
534
                sampler.persist_state()?;
×
535
            }
536
            Err(SamplerError::Exhausted(name)) => {
1✔
537
                eprintln!(
1✔
538
                    "Triplet sampler exhausted recipe '{}'. Ensure both positive and negative examples exist.",
1✔
539
                    name
1✔
540
                );
1✔
541
            }
1✔
UNCOV
542
            Err(err) => return Err(err.into()),
×
543
        }
544
    }
545

546
    Ok(())
4✔
547
}
4✔
548

549
fn parse_positive_usize(raw: &str) -> Result<usize, String> {
8✔
550
    let parsed = raw.parse::<usize>().map_err(|_| {
8✔
551
        format!(
1✔
552
            "Could not parse --batch-size value '{}' as a positive integer",
553
            raw
554
        )
555
    })?;
1✔
556
    if parsed == 0 {
7✔
557
        return Err("--batch-size must be greater than zero".to_string());
2✔
558
    }
5✔
559
    Ok(parsed)
5✔
560
}
8✔
561

562
fn parse_cli<T, I>(args: I) -> Result<Option<T>, Box<dyn Error>>
10✔
563
where
10✔
564
    T: Parser,
10✔
565
    I: IntoIterator,
10✔
566
    I::Item: Into<std::ffi::OsString> + Clone,
10✔
567
{
568
    match T::try_parse_from(args) {
10✔
569
        Ok(cli) => Ok(Some(cli)),
6✔
570
        Err(err) => match err.kind() {
4✔
571
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
572
                err.print()?;
2✔
573
                Ok(None)
2✔
574
            }
575
            _ => Err(err.into()),
2✔
576
        },
577
    }
578
}
10✔
579

580
fn parse_split_ratios_arg(raw: &str) -> Result<SplitRatios, String> {
6✔
581
    let parts: Vec<&str> = raw.split(',').collect();
6✔
582
    if parts.len() != 3 {
6✔
583
        return Err("--split-ratios expects exactly 3 comma-separated values".to_string());
1✔
584
    }
5✔
585
    let train = parts[0]
5✔
586
        .trim()
5✔
587
        .parse::<f32>()
5✔
588
        .map_err(|_| format!("invalid train ratio '{}': must be a float", parts[0].trim()))?;
5✔
589
    let validation = parts[1].trim().parse::<f32>().map_err(|_| {
5✔
UNCOV
590
        format!(
×
591
            "invalid validation ratio '{}': must be a float",
UNCOV
592
            parts[1].trim()
×
593
        )
UNCOV
594
    })?;
×
595
    let test = parts[2]
5✔
596
        .trim()
5✔
597
        .parse::<f32>()
5✔
598
        .map_err(|_| format!("invalid test ratio '{}': must be a float", parts[2].trim()))?;
5✔
599
    let ratios = SplitRatios {
5✔
600
        train,
5✔
601
        validation,
5✔
602
        test,
5✔
603
    };
5✔
604
    let sum = ratios.train + ratios.validation + ratios.test;
5✔
605
    if (sum - 1.0).abs() > 1e-5 {
5✔
606
        return Err(format!(
1✔
607
            "split ratios must sum to 1.0, got {:.6} (train={}, validation={}, test={})",
1✔
608
            sum, ratios.train, ratios.validation, ratios.test
1✔
609
        ));
1✔
610
    }
4✔
611
    if ratios.train < 0.0 || ratios.validation < 0.0 || ratios.test < 0.0 {
4✔
612
        return Err("split ratios must be non-negative".to_string());
1✔
613
    }
3✔
614
    Ok(ratios)
3✔
615
}
6✔
616

617
fn print_triplet_batch(
1✔
618
    strategy: &ChunkingStrategy,
1✔
619
    batch: &TripletBatch,
1✔
620
    split_store: &impl SplitStore,
1✔
621
) {
1✔
622
    println!("=== triplet batch ===");
1✔
623
    for (idx, triplet) in batch.triplets.iter().enumerate() {
1✔
624
        println!("--- triplet #{} ---", idx);
1✔
625
        println!("recipe       : {}", triplet.recipe);
1✔
626
        println!("sample_weight: {:.4}", triplet.weight);
1✔
627
        if let Some(instr) = &triplet.instruction {
1✔
628
            println!("instruction shown to model:\n{}\n", instr);
1✔
629
        }
1✔
630
        print_chunk_block("ANCHOR", &triplet.anchor, strategy, split_store);
1✔
631
        print_chunk_block("POSITIVE", &triplet.positive, strategy, split_store);
1✔
632
        print_chunk_block("NEGATIVE", &triplet.negative, strategy, split_store);
1✔
633
    }
634
    print_source_summary(
1✔
635
        "triplet anchors",
1✔
636
        batch
1✔
637
            .triplets
1✔
638
            .iter()
1✔
639
            .map(|triplet| triplet.anchor.record_id.as_str()),
1✔
640
    );
641
    print_recipe_summary_by_source(
1✔
642
        "triplet recipes by source",
1✔
643
        batch
1✔
644
            .triplets
1✔
645
            .iter()
1✔
646
            .map(|triplet| (triplet.anchor.record_id.as_str(), triplet.recipe.as_str())),
1✔
647
    );
648
}
1✔
649

650
fn print_text_batch(strategy: &ChunkingStrategy, batch: &TextBatch, split_store: &impl SplitStore) {
1✔
651
    println!("=== text batch ===");
1✔
652
    for (idx, sample) in batch.samples.iter().enumerate() {
1✔
653
        println!("--- sample #{} ---", idx);
1✔
654
        println!("recipe       : {}", sample.recipe);
1✔
655
        println!("sample_weight: {:.4}", sample.weight);
1✔
656
        if let Some(instr) = &sample.instruction {
1✔
657
            println!("instruction shown to model:\n{}\n", instr);
1✔
658
        }
1✔
659
        print_chunk_block("TEXT", &sample.chunk, strategy, split_store);
1✔
660
    }
661
    print_source_summary(
1✔
662
        "text samples",
1✔
663
        batch
1✔
664
            .samples
1✔
665
            .iter()
1✔
666
            .map(|sample| sample.chunk.record_id.as_str()),
1✔
667
    );
668
    print_recipe_summary_by_source(
1✔
669
        "text recipes by source",
1✔
670
        batch
1✔
671
            .samples
1✔
672
            .iter()
1✔
673
            .map(|sample| (sample.chunk.record_id.as_str(), sample.recipe.as_str())),
1✔
674
    );
675
}
1✔
676

677
fn print_pair_batch(
1✔
678
    strategy: &ChunkingStrategy,
1✔
679
    batch: &SampleBatch,
1✔
680
    split_store: &impl SplitStore,
1✔
681
) {
1✔
682
    println!("=== pair batch ===");
1✔
683
    for (idx, pair) in batch.pairs.iter().enumerate() {
1✔
684
        println!("--- pair #{} ---", idx);
1✔
685
        println!("recipe       : {}", pair.recipe);
1✔
686
        println!("label        : {:?}", pair.label);
1✔
687
        if let Some(reason) = &pair.reason {
1✔
688
            println!("reason       : {}", reason);
1✔
689
        }
1✔
690
        print_chunk_block("ANCHOR", &pair.anchor, strategy, split_store);
1✔
691
        print_chunk_block("OTHER", &pair.positive, strategy, split_store);
1✔
692
    }
693
    print_source_summary(
1✔
694
        "pair anchors",
1✔
695
        batch
1✔
696
            .pairs
1✔
697
            .iter()
1✔
698
            .map(|pair| pair.anchor.record_id.as_str()),
1✔
699
    );
700
    print_recipe_summary_by_source(
1✔
701
        "pair recipes by source",
1✔
702
        batch
1✔
703
            .pairs
1✔
704
            .iter()
1✔
705
            .map(|pair| (pair.anchor.record_id.as_str(), pair.recipe.as_str())),
1✔
706
    );
707
}
1✔
708

709
fn print_text_recipes(recipes: &[TextRecipe]) {
2✔
710
    println!("=== available text recipes ===");
2✔
711
    for recipe in recipes {
4✔
712
        println!(
4✔
713
            "- {} (weight: {:.3}) selector={:?}",
714
            recipe.name, recipe.weight, recipe.selector
715
        );
716
        if let Some(instr) = &recipe.instruction {
4✔
717
            println!("  instruction: {}", instr);
1✔
718
        }
3✔
719
    }
720
}
2✔
721

722
trait ChunkDebug {
723
    fn view_name(&self) -> String;
724
}
725

726
impl ChunkDebug for RecordChunk {
727
    fn view_name(&self) -> String {
6✔
728
        match &self.view {
6✔
729
            ChunkView::Window {
730
                index,
4✔
731
                span,
4✔
732
                overlap,
4✔
733
                start_ratio,
4✔
734
            } => format!(
4✔
735
                "window#index={} span={} overlap={} start_ratio={:.3} tokens={}",
736
                index, span, overlap, start_ratio, self.tokens_estimate
737
            ),
738
            ChunkView::SummaryFallback { strategy, .. } => {
2✔
739
                format!("summary:{} tokens={}", strategy, self.tokens_estimate)
2✔
740
            }
741
        }
742
    }
6✔
743
}
744

745
fn print_chunk_block(
6✔
746
    title: &str,
6✔
747
    chunk: &RecordChunk,
6✔
748
    strategy: &ChunkingStrategy,
6✔
749
    split_store: &impl SplitStore,
6✔
750
) {
6✔
751
    let chunk_weight = chunk_weight(strategy, chunk);
6✔
752
    let split = split_store
6✔
753
        .label_for(&chunk.record_id)
6✔
754
        .map(|label| format!("{:?}", label))
6✔
755
        .unwrap_or_else(|| "Unknown".to_string());
6✔
756
    println!("--- {} ---", title);
6✔
757
    println!("split        : {}", split);
6✔
758
    println!("view         : {}", chunk.view_name());
6✔
759
    println!("chunk_weight : {:.4}", chunk_weight);
6✔
760
    println!("record_id    : {}", chunk.record_id);
6✔
761
    println!("section_idx  : {}", chunk.section_idx);
6✔
762
    println!("token_est    : {}", chunk.tokens_estimate);
6✔
763
    println!("model_input (exact text sent to the model):");
6✔
764
    println!(
6✔
765
        "<<< BEGIN MODEL TEXT >>>\n{}\n<<< END MODEL TEXT >>>\n",
766
        chunk.text
767
    );
768
}
6✔
769

770
fn print_source_summary<'a, I>(label: &str, ids: I)
3✔
771
where
3✔
772
    I: Iterator<Item = &'a str>,
3✔
773
{
774
    let mut counts: HashMap<SourceId, usize> = HashMap::new();
3✔
775
    for id in ids {
3✔
776
        let source = extract_source(id);
3✔
777
        *counts.entry(source).or_insert(0) += 1;
3✔
778
    }
3✔
779
    if counts.is_empty() {
3✔
UNCOV
780
        return;
×
781
    }
3✔
782
    let skew = source_skew(&counts);
3✔
783
    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
3✔
784
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
3✔
785
    println!("--- {} by source ---", label);
3✔
786
    if let Some(skew) = skew {
3✔
787
        for entry in &skew.per_source {
3✔
788
            println!(
3✔
789
                "{}: count={} share={:.2}",
3✔
790
                entry.source, entry.count, entry.share
3✔
791
            );
3✔
792
        }
3✔
793
        println!(
3✔
794
            "skew: sources={} total={} min={} max={} mean={:.2} ratio={:.2}",
795
            skew.sources, skew.total, skew.min, skew.max, skew.mean, skew.ratio
796
        );
797
    } else {
UNCOV
798
        for (source, count) in &entries {
×
UNCOV
799
            println!("{source}: count={count}");
×
UNCOV
800
        }
×
801
    }
802
}
3✔
803

804
fn print_recipe_summary_by_source<'a, I>(label: &str, entries: I)
3✔
805
where
3✔
806
    I: Iterator<Item = (&'a str, &'a str)>,
3✔
807
{
808
    let mut counts: HashMap<SourceId, HashMap<String, usize>> = HashMap::new();
3✔
809
    for (record_id, recipe) in entries {
3✔
810
        let source = extract_source(record_id);
3✔
811
        let entry = counts
3✔
812
            .entry(source)
3✔
813
            .or_default()
3✔
814
            .entry(recipe.to_string())
3✔
815
            .or_insert(0);
3✔
816
        *entry += 1;
3✔
817
    }
3✔
818
    if counts.is_empty() {
3✔
UNCOV
819
        return;
×
820
    }
3✔
821
    let mut sources: Vec<(SourceId, HashMap<String, usize>)> = counts.into_iter().collect();
3✔
822
    sources.sort_by(|a, b| a.0.cmp(&b.0));
3✔
823
    println!("--- {} ---", label);
3✔
824
    for (source, recipes) in sources {
3✔
825
        println!("{source}");
3✔
826
        let mut entries: Vec<(String, usize)> = recipes.into_iter().collect();
3✔
827
        entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
3✔
828
        for (recipe, count) in entries {
3✔
829
            println!("  - {recipe}={count}");
3✔
830
        }
3✔
831
    }
832
}
3✔
833

834
fn extract_source(record_id: &str) -> SourceId {
8✔
835
    record_id
8✔
836
        .split_once("::")
8✔
837
        .map(|(source, _)| source.to_string())
8✔
838
        .unwrap_or_else(|| "unknown".to_string())
8✔
839
}
8✔
840

841
#[cfg(test)]
842
mod tests {
843
    use super::*;
844
    use crate::DeterministicSplitStore;
845
    use crate::data::SectionRole;
846
    use crate::source::{SourceCursor, SourceSnapshot};
847
    use chrono::Utc;
848
    use tempfile::tempdir;
849

850
    /// Minimal in-memory `DataSource` test double for example app tests.
851
    struct TestSource {
852
        id: String,
853
        count: Option<u128>,
854
        recipes: Vec<TripletRecipe>,
855
    }
856

857
    impl DataSource for TestSource {
858
        fn id(&self) -> &str {
66✔
859
            &self.id
66✔
860
        }
66✔
861

862
        fn refresh(
15✔
863
            &self,
15✔
864
            _cursor: Option<&SourceCursor>,
15✔
865
            _limit: Option<usize>,
15✔
866
        ) -> Result<SourceSnapshot, SamplerError> {
15✔
867
            Ok(SourceSnapshot {
15✔
868
                records: Vec::new(),
15✔
869
                cursor: SourceCursor {
15✔
870
                    last_seen: Utc::now(),
15✔
871
                    revision: 0,
15✔
872
                },
15✔
873
            })
15✔
874
        }
15✔
875

876
        fn reported_record_count(&self) -> Result<u128, SamplerError> {
2✔
877
            self.count.ok_or_else(|| SamplerError::SourceInconsistent {
2✔
878
                source_id: self.id.clone(),
1✔
879
                details: "test source has no configured exact count".to_string(),
1✔
880
            })
1✔
881
        }
2✔
882

883
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
6✔
884
            self.recipes.clone()
6✔
885
        }
6✔
886
    }
887

888
    fn default_recipe(name: &str) -> TripletRecipe {
6✔
889
        TripletRecipe {
6✔
890
            name: name.to_string().into(),
6✔
891
            anchor: crate::config::Selector::Role(SectionRole::Anchor),
6✔
892
            positive_selector: crate::config::Selector::Role(SectionRole::Context),
6✔
893
            negative_selector: crate::config::Selector::Role(SectionRole::Context),
6✔
894
            negative_strategy: crate::config::NegativeStrategy::WrongArticle,
6✔
895
            weight: 1.0,
6✔
896
            instruction: None,
6✔
897
        }
6✔
898
    }
6✔
899

900
    #[test]
901
    fn parse_helpers_validate_inputs() {
1✔
902
        assert_eq!(parse_positive_usize("2").unwrap(), 2);
1✔
903
        assert!(parse_positive_usize("0").is_err());
1✔
904
        assert!(parse_positive_usize("abc").is_err());
1✔
905

906
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
907
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
908
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
909
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
910
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
911
    }
1✔
912

913
    #[test]
914
    fn parse_cli_handles_help_and_invalid_args() {
1✔
915
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
916
        assert!(help.is_none());
1✔
917

918
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
919
        assert!(err.is_err());
1✔
920
    }
1✔
921

922
    #[test]
923
    fn run_estimate_capacity_succeeds_with_reported_counts() {
1✔
924
        let result = run_estimate_capacity(
1✔
925
            std::iter::empty::<String>(),
1✔
926
            |roots| {
1✔
927
                assert!(roots.is_empty());
1✔
928
                Ok(())
1✔
929
            },
1✔
930
            |_| {
1✔
931
                vec![Box::new(TestSource {
1✔
932
                    id: "source_a".into(),
1✔
933
                    count: Some(12),
1✔
934
                    recipes: vec![default_recipe("r1")],
1✔
935
                }) as DynSource]
1✔
936
            },
1✔
937
        );
938

939
        assert!(result.is_ok());
1✔
940
    }
1✔
941

942
    #[test]
943
    fn run_estimate_capacity_errors_when_source_count_missing() {
1✔
944
        let result = run_estimate_capacity(
1✔
945
            std::iter::empty::<String>(),
1✔
946
            |_| Ok(()),
1✔
947
            |_| {
1✔
948
                vec![Box::new(TestSource {
1✔
949
                    id: "source_missing".into(),
1✔
950
                    count: None,
1✔
951
                    recipes: vec![default_recipe("r1")],
1✔
952
                }) as DynSource]
1✔
953
            },
1✔
954
        );
955

956
        let err = result.unwrap_err().to_string();
1✔
957
        assert!(err.contains("failed to report exact record count"));
1✔
958
    }
1✔
959

960
    #[test]
961
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
962
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
963
        assert!(help.is_none());
1✔
964

965
        let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1✔
966
        assert!(err.is_err());
1✔
967
    }
1✔
968

969
    #[test]
970
    fn run_multi_source_demo_list_text_recipes_path_succeeds() {
1✔
971
        let dir = tempdir().unwrap();
1✔
972
        let mut args = vec![
1✔
973
            "--list-text-recipes".to_string(),
1✔
974
            "--split-store-dir".to_string(),
1✔
975
            dir.path().to_string_lossy().to_string(),
1✔
976
        ];
977
        let result = run_multi_source_demo(
1✔
978
            args.drain(..),
1✔
979
            |_| Ok(()),
1✔
980
            |_| {
1✔
981
                vec![Box::new(TestSource {
1✔
982
                    id: "source_for_recipes".into(),
1✔
983
                    count: Some(10),
1✔
984
                    recipes: vec![default_recipe("recipe_a")],
1✔
985
                }) as DynSource]
1✔
986
            },
1✔
987
        );
988

989
        assert!(result.is_ok());
1✔
990
    }
1✔
991

992
    #[test]
993
    fn run_multi_source_demo_sampling_modes_handle_empty_sources() {
1✔
994
        for mode in [
3✔
995
            vec!["--pair-batch".to_string()],
1✔
996
            vec!["--text-recipes".to_string()],
1✔
997
            vec![],
1✔
998
        ] {
1✔
999
            let dir = tempdir().unwrap();
3✔
1000
            let mut args = mode;
3✔
1001
            args.push("--split-store-dir".to_string());
3✔
1002
            args.push(dir.path().to_string_lossy().to_string());
3✔
1003
            args.push("--split".to_string());
3✔
1004
            args.push("validation".to_string());
3✔
1005

1006
            let result = run_multi_source_demo(
3✔
1007
                args.into_iter(),
3✔
1008
                |_| Ok(()),
3✔
1009
                |_| {
3✔
1010
                    vec![Box::new(TestSource {
3✔
1011
                        id: "source_empty".into(),
3✔
1012
                        count: Some(0),
3✔
1013
                        recipes: vec![default_recipe("recipe_empty")],
3✔
1014
                    }) as DynSource]
3✔
1015
                },
3✔
1016
            );
1017

1018
            assert!(result.is_ok());
3✔
1019
        }
1020
    }
1✔
1021

1022
    #[test]
1023
    fn print_helpers_and_extract_source_cover_paths() {
1✔
1024
        let split = SplitRatios::default();
1✔
1025
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
1026
        let strategy = ChunkingStrategy::default();
1✔
1027

1028
        let anchor = RecordChunk {
1✔
1029
            record_id: "source_a::rec1".to_string(),
1✔
1030
            section_idx: 0,
1✔
1031
            view: ChunkView::Window {
1✔
1032
                index: 1,
1✔
1033
                overlap: 2,
1✔
1034
                span: 12,
1✔
1035
                start_ratio: 0.25,
1✔
1036
            },
1✔
1037
            text: "anchor text".to_string(),
1✔
1038
            tokens_estimate: 8,
1✔
1039
            quality: crate::data::QualityScore { trust: 0.9 },
1✔
1040
        };
1✔
1041
        let positive = RecordChunk {
1✔
1042
            record_id: "source_a::rec2".to_string(),
1✔
1043
            section_idx: 1,
1✔
1044
            view: ChunkView::SummaryFallback {
1✔
1045
                strategy: "summary".to_string(),
1✔
1046
                weight: 0.7,
1✔
1047
            },
1✔
1048
            text: "positive text".to_string(),
1✔
1049
            tokens_estimate: 6,
1✔
1050
            quality: crate::data::QualityScore { trust: 0.8 },
1✔
1051
        };
1✔
1052
        let negative = RecordChunk {
1✔
1053
            record_id: "source_b::rec3".to_string(),
1✔
1054
            section_idx: 2,
1✔
1055
            view: ChunkView::Window {
1✔
1056
                index: 0,
1✔
1057
                overlap: 0,
1✔
1058
                span: 16,
1✔
1059
                start_ratio: 0.0,
1✔
1060
            },
1✔
1061
            text: "negative text".to_string(),
1✔
1062
            tokens_estimate: 7,
1✔
1063
            quality: crate::data::QualityScore { trust: 0.5 },
1✔
1064
        };
1✔
1065

1066
        let triplet_batch = TripletBatch {
1✔
1067
            triplets: vec![crate::SampleTriplet {
1✔
1068
                recipe: "triplet_recipe".to_string(),
1✔
1069
                anchor: anchor.clone(),
1✔
1070
                positive: positive.clone(),
1✔
1071
                negative: negative.clone(),
1✔
1072
                weight: 1.0,
1✔
1073
                instruction: Some("triplet instruction".to_string()),
1✔
1074
            }],
1✔
1075
        };
1✔
1076
        print_triplet_batch(&strategy, &triplet_batch, &store);
1✔
1077

1078
        let pair_batch = SampleBatch {
1✔
1079
            pairs: vec![crate::SamplePair {
1✔
1080
                recipe: "pair_recipe".to_string(),
1✔
1081
                anchor: anchor.clone(),
1✔
1082
                positive: positive.clone(),
1✔
1083
                weight: 1.0,
1✔
1084
                instruction: None,
1✔
1085
                label: crate::PairLabel::Positive,
1✔
1086
                reason: Some("same topic".to_string()),
1✔
1087
            }],
1✔
1088
        };
1✔
1089
        print_pair_batch(&strategy, &pair_batch, &store);
1✔
1090

1091
        let text_batch = TextBatch {
1✔
1092
            samples: vec![crate::TextSample {
1✔
1093
                recipe: "text_recipe".to_string(),
1✔
1094
                chunk: negative,
1✔
1095
                weight: 0.8,
1✔
1096
                instruction: Some("text instruction".to_string()),
1✔
1097
            }],
1✔
1098
        };
1✔
1099
        print_text_batch(&strategy, &text_batch, &store);
1✔
1100

1101
        let recipes = vec![TextRecipe {
1✔
1102
            name: "recipe_name".into(),
1✔
1103
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
1104
            instruction: Some("instruction".into()),
1✔
1105
            weight: 1.0,
1✔
1106
        }];
1✔
1107
        print_text_recipes(&recipes);
1✔
1108

1109
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
1110
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
1111
    }
1✔
1112
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc