Skip to main content

triplets_core/source/backends/
csv_source.rs

1use chrono::{DateTime, Utc};
2use std::path::PathBuf;
3
4use crate::config::{NegativeStrategy, SamplerConfig, Selector, TripletRecipe};
5use crate::data::{DataRecord, QualityScore, SectionRole};
6use crate::errors::SamplerError;
7use crate::source::{DataSource, IndexablePager, IndexableSource, SourceCursor, SourceSnapshot};
8use crate::types::SourceId;
9use crate::utils::{file_times, make_section, normalize_inline_whitespace};
10
11const CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE: &str = "csv_anchor_positive_wrong_article";
12const CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE: &str = "csv_anchor_anchor_wrong_article";
13/// Default CSV text-columns-mode SimCSE-style recipe name.
14pub 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)]
33pub 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
55impl 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 {
58        Self {
59            source_id: source_id.into(),
60            path: path.into(),
61            anchor_column: None,
62            positive_column: None,
63            text_column: None,
64            trust: 0.85,
65        }
66    }
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 {
70        self.anchor_column = Some(column.into());
71        self
72    }
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 {
76        self.positive_column = Some(column.into());
77        self
78    }
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 {
82        self.text_column = Some(column.into());
83        self
84    }
85
86    /// Override the default trust score.
87    pub fn with_trust(mut self, trust: f32) -> Self {
88        self.trust = trust;
89        self
90    }
91
92    fn is_role_mode(&self) -> bool {
93        self.anchor_column.is_some()
94    }
95
96    fn validate(&self) -> Result<(), SamplerError> {
97        if self.anchor_column.is_some() && self.text_column.is_some() {
98            return Err(SamplerError::Configuration(
99                "CsvSourceConfig: `anchor_column` and `text_column` are mutually exclusive"
100                    .to_string(),
101            ));
102        }
103        if self.anchor_column.is_none() && self.text_column.is_none() {
104            return Err(SamplerError::Configuration(
105                "CsvSourceConfig: one of `anchor_column` or `text_column` must be set".to_string(),
106            ));
107        }
108        if self.positive_column.is_some() && self.anchor_column.is_none() {
109            return Err(SamplerError::Configuration(
110                "CsvSourceConfig: `positive_column` requires `anchor_column` to be set".to_string(),
111            ));
112        }
113        Ok(())
114    }
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_core::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)]
144pub struct CsvSource {
145    config: CsvSourceConfig,
146    records: Vec<DataRecord>,
147}
148
149impl 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> {
156        config.validate()?;
157        let records = Self::load_records(&config)?;
158        Ok(Self { config, records })
159    }
160
161    fn load_records(config: &CsvSourceConfig) -> Result<Vec<DataRecord>, SamplerError> {
162        let (created_at, updated_at) = file_times(&config.path);
163
164        let mut reader = csv::ReaderBuilder::new()
165            .has_headers(true)
166            .flexible(false)
167            .trim(csv::Trim::All)
168            .from_path(&config.path)
169            .map_err(|err| SamplerError::SourceUnavailable {
170                source_id: config.source_id.clone(),
171                reason: format!("failed to open CSV file '{}': {err}", config.path.display()),
172            })?;
173
174        // Columns are selected by name against the header row.
175        let headers = reader
176            .headers()
177            .map_err(|err| SamplerError::SourceUnavailable {
178                source_id: config.source_id.clone(),
179                reason: format!(
180                    "failed to read CSV headers in '{}': {err}",
181                    config.path.display()
182                ),
183            })?
184            .clone();
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 {
189            Some(column_index(&headers, col).ok_or_else(|| {
190                SamplerError::Configuration(format!(
191                    "anchor_column '{}' not found in CSV headers of '{}'",
192                    col,
193                    config.path.display()
194                ))
195            })?)
196        } else {
197            None
198        };
199
200        let positive_idx = if let Some(col) = &config.positive_column {
201            Some(column_index(&headers, col).ok_or_else(|| {
202                SamplerError::Configuration(format!(
203                    "positive_column '{}' not found in CSV headers of '{}'",
204                    col,
205                    config.path.display()
206                ))
207            })?)
208        } else {
209            None
210        };
211
212        let text_idx = if let Some(col) = &config.text_column {
213            Some(column_index(&headers, col).ok_or_else(|| {
214                SamplerError::Configuration(format!(
215                    "text_column '{}' not found in CSV headers of '{}'",
216                    col,
217                    config.path.display()
218                ))
219            })?)
220        } else {
221            None
222        };
223
224        let mut records = Vec::new();
225
226        let cols = ColumnIndices {
227            anchor: anchor_idx,
228            positive: positive_idx,
229            text: text_idx,
230        };
231
232        for (row_idx, result) in reader.records().enumerate() {
233            let row = result.map_err(|err| SamplerError::SourceUnavailable {
234                source_id: config.source_id.clone(),
235                reason: format!(
236                    "failed to read row {} in '{}': {err}",
237                    row_idx,
238                    config.path.display()
239                ),
240            })?;
241
242            if let Some(record) = build_record(config, &row, row_idx, &cols, created_at, updated_at)
243            {
244                records.push(record);
245            }
246        }
247
248        Ok(records)
249    }
250}
251
252/// Resolve a column name to its zero-based index in a header record.
253fn column_index(headers: &csv::StringRecord, name: &str) -> Option<usize> {
254    headers.iter().position(|h| h.eq_ignore_ascii_case(name))
255}
256
257/// Pre-resolved column indices for a CSV source.
258struct 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.
267fn build_record(
268    config: &CsvSourceConfig,
269    row: &csv::StringRecord,
270    row_idx: usize,
271    cols: &ColumnIndices,
272    created_at: DateTime<Utc>,
273    updated_at: DateTime<Utc>,
274) -> Option<DataRecord> {
275    let id = format!("{}::row_{}", config.source_id, row_idx);
276
277    let sections = if config.is_role_mode() {
278        // Role mode: anchor + optional positive
279        let anchor_raw = cols.anchor.and_then(|i| row.get(i)).unwrap_or("");
280        let anchor_text = normalize_inline_whitespace(anchor_raw);
281        if anchor_text.is_empty() {
282            return None;
283        }
284
285        let positive_text = if let Some(pidx) = cols.positive {
286            let raw = row.get(pidx).unwrap_or("");
287            let normalized = normalize_inline_whitespace(raw);
288            if normalized.is_empty() {
289                return None;
290            }
291            normalized
292        } else {
293            // Fall back to anchor text as positive when no positive column is set.
294            anchor_text.clone()
295        };
296
297        let anchor_heading = config.anchor_column.as_deref();
298        let positive_heading = config
299            .positive_column
300            .as_deref()
301            .or(config.anchor_column.as_deref());
302
303        vec![
304            make_section(SectionRole::Anchor, anchor_heading, &anchor_text),
305            make_section(SectionRole::Context, positive_heading, &positive_text),
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("");
310        let text = normalize_inline_whitespace(raw);
311        if text.is_empty() {
312            return None;
313        }
314
315        let heading = config.text_column.as_deref();
316        vec![
317            make_section(SectionRole::Anchor, heading, &text),
318            make_section(SectionRole::Context, heading, &text),
319        ]
320    };
321
322    Some(DataRecord {
323        id,
324        source: config.source_id.clone(),
325        created_at,
326        updated_at,
327        quality: QualityScore {
328            trust: config.trust,
329        },
330        taxonomy: vec![config.source_id.clone()],
331        sections,
332        meta_prefix: None,
333        label: None,
334    })
335}
336
337impl IndexableSource for CsvSource {
338    fn id(&self) -> &str {
339        &self.config.source_id
340    }
341
342    fn len_hint(&self) -> Option<usize> {
343        Some(self.records.len())
344    }
345
346    fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
347        Ok(self.records.get(idx).cloned())
348    }
349}
350
351impl DataSource for CsvSource {
352    fn id(&self) -> &str {
353        &self.config.source_id
354    }
355
356    fn refresh(
357        &self,
358        _config: &SamplerConfig,
359        cursor: Option<&SourceCursor>,
360        limit: Option<usize>,
361    ) -> Result<SourceSnapshot, SamplerError> {
362        IndexablePager::new(&self.config.source_id).refresh(self, cursor, limit)
363    }
364
365    fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
366        Ok(self.records.len() as u128)
367    }
368
369    fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
370        if !self.config.is_role_mode() {
371            // Text mode: SimCSE-style recipe that allows same anchor/positive text.
372            // Dropout noise provides the necessary embedding variation between
373            // the two identical slots; the negative comes from a different record.
374            return vec![TripletRecipe {
375                name: CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE.into(),
376                anchor: Selector::Role(SectionRole::Anchor),
377                positive_selector: Selector::Role(SectionRole::Context),
378                negative_selector: Selector::Role(SectionRole::Context),
379                negative_strategy: NegativeStrategy::WrongArticle,
380                weight: 1.0,
381                instruction: None,
382                allow_same_anchor_positive: true,
383            }];
384        }
385
386        vec![
387            // Primary lane: context (positive) negatives for broad coverage.
388            TripletRecipe {
389                name: CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE.into(),
390                anchor: Selector::Role(SectionRole::Anchor),
391                positive_selector: Selector::Role(SectionRole::Context),
392                negative_selector: Selector::Role(SectionRole::Context),
393                negative_strategy: NegativeStrategy::WrongArticle,
394                weight: 0.75,
395                instruction: None,
396                allow_same_anchor_positive: false,
397            },
398            // Medium-hard lane: anchor-as-negative for discrimination pressure.
399            TripletRecipe {
400                name: CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE.into(),
401                anchor: Selector::Role(SectionRole::Anchor),
402                positive_selector: Selector::Role(SectionRole::Context),
403                negative_selector: Selector::Role(SectionRole::Anchor),
404                negative_strategy: NegativeStrategy::WrongArticle,
405                weight: 0.25,
406                instruction: None,
407                allow_same_anchor_positive: false,
408            },
409        ]
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::config::SamplerConfig;
417    use crate::source::DataSource;
418    use std::io::Write;
419    use tempfile::NamedTempFile;
420
421    fn write_csv(content: &str) -> NamedTempFile {
422        let mut f = NamedTempFile::new().unwrap();
423        write!(f, "{content}").unwrap();
424        f
425    }
426
427    fn sampler_config() -> SamplerConfig {
428        SamplerConfig {
429            seed: 42,
430            ..SamplerConfig::default()
431        }
432    }
433
434    // ──────────────────────────────────────────────────────────── construction
435
436    #[test]
437    fn rejects_anchor_and_text_columns_together() {
438        let f = write_csv("anchor,text\nhello,world\n");
439        let err = CsvSource::new(
440            CsvSourceConfig::new("src", f.path())
441                .with_anchor_column("anchor")
442                .with_text_column("text"),
443        )
444        .unwrap_err();
445        assert!(
446            matches!(err, SamplerError::Configuration(_)),
447            "expected Configuration error, got {err:?}"
448        );
449    }
450
451    #[test]
452    fn rejects_missing_column_spec() {
453        let f = write_csv("anchor,text\nhello,world\n");
454        let err = CsvSource::new(CsvSourceConfig::new("src", f.path())).unwrap_err();
455        assert!(matches!(err, SamplerError::Configuration(_)));
456    }
457
458    #[test]
459    fn rejects_positive_without_anchor() {
460        let f = write_csv("anchor,text\nhello,world\n");
461        let err =
462            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_positive_column("text"))
463                .unwrap_err();
464        assert!(matches!(err, SamplerError::Configuration(_)));
465    }
466
467    #[test]
468    fn rejects_missing_anchor_column_in_file() {
469        let f = write_csv("question,answer\nhello,world\n");
470        let err =
471            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_anchor_column("missing_col"))
472                .unwrap_err();
473        assert!(matches!(err, SamplerError::Configuration(_)));
474    }
475
476    #[test]
477    fn rejects_missing_text_column_in_file() {
478        let f = write_csv("question,answer\nhello,world\n");
479        let err =
480            CsvSource::new(CsvSourceConfig::new("src", f.path()).with_text_column("missing_col"))
481                .unwrap_err();
482        assert!(matches!(err, SamplerError::Configuration(_)));
483    }
484
485    #[test]
486    fn rejects_missing_positive_column_in_file() {
487        let f = write_csv("question,answer\nhello,world\n");
488        let err = CsvSource::new(
489            CsvSourceConfig::new("src", f.path())
490                .with_anchor_column("question")
491                .with_positive_column("missing_col"),
492        )
493        .unwrap_err();
494        assert!(matches!(err, SamplerError::Configuration(_)));
495    }
496
497    // ──────────────────────────────────────────────────────────── role mode
498
499    #[test]
500    fn role_mode_anchor_and_positive() {
501        let f = write_csv("question,answer\nWhat is Rust?,A systems language.\n");
502        let source = CsvSource::new(
503            CsvSourceConfig::new("qna", f.path())
504                .with_anchor_column("question")
505                .with_positive_column("answer"),
506        )
507        .unwrap();
508
509        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
510        assert_eq!(snapshot.records.len(), 1);
511        let record = &snapshot.records[0];
512        assert_eq!(record.source, "qna");
513        assert_eq!(record.sections.len(), 2);
514        assert_eq!(record.sections[0].role, SectionRole::Anchor);
515        assert_eq!(record.sections[0].text, "What is Rust?");
516        assert_eq!(record.sections[1].role, SectionRole::Context);
517        assert_eq!(record.sections[1].text, "A systems language.");
518    }
519
520    #[test]
521    fn role_mode_anchor_only_duplicates_to_context() {
522        let f = write_csv("sentence\nHello world\n");
523        let source = CsvSource::new(
524            CsvSourceConfig::new("anchors", f.path()).with_anchor_column("sentence"),
525        )
526        .unwrap();
527
528        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
529        assert_eq!(snapshot.records.len(), 1);
530        let record = &snapshot.records[0];
531        assert_eq!(record.sections.len(), 2);
532        assert_eq!(record.sections[0].role, SectionRole::Anchor);
533        assert_eq!(record.sections[1].role, SectionRole::Context);
534        // Context must mirror the anchor text.
535        assert_eq!(record.sections[0].text, record.sections[1].text);
536    }
537
538    #[test]
539    fn role_mode_skips_rows_with_empty_anchor() {
540        let f = write_csv(
541            "question,answer\n\
542             What is Rust?,A systems language.\n\
543             ,Missing anchor\n\
544             What is Go?,A concurrent language.\n",
545        );
546        let source = CsvSource::new(
547            CsvSourceConfig::new("qna", f.path())
548                .with_anchor_column("question")
549                .with_positive_column("answer"),
550        )
551        .unwrap();
552        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
553        assert_eq!(snapshot.records.len(), 2);
554    }
555
556    #[test]
557    fn role_mode_skips_rows_with_empty_positive() {
558        let f = write_csv(
559            "question,answer\n\
560             What is Rust?,A systems language.\n\
561             What is Go?,\n",
562        );
563        let source = CsvSource::new(
564            CsvSourceConfig::new("qna", f.path())
565                .with_anchor_column("question")
566                .with_positive_column("answer"),
567        )
568        .unwrap();
569        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
570        assert_eq!(snapshot.records.len(), 1);
571    }
572
573    // ──────────────────────────────────────────────────────────── text mode
574
575    #[test]
576    fn text_mode_produces_identical_anchor_and_context() {
577        let f = write_csv("text\nThe quick brown fox\n");
578        let source =
579            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
580                .unwrap();
581
582        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
583        assert_eq!(snapshot.records.len(), 1);
584        let record = &snapshot.records[0];
585        assert_eq!(record.sections.len(), 2);
586        assert_eq!(record.sections[0].role, SectionRole::Anchor);
587        assert_eq!(record.sections[1].role, SectionRole::Context);
588        assert_eq!(record.sections[0].text, record.sections[1].text);
589    }
590
591    #[test]
592    fn text_mode_skips_empty_rows() {
593        let f = write_csv("text\nHello\n\nWorld\n");
594        let source =
595            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
596                .unwrap();
597        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
598        assert_eq!(snapshot.records.len(), 2);
599    }
600
601    // ──────────────────────────────────────────────────────── quality / trust
602
603    #[test]
604    fn applies_trust_score() {
605        let f = write_csv("text\nHello world\n");
606        let source = CsvSource::new(
607            CsvSourceConfig::new("corpus", f.path())
608                .with_text_column("text")
609                .with_trust(0.7),
610        )
611        .unwrap();
612        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
613        assert_eq!(snapshot.records[0].quality.trust, 0.7);
614    }
615
616    // ──────────────────────────────────────────────────────── default recipes
617
618    #[test]
619    fn text_mode_default_recipes_is_simcse() {
620        let f = write_csv("text\nHello\n");
621        let source =
622            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
623                .unwrap();
624        let recipes = source.default_triplet_recipes();
625        assert_eq!(recipes.len(), 1);
626        assert_eq!(recipes[0].name, CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE);
627        assert!(
628            recipes[0].allow_same_anchor_positive,
629            "SimCSE recipe must allow same anchor/positive"
630        );
631    }
632
633    #[test]
634    fn role_mode_default_recipes_returns_two_recipes() {
635        let f = write_csv("question,answer\nQ,A\n");
636        let source = CsvSource::new(
637            CsvSourceConfig::new("qna", f.path())
638                .with_anchor_column("question")
639                .with_positive_column("answer"),
640        )
641        .unwrap();
642        let recipes = source.default_triplet_recipes();
643        assert_eq!(recipes.len(), 2);
644        let names: Vec<&str> = recipes.iter().map(|r| r.name.as_ref()).collect();
645        assert!(names.contains(&CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE));
646        assert!(names.contains(&CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE));
647        assert!(
648            recipes.iter().all(|r| !r.allow_same_anchor_positive),
649            "role-mode recipes must not allow same anchor/positive"
650        );
651    }
652
653    // ──────────────────────────────────────────────────────── IndexableSource
654
655    #[test]
656    fn len_hint_matches_loaded_record_count() {
657        let f = write_csv("text\nAlpha\nBeta\nGamma\n");
658        let source =
659            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
660                .unwrap();
661        assert_eq!(source.len_hint(), Some(3));
662    }
663
664    #[test]
665    fn record_at_returns_correct_record() {
666        let f = write_csv("question,answer\nFirst?,Yes.\nSecond?,No.\n");
667        let source = CsvSource::new(
668            CsvSourceConfig::new("qna", f.path())
669                .with_anchor_column("question")
670                .with_positive_column("answer"),
671        )
672        .unwrap();
673        let r0 = source.record_at(0).unwrap().unwrap();
674        let r1 = source.record_at(1).unwrap().unwrap();
675        assert_eq!(r0.sections[0].text, "First?");
676        assert_eq!(r1.sections[0].text, "Second?");
677        assert!(source.record_at(99).unwrap().is_none());
678    }
679
680    // ──────────────────────────────────────────────────────── reported count
681
682    #[test]
683    fn reported_record_count_matches_loaded_records() {
684        let f = write_csv("text\nAlpha\nBeta\n");
685        let source =
686            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
687                .unwrap();
688        let count = source.reported_record_count(&sampler_config()).unwrap();
689        assert_eq!(count, 2);
690    }
691
692    // ──────────────────────────────────────────────────────── stable record IDs
693
694    #[test]
695    fn record_ids_are_stable_across_refreshes() {
696        let f = write_csv("text\nAlpha\nBeta\n");
697        let source =
698            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
699                .unwrap();
700        let ids_a: Vec<_> = source
701            .refresh(&sampler_config(), None, None)
702            .unwrap()
703            .records
704            .iter()
705            .map(|r| r.id.clone())
706            .collect();
707        let ids_b: Vec<_> = source
708            .refresh(&sampler_config(), None, None)
709            .unwrap()
710            .records
711            .iter()
712            .map(|r| r.id.clone())
713            .collect();
714        // IDs must be the same set (order may differ due to pager permutation).
715        let mut sorted_a = ids_a.clone();
716        let mut sorted_b = ids_b.clone();
717        sorted_a.sort();
718        sorted_b.sort();
719        assert_eq!(sorted_a, sorted_b);
720    }
721
722    // ──────────────────────────────────────────────────────── source id
723
724    #[test]
725    fn source_id_is_propagated_to_records() {
726        let f = write_csv("text\nHello\n");
727        let source =
728            CsvSource::new(CsvSourceConfig::new("my_source", f.path()).with_text_column("text"))
729                .unwrap();
730        assert_eq!(DataSource::id(&source), "my_source");
731        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
732        assert_eq!(snapshot.records[0].source, "my_source");
733    }
734
735    // ──────────────────────────────────────────────────── column name trimming
736
737    #[test]
738    fn column_lookup_is_case_insensitive() {
739        let f = write_csv("Question,Answer\nWhat is Rust?,A systems language.\n");
740        // Lower-case lookup against mixed-case headers.
741        let source = CsvSource::new(
742            CsvSourceConfig::new("qna", f.path())
743                .with_anchor_column("question")
744                .with_positive_column("answer"),
745        )
746        .unwrap();
747        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
748        assert_eq!(snapshot.records.len(), 1);
749        assert_eq!(snapshot.records[0].sections[0].text, "What is Rust?");
750    }
751
752    // ──────────────────────────────────────────────────── multi-row paging
753
754    #[test]
755    fn refresh_with_limit_returns_at_most_limit_records() {
756        let f = write_csv("text\nA\nB\nC\nD\nE\n");
757        let source =
758            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
759                .unwrap();
760        let snapshot = source.refresh(&sampler_config(), None, Some(3)).unwrap();
761        assert!(
762            snapshot.records.len() <= 3,
763            "expected at most 3 records, got {}",
764            snapshot.records.len()
765        );
766    }
767
768    // ──────────────────────────────────────────── validate: third error branch
769
770    #[test]
771    fn rejects_positive_and_text_column_without_anchor() {
772        // positive_column + text_column (but no anchor_column) must reach the
773        // third validate() check after passing the first two guards.
774        let f = write_csv("text,answer\nhello,world\n");
775        let err = CsvSource::new(
776            CsvSourceConfig::new("src", f.path())
777                .with_text_column("text")
778                .with_positive_column("answer"),
779        )
780        .unwrap_err();
781        assert!(
782            matches!(err, SamplerError::Configuration(_)),
783            "expected Configuration error, got {err:?}"
784        );
785    }
786
787    // ───────────────────────────────────────────────── file open failure path
788
789    #[test]
790    fn returns_source_unavailable_for_nonexistent_file() {
791        // Exercises the from_path error closure.
792        let err = CsvSource::new(
793            CsvSourceConfig::new("src", "/nonexistent/does-not-exist.csv").with_text_column("text"),
794        )
795        .unwrap_err();
796        assert!(
797            matches!(err, SamplerError::SourceUnavailable { .. }),
798            "expected SourceUnavailable, got {err:?}"
799        );
800    }
801
802    // ──────────────────────────────────────────────────── row parse error path
803
804    #[test]
805    fn returns_source_unavailable_for_malformed_row() {
806        // With flexible(false), a data row that has more columns than the header
807        // triggers a csv parse error, which must map to SourceUnavailable.
808        let f = write_csv("question,answer\nWhat is Rust?,Good language.,extra_column\n");
809        let err = CsvSource::new(
810            CsvSourceConfig::new("src", f.path())
811                .with_anchor_column("question")
812                .with_positive_column("answer"),
813        )
814        .unwrap_err();
815        assert!(
816            matches!(err, SamplerError::SourceUnavailable { .. }),
817            "expected SourceUnavailable for malformed row, got {err:?}"
818        );
819    }
820
821    // ─────────────────────────────── text mode: whitespace-only cell is skipped
822
823    #[test]
824    fn text_mode_skips_whitespace_only_cells() {
825        // A cell containing only spaces is trimmed to "" by the csv reader
826        // (Trim::All).  Our normalize_inline_whitespace("") returns "", so
827        // the record is skipped via the empty-text guard in build_record.
828        // Blank lines (just "\n") are silently dropped by the csv crate before
829        // reaching build_record, so we need an actual whitespace-valued cell.
830        let f = write_csv("text\nHello\n   \nWorld\n");
831        let source =
832            CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
833                .unwrap();
834        let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
835        assert_eq!(
836            snapshot.records.len(),
837            2,
838            "whitespace-only cell should be skipped"
839        );
840    }
841
842    // ─────────────────────────────────────── IndexableSource::id() is reachable
843
844    #[test]
845    fn indexable_source_id_matches_config() {
846        // IndexableSource::id() is only invoked through the trait
847        // object; call it directly to ensure the implementation is exercised.
848        let f = write_csv("text\nHello\n");
849        let source =
850            CsvSource::new(CsvSourceConfig::new("explicit_id", f.path()).with_text_column("text"))
851                .unwrap();
852        assert_eq!(IndexableSource::id(&source), "explicit_id");
853    }
854}