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

jzombie / rust-triplets / 24757375850

22 Apr 2026 02:41AM UTC coverage: 95.528% (+0.001%) from 95.527%
24757375850

Pull #78

github

web-flow
Merge 24ca6478b into 61bf65596
Pull Request #78: Add optional file source index override

49 of 51 new or added lines in 1 file covered. (96.08%)

17771 of 18603 relevant lines covered (95.53%)

119580.65 hits per line

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

97.41
/src/source/backends/file_source.rs
1
use std::collections::HashMap;
2
use std::path::{Path, PathBuf};
3
use std::sync::Arc;
4

5
use crate::config::{NegativeStrategy, SamplerConfig, Selector, TripletRecipe};
6
use crate::data::{DataRecord, QualityScore, RecordSection, SectionRole};
7
use crate::errors::SamplerError;
8
use crate::source::indexing::file_corpus::FileCorpusIndex;
9
use crate::source::{DataSource, SourceCursor, SourceSnapshot};
10
use crate::types::{CategoryId, SourceId, TaxonomyValue};
11
use crate::utils::{file_times, is_text_file};
12
use crate::utils::{make_section, normalize_inline_whitespace};
13

14
const FILE_RECIPE_TITLE_CONTEXT_WRONG_DATE: &str = "title_context_wrong_date";
15
const FILE_RECIPE_TITLE_ANCHOR_WRONG_DATE: &str = "title_anchor_wrong_date";
16
const FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE: &str = "title_context_wrong_article";
17
const FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE: &str = "title_anchor_wrong_article";
18

19
/// Builds taxonomy values from a root path and file path.
20
pub type TaxonomyBuilder =
21
    Arc<dyn Fn(&Path, &Path, &SourceId) -> Vec<TaxonomyValue> + Send + Sync + 'static>;
22

23
/// Builds record sections from a normalized title and body.
24
pub type SectionBuilder = Arc<dyn Fn(&str, &str) -> Vec<RecordSection> + Send + Sync + 'static>;
25

26
/// Configuration for a generic filesystem-backed data source.
27
#[derive(Clone)]
28
pub struct FileSourceConfig {
29
    /// Stable source identifier used in records and persistence keys.
30
    pub source_id: SourceId,
31
    /// Root directory containing source files.
32
    pub root: PathBuf,
33
    /// Default quality trust score applied to generated records.
34
    pub trust: f32,
35
    /// Optional trust overrides keyed by taxonomy segment.
36
    pub category_trust: HashMap<CategoryId, f32>,
37
    /// Whether to follow symlinks during index walking.
38
    pub follow_links: bool,
39
    /// Whether indexing should include only text files.
40
    pub text_files_only: bool,
41
    /// Whether deterministic directory grouping is enabled.
42
    pub group_by_directory: bool,
43
    /// Whether title extraction should replace underscores with spaces.
44
    pub title_replace_underscores: bool,
45
    /// Whether default recipe set includes the date-aware negative lane.
46
    pub include_date_aware_default_recipe: bool,
47
    /// Optional directory used for persisted file-corpus index stores.
48
    ///
49
    /// When `None`, file-corpus indexing uses the managed cache discovery root.
50
    /// Set this in tests to keep index writes inside temporary directories.
51
    pub index_dir: Option<PathBuf>,
52
    /// Optional default recipes returned by this source.
53
    pub default_triplet_recipes: Vec<TripletRecipe>,
54
    /// Taxonomy builder invoked per file.
55
    pub taxonomy_builder: TaxonomyBuilder,
56
    /// Section builder invoked per file.
57
    pub section_builder: SectionBuilder,
58
}
59

