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

jzombie / rust-triplets / 23922100021

02 Apr 2026 09:08PM UTC coverage: 95.35% (-0.002%) from 95.352%
23922100021

Pull #55

github

web-flow
Merge 002a685bb into bd200a76a
Pull Request #55: Add CSV source input

466 of 489 new or added lines in 1 file covered. (95.3%)

17369 of 18216 relevant lines covered (95.35%)

121962.15 hits per line

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

95.3
/src/source/backends/csv_source.rs
1
use chrono::{DateTime, Utc};
2
use std::path::PathBuf;
3

4
use crate::config::{NegativeStrategy, SamplerConfig, Selector, TripletRecipe};
5
use crate::data::{DataRecord, QualityScore, SectionRole};
6
use crate::errors::SamplerError;
7
use crate::source::{DataSource, IndexablePager, IndexableSource, SourceCursor, SourceSnapshot};
8
use crate::types::SourceId;
9
use crate::utils::{file_times, make_section, normalize_inline_whitespace};
10

11
const CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE: &str = "csv_anchor_positive_wrong_article";
12
const CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE: &str = "csv_anchor_anchor_wrong_article";
13
/// Default CSV text-columns-mode SimCSE-style recipe name.
14
pub const CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE: &str = "csv_text_simcse_wrong_article";
15

16
/// Configuration for a CSV-backed data source.
17
///
18
/// Two modes are supported:
19
///
20
/// - **Role mode** — `anchor_column` is set (with an optional `positive_column`).
21
///   Each row produces an `Anchor` section from `anchor_column` and a `Context`
22
///   section from `positive_column` (or the anchor text when `positive_column` is
23
///   absent).
24
///
25
/// - **Text mode** — only `text_column` is set.  Each row produces both an
26
///   `Anchor` and a `Context` section from the same column (SimCSE-style).
27
///
28
/// `anchor_column` and `text_column` are mutually exclusive.
29
#[derive(Clone, Debug)]
30
pub struct CsvSourceConfig {
31
    /// Stable source identifier used in records and persistence keys.
32
    pub source_id: SourceId,
33
    /// Path to the CSV file.
34
    pub path: PathBuf,
35
    /// Column name for anchor text.  Enables role mode when set.
36
    ///
37
    /// Mutually exclusive with `text_column`.
38
    pub anchor_column: Option<String>,
39
    /// Column name for positive/context text.  Used with `anchor_column`.
40
    ///
41
    /// When absent in role mode, the anchor text is reused as the context
42
    /// (identical-positive fallback, suitable for contrastive pre-training).
43
    pub positive_column: Option<String>,
44
    /// Column name for single-text mode.
45
    ///
46
    /// Mutually exclusive with `anchor_column`.
47
    pub text_column: Option<String>,
48
    /// Trust/quality score assigned to every record from this source.
49
    pub trust: f32,
50
    /// Whether the CSV file has a header row.  Defaults to `true`.
51
    pub has_headers: bool,
52
}
53

