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, RecordKey, 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    pub(crate) fn validate_weights(
570        &self,
571        weights: &HashMap<SourceId, f32>,
572    ) -> Result<(), SamplerError> {
573        let known_ids: std::collections::HashSet<&str> =
574            self.sources.iter().map(|s| s.source.id()).collect();
575        for (id, &w) in weights {
576            if !known_ids.contains(id.as_str()) {
577                return Err(SamplerError::InvalidWeight {
578                    source_id: id.clone(),
579                    reason: "source is not registered".to_string(),
580                });
581            }
582            if w < 0.0 {
583                return Err(SamplerError::InvalidWeight {
584                    source_id: id.clone(),
585                    reason: format!("weight {w} is negative"),
586                });
587            }
588        }
589        Ok(())
590    }
591
592    /// Rebuild the shared cache by round-robin draining per-source buffers.
593    ///
594    /// When `force_refresh` is false, each source only refreshes when its buffer
595    /// is empty; when true, all buffers are cleared and all sources refresh.
596    /// If `step` is provided, performs a rolling update of `step` records (no clear).
597    /// If `step` is None, clears the cache and fills up to max capacity.
598    fn refresh_all_internal(
599        &mut self,
600        force_refresh: bool,
601        step: Option<usize>,
602        weights: Option<&HashMap<SourceId, f32>>,
603    ) {
604        self.last_refreshed_sources.clear();
605        if force_refresh {
606            // Bump before clearing/refilling so a subsequent delta sync can
607            // observe that a full pool replacement happened and fall back to
608            // full-resync semantics (see `sync_records_from_cache`).
609            self.force_refresh_generation = self.force_refresh_generation.saturating_add(1);
610        }
611        let mut refresh_plan = Vec::new();
612        for (idx, state) in self.sources.iter_mut().enumerate() {
613            if force_refresh {
614                state.buffer.clear();
615            }
616            if force_refresh || state.buffer.is_empty() {
617                refresh_plan.push((idx, state.cursor.clone()));
618            }
619        }
620
621        if !refresh_plan.is_empty() {
622            self.source_refresh_generation = self.source_refresh_generation.saturating_add(1);
623            self.last_refreshed_sources = refresh_plan
624                .iter()
625                .map(|(idx, _)| self.sources[*idx].source.id().to_string())
626                .collect();
627            let mut results: Vec<
628                Option<(Result<SourceSnapshot, SamplerError>, std::time::Duration)>,
629            > = Vec::with_capacity(self.sources.len());
630            results.resize_with(self.sources.len(), || None);
631            let fetch_limit = self.max_records;
632            let sampler_config = self.sampler_config.clone();
633            let step = self.epoch_step;
634            thread::scope(|scope| {
635                let mut handles = Vec::with_capacity(refresh_plan.len());
636                for (idx, cursor) in &refresh_plan {
637                    let source = &self.sources[*idx].source;
638                    let cursor = cursor.clone();
639                    let sampler_config = sampler_config.clone();
640                    let epoch = self.epoch;
641                    handles.push((
642                        *idx,
643                        scope.spawn(move || {
644                            let start = std::time::Instant::now();
645                            // XOR the source epoch into the seed so each epoch
646                            // produces a distinct permutation within the source.
647                            // XOR the step counter into the seed so every
648                            // advance/refresh call gets a distinct seed, which
649                            // sources can use for e.g. shard ordering.
650                            let epoch_config = SamplerConfig {
651                                seed: derive_epoch_seed(sampler_config.seed, epoch) ^ step,
652                                ..sampler_config
653                            };
654                            let result =
655                                source.refresh(&epoch_config, cursor.as_ref(), Some(fetch_limit));
656                            let elapsed = start.elapsed();
657                            (result, elapsed)
658                        }),
659                    ));
660                }
661                for (idx, handle) in handles {
662                    let result = match handle.join() {
663                        Ok((result, elapsed)) => {
664                            debug!(
665                                source_id = %self.sources[idx].source.id(),
666                                refresh_ms = elapsed.as_millis(),
667                                "source refresh completed"
668                            );
669                            (result, elapsed)
670                        }
671                        Err(_) => (
672                            Err(SamplerError::SourceUnavailable {
673                                source_id: self.sources[idx].source.id().to_string(),
674                                reason: "source refresh thread panicked".into(),
675                            }),
676                            std::time::Duration::from_secs(0),
677                        ),
678                    };
679                    results[idx] = Some(result);
680                }
681            });
682
683            for (idx, _) in refresh_plan {
684                let Some((result, elapsed)) = results[idx].take() else {
685                    continue;
686                };
687                match result {
688                    Ok(snapshot) => {
689                        let SourceSnapshot {
690                            records,
691                            cursor: next_cursor,
692                        } = snapshot;
693                        let record_count = records.len();
694                        let seconds = elapsed.as_secs_f64();
695                        let per_sec = if seconds > 0.0 {
696                            (record_count as f64) / seconds
697                        } else {
698                            0.0
699                        };
700                        let stats = &mut self.sources[idx].stats;
701                        stats.last_refresh_ms = elapsed.as_millis();
702                        stats.last_record_count = record_count;
703                        stats.last_records_per_sec = per_sec;
704                        stats.last_error = None;
705                        debug!(
706                            source_id = %self.sources[idx].source.id(),
707                            record_count,
708                            refresh_ms = elapsed.as_millis(),
709                            records_per_sec = per_sec,
710                            "source refresh ingested records"
711                        );
712                        let source_id = self.sources[idx].source.id().to_string();
713                        let normalized = records
714                            .into_iter()
715                            .map(|mut record| {
716                                record.source = source_id.clone();
717                                record
718                            })
719                            .collect::<Vec<_>>();
720                        self.sources[idx].buffer.extend(normalized);
721                        self.sources[idx].cursor = Some(next_cursor);
722                    }
723                    Err(err) => {
724                        let stats = &mut self.sources[idx].stats;
725                        stats.last_refresh_ms = elapsed.as_millis();
726                        stats.last_record_count = 0;
727                        stats.last_records_per_sec = 0.0;
728                        stats.last_error = Some(err.to_string());
729                        stats.error_count = stats.error_count.saturating_add(1);
730                        warn!(
731                            source = %self.sources[idx].source.id(),
732                            error = %err,
733                            "Source refresh failed"
734                        );
735                    }
736                }
737            }
738        }
739
740        // On a full refresh (step=None) clear every per-source cache so that the
741        // snapshot always reflects the newest window, matching the previous
742        // shared-cache clear semantics.
743        if step.is_none() {
744            for state in self.sources.iter_mut() {
745                state.cache.clear();
746            }
747        }
748        if self.max_records == 0 {
749            return;
750        }
751        let target_limit = step.unwrap_or(self.max_records);
752        if let Some(weights) = weights {
753            self.weighted_drain_into_caches(target_limit, weights);
754        } else {
755            // Fair round-robin drain: start from `drain_start` instead of 0 so
756            // that the drain cursor rotates across cycles.  This prevents head
757            // sources (low indices) from always draining faster than tail sources,
758            // which was starving tail sources of refresh opportunities.
759            let n = self.sources.len();
760            if n > 0 {
761                let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); n];
762                let mut total_drained = 0;
763                let mut any_remaining = true;
764                while total_drained < target_limit && any_remaining {
765                    any_remaining = false;
766                    for offset in 0..n {
767                        if total_drained >= target_limit {
768                            break;
769                        }
770                        let idx = (self.drain_start + offset) % n;
771                        if let Some(record) = self.sources[idx].buffer.pop_front() {
772                            per_source[idx].push(record);
773                            total_drained += 1;
774                            any_remaining = true;
775                        }
776                    }
777                }
778                // Advance the drain cursor so the next cycle starts from a different
779                // position.  Only advance when at least one record was drained, so a
780                // burst of drain-noop cycles on an empty source list doesn't rotate.
781                if total_drained > 0 {
782                    self.drain_start = (self.drain_start + 1) % n;
783                }
784                // Increment global version for this batch
785                self.global_next_version = self.global_next_version.saturating_add(1);
786                for (idx, batch) in per_source.into_iter().enumerate() {
787                    if !batch.is_empty() {
788                        self.sources[idx]
789                            .cache
790                            .ingest_with_version(batch, self.global_next_version);
791                    }
792                }
793            }
794        }
795    }
796
797    fn weighted_drain_into_caches(&mut self, limit: usize, weights: &HashMap<SourceId, f32>) {
798        let len = self.sources.len();
799        if len == 0 {
800            return;
801        }
802        let mut weight_values = Vec::with_capacity(len);
803        let mut any_positive = false;
804        for state in &self.sources {
805            let weight = weights.get(state.source.id()).copied().unwrap_or(1.0);
806            if weight > 0.0 {
807                any_positive = true;
808            }
809            weight_values.push(weight);
810        }
811        if !any_positive {
812            weight_values.fill(1.0);
813        }
814
815        let mut current = vec![0.0f32; len];
816        let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); len];
817        let mut total = 0;
818        while total < limit {
819            let mut total_weight = 0.0f32;
820            for (idx, weight) in weight_values.iter().copied().enumerate().take(len) {
821                if weight <= 0.0 {
822                    continue;
823                }
824                if self.sources[idx].buffer.is_empty() {
825                    continue;
826                }
827                total_weight += weight;
828            }
829            if total_weight == 0.0 {
830                break;
831            }
832
833            let mut best_idx = None;
834            let mut best_score = f32::MIN;
835            // Rotating tie-breaker: when scores are equal, prefer the source
836            // just PAST drain_start in rotation order (i.e. the source whose
837            // turn is coming up in the round-robin).
838            let closer_to_start = |a: usize, b: usize| -> bool {
839                let da = (a + len - self.drain_start) % len;
840                let db = (b + len - self.drain_start) % len;
841                da < db
842            };
843            for idx in 0..len {
844                if weight_values[idx] <= 0.0 {
845                    continue;
846                }
847                if self.sources[idx].buffer.is_empty() {
848                    continue;
849                }
850                current[idx] += weight_values[idx];
851                let is_better = if current[idx] > best_score {
852                    true
853                } else if current[idx] == best_score {
854                    closer_to_start(idx, best_idx.unwrap_or(0))
855                } else {
856                    false
857                };
858                if is_better {
859                    best_score = current[idx];
860                    best_idx = Some(idx);
861                }
862            }
863
864            let idx = match best_idx {
865                Some(idx) => idx,
866                None => break,
867            };
868            current[idx] -= total_weight;
869            if let Some(record) = self.sources[idx].buffer.pop_front() {
870                per_source[idx].push(record);
871                total += 1;
872            }
873        }
874
875        if total > 0 && len > 0 {
876            self.drain_start = (self.drain_start + 1) % len;
877        }
878
879        // Increment global version for this batch
880        self.global_next_version = self.global_next_version.saturating_add(1);
881        for (idx, batch) in per_source.into_iter().enumerate() {
882            if !batch.is_empty() {
883                self.sources[idx]
884                    .cache
885                    .ingest_with_version(batch, self.global_next_version);
886            }
887        }
888    }
889
890    /// Returns `true` when at least one source is registered.
891    pub fn has_sources(&self) -> bool {
892        !self.sources.is_empty()
893    }
894
895    /// Return a delta of records added since `last_version` and record keys
896    /// evicted since `last_version`. Evicted ids are scoped to their source
897    /// here (per-source caches key by bare id, which is only unique within
898    /// one source). Queries all child caches using the unified version
899    /// watermark and aggregates the results.
900    pub fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordKey>) {
901        let mut all_added = Vec::new();
902        let mut all_evicted = Vec::new();
903        let mut max_version = last_version;
904
905        for state in &mut self.sources {
906            let (new_version, added, evicted) = state.cache.sync_delta(last_version);
907            if new_version > max_version {
908                max_version = new_version;
909            }
910            all_added.extend(added);
911            all_evicted.extend(
912                evicted
913                    .into_iter()
914                    .map(|id| RecordKey::new(state.source.id(), id)),
915            );
916        }
917
918        (max_version, all_added, all_evicted)
919    }
920
921    /// Return the current global version counter.
922    pub fn global_version(&self) -> u64 {
923        self.global_next_version
924    }
925}
926
927/// Per-source ingestion runtime state.
928struct SourceState {
929    source: Box<dyn DataSource + 'static>,
930    cursor: Option<SourceCursor>,
931    buffer: VecDeque<DataRecord>,
932    /// Per-source LRU record cache capped at `max_records`.
933    cache: RecordCache,
934    stats: SourceRefreshStats,
935}
936
937#[cfg(test)]
938// The unweighted batch-fetch convenience methods (`next_*_batch`,
939// `next_*_batch_for_split`, `prefetch_*_batches`) are deprecated in favor of the
940// `*_with_weights` variants. These tests intentionally exercise the unweighted
941// path, so suppress the deprecation lint here.
942#[allow(deprecated)]
943mod tests {
944    use super::*;
945    use crate::TripletSampler;
946    use crate::config::{Selector, TextRecipe, TripletRecipe};
947    use crate::data::{QualityScore, RecordSection, SectionRole};
948    use crate::sampler::Sampler;
949    use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
950    use chrono::Utc;
951    use std::collections::HashMap;
952    use std::collections::VecDeque;
953    use std::sync::atomic::{AtomicUsize, Ordering};
954    use std::sync::{Arc, Mutex};
955
956    fn make_record(id: &str, source: &str) -> DataRecord {
957        let now = Utc::now();
958        DataRecord {
959            id: id.to_string(),
960            source: source.to_string(),
961            created_at: now,
962            updated_at: now,
963            quality: QualityScore { trust: 1.0 },
964            taxonomy: Vec::new(),
965            sections: vec![RecordSection {
966                role: SectionRole::Anchor,
967                heading: None,
968                text: id.to_string(),
969                sentences: vec![id.to_string()],
970                token_count: 0,
971                embedding: None,
972            }],
973            meta_prefix: None,
974            label: None,
975        }
976    }
977
978    struct ScriptedSource {
979        id: String,
980        refreshes: Arc<AtomicUsize>,
981        script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
982    }
983
984    impl ScriptedSource {
985        fn new(
986            id: &str,
987            refreshes: Arc<AtomicUsize>,
988            script: Vec<Result<SourceSnapshot, SamplerError>>,
989        ) -> Self {
990            Self {
991                id: id.to_string(),
992                refreshes,
993                script: Arc::new(Mutex::new(script.into_iter().collect())),
994            }
995        }
996    }
997
998    impl DataSource for ScriptedSource {
999        fn id(&self) -> &str {
1000            &self.id
1001        }
1002
1003        fn refresh(
1004            &self,
1005            _config: &SamplerConfig,
1006            _cursor: Option<&SourceCursor>,
1007            _limit: Option<usize>,
1008        ) -> Result<SourceSnapshot, SamplerError> {
1009            self.refreshes.fetch_add(1, Ordering::SeqCst);
1010            let mut guard = self.script.lock().expect("script lock poisoned");
1011            guard.pop_front().unwrap_or_else(|| {
1012                Ok(SourceSnapshot {
1013                    records: Vec::new(),
1014                    cursor: SourceCursor {
1015                        last_seen: Utc::now(),
1016                        revision: 0,
1017                    },
1018                })
1019            })
1020        }
1021
1022        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1023            Ok(0)
1024        }
1025
1026        fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1027            Vec::new()
1028        }
1029    }
1030
1031    struct PanicSource {
1032        id: String,
1033    }
1034
1035    impl DataSource for PanicSource {
1036        fn id(&self) -> &str {
1037            &self.id
1038        }
1039
1040        fn refresh(
1041            &self,
1042            _config: &SamplerConfig,
1043            _cursor: Option<&SourceCursor>,
1044            _limit: Option<usize>,
1045        ) -> Result<SourceSnapshot, SamplerError> {
1046            panic!("panic source refresh")
1047        }
1048
1049        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1050            Ok(0)
1051        }
1052    }
1053
1054    #[test]
1055    fn record_cache_waits_len_and_clear_paths_are_covered() {
1056        let cache = RecordCache::new(2);
1057        assert!(cache.is_empty());
1058        assert_eq!(cache.len(), 0);
1059        assert_eq!(cache.ingest_count(), 0);
1060
1061        cache.ingest(Vec::<DataRecord>::new());
1062        assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
1063
1064        let cache_for_waiter = cache.clone();
1065        let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
1066        std::thread::sleep(Duration::from_millis(5));
1067        cache.ingest(vec![make_record("r1", "s")]);
1068        assert_eq!(handle.join().unwrap(), 1);
1069        assert_eq!(cache.ingest_count(), 1);
1070
1071        cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
1072        assert_eq!(cache.len(), 2);
1073        let ids: Vec<String> = cache
1074            .snapshot()
1075            .into_iter()
1076            .map(|record| record.id.clone())
1077            .collect();
1078        assert!(ids.contains(&"r2".to_string()));
1079        assert!(ids.contains(&"r3".to_string()));
1080
1081        cache.clear();
1082        assert!(cache.is_empty());
1083    }
1084
1085    #[test]
1086    fn record_cache_zero_limit_discards_everything() {
1087        let cache = RecordCache::new(0);
1088        cache.ingest(vec![make_record("r1", "s")]);
1089        assert!(cache.is_empty());
1090        assert_eq!(cache.len(), 0);
1091    }
1092
1093    #[test]
1094    fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
1095        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1096        assert!(!manager.has_sources());
1097        manager.load_cursors(&[]);
1098
1099        let refreshes = Arc::new(AtomicUsize::new(0));
1100        manager
1101            .register_source(Box::new(ScriptedSource::new(
1102                "cursor_source",
1103                refreshes,
1104                vec![Ok(SourceSnapshot {
1105                    records: vec![make_record("id_1", "original_source")],
1106                    cursor: SourceCursor {
1107                        last_seen: Utc::now(),
1108                        revision: 33,
1109                    },
1110                })],
1111            )))
1112            .unwrap();
1113        assert!(manager.has_sources());
1114
1115        manager.load_cursors(&[("cursor_source".to_string(), 7)]);
1116        let cursors = manager.snapshot_cursors();
1117        assert_eq!(cursors.len(), 1);
1118        assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
1119
1120        manager.refresh_all();
1121        let updated = manager.snapshot_cursors();
1122        assert_eq!(updated.len(), 1);
1123        assert_eq!(updated[0], ("cursor_source".to_string(), 33));
1124        let records = manager.all_records_snapshot();
1125        assert_eq!(records.len(), 1);
1126        assert_eq!(records[0].source, "cursor_source");
1127    }
1128
1129    #[test]
1130    fn advance_uses_buffer_before_refreshing_again() {
1131        let refreshes = Arc::new(AtomicUsize::new(0));
1132        let mut manager = IngestionManager::new(5, SamplerConfig::default());
1133        manager
1134            .register_source(Box::new(ScriptedSource::new(
1135                "buffered",
1136                refreshes.clone(),
1137                vec![Ok(SourceSnapshot {
1138                    records: vec![
1139                        make_record("a", "x"),
1140                        make_record("b", "x"),
1141                        make_record("c", "x"),
1142                    ],
1143                    cursor: SourceCursor {
1144                        last_seen: Utc::now(),
1145                        revision: 1,
1146                    },
1147                })],
1148            )))
1149            .unwrap();
1150
1151        manager.advance(1);
1152        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1153        assert_eq!(manager.all_records_len(), 1);
1154
1155        manager.advance(1);
1156        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1157        assert_eq!(manager.all_records_len(), 2);
1158    }
1159
1160    #[test]
1161    fn force_refresh_clears_buffer_and_fetches_again() {
1162        let refreshes = Arc::new(AtomicUsize::new(0));
1163        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1164        manager
1165            .register_source(Box::new(ScriptedSource::new(
1166                "force",
1167                refreshes.clone(),
1168                vec![
1169                    Ok(SourceSnapshot {
1170                        records: vec![
1171                            make_record("r1", "x"),
1172                            make_record("r2", "x"),
1173                            make_record("r3", "x"),
1174                        ],
1175                        cursor: SourceCursor {
1176                            last_seen: Utc::now(),
1177                            revision: 10,
1178                        },
1179                    }),
1180                    Ok(SourceSnapshot {
1181                        records: vec![make_record("r4", "x")],
1182                        cursor: SourceCursor {
1183                            last_seen: Utc::now(),
1184                            revision: 11,
1185                        },
1186                    }),
1187                ],
1188            )))
1189            .unwrap();
1190
1191        manager.advance(1);
1192        assert_eq!(manager.all_records_len(), 1);
1193        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1194
1195        manager.force_refresh_all();
1196        assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1197        let records = manager.all_records_snapshot();
1198        assert_eq!(records.len(), 1);
1199        assert_eq!(records[0].id, "r4");
1200    }
1201
1202    #[test]
1203    fn weighted_drain_respects_zero_and_fallback_weights() {
1204        let mut manager = IngestionManager::new(6, SamplerConfig::default());
1205        manager
1206            .register_source(Box::new(ScriptedSource::new(
1207                "a",
1208                Arc::new(AtomicUsize::new(0)),
1209                vec![Ok(SourceSnapshot {
1210                    records: vec![make_record("a1", "a"), make_record("a2", "a")],
1211                    cursor: SourceCursor {
1212                        last_seen: Utc::now(),
1213                        revision: 1,
1214                    },
1215                })],
1216            )))
1217            .unwrap();
1218        manager
1219            .register_source(Box::new(ScriptedSource::new(
1220                "b",
1221                Arc::new(AtomicUsize::new(0)),
1222                vec![Ok(SourceSnapshot {
1223                    records: vec![make_record("b1", "b"), make_record("b2", "b")],
1224                    cursor: SourceCursor {
1225                        last_seen: Utc::now(),
1226                        revision: 1,
1227                    },
1228                })],
1229            )))
1230            .unwrap();
1231
1232        let mut only_b = HashMap::new();
1233        only_b.insert("a".to_string(), 0.0);
1234        only_b.insert("b".to_string(), 1.0);
1235        manager.refresh_all_with_weights(&only_b).unwrap();
1236        let ids: Vec<String> = manager
1237            .all_records_snapshot()
1238            .into_iter()
1239            .map(|record| record.id.clone())
1240            .collect();
1241        assert!(ids.iter().all(|id| id.starts_with('b')));
1242
1243        let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1244        manager_fallback
1245            .register_source(Box::new(ScriptedSource::new(
1246                "a",
1247                Arc::new(AtomicUsize::new(0)),
1248                vec![Ok(SourceSnapshot {
1249                    records: vec![make_record("a1", "a")],
1250                    cursor: SourceCursor {
1251                        last_seen: Utc::now(),
1252                        revision: 2,
1253                    },
1254                })],
1255            )))
1256            .unwrap();
1257        manager_fallback
1258            .register_source(Box::new(ScriptedSource::new(
1259                "b",
1260                Arc::new(AtomicUsize::new(0)),
1261                vec![Ok(SourceSnapshot {
1262                    records: vec![make_record("b1", "b")],
1263                    cursor: SourceCursor {
1264                        last_seen: Utc::now(),
1265                        revision: 2,
1266                    },
1267                })],
1268            )))
1269            .unwrap();
1270
1271        let mut all_zero = HashMap::new();
1272        all_zero.insert("a".to_string(), 0.0);
1273        all_zero.insert("b".to_string(), 0.0);
1274        manager_fallback
1275            .refresh_all_with_weights(&all_zero)
1276            .unwrap();
1277        let ids: Vec<String> = manager_fallback
1278            .all_records_snapshot()
1279            .into_iter()
1280            .map(|record| record.id.clone())
1281            .collect();
1282        assert!(ids.contains(&"a1".to_string()));
1283        assert!(ids.contains(&"b1".to_string()));
1284    }
1285
1286    #[test]
1287    fn refresh_errors_and_panics_update_source_stats() {
1288        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1289        manager
1290            .register_source(Box::new(ScriptedSource::new(
1291                "err_source",
1292                Arc::new(AtomicUsize::new(0)),
1293                vec![Err(SamplerError::SourceUnavailable {
1294                    source_id: "err_source".to_string(),
1295                    reason: "boom".to_string(),
1296                })],
1297            )))
1298            .unwrap();
1299        manager
1300            .register_source(Box::new(PanicSource {
1301                id: "panic_source".to_string(),
1302            }))
1303            .unwrap();
1304
1305        manager.refresh_all();
1306        let stats = manager.source_refresh_stats();
1307        let err_stats = stats
1308            .iter()
1309            .find(|(source, _)| source == "err_source")
1310            .map(|(_, stats)| stats)
1311            .unwrap();
1312        assert_eq!(err_stats.error_count, 1);
1313        assert!(
1314            err_stats
1315                .last_error
1316                .as_ref()
1317                .is_some_and(|msg| msg.contains("boom"))
1318        );
1319
1320        let panic_stats = stats
1321            .iter()
1322            .find(|(source, _)| source == "panic_source")
1323            .map(|(_, stats)| stats)
1324            .unwrap();
1325        assert_eq!(panic_stats.error_count, 1);
1326        assert!(
1327            panic_stats
1328                .last_error
1329                .as_ref()
1330                .is_some_and(|msg| msg.contains("panicked"))
1331        );
1332    }
1333
1334    #[test]
1335    fn force_refresh_with_weights_path_is_exercised() {
1336        let mut manager = IngestionManager::new(3, SamplerConfig::default());
1337        manager
1338            .register_source(Box::new(ScriptedSource::new(
1339                "w",
1340                Arc::new(AtomicUsize::new(0)),
1341                vec![Ok(SourceSnapshot {
1342                    records: vec![make_record("w1", "w")],
1343                    cursor: SourceCursor {
1344                        last_seen: Utc::now(),
1345                        revision: 3,
1346                    },
1347                })],
1348            )))
1349            .unwrap();
1350
1351        let mut weights = HashMap::new();
1352        weights.insert("w".to_string(), 1.0);
1353        manager.force_refresh_all_with_weights(&weights).unwrap();
1354        assert_eq!(manager.all_records_len(), 1);
1355    }
1356
1357    #[test]
1358    fn advance_with_weights_rejects_unknown_source() {
1359        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1360        manager
1361            .register_source(Box::new(ScriptedSource::new(
1362                "known",
1363                Arc::new(AtomicUsize::new(0)),
1364                vec![],
1365            )))
1366            .unwrap();
1367
1368        let mut weights = HashMap::new();
1369        weights.insert("known".to_string(), 1.0);
1370        weights.insert("unknown".to_string(), 0.5);
1371
1372        let err = manager.advance_with_weights(1, &weights).unwrap_err();
1373        assert!(
1374            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1375            "expected InvalidWeight for unknown source, got {err:?}"
1376        );
1377    }
1378
1379    #[test]
1380    fn refresh_all_with_weights_rejects_negative_weight() {
1381        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1382        manager
1383            .register_source(Box::new(ScriptedSource::new(
1384                "src",
1385                Arc::new(AtomicUsize::new(0)),
1386                vec![],
1387            )))
1388            .unwrap();
1389
1390        let mut weights = HashMap::new();
1391        weights.insert("src".to_string(), -1.0);
1392
1393        let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1394        assert!(
1395            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1396            "expected InvalidWeight for negative weight, got {err:?}"
1397        );
1398    }
1399
1400    #[test]
1401    fn force_refresh_all_with_weights_rejects_unknown_source() {
1402        let mut manager = IngestionManager::new(4, SamplerConfig::default());
1403        manager
1404            .register_source(Box::new(ScriptedSource::new(
1405                "real",
1406                Arc::new(AtomicUsize::new(0)),
1407                vec![],
1408            )))
1409            .unwrap();
1410
1411        let mut weights = HashMap::new();
1412        weights.insert("ghost".to_string(), 1.0);
1413
1414        let err = manager
1415            .force_refresh_all_with_weights(&weights)
1416            .unwrap_err();
1417        assert!(
1418            matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1419            "expected InvalidWeight for unknown source, got {err:?}"
1420        );
1421    }
1422
1423    /// A source that records the `config.seed` value it receives on each `refresh()` call.
1424    struct SeedCapturingSource {
1425        id: String,
1426        received_seeds: Arc<Mutex<Vec<u64>>>,
1427    }
1428
1429    impl SeedCapturingSource {
1430        fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1431            Self {
1432                id: id.to_string(),
1433                received_seeds,
1434            }
1435        }
1436    }
1437
1438    impl DataSource for SeedCapturingSource {
1439        fn id(&self) -> &str {
1440            &self.id
1441        }
1442
1443        fn refresh(
1444            &self,
1445            config: &SamplerConfig,
1446            _cursor: Option<&SourceCursor>,
1447            _limit: Option<usize>,
1448        ) -> Result<SourceSnapshot, SamplerError> {
1449            self.received_seeds
1450                .lock()
1451                .expect("seed lock poisoned")
1452                .push(config.seed);
1453            Ok(SourceSnapshot {
1454                records: Vec::new(),
1455                cursor: SourceCursor {
1456                    last_seen: Utc::now(),
1457                    revision: 0,
1458                },
1459            })
1460        }
1461
1462        fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1463            Ok(0)
1464        }
1465
1466        fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1467            Vec::new()
1468        }
1469    }
1470
1471    #[test]
1472    fn epoch_xor_changes_seed_received_by_source() {
1473        // Verify that derive_epoch_seed(base, epoch) is actually threaded through to
1474        // the source's refresh() call, and that different epochs produce different seeds.
1475        let base_seed = 0xDEAD_BEEF_u64;
1476        let config = SamplerConfig {
1477            seed: base_seed,
1478            ..SamplerConfig::default()
1479        };
1480
1481        let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1482        let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1483
1484        // --- epoch 0 ---
1485        let mut manager = IngestionManager::new(4, config.clone());
1486        manager
1487            .register_source(Box::new(SeedCapturingSource::new(
1488                "src",
1489                Arc::clone(&seeds_epoch0),
1490            )))
1491            .unwrap();
1492        // epoch defaults to 0; refresh_all passes derive_epoch_seed(base, 0)
1493        manager.refresh_all();
1494
1495        // --- epoch 1 ---
1496        let mut manager2 = IngestionManager::new(4, config.clone());
1497        manager2
1498            .register_source(Box::new(SeedCapturingSource::new(
1499                "src",
1500                Arc::clone(&seeds_epoch1),
1501            )))
1502            .unwrap();
1503        manager2.set_epoch(1);
1504        manager2.refresh_all();
1505
1506        let received0 = seeds_epoch0.lock().unwrap();
1507        let received1 = seeds_epoch1.lock().unwrap();
1508
1509        assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1510        assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1511
1512        let seed_at_epoch0 = received0[0];
1513        let seed_at_epoch1 = received1[0];
1514
1515        // The seeds must differ — epoch XOR has a real effect.
1516        assert_ne!(
1517            seed_at_epoch0, seed_at_epoch1,
1518            "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1519             derive_epoch_seed is not reaching the source"
1520        );
1521
1522        // They must match the expected derive_epoch_seed values.
1523        // epoch_step stays at 0 for direct refresh_all calls
1524        // (it's only bumped by next_*_batch calls).
1525        assert_eq!(
1526            seed_at_epoch0,
1527            derive_epoch_seed(base_seed, 0),
1528            "epoch-0 seed mismatch (epoch_step=0)"
1529        );
1530        assert_eq!(
1531            seed_at_epoch1,
1532            derive_epoch_seed(base_seed, 1),
1533            "epoch-1 seed mismatch (epoch_step=0)"
1534        );
1535    }
1536
1537    #[test]
1538    fn epoch_step_resets_on_epoch_change() {
1539        // Proves that set_epoch + reset_epoch_step gives epoch_step=0 so each
1540        // epoch produces the same step sequence starting from step 1.
1541        let config = SamplerConfig::default();
1542        let seeds = Arc::new(Mutex::new(Vec::new()));
1543
1544        let mut manager = IngestionManager::new(4, config.clone());
1545        manager
1546            .register_source(Box::new(SeedCapturingSource::new(
1547                "src",
1548                Arc::clone(&seeds),
1549            )))
1550            .unwrap();
1551
1552        // Epoch 0, first refresh: epoch_step stays at 0 (no longer
1553        // incremented by refresh_all — it's bumped per batch call instead).
1554        manager.refresh_all();
1555        let step1_seed = seeds.lock().unwrap()[0];
1556        assert_eq!(
1557            step1_seed,
1558            derive_epoch_seed(config.seed, 0),
1559            "epoch 0 step 0 seed"
1560        );
1561
1562        // Advance epoch — epoch_step must stay 0 (no batch calls yet).
1563        manager.set_epoch(1);
1564        assert_eq!(manager.epoch_step(), 0);
1565        seeds.lock().unwrap().clear();
1566
1567        // Epoch 1, first refresh: step stays at 0 (no batch calls in this test).
1568        manager.refresh_all();
1569        let step1_epoch1 = seeds.lock().unwrap()[0];
1570        assert_eq!(
1571            step1_epoch1,
1572            derive_epoch_seed(config.seed, 1),
1573            "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1574        );
1575    }
1576
1577    #[test]
1578    fn epoch_step_survives_sampler_save_and_load_state() {
1579        // Proves the epoch step survives through the REAL API path:
1580        //   TripletSampler::save_sampler_state → persist_source_state
1581        //   → self.ingestion.snapshot_cursors() internally
1582        //   → DeterministicSplitStore.save_sampler_state
1583        //   → load_sampler_state → load_cursors
1584        let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1585
1586        // Sampler with ScriptedSource (provides empty recipes but that's fine)
1587        let sampler = TripletSampler::new(
1588            SamplerConfig {
1589                seed: 42,
1590                ..SamplerConfig::default()
1591            },
1592            Arc::clone(&store),
1593        );
1594        let refreshes = Arc::new(AtomicUsize::new(0));
1595        sampler
1596            .register_source(Box::new(ScriptedSource::new(
1597                "src",
1598                refreshes,
1599                vec![Ok(SourceSnapshot {
1600                    records: vec![make_record("r1", "src")],
1601                    cursor: SourceCursor {
1602                        last_seen: Utc::now(),
1603                        revision: 1,
1604                    },
1605                })],
1606            )))
1607            .unwrap();
1608
1609        // Call a batch method to trigger ensure_source_state() so
1610        // save_sampler_state actually writes.  The batch itself may
1611        // exhaust (no recipes) but the state is loaded regardless.
1612        let _ = sampler.next_text_batch(SplitLabel::Train);
1613
1614        // Save through the REAL sampler API
1615        sampler.save_sampler_state(None).unwrap();
1616
1617        // Load back what the sampler saved
1618        let loaded = store.load_sampler_state().unwrap().unwrap();
1619        let step_saved = loaded.epoch_step;
1620        assert!(
1621            step_saved > 0,
1622            "epoch_step must be >0 after batch call through TripletSampler"
1623        );
1624
1625        // Feed the REAL loaded cursors to a new manager
1626        let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1627        manager2
1628            .register_source(Box::new(ScriptedSource::new(
1629                "src",
1630                Arc::new(AtomicUsize::new(0)),
1631                vec![Ok(SourceSnapshot {
1632                    records: vec![make_record("r2", "src")],
1633                    cursor: SourceCursor {
1634                        last_seen: Utc::now(),
1635                        revision: 2,
1636                    },
1637                })],
1638            )))
1639            .unwrap();
1640        manager2.load_cursors(&loaded.source_stream_cursors);
1641        manager2.set_epoch_step(loaded.epoch_step);
1642
1643        // epoch_step restored to saved value; verify it's stable (no batch
1644        // call was made, so step does not change).
1645        let step_before = manager2.epoch_step();
1646        assert_eq!(
1647            step_before, step_saved,
1648            "load_cursors must restore epoch_step to saved value"
1649        );
1650
1651        // Verify the step is still present after a refresh (no increment).
1652        manager2.refresh_all();
1653        let step_after = manager2.epoch_step();
1654        assert_eq!(
1655            step_after, step_saved,
1656            "epoch_step must survive refresh_all without increment (step is per-batch,
1657             not per-refresh): loaded {step_saved}, got {step_after}"
1658        );
1659    }
1660
1661    #[test]
1662    fn scripted_and_panic_sources_cover_default_trait_paths() {
1663        let refreshes = Arc::new(AtomicUsize::new(0));
1664        let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1665
1666        // Empty script falls back to an empty snapshot.
1667        let snapshot = scripted
1668            .refresh(&SamplerConfig::default(), None, None)
1669            .expect("fallback snapshot");
1670        assert!(snapshot.records.is_empty());
1671        assert_eq!(snapshot.cursor.revision, 0);
1672
1673        assert_eq!(
1674            scripted
1675                .reported_record_count(&SamplerConfig::default())
1676                .expect("record count"),
1677            0
1678        );
1679        assert!(scripted.default_triplet_recipes().is_empty());
1680
1681        let panic_source = PanicSource {
1682            id: "panic_count".to_string(),
1683        };
1684        assert_eq!(
1685            panic_source
1686                .reported_record_count(&SamplerConfig::default())
1687                .expect("record count"),
1688            0
1689        );
1690    }
1691
1692    #[test]
1693    fn seed_capturing_source_trait_defaults_are_exercised() {
1694        let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1695        assert_eq!(
1696            source
1697                .reported_record_count(&SamplerConfig::default())
1698                .expect("record count"),
1699            0
1700        );
1701        assert!(source.default_triplet_recipes().is_empty());
1702    }
1703
1704    #[test]
1705    fn refresh_paths_handle_zero_capacity_and_no_sources() {
1706        let mut manager = IngestionManager::new(0, SamplerConfig::default());
1707        manager
1708            .register_source(Box::new(ScriptedSource::new(
1709                "zero_capacity",
1710                Arc::new(AtomicUsize::new(0)),
1711                vec![Ok(SourceSnapshot {
1712                    records: vec![make_record("r1", "zero_capacity")],
1713                    cursor: SourceCursor {
1714                        last_seen: Utc::now(),
1715                        revision: 1,
1716                    },
1717                })],
1718            )))
1719            .unwrap();
1720        manager.refresh_all();
1721        assert!(manager.all_caches_empty());
1722
1723        // Weighted refresh with no sources should be a no-op.
1724        let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1725        let empty_weights = HashMap::new();
1726        empty_manager
1727            .refresh_all_with_weights(&empty_weights)
1728            .expect("no sources should not error");
1729        assert!(empty_manager.all_caches_empty());
1730    }
1731
1732    #[test]
1733    fn drain_start_rotates_fairly_across_sources() {
1734        // Create 3 sources, each with 10 records in their buffer after refresh.
1735        // The fair round-robin should ensure all 3 drain at the same rate
1736        // over multiple advance cycles.
1737        struct FairSource {
1738            id: String,
1739            refresh_count: Arc<AtomicUsize>,
1740        }
1741
1742        impl DataSource for FairSource {
1743            fn id(&self) -> &str {
1744                &self.id
1745            }
1746            fn refresh(
1747                &self,
1748                _config: &SamplerConfig,
1749                _cursor: Option<&SourceCursor>,
1750                _limit: Option<usize>,
1751            ) -> Result<SourceSnapshot, SamplerError> {
1752                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1753                Ok(SourceSnapshot {
1754                    records: (0..10)
1755                        .map(|i| make_record(&format!("r{i}"), &self.id))
1756                        .collect(),
1757                    cursor: SourceCursor {
1758                        last_seen: Utc::now(),
1759                        revision: 1,
1760                    },
1761                })
1762            }
1763            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1764                Ok(10)
1765            }
1766        }
1767
1768        let counts = (
1769            Arc::new(AtomicUsize::new(0)),
1770            Arc::new(AtomicUsize::new(0)),
1771            Arc::new(AtomicUsize::new(0)),
1772        );
1773
1774        let mut manager = IngestionManager::new(30, SamplerConfig::default());
1775        manager
1776            .register_source(Box::new(FairSource {
1777                id: "src_0".to_string(),
1778                refresh_count: Arc::clone(&counts.0),
1779            }))
1780            .unwrap();
1781        manager
1782            .register_source(Box::new(FairSource {
1783                id: "src_1".to_string(),
1784                refresh_count: Arc::clone(&counts.1),
1785            }))
1786            .unwrap();
1787        manager
1788            .register_source(Box::new(FairSource {
1789                id: "src_2".to_string(),
1790                refresh_count: Arc::clone(&counts.2),
1791            }))
1792            .unwrap();
1793
1794        // First refresh_all fills all buffers.
1795        manager.refresh_all();
1796        // All 3 refreshed once.
1797        assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1798        assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1799        assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1800
1801        // Each advance(1) drains 1 record from 1 source, rotating via
1802        // drain_start.  With 3 sources, after 3 advances each source
1803        // loses 1 record.  After 30 advances each source loses 10 records
1804        // and triggers a refresh.  Run 33 advances and check all 3 refreshed
1805        // roughly the same number of times.
1806        for _ in 0..33 {
1807            manager.advance(1);
1808        }
1809
1810        let r0 = counts.0.load(Ordering::SeqCst);
1811        let r1 = counts.1.load(Ordering::SeqCst);
1812        let r2 = counts.2.load(Ordering::SeqCst);
1813
1814        // Each had 10 records after initial refresh_all.
1815        // 10 records / (1 drained per 3 cycles) = 30 cycles to drain each buffer.
1816        // After 33 more advances each buffer emptied ~1 time and re-filled, so
1817        // each source should have refreshed ~2 times total (initial + 1 drain).
1818        // The exact count can vary by 1 due to timing, but all 3 must be within
1819        // 1 of each other — no source can be starved.
1820        let min = r0.min(r1).min(r2);
1821        let max = r0.max(r1).max(r2);
1822        assert!(
1823            max <= min + 1,
1824            "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1825        );
1826    }
1827
1828    #[test]
1829    fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1830        // Direct IngestionManager test with batch_size=2, 5 sources.
1831        // Isolates the drain_start rotation from the sampler pipeline.
1832        struct SimpleSource {
1833            id: String,
1834            refresh_count: Arc<AtomicUsize>,
1835        }
1836
1837        impl DataSource for SimpleSource {
1838            fn id(&self) -> &str {
1839                &self.id
1840            }
1841            fn refresh(
1842                &self,
1843                _: &SamplerConfig,
1844                _: Option<&SourceCursor>,
1845                _: Option<usize>,
1846            ) -> Result<SourceSnapshot, SamplerError> {
1847                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1848                let now = Utc::now();
1849                let records: Vec<DataRecord> = (0..8)
1850                    .map(|i| DataRecord {
1851                        id: format!("{}_r{i}", self.id),
1852                        source: self.id.clone(),
1853                        created_at: now,
1854                        updated_at: now,
1855                        quality: QualityScore { trust: 1.0 },
1856                        taxonomy: Vec::new(),
1857                        sections: Vec::new(),
1858                        meta_prefix: None,
1859                        label: None,
1860                    })
1861                    .collect();
1862                Ok(SourceSnapshot {
1863                    records,
1864                    cursor: SourceCursor {
1865                        last_seen: now,
1866                        revision: 1,
1867                    },
1868                })
1869            }
1870            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1871                Ok(8)
1872            }
1873        }
1874
1875        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1876        let mut manager = IngestionManager::new(40, SamplerConfig::default());
1877        for (i, count) in counts.iter().enumerate() {
1878            manager
1879                .register_source(Box::new(SimpleSource {
1880                    id: format!("src_{i}"),
1881                    refresh_count: Arc::clone(count),
1882                }))
1883                .unwrap();
1884        }
1885
1886        manager.refresh_all();
1887        for c in &counts {
1888            assert_eq!(c.load(Ordering::SeqCst), 1);
1889        }
1890
1891        for _ in 0..80 {
1892            manager.advance(2);
1893        }
1894
1895        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1896        // Round-robin drain with 5 sources, 8 records each, batch_size=2, 80
1897        // advance calls.  Most sources refresh 5×; sources 2-4 get one extra
1898        // cycle (6) due to the rotating drain_start tie-breaker positioning.
1899        assert_eq!(
1900            totals,
1901            vec![5, 5, 6, 6, 6],
1902            "direct manager: unexpected refresh distribution"
1903        );
1904    }
1905
1906    /// Helper: create 5 sources with 8 records each and a sampler configured for
1907    /// text batches with batch_size=2.  Returns the sampler and refresh counters.
1908    fn make_five_source_sampler(
1909        counts: &[Arc<AtomicUsize>],
1910    ) -> TripletSampler<DeterministicSplitStore> {
1911        struct Tracked {
1912            id: String,
1913            refresh_count: Arc<AtomicUsize>,
1914        }
1915        impl DataSource for Tracked {
1916            fn id(&self) -> &str {
1917                &self.id
1918            }
1919            fn refresh(
1920                &self,
1921                _: &SamplerConfig,
1922                _: Option<&SourceCursor>,
1923                _: Option<usize>,
1924            ) -> Result<SourceSnapshot, SamplerError> {
1925                self.refresh_count.fetch_add(1, Ordering::SeqCst);
1926                let now = Utc::now();
1927                let records: Vec<DataRecord> = (0..8)
1928                    .map(|i| DataRecord {
1929                        id: format!("{}_r{i}", self.id),
1930                        source: self.id.clone(),
1931                        created_at: now,
1932                        updated_at: now,
1933                        quality: QualityScore { trust: 1.0 },
1934                        taxonomy: Vec::new(),
1935                        sections: vec![RecordSection {
1936                            role: SectionRole::Anchor,
1937                            heading: None,
1938                            text: format!("{}_x{}", self.id, i),
1939                            sentences: vec![format!("{}_x{}", self.id, i)],
1940                            token_count: 0,
1941                            embedding: None,
1942                        }],
1943                        meta_prefix: None,
1944                        label: None,
1945                    })
1946                    .collect();
1947                Ok(SourceSnapshot {
1948                    records,
1949                    cursor: SourceCursor {
1950                        last_seen: now,
1951                        revision: 1,
1952                    },
1953                })
1954            }
1955            fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1956                Ok(8)
1957            }
1958        }
1959
1960        let config = SamplerConfig {
1961            batch_size: 2,
1962            text_recipes: vec![TextRecipe {
1963                name: "anchor".into(),
1964                selector: Selector::Role(SectionRole::Anchor),
1965                weight: 1.0,
1966                instruction: None,
1967            }],
1968            split: SplitRatios {
1969                train: 1.0,
1970                validation: 0.0,
1971                test: 0.0,
1972            },
1973            allowed_splits: vec![SplitLabel::Train],
1974            // Small enough that the cache slides on every advance, changing
1975            // the record pool each batch.  Without this, all 40 records fit in
1976            // the cache permanently and the cross-batch text dedup would never
1977            // clear `emitted_texts`, causing early Exhausted after only a few
1978            // batches (only 8 unique texts across 40 records).
1979            ingestion_max_records: 4,
1980            ..SamplerConfig::default()
1981        };
1982        let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1983        let sampler = TripletSampler::new(config, store);
1984
1985        for (i, count) in counts.iter().enumerate() {
1986            sampler
1987                .register_source(Box::new(Tracked {
1988                    id: format!("src_{i}"),
1989                    refresh_count: Arc::clone(count),
1990                }))
1991                .unwrap();
1992        }
1993        sampler
1994    }
1995
1996    #[test]
1997    fn sampler_unweighted_drain_distributes_evenly() {
1998        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1999        let sampler = make_five_source_sampler(&counts);
2000
2001        sampler.next_text_batch(SplitLabel::Train).unwrap();
2002        for _ in 0..80 {
2003            sampler.next_text_batch(SplitLabel::Train).unwrap();
2004        }
2005
2006        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2007        let min = *totals.iter().min().unwrap();
2008        let max = *totals.iter().max().unwrap();
2009        assert!(
2010            max <= min + 1,
2011            "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
2012        );
2013        assert!(
2014            min >= 4,
2015            "unweighted: each source should have refreshed at least 4 times: {totals:?}"
2016        );
2017    }
2018
2019    #[test]
2020    fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
2021        // Same as unweighted but goes through the public weighted API with equal
2022        // weights for all 5 sources — verifies the weighted drain also rotates
2023        // fairly via drain_start bias.
2024        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2025        let sampler = make_five_source_sampler(&counts);
2026
2027        let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2028
2029        sampler
2030            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2031            .unwrap();
2032        for _ in 0..80 {
2033            sampler
2034                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2035                .unwrap();
2036        }
2037
2038        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2039        let min = *totals.iter().min().unwrap();
2040        let max = *totals.iter().max().unwrap();
2041        assert!(
2042            max <= min + 1,
2043            "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
2044        );
2045        assert!(
2046            min >= 4,
2047            "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
2048        );
2049    }
2050
2051    #[test]
2052    fn sampler_unweighted_and_weighted_match_distribution() {
2053        // Verify both paths produce similar refresh distributions.
2054        let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2055        let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2056        let usampler = make_five_source_sampler(&uc);
2057        let wsampler = make_five_source_sampler(&wc);
2058
2059        let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2060
2061        usampler.next_text_batch(SplitLabel::Train).unwrap();
2062        wsampler
2063            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2064            .unwrap();
2065        for _ in 0..80 {
2066            usampler.next_text_batch(SplitLabel::Train).unwrap();
2067            wsampler
2068                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2069                .unwrap();
2070        }
2071
2072        let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2073        let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2074        let umax = *ut.iter().max().unwrap();
2075        let umin = *ut.iter().min().unwrap();
2076        let wmax = *wt.iter().max().unwrap();
2077        let wmin = *wt.iter().min().unwrap();
2078        assert!(umax <= umin + 1, "unweighted: {ut:?}");
2079        assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
2080    }
2081
2082    #[test]
2083    fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
2084        // Source 3 gets weight 2.0, others get 1.0.  The weighted
2085        // proportional-fair drain must give source 3 proportionally more
2086        // refresh cycles than any weight-1.0 source.
2087        let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2088        let sampler = make_five_source_sampler(&counts);
2089
2090        let mut weights = HashMap::new();
2091        for i in 0..5 {
2092            weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
2093        }
2094
2095        sampler
2096            .next_text_batch_with_weights(SplitLabel::Train, &weights)
2097            .unwrap();
2098        for _ in 0..200 {
2099            sampler
2100                .next_text_batch_with_weights(SplitLabel::Train, &weights)
2101                .unwrap();
2102        }
2103
2104        let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2105        // The highest-weight source (src_3, w=2.0) must have strictly more
2106        // refreshes than EVERY weight-1.0 source.
2107        // Note: src_4 (index 4, w=1.0) gets 12 refreshes while the other
2108        // weight-1.0 sources get 6-7.  This happens because the proportional-
2109        // fair scheduler uses drain_start as a rotating tie-breaker — being
2110        // positioned right after the high-weight source (src_3) gives src_4
2111        // extra wins when src_3's buffer empties and refills more frequently.
2112        assert!(
2113            totals
2114                .iter()
2115                .enumerate()
2116                .all(|(i, &t)| i == 3 || t < totals[3]),
2117            "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
2118        );
2119        // Lock in the exact deterministic distribution.
2120        // (Distribution changed after per-source text uniqueness: each source
2121        // now emits distinct texts (`{id}_x{i}` instead of shared `x{i}`), so
2122        // cross-batch text dedup no longer collides across sources and the
2123        // sampler exhausts and force-refreshes less often, which reduces the
2124        // total number of refresh cycles but preserves the weighted
2125        // distribution pattern. The key invariant (src_3 with w=2.0 outpaces
2126        // all w=1.0 sources) is unchanged.)
2127        assert_eq!(
2128            totals,
2129            vec![6, 6, 6, 26, 11],
2130            "unequal-weights: unexpected refresh distribution"
2131        );
2132    }
2133
2134    #[test]
2135    fn register_source_rejects_reserved_id_pattern() {
2136        use crate::constants::splits::is_reserved_source_id;
2137
2138        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2139
2140        // Verify the utility function catches common patterns
2141        assert!(is_reserved_source_id("__meta__"));
2142        assert!(is_reserved_source_id("__anything__"));
2143        assert!(is_reserved_source_id("__x__"));
2144        assert!(!is_reserved_source_id(""));
2145        assert!(!is_reserved_source_id("__"));
2146        assert!(!is_reserved_source_id("___"));
2147        assert!(!is_reserved_source_id("normal_source"));
2148        assert!(!is_reserved_source_id("_prefix_suffix_"));
2149        assert!(!is_reserved_source_id("__unclosed"));
2150        assert!(!is_reserved_source_id("unopened__"));
2151
2152        // Registering with a `__*__` id should fail
2153        let result = manager.register_source(Box::new(ScriptedSource::new(
2154            "__reserved__",
2155            Arc::new(AtomicUsize::new(0)),
2156            vec![],
2157        )));
2158        assert!(
2159            result.is_err(),
2160            "register_source should reject reserved source id"
2161        );
2162        let err = result.unwrap_err();
2163        assert!(
2164            matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
2165            "expected ReservedSourceId error, got: {err}"
2166        );
2167
2168        // Verify source was NOT registered (still zero sources)
2169        assert!(!manager.has_sources());
2170
2171        // Normal source IDs still work
2172        manager
2173            .register_source(Box::new(ScriptedSource::new(
2174                "valid_source",
2175                Arc::new(AtomicUsize::new(0)),
2176                vec![],
2177            )))
2178            .unwrap();
2179        assert!(manager.has_sources());
2180    }
2181
2182    #[test]
2183    fn record_cache_ingest_with_version_empty_batch_is_noop() {
2184        let cache = RecordCache::new(4);
2185        cache.ingest_with_version(Vec::<DataRecord>::new(), 99);
2186        assert!(cache.is_empty());
2187        assert_eq!(cache.ingest_count(), 0);
2188    }
2189
2190    #[test]
2191    fn record_cache_ingest_with_version_assigns_explicit_version() {
2192        let cache = RecordCache::new(4);
2193        cache.ingest_with_version(vec![make_record("v1", "s")], 10);
2194        assert_eq!(cache.len(), 1);
2195        // A subsequent sync_delta from version 0 should see the record as added.
2196        let (_, added, evicted) = cache.sync_delta(0);
2197        assert_eq!(added.len(), 1);
2198        assert!(evicted.is_empty());
2199        assert_eq!(added[0].id, "v1");
2200    }
2201
2202    #[test]
2203    fn record_cache_sync_delta_returns_nothing_when_fully_consumed() {
2204        let cache = RecordCache::new(4);
2205        cache.ingest_with_version(vec![make_record("a", "s")], 5);
2206        // First pull consumes the delta.
2207        let (v1, added1, _) = cache.sync_delta(0);
2208        assert_eq!(added1.len(), 1);
2209        // Second pull with same watermark returns empty.
2210        let (v2, added2, evicted2) = cache.sync_delta(v1);
2211        assert!(added2.is_empty());
2212        assert!(evicted2.is_empty());
2213        assert_eq!(v2, v1);
2214    }
2215
2216    #[test]
2217    fn record_cache_sync_delta_reports_evictions_from_clear() {
2218        let cache = RecordCache::new(4);
2219        cache.ingest(vec![make_record("e1", "s"), make_record("e2", "s")]);
2220        // Consume the initial add.
2221        let (v1, _, _) = cache.sync_delta(0);
2222        assert!(v1 > 0);
2223        // Clear records — should log evictions.
2224        cache.clear();
2225        let (_, added, evicted) = cache.sync_delta(v1);
2226        assert!(added.is_empty());
2227        assert_eq!(evicted.len(), 2);
2228        assert!(evicted.contains(&"e1".to_string()));
2229        assert!(evicted.contains(&"e2".to_string()));
2230    }
2231
2232    #[test]
2233    fn record_cache_sync_delta_filters_evictions_by_version_watermark() {
2234        let cache = RecordCache::new(4);
2235        cache.ingest_with_version(vec![make_record("old", "s")], 1);
2236        let (v1, _, _) = cache.sync_delta(0);
2237        assert!(v1 >= 1);
2238        cache.clear();
2239        // Pull at the old watermark — should see eviction for "old".
2240        let (_, _, evicted_at_v1) = cache.sync_delta(v1);
2241        assert_eq!(evicted_at_v1.len(), 1);
2242        // Pull at a higher watermark that was bumped by clear — should be empty.
2243        let (_, _, evicted_at_higher) = cache.sync_delta(v1 + 1);
2244        assert!(evicted_at_higher.is_empty());
2245    }
2246
2247    #[test]
2248    fn record_cache_enforce_limit_trims_fifo_and_logs_evictions() {
2249        let cache = RecordCache::new(2);
2250        cache.ingest(vec![
2251            make_record("first", "s"),
2252            make_record("second", "s"),
2253            make_record("third", "s"),
2254        ]);
2255        // Cache should hold only 2 records.
2256        assert_eq!(cache.len(), 2);
2257        // Consume delta to establish a baseline version.
2258        let (v1, _, _) = cache.sync_delta(0);
2259        // Ingest one more to trigger eviction of "second" (FIFO order).
2260        cache.ingest(vec![make_record("fourth", "s")]);
2261        let (_, _, evicted) = cache.sync_delta(v1);
2262        // "first" was evicted when "third" was ingested, "second" when "fourth" was.
2263        // At minimum one eviction must appear.
2264        assert!(!evicted.is_empty());
2265    }
2266
2267    #[test]
2268    fn manager_force_refresh_generation_and_global_version() {
2269        let refreshes = Arc::new(AtomicUsize::new(0));
2270        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2271        assert_eq!(manager.force_refresh_generation(), 0);
2272        assert_eq!(manager.global_version(), 0);
2273
2274        manager
2275            .register_source(Box::new(ScriptedSource::new(
2276                "gen_src",
2277                refreshes,
2278                vec![Ok(SourceSnapshot {
2279                    records: vec![make_record("g1", "gen_src")],
2280                    cursor: SourceCursor {
2281                        last_seen: Utc::now(),
2282                        revision: 1,
2283                    },
2284                })],
2285            )))
2286            .unwrap();
2287
2288        manager.refresh_all();
2289        assert_eq!(manager.force_refresh_generation(), 0);
2290        assert!(manager.global_version() > 0);
2291
2292        let v_before = manager.global_version();
2293        manager.force_refresh_all();
2294        assert_eq!(manager.force_refresh_generation(), 1);
2295        assert!(manager.global_version() > v_before);
2296    }
2297
2298    #[test]
2299    fn manager_sync_delta_aggregates_across_sources() {
2300        let refreshes = Arc::new(AtomicUsize::new(0));
2301        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2302        manager
2303            .register_source(Box::new(ScriptedSource::new(
2304                "agg_a",
2305                refreshes.clone(),
2306                vec![Ok(SourceSnapshot {
2307                    records: vec![make_record("a1", "agg_a")],
2308                    cursor: SourceCursor {
2309                        last_seen: Utc::now(),
2310                        revision: 1,
2311                    },
2312                })],
2313            )))
2314            .unwrap();
2315        manager
2316            .register_source(Box::new(ScriptedSource::new(
2317                "agg_b",
2318                refreshes,
2319                vec![Ok(SourceSnapshot {
2320                    records: vec![make_record("b1", "agg_b")],
2321                    cursor: SourceCursor {
2322                        last_seen: Utc::now(),
2323                        revision: 1,
2324                    },
2325                })],
2326            )))
2327            .unwrap();
2328
2329        manager.refresh_all();
2330        let (version, added, evicted) = manager.sync_delta(0);
2331        assert!(version > 0);
2332        assert_eq!(added.len(), 2);
2333        let ids: Vec<&str> = added.iter().map(|r| r.id.as_str()).collect();
2334        assert!(ids.contains(&"a1"));
2335        assert!(ids.contains(&"b1"));
2336        assert!(evicted.is_empty());
2337
2338        // Second pull should be empty.
2339        let (_, added2, evicted2) = manager.sync_delta(version);
2340        assert!(added2.is_empty());
2341        assert!(evicted2.is_empty());
2342    }
2343
2344    #[test]
2345    fn manager_source_refresh_stats_returns_telemetry() {
2346        let refreshes = Arc::new(AtomicUsize::new(0));
2347        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2348        manager
2349            .register_source(Box::new(ScriptedSource::new(
2350                "stats_src",
2351                refreshes,
2352                vec![Ok(SourceSnapshot {
2353                    records: vec![make_record("s1", "stats_src")],
2354                    cursor: SourceCursor {
2355                        last_seen: Utc::now(),
2356                        revision: 1,
2357                    },
2358                })],
2359            )))
2360            .unwrap();
2361
2362        let stats = manager.source_refresh_stats();
2363        assert_eq!(stats.len(), 1);
2364        assert_eq!(stats[0].0, "stats_src");
2365        assert_eq!(stats[0].1.last_refresh_ms, 0);
2366
2367        manager.refresh_all();
2368        let stats = manager.source_refresh_stats();
2369        assert_eq!(stats[0].1.last_record_count, 1);
2370    }
2371
2372    #[test]
2373    fn record_cache_ingest_batch_replaces_existing_record() {
2374        let cache = RecordCache::new(4);
2375        // First ingest creates the record.
2376        cache.ingest(vec![make_record("dup", "s")]);
2377        assert_eq!(cache.len(), 1);
2378        // Re-ingest same id replaces it (does not duplicate).
2379        cache.ingest(vec![make_record("dup", "s")]);
2380        assert_eq!(cache.len(), 1);
2381    }
2382
2383    #[test]
2384    fn record_cache_ingest_with_version_replaces_existing_record() {
2385        let cache = RecordCache::new(4);
2386        cache.ingest_with_version(vec![make_record("vr", "s")], 5);
2387        let (v1, _, _) = cache.sync_delta(0);
2388        assert_eq!(cache.len(), 1);
2389
2390        // Re-ingest same id with higher version.
2391        cache.ingest_with_version(vec![make_record("vr", "s")], v1 + 5);
2392        let (_, added, evicted) = cache.sync_delta(v1);
2393        assert_eq!(added.len(), 1);
2394        assert!(evicted.is_empty());
2395        assert_eq!(cache.len(), 1);
2396    }
2397
2398    #[test]
2399    fn record_cache_enforce_limit_evicts_oldest_and_trims_eviction_log() {
2400        // Create a cache with max 3 records and ingest 5 — the eviction log
2401        // must stay bounded at max_records.
2402        let cache = RecordCache::new(3);
2403        for i in 0..5 {
2404            cache.ingest(vec![make_record(&format!("r{i}"), "s")]);
2405        }
2406        assert_eq!(cache.len(), 3);
2407
2408        // Consume delta to flush.
2409        let (v1, _, _) = cache.sync_delta(0);
2410
2411        // Verify eviction log is bounded by calling sync_delta —
2412        // should not panic or grow unbounded.
2413        let (_, added, evicted) = cache.sync_delta(v1);
2414        // New records from the last ingest may appear as added.
2415        // Evictions from the FIFO limit should appear.
2416        assert!(added.len() + evicted.len() <= 5);
2417    }
2418
2419    #[test]
2420    fn weighted_drain_breaks_when_all_buffers_empty() {
2421        let refreshes = Arc::new(AtomicUsize::new(0));
2422        let mut manager = IngestionManager::new(4, SamplerConfig::default());
2423        // Register source but don't refresh — buffer is empty.
2424        manager
2425            .register_source(Box::new(ScriptedSource::new(
2426                "empty_buf",
2427                refreshes,
2428                vec![],
2429            )))
2430            .unwrap();
2431
2432        let mut weights = HashMap::new();
2433        weights.insert("empty_buf".to_string(), 1.0);
2434        // advance_with_weights calls weighted_drain_into_caches with empty buffers.
2435        // Should not panic — the drain loop breaks when total_weight == 0.
2436        manager.advance_with_weights(1, &weights).unwrap();
2437        assert_eq!(manager.all_records_len(), 0);
2438    }
2439
2440    #[test]
2441    fn wait_for_ingest_times_out_without_notification() {
2442        let cache = RecordCache::new(4);
2443        // No ingest happens — should time out immediately.
2444        let result = cache.wait_for_ingest(0, Duration::from_millis(1));
2445        assert_eq!(result, 0);
2446    }
2447}