60
impl FileSourceConfig {
61
    /// Create a config for a filesystem source with explicit id and root.
62
    pub fn new(source_id: impl Into<SourceId>, root: impl Into<PathBuf>) -> Self {
15✔
63
        Self {
15✔
64
            source_id: source_id.into(),
15✔
65
            root: root.into(),
15✔
66
            trust: 0.85,
15✔
67
            category_trust: HashMap::new(),
15✔
68
            follow_links: true,
15✔
69
            text_files_only: false,
15✔
70
            group_by_directory: true,
15✔
71
            title_replace_underscores: true,
15✔
72
            include_date_aware_default_recipe: false,
15✔
73
            index_dir: None,
15✔
74
            default_triplet_recipes: default_title_context_triplet_recipes(false),
15✔
75
            taxonomy_builder: Arc::new(taxonomy_from_path),
15✔
76
            section_builder: Arc::new(anchor_context_sections),
15✔
77
        }
15✔
78
    }
15✔
79

80
    /// Override default trust score.
81
    pub fn with_trust(mut self, trust: f32) -> Self {
1✔
82
        self.trust = trust;
1✔
83
        self
1✔
84
    }
1✔
85

86
    /// Add a taxonomy-segment trust override.
87
    pub fn with_category_trust(mut self, category: impl Into<String>, trust: f32) -> Self {
3✔
88
        self.category_trust
3✔
89
            .insert(category.into().to_lowercase(), trust);
3✔
90
        self
3✔
91
    }
3✔
92

93
    /// Override whether symlinks are followed during index walk.
94
    pub fn with_follow_links(mut self, follow_links: bool) -> Self {
×
95
        self.follow_links = follow_links;
×
96
        self
×
97
    }
×
98

99
    /// Override whether index walk includes only text files.
100
    pub fn with_text_files_only(mut self, text_files_only: bool) -> Self {
1✔
101
        self.text_files_only = text_files_only;
1✔
102
        self
1✔
103
    }
1✔
104

105
    /// Enable or disable deterministic directory grouping.
106
    pub fn with_directory_grouping(mut self, group_by_directory: bool) -> Self {
×
107
        self.group_by_directory = group_by_directory;
×
108
        self
×
109
    }
×
110

111
    /// Set whether title extraction replaces underscores with spaces.
112
    pub fn with_title_replace_underscores(mut self, replace_underscores: bool) -> Self {
1✔
113
        self.title_replace_underscores = replace_underscores;
1✔
114
        self
1✔
115
    }
1✔
116

117
    /// Enable/disable the date-aware default recipe lane (`WrongPublicationDate`).
118
    ///
119
    /// "Date-aware" here uses publication-date metadata on records (for example
120
    /// taxonomy/meta date fields), not filesystem timestamps from source files.
121
    pub fn with_date_aware_default_recipe(mut self, include: bool) -> Self {
1✔
122
        self.include_date_aware_default_recipe = include;
1✔
123
        self.default_triplet_recipes = default_title_context_triplet_recipes(include);
1✔
124
        self
1✔
125
    }
1✔
126

127
    /// Override the directory used to persist file-corpus index stores.
128
    pub fn with_index_dir(mut self, index_dir: impl Into<PathBuf>) -> Self {
2✔
129
        self.index_dir = Some(index_dir.into());
2✔
130
        self
2✔
131
    }
2✔
132

133
    /// Set source-provided default triplet recipes.
134
    pub fn with_default_triplet_recipes(mut self, recipes: Vec<TripletRecipe>) -> Self {
2✔
135
        self.default_triplet_recipes = recipes;
2✔
136
        self
2✔
137
    }
2✔
138

139
    /// Set a custom taxonomy builder.
140
    pub fn with_taxonomy_builder(mut self, taxonomy_builder: TaxonomyBuilder) -> Self {
1✔
141
        self.taxonomy_builder = taxonomy_builder;
1✔
142
        self
1✔
143
    }
1✔
144

145
    /// Set a custom section builder.
146
    pub fn with_section_builder(mut self, section_builder: SectionBuilder) -> Self {
1✔
147
        self.section_builder = section_builder;
1✔
148
        self
1✔
149
    }
1✔
150
}
151

