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

jzombie / rust-triplets / 22355015974

24 Feb 2026 02:24PM UTC coverage: 91.416% (-1.3%) from 92.675%
22355015974

Pull #7

github

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

3819 of 4360 new or added lines in 6 files covered. (87.59%)

93 existing lines in 3 files now uncovered.

13206 of 14446 relevant lines covered (91.42%)

2735.09 hits per line

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

93.66
/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
    for source in &sources {
2✔
189
        source.configure_sampler(&config);
2✔
190
    }
2✔
191

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3✔
369
        grand.triplets += totals.triplets;
3✔
370
        grand.effective_triplets += totals.effective_triplets;
3✔
371
        grand.pairs += totals.pairs;
3✔
372
        grand.text_samples += totals.text_samples;
3✔
373

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

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

428
    Ok(())
1✔
429
}
2✔
430

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

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

451
    let roots = resolve_roots(cli.source_roots)?;
4✔
452

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

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

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

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

549
    Ok(())
4✔
550
}
4✔
551

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

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

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

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

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

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

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

725
trait ChunkDebug {
726
    fn view_name(&self) -> String;
727
}
728

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

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

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

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

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

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

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

860
    impl DataSource for TestSource {
861
        fn id(&self) -> &str {
66✔
862
            &self.id
66✔
863
        }
66✔
864

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

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

886
        fn configure_sampler(&self, _config: &SamplerConfig) {}
6✔
887

888
        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
6✔
889
            self.recipes.clone()
6✔
890
        }
6✔
891
    }
892

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

905
    #[test]
906
    fn parse_helpers_validate_inputs() {
1✔
907
        assert_eq!(parse_positive_usize("2").unwrap(), 2);
1✔
908
        assert!(parse_positive_usize("0").is_err());
1✔
909
        assert!(parse_positive_usize("abc").is_err());
1✔
910

911
        let split = parse_split_ratios_arg("0.8,0.1,0.1").unwrap();
1✔
912
        assert!((split.train - 0.8).abs() < 1e-6);
1✔
913
        assert!(parse_split_ratios_arg("0.8,0.1").is_err());
1✔
914
        assert!(parse_split_ratios_arg("1.0,0.0,0.1").is_err());
1✔
915
        assert!(parse_split_ratios_arg("-0.1,0.6,0.5").is_err());
1✔
916
    }
1✔
917

918
    #[test]
919
    fn parse_cli_handles_help_and_invalid_args() {
1✔
920
        let help = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--help"]).unwrap();
1✔
921
        assert!(help.is_none());
1✔
922

923
        let err = parse_cli::<EstimateCapacityCli, _>(["estimate_capacity", "--unknown"]);
1✔
924
        assert!(err.is_err());
1✔
925
    }
1✔
926

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

944
        assert!(result.is_ok());
1✔
945
    }
1✔
946

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

961
        let err = result.unwrap_err().to_string();
1✔
962
        assert!(err.contains("failed to report exact record count"));
1✔
963
    }
1✔
964

965
    #[test]
966
    fn parse_multi_source_cli_handles_help_and_batch_size_validation() {
1✔
967
        let help = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--help"]).unwrap();
1✔
968
        assert!(help.is_none());
1✔
969

970
        let err = parse_cli::<MultiSourceDemoCli, _>(["multi_source_demo", "--batch-size", "0"]);
1✔
971
        assert!(err.is_err());
1✔
972
    }
1✔
973

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

994
        assert!(result.is_ok());
1✔
995
    }
1✔
996

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

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

1023
            assert!(result.is_ok());
3✔
1024
        }
1025
    }
1✔
1026

1027
    #[test]
1028
    fn print_helpers_and_extract_source_cover_paths() {
1✔
1029
        let split = SplitRatios::default();
1✔
1030
        let store = DeterministicSplitStore::new(split, 42).unwrap();
1✔
1031
        let strategy = ChunkingStrategy::default();
1✔
1032

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

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

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

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

1106
        let recipes = vec![TextRecipe {
1✔
1107
            name: "recipe_name".into(),
1✔
1108
            selector: crate::config::Selector::Role(SectionRole::Context),
1✔
1109
            instruction: Some("instruction".into()),
1✔
1110
            weight: 1.0,
1✔
1111
        }];
1✔
1112
        print_text_recipes(&recipes);
1✔
1113

1114
        assert_eq!(extract_source("source_a::record"), "source_a");
1✔
1115
        assert_eq!(extract_source("record-without-delimiter"), "unknown");
1✔
1116
    }
1✔
1117
}
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