54
impl CsvSourceConfig {
55
    /// Create a config for a CSV source with the given identifier and path.
56
    pub fn new(source_id: impl Into<SourceId>, path: impl Into<PathBuf>) -> Self {
22✔
57
        Self {
22✔
58
            source_id: source_id.into(),
22✔
59
            path: path.into(),
22✔
60
            anchor_column: None,
22✔
61
            positive_column: None,
22✔
62
            text_column: None,
22✔
63
            trust: 0.85,
22✔
64
            has_headers: true,
22✔
65
        }
22✔
66
    }
22✔
67

68
    /// Set the column used as the anchor (enables role mode).
69
    pub fn with_anchor_column(mut self, column: impl Into<String>) -> Self {
10✔
70
        self.anchor_column = Some(column.into());
10✔
71
        self
10✔
72
    }
10✔
73

74
    /// Set the column used as the positive/context (role mode only).
75
    pub fn with_positive_column(mut self, column: impl Into<String>) -> Self {
8✔
76
        self.positive_column = Some(column.into());
8✔
77
        self
8✔
78
    }
8✔
79

80
    /// Set the column used as the single text field (enables text mode).
81
    pub fn with_text_column(mut self, column: impl Into<String>) -> Self {
11✔
82
        self.text_column = Some(column.into());
11✔
83
        self
11✔
84
    }
11✔
85

86
    /// Override the default trust score.
87
    pub fn with_trust(mut self, trust: f32) -> Self {
1✔
88
        self.trust = trust;
1✔
89
        self
1✔
90
    }
1✔
91

92
    /// Set whether the CSV file has a header row.
NEW
93
    pub fn with_headers(mut self, has_headers: bool) -> Self {
×
NEW
94
        self.has_headers = has_headers;
×
NEW
95
        self
×
NEW
96
    }
×
97

98
    fn is_role_mode(&self) -> bool {
31✔
99
        self.anchor_column.is_some()
31✔
100
    }
31✔
101

102
    fn validate(&self) -> Result<(), SamplerError> {
22✔
103
        if self.anchor_column.is_some() && self.text_column.is_some() {
22✔
104
            return Err(SamplerError::Configuration(
1✔
105
                "CsvSourceConfig: `anchor_column` and `text_column` are mutually exclusive"
1✔
106
                    .to_string(),
1✔
107
            ));
1✔
108
        }
21✔
109
        if self.anchor_column.is_none() && self.text_column.is_none() {
21✔
110
            return Err(SamplerError::Configuration(
2✔
111
                "CsvSourceConfig: one of `anchor_column` or `text_column` must be set".to_string(),
2✔
112
            ));
2✔
113
        }
19✔
114
        if self.positive_column.is_some() && self.anchor_column.is_none() {
19✔
NEW
115
            return Err(SamplerError::Configuration(
×
NEW
116
                "CsvSourceConfig: `positive_column` requires `anchor_column` to be set".to_string(),
×
NEW
117
            ));
×
118
        }
19✔
119
        Ok(())
19✔
120
    }
22✔
121
}
122

123
/// Column-mapped CSV data source.
124
///
125
/// Reads all rows from a CSV file at construction and exposes them as
126
/// [`DataRecord`]s.  Suitable for small-to-medium datasets that fit comfortably
127
/// in memory.
128
///
129
/// ## Modes
130
///
131
/// Configure the source with either anchor/positive columns (role mode) or a
132
/// single text column (text mode):
133
///
134
/// ```rust,no_run
135
/// use triplets::source::{CsvSource, CsvSourceConfig};
136
///
137
/// // Role mode: explicit anchor + positive columns.
138
/// let config = CsvSourceConfig::new("my_qna", "data/qna.csv")
139
///     .with_anchor_column("question")
140
///     .with_positive_column("answer")
141
///     .with_trust(0.9);
142
/// let source = CsvSource::new(config).unwrap();
143
///
144
/// // Text mode: single text column.
145
/// let config2 = CsvSourceConfig::new("my_corpus", "data/corpus.csv")
146
///     .with_text_column("text");
147
/// let source2 = CsvSource::new(config2).unwrap();
148
/// ```
149
#[derive(Debug)]
150
pub struct CsvSource {
151
    config: CsvSourceConfig,
152
    records: Vec<DataRecord>,
153
}
154

