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