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

jzombie / rust-triplets / 23923439390

02 Apr 2026 09:44PM UTC coverage: 95.453% (+0.1%) from 95.352%
23923439390

Pull #55

github

web-flow
Merge 0eab4fca7 into bd200a76a
Pull Request #55: Add CSV source input

522 of 527 new or added lines in 1 file covered. (99.05%)

1 existing line in 1 file now uncovered.

17424 of 18254 relevant lines covered (95.45%)

121712.18 hits per line

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

99.05
/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
///
30
/// The CSV file **must** have a named header row.  Columns are always looked up
31
/// by name, so a header-free file cannot be used with this source.
32
#[derive(Clone, Debug)]
33
pub struct CsvSourceConfig {
34
    /// Stable source identifier used in records and persistence keys.
35
    pub source_id: SourceId,
36
    /// Path to the CSV file.
37
    pub path: PathBuf,
38
    /// Column name for anchor text.  Enables role mode when set.
39
    ///
40
    /// Mutually exclusive with `text_column`.
41
    pub anchor_column: Option<String>,
42
    /// Column name for positive/context text.  Used with `anchor_column`.
43
    ///
44
    /// When absent in role mode, the anchor text is reused as the context
45
    /// (identical-positive fallback, suitable for contrastive pre-training).
46
    pub positive_column: Option<String>,
47
    /// Column name for single-text mode.
48
    ///
49
    /// Mutually exclusive with `anchor_column`.
50
    pub text_column: Option<String>,
51
    /// Trust/quality score assigned to every record from this source.
52
    pub trust: f32,
53
}
54

55
impl CsvSourceConfig {
56
    /// Create a config for a CSV source with the given identifier and path.
57
    pub fn new(source_id: impl Into<SourceId>, path: impl Into<PathBuf>) -> Self {
27✔
58
        Self {
27✔
59
            source_id: source_id.into(),
27✔
60
            path: path.into(),
27✔
61
            anchor_column: None,
27✔
62
            positive_column: None,
27✔
63
            text_column: None,
27✔
64
            trust: 0.85,
27✔
65
        }
27✔
66
    }
27✔
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 {
11✔
70
        self.anchor_column = Some(column.into());
11✔
71
        self
11✔
72
    }
11✔
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 {
10✔
76
        self.positive_column = Some(column.into());
10✔
77
        self
10✔
78
    }
10✔
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 {
15✔
82
        self.text_column = Some(column.into());
15✔
83
        self
15✔
84
    }
15✔
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
    fn is_role_mode(&self) -> bool {
35✔
93
        self.anchor_column.is_some()
35✔
94
    }
35✔
95

96
    fn validate(&self) -> Result<(), SamplerError> {
27✔
97
        if self.anchor_column.is_some() && self.text_column.is_some() {
27✔
98
            return Err(SamplerError::Configuration(
1✔
99
                "CsvSourceConfig: `anchor_column` and `text_column` are mutually exclusive"
1✔
100
                    .to_string(),
1✔
101
            ));
1✔
102
        }
26✔
103
        if self.anchor_column.is_none() && self.text_column.is_none() {
26✔
104
            return Err(SamplerError::Configuration(
2✔
105
                "CsvSourceConfig: one of `anchor_column` or `text_column` must be set".to_string(),
2✔
106
            ));
2✔
107
        }
24✔
108
        if self.positive_column.is_some() && self.anchor_column.is_none() {
24✔
109
            return Err(SamplerError::Configuration(
1✔
110
                "CsvSourceConfig: `positive_column` requires `anchor_column` to be set".to_string(),
1✔
111
            ));
1✔
112
        }
23✔
113
        Ok(())
23✔
114
    }
27✔
115
}
116

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

