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    /// Log of evicted record IDs with their version numbers for delta sync.
31    eviction_log: VecDeque<(u64, RecordId)>,
32}
33
34/// Internal cache entry plus monotonic version marker.
35struct CachedRecord {
36    record: Arc<DataRecord>,
37    version: u64,
38}
39
40/// Internal ingest notification counters.
41#[derive(Default)]
42struct CacheStats {
43    ingests: u64,
44}
45
46impl RecordCache {
47    /// Create a cache capped to at most `max_records` live records.
48    pub fn new(max_records: usize) -> Self {
49        Self {
50            inner: Arc::new(RwLock::new(RecordCacheInner {
51                records: IndexMap::new(),
52                order: VecDeque::new(),
53                max_records,
54                next_version: 0,
55                eviction_log: VecDeque::new(),
56            })),
57            notifier: Arc::new((Mutex::new(CacheStats::default()), Condvar::new())),
58        }
59    }
60
61    /// Ingest a batch of records, replacing existing entries by id.
62    pub fn ingest<I>(&self, records: I)
63    where
64        I: IntoIterator<Item = DataRecord>,
65    {
66        let mut batch: Vec<DataRecord> = records.into_iter().collect();
67        if batch.is_empty() {
68            return;
69        }
70        let mut inner = self.inner.write().expect("record cache poisoned");
71        inner.ingest_batch(&mut batch);
72        drop(inner);
73        let (lock, cvar) = &*self.notifier;
74        let mut stats = lock.lock().expect("record cache stats poisoned");
75        stats.ingests = stats.ingests.saturating_add(1);
76        cvar.notify_all();
77    }
78
79    /// Ingest a batch of records with an explicit version number from the global counter.
80    pub fn ingest_with_version<I>(&self, records: I, version: u64)
81    where
82        I: IntoIterator<Item = DataRecord>,
83    {
84        let mut batch: Vec<DataRecord> = records.into_iter().collect();
85        if batch.is_empty() {
86            return;
87        }
88        let mut inner = self.inner.write().expect("record cache poisoned");
89        inner.ingest_batch_with_version(&mut batch, version);
90        drop(inner);
91        let (lock, cvar) = &*self.notifier;
92        let mut stats = lock.lock().expect("record cache stats poisoned");
93        stats.ingests = stats.ingests.saturating_add(1);
94        cvar.notify_all();
95    }
96
97    /// Remove all cached records.
98    pub fn clear(&self) {
99        let mut inner = self.inner.write().expect("record cache poisoned");
100        // Log evictions so a subsequent `sync_delta` reports records removed
101        // by the clear (e.g. force-refresh replacing the pool) and the sampler
102        // can prune `emitted_text_hashes` / `split_labels` for IDs that truly
103        // left. The version is bumped so entries survive the
104        // `version > last_version` filter in `sync_delta`; the sampler keeps
105        // state for IDs simultaneously re-added (see `sync_records_from_cache`
106        // evicted-minus-added guard). The log stays bounded at `max_records`
107        // (same bound as `enforce_limit`).
108        if !inner.records.is_empty() {
109            inner.next_version = inner.next_version.saturating_add(1);
110            let version = inner.next_version;
111            let evicted: Vec<RecordId> = inner.records.keys().cloned().collect();
112            for id in evicted {
113                inner.eviction_log.push_back((version, id));
114            }
115            while inner.eviction_log.len() > inner.max_records {
116                inner.eviction_log.pop_front();
117            }
118        }
119        inner.records.clear();
120        inner.order.clear();
121    }
122
123    /// Return a snapshot of current cached records as Arc references (cheap clone).
124    pub fn snapshot(&self) -> Vec<Arc<DataRecord>> {
125        let inner = self.inner.read().expect("record cache poisoned");
126        inner
127            .records
128            .values()
129            .map(|entry| Arc::clone(&entry.record))
130            .collect()
131    }
132
133    /// Return the number of completed ingest operations.
134    pub fn ingest_count(&self) -> u64 {
135        let (lock, _) = &*self.notifier;
136        lock.lock().expect("record cache stats poisoned").ingests
137    }
138
139    /// Wait until ingest count exceeds `last_seen`, or until timeout elapses.
140    pub fn wait_for_ingest(&self, last_seen: u64, timeout: Duration) -> u64 {
141        let (lock, cvar) = &*self.notifier;
142        let mut stats = lock.lock().expect("record cache stats poisoned");
143        while stats.ingests <= last_seen {
144            let result = cvar
145                .wait_timeout(stats, timeout)
146                .expect("record cache stats poisoned");
147            stats = result.0;
148            if result.1.timed_out() {
149                break;
150            }
151        }
152        stats.ingests
153    }
154
155    /// Wait indefinitely until ingest count exceeds `last_seen`.
156    pub fn wait_for_ingest_blocking(&self, last_seen: u64) -> u64 {
157        let (lock, cvar) = &*self.notifier;
158        let mut stats = lock.lock().expect("record cache stats poisoned");
159        while stats.ingests <= last_seen {
160            stats = cvar.wait(stats).expect("record cache stats poisoned");
161        }
162        stats.ingests
163    }
164
165    /// Returns `true` when the cache has no records.
166    pub fn is_empty(&self) -> bool {
167        let inner = self.inner.read().expect("record cache poisoned");
168        inner.records.is_empty()
169    }
170
171    /// Return the number of records currently cached.
172    pub fn len(&self) -> usize {
173        let inner = self.inner.read().expect("record cache poisoned");
174        inner.records.len()
175    }
176
177    /// Return a delta of records added since `last_version` and record IDs evicted since `last_version`.
178    pub fn sync_delta(&self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
179        let mut inner = self.inner.write().expect("record cache poisoned");
180        inner.sync_delta(last_version)
181    }
182}
183
184impl RecordCacheInner {
185    fn ingest_batch(&mut self, records: &mut Vec<DataRecord>) {
186        for record in records.drain(..) {
187            self.next_version = self.next_version.saturating_add(1);
188            let record_id = record.id.clone();
189            if self.records.contains_key(&record_id) {
190                if let Some(entry) = self.records.get_mut(&record_id) {
191                    entry.record = Arc::new(record);
192                    entry.version = self.next_version;
193                }
194                Self::refresh_order(&mut self.order, &record_id);
195                self.order.push_back(record_id);
196            } else {
197                self.order.push_back(record_id.clone());
198                self.records.insert(
199                    record_id,
200                    CachedRecord {
201                        record: Arc::new(record),
202                        version: self.next_version,
203                    },
204                );
205            }
206            self.enforce_limit();
207        }
208    }
209
210    fn ingest_batch_with_version(&mut self, records: &mut Vec<DataRecord>, version: u64) {
211        for record in records.drain(..) {
212            self.next_version = version;
213            let record_id = record.id.clone();
214            if self.records.contains_key(&record_id) {
215                if let Some(entry) = self.records.get_mut(&record_id) {
216                    entry.record = Arc::new(record);
217                    entry.version = version;
218                }
219                Self::refresh_order(&mut self.order, &record_id);
220                self.order.push_back(record_id);
221            } else {
222                self.order.push_back(record_id.clone());
223                self.records.insert(
224                    record_id,
225                    CachedRecord {
226                        record: Arc::new(record),
227                        version,
228                    },
229                );
230            }
231            self.enforce_limit();
232        }
233    }
234
235    fn enforce_limit(&mut self) {
236        if self.max_records == 0 {
237            self.records.clear();
238            self.order.clear();
239            return;
240        }
241        while self.records.len() > self.max_records {
242            if let Some(oldest) = self.order.pop_front() {
243                self.records.swap_remove(&oldest);
244                // Log the eviction for delta sync
245                self.eviction_log.push_back((self.next_version, oldest));
246                // Bound eviction_log to max_records to prevent unbounded growth
247                while self.eviction_log.len() > self.max_records {
248                    self.eviction_log.pop_front();
249                }
250            } else {
251                break;
252            }
253        }
254    }
255
256    fn refresh_order(order: &mut VecDeque<RecordId>, id: &RecordId) {
257        if order.is_empty() {
258            return;
259        }
260        if let Some(pos) = order.iter().position(|existing| existing == id) {
261            order.remove(pos);
262        }
263    }
264
265    /// Return a delta of records added since `last_version` and record IDs evicted since `last_version`.
266    fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
267        // Drain eviction log entries where version <= last_version.
268        // This preserves entries for version watermarks that haven't synced yet.
269        self.eviction_log.retain(|&(v, _)| v > last_version);
270
271        // Collect added records (version > last_version)
272        let added: Vec<Arc<DataRecord>> = self
273            .records
274            .values()
275            .filter(|entry| entry.version > last_version)
276            .map(|entry| Arc::clone(&entry.record))
277            .collect();
278
279        // Collect evicted IDs since last_version
280        let evicted: Vec<RecordId> = self.eviction_log.iter().map(|(_, id)| id.clone()).collect();
281
282        // Do NOT clear eviction_log here — retain entries for watermarks
283        // that haven't called sync_delta yet. enforce_limit() handles
284        // bounding the log size.
285
286        (self.next_version, added, evicted)
287    }
288}
289
290/// Coordinates on-demand source refresh and per-source-cache population.
291pub struct IngestionManager {
292    sources: Vec<SourceState>,
293    max_records: usize,
294    sampler_config: SamplerConfig,
295    /// Current epoch used to vary per-source permutation seeds across epochs.
296    epoch: u64,
297    /// Monotonic generation incremented whenever at least one source is refreshed.
298    source_refresh_generation: u64,
299    /// Source ids refreshed during the most recent `refresh_all_internal` call.
300    ///
301    /// This is updated even when cache ingest does not change, and is cleared when
302    /// no source refresh occurs in that cycle.
303    last_refreshed_sources: Vec<SourceId>,
304    /// Rotating start index for the round-robin buffer drain.  Instead of always
305    /// draining from source 0 (which starves high-index sources of refresh
306    /// opportunities), each cycle begins at this position and advances by one.
307    /// Over N cycles every source is drained first exactly once.
308    drain_start: usize,
309    /// Monotonic step counter incremented on every advance/refresh_all and set
310    /// on SourceCursor.step so all sources can observe per-call progress.
311    epoch_step: u64,
312    /// Global monotonic version counter shared across all source caches.
313    /// Ensures a single, monotonic version timeline across all registered sources.
314    global_next_version: u64,
315    /// Monotonic generation incremented on every force-refresh cycle (cache
316    /// clear + refill). The sampler watches this to fall back to a full
317    /// record-pool resync after force refreshes, restoring exact full-snapshot
318    /// semantics on that path while steady-state advances keep the O(Δ) delta
319    /// fast path. Reset-type clears that do NOT refill (e.g. epoch stream
320    /// cursor resets) do not touch this counter.
321    force_refresh_generation: u64,
322}
323
324#[derive(Clone, Debug, Default)]
325/// Last-refresh telemetry captured per source.
326pub struct SourceRefreshStats {
327    /// Duration of the most recent refresh in milliseconds.
328    pub last_refresh_ms: u128,
329    /// Number of records returned by the most recent refresh.
330    pub last_record_count: usize,
331    /// Throughput estimate from the most recent refresh.
332    pub last_records_per_sec: f64,
333    /// Last refresh error message, if any.
334    pub last_error: Option<String>,
335    /// Total refresh failures seen for this source.
336    pub error_count: u64,
337}
338
339impl IngestionManager {
340    /// Create a new ingestion manager that ingests on demand.
341    pub fn new(max_records: usize, sampler_config: SamplerConfig) -> Self {
342        Self {
343            sources: Vec::new(),
344            max_records,
345            sampler_config,
346            epoch: 0,
347            source_refresh_generation: 0,
348            last_refreshed_sources: Vec::new(),
349            drain_start: 0,
350            epoch_step: 0,
351            global_next_version: 0,
352            force_refresh_generation: 0,
353        }
354    }
355
356    /// Return a monotonic generation for source refresh cycles.
357    pub fn source_refresh_generation(&self) -> u64 {
358        self.source_refresh_generation
359    }
360
361    /// Return the force-refresh generation. Incremented once per
362    /// force-refresh cycle; see the field docs.
363    pub fn force_refresh_generation(&self) -> u64 {
364        self.force_refresh_generation
365    }
366
367    /// Return source ids refreshed by the most recent refresh cycle.
368    pub fn last_refreshed_sources(&self) -> &[SourceId] {
369        &self.last_refreshed_sources
370    }
371
372    /// Update the current epoch value so subsequent `refresh` calls pass
373    /// `seed ^ epoch` to sources, producing a different permutation.
374    /// Stream cursors are intentionally NOT reset here — the cursor is a raw
375    /// I/O offset into the source's stream and must continue advancing so
376    /// every record is eventually fetched (resetting it would repeat the
377    /// leading slice of the source on every epoch boundary).
378    pub(crate) fn set_epoch(&mut self, epoch: u64) {
379        self.epoch = epoch;
380    }
381
382    /// Reset epoch step counter to 0 (called at epoch boundaries).
383    /// The step counter is a sub-counter within an epoch: each epoch starts
384    /// with step=0 so that batch calls produce deterministic seeds.
385    pub(crate) fn reset_epoch_step(&mut self) {
386        self.epoch_step = 0;
387    }
388
389    /// Increment epoch step counter by 1. Called once per `next_*_batch` call so that
390    /// the epoch step tracks model training steps, not ingestion refresh events.
391    pub(crate) fn increment_epoch_step(&mut self) {
392        self.epoch_step = self.epoch_step.saturating_add(1);
393    }
394
395    /// Return the current epoch step counter.
396    pub fn epoch_step(&self) -> u64 {
397        self.epoch_step
398    }
399
400    /// Set the epoch step counter directly (used when restoring persisted state).
401    pub(crate) fn set_epoch_step(&mut self, step: u64) {
402        self.epoch_step = step;
403    }
404
405    /// Return the current epoch.
406    #[cfg(test)]
407    pub fn epoch(&self) -> u64 {
408        self.epoch
409    }
410
411    /// Reset all raw source stream cursors and drain per-source buffers.
412    ///
413    /// Use this only when starting a deterministic replay from a specific
414    /// epoch (e.g. explicit `set_epoch` calls). A clean-start reset ensures
415    /// the new permutation begins at position 0 of the permuted index space.
416    pub(crate) fn reset_stream_cursors(&mut self) {
417        for state in &mut self.sources {
418            state.cursor = None;
419            state.buffer.clear();
420            state.cache.clear();
421        }
422    }
423
424    /// Register a source for on-demand ingestion.
425    ///
426    /// Returns an error if the source's `id()` matches the reserved `__*__`
427    /// pattern used for internal synthetic/metadata source identifiers.
428    pub fn register_source(
429        &mut self,
430        source: Box<dyn DataSource + 'static>,
431    ) -> Result<(), SamplerError> {
432        let source_id = source.id().to_string();
433        if is_reserved_source_id(&source_id) {
434            return Err(SamplerError::ReservedSourceId(source_id));
435        }
436        let cache = RecordCache::new(self.max_records);
437        self.sources.push(SourceState {
438            source,
439            cursor: None,
440            buffer: VecDeque::new(),
441            cache,
442            stats: SourceRefreshStats::default(),
443        });
444        Ok(())
445    }
446
447    /// Load persisted per-source stream cursors.
448    pub fn load_cursors(&mut self, cursors: &[(SourceId, u64)]) {
449        if cursors.is_empty() {
450            return;
451        }
452        let mut map = std::collections::HashMap::with_capacity(cursors.len());
453        for (id, revision) in cursors {
454            map.insert(id.as_str(), *revision);
455        }
456        for state in &mut self.sources {
457            if let Some(revision) = map.get(state.source.id()) {
458                state.cursor = Some(SourceCursor {
459                    last_seen: Utc::now(),
460                    revision: *revision,
461                });
462            }
463        }
464    }
465
466    /// Snapshot current per-source stream cursors.
467    pub fn snapshot_cursors(&self) -> Vec<(SourceId, u64)> {
468        let mut out = Vec::new();
469        for state in &self.sources {
470            if let Some(cursor) = state.cursor.as_ref() {
471                out.push((state.source.id().to_string(), cursor.revision));
472            }
473        }
474        out
475    }
476
477    /// Return latest refresh telemetry for each registered source.
478    pub fn source_refresh_stats(&self) -> Vec<(SourceId, SourceRefreshStats)> {
479        self.sources
480            .iter()
481            .map(|state| (state.source.id().to_string(), state.stats.clone()))
482            .collect()
483    }
484
485    /// Return a flat snapshot of every record currently in all per-source caches.
486    ///
487    /// Records are returned as Arc references (cheap clone); the `source` field
488    /// is guaranteed to be set (it is normalised in `refresh_all_internal`).
489    pub fn all_records_snapshot(&self) -> Vec<Arc<DataRecord>> {
490        self.sources
491            .iter()
492            .flat_map(|s| s.cache.snapshot())
493            .collect()
494    }
495
496    /// Returns `true` when ALL per-source caches are empty.
497    pub fn all_caches_empty(&self) -> bool {
498        self.sources.iter().all(|s| s.cache.is_empty())
499    }
500
501    /// Returns the total number of records across all per-source caches.
502    pub fn all_records_len(&self) -> usize {
503        self.sources.iter().map(|s| s.cache.len()).sum()
504    }
505
506    /// Returns the sum of ingest counts across all per-source caches.
507    ///
508    /// Used as a monotonic proxy to detect whether any cache has been updated
509    /// since the last sync.
510    pub fn total_ingest_count(&self) -> u64 {
511        self.sources.iter().map(|s| s.cache.ingest_count()).sum()
512    }
513
514    /// Refresh all registered sources once.
515    pub fn refresh_all(&mut self) {
516        self.refresh_all_internal(false, None, None);
517    }
518
519    /// Advance the ingestion window by ingesting `step` new records.
520    pub fn advance(&mut self, step: usize) {
521        self.refresh_all_internal(false, Some(step), None);
522    }
523
524    /// Advance the ingestion window by ingesting `step` new records with weights.
525    ///
526    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
527    /// source ID or a negative value.
528    pub fn advance_with_weights(
529        &mut self,
530        step: usize,
531        weights: &HashMap<SourceId, f32>,
532    ) -> Result<(), SamplerError> {
533        self.validate_weights(weights)?;
534        self.refresh_all_internal(false, Some(step), Some(weights));
535        Ok(())
536    }
537
538    /// Force refresh all registered sources, discarding buffered records.
539    pub fn force_refresh_all(&mut self) {
540        self.refresh_all_internal(true, None, None);
541    }
542
543    /// Refresh all registered sources once with per-call source weights.
544    ///
545    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
546    /// source ID or a negative value.
547    pub fn refresh_all_with_weights(
548        &mut self,
549        weights: &HashMap<SourceId, f32>,
550    ) -> Result<(), SamplerError> {
551        self.validate_weights(weights)?;
552        self.refresh_all_internal(false, None, Some(weights));
553        Ok(())
554    }
555
556    /// Force refresh all registered sources with per-call source weights.
557    ///
558    /// Returns `Err(SamplerError::InvalidWeight)` if `weights` contains an unregistered
559    /// source ID or a negative value.
560    pub fn force_refresh_all_with_weights(
561        &mut self,
562        weights: &HashMap<SourceId, f32>,
563    ) -> Result<(), SamplerError> {
564        self.validate_weights(weights)?;
565        self.refresh_all_internal(true, None, Some(weights));
566        Ok(())
567    }
568
569    fn validate_weights(&self, weights: &HashMap<SourceId, f32>) -> Result<(), SamplerError> {
570        let known_ids: std::collections::HashSet<&str> =
571            self.sources.iter().map(|s| s.source.id()).collect();
572        for (id, &w) in weights {
573            if !known_ids.contains(id.as_str()) {
574                return Err(SamplerError::InvalidWeight {
575                    source_id: id.clone(),
576                    reason: "source is not registered".to_string(),
577                });
578            }
579            if w < 0.0 {
580                return Err(SamplerError::InvalidWeight {
581                    source_id: id.clone(),
582                    reason: format!("weight {w} is negative"),
583                });
584            }
585        }
586        Ok(())
587    }
588
589    /// Rebuild the shared cache by round-robin draining per-source buffers.
590    ///
591    /// When `force_refresh` is false, each source only refreshes when its buffer
592    /// is empty; when true, all buffers are cleared and all sources refresh.
593    /// If `step` is provided, performs a rolling update of `step` records (no clear).
594    /// If `step` is None, clears the cache and fills up to max capacity.
595    fn refresh_all_internal(
596        &mut self,
597        force_refresh: bool,
598        step: Option<usize>,
599        weights: Option<&HashMap<SourceId, f32>>,
600    ) {
601        self.last_refreshed_sources.clear();
602        if force_refresh {
603            // Bump before clearing/refilling so a subsequent delta sync can
604            // observe that a full pool replacement happened and fall back to
605            // full-resync semantics (see `sync_records_from_cache`).
606            self.force_refresh_generation = self.force_refresh_generation.saturating_add(1);
607        }
608        let mut refresh_plan = Vec::new();
609        for (idx, state) in self.sources.iter_mut().enumerate() {
610            if force_refresh {
611                state.buffer.clear();
612            }
613            if force_refresh || state.buffer.is_empty() {
614                refresh_plan.push((idx, state.cursor.clone()));
615            }
616        }
617
618        if !refresh_plan.is_empty() {
619            self.source_refresh_generation = self.source_refresh_generation.saturating_add(1);
620            self.last_refreshed_sources = refresh_plan
621                .iter()
622                .map(|(idx, _)| self.sources[*idx].source.id().to_string())
623                .collect();
624            let mut results: Vec<
625                Option<(Result<SourceSnapshot, SamplerError>, std::time::Duration)>,
626            > = Vec::with_capacity(self.sources.len());
627            results.resize_with(self.sources.len(), || None);
628            let fetch_limit = self.max_records;
629            let sampler_config = self.sampler_config.clone();
630            let step = self.epoch_step;
631            thread::scope(|scope| {
632                let mut handles = Vec::with_capacity(refresh_plan.len());
633                for (idx, cursor) in &refresh_plan {
634                    let source = &self.sources[*idx].source;
635                    let cursor = cursor.clone();
636                    let sampler_config = sampler_config.clone();
637                    let epoch = self.epoch;
638                    handles.push((
639                        *idx,
640                        scope.spawn(move || {
641                            let start = std::time::Instant::now();
642                            // XOR the source epoch into the seed so each epoch
643                            // produces a distinct permutation within the source.
644                            // XOR the step counter into the seed so every
645                            // advance/refresh call gets a distinct seed, which
646                            // sources can use for e.g. shard ordering.
647                            let epoch_config = SamplerConfig {
648                                seed: derive_epoch_seed(sampler_config.seed, epoch) ^ step,
649                                ..sampler_config
650                            };
651                            let result =
652                                source.refresh(&epoch_config, cursor.as_ref(), Some(fetch_limit));
653                            let elapsed = start.elapsed();
654                            (result, elapsed)
655                        }),
656                    ));
657                }
658                for (idx, handle) in handles {
659                    let result = match handle.join() {
660                        Ok((result, elapsed)) => {
661                            debug!(
662                                source_id = %self.sources[idx].source.id(),
663                                refresh_ms = elapsed.as_millis(),
664                                "source refresh completed"
665                            );
666                            (result, elapsed)
667                        }
668                        Err(_) => (
669                            Err(SamplerError::SourceUnavailable {
670                                source_id: self.sources[idx].source.id().to_string(),
671                                reason: "source refresh thread panicked".into(),
672                            }),
673                            std::time::Duration::from_secs(0),
674                        ),
675                    };
676                    results[idx] = Some(result);
677                }
678            });
679
680            for (idx, _) in refresh_plan {
681                let Some((result, elapsed)) = results[idx].take() else {
682                    continue;
683                };
684                match result {
685                    Ok(snapshot) => {
686                        let SourceSnapshot {
687                            records,
688                            cursor: next_cursor,
689                        } = snapshot;
690                        let record_count = records.len();
691                        let seconds = elapsed.as_secs_f64();
692                        let per_sec = if seconds > 0.0 {
693                            (record_count as f64) / seconds
694                        } else {
695                            0.0
696                        };
697                        let stats = &mut self.sources[idx].stats;
698                        stats.last_refresh_ms = elapsed.as_millis();
699                        stats.last_record_count = record_count;
700                        stats.last_records_per_sec = per_sec;
701                        stats.last_error = None;
702                        debug!(
703                            source_id = %self.sources[idx].source.id(),
704                            record_count,
705                            refresh_ms = elapsed.as_millis(),
706                            records_per_sec = per_sec,
707                            "source refresh ingested records"
708                        );
709                        let source_id = self.sources[idx].source.id().to_string();
710                        let normalized = records
711                            .into_iter()
712                            .map(|mut record| {
713                                record.source = source_id.clone();
714                                record
715                            })
716                            .collect::<Vec<_>>();
717                        self.sources[idx].buffer.extend(normalized);
718                        self.sources[idx].cursor = Some(next_cursor);
719                    }
720                    Err(err) => {
721                        let stats = &mut self.sources[idx].stats;
722                        stats.last_refresh_ms = elapsed.as_millis();
723                        stats.last_record_count = 0;
724                        stats.last_records_per_sec = 0.0;
725                        stats.last_error = Some(err.to_string());
726                        stats.error_count = stats.error_count.saturating_add(1);
727                        warn!(
728                            source = %self.sources[idx].source.id(),
729                            error = %err,
730                            "Source refresh failed"
731                        );
732                    }
733                }
734            }
735        }
736
737        // On a full refresh (step=None) clear every per-source cache so that the
738        // snapshot always reflects the newest window, matching the previous
739        // shared-cache clear semantics.
740        if step.is_none() {
741            for state in self.sources.iter_mut() {
742                state.cache.clear();
743            }
744        }
745        if self.max_records == 0 {
746            return;
747        }
748        let target_limit = step.unwrap_or(self.max_records);
749        if let Some(weights) = weights {
750            self.weighted_drain_into_caches(target_limit, weights);
751        } else {
752            // Fair round-robin drain: start from `drain_start` instead of 0 so
753            // that the drain cursor rotates across cycles.  This prevents head
754            // sources (low indices) from always draining faster than tail sources,
755            // which was starving tail sources of refresh opportunities.
756            let n = self.sources.len();
757            if n > 0 {
758                let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); n];
759                let mut total_drained = 0;
760                let mut any_remaining = true;
761                while total_drained < target_limit && any_remaining {
762                    any_remaining = false;
763                    for offset in 0..n {
764                        if total_drained >= target_limit {
765                            break;
766                        }
767                        let idx = (self.drain_start + offset) % n;
768                        if let Some(record) = self.sources[idx].buffer.pop_front() {
769                            per_source[idx].push(record);
770                            total_drained += 1;
771                            any_remaining = true;
772                        }
773                    }
774                }
775                // Advance the drain cursor so the next cycle starts from a different
776                // position.  Only advance when at least one record was drained, so a
777                // burst of drain-noop cycles on an empty source list doesn't rotate.
778                if total_drained > 0 {
779                    self.drain_start = (self.drain_start + 1) % n;
780                }
781                // Increment global version for this batch
782                self.global_next_version = self.global_next_version.saturating_add(1);
783                for (idx, batch) in per_source.into_iter().enumerate() {
784                    if !batch.is_empty() {
785                        self.sources[idx]
786                            .cache
787                            .ingest_with_version(batch, self.global_next_version);
788                    }
789                }
790            }
791        }
792    }
793
794    fn weighted_drain_into_caches(&mut self, limit: usize, weights: &HashMap<SourceId, f32>) {
795        let len = self.sources.len();
796        if len == 0 {
797            return;
798        }
799        let mut weight_values = Vec::with_capacity(len);
800        let mut any_positive = false;
801        for state in &self.sources {
802            let weight = weights.get(state.source.id()).copied().unwrap_or(1.0);
803            if weight > 0.0 {
804                any_positive = true;
805            }
806            weight_values.push(weight);
807        }
808        if !any_positive {
809            weight_values.fill(1.0);
810        }
811
812        let mut current = vec![0.0f32; len];
813        let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); len];
814        let mut total = 0;
815        while total < limit {
816            let mut total_weight = 0.0f32;
817            for (idx, weight) in weight_values.iter().copied().enumerate().take(len) {
818                if weight <= 0.0 {
819                    continue;
820                }
821                if self.sources[idx].buffer.is_empty() {
822                    continue;
823                }
824                total_weight += weight;
825            }
826            if total_weight == 0.0 {
827                break;
828            }
829
830            let mut best_idx = None;
831            let mut best_score = f32::MIN;
832            // Rotating tie-breaker: when scores are equal, prefer the source
833            // just PAST drain_start in rotation order (i.e. the source whose
834            // turn is coming up in the round-robin).
835            let closer_to_start = |a: usize, b: usize| -> bool {
836                let da = (a + len - self.drain_start) % len;
837                let db = (b + len - self.drain_start) % len;
838                da < db
839            };
840            for idx in 0..len {
841                if weight_values[idx] <= 0.0 {
842                    continue;
843                }
844                if self.sources[idx].buffer.is_empty() {
845                    continue;
846                }
847                current[idx] += weight_values[idx];
848                let is_better = if current[idx] > best_score {
849                    true
850                } else if current[idx] == best_score {
851                    closer_to_start(idx, best_idx.unwrap_or(0))
852                } else {
853                    false
854                };
855                if is_better {
856                    best_score = current[idx];
857                    best_idx = Some(idx);
858                }
859            }
860
861            let idx = match best_idx {
862                Some(idx) => idx,
863                None => break,
864            };
865            current[idx] -= total_weight;
866            if let Some(record) = self.sources[idx].buffer.pop_front() {
867                per_source[idx].push(record);
868                total += 1;
869            }
870        }
871
872        if total > 0 && len > 0 {
873            self.drain_start = (self.drain_start + 1) % len;
874        }
875
876        // Increment global version for this batch
877        self.global_next_version = self.global_next_version.saturating_add(1);
878        for (idx, batch) in per_source.into_iter().enumerate() {
879            if !batch.is_empty() {
880                self.sources[idx]
881                    .cache
882                    .ingest_with_version(batch, self.global_next_version);
883            }
884        }
885    }
886
887    /// Returns `true` when at least one source is registered.
888    pub fn has_sources(&self) -> bool {
889        !self.sources.is_empty()
890    }
891
892    /// Return a delta of records added since `last_version` and record IDs evicted since `last_version`.
893    /// Queries all child caches using the unified version watermark and aggregates the results.
894    pub fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
895        let mut all_added = Vec::new();
896        let mut all_evicted = Vec::new();
897        let mut max_version = last_version;
898
899        for state in &mut self.sources {
900            let (new_version, added, evicted) = state.cache.sync_delta(last_version);
901            if new_version > max_version {
902                max_version = new_version;
903            }
904            all_added.extend(added);
905            all_evicted.extend(evicted);
906        }
907
908        (max_version, all_added, all_evicted)
909    }
910
911    /// Return the current global version counter.
912    pub fn global_version(&self) -> u64 {
913        self.global_next_version
914    }
915}
916
917/// Per-source ingestion runtime state.
918struct SourceState {
919    source: Box<dyn DataSource + 'static>,
920    cursor: Option<SourceCursor>,
921    buffer: VecDeque<DataRecord>,
922    /// Per-source LRU record cache capped at `max_records`.
923    cache: RecordCache,
924    stats: SourceRefreshStats,
925}
926
927#[cfg(test)]
928// The unweighted batch-fetch convenience methods (`next_*_batch`,
929// `next_*_batch_for_split`, `prefetch_*_batches`) are deprecated in favor of the
930// `*_with_weights` variants. These tests intentionally exercise the unweighted
931// path, so suppress the deprecation lint here.
932#[allow(deprecated)]
933mod tests {
934    use super::*;
935    use crate::TripletSampler;
936    use crate::config::{Selector, TextRecipe, TripletRecipe};
937    use crate::data::{QualityScore, RecordSection, SectionRole};
938    use crate::sampler::Sampler;
939    use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
940    use chrono::Utc;
941    use std::collections::HashMap;
942    use std::collections::VecDeque;
943    use std::sync::atomic::{AtomicUsize, Ordering};
944    use std::sync::{Arc, Mutex};
945
946    fn make_record(id: &str, source: &str) -> DataRecord {
947        let now = Utc::now();
948        DataRecord {
949            id: id.to_string(),
950            source: source.to_string(),
951            created_at: now,
952            updated_at: now,
953            quality: QualityScore { trust: 1.0 },
954            taxonomy: Vec::new(),
955            sections: vec![RecordSection {
956                role: SectionRole::Anchor,
957                heading: None,
958                text: id.to_string(),
959                sentences: vec![id.to_string()],
960                token_count: 0,
961            }],
962            meta_prefix: None,
963            label: None,
964        }
965    }
966
967    struct ScriptedSource {
968        id: String,
969        refreshes: Arc<AtomicUsize>,
970        script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
971    }
972
973    impl ScriptedSource {
974        fn new(
975            id: &str,
976            refreshes: Arc<AtomicUsize>,
977            script: Vec<Result<SourceSnapshot, SamplerError>>,
978        ) -> Self {
979            Self {
980                id: id.to_string(),
981                refreshes,
982                script: Arc::new(Mutex::new(script.into_iter().collect())),
983            }
984        }
985    }
986
987    impl DataSource for ScriptedSource {
988        fn id(&self) -> &str {
989            &self.id
990        }
991
992        fn refresh(
993            &self,
994            _config: &SamplerConfig,
995            _cursor: Option<&SourceCursor>,
996            _limit: Option<usize>,
997        ) -> Result<SourceSnapshot, SamplerError> {
998            self.refreshes.fetch_add(1, Ordering::SeqCst);
999            let mut guard = self.script.lock().expect("script lock poisoned");
1000            guard.pop_front().unwrap_or_else(|| {
1001                Ok(SourceSnapshot {
1002                    records: Vec::new(),
1003                    cursor: SourceCursor {
1004                        last_seen: Utc::now(),
1005                        revision: 0,
1006                    },
1007                })
1008            })
1009        }
1010
1011        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1012            Ok(0)
1013        }
1014
1015        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1016            Vec::new()
1017        }
1018    }
1019
1020    struct PanicSource {
1021        id: String,
1022    }
1023
1024    impl DataSource for PanicSource {
1025        fn id(&self) -> &str {
1026            &self.id
1027        }
1028
1029        fn refresh(
1030            &self,
1031            _config: &SamplerConfig,
1032            _cursor: Option<&SourceCursor>,
1033            _limit: Option<usize>,
1034        ) -> Result<SourceSnapshot, SamplerError> {
1035            panic!("panic source refresh")
1036        }
1037
1038        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1039            Ok(0)
1040        }
1041    }
1042
1043    #[test]
1044    fn record_cache_waits_len_and_clear_paths_are_covered() {
1045        let cache = RecordCache::new(2);
1046        assert!(cache.is_empty());
1047        assert_eq!(cache.len(), 0);
1048        assert_eq!(cache.ingest_count(), 0);
1049
1050        cache.ingest(Vec::<DataRecord>::new());
1051        assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
1052
1053        let cache_for_waiter = cache.clone();
1054        let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
1055        std::thread::sleep(Duration::from_millis(5));
1056        cache.ingest(vec![make_record("r1", "s")]);
1057        assert_eq!(handle.join().unwrap(), 1);
1058        assert_eq!(cache.ingest_count(), 1);
1059
1060        cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
1061        assert_eq!(cache.len(), 2);
1062        let ids: Vec<String> = cache
1063            .snapshot()
1064            .into_iter()
1065            .map(|record| record.id.clone())
1066            .collect();
1067        assert!(ids.contains(&"r2".to_string()));
1068        assert!(ids.contains(&"r3".to_string()));
1069
1070        cache.clear();
1071        assert!(cache.is_empty());
1072    }
1073
1074    #[test]
1075    fn record_cache_zero_limit_discards_everything() {
1076        let cache = RecordCache::new(0);
1077        cache.ingest(vec![make_record("r1", "s")]);
1078        assert!(cache.is_empty());
1079        assert_eq!(cache.len(), 0);
1080    }
1081
1082    #[test]
1083    fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
1084        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1085        assert!(!manager.has_sources());
1086        manager.load_cursors(&[]);
1087
1088        let refreshes = Arc::new(AtomicUsize::new(0));
1089        manager
1090            .register_source(Box::new(ScriptedSource::new(
1091                "cursor_source",
1092                refreshes,
1093                vec![Ok(SourceSnapshot {
1094                    records: vec![make_record("id_1", "original_source")],
1095                    cursor: SourceCursor {
1096                        last_seen: Utc::now(),
1097                        revision: 33,
1098                    },
1099                })],
1100            )))
1101            .unwrap();
1102        assert!(manager.has_sources());
1103
1104        manager.load_cursors(&[("cursor_source".to_string(), 7)]);
1105        let cursors = manager.snapshot_cursors();
1106        assert_eq!(cursors.len(), 1);
1107        assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
1108
1109        manager.refresh_all();
1110        let updated = manager.snapshot_cursors();
1111        assert_eq!(updated.len(), 1);
1112        assert_eq!(updated[0], ("cursor_source".to_string(), 33));
1113        let records = manager.all_records_snapshot();
1114        assert_eq!(records.len(), 1);
1115        assert_eq!(records[0].source, "cursor_source");
1116    }
1117
1118    #[test]
1119    fn advance_uses_buffer_before_refreshing_again() {
1120        let refreshes = Arc::new(AtomicUsize::new(0));
1121        let mut manager = IngestionManager::new(5, SamplerConfig::default());
1122        manager
1123            .register_source(Box::new(ScriptedSource::new(
1124                "buffered",
1125                refreshes.clone(),
1126                vec![Ok(SourceSnapshot {
1127                    records: vec![
1128                        make_record("a", "x"),
1129                        make_record("b", "x"),
1130                        make_record("c", "x"),
1131                    ],
1132                    cursor: SourceCursor {
1133                        last_seen: Utc::now(),
1134                        revision: 1,
1135                    },
1136                })],
1137            )))
1138            .unwrap();
1139
1140        manager.advance(1);
1141        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1142        assert_eq!(manager.all_records_len(), 1);
1143
1144        manager.advance(1);
1145        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1146        assert_eq!(manager.all_records_len(), 2);
1147    }
1148
1149    #[test]
1150    fn force_refresh_clears_buffer_and_fetches_again() {
1151        let refreshes = Arc::new(AtomicUsize::new(0));
1152        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1153        manager
1154            .register_source(Box::new(ScriptedSource::new(
1155                "force",
1156                refreshes.clone(),
1157                vec![
1158                    Ok(SourceSnapshot {
1159                        records: vec![
1160                            make_record("r1", "x"),
1161                            make_record("r2", "x"),
1162                            make_record("r3", "x"),
1163                        ],
1164                        cursor: SourceCursor {
1165                            last_seen: Utc::now(),
1166                            revision: 10,
1167                        },
1168                    }),
1169                    Ok(SourceSnapshot {
1170                        records: vec![make_record("r4", "x")],
1171                        cursor: SourceCursor {
1172                            last_seen: Utc::now(),
1173                            revision: 11,
1174                        },
1175                    }),
1176                ],
1177            )))
1178            .unwrap();
1179
1180        manager.advance(1);
1181        assert_eq!(manager.all_records_len(), 1);
1182        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1183
1184        manager.force_refresh_all();
1185        assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1186        let records = manager.all_records_snapshot();
1187        assert_eq!(records.len(), 1);
1188        assert_eq!(records[0].id, "r4");
1189    }
1190
1191    #[test]
1192    fn weighted_drain_respects_zero_and_fallback_weights() {
1193        let mut manager = IngestionManager::new(6, SamplerConfig::default());
1194        manager
1195            .register_source(Box::new(ScriptedSource::new(
1196                "a",
1197                Arc::new(AtomicUsize::new(0)),
1198                vec![Ok(SourceSnapshot {
1199                    records: vec![make_record("a1", "a"), make_record("a2", "a")],
1200                    cursor: SourceCursor {
1201                        last_seen: Utc::now(),
1202                        revision: 1,
1203                    },
1204                })],
1205            )))
1206            .unwrap();
1207        manager
1208            .register_source(Box::new(ScriptedSource::new(
1209                "b",
1210                Arc::new(AtomicUsize::new(0)),
1211                vec![Ok(SourceSnapshot {
1212                    records: vec![make_record("b1", "b"), make_record("b2", "b")],
1213                    cursor: SourceCursor {
1214                        last_seen: Utc::now(),
1215                        revision: 1,
1216                    },
1217                })],
1218            )))
1219            .unwrap();
1220
1221        let mut only_b = HashMap::new();
1222        only_b.insert("a".to_string(), 0.0);
1223        only_b.insert("b".to_string(), 1.0);
1224        manager.refresh_all_with_weights(&only_b).unwrap();
1225        let ids: Vec<String> = manager
1226            .all_records_snapshot()
1227            .into_iter()
1228            .map(|record| record.id.clone())
1229            .collect();
1230        assert!(ids.iter().all(|id| id.starts_with('b')));
1231
1232        let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1233        manager_fallback
1234            .register_source(Box::new(ScriptedSource::new(
1235                "a",
1236                Arc::new(AtomicUsize::new(0)),
1237                vec![Ok(SourceSnapshot {
1238                    records: vec![make_record("a1", "a")],
1239                    cursor: SourceCursor {
1240                        last_seen: Utc::now(),
1241                        revision: 2,
1242                    },
1243                })],
1244            )))
1245            .unwrap();
1246        manager_fallback
1247            .register_source(Box::new(ScriptedSource::new(
1248                "b",
1249                Arc::new(AtomicUsize::new(0)),
1250                vec![Ok(SourceSnapshot {
1251                    records: vec![make_record("b1", "b")],
1252                    cursor: SourceCursor {
1253                        last_seen: Utc::now(),
1254                        revision: 2,
1255                    },
1256                })],
1257            )))
1258            .unwrap();
1259
1260        let mut all_zero = HashMap::new();
1261        all_zero.insert("a".to_string(), 0.0);
1262        all_zero.insert("b".to_string(), 0.0);
1263        manager_fallback
1264            .refresh_all_with_weights(&all_zero)
1265            .unwrap();
1266        let ids: Vec<String> = manager_fallback
1267            .all_records_snapshot()
1268            .into_iter()
1269            .map(|record| record.id.clone())
1270            .collect();
1271        assert!(ids.contains(&"a1".to_string()));
1272        assert!(ids.contains(&"b1".to_string()));
1273    }
1274
1275    #[test]
1276    fn refresh_errors_and_panics_update_source_stats() {
1277        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1278        manager
1279            .register_source(Box::new(ScriptedSource::new(
1280                "err_source",
1281                Arc::new(AtomicUsize::new(0)),
1282                vec![Err(SamplerError::SourceUnavailable {
1283                    source_id: "err_source".to_string(),
1284                    reason: "boom".to_string(),
1285                })],
1286            )))
1287            .unwrap();
1288        manager
1289            .register_source(Box::new(PanicSource {
1290                id: "panic_source".to_string(),
1291            }))
1292            .unwrap();
1293
1294        manager.refresh_all();
1295        let stats = manager.source_refresh_stats();
1296        let err_stats = stats
1297            .iter()
1298            .find(|(source, _)| source == "err_source")
1299            .map(|(_, stats)| stats)
1300            .unwrap();
1301        assert_eq!(err_stats.error_count, 1);
1302        assert!(
1303            err_stats
1304                .last_error
1305                .as_ref()
1306                .is_some_and(|msg| msg.contains("boom"))
1307        );
1308
1309        let panic_stats = stats
1310            .iter()
1311            .find(|(source, _)| source == "panic_source")
1312            .map(|(_, stats)| stats)
1313            .unwrap();
1314        assert_eq!(panic_stats.error_count, 1);
1315        assert!(
1316            panic_stats
1317                .last_error
1318                .as_ref()
1319                .is_some_and(|msg| msg.contains("panicked"))
1320        );
1321    }
1322
1323    #[test]
1324    fn force_refresh_with_weights_path_is_exercised() {
1325        let mut manager = IngestionManager::new(3, SamplerConfig::default());
1326        manager
1327            .register_source(Box::new(ScriptedSource::new(
1328                "w",
1329                Arc::new(AtomicUsize::new(0)),
1330                vec![Ok(SourceSnapshot {
1331                    records: vec![make_record("w1", "w")],
1332                    cursor: SourceCursor {
1333                        last_seen: Utc::now(),
1334                        revision: 3,
1335                    },
1336                })],
1337            )))
1338            .unwrap();
1339
1340        let mut weights = HashMap::new();
1341        weights.insert("w".to_string(), 1.0);
1342        manager.force_refresh_all_with_weights(&weights).unwrap();
1343        assert_eq!(manager.all_records_len(), 1);
1344    }
1345
1346    #[test]
1347    fn advance_with_weights_rejects_unknown_source() {
1348        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1349        manager
1350            .register_source(Box::new(ScriptedSource::new(
1351                "known",
1352                Arc::new(AtomicUsize::new(0)),
1353                vec![],
1354            )))
1355            .unwrap();
1356
1357        let mut weights = HashMap::new();
1358        weights.insert("known".to_string(), 1.0);
1359        weights.insert("unknown".to_string(), 0.5);
1360
1361        let err = manager.advance_with_weights(1, &weights).unwrap_err();
1362        assert!(
1363            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1364            "expected InvalidWeight for unknown source, got {err:?}"
1365        );
1366    }
1367
1368    #[test]
1369    fn refresh_all_with_weights_rejects_negative_weight() {
1370        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1371        manager
1372            .register_source(Box::new(ScriptedSource::new(
1373                "src",
1374                Arc::new(AtomicUsize::new(0)),
1375                vec![],
1376            )))
1377            .unwrap();
1378
1379        let mut weights = HashMap::new();
1380        weights.insert("src".to_string(), -1.0);
1381
1382        let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1383        assert!(
1384            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1385            "expected InvalidWeight for negative weight, got {err:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn force_refresh_all_with_weights_rejects_unknown_source() {
1391        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1392        manager
1393            .register_source(Box::new(ScriptedSource::new(
1394                "real",
1395                Arc::new(AtomicUsize::new(0)),
1396                vec![],
1397            )))
1398            .unwrap();
1399
1400        let mut weights = HashMap::new();
1401        weights.insert("ghost".to_string(), 1.0);
1402
1403        let err = manager
1404            .force_refresh_all_with_weights(&weights)
1405            .unwrap_err();
1406        assert!(
1407            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1408            "expected InvalidWeight for unknown source, got {err:?}"
1409        );
1410    }
1411
1412    /// A source that records the `config.seed` value it receives on each `refresh()` call.
1413    struct SeedCapturingSource {
1414        id: String,
1415        received_seeds: Arc<Mutex<Vec<u64>>>,
1416    }
1417
1418    impl SeedCapturingSource {
1419        fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1420            Self {
1421                id: id.to_string(),
1422                received_seeds,
1423            }
1424        }
1425    }
1426
1427    impl DataSource for SeedCapturingSource {
1428        fn id(&self) -> &str {
1429            &self.id
1430        }
1431
1432        fn refresh(
1433            &self,
1434            config: &SamplerConfig,
1435            _cursor: Option<&SourceCursor>,
1436            _limit: Option<usize>,
1437        ) -> Result<SourceSnapshot, SamplerError> {
1438            self.received_seeds
1439                .lock()
1440                .expect("seed lock poisoned")
1441                .push(config.seed);
1442            Ok(SourceSnapshot {
1443                records: Vec::new(),
1444                cursor: SourceCursor {
1445                    last_seen: Utc::now(),
1446                    revision: 0,
1447                },
1448            })
1449        }
1450
1451        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1452            Ok(0)
1453        }
1454
1455        fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1456            Vec::new()
1457        }
1458    }
1459
1460    #[test]
1461    fn epoch_xor_changes_seed_received_by_source() {
1462        // Verify that derive_epoch_seed(base, epoch) is actually threaded through to
1463        // the source's refresh() call, and that different epochs produce different seeds.
1464        let base_seed = 0xDEAD_BEEF_u64;
1465        let config = SamplerConfig {
1466            seed: base_seed,
1467            ..SamplerConfig::default()
1468        };
1469
1470        let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1471        let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1472
1473        // --- epoch 0 ---
1474        let mut manager = IngestionManager::new(4, config.clone());
1475        manager
1476            .register_source(Box::new(SeedCapturingSource::new(
1477                "src",
1478                Arc::clone(&seeds_epoch0),
1479            )))
1480            .unwrap();
1481        // epoch defaults to 0; refresh_all passes derive_epoch_seed(base, 0)
1482        manager.refresh_all();
1483
1484        // --- epoch 1 ---
1485        let mut manager2 = IngestionManager::new(4, config.clone());
1486        manager2
1487            .register_source(Box::new(SeedCapturingSource::new(
1488                "src",
1489                Arc::clone(&seeds_epoch1),
1490            )))
1491            .unwrap();
1492        manager2.set_epoch(1);
1493        manager2.refresh_all();
1494
1495        let received0 = seeds_epoch0.lock().unwrap();
1496        let received1 = seeds_epoch1.lock().unwrap();
1497
1498        assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1499        assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1500
1501        let seed_at_epoch0 = received0[0];
1502        let seed_at_epoch1 = received1[0];
1503
1504        // The seeds must differ — epoch XOR has a real effect.
1505        assert_ne!(
1506            seed_at_epoch0, seed_at_epoch1,
1507            "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1508             derive_epoch_seed is not reaching the source"
1509        );
1510
1511        // They must match the expected derive_epoch_seed values.
1512        // epoch_step stays at 0 for direct refresh_all calls
1513        // (it's only bumped by next_*_batch calls).
1514        assert_eq!(
1515            seed_at_epoch0,
1516            derive_epoch_seed(base_seed, 0),
1517            "epoch-0 seed mismatch (epoch_step=0)"
1518        );
1519        assert_eq!(
1520            seed_at_epoch1,
1521            derive_epoch_seed(base_seed, 1),
1522            "epoch-1 seed mismatch (epoch_step=0)"
1523        );
1524    }
1525
1526    #[test]
1527    fn epoch_step_resets_on_epoch_change() {
1528        // Proves that set_epoch + reset_epoch_step gives epoch_step=0 so each
1529        // epoch produces the same step sequence starting from step 1.
1530        let config = SamplerConfig::default();
1531        let seeds = Arc::new(Mutex::new(Vec::new()));
1532
1533        let mut manager = IngestionManager::new(4, config.clone());
1534        manager
1535            .register_source(Box::new(SeedCapturingSource::new(
1536                "src",
1537                Arc::clone(&seeds),
1538            )))
1539            .unwrap();
1540
1541        // Epoch 0, first refresh: epoch_step stays at 0 (no longer
1542        // incremented by refresh_all — it's bumped per batch call instead).
1543        manager.refresh_all();
1544        let step1_seed = seeds.lock().unwrap()[0];
1545        assert_eq!(
1546            step1_seed,
1547            derive_epoch_seed(config.seed, 0),
1548            "epoch 0 step 0 seed"
1549        );
1550
1551        // Advance epoch — epoch_step must stay 0 (no batch calls yet).
1552        manager.set_epoch(1);
1553        assert_eq!(manager.epoch_step(), 0);
1554        seeds.lock().unwrap().clear();
1555
1556        // Epoch 1, first refresh: step stays at 0 (no batch calls in this test).
1557        manager.refresh_all();
1558        let step1_epoch1 = seeds.lock().unwrap()[0];
1559        assert_eq!(
1560            step1_epoch1,
1561            derive_epoch_seed(config.seed, 1),
1562            "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1563        );
1564    }
1565
1566    #[test]
1567    fn epoch_step_survives_sampler_save_and_load_state() {
1568        // Proves the epoch step survives through the REAL API path:
1569        //   TripletSampler::save_sampler_state → persist_source_state
1570        //   → self.ingestion.snapshot_cursors() internally
1571        //   → DeterministicSplitStore.save_sampler_state
1572        //   → load_sampler_state → load_cursors
1573        let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1574
1575        // Sampler with ScriptedSource (provides empty recipes but that's fine)
1576        let sampler = TripletSampler::new(
1577            SamplerConfig {
1578                seed: 42,
1579                ..SamplerConfig::default()
1580            },
1581            Arc::clone(&store),
1582        );
1583        let refreshes = Arc::new(AtomicUsize::new(0));
1584        sampler
1585            .register_source(Box::new(ScriptedSource::new(
1586                "src",
1587                refreshes,
1588                vec![Ok(SourceSnapshot {
1589                    records: vec![make_record("r1", "src")],
1590                    cursor: SourceCursor {
1591                        last_seen: Utc::now(),
1592                        revision: 1,
1593                    },
1594                })],
1595            )))
1596            .unwrap();
1597
1598        // Call a batch method to trigger ensure_source_state() so
1599        // save_sampler_state actually writes.  The batch itself may
1600        // exhaust (no recipes) but the state is loaded regardless.
1601        let _ = sampler.next_text_batch(SplitLabel::Train);
1602
1603        // Save through the REAL sampler API
1604        sampler.save_sampler_state(None).unwrap();
1605
1606        // Load back what the sampler saved
1607        let loaded = store.load_sampler_state().unwrap().unwrap();
1608        let step_saved = loaded.epoch_step;
1609        assert!(
1610            step_saved > 0,
1611            "epoch_step must be >0 after batch call through TripletSampler"
1612        );
1613
1614        // Feed the REAL loaded cursors to a new manager
1615        let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1616        manager2
1617            .register_source(Box::new(ScriptedSource::new(
1618                "src",
1619                Arc::new(AtomicUsize::new(0)),
1620                vec![Ok(SourceSnapshot {
1621                    records: vec![make_record("r2", "src")],
1622                    cursor: SourceCursor {
1623                        last_seen: Utc::now(),
1624                        revision: 2,
1625                    },
1626                })],
1627            )))
1628            .unwrap();
1629        manager2.load_cursors(&loaded.source_stream_cursors);
1630        manager2.set_epoch_step(loaded.epoch_step);
1631
1632        // epoch_step restored to saved value; verify it's stable (no batch
1633        // call was made, so step does not change).
1634        let step_before = manager2.epoch_step();
1635        assert_eq!(
1636            step_before, step_saved,
1637            "load_cursors must restore epoch_step to saved value"
1638        );
1639
1640        // Verify the step is still present after a refresh (no increment).
1641        manager2.refresh_all();
1642        let step_after = manager2.epoch_step();
1643        assert_eq!(
1644            step_after, step_saved,
1645            "epoch_step must survive refresh_all without increment (step is per-batch,
1646             not per-refresh): loaded {step_saved}, got {step_after}"
1647        );
1648    }
1649
1650    #[test]
1651    fn scripted_and_panic_sources_cover_default_trait_paths() {
1652        let refreshes = Arc::new(AtomicUsize::new(0));
1653        let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1654
1655        // Empty script falls back to an empty snapshot.
1656        let snapshot = scripted
1657            .refresh(&SamplerConfig::default(), None, None)
1658            .expect("fallback snapshot");
1659        assert!(snapshot.records.is_empty());
1660        assert_eq!(snapshot.cursor.revision, 0);
1661
1662        assert_eq!(
1663            scripted
1664                .reported_record_count(&SamplerConfig::default())
1665                .expect("record count"),
1666            0
1667        );
1668        assert!(scripted.default_triplet_recipes().is_empty());
1669
1670        let panic_source = PanicSource {
1671            id: "panic_count".to_string(),
1672        };
1673        assert_eq!(
1674            panic_source
1675                .reported_record_count(&SamplerConfig::default())
1676                .expect("record count"),
1677            0
1678        );
1679    }
1680
1681    #[test]
1682    fn seed_capturing_source_trait_defaults_are_exercised() {
1683        let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1684        assert_eq!(
1685            source
1686                .reported_record_count(&SamplerConfig::default())
1687                .expect("record count"),
1688            0
1689        );
1690        assert!(source.default_triplet_recipes().is_empty());
1691    }
1692
1693    #[test]
1694    fn refresh_paths_handle_zero_capacity_and_no_sources() {
1695        let mut manager = IngestionManager::new(0, SamplerConfig::default());
1696        manager
1697            .register_source(Box::new(ScriptedSource::new(
1698                "zero_capacity",
1699                Arc::new(AtomicUsize::new(0)),
1700                vec![Ok(SourceSnapshot {
1701                    records: vec![make_record("r1", "zero_capacity")],
1702                    cursor: SourceCursor {
1703                        last_seen: Utc::now(),
1704                        revision: 1,
1705                    },
1706                })],
1707            )))
1708            .unwrap();
1709        manager.refresh_all();
1710        assert!(manager.all_caches_empty());
1711
1712        // Weighted refresh with no sources should be a no-op.
1713        let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1714        let empty_weights = HashMap::new();
1715        empty_manager
1716            .refresh_all_with_weights(&empty_weights)
1717            .expect("no sources should not error");
1718        assert!(empty_manager.all_caches_empty());
1719    }
1720
1721    #[test]
1722    fn drain_start_rotates_fairly_across_sources() {
1723        // Create 3 sources, each with 10 records in their buffer after refresh.
1724        // The fair round-robin should ensure all 3 drain at the same rate
1725        // over multiple advance cycles.
1726        struct FairSource {
1727            id: String,
1728            refresh_count: Arc<AtomicUsize>,
1729        }
1730
1731        impl DataSource for FairSource {
1732            fn id(&self) -> &str {
1733                &self.id
1734            }
1735            fn refresh(
1736                &self,
1737                _config: &SamplerConfig,
1738                _cursor: Option<&SourceCursor>,
1739                _limit: Option<usize>,
1740            ) -> Result<SourceSnapshot, SamplerError> {
1741                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1742                Ok(SourceSnapshot {
1743                    records: (0..10)
1744                        .map(|i| make_record(&format!("r{i}"), &self.id))
1745                        .collect(),
1746                    cursor: SourceCursor {
1747                        last_seen: Utc::now(),
1748                        revision: 1,
1749                    },
1750                })
1751            }
1752            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1753                Ok(10)
1754            }
1755        }
1756
1757        let counts = (
1758            Arc::new(AtomicUsize::new(0)),
1759            Arc::new(AtomicUsize::new(0)),
1760            Arc::new(AtomicUsize::new(0)),
1761        );
1762
1763        let mut manager = IngestionManager::new(30, SamplerConfig::default());
1764        manager
1765            .register_source(Box::new(FairSource {
1766                id: "src_0".to_string(),
1767                refresh_count: Arc::clone(&counts.0),
1768            }))
1769            .unwrap();
1770        manager
1771            .register_source(Box::new(FairSource {
1772                id: "src_1".to_string(),
1773                refresh_count: Arc::clone(&counts.1),
1774            }))
1775            .unwrap();
1776        manager
1777            .register_source(Box::new(FairSource {
1778                id: "src_2".to_string(),
1779                refresh_count: Arc::clone(&counts.2),
1780            }))
1781            .unwrap();
1782
1783        // First refresh_all fills all buffers.
1784        manager.refresh_all();
1785        // All 3 refreshed once.
1786        assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1787        assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1788        assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1789
1790        // Each advance(1) drains 1 record from 1 source, rotating via
1791        // drain_start.  With 3 sources, after 3 advances each source
1792        // loses 1 record.  After 30 advances each source loses 10 records
1793        // and triggers a refresh.  Run 33 advances and check all 3 refreshed
1794        // roughly the same number of times.
1795        for _ in 0..33 {
1796            manager.advance(1);
1797        }
1798
1799        let r0 = counts.0.load(Ordering::SeqCst);
1800        let r1 = counts.1.load(Ordering::SeqCst);
1801        let r2 = counts.2.load(Ordering::SeqCst);
1802
1803        // Each had 10 records after initial refresh_all.
1804        // 10 records / (1 drained per 3 cycles) = 30 cycles to drain each buffer.
1805        // After 33 more advances each buffer emptied ~1 time and re-filled, so
1806        // each source should have refreshed ~2 times total (initial + 1 drain).
1807        // The exact count can vary by 1 due to timing, but all 3 must be within
1808        // 1 of each other — no source can be starved.
1809        let min = r0.min(r1).min(r2);
1810        let max = r0.max(r1).max(r2);
1811        assert!(
1812            max <= min + 1,
1813            "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1814        );
1815    }
1816
1817    #[test]
1818    fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1819        // Direct IngestionManager test with batch_size=2, 5 sources.
1820        // Isolates the drain_start rotation from the sampler pipeline.
1821        struct SimpleSource {
1822            id: String,
1823            refresh_count: Arc<AtomicUsize>,
1824        }
1825
1826        impl DataSource for SimpleSource {
1827            fn id(&self) -> &str {
1828                &self.id
1829            }
1830            fn refresh(
1831                &self,
1832                _: &SamplerConfig,
1833                _: Option<&SourceCursor>,
1834                _: Option<usize>,
1835            ) -> Result<SourceSnapshot, SamplerError> {
1836                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1837                let now = Utc::now();
1838                let records: Vec<DataRecord> = (0..8)
1839                    .map(|i| DataRecord {
1840                        id: format!("{}_r{i}", self.id),
1841                        source: self.id.clone(),
1842                        created_at: now,
1843                        updated_at: now,
1844                        quality: QualityScore { trust: 1.0 },
1845                        taxonomy: Vec::new(),
1846                        sections: Vec::new(),
1847                        meta_prefix: None,
1848                        label: None,
1849                    })
1850                    .collect();
1851                Ok(SourceSnapshot {
1852                    records,
1853                    cursor: SourceCursor {
1854                        last_seen: now,
1855                        revision: 1,
1856                    },
1857                })
1858            }
1859            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1860                Ok(8)
1861            }
1862        }
1863
1864        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1865        let mut manager = IngestionManager::new(40, SamplerConfig::default());
1866        for (i, count) in counts.iter().enumerate() {
1867            manager
1868                .register_source(Box::new(SimpleSource {
1869                    id: format!("src_{i}"),
1870                    refresh_count: Arc::clone(count),
1871                }))
1872                .unwrap();
1873        }
1874
1875        manager.refresh_all();
1876        for c in &counts {
1877            assert_eq!(c.load(Ordering::SeqCst), 1);
1878        }
1879
1880        for _ in 0..80 {
1881            manager.advance(2);
1882        }
1883
1884        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1885        // Round-robin drain with 5 sources, 8 records each, batch_size=2, 80
1886        // advance calls.  Most sources refresh 5×; sources 2-4 get one extra
1887        // cycle (6) due to the rotating drain_start tie-breaker positioning.
1888        assert_eq!(
1889            totals,
1890            vec![5, 5, 6, 6, 6],
1891            "direct manager: unexpected refresh distribution"
1892        );
1893    }
1894
1895    /// Helper: create 5 sources with 8 records each and a sampler configured for
1896    /// text batches with batch_size=2.  Returns the sampler and refresh counters.
1897    fn make_five_source_sampler(
1898        counts: &[Arc<AtomicUsize>],
1899    ) -> TripletSampler<DeterministicSplitStore> {
1900        struct Tracked {
1901            id: String,
1902            refresh_count: Arc<AtomicUsize>,
1903        }
1904        impl DataSource for Tracked {
1905            fn id(&self) -> &str {
1906                &self.id
1907            }
1908            fn refresh(
1909                &self,
1910                _: &SamplerConfig,
1911                _: Option<&SourceCursor>,
1912                _: Option<usize>,
1913            ) -> Result<SourceSnapshot, SamplerError> {
1914                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1915                let now = Utc::now();
1916                let records: Vec<DataRecord> = (0..8)
1917                    .map(|i| DataRecord {
1918                        id: format!("{}_r{i}", self.id),
1919                        source: self.id.clone(),
1920                        created_at: now,
1921                        updated_at: now,
1922                        quality: QualityScore { trust: 1.0 },
1923                        taxonomy: Vec::new(),
1924                        sections: vec![RecordSection {
1925                            role: SectionRole::Anchor,
1926                            heading: None,
1927                            text: format!("{}_x{}", self.id, i),
1928                            sentences: vec![format!("{}_x{}", self.id, i)],
1929                            token_count: 0,
1930                        }],
1931                        meta_prefix: None,
1932                        label: None,
1933                    })
1934                    .collect();
1935                Ok(SourceSnapshot {
1936                    records,
1937                    cursor: SourceCursor {
1938                        last_seen: now,
1939                        revision: 1,
1940                    },
1941                })
1942            }
1943            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1944                Ok(8)
1945            }
1946        }
1947
1948        let config = SamplerConfig {
1949            batch_size: 2,
1950            text_recipes: vec![TextRecipe {
1951                name: "anchor".into(),
1952                selector: Selector::Role(SectionRole::Anchor),
1953                weight: 1.0,
1954                instruction: None,
1955            }],
1956            split: SplitRatios {
1957                train: 1.0,
1958                validation: 0.0,
1959                test: 0.0,
1960            },
1961            allowed_splits: vec![SplitLabel::Train],
1962            // Small enough that the cache slides on every advance, changing
1963            // the record pool each batch.  Without this, all 40 records fit in
1964            // the cache permanently and the cross-batch text dedup would never
1965            // clear `emitted_texts`, causing early Exhausted after only a few
1966            // batches (only 8 unique texts across 40 records).
1967            ingestion_max_records: 4,
1968            ..SamplerConfig::default()
1969        };
1970        let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1971        let sampler = TripletSampler::new(config, store);
1972
1973        for (i, count) in counts.iter().enumerate() {
1974            sampler
1975                .register_source(Box::new(Tracked {
1976                    id: format!("src_{i}"),
1977                    refresh_count: Arc::clone(count),
1978                }))
1979                .unwrap();
1980        }
1981        sampler
1982    }
1983
1984    #[test]
1985    fn sampler_unweighted_drain_distributes_evenly() {
1986        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1987        let sampler = make_five_source_sampler(&counts);
1988
1989        sampler.next_text_batch(SplitLabel::Train).unwrap();
1990        for _ in 0..80 {
1991            sampler.next_text_batch(SplitLabel::Train).unwrap();
1992        }
1993
1994        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1995        let min = *totals.iter().min().unwrap();
1996        let max = *totals.iter().max().unwrap();
1997        assert!(
1998            max <= min + 1,
1999            "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
2000        );
2001        assert!(
2002            min >= 4,
2003            "unweighted: each source should have refreshed at least 4 times: {totals:?}"
2004        );
2005    }
2006
2007    #[test]
2008    fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
2009        // Same as unweighted but goes through the public weighted API with equal
2010        // weights for all 5 sources — verifies the weighted drain also rotates
2011        // fairly via drain_start bias.
2012        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2013        let sampler = make_five_source_sampler(&counts);
2014
2015        let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2016
2017        sampler
2018            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2019            .unwrap();
2020        for _ in 0..80 {
2021            sampler
2022                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2023                .unwrap();
2024        }
2025
2026        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2027        let min = *totals.iter().min().unwrap();
2028        let max = *totals.iter().max().unwrap();
2029        assert!(
2030            max <= min + 1,
2031            "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
2032        );
2033        assert!(
2034            min >= 4,
2035            "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
2036        );
2037    }
2038
2039    #[test]
2040    fn sampler_unweighted_and_weighted_match_distribution() {
2041        // Verify both paths produce similar refresh distributions.
2042        let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2043        let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2044        let usampler = make_five_source_sampler(&uc);
2045        let wsampler = make_five_source_sampler(&wc);
2046
2047        let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2048
2049        usampler.next_text_batch(SplitLabel::Train).unwrap();
2050        wsampler
2051            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2052            .unwrap();
2053        for _ in 0..80 {
2054            usampler.next_text_batch(SplitLabel::Train).unwrap();
2055            wsampler
2056                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2057                .unwrap();
2058        }
2059
2060        let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2061        let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2062        let umax = *ut.iter().max().unwrap();
2063        let umin = *ut.iter().min().unwrap();
2064        let wmax = *wt.iter().max().unwrap();
2065        let wmin = *wt.iter().min().unwrap();
2066        assert!(umax <= umin + 1, "unweighted: {ut:?}");
2067        assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
2068    }
2069
2070    #[test]
2071    fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
2072        // Source 3 gets weight 2.0, others get 1.0.  The weighted
2073        // proportional-fair drain must give source 3 proportionally more
2074        // refresh cycles than any weight-1.0 source.
2075        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2076        let sampler = make_five_source_sampler(&counts);
2077
2078        let mut weights = HashMap::new();
2079        for i in 0..5 {
2080            weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
2081        }
2082
2083        sampler
2084            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2085            .unwrap();
2086        for _ in 0..200 {
2087            sampler
2088                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2089                .unwrap();
2090        }
2091
2092        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2093        // The highest-weight source (src_3, w=2.0) must have strictly more
2094        // refreshes than EVERY weight-1.0 source.
2095        // Note: src_4 (index 4, w=1.0) gets 12 refreshes while the other
2096        // weight-1.0 sources get 6-7.  This happens because the proportional-
2097        // fair scheduler uses drain_start as a rotating tie-breaker — being
2098        // positioned right after the high-weight source (src_3) gives src_4
2099        // extra wins when src_3's buffer empties and refills more frequently.
2100        assert!(
2101            totals
2102                .iter()
2103                .enumerate()
2104                .all(|(i, &t)| i == 3 || t < totals[3]),
2105            "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
2106        );
2107        // Lock in the exact deterministic distribution.
2108        // (Distribution changed after per-source text uniqueness: each source
2109        // now emits distinct texts (`{id}_x{i}` instead of shared `x{i}`), so
2110        // cross-batch text dedup no longer collides across sources and the
2111        // sampler exhausts and force-refreshes less often, which reduces the
2112        // total number of refresh cycles but preserves the weighted
2113        // distribution pattern. The key invariant (src_3 with w=2.0 outpaces
2114        // all w=1.0 sources) is unchanged.)
2115        assert_eq!(
2116            totals,
2117            vec![6, 6, 6, 26, 11],
2118            "unequal-weights: unexpected refresh distribution"
2119        );
2120    }
2121
2122    #[test]
2123    fn register_source_rejects_reserved_id_pattern() {
2124        use crate::constants::splits::is_reserved_source_id;
2125
2126        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2127
2128        // Verify the utility function catches common patterns
2129        assert!(is_reserved_source_id("__meta__"));
2130        assert!(is_reserved_source_id("__anything__"));
2131        assert!(is_reserved_source_id("__x__"));
2132        assert!(!is_reserved_source_id(""));
2133        assert!(!is_reserved_source_id("__"));
2134        assert!(!is_reserved_source_id("___"));
2135        assert!(!is_reserved_source_id("normal_source"));
2136        assert!(!is_reserved_source_id("_prefix_suffix_"));
2137        assert!(!is_reserved_source_id("__unclosed"));
2138        assert!(!is_reserved_source_id("unopened__"));
2139
2140        // Registering with a `__*__` id should fail
2141        let result = manager.register_source(Box::new(ScriptedSource::new(
2142            "__reserved__",
2143            Arc::new(AtomicUsize::new(0)),
2144            vec![],
2145        )));
2146        assert!(
2147            result.is_err(),
2148            "register_source should reject reserved source id"
2149        );
2150        let err = result.unwrap_err();
2151        assert!(
2152            matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
2153            "expected ReservedSourceId error, got: {err}"
2154        );
2155
2156        // Verify source was NOT registered (still zero sources)
2157        assert!(!manager.has_sources());
2158
2159        // Normal source IDs still work
2160        manager
2161            .register_source(Box::new(ScriptedSource::new(
2162                "valid_source",
2163                Arc::new(AtomicUsize::new(0)),
2164                vec![],
2165            )))
2166            .unwrap();
2167        assert!(manager.has_sources());
2168    }
2169
2170    #[test]
2171    fn record_cache_ingest_with_version_empty_batch_is_noop() {
2172        let cache = RecordCache::new(4);
2173        cache.ingest_with_version(Vec::<DataRecord>::new(), 99);
2174        assert!(cache.is_empty());
2175        assert_eq!(cache.ingest_count(), 0);
2176    }
2177
2178    #[test]
2179    fn record_cache_ingest_with_version_assigns_explicit_version() {
2180        let cache = RecordCache::new(4);
2181        cache.ingest_with_version(vec![make_record("v1", "s")], 10);
2182        assert_eq!(cache.len(), 1);
2183        // A subsequent sync_delta from version 0 should see the record as added.
2184        let (_, added, evicted) = cache.sync_delta(0);
2185        assert_eq!(added.len(), 1);
2186        assert!(evicted.is_empty());
2187        assert_eq!(added[0].id, "v1");
2188    }
2189
2190    #[test]
2191    fn record_cache_sync_delta_returns_nothing_when_fully_consumed() {
2192        let cache = RecordCache::new(4);
2193        cache.ingest_with_version(vec![make_record("a", "s")], 5);
2194        // First pull consumes the delta.
2195        let (v1, added1, _) = cache.sync_delta(0);
2196        assert_eq!(added1.len(), 1);
2197        // Second pull with same watermark returns empty.
2198        let (v2, added2, evicted2) = cache.sync_delta(v1);
2199        assert!(added2.is_empty());
2200        assert!(evicted2.is_empty());
2201        assert_eq!(v2, v1);
2202    }
2203
2204    #[test]
2205    fn record_cache_sync_delta_reports_evictions_from_clear() {
2206        let cache = RecordCache::new(4);
2207        cache.ingest(vec![make_record("e1", "s"), make_record("e2", "s")]);
2208        // Consume the initial add.
2209        let (v1, _, _) = cache.sync_delta(0);
2210        assert!(v1 > 0);
2211        // Clear records — should log evictions.
2212        cache.clear();
2213        let (_, added, evicted) = cache.sync_delta(v1);
2214        assert!(added.is_empty());
2215        assert_eq!(evicted.len(), 2);
2216        assert!(evicted.contains(&"e1".to_string()));
2217        assert!(evicted.contains(&"e2".to_string()));
2218    }
2219
2220    #[test]
2221    fn record_cache_sync_delta_filters_evictions_by_version_watermark() {
2222        let cache = RecordCache::new(4);
2223        cache.ingest_with_version(vec![make_record("old", "s")], 1);
2224        let (v1, _, _) = cache.sync_delta(0);
2225        assert!(v1 >= 1);
2226        cache.clear();
2227        // Pull at the old watermark — should see eviction for "old".
2228        let (_, _, evicted_at_v1) = cache.sync_delta(v1);
2229        assert_eq!(evicted_at_v1.len(), 1);
2230        // Pull at a higher watermark that was bumped by clear — should be empty.
2231        let (_, _, evicted_at_higher) = cache.sync_delta(v1 + 1);
2232        assert!(evicted_at_higher.is_empty());
2233    }
2234
2235    #[test]
2236    fn record_cache_enforce_limit_trims_fifo_and_logs_evictions() {
2237        let cache = RecordCache::new(2);
2238        cache.ingest(vec![
2239            make_record("first", "s"),
2240            make_record("second", "s"),
2241            make_record("third", "s"),
2242        ]);
2243        // Cache should hold only 2 records.
2244        assert_eq!(cache.len(), 2);
2245        // Consume delta to establish a baseline version.
2246        let (v1, _, _) = cache.sync_delta(0);
2247        // Ingest one more to trigger eviction of "second" (FIFO order).
2248        cache.ingest(vec![make_record("fourth", "s")]);
2249        let (_, _, evicted) = cache.sync_delta(v1);
2250        // "first" was evicted when "third" was ingested, "second" when "fourth" was.
2251        // At minimum one eviction must appear.
2252        assert!(!evicted.is_empty());
2253    }
2254
2255    #[test]
2256    fn manager_force_refresh_generation_and_global_version() {
2257        let refreshes = Arc::new(AtomicUsize::new(0));
2258        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2259        assert_eq!(manager.force_refresh_generation(), 0);
2260        assert_eq!(manager.global_version(), 0);
2261
2262        manager
2263            .register_source(Box::new(ScriptedSource::new(
2264                "gen_src",
2265                refreshes,
2266                vec![Ok(SourceSnapshot {
2267                    records: vec![make_record("g1", "gen_src")],
2268                    cursor: SourceCursor {
2269                        last_seen: Utc::now(),
2270                        revision: 1,
2271                    },
2272                })],
2273            )))
2274            .unwrap();
2275
2276        manager.refresh_all();
2277        assert_eq!(manager.force_refresh_generation(), 0);
2278        assert!(manager.global_version() > 0);
2279
2280        let v_before = manager.global_version();
2281        manager.force_refresh_all();
2282        assert_eq!(manager.force_refresh_generation(), 1);
2283        assert!(manager.global_version() > v_before);
2284    }
2285
2286    #[test]
2287    fn manager_sync_delta_aggregates_across_sources() {
2288        let refreshes = Arc::new(AtomicUsize::new(0));
2289        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2290        manager
2291            .register_source(Box::new(ScriptedSource::new(
2292                "agg_a",
2293                refreshes.clone(),
2294                vec![Ok(SourceSnapshot {
2295                    records: vec![make_record("a1", "agg_a")],
2296                    cursor: SourceCursor {
2297                        last_seen: Utc::now(),
2298                        revision: 1,
2299                    },
2300                })],
2301            )))
2302            .unwrap();
2303        manager
2304            .register_source(Box::new(ScriptedSource::new(
2305                "agg_b",
2306                refreshes,
2307                vec![Ok(SourceSnapshot {
2308                    records: vec![make_record("b1", "agg_b")],
2309                    cursor: SourceCursor {
2310                        last_seen: Utc::now(),
2311                        revision: 1,
2312                    },
2313                })],
2314            )))
2315            .unwrap();
2316
2317        manager.refresh_all();
2318        let (version, added, evicted) = manager.sync_delta(0);
2319        assert!(version > 0);
2320        assert_eq!(added.len(), 2);
2321        let ids: Vec<&str> = added.iter().map(|r| r.id.as_str()).collect();
2322        assert!(ids.contains(&"a1"));
2323        assert!(ids.contains(&"b1"));
2324        assert!(evicted.is_empty());
2325
2326        // Second pull should be empty.
2327        let (_, added2, evicted2) = manager.sync_delta(version);
2328        assert!(added2.is_empty());
2329        assert!(evicted2.is_empty());
2330    }
2331
2332    #[test]
2333    fn manager_source_refresh_stats_returns_telemetry() {
2334        let refreshes = Arc::new(AtomicUsize::new(0));
2335        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2336        manager
2337            .register_source(Box::new(ScriptedSource::new(
2338                "stats_src",
2339                refreshes,
2340                vec![Ok(SourceSnapshot {
2341                    records: vec![make_record("s1", "stats_src")],
2342                    cursor: SourceCursor {
2343                        last_seen: Utc::now(),
2344                        revision: 1,
2345                    },
2346                })],
2347            )))
2348            .unwrap();
2349
2350        let stats = manager.source_refresh_stats();
2351        assert_eq!(stats.len(), 1);
2352        assert_eq!(stats[0].0, "stats_src");
2353        assert_eq!(stats[0].1.last_refresh_ms, 0);
2354
2355        manager.refresh_all();
2356        let stats = manager.source_refresh_stats();
2357        assert_eq!(stats[0].1.last_record_count, 1);
2358    }
2359
2360    #[test]
2361    fn record_cache_ingest_batch_replaces_existing_record() {
2362        let cache = RecordCache::new(4);
2363        // First ingest creates the record.
2364        cache.ingest(vec![make_record("dup", "s")]);
2365        assert_eq!(cache.len(), 1);
2366        // Re-ingest same id replaces it (does not duplicate).
2367        cache.ingest(vec![make_record("dup", "s")]);
2368        assert_eq!(cache.len(), 1);
2369    }
2370
2371    #[test]
2372    fn record_cache_ingest_with_version_replaces_existing_record() {
2373        let cache = RecordCache::new(4);
2374        cache.ingest_with_version(vec![make_record("vr", "s")], 5);
2375        let (v1, _, _) = cache.sync_delta(0);
2376        assert_eq!(cache.len(), 1);
2377
2378        // Re-ingest same id with higher version.
2379        cache.ingest_with_version(vec![make_record("vr", "s")], v1 + 5);
2380        let (_, added, evicted) = cache.sync_delta(v1);
2381        assert_eq!(added.len(), 1);
2382        assert!(evicted.is_empty());
2383        assert_eq!(cache.len(), 1);
2384    }
2385
2386    #[test]
2387    fn record_cache_enforce_limit_evicts_oldest_and_trims_eviction_log() {
2388        // Create a cache with max 3 records and ingest 5 — the eviction log
2389        // must stay bounded at max_records.
2390        let cache = RecordCache::new(3);
2391        for i in 0..5 {
2392            cache.ingest(vec![make_record(&format!("r{i}"), "s")]);
2393        }
2394        assert_eq!(cache.len(), 3);
2395
2396        // Consume delta to flush.
2397        let (v1, _, _) = cache.sync_delta(0);
2398
2399        // Verify eviction log is bounded by calling sync_delta —
2400        // should not panic or grow unbounded.
2401        let (_, added, evicted) = cache.sync_delta(v1);
2402        // New records from the last ingest may appear as added.
2403        // Evictions from the FIFO limit should appear.
2404        assert!(added.len() + evicted.len() <= 5);
2405    }
2406
2407    #[test]
2408    fn weighted_drain_breaks_when_all_buffers_empty() {
2409        let refreshes = Arc::new(AtomicUsize::new(0));
2410        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2411        // Register source but don't refresh — buffer is empty.
2412        manager
2413            .register_source(Box::new(ScriptedSource::new(
2414                "empty_buf",
2415                refreshes,
2416                vec![],
2417            )))
2418            .unwrap();
2419
2420        let mut weights = HashMap::new();
2421        weights.insert("empty_buf".to_string(), 1.0);
2422        // advance_with_weights calls weighted_drain_into_caches with empty buffers.
2423        // Should not panic — the drain loop breaks when total_weight == 0.
2424        manager.advance_with_weights(1, &weights).unwrap();
2425        assert_eq!(manager.all_records_len(), 0);
2426    }
2427
2428    #[test]
2429    fn wait_for_ingest_times_out_without_notification() {
2430        let cache = RecordCache::new(4);
2431        // No ingest happens — should time out immediately.
2432        let result = cache.wait_for_ingest(0, Duration::from_millis(1));
2433        assert_eq!(result, 0);
2434    }
2435}