152
/// Default mixed-negative recipes used by `FileSource` title/body corpora.
153
///
154
/// When `include_date_aware` is enabled, the date-aware lane compares metadata
155
/// publication dates, not filesystem mtime/ctime/atime values.
156
pub fn default_title_context_triplet_recipes(include_date_aware: bool) -> Vec<TripletRecipe> {
16✔
157
    let mut recipes = Vec::new();
16✔
158
    if include_date_aware {
16✔
159
        // Make date-aware summary negatives nearly as common as summary wrong-article
1✔
160
        // negatives so temporal contrast is meaningfully represented.
1✔
161
        recipes.push(TripletRecipe {
1✔
162
            name: FILE_RECIPE_TITLE_CONTEXT_WRONG_DATE.into(),
1✔
163
            anchor: Selector::Role(SectionRole::Anchor),
1✔
164
            positive_selector: Selector::Role(SectionRole::Context),
1✔
165
            negative_selector: Selector::Role(SectionRole::Context),
1✔
166
            negative_strategy: NegativeStrategy::WrongPublicationDate,
1✔
167
            weight: 0.30,
1✔
168
            instruction: None,
1✔
169
            allow_same_anchor_positive: false,
1✔
170
        });
1✔
171
        // Keep a smaller anchor-negative date-aware lane for harder examples
1✔
172
        // without overwhelming the primary summary-driven objectives.
1✔
173
        // Date-aware means publication-date metadata comparison, not file mtime.
1✔
174
        recipes.push(TripletRecipe {
1✔
175
            name: FILE_RECIPE_TITLE_ANCHOR_WRONG_DATE.into(),
1✔
176
            anchor: Selector::Role(SectionRole::Anchor),
1✔
177
            positive_selector: Selector::Role(SectionRole::Context),
1✔
178
            negative_selector: Selector::Role(SectionRole::Anchor),
1✔
179
            negative_strategy: NegativeStrategy::WrongPublicationDate,
1✔
180
            weight: 0.10,
1✔
181
            instruction: None,
1✔
182
            allow_same_anchor_positive: false,
1✔
183
        });
1✔
184
    }
15✔
185
    // Rebalance summary wrong-article depending on whether date-aware lanes are
186
    // enabled so the full pool stays intentionally weighted in both modes.
187
    recipes.push(TripletRecipe {
16✔
188
        name: FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE.into(),
16✔
189
        anchor: Selector::Role(SectionRole::Anchor),
16✔
190
        positive_selector: Selector::Role(SectionRole::Context),
16✔
191
        negative_selector: Selector::Role(SectionRole::Context),
16✔
192
        negative_strategy: NegativeStrategy::WrongArticle,
16✔
193
        weight: if include_date_aware { 0.35 } else { 0.75 },
16✔
194
        instruction: None,
16✔
195
        allow_same_anchor_positive: false,
196
    });
197
    // Medium-hard lane adds anchor-as-negative pressure to improve
198
    // discrimination among title-like anchor fields.
199
    recipes.push(TripletRecipe {
16✔
200
        name: FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE.into(),
16✔
201
        anchor: Selector::Role(SectionRole::Anchor),
16✔
202
        positive_selector: Selector::Role(SectionRole::Context),
16✔
203
        negative_selector: Selector::Role(SectionRole::Anchor),
16✔
204
        negative_strategy: NegativeStrategy::WrongArticle,
16✔
205
        weight: 0.25,
16✔
206
        instruction: None,
16✔
207
        allow_same_anchor_positive: false,
16✔
208
    });
16✔
209
    recipes
16✔
210
}
16✔
211

212
/// Generic filesystem-backed source with configurable taxonomy and section mapping.
213
pub struct FileSource {
214
    config: FileSourceConfig,
215
}
216

217
impl FileSource {
218
    /// Create a generic file source from configuration.
219
    pub fn new(config: FileSourceConfig) -> Self {
15✔
220
        Self { config }
15✔
221
    }
15✔
222

223
    fn file_corpus_index(&self, sampler_seed: u64) -> FileCorpusIndex {
13✔
224
        let mut index = FileCorpusIndex::new(&self.config.root, &self.config.source_id)
13✔
225
            .with_sampler_seed(sampler_seed)
13✔
226
            .with_follow_links(self.config.follow_links)
13✔
227
            .with_text_files_only(self.config.text_files_only)
13✔
228
            .with_directory_grouping(self.config.group_by_directory);
13✔
229

230
        if let Some(index_dir) = &self.config.index_dir {
13✔
231
            index = index.with_index_dir(index_dir.clone());
2✔
232
        }
11✔
233

234
        index
13✔
235
    }
13✔
236

237
    fn trust_for_taxonomy(&self, taxonomy: &[String]) -> f32 {
33✔
238
        for segment in taxonomy.iter().skip(1) {
33✔
239
            if let Some(weight) = self.config.category_trust.get(&segment.to_lowercase()) {
4✔
240
                return *weight;
2✔
241
            }
2✔
242
        }
243
        self.config.trust
31✔
244
    }
33✔
245

246
    fn build_record(&self, path: &Path) -> Result<Option<DataRecord>, SamplerError> {
34✔
247
        if !is_text_file(path) {
34✔
248
            return Ok(None);
1✔
249
        }
33✔
250
        let title = FileCorpusIndex::normalized_title_from_stem(
33✔
251
            path,
33✔
252
            &self.config.source_id,
33✔
253
            self.config.title_replace_underscores,
33✔
254
        )?;
×
255
        if title.is_empty() {
33✔
256
            return Ok(None);
×
257
        }
33✔
258

259
        let body_raw = std::fs::read_to_string(path)?;
33✔
260
        let body = normalize_inline_whitespace(body_raw);
33✔
261
        if body.is_empty() {
33✔
262
            return Ok(None);
×
263
        }
33✔
264

265
        let taxonomy =
33✔
266
            (self.config.taxonomy_builder)(&self.config.root, path, &self.config.source_id);
33✔
267
        let sections = (self.config.section_builder)(&title, &body);
33✔
268
        let trust = self.trust_for_taxonomy(&taxonomy);
33✔
269
        let (created_at, updated_at) = file_times(path);
33✔
270

271
        Ok(Some(DataRecord {
33✔
272
            id: FileCorpusIndex::source_scoped_record_id(
33✔
273
                &self.config.source_id,
33✔
274
                &self.config.root,
33✔
275
                path,
33✔
276
            ),
33✔
277
            source: self.config.source_id.clone(),
33✔
278
            created_at,
33✔
279
            updated_at,
33✔
280
            quality: QualityScore { trust },
33✔
281
            taxonomy,
33✔
282
            sections,
33✔
283
            meta_prefix: None,
33✔
284
        }))
33✔
285
    }
34✔
286
}
287

