Skip to main content

triplets_core/sampler/
mod.rs

1mod backends;
2
3use crate::chunking::{ChunkingAlgorithm, SlidingWindowChunker};
4use chrono::Duration;
5use indexmap::IndexMap;
6use rand::prelude::*;
7use rayon::prelude::*;
8use std::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::mpsc;
13use std::sync::{Arc, Mutex};
14use std::thread;
15
16use crate::config::{
17    ChunkingStrategy, NegativeStrategy, SamplerConfig, Selector, TextRecipe, TripletRecipe,
18};
19use crate::constants::sampler::AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME;
20use crate::constants::sampler::{
21    ANCHOR_POSITIVE_SWAP_MASK, EPOCH_SEED_OFFSET, EXHAUSTION_RETRY_LIMIT, NEG_REASON_SAME_RECORD,
22    NEG_REASON_WRONG_ARTICLE, NEG_REASON_WRONG_DATE, NEG_REASON_WRONG_QA, PREFETCHER_SOURCE_ID,
23    PREFETCHER_STOPPED_REASON, RECIPE_LABEL_TEXT, RECIPE_LABEL_TRIPLETS,
24    RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER, ROLE_LABEL_ANCHOR, ROLE_LABEL_CONTEXT,
25    SAME_SELECTOR_PAIR_RETRY_LIMIT,
26};
27use crate::data::{
28    ChunkView, DataRecord, PairLabel, RecordChunk, RecordSection, SampleBatch, SamplePair,
29    SampleTriplet, SectionRole, TextBatch, TextSample, TripletBatch,
30};
31use crate::epoch::EpochTracker;
32use crate::errors::SamplerError;
33use crate::hash::{derive_epoch_seed, stable_hash_str};
34use crate::ingestion::IngestionManager;
35use crate::metadata::{META_FIELD_DATE, MetadataKey};
36use crate::metrics::{chunk_proximity_score, window_index_proximity};
37use crate::source::DataSource;
38use crate::splits::{
39    EpochStateStore, PersistedSamplerState, SamplerStateStore, SplitLabel, SplitStore,
40};
41use crate::tokenizer::{Tokenizer, WhitespaceTokenizer};
42use crate::types::{RecipeKey, RecordId, SourceId};
43use crate::utils::platform_newline;
44
45// AUTO-RECIPE HANDLING OVERVIEW (end-to-end):
46// Stage A: Source-level injection eligibility ("should this source even get the recipe?")
47//   - `sync_records_from_cache` -> `record_has_long_anchor_or_context_section` marks
48//     `sources_with_long_sections`.
49//   - `resolve_source_triplet_plan` -> `should_auto_inject_chunk_pair_recipe` appends
50//     `source_chunk_pair_recipe` for eligible sources.
51// Stage B: Record-level execution eligibility ("can this specific record run it now?")
52//   - `make_auto_chunk_pair_triplet_with_anchor` checks
53//     `record_has_at_least_two_window_chunks_for_selector` before sampling.
54// Stage C: Actual chunk window layout ("how are windows formed?")
55//   - `materialize_chunks` is the source of truth for token window construction.
56
57#[derive(Debug, Clone)]
58/// Small deterministic RNG used for reproducible sampler behavior.
59struct DeterministicRng {
60    state: u64,
61}
62
63impl DeterministicRng {
64    fn new(seed: u64) -> Self {
65        Self { state: seed }
66    }
67
68    fn from_state(state: u64) -> Self {
69        Self { state }
70    }
71
72    fn state(&self) -> u64 {
73        self.state
74    }
75
76    fn next_u64_internal(&mut self) -> u64 {
77        let mut z = self.state.wrapping_add(0x9E3779B97F4A7C15);
78        self.state = z;
79        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
80        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
81        z ^ (z >> 31)
82    }
83}
84
85impl rand::RngCore for DeterministicRng {
86    fn next_u32(&mut self) -> u32 {
87        self.next_u64_internal() as u32
88    }
89
90    fn next_u64(&mut self) -> u64 {
91        self.next_u64_internal()
92    }
93
94    fn fill_bytes(&mut self, dest: &mut [u8]) {
95        let mut offset = 0;
96        while offset < dest.len() {
97            let value = self.next_u64_internal();
98            let bytes = value.to_le_bytes();
99            let remaining = dest.len() - offset;
100            let copy_len = remaining.min(bytes.len());
101            dest[offset..offset + copy_len].copy_from_slice(&bytes[..copy_len]);
102            offset += copy_len;
103        }
104    }
105}
106
107/// Public helper so callers (and examples) can compute the same chunk weighting the sampler uses.
108///
109/// Definitions:
110/// - `trust` (range 0.0-1.0): comes from `RecordChunk.quality.trust`; scales the contribution of a chunk.
111/// - `base`: window chunks use proximity-to-head (`1 / (index + 1)`); summary chunks use their configured `summary_fallback_weight`.
112/// - `chunk_weight_floor`: minimum weight applied after scaling.
113///
114/// Formula: `max(chunk_weight_floor, base * trust)`.
115pub fn chunk_weight(strategy: &ChunkingStrategy, chunk: &RecordChunk) -> f32 {
116    let floor = strategy.chunk_weight_floor;
117    let trust = chunk.quality.trust.clamp(0.0, 1.0);
118    let base = match &chunk.view {
119        ChunkView::Window { index, .. } => window_index_proximity(*index),
120        ChunkView::SummaryFallback { weight, .. } => *weight,
121    };
122    (base * trust).max(floor)
123}
124
125/// Public sampling interface for pair, triplet, and text batch generation.
126pub trait Sampler {
127    /// Returns a batch of pairs. Consumes the shared epoch cursor for anchor selection.
128    fn next_pair_batch(&self, split: SplitLabel) -> Result<SampleBatch, SamplerError> {
129        self.next_pair_batch_with_weights(split, &HashMap::new())
130    }
131    /// Returns a batch of text samples. Consumes the shared epoch cursor for anchor selection.
132    fn next_text_batch(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
133        self.next_text_batch_with_weights(split, &HashMap::new())
134    }
135    /// Returns a batch of triplets. Consumes the shared epoch cursor for anchor selection.
136    fn next_triplet_batch(&self, split: SplitLabel) -> Result<TripletBatch, SamplerError> {
137        self.next_triplet_batch_with_weights(split, &HashMap::new())
138    }
139    /// Returns a batch of pairs with per-call source weights.
140    fn next_pair_batch_with_weights(
141        &self,
142        split: SplitLabel,
143        weights: &HashMap<SourceId, f32>,
144    ) -> Result<SampleBatch, SamplerError>;
145    /// Returns a batch of text samples with per-call source weights.
146    fn next_text_batch_with_weights(
147        &self,
148        split: SplitLabel,
149        weights: &HashMap<SourceId, f32>,
150    ) -> Result<TextBatch, SamplerError>;
151    /// Returns a batch of triplets with per-call source weights.
152    fn next_triplet_batch_with_weights(
153        &self,
154        split: SplitLabel,
155        weights: &HashMap<SourceId, f32>,
156    ) -> Result<TripletBatch, SamplerError>;
157    /// Persist sampler and split runtime state for restart-resume.
158    ///
159    /// Default is a no-op, allowing mock/test implementations to skip persistence.
160    fn save_sampler_state(&self, _save_to: Option<&std::path::Path>) -> Result<(), SamplerError> {
161        Ok(())
162    }
163}
164
165/// Background prefetcher that fills a bounded queue with sample batches.
166pub struct BatchPrefetcher<T> {
167    receiver: Option<mpsc::Receiver<Result<T, SamplerError>>>,
168    handle: Option<thread::JoinHandle<()>>,
169    stats: Arc<PrefetcherStats>,
170}
171
172#[derive(Default)]
173/// Prefetcher runtime counters.
174struct PrefetcherStats {
175    queued: AtomicUsize,
176    produced: AtomicUsize,
177    errors: AtomicUsize,
178}
179
180impl<T: Send + 'static> BatchPrefetcher<T> {
181    fn new<F>(capacity: usize, mut producer: F) -> Self
182    where
183        F: FnMut() -> Result<T, SamplerError> + Send + 'static,
184    {
185        let (sender, receiver) = mpsc::sync_channel(capacity.max(1));
186        let stats = Arc::new(PrefetcherStats::default());
187        let stats_thread = Arc::clone(&stats);
188        let handle = thread::spawn(move || {
189            loop {
190                let result = producer();
191                if result.is_err() {
192                    stats_thread.errors.fetch_add(1, Ordering::Relaxed);
193                }
194                if sender.send(result).is_err() {
195                    return;
196                }
197                stats_thread.queued.fetch_add(1, Ordering::Relaxed);
198                stats_thread.produced.fetch_add(1, Ordering::Relaxed);
199            }
200        });
201        Self {
202            receiver: Some(receiver),
203            handle: Some(handle),
204            stats,
205        }
206    }
207
208    /// Block until the next prefetched batch is available.
209    pub fn next(&self) -> Result<T, SamplerError> {
210        let receiver = self
211            .receiver
212            .as_ref()
213            .ok_or_else(|| SamplerError::SourceUnavailable {
214                source_id: PREFETCHER_SOURCE_ID.into(),
215                reason: PREFETCHER_STOPPED_REASON.into(),
216            })?;
217        let result = receiver.recv().unwrap_or_else(|_| {
218            Err(SamplerError::SourceUnavailable {
219                source_id: PREFETCHER_SOURCE_ID.into(),
220                reason: PREFETCHER_STOPPED_REASON.into(),
221            })
222        });
223        self.stats
224            .queued
225            .try_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
226                Some(value.saturating_sub(1))
227            })
228            .ok();
229        result
230    }
231
232    /// Number of prefetched batches currently queued.
233    pub fn queue_len(&self) -> usize {
234        self.stats.queued.load(Ordering::Relaxed)
235    }
236
237    /// Total number of batches produced by the background worker.
238    pub fn produced_count(&self) -> usize {
239        self.stats.produced.load(Ordering::Relaxed)
240    }
241
242    /// Total number of errors produced by the background worker.
243    pub fn error_count(&self) -> usize {
244        self.stats.errors.load(Ordering::Relaxed)
245    }
246}
247
248impl<T> Drop for BatchPrefetcher<T> {
249    fn drop(&mut self) {
250        self.receiver.take();
251        if let Some(handle) = self.handle.take() {
252            let _ = handle.join();
253        }
254    }
255}
256
257/// Sampler that draws anchors from a single shared epoch cursor and then
258/// selects chunks from those records. Ingestion happens on demand when sampling.
259pub struct TripletSampler<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
260    inner: Mutex<TripletSamplerInner<S>>,
261}
262
263/// Internal sampler state implementation guarded by `TripletSampler`.
264struct TripletSamplerInner<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
265    /// Immutable sampler configuration (seed, batch size, recipes, splits, etc.).
266    config: SamplerConfig,
267    /// Active chunking implementation used to materialize section chunks.
268    chunker: Arc<dyn ChunkingAlgorithm>,
269    /// Split store backing train/val/test assignments and persisted sampler state.
270    split_store: Arc<S>,
271    /// On-demand ingestion manager that fills the batch-sized buffer.
272    ingestion: IngestionManager,
273    /// Current in-memory record pool keyed by record id.
274    records: IndexMap<RecordId, Arc<DataRecord>>,
275    /// Deterministic RNG for per-batch shuffles and sampling.
276    rng: DeterministicRng,
277    /// Config-level triplet recipes used when sources do not supply their own.
278    triplet_recipes: Vec<TripletRecipe>,
279    /// Config-level text recipes used when sources do not supply their own.
280    text_recipes: Vec<TextRecipe>,
281    /// Per-source triplet recipes keyed by source id.
282    source_triplet_recipes: HashMap<SourceId, Vec<TripletRecipe>>,
283    /// Sources that currently contain at least one section larger than the chunk window.
284    sources_with_long_sections: HashSet<SourceId>,
285    /// Per-source text recipes keyed by source id.
286    source_text_recipes: HashMap<SourceId, Vec<TextRecipe>>,
287    /// True if triplet recipes came from config (no source defaults).
288    using_config_triplet_recipes: bool,
289    /// True if text recipes came from config (no derivation).
290    using_config_text_recipes: bool,
291    /// Last observed ingestion counter for cache updates.
292    last_observed_ingest: u64,
293    /// Epoch tracker for split-aware deterministic sampling.
294    epoch_tracker: EpochTracker,
295    /// Per-record, per-section chunk cursor to rotate through chunk windows.
296    chunk_cursors: HashMap<(RecordId, usize), usize>,
297    /// Per-record, per-role cursor to rotate through role-specific sections.
298    role_cursors: HashMap<(RecordId, String), usize>,
299    /// Pluggable negative-selection backend (uniform-random or BM25).
300    negative_backend: Box<dyn backends::NegativeBackend>,
301    /// Chunk id to record id lookup (used by epoch tracker).
302    chunk_index: HashMap<RecordId, RecordId>,
303    /// Round-robin order of source ids (deterministic).
304    source_order: Vec<SourceId>,
305    /// Current index into `source_order` for shuffled-cycle sampling.
306    source_cycle_idx: usize,
307    /// True once persisted source state is loaded.
308    source_state_loaded: bool,
309    /// True once persisted ingestion cursors are loaded.
310    ingestion_cursors_loaded: bool,
311    /// Marks whether source state needs persistence.
312    source_state_dirty: bool,
313    /// Record indices per source for round-robin within a source.
314    source_record_indices: HashMap<SourceId, Vec<usize>>,
315    /// Per-source cursor into `source_record_indices`.
316    source_record_cursors: HashMap<SourceId, usize>,
317    /// Round-robin index for triplet recipe cycling.
318    triplet_recipe_rr_idx: usize,
319    /// Hashes of text already emitted by the text batch path while the record
320    /// pool is stable.  Pruned on sync_records_from_cache only when records
321    /// are evicted.  Prevents the same text from being sampled again within
322    /// the same ingestion window, regardless of source wrapping or epoch.
323    ///
324    /// Per-record tracking lets us surgically remove hashes only for evicted
325    /// records, preserving cross-batch dedup across small window advances
326    /// (e.g. window=512, batch_size=4 where only 4/512 records are swapped).
327    emitted_text_hashes: HashMap<RecordId, HashSet<u64>>,
328    /// Round-robin index for text recipe cycling.
329    text_recipe_rr_idx: usize,
330    /// Epoch counter for per-source deterministic shuffling (seed ^ epoch).
331    epoch: u64,
332
333    /// Tracks whether each source has wrapped its cursor in the current epoch.
334    source_wrapped: HashMap<SourceId, bool>,
335}
336
337impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> TripletSamplerInner<S> {
338    fn new(config: SamplerConfig, split_store: Arc<S>) -> Self {
339        Self::new_with_chunker(config, split_store, Arc::new(SlidingWindowChunker))
340    }
341
342    fn new_with_chunker(
343        config: SamplerConfig,
344        split_store: Arc<S>,
345        chunker: Arc<dyn ChunkingAlgorithm>,
346    ) -> Self {
347        let buffer_size = config.ingestion_max_records.max(config.batch_size).max(2);
348        let using_config_triplet_recipes = !config.recipes.is_empty();
349        let using_config_text_recipes = !config.text_recipes.is_empty();
350        let triplet_recipes = if using_config_triplet_recipes {
351            config.recipes.clone()
352        } else {
353            Vec::new()
354        };
355        let text_recipes = if using_config_text_recipes {
356            config.text_recipes.clone()
357        } else if !triplet_recipes.is_empty() {
358            Self::build_derived_text_recipes(&triplet_recipes)
359        } else {
360            Vec::new()
361        };
362        let ingestion = IngestionManager::new(buffer_size, config.clone());
363        let epoch_backend = Some(Arc::clone(&split_store) as Arc<dyn EpochStateStore>);
364        let epoch_tracker = EpochTracker::new(
365            true,
366            epoch_backend,
367            derive_epoch_seed(config.seed, EPOCH_SEED_OFFSET),
368        );
369        let mut sampler = Self {
370            rng: DeterministicRng::new(config.seed),
371            config,
372            chunker,
373            split_store,
374            ingestion,
375            records: IndexMap::new(),
376            triplet_recipes,
377            text_recipes,
378            source_triplet_recipes: HashMap::new(),
379            sources_with_long_sections: HashSet::new(),
380            source_text_recipes: HashMap::new(),
381            using_config_triplet_recipes,
382            using_config_text_recipes,
383            last_observed_ingest: 0,
384            epoch_tracker,
385            chunk_cursors: HashMap::new(),
386            role_cursors: HashMap::new(),
387            negative_backend: {
388                #[cfg(feature = "bm25-mining")]
389                {
390                    Box::new(backends::Bm25Backend::new())
391                }
392                #[cfg(not(feature = "bm25-mining"))]
393                {
394                    Box::new(backends::DefaultBackend)
395                }
396            },
397            chunk_index: HashMap::new(),
398            source_order: Vec::new(),
399            source_cycle_idx: 0,
400            source_state_loaded: false,
401            ingestion_cursors_loaded: false,
402            source_state_dirty: false,
403            source_record_indices: HashMap::new(),
404            source_record_cursors: HashMap::new(),
405            emitted_text_hashes: HashMap::new(),
406            triplet_recipe_rr_idx: 0,
407            text_recipe_rr_idx: 0,
408            epoch: 0,
409            source_wrapped: HashMap::new(),
410        };
411        if !sampler.using_config_text_recipes {
412            sampler.rebuild_derived_text_recipes();
413        }
414        sampler
415    }
416
417    fn text_recipes(&self) -> &[TextRecipe] {
418        &self.text_recipes
419    }
420
421    /// Current epoch-adjusted seed: mixes `epoch` into `config.seed` so every epoch
422    /// produces a distinct permutation across all seed-dependent operations.
423    fn epoch_seed(&self) -> u64 {
424        derive_epoch_seed(self.config.seed, self.epoch)
425    }
426
427    fn register_source(
428        &mut self,
429        source: Box<dyn DataSource + 'static>,
430    ) -> Result<(), SamplerError> {
431        let source_id = source.id().to_string();
432        if !self.using_config_triplet_recipes {
433            let triplets = source.default_triplet_recipes();
434            if !triplets.is_empty() {
435                self.source_triplet_recipes
436                    .insert(source_id.clone(), triplets.clone());
437                if !self.using_config_text_recipes {
438                    let derived = Self::build_derived_text_recipes(&triplets);
439                    self.source_text_recipes
440                        .insert(source_id.clone(), derived.clone());
441                    self.extend_text_recipes_unique(&derived);
442                }
443            }
444        }
445        self.ingestion.register_source(source)?;
446        Ok(())
447    }
448
449    fn set_epoch(&mut self, epoch: u64) -> Result<(), SamplerError> {
450        self.epoch_tracker.ensure_loaded()?;
451        self.epoch_tracker.force_epoch(epoch);
452        self.epoch = epoch;
453        self.ingestion.set_epoch(epoch);
454        self.ingestion.reset_stream_cursors();
455        // Reset epoch step at epoch boundary so each epoch
456        // starts with step=0, giving deterministic step sequences.
457        self.ingestion.reset_epoch_step();
458        self.source_record_cursors.clear();
459        self.source_cycle_idx = 0;
460        for source in &self.source_order {
461            self.source_wrapped.insert(source.clone(), false);
462        }
463        self.rebuild_source_index()?;
464        self.source_state_dirty = self.source_order.len() > 1;
465        Ok(())
466    }
467
468    fn next_chunk_from_pool(
469        &mut self,
470        record_id: &str,
471        section_idx: usize,
472        pool: Vec<RecordChunk>,
473    ) -> Option<RecordChunk> {
474        if pool.is_empty() {
475            return None;
476        }
477        let key = (record_id.to_string(), section_idx);
478        if !self.chunk_cursors.contains_key(&key) {
479            // First touch should **not** always start at window 0.
480            // We derive a deterministic per-(record,section) starting offset so:
481            // - repeated runs with the same seed/epoch are reproducible,
482            // - first sampled window is spread across records/sections,
483            // - subsequent calls still rotate cyclically through the pool.
484            let cursor_key = format!("{}::{}", record_id, section_idx);
485            let start = (stable_hash_str(self.epoch_seed(), &cursor_key) as usize) % pool.len();
486            self.chunk_cursors.insert(key.clone(), start);
487        }
488        let cursor = self.chunk_cursors.entry(key).or_insert(0);
489        if *cursor >= pool.len() {
490            *cursor = 0;
491        }
492        let chunk = pool.get(*cursor).cloned();
493        *cursor = (*cursor + 1) % pool.len();
494        chunk
495    }
496
497    fn prune_cursor_state(&mut self) {
498        if self.chunk_cursors.is_empty()
499            && self.role_cursors.is_empty()
500            && self.negative_backend.cursors_empty()
501        {
502            return;
503        }
504        let valid_ids: HashSet<RecordId> = self.records.keys().cloned().collect();
505        self.chunk_cursors
506            .retain(|(record_id, _), _| valid_ids.contains(record_id));
507        self.role_cursors
508            .retain(|(record_id, _), _| valid_ids.contains(record_id));
509        self.negative_backend.prune_cursors(&valid_ids);
510    }
511
512    fn rebuild_chunk_index(&mut self) {
513        self.chunk_index.clear();
514        for record in self.records.values() {
515            self.chunk_index
516                .insert(record.id.clone(), record.id.clone());
517        }
518    }
519
520    fn rebuild_source_index(&mut self) -> Result<(), SamplerError> {
521        self.source_record_indices.clear();
522        let mut label_cache: HashMap<RecordId, SplitLabel> = HashMap::new();
523        let allowed = self.allowed_target_splits();
524        let allowed_set: HashSet<SplitLabel> = allowed.into_iter().collect();
525        for (idx, record) in self.records.values().enumerate() {
526            let label = if let Some(label) = label_cache.get(&record.id) {
527                *label
528            } else {
529                let label = match self.split_store.label_for(&record.id) {
530                    Some(label) => label,
531                    None => self.split_store.ensure(record.id.clone())?,
532                };
533                label_cache.insert(record.id.clone(), label);
534                label
535            };
536            if !allowed_set.contains(&label) {
537                continue;
538            }
539            self.source_record_indices
540                .entry(record.source.clone())
541                .or_default()
542                .push(idx);
543        }
544
545        let shuffle_seed = self.epoch_seed();
546        for indices in self.source_record_indices.values_mut() {
547            indices.sort_by_key(|idx| {
548                self.records
549                    .get_index(*idx)
550                    .map(|(_, record)| stable_hash_str(shuffle_seed, &record.id))
551                    .unwrap_or(0)
552            });
553        }
554
555        self.source_order = self.source_record_indices.keys().cloned().collect();
556        self.source_order.sort();
557        self.refresh_source_wrapped();
558
559        self.source_record_cursors
560            .retain(|source, _| self.source_record_indices.contains_key(source));
561        if self.source_state_loaded {
562            if self.source_order.is_empty() {
563                self.source_cycle_idx = 0;
564            }
565            self.source_state_dirty = self.source_order.len() > 1;
566        }
567        Ok(())
568    }
569
570    fn refresh_source_wrapped(&mut self) {
571        self.source_wrapped.clear();
572        for source in &self.source_order {
573            let len = self
574                .source_record_indices
575                .get(source)
576                .map(|items| items.len())
577                .unwrap_or(0);
578            if len == 0 {
579                self.source_wrapped.insert(source.clone(), false);
580                continue;
581            }
582            let cursor = self.source_record_cursors.get(source).copied().unwrap_or(0);
583            let wrapped = cursor > 0 && cursor % len == 0;
584            self.source_wrapped.insert(source.clone(), wrapped);
585        }
586    }
587
588    fn shuffled_source_cycle(&self, cycle: u64) -> Vec<SourceId> {
589        let mut sources = self.source_order.clone();
590        let seed = self.epoch_seed() ^ cycle;
591        sources.sort_by_key(|source| stable_hash_str(seed, source));
592        sources
593    }
594
595    fn ensure_source_state(&mut self) -> Result<(), SamplerError> {
596        if self.source_state_loaded {
597            return Ok(());
598        }
599        let persisted = self.split_store.load_sampler_state()?;
600        self.source_cycle_idx = persisted
601            .as_ref()
602            .map(|state| state.source_cycle_idx as usize)
603            .unwrap_or(0);
604        if let Some(state) = persisted {
605            for (source, cursor) in state.source_record_cursors {
606                if self.source_record_indices.contains_key(&source) {
607                    self.source_record_cursors.insert(source, cursor as usize);
608                }
609            }
610            self.epoch = state.epoch;
611            self.ingestion.set_epoch(state.epoch);
612            self.rng = DeterministicRng::from_state(state.rng_state);
613            self.triplet_recipe_rr_idx = state.triplet_recipe_rr_idx as usize;
614            self.text_recipe_rr_idx = state.text_recipe_rr_idx as usize;
615        }
616        self.refresh_source_wrapped();
617        self.source_state_loaded = true;
618        self.source_state_dirty = true;
619        Ok(())
620    }
621
622    fn persist_source_state(&mut self, save_to: Option<&Path>) -> Result<(), SamplerError> {
623        if !self.source_state_loaded {
624            return Ok(());
625        }
626        let state = PersistedSamplerState {
627            source_cycle_idx: self.source_cycle_idx as u64,
628            source_record_cursors: self
629                .source_record_cursors
630                .iter()
631                .map(|(source, cursor)| (source.clone(), *cursor as u64))
632                .collect(),
633            epoch: self.epoch,
634            epoch_step: self.ingestion.epoch_step(),
635            rng_state: self.rng.state(),
636            triplet_recipe_rr_idx: self.triplet_recipe_rr_idx as u64,
637            text_recipe_rr_idx: self.text_recipe_rr_idx as u64,
638            source_stream_cursors: self.ingestion.snapshot_cursors(),
639        };
640        self.split_store.save_sampler_state(&state, save_to)?;
641        self.source_state_dirty = false;
642        Ok(())
643    }
644
645    fn rebuild_derived_text_recipes(&mut self) {
646        if self.using_config_text_recipes {
647            return;
648        }
649        if self.triplet_recipes.is_empty() {
650            self.text_recipes.clear();
651        } else {
652            self.text_recipes = Self::build_derived_text_recipes(&self.triplet_recipes);
653        }
654    }
655
656    fn extend_text_recipes_unique(&mut self, recipes: &[TextRecipe]) {
657        for recipe in recipes {
658            if self
659                .text_recipes
660                .iter()
661                .any(|existing| existing.name == recipe.name)
662            {
663                continue;
664            }
665            self.text_recipes.push(recipe.clone());
666        }
667    }
668
669    fn configured_triplet_recipes_for_source<'a>(&'a self, source: &str) -> &'a [TripletRecipe] {
670        if self.using_config_triplet_recipes {
671            return &self.triplet_recipes;
672        }
673        self.source_triplet_recipes
674            .get(source)
675            .map(|recipes| recipes.as_slice())
676            .unwrap_or(&[])
677    }
678
679    /// Returns true when `recipes` already contains the long-section auto recipe.
680    fn contains_auto_chunk_pair_recipe(recipes: &[TripletRecipe]) -> bool {
681        recipes
682            .iter()
683            .any(|recipe| recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME)
684    }
685
686    fn source_supports_chunk_pair_recipe(&self, source: &str) -> bool {
687        if self.config.chunking.max_window_tokens == 0 {
688            return false;
689        }
690        self.sources_with_long_sections.contains(source)
691    }
692
693    /// Stage A (source-level): decide whether to append auto recipe for `source`.
694    ///
695    /// Decision criteria:
696    /// - source must be eligible (`source_supports_chunk_pair_recipe`), and
697    /// - configured pool must not already include the auto recipe name.
698    fn should_auto_inject_chunk_pair_recipe(
699        &self,
700        source: &str,
701        recipes: &[TripletRecipe],
702    ) -> bool {
703        self.source_supports_chunk_pair_recipe(source)
704            && !Self::contains_auto_chunk_pair_recipe(recipes)
705    }
706
707    /// Build the auto-injected long-section recipe.
708    ///
709    /// Semantics:
710    /// - Anchor selector: `Context`
711    /// - Positive selector: `Context`
712    /// - Negative selector: `Context` on a different record (`WrongArticle`)
713    ///
714    /// Anchor and positive are selected by two independent `select_chunk` calls
715    /// from the same context chunk candidate pool for the chosen record.
716    /// They are not concatenated and one is not derived from the other's text.
717    fn source_chunk_pair_recipe() -> TripletRecipe {
718        TripletRecipe {
719            name: Cow::Borrowed(AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME),
720            anchor: Selector::Role(SectionRole::Context),
721            positive_selector: Selector::Role(SectionRole::Context),
722            negative_selector: Selector::Role(SectionRole::Context),
723            negative_strategy: NegativeStrategy::WrongArticle,
724            weight: 1.0,
725            instruction: None,
726            allow_same_anchor_positive: false,
727        }
728    }
729
730    /// Resolve the source triplet plan.
731    ///
732    /// This is Stage A (source-level injection), not record-level execution.
733    ///
734    /// Algorithm for the auto-triplet injection path:
735    /// 1) Start from the configured source recipe pool (`configured_triplet_recipes_for_source`).
736    /// 2) Check auto-injection eligibility (`should_auto_inject_chunk_pair_recipe`), which is true only when:
737    ///    - the source has long sections discovered during ingest sync, and
738    ///    - the pool does not already include the auto recipe name.
739    /// 3) If eligible, append `source_chunk_pair_recipe`.
740    /// 4) Return both the effective recipe pool and whether step (3) happened.
741    fn resolve_source_triplet_plan(&self, source: &str) -> (Vec<TripletRecipe>, bool) {
742        let mut recipes = self.configured_triplet_recipes_for_source(source).to_vec();
743        let mut auto_injected = false;
744        if self.should_auto_inject_chunk_pair_recipe(source, &recipes) {
745            recipes.push(Self::source_chunk_pair_recipe());
746            auto_injected = true;
747        }
748        (recipes, auto_injected)
749    }
750
751    #[cfg(test)]
752    fn triplet_recipes_for_source(&self, source: &str) -> Vec<TripletRecipe> {
753        self.resolve_source_triplet_plan(source).0
754    }
755
756    fn triplet_recipe_count_for_source(&self, source: &str) -> usize {
757        let (recipes, _auto_injected) = self.resolve_source_triplet_plan(source);
758        recipes.len()
759    }
760
761    fn text_recipes_for_source<'a>(&'a self, source: &str) -> &'a [TextRecipe] {
762        if self.using_config_text_recipes || self.using_config_triplet_recipes {
763            return &self.text_recipes;
764        }
765        self.source_text_recipes
766            .get(source)
767            .map(|recipes| recipes.as_slice())
768            .unwrap_or(&[])
769    }
770
771    /// Build a weighted, shuffled selection order from a slice of recipe weights.
772    ///
773    /// Each recipe with `weight > 0.0` receives a number of slots proportional to its weight
774    /// relative to the smallest positive weight, capped at
775    /// [`RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER`].  The slot list is then shuffled so that
776    /// deterministic round-robin cycling naturally draws recipes at the requested frequency:
777    ///
778    /// * `weight = [1.0, 1.0, 1.0]` → one slot each — identical to uniform round-robin.
779    /// * `weight = [3.0, 1.0]`       → three slots for recipe 0, one for recipe 1.
780    /// * `weight = [1.0, 0.0]`       → recipe 1 is excluded entirely (zero slots).
781    fn recipe_order_weighted_shuffled(
782        &mut self,
783        weights: &[f32],
784        rng: &mut DeterministicRng,
785    ) -> Vec<usize> {
786        weighted_recipe_order(weights, rng)
787    }
788
789    /// Weighted shuffle rotated by `rr_idx` for deterministic round-robin cycling.
790    ///
791    /// Calls [`Self::recipe_order_weighted_shuffled`] and rotates the result by
792    /// `rr_idx % order.len()` so successive batches start from different recipes,
793    /// preserving both the proportional frequency and the cross-batch cycling.
794    fn recipe_order_weighted_cycled(
795        &mut self,
796        weights: &[f32],
797        rr_idx: usize,
798        rng: &mut DeterministicRng,
799    ) -> Vec<usize> {
800        let base = self.recipe_order_weighted_shuffled(weights, rng);
801        if base.is_empty() {
802            return base;
803        }
804        let start = rr_idx % base.len();
805        let mut order = Vec::with_capacity(base.len());
806        order.extend_from_slice(&base[start..]);
807        order.extend_from_slice(&base[..start]);
808        order
809    }
810
811    /// Weighted shuffled selection order for text recipes — same algorithm as
812    /// [`Self::recipe_order_weighted_shuffled`], kept separate so the text and triplet
813    /// round-robin counters can advance independently.
814    fn text_recipe_order_weighted_shuffled(
815        &mut self,
816        weights: &[f32],
817        rng: &mut DeterministicRng,
818    ) -> Vec<usize> {
819        weighted_recipe_order(weights, rng)
820    }
821
822    fn text_recipe_order_weighted_cycled(
823        &mut self,
824        weights: &[f32],
825        rr_idx: usize,
826        rng: &mut DeterministicRng,
827    ) -> Vec<usize> {
828        let base = self.text_recipe_order_weighted_shuffled(weights, rng);
829        if base.is_empty() {
830            return base;
831        }
832        let start = rr_idx % base.len();
833        let mut order = Vec::with_capacity(base.len());
834        order.extend_from_slice(&base[start..]);
835        order.extend_from_slice(&base[..start]);
836        order
837    }
838
839    fn allowed_target_splits(&self) -> Vec<SplitLabel> {
840        self.config.allowed_splits.clone()
841    }
842
843    fn ensure_split_allowed(&self, split: SplitLabel) -> Result<(), SamplerError> {
844        let allowed = self.allowed_target_splits();
845        if allowed.contains(&split) {
846            return Ok(());
847        }
848        Err(SamplerError::Configuration(format!(
849            "requested split {:?} is not in allowed_splits {:?}",
850            split, allowed
851        )))
852    }
853
854    fn ensure_split_has_records(&mut self, target_split: SplitLabel) -> Result<(), SamplerError> {
855        let records_by_split = self.records_by_split()?;
856        if records_by_split
857            .get(&target_split)
858            .map(|records| !records.is_empty())
859            .unwrap_or(false)
860        {
861            return Ok(());
862        }
863        Err(SamplerError::Exhausted(
864            "no records available for target split".into(),
865        ))
866    }
867
868    fn records_by_split(
869        &self,
870    ) -> Result<HashMap<SplitLabel, Vec<(RecordId, SourceId)>>, SamplerError> {
871        let mut map: HashMap<SplitLabel, Vec<(RecordId, SourceId)>> = HashMap::new();
872        let mut label_cache: HashMap<RecordId, SplitLabel> = HashMap::new();
873        for (chunk_id, record_id) in &self.chunk_index {
874            let Some(record) = self.records.get(record_id) else {
875                continue;
876            };
877            let label = if let Some(label) = label_cache.get(record_id) {
878                *label
879            } else {
880                let label = match self.split_store.label_for(record_id) {
881                    Some(label) => label,
882                    None => self.split_store.ensure(record_id.clone())?,
883                };
884                label_cache.insert(record_id.clone(), label);
885                label
886            };
887            map.entry(label)
888                .or_default()
889                .push((chunk_id.clone(), record.source.clone()));
890        }
891        Ok(map)
892    }
893
894    fn choose_anchor_record(
895        &mut self,
896        source: Option<&str>,
897        split: SplitLabel,
898    ) -> Option<Arc<DataRecord>> {
899        if let Some(source) = source {
900            let indices = self.source_record_indices.get(source)?;
901            if indices.is_empty() {
902                return None;
903            }
904            let mut cursor = *self.source_record_cursors.get(source).unwrap_or(&0);
905            let cycle = cursor / indices.len();
906            let offset_seed = self.epoch_seed() ^ (cycle as u64);
907            let offset = (stable_hash_str(offset_seed, source) as usize) % indices.len();
908            let mut wrapped = false;
909            let mut selected: Option<Arc<DataRecord>> = None;
910            for _ in 0..indices.len() {
911                let pos = (cursor % indices.len()).saturating_add(offset) % indices.len();
912                let idx = indices[pos];
913                cursor = cursor.saturating_add(1);
914                if cursor.is_multiple_of(indices.len()) {
915                    wrapped = true;
916                }
917                if let Some((_, record)) = self.records.get_index(idx) {
918                    if self.split_store.label_for(&record.id) != Some(split) {
919                        continue;
920                    }
921                    selected = Some(Arc::clone(record));
922                    break;
923                }
924            }
925            self.source_record_cursors
926                .insert(source.to_string(), cursor);
927            if wrapped {
928                self.mark_source_wrapped(source);
929            }
930            return selected;
931        }
932        while let Some(chunk_id) = self.epoch_tracker.next_record(split) {
933            if let Some(record_id) = self.chunk_index.get(&chunk_id)
934                && let Some(record) = self.records.get(record_id)
935            {
936                return Some(Arc::clone(record));
937            }
938        }
939        None
940    }
941
942    fn save_sampler_state(&mut self, save_to: Option<&Path>) -> Result<(), SamplerError> {
943        if self.epoch_tracker.is_enabled() {
944            self.epoch_tracker.persist()?;
945        }
946        self.persist_source_state(save_to)?;
947        Ok(())
948    }
949
950    fn mark_source_wrapped(&mut self, source: &str) {
951        self.source_wrapped.insert(source.to_string(), true);
952        if self.source_order.is_empty() {
953            return;
954        }
955        let all_wrapped = self
956            .source_order
957            .iter()
958            .all(|name| self.source_wrapped.get(name).copied().unwrap_or(false));
959        if all_wrapped {
960            self.advance_epoch();
961        }
962    }
963
964    fn advance_epoch(&mut self) {
965        // The epoch advanced, changing the sampling permutation.
966        // emitted_text_hashes is NOT cleared: the record pool is unchanged,
967        // so texts already emitted are still off-limits.  The epoch
968        // changes the shuffling permutation, not the dedup window.
969        // Advance the epoch counter so source refreshes receive a different
970        // permutation seed (seed ^ epoch), producing a fresh traversal order.
971        self.epoch = self.epoch.saturating_add(1);
972        self.ingestion.set_epoch(self.epoch);
973        // Do NOT reset the epoch step here: it tracks the number of
974        // refresh batches, which is orthogonal to the sampling epoch.
975        // Resetting it would corrupt the persisted cursor value on resume.
976        // Reset per-source record cursors so the new epoch starts from the
977        // beginning of each source's permuted index space (not from where the
978        // previous epoch left off).
979        self.source_record_cursors.clear();
980        self.source_cycle_idx = 0;
981        // Reset the wrapped-tracking so all sources are eligible for sampling
982        // in the new epoch.
983        for source in &self.source_order {
984            self.source_wrapped.insert(source.clone(), false);
985        }
986        let _ = self.rebuild_source_index();
987        self.source_state_dirty = self.source_order.len() > 1;
988    }
989
990    fn select_temporal_neighbor(
991        &'_ self,
992        record: &DataRecord,
993        offset_days: i32,
994    ) -> Option<Arc<DataRecord>> {
995        let target = record.created_at + Duration::days(offset_days.into());
996        let key = record.taxonomy.first().cloned();
997        let record_split = self.split_store.label_for(&record.id)?;
998        self.records
999            .values()
1000            .filter(|candidate| {
1001                candidate.id != record.id
1002                    && self
1003                        .split_store
1004                        .label_for(&candidate.id)
1005                        .map(|label| label == record_split)
1006                        .unwrap_or(false)
1007                    && (candidate.source == record.source
1008                        || key
1009                            .as_ref()
1010                            .zip(candidate.taxonomy.first())
1011                            .map(|(a, b)| a == b)
1012                            .unwrap_or(false))
1013            })
1014            .min_by_key(|candidate| (candidate.created_at - target).num_seconds().abs())
1015            .cloned()
1016    }
1017
1018    fn select_negative_record(
1019        &self,
1020        anchor_record: &DataRecord,
1021        strategy: &NegativeStrategy,
1022        anchor_query_text: Option<&str>,
1023        rng: &mut dyn rand::RngCore,
1024    ) -> Option<(Arc<DataRecord>, bool)> {
1025        let anchor_split = self.split_store.label_for(&anchor_record.id)?;
1026
1027        let in_anchor_split = |candidate: &DataRecord| {
1028            self.split_store
1029                .label_for(&candidate.id)
1030                .map(|label| label == anchor_split)
1031                .unwrap_or(false)
1032        };
1033
1034        match strategy {
1035            NegativeStrategy::WrongArticle => {
1036                let anchor_date =
1037                    taxonomy_value(anchor_record, META_FIELD_DATE).map(|d| d.to_string());
1038                let mut same_date: Vec<Arc<DataRecord>> = self
1039                    .records
1040                    .values()
1041                    .filter(|candidate| {
1042                        candidate.source == anchor_record.source
1043                            && candidate.id != anchor_record.id
1044                            && in_anchor_split(candidate)
1045                    })
1046                    .filter(|candidate| {
1047                        anchor_date
1048                            .as_deref()
1049                            .zip(taxonomy_value(candidate, META_FIELD_DATE))
1050                            .map(|(a, b)| a == b)
1051                            .unwrap_or(false)
1052                    })
1053                    .cloned()
1054                    .collect();
1055                if same_date.is_empty() {
1056                    same_date = self
1057                        .records
1058                        .values()
1059                        .filter(|candidate| {
1060                            candidate.source == anchor_record.source
1061                                && candidate.id != anchor_record.id
1062                                && in_anchor_split(candidate)
1063                        })
1064                        .cloned()
1065                        .collect();
1066                }
1067                if !same_date.is_empty() {
1068                    return self.negative_backend.choose_negative(
1069                        anchor_record,
1070                        anchor_split,
1071                        same_date,
1072                        false,
1073                        anchor_query_text,
1074                        rng,
1075                    );
1076                }
1077                let pool = self
1078                    .records
1079                    .values()
1080                    .filter(|candidate| {
1081                        candidate.id != anchor_record.id && in_anchor_split(candidate)
1082                    })
1083                    .cloned()
1084                    .collect::<Vec<_>>();
1085                self.negative_backend.choose_negative(
1086                    anchor_record,
1087                    anchor_split,
1088                    pool,
1089                    true,
1090                    anchor_query_text,
1091                    rng,
1092                )
1093            }
1094            NegativeStrategy::WrongPublicationDate => {
1095                let anchor_date =
1096                    taxonomy_value(anchor_record, META_FIELD_DATE).map(|d| d.to_string());
1097                let pool: Vec<Arc<DataRecord>> = self
1098                    .records
1099                    .values()
1100                    .filter(|candidate| {
1101                        candidate.source == anchor_record.source
1102                            && candidate.id != anchor_record.id
1103                            && in_anchor_split(candidate)
1104                    })
1105                    .filter(|candidate| {
1106                        match (
1107                            anchor_date.as_deref(),
1108                            taxonomy_value(candidate, META_FIELD_DATE),
1109                        ) {
1110                            (Some(anchor), Some(candidate_date)) => anchor != candidate_date,
1111                            (Some(_), None) => true,
1112                            (None, Some(_)) => true,
1113                            (None, None) => false,
1114                        }
1115                    })
1116                    .cloned()
1117                    .collect();
1118                if pool.is_empty() {
1119                    // Fallback to any other record in the same split so split boundaries
1120                    // remain strictly isolated.
1121                    let fallback_pool = self
1122                        .records
1123                        .values()
1124                        .filter(|candidate| {
1125                            candidate.id != anchor_record.id && in_anchor_split(candidate)
1126                        })
1127                        .cloned()
1128                        .collect::<Vec<_>>();
1129
1130                    return self.negative_backend.choose_negative(
1131                        anchor_record,
1132                        anchor_split,
1133                        fallback_pool,
1134                        true,
1135                        anchor_query_text,
1136                        rng,
1137                    );
1138                }
1139
1140                self.negative_backend.choose_negative(
1141                    anchor_record,
1142                    anchor_split,
1143                    pool,
1144                    false,
1145                    anchor_query_text,
1146                    rng,
1147                )
1148            }
1149            NegativeStrategy::QuestionAnswerMismatch => {
1150                let pool: Vec<Arc<DataRecord>> = self
1151                    .records
1152                    .values()
1153                    .filter(|candidate| {
1154                        candidate.source == anchor_record.source
1155                            && candidate.id != anchor_record.id
1156                            && in_anchor_split(candidate)
1157                    })
1158                    .cloned()
1159                    .collect();
1160                if pool.is_empty() {
1161                    // Fallback to any other record in the same split so split boundaries
1162                    // remain strictly isolated.
1163                    let fallback_pool = self
1164                        .records
1165                        .values()
1166                        .filter(|candidate| {
1167                            candidate.id != anchor_record.id && in_anchor_split(candidate)
1168                        })
1169                        .cloned()
1170                        .collect::<Vec<_>>();
1171
1172                    return self.negative_backend.choose_negative(
1173                        anchor_record,
1174                        anchor_split,
1175                        fallback_pool,
1176                        true,
1177                        anchor_query_text,
1178                        rng,
1179                    );
1180                }
1181
1182                self.negative_backend.choose_negative(
1183                    anchor_record,
1184                    anchor_split,
1185                    pool,
1186                    false,
1187                    anchor_query_text,
1188                    rng,
1189                )
1190            }
1191            NegativeStrategy::SameRecord => {
1192                // Return the anchor record itself.  The caller
1193                // (finalize_triplet_with_negative) will call select_chunk on
1194                // this record using the recipe's negative_selector, which
1195                // picks from Context sections.  The dedup guard rejects
1196                // triplets where any two slots share identical text.
1197                Some((Arc::new(anchor_record.clone()), false))
1198            }
1199        }
1200    }
1201
1202    /// True when the recipe is the special auto-injected long-section chunk-pair recipe.
1203    fn is_auto_chunk_pair_recipe(recipe: &TripletRecipe) -> bool {
1204        recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
1205    }
1206
1207    /// Select anchor/positive chunk pair from selectors for a single record.
1208    ///
1209    /// When selectors are the same, retries until a valid pair is found or retry limit is hit.
1210    fn select_anchor_positive_pair(
1211        &mut self,
1212        record: &DataRecord,
1213        anchor_selector: &Selector,
1214        positive_selector: &Selector,
1215        enforce_window_pair: bool,
1216    ) -> Option<(RecordChunk, RecordChunk)> {
1217        let mut anchor_chunk = self.select_chunk(record, anchor_selector)?;
1218        let mut positive_chunk = self.select_chunk(record, positive_selector)?;
1219        if anchor_selector == positive_selector {
1220            let mut retries = 0usize;
1221            while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair)
1222                && retries < SAME_SELECTOR_PAIR_RETRY_LIMIT
1223            {
1224                let Some(redraw_anchor) = self.select_chunk(record, anchor_selector) else {
1225                    break;
1226                };
1227                let Some(redraw_positive) = self.select_chunk(record, positive_selector) else {
1228                    break;
1229                };
1230                anchor_chunk = redraw_anchor;
1231                positive_chunk = redraw_positive;
1232                retries += 1;
1233            }
1234            if !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair) {
1235                return None;
1236            }
1237        }
1238        Some((anchor_chunk, positive_chunk))
1239    }
1240
1241    /// Select two distinct *window* chunks for the auto-injected recipe using recipe selectors.
1242    ///
1243    /// This method intentionally uses the recipe's selectors (no hardcoded selector values).
1244    /// For the current auto recipe those selectors are both `Role(Context)`, but the behavior
1245    /// remains tied to recipe configuration, not this helper body.
1246    ///
1247    /// NOTE: this does *not* decide whether the auto recipe is enabled for a source.
1248    /// Source-level enablement happens in `resolve_source_triplet_plan` via
1249    /// `should_auto_inject_chunk_pair_recipe`.
1250    fn select_distinct_window_pair_for_auto_recipe(
1251        &mut self,
1252        recipe: &TripletRecipe,
1253        record: &DataRecord,
1254    ) -> Option<(RecordChunk, RecordChunk)> {
1255        if recipe.anchor != recipe.positive_selector {
1256            return None;
1257        }
1258        self.select_anchor_positive_pair(record, &recipe.anchor, &recipe.positive_selector, true)
1259    }
1260
1261    /// Stage B (record-level): guard for the auto-injected recipe.
1262    ///
1263    /// Requires at least two `ChunkView::Window` candidates across sections addressed by
1264    /// `selector`. This makes the auto recipe eligibility check explicit at execution time.
1265    fn record_has_at_least_two_window_chunks_for_selector(
1266        &self,
1267        record: &DataRecord,
1268        selector: &Selector,
1269    ) -> bool {
1270        let section_indices: Vec<usize> = match selector {
1271            Selector::Role(role) => record
1272                .sections
1273                .iter()
1274                .enumerate()
1275                .filter(|(_, section)| roles_match(role, &section.role))
1276                .map(|(idx, _)| idx)
1277                .collect(),
1278            Selector::Paragraph(idx) => {
1279                if *idx < record.sections.len() {
1280                    vec![*idx]
1281                } else {
1282                    Vec::new()
1283                }
1284            }
1285            Selector::Random => (0..record.sections.len()).collect(),
1286            Selector::TemporalOffset(_) => return false,
1287        };
1288
1289        let mut window_count = 0usize;
1290        for section_idx in section_indices {
1291            let Some(section) = record.sections.get(section_idx) else {
1292                continue;
1293            };
1294            let chunks = self.materialize_chunks(record, section_idx, section);
1295            window_count += chunks
1296                .iter()
1297                .filter(|chunk| matches!(chunk.view, ChunkView::Window { .. }))
1298                .count();
1299            if window_count >= 2 {
1300                return true;
1301            }
1302        }
1303        false
1304    }
1305
1306    /// Shared selector-pair stage for both standard and auto-injected triplet recipes.
1307    fn build_triplet_with_selector_pair_policy(
1308        &mut self,
1309        recipe: &TripletRecipe,
1310        record: &DataRecord,
1311        enforce_window_pair: bool,
1312        rng: &mut DeterministicRng,
1313    ) -> Option<SampleTriplet> {
1314        let (mut anchor_chunk, mut positive_chunk) = self.select_anchor_positive_pair(
1315            record,
1316            &recipe.anchor,
1317            &recipe.positive_selector,
1318            enforce_window_pair,
1319        )?;
1320        let anchor_raw_text = anchor_chunk.text.clone();
1321        self.decorate_chunk(record, &mut anchor_chunk, rng);
1322        self.decorate_chunk(record, &mut positive_chunk, rng);
1323        // Snapshot the raw anchor text for BM25 querying before decoration
1324        // added the metadata prefix — prefix tokens are absent from the index.
1325        self.finalize_triplet_with_negative(
1326            recipe,
1327            record,
1328            anchor_chunk,
1329            positive_chunk,
1330            &anchor_raw_text,
1331            rng,
1332        )
1333    }
1334
1335    /// Execute the special auto-injected long-section chunk-pair recipe.
1336    ///
1337    /// Algorithm:
1338    /// 0) Stage B check: current record must expose at least two window chunks for selector.
1339    /// 1) Draw anchor and positive from the same record using recipe selectors.
1340    /// 2) Require the two chunks to be distinct.
1341    /// 3) Additionally require both chunks to be `ChunkView::Window` variants.
1342    /// 4) Apply metadata decoration to both chunks.
1343    /// 5) Draw the negative according to recipe negative strategy.
1344    fn make_auto_chunk_pair_triplet_with_anchor(
1345        &mut self,
1346        recipe: &TripletRecipe,
1347        record: &DataRecord,
1348        rng: &mut DeterministicRng,
1349    ) -> Option<SampleTriplet> {
1350        if !self.record_has_at_least_two_window_chunks_for_selector(record, &recipe.anchor) {
1351            return None;
1352        }
1353        let (mut anchor_chunk, mut positive_chunk) =
1354            self.select_distinct_window_pair_for_auto_recipe(recipe, record)?;
1355        let anchor_raw_text = anchor_chunk.text.clone();
1356        self.decorate_chunk(record, &mut anchor_chunk, rng);
1357        self.decorate_chunk(record, &mut positive_chunk, rng);
1358        self.finalize_triplet_with_negative(
1359            recipe,
1360            record,
1361            anchor_chunk,
1362            positive_chunk,
1363            &anchor_raw_text,
1364            rng,
1365        )
1366    }
1367
1368    fn make_standard_triplet_with_anchor(
1369        &mut self,
1370        recipe: &TripletRecipe,
1371        record: &DataRecord,
1372        rng: &mut DeterministicRng,
1373    ) -> Option<SampleTriplet> {
1374        self.build_triplet_with_selector_pair_policy(recipe, record, false, rng)
1375    }
1376
1377    /// Finalize a triplet by selecting a negative and applying a deterministic 50 % coin-flip
1378    /// that swaps anchor and positive.
1379    ///
1380    /// # Anchor/positive swap
1381    ///
1382    /// Before constructing the [`SampleTriplet`], the sampler tests the least-significant bit of
1383    /// the next RNG word. When the bit is zero (≈ 50 % of the time) the anchor and positive
1384    /// slots are exchanged so that what was originally selected as the positive becomes the
1385    /// anchor, and vice-versa.
1386    ///
1387    /// **Why this matters:** contrastive objectives such as InfoNCE treat the two non-negative
1388    /// slots asymmetrically only in so far as downstream code does. If the model can learn a
1389    /// positional shortcut — for example "the anchor is always the shorter text" — it can
1390    /// achieve low loss without learning the intended similarity structure. The swap eliminates
1391    /// that opportunity by ensuring both orderings appear at equal frequency, forcing the model
1392    /// to treat the two positive-pair slots symmetrically.
1393    ///
1394    /// The negative chunk is unaffected by the swap.
1395    fn finalize_triplet_with_negative(
1396        &mut self,
1397        recipe: &TripletRecipe,
1398        record: &DataRecord,
1399        anchor_chunk: RecordChunk,
1400        positive_chunk: RecordChunk,
1401        anchor_raw_text: &str,
1402        rng: &mut DeterministicRng,
1403    ) -> Option<SampleTriplet> {
1404        let (negative_record, fallback_used) = self.select_negative_record(
1405            record,
1406            &recipe.negative_strategy,
1407            Some(anchor_raw_text),
1408            rng,
1409        )?;
1410        let mut negative_chunk = self.select_chunk(&negative_record, &recipe.negative_selector)?;
1411        self.decorate_chunk(&negative_record, &mut negative_chunk, rng);
1412
1413        // 50 % coin-flip: swap anchor and positive to prevent positional shortcuts.
1414        let (anchor_chunk, positive_chunk) = if rng.next_u64() & ANCHOR_POSITIVE_SWAP_MASK == 0 {
1415            (positive_chunk, anchor_chunk)
1416        } else {
1417            (anchor_chunk, positive_chunk)
1418        };
1419
1420        // Reject if any two slots share identical rendered text.  This catches sources
1421        // that produce multiple records with the same string content: a negative whose
1422        // text matches the positive (or the anchor) would produce a trivially-invalid
1423        // training example.  String equality short-circuits on the first differing byte,
1424        // so the guard is effectively free when texts differ (the common case).
1425        //
1426        // When `allow_same_anchor_positive` is set the anchor==positive check is skipped
1427        // to support SimCSE-style training where both slots carry identical text and the
1428        // model's dropout layers produce the required embedding variation at train time.
1429        // The negative must still differ from both anchor and positive.
1430        if (!recipe.allow_same_anchor_positive && anchor_chunk.text == positive_chunk.text)
1431            || negative_chunk.text == positive_chunk.text
1432            || negative_chunk.text == anchor_chunk.text
1433        {
1434            return None;
1435        }
1436
1437        let chunk_weight =
1438            self.triplet_chunk_weight(recipe, &anchor_chunk, &positive_chunk, &negative_chunk);
1439        let weight = recipe.weight * chunk_weight;
1440        let recipe_name = if fallback_used {
1441            format!("{}_fallback_same_split", recipe.name)
1442        } else {
1443            recipe.name.to_string()
1444        };
1445        Some(SampleTriplet {
1446            recipe: recipe_name,
1447            anchor: anchor_chunk,
1448            positive: positive_chunk,
1449            negative: negative_chunk,
1450            weight,
1451            instruction: recipe.instruction.as_ref().map(|s| s.to_string()),
1452        })
1453    }
1454
1455    fn make_triplet_with_anchor(
1456        &mut self,
1457        recipe: &TripletRecipe,
1458        record: &DataRecord,
1459        rng: &mut DeterministicRng,
1460    ) -> Option<SampleTriplet> {
1461        if Self::is_auto_chunk_pair_recipe(recipe) {
1462            return self.make_auto_chunk_pair_triplet_with_anchor(recipe, record, rng);
1463        }
1464        self.make_standard_triplet_with_anchor(recipe, record, rng)
1465    }
1466
1467    fn make_text_sample_for_split(
1468        &mut self,
1469        recipe: &TextRecipe,
1470        source: Option<&str>,
1471        split: SplitLabel,
1472        rng: &mut DeterministicRng,
1473    ) -> Option<TextSample> {
1474        let record = self.choose_anchor_record(source, split)?;
1475        let mut chunk = self.select_chunk(&record, &recipe.selector)?;
1476        self.decorate_chunk(&record, &mut chunk, rng);
1477        let weight = recipe.weight * self.chunk_weight(&chunk);
1478        Some(TextSample {
1479            recipe: recipe.name.to_string(),
1480            chunk,
1481            weight,
1482            instruction: recipe.instruction.as_ref().map(|s| s.to_string()),
1483        })
1484    }
1485
1486    fn chunk_weight(&self, chunk: &RecordChunk) -> f32 {
1487        chunk_weight(&self.config.chunking, chunk)
1488    }
1489
1490    fn triplet_chunk_weight(
1491        &self,
1492        recipe: &TripletRecipe,
1493        anchor: &RecordChunk,
1494        positive: &RecordChunk,
1495        negative: &RecordChunk,
1496    ) -> f32 {
1497        let floor = self.config.chunking.chunk_weight_floor;
1498        let negative_weight = negative.quality.trust.clamp(0.0, 1.0).max(floor);
1499        if Self::is_auto_chunk_pair_recipe(recipe) {
1500            // For the auto long-section recipe, use one coherence signal (proximity)
1501            // for the anchor/positive pair.
1502            let pair_trust = ((anchor.quality.trust.clamp(0.0, 1.0)
1503                + positive.quality.trust.clamp(0.0, 1.0))
1504                / 2.0)
1505                .clamp(0.0, 1.0);
1506            let pair_weight = (chunk_proximity_score(anchor, positive) * pair_trust).max(floor);
1507            // Keep negative weighting simple in this recipe: trust + floor only.
1508            return (pair_weight + pair_weight + negative_weight) / 3.0;
1509        }
1510        // Non-auto recipes also apply anchor-positive proximity, while keeping
1511        // negative weighting trust-only.
1512        let pair_proximity = chunk_proximity_score(anchor, positive);
1513        let anchor_weight = (self.chunk_weight(anchor) * pair_proximity).max(floor);
1514        let positive_weight = (self.chunk_weight(positive) * pair_proximity).max(floor);
1515        (anchor_weight + positive_weight + negative_weight) / 3.0
1516    }
1517
1518    fn decorate_chunk(
1519        &mut self,
1520        record: &DataRecord,
1521        chunk: &mut RecordChunk,
1522        rng: &mut DeterministicRng,
1523    ) {
1524        chunk.kvp_meta = record
1525            .meta_prefix
1526            .as_ref()
1527            .map(|s| s.all_metadata())
1528            .unwrap_or_default();
1529        if let Some(spec) = record.meta_prefix.as_ref()
1530            && let Some(prefix) = spec.sample(rng)
1531        {
1532            let body_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&chunk.text);
1533            let prefix_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&prefix);
1534            let total_tokens = prefix_tokens.len() + body_tokens.len();
1535            let max_window = self.config.chunking.max_window_tokens;
1536            if max_window > 0 && total_tokens > max_window {
1537                if prefix_tokens.len() >= max_window {
1538                    chunk.text = prefix_tokens
1539                        .into_iter()
1540                        .take(max_window)
1541                        .collect::<Vec<_>>()
1542                        .join(" ");
1543                    chunk.tokens_estimate = max_window;
1544                } else {
1545                    let remaining = max_window - prefix_tokens.len();
1546                    let trimmed_body: Vec<&str> = body_tokens.into_iter().take(remaining).collect();
1547                    chunk.text =
1548                        format!("{}{}{}", prefix, platform_newline(), trimmed_body.join(" "));
1549                    chunk.tokens_estimate = max_window;
1550                }
1551            } else {
1552                chunk.text = format!("{}{}{}", prefix, platform_newline(), chunk.text);
1553                chunk.tokens_estimate = total_tokens;
1554            }
1555        }
1556    }
1557
1558    // ── parallel-batch helpers (&self, rng-based, no cursor maps) ────────────
1559
1560    /// Select a chunk using rng instead of cursor maps (for parallel execution).
1561    fn select_chunk_parallel(
1562        &self,
1563        record: &DataRecord,
1564        selector: &Selector,
1565        rng: &mut DeterministicRng,
1566    ) -> Option<RecordChunk> {
1567        match selector {
1568            Selector::Role(role) => self.select_role_parallel(record, role, rng),
1569            Selector::Paragraph(idx) => record.sections.get(*idx).and_then(|section| {
1570                let pool = self.materialize_chunks(record, *idx, section);
1571                if pool.is_empty() {
1572                    return None;
1573                }
1574                let i = rng.random_range(0..pool.len());
1575                pool.into_iter().nth(i)
1576            }),
1577            Selector::TemporalOffset(offset) => self
1578                .select_temporal_neighbor(record, *offset)
1579                .and_then(|neighbor| {
1580                    self.select_role_parallel(&neighbor, &SectionRole::Context, rng)
1581                }),
1582            Selector::Random => {
1583                if record.sections.is_empty() {
1584                    return None;
1585                }
1586                let idx = rng.random_range(0..record.sections.len());
1587                record.sections.get(idx).and_then(|section| {
1588                    let pool = self.materialize_chunks(record, idx, section);
1589                    if pool.is_empty() {
1590                        return None;
1591                    }
1592                    let i = rng.random_range(0..pool.len());
1593                    pool.into_iter().nth(i)
1594                })
1595            }
1596        }
1597    }
1598
1599    /// Select a chunk by role using rng (parallel path — no role_cursors written).
1600    fn select_role_parallel(
1601        &self,
1602        record: &DataRecord,
1603        role: &SectionRole,
1604        rng: &mut DeterministicRng,
1605    ) -> Option<RecordChunk> {
1606        let indices: Vec<usize> = record
1607            .sections
1608            .iter()
1609            .enumerate()
1610            .filter(|(_, s)| roles_match(role, &s.role))
1611            .map(|(i, _)| i)
1612            .collect();
1613        if indices.is_empty() {
1614            return None;
1615        }
1616        let start = rng.random_range(0..indices.len());
1617        for offset in 0..indices.len() {
1618            let section_idx = indices[(start + offset) % indices.len()];
1619            let section = &record.sections[section_idx];
1620            let pool = self.materialize_chunks(record, section_idx, section);
1621            if !pool.is_empty() {
1622                let i = rng.random_range(0..pool.len());
1623                return pool.into_iter().nth(i);
1624            }
1625        }
1626        None
1627    }
1628
1629    /// Decorate a chunk using a provided rng (parallel-safe, &self).
1630    fn decorate_chunk_parallel(
1631        &self,
1632        record: &DataRecord,
1633        chunk: &mut RecordChunk,
1634        rng: &mut DeterministicRng,
1635    ) {
1636        chunk.kvp_meta = record
1637            .meta_prefix
1638            .as_ref()
1639            .map(|s| s.all_metadata())
1640            .unwrap_or_default();
1641        if let Some(spec) = record.meta_prefix.as_ref()
1642            && let Some(prefix) = spec.sample(rng)
1643        {
1644            let body_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&chunk.text);
1645            let prefix_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&prefix);
1646            let total_tokens = prefix_tokens.len() + body_tokens.len();
1647            let max_window = self.config.chunking.max_window_tokens;
1648            if max_window > 0 && total_tokens > max_window {
1649                if prefix_tokens.len() >= max_window {
1650                    chunk.text = prefix_tokens
1651                        .into_iter()
1652                        .take(max_window)
1653                        .collect::<Vec<_>>()
1654                        .join(" ");
1655                    chunk.tokens_estimate = max_window;
1656                } else {
1657                    let remaining = max_window - prefix_tokens.len();
1658                    let trimmed_body: Vec<&str> = body_tokens.into_iter().take(remaining).collect();
1659                    chunk.text =
1660                        format!("{}{}{}", prefix, platform_newline(), trimmed_body.join(" "));
1661                    chunk.tokens_estimate = max_window;
1662                }
1663            } else {
1664                chunk.text = format!("{}{}{}", prefix, platform_newline(), chunk.text);
1665                chunk.tokens_estimate = total_tokens;
1666            }
1667        }
1668    }
1669
1670    /// Select an anchor/positive pair for the parallel path (rng-based).
1671    fn select_anchor_positive_parallel(
1672        &self,
1673        record: &DataRecord,
1674        anchor_selector: &Selector,
1675        positive_selector: &Selector,
1676        enforce_window_pair: bool,
1677        rng: &mut DeterministicRng,
1678    ) -> Option<(RecordChunk, RecordChunk)> {
1679        let anchor_chunk = self.select_chunk_parallel(record, anchor_selector, rng)?;
1680        let mut positive_chunk = self.select_chunk_parallel(record, positive_selector, rng)?;
1681        if anchor_selector == positive_selector {
1682            let mut retries = 0usize;
1683            while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair)
1684                && retries < SAME_SELECTOR_PAIR_RETRY_LIMIT
1685            {
1686                positive_chunk = self.select_chunk_parallel(record, positive_selector, rng)?;
1687                retries += 1;
1688            }
1689            if !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair) {
1690                return None;
1691            }
1692        }
1693        // No swap here — finalize_triplet_parallel applies the coin-flip swap.
1694        Some((anchor_chunk, positive_chunk))
1695    }
1696
1697    /// Select (anchor_chunk, positive_chunk, anchor_raw_text) for a recipe — parallel-safe.
1698    /// Does NOT select a negative; that happens sequentially in Phase 3.
1699    fn select_anchor_positive_for_recipe(
1700        &self,
1701        recipe: &TripletRecipe,
1702        anchor_record: &DataRecord,
1703        rng: &mut DeterministicRng,
1704    ) -> Option<(RecordChunk, RecordChunk, String)> {
1705        if Self::is_auto_chunk_pair_recipe(recipe) {
1706            if !self
1707                .record_has_at_least_two_window_chunks_for_selector(anchor_record, &recipe.anchor)
1708            {
1709                return None;
1710            }
1711            let mut anchor_chunk =
1712                self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1713            let mut positive_chunk =
1714                self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1715            let mut tries = 0usize;
1716            while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, true) {
1717                tries += 1;
1718                if tries >= SAME_SELECTOR_PAIR_RETRY_LIMIT {
1719                    return None;
1720                }
1721                anchor_chunk = self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1722                positive_chunk = self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1723            }
1724            let anchor_raw_text = anchor_chunk.text.clone();
1725            self.decorate_chunk_parallel(anchor_record, &mut anchor_chunk, rng);
1726            self.decorate_chunk_parallel(anchor_record, &mut positive_chunk, rng);
1727            return Some((anchor_chunk, positive_chunk, anchor_raw_text));
1728        }
1729        let (mut anchor_chunk, mut positive_chunk) = self.select_anchor_positive_parallel(
1730            anchor_record,
1731            &recipe.anchor,
1732            &recipe.positive_selector,
1733            false,
1734            rng,
1735        )?;
1736        let anchor_raw_text = anchor_chunk.text.clone();
1737        self.decorate_chunk_parallel(anchor_record, &mut anchor_chunk, rng);
1738        self.decorate_chunk_parallel(anchor_record, &mut positive_chunk, rng);
1739        Some((anchor_chunk, positive_chunk, anchor_raw_text))
1740    }
1741
1742    fn select_chunk(&mut self, record: &DataRecord, selector: &Selector) -> Option<RecordChunk> {
1743        match selector {
1744            Selector::Role(role) => self.select_by_role(record, role),
1745            Selector::Paragraph(idx) => record.sections.get(*idx).and_then(|section| {
1746                let pool = self.materialize_chunks(record, *idx, section);
1747                self.next_chunk_from_pool(&record.id, *idx, pool)
1748            }),
1749            Selector::TemporalOffset(offset) => self
1750                .select_temporal_neighbor(record, *offset)
1751                .and_then(|neighbor| self.select_by_role(&neighbor, &SectionRole::Context)),
1752            Selector::Random => {
1753                if record.sections.is_empty() {
1754                    return None;
1755                }
1756                let idx = self.rng.random_range(0..record.sections.len());
1757                record.sections.get(idx).and_then(|section| {
1758                    let pool = self.materialize_chunks(record, idx, section);
1759                    self.next_chunk_from_pool(&record.id, idx, pool)
1760                })
1761            }
1762        }
1763    }
1764
1765    fn select_by_role(&mut self, record: &DataRecord, role: &SectionRole) -> Option<RecordChunk> {
1766        let indices: Vec<usize> = record
1767            .sections
1768            .iter()
1769            .enumerate()
1770            .filter(|(_, section)| roles_match(role, &section.role))
1771            .map(|(idx, _)| idx)
1772            .collect();
1773        if indices.is_empty() {
1774            return None;
1775        }
1776        let key = role_cursor_key(&record.id, role);
1777        let start_offset = self
1778            .role_cursors
1779            .get(&key)
1780            .and_then(|last_idx| indices.iter().position(|idx| idx == last_idx))
1781            .map(|pos| (pos + 1) % indices.len())
1782            .unwrap_or_else(|| {
1783                // On first use for this (record,role), choose a deterministic hashed
1784                // section offset instead of always starting at the first matching section.
1785                // This avoids systematic head-bias while preserving reproducibility.
1786                let seed_key = format!("{}::{}", key.0, key.1);
1787                (stable_hash_str(self.epoch_seed(), &seed_key) as usize) % indices.len()
1788            });
1789        for offset in 0..indices.len() {
1790            let section_idx = indices[(start_offset + offset) % indices.len()];
1791            let section = &record.sections[section_idx];
1792            let pool = self.materialize_chunks(record, section_idx, section);
1793            if let Some(chunk) = self.next_chunk_from_pool(&record.id, section_idx, pool) {
1794                self.role_cursors.insert(key.clone(), section_idx);
1795                return Some(chunk);
1796            }
1797        }
1798        None
1799    }
1800
1801    /// Materialize chunk windows for one section according to `ChunkingStrategy`.
1802    ///
1803    /// Window layout algorithm:
1804    /// 1) Tokenize section text with `split_whitespace`.
1805    /// 2) Set `span = min(max_window_tokens, total_tokens)`.
1806    /// 3) If `span == total_tokens`, emit one full-section `ChunkView::Window`.
1807    /// 4) Otherwise, for each configured overlap:
1808    ///    - `stride = max(1, span - overlap)`
1809    ///    - emit windows `[start, min(start + span, total_tokens))` while advancing by `stride`.
1810    /// 5) Optionally append one `SummaryFallback` chunk when section is longer than `max_window_tokens`.
1811    ///
1812    /// This is the source of truth for how chunk windows are "laid" in a record.
1813    fn materialize_chunks(
1814        &self,
1815        record: &DataRecord,
1816        section_idx: usize,
1817        section: &RecordSection,
1818    ) -> Vec<RecordChunk> {
1819        self.chunker
1820            .materialize(&self.config.chunking, record, section_idx, section)
1821    }
1822
1823    fn build_derived_text_recipes(recipes: &[TripletRecipe]) -> Vec<TextRecipe> {
1824        let mut derived = Vec::new();
1825        for recipe in recipes {
1826            let base = recipe.name.as_ref();
1827            derived.push(TextRecipe {
1828                name: Cow::Owned(format!("{base}_anchor")),
1829                selector: recipe.anchor.clone(),
1830                weight: recipe.weight,
1831                instruction: None,
1832            });
1833            derived.push(TextRecipe {
1834                name: Cow::Owned(format!("{base}_positive")),
1835                selector: recipe.positive_selector.clone(),
1836                weight: recipe.weight,
1837                instruction: None,
1838            });
1839            derived.push(TextRecipe {
1840                name: Cow::Owned(format!("{base}_negative")),
1841                selector: recipe.negative_selector.clone(),
1842                weight: recipe.weight,
1843                instruction: None,
1844            });
1845        }
1846        derived
1847    }
1848
1849    /// Stage A helper: true when record contains an Anchor/Context section whose token
1850    /// count exceeds `chunking.max_window_tokens`.
1851    fn record_has_long_anchor_or_context_section(&self, record: &DataRecord) -> bool {
1852        let window = self.config.chunking.max_window_tokens;
1853        if window == 0 {
1854            return false;
1855        }
1856        record.sections.iter().any(|section| {
1857            matches!(section.role, SectionRole::Anchor | SectionRole::Context)
1858                && WhitespaceTokenizer.token_count(&section.text) > window
1859        })
1860    }
1861
1862    fn sync_records_from_cache(&mut self) -> Result<(), SamplerError> {
1863        let mut snapshot = self.ingestion.all_records_snapshot();
1864        snapshot.sort_by(|a, b| a.id.cmp(&b.id));
1865
1866        // Determine which records are being evicted BEFORE clearing self.records.
1867        // Use owned strings to avoid borrowing self.records across the clear().
1868        let old_ids: HashSet<String> = self.records.keys().cloned().collect();
1869        let incoming_ids: HashSet<String> = snapshot.iter().map(|r| r.id.clone()).collect();
1870        let evicted: HashSet<&String> = old_ids.difference(&incoming_ids).collect();
1871
1872        // Replace the record pool with the current cache snapshot so that
1873        // records evicted from the bounded in-memory cache no longer consume
1874        // memory here.  Without this clear, stale records accumulate across
1875        // cache refreshes, causing unbounded memory growth.
1876        self.records.clear();
1877        // Rebuild the long-section set from the new record pool.  Without
1878        // clearing first, sources whose records were evicted would leave
1879        // stale entries, though this is bounded by num_sources in practice.
1880        self.sources_with_long_sections.clear();
1881        // Prune emitted text hashes only for records that were actually
1882        // evicted from the pool, not on every ingestion advance.  This
1883        // preserves cross-batch dedup within a stable pool: with a window
1884        // of 512 and batch_size of 4, only 4 records are swapped per
1885        // advance, and the other 508 records' emitted hashes must survive.
1886        //
1887        // Old code cleared the entire set on ANY pool change, which broke
1888        // cross-batch dedup on every single advance call.
1889        for evicted_id in &evicted {
1890            self.emitted_text_hashes.remove(*evicted_id);
1891        }
1892        // Cursor state (BM25 hard-negative caches, chunk/role cursors) must
1893        // never outlive a record snapshot boundary.  BM25 backend clears its
1894        // negative cursors here; chunk/role cursors are pruned below in
1895        // prune_cursor_state() against the new record pool.
1896        self.negative_backend.on_sync_start();
1897        for record in snapshot {
1898            if self.split_store.label_for(&record.id).is_none() {
1899                self.split_store.ensure(record.id.clone())?;
1900            }
1901            if self.record_has_long_anchor_or_context_section(&record) {
1902                // Mark source-level eligibility for auto-injected chunk-pair recipe.
1903                self.sources_with_long_sections
1904                    .insert(record.source.clone());
1905            }
1906            self.records.insert(record.id.clone(), Arc::new(record));
1907        }
1908        self.prune_cursor_state();
1909        self.rebuild_chunk_index();
1910        self.rebuild_source_index()?;
1911        Ok(())
1912    }
1913
1914    fn ingest_internal_for_split(&mut self, target_split: SplitLabel) -> Result<(), SamplerError> {
1915        if !self.ingestion.has_sources() {
1916            return Ok(());
1917        }
1918        if !self.ingestion_cursors_loaded {
1919            if let Some(state) = self.split_store.load_sampler_state()? {
1920                self.ingestion.load_cursors(&state.source_stream_cursors);
1921                self.ingestion.set_epoch(state.epoch);
1922                self.ingestion.set_epoch_step(state.epoch_step);
1923            }
1924            self.ingestion_cursors_loaded = true;
1925        }
1926        if self.ingestion.all_caches_empty() {
1927            self.ingestion.refresh_all();
1928        } else {
1929            self.ingestion.advance(self.config.batch_size);
1930        }
1931        let observed = self.ingestion.total_ingest_count();
1932        if observed == self.last_observed_ingest && !self.records.is_empty() {
1933            return Ok(());
1934        }
1935        self.last_observed_ingest = observed;
1936        self.sync_records_from_cache()?;
1937        let max_window_tokens = self.config.chunking.max_window_tokens;
1938        self.negative_backend.on_records_refreshed(
1939            &self.records,
1940            max_window_tokens,
1941            &|id| self.split_store.label_for(id),
1942            self.ingestion.last_refreshed_sources(),
1943        );
1944        // Epoch tracking and source-state management must run every batch regardless
1945        // of whether the record pool changed — reconcile advances the sampling cursor.
1946        self.epoch_tracker.ensure_loaded()?;
1947        let records_by_split = self.records_by_split()?;
1948        self.epoch_tracker
1949            .reconcile(target_split, &records_by_split);
1950        self.ensure_source_state()?;
1951        Ok(())
1952    }
1953
1954    /// Ensure ingestion cursors (including epoch step) are loaded from the
1955    /// persisted state so the step counter reflects the saved value before
1956    /// the wrapper increments it for the current call.
1957    ///
1958    /// Only loads ingestion cursors and source epoch.  The sampling state
1959    /// (RNG, source_record_cursors, …) is restored later by
1960    /// `ingest_internal_for_split` → `ensure_source_state`, after
1961    /// `sync_records_from_cache` has populated `source_record_indices`.
1962    fn ensure_ingestion_cursors_loaded(&mut self) -> Result<(), SamplerError> {
1963        if !self.ingestion_cursors_loaded {
1964            if let Some(state) = self.split_store.load_sampler_state()? {
1965                self.ingestion.load_cursors(&state.source_stream_cursors);
1966                self.ingestion.set_epoch(state.epoch);
1967                self.ingestion.set_epoch_step(state.epoch_step);
1968            }
1969            self.ingestion_cursors_loaded = true;
1970        }
1971        Ok(())
1972    }
1973
1974    #[cfg(test)]
1975    fn ingest_internal(&mut self, split: SplitLabel) -> Result<(), SamplerError> {
1976        self.ingest_internal_for_split(split)
1977    }
1978
1979    fn ingest_internal_with_weights_for_split(
1980        &mut self,
1981        target_split: SplitLabel,
1982        weights: &HashMap<SourceId, f32>,
1983    ) -> Result<(), SamplerError> {
1984        if !self.ingestion.has_sources() {
1985            return Ok(());
1986        }
1987        if !self.ingestion_cursors_loaded {
1988            if let Some(state) = self.split_store.load_sampler_state()? {
1989                self.ingestion.load_cursors(&state.source_stream_cursors);
1990                self.ingestion.set_epoch(state.epoch);
1991                self.ingestion.set_epoch_step(state.epoch_step);
1992            }
1993            self.ingestion_cursors_loaded = true;
1994        }
1995        if self.ingestion.all_caches_empty() {
1996            self.ingestion.refresh_all_with_weights(weights)?;
1997        } else {
1998            self.ingestion
1999                .advance_with_weights(self.config.batch_size, weights)?;
2000        }
2001        let observed = self.ingestion.total_ingest_count();
2002        if observed == self.last_observed_ingest && !self.records.is_empty() {
2003            return Ok(());
2004        }
2005        self.last_observed_ingest = observed;
2006        self.sync_records_from_cache()?;
2007        let max_window_tokens = self.config.chunking.max_window_tokens;
2008        self.negative_backend.on_records_refreshed(
2009            &self.records,
2010            max_window_tokens,
2011            &|id| self.split_store.label_for(id),
2012            self.ingestion.last_refreshed_sources(),
2013        );
2014        self.epoch_tracker.ensure_loaded()?;
2015        let records_by_split = self.records_by_split()?;
2016        self.epoch_tracker
2017            .reconcile(target_split, &records_by_split);
2018        self.ensure_source_state()?;
2019        Ok(())
2020    }
2021
2022    /// Helper that centralises the weight-fallback logic.
2023    ///
2024    /// If `weights` is `Some` and non-uniform or if `weights` is `None`,
2025    /// delegates to the appropriate ingest method.
2026    fn ingest_with_weights_fallback(
2027        &mut self,
2028        target_split: SplitLabel,
2029        weights: Option<&HashMap<SourceId, f32>>,
2030    ) -> Result<(), SamplerError> {
2031        match weights {
2032            Some(weights)
2033                if !weights.is_empty()
2034                    && !weights
2035                        .values()
2036                        .all(|&w| w == *weights.values().next().unwrap()) =>
2037            {
2038                self.ingest_internal_with_weights_for_split(target_split, weights)?
2039            }
2040            _ => self.ingest_internal_for_split(target_split)?,
2041        }
2042        Ok(())
2043    }
2044
2045    fn force_ingest_refresh_with_weights_for_split(
2046        &mut self,
2047        target_split: SplitLabel,
2048        weights: &HashMap<SourceId, f32>,
2049    ) -> Result<(), SamplerError> {
2050        if !self.ingestion.has_sources() {
2051            return Ok(());
2052        }
2053        if !self.ingestion_cursors_loaded {
2054            if let Some(state) = self.split_store.load_sampler_state()? {
2055                self.ingestion.load_cursors(&state.source_stream_cursors);
2056                self.ingestion.set_epoch(state.epoch);
2057                self.ingestion.set_epoch_step(state.epoch_step);
2058            }
2059            self.ingestion_cursors_loaded = true;
2060        }
2061        self.ingestion.force_refresh_all_with_weights(weights)?;
2062        self.last_observed_ingest = self.ingestion.total_ingest_count();
2063        self.sync_records_from_cache()?;
2064        let max_window_tokens = self.config.chunking.max_window_tokens;
2065        self.negative_backend.on_records_refreshed(
2066            &self.records,
2067            max_window_tokens,
2068            &|id| self.split_store.label_for(id),
2069            self.ingestion.last_refreshed_sources(),
2070        );
2071        self.epoch_tracker.ensure_loaded()?;
2072        let records_by_split = self.records_by_split()?;
2073        self.epoch_tracker
2074            .reconcile(target_split, &records_by_split);
2075        self.ensure_source_state()?;
2076        Ok(())
2077    }
2078
2079    /// Select one triplet candidate for a specific source and split.
2080    ///
2081    /// This helper is the source-level recipe execution path used by both pair and
2082    /// triplet batch builders. It always starts from `triplet_recipes_for_source`,
2083    /// which already includes any auto-injected long-section recipe when eligible.
2084    ///
2085    /// Returns:
2086    /// - candidate triplet (with recipe) if one was sampled,
2087    /// - number of recipe attempts consumed (for round-robin bookkeeping).
2088    fn sample_source_triplet_candidate(
2089        &mut self,
2090        source: &str,
2091        target_split: SplitLabel,
2092        recipe_orders: &mut HashMap<RecipeKey, Vec<usize>>,
2093        recipe_positions: &mut HashMap<RecipeKey, usize>,
2094        rng: &mut DeterministicRng,
2095    ) -> (Option<(TripletRecipe, SampleTriplet)>, usize) {
2096        // Stage A (source-level injection): resolve effective recipe pool,
2097        // including auto-injected long-section recipe when source is eligible.
2098        let (recipes, _auto_injected) = self.resolve_source_triplet_plan(source);
2099        if recipes.is_empty() {
2100            return (None, 0);
2101        }
2102        if !recipe_orders.contains_key(source) {
2103            let recipe_weights: Vec<f32> = recipes.iter().map(|r| r.weight).collect();
2104            let order =
2105                self.recipe_order_weighted_cycled(&recipe_weights, self.triplet_recipe_rr_idx, rng);
2106            recipe_orders.insert(source.to_string(), order);
2107        }
2108        let order = recipe_orders
2109            .get(source)
2110            .expect("recipe order missing for source");
2111        let pos = recipe_positions.entry(source.to_string()).or_insert(0);
2112        let Some(anchor) = self.choose_anchor_record(Some(source), target_split) else {
2113            return (None, 0);
2114        };
2115
2116        let mut attempts = 0usize;
2117        for offset in 0..order.len() {
2118            let idx = order[(*pos + offset) % order.len()];
2119            attempts = attempts.saturating_add(1);
2120            let recipe = recipes[idx].clone();
2121            // Stage B/C happen inside `make_triplet_with_anchor`:
2122            // - Stage B: record-level auto-recipe eligibility gate,
2123            // - Stage C: chunk window materialization/selection.
2124            if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, rng) {
2125                *pos = (*pos + offset + 1) % order.len();
2126                return (Some((recipe, sample)), attempts);
2127            }
2128        }
2129
2130        (None, attempts)
2131    }
2132
2133    fn next_pair_batch_inner_with_weights(
2134        &mut self,
2135        target_split: SplitLabel,
2136        weights: Option<&HashMap<SourceId, f32>>,
2137    ) -> Result<SampleBatch, SamplerError> {
2138        self.ingest_with_weights_fallback(target_split, weights)?;
2139        self.ensure_split_has_records(target_split)?;
2140        let sources = self.source_order.clone();
2141        if sources.is_empty() {
2142            if self.triplet_recipes.is_empty() {
2143                return Err(SamplerError::Configuration(
2144                    "no triplet recipes available".into(),
2145                ));
2146            }
2147            let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2148            let recipe_weights: Vec<f32> = self.triplet_recipes.iter().map(|r| r.weight).collect();
2149            let recipe_order = self.recipe_order_weighted_cycled(
2150                &recipe_weights,
2151                self.triplet_recipe_rr_idx,
2152                &mut rng,
2153            );
2154            let mut pairs = Vec::new();
2155            let mut seen = HashSet::new();
2156            let mut last_recipe_name = None;
2157            let mut recipe_pos = 0usize;
2158            let mut recipe_steps = 0usize;
2159            let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2160            for _ in 0..attempts {
2161                if pairs.len() >= self.config.batch_size {
2162                    break;
2163                }
2164                let Some(anchor) = self.choose_anchor_record(None, target_split) else {
2165                    break;
2166                };
2167                let mut triplet = None;
2168                for offset in 0..recipe_order.len() {
2169                    let idx = recipe_order[(recipe_pos + offset) % recipe_order.len()];
2170                    recipe_steps = recipe_steps.saturating_add(1);
2171                    let recipe = self.triplet_recipes[idx].clone();
2172                    last_recipe_name = Some(recipe.name.clone());
2173                    if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, &mut rng)
2174                    {
2175                        triplet = Some((recipe, sample));
2176                        recipe_pos = (recipe_pos + offset + 1) % recipe_order.len();
2177                        break;
2178                    }
2179                }
2180                if let Some((recipe, triplet)) = triplet {
2181                    let key = (
2182                        triplet.anchor.record_id.clone(),
2183                        triplet.positive.record_id.clone(),
2184                        triplet.negative.record_id.clone(),
2185                    );
2186                    if seen.insert(key) {
2187                        let SampleTriplet {
2188                            recipe: triplet_recipe_name,
2189                            anchor,
2190                            positive,
2191                            negative,
2192                            weight,
2193                            instruction,
2194                        } = triplet;
2195                        if pairs.len() < self.config.batch_size {
2196                            pairs.push(SamplePair {
2197                                recipe: triplet_recipe_name.clone(),
2198                                anchor: anchor.clone(),
2199                                positive: positive.clone(),
2200                                weight,
2201                                instruction: instruction.clone(),
2202                                label: PairLabel::Positive,
2203                                reason: None,
2204                            });
2205                        }
2206                        if pairs.len() < self.config.batch_size {
2207                            pairs.push(SamplePair {
2208                                recipe: triplet_recipe_name,
2209                                anchor,
2210                                positive: negative,
2211                                weight,
2212                                instruction,
2213                                label: PairLabel::Negative,
2214                                reason: Some(
2215                                    strategy_reason(&recipe.negative_strategy).to_string(),
2216                                ),
2217                            });
2218                        }
2219                    }
2220                }
2221            }
2222            if recipe_steps > 0 {
2223                self.triplet_recipe_rr_idx =
2224                    self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2225            }
2226            self.rng = rng;
2227            pad_with_reuse(&mut pairs, self.config.batch_size);
2228            if pairs.len() == self.config.batch_size {
2229                return Ok(SampleBatch { pairs });
2230            }
2231            return Err(SamplerError::Exhausted(
2232                last_recipe_name
2233                    .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2234                    .to_string(),
2235            ));
2236        }
2237
2238        let mut pairs = Vec::new();
2239        let mut seen = HashSet::new();
2240        let mut source_steps = 0usize;
2241        let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2242        let mut source_idx = self.source_cycle_idx % sources.len();
2243        let mut cycle_sources = self.shuffled_source_cycle(cycle);
2244        let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2245        let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2246        let mut recipe_steps = 0usize;
2247        let max_recipe_len = sources
2248            .iter()
2249            .map(|source| self.triplet_recipe_count_for_source(source))
2250            .max()
2251            .unwrap_or(1)
2252            .max(1);
2253        let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2254        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2255        for _ in 0..attempts {
2256            if pairs.len() >= self.config.batch_size {
2257                break;
2258            }
2259            let source = cycle_sources[source_idx].as_str();
2260            let (triplet, attempts_used) = self.sample_source_triplet_candidate(
2261                source,
2262                target_split,
2263                &mut recipe_orders,
2264                &mut recipe_positions,
2265                &mut rng,
2266            );
2267            recipe_steps = recipe_steps.saturating_add(attempts_used);
2268            if let Some((recipe, triplet)) = triplet {
2269                let key = (
2270                    triplet.anchor.record_id.clone(),
2271                    triplet.positive.record_id.clone(),
2272                    triplet.negative.record_id.clone(),
2273                );
2274                if seen.insert(key) {
2275                    let SampleTriplet {
2276                        recipe: triplet_recipe_name,
2277                        anchor,
2278                        positive,
2279                        negative,
2280                        weight,
2281                        instruction,
2282                    } = triplet;
2283                    if pairs.len() < self.config.batch_size {
2284                        pairs.push(SamplePair {
2285                            recipe: triplet_recipe_name.clone(),
2286                            anchor: anchor.clone(),
2287                            positive: positive.clone(),
2288                            weight,
2289                            instruction: instruction.clone(),
2290                            label: PairLabel::Positive,
2291                            reason: None,
2292                        });
2293                    }
2294                    if pairs.len() < self.config.batch_size {
2295                        pairs.push(SamplePair {
2296                            recipe: triplet_recipe_name,
2297                            anchor,
2298                            positive: negative,
2299                            weight,
2300                            instruction,
2301                            label: PairLabel::Negative,
2302                            reason: Some(strategy_reason(&recipe.negative_strategy).to_string()),
2303                        });
2304                    }
2305                }
2306            }
2307            source_idx += 1;
2308            source_steps += 1;
2309            if source_idx >= cycle_sources.len() {
2310                source_idx = 0;
2311                cycle = cycle.saturating_add(1);
2312                cycle_sources = self.shuffled_source_cycle(cycle);
2313            }
2314        }
2315        if recipe_steps > 0 {
2316            self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2317        }
2318        self.rng = rng;
2319        pad_with_reuse(&mut pairs, self.config.batch_size);
2320        if pairs.len() == self.config.batch_size {
2321            self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2322            self.source_state_dirty = sources.len() > 1;
2323            return Ok(SampleBatch { pairs });
2324        }
2325        Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2326    }
2327
2328    /// Check whether `sample` passes both per-batch and per-window text dedup.
2329    /// The per-batch set is never cleared mid-batch (survives epoch advances),
2330    /// while `emitted_text_hashes` is cleared on epoch advance to allow fresh
2331    /// permutations.  Both must pass for the sample to be emitted.
2332    fn try_emit_text_sample(
2333        &mut self,
2334        sample: &TextSample,
2335        batch_texts: &mut HashSet<String>,
2336    ) -> bool {
2337        batch_texts.insert(sample.chunk.text.clone())
2338            && self
2339                .emitted_text_hashes
2340                .entry(sample.chunk.record_id.clone())
2341                .or_default()
2342                .insert(stable_hash_str(0, &sample.chunk.text))
2343    }
2344
2345    fn next_text_batch_inner_with_weights(
2346        &mut self,
2347        target_split: SplitLabel,
2348        weights: Option<&HashMap<SourceId, f32>>,
2349    ) -> Result<TextBatch, SamplerError> {
2350        self.ingest_with_weights_fallback(target_split, weights)?;
2351        self.ensure_split_has_records(target_split)?;
2352        let sources = self.source_order.clone();
2353        if sources.is_empty() {
2354            if self.text_recipes.is_empty() {
2355                return Err(SamplerError::Configuration(
2356                    "no text recipes configured".into(),
2357                ));
2358            }
2359            let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2360            let recipe_weights: Vec<f32> = self.text_recipes.iter().map(|r| r.weight).collect();
2361            let recipe_order = self.text_recipe_order_weighted_cycled(
2362                &recipe_weights,
2363                self.text_recipe_rr_idx,
2364                &mut rng,
2365            );
2366            let mut samples = Vec::new();
2367            let mut batch_texts = HashSet::new();
2368            let mut last_recipe_name = None;
2369            let mut recipe_pos = 0usize;
2370            let mut recipe_steps = 0usize;
2371            let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2372            for _ in 0..attempts {
2373                if samples.len() >= self.config.batch_size {
2374                    break;
2375                }
2376                let recipe_idx = recipe_order[recipe_pos];
2377                recipe_pos = (recipe_pos + 1) % recipe_order.len();
2378                recipe_steps = recipe_steps.saturating_add(1);
2379                let recipe = self.text_recipes[recipe_idx].clone();
2380                last_recipe_name = Some(recipe.name.clone());
2381                if let Some(sample) =
2382                    self.make_text_sample_for_split(&recipe, None, target_split, &mut rng)
2383                    && self.try_emit_text_sample(&sample, &mut batch_texts)
2384                {
2385                    samples.push(sample);
2386                }
2387            }
2388            if recipe_steps > 0 {
2389                self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2390            }
2391            self.rng = rng;
2392            pad_with_reuse(&mut samples, self.config.batch_size);
2393            if samples.len() == self.config.batch_size {
2394                return Ok(TextBatch { samples });
2395            }
2396            return Err(SamplerError::Exhausted(
2397                last_recipe_name
2398                    .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TEXT))
2399                    .to_string(),
2400            ));
2401        }
2402
2403        let mut samples = Vec::new();
2404        let mut batch_texts = HashSet::new();
2405        let mut source_steps = 0usize;
2406        let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2407        let mut idx = self.source_cycle_idx % sources.len();
2408        let mut cycle_sources = self.shuffled_source_cycle(cycle);
2409        let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2410        let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2411        let mut recipe_steps = 0usize;
2412        let max_recipe_len = sources
2413            .iter()
2414            .map(|source| self.text_recipes_for_source(source).len())
2415            .max()
2416            .unwrap_or(1)
2417            .max(1);
2418        let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2419        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2420        for _ in 0..attempts {
2421            if samples.len() >= self.config.batch_size {
2422                break;
2423            }
2424            let source = cycle_sources[idx].as_str();
2425            let recipes = self.text_recipes_for_source(source).to_vec();
2426            if recipes.is_empty() {
2427                idx += 1;
2428                source_steps += 1;
2429                if idx >= cycle_sources.len() {
2430                    idx = 0;
2431                    cycle = cycle.saturating_add(1);
2432                    cycle_sources = self.shuffled_source_cycle(cycle);
2433                }
2434                continue;
2435            }
2436            if !recipe_orders.contains_key(source) {
2437                let recipe_weights: Vec<f32> = recipes.iter().map(|r| r.weight).collect();
2438                let order = self.text_recipe_order_weighted_cycled(
2439                    &recipe_weights,
2440                    self.text_recipe_rr_idx,
2441                    &mut rng,
2442                );
2443                recipe_orders.insert(source.to_string(), order);
2444            }
2445            let order = recipe_orders
2446                .get(source)
2447                .expect("recipe order missing for source");
2448            let pos = recipe_positions.entry(source.to_string()).or_insert(0);
2449            let mut sample: Option<(TextRecipe, TextSample)> = None;
2450            for offset in 0..order.len() {
2451                let recipe_idx = order[(*pos + offset) % order.len()];
2452                let recipe = recipes[recipe_idx].clone();
2453                if let Some(item) =
2454                    self.make_text_sample_for_split(&recipe, Some(source), target_split, &mut rng)
2455                    && self.try_emit_text_sample(&item, &mut batch_texts)
2456                {
2457                    recipe_steps = recipe_steps.saturating_add(offset + 1);
2458                    *pos = (*pos + offset + 1) % order.len();
2459                    sample = Some((recipe, item));
2460                    break;
2461                }
2462            }
2463            if sample.is_none() {
2464                recipe_steps = recipe_steps.saturating_add(order.len());
2465            }
2466            if let Some((_recipe, sample)) = sample {
2467                samples.push(sample);
2468            }
2469            idx += 1;
2470            source_steps += 1;
2471            if idx >= cycle_sources.len() {
2472                idx = 0;
2473                cycle = cycle.saturating_add(1);
2474                cycle_sources = self.shuffled_source_cycle(cycle);
2475            }
2476        }
2477        if samples.len() != self.config.batch_size {
2478            pad_with_reuse(&mut samples, self.config.batch_size);
2479        }
2480        if samples.len() != self.config.batch_size {
2481            self.rng = rng;
2482            return Err(SamplerError::Exhausted(RECIPE_LABEL_TEXT.into()));
2483        }
2484        self.rng = rng;
2485        self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2486        self.source_state_dirty = sources.len() > 1;
2487        if recipe_steps > 0 {
2488            self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2489        }
2490        Ok(TextBatch { samples })
2491    }
2492
2493    fn next_triplet_batch_inner_with_weights(
2494        &mut self,
2495        target_split: SplitLabel,
2496        weights: Option<&HashMap<SourceId, f32>>,
2497    ) -> Result<TripletBatch, SamplerError> {
2498        self.ingest_with_weights_fallback(target_split, weights)?;
2499        self.ensure_split_has_records(target_split)?;
2500        let sources = self.source_order.clone();
2501        if sources.is_empty() {
2502            if self.triplet_recipes.is_empty() {
2503                return Err(SamplerError::Configuration(
2504                    "no triplet recipes configured".into(),
2505                ));
2506            }
2507            let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2508            let recipe_weights: Vec<f32> = self.triplet_recipes.iter().map(|r| r.weight).collect();
2509            let recipe_order = self.recipe_order_weighted_cycled(
2510                &recipe_weights,
2511                self.triplet_recipe_rr_idx,
2512                &mut rng,
2513            );
2514            let mut triplets = Vec::new();
2515            let mut seen = HashSet::new();
2516            let mut last_recipe_name = None;
2517            let mut recipe_pos = 0usize;
2518            let mut recipe_steps = 0usize;
2519            let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2520            for _ in 0..attempts {
2521                if triplets.len() >= self.config.batch_size {
2522                    break;
2523                }
2524                let Some(anchor) = self.choose_anchor_record(None, target_split) else {
2525                    break;
2526                };
2527                let mut triplet = None;
2528                for offset in 0..recipe_order.len() {
2529                    let idx = recipe_order[(recipe_pos + offset) % recipe_order.len()];
2530                    recipe_steps = recipe_steps.saturating_add(1);
2531                    let recipe = self.triplet_recipes[idx].clone();
2532                    last_recipe_name = Some(recipe.name.clone());
2533                    if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, &mut rng)
2534                    {
2535                        triplet = Some(sample);
2536                        recipe_pos = (recipe_pos + offset + 1) % recipe_order.len();
2537                        break;
2538                    }
2539                }
2540                if let Some(triplet) = triplet {
2541                    let key = (
2542                        triplet.anchor.record_id.clone(),
2543                        triplet.positive.record_id.clone(),
2544                        triplet.negative.record_id.clone(),
2545                    );
2546                    if seen.insert(key) {
2547                        triplets.push(triplet);
2548                    }
2549                }
2550            }
2551            if recipe_steps > 0 {
2552                self.triplet_recipe_rr_idx =
2553                    self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2554            }
2555            self.rng = rng;
2556            pad_with_reuse(&mut triplets, self.config.batch_size);
2557            if triplets.len() == self.config.batch_size {
2558                return Ok(TripletBatch { triplets });
2559            }
2560            return Err(SamplerError::Exhausted(
2561                last_recipe_name
2562                    .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2563                    .to_string(),
2564            ));
2565        }
2566
2567        // ── Declarations for multi-source parallel path ────────────────────────
2568        let mut triplets: Vec<SampleTriplet> = Vec::new();
2569        let mut seen: HashSet<(RecordId, RecordId, RecordId)> = HashSet::new();
2570        let mut source_steps = 0usize;
2571        let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2572        let mut source_idx = self.source_cycle_idx % sources.len();
2573        let mut cycle_sources = self.shuffled_source_cycle(cycle);
2574        let mut recipe_steps = 0usize;
2575        let max_recipe_len = sources
2576            .iter()
2577            .map(|source| self.triplet_recipe_count_for_source(source.as_str()))
2578            .max()
2579            .unwrap_or(1)
2580            .max(1);
2581        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2582
2583        // ── Phase 1: sequential anchor pre-assignment ──────────────────────────
2584        // Advance cursors and pick anchors deterministically. Capture a fork seed
2585        // for each slot so that Phase 2 can reconstruct independent rngs in
2586        // deterministic order.
2587        struct SlotPlan {
2588            anchor: Arc<DataRecord>,
2589            recipes: Vec<TripletRecipe>,
2590            fork_seed: u64,
2591        }
2592        let target_slots = self.config.batch_size * 4 * max_recipe_len;
2593        let mut slot_plans: Vec<SlotPlan> = Vec::with_capacity(target_slots);
2594
2595        for _ in 0..target_slots {
2596            if slot_plans.len() >= target_slots {
2597                break;
2598            }
2599            let source = cycle_sources[source_idx].as_str();
2600            let (recipes, _) = self.resolve_source_triplet_plan(source);
2601            if !recipes.is_empty() {
2602                let fork_seed = rng.next_u64();
2603                if let Some(anchor) = self.choose_anchor_record(Some(source), target_split) {
2604                    slot_plans.push(SlotPlan {
2605                        anchor,
2606                        recipes,
2607                        fork_seed,
2608                    });
2609                    recipe_steps = recipe_steps.saturating_add(1);
2610                }
2611            }
2612            source_idx += 1;
2613            source_steps += 1;
2614            if source_idx >= cycle_sources.len() {
2615                source_idx = 0;
2616                cycle = cycle.saturating_add(1);
2617                cycle_sources = self.shuffled_source_cycle(cycle);
2618            }
2619        }
2620        self.rng = rng;
2621
2622        // ── Phase 2: parallel anchor+positive selection ────────────────────────
2623        // Only chunk selection runs in parallel; negative selection and the
2624        // coin-flip swap remain sequential in Phase 3 so that the BM25 cursor
2625        // rotation (shared RwLock state) is deterministic across runs.
2626        struct SlotCandidate {
2627            recipe: TripletRecipe,
2628            anchor: Arc<DataRecord>,
2629            anchor_chunk: RecordChunk,
2630            positive_chunk: RecordChunk,
2631            anchor_raw_text: String,
2632        }
2633        let mut raw_candidates: Vec<(usize, Option<SlotCandidate>)> = slot_plans
2634            .par_iter()
2635            .enumerate()
2636            .map(|(slot_idx, plan)| {
2637                let mut fork_rng = DeterministicRng::new(plan.fork_seed);
2638                // Use weighted shuffle so recipes with higher weight are tried more
2639                // often and zero-weight recipes are excluded entirely.
2640                let weights: Vec<f32> = plan.recipes.iter().map(|r| r.weight).collect();
2641                let order = weighted_recipe_order(&weights, &mut fork_rng);
2642                let mut candidate = None;
2643                for &idx in &order {
2644                    let recipe = &plan.recipes[idx];
2645                    if let Some((ac, pc, raw)) =
2646                        self.select_anchor_positive_for_recipe(recipe, &plan.anchor, &mut fork_rng)
2647                    {
2648                        candidate = Some(SlotCandidate {
2649                            recipe: recipe.clone(),
2650                            anchor: Arc::clone(&plan.anchor),
2651                            anchor_chunk: ac,
2652                            positive_chunk: pc,
2653                            anchor_raw_text: raw,
2654                        });
2655                        break;
2656                    }
2657                }
2658                (slot_idx, candidate)
2659            })
2660            .collect();
2661
2662        // ── Phase 3: sequential finalization (negative selection + coin-flip) ───
2663        // Sort by slot_idx to restore deterministic ordering, then finalize each
2664        // candidate sequentially so that BM25 cursor rotation advances in a fixed
2665        // order regardless of rayon's thread scheduling.
2666        raw_candidates.sort_unstable_by_key(|(i, _)| *i);
2667        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2668        for (_, candidate) in raw_candidates {
2669            if triplets.len() >= self.config.batch_size {
2670                break;
2671            }
2672            let Some(sc) = candidate else { continue };
2673            let (negative_record, fallback_used) = match self.select_negative_record(
2674                &sc.anchor,
2675                &sc.recipe.negative_strategy,
2676                Some(&sc.anchor_raw_text),
2677                &mut rng,
2678            ) {
2679                Some(pair) => pair,
2680                None => continue,
2681            };
2682            let mut negative_chunk = match self.select_chunk_parallel(
2683                &negative_record,
2684                &sc.recipe.negative_selector,
2685                &mut rng,
2686            ) {
2687                Some(c) => c,
2688                None => continue,
2689            };
2690            self.decorate_chunk_parallel(&negative_record, &mut negative_chunk, &mut rng);
2691
2692            // 50 % coin-flip swap using the pre-sampled per-slot seed + main rng.
2693            let (anchor_chunk, positive_chunk) = if rng.next_u64() & ANCHOR_POSITIVE_SWAP_MASK == 0
2694            {
2695                (sc.positive_chunk, sc.anchor_chunk)
2696            } else {
2697                (sc.anchor_chunk, sc.positive_chunk)
2698            };
2699
2700            if (!sc.recipe.allow_same_anchor_positive && anchor_chunk.text == positive_chunk.text)
2701                || negative_chunk.text == positive_chunk.text
2702                || negative_chunk.text == anchor_chunk.text
2703            {
2704                continue;
2705            }
2706
2707            let chunk_weight = self.triplet_chunk_weight(
2708                &sc.recipe,
2709                &anchor_chunk,
2710                &positive_chunk,
2711                &negative_chunk,
2712            );
2713            let weight = sc.recipe.weight * chunk_weight;
2714            let recipe_name = if fallback_used {
2715                format!("{}_fallback_same_split", sc.recipe.name)
2716            } else {
2717                sc.recipe.name.to_string()
2718            };
2719            let triplet = SampleTriplet {
2720                recipe: recipe_name,
2721                anchor: anchor_chunk,
2722                positive: positive_chunk,
2723                negative: negative_chunk,
2724                weight,
2725                instruction: sc.recipe.instruction.as_ref().map(|s| s.to_string()),
2726            };
2727            let key = (
2728                triplet.anchor.record_id.clone(),
2729                triplet.positive.record_id.clone(),
2730                triplet.negative.record_id.clone(),
2731            );
2732            if seen.insert(key) && triplets.len() < self.config.batch_size {
2733                triplets.push(triplet);
2734            }
2735        }
2736        self.rng = rng;
2737
2738        if recipe_steps > 0 {
2739            self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2740        }
2741        pad_with_reuse(&mut triplets, self.config.batch_size);
2742        if triplets.len() == self.config.batch_size {
2743            self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2744            self.source_state_dirty = sources.len() > 1;
2745            let batch = TripletBatch { triplets };
2746            return Ok(batch);
2747        }
2748        Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2749    }
2750
2751    // ── test-only helpers ─────────────────────────────────────────────────────
2752
2753    /// Return a mutable reference to the concrete [`backends::Bm25Backend`], panicking
2754    /// when the feature is disabled (which won't happen — call sites are
2755    /// equally gated on `#[cfg(feature = "bm25-mining")]`).
2756    #[cfg(test)]
2757    #[cfg(feature = "bm25-mining")]
2758    fn bm25_backend_mut(&mut self) -> &mut backends::Bm25Backend {
2759        self.negative_backend
2760            .as_any_mut()
2761            .downcast_mut::<backends::Bm25Backend>()
2762            .expect("bm25_backend_mut: negative_backend is Bm25Backend when bm25-mining feature is active")
2763    }
2764
2765    /// Test shim: `recipe_order_weighted_shuffled` using the inner rng.
2766    #[cfg(test)]
2767    fn recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2768        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2769        let result = self.recipe_order_weighted_shuffled(weights, &mut rng);
2770        self.rng = rng;
2771        result
2772    }
2773
2774    /// Test shim: `recipe_order_weighted_cycled` using the inner rng.
2775    #[cfg(test)]
2776    fn recipe_order_weighted_cycled_seeded(
2777        &mut self,
2778        weights: &[f32],
2779        rr_idx: usize,
2780    ) -> Vec<usize> {
2781        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2782        let result = self.recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2783        self.rng = rng;
2784        result
2785    }
2786
2787    /// Test shim: `text_recipe_order_weighted_shuffled` using the inner rng.
2788    #[cfg(test)]
2789    fn text_recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2790        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2791        let result = self.text_recipe_order_weighted_shuffled(weights, &mut rng);
2792        self.rng = rng;
2793        result
2794    }
2795
2796    /// Test shim: `text_recipe_order_weighted_cycled` using the inner rng.
2797    #[cfg(test)]
2798    fn text_recipe_order_weighted_cycled_seeded(
2799        &mut self,
2800        weights: &[f32],
2801        rr_idx: usize,
2802    ) -> Vec<usize> {
2803        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2804        let result = self.text_recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2805        self.rng = rng;
2806        result
2807    }
2808
2809    /// Test shim: `select_negative_record` using the inner rng.
2810    #[cfg(test)]
2811    fn select_negative_record_seeded(
2812        &mut self,
2813        anchor_record: &DataRecord,
2814        strategy: &NegativeStrategy,
2815        anchor_query_text: Option<&str>,
2816    ) -> Option<(Arc<DataRecord>, bool)> {
2817        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2818        let result =
2819            self.select_negative_record(anchor_record, strategy, anchor_query_text, &mut rng);
2820        self.rng = rng;
2821        result
2822    }
2823
2824    /// Test shim: `make_triplet_with_anchor` using the inner rng.
2825    #[cfg(test)]
2826    fn make_triplet_with_anchor_seeded(
2827        &mut self,
2828        recipe: &TripletRecipe,
2829        anchor: &DataRecord,
2830    ) -> Option<SampleTriplet> {
2831        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2832        let result = self.make_triplet_with_anchor(recipe, anchor, &mut rng);
2833        self.rng = rng;
2834        result
2835    }
2836
2837    /// Test shim: `decorate_chunk` using the inner rng.
2838    #[cfg(test)]
2839    fn decorate_chunk_seeded(&mut self, record: &DataRecord, chunk: &mut RecordChunk) {
2840        let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2841        self.decorate_chunk(record, chunk, &mut rng);
2842        self.rng = rng;
2843    }
2844
2845    // ── extended-metrics helpers ───────────────────────────────────────────────
2846
2847    /// Return cumulative `(fallback_count, selection_count)` from the BM25
2848    /// backend since it was created.  Only available when both `bm25-mining`
2849    /// and `extended-metrics` features are active.
2850    #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
2851    fn bm25_fallback_stats(&self) -> (u64, u64) {
2852        self.negative_backend.bm25_fallback_stats()
2853    }
2854
2855    /// Look up `anchor`'s split label and return BM25-ranked candidate IDs.
2856    ///
2857    /// Mirrors the old `bm25_ranked_candidates` method that tests called on
2858    /// `TripletSamplerInner` before BM25 state moved into `Bm25Backend`.
2859    #[cfg(test)]
2860    #[cfg(feature = "bm25-mining")]
2861    fn bm25_ranked_candidates(&mut self, anchor: &crate::data::DataRecord) -> Vec<RecordId> {
2862        let split = self
2863            .split_store
2864            .label_for(&anchor.id)
2865            .unwrap_or(SplitLabel::Train);
2866        self.negative_backend
2867            .as_any_mut()
2868            .downcast_mut::<backends::Bm25Backend>()
2869            .expect("bm25_ranked_candidates: Bm25Backend")
2870            .ranked_candidates_pub(anchor, split)
2871    }
2872}
2873
2874/// Build a shuffled selection order from a slice of recipe weights.
2875///
2876/// Each recipe with `weight > 0.0` receives a number of slots proportional to its weight
2877/// relative to the smallest positive weight, capped at
2878/// [`RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER`].  The resulting expanded slot list is then
2879/// shuffled, giving each recipe approximately the requested frequency in round-robin
2880/// cycling.
2881///
2882/// This is a free function (no `self`) so it can be called from rayon parallel closures as
2883/// well as from within the `TripletSamplerInner` instance methods.
2884fn weighted_recipe_order(weights: &[f32], rng: &mut DeterministicRng) -> Vec<usize> {
2885    let nonzero: Vec<(usize, f32)> = weights
2886        .iter()
2887        .enumerate()
2888        .filter(|(_, w)| **w > 0.0)
2889        .map(|(i, &w)| (i, w))
2890        .collect();
2891    if nonzero.is_empty() {
2892        return Vec::new();
2893    }
2894    let w_min = nonzero
2895        .iter()
2896        .map(|(_, w)| *w)
2897        .fold(f32::INFINITY, f32::min);
2898    let mut order: Vec<usize> = Vec::new();
2899    for (recipe_idx, w) in &nonzero {
2900        let tickets = ((w / w_min).round() as usize).clamp(1, RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER);
2901        for _ in 0..tickets {
2902            order.push(*recipe_idx);
2903        }
2904    }
2905    order.shuffle(rng);
2906    order
2907}
2908
2909fn same_selector_pair_is_valid(
2910    anchor_chunk: &RecordChunk,
2911    positive_chunk: &RecordChunk,
2912    enforce_window_pair: bool,
2913) -> bool {
2914    if triplet_chunk_key(anchor_chunk) == triplet_chunk_key(positive_chunk) {
2915        return false;
2916    }
2917    if !enforce_window_pair {
2918        return true;
2919    }
2920    matches!(
2921        (&anchor_chunk.view, &positive_chunk.view),
2922        (ChunkView::Window { .. }, ChunkView::Window { .. })
2923    )
2924}
2925
2926impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> TripletSampler<S> {
2927    /// Create a sampler from config and a split-state backend.
2928    pub fn new(config: SamplerConfig, split_store: Arc<S>) -> Self {
2929        let inner = TripletSamplerInner::new(config, split_store);
2930        Self {
2931            inner: Mutex::new(inner),
2932        }
2933    }
2934
2935    /// Create a sampler from config with a custom chunking implementation.
2936    pub fn new_with_chunker(
2937        config: SamplerConfig,
2938        split_store: Arc<S>,
2939        chunker: Arc<dyn ChunkingAlgorithm>,
2940    ) -> Self {
2941        let inner = TripletSamplerInner::new_with_chunker(config, split_store, chunker);
2942        Self {
2943            inner: Mutex::new(inner),
2944        }
2945    }
2946
2947    /// Return an unweighted pair batch for `split`.
2948    pub fn next_pair_batch_for_split(
2949        &self,
2950        split: SplitLabel,
2951    ) -> Result<SampleBatch, SamplerError> {
2952        self.next_pair_batch_with_weights_for_split(split, &HashMap::new())
2953    }
2954
2955    /// Return an unweighted text batch for `split`.
2956    pub fn next_text_batch_for_split(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
2957        self.next_text_batch_with_weights_for_split(split, &HashMap::new())
2958    }
2959
2960    /// Return an unweighted triplet batch for `split`.
2961    pub fn next_triplet_batch_for_split(
2962        &self,
2963        split: SplitLabel,
2964    ) -> Result<TripletBatch, SamplerError> {
2965        self.next_triplet_batch_with_weights_for_split(split, &HashMap::new())
2966    }
2967
2968    /// Shared retry loop for all three batch types: load state, bump step,
2969    /// retry on exhaustion up to EXHAUSTION_RETRY_LIMIT times, and return
2970    /// the batch or propagate the final error.
2971    fn next_batch_with_retry<B>(
2972        &self,
2973        split: SplitLabel,
2974        weights: &HashMap<SourceId, f32>,
2975        inner_fn: impl Fn(
2976            &mut TripletSamplerInner<S>,
2977            SplitLabel,
2978            Option<&HashMap<SourceId, f32>>,
2979        ) -> Result<B, SamplerError>,
2980    ) -> Result<B, SamplerError> {
2981        let mut inner = self.inner.lock().unwrap();
2982        inner.ensure_split_allowed(split)?;
2983        // Load persisted state BEFORE bumping step so the restored step
2984        // counter is reflected before we increment it for this call.
2985        inner.ensure_ingestion_cursors_loaded()?;
2986        // Bump epoch step once per public call, regardless of success/exhaustion.
2987        inner.ingestion.increment_epoch_step();
2988        for attempt in 0..=EXHAUSTION_RETRY_LIMIT {
2989            match inner_fn(&mut inner, split, Some(weights)) {
2990                Ok(batch) => return Ok(batch),
2991                Err(SamplerError::Exhausted(_)) if attempt < EXHAUSTION_RETRY_LIMIT => {
2992                    inner.force_ingest_refresh_with_weights_for_split(split, weights)?;
2993                }
2994                Err(err) => return Err(err),
2995            }
2996        }
2997        // The for-loop always returns via the match arms above.
2998        unreachable!()
2999    }
3000
3001    /// Return a weighted pair batch for `split` using per-source weights.
3002    pub fn next_pair_batch_with_weights_for_split(
3003        &self,
3004        split: SplitLabel,
3005        weights: &HashMap<SourceId, f32>,
3006    ) -> Result<SampleBatch, SamplerError> {
3007        self.next_batch_with_retry(split, weights, |inner, split, weights| {
3008            inner.next_pair_batch_inner_with_weights(split, weights)
3009        })
3010    }
3011
3012    /// Return a weighted text batch for `split` using per-source weights.
3013    pub fn next_text_batch_with_weights_for_split(
3014        &self,
3015        split: SplitLabel,
3016        weights: &HashMap<SourceId, f32>,
3017    ) -> Result<TextBatch, SamplerError> {
3018        self.next_batch_with_retry(split, weights, |inner, split, weights| {
3019            inner.next_text_batch_inner_with_weights(split, weights)
3020        })
3021    }
3022
3023    /// Return a weighted triplet batch for `split` using per-source weights.
3024    pub fn next_triplet_batch_with_weights_for_split(
3025        &self,
3026        split: SplitLabel,
3027        weights: &HashMap<SourceId, f32>,
3028    ) -> Result<TripletBatch, SamplerError> {
3029        self.next_batch_with_retry(split, weights, |inner, split, weights| {
3030            inner.next_triplet_batch_inner_with_weights(split, weights)
3031        })
3032    }
3033
3034    /// Spawn a background prefetcher for triplet batches.
3035    pub fn prefetch_triplet_batches(
3036        self: Arc<Self>,
3037        split: SplitLabel,
3038        capacity: usize,
3039    ) -> BatchPrefetcher<TripletBatch> {
3040        BatchPrefetcher::new(capacity, move || self.next_triplet_batch_for_split(split))
3041    }
3042
3043    /// Spawn a background prefetcher for weighted triplet batches.
3044    pub fn prefetch_triplet_batches_with_weights(
3045        self: Arc<Self>,
3046        split: SplitLabel,
3047        capacity: usize,
3048        weights: HashMap<SourceId, f32>,
3049    ) -> BatchPrefetcher<TripletBatch> {
3050        BatchPrefetcher::new(capacity, move || {
3051            self.next_triplet_batch_with_weights_for_split(split, &weights)
3052        })
3053    }
3054
3055    /// Spawn a background prefetcher for pair batches.
3056    pub fn prefetch_pair_batches(
3057        self: Arc<Self>,
3058        split: SplitLabel,
3059        capacity: usize,
3060    ) -> BatchPrefetcher<SampleBatch> {
3061        BatchPrefetcher::new(capacity, move || self.next_pair_batch_for_split(split))
3062    }
3063
3064    /// Spawn a background prefetcher for weighted pair batches.
3065    pub fn prefetch_pair_batches_with_weights(
3066        self: Arc<Self>,
3067        split: SplitLabel,
3068        capacity: usize,
3069        weights: HashMap<SourceId, f32>,
3070    ) -> BatchPrefetcher<SampleBatch> {
3071        BatchPrefetcher::new(capacity, move || {
3072            self.next_pair_batch_with_weights_for_split(split, &weights)
3073        })
3074    }
3075
3076    /// Spawn a background prefetcher for text batches.
3077    pub fn prefetch_text_batches(
3078        self: Arc<Self>,
3079        split: SplitLabel,
3080        capacity: usize,
3081    ) -> BatchPrefetcher<TextBatch> {
3082        BatchPrefetcher::new(capacity, move || self.next_text_batch_for_split(split))
3083    }
3084
3085    /// Spawn a background prefetcher for weighted text batches.
3086    pub fn prefetch_text_batches_with_weights(
3087        self: Arc<Self>,
3088        split: SplitLabel,
3089        capacity: usize,
3090        weights: HashMap<SourceId, f32>,
3091    ) -> BatchPrefetcher<TextBatch> {
3092        BatchPrefetcher::new(capacity, move || {
3093            self.next_text_batch_with_weights_for_split(split, &weights)
3094        })
3095    }
3096
3097    /// Return the currently active text recipes.
3098    pub fn text_recipes(&self) -> Vec<TextRecipe> {
3099        let inner = self.inner.lock().unwrap();
3100        inner.text_recipes().to_vec()
3101    }
3102
3103    /// Register a data source for ingestion and sampling.
3104    ///
3105    /// Returns an error if the source's `id()` matches the reserved `__*__`
3106    /// pattern used for internal synthetic/metadata source identifiers.
3107    pub fn register_source(
3108        &self,
3109        source: Box<dyn DataSource + 'static>,
3110    ) -> Result<(), SamplerError> {
3111        let mut inner = self.inner.lock().unwrap();
3112        inner.register_source(source)
3113    }
3114
3115    /// Force sampler epoch to `epoch` (advanced deterministic replay control).
3116    pub fn set_epoch(&self, epoch: u64) -> Result<(), SamplerError> {
3117        let mut inner = self.inner.lock().unwrap();
3118        inner.set_epoch(epoch)
3119    }
3120
3121    /// Persist sampler and split runtime state for restart-resume.
3122    ///
3123    /// When `save_to` is `Some(path)`, current persisted runtime state is also
3124    /// mirrored to `path` when supported by the split-store backend.
3125    pub fn save_sampler_state(&self, save_to: Option<&Path>) -> Result<(), SamplerError> {
3126        let mut inner = self.inner.lock().unwrap();
3127        inner.save_sampler_state(save_to)
3128    }
3129
3130    /// Return cumulative `(fallback_count, selection_count)` from the BM25
3131    /// backend since it was created.  Only available when both `bm25-mining`
3132    /// and `extended-metrics` features are active.
3133    ///
3134    /// `selection_count` counts calls where the pool was non-empty.
3135    /// `fallback_count` counts the subset where BM25 produced no candidates
3136    /// intersecting the pool and random selection was used instead.
3137    #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
3138    pub fn bm25_fallback_stats(&self) -> (u64, u64) {
3139        let inner = self.inner.lock().unwrap();
3140        inner.bm25_fallback_stats()
3141    }
3142}
3143
3144impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> Sampler for TripletSampler<S> {
3145    fn next_pair_batch(&self, split: SplitLabel) -> Result<SampleBatch, SamplerError> {
3146        self.next_pair_batch_for_split(split)
3147    }
3148
3149    fn next_pair_batch_with_weights(
3150        &self,
3151        split: SplitLabel,
3152        weights: &HashMap<SourceId, f32>,
3153    ) -> Result<SampleBatch, SamplerError> {
3154        self.next_pair_batch_with_weights_for_split(split, weights)
3155    }
3156
3157    fn next_text_batch(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
3158        self.next_text_batch_for_split(split)
3159    }
3160
3161    fn next_text_batch_with_weights(
3162        &self,
3163        split: SplitLabel,
3164        weights: &HashMap<SourceId, f32>,
3165    ) -> Result<TextBatch, SamplerError> {
3166        self.next_text_batch_with_weights_for_split(split, weights)
3167    }
3168
3169    fn next_triplet_batch(&self, split: SplitLabel) -> Result<TripletBatch, SamplerError> {
3170        self.next_triplet_batch_for_split(split)
3171    }
3172
3173    fn next_triplet_batch_with_weights(
3174        &self,
3175        split: SplitLabel,
3176        weights: &HashMap<SourceId, f32>,
3177    ) -> Result<TripletBatch, SamplerError> {
3178        self.next_triplet_batch_with_weights_for_split(split, weights)
3179    }
3180
3181    fn save_sampler_state(&self, save_to: Option<&std::path::Path>) -> Result<(), SamplerError> {
3182        TripletSampler::save_sampler_state(self, save_to)
3183    }
3184}
3185
3186fn roles_match(target: &SectionRole, candidate: &SectionRole) -> bool {
3187    target == candidate
3188}
3189
3190fn role_cursor_key(record_id: &RecordId, role: &SectionRole) -> (RecordId, String) {
3191    (record_id.clone(), role_label(role))
3192}
3193
3194fn role_label(role: &SectionRole) -> String {
3195    match role {
3196        SectionRole::Anchor => ROLE_LABEL_ANCHOR.into(),
3197        SectionRole::Context => ROLE_LABEL_CONTEXT.into(),
3198    }
3199}
3200
3201fn taxonomy_value(record: &DataRecord, field: MetadataKey) -> Option<&str> {
3202    record.taxonomy.iter().find_map(|entry| field.strip(entry))
3203}
3204
3205fn strategy_reason(strategy: &NegativeStrategy) -> &'static str {
3206    match strategy {
3207        NegativeStrategy::WrongPublicationDate => NEG_REASON_WRONG_DATE,
3208        NegativeStrategy::WrongArticle => NEG_REASON_WRONG_ARTICLE,
3209        NegativeStrategy::QuestionAnswerMismatch => NEG_REASON_WRONG_QA,
3210        NegativeStrategy::SameRecord => NEG_REASON_SAME_RECORD,
3211    }
3212}
3213
3214/// Dedup key for text batch generation: (record_id, text).
3215///
3216/// Unlike `triplet_chunk_key` which compares structural identity
3217/// (record_id, section_idx, window_index), this key compares actual
3218/// text content. This prevents text-columns mode sources (where Anchor
3219/// and Context sections carry identical text) from producing duplicate
3220/// samples from the same record.
3221#[cfg(test)]
3222fn text_dedup_key(chunk: &RecordChunk) -> (String, String) {
3223    (chunk.record_id.clone(), chunk.text.clone())
3224}
3225
3226fn triplet_chunk_key(chunk: &RecordChunk) -> String {
3227    match &chunk.view {
3228        ChunkView::Window { index, .. } => {
3229            format!("{}|{}|w|{}", chunk.record_id, chunk.section_idx, index)
3230        }
3231        ChunkView::SummaryFallback { strategy, .. } => {
3232            format!("{}|{}|s|{}", chunk.record_id, chunk.section_idx, strategy)
3233        }
3234    }
3235}
3236
3237fn pad_with_reuse<T: Clone>(items: &mut Vec<T>, target: usize) {
3238    if items.is_empty() || items.len() >= target {
3239        return;
3240    }
3241    let seed = items.clone();
3242    let base_len = seed.len();
3243    for idx in 0..(target - items.len()) {
3244        items.push(seed[idx % base_len].clone());
3245    }
3246}
3247
3248#[cfg(test)]
3249mod tests;