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