288
impl DataSource for FileSource {
289
    fn id(&self) -> &str {
1✔
290
        &self.config.source_id
1✔
291
    }
1✔
292

293
    fn refresh(
11✔
294
        &self,
11✔
295
        config: &SamplerConfig,
11✔
296
        cursor: Option<&SourceCursor>,
11✔
297
        limit: Option<usize>,
11✔
298
    ) -> Result<SourceSnapshot, SamplerError> {
11✔
299
        self.file_corpus_index(config.seed)
11✔
300
            .refresh_indexable(cursor, limit, |path| self.build_record(path))
34✔
301
    }
11✔
302

303
    fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
2✔
304
        self.file_corpus_index(config.seed)
2✔
305
            .indexed_record_count()
2✔
306
            .map(|count| count as u128)
2✔
307
    }
2✔
308

309
    fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
4✔
310
        self.config.default_triplet_recipes.clone()
4✔
311
    }
4✔
312
}
313

314
/// Build default taxonomy from the file path relative to `root`.
315
///
316
/// Output shape is `[source_id, <parent segments...>]`.
317
pub fn taxonomy_from_path(root: &Path, path: &Path, source_id: &SourceId) -> Vec<TaxonomyValue> {
34✔
318
    let mut taxonomy = vec![source_id.to_string()];
34✔
319
    if let Ok(rel) = path.strip_prefix(root)
34✔
320
        && let Some(parent) = rel.parent()
33✔
321
    {
322
        for segment in parent.iter() {
33✔
323
            taxonomy.push(segment.to_string_lossy().to_string());
5✔
324
        }
5✔
325
    }
1✔
326
    taxonomy
34✔
327
}
34✔
328

329
/// Build a default two-section payload of title anchor and body context.
330
pub fn anchor_context_sections(title: &str, body: &str) -> Vec<RecordSection> {
33✔
331
    vec![
33✔
332
        make_section(SectionRole::Anchor, None, title),
33✔
333
        make_section(SectionRole::Context, None, body),
33✔
334
    ]
335
}
33✔
336