155
impl CsvSource {
156
    /// Load a CSV source from the given configuration.
157
    ///
158
    /// Returns a `SamplerError::Configuration` error if the config is invalid,
159
    /// or a `SamplerError::SourceUnavailable` error if the CSV file cannot be
160
    /// opened or parsed.
161
    pub fn new(config: CsvSourceConfig) -> Result<Self, SamplerError> {
22✔
162
        config.validate()?;
22✔
163
        let records = Self::load_records(&config)?;
19✔
164
        Ok(Self { config, records })
16✔
165
    }
22✔
166

167
    fn load_records(config: &CsvSourceConfig) -> Result<Vec<DataRecord>, SamplerError> {
19✔
168
        let (created_at, updated_at) = file_times(&config.path);
19✔
169

170
        let mut reader = csv::ReaderBuilder::new()
19✔
171
            .has_headers(config.has_headers)
19✔
172
            .flexible(false)
19✔
173
            .trim(csv::Trim::All)
19✔
174
            .from_path(&config.path)
19✔
175
            .map_err(|err| SamplerError::SourceUnavailable {
19✔
NEW
176
                source_id: config.source_id.clone(),
×
NEW
177
                reason: format!("failed to open CSV file '{}': {err}", config.path.display()),
×
NEW
178
            })?;
×
179

180
        // When has_headers is false the csv crate still owns a "headers" slot
181
        // but it will be empty; column lookup by name is unavailable so we
182
        // require named headers in that case. Build the header map once.
183
        let headers = reader
19✔
184
            .headers()
19✔
185
            .map_err(|err| SamplerError::SourceUnavailable {
19✔
NEW
186
                source_id: config.source_id.clone(),
×
NEW
187
                reason: format!(
×
188
                    "failed to read CSV headers in '{}': {err}",
NEW
189
                    config.path.display()
×
190
                ),
NEW
191
            })?
×
192
            .clone();
19✔
193

194
        // Pre-resolve column indices so we error early on bad config rather
195
        // than silently skipping every row.
196
        let anchor_idx = if let Some(col) = &config.anchor_column {
19✔
197
            Some(column_index(&headers, col).ok_or_else(|| {
9✔
198
                SamplerError::Configuration(format!(
1✔
199
                    "anchor_column '{}' not found in CSV headers of '{}'",
1✔
200
                    col,
1✔
201
                    config.path.display()
1✔
202
                ))
1✔
203
            })?)
1✔
204
        } else {
205
            None
10✔
206
        };
207

208
        let positive_idx = if let Some(col) = &config.positive_column {
18✔
209
            Some(column_index(&headers, col).ok_or_else(|| {
7✔
210
                SamplerError::Configuration(format!(
1✔
211
                    "positive_column '{}' not found in CSV headers of '{}'",
1✔
212
                    col,
1✔
213
                    config.path.display()
1✔
214
                ))
1✔
215
            })?)
1✔
216
        } else {
217
            None
11✔
218
        };
219

220
        let text_idx = if let Some(col) = &config.text_column {
17✔
221
            Some(column_index(&headers, col).ok_or_else(|| {
10✔
222
                SamplerError::Configuration(format!(
1✔
223
                    "text_column '{}' not found in CSV headers of '{}'",
1✔
224
                    col,
1✔
225
                    config.path.display()
1✔
226
                ))
1✔
227
            })?)
1✔
228
        } else {
229
            None
7✔
230
        };
231

232
        let mut records = Vec::new();
16✔
233

234
        let cols = ColumnIndices {
16✔
235
            anchor: anchor_idx,
16✔
236
            positive: positive_idx,
16✔
237
            text: text_idx,
16✔
238
        };
16✔
239

240
        for (row_idx, result) in reader.records().enumerate() {
29✔
241
            let row = result.map_err(|err| SamplerError::SourceUnavailable {
29✔
NEW
242
                source_id: config.source_id.clone(),
×
NEW
243
                reason: format!(
×
244
                    "failed to read row {} in '{}': {err}",
245
                    row_idx,
NEW
246
                    config.path.display()
×
247
                ),
NEW
248
            })?;
×
249

250
            if let Some(record) = build_record(config, &row, row_idx, &cols, created_at, updated_at)
29✔
251
            {
27✔
252
                records.push(record);
27✔
253
            }
27✔
254
        }
255

256
        Ok(records)
16✔
257
    }
19✔
258
}
259

260
/// Resolve a column name to its zero-based index in a header record.
261
fn column_index(headers: &csv::StringRecord, name: &str) -> Option<usize> {
26✔
262
    headers.iter().position(|h| h.eq_ignore_ascii_case(name))
35✔
263
}
26✔
264

265
/// Pre-resolved column indices for a CSV source.
266
struct ColumnIndices {
267
    anchor: Option<usize>,
268
    positive: Option<usize>,
269
    text: Option<usize>,
270
}
271