149
impl CsvSource {
150
    /// Load a CSV source from the given configuration.
151
    ///
152
    /// Returns a `SamplerError::Configuration` error if the config is invalid,
153
    /// or a `SamplerError::SourceUnavailable` error if the CSV file cannot be
154
    /// opened or parsed.
155
    pub fn new(config: CsvSourceConfig) -> Result<Self, SamplerError> {
27✔
156
        config.validate()?;
27✔
157
        let records = Self::load_records(&config)?;
23✔
158
        Ok(Self { config, records })
18✔
159
    }
27✔
160

161
    fn load_records(config: &CsvSourceConfig) -> Result<Vec<DataRecord>, SamplerError> {
23✔
162
        let (created_at, updated_at) = file_times(&config.path);
23✔
163

164
        let mut reader = csv::ReaderBuilder::new()
23✔
165
            .has_headers(true)
23✔
166
            .flexible(false)
23✔
167
            .trim(csv::Trim::All)
23✔
168
            .from_path(&config.path)
23✔
169
            .map_err(|err| SamplerError::SourceUnavailable {
23✔
170
                source_id: config.source_id.clone(),
1✔
171
                reason: format!("failed to open CSV file '{}': {err}", config.path.display()),
1✔
172
            })?;
1✔
173

174
        // Columns are selected by name against the header row.
175
        let headers = reader
22✔
176
            .headers()
22✔
177
            .map_err(|err| SamplerError::SourceUnavailable {
22✔
NEW
178
                source_id: config.source_id.clone(),
×
NEW
179
                reason: format!(
×
180
                    "failed to read CSV headers in '{}': {err}",
NEW
181
                    config.path.display()
×
182
                ),
NEW
183
            })?
×
184
            .clone();
22✔
185

186
        // Pre-resolve column indices so we error early on bad config rather
187
        // than silently skipping every row.
188
        let anchor_idx = if let Some(col) = &config.anchor_column {
22✔
189
            Some(column_index(&headers, col).ok_or_else(|| {
10✔
190
                SamplerError::Configuration(format!(
1✔
191
                    "anchor_column '{}' not found in CSV headers of '{}'",
1✔
192
                    col,
1✔
193
                    config.path.display()
1✔
194
                ))
1✔
195
            })?)
1✔
196
        } else {
197
            None
12✔
198
        };
199

200
        let positive_idx = if let Some(col) = &config.positive_column {
21✔
201
            Some(column_index(&headers, col).ok_or_else(|| {
8✔
202
                SamplerError::Configuration(format!(
1✔
203
                    "positive_column '{}' not found in CSV headers of '{}'",
1✔
204
                    col,
1✔
205
                    config.path.display()
1✔
206
                ))
1✔
207
            })?)
1✔
208
        } else {
209
            None
13✔
210
        };
211

212
        let text_idx = if let Some(col) = &config.text_column {
20✔
213
            Some(column_index(&headers, col).ok_or_else(|| {
12✔
214
                SamplerError::Configuration(format!(
1✔
215
                    "text_column '{}' not found in CSV headers of '{}'",
1✔
216
                    col,
1✔
217
                    config.path.display()
1✔
218
                ))
1✔
219
            })?)
1✔
220
        } else {
221
            None
8✔
222
        };
223

224
        let mut records = Vec::new();
19✔
225

226
        let cols = ColumnIndices {
19✔
227
            anchor: anchor_idx,
19✔
228
            positive: positive_idx,
19✔
229
            text: text_idx,
19✔
230
        };
19✔
231

232
        for (row_idx, result) in reader.records().enumerate() {
34✔
233
            let row = result.map_err(|err| SamplerError::SourceUnavailable {
34✔
234
                source_id: config.source_id.clone(),
1✔
235
                reason: format!(
1✔
236
                    "failed to read row {} in '{}': {err}",
237
                    row_idx,
238
                    config.path.display()
1✔
239
                ),
240
            })?;
1✔
241

242
            if let Some(record) = build_record(config, &row, row_idx, &cols, created_at, updated_at)
33✔
243
            {
30✔
244
                records.push(record);
30✔
245
            }
30✔
246
        }
247

248
        Ok(records)
18✔
249
    }
23✔
250
}
251

252
/// Resolve a column name to its zero-based index in a header record.
253
fn column_index(headers: &csv::StringRecord, name: &str) -> Option<usize> {
30✔
254
    headers.iter().position(|h| h.eq_ignore_ascii_case(name))
40✔
255
}
30✔
256

257
/// Pre-resolved column indices for a CSV source.
258
struct ColumnIndices {
259
    anchor: Option<usize>,
260
    positive: Option<usize>,
261
    text: Option<usize>,
262
}
263

