Skip to main content

triplets_core/
ingestion.rs

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/// Thread-safe in-memory cache of ingested records keyed by record id.
18#[derive(Clone)]
19pub struct RecordCache {
20    inner: Arc<RwLock<RecordCacheInner>>,
21    notifier: Arc<(Mutex<CacheStats>, Condvar)>,
22}
23
24/// Internal mutable cache storage behind `RecordCache` locks.
25struct RecordCacheInner {
26    records: IndexMap<RecordId, CachedRecord>,
27    order: VecDeque<RecordId>,
28    max_records: usize,
29    next_version: u64,
30}
31
32/// Internal cache entry plus monotonic version marker.
33struct CachedRecord {
34    record: DataRecord,
35    version: u64,
36}
37
38/// Internal ingest notification counters.
39#[derive(Default)]
40struct CacheStats {
41    ingests: u64,
42}
43
44impl RecordCache {
45    /// Create a cache capped to at most `max_records` live records.
46    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    /// Ingest a batch of records, replacing existing entries by id.
59    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    /// Remove all cached records.
77    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    /// Return a cloned snapshot of current cached records.
84    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    /// Return the number of completed ingest operations.
94    pub fn ingest_count(&self) -> u64 {
95        let (lock, _) = &*self.notifier;
96        lock.lock().expect("record cache stats poisoned").ingests
97    }
98
99    /// Wait until ingest count exceeds `last_seen`, or until timeout elapses.
100    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    /// Wait indefinitely until ingest count exceeds `last_seen`.
116    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    /// Returns `true` when the cache has no records.
126    pub fn is_empty(&self) -> bool {
127        let inner = self.inner.read().expect("record cache poisoned");
128        inner.records.is_empty()
129    }
130
131    /// Return the number of records currently cached.
132    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
189/// Coordinates on-demand source refresh and per-source-cache population.
190pub struct IngestionManager {
191    sources: Vec<SourceState>,
192    max_records: usize,
193    sampler_config: SamplerConfig,
194    /// Current epoch used to vary per-source permutation seeds across epochs.
195    epoch: u64,
196    /// Monotonic generation incremented whenever at least one source is refreshed.
197    source_refresh_generation: u64,
198    /// Source ids refreshed during the most recent `refresh_all_internal` call.
199    ///
200    /// This is updated even when cache ingest does not change, and is cleared when
201    /// no source refresh occurs in that cycle.
202    last_refreshed_sources: Vec<SourceId>,
203    /// Rotating start index for the round-robin buffer drain.  Instead of always
204    /// draining from source 0 (which starves high-index sources of refresh
205    /// opportunities), each cycle begins at this position and advances by one.
206    /// Over N cycles every source is drained first exactly once.
207    drain_start: usize,
208    /// Monotonic step counter incremented on every advance/refresh_all and set
209    /// on SourceCursor.step so all sources can observe per-call progress.
210    epoch_step: u64,
211}
212
213#[derive(Clone, Debug, Default)]
214/// Last-refresh telemetry captured per source.
215pub struct SourceRefreshStats {
216    /// Duration of the most recent refresh in milliseconds.
217    pub last_refresh_ms: u128,
218    /// Number of records returned by the most recent refresh.
219    pub last_record_count: usize,
220    /// Throughput estimate from the most recent refresh.
221    pub last_records_per_sec: f64,
222    /// Last refresh error message, if any.
223    pub last_error: Option<String>,
224    /// Total refresh failures seen for this source.
225    pub error_count: u64,
226}
227
228impl IngestionManager {
229    /// Create a new ingestion manager that ingests on demand.
230    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    /// Return a monotonic generation for source refresh cycles.
244    pub fn source_refresh_generation(&self) -> u64 {
245        self.source_refresh_generation
246    }
247
248    /// Return source ids refreshed by the most recent refresh cycle.
249    pub fn last_refreshed_sources(&self) -> &[SourceId] {
250        &self.last_refreshed_sources
251    }
252
253    /// Update the current epoch value so subsequent `refresh` calls pass
254    /// `seed ^ epoch` to sources, producing a different permutation.
255    /// Stream cursors are intentionally NOT reset here — the cursor is a raw
256    /// I/O offset into the source's stream and must continue advancing so
257    /// every record is eventually fetched (resetting it would repeat the
258    /// leading slice of the source on every epoch boundary).
259    pub(crate) fn set_epoch(&mut self, epoch: u64) {
260        self.epoch = epoch;
261    }
262
263    /// Reset epoch step counter to 0 (called at epoch boundaries).
264    /// The step counter is a sub-counter within an epoch: each epoch starts
265    /// with step=0 so that batch calls produce deterministic seeds.
266    pub(crate) fn reset_epoch_step(&mut self) {
267        self.epoch_step = 0;
268    }
269
270    /// Increment epoch step counter by 1. Called once per `next_*_batch` call so that
271    /// the epoch step tracks model training steps, not ingestion refresh events.
272    pub(crate) fn increment_epoch_step(&mut self) {
273        self.epoch_step = self.epoch_step.saturating_add(1);
274    }
275
276    /// Return the current epoch step counter.
277    pub fn epoch_step(&self) -> u64 {
278        self.epoch_step
279    }
280
281    /// Set the epoch step counter directly (used when restoring persisted state).
282    pub(crate) fn set_epoch_step(&mut self, step: u64) {
283        self.epoch_step = step;
284    }
285
286    /// Return the current epoch.
287    #[cfg(test)]
288    pub fn epoch(&self) -> u64 {
289        self.epoch
290    }
291
292    /// Reset all raw source stream cursors and drain per-source buffers.
293    ///
294    /// Use this only when starting a deterministic replay from a specific
295    /// epoch (e.g. explicit `set_epoch` calls). A clean-start reset ensures
296    /// the new permutation begins at position 0 of the permuted index space.
297    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    /// Register a source for on-demand ingestion.
306    ///
307    /// Returns an error if the source's `id()` matches the reserved `__*__`
308    /// pattern used for internal synthetic/metadata source identifiers.
309    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    /// Load persisted per-source stream cursors.
329    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    /// Snapshot current per-source stream cursors.
348    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    /// Return latest refresh telemetry for each registered source.
359    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    /// Return a flat snapshot of every record currently in all per-source caches.
367    ///
368    /// Records are cloned in source order; the `source` field is guaranteed
369    /// to be set (it is normalised in `refresh_all_internal`).
370    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    /// Returns `true` when ALL per-source caches are empty.
378    pub fn all_caches_empty(&self) -> bool {
379        self.sources.iter().all(|s| s.cache.is_empty())
380    }
381
382    /// Returns the total number of records across all per-source caches.
383    pub fn all_records_len(&self) -> usize {
384        self.sources.iter().map(|s| s.cache.len()).sum()
385    }
386
387    /// Returns the sum of ingest counts across all per-source caches.
388    ///
389    /// Used as a monotonic proxy to detect whether any cache has been updated
390    /// since the last sync.
391    pub fn total_ingest_count(&self) -> u64 {
392        self.sources.iter().map(|s| s.cache.ingest_count()).sum()
393    }
394
395    /// Refresh all registered sources once.
396    pub fn refresh_all(&mut self) {
397        self.refresh_all_internal(false, None, None);
398    }
399
400    /// Advance the ingestion window by ingesting `step` new records.
401    pub fn advance(&mut self, step: usize) {
402        self.refresh_all_internal(false, Some(step), None);
403    }
404
405    /// Advance the ingestion window by ingesting `step` new records with weights.
406    ///
407    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
408    /// source ID or a negative value.
409    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    /// Force refresh all registered sources, discarding buffered records.
420    pub fn force_refresh_all(&mut self) {
421        self.refresh_all_internal(true, None, None);
422    }
423
424    /// Refresh all registered sources once with per-call source weights.
425    ///
426    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
427    /// source ID or a negative value.
428    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    /// Force refresh all registered sources with per-call source weights.
438    ///
439    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
440    /// source ID or a negative value.
441    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    /// Rebuild the shared cache by round-robin draining per-source buffers.
471    ///
472    /// When `force_refresh` is false, each source only refreshes when its buffer
473    /// is empty; when true, all buffers are cleared and all sources refresh.
474    /// If `step` is provided, performs a rolling update of `step` records (no clear).
475    /// If `step` is None, clears the cache and fills up to max capacity.
476    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                            // XOR the source epoch into the seed so each epoch
518                            // produces a distinct permutation within the source.
519                            // XOR the step counter into the seed so every
520                            // advance/refresh call gets a distinct seed, which
521                            // sources can use for e.g. shard ordering.
522                            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        // On a full refresh (step=None) clear every per-source cache so that the
613        // snapshot always reflects the newest window, matching the previous
614        // shared-cache clear semantics.
615        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            // Fair round-robin drain: start from `drain_start` instead of 0 so
628            // that the drain cursor rotates across cycles.  This prevents head
629            // sources (low indices) from always draining faster than tail sources,
630            // which was starving tail sources of refresh opportunities.
631            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                // Advance the drain cursor so the next cycle starts from a different
651                // position.  Only advance when at least one record was drained, so a
652                // burst of drain-noop cycles on an empty source list doesn't rotate.
653                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            // Rotating tie-breaker: when scores are equal, prefer the source
704            // just PAST drain_start in rotation order (i.e. the source whose
705            // turn is coming up in the round-robin).
706            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    /// Returns `true` when at least one source is registered.
755    pub fn has_sources(&self) -> bool {
756        !self.sources.is_empty()
757    }
758}
759
760/// Per-source ingestion runtime state.
761struct SourceState {
762    source: Box<dyn DataSource + 'static>,
763    cursor: Option<SourceCursor>,
764    buffer: VecDeque<DataRecord>,
765    /// Per-source LRU record cache capped at `max_records`.
766    cache: RecordCache,
767    stats: SourceRefreshStats,
768}
769
770#[cfg(test)]
771// The unweighted batch-fetch convenience methods (`next_*_batch`,
772// `next_*_batch_for_split`, `prefetch_*_batches`) are deprecated in favor of the
773// `*_with_weights` variants. These tests intentionally exercise the unweighted
774// path, so suppress the deprecation lint here.
775#[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    /// A source that records the `config.seed` value it receives on each `refresh()` call.
1255    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        // Verify that derive_epoch_seed(base, epoch) is actually threaded through to
1305        // the source's refresh() call, and that different epochs produce different seeds.
1306        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        // --- epoch 0 ---
1316        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        // epoch defaults to 0; refresh_all passes derive_epoch_seed(base, 0)
1324        manager.refresh_all();
1325
1326        // --- epoch 1 ---
1327        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        // The seeds must differ — epoch XOR has a real effect.
1347        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        // They must match the expected derive_epoch_seed values.
1354        // epoch_step stays at 0 for direct refresh_all calls
1355        // (it's only bumped by next_*_batch calls).
1356        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        // Proves that set_epoch + reset_epoch_step gives epoch_step=0 so each
1371        // epoch produces the same step sequence starting from step 1.
1372        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        // Epoch 0, first refresh: epoch_step stays at 0 (no longer
1384        // incremented by refresh_all — it's bumped per batch call instead).
1385        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        // Advance epoch — epoch_step must stay 0 (no batch calls yet).
1394        manager.set_epoch(1);
1395        assert_eq!(manager.epoch_step(), 0);
1396        seeds.lock().unwrap().clear();
1397
1398        // Epoch 1, first refresh: step stays at 0 (no batch calls in this test).
1399        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        // Proves the epoch step survives through the REAL API path:
1411        //   TripletSampler::save_sampler_state → persist_source_state
1412        //   → self.ingestion.snapshot_cursors() internally
1413        //   → DeterministicSplitStore.save_sampler_state
1414        //   → load_sampler_state → load_cursors
1415        let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1416
1417        // Sampler with ScriptedSource (provides empty recipes but that's fine)
1418        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        // Call a batch method to trigger ensure_source_state() so
1441        // save_sampler_state actually writes.  The batch itself may
1442        // exhaust (no recipes) but the state is loaded regardless.
1443        let _ = sampler.next_text_batch(SplitLabel::Train);
1444
1445        // Save through the REAL sampler API
1446        sampler.save_sampler_state(None).unwrap();
1447
1448        // Load back what the sampler saved
1449        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        // Feed the REAL loaded cursors to a new manager
1457        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        // epoch_step restored to saved value; verify it's stable (no batch
1475        // call was made, so step does not change).
1476        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        // Verify the step is still present after a refresh (no increment).
1483        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        // Empty script falls back to an empty snapshot.
1498        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        // Weighted refresh with no sources should be a no-op.
1555        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        // Create 3 sources, each with 10 records in their buffer after refresh.
1566        // The fair round-robin should ensure all 3 drain at the same rate
1567        // over multiple advance cycles.
1568        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        // First refresh_all fills all buffers.
1626        manager.refresh_all();
1627        // All 3 refreshed once.
1628        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        // Each advance(1) drains 1 record from 1 source, rotating via
1633        // drain_start.  With 3 sources, after 3 advances each source
1634        // loses 1 record.  After 30 advances each source loses 10 records
1635        // and triggers a refresh.  Run 33 advances and check all 3 refreshed
1636        // roughly the same number of times.
1637        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        // Each had 10 records after initial refresh_all.
1646        // 10 records / (1 drained per 3 cycles) = 30 cycles to drain each buffer.
1647        // After 33 more advances each buffer emptied ~1 time and re-filled, so
1648        // each source should have refreshed ~2 times total (initial + 1 drain).
1649        // The exact count can vary by 1 due to timing, but all 3 must be within
1650        // 1 of each other — no source can be starved.
1651        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        // Direct IngestionManager test with batch_size=2, 5 sources.
1662        // Isolates the drain_start rotation from the sampler pipeline.
1663        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        // Round-robin drain with 5 sources, 8 records each, batch_size=2, 80
1728        // advance calls.  Most sources refresh 5×; sources 2-4 get one extra
1729        // cycle (6) due to the rotating drain_start tie-breaker positioning.
1730        assert_eq!(
1731            totals,
1732            vec![5, 5, 6, 6, 6],
1733            "direct manager: unexpected refresh distribution"
1734        );
1735    }
1736
1737    /// Helper: create 5 sources with 8 records each and a sampler configured for
1738    /// text batches with batch_size=2.  Returns the sampler and refresh counters.
1739    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            // Small enough that the cache slides on every advance, changing
1804            // the record pool each batch.  Without this, all 40 records fit in
1805            // the cache permanently and the cross-batch text dedup would never
1806            // clear `emitted_texts`, causing early Exhausted after only a few
1807            // batches (only 8 unique texts across 40 records).
1808            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        // Same as unweighted but goes through the public weighted API with equal
1851        // weights for all 5 sources — verifies the weighted drain also rotates
1852        // fairly via drain_start bias.
1853        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        // Verify both paths produce similar refresh distributions.
1883        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        // Source 3 gets weight 2.0, others get 1.0.  The weighted
1914        // proportional-fair drain must give source 3 proportionally more
1915        // refresh cycles than any weight-1.0 source.
1916        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        // The highest-weight source (src_3, w=2.0) must have strictly more
1935        // refreshes than EVERY weight-1.0 source.
1936        // Note: src_4 (index 4, w=1.0) gets 12 refreshes while the other
1937        // weight-1.0 sources get 6-7.  This happens because the proportional-
1938        // fair scheduler uses drain_start as a rotating tie-breaker — being
1939        // positioned right after the high-weight source (src_3) gives src_4
1940        // extra wins when src_3's buffer empties and refills more frequently.
1941        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        // Lock in the exact deterministic distribution.
1949        // (Distribution changed after the cross-batch dedup fix: with proper
1950        // per-record hash tracking, the sampler exhausts and force-refreshes
1951        // more often, which increases the total number of refresh cycles but
1952        // preserves the weighted distribution pattern. The key invariant
1953        // (src_3 with w=2.0 outpaces all w=1.0 sources) is unchanged.)
1954        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        // Verify the utility function catches common patterns
1968        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        // Registering with a `__*__` id should fail
1980        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        // Verify source was NOT registered (still zero sources)
1996        assert!(!manager.has_sources());
1997
1998        // Normal source IDs still work
1999        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}