1use chrono::{DateTime, Utc};
10use std::hash::Hash;
11use std::time::Instant;
12use tracing::info;
13
14use crate::config::{SamplerConfig, TripletRecipe};
15use crate::data::DataRecord;
16use crate::errors::SamplerError;
17use crate::hash::stable_hash_with;
18use crate::types::SourceId;
19
20pub mod backends;
22pub mod indexing;
24pub use backends::csv_source::{CsvSource, CsvSourceConfig};
25pub use backends::file_source::{
26 FileSource, FileSourceConfig, SectionBuilder, TaxonomyBuilder, anchor_context_sections,
27 taxonomy_from_path,
28};
29
30pub use backends::in_memory_source::InMemorySource;
31
32#[derive(Clone, Debug)]
37pub struct SourceCursor {
38 pub last_seen: DateTime<Utc>,
40 pub revision: u64,
42}
43
44#[derive(Clone, Debug)]
48pub struct SourceSnapshot {
49 pub records: Vec<DataRecord>,
51 pub cursor: SourceCursor,
53}
54
55pub trait DataSource: Send + Sync {
60 fn id(&self) -> &str;
62 fn refresh(
66 &self,
67 config: &SamplerConfig,
68 cursor: Option<&SourceCursor>,
69 limit: Option<usize>,
70 ) -> Result<SourceSnapshot, SamplerError>;
71
72 fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError>;
82
83 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
87 Vec::new()
88 }
89}
90
91pub trait IndexableSource: Send + Sync {
100 fn id(&self) -> &str;
102 fn len_hint(&self) -> Option<usize>;
104 fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError>;
106}
107
108pub struct IndexablePager {
113 source_id: SourceId,
114}
115
116impl IndexablePager {
117 pub fn new(source_id: impl Into<SourceId>) -> Self {
119 Self {
120 source_id: source_id.into(),
121 }
122 }
123
124 pub fn refresh(
126 &self,
127 source: &dyn IndexableSource,
128 cursor: Option<&SourceCursor>,
129 limit: Option<usize>,
130 ) -> Result<SourceSnapshot, SamplerError> {
131 let total = source
132 .len_hint()
133 .ok_or_else(|| SamplerError::SourceInconsistent {
134 source_id: source.id().to_string(),
135 details: "indexable source did not provide len_hint".into(),
136 })?;
137 self.refresh_with(total, cursor, limit, |idx| source.record_at(idx))
138 }
139
140 pub fn refresh_with<F>(
149 &self,
150 total: usize,
151 cursor: Option<&SourceCursor>,
152 limit: Option<usize>,
153 fetch: F,
154 ) -> Result<SourceSnapshot, SamplerError>
155 where
156 F: Fn(usize) -> Result<Option<DataRecord>, SamplerError> + Send + Sync,
157 {
158 if total == 0 {
159 return Ok(SourceSnapshot {
160 records: Vec::new(),
161 cursor: SourceCursor {
162 last_seen: Utc::now(),
163 revision: 0,
164 },
165 });
166 }
167 let mut start = cursor.map(|cursor| cursor.revision as usize).unwrap_or(0);
168 if start >= total {
169 start = 0;
170 }
171 let max = limit.unwrap_or(total);
172 let seed = Self::seed_for(&self.source_id, total);
173
174 let mut permutation = IndexPermutation::new(total, seed, start as u64);
177 let seq: Vec<(usize, usize)> = (0..total)
178 .map(|_| {
179 let idx = permutation.next();
180 (idx, permutation.cursor())
181 })
182 .collect();
183
184 let should_report = total >= 10_000 || max >= 1_024;
185 let refresh_start = Instant::now();
186 if should_report {
187 info!(
188 source = %self.source_id,
189 source_records = total,
190 ingestion_limit = max,
191 "Source refresh started"
192 );
193 }
194
195 use rayon::prelude::*;
196 let par_end = max.min(total);
207 let results: Vec<Result<Option<DataRecord>, SamplerError>> = seq[..par_end]
208 .par_iter()
209 .map(|&(idx, _)| fetch(idx))
210 .collect();
211 let mut records = Vec::with_capacity(max.min(total));
212 let mut final_cursor = start;
213 for (result, &(_, cursor_after)) in results.into_iter().zip(seq[..par_end].iter()) {
214 if records.len() >= max {
215 break;
216 }
217 if let Some(r) = result? {
218 records.push(r)
219 }
220 final_cursor = cursor_after;
221 }
222 for &(idx, cursor_after) in &seq[par_end..] {
224 if records.len() >= max {
225 break;
226 }
227 if let Some(r) = fetch(idx)? {
228 records.push(r);
229 }
230 final_cursor = cursor_after;
231 }
232
233 if should_report {
234 info!(
235 source = %self.source_id,
236 source_records = total,
237 ingested = records.len(),
238 elapsed_secs = refresh_start.elapsed().as_secs_f64(),
239 "Source refresh completed"
240 );
241 }
242 let last_seen = records
243 .iter()
244 .map(|record| record.updated_at)
245 .max()
246 .unwrap_or_else(Utc::now);
247 Ok(SourceSnapshot {
248 records,
249 cursor: SourceCursor {
250 last_seen,
251 revision: final_cursor as u64,
252 },
253 })
254 }
255
256 pub(crate) fn seed_for(source_id: &SourceId, total: usize) -> u64 {
258 Self::stable_index_shuffle_key(source_id, 0)
259 ^ Self::stable_index_shuffle_key(source_id, total)
260 }
261
262 pub fn seed_for_sampler(source_id: &SourceId, total: usize, sampler_seed: u64) -> u64 {
264 Self::seed_for(source_id, total)
265 ^ stable_hash_with(|hasher| {
266 "triplets_sampler_seed".hash(hasher);
267 source_id.hash(hasher);
268 total.hash(hasher);
269 sampler_seed.hash(hasher);
270 })
271 }
272
273 fn stable_index_shuffle_key(source_id: &SourceId, idx: usize) -> u64 {
274 stable_hash_with(|hasher| {
275 source_id.hash(hasher);
276 idx.hash(hasher);
277 })
278 }
279}
280
281pub struct IndexableAdapter<T: IndexableSource> {
283 inner: T,
284}
285
286impl<T: IndexableSource> IndexableAdapter<T> {
287 pub fn new(inner: T) -> Self {
289 Self { inner }
290 }
291}
292
293impl<T: IndexableSource> DataSource for IndexableAdapter<T> {
294 fn id(&self) -> &str {
295 self.inner.id()
296 }
297
298 fn refresh(
299 &self,
300 _config: &SamplerConfig,
301 cursor: Option<&SourceCursor>,
302 limit: Option<usize>,
303 ) -> Result<SourceSnapshot, SamplerError> {
304 let pager = IndexablePager::new(self.inner.id());
305 pager.refresh(&self.inner, cursor, limit)
306 }
307
308 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
309 self.inner
310 .len_hint()
311 .map(|value| value as u128)
312 .ok_or_else(|| SamplerError::SourceInconsistent {
313 source_id: self.inner.id().to_string(),
314 details: "indexable source did not provide len_hint".into(),
315 })
316 }
317}
318
319pub struct IndexPermutation {
321 total: u64,
322 domain_bits: u32,
323 domain_size: u64,
324 seed: u64,
325 counter: u64,
326}
327
328impl IndexPermutation {
329 pub fn new(total: usize, seed: u64, counter: u64) -> Self {
331 let total_u64 = total as u64;
332 let domain_bits = (64 - (total_u64 - 1).leading_zeros()).max(1);
333 let domain_size = 1u64 << domain_bits;
334 Self {
335 total: total_u64,
336 domain_bits,
337 domain_size,
338 seed,
339 counter,
340 }
341 }
342
343 #[allow(clippy::should_implement_trait)]
348 pub fn next(&mut self) -> usize {
349 loop {
350 let v =
351 Self::permute_bits(self.counter % self.domain_size, self.domain_bits, self.seed);
352 self.counter = self.counter.wrapping_add(1);
353 if v < self.total {
354 return v as usize;
355 }
356 }
357 }
358
359 pub fn cursor(&self) -> usize {
361 (self.counter as usize) % (self.total as usize)
362 }
363 fn permute_bits(value: u64, bits: u32, seed: u64) -> u64 {
364 if bits == 0 {
365 return 0;
366 }
367 let mask = if bits == 64 {
368 u64::MAX
369 } else {
370 (1u64 << bits) - 1
371 };
372 let mut a = (seed | 1) & mask;
373 if a == 0 {
374 a = 1;
375 }
376 let b = (seed >> 1) & mask;
377 a.wrapping_mul(value).wrapping_add(b) & mask
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::data::{QualityScore, RecordSection, SectionRole};
385 use crate::types::RecordId;
386 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
387 use std::thread;
388 use std::time::Duration as StdDuration;
389
390 struct IndexableStub {
392 id: SourceId,
393 count: usize,
394 }
395
396 struct NoLenHintStub {
397 id: SourceId,
398 }
399
400 impl IndexableStub {
401 fn new(id: &str, count: usize) -> Self {
402 Self {
403 id: id.to_string(),
404 count,
405 }
406 }
407 }
408
409 impl NoLenHintStub {
410 fn new(id: &str) -> Self {
411 Self { id: id.to_string() }
412 }
413 }
414
415 impl IndexableSource for IndexableStub {
416 fn id(&self) -> &str {
417 &self.id
418 }
419
420 fn len_hint(&self) -> Option<usize> {
421 Some(self.count)
422 }
423
424 fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
425 if idx >= self.count {
426 return Ok(None);
427 }
428 let now = Utc::now();
429 Ok(Some(DataRecord {
430 id: format!("record_{idx}"),
431 source: self.id.clone(),
432 created_at: now,
433 updated_at: now,
434 quality: QualityScore { trust: 1.0 },
435 taxonomy: Vec::new(),
436 sections: vec![RecordSection {
437 role: SectionRole::Anchor,
438 heading: None,
439 text: "stub".into(),
440 sentences: vec!["stub".into()],
441 token_count: 0,
442 embedding: None,
443 }],
444 meta_prefix: None,
445 label: None,
446 }))
447 }
448 }
449
450 impl IndexableSource for NoLenHintStub {
451 fn id(&self) -> &str {
452 &self.id
453 }
454
455 fn len_hint(&self) -> Option<usize> {
456 None
457 }
458
459 fn record_at(&self, _idx: usize) -> Result<Option<DataRecord>, SamplerError> {
460 Ok(None)
461 }
462 }
463
464 #[test]
465 fn indexable_adapter_pages_in_stable_order() {
466 let adapter = IndexableAdapter::new(IndexableStub::new("stub", 6));
467 let config = SamplerConfig::default();
468 let full = adapter.refresh(&config, None, None).unwrap();
469 let full_ids: Vec<RecordId> = full.records.into_iter().map(|r| r.id).collect();
470
471 let mut cursor = None;
472 let mut paged = Vec::new();
473 for _ in 0..3 {
474 let snapshot = adapter.refresh(&config, cursor.as_ref(), Some(3)).unwrap();
475 cursor = Some(snapshot.cursor);
476 paged.extend(snapshot.records.into_iter().map(|r| r.id));
477 if paged.len() >= full_ids.len() {
478 break;
479 }
480 }
481 assert_eq!(paged, full_ids);
482 }
483
484 #[test]
485 fn indexable_paging_spans_multiple_regimes() {
486 let total = 256usize;
489 let mask = (1u64 << (64 - (total as u64 - 1).leading_zeros())) - 1;
490 let source_id = (0..512)
491 .map(|idx| format!("regime_test_{idx}"))
492 .find(|id| {
493 let seed = IndexablePager::seed_for(id, total);
494 let a = (seed | 1) & mask;
495 a != 1 && a != mask
496 })
497 .unwrap();
498
499 let adapter = IndexableAdapter::new(IndexableStub::new(&source_id, total));
502 let snapshot = adapter
503 .refresh(&SamplerConfig::default(), None, Some(64))
504 .unwrap();
505 let indices: Vec<usize> = snapshot
506 .records
507 .into_iter()
508 .map(|r| {
509 r.id.strip_prefix("record_")
510 .unwrap()
511 .parse::<usize>()
512 .unwrap()
513 })
514 .collect();
515 let min_idx = *indices.iter().min().unwrap();
516 let max_idx = *indices.iter().max().unwrap();
517 assert!(
518 max_idx - min_idx >= total / 2,
519 "expected spread across the index space, got min={min_idx} max={max_idx}"
520 );
521 }
522
523 #[test]
524 fn indexable_pager_errors_when_len_hint_missing() {
525 let pager = IndexablePager::new("no_len_hint");
526 let source = NoLenHintStub::new("no_len_hint");
527 let result = pager.refresh(&source, None, Some(3));
528 assert!(result.is_err());
529 }
530
531 #[test]
532 fn indexable_adapter_reported_count_errors_when_len_hint_missing() {
533 let adapter = IndexableAdapter::new(NoLenHintStub::new("no_len_hint"));
534 let result = adapter.reported_record_count(&SamplerConfig::default());
535 assert!(result.is_err());
536 }
537
538 #[test]
539 fn indexable_pager_refresh_with_zero_total_returns_empty_snapshot() {
540 let pager = IndexablePager::new("empty");
541 let snapshot = pager
542 .refresh_with(0, None, Some(4), |_idx| Ok(None))
543 .unwrap();
544 assert!(snapshot.records.is_empty());
545 assert_eq!(snapshot.cursor.revision, 0);
546 }
547
548 #[test]
549 fn index_permutation_permute_bits_handles_zero_bits_and_zero_seed_path() {
550 assert_eq!(IndexPermutation::permute_bits(123, 0, 99), 0);
551
552 let bits = 1;
553 let value = 1;
554 let out = IndexPermutation::permute_bits(value, bits, 0);
555 assert!(out <= 1);
556 }
557
558 #[test]
559 fn index_permutation_next_stays_within_total_and_cursor_advances() {
560 let mut perm = IndexPermutation::new(3, 7, 0);
561 let mut seen = Vec::new();
562 for _ in 0..8 {
563 seen.push(perm.next());
564 }
565 assert!(seen.iter().all(|idx| *idx < 3));
566 assert!(perm.cursor() < 3);
567 }
568
569 #[test]
570 fn indexable_pager_large_refresh_triggers_reporting_branch_and_wraps_cursor() {
571 let pager = IndexablePager::new("reporting");
572 let cursor = SourceCursor {
573 last_seen: Utc::now(),
574 revision: 20_000,
575 };
576 let snapshot = pager
577 .refresh_with(10_000, Some(&cursor), Some(4), |idx| {
578 Ok(Some(DataRecord {
579 id: format!("record_{idx}"),
580 source: "reporting".to_string(),
581 created_at: Utc::now(),
582 updated_at: Utc::now(),
583 quality: QualityScore { trust: 1.0 },
584 taxonomy: Vec::new(),
585 sections: vec![RecordSection {
586 role: SectionRole::Anchor,
587 heading: None,
588 text: "t".to_string(),
589 sentences: vec!["t".to_string()],
590 token_count: 0,
591 embedding: None,
592 }],
593 meta_prefix: None,
594 label: None,
595 }))
596 })
597 .unwrap();
598
599 assert_eq!(snapshot.records.len(), 4);
600 assert!(snapshot.cursor.revision < 10_000);
601 }
602
603 #[test]
604 fn indexable_pager_reporting_branch_emits_progress_when_refresh_is_slow() {
605 let pager = IndexablePager::new("slow_reporting");
606 let slept = AtomicBool::new(false);
607 let snapshot = pager
608 .refresh_with(2_000, None, Some(1_024), |_idx| {
609 if !slept.swap(true, Ordering::Relaxed) {
610 thread::sleep(StdDuration::from_millis(800));
611 }
612 Ok(None)
613 })
614 .unwrap();
615
616 assert!(snapshot.records.is_empty());
617 assert!(snapshot.cursor.revision < 2_000);
618 }
619
620 #[test]
621 fn source_ids_and_reported_counts_are_exposed() {
622 let adapter = IndexableAdapter::new(IndexableStub::new("stub_id", 3));
623 assert_eq!(adapter.id(), "stub_id");
624 assert_eq!(
625 adapter
626 .reported_record_count(&SamplerConfig::default())
627 .unwrap(),
628 3
629 );
630 }
631
632 #[test]
633 fn indexable_pager_sequential_fallback_fills_quota_when_parallel_pass_yields_none() {
634 let pager = IndexablePager::new("fallback_fill");
639 let call_count = AtomicUsize::new(0);
640 let par_end = 4usize;
641 let snapshot = pager
642 .refresh_with(8, None, Some(par_end), |idx| {
643 let n = call_count.fetch_add(1, Ordering::Relaxed);
644 if n < par_end {
645 Ok(None)
646 } else {
647 Ok(Some(DataRecord {
648 id: format!("r_{idx}"),
649 source: "fallback_fill".to_string(),
650 created_at: Utc::now(),
651 updated_at: Utc::now(),
652 quality: QualityScore { trust: 1.0 },
653 taxonomy: Vec::new(),
654 sections: vec![RecordSection {
655 role: SectionRole::Anchor,
656 heading: None,
657 text: "t".to_string(),
658 sentences: vec!["t".to_string()],
659 token_count: 0,
660 embedding: None,
661 }],
662 meta_prefix: None,
663 label: None,
664 }))
665 }
666 })
667 .unwrap();
668 assert_eq!(snapshot.records.len(), par_end);
669 }
670
671 #[test]
672 fn indexable_pager_refresh_with_propagates_fetch_error() {
673 let pager = IndexablePager::new("err");
674 let err = pager
675 .refresh_with(8, None, Some(2), |_idx| {
676 Err(SamplerError::SourceUnavailable {
677 source_id: "err".to_string(),
678 reason: "fetch failed".to_string(),
679 })
680 })
681 .unwrap_err();
682 assert!(matches!(
683 err,
684 SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("fetch failed")
685 ));
686 }
687
688 #[test]
689 fn seed_for_sampler_depends_on_sampler_seed() {
690 let source_id = "seeded".to_string();
691 let base = IndexablePager::seed_for(&source_id, 17);
692 let with_a = IndexablePager::seed_for_sampler(&source_id, 17, 1);
693 let with_b = IndexablePager::seed_for_sampler(&source_id, 17, 2);
694 assert_ne!(with_a, with_b);
695 assert_ne!(with_a, base);
696 }
697
698 #[test]
699 fn stable_hash_is_deterministic_across_calls() {
700 let a = crate::hash::stable_hash_str(42, "hello");
701 let b = crate::hash::stable_hash_str(42, "hello");
702 assert_eq!(a, b, "same inputs must produce same hash");
703 }
704
705 #[test]
706 fn stable_hash_varies_with_seed() {
707 let a = crate::hash::stable_hash_str(1, "hello");
708 let b = crate::hash::stable_hash_str(2, "hello");
709 assert_ne!(a, b, "different seeds should produce different hashes");
710 }
711
712 #[test]
713 fn stable_hash_varies_with_value() {
714 let a = crate::hash::stable_hash_str(42, "hello");
715 let b = crate::hash::stable_hash_str(42, "world");
716 assert_ne!(a, b, "different values should produce different hashes");
717 }
718
719 #[test]
720 fn stable_hash_str_handles_empty_string() {
721 let result = crate::hash::stable_hash_str(0, "");
722 assert_ne!(result, 0, "empty string should produce non-zero hash");
723 }
724}