272
/// Build a [`DataRecord`] from a single CSV row.
273
///
274
/// Returns `None` when required column values are empty or missing.
275
fn build_record(
29✔
276
    config: &CsvSourceConfig,
29✔
277
    row: &csv::StringRecord,
29✔
278
    row_idx: usize,
29✔
279
    cols: &ColumnIndices,
29✔
280
    created_at: DateTime<Utc>,
29✔
281
    updated_at: DateTime<Utc>,
29✔
282
) -> Option<DataRecord> {
29✔
283
    let id = format!("{}::row_{}", config.source_id, row_idx);
29✔
284

285
    let sections = if config.is_role_mode() {
29✔
286
        // Role mode: anchor + optional positive
287
        let anchor_raw = cols.anchor.and_then(|i| row.get(i)).unwrap_or("");
11✔
288
        let anchor_text = normalize_inline_whitespace(anchor_raw);
11✔
289
        if anchor_text.is_empty() {
11✔
290
            return None;
1✔
291
        }
10✔
292

293
        let positive_text = if let Some(pidx) = cols.positive {
10✔
294
            let raw = row.get(pidx).unwrap_or("");
9✔
295
            let normalized = normalize_inline_whitespace(raw);
9✔
296
            if normalized.is_empty() {
9✔
297
                return None;
1✔
298
            }
8✔
299
            normalized
8✔
300
        } else {
301
            // Fall back to anchor text as positive when no positive column is set.
302
            anchor_text.clone()
1✔
303
        };
304

305
        let anchor_heading = config.anchor_column.as_deref();
9✔
306
        let positive_heading = config
9✔
307
            .positive_column
9✔
308
            .as_deref()
9✔
309
            .or(config.anchor_column.as_deref());
9✔
310

311
        vec![
9✔
312
            make_section(SectionRole::Anchor, anchor_heading, &anchor_text),
9✔
313
            make_section(SectionRole::Context, positive_heading, &positive_text),
9✔
314
        ]
315
    } else {
316
        // Text mode: single column used for both Anchor and Context (SimCSE pattern).
317
        let raw = cols.text.and_then(|i| row.get(i)).unwrap_or("");
18✔
318
        let text = normalize_inline_whitespace(raw);
18✔
319
        if text.is_empty() {
18✔
NEW
320
            return None;
×
321
        }
18✔
322

323
        let heading = config.text_column.as_deref();
18✔
324
        vec![
18✔
325
            make_section(SectionRole::Anchor, heading, &text),
18✔
326
            make_section(SectionRole::Context, heading, &text),
18✔
327
        ]
328
    };
329

330
    Some(DataRecord {
27✔
331
        id,
27✔
332
        source: config.source_id.clone(),
27✔
333
        created_at,
27✔
334
        updated_at,
27✔
335
        quality: QualityScore {
27✔
336
            trust: config.trust,
27✔
337
        },
27✔
338
        taxonomy: vec![config.source_id.clone()],
27✔
339
        sections,
27✔
340
        meta_prefix: None,
27✔
341
    })
27✔
342
}
29✔
343

344
impl IndexableSource for CsvSource {
NEW
345
    fn id(&self) -> &str {
×
NEW
346
        &self.config.source_id
×
NEW
347
    }
×
348

349
    fn len_hint(&self) -> Option<usize> {
13✔
350
        Some(self.records.len())
13✔
351
    }
13✔
352

353
    fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
21✔
354
        Ok(self.records.get(idx).cloned())
21✔
355
    }
21✔
356
}
357

358
impl DataSource for CsvSource {
359
    fn id(&self) -> &str {
1✔
360
        &self.config.source_id
1✔
361
    }
1✔
362

363
    fn refresh(
12✔
364
        &self,
12✔
365
        _config: &SamplerConfig,
12✔
366
        cursor: Option<&SourceCursor>,
12✔
367
        limit: Option<usize>,
12✔
368
    ) -> Result<SourceSnapshot, SamplerError> {
12✔
369
        IndexablePager::new(&self.config.source_id).refresh(self, cursor, limit)
12✔
370
    }
12✔
371

372
    fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
373
        Ok(self.records.len() as u128)
1✔
374
    }
1✔
375

376
    fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