337
#[cfg(test)]
338
mod tests {
339
    use super::*;
340
    use crate::config::{NegativeStrategy, Selector};
341
    use tempfile::tempdir;
342

343
    fn sampler_config(seed: u64) -> SamplerConfig {
12✔
344
        SamplerConfig {
12✔
345
            seed,
12✔
346
            ..SamplerConfig::default()
12✔
347
        }
12✔
348
    }
12✔
349

350
    #[test]
351
    fn reads_records_without_default_source_id() {
1✔
352
        let temp = tempdir().unwrap();
1✔
353
        let category = temp.path().join("factual");
1✔
354
        std::fs::create_dir_all(&category).unwrap();
1✔
355
        std::fs::write(
1✔
356
            category.join("What_is_alpha.txt"),
1✔
357
            "Alpha measures risk-adjusted outperformance.",
358
        )
359
        .unwrap();
1✔
360

361
        let source = FileSource::new(FileSourceConfig::new("qa_custom", temp.path()));
1✔
362
        let snapshot = source.refresh(&sampler_config(101), None, None).unwrap();
1✔
363

364
        assert_eq!(snapshot.records.len(), 1);
1✔
365
        assert_eq!(snapshot.records[0].source, "qa_custom");
1✔
366
    }
1✔
367

368
    #[test]
369
    fn applies_category_trust_overrides() {
1✔
370
        let temp = tempdir().unwrap();
1✔
371
        let factual = temp.path().join("factual");
1✔
372
        let opinion = temp.path().join("opinionated");
1✔
373
        std::fs::create_dir_all(&factual).unwrap();
1✔
374
        std::fs::create_dir_all(&opinion).unwrap();
1✔
375
        std::fs::write(
1✔
376
            factual.join("What_is_beta.txt"),
1✔
377
            "Beta compares volatility.",
378
        )
379
        .unwrap();
1✔
380
        std::fs::write(
1✔
381
            opinion.join("Will_rates_fall.txt"),
1✔
382
            "Probably not this year.",
383
        )
384
        .unwrap();
1✔
385

386
        let source = FileSource::new(
1✔
387
            FileSourceConfig::new("qa_weighted", temp.path())
1✔
388
                .with_category_trust("factual", 0.95)
1✔
389
                .with_category_trust("opinionated", 0.6),
1✔
390
        );
391
        let snapshot = source.refresh(&sampler_config(101), None, None).unwrap();
1✔
392

393
        let factual_record = snapshot
1✔
394
            .records
1✔
395
            .iter()
1✔
396
            .find(|record| record.taxonomy.iter().any(|value| value == "factual"))
2✔
397
            .unwrap();
1✔
398
        let opinion_record = snapshot
1✔
399
            .records
1✔
400
            .iter()
1✔
401
            .find(|record| record.taxonomy.iter().any(|value| value == "opinionated"))
4✔
402
            .unwrap();
1✔
403
        assert_eq!(factual_record.quality.trust, 0.95);
1✔
404
        assert_eq!(opinion_record.quality.trust, 0.6);
1✔
405
    }
1✔
406

407
    #[test]
408
    fn supports_custom_sections_and_default_recipes() {
1✔
409
        let temp = tempdir().unwrap();
1✔
410
        std::fs::write(
1✔
411
            temp.path().join("What_is_gamma.txt"),
1✔
412
            "Gamma measures convexity.",
413
        )
414
        .unwrap();
1✔
415

416
        let sections: SectionBuilder = Arc::new(|question, answer| {
1✔
417
            vec![
1✔
418
                make_section(SectionRole::Anchor, Some("Question"), question),
1✔
419
                make_section(SectionRole::Context, Some("Answer"), answer),
1✔
420
            ]
421
        });
1✔
422

423
        let recipes = vec![TripletRecipe {
1✔
424
            name: "question_answer".into(),
1✔
425
            anchor: Selector::Role(SectionRole::Anchor),
1✔
426
            positive_selector: Selector::Role(SectionRole::Context),
1✔
427
            negative_selector: Selector::Role(SectionRole::Context),
1✔
428
            negative_strategy: NegativeStrategy::QuestionAnswerMismatch,
1✔
429
            weight: 1.0,
1✔
430
            instruction: None,
1✔
431
            allow_same_anchor_positive: false,
1✔
432
        }];
1✔
433

434
        let source = FileSource::new(
1✔
435
            FileSourceConfig::new("qa_sections", temp.path())
1✔
436
                .with_section_builder(sections)
1✔
437
                .with_default_triplet_recipes(recipes.clone()),
1✔
438
        );
439

440
        let snapshot = source.refresh(&sampler_config(101), None, None).unwrap();
1✔
441
        assert_eq!(snapshot.records.len(), 1);
1✔
442
        assert_eq!(snapshot.records[0].sections.len(), 2);
1✔
443
        assert_eq!(source.default_triplet_recipes().len(), recipes.len());
1✔
444
    }
1✔
445

446
    #[test]
447
    fn file_source_config_new_has_explicit_default_triplet_recipes() {
1✔
448
        let temp = tempdir().unwrap();
1✔
449
        let source = FileSource::new(FileSourceConfig::new("qa_defaults", temp.path()));
1✔
450
        let defaults = source.default_triplet_recipes();
1✔
451
        assert!(!defaults.is_empty());
1✔
452
        let names: Vec<&str> = defaults.iter().map(|recipe| recipe.name.as_ref()).collect();
2✔
453
        assert!(!names.contains(&FILE_RECIPE_TITLE_CONTEXT_WRONG_DATE));
1✔
454
        assert!(!names.contains(&FILE_RECIPE_TITLE_ANCHOR_WRONG_DATE));
1✔
455
        assert!(names.contains(&FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE));
1✔
456
        assert!(names.contains(&FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE));
1✔
457
        let summary_wrong_article = defaults
1✔
458
            .iter()
1✔
459
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE)
1✔
460
            .unwrap();
1✔
461
        let anchor_wrong_article = defaults
1✔
462
            .iter()
1✔
463
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE)
2✔
464
            .unwrap();
