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)]
771mod tests {
772 use super::*;
773 use crate::TripletSampler;
774 use crate::config::{Selector, TextRecipe, TripletRecipe};
775 use crate::data::{QualityScore, RecordSection, SectionRole};
776 use crate::sampler::Sampler;
777 use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
778 use chrono::Utc;
779 use std::collections::HashMap;
780 use std::collections::VecDeque;
781 use std::sync::atomic::{AtomicUsize, Ordering};
782 use std::sync::{Arc, Mutex};
783
784 fn make_record(id: &str, source: &str) -> DataRecord {
785 let now = Utc::now();
786 DataRecord {
787 id: id.to_string(),
788 source: source.to_string(),
789 created_at: now,
790 updated_at: now,
791 quality: QualityScore { trust: 1.0 },
792 taxonomy: Vec::new(),
793 sections: vec![RecordSection {
794 role: SectionRole::Anchor,
795 heading: None,
796 text: id.to_string(),
797 sentences: vec![id.to_string()],
798 }],
799 meta_prefix: None,
800 label: None,
801 }
802 }
803
804 struct ScriptedSource {
805 id: String,
806 refreshes: Arc<AtomicUsize>,
807 script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
808 }
809
810 impl ScriptedSource {
811 fn new(
812 id: &str,
813 refreshes: Arc<AtomicUsize>,
814 script: Vec<Result<SourceSnapshot, SamplerError>>,
815 ) -> Self {
816 Self {
817 id: id.to_string(),
818 refreshes,
819 script: Arc::new(Mutex::new(script.into_iter().collect())),
820 }
821 }
822 }
823
824 impl DataSource for ScriptedSource {
825 fn id(&self) -> &str {
826 &self.id
827 }
828
829 fn refresh(
830 &self,
831 _config: &SamplerConfig,
832 _cursor: Option<&SourceCursor>,
833 _limit: Option<usize>,
834 ) -> Result<SourceSnapshot, SamplerError> {
835 self.refreshes.fetch_add(1, Ordering::SeqCst);
836 let mut guard = self.script.lock().expect("script lock poisoned");
837 guard.pop_front().unwrap_or_else(|| {
838 Ok(SourceSnapshot {
839 records: Vec::new(),
840 cursor: SourceCursor {
841 last_seen: Utc::now(),
842 revision: 0,
843 },
844 })
845 })
846 }
847
848 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
849 Ok(0)
850 }
851
852 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
853 Vec::new()
854 }
855 }
856
857 struct PanicSource {
858 id: String,
859 }
860
861 impl DataSource for PanicSource {
862 fn id(&self) -> &str {
863 &self.id
864 }
865
866 fn refresh(
867 &self,
868 _config: &SamplerConfig,
869 _cursor: Option<&SourceCursor>,
870 _limit: Option<usize>,
871 ) -> Result<SourceSnapshot, SamplerError> {
872 panic!("panic source refresh")
873 }
874
875 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
876 Ok(0)
877 }
878 }
879
880 #[test]
881 fn record_cache_waits_len_and_clear_paths_are_covered() {
882 let cache = RecordCache::new(2);
883 assert!(cache.is_empty());
884 assert_eq!(cache.len(), 0);
885 assert_eq!(cache.ingest_count(), 0);
886
887 cache.ingest(Vec::<DataRecord>::new());
888 assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
889
890 let cache_for_waiter = cache.clone();
891 let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
892 std::thread::sleep(Duration::from_millis(5));
893 cache.ingest(vec![make_record("r1", "s")]);
894 assert_eq!(handle.join().unwrap(), 1);
895 assert_eq!(cache.ingest_count(), 1);
896
897 cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
898 assert_eq!(cache.len(), 2);
899 let ids: Vec<String> = cache
900 .snapshot()
901 .into_iter()
902 .map(|record| record.id)
903 .collect();
904 assert!(ids.contains(&"r2".to_string()));
905 assert!(ids.contains(&"r3".to_string()));
906
907 cache.clear();
908 assert!(cache.is_empty());
909 }
910
911 #[test]
912 fn record_cache_zero_limit_discards_everything() {
913 let cache = RecordCache::new(0);
914 cache.ingest(vec![make_record("r1", "s")]);
915 assert!(cache.is_empty());
916 assert_eq!(cache.len(), 0);
917 }
918
919 #[test]
920 fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
921 let mut manager = IngestionManager::new(4, SamplerConfig::default());
922 assert!(!manager.has_sources());
923 manager.load_cursors(&[]);
924
925 let refreshes = Arc::new(AtomicUsize::new(0));
926 manager
927 .register_source(Box::new(ScriptedSource::new(
928 "cursor_source",
929 refreshes,
930 vec![Ok(SourceSnapshot {
931 records: vec![make_record("id_1", "original_source")],
932 cursor: SourceCursor {
933 last_seen: Utc::now(),
934 revision: 33,
935 },
936 })],
937 )))
938 .unwrap();
939 assert!(manager.has_sources());
940
941 manager.load_cursors(&[("cursor_source".to_string(), 7)]);
942 let cursors = manager.snapshot_cursors();
943 assert_eq!(cursors.len(), 1);
944 assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
945
946 manager.refresh_all();
947 let updated = manager.snapshot_cursors();
948 assert_eq!(updated.len(), 1);
949 assert_eq!(updated[0], ("cursor_source".to_string(), 33));
950 let records = manager.all_records_snapshot();
951 assert_eq!(records.len(), 1);
952 assert_eq!(records[0].source, "cursor_source");
953 }
954
955 #[test]
956 fn advance_uses_buffer_before_refreshing_again() {
957 let refreshes = Arc::new(AtomicUsize::new(0));
958 let mut manager = IngestionManager::new(5, SamplerConfig::default());
959 manager
960 .register_source(Box::new(ScriptedSource::new(
961 "buffered",
962 refreshes.clone(),
963 vec![Ok(SourceSnapshot {
964 records: vec![
965 make_record("a", "x"),
966 make_record("b", "x"),
967 make_record("c", "x"),
968 ],
969 cursor: SourceCursor {
970 last_seen: Utc::now(),
971 revision: 1,
972 },
973 })],
974 )))
975 .unwrap();
976
977 manager.advance(1);
978 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
979 assert_eq!(manager.all_records_len(), 1);
980
981 manager.advance(1);
982 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
983 assert_eq!(manager.all_records_len(), 2);
984 }
985
986 #[test]
987 fn force_refresh_clears_buffer_and_fetches_again() {
988 let refreshes = Arc::new(AtomicUsize::new(0));
989 let mut manager = IngestionManager::new(4, SamplerConfig::default());
990 manager
991 .register_source(Box::new(ScriptedSource::new(
992 "force",
993 refreshes.clone(),
994 vec![
995 Ok(SourceSnapshot {
996 records: vec![
997 make_record("r1", "x"),
998 make_record("r2", "x"),
999 make_record("r3", "x"),
1000 ],
1001 cursor: SourceCursor {
1002 last_seen: Utc::now(),
1003 revision: 10,
1004 },
1005 }),
1006 Ok(SourceSnapshot {
1007 records: vec![make_record("r4", "x")],
1008 cursor: SourceCursor {
1009 last_seen: Utc::now(),
1010 revision: 11,
1011 },
1012 }),
1013 ],
1014 )))
1015 .unwrap();
1016
1017 manager.advance(1);
1018 assert_eq!(manager.all_records_len(), 1);
1019 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1020
1021 manager.force_refresh_all();
1022 assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1023 let records = manager.all_records_snapshot();
1024 assert_eq!(records.len(), 1);
1025 assert_eq!(records[0].id, "r4");
1026 }
1027
1028 #[test]
1029 fn weighted_drain_respects_zero_and_fallback_weights() {
1030 let mut manager = IngestionManager::new(6, SamplerConfig::default());
1031 manager
1032 .register_source(Box::new(ScriptedSource::new(
1033 "a",
1034 Arc::new(AtomicUsize::new(0)),
1035 vec![Ok(SourceSnapshot {
1036 records: vec![make_record("a1", "a"), make_record("a2", "a")],
1037 cursor: SourceCursor {
1038 last_seen: Utc::now(),
1039 revision: 1,
1040 },
1041 })],
1042 )))
1043 .unwrap();
1044 manager
1045 .register_source(Box::new(ScriptedSource::new(
1046 "b",
1047 Arc::new(AtomicUsize::new(0)),
1048 vec![Ok(SourceSnapshot {
1049 records: vec![make_record("b1", "b"), make_record("b2", "b")],
1050 cursor: SourceCursor {
1051 last_seen: Utc::now(),
1052 revision: 1,
1053 },
1054 })],
1055 )))
1056 .unwrap();
1057
1058 let mut only_b = HashMap::new();
1059 only_b.insert("a".to_string(), 0.0);
1060 only_b.insert("b".to_string(), 1.0);
1061 manager.refresh_all_with_weights(&only_b).unwrap();
1062 let ids: Vec<String> = manager
1063 .all_records_snapshot()
1064 .into_iter()
1065 .map(|record| record.id)
1066 .collect();
1067 assert!(ids.iter().all(|id| id.starts_with('b')));
1068
1069 let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1070 manager_fallback
1071 .register_source(Box::new(ScriptedSource::new(
1072 "a",
1073 Arc::new(AtomicUsize::new(0)),
1074 vec![Ok(SourceSnapshot {
1075 records: vec![make_record("a1", "a")],
1076 cursor: SourceCursor {
1077 last_seen: Utc::now(),
1078 revision: 2,
1079 },
1080 })],
1081 )))
1082 .unwrap();
1083 manager_fallback
1084 .register_source(Box::new(ScriptedSource::new(
1085 "b",
1086 Arc::new(AtomicUsize::new(0)),
1087 vec![Ok(SourceSnapshot {
1088 records: vec![make_record("b1", "b")],
1089 cursor: SourceCursor {
1090 last_seen: Utc::now(),
1091 revision: 2,
1092 },
1093 })],
1094 )))
1095 .unwrap();
1096
1097 let mut all_zero = HashMap::new();
1098 all_zero.insert("a".to_string(), 0.0);
1099 all_zero.insert("b".to_string(), 0.0);
1100 manager_fallback
1101 .refresh_all_with_weights(&all_zero)
1102 .unwrap();
1103 let ids: Vec<String> = manager_fallback
1104 .all_records_snapshot()
1105 .into_iter()
1106 .map(|record| record.id)
1107 .collect();
1108 assert!(ids.contains(&"a1".to_string()));
1109 assert!(ids.contains(&"b1".to_string()));
1110 }
1111
1112 #[test]
1113 fn refresh_errors_and_panics_update_source_stats() {
1114 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1115 manager
1116 .register_source(Box::new(ScriptedSource::new(
1117 "err_source",
1118 Arc::new(AtomicUsize::new(0)),
1119 vec![Err(SamplerError::SourceUnavailable {
1120 source_id: "err_source".to_string(),
1121 reason: "boom".to_string(),
1122 })],
1123 )))
1124 .unwrap();
1125 manager
1126 .register_source(Box::new(PanicSource {
1127 id: "panic_source".to_string(),
1128 }))
1129 .unwrap();
1130
1131 manager.refresh_all();
1132 let stats = manager.source_refresh_stats();
1133 let err_stats = stats
1134 .iter()
1135 .find(|(source, _)| source == "err_source")
1136 .map(|(_, stats)| stats)
1137 .unwrap();
1138 assert_eq!(err_stats.error_count, 1);
1139 assert!(
1140 err_stats
1141 .last_error
1142 .as_ref()
1143 .is_some_and(|msg| msg.contains("boom"))
1144 );
1145
1146 let panic_stats = stats
1147 .iter()
1148 .find(|(source, _)| source == "panic_source")
1149 .map(|(_, stats)| stats)
1150 .unwrap();
1151 assert_eq!(panic_stats.error_count, 1);
1152 assert!(
1153 panic_stats
1154 .last_error
1155 .as_ref()
1156 .is_some_and(|msg| msg.contains("panicked"))
1157 );
1158 }
1159
1160 #[test]
1161 fn force_refresh_with_weights_path_is_exercised() {
1162 let mut manager = IngestionManager::new(3, SamplerConfig::default());
1163 manager
1164 .register_source(Box::new(ScriptedSource::new(
1165 "w",
1166 Arc::new(AtomicUsize::new(0)),
1167 vec![Ok(SourceSnapshot {
1168 records: vec![make_record("w1", "w")],
1169 cursor: SourceCursor {
1170 last_seen: Utc::now(),
1171 revision: 3,
1172 },
1173 })],
1174 )))
1175 .unwrap();
1176
1177 let mut weights = HashMap::new();
1178 weights.insert("w".to_string(), 1.0);
1179 manager.force_refresh_all_with_weights(&weights).unwrap();
1180 assert_eq!(manager.all_records_len(), 1);
1181 }
1182
1183 #[test]
1184 fn advance_with_weights_rejects_unknown_source() {
1185 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1186 manager
1187 .register_source(Box::new(ScriptedSource::new(
1188 "known",
1189 Arc::new(AtomicUsize::new(0)),
1190 vec![],
1191 )))
1192 .unwrap();
1193
1194 let mut weights = HashMap::new();
1195 weights.insert("known".to_string(), 1.0);
1196 weights.insert("unknown".to_string(), 0.5);
1197
1198 let err = manager.advance_with_weights(1, &weights).unwrap_err();
1199 assert!(
1200 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1201 "expected InvalidWeight for unknown source, got {err:?}"
1202 );
1203 }
1204
1205 #[test]
1206 fn refresh_all_with_weights_rejects_negative_weight() {
1207 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1208 manager
1209 .register_source(Box::new(ScriptedSource::new(
1210 "src",
1211 Arc::new(AtomicUsize::new(0)),
1212 vec![],
1213 )))
1214 .unwrap();
1215
1216 let mut weights = HashMap::new();
1217 weights.insert("src".to_string(), -1.0);
1218
1219 let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1220 assert!(
1221 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1222 "expected InvalidWeight for negative weight, got {err:?}"
1223 );
1224 }
1225
1226 #[test]
1227 fn force_refresh_all_with_weights_rejects_unknown_source() {
1228 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1229 manager
1230 .register_source(Box::new(ScriptedSource::new(
1231 "real",
1232 Arc::new(AtomicUsize::new(0)),
1233 vec![],
1234 )))
1235 .unwrap();
1236
1237 let mut weights = HashMap::new();
1238 weights.insert("ghost".to_string(), 1.0);
1239
1240 let err = manager
1241 .force_refresh_all_with_weights(&weights)
1242 .unwrap_err();
1243 assert!(
1244 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1245 "expected InvalidWeight for unknown source, got {err:?}"
1246 );
1247 }
1248
1249 struct SeedCapturingSource {
1251 id: String,
1252 received_seeds: Arc<Mutex<Vec<u64>>>,
1253 }
1254
1255 impl SeedCapturingSource {
1256 fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1257 Self {
1258 id: id.to_string(),
1259 received_seeds,
1260 }
1261 }
1262 }
1263
1264 impl DataSource for SeedCapturingSource {
1265 fn id(&self) -> &str {
1266 &self.id
1267 }
1268
1269 fn refresh(
1270 &self,
1271 config: &SamplerConfig,
1272 _cursor: Option<&SourceCursor>,
1273 _limit: Option<usize>,
1274 ) -> Result<SourceSnapshot, SamplerError> {
1275 self.received_seeds
1276 .lock()
1277 .expect("seed lock poisoned")
1278 .push(config.seed);
1279 Ok(SourceSnapshot {
1280 records: Vec::new(),
1281 cursor: SourceCursor {
1282 last_seen: Utc::now(),
1283 revision: 0,
1284 },
1285 })
1286 }
1287
1288 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1289 Ok(0)
1290 }
1291
1292 fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1293 Vec::new()
1294 }
1295 }
1296
1297 #[test]
1298 fn epoch_xor_changes_seed_received_by_source() {
1299 let base_seed = 0xDEAD_BEEF_u64;
1302 let config = SamplerConfig {
1303 seed: base_seed,
1304 ..SamplerConfig::default()
1305 };
1306
1307 let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1308 let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1309
1310 let mut manager = IngestionManager::new(4, config.clone());
1312 manager
1313 .register_source(Box::new(SeedCapturingSource::new(
1314 "src",
1315 Arc::clone(&seeds_epoch0),
1316 )))
1317 .unwrap();
1318 manager.refresh_all();
1320
1321 let mut manager2 = IngestionManager::new(4, config.clone());
1323 manager2
1324 .register_source(Box::new(SeedCapturingSource::new(
1325 "src",
1326 Arc::clone(&seeds_epoch1),
1327 )))
1328 .unwrap();
1329 manager2.set_epoch(1);
1330 manager2.refresh_all();
1331
1332 let received0 = seeds_epoch0.lock().unwrap();
1333 let received1 = seeds_epoch1.lock().unwrap();
1334
1335 assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1336 assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1337
1338 let seed_at_epoch0 = received0[0];
1339 let seed_at_epoch1 = received1[0];
1340
1341 assert_ne!(
1343 seed_at_epoch0, seed_at_epoch1,
1344 "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1345 derive_epoch_seed is not reaching the source"
1346 );
1347
1348 assert_eq!(
1352 seed_at_epoch0,
1353 derive_epoch_seed(base_seed, 0),
1354 "epoch-0 seed mismatch (epoch_step=0)"
1355 );
1356 assert_eq!(
1357 seed_at_epoch1,
1358 derive_epoch_seed(base_seed, 1),
1359 "epoch-1 seed mismatch (epoch_step=0)"
1360 );
1361 }
1362
1363 #[test]
1364 fn epoch_step_resets_on_epoch_change() {
1365 let config = SamplerConfig::default();
1368 let seeds = Arc::new(Mutex::new(Vec::new()));
1369
1370 let mut manager = IngestionManager::new(4, config.clone());
1371 manager
1372 .register_source(Box::new(SeedCapturingSource::new(
1373 "src",
1374 Arc::clone(&seeds),
1375 )))
1376 .unwrap();
1377
1378 manager.refresh_all();
1381 let step1_seed = seeds.lock().unwrap()[0];
1382 assert_eq!(
1383 step1_seed,
1384 derive_epoch_seed(config.seed, 0),
1385 "epoch 0 step 0 seed"
1386 );
1387
1388 manager.set_epoch(1);
1390 assert_eq!(manager.epoch_step(), 0);
1391 seeds.lock().unwrap().clear();
1392
1393 manager.refresh_all();
1395 let step1_epoch1 = seeds.lock().unwrap()[0];
1396 assert_eq!(
1397 step1_epoch1,
1398 derive_epoch_seed(config.seed, 1),
1399 "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1400 );
1401 }
1402
1403 #[test]
1404 fn epoch_step_survives_sampler_save_and_load_state() {
1405 let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1411
1412 let sampler = TripletSampler::new(
1414 SamplerConfig {
1415 seed: 42,
1416 ..SamplerConfig::default()
1417 },
1418 Arc::clone(&store),
1419 );
1420 let refreshes = Arc::new(AtomicUsize::new(0));
1421 sampler
1422 .register_source(Box::new(ScriptedSource::new(
1423 "src",
1424 refreshes,
1425 vec![Ok(SourceSnapshot {
1426 records: vec![make_record("r1", "src")],
1427 cursor: SourceCursor {
1428 last_seen: Utc::now(),
1429 revision: 1,
1430 },
1431 })],
1432 )))
1433 .unwrap();
1434
1435 let _ = sampler.next_text_batch(SplitLabel::Train);
1439
1440 sampler.save_sampler_state(None).unwrap();
1442
1443 let loaded = store.load_sampler_state().unwrap().unwrap();
1445 let step_saved = loaded.epoch_step;
1446 assert!(
1447 step_saved > 0,
1448 "epoch_step must be >0 after batch call through TripletSampler"
1449 );
1450
1451 let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1453 manager2
1454 .register_source(Box::new(ScriptedSource::new(
1455 "src",
1456 Arc::new(AtomicUsize::new(0)),
1457 vec![Ok(SourceSnapshot {
1458 records: vec![make_record("r2", "src")],
1459 cursor: SourceCursor {
1460 last_seen: Utc::now(),
1461 revision: 2,
1462 },
1463 })],
1464 )))
1465 .unwrap();
1466 manager2.load_cursors(&loaded.source_stream_cursors);
1467 manager2.set_epoch_step(loaded.epoch_step);
1468
1469 let step_before = manager2.epoch_step();
1472 assert_eq!(
1473 step_before, step_saved,
1474 "load_cursors must restore epoch_step to saved value"
1475 );
1476
1477 manager2.refresh_all();
1479 let step_after = manager2.epoch_step();
1480 assert_eq!(
1481 step_after, step_saved,
1482 "epoch_step must survive refresh_all without increment (step is per-batch,
1483 not per-refresh): loaded {step_saved}, got {step_after}"
1484 );
1485 }
1486
1487 #[test]
1488 fn scripted_and_panic_sources_cover_default_trait_paths() {
1489 let refreshes = Arc::new(AtomicUsize::new(0));
1490 let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1491
1492 let snapshot = scripted
1494 .refresh(&SamplerConfig::default(), None, None)
1495 .expect("fallback snapshot");
1496 assert!(snapshot.records.is_empty());
1497 assert_eq!(snapshot.cursor.revision, 0);
1498
1499 assert_eq!(
1500 scripted
1501 .reported_record_count(&SamplerConfig::default())
1502 .expect("record count"),
1503 0
1504 );
1505 assert!(scripted.default_triplet_recipes().is_empty());
1506
1507 let panic_source = PanicSource {
1508 id: "panic_count".to_string(),
1509 };
1510 assert_eq!(
1511 panic_source
1512 .reported_record_count(&SamplerConfig::default())
1513 .expect("record count"),
1514 0
1515 );
1516 }
1517
1518 #[test]
1519 fn seed_capturing_source_trait_defaults_are_exercised() {
1520 let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1521 assert_eq!(
1522 source
1523 .reported_record_count(&SamplerConfig::default())
1524 .expect("record count"),
1525 0
1526 );
1527 assert!(source.default_triplet_recipes().is_empty());
1528 }
1529
1530 #[test]
1531 fn refresh_paths_handle_zero_capacity_and_no_sources() {
1532 let mut manager = IngestionManager::new(0, SamplerConfig::default());
1533 manager
1534 .register_source(Box::new(ScriptedSource::new(
1535 "zero_capacity",
1536 Arc::new(AtomicUsize::new(0)),
1537 vec![Ok(SourceSnapshot {
1538 records: vec![make_record("r1", "zero_capacity")],
1539 cursor: SourceCursor {
1540 last_seen: Utc::now(),
1541 revision: 1,
1542 },
1543 })],
1544 )))
1545 .unwrap();
1546 manager.refresh_all();
1547 assert!(manager.all_caches_empty());
1548
1549 let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1551 let empty_weights = HashMap::new();
1552 empty_manager
1553 .refresh_all_with_weights(&empty_weights)
1554 .expect("no sources should not error");
1555 assert!(empty_manager.all_caches_empty());
1556 }
1557
1558 #[test]
1559 fn drain_start_rotates_fairly_across_sources() {
1560 struct FairSource {
1564 id: String,
1565 refresh_count: Arc<AtomicUsize>,
1566 }
1567
1568 impl DataSource for FairSource {
1569 fn id(&self) -> &str {
1570 &self.id
1571 }
1572 fn refresh(
1573 &self,
1574 _config: &SamplerConfig,
1575 _cursor: Option<&SourceCursor>,
1576 _limit: Option<usize>,
1577 ) -> Result<SourceSnapshot, SamplerError> {
1578 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1579 Ok(SourceSnapshot {
1580 records: (0..10)
1581 .map(|i| make_record(&format!("r{i}"), &self.id))
1582 .collect(),
1583 cursor: SourceCursor {
1584 last_seen: Utc::now(),
1585 revision: 1,
1586 },
1587 })
1588 }
1589 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1590 Ok(10)
1591 }
1592 }
1593
1594 let counts = (
1595 Arc::new(AtomicUsize::new(0)),
1596 Arc::new(AtomicUsize::new(0)),
1597 Arc::new(AtomicUsize::new(0)),
1598 );
1599
1600 let mut manager = IngestionManager::new(30, SamplerConfig::default());
1601 manager
1602 .register_source(Box::new(FairSource {
1603 id: "src_0".to_string(),
1604 refresh_count: Arc::clone(&counts.0),
1605 }))
1606 .unwrap();
1607 manager
1608 .register_source(Box::new(FairSource {
1609 id: "src_1".to_string(),
1610 refresh_count: Arc::clone(&counts.1),
1611 }))
1612 .unwrap();
1613 manager
1614 .register_source(Box::new(FairSource {
1615 id: "src_2".to_string(),
1616 refresh_count: Arc::clone(&counts.2),
1617 }))
1618 .unwrap();
1619
1620 manager.refresh_all();
1622 assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1624 assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1625 assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1626
1627 for _ in 0..33 {
1633 manager.advance(1);
1634 }
1635
1636 let r0 = counts.0.load(Ordering::SeqCst);
1637 let r1 = counts.1.load(Ordering::SeqCst);
1638 let r2 = counts.2.load(Ordering::SeqCst);
1639
1640 let min = r0.min(r1).min(r2);
1647 let max = r0.max(r1).max(r2);
1648 assert!(
1649 max <= min + 1,
1650 "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1651 );
1652 }
1653
1654 #[test]
1655 fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1656 struct SimpleSource {
1659 id: String,
1660 refresh_count: Arc<AtomicUsize>,
1661 }
1662
1663 impl DataSource for SimpleSource {
1664 fn id(&self) -> &str {
1665 &self.id
1666 }
1667 fn refresh(
1668 &self,
1669 _: &SamplerConfig,
1670 _: Option<&SourceCursor>,
1671 _: Option<usize>,
1672 ) -> Result<SourceSnapshot, SamplerError> {
1673 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1674 let now = Utc::now();
1675 let records: Vec<DataRecord> = (0..8)
1676 .map(|i| DataRecord {
1677 id: format!("{}_r{i}", self.id),
1678 source: self.id.clone(),
1679 created_at: now,
1680 updated_at: now,
1681 quality: QualityScore { trust: 1.0 },
1682 taxonomy: Vec::new(),
1683 sections: Vec::new(),
1684 meta_prefix: None,
1685 label: None,
1686 })
1687 .collect();
1688 Ok(SourceSnapshot {
1689 records,
1690 cursor: SourceCursor {
1691 last_seen: now,
1692 revision: 1,
1693 },
1694 })
1695 }
1696 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1697 Ok(8)
1698 }
1699 }
1700
1701 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1702 let mut manager = IngestionManager::new(40, SamplerConfig::default());
1703 for (i, count) in counts.iter().enumerate() {
1704 manager
1705 .register_source(Box::new(SimpleSource {
1706 id: format!("src_{i}"),
1707 refresh_count: Arc::clone(count),
1708 }))
1709 .unwrap();
1710 }
1711
1712 manager.refresh_all();
1713 for c in &counts {
1714 assert_eq!(c.load(Ordering::SeqCst), 1);
1715 }
1716
1717 for _ in 0..80 {
1718 manager.advance(2);
1719 }
1720
1721 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1722 assert_eq!(
1726 totals,
1727 vec![5, 5, 6, 6, 6],
1728 "direct manager: unexpected refresh distribution"
1729 );
1730 }
1731
1732 fn make_five_source_sampler(
1735 counts: &[Arc<AtomicUsize>],
1736 ) -> TripletSampler<DeterministicSplitStore> {
1737 struct Tracked {
1738 id: String,
1739 refresh_count: Arc<AtomicUsize>,
1740 }
1741 impl DataSource for Tracked {
1742 fn id(&self) -> &str {
1743 &self.id
1744 }
1745 fn refresh(
1746 &self,
1747 _: &SamplerConfig,
1748 _: Option<&SourceCursor>,
1749 _: Option<usize>,
1750 ) -> Result<SourceSnapshot, SamplerError> {
1751 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1752 let now = Utc::now();
1753 let records: Vec<DataRecord> = (0..8)
1754 .map(|i| DataRecord {
1755 id: format!("{}_r{i}", self.id),
1756 source: self.id.clone(),
1757 created_at: now,
1758 updated_at: now,
1759 quality: QualityScore { trust: 1.0 },
1760 taxonomy: Vec::new(),
1761 sections: vec![RecordSection {
1762 role: SectionRole::Anchor,
1763 heading: None,
1764 text: format!("x{i}"),
1765 sentences: vec![format!("x{i}")],
1766 }],
1767 meta_prefix: None,
1768 label: None,
1769 })
1770 .collect();
1771 Ok(SourceSnapshot {
1772 records,
1773 cursor: SourceCursor {
1774 last_seen: now,
1775 revision: 1,
1776 },
1777 })
1778 }
1779 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1780 Ok(8)
1781 }
1782 }
1783
1784 let config = SamplerConfig {
1785 batch_size: 2,
1786 text_recipes: vec![TextRecipe {
1787 name: "anchor".into(),
1788 selector: Selector::Role(SectionRole::Anchor),
1789 weight: 1.0,
1790 instruction: None,
1791 }],
1792 split: SplitRatios {
1793 train: 1.0,
1794 validation: 0.0,
1795 test: 0.0,
1796 },
1797 allowed_splits: vec![SplitLabel::Train],
1798 ingestion_max_records: 4,
1804 ..SamplerConfig::default()
1805 };
1806 let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1807 let sampler = TripletSampler::new(config, store);
1808
1809 for (i, count) in counts.iter().enumerate() {
1810 sampler
1811 .register_source(Box::new(Tracked {
1812 id: format!("src_{i}"),
1813 refresh_count: Arc::clone(count),
1814 }))
1815 .unwrap();
1816 }
1817 sampler
1818 }
1819
1820 #[test]
1821 fn sampler_unweighted_drain_distributes_evenly() {
1822 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1823 let sampler = make_five_source_sampler(&counts);
1824
1825 sampler.next_text_batch(SplitLabel::Train).unwrap();
1826 for _ in 0..80 {
1827 sampler.next_text_batch(SplitLabel::Train).unwrap();
1828 }
1829
1830 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1831 let min = *totals.iter().min().unwrap();
1832 let max = *totals.iter().max().unwrap();
1833 assert!(
1834 max <= min + 1,
1835 "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
1836 );
1837 assert!(
1838 min >= 4,
1839 "unweighted: each source should have refreshed at least 4 times: {totals:?}"
1840 );
1841 }
1842
1843 #[test]
1844 fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
1845 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1849 let sampler = make_five_source_sampler(&counts);
1850
1851 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
1852
1853 sampler
1854 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1855 .unwrap();
1856 for _ in 0..80 {
1857 sampler
1858 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1859 .unwrap();
1860 }
1861
1862 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1863 let min = *totals.iter().min().unwrap();
1864 let max = *totals.iter().max().unwrap();
1865 assert!(
1866 max <= min + 1,
1867 "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
1868 );
1869 assert!(
1870 min >= 4,
1871 "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
1872 );
1873 }
1874
1875 #[test]
1876 fn sampler_unweighted_and_weighted_match_distribution() {
1877 let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1879 let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1880 let usampler = make_five_source_sampler(&uc);
1881 let wsampler = make_five_source_sampler(&wc);
1882
1883 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
1884
1885 usampler.next_text_batch(SplitLabel::Train).unwrap();
1886 wsampler
1887 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1888 .unwrap();
1889 for _ in 0..80 {
1890 usampler.next_text_batch(SplitLabel::Train).unwrap();
1891 wsampler
1892 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1893 .unwrap();
1894 }
1895
1896 let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1897 let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1898 let umax = *ut.iter().max().unwrap();
1899 let umin = *ut.iter().min().unwrap();
1900 let wmax = *wt.iter().max().unwrap();
1901 let wmin = *wt.iter().min().unwrap();
1902 assert!(umax <= umin + 1, "unweighted: {ut:?}");
1903 assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
1904 }
1905
1906 #[test]
1907 fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
1908 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1912 let sampler = make_five_source_sampler(&counts);
1913
1914 let mut weights = HashMap::new();
1915 for i in 0..5 {
1916 weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
1917 }
1918
1919 sampler
1920 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1921 .unwrap();
1922 for _ in 0..200 {
1923 sampler
1924 .next_text_batch_with_weights(SplitLabel::Train, &weights)
1925 .unwrap();
1926 }
1927
1928 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1929 assert!(
1937 totals
1938 .iter()
1939 .enumerate()
1940 .all(|(i, &t)| i == 3 || t < totals[3]),
1941 "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
1942 );
1943 assert_eq!(
1950 totals,
1951 vec![11, 11, 11, 31, 17],
1952 "unequal-weights: unexpected refresh distribution"
1953 );
1954 }
1955
1956 #[test]
1957 fn register_source_rejects_reserved_id_pattern() {
1958 use crate::constants::splits::is_reserved_source_id;
1959
1960 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1961
1962 assert!(is_reserved_source_id("__meta__"));
1964 assert!(is_reserved_source_id("__anything__"));
1965 assert!(is_reserved_source_id("__x__"));
1966 assert!(!is_reserved_source_id(""));
1967 assert!(!is_reserved_source_id("__"));
1968 assert!(!is_reserved_source_id("___"));
1969 assert!(!is_reserved_source_id("normal_source"));
1970 assert!(!is_reserved_source_id("_prefix_suffix_"));
1971 assert!(!is_reserved_source_id("__unclosed"));
1972 assert!(!is_reserved_source_id("unopened__"));
1973
1974 let result = manager.register_source(Box::new(ScriptedSource::new(
1976 "__reserved__",
1977 Arc::new(AtomicUsize::new(0)),
1978 vec![],
1979 )));
1980 assert!(
1981 result.is_err(),
1982 "register_source should reject reserved source id"
1983 );
1984 let err = result.unwrap_err();
1985 assert!(
1986 matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
1987 "expected ReservedSourceId error, got: {err}"
1988 );
1989
1990 assert!(!manager.has_sources());
1992
1993 manager
1995 .register_source(Box::new(ScriptedSource::new(
1996 "valid_source",
1997 Arc::new(AtomicUsize::new(0)),
1998 vec![],
1999 )))
2000 .unwrap();
2001 assert!(manager.has_sources());
2002 }
2003}