2✔
377
        if !self.config.is_role_mode() {
2✔
378
            // Text mode: SimCSE-style recipe that allows same anchor/positive text.
379
            // Dropout noise provides the necessary embedding variation between
380
            // the two identical slots; the negative comes from a different record.
381
            return vec![TripletRecipe {
1✔
382
                name: CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE.into(),
1✔
383
                anchor: Selector::Role(SectionRole::Anchor),
1✔
384
                positive_selector: Selector::Role(SectionRole::Context),
1✔
385
                negative_selector: Selector::Role(SectionRole::Context),
1✔
386
                negative_strategy: NegativeStrategy::WrongArticle,
1✔
387
                weight: 1.0,
1✔
388
                instruction: None,
1✔
389
                allow_same_anchor_positive: true,
1✔
390
            }];
1✔
391
        }
1✔
392

393
        vec![
1✔
394
            // Primary lane: context (positive) negatives for broad coverage.
395
            TripletRecipe {
1✔
396
                name: CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE.into(),
1✔
397
                anchor: Selector::Role(SectionRole::Anchor),
1✔
398
                positive_selector: Selector::Role(SectionRole::Context),
1✔
399
                negative_selector: Selector::Role(SectionRole::Context),
1✔
400
                negative_strategy: NegativeStrategy::WrongArticle,
1✔
401
                weight: 0.75,
1✔
402
                instruction: None,
1✔
403
                allow_same_anchor_positive: false,
1✔
404
            },
1✔
405
            // Medium-hard lane: anchor-as-negative for discrimination pressure.
406
            TripletRecipe {
1✔
407
                name: CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE.into(),
1✔
408
                anchor: Selector::Role(SectionRole::Anchor),
1✔
409
                positive_selector: Selector::Role(SectionRole::Context),
1✔
410
                negative_selector: Selector::Role(SectionRole::Anchor),
1✔
411
                negative_strategy: NegativeStrategy::WrongArticle,
1✔
412
                weight: 0.25,
1✔
413
                instruction: None,
1✔
414
                allow_same_anchor_positive: false,
1✔
415
            },
1✔
416
        ]
417
    }
2✔
418
}
419

420
#[cfg(test)]
421
mod tests {
422
    use super::*;
423
    use crate::config::SamplerConfig;
424
    use crate::source::DataSource;
425
    use std::io::Write;
426
    use tempfile::NamedTempFile;
427

428
    fn write_csv(content: &str) -> NamedTempFile {
22✔
429
        let mut f = NamedTempFile::new().unwrap();
22✔
430
        write!(f, "{content}").unwrap();
22✔
431
        f
22✔
432
    }
22✔
433

434
    fn sampler_config() -> SamplerConfig {
13✔
435
        SamplerConfig {
13✔
436
            seed: 42,
13✔
437
            ..SamplerConfig::default()
13✔
438
        }
13✔
439
    }
13✔
440

441
    // ──────────────────────────────────────────────────────────── construction
442

443
    #[test]
444
    fn rejects_anchor_and_text_columns_together() {
1✔
445
        let f = write_csv("anchor,text\nhello,world\n");
1✔
446
        let err = CsvSource::new(
1✔
447
            CsvSourceConfig::new("src", f.path())
1✔
448
                .with_anchor_column("anchor")
1✔
449
                .with_text_column("text"),
1✔
450
        )
451
        .unwrap_err();
1✔
452
        assert!(
1✔
453
            matches!(err, SamplerError::Configuration(_)),
1✔
454
            "expected Configuration error, got {err:?}"
455
        );
456
    }
1✔
457

458
    #[test]
459
    fn rejects_missing_column_spec() {
1✔
460
        let f = write_csv("anchor,text\nhello,world\n");
1✔
461
        let err = CsvSource::new(CsvSourceConfig::new("src", f.path())).unwrap_err();
1✔
462
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
463
    }
1✔
464

465
    #[test]
466
    fn rejects_positive_without_anchor() {
1✔
467
        let f = write_csv("anchor,text\nhello,world\n");
1✔
468
        let err =
1✔
469
            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_positive_column("text"))
1✔
470
                .unwrap_err();
1✔
471
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
472
    }
1✔
473

474
    #[test]