1✔
465
        assert_eq!(summary_wrong_article.weight, 0.75);
1✔
466
        assert_eq!(anchor_wrong_article.weight, 0.25);
1✔
467
    }
1✔
468

469
    #[test]
470
    fn file_source_config_can_enable_date_aware_default_recipe() {
1✔
471
        let temp = tempdir().unwrap();
1✔
472
        let source = FileSource::new(
1✔
473
            FileSourceConfig::new("qa_defaults_with_date", temp.path())
1✔
474
                .with_date_aware_default_recipe(true),
1✔
475
        );
476
        let defaults = source.default_triplet_recipes();
1✔
477
        let names: Vec<&str> = defaults.iter().map(|recipe| recipe.name.as_ref()).collect();
4✔
478
        assert!(names.contains(&FILE_RECIPE_TITLE_CONTEXT_WRONG_DATE));
1✔
479
        assert!(names.contains(&FILE_RECIPE_TITLE_ANCHOR_WRONG_DATE));
1✔
480
        assert!(names.contains(&FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE));
1✔
481
        assert!(names.contains(&FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE));
1✔
482
        let summary_wrong_date = defaults
1✔
483
            .iter()
1✔
484
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_CONTEXT_WRONG_DATE)
1✔
485
            .unwrap();
1✔
486
        let anchor_wrong_date = defaults
1✔
487
            .iter()
1✔
488
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_ANCHOR_WRONG_DATE)
2✔
489
            .unwrap();
1✔
490
        let summary_wrong_article = defaults
1✔
491
            .iter()
1✔
492
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_CONTEXT_WRONG_ARTICLE)
3✔
493
            .unwrap();
1✔
494
        let anchor_wrong_article = defaults
1✔
495
            .iter()
1✔
496
            .find(|recipe| recipe.name == FILE_RECIPE_TITLE_ANCHOR_WRONG_ARTICLE)
4✔
497
            .unwrap();
1✔
498
        assert_eq!(summary_wrong_date.weight, 0.30);
1✔
499
        assert_eq!(anchor_wrong_date.weight, 0.10);
1✔
500
        assert_eq!(summary_wrong_article.weight, 0.35);
1✔
501
        assert_eq!(anchor_wrong_article.weight, 0.25);
1✔
502
    }
1✔
503

504
    #[test]
505
    fn file_source_config_override_replaces_default_triplet_recipes() {
1✔
506
        let temp = tempdir().unwrap();
1✔
507
        let custom = vec![TripletRecipe {
1✔
508
            name: "custom_only".into(),
1✔
509
            anchor: Selector::Role(SectionRole::Context),
1✔
510
            positive_selector: Selector::Role(SectionRole::Context),
1✔
511
            negative_selector: Selector::Role(SectionRole::Context),
1✔
512
            negative_strategy: NegativeStrategy::WrongArticle,
1✔
513
            weight: 1.0,
1✔
514
            instruction: None,
1✔
515
            allow_same_anchor_positive: false,
1✔
516
        }];
1✔
517
        let source = FileSource::new(
1✔
518
            FileSourceConfig::new("qa_defaults_override", temp.path())
1✔
519
                .with_default_triplet_recipes(custom.clone()),
1✔
520
        );
521
        let recipes = source.default_triplet_recipes();
1✔
522
        assert_eq!(recipes.len(), 1);
1✔
523
        assert_eq!(recipes[0].name.as_ref(), "custom_only");
1✔
524
    }
1✔
525

526
    #[test]
527
    fn taxonomy_from_path_handles_nested_and_non_descendant_paths() {
1✔
528
        let temp = tempdir().unwrap();
1✔
529
        let root = temp.path().join("root");
1✔
530
        std::fs::create_dir_all(root.join("topic/subtopic")).unwrap();
1✔
531

532
        let nested = root.join("topic/subtopic/doc.txt");
1✔
533
        let taxonomy = taxonomy_from_path(&root, &nested, &"qa_tax".to_string());
1✔
534
        assert_eq!(taxonomy, vec!["qa_tax", "topic", "subtopic"]);
1✔
535

536
        let outside = temp.path().join("outside.txt");
1✔
537
        let outside_taxonomy = taxonomy_from_path(&root, &outside, &"qa_tax".to_string());
1✔
538
        assert_eq!(outside_taxonomy, vec!["qa_tax"]);
1✔
539
    }