264
/// Build a [`DataRecord`] from a single CSV row.
265
///
266
/// Returns `None` when required column values are empty or missing.
267
fn build_record(
33✔
268
    config: &CsvSourceConfig,
33✔
269
    row: &csv::StringRecord,
33✔
270
    row_idx: usize,
33✔
271
    cols: &ColumnIndices,
33✔
272
    created_at: DateTime<Utc>,
33✔
273
    updated_at: DateTime<Utc>,
33✔
274
) -> Option<DataRecord> {
33✔
275
    let id = format!("{}::row_{}", config.source_id, row_idx);
33✔
276

277
    let sections = if config.is_role_mode() {
33✔
278
        // Role mode: anchor + optional positive
279
        let anchor_raw = cols.anchor.and_then(|i| row.get(i)).unwrap_or("");
11✔
280
        let anchor_text = normalize_inline_whitespace(anchor_raw);
11✔
281
        if anchor_text.is_empty() {
11✔
282
            return None;
1✔
283
        }
10✔
284

285
        let positive_text = if let Some(pidx) = cols.positive {
10✔
286
            let raw = row.get(pidx).unwrap_or("");
9✔
287
            let normalized = normalize_inline_whitespace(raw);
9✔
288
            if normalized.is_empty() {
9✔
289
                return None;
1✔
290
            }
8✔
291
            normalized
8✔
292
        } else {
293
            // Fall back to anchor text as positive when no positive column is set.
294
            anchor_text.clone()
1✔
295
        };
296

297
        let anchor_heading = config.anchor_column.as_deref();
9✔
298
        let positive_heading = config
9✔
299
            .positive_column
9✔
300
            .as_deref()
9✔
301
            .or(config.anchor_column.as_deref());
9✔
302

303
        vec![
9✔
304
            make_section(SectionRole::Anchor, anchor_heading, &anchor_text),
9✔
305
            make_section(SectionRole::Context, positive_heading, &positive_text),
9✔
306
        ]
307
    } else {
308
        // Text mode: single column used for both Anchor and Context (SimCSE pattern).
309
        let raw = cols.text.and_then(|i| row.get(i)).unwrap_or("");
22✔
310
        let text = normalize_inline_whitespace(raw);
22✔
311
        if text.is_empty() {
22✔
312
            return None;
1✔
313
        }
21✔
314

315
        let heading = config.text_column.as_deref();
21✔
316
        vec![
21✔
317
            make_section(SectionRole::Anchor, heading, &text),
21✔
318
            make_section(SectionRole::Context, heading, &text),
21✔
319
        ]
320
    };
321

322
    Some(DataRecord {
30✔
323
        id,
30✔
324
        source: config.source_id.clone(),
30✔
325
        created_at,
30✔
326
        updated_at,
30✔
327
        quality: QualityScore {
30✔
328
            trust: config.trust,
30✔
329
        },
30✔
330
        taxonomy: vec![config.source_id.clone()],
30✔
331
        sections,
30✔
332
        meta_prefix: None,
30✔
333
    })
30✔
334
}
33✔
335

336
impl IndexableSource for CsvSource {
337
    fn id(&self) -> &str {
1✔
338
        &self.config.source_id
1✔
339
    }
1✔
340

341
    fn len_hint(&self) -> Option<usize> {
14✔
342
        Some(self.records.len())
14✔
343
    }
14✔
344

345
    fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
23✔
346
        Ok(self.records.get(idx).cloned())
23✔
347
    }
23✔
348
}
349

350
impl DataSource for CsvSource {
351
    fn id(&self) -> &str {
1✔
352
        &self.config.source_id
1✔
353
    }
1✔
354

355
    fn refresh(
13✔
356
        &self,
13✔
357
        _config: &SamplerConfig,
13✔
358
        cursor: Option<&SourceCursor>,
13✔
359
        limit: Option<usize>,
13✔
360
    ) -> Result<SourceSnapshot, SamplerError> {
13✔
361
        IndexablePager::new(&self.config.source_id).refresh(self, cursor, limit)
13✔
362
    }
13✔
363

364
    fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1✔
365
        Ok(self.records.len() as u128)
1✔
366
    }
1✔
367

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

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

