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)]
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    /// A source that records the `config.seed` value it receives on each `refresh()` call.
1250    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        // Verify that derive_epoch_seed(base, epoch) is actually threaded through to
1300        // the source's refresh() call, and that different epochs produce different seeds.
1301        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        // --- epoch 0 ---
1311        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        // epoch defaults to 0; refresh_all passes derive_epoch_seed(base, 0)
1319        manager.refresh_all();
1320
1321        // --- epoch 1 ---
1322        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        // The seeds must differ — epoch XOR has a real effect.
1342        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        // They must match the expected derive_epoch_seed values.
1349        // epoch_step stays at 0 for direct refresh_all calls
1350        // (it's only bumped by next_*_batch calls).
1351        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        // Proves that set_epoch + reset_epoch_step gives epoch_step=0 so each
1366        // epoch produces the same step sequence starting from step 1.
1367        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        // Epoch 0, first refresh: epoch_step stays at 0 (no longer
1379        // incremented by refresh_all — it's bumped per batch call instead).
1380        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        // Advance epoch — epoch_step must stay 0 (no batch calls yet).
1389        manager.set_epoch(1);
1390        assert_eq!(manager.epoch_step(), 0);
1391        seeds.lock().unwrap().clear();
1392
1393        // Epoch 1, first refresh: step stays at 0 (no batch calls in this test).
1394        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        // Proves the epoch step survives through the REAL API path:
1406        //   TripletSampler::save_sampler_state → persist_source_state
1407        //   → self.ingestion.snapshot_cursors() internally
1408        //   → DeterministicSplitStore.save_sampler_state
1409        //   → load_sampler_state → load_cursors
1410        let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1411
1412        // Sampler with ScriptedSource (provides empty recipes but that's fine)
1413        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        // Call a batch method to trigger ensure_source_state() so
1436        // save_sampler_state actually writes.  The batch itself may
1437        // exhaust (no recipes) but the state is loaded regardless.
1438        let _ = sampler.next_text_batch(SplitLabel::Train);
1439
1440        // Save through the REAL sampler API
1441        sampler.save_sampler_state(None).unwrap();
1442
1443        // Load back what the sampler saved
1444        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        // Feed the REAL loaded cursors to a new manager
1452        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        // epoch_step restored to saved value; verify it's stable (no batch
1470        // call was made, so step does not change).
1471        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        // Verify the step is still present after a refresh (no increment).
1478        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        // Empty script falls back to an empty snapshot.
1493        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        // Weighted refresh with no sources should be a no-op.
1550        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        // Create 3 sources, each with 10 records in their buffer after refresh.
1561        // The fair round-robin should ensure all 3 drain at the same rate
1562        // over multiple advance cycles.
1563        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        // First refresh_all fills all buffers.
1621        manager.refresh_all();
1622        // All 3 refreshed once.
1623        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        // Each advance(1) drains 1 record from 1 source, rotating via
1628        // drain_start.  With 3 sources, after 3 advances each source
1629        // loses 1 record.  After 30 advances each source loses 10 records
1630        // and triggers a refresh.  Run 33 advances and check all 3 refreshed
1631        // roughly the same number of times.
1632        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        // Each had 10 records after initial refresh_all.
1641        // 10 records / (1 drained per 3 cycles) = 30 cycles to drain each buffer.
1642        // After 33 more advances each buffer emptied ~1 time and re-filled, so
1643        // each source should have refreshed ~2 times total (initial + 1 drain).
1644        // The exact count can vary by 1 due to timing, but all 3 must be within
1645        // 1 of each other — no source can be starved.
1646        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        // Direct IngestionManager test with batch_size=2, 5 sources.
1657        // Isolates the drain_start rotation from the sampler pipeline.
1658        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        // Round-robin drain with 5 sources, 8 records each, batch_size=2, 80
1723        // advance calls.  Most sources refresh 5×; sources 2-4 get one extra
1724        // cycle (6) due to the rotating drain_start tie-breaker positioning.
1725        assert_eq!(
1726            totals,
1727            vec![5, 5, 6, 6, 6],
1728            "direct manager: unexpected refresh distribution"
1729        );
1730    }
1731
1732    /// Helper: create 5 sources with 8 records each and a sampler configured for
1733    /// text batches with batch_size=2.  Returns the sampler and refresh counters.
1734    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            // Small enough that the cache slides on every advance, changing
1799            // the record pool each batch.  Without this, all 40 records fit in
1800            // the cache permanently and the cross-batch text dedup would never
1801            // clear `emitted_texts`, causing early Exhausted after only a few
1802            // batches (only 8 unique texts across 40 records).
1803            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        // Same as unweighted but goes through the public weighted API with equal
1846        // weights for all 5 sources — verifies the weighted drain also rotates
1847        // fairly via drain_start bias.
1848        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        // Verify both paths produce similar refresh distributions.
1878        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        // Source 3 gets weight 2.0, others get 1.0.  The weighted
1909        // proportional-fair drain must give source 3 proportionally more
1910        // refresh cycles than any weight-1.0 source.
1911        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        // The highest-weight source (src_3, w=2.0) must have strictly more
1930        // refreshes than EVERY weight-1.0 source.
1931        // Note: src_4 (index 4, w=1.0) gets 12 refreshes while the other
1932        // weight-1.0 sources get 6-7.  This happens because the proportional-
1933        // fair scheduler uses drain_start as a rotating tie-breaker — being
1934        // positioned right after the high-weight source (src_3) gives src_4
1935        // extra wins when src_3's buffer empties and refills more frequently.
1936        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        // Lock in the exact deterministic distribution.
1944        // (Distribution changed after the cross-batch dedup fix: with proper
1945        // per-record hash tracking, the sampler exhausts and force-refreshes
1946        // more often, which increases the total number of refresh cycles but
1947        // preserves the weighted distribution pattern. The key invariant
1948        // (src_3 with w=2.0 outpaces all w=1.0 sources) is unchanged.)
1949        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        // Verify the utility function catches common patterns
1963        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        // Registering with a `__*__` id should fail
1975        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        // Verify source was NOT registered (still zero sources)
1991        assert!(!manager.has_sources());
1992
1993        // Normal source IDs still work
1994        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}