1✔
540

541
    #[test]
542
    fn anchor_context_sections_build_expected_roles_and_text() {
1✔
543
        let sections = anchor_context_sections("What is delta", "Delta is change over time.");
1✔
544
        assert_eq!(sections.len(), 2);
1✔
545
        assert_eq!(sections[0].role, SectionRole::Anchor);
1✔
546
        assert_eq!(sections[0].text, "What is delta");
1✔
547
        assert_eq!(sections[1].role, SectionRole::Context);
1✔
548
        assert_eq!(sections[1].text, "Delta is change over time.");
1✔
549
    }
1✔
550

551
    #[test]
552
    fn title_replace_underscores_toggle_changes_anchor_title_text() {
1✔
553
        let temp = tempdir().unwrap();
1✔
554
        std::fs::write(
1✔
555
            temp.path().join("What_is_delta.txt"),
1✔
556
            "Delta captures directional change.",
557
        )
558
        .unwrap();
1✔
559

560
        let source_default =
1✔
561
            FileSource::new(FileSourceConfig::new("qa_title_default", temp.path()));
1✔
562
        let default_snapshot = source_default
1✔
563
            .refresh(&sampler_config(101), None, Some(1))
1✔
564
            .unwrap();
1✔
565
        assert_eq!(default_snapshot.records.len(), 1);
1✔
566
        assert_eq!(
1✔
567
            default_snapshot.records[0].sections[0].text,
1✔
568
            "What is delta"
569
        );
570

571
        let source_preserve = FileSource::new(
1✔
572
            FileSourceConfig::new("qa_title_preserve", temp.path())
1✔
573
                .with_title_replace_underscores(false),
1✔
574
        );
575
        let preserve_snapshot = source_preserve
1✔
576
            .refresh(&sampler_config(101), None, Some(1))
1✔
577
            .unwrap();
1✔
578
        assert_eq!(preserve_snapshot.records.len(), 1);
1✔
579
        assert_eq!(
1✔
580
            preserve_snapshot.records[0].sections[0].text,
1✔
581
            "What_is_delta"
582
        );
583
    }
1✔
584

585
    #[test]
586
    fn refresh_skips_non_txt_files_even_when_text_only_disabled() {
1✔
587
        let temp = tempdir().unwrap();
1✔
588
        std::fs::write(temp.path().join("notes.md"), "markdown should be skipped").unwrap();
1✔
589
        std::fs::write(temp.path().join("doc.txt"), "plain text should be indexed").unwrap();
1✔
590

591
        let source = FileSource::new(
1✔
592
            FileSourceConfig::new("qa_filtering", temp.path()).with_text_files_only(false),
1✔
593
        );
594
        let snapshot = source.refresh(&sampler_config(101), None, None).unwrap();
1✔
595
        assert_eq!(snapshot.records.len(), 1);
1✔
596
        assert!(snapshot.records[0].id.contains("doc.txt"));
1✔
597
    }
1✔
598

599
    #[test]
600
    fn trust_falls_back_to_default_and_count_and_id_are_exposed() {
1✔
601
        let temp = tempdir().unwrap();
1✔
602
        let docs = temp.path().join("docs");
1✔
603
        std::fs::create_dir_all(&docs).unwrap();
1✔
604
        std::fs::write(docs.join("alpha.txt"), "Alpha body.").unwrap();
1✔
605

606
        let source = FileSource::new(
1✔
607
            FileSourceConfig::new("qa_count", temp.path())
1✔
608
                .with_trust(0.42)
1✔
609
                .with_category_trust("factual", 0.95)
1✔
610
                .with_taxonomy_builder(Arc::new(|_, _, source_id| {
1✔
611
                    vec![source_id.clone(), "UNMATCHED".to_string()]
1✔
612
                })),
1✔
613
        );
614

615
        let seed_101 = sampler_config(101);
1✔
616
        let snapshot = source.refresh(&seed_101, None, None).unwrap();
1✔
617
        assert_eq!(snapshot.records.len(), 1);
1✔
618
        assert_eq!(snapshot.records[0].quality.trust, 0.42);
1✔
619
        assert_eq!(source.id(), "qa_count");
1✔
620
        assert_eq!(source.reported_record_count(&seed_101).unwrap(), 1);
1✔
621
    }
1✔
622

623
    #[test]