475
    fn rejects_missing_anchor_column_in_file() {
1✔
476
        let f = write_csv("question,answer\nhello,world\n");
1✔
477
        let err =
1✔
478
            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_anchor_column("missing_col"))
1✔
479
                .unwrap_err();
1✔
480
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
481
    }
1✔
482

483
    #[test]
484
    fn rejects_missing_text_column_in_file() {
1✔
485
        let f = write_csv("question,answer\nhello,world\n");
1✔
486
        let err =
1✔
487
            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_text_column("missing_col"))
1✔
488
                .unwrap_err();
1✔
489
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
490
    }
1✔
491

492
    #[test]
493
    fn rejects_missing_positive_column_in_file() {
1✔
494
        let f = write_csv("question,answer\nhello,world\n");
1✔
495
        let err = CsvSource::new(
1✔
496
            CsvSourceConfig::new("src", f.path())
1✔
497
                .with_anchor_column("question")
1✔
498
                .with_positive_column("missing_col"),
1✔
499
        )
500
        .unwrap_err();
1✔
501
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
502
    }
1✔
503

504
    // ──────────────────────────────────────────────────────────── role mode
505

506
    #[test]
507
    fn role_mode_anchor_and_positive() {
1✔
508
        let f = write_csv("question,answer\nWhat is Rust?,A systems language.\n");
1✔
509
        let source = CsvSource::new(
1✔
510
            CsvSourceConfig::new("qna", f.path())
1✔
511
                .with_anchor_column("question")
1✔
512
                .with_positive_column("answer"),
1✔
513
        )
514
        .unwrap();
1✔
515

516
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
517
        assert_eq!(snapshot.records.len(), 1);
1✔
518
        let record = &snapshot.records[0];
1✔
519
        assert_eq!(record.source, "qna");
1✔
520
        assert_eq!(record.sections.len(), 2);
1✔
521
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
522
        assert_eq!(record.sections[0].text, "What is Rust?");
1✔
523
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
524
        assert_eq!(record.sections[1].text, "A systems language.");
1✔
525
    }
1✔
526

527
    #[test]
528
    fn role_mode_anchor_only_duplicates_to_context() {
1✔
529
        let f = write_csv("sentence\nHello world\n");
1✔
530
        let source = CsvSource::new(
1✔
531
            CsvSourceConfig::new("anchors", f.path()).with_anchor_column("sentence"),
1✔
532
        )
533
        .unwrap();
1✔
534

535
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
536
        assert_eq!(snapshot.records.len(), 1);
1✔
537
        let record = &snapshot.records[0];
1✔
538
        assert_eq!(record.sections.len(), 2);
1✔
539
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
540
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
541
        // Context must mirror the anchor text.
542
        assert_eq!(record.sections[0].text, record.sections[1].text);
1✔
543
    }
1✔
544

545
    #[test]
546
    fn role_mode_skips_rows_with_empty_anchor() {
1✔
547
        let f = write_csv(
1✔
548
            "question,answer\n\
1✔
549
             What is Rust?,A systems language.\n\
1✔
550
             ,Missing anchor\n\
1✔
551
             What is Go?,A concurrent language.\n",
1✔
552
        );
553
        let source = CsvSource::new(
1✔
554
            CsvSourceConfig::new("qna", f.path())
1✔
555
                .with_anchor_column("question")
1✔
556
                .with_positive_column("answer"),
1✔
557
        )
558
        .unwrap();
1✔
559
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
560
        assert_eq!(snapshot.records.len(), 2);
1✔
561
    }
1✔
562

563
    #[test]
564
    fn role_mode_skips_rows_with_empty_positive() {
1✔
565
        let f = write_csv(
1✔
566
            "question,answer\n\
1✔
567
             What is Rust?,A systems language.\n\
1✔
568
             What is Go?,\n",
1✔
569
        );
570
        let source = CsvSource::new(
1✔
571
            CsvSourceConfig::new("qna", f.path())
1✔
572
                .with_anchor_column("question")
1✔
573
                .with_positive_column("answer"),
1✔
574
        )
575
        .unwrap();
1✔
576
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
577
        assert_eq!(snapshot.records.len(), 1);
1✔
578
    }
1✔
579