412
#[cfg(test)]
413
mod tests {
414
    use super::*;
415
    use crate::config::SamplerConfig;
416
    use crate::source::DataSource;
417
    use std::io::Write;
418
    use tempfile::NamedTempFile;
419

420
    fn write_csv(content: &str) -> NamedTempFile {
26✔
421
        let mut f = NamedTempFile::new().unwrap();
26✔
422
        write!(f, "{content}").unwrap();
26✔
423
        f
26✔
424
    }
26✔
425

426
    fn sampler_config() -> SamplerConfig {
14✔
427
        SamplerConfig {
14✔
428
            seed: 42,
14✔
429
            ..SamplerConfig::default()
14✔
430
        }
14✔
431
    }
14✔
432

433
    // ──────────────────────────────────────────────────────────── construction
434

435
    #[test]
436
    fn rejects_anchor_and_text_columns_together() {
1✔
437
        let f = write_csv("anchor,text\nhello,world\n");
1✔
438
        let err = CsvSource::new(
1✔
439
            CsvSourceConfig::new("src", f.path())
1✔
440
                .with_anchor_column("anchor")
1✔
441
                .with_text_column("text"),
1✔
442
        )
443
        .unwrap_err();
1✔
444
        assert!(
1✔
445
            matches!(err, SamplerError::Configuration(_)),
1✔
446
            "expected Configuration error, got {err:?}"
447
        );
448
    }
1✔
449

450
    #[test]
451
    fn rejects_missing_column_spec() {
1✔
452
        let f = write_csv("anchor,text\nhello,world\n");
1✔
453
        let err = CsvSource::new(CsvSourceConfig::new("src", f.path())).unwrap_err();
1✔
454
        assert!(matches!(err, SamplerError::Configuration(_)));
1✔
455
    }
1✔
456

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

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

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

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

496
    // ──────────────────────────────────────────────────────────── role mode
497

498
    #[test]
499
    fn role_mode_anchor_and_positive() {
1✔
500
        let f = write_csv("question,answer\nWhat is Rust?,A systems language.\n");
1✔
501
        let source = CsvSource::new(
1✔
502
            CsvSourceConfig::new("qna", f.path())
1✔
503
                .with_anchor_column("question")
1✔
504
                .with_positive_column("answer"),
1✔
505
        )
506
        .unwrap();
1✔
507

508
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
509
        assert_eq!(snapshot.records.len(), 1);
1✔
510
        let record = &snapshot.records[0];
1✔
511
        assert_eq!(record.source, "qna");
1✔
512
        assert_eq!(record.sections.len(), 2);
1✔
513
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
514
        assert_eq!(record.sections[0].text, "What is Rust?");
1✔
515
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
516
        assert_eq!(record.sections[1].text, "A systems language.");
1✔
517
    }
1✔
518

519
    #[test]
520
    fn role_mode_anchor_only_duplicates_to_context() {
1✔
521
        let f = write_csv("sentence\nHello world\n");
1✔
522
        let source = CsvSource::new(
1✔
523
            CsvSourceConfig::new("anchors", f.path()).with_anchor_column("sentence"),
1✔
524
        )
525
        .unwrap();
1✔
526

527
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
528
        assert_eq!(snapshot.records.len(), 1);
1✔
529
        let record = &snapshot.records[0];
1✔
530
        assert_eq!(record.sections.len(), 2);
1✔
531
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
532
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
533
        // Context must mirror the anchor text.
534
        assert_eq!(record.sections[0].text, record.sections[1].text);
1✔
535
    }
1✔
536

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

555
    #[test]
556
    fn role_mode_skips_rows_with_empty_positive() {
1✔
557
        let f = write_csv(
1✔
558
            "question,answer\n\
1✔
559
             What is Rust?,A systems language.\n\
1✔
560
             What is Go?,\n",
1✔
561
        );
562
        let source = CsvSource::new(
1✔
563
            CsvSourceConfig::new("qna", f.path())
1✔
564
                .with_anchor_column("question")
1✔
565
                .with_positive_column("answer"),
1✔
566
        )
567
        .unwrap();
1✔
568
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
569
        assert_eq!(snapshot.records.len(), 1);
1✔
570
    }
1✔
571

572
    // ──────────────────────────────────────────────────────────── text mode
573

574
    #[test]
575
    fn text_mode_produces_identical_anchor_and_context() {
1✔
576
        let f = write_csv("text\nThe quick brown fox\n");
1✔
577
        let source =
1✔
578
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
579
                .unwrap();
1✔
580

581
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
582
        assert_eq!(snapshot.records.len(), 1);
1✔
583
        let record = &snapshot.records[0];
1✔
584
        assert_eq!(record.sections.len(), 2);
1✔
585
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
586
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
587
        assert_eq!(record.sections[0].text, record.sections[1].text);
1✔
588
    }
1✔
589

590
    #[test]
591
    fn text_mode_skips_empty_rows() {
1✔
592
        let f = write_csv("text\nHello\n\nWorld\n");
1✔
593
        let source =
1✔
594
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
595
                .unwrap();
1✔
596
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
597
        assert_eq!(snapshot.records.len(), 2);
1✔
598
    }
1✔
599

600
    // ──────────────────────────────────────────────────────── quality / trust
601

602
    #[test]
603
    fn applies_trust_score() {
1✔
604
        let f = write_csv("text\nHello world\n");
1✔
605
        let source = CsvSource::new(
1✔
606
            CsvSourceConfig::new("corpus", f.path())
1✔
607
                .with_text_column("text")
1✔
608
                .with_trust(0.7),
1✔
609
        )
610
        .unwrap();
1✔
611
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
612
        assert_eq!(snapshot.records[0].quality.trust, 0.7);
1✔
613
    }
1✔
614

615
    // ──────────────────────────────────────────────────────── default recipes
616

617
    #[test]
618
    fn text_mode_default_recipes_is_simcse() {
1✔
619
        let f = write_csv("text\nHello\n");
1✔
620
        let source =
1✔
621
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
622
                .unwrap();
1✔
623
        let recipes = source.default_triplet_recipes();
1✔
624
        assert_eq!(recipes.len(), 1);
1✔
625
        assert_eq!(recipes[0].name, CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE);
1✔
626
        assert!(
1✔
627
            recipes[0].allow_same_anchor_positive,
1✔
628
            "SimCSE recipe must allow same anchor/positive"
629
        );
630
    }
1✔
631

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

652
    // ──────────────────────────────────────────────────────── IndexableSource
653

654
    #[test]
655
    fn len_hint_matches_loaded_record_count() {
1✔
656
        let f = write_csv("text\nAlpha\nBeta\nGamma\n");
1✔
657
        let source =
1✔
658
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
659
                .unwrap();
1✔
660
        assert_eq!(source.len_hint(), Some(3));
1✔
661
    }
1✔
662

663
    #[test]
664
    fn record_at_returns_correct_record() {
1✔
665
        let f = write_csv("question,answer\nFirst?,Yes.\nSecond?,No.\n");
1✔
666
        let source = CsvSource::new(
1✔
667
            CsvSourceConfig::new("qna", f.path())
1✔
668
                .with_anchor_column("question")
1✔
669
                .with_positive_column("answer"),
1✔
670
        )
671
        .unwrap();
1✔
672
        let r0 = source.record_at(0).unwrap().unwrap();
1✔
673
        let r1 = source.record_at(1).unwrap().unwrap();
1✔
674
        assert_eq!(r0.sections[0].text, "First?");
1✔
675
        assert_eq!(r1.sections[0].text, "Second?");
1✔
676
        assert!(source.record_at(99).unwrap().is_none());
1✔
677
    }
1✔
678

679
    // ──────────────────────────────────────────────────────── reported count
680

681
    #[test]
682
    fn reported_record_count_matches_loaded_records() {
1✔
683
        let f = write_csv("text\nAlpha\nBeta\n");
1✔
684
        let source =
1✔
685
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
686
                .unwrap();
1✔
687
        let count = source.reported_record_count(&sampler_config()).unwrap();
1✔
688
        assert_eq!(count, 2);
1✔
689
    }
1✔
690

691
    // ──────────────────────────────────────────────────────── stable record IDs
692

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

721
    // ──────────────────────────────────────────────────────── source id
722

723
    #[test]
724
    fn source_id_is_propagated_to_records() {
1✔
725
        let f = write_csv("text\nHello\n");
1✔
726
        let source =
1✔
727
            CsvSource::new(CsvSourceConfig::new("my_source", f.path()).with_text_column("text"))
1✔
728
                .unwrap();
1✔
729
        assert_eq!(DataSource::id(&source), "my_source");
1✔
730
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
731
        assert_eq!(snapshot.records[0].source, "my_source");
1✔
732
    }
1✔
733

734
    // ──────────────────────────────────────────────────── column name trimming
735

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

751
    // ──────────────────────────────────────────────────── multi-row paging
752

753
    #[test]
754
    fn refresh_with_limit_returns_at_most_limit_records() {
1✔
755
        let f = write_csv("text\nA\nB\nC\nD\nE\n");
1✔
756
        let source =
1✔
757
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
758
                .unwrap();
1✔
759
        let snapshot = source.refresh(&sampler_config(), None, Some(3)).unwrap();
1✔
760
        assert!(
1✔
761
            snapshot.records.len() <= 3,
1✔
762
            "expected at most 3 records, got {}",
NEW
763
            snapshot.records.len()
×
764
        );
765
    }
1✔
766

767
    // ──────────────────────────────────────────── validate: third error branch
768

769
    #[test]
770
    fn rejects_positive_and_text_column_without_anchor() {
1✔
771
        // positive_column + text_column (but no anchor_column) must reach the
772
        // third validate() check after passing the first two guards.
773
        let f = write_csv("text,answer\nhello,world\n");
1✔
774
        let err = CsvSource::new(
1✔
775
            CsvSourceConfig::new("src", f.path())
1✔
776
                .with_text_column("text")
1✔
777
                .with_positive_column("answer"),
1✔
778
        )
779
        .unwrap_err();
1✔
780
        assert!(
1✔
781
            matches!(err, SamplerError::Configuration(_)),
1✔
782
            "expected Configuration error, got {err:?}"
783
        );
784
    }
1✔
785

786
    // ───────────────────────────────────────────────── file open failure path
787

788
    #[test]
789
    fn returns_source_unavailable_for_nonexistent_file() {
1✔
790
        // Exercises the from_path error closure.
791
        let err = CsvSource::new(
1✔
792
            CsvSourceConfig::new("src", "/nonexistent/does-not-exist.csv").with_text_column("text"),
1✔
793
        )
794
        .unwrap_err();
1✔
795
        assert!(
1✔
796
            matches!(err, SamplerError::SourceUnavailable { .. }),
1✔
797
            "expected SourceUnavailable, got {err:?}"
798
        );
799
    }
1✔
800

801
    // ──────────────────────────────────────────────────── row parse error path
802

803
    #[test]
804
    fn returns_source_unavailable_for_malformed_row() {
1✔
805
        // With flexible(false), a data row that has more columns than the header
806
        // triggers a csv parse error, which must map to SourceUnavailable.
807
        let f = write_csv("question,answer\nWhat is Rust?,Good language.,extra_column\n");
1✔
808
        let err = CsvSource::new(
1✔
809
            CsvSourceConfig::new("src", f.path())
1✔
810
                .with_anchor_column("question")
1✔
811
                .with_positive_column("answer"),
1✔
812
        )
813
        .unwrap_err();
1✔
814
        assert!(
1✔
815
            matches!(err, SamplerError::SourceUnavailable { .. }),
1✔
816
            "expected SourceUnavailable for malformed row, got {err:?}"
817
        );
818
    }
1✔
819

820
    // ─────────────────────────────── text mode: whitespace-only cell is skipped
821

822
    #[test]
823
    fn text_mode_skips_whitespace_only_cells() {
1✔
824
        // A cell containing only spaces is trimmed to "" by the csv reader
825
        // (Trim::All).  Our normalize_inline_whitespace("") returns "", so
826
        // the record is skipped via the empty-text guard in build_record.
827
        // Blank lines (just "\n") are silently dropped by the csv crate before
828
        // reaching build_record, so we need an actual whitespace-valued cell.
829
        let f = write_csv("text\nHello\n   \nWorld\n");
1✔
830
        let source =
1✔
831
            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
1✔
832
                .unwrap();
1✔
833
        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
1✔
834
        assert_eq!(
1✔
835
            snapshot.records.len(),
1✔
836
            2,
837
            "whitespace-only cell should be skipped"
838
        );
839
    }
1✔
840

841
    // ─────────────────────────────────────── IndexableSource::id() is reachable
842

843
    #[test]
844
    fn indexable_source_id_matches_config() {
1✔
845
        // IndexableSource::id() is only invoked through the trait
846
        // object; call it directly to ensure the implementation is exercised.
847
        let f = write_csv("text\nHello\n");
1✔
848
        let source =
1✔
849
            CsvSource::new(CsvSourceConfig::new("explicit_id", f.path()).with_text_column("text"))
1✔
850
                .unwrap();
1✔
851
        assert_eq!(IndexableSource::id(&source), "explicit_id");
1✔
852
    }
1✔
853
}
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