624
    fn sampler_seed_controls_file_source_refresh_order() {
1✔
625
        let temp = tempdir().unwrap();
1✔
626
        for idx in 0..12 {
12✔
627
            std::fs::write(
12✔
628
                temp.path().join(format!("doc_{idx:02}.txt")),
12✔
629
                format!("Body text for {idx}"),
12✔
630
            )
12✔
631
            .unwrap();
12✔
632
        }
12✔
633

634
        let source_a = FileSource::new(FileSourceConfig::new("seeded_a", temp.path()));
1✔
635
        let source_b = FileSource::new(FileSourceConfig::new("seeded_a", temp.path()));
1✔
636
        let source_c = FileSource::new(FileSourceConfig::new("seeded_a", temp.path()));
1✔
637

638
        let ids_a: Vec<String> = source_a
1✔
639
            .refresh(&sampler_config(11), None, Some(8))
1✔
640
            .unwrap()
1✔
641
            .records
1✔
642
            .into_iter()
1✔
643
            .map(|record| record.id)
1✔
644
            .collect();
1✔
645
        let ids_b: Vec<String> = source_b
1✔
646
            .refresh(&sampler_config(11), None, Some(8))
1✔
647
            .unwrap()
1✔
648
            .records
1✔
649
            .into_iter()
1✔
650
            .map(|record| record.id)
1✔
651
            .collect();
1✔
652
        let ids_c: Vec<String> = source_c
1✔
653
            .refresh(&sampler_config(29), None, Some(8))
1✔
654
            .unwrap()
1✔
655
            .records
1✔
656
            .into_iter()
1✔
657
            .map(|record| record.id)
1✔
658
            .collect();
1✔
659

660
        assert_eq!(ids_a, ids_b);
1✔
661
        assert_ne!(ids_a, ids_c);
1✔
662
    }
1✔
663

664
    #[test]
665
    fn with_index_dir_persists_refresh_index_store_under_custom_directory() {
1✔
666
        let temp = tempdir().unwrap();
1✔
667
        let index_temp = tempdir().unwrap();
1✔
668
        std::fs::write(temp.path().join("alpha.txt"), "Alpha body").unwrap();
1✔
669

670
        let custom_index_dir = index_temp.path().join("custom_index_store");
1✔
671
        std::fs::create_dir_all(&custom_index_dir).unwrap();
1✔
672

673
        let source_id = "qa_custom_index_refresh".to_string();
1✔
674
        let source = FileSource::new(
1✔
675
            FileSourceConfig::new(source_id.clone(), temp.path())
1✔
676
                .with_index_dir(custom_index_dir.clone()),
1✔
677
        );
678

679
        let snapshot = source.refresh(&sampler_config(101), None, None).unwrap();
1✔
680
        assert_eq!(snapshot.records.len(), 1);
1✔
681

682
        let expected_store_path = FileCorpusIndex::index_store_path_for(
1✔
683
            Some(custom_index_dir.as_path()),
1✔
684
            temp.path(),
1✔
685
            &source_id,
1✔
686
        );
687
        assert!(
1✔
688
            expected_store_path.is_file(),
1✔
689
            "expected index store to exist at {}",
NEW
690
            expected_store_path.display()
×
691
        );
692
    }
1✔
693

694
    #[test]
695
    fn with_index_dir_persists_count_index_store_under_custom_directory() {
1✔
696
        let temp = tempdir().unwrap();
1✔
697
        let index_temp = tempdir().unwrap();
1✔
698
        std::fs::write(temp.path().join("alpha.txt"), "Alpha body").unwrap();
1✔
699

700
        let custom_index_dir = index_temp.path().join("custom_index_store");
1✔
701
        std::fs::create_dir_all(&custom_index_dir).unwrap();
1✔
702

703
        let source_id = "qa_custom_index_count".to_string();
1✔
704
        let source = FileSource::new(
1✔
705
            FileSourceConfig::new(source_id.clone(), temp.path())
1✔
706
                .with_index_dir(custom_index_dir.clone()),
1✔
707
        );
708

709
        assert_eq!(
1✔
710
            source.reported_record_count(&sampler_config(101)).unwrap(),
1✔
711
            1
712
        );
713

714
        let expected_store_path = FileCorpusIndex::index_store_path_for(
1✔
715
            Some(custom_index_dir.as_path()),
1✔
716
            temp.path(),
1✔
717
            &source_id,
1✔
718
        );
719
        assert!(
1✔
720
            expected_store_path.is_file(),
1✔
721
            "expected index store to exist at {}",
NEW
722
            expected_store_path.display()
×
723
        );
724
    }
1✔
725
}
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