580
    // ──────────────────────────────────────────────────────────── text mode
581

582
    #[test]
583
    fn text_mode_produces_identical_anchor_and_context() {
1✔
584
        let f = write_csv("text\nThe quick brown fox\n");
1✔
585
        let source =
1✔
586
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
587
                .unwrap();
1✔
588

589
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
590
        assert_eq!(snapshot.records.len(), 1);
1✔
591
        let record = &snapshot.records[0];
1✔
592
        assert_eq!(record.sections.len(), 2);
1✔
593
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
594
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
595
        assert_eq!(record.sections[0].text, record.sections[1].text);
1✔
596
    }
1✔
597

598
    #[test]
599
    fn text_mode_skips_empty_rows() {
1✔
600
        let f = write_csv("text\nHello\n\nWorld\n");
1✔
601
        let source =
1✔
602
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
603
                .unwrap();
1✔
604
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
605
        assert_eq!(snapshot.records.len(), 2);
1✔
606
    }
1✔
607

608
    // ──────────────────────────────────────────────────────── quality / trust
609

610
    #[test]
611
    fn applies_trust_score() {
1✔
612
        let f = write_csv("text\nHello world\n");
1✔
613
        let source = CsvSource::new(
1✔
614
            CsvSourceConfig::new("corpus", f.path())
1✔
615
                .with_text_column("text")
1✔
616
                .with_trust(0.7),
1✔
617
        )
618
        .unwrap();
1✔
619
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
620
        assert_eq!(snapshot.records[0].quality.trust, 0.7);
1✔
621
    }
1✔
622

623
    // ──────────────────────────────────────────────────────── default recipes
624

625
    #[test]
626
    fn text_mode_default_recipes_is_simcse() {
1✔
627
        let f = write_csv("text\nHello\n");
1✔
628
        let source =
1✔
629
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
630
                .unwrap();
1✔
631
        let recipes = source.default_triplet_recipes();
1✔
632
        assert_eq!(recipes.len(), 1);
1✔
633
        assert_eq!(recipes[0].name, CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE);
1✔
634
        assert!(
1✔
635
            recipes[0].allow_same_anchor_positive,
1✔
636
            "SimCSE recipe must allow same anchor/positive"
637
        );
638
    }
1✔
639

640
    #[test]
641
    fn role_mode_default_recipes_returns_two_recipes() {
1✔
642
        let f = write_csv("question,answer\nQ,A\n");
1✔
643
        let source = CsvSource::new(
1✔
644
            CsvSourceConfig::new("qna", f.path())
1✔
645
                .with_anchor_column("question")
1✔
646
                .with_positive_column("answer"),
1✔
647
        )
648
        .unwrap();
1✔
649
        let recipes = source.default_triplet_recipes();
1✔
650
        assert_eq!(recipes.len(), 2);
1✔
651
        let names: Vec<&str> = recipes.iter().map(|r| r.name.as_ref()).collect();
2✔
652
        assert!(names.contains(&CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE));
1✔
653
        assert!(names.contains(&CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE));
1✔
654
        assert!(
1✔
655
            recipes.iter().all(|r| !r.allow_same_anchor_positive),
2✔
656
            "role-mode recipes must not allow same anchor/positive"
657
        );
658
    }
1✔
659

660
    // ──────────────────────────────────────────────────────── IndexableSource
661

662
    #[test]
663
    fn len_hint_matches_loaded_record_count() {
1✔
664
        let f = write_csv("text\nAlpha\nBeta\nGamma\n");
1✔
665
        let source =
1✔
666
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
667
                .unwrap();
1✔
668
        assert_eq!(source.len_hint(), Some(3));
1✔
669
    }
1✔
670

671
    #[test]
672
    fn record_at_returns_correct_record() {
1✔
673
        let f = write_csv("question,answer\nFirst?,Yes.\nSecond?,No.\n");
1✔
674
        let source = CsvSource::new(
1✔
675
            CsvSourceConfig::new("qna", f.path())
1✔
676
                .with_anchor_column("question")
1✔
677
                .with_positive_column("answer"),
1✔
678
        )
679
        .unwrap();
1✔
680
        let r0 = source.record_at(0).unwrap().unwrap();
1✔
681
        let r1 = source.record_at(1).unwrap().unwrap();
1✔
682
        assert_eq!(r0.sections[0].text, "First?");
1✔
683
        assert_eq!(r1.sections[0].text, "Second?");
1✔
684
        assert!(source.record_at(99).unwrap().is_none());
1✔
685
    }
