1use crate::config::SamplerConfig;
2use crate::constants::splits::is_reserved_source_id;
3use crate::data::DataRecord;
4use crate::errors::SamplerError;
5use crate::hash::derive_epoch_seed;
6use crate::source::{DataSource, SourceCursor, SourceSnapshot};
7use crate::types::{RecordId, SourceId};
8use chrono::Utc;
9use indexmap::IndexMap;
10use std::collections::HashMap;
11use std::collections::VecDeque;
12use std::sync::{Arc, Condvar, Mutex, RwLock};
13use std::thread;
14use std::time::Duration;
15use tracing::{debug, warn};
16
17#[derive(Clone)]
19pub struct RecordCache {
20 inner: Arc<RwLock<RecordCacheInner>>,
21 notifier: Arc<(Mutex<CacheStats>, Condvar)>,
22}
23
24struct RecordCacheInner {
26 records: IndexMap<RecordId, CachedRecord>,
27 order: VecDeque<RecordId>,
28 max_records: usize,
29 next_version: u64,
30}
31
32struct CachedRecord {
34 record: DataRecord,
35 version: u64,
36}
37
38#[derive(Default)]
40struct CacheStats {
41 ingests: u64,
42}
43
44impl RecordCache {
45 pub fn new(max_records: usize) -> Self {
47 Self {
48 inner: Arc::new(RwLock::new(RecordCacheInner {
49 records: IndexMap::new(),
50 order: VecDeque::new(),
51 max_records,
52 next_version: 0,
53 })),
54 notifier: Arc::new((Mutex::new(CacheStats::default()), Condvar::new())),
55 }
56 }
57
58 pub fn ingest<I>(&self, records: I)
60 where
61 I: IntoIterator<Item = DataRecord>,
62 {
63 let mut batch: Vec<DataRecord> = records.into_iter().collect();
64 if batch.is_empty() {
65 return;
66 }
67 let mut inner = self.inner.write().expect("record cache poisoned");
68 inner.ingest_batch(&mut batch);
69 drop(inner);
70 let (lock, cvar) = &*self.notifier;
71 let mut stats = lock.lock().expect("record cache stats poisoned");
72 stats.ingests = stats.ingests.saturating_add(1);
73 cvar.notify_all();
74 }
75
76 pub fn clear(&self) {
78 let mut inner = self.inner.write().expect("record cache poisoned");
79 inner.records.clear();
80 inner.order.clear();
81 }
82
83 pub fn snapshot(&self) -> Vec<DataRecord> {
85 let inner = self.inner.read().expect("record cache poisoned");
86 inner
87 .records
88 .values()
89 .map(|entry| entry.record.clone())
90 .collect()
91 }
92
93 pub fn ingest_count(&self) -> u64 {
95 let (lock, _) = &*self.notifier;
96 lock.lock().expect("record cache stats poisoned").ingests
97 }
98
99 pub fn wait_for_ingest(&self, last_seen: u64, timeout: Duration) -> u64 {
101 let (lock, cvar) = &*self.notifier;
102 let mut stats = lock.lock().expect("record cache stats poisoned");
103 while stats.ingests <= last_seen {
104 let result = cvar
105 .wait_timeout(stats, timeout)
106 .expect("record cache stats poisoned");
107 stats = result.0;
108 if result.1.timed_out() {
109 break;
110 }
111 }
112 stats.ingests
113 }
114
115 pub fn wait_for_ingest_blocking(&self, last_seen: u64) -> u64 {
117 let (lock, cvar) = &*self.notifier;
118 let mut stats = lock.lock().expect("record cache stats poisoned");
119 while stats.ingests <= last_seen {
120 stats = cvar.wait(stats).expect("record cache stats poisoned");
121 }
122 stats.ingests
123 }
124
125 pub fn is_empty(&self) -> bool {
127 let inner = self.inner.read().expect("record cache poisoned");
128 inner.records.is_empty()
129 }
130
131 pub fn len(&self) -> usize {
133 let inner = self.inner.read().expect("record cache poisoned");
134 inner.records.len()
135 }
136}
137
138impl RecordCacheInner {
139 fn ingest_batch(&mut self, records: &mut Vec<DataRecord>) {
140 for record in records.drain(..) {
141 self.next_version = self.next_version.saturating_add(1);
142 let record_id = record.id.clone();
143 if self.records.contains_key(&record_id) {
144 if let Some(entry) = self.records.get_mut(&record_id) {
145 entry.record = record;
146 entry.version = self.next_version;
147 }
148 Self::refresh_order(&mut self.order, &record_id);
149 self.order.push_back(record_id);
150 } else {
151 self.order.push_back(record_id.clone());
152 self.records.insert(
153 record_id,
154 CachedRecord {
155 record,
156 version: self.next_version,
157 },
158 );
159 }
160 self.enforce_limit();
161 }
162 }
163
164 fn enforce_limit(&mut self) {
165 if self.max_records == 0 {
166 self.records.clear();
167 self.order.clear();
168 return;
169 }
170 while self.records.len() > self.max_records {
171 if let Some(oldest) = self.order.pop_front() {
172 self.records.swap_remove(&oldest);
173 } else {
174 break;
175 }
176 }
177 }
178
179 fn refresh_order(order: &mut VecDeque<RecordId>, id: &RecordId) {
180 if order.is_empty() {
181 return;
182 }
183 if let Some(pos) = order.iter().position(|existing| existing == id) {
184 order.remove(pos);
185 }
186 }
187}
188
189pub struct IngestionManager {
191 sources: Vec<SourceState>,
192 max_records: usize,
193 sampler_config: SamplerConfig,
194 epoch: u64,
196 source_refresh_generation: u64,
198 last_refreshed_sources: Vec<SourceId>,
203 drain_start: usize,
208 epoch_step: u64,
211}
212
213#[derive(Clone, Debug, Default)]
214pub struct SourceRefreshStats {
216 pub last_refresh_ms: u128,
218 pub last_record_count: usize,
220 pub last_records_per_sec: f64,
222 pub last_error: Option<String>,
224 pub error_count: u64,
226}
227
228impl IngestionManager {
229 pub fn new(max_records: usize, sampler_config: SamplerConfig) -> Self {
231 Self {
232 sources: Vec::new(),
233 max_records,
234 sampler_config,
235 epoch: 0,
236 source_refresh_generation: 0,
237 last_refreshed_sources: Vec::new(),
238 drain_start: 0,
239 epoch_step: 0,
240 }
241 }
242
243 pub fn source_refresh_generation(&self) -> u64 {
245 self.source_refresh_generation
246 }
247
248 pub fn last_refreshed_sources(&self) -> &[SourceId] {
250 &self.last_refreshed_sources
251 }
252
253 pub(crate) fn set_epoch(&mut self, epoch: u64) {
260 self.epoch = epoch;
261 }
262
263 pub(crate) fn reset_epoch_step(&mut self) {
267 self.epoch_step = 0;
268 }
269
270 pub(crate) fn increment_epoch_step(&mut self) {
273 self.epoch_step = self.epoch_step.saturating_add(1);
274 }
275
276 pub fn epoch_step(&self) -> u64 {
278 self.epoch_step
279 }
280
281 pub(crate) fn set_epoch_step(&mut self, step: u64) {
283 self.epoch_step = step;
284 }
285
286 #[cfg(test)]
288 pub fn epoch(&self) -> u64 {
289 self.epoch
290 }
291
292 pub(crate) fn reset_stream_cursors(&mut self) {
298 for state in &mut self.sources {
299 state.cursor = None;
300 state.buffer.clear();
301 state.cache.clear();
302 }
303 }
304
305 pub fn register_source(
310 &mut self,
311 source: Box<dyn DataSource + 'static>,
312 ) -> Result<(), SamplerError> {
313 let source_id = source.id().to_string();
314 if is_reserved_source_id(&source_id) {
315 return Err(SamplerError::ReservedSourceId(source_id));
316 }
317 let cache = RecordCache::new(self.max_records);
318 self.sources.push(SourceState {
319 source,
320 cursor: None,
321 buffer: VecDeque::new(),
322 cache,
323 stats: SourceRefreshStats::default(),
324 });
325 Ok(())
326 }
327
328 pub fn load_cursors(&mut self, cursors: &[(SourceId, u64)]) {
330 if cursors.is_empty() {
331 return;
332 }
333 let mut map = std::collections::HashMap::with_capacity(cursors.len());
334 for (id, revision) in cursors {
335 map.insert(id.as_str(), *revision);
336 }
337 for state in &mut self.sources {
338 if let Some(revision) = map.get(state.source.id()) {
339 state.cursor = Some(SourceCursor {
340 last_seen: Utc::now(),
341 revision: *revision,
342 });
343 }
344 }
345 }
346
347 pub fn snapshot_cursors(&self) -> Vec<(SourceId, u64)> {
349 let mut out = Vec::new();
350 for state in &self.sources {
351 if let Some(cursor) = state.cursor.as_ref() {
352 out.push((state.source.id().to_string(), cursor.revision));
353 }
354 }
355 out
356 }
357
358 pub fn source_refresh_stats(&self) -> Vec<(SourceId, SourceRefreshStats)> {
360 self.sources
361 .iter()
362 .map(|state| (state.source.id().to_string(), state.stats.clone()))
363 .collect()
364 }
365
366 pub fn all_records_snapshot(&self) -> Vec<DataRecord> {
371 self.sources
372 .iter()
373 .flat_map(|s| s.cache.snapshot())
374 .collect()
375 }
376
377 pub fn all_caches_empty(&self) -> bool {
379 self.sources.iter().all(|s| s.cache.is_empty())
380 }
381
382 pub fn all_records_len(&self) -> usize {
384 self.sources.iter().map(|s| s.cache.len()).sum()
385 }
386
387 pub fn total_ingest_count(&self) -> u64 {
392 self.sources.iter().map(|s| s.cache.ingest_count()).sum()
393 }
394
395 pub fn refresh_all(&mut self) {
397 self.refresh_all_internal(false, None, None);
398 }
399
400 pub fn advance(&mut self, step: usize) {
402 self.refresh_all_internal(false, Some(step), None);
403 }
404
405 pub fn advance_with_weights(
410 &mut self,
411 step: usize,
412 weights: &HashMap<SourceId, f32>,
413 ) -> Result<(), SamplerError> {
414 self.validate_weights(weights)?;
415 self.refresh_all_internal(false, Some(step), Some(weights));
416 Ok(())
417 }
418
419 pub fn force_refresh_all(&mut self) {
421 self.refresh_all_internal(true, None, None);
422 }
423
424 pub fn refresh_all_with_weights(
429 &mut self,
430 weights: &HashMap<SourceId, f32>,
431 ) -> Result<(), SamplerError> {
432 self.validate_weights(weights)?;
433 self.refresh_all_internal(false, None, Some(weights));
434 Ok(())
435 }
436
437 pub fn force_refresh_all_with_weights(
442 &mut self,
443 weights: &HashMap<SourceId, f32>,
444 ) -> Result<(), SamplerError> {
445 self.validate_weights(weights)?;
446 self.refresh_all_internal(true, None, Some(weights));
447 Ok(())
448 }
449
450 fn validate_weights(&self, weights: &HashMap<SourceId, f32>) -> Result<(), SamplerError> {
451 let known_ids: std::collections::HashSet<&str> =
452 self.sources.iter().map(|s| s.source.id()).collect();
453 for (id, &w) in weights {
454 if !known_ids.contains(id.as_str()) {
455 return Err(SamplerError::InvalidWeight {
456 source_id: id.clone(),
457 reason: "source is not registered".to_string(),
458 });
459 }
460 if w < 0.0 {
461 return Err(SamplerError::InvalidWeight {
462 source_id: id.clone(),
463 reason: format!("weight {w} is negative"),
464 });
465 }
466 }
467 Ok(())
468 }
469
470 fn refresh_all_internal(
477 &mut self,
478 force_refresh: bool,
479 step: Option<usize>,
480 weights: Option<&HashMap<SourceId, f32>>,
481 ) {
482 self.last_refreshed_sources.clear();
483 let mut refresh_plan = Vec::new();
484 for (idx, state) in self.sources.iter_mut().enumerate() {
485 if force_refresh {
486 state.buffer.clear();
487 }
488 if force_refresh || state.buffer.is_empty() {
489 refresh_plan.push((idx, state.cursor.clone()));
490 }
491 }
492
493 if !refresh_plan.is_empty() {
494 self.source_refresh_generation = self.source_refresh_generation.saturating_add(1);
495 self.last_refreshed_sources = refresh_plan
496 .iter()
497 .map(|(idx, _)| self.sources[*idx].source.id().to_string())
498 .collect();
499 let mut results: Vec<
500 Option<(Result<SourceSnapshot, SamplerError>, std::time::Duration)>,
501 > = Vec::with_capacity(self.sources.len());
502 results.resize_with(self.sources.len(), || None);
503 let fetch_limit = self.max_records;
504 let sampler_config = self.sampler_config.clone();
505 let step = self.epoch_step;
506 thread::scope(|scope| {
507 let mut handles = Vec::with_capacity(refresh_plan.len());
508 for (idx, cursor) in &refresh_plan {
509 let source = &self.sources[*idx].source;
510 let cursor = cursor.clone();
511 let sampler_config = sampler_config.clone();
512 let epoch = self.epoch;
513 handles.push((
514 *idx,
515 scope.spawn(move || {
516 let start = std::time::Instant::now();
517 let epoch_config = SamplerConfig {
523 seed: derive_epoch_seed(sampler_config.seed, epoch) ^ step,
524 ..sampler_config
525 };
526 let result =
527 source.refresh(&epoch_config, cursor.as_ref(), Some(fetch_limit));
528 let elapsed = start.elapsed();
529 (result, elapsed)
530 }),
531 ));
532 }
533 for (idx, handle) in handles {
534 let result = match handle.join() {
535 Ok((result, elapsed)) => {
536 debug!(
537 source_id = %self.sources[idx].source.id(),
538 refresh_ms = elapsed.as_millis(),
539 "source refresh completed"
540 );
541 (result, elapsed)
542 }
543 Err(_) => (
544 Err(SamplerError::SourceUnavailable {
545 source_id: self.sources[idx].source.id().to_string(),
546 reason: "source refresh thread panicked".into(),
547 }),
548 std::time::Duration::from_secs(0),
549 ),
550 };
551 results[idx] = Some(result);
552 }
553 });
554
555 for (idx, _) in refresh_plan {
556 let Some((result, elapsed)) = results[idx].take() else {
557 continue;
558 };
559 match result {
560 Ok(snapshot) => {
561 let SourceSnapshot {
562 records,
563 cursor: next_cursor,
564 } = snapshot;
565 let record_count = records.len();
566 let seconds = elapsed.as_secs_f64();
567 let per_sec = if seconds > 0.0 {
568 (record_count as f64) / seconds
569 } else {
570 0.0
571 };
572 let stats = &mut self.sources[idx].stats;
573 stats.last_refresh_ms = elapsed.as_millis();
574 stats.last_record_count = record_count;
575 stats.last_records_per_sec = per_sec;
576 stats.last_error = None;
577 debug!(
578 source_id = %self.sources[idx].source.id(),
579 record_count,
580 refresh_ms = elapsed.as_millis(),
581 records_per_sec = per_sec,
582 "source refresh ingested records"
583 );
584 let source_id = self.sources[idx].source.id().to_string();
585 let normalized = records
586 .into_iter()
587 .map(|mut record| {
588 record.source = source_id.clone();
589 record
590 })
591 .collect::<Vec<_>>();
592 self.sources[idx].buffer.extend(normalized);
593 self.sources[idx].cursor = Some(next_cursor);
594 }
595 Err(err) => {
596 let stats = &mut self.sources[idx].stats;
597 stats.last_refresh_ms = elapsed.as_millis();
598 stats.last_record_count = 0;
599 stats.last_records_per_sec = 0.0;
600 stats.last_error = Some(err.to_string());
601 stats.error_count = stats.error_count.saturating_add(1);
602 warn!(
603 source = %self.sources[idx].source.id(),
604 error = %err,
605 "Source refresh failed"
606 );
607 }
608 }
609 }
610 }
611
612 if step.is_none() {
616 for state in self.sources.iter_mut() {
617 state.cache.clear();
618 }
619 }
620 if self.max_records == 0 {
621 return;
622 }
623 let target_limit = step.unwrap_or(self.max_records);
624 if let Some(weights) = weights {
625 self.weighted_drain_into_caches(target_limit, weights);
626 } else {
627 let n = self.sources.len();
632 if n > 0 {
633 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); n];
634 let mut total_drained = 0;
635 let mut any_remaining = true;
636 while total_drained < target_limit && any_remaining {
637 any_remaining = false;
638 for offset in 0..n {
639 if total_drained >= target_limit {
640 break;
641 }
642 let idx = (self.drain_start + offset) % n;
643 if let Some(record) = self.sources[idx].buffer.pop_front() {
644 per_source[idx].push(record);
645 total_drained += 1;
646 any_remaining = true;
647 }
648 }
649 }
650 if total_drained > 0 {
654 self.drain_start = (self.drain_start + 1) % n;
655 }
656 for (idx, batch) in per_source.into_iter().enumerate() {
657 if !batch.is_empty() {
658 self.sources[idx].cache.ingest(batch);
659 }
660 }
661 }
662 }
663 }
664
665 fn weighted_drain_into_caches(&mut self, limit: usize, weights: &HashMap<SourceId, f32>) {
666 let len = self.sources.len();
667 if len == 0 {
668 return;
669 }
670 let mut weight_values = Vec::with_capacity(len);
671 let mut any_positive = false;
672 for state in &self.sources {
673 let weight = weights.get(state.source.id()).copied().unwrap_or(1.0);
674 if weight > 0.0 {
675 any_positive = true;
676 }
677 weight_values.push(weight);
678 }
679 if !any_positive {
680 weight_values.fill(1.0);
681 }
682
683 let mut current = vec![0.0f32; len];
684 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); len];
685 let mut total = 0;
686 while total < limit {
687 let mut total_weight = 0.0f32;
688 for (idx, weight) in weight_values.iter().copied().enumerate().take(len) {
689 if weight <= 0.0 {
690 continue;
691 }
692 if self.sources[idx].buffer.is_empty() {
693 continue;
694 }
695 total_weight += weight;
696 }
697 if total_weight == 0.0 {
698 break;
699 }
700
701 let mut best_idx = None;
702 let mut best_score = f32::MIN;
703 let closer_to_start = |a: usize, b: usize| -> bool {
707 let da = (a + len - self.drain_start) % len;
708 let db = (b + len - self.drain_start) % len;
709 da < db
710 };
711 for idx in 0..len {
712 if weight_values[idx] <= 0.0 {
713 continue;
714 }
715 if self.sources[idx].buffer.is_empty() {
716 continue;
717 }
718 current[idx] += weight_values[idx];
719 let is_better = if current[idx] > best_score {
720 true
721 } else if current[idx] == best_score {
722 closer_to_start(idx, best_idx.unwrap_or(0))
723 } else {
724 false
725 };
726 if is_better {
727 best_score = current[idx];
728 best_idx = Some(idx);
729 }
730 }
731
732 let idx = match best_idx {
733 Some(idx) => idx,
734 None => break,
735 };
736 current[idx] -= total_weight;
737 if let Some(record) = self.sources[idx].buffer.pop_front() {
738 per_source[idx].push(record);
739 total += 1;
740 }
741 }
742
743 if total > 0 && len > 0 {
744 self.drain_start = (self.drain_start + 1) % len;
745 }
746
747 for (idx, batch) in per_source.into_iter().enumerate() {
748 if !batch.is_empty() {
749 self.sources[idx].cache.ingest(batch);
750 }
751 }
752 }
753
754 pub fn has_sources(&self) -> bool {
756 !self.sources.is_empty()
757 }
758}
759
760struct SourceState {
762 source: Box<dyn DataSource + 'static>,
763 cursor: Option<SourceCursor>,
764 buffer: VecDeque<DataRecord>,
765 cache: RecordCache,
767 stats: SourceRefreshStats,
768}
769
770#[cfg(test)]
771#[allow(deprecated)]
776mod tests {
777 use super::*;
778 use crate::TripletSampler;
779 use crate::config::{Selector, TextRecipe, TripletRecipe};
780 use crate::data::{QualityScore, RecordSection, SectionRole};
781 use crate::sampler::Sampler;
782 use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
783 use chrono::Utc;
784 use std::collections::HashMap;
785 use std::collections::VecDeque;
786 use std::sync::atomic::{AtomicUsize, Ordering};
787 use std::sync::{Arc, Mutex};
788
789 fn make_record(id: &str, source: &str) -> DataRecord {
790 let now = Utc::now();
791 DataRecord {
792 id: id.to_string(),
793 source: source.to_string(),
794 created_at: now,
795 updated_at: now,
796 quality: QualityScore { trust: 1.0 },
797 taxonomy: Vec::new(),
798 sections: vec![RecordSection {
799 role: SectionRole::Anchor,
800 heading: None,
801 text: id.to_string(),
802 sentences: vec![id.to_string()],
803 }],
804 meta_prefix: None,
805 label: None,
806 }
807 }
808
809 struct ScriptedSource {
810 id: String,
811 refreshes: Arc<AtomicUsize>,
812 script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
813 }
814
815 impl ScriptedSource {
816 fn new(
817 id: &str,
818 refreshes: Arc<AtomicUsize>,
819 script: Vec<Result<SourceSnapshot, SamplerError>>,
820 ) -> Self {
821 Self {
822 id: id.to_string(),
823 refreshes,
824 script: Arc::new(Mutex::new(script.into_iter().collect())),
825 }
826 }
827 }
828
829 impl DataSource for ScriptedSource {
830 fn id(&self) -> &str {
831 &self.id
832 }
833
834 fn refresh(
835 &self,
836 _config: &SamplerConfig,
837 _cursor: Option<&SourceCursor>,
838 _limit: Option<usize>,
839 ) -> Result<SourceSnapshot, SamplerError> {
840 self.refreshes.fetch_add(1, Ordering::SeqCst);
841 let mut guard = self.script.lock().expect("script lock poisoned");
842 guard.pop_front().unwrap_or_else(|| {
843 Ok(SourceSnapshot {
844 records: Vec::new(),
845 cursor: SourceCursor {
846 last_seen: Utc::now(),
847 revision: 0,
848 },
849 })
850 })
851 }
852
853 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
854 Ok(0)
855 }
856
857 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
858 Vec::new()
859 }
860 }
861
862 struct PanicSource {
863 id: String,
864 }
865
866 impl DataSource for PanicSource {
867 fn id(&self) -> &str {
868 &self.id
869 }
870
871 fn refresh(
872 &self,
873 _config: &SamplerConfig,
874 _cursor: Option<&SourceCursor>,
875 _limit: Option<usize>,
876 ) -> Result<SourceSnapshot, SamplerError> {
877 panic!("panic source refresh")
878 }
879
880 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
881 Ok(0)
882 }
883 }
884
885 #[test]
886 fn record_cache_waits_len_and_clear_paths_are_covered() {
887 let cache = RecordCache::new(2);
888 assert!(cache.is_empty());
889 assert_eq!(cache.len(), 0);
890 assert_eq!(cache.ingest_count(), 0);
891
892 cache.ingest(Vec::<DataRecord>::new());
893 assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
894
895 let cache_for_waiter = cache.clone();
896 let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
897 std::thread::sleep(Duration::from_millis(5));
898 cache.ingest(vec![make_record("r1", "s")]);
899 assert_eq!(handle.join().unwrap(), 1);
900 assert_eq!(cache.ingest_count(), 1);
901
902 cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
903 assert_eq!(cache.len(), 2);
904 let ids: Vec<String> = cache
905 .snapshot()
906 .into_iter()
907 .map(|record| record.id)
908 .collect();
909 assert!(ids.contains(&"r2".to_string()));
910 assert!(ids.contains(&"r3".to_string()));
911
912 cache.clear();
913 assert!(cache.is_empty());
914 }
915
916 #[test]
917 fn record_cache_zero_limit_discards_everything() {
918 let cache = RecordCache::new(0);
919 cache.ingest(vec![make_record("r1", "s")]);
920 assert!(cache.is_empty());
921 assert_eq!(cache.len(), 0);
922 }
923
924 #[test]
925 fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
926 let mut manager = IngestionManager::new(4, SamplerConfig::default());
927 assert!(!manager.has_sources());
928 manager.load_cursors(&[]);
929
930 let refreshes = Arc::new(AtomicUsize::new(0));
931 manager
932 .register_source(Box::new(ScriptedSource::new(
933 "cursor_source",
934 refreshes,
935 vec![Ok(SourceSnapshot {
936 records: vec![make_record("id_1", "original_source")],
937 cursor: SourceCursor {
938 last_seen: Utc::now(),
939 revision: 33,
940 },
941 })],
942 )))
943 .unwrap();
944 assert!(manager.has_sources());
945
946 manager.load_cursors(&[("cursor_source".to_string(), 7)]);
947 let cursors = manager.snapshot_cursors();
948 assert_eq!(cursors.len(), 1);
949 assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
950
951 manager.refresh_all();
952 let updated = manager.snapshot_cursors();
953 assert_eq!(updated.len(), 1);
954 assert_eq!(updated[0], ("cursor_source".to_string(), 33));
955 let records = manager.all_records_snapshot();
956 assert_eq!(records.len(), 1);
957 assert_eq!(records[0].source, "cursor_source");
958 }
959
960 #[test]
961 fn advance_uses_buffer_before_refreshing_again() {
962 let refreshes = Arc::new(AtomicUsize::new(0));
963 let mut manager = IngestionManager::new(5, SamplerConfig::default());
964 manager
965 .register_source(Box::new(ScriptedSource::new(
966 "buffered",
967 refreshes.clone(),
968 vec![Ok(SourceSnapshot {
969 records: vec![
970 make_record("a", "x"),
971 make_record("b", "x"),
972 make_record("c", "x"),
973 ],
974 cursor: SourceCursor {
975 last_seen: Utc::now(),
976 revision: 1,
977 },
978 })],
979 )))
980 .unwrap();
981
982 manager.advance(1);
983 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
984 assert_eq!(manager.all_records_len(), 1);
985
986 manager.advance(1);
987 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
988 assert_eq!(manager.all_records_len(), 2);
989 }
990
991 #[test]
992 fn force_refresh_clears_buffer_and_fetches_again() {
993 let refreshes = Arc::new(AtomicUsize::new(0));
994 let mut manager = IngestionManager::new(4, SamplerConfig::default());
995 manager
996 .register_source(Box::new(ScriptedSource::new(
997 "force",
998 refreshes.clone(),
999 vec![
1000 Ok(SourceSnapshot {
1001 records: vec![
1002 make_record("r1", "x"),
1003 make_record("r2", "x"),
1004 make_record("r3", "x"),
1005 ],
1006 cursor: SourceCursor {
1007 last_seen: Utc::now(),
1008 revision: 10,
1009 },
1010 }),
1011 Ok(SourceSnapshot {
1012 records: vec![make_record("r4", "x")],
1013 cursor: SourceCursor {
1014 last_seen: Utc::now(),
1015 revision: 11,
1016 },
1017 }),
1018 ],
1019 )))
1020 .unwrap();
1021
1022 manager.advance(1);
1023 assert_eq!(manager.all_records_len(), 1);
1024 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1025
1026 manager.force_refresh_all();
1027 assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1028 let records = manager.all_records_snapshot();
1029 assert_eq!(records.len(), 1);
1030 assert_eq!(records[0].id, "r4");
1031 }
1032
1033 #[test]
1034 fn weighted_drain_respects_zero_and_fallback_weights() {
1035 let mut manager = IngestionManager::new(6, SamplerConfig::default());
1036 manager
1037 .register_source(Box::new(ScriptedSource::new(
1038 "a",
1039 Arc::new(AtomicUsize::new(0)),
1040 vec![Ok(SourceSnapshot {
1041 records: vec![make_record("a1", "a"), make_record("a2", "a")],
1042 cursor: SourceCursor {
1043 last_seen: Utc::now(),
1044 revision: 1,
1045 },
1046 })],
1047 )))
1048 .unwrap();
1049 manager
1050 .register_source(Box::new(ScriptedSource::new(
1051 "b",
1052 Arc::new(AtomicUsize::new(0)),
1053 vec![Ok(SourceSnapshot {
1054 records: vec![make_record("b1", "b"), make_record("b2", "b")],
1055 cursor: SourceCursor {
1056 last_seen: Utc::now(),
1057 revision: 1,
1058 },
1059 })],
1060 )))
1061 .unwrap();
1062
1063 let mut only_b = HashMap::new();
1064 only_b.insert("a".to_string(), 0.0);
1065 only_b.insert("b".to_string(), 1.0);
1066 manager.refresh_all_with_weights(&only_b).unwrap();
1067 let ids: Vec<String> = manager
1068 .all_records_snapshot()
1069 .into_iter()
1070 .map(|record| record.id)
1071 .collect();
1072 assert!(ids.iter().all(|id| id.starts_with('b')));
1073
1074 let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1075 manager_fallback
1076 .register_source(Box::new(ScriptedSource::new(
1077 "a",
1078 Arc::new(AtomicUsize::new(0)),
1079 vec![Ok(SourceSnapshot {
1080 records: vec![make_record("a1", "a")],
1081 cursor: SourceCursor {
1082 last_seen: Utc::now(),
1083 revision: 2,
1084 },
1085 })],
1086 )))
1087 .unwrap();
1088 manager_fallback
1089 .register_source(Box::new(ScriptedSource::new(
1090 "b",
1091 Arc::new(AtomicUsize::new(0)),
1092 vec![Ok(SourceSnapshot {
1093 records: vec![make_record("b1", "b")],
1094 cursor: SourceCursor {
1095 last_seen: Utc::now(),
1096 revision: 2,
1097 },
1098 })],
1099 )))
1100 .unwrap();
1101
1102 let mut all_zero = HashMap::new();
1103 all_zero.insert("a".to_string(), 0.0);
1104 all_zero.insert("b".to_string(), 0.0);
1105 manager_fallback
1106 .refresh_all_with_weights(&all_zero)
1107 .unwrap();
1108 let ids: Vec<String> = manager_fallback
1109 .all_records_snapshot()
1110 .into_iter()
1111 .map(|record| record.id)
1112 .collect();
1113 assert!(ids.contains(&"a1".to_string()));
1114 assert!(ids.contains(&"b1".to_string()));
1115 }
1116
1117 #[test]
1118 fn refresh_errors_and_panics_update_source_stats() {
1119 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1120 manager
1121 .register_source(Box::new(ScriptedSource::new(
1122 "err_source",
1123 Arc::new(AtomicUsize::new(0)),
1124 vec![Err(SamplerError::SourceUnavailable {
1125 source_id: "err_source".to_string(),
1126 reason: "boom".to_string(),
1127 })],
1128 )))
1129 .unwrap();
1130 manager
1131 .register_source(Box::new(PanicSource {
1132 id: "panic_source".to_string(),
1133 }))
1134 .unwrap();
1135
1136 manager.refresh_all();
1137 let stats = manager.source_refresh_stats();
1138 let err_stats = stats
1139 .iter()
1140 .find(|(source, _)| source == "err_source")
1141 .map(|(_, stats)| stats)
1142 .unwrap();
1143 assert_eq!(err_stats.error_count, 1);
1144 assert!(
1145 err_stats
1146 .last_error
1147 .as_ref()
1148 .is_some_and(|msg| msg.contains("boom"))
1149 );
1150
1151 let panic_stats = stats
1152 .iter()
1153 .find(|(source, _)| source == "panic_source")
1154 .map(|(_, stats)| stats)
1155 .unwrap();
1156 assert_eq!(panic_stats.error_count, 1);
1157 assert!(
1158 panic_stats
1159 .last_error
1160 .as_ref()
1161 .is_some_and(|msg| msg.contains("panicked"))
1162 );
1163 }
1164
1165 #[test]
1166 fn force_refresh_with_weights_path_is_exercised() {
1167 let mut manager = IngestionManager::new(3, SamplerConfig::default());
1168 manager
1169 .register_source(Box::new(ScriptedSource::new(
1170 "w",
1171 Arc::new(AtomicUsize::new(0)),
1172 vec![Ok(SourceSnapshot {
1173 records: vec![make_record("w1", "w")],
1174 cursor: SourceCursor {
1175 last_seen: Utc::now(),
1176 revision: 3,
1177 },
1178 })],
1179 )))
1180 .unwrap();
1181
1182 let mut weights = HashMap::new();
1183 weights.insert("w".to_string(), 1.0);
1184 manager.force_refresh_all_with_weights(&weights).unwrap();
1185 assert_eq!(manager.all_records_len(), 1);
1186 }
1187
1188 #[test]
1189 fn advance_with_weights_rejects_unknown_source() {
1190 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1191 manager
1192 .register_source(Box::new(ScriptedSource::new(
1193 "known",
1194 Arc::new(AtomicUsize::new(0)),
1195 vec![],
1196 )))
1197 .unwrap();
1198
1199 let mut weights = HashMap::new();
1200 weights.insert("known".to_string(), 1.0);
1201 weights.insert("unknown".to_string(), 0.5);
1202
1203 let err = manager.advance_with_weights(1, &weights).unwrap_err();
1204 assert!(
1205 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1206 "expected InvalidWeight for unknown source, got {err:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn refresh_all_with_weights_rejects_negative_weight() {
1212 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1213 manager
1214 .register_source(Box::new(ScriptedSource::new(
1215 "src",
1216 Arc::new(AtomicUsize::new(0)),
1217 vec![],
1218 )))
1219 .unwrap();
1220
1221 let mut weights = HashMap::new();
1222 weights.insert("src".to_string(), -1.0);
1223
1224 let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1225 assert!(
1226 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1227 "expected InvalidWeight for negative weight, got {err:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn force_refresh_all_with_weights_rejects_unknown_source() {
1233 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1234 manager
1235 .register_source(Box::new(ScriptedSource::new(
1236 "real",
1237 Arc::new(AtomicUsize::new(0)),
1238 vec![],
1239 )))
1240 .unwrap();
1241
1242 let mut weights = HashMap::new();
1243 weights.insert("ghost".to_string(), 1.0);
1244
1245 let err = manager
1246 .force_refresh_all_with_weights(&weights)
1247 .unwrap_err();
1248 assert!(
1249 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1250 "expected InvalidWeight for unknown source, got {err:?}"
1251 );
1252 }
1253
1254 struct SeedCapturingSource {
1256 id: String,
1257 received_seeds: Arc<Mutex<Vec<u64>>>,
1258 }
1259
1260 impl SeedCapturingSource {
1261 fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1262 Self {
1263 id: id.to_string(),
1264 received_seeds,
1265 }
1266 }
1267 }
1268
1269 impl DataSource for SeedCapturingSource {
1270 fn id(&self) -> &str {
1271 &self.id
1272 }
1273
1274 fn refresh(
1275 &self,
1276 config: &SamplerConfig,
1277 _cursor: Option<&SourceCursor>,
1278 _limit: Option<usize>,
1279 ) -> Result<SourceSnapshot, SamplerError> {
1280 self.received_seeds
1281 .lock()
1282 .expect("seed lock poisoned")
1283 .push(config.seed);
1284 Ok(SourceSnapshot {
1285 records: Vec::new(),
1286 cursor: SourceCursor {
1287 last_seen: Utc::now(),
1288 revision: 0,
1289 },
1290 })
1291 }
1292
1293 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1294 Ok(0)
1295 }
1296
1297 fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1298 Vec::new()
1299 }
1300 }
1301
1302 #[test]
1303 fn epoch_xor_changes_seed_received_by_source() {
1304 let base_seed = 0xDEAD_BEEF_u64;
1307 let config = SamplerConfig {
1308 seed: base_seed,
1309 ..SamplerConfig::default()
1310 };
1311
1312 let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1313 let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1314
1315 let mut manager = IngestionManager::new(4, config.clone());
1317 manager
1318 .register_source(Box::new(SeedCapturingSource::new(
1319 "src",
1320 Arc::clone(&seeds_epoch0),
1321 )))
1322 .unwrap();
1323 manager.refresh_all();
1325
1326 let mut manager2 = IngestionManager::new(4, config.clone());
1328 manager2
1329 .register_source(Box::new(SeedCapturingSource::new(
1330 "src",
1331 Arc::clone(&seeds_epoch1),
1332 )))
1333 .unwrap();
1334 manager2.set_epoch(1);
1335 manager2.refresh_all();
1336
1337 let received0 = seeds_epoch0.lock().unwrap();
1338 let received1 = seeds_epoch1.lock().unwrap();
1339
1340 assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1341 assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1342
1343 let seed_at_epoch0 = received0[0];
1344 let seed_at_epoch1 = received1[0];
1345
1346 assert_ne!(
1348 seed_at_epoch0, seed_at_epoch1,
1349 "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1350 derive_epoch_seed is not reaching the source"
1351 );
1352
1353 assert_eq!(
1357 seed_at_epoch0,
1358 derive_epoch_seed(base_seed, 0),
1359 "epoch-0 seed mismatch (epoch_step=0)"
1360 );
1361 assert_eq!(
1362 seed_at_epoch1,
1363 derive_epoch_seed(base_seed, 1),
1364 "epoch-1 seed mismatch (epoch_step=0)"
1365 );
1366 }
1367
1368 #[test]
1369 fn epoch_step_resets_on_epoch_change() {
1370 let config = SamplerConfig::default();
1373 let seeds = Arc::new(Mutex::new(Vec::new()));
1374
1375 let mut manager = IngestionManager::new(4, config.clone());
1376 manager
1377 .register_source(Box::new(SeedCapturingSource::new(
1378 "src",
1379 Arc::clone(&seeds),
1380 )))
1381 .unwrap();
1382
1383 manager.refresh_all();
1386 let step1_seed = seeds.lock().unwrap()[0];
1387 assert_eq!(
1388 step1_seed,
1389 derive_epoch_seed(config.seed, 0),
1390 "epoch 0 step 0 seed"
1391 );
1392
1393 manager.set_epoch(1);
1395 assert_eq!(manager.epoch_step(), 0);
1396 seeds.lock().unwrap().clear();
1397
1398 manager.refresh_all();
1400 let step1_epoch1 = seeds.lock().unwrap()[0];
1401 assert_eq!(
1402 step1_epoch1,
1403 derive_epoch_seed(config.seed, 1),
1404 "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1405 );
1406 }
1407
1408 #[test]
1409 fn epoch_step_survives_sampler_save_and_load_state() {
1410 let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1416
1417 let sampler = TripletSampler::new(
1419 SamplerConfig {
1420 seed: 42,
1421 ..SamplerConfig::default()
1422 },
1423 Arc::clone(&store),
1424 );
1425 let refreshes = Arc::new(AtomicUsize::new(0));
1426 sampler
1427 .register_source(Box::new(ScriptedSource::new(
1428 "src",
1429 refreshes,
1430 vec![Ok(SourceSnapshot {
1431 records: vec![make_record("r1", "src")],
1432 cursor: SourceCursor {
1433 last_seen: Utc::now(),
1434 revision: 1,
1435 },
1436 })],
1437 )))
1438 .unwrap();
1439
1440 let _ = sampler.next_text_batch(SplitLabel::Train);
1444
1445 sampler.save_sampler_state(None).unwrap();
1447
1448 let loaded = store.load_sampler_state().unwrap().unwrap();
1450 let step_saved = loaded.epoch_step;
1451 assert!(
1452 step_saved > 0,
1453 "epoch_step must be >0 after batch call through TripletSampler"
1454 );
1455
1456 let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1458 manager2
1459 .register_source(Box::new(ScriptedSource::new(
1460 "src",
1461 Arc::new(AtomicUsize::new(0)),
1462 vec![Ok(SourceSnapshot {
1463 records: vec![make_record("r2", "src")],
1464 cursor: SourceCursor {
1465 last_seen: Utc::now(),
1466 revision: 2,
1467 },
1468 })],
1469 )))
1470 .unwrap();
1471 manager2.load_cursors(&loaded.source_stream_cursors);
1472 manager2.set_epoch_step(loaded.epoch_step);
1473
1474 let step_before = manager2.epoch_step();
1477 assert_eq!(
1478 step_before, step_saved,
1479 "load_cursors must restore epoch_step to saved value"
1480 );
1481
1482 manager2.refresh_all();
1484 let step_after = manager2.epoch_step();
1485 assert_eq!(
1486 step_after, step_saved,
1487 "epoch_step must survive refresh_all without increment (step is per-batch,
1488 not per-refresh): loaded {step_saved}, got {step_after}"
1489 );
1490 }
1491
1492 #[test]
1493 fn scripted_and_panic_sources_cover_default_trait_paths() {
1494 let refreshes = Arc::new(AtomicUsize::new(0));
1495 let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1496
1497 let snapshot = scripted
1499 .refresh(&SamplerConfig::default(), None, None)
1500 .expect("fallback snapshot");
1501 assert!(snapshot.records.is_empty());
1502 assert_eq!(snapshot.cursor.revision, 0);
1503
1504 assert_eq!(
1505 scripted
1506 .reported_record_count(&SamplerConfig::default())
1507 .expect("record count"),
1508 0
1509 );
1510 assert!(scripted.default_triplet_recipes().is_empty());
1511
1512 let panic_source = PanicSource {
1513 id: "panic_count".to_string(),
1514 };
1515 assert_eq!(
1516 panic_source
1517 .reported_record_count(&SamplerConfig::default())
1518 .expect("record count"),
1519 0
1520 );
1521 }
1522
1523 #[test]
1524 fn seed_capturing_source_trait_defaults_are_exercised() {
1525 let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1526 assert_eq!(
1527 source
1528 .reported_record_count(&SamplerConfig::default())
1529 .expect("record count"),
1530 0
1531 );
1532 assert!(source.default_triplet_recipes().is_empty());
1533 }
1534
1535 #[test]
1536 fn refresh_paths_handle_zero_capacity_and_no_sources() {
1537 let mut manager = IngestionManager::new(0, SamplerConfig::default());
1538 manager
1539 .register_source(Box::new(ScriptedSource::new(
1540 "zero_capacity",
1541 Arc::new(AtomicUsize::new(0)),
1542 vec![Ok(SourceSnapshot {
1543 records: vec![make_record("r1", "zero_capacity")],
1544 cursor: SourceCursor {
1545 last_seen: Utc::now(),
1546 revision: 1,
1547 },
1548 })],
1549 )))
1550 .unwrap();
1551 manager.refresh_all();
1552 assert!(manager.all_caches_empty());
1553
1554 let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1556 let empty_weights = HashMap::new();
1557 empty_manager
1558 .refresh_all_with_weights(&empty_weights)
1559 .expect("no sources should not error");
1560 assert!(empty_manager.all_caches_empty());
1561 }
1562
1563 #[test]
1564 fn drain_start_rotates_fairly_across_sources() {
1565 struct FairSource {
1569 id: String,
1570 refresh_count: Arc<AtomicUsize>,
1571 }
1572
1573 impl DataSource for FairSource {
1574 fn id(&self) -> &str {
1575 &self.id
1576 }
1577 fn refresh(
1578 &self,
1579 _config: &SamplerConfig,
1580 _cursor: Option<&SourceCursor>,
1581 _limit: Option<usize>,
1582 ) -> Result<SourceSnapshot, SamplerError> {
1583 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1584 Ok(SourceSnapshot {
1585 records: (0..10)
1586 .map(|i| make_record(&format!("r{i}"), &self.id))
1587 .collect(),
1588 cursor: SourceCursor {
1589 last_seen: Utc::now(),
1590 revision: 1,
1591 },
1592 })
1593 }
1594 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1595 Ok(10)
1596 }
1597 }
1598
1599 let counts = (
1600 Arc::new(AtomicUsize::new(0)),
1601 Arc::new(AtomicUsize::new(0)),
1602 Arc::new(AtomicUsize::new(0)),
1603 );
1604
1605 let mut manager = IngestionManager::new(30, SamplerConfig::default());
1606 manager
1607 .register_source(Box::new(FairSource {
1608 id: "src_0".to_string(),
1609 refresh_count: Arc::clone(&counts.0),
1610 }))
1611 .unwrap();
1612 manager
1613 .register_source(Box::new(FairSource {
1614 id: "src_1".to_string(),
1615 refresh_count: Arc::clone(&counts.1),
1616 }))
1617 .unwrap();
1618 manager
1619 .register_source(Box::new(FairSource {
1620 id: "src_2".to_string(),
1621 refresh_count: Arc::clone(&counts.2),
1622 }))
1623 .unwrap();
1624
1625 manager.refresh_all();
1627 assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1629 assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1630 assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1631
1632 for _ in 0..33 {
1638 manager.advance(1);
1639 }
1640
1641 let r0 = counts.0.load(Ordering::SeqCst);
1642 let r1 = counts.1.load(Ordering::SeqCst);
1643 let r2 = counts.2.load(Ordering::SeqCst);
1644
1645 let min = r0.min(r1).min(r2);
1652 let max = r0.max(r1).max(r2);
1653 assert!(
1654 max <= min + 1,
1655 "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1656 );
1657 }
1658
1659 #[test]
1660 fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1661 struct SimpleSource {
1664 id: String,
1665 refresh_count: Arc<AtomicUsize>,
1666 }
1667
1668 impl DataSource for SimpleSource {
1669 fn id(&self) -> &str {
1670 &self.id
1671 }
1672 fn refresh(
1673 &self,
1674 _: &SamplerConfig,
1675 _: Option<&SourceCursor>,
1676 _: Option<usize>,
1677 ) -> Result<SourceSnapshot, SamplerError> {
1678 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1679 let now = Utc::now();
1680 let records: Vec<DataRecord> = (0..8)
1681 .map(|i| DataRecord {
1682 id: format!("{}_r{i}", self.id),
1683 source: self.id.clone(),
1684 created_at: now,
1685 updated_at: now,
1686 quality: QualityScore { trust: 1.0 },
1687 taxonomy: Vec::new(),
1688 sections: Vec::new(),
1689 meta_prefix: None,
1690 label: None,
1691 })
1692 .collect();
1693 Ok(SourceSnapshot {
1694 records,
1695 cursor: SourceCursor {
1696 last_seen: now,
1697 revision: 1,
1698 },
1699 })
1700 }
1701 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1702 Ok(8)
1703 }
1704 }
1705
1706 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1707 let mut manager = IngestionManager::new(40, SamplerConfig::default());
1708 for (i, count) in counts.iter().enumerate() {
1709 manager
1710 .register_source(Box::new(SimpleSource {
1711 id: format!("src_{i}"),
1712 refresh_count: Arc::clone(count),
1713 }))
1714 .unwrap();
1715 }
1716
1717 manager.refresh_all();
1718 for c in &counts {
1719 assert_eq!(c.load(Ordering::SeqCst), 1);
1720 }
1721
1722 for _ in 0..80 {
1723 manager.advance(2);
1724 }
1725
1726 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1727 assert_eq!(
1731 totals,
1732 vec![5, 5, 6, 6, 6],
1733 "direct manager: unexpected refresh distribution"
1734 );
1735 }
1736
1737 fn make_five_source_sampler(
1740 counts: &[Arc<AtomicUsize>],
1741 ) -> TripletSampler<DeterministicSplitStore> {
1742 struct Tracked {
1743 id: String,
1744 refresh_count: Arc<AtomicUsize>,
1745 }
1746 impl DataSource for Tracked {
1747 fn id(&self) -> &str {
1748 &self.id
1749 }
1750 fn refresh(
1751 &self,
1752 _: &SamplerConfig,
1753 _: Option<&SourceCursor>,
1754 _: Option<usize>,
1755 ) -> Result<SourceSnapshot, SamplerError> {
1756 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1757 let now = Utc::now();
1758 let records: Vec<DataRecord> = (0..8)
1759 .map(|i| DataRecord {
1760 id: format!("{}_r{i}", self.id),
1761 source: self.id.clone(),
1762 created_at: now,
1763 updated_at: now,
1764 quality: QualityScore { trust: 1.0 },
1765 taxonomy: Vec::new(),
1766 sections: vec![RecordSection {
1767 role: SectionRole::Anchor,
1768 heading: None,
1769 text: format!("x{i}"),
1770 sentences: vec![format!("x{i}")],
1771 }],
1772 meta_prefix: None,
1773 label: None,
1774 })
1775 .collect();
1776 Ok(SourceSnapshot {
1777 records,
1778 cursor: SourceCursor {
1779 last_seen: now,
1780 revision: 1,
1781 },
1782 })
1783 }
1784 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1785 Ok(8)
1786 }
1787 }
1788
1789 let config = SamplerConfig {
1790 batch_size: 2,
1791 text_recipes: vec![TextRecipe {
1792 name: "anchor".into(),
1793 selector: Selector::Role(SectionRole::Anchor),
1794 weight: 1.0,
1795 instruction: None,
1796 }],
1797 split: SplitRatios {
1798 train: 1.0,
1799 validation: 0.0,
1800 test: 0.0,
1801 },
1802 allowed_splits: vec![SplitLabel::Train],
1803 ingestion_max_records: 4,
1809 ..SamplerConfig::default()
1810 };
1811 let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1812 let sampler = TripletSampler::new(config, store);
1813
1814 for (i, count) in counts.iter().enumerate() {
1815 sampler
1816 .register_source(Box::new(Tracked {
1817 id: format!("src_{i}"),
1818 refresh_count: Arc::clone(count),
1819 }))
1820 .unwrap();
1821 }
1822 sampler
1823 }
1824
1825 #[test]
1826 fn sampler_unweighted_drain_distributes_evenly() {
1827 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1828 let sampler = make_five_source_sampler(&counts);
1829
1830 sampler.next_text_batch(SplitLabel::Train).unwrap();
1831 for _ in 0..80 {
1832 sampler.next_text_batch(SplitLabel::Train).unwrap();
1833 }
1834
1835 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1836 let min = *totals.iter().min().unwrap();
1837 let max = *totals.iter().max().unwrap();
1838 assert!(
1839 max <= min + 1,
1840 "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
1841 );
1842 assert!(
1843 min >= 4,
1844 "unweighted: each source should have refreshed at least 4 times: {totals:?}"
1845 );
1846 }
1847
1848 #[test]
1849 fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
1850 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1854 let sampler = make_five_source_sampler(&counts);
1855
1856 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
1857
1858 sampler
1859 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1860 .unwrap();
1861 for _ in 0..80 {
1862 sampler
1863 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1864 .unwrap();
1865 }
1866
1867 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1868 let min = *totals.iter().min().unwrap();
1869 let max = *totals.iter().max().unwrap();
1870 assert!(
1871 max <= min + 1,
1872 "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
1873 );
1874 assert!(
1875 min >= 4,
1876 "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
1877 );
1878 }
1879
1880 #[test]
1881 fn sampler_unweighted_and_weighted_match_distribution() {
1882 let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1884 let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1885 let usampler = make_five_source_sampler(&uc);
1886 let wsampler = make_five_source_sampler(&wc);
1887
1888 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
1889
1890 usampler.next_text_batch(SplitLabel::Train).unwrap();
1891 wsampler
1892 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1893 .unwrap();
1894 for _ in 0..80 {
1895 usampler.next_text_batch(SplitLabel::Train).unwrap();
1896 wsampler
1897 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1898 .unwrap();
1899 }
1900
1901 let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1902 let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1903 let umax = *ut.iter().max().unwrap();
1904 let umin = *ut.iter().min().unwrap();
1905 let wmax = *wt.iter().max().unwrap();
1906 let wmin = *wt.iter().min().unwrap();
1907 assert!(umax <= umin + 1, "unweighted: {ut:?}");
1908 assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
1909 }
1910
1911 #[test]
1912 fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
1913 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1917 let sampler = make_five_source_sampler(&counts);
1918
1919 let mut weights = HashMap::new();
1920 for i in 0..5 {
1921 weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
1922 }
1923
1924 sampler
1925 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1926 .unwrap();
1927 for _ in 0..200 {
1928 sampler
1929 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1930 .unwrap();
1931 }
1932
1933 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1934 assert!(
1942 totals
1943 .iter()
1944 .enumerate()
1945 .all(|(i, &t)| i == 3 || t < totals[3]),
1946 "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
1947 );
1948 assert_eq!(
1955 totals,
1956 vec![11, 11, 11, 31, 17],
1957 "unequal-weights: unexpected refresh distribution"
1958 );
1959 }
1960
1961 #[test]
1962 fn register_source_rejects_reserved_id_pattern() {
1963 use crate::constants::splits::is_reserved_source_id;
1964
1965 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1966
1967 assert!(is_reserved_source_id("__meta__"));
1969 assert!(is_reserved_source_id("__anything__"));
1970 assert!(is_reserved_source_id("__x__"));
1971 assert!(!is_reserved_source_id(""));
1972 assert!(!is_reserved_source_id("__"));
1973 assert!(!is_reserved_source_id("___"));
1974 assert!(!is_reserved_source_id("normal_source"));
1975 assert!(!is_reserved_source_id("_prefix_suffix_"));
1976 assert!(!is_reserved_source_id("__unclosed"));
1977 assert!(!is_reserved_source_id("unopened__"));
1978
1979 let result = manager.register_source(Box::new(ScriptedSource::new(
1981 "__reserved__",
1982 Arc::new(AtomicUsize::new(0)),
1983 vec![],
1984 )));
1985 assert!(
1986 result.is_err(),
1987 "register_source should reject reserved source id"
1988 );
1989 let err = result.unwrap_err();
1990 assert!(
1991 matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
1992 "expected ReservedSourceId error, got: {err}"
1993 );
1994
1995 assert!(!manager.has_sources());
1997
1998 manager
2000 .register_source(Box::new(ScriptedSource::new(
2001 "valid_source",
2002 Arc::new(AtomicUsize::new(0)),
2003 vec![],
2004 )))
2005 .unwrap();
2006 assert!(manager.has_sources());
2007 }
2008}