1✔
686

687
    // ──────────────────────────────────────────────────────── reported count
688

689
    #[test]
690
    fn reported_record_count_matches_loaded_records() {
1✔
691
        let f = write_csv("text\nAlpha\nBeta\n");
1✔
692
        let source =
1✔
693
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
694
                .unwrap();
1✔
695
        let count = source.reported_record_count(&sampler_config()).unwrap();
1✔
696
        assert_eq!(count, 2);
1✔
697
    }
1✔
698

699
    // ──────────────────────────────────────────────────────── stable record IDs
700

701
    #[test]
702
    fn record_ids_are_stable_across_refreshes() {
1✔
703
        let f = write_csv("text\nAlpha\nBeta\n");
1✔
704
        let source =
1✔
705
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
706
                .unwrap();
1✔
707
        let ids_a: Vec<_> = source
1✔
708
            .refresh(&sampler_config(), None, None)
1✔
709
            .unwrap()
1✔
710
            .records
1✔
711
            .iter()
1✔
712
            .map(|r| r.id.clone())
2✔
713
            .collect();
1✔
714
        let ids_b: Vec<_> = source
1✔
715
            .refresh(&sampler_config(), None, None)
1✔
716
            .unwrap()
1✔
717
            .records
1✔
718
            .iter()
1✔
719
            .map(|r| r.id.clone())
2✔
720
            .collect();
1✔
721
        // IDs must be the same set (order may differ due to pager permutation).
722
        let mut sorted_a = ids_a.clone();
1✔
723
        let mut sorted_b = ids_b.clone();
1✔
724
        sorted_a.sort();
1✔
725
        sorted_b.sort();
1✔
726
        assert_eq!(sorted_a, sorted_b);
1✔
727
    }
1✔
728

729
    // ──────────────────────────────────────────────────────── source id
730

731
    #[test]
732
    fn source_id_is_propagated_to_records() {
1✔
733
        let f = write_csv("text\nHello\n");
1✔
734
        let source =
1✔
735
            CsvSource::new(CsvSourceConfig::new("my_source", f.path()).with_text_column("text"))
1✔
736
                .unwrap();
1✔
737
        assert_eq!(DataSource::id(&source), "my_source");
1✔
738
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
739
        assert_eq!(snapshot.records[0].source, "my_source");
1✔
740
    }
1✔
741

742
    // ──────────────────────────────────────────────────── column name trimming
743

744
    #[test]
745
    fn column_lookup_is_case_insensitive() {
1✔
746
        let f = write_csv("Question,Answer\nWhat is Rust?,A systems language.\n");
1✔
747
        // Lower-case lookup against mixed-case headers.
748
        let source = CsvSource::new(
1✔
749
            CsvSourceConfig::new("qna", f.path())
1✔
750
                .with_anchor_column("question")
1✔
751
                .with_positive_column("answer"),
1✔
752
        )
753
        .unwrap();
1✔
754
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
755
        assert_eq!(snapshot.records.len(), 1);
1✔
756
        assert_eq!(snapshot.records[0].sections[0].text, "What is Rust?");
1✔
757
    }
1✔
758

759
    // ──────────────────────────────────────────────────── multi-row paging
760

761
    #[test]
762
    fn refresh_with_limit_returns_at_most_limit_records() {
1✔
763
        let f = write_csv("text\nA\nB\nC\nD\nE\n");
1✔
764
        let source =
1✔
765
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
766
                .unwrap();
1✔
767
        let snapshot = source.refresh(&sampler_config(), None, Some(3)).unwrap();
1✔
768
        assert!(
1✔
769
            snapshot.records.len() <= 3,
1✔
770
            "expected at most 3 records, got {}",
NEW
771
            snapshot.records.len()
×
772
        );
773
    }
1✔
774
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc