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#[derive(Debug, Clone)]
58struct 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
107pub 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
125pub trait Sampler {
127 fn next_pair_batch(&self, split: SplitLabel) -> Result<SampleBatch, SamplerError> {
129 self.next_pair_batch_with_weights(split, &HashMap::new())
130 }
131 fn next_text_batch(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
133 self.next_text_batch_with_weights(split, &HashMap::new())
134 }
135 fn next_triplet_batch(&self, split: SplitLabel) -> Result<TripletBatch, SamplerError> {
137 self.next_triplet_batch_with_weights(split, &HashMap::new())
138 }
139 fn next_pair_batch_with_weights(
141 &self,
142 split: SplitLabel,
143 weights: &HashMap<SourceId, f32>,
144 ) -> Result<SampleBatch, SamplerError>;
145 fn next_text_batch_with_weights(
147 &self,
148 split: SplitLabel,
149 weights: &HashMap<SourceId, f32>,
150 ) -> Result<TextBatch, SamplerError>;
151 fn next_triplet_batch_with_weights(
153 &self,
154 split: SplitLabel,
155 weights: &HashMap<SourceId, f32>,
156 ) -> Result<TripletBatch, SamplerError>;
157}
158
159pub struct BatchPrefetcher<T> {
161 receiver: Option<mpsc::Receiver<Result<T, SamplerError>>>,
162 handle: Option<thread::JoinHandle<()>>,
163 stats: Arc<PrefetcherStats>,
164}
165
166#[derive(Default)]
167struct PrefetcherStats {
169 queued: AtomicUsize,
170 produced: AtomicUsize,
171 errors: AtomicUsize,
172}
173
174impl<T: Send + 'static> BatchPrefetcher<T> {
175 fn new<F>(capacity: usize, mut producer: F) -> Self
176 where
177 F: FnMut() -> Result<T, SamplerError> + Send + 'static,
178 {
179 let (sender, receiver) = mpsc::sync_channel(capacity.max(1));
180 let stats = Arc::new(PrefetcherStats::default());
181 let stats_thread = Arc::clone(&stats);
182 let handle = thread::spawn(move || {
183 loop {
184 let result = producer();
185 if result.is_err() {
186 stats_thread.errors.fetch_add(1, Ordering::Relaxed);
187 }
188 if sender.send(result).is_err() {
189 return;
190 }
191 stats_thread.queued.fetch_add(1, Ordering::Relaxed);
192 stats_thread.produced.fetch_add(1, Ordering::Relaxed);
193 }
194 });
195 Self {
196 receiver: Some(receiver),
197 handle: Some(handle),
198 stats,
199 }
200 }
201
202 pub fn next(&self) -> Result<T, SamplerError> {
204 let receiver = self
205 .receiver
206 .as_ref()
207 .ok_or_else(|| SamplerError::SourceUnavailable {
208 source_id: PREFETCHER_SOURCE_ID.into(),
209 reason: PREFETCHER_STOPPED_REASON.into(),
210 })?;
211 let result = receiver.recv().unwrap_or_else(|_| {
212 Err(SamplerError::SourceUnavailable {
213 source_id: PREFETCHER_SOURCE_ID.into(),
214 reason: PREFETCHER_STOPPED_REASON.into(),
215 })
216 });
217 self.stats
218 .queued
219 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
220 Some(value.saturating_sub(1))
221 })
222 .ok();
223 result
224 }
225
226 pub fn queue_len(&self) -> usize {
228 self.stats.queued.load(Ordering::Relaxed)
229 }
230
231 pub fn produced_count(&self) -> usize {
233 self.stats.produced.load(Ordering::Relaxed)
234 }
235
236 pub fn error_count(&self) -> usize {
238 self.stats.errors.load(Ordering::Relaxed)
239 }
240}
241
242impl<T> Drop for BatchPrefetcher<T> {
243 fn drop(&mut self) {
244 self.receiver.take();
245 if let Some(handle) = self.handle.take() {
246 let _ = handle.join();
247 }
248 }
249}
250
251pub struct TripletSampler<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
254 inner: Mutex<TripletSamplerInner<S>>,
255}
256
257struct TripletSamplerInner<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
259 config: SamplerConfig,
261 chunker: Arc<dyn ChunkingAlgorithm>,
263 split_store: Arc<S>,
265 ingestion: IngestionManager,
267 records: IndexMap<RecordId, Arc<DataRecord>>,
269 rng: DeterministicRng,
271 triplet_recipes: Vec<TripletRecipe>,
273 text_recipes: Vec<TextRecipe>,
275 source_triplet_recipes: HashMap<SourceId, Vec<TripletRecipe>>,
277 sources_with_long_sections: HashSet<SourceId>,
279 source_text_recipes: HashMap<SourceId, Vec<TextRecipe>>,
281 using_config_triplet_recipes: bool,
283 using_config_text_recipes: bool,
285 last_observed_ingest: u64,
287 epoch_tracker: EpochTracker,
289 chunk_cursors: HashMap<(RecordId, usize), usize>,
291 role_cursors: HashMap<(RecordId, String), usize>,
293 negative_backend: Box<dyn backends::NegativeBackend>,
295 chunk_index: HashMap<RecordId, RecordId>,
297 source_order: Vec<SourceId>,
299 source_cycle_idx: usize,
301 source_state_loaded: bool,
303 ingestion_cursors_loaded: bool,
305 source_state_dirty: bool,
307 source_record_indices: HashMap<SourceId, Vec<usize>>,
309 source_record_cursors: HashMap<SourceId, usize>,
311 triplet_recipe_rr_idx: usize,
313 emitted_text_hashes: HashMap<RecordId, HashSet<u64>>,
322 text_recipe_rr_idx: usize,
324 epoch: u64,
326
327 source_wrapped: HashMap<SourceId, bool>,
329}
330
331impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> TripletSamplerInner<S> {
332 fn new(config: SamplerConfig, split_store: Arc<S>) -> Self {
333 Self::new_with_chunker(config, split_store, Arc::new(SlidingWindowChunker))
334 }
335
336 fn new_with_chunker(
337 config: SamplerConfig,
338 split_store: Arc<S>,
339 chunker: Arc<dyn ChunkingAlgorithm>,
340 ) -> Self {
341 let buffer_size = config.ingestion_max_records.max(config.batch_size).max(2);
342 let using_config_triplet_recipes = !config.recipes.is_empty();
343 let using_config_text_recipes = !config.text_recipes.is_empty();
344 let triplet_recipes = if using_config_triplet_recipes {
345 config.recipes.clone()
346 } else {
347 Vec::new()
348 };
349 let text_recipes = if using_config_text_recipes {
350 config.text_recipes.clone()
351 } else if !triplet_recipes.is_empty() {
352 Self::build_derived_text_recipes(&triplet_recipes)
353 } else {
354 Vec::new()
355 };
356 let ingestion = IngestionManager::new(buffer_size, config.clone());
357 let epoch_backend = Some(Arc::clone(&split_store) as Arc<dyn EpochStateStore>);
358 let epoch_tracker = EpochTracker::new(
359 true,
360 epoch_backend,
361 derive_epoch_seed(config.seed, EPOCH_SEED_OFFSET),
362 );
363 let mut sampler = Self {
364 rng: DeterministicRng::new(config.seed),
365 config,
366 chunker,
367 split_store,
368 ingestion,
369 records: IndexMap::new(),
370 triplet_recipes,
371 text_recipes,
372 source_triplet_recipes: HashMap::new(),
373 sources_with_long_sections: HashSet::new(),
374 source_text_recipes: HashMap::new(),
375 using_config_triplet_recipes,
376 using_config_text_recipes,
377 last_observed_ingest: 0,
378 epoch_tracker,
379 chunk_cursors: HashMap::new(),
380 role_cursors: HashMap::new(),
381 negative_backend: {
382 #[cfg(feature = "bm25-mining")]
383 {
384 Box::new(backends::Bm25Backend::new())
385 }
386 #[cfg(not(feature = "bm25-mining"))]
387 {
388 Box::new(backends::DefaultBackend)
389 }
390 },
391 chunk_index: HashMap::new(),
392 source_order: Vec::new(),
393 source_cycle_idx: 0,
394 source_state_loaded: false,
395 ingestion_cursors_loaded: false,
396 source_state_dirty: false,
397 source_record_indices: HashMap::new(),
398 source_record_cursors: HashMap::new(),
399 emitted_text_hashes: HashMap::new(),
400 triplet_recipe_rr_idx: 0,
401 text_recipe_rr_idx: 0,
402 epoch: 0,
403 source_wrapped: HashMap::new(),
404 };
405 if !sampler.using_config_text_recipes {
406 sampler.rebuild_derived_text_recipes();
407 }
408 sampler
409 }
410
411 fn text_recipes(&self) -> &[TextRecipe] {
412 &self.text_recipes
413 }
414
415 fn epoch_seed(&self) -> u64 {
418 derive_epoch_seed(self.config.seed, self.epoch)
419 }
420
421 fn register_source(
422 &mut self,
423 source: Box<dyn DataSource + 'static>,
424 ) -> Result<(), SamplerError> {
425 let source_id = source.id().to_string();
426 if !self.using_config_triplet_recipes {
427 let triplets = source.default_triplet_recipes();
428 if !triplets.is_empty() {
429 self.source_triplet_recipes
430 .insert(source_id.clone(), triplets.clone());
431 if !self.using_config_text_recipes {
432 let derived = Self::build_derived_text_recipes(&triplets);
433 self.source_text_recipes
434 .insert(source_id.clone(), derived.clone());
435 self.extend_text_recipes_unique(&derived);
436 }
437 }
438 }
439 self.ingestion.register_source(source)?;
440 Ok(())
441 }
442
443 fn set_epoch(&mut self, epoch: u64) -> Result<(), SamplerError> {
444 self.epoch_tracker.ensure_loaded()?;
445 self.epoch_tracker.force_epoch(epoch);
446 self.epoch = epoch;
447 self.ingestion.set_epoch(epoch);
448 self.ingestion.reset_stream_cursors();
449 self.ingestion.reset_epoch_step();
452 self.source_record_cursors.clear();
453 self.source_cycle_idx = 0;
454 for source in &self.source_order {
455 self.source_wrapped.insert(source.clone(), false);
456 }
457 self.rebuild_source_index()?;
458 self.source_state_dirty = self.source_order.len() > 1;
459 Ok(())
460 }
461
462 fn next_chunk_from_pool(
463 &mut self,
464 record_id: &str,
465 section_idx: usize,
466 pool: Vec<RecordChunk>,
467 ) -> Option<RecordChunk> {
468 if pool.is_empty() {
469 return None;
470 }
471 let key = (record_id.to_string(), section_idx);
472 if !self.chunk_cursors.contains_key(&key) {
473 let cursor_key = format!("{}::{}", record_id, section_idx);
479 let start = (stable_hash_str(self.epoch_seed(), &cursor_key) as usize) % pool.len();
480 self.chunk_cursors.insert(key.clone(), start);
481 }
482 let cursor = self.chunk_cursors.entry(key).or_insert(0);
483 if *cursor >= pool.len() {
484 *cursor = 0;
485 }
486 let chunk = pool.get(*cursor).cloned();
487 *cursor = (*cursor + 1) % pool.len();
488 chunk
489 }
490
491 fn prune_cursor_state(&mut self) {
492 if self.chunk_cursors.is_empty()
493 && self.role_cursors.is_empty()
494 && self.negative_backend.cursors_empty()
495 {
496 return;
497 }
498 let valid_ids: HashSet<RecordId> = self.records.keys().cloned().collect();
499 self.chunk_cursors
500 .retain(|(record_id, _), _| valid_ids.contains(record_id));
501 self.role_cursors
502 .retain(|(record_id, _), _| valid_ids.contains(record_id));
503 self.negative_backend.prune_cursors(&valid_ids);
504 }
505
506 fn rebuild_chunk_index(&mut self) {
507 self.chunk_index.clear();
508 for record in self.records.values() {
509 self.chunk_index
510 .insert(record.id.clone(), record.id.clone());
511 }
512 }
513
514 fn rebuild_source_index(&mut self) -> Result<(), SamplerError> {
515 self.source_record_indices.clear();
516 let mut label_cache: HashMap<RecordId, SplitLabel> = HashMap::new();
517 let allowed = self.allowed_target_splits();
518 let allowed_set: HashSet<SplitLabel> = allowed.into_iter().collect();
519 for (idx, record) in self.records.values().enumerate() {
520 let label = if let Some(label) = label_cache.get(&record.id) {
521 *label
522 } else {
523 let label = match self.split_store.label_for(&record.id) {
524 Some(label) => label,
525 None => self.split_store.ensure(record.id.clone())?,
526 };
527 label_cache.insert(record.id.clone(), label);
528 label
529 };
530 if !allowed_set.contains(&label) {
531 continue;
532 }
533 self.source_record_indices
534 .entry(record.source.clone())
535 .or_default()
536 .push(idx);
537 }
538
539 let shuffle_seed = self.epoch_seed();
540 for indices in self.source_record_indices.values_mut() {
541 indices.sort_by_key(|idx| {
542 self.records
543 .get_index(*idx)
544 .map(|(_, record)| stable_hash_str(shuffle_seed, &record.id))
545 .unwrap_or(0)
546 });
547 }
548
549 self.source_order = self.source_record_indices.keys().cloned().collect();
550 self.source_order.sort();
551 self.refresh_source_wrapped();
552
553 self.source_record_cursors
554 .retain(|source, _| self.source_record_indices.contains_key(source));
555 if self.source_state_loaded {
556 if self.source_order.is_empty() {
557 self.source_cycle_idx = 0;
558 }
559 self.source_state_dirty = self.source_order.len() > 1;
560 }
561 Ok(())
562 }
563
564 fn refresh_source_wrapped(&mut self) {
565 self.source_wrapped.clear();
566 for source in &self.source_order {
567 let len = self
568 .source_record_indices
569 .get(source)
570 .map(|items| items.len())
571 .unwrap_or(0);
572 if len == 0 {
573 self.source_wrapped.insert(source.clone(), false);
574 continue;
575 }
576 let cursor = self.source_record_cursors.get(source).copied().unwrap_or(0);
577 let wrapped = cursor > 0 && cursor % len == 0;
578 self.source_wrapped.insert(source.clone(), wrapped);
579 }
580 }
581
582 fn shuffled_source_cycle(&self, cycle: u64) -> Vec<SourceId> {
583 let mut sources = self.source_order.clone();
584 let seed = self.epoch_seed() ^ cycle;
585 sources.sort_by_key(|source| stable_hash_str(seed, source));
586 sources
587 }
588
589 fn ensure_source_state(&mut self) -> Result<(), SamplerError> {
590 if self.source_state_loaded {
591 return Ok(());
592 }
593 let persisted = self.split_store.load_sampler_state()?;
594 self.source_cycle_idx = persisted
595 .as_ref()
596 .map(|state| state.source_cycle_idx as usize)
597 .unwrap_or(0);
598 if let Some(state) = persisted {
599 for (source, cursor) in state.source_record_cursors {
600 if self.source_record_indices.contains_key(&source) {
601 self.source_record_cursors.insert(source, cursor as usize);
602 }
603 }
604 self.epoch = state.epoch;
605 self.ingestion.set_epoch(state.epoch);
606 self.rng = DeterministicRng::from_state(state.rng_state);
607 self.triplet_recipe_rr_idx = state.triplet_recipe_rr_idx as usize;
608 self.text_recipe_rr_idx = state.text_recipe_rr_idx as usize;
609 }
610 self.refresh_source_wrapped();
611 self.source_state_loaded = true;
612 self.source_state_dirty = true;
613 Ok(())
614 }
615
616 fn persist_source_state(&mut self, save_to: Option<&Path>) -> Result<(), SamplerError> {
617 if !self.source_state_loaded {
618 return Ok(());
619 }
620 let state = PersistedSamplerState {
621 source_cycle_idx: self.source_cycle_idx as u64,
622 source_record_cursors: self
623 .source_record_cursors
624 .iter()
625 .map(|(source, cursor)| (source.clone(), *cursor as u64))
626 .collect(),
627 epoch: self.epoch,
628 epoch_step: self.ingestion.epoch_step(),
629 rng_state: self.rng.state(),
630 triplet_recipe_rr_idx: self.triplet_recipe_rr_idx as u64,
631 text_recipe_rr_idx: self.text_recipe_rr_idx as u64,
632 source_stream_cursors: self.ingestion.snapshot_cursors(),
633 };
634 self.split_store.save_sampler_state(&state, save_to)?;
635 self.source_state_dirty = false;
636 Ok(())
637 }
638
639 fn rebuild_derived_text_recipes(&mut self) {
640 if self.using_config_text_recipes {
641 return;
642 }
643 if self.triplet_recipes.is_empty() {
644 self.text_recipes.clear();
645 } else {
646 self.text_recipes = Self::build_derived_text_recipes(&self.triplet_recipes);
647 }
648 }
649
650 fn extend_text_recipes_unique(&mut self, recipes: &[TextRecipe]) {
651 for recipe in recipes {
652 if self
653 .text_recipes
654 .iter()
655 .any(|existing| existing.name == recipe.name)
656 {
657 continue;
658 }
659 self.text_recipes.push(recipe.clone());
660 }
661 }
662
663 fn configured_triplet_recipes_for_source<'a>(&'a self, source: &str) -> &'a [TripletRecipe] {
664 if self.using_config_triplet_recipes {
665 return &self.triplet_recipes;
666 }
667 self.source_triplet_recipes
668 .get(source)
669 .map(|recipes| recipes.as_slice())
670 .unwrap_or(&[])
671 }
672
673 fn contains_auto_chunk_pair_recipe(recipes: &[TripletRecipe]) -> bool {
675 recipes
676 .iter()
677 .any(|recipe| recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME)
678 }
679
680 fn source_supports_chunk_pair_recipe(&self, source: &str) -> bool {
681 if self.config.chunking.max_window_tokens == 0 {
682 return false;
683 }
684 self.sources_with_long_sections.contains(source)
685 }
686
687 fn should_auto_inject_chunk_pair_recipe(
693 &self,
694 source: &str,
695 recipes: &[TripletRecipe],
696 ) -> bool {
697 self.source_supports_chunk_pair_recipe(source)
698 && !Self::contains_auto_chunk_pair_recipe(recipes)
699 }
700
701 fn source_chunk_pair_recipe() -> TripletRecipe {
712 TripletRecipe {
713 name: Cow::Borrowed(AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME),
714 anchor: Selector::Role(SectionRole::Context),
715 positive_selector: Selector::Role(SectionRole::Context),
716 negative_selector: Selector::Role(SectionRole::Context),
717 negative_strategy: NegativeStrategy::WrongArticle,
718 weight: 1.0,
719 instruction: None,
720 allow_same_anchor_positive: false,
721 }
722 }
723
724 fn resolve_source_triplet_plan(&self, source: &str) -> (Vec<TripletRecipe>, bool) {
736 let mut recipes = self.configured_triplet_recipes_for_source(source).to_vec();
737 let mut auto_injected = false;
738 if self.should_auto_inject_chunk_pair_recipe(source, &recipes) {
739 recipes.push(Self::source_chunk_pair_recipe());
740 auto_injected = true;
741 }
742 (recipes, auto_injected)
743 }
744
745 #[cfg(test)]
746 fn triplet_recipes_for_source(&self, source: &str) -> Vec<TripletRecipe> {
747 self.resolve_source_triplet_plan(source).0
748 }
749
750 fn triplet_recipe_count_for_source(&self, source: &str) -> usize {
751 let (recipes, _auto_injected) = self.resolve_source_triplet_plan(source);
752 recipes.len()
753 }
754
755 fn text_recipes_for_source<'a>(&'a self, source: &str) -> &'a [TextRecipe] {
756 if self.using_config_text_recipes || self.using_config_triplet_recipes {
757 return &self.text_recipes;
758 }
759 self.source_text_recipes
760 .get(source)
761 .map(|recipes| recipes.as_slice())
762 .unwrap_or(&[])
763 }
764
765 fn recipe_order_weighted_shuffled(
776 &mut self,
777 weights: &[f32],
778 rng: &mut DeterministicRng,
779 ) -> Vec<usize> {
780 weighted_recipe_order(weights, rng)
781 }
782
783 fn recipe_order_weighted_cycled(
789 &mut self,
790 weights: &[f32],
791 rr_idx: usize,
792 rng: &mut DeterministicRng,
793 ) -> Vec<usize> {
794 let base = self.recipe_order_weighted_shuffled(weights, rng);
795 if base.is_empty() {
796 return base;
797 }
798 let start = rr_idx % base.len();
799 let mut order = Vec::with_capacity(base.len());
800 order.extend_from_slice(&base[start..]);
801 order.extend_from_slice(&base[..start]);
802 order
803 }
804
805 fn text_recipe_order_weighted_shuffled(
809 &mut self,
810 weights: &[f32],
811 rng: &mut DeterministicRng,
812 ) -> Vec<usize> {
813 weighted_recipe_order(weights, rng)
814 }
815
816 fn text_recipe_order_weighted_cycled(
817 &mut self,
818 weights: &[f32],
819 rr_idx: usize,
820 rng: &mut DeterministicRng,
821 ) -> Vec<usize> {
822 let base = self.text_recipe_order_weighted_shuffled(weights, rng);
823 if base.is_empty() {
824 return base;
825 }
826 let start = rr_idx % base.len();
827 let mut order = Vec::with_capacity(base.len());
828 order.extend_from_slice(&base[start..]);
829 order.extend_from_slice(&base[..start]);
830 order
831 }
832
833 fn allowed_target_splits(&self) -> Vec<SplitLabel> {
834 self.config.allowed_splits.clone()
835 }
836
837 fn ensure_split_allowed(&self, split: SplitLabel) -> Result<(), SamplerError> {
838 let allowed = self.allowed_target_splits();
839 if allowed.contains(&split) {
840 return Ok(());
841 }
842 Err(SamplerError::Configuration(format!(
843 "requested split {:?} is not in allowed_splits {:?}",
844 split, allowed
845 )))
846 }
847
848 fn ensure_split_has_records(&mut self, target_split: SplitLabel) -> Result<(), SamplerError> {
849 let records_by_split = self.records_by_split()?;
850 if records_by_split
851 .get(&target_split)
852 .map(|records| !records.is_empty())
853 .unwrap_or(false)
854 {
855 return Ok(());
856 }
857 Err(SamplerError::Exhausted(
858 "no records available for target split".into(),
859 ))
860 }
861
862 fn records_by_split(
863 &self,
864 ) -> Result<HashMap<SplitLabel, Vec<(RecordId, SourceId)>>, SamplerError> {
865 let mut map: HashMap<SplitLabel, Vec<(RecordId, SourceId)>> = HashMap::new();
866 let mut label_cache: HashMap<RecordId, SplitLabel> = HashMap::new();
867 for (chunk_id, record_id) in &self.chunk_index {
868 let Some(record) = self.records.get(record_id) else {
869 continue;
870 };
871 let label = if let Some(label) = label_cache.get(record_id) {
872 *label
873 } else {
874 let label = match self.split_store.label_for(record_id) {
875 Some(label) => label,
876 None => self.split_store.ensure(record_id.clone())?,
877 };
878 label_cache.insert(record_id.clone(), label);
879 label
880 };
881 map.entry(label)
882 .or_default()
883 .push((chunk_id.clone(), record.source.clone()));
884 }
885 Ok(map)
886 }
887
888 fn choose_anchor_record(
889 &mut self,
890 source: Option<&str>,
891 split: SplitLabel,
892 ) -> Option<Arc<DataRecord>> {
893 if let Some(source) = source {
894 let indices = self.source_record_indices.get(source)?;
895 if indices.is_empty() {
896 return None;
897 }
898 let mut cursor = *self.source_record_cursors.get(source).unwrap_or(&0);
899 let cycle = cursor / indices.len();
900 let offset_seed = self.epoch_seed() ^ (cycle as u64);
901 let offset = (stable_hash_str(offset_seed, source) as usize) % indices.len();
902 let mut wrapped = false;
903 let mut selected: Option<Arc<DataRecord>> = None;
904 for _ in 0..indices.len() {
905 let pos = (cursor % indices.len()).saturating_add(offset) % indices.len();
906 let idx = indices[pos];
907 cursor = cursor.saturating_add(1);
908 if cursor.is_multiple_of(indices.len()) {
909 wrapped = true;
910 }
911 if let Some((_, record)) = self.records.get_index(idx) {
912 if self.split_store.label_for(&record.id) != Some(split) {
913 continue;
914 }
915 selected = Some(Arc::clone(record));
916 break;
917 }
918 }
919 self.source_record_cursors
920 .insert(source.to_string(), cursor);
921 if wrapped {
922 self.mark_source_wrapped(source);
923 }
924 return selected;
925 }
926 while let Some(chunk_id) = self.epoch_tracker.next_record(split) {
927 if let Some(record_id) = self.chunk_index.get(&chunk_id)
928 && let Some(record) = self.records.get(record_id)
929 {
930 return Some(Arc::clone(record));
931 }
932 }
933 None
934 }
935
936 fn save_sampler_state(&mut self, save_to: Option<&Path>) -> Result<(), SamplerError> {
937 if self.epoch_tracker.is_enabled() {
938 self.epoch_tracker.persist()?;
939 }
940 self.persist_source_state(save_to)?;
941 Ok(())
942 }
943
944 fn mark_source_wrapped(&mut self, source: &str) {
945 self.source_wrapped.insert(source.to_string(), true);
946 if self.source_order.is_empty() {
947 return;
948 }
949 let all_wrapped = self
950 .source_order
951 .iter()
952 .all(|name| self.source_wrapped.get(name).copied().unwrap_or(false));
953 if all_wrapped {
954 self.advance_epoch();
955 }
956 }
957
958 fn advance_epoch(&mut self) {
959 self.epoch = self.epoch.saturating_add(1);
966 self.ingestion.set_epoch(self.epoch);
967 self.source_record_cursors.clear();
974 self.source_cycle_idx = 0;
975 for source in &self.source_order {
978 self.source_wrapped.insert(source.clone(), false);
979 }
980 let _ = self.rebuild_source_index();
981 self.source_state_dirty = self.source_order.len() > 1;
982 }
983
984 fn select_temporal_neighbor(
985 &'_ self,
986 record: &DataRecord,
987 offset_days: i32,
988 ) -> Option<Arc<DataRecord>> {
989 let target = record.created_at + Duration::days(offset_days.into());
990 let key = record.taxonomy.first().cloned();
991 let record_split = self.split_store.label_for(&record.id)?;
992 self.records
993 .values()
994 .filter(|candidate| {
995 candidate.id != record.id
996 && self
997 .split_store
998 .label_for(&candidate.id)
999 .map(|label| label == record_split)
1000 .unwrap_or(false)
1001 && (candidate.source == record.source
1002 || key
1003 .as_ref()
1004 .zip(candidate.taxonomy.first())
1005 .map(|(a, b)| a == b)
1006 .unwrap_or(false))
1007 })
1008 .min_by_key(|candidate| (candidate.created_at - target).num_seconds().abs())
1009 .cloned()
1010 }
1011
1012 fn select_negative_record(
1013 &self,
1014 anchor_record: &DataRecord,
1015 strategy: &NegativeStrategy,
1016 anchor_query_text: Option<&str>,
1017 rng: &mut dyn rand::RngCore,
1018 ) -> Option<(Arc<DataRecord>, bool)> {
1019 let anchor_split = self.split_store.label_for(&anchor_record.id)?;
1020
1021 let in_anchor_split = |candidate: &DataRecord| {
1022 self.split_store
1023 .label_for(&candidate.id)
1024 .map(|label| label == anchor_split)
1025 .unwrap_or(false)
1026 };
1027
1028 match strategy {
1029 NegativeStrategy::WrongArticle => {
1030 let anchor_date =
1031 taxonomy_value(anchor_record, META_FIELD_DATE).map(|d| d.to_string());
1032 let mut same_date: Vec<Arc<DataRecord>> = self
1033 .records
1034 .values()
1035 .filter(|candidate| {
1036 candidate.source == anchor_record.source
1037 && candidate.id != anchor_record.id
1038 && in_anchor_split(candidate)
1039 })
1040 .filter(|candidate| {
1041 anchor_date
1042 .as_deref()
1043 .zip(taxonomy_value(candidate, META_FIELD_DATE))
1044 .map(|(a, b)| a == b)
1045 .unwrap_or(false)
1046 })
1047 .cloned()
1048 .collect();
1049 if same_date.is_empty() {
1050 same_date = self
1051 .records
1052 .values()
1053 .filter(|candidate| {
1054 candidate.source == anchor_record.source
1055 && candidate.id != anchor_record.id
1056 && in_anchor_split(candidate)
1057 })
1058 .cloned()
1059 .collect();
1060 }
1061 if !same_date.is_empty() {
1062 return self.negative_backend.choose_negative(
1063 anchor_record,
1064 anchor_split,
1065 same_date,
1066 false,
1067 anchor_query_text,
1068 rng,
1069 );
1070 }
1071 let pool = self
1072 .records
1073 .values()
1074 .filter(|candidate| {
1075 candidate.id != anchor_record.id && in_anchor_split(candidate)
1076 })
1077 .cloned()
1078 .collect::<Vec<_>>();
1079 self.negative_backend.choose_negative(
1080 anchor_record,
1081 anchor_split,
1082 pool,
1083 true,
1084 anchor_query_text,
1085 rng,
1086 )
1087 }
1088 NegativeStrategy::WrongPublicationDate => {
1089 let anchor_date =
1090 taxonomy_value(anchor_record, META_FIELD_DATE).map(|d| d.to_string());
1091 let pool: Vec<Arc<DataRecord>> = self
1092 .records
1093 .values()
1094 .filter(|candidate| {
1095 candidate.source == anchor_record.source
1096 && candidate.id != anchor_record.id
1097 && in_anchor_split(candidate)
1098 })
1099 .filter(|candidate| {
1100 match (
1101 anchor_date.as_deref(),
1102 taxonomy_value(candidate, META_FIELD_DATE),
1103 ) {
1104 (Some(anchor), Some(candidate_date)) => anchor != candidate_date,
1105 (Some(_), None) => true,
1106 (None, Some(_)) => true,
1107 (None, None) => false,
1108 }
1109 })
1110 .cloned()
1111 .collect();
1112 if pool.is_empty() {
1113 let fallback_pool = self
1116 .records
1117 .values()
1118 .filter(|candidate| {
1119 candidate.id != anchor_record.id && in_anchor_split(candidate)
1120 })
1121 .cloned()
1122 .collect::<Vec<_>>();
1123
1124 return self.negative_backend.choose_negative(
1125 anchor_record,
1126 anchor_split,
1127 fallback_pool,
1128 true,
1129 anchor_query_text,
1130 rng,
1131 );
1132 }
1133
1134 self.negative_backend.choose_negative(
1135 anchor_record,
1136 anchor_split,
1137 pool,
1138 false,
1139 anchor_query_text,
1140 rng,
1141 )
1142 }
1143 NegativeStrategy::QuestionAnswerMismatch => {
1144 let pool: Vec<Arc<DataRecord>> = self
1145 .records
1146 .values()
1147 .filter(|candidate| {
1148 candidate.source == anchor_record.source
1149 && candidate.id != anchor_record.id
1150 && in_anchor_split(candidate)
1151 })
1152 .cloned()
1153 .collect();
1154 if pool.is_empty() {
1155 let fallback_pool = self
1158 .records
1159 .values()
1160 .filter(|candidate| {
1161 candidate.id != anchor_record.id && in_anchor_split(candidate)
1162 })
1163 .cloned()
1164 .collect::<Vec<_>>();
1165
1166 return self.negative_backend.choose_negative(
1167 anchor_record,
1168 anchor_split,
1169 fallback_pool,
1170 true,
1171 anchor_query_text,
1172 rng,
1173 );
1174 }
1175
1176 self.negative_backend.choose_negative(
1177 anchor_record,
1178 anchor_split,
1179 pool,
1180 false,
1181 anchor_query_text,
1182 rng,
1183 )
1184 }
1185 NegativeStrategy::SameRecord => {
1186 Some((Arc::new(anchor_record.clone()), false))
1192 }
1193 }
1194 }
1195
1196 fn is_auto_chunk_pair_recipe(recipe: &TripletRecipe) -> bool {
1198 recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
1199 }
1200
1201 fn select_anchor_positive_pair(
1205 &mut self,
1206 record: &DataRecord,
1207 anchor_selector: &Selector,
1208 positive_selector: &Selector,
1209 enforce_window_pair: bool,
1210 ) -> Option<(RecordChunk, RecordChunk)> {
1211 let mut anchor_chunk = self.select_chunk(record, anchor_selector)?;
1212 let mut positive_chunk = self.select_chunk(record, positive_selector)?;
1213 if anchor_selector == positive_selector {
1214 let mut retries = 0usize;
1215 while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair)
1216 && retries < SAME_SELECTOR_PAIR_RETRY_LIMIT
1217 {
1218 let Some(redraw_anchor) = self.select_chunk(record, anchor_selector) else {
1219 break;
1220 };
1221 let Some(redraw_positive) = self.select_chunk(record, positive_selector) else {
1222 break;
1223 };
1224 anchor_chunk = redraw_anchor;
1225 positive_chunk = redraw_positive;
1226 retries += 1;
1227 }
1228 if !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair) {
1229 return None;
1230 }
1231 }
1232 Some((anchor_chunk, positive_chunk))
1233 }
1234
1235 fn select_distinct_window_pair_for_auto_recipe(
1245 &mut self,
1246 recipe: &TripletRecipe,
1247 record: &DataRecord,
1248 ) -> Option<(RecordChunk, RecordChunk)> {
1249 if recipe.anchor != recipe.positive_selector {
1250 return None;
1251 }
1252 self.select_anchor_positive_pair(record, &recipe.anchor, &recipe.positive_selector, true)
1253 }
1254
1255 fn record_has_at_least_two_window_chunks_for_selector(
1260 &self,
1261 record: &DataRecord,
1262 selector: &Selector,
1263 ) -> bool {
1264 let section_indices: Vec<usize> = match selector {
1265 Selector::Role(role) => record
1266 .sections
1267 .iter()
1268 .enumerate()
1269 .filter(|(_, section)| roles_match(role, §ion.role))
1270 .map(|(idx, _)| idx)
1271 .collect(),
1272 Selector::Paragraph(idx) => {
1273 if *idx < record.sections.len() {
1274 vec![*idx]
1275 } else {
1276 Vec::new()
1277 }
1278 }
1279 Selector::Random => (0..record.sections.len()).collect(),
1280 Selector::TemporalOffset(_) => return false,
1281 };
1282
1283 let mut window_count = 0usize;
1284 for section_idx in section_indices {
1285 let Some(section) = record.sections.get(section_idx) else {
1286 continue;
1287 };
1288 let chunks = self.materialize_chunks(record, section_idx, section);
1289 window_count += chunks
1290 .iter()
1291 .filter(|chunk| matches!(chunk.view, ChunkView::Window { .. }))
1292 .count();
1293 if window_count >= 2 {
1294 return true;
1295 }
1296 }
1297 false
1298 }
1299
1300 fn build_triplet_with_selector_pair_policy(
1302 &mut self,
1303 recipe: &TripletRecipe,
1304 record: &DataRecord,
1305 enforce_window_pair: bool,
1306 rng: &mut DeterministicRng,
1307 ) -> Option<SampleTriplet> {
1308 let (mut anchor_chunk, mut positive_chunk) = self.select_anchor_positive_pair(
1309 record,
1310 &recipe.anchor,
1311 &recipe.positive_selector,
1312 enforce_window_pair,
1313 )?;
1314 let anchor_raw_text = anchor_chunk.text.clone();
1315 self.decorate_chunk(record, &mut anchor_chunk, rng);
1316 self.decorate_chunk(record, &mut positive_chunk, rng);
1317 self.finalize_triplet_with_negative(
1320 recipe,
1321 record,
1322 anchor_chunk,
1323 positive_chunk,
1324 &anchor_raw_text,
1325 rng,
1326 )
1327 }
1328
1329 fn make_auto_chunk_pair_triplet_with_anchor(
1339 &mut self,
1340 recipe: &TripletRecipe,
1341 record: &DataRecord,
1342 rng: &mut DeterministicRng,
1343 ) -> Option<SampleTriplet> {
1344 if !self.record_has_at_least_two_window_chunks_for_selector(record, &recipe.anchor) {
1345 return None;
1346 }
1347 let (mut anchor_chunk, mut positive_chunk) =
1348 self.select_distinct_window_pair_for_auto_recipe(recipe, record)?;
1349 let anchor_raw_text = anchor_chunk.text.clone();
1350 self.decorate_chunk(record, &mut anchor_chunk, rng);
1351 self.decorate_chunk(record, &mut positive_chunk, rng);
1352 self.finalize_triplet_with_negative(
1353 recipe,
1354 record,
1355 anchor_chunk,
1356 positive_chunk,
1357 &anchor_raw_text,
1358 rng,
1359 )
1360 }
1361
1362 fn make_standard_triplet_with_anchor(
1363 &mut self,
1364 recipe: &TripletRecipe,
1365 record: &DataRecord,
1366 rng: &mut DeterministicRng,
1367 ) -> Option<SampleTriplet> {
1368 self.build_triplet_with_selector_pair_policy(recipe, record, false, rng)
1369 }
1370
1371 fn finalize_triplet_with_negative(
1390 &mut self,
1391 recipe: &TripletRecipe,
1392 record: &DataRecord,
1393 anchor_chunk: RecordChunk,
1394 positive_chunk: RecordChunk,
1395 anchor_raw_text: &str,
1396 rng: &mut DeterministicRng,
1397 ) -> Option<SampleTriplet> {
1398 let (negative_record, fallback_used) = self.select_negative_record(
1399 record,
1400 &recipe.negative_strategy,
1401 Some(anchor_raw_text),
1402 rng,
1403 )?;
1404 let mut negative_chunk = self.select_chunk(&negative_record, &recipe.negative_selector)?;
1405 self.decorate_chunk(&negative_record, &mut negative_chunk, rng);
1406
1407 let (anchor_chunk, positive_chunk) = if rng.next_u64() & ANCHOR_POSITIVE_SWAP_MASK == 0 {
1409 (positive_chunk, anchor_chunk)
1410 } else {
1411 (anchor_chunk, positive_chunk)
1412 };
1413
1414 if (!recipe.allow_same_anchor_positive && anchor_chunk.text == positive_chunk.text)
1425 || negative_chunk.text == positive_chunk.text
1426 || negative_chunk.text == anchor_chunk.text
1427 {
1428 return None;
1429 }
1430
1431 let chunk_weight =
1432 self.triplet_chunk_weight(recipe, &anchor_chunk, &positive_chunk, &negative_chunk);
1433 let weight = recipe.weight * chunk_weight;
1434 let recipe_name = if fallback_used {
1435 format!("{}_fallback_same_split", recipe.name)
1436 } else {
1437 recipe.name.to_string()
1438 };
1439 Some(SampleTriplet {
1440 recipe: recipe_name,
1441 anchor: anchor_chunk,
1442 positive: positive_chunk,
1443 negative: negative_chunk,
1444 weight,
1445 instruction: recipe.instruction.as_ref().map(|s| s.to_string()),
1446 })
1447 }
1448
1449 fn make_triplet_with_anchor(
1450 &mut self,
1451 recipe: &TripletRecipe,
1452 record: &DataRecord,
1453 rng: &mut DeterministicRng,
1454 ) -> Option<SampleTriplet> {
1455 if Self::is_auto_chunk_pair_recipe(recipe) {
1456 return self.make_auto_chunk_pair_triplet_with_anchor(recipe, record, rng);
1457 }
1458 self.make_standard_triplet_with_anchor(recipe, record, rng)
1459 }
1460
1461 fn make_text_sample_for_split(
1462 &mut self,
1463 recipe: &TextRecipe,
1464 source: Option<&str>,
1465 split: SplitLabel,
1466 rng: &mut DeterministicRng,
1467 ) -> Option<TextSample> {
1468 let record = self.choose_anchor_record(source, split)?;
1469 let mut chunk = self.select_chunk(&record, &recipe.selector)?;
1470 self.decorate_chunk(&record, &mut chunk, rng);
1471 let weight = recipe.weight * self.chunk_weight(&chunk);
1472 Some(TextSample {
1473 recipe: recipe.name.to_string(),
1474 chunk,
1475 weight,
1476 instruction: recipe.instruction.as_ref().map(|s| s.to_string()),
1477 })
1478 }
1479
1480 fn chunk_weight(&self, chunk: &RecordChunk) -> f32 {
1481 chunk_weight(&self.config.chunking, chunk)
1482 }
1483
1484 fn triplet_chunk_weight(
1485 &self,
1486 recipe: &TripletRecipe,
1487 anchor: &RecordChunk,
1488 positive: &RecordChunk,
1489 negative: &RecordChunk,
1490 ) -> f32 {
1491 let floor = self.config.chunking.chunk_weight_floor;
1492 let negative_weight = negative.quality.trust.clamp(0.0, 1.0).max(floor);
1493 if Self::is_auto_chunk_pair_recipe(recipe) {
1494 let pair_trust = ((anchor.quality.trust.clamp(0.0, 1.0)
1497 + positive.quality.trust.clamp(0.0, 1.0))
1498 / 2.0)
1499 .clamp(0.0, 1.0);
1500 let pair_weight = (chunk_proximity_score(anchor, positive) * pair_trust).max(floor);
1501 return (pair_weight + pair_weight + negative_weight) / 3.0;
1503 }
1504 let pair_proximity = chunk_proximity_score(anchor, positive);
1507 let anchor_weight = (self.chunk_weight(anchor) * pair_proximity).max(floor);
1508 let positive_weight = (self.chunk_weight(positive) * pair_proximity).max(floor);
1509 (anchor_weight + positive_weight + negative_weight) / 3.0
1510 }
1511
1512 fn decorate_chunk(
1513 &mut self,
1514 record: &DataRecord,
1515 chunk: &mut RecordChunk,
1516 rng: &mut DeterministicRng,
1517 ) {
1518 chunk.kvp_meta = record
1519 .meta_prefix
1520 .as_ref()
1521 .map(|s| s.all_metadata())
1522 .unwrap_or_default();
1523 if let Some(spec) = record.meta_prefix.as_ref()
1524 && let Some(prefix) = spec.sample(rng)
1525 {
1526 let body_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&chunk.text);
1527 let prefix_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&prefix);
1528 let total_tokens = prefix_tokens.len() + body_tokens.len();
1529 let max_window = self.config.chunking.max_window_tokens;
1530 if max_window > 0 && total_tokens > max_window {
1531 if prefix_tokens.len() >= max_window {
1532 chunk.text = prefix_tokens
1533 .into_iter()
1534 .take(max_window)
1535 .collect::<Vec<_>>()
1536 .join(" ");
1537 chunk.tokens_estimate = max_window;
1538 } else {
1539 let remaining = max_window - prefix_tokens.len();
1540 let trimmed_body: Vec<&str> = body_tokens.into_iter().take(remaining).collect();
1541 chunk.text =
1542 format!("{}{}{}", prefix, platform_newline(), trimmed_body.join(" "));
1543 chunk.tokens_estimate = max_window;
1544 }
1545 } else {
1546 chunk.text = format!("{}{}{}", prefix, platform_newline(), chunk.text);
1547 chunk.tokens_estimate = total_tokens;
1548 }
1549 }
1550 }
1551
1552 fn select_chunk_parallel(
1556 &self,
1557 record: &DataRecord,
1558 selector: &Selector,
1559 rng: &mut DeterministicRng,
1560 ) -> Option<RecordChunk> {
1561 match selector {
1562 Selector::Role(role) => self.select_role_parallel(record, role, rng),
1563 Selector::Paragraph(idx) => record.sections.get(*idx).and_then(|section| {
1564 let pool = self.materialize_chunks(record, *idx, section);
1565 if pool.is_empty() {
1566 return None;
1567 }
1568 let i = rng.random_range(0..pool.len());
1569 pool.into_iter().nth(i)
1570 }),
1571 Selector::TemporalOffset(offset) => self
1572 .select_temporal_neighbor(record, *offset)
1573 .and_then(|neighbor| {
1574 self.select_role_parallel(&neighbor, &SectionRole::Context, rng)
1575 }),
1576 Selector::Random => {
1577 if record.sections.is_empty() {
1578 return None;
1579 }
1580 let idx = rng.random_range(0..record.sections.len());
1581 record.sections.get(idx).and_then(|section| {
1582 let pool = self.materialize_chunks(record, idx, section);
1583 if pool.is_empty() {
1584 return None;
1585 }
1586 let i = rng.random_range(0..pool.len());
1587 pool.into_iter().nth(i)
1588 })
1589 }
1590 }
1591 }
1592
1593 fn select_role_parallel(
1595 &self,
1596 record: &DataRecord,
1597 role: &SectionRole,
1598 rng: &mut DeterministicRng,
1599 ) -> Option<RecordChunk> {
1600 let indices: Vec<usize> = record
1601 .sections
1602 .iter()
1603 .enumerate()
1604 .filter(|(_, s)| roles_match(role, &s.role))
1605 .map(|(i, _)| i)
1606 .collect();
1607 if indices.is_empty() {
1608 return None;
1609 }
1610 let start = rng.random_range(0..indices.len());
1611 for offset in 0..indices.len() {
1612 let section_idx = indices[(start + offset) % indices.len()];
1613 let section = &record.sections[section_idx];
1614 let pool = self.materialize_chunks(record, section_idx, section);
1615 if !pool.is_empty() {
1616 let i = rng.random_range(0..pool.len());
1617 return pool.into_iter().nth(i);
1618 }
1619 }
1620 None
1621 }
1622
1623 fn decorate_chunk_parallel(
1625 &self,
1626 record: &DataRecord,
1627 chunk: &mut RecordChunk,
1628 rng: &mut DeterministicRng,
1629 ) {
1630 chunk.kvp_meta = record
1631 .meta_prefix
1632 .as_ref()
1633 .map(|s| s.all_metadata())
1634 .unwrap_or_default();
1635 if let Some(spec) = record.meta_prefix.as_ref()
1636 && let Some(prefix) = spec.sample(rng)
1637 {
1638 let body_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&chunk.text);
1639 let prefix_tokens: Vec<&str> = WhitespaceTokenizer.tokenize(&prefix);
1640 let total_tokens = prefix_tokens.len() + body_tokens.len();
1641 let max_window = self.config.chunking.max_window_tokens;
1642 if max_window > 0 && total_tokens > max_window {
1643 if prefix_tokens.len() >= max_window {
1644 chunk.text = prefix_tokens
1645 .into_iter()
1646 .take(max_window)
1647 .collect::<Vec<_>>()
1648 .join(" ");
1649 chunk.tokens_estimate = max_window;
1650 } else {
1651 let remaining = max_window - prefix_tokens.len();
1652 let trimmed_body: Vec<&str> = body_tokens.into_iter().take(remaining).collect();
1653 chunk.text =
1654 format!("{}{}{}", prefix, platform_newline(), trimmed_body.join(" "));
1655 chunk.tokens_estimate = max_window;
1656 }
1657 } else {
1658 chunk.text = format!("{}{}{}", prefix, platform_newline(), chunk.text);
1659 chunk.tokens_estimate = total_tokens;
1660 }
1661 }
1662 }
1663
1664 fn select_anchor_positive_parallel(
1666 &self,
1667 record: &DataRecord,
1668 anchor_selector: &Selector,
1669 positive_selector: &Selector,
1670 enforce_window_pair: bool,
1671 rng: &mut DeterministicRng,
1672 ) -> Option<(RecordChunk, RecordChunk)> {
1673 let anchor_chunk = self.select_chunk_parallel(record, anchor_selector, rng)?;
1674 let mut positive_chunk = self.select_chunk_parallel(record, positive_selector, rng)?;
1675 if anchor_selector == positive_selector {
1676 let mut retries = 0usize;
1677 while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair)
1678 && retries < SAME_SELECTOR_PAIR_RETRY_LIMIT
1679 {
1680 positive_chunk = self.select_chunk_parallel(record, positive_selector, rng)?;
1681 retries += 1;
1682 }
1683 if !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, enforce_window_pair) {
1684 return None;
1685 }
1686 }
1687 Some((anchor_chunk, positive_chunk))
1689 }
1690
1691 fn select_anchor_positive_for_recipe(
1694 &self,
1695 recipe: &TripletRecipe,
1696 anchor_record: &DataRecord,
1697 rng: &mut DeterministicRng,
1698 ) -> Option<(RecordChunk, RecordChunk, String)> {
1699 if Self::is_auto_chunk_pair_recipe(recipe) {
1700 if !self
1701 .record_has_at_least_two_window_chunks_for_selector(anchor_record, &recipe.anchor)
1702 {
1703 return None;
1704 }
1705 let mut anchor_chunk =
1706 self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1707 let mut positive_chunk =
1708 self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1709 let mut tries = 0usize;
1710 while !same_selector_pair_is_valid(&anchor_chunk, &positive_chunk, true) {
1711 tries += 1;
1712 if tries >= SAME_SELECTOR_PAIR_RETRY_LIMIT {
1713 return None;
1714 }
1715 anchor_chunk = self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1716 positive_chunk = self.select_chunk_parallel(anchor_record, &recipe.anchor, rng)?;
1717 }
1718 let anchor_raw_text = anchor_chunk.text.clone();
1719 self.decorate_chunk_parallel(anchor_record, &mut anchor_chunk, rng);
1720 self.decorate_chunk_parallel(anchor_record, &mut positive_chunk, rng);
1721 return Some((anchor_chunk, positive_chunk, anchor_raw_text));
1722 }
1723 let (mut anchor_chunk, mut positive_chunk) = self.select_anchor_positive_parallel(
1724 anchor_record,
1725 &recipe.anchor,
1726 &recipe.positive_selector,
1727 false,
1728 rng,
1729 )?;
1730 let anchor_raw_text = anchor_chunk.text.clone();
1731 self.decorate_chunk_parallel(anchor_record, &mut anchor_chunk, rng);
1732 self.decorate_chunk_parallel(anchor_record, &mut positive_chunk, rng);
1733 Some((anchor_chunk, positive_chunk, anchor_raw_text))
1734 }
1735
1736 fn select_chunk(&mut self, record: &DataRecord, selector: &Selector) -> Option<RecordChunk> {
1737 match selector {
1738 Selector::Role(role) => self.select_by_role(record, role),
1739 Selector::Paragraph(idx) => record.sections.get(*idx).and_then(|section| {
1740 let pool = self.materialize_chunks(record, *idx, section);
1741 self.next_chunk_from_pool(&record.id, *idx, pool)
1742 }),
1743 Selector::TemporalOffset(offset) => self
1744 .select_temporal_neighbor(record, *offset)
1745 .and_then(|neighbor| self.select_by_role(&neighbor, &SectionRole::Context)),
1746 Selector::Random => {
1747 if record.sections.is_empty() {
1748 return None;
1749 }
1750 let idx = self.rng.random_range(0..record.sections.len());
1751 record.sections.get(idx).and_then(|section| {
1752 let pool = self.materialize_chunks(record, idx, section);
1753 self.next_chunk_from_pool(&record.id, idx, pool)
1754 })
1755 }
1756 }
1757 }
1758
1759 fn select_by_role(&mut self, record: &DataRecord, role: &SectionRole) -> Option<RecordChunk> {
1760 let indices: Vec<usize> = record
1761 .sections
1762 .iter()
1763 .enumerate()
1764 .filter(|(_, section)| roles_match(role, §ion.role))
1765 .map(|(idx, _)| idx)
1766 .collect();
1767 if indices.is_empty() {
1768 return None;
1769 }
1770 let key = role_cursor_key(&record.id, role);
1771 let start_offset = self
1772 .role_cursors
1773 .get(&key)
1774 .and_then(|last_idx| indices.iter().position(|idx| idx == last_idx))
1775 .map(|pos| (pos + 1) % indices.len())
1776 .unwrap_or_else(|| {
1777 let seed_key = format!("{}::{}", key.0, key.1);
1781 (stable_hash_str(self.epoch_seed(), &seed_key) as usize) % indices.len()
1782 });
1783 for offset in 0..indices.len() {
1784 let section_idx = indices[(start_offset + offset) % indices.len()];
1785 let section = &record.sections[section_idx];
1786 let pool = self.materialize_chunks(record, section_idx, section);
1787 if let Some(chunk) = self.next_chunk_from_pool(&record.id, section_idx, pool) {
1788 self.role_cursors.insert(key.clone(), section_idx);
1789 return Some(chunk);
1790 }
1791 }
1792 None
1793 }
1794
1795 fn materialize_chunks(
1808 &self,
1809 record: &DataRecord,
1810 section_idx: usize,
1811 section: &RecordSection,
1812 ) -> Vec<RecordChunk> {
1813 self.chunker
1814 .materialize(&self.config.chunking, record, section_idx, section)
1815 }
1816
1817 fn build_derived_text_recipes(recipes: &[TripletRecipe]) -> Vec<TextRecipe> {
1818 let mut derived = Vec::new();
1819 for recipe in recipes {
1820 let base = recipe.name.as_ref();
1821 derived.push(TextRecipe {
1822 name: Cow::Owned(format!("{base}_anchor")),
1823 selector: recipe.anchor.clone(),
1824 weight: recipe.weight,
1825 instruction: None,
1826 });
1827 derived.push(TextRecipe {
1828 name: Cow::Owned(format!("{base}_positive")),
1829 selector: recipe.positive_selector.clone(),
1830 weight: recipe.weight,
1831 instruction: None,
1832 });
1833 derived.push(TextRecipe {
1834 name: Cow::Owned(format!("{base}_negative")),
1835 selector: recipe.negative_selector.clone(),
1836 weight: recipe.weight,
1837 instruction: None,
1838 });
1839 }
1840 derived
1841 }
1842
1843 fn record_has_long_anchor_or_context_section(&self, record: &DataRecord) -> bool {
1846 let window = self.config.chunking.max_window_tokens;
1847 if window == 0 {
1848 return false;
1849 }
1850 record.sections.iter().any(|section| {
1851 matches!(section.role, SectionRole::Anchor | SectionRole::Context)
1852 && WhitespaceTokenizer.token_count(§ion.text) > window
1853 })
1854 }
1855
1856 fn sync_records_from_cache(&mut self) -> Result<(), SamplerError> {
1857 let mut snapshot = self.ingestion.all_records_snapshot();
1858 snapshot.sort_by(|a, b| a.id.cmp(&b.id));
1859
1860 let old_ids: HashSet<String> = self.records.keys().cloned().collect();
1863 let incoming_ids: HashSet<String> = snapshot.iter().map(|r| r.id.clone()).collect();
1864 let evicted: HashSet<&String> = old_ids.difference(&incoming_ids).collect();
1865
1866 self.records.clear();
1871 self.sources_with_long_sections.clear();
1875 for evicted_id in &evicted {
1884 self.emitted_text_hashes.remove(*evicted_id);
1885 }
1886 self.negative_backend.on_sync_start();
1891 for record in snapshot {
1892 if self.split_store.label_for(&record.id).is_none() {
1893 self.split_store.ensure(record.id.clone())?;
1894 }
1895 if self.record_has_long_anchor_or_context_section(&record) {
1896 self.sources_with_long_sections
1898 .insert(record.source.clone());
1899 }
1900 self.records.insert(record.id.clone(), Arc::new(record));
1901 }
1902 self.prune_cursor_state();
1903 self.rebuild_chunk_index();
1904 self.rebuild_source_index()?;
1905 Ok(())
1906 }
1907
1908 fn ingest_internal_for_split(&mut self, target_split: SplitLabel) -> Result<(), SamplerError> {
1909 if !self.ingestion.has_sources() {
1910 return Ok(());
1911 }
1912 if !self.ingestion_cursors_loaded {
1913 if let Some(state) = self.split_store.load_sampler_state()? {
1914 self.ingestion.load_cursors(&state.source_stream_cursors);
1915 self.ingestion.set_epoch(state.epoch);
1916 self.ingestion.set_epoch_step(state.epoch_step);
1917 }
1918 self.ingestion_cursors_loaded = true;
1919 }
1920 if self.ingestion.all_caches_empty() {
1921 self.ingestion.refresh_all();
1922 } else {
1923 self.ingestion.advance(self.config.batch_size);
1924 }
1925 let observed = self.ingestion.total_ingest_count();
1926 if observed == self.last_observed_ingest && !self.records.is_empty() {
1927 return Ok(());
1928 }
1929 self.last_observed_ingest = observed;
1930 self.sync_records_from_cache()?;
1931 let max_window_tokens = self.config.chunking.max_window_tokens;
1932 self.negative_backend.on_records_refreshed(
1933 &self.records,
1934 max_window_tokens,
1935 &|id| self.split_store.label_for(id),
1936 self.ingestion.last_refreshed_sources(),
1937 );
1938 self.epoch_tracker.ensure_loaded()?;
1941 let records_by_split = self.records_by_split()?;
1942 self.epoch_tracker
1943 .reconcile(target_split, &records_by_split);
1944 self.ensure_source_state()?;
1945 Ok(())
1946 }
1947
1948 fn ensure_ingestion_cursors_loaded(&mut self) -> Result<(), SamplerError> {
1957 if !self.ingestion_cursors_loaded {
1958 if let Some(state) = self.split_store.load_sampler_state()? {
1959 self.ingestion.load_cursors(&state.source_stream_cursors);
1960 self.ingestion.set_epoch(state.epoch);
1961 self.ingestion.set_epoch_step(state.epoch_step);
1962 }
1963 self.ingestion_cursors_loaded = true;
1964 }
1965 Ok(())
1966 }
1967
1968 #[cfg(test)]
1969 fn ingest_internal(&mut self, split: SplitLabel) -> Result<(), SamplerError> {
1970 self.ingest_internal_for_split(split)
1971 }
1972
1973 fn ingest_internal_with_weights_for_split(
1974 &mut self,
1975 target_split: SplitLabel,
1976 weights: &HashMap<SourceId, f32>,
1977 ) -> Result<(), SamplerError> {
1978 if !self.ingestion.has_sources() {
1979 return Ok(());
1980 }
1981 if !self.ingestion_cursors_loaded {
1982 if let Some(state) = self.split_store.load_sampler_state()? {
1983 self.ingestion.load_cursors(&state.source_stream_cursors);
1984 self.ingestion.set_epoch(state.epoch);
1985 self.ingestion.set_epoch_step(state.epoch_step);
1986 }
1987 self.ingestion_cursors_loaded = true;
1988 }
1989 if self.ingestion.all_caches_empty() {
1990 self.ingestion.refresh_all_with_weights(weights)?;
1991 } else {
1992 self.ingestion
1993 .advance_with_weights(self.config.batch_size, weights)?;
1994 }
1995 let observed = self.ingestion.total_ingest_count();
1996 if observed == self.last_observed_ingest && !self.records.is_empty() {
1997 return Ok(());
1998 }
1999 self.last_observed_ingest = observed;
2000 self.sync_records_from_cache()?;
2001 let max_window_tokens = self.config.chunking.max_window_tokens;
2002 self.negative_backend.on_records_refreshed(
2003 &self.records,
2004 max_window_tokens,
2005 &|id| self.split_store.label_for(id),
2006 self.ingestion.last_refreshed_sources(),
2007 );
2008 self.epoch_tracker.ensure_loaded()?;
2009 let records_by_split = self.records_by_split()?;
2010 self.epoch_tracker
2011 .reconcile(target_split, &records_by_split);
2012 self.ensure_source_state()?;
2013 Ok(())
2014 }
2015
2016 fn ingest_with_weights_fallback(
2021 &mut self,
2022 target_split: SplitLabel,
2023 weights: Option<&HashMap<SourceId, f32>>,
2024 ) -> Result<(), SamplerError> {
2025 match weights {
2026 Some(weights)
2027 if !weights.is_empty()
2028 && !weights
2029 .values()
2030 .all(|&w| w == *weights.values().next().unwrap()) =>
2031 {
2032 self.ingest_internal_with_weights_for_split(target_split, weights)?
2033 }
2034 _ => self.ingest_internal_for_split(target_split)?,
2035 }
2036 Ok(())
2037 }
2038
2039 fn force_ingest_refresh_with_weights_for_split(
2040 &mut self,
2041 target_split: SplitLabel,
2042 weights: &HashMap<SourceId, f32>,
2043 ) -> Result<(), SamplerError> {
2044 if !self.ingestion.has_sources() {
2045 return Ok(());
2046 }
2047 if !self.ingestion_cursors_loaded {
2048 if let Some(state) = self.split_store.load_sampler_state()? {
2049 self.ingestion.load_cursors(&state.source_stream_cursors);
2050 self.ingestion.set_epoch(state.epoch);
2051 self.ingestion.set_epoch_step(state.epoch_step);
2052 }
2053 self.ingestion_cursors_loaded = true;
2054 }
2055 self.ingestion.force_refresh_all_with_weights(weights)?;
2056 self.last_observed_ingest = self.ingestion.total_ingest_count();
2057 self.sync_records_from_cache()?;
2058 let max_window_tokens = self.config.chunking.max_window_tokens;
2059 self.negative_backend.on_records_refreshed(
2060 &self.records,
2061 max_window_tokens,
2062 &|id| self.split_store.label_for(id),
2063 self.ingestion.last_refreshed_sources(),
2064 );
2065 self.epoch_tracker.ensure_loaded()?;
2066 let records_by_split = self.records_by_split()?;
2067 self.epoch_tracker
2068 .reconcile(target_split, &records_by_split);
2069 self.ensure_source_state()?;
2070 Ok(())
2071 }
2072
2073 fn sample_source_triplet_candidate(
2083 &mut self,
2084 source: &str,
2085 target_split: SplitLabel,
2086 recipe_orders: &mut HashMap<RecipeKey, Vec<usize>>,
2087 recipe_positions: &mut HashMap<RecipeKey, usize>,
2088 rng: &mut DeterministicRng,
2089 ) -> (Option<(TripletRecipe, SampleTriplet)>, usize) {
2090 let (recipes, _auto_injected) = self.resolve_source_triplet_plan(source);
2093 if recipes.is_empty() {
2094 return (None, 0);
2095 }
2096 if !recipe_orders.contains_key(source) {
2097 let recipe_weights: Vec<f32> = recipes.iter().map(|r| r.weight).collect();
2098 let order =
2099 self.recipe_order_weighted_cycled(&recipe_weights, self.triplet_recipe_rr_idx, rng);
2100 recipe_orders.insert(source.to_string(), order);
2101 }
2102 let order = recipe_orders
2103 .get(source)
2104 .expect("recipe order missing for source");
2105 let pos = recipe_positions.entry(source.to_string()).or_insert(0);
2106 let Some(anchor) = self.choose_anchor_record(Some(source), target_split) else {
2107 return (None, 0);
2108 };
2109
2110 let mut attempts = 0usize;
2111 for offset in 0..order.len() {
2112 let idx = order[(*pos + offset) % order.len()];
2113 attempts = attempts.saturating_add(1);
2114 let recipe = recipes[idx].clone();
2115 if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, rng) {
2119 *pos = (*pos + offset + 1) % order.len();
2120 return (Some((recipe, sample)), attempts);
2121 }
2122 }
2123
2124 (None, attempts)
2125 }
2126
2127 fn next_pair_batch_inner_with_weights(
2128 &mut self,
2129 target_split: SplitLabel,
2130 weights: Option<&HashMap<SourceId, f32>>,
2131 ) -> Result<SampleBatch, SamplerError> {
2132 self.ingest_with_weights_fallback(target_split, weights)?;
2133 self.ensure_split_has_records(target_split)?;
2134 let sources = self.source_order.clone();
2135 if sources.is_empty() {
2136 if self.triplet_recipes.is_empty() {
2137 return Err(SamplerError::Configuration(
2138 "no triplet recipes available".into(),
2139 ));
2140 }
2141 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2142 let recipe_weights: Vec<f32> = self.triplet_recipes.iter().map(|r| r.weight).collect();
2143 let recipe_order = self.recipe_order_weighted_cycled(
2144 &recipe_weights,
2145 self.triplet_recipe_rr_idx,
2146 &mut rng,
2147 );
2148 let mut pairs = Vec::new();
2149 let mut seen = HashSet::new();
2150 let mut last_recipe_name = None;
2151 let mut recipe_pos = 0usize;
2152 let mut recipe_steps = 0usize;
2153 let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2154 for _ in 0..attempts {
2155 if pairs.len() >= self.config.batch_size {
2156 break;
2157 }
2158 let Some(anchor) = self.choose_anchor_record(None, target_split) else {
2159 break;
2160 };
2161 let mut triplet = None;
2162 for offset in 0..recipe_order.len() {
2163 let idx = recipe_order[(recipe_pos + offset) % recipe_order.len()];
2164 recipe_steps = recipe_steps.saturating_add(1);
2165 let recipe = self.triplet_recipes[idx].clone();
2166 last_recipe_name = Some(recipe.name.clone());
2167 if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, &mut rng)
2168 {
2169 triplet = Some((recipe, sample));
2170 recipe_pos = (recipe_pos + offset + 1) % recipe_order.len();
2171 break;
2172 }
2173 }
2174 if let Some((recipe, triplet)) = triplet {
2175 let key = (
2176 triplet.anchor.record_id.clone(),
2177 triplet.positive.record_id.clone(),
2178 triplet.negative.record_id.clone(),
2179 );
2180 if seen.insert(key) {
2181 let SampleTriplet {
2182 recipe: triplet_recipe_name,
2183 anchor,
2184 positive,
2185 negative,
2186 weight,
2187 instruction,
2188 } = triplet;
2189 if pairs.len() < self.config.batch_size {
2190 pairs.push(SamplePair {
2191 recipe: triplet_recipe_name.clone(),
2192 anchor: anchor.clone(),
2193 positive: positive.clone(),
2194 weight,
2195 instruction: instruction.clone(),
2196 label: PairLabel::Positive,
2197 reason: None,
2198 });
2199 }
2200 if pairs.len() < self.config.batch_size {
2201 pairs.push(SamplePair {
2202 recipe: triplet_recipe_name,
2203 anchor,
2204 positive: negative,
2205 weight,
2206 instruction,
2207 label: PairLabel::Negative,
2208 reason: Some(
2209 strategy_reason(&recipe.negative_strategy).to_string(),
2210 ),
2211 });
2212 }
2213 }
2214 }
2215 }
2216 if recipe_steps > 0 {
2217 self.triplet_recipe_rr_idx =
2218 self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2219 }
2220 self.rng = rng;
2221 pad_with_reuse(&mut pairs, self.config.batch_size);
2222 if pairs.len() == self.config.batch_size {
2223 return Ok(SampleBatch { pairs });
2224 }
2225 return Err(SamplerError::Exhausted(
2226 last_recipe_name
2227 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2228 .to_string(),
2229 ));
2230 }
2231
2232 let mut pairs = Vec::new();
2233 let mut seen = HashSet::new();
2234 let mut source_steps = 0usize;
2235 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2236 let mut source_idx = self.source_cycle_idx % sources.len();
2237 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2238 let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2239 let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2240 let mut recipe_steps = 0usize;
2241 let max_recipe_len = sources
2242 .iter()
2243 .map(|source| self.triplet_recipe_count_for_source(source))
2244 .max()
2245 .unwrap_or(1)
2246 .max(1);
2247 let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2248 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2249 for _ in 0..attempts {
2250 if pairs.len() >= self.config.batch_size {
2251 break;
2252 }
2253 let source = cycle_sources[source_idx].as_str();
2254 let (triplet, attempts_used) = self.sample_source_triplet_candidate(
2255 source,
2256 target_split,
2257 &mut recipe_orders,
2258 &mut recipe_positions,
2259 &mut rng,
2260 );
2261 recipe_steps = recipe_steps.saturating_add(attempts_used);
2262 if let Some((recipe, triplet)) = triplet {
2263 let key = (
2264 triplet.anchor.record_id.clone(),
2265 triplet.positive.record_id.clone(),
2266 triplet.negative.record_id.clone(),
2267 );
2268 if seen.insert(key) {
2269 let SampleTriplet {
2270 recipe: triplet_recipe_name,
2271 anchor,
2272 positive,
2273 negative,
2274 weight,
2275 instruction,
2276 } = triplet;
2277 if pairs.len() < self.config.batch_size {
2278 pairs.push(SamplePair {
2279 recipe: triplet_recipe_name.clone(),
2280 anchor: anchor.clone(),
2281 positive: positive.clone(),
2282 weight,
2283 instruction: instruction.clone(),
2284 label: PairLabel::Positive,
2285 reason: None,
2286 });
2287 }
2288 if pairs.len() < self.config.batch_size {
2289 pairs.push(SamplePair {
2290 recipe: triplet_recipe_name,
2291 anchor,
2292 positive: negative,
2293 weight,
2294 instruction,
2295 label: PairLabel::Negative,
2296 reason: Some(strategy_reason(&recipe.negative_strategy).to_string()),
2297 });
2298 }
2299 }
2300 }
2301 source_idx += 1;
2302 source_steps += 1;
2303 if source_idx >= cycle_sources.len() {
2304 source_idx = 0;
2305 cycle = cycle.saturating_add(1);
2306 cycle_sources = self.shuffled_source_cycle(cycle);
2307 }
2308 }
2309 if recipe_steps > 0 {
2310 self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2311 }
2312 self.rng = rng;
2313 pad_with_reuse(&mut pairs, self.config.batch_size);
2314 if pairs.len() == self.config.batch_size {
2315 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2316 self.source_state_dirty = sources.len() > 1;
2317 return Ok(SampleBatch { pairs });
2318 }
2319 Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2320 }
2321
2322 fn try_emit_text_sample(
2327 &mut self,
2328 sample: &TextSample,
2329 batch_texts: &mut HashSet<String>,
2330 ) -> bool {
2331 batch_texts.insert(sample.chunk.text.clone())
2332 && self
2333 .emitted_text_hashes
2334 .entry(sample.chunk.record_id.clone())
2335 .or_default()
2336 .insert(stable_hash_str(0, &sample.chunk.text))
2337 }
2338
2339 fn next_text_batch_inner_with_weights(
2340 &mut self,
2341 target_split: SplitLabel,
2342 weights: Option<&HashMap<SourceId, f32>>,
2343 ) -> Result<TextBatch, SamplerError> {
2344 self.ingest_with_weights_fallback(target_split, weights)?;
2345 self.ensure_split_has_records(target_split)?;
2346 let sources = self.source_order.clone();
2347 if sources.is_empty() {
2348 if self.text_recipes.is_empty() {
2349 return Err(SamplerError::Configuration(
2350 "no text recipes configured".into(),
2351 ));
2352 }
2353 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2354 let recipe_weights: Vec<f32> = self.text_recipes.iter().map(|r| r.weight).collect();
2355 let recipe_order = self.text_recipe_order_weighted_cycled(
2356 &recipe_weights,
2357 self.text_recipe_rr_idx,
2358 &mut rng,
2359 );
2360 let mut samples = Vec::new();
2361 let mut batch_texts = HashSet::new();
2362 let mut last_recipe_name = None;
2363 let mut recipe_pos = 0usize;
2364 let mut recipe_steps = 0usize;
2365 let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2366 for _ in 0..attempts {
2367 if samples.len() >= self.config.batch_size {
2368 break;
2369 }
2370 let recipe_idx = recipe_order[recipe_pos];
2371 recipe_pos = (recipe_pos + 1) % recipe_order.len();
2372 recipe_steps = recipe_steps.saturating_add(1);
2373 let recipe = self.text_recipes[recipe_idx].clone();
2374 last_recipe_name = Some(recipe.name.clone());
2375 if let Some(sample) =
2376 self.make_text_sample_for_split(&recipe, None, target_split, &mut rng)
2377 && self.try_emit_text_sample(&sample, &mut batch_texts)
2378 {
2379 samples.push(sample);
2380 }
2381 }
2382 if recipe_steps > 0 {
2383 self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2384 }
2385 self.rng = rng;
2386 pad_with_reuse(&mut samples, self.config.batch_size);
2387 if samples.len() == self.config.batch_size {
2388 return Ok(TextBatch { samples });
2389 }
2390 return Err(SamplerError::Exhausted(
2391 last_recipe_name
2392 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TEXT))
2393 .to_string(),
2394 ));
2395 }
2396
2397 let mut samples = Vec::new();
2398 let mut batch_texts = HashSet::new();
2399 let mut source_steps = 0usize;
2400 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2401 let mut idx = self.source_cycle_idx % sources.len();
2402 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2403 let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2404 let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2405 let mut recipe_steps = 0usize;
2406 let max_recipe_len = sources
2407 .iter()
2408 .map(|source| self.text_recipes_for_source(source).len())
2409 .max()
2410 .unwrap_or(1)
2411 .max(1);
2412 let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2413 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2414 for _ in 0..attempts {
2415 if samples.len() >= self.config.batch_size {
2416 break;
2417 }
2418 let source = cycle_sources[idx].as_str();
2419 let recipes = self.text_recipes_for_source(source).to_vec();
2420 if recipes.is_empty() {
2421 idx += 1;
2422 source_steps += 1;
2423 if idx >= cycle_sources.len() {
2424 idx = 0;
2425 cycle = cycle.saturating_add(1);
2426 cycle_sources = self.shuffled_source_cycle(cycle);
2427 }
2428 continue;
2429 }
2430 if !recipe_orders.contains_key(source) {
2431 let recipe_weights: Vec<f32> = recipes.iter().map(|r| r.weight).collect();
2432 let order = self.text_recipe_order_weighted_cycled(
2433 &recipe_weights,
2434 self.text_recipe_rr_idx,
2435 &mut rng,
2436 );
2437 recipe_orders.insert(source.to_string(), order);
2438 }
2439 let order = recipe_orders
2440 .get(source)
2441 .expect("recipe order missing for source");
2442 let pos = recipe_positions.entry(source.to_string()).or_insert(0);
2443 let mut sample: Option<(TextRecipe, TextSample)> = None;
2444 for offset in 0..order.len() {
2445 let recipe_idx = order[(*pos + offset) % order.len()];
2446 let recipe = recipes[recipe_idx].clone();
2447 if let Some(item) =
2448 self.make_text_sample_for_split(&recipe, Some(source), target_split, &mut rng)
2449 && self.try_emit_text_sample(&item, &mut batch_texts)
2450 {
2451 recipe_steps = recipe_steps.saturating_add(offset + 1);
2452 *pos = (*pos + offset + 1) % order.len();
2453 sample = Some((recipe, item));
2454 break;
2455 }
2456 }
2457 if sample.is_none() {
2458 recipe_steps = recipe_steps.saturating_add(order.len());
2459 }
2460 if let Some((_recipe, sample)) = sample {
2461 samples.push(sample);
2462 }
2463 idx += 1;
2464 source_steps += 1;
2465 if idx >= cycle_sources.len() {
2466 idx = 0;
2467 cycle = cycle.saturating_add(1);
2468 cycle_sources = self.shuffled_source_cycle(cycle);
2469 }
2470 }
2471 if samples.len() != self.config.batch_size {
2472 pad_with_reuse(&mut samples, self.config.batch_size);
2473 }
2474 if samples.len() != self.config.batch_size {
2475 self.rng = rng;
2476 return Err(SamplerError::Exhausted(RECIPE_LABEL_TEXT.into()));
2477 }
2478 self.rng = rng;
2479 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2480 self.source_state_dirty = sources.len() > 1;
2481 if recipe_steps > 0 {
2482 self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2483 }
2484 Ok(TextBatch { samples })
2485 }
2486
2487 fn next_triplet_batch_inner_with_weights(
2488 &mut self,
2489 target_split: SplitLabel,
2490 weights: Option<&HashMap<SourceId, f32>>,
2491 ) -> Result<TripletBatch, SamplerError> {
2492 self.ingest_with_weights_fallback(target_split, weights)?;
2493 self.ensure_split_has_records(target_split)?;
2494 let sources = self.source_order.clone();
2495 if sources.is_empty() {
2496 if self.triplet_recipes.is_empty() {
2497 return Err(SamplerError::Configuration(
2498 "no triplet recipes configured".into(),
2499 ));
2500 }
2501 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2502 let recipe_weights: Vec<f32> = self.triplet_recipes.iter().map(|r| r.weight).collect();
2503 let recipe_order = self.recipe_order_weighted_cycled(
2504 &recipe_weights,
2505 self.triplet_recipe_rr_idx,
2506 &mut rng,
2507 );
2508 let mut triplets = Vec::new();
2509 let mut seen = HashSet::new();
2510 let mut last_recipe_name = None;
2511 let mut recipe_pos = 0usize;
2512 let mut recipe_steps = 0usize;
2513 let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2514 for _ in 0..attempts {
2515 if triplets.len() >= self.config.batch_size {
2516 break;
2517 }
2518 let Some(anchor) = self.choose_anchor_record(None, target_split) else {
2519 break;
2520 };
2521 let mut triplet = None;
2522 for offset in 0..recipe_order.len() {
2523 let idx = recipe_order[(recipe_pos + offset) % recipe_order.len()];
2524 recipe_steps = recipe_steps.saturating_add(1);
2525 let recipe = self.triplet_recipes[idx].clone();
2526 last_recipe_name = Some(recipe.name.clone());
2527 if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, &mut rng)
2528 {
2529 triplet = Some(sample);
2530 recipe_pos = (recipe_pos + offset + 1) % recipe_order.len();
2531 break;
2532 }
2533 }
2534 if let Some(triplet) = triplet {
2535 let key = (
2536 triplet.anchor.record_id.clone(),
2537 triplet.positive.record_id.clone(),
2538 triplet.negative.record_id.clone(),
2539 );
2540 if seen.insert(key) {
2541 triplets.push(triplet);
2542 }
2543 }
2544 }
2545 if recipe_steps > 0 {
2546 self.triplet_recipe_rr_idx =
2547 self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2548 }
2549 self.rng = rng;
2550 pad_with_reuse(&mut triplets, self.config.batch_size);
2551 if triplets.len() == self.config.batch_size {
2552 return Ok(TripletBatch { triplets });
2553 }
2554 return Err(SamplerError::Exhausted(
2555 last_recipe_name
2556 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2557 .to_string(),
2558 ));
2559 }
2560
2561 let mut triplets: Vec<SampleTriplet> = Vec::new();
2563 let mut seen: HashSet<(RecordId, RecordId, RecordId)> = HashSet::new();
2564 let mut source_steps = 0usize;
2565 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2566 let mut source_idx = self.source_cycle_idx % sources.len();
2567 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2568 let mut recipe_steps = 0usize;
2569 let max_recipe_len = sources
2570 .iter()
2571 .map(|source| self.triplet_recipe_count_for_source(source.as_str()))
2572 .max()
2573 .unwrap_or(1)
2574 .max(1);
2575 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2576
2577 struct SlotPlan {
2582 anchor: Arc<DataRecord>,
2583 recipes: Vec<TripletRecipe>,
2584 fork_seed: u64,
2585 }
2586 let target_slots = self.config.batch_size * 4 * max_recipe_len;
2587 let mut slot_plans: Vec<SlotPlan> = Vec::with_capacity(target_slots);
2588
2589 for _ in 0..target_slots {
2590 if slot_plans.len() >= target_slots {
2591 break;
2592 }
2593 let source = cycle_sources[source_idx].as_str();
2594 let (recipes, _) = self.resolve_source_triplet_plan(source);
2595 if !recipes.is_empty() {
2596 let fork_seed = rng.next_u64();
2597 if let Some(anchor) = self.choose_anchor_record(Some(source), target_split) {
2598 slot_plans.push(SlotPlan {
2599 anchor,
2600 recipes,
2601 fork_seed,
2602 });
2603 recipe_steps = recipe_steps.saturating_add(1);
2604 }
2605 }
2606 source_idx += 1;
2607 source_steps += 1;
2608 if source_idx >= cycle_sources.len() {
2609 source_idx = 0;
2610 cycle = cycle.saturating_add(1);
2611 cycle_sources = self.shuffled_source_cycle(cycle);
2612 }
2613 }
2614 self.rng = rng;
2615
2616 struct SlotCandidate {
2621 recipe: TripletRecipe,
2622 anchor: Arc<DataRecord>,
2623 anchor_chunk: RecordChunk,
2624 positive_chunk: RecordChunk,
2625 anchor_raw_text: String,
2626 }
2627 let mut raw_candidates: Vec<(usize, Option<SlotCandidate>)> = slot_plans
2628 .par_iter()
2629 .enumerate()
2630 .map(|(slot_idx, plan)| {
2631 let mut fork_rng = DeterministicRng::new(plan.fork_seed);
2632 let weights: Vec<f32> = plan.recipes.iter().map(|r| r.weight).collect();
2635 let order = weighted_recipe_order(&weights, &mut fork_rng);
2636 let mut candidate = None;
2637 for &idx in &order {
2638 let recipe = &plan.recipes[idx];
2639 if let Some((ac, pc, raw)) =
2640 self.select_anchor_positive_for_recipe(recipe, &plan.anchor, &mut fork_rng)
2641 {
2642 candidate = Some(SlotCandidate {
2643 recipe: recipe.clone(),
2644 anchor: Arc::clone(&plan.anchor),
2645 anchor_chunk: ac,
2646 positive_chunk: pc,
2647 anchor_raw_text: raw,
2648 });
2649 break;
2650 }
2651 }
2652 (slot_idx, candidate)
2653 })
2654 .collect();
2655
2656 raw_candidates.sort_unstable_by_key(|(i, _)| *i);
2661 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2662 for (_, candidate) in raw_candidates {
2663 if triplets.len() >= self.config.batch_size {
2664 break;
2665 }
2666 let Some(sc) = candidate else { continue };
2667 let (negative_record, fallback_used) = match self.select_negative_record(
2668 &sc.anchor,
2669 &sc.recipe.negative_strategy,
2670 Some(&sc.anchor_raw_text),
2671 &mut rng,
2672 ) {
2673 Some(pair) => pair,
2674 None => continue,
2675 };
2676 let mut negative_chunk = match self.select_chunk_parallel(
2677 &negative_record,
2678 &sc.recipe.negative_selector,
2679 &mut rng,
2680 ) {
2681 Some(c) => c,
2682 None => continue,
2683 };
2684 self.decorate_chunk_parallel(&negative_record, &mut negative_chunk, &mut rng);
2685
2686 let (anchor_chunk, positive_chunk) = if rng.next_u64() & ANCHOR_POSITIVE_SWAP_MASK == 0
2688 {
2689 (sc.positive_chunk, sc.anchor_chunk)
2690 } else {
2691 (sc.anchor_chunk, sc.positive_chunk)
2692 };
2693
2694 if (!sc.recipe.allow_same_anchor_positive && anchor_chunk.text == positive_chunk.text)
2695 || negative_chunk.text == positive_chunk.text
2696 || negative_chunk.text == anchor_chunk.text
2697 {
2698 continue;
2699 }
2700
2701 let chunk_weight = self.triplet_chunk_weight(
2702 &sc.recipe,
2703 &anchor_chunk,
2704 &positive_chunk,
2705 &negative_chunk,
2706 );
2707 let weight = sc.recipe.weight * chunk_weight;
2708 let recipe_name = if fallback_used {
2709 format!("{}_fallback_same_split", sc.recipe.name)
2710 } else {
2711 sc.recipe.name.to_string()
2712 };
2713 let triplet = SampleTriplet {
2714 recipe: recipe_name,
2715 anchor: anchor_chunk,
2716 positive: positive_chunk,
2717 negative: negative_chunk,
2718 weight,
2719 instruction: sc.recipe.instruction.as_ref().map(|s| s.to_string()),
2720 };
2721 let key = (
2722 triplet.anchor.record_id.clone(),
2723 triplet.positive.record_id.clone(),
2724 triplet.negative.record_id.clone(),
2725 );
2726 if seen.insert(key) && triplets.len() < self.config.batch_size {
2727 triplets.push(triplet);
2728 }
2729 }
2730 self.rng = rng;
2731
2732 if recipe_steps > 0 {
2733 self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2734 }
2735 pad_with_reuse(&mut triplets, self.config.batch_size);
2736 if triplets.len() == self.config.batch_size {
2737 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2738 self.source_state_dirty = sources.len() > 1;
2739 let batch = TripletBatch { triplets };
2740 return Ok(batch);
2741 }
2742 Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2743 }
2744
2745 #[cfg(test)]
2751 #[cfg(feature = "bm25-mining")]
2752 fn bm25_backend_mut(&mut self) -> &mut backends::Bm25Backend {
2753 self.negative_backend
2754 .as_any_mut()
2755 .downcast_mut::<backends::Bm25Backend>()
2756 .expect("bm25_backend_mut: negative_backend is Bm25Backend when bm25-mining feature is active")
2757 }
2758
2759 #[cfg(test)]
2761 fn recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2762 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2763 let result = self.recipe_order_weighted_shuffled(weights, &mut rng);
2764 self.rng = rng;
2765 result
2766 }
2767
2768 #[cfg(test)]
2770 fn recipe_order_weighted_cycled_seeded(
2771 &mut self,
2772 weights: &[f32],
2773 rr_idx: usize,
2774 ) -> Vec<usize> {
2775 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2776 let result = self.recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2777 self.rng = rng;
2778 result
2779 }
2780
2781 #[cfg(test)]
2783 fn text_recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2784 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2785 let result = self.text_recipe_order_weighted_shuffled(weights, &mut rng);
2786 self.rng = rng;
2787 result
2788 }
2789
2790 #[cfg(test)]
2792 fn text_recipe_order_weighted_cycled_seeded(
2793 &mut self,
2794 weights: &[f32],
2795 rr_idx: usize,
2796 ) -> Vec<usize> {
2797 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2798 let result = self.text_recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2799 self.rng = rng;
2800 result
2801 }
2802
2803 #[cfg(test)]
2805 fn select_negative_record_seeded(
2806 &mut self,
2807 anchor_record: &DataRecord,
2808 strategy: &NegativeStrategy,
2809 anchor_query_text: Option<&str>,
2810 ) -> Option<(Arc<DataRecord>, bool)> {
2811 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2812 let result =
2813 self.select_negative_record(anchor_record, strategy, anchor_query_text, &mut rng);
2814 self.rng = rng;
2815 result
2816 }
2817
2818 #[cfg(test)]
2820 fn make_triplet_with_anchor_seeded(
2821 &mut self,
2822 recipe: &TripletRecipe,
2823 anchor: &DataRecord,
2824 ) -> Option<SampleTriplet> {
2825 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2826 let result = self.make_triplet_with_anchor(recipe, anchor, &mut rng);
2827 self.rng = rng;
2828 result
2829 }
2830
2831 #[cfg(test)]
2833 fn decorate_chunk_seeded(&mut self, record: &DataRecord, chunk: &mut RecordChunk) {
2834 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2835 self.decorate_chunk(record, chunk, &mut rng);
2836 self.rng = rng;
2837 }
2838
2839 #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
2845 fn bm25_fallback_stats(&self) -> (u64, u64) {
2846 self.negative_backend.bm25_fallback_stats()
2847 }
2848
2849 #[cfg(test)]
2854 #[cfg(feature = "bm25-mining")]
2855 fn bm25_ranked_candidates(&mut self, anchor: &crate::data::DataRecord) -> Vec<RecordId> {
2856 let split = self
2857 .split_store
2858 .label_for(&anchor.id)
2859 .unwrap_or(SplitLabel::Train);
2860 self.negative_backend
2861 .as_any_mut()
2862 .downcast_mut::<backends::Bm25Backend>()
2863 .expect("bm25_ranked_candidates: Bm25Backend")
2864 .ranked_candidates_pub(anchor, split)
2865 }
2866}
2867
2868fn weighted_recipe_order(weights: &[f32], rng: &mut DeterministicRng) -> Vec<usize> {
2879 let nonzero: Vec<(usize, f32)> = weights
2880 .iter()
2881 .enumerate()
2882 .filter(|(_, w)| **w > 0.0)
2883 .map(|(i, &w)| (i, w))
2884 .collect();
2885 if nonzero.is_empty() {
2886 return Vec::new();
2887 }
2888 let w_min = nonzero
2889 .iter()
2890 .map(|(_, w)| *w)
2891 .fold(f32::INFINITY, f32::min);
2892 let mut order: Vec<usize> = Vec::new();
2893 for (recipe_idx, w) in &nonzero {
2894 let tickets = ((w / w_min).round() as usize).clamp(1, RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER);
2895 for _ in 0..tickets {
2896 order.push(*recipe_idx);
2897 }
2898 }
2899 order.shuffle(rng);
2900 order
2901}
2902
2903fn same_selector_pair_is_valid(
2904 anchor_chunk: &RecordChunk,
2905 positive_chunk: &RecordChunk,
2906 enforce_window_pair: bool,
2907) -> bool {
2908 if triplet_chunk_key(anchor_chunk) == triplet_chunk_key(positive_chunk) {
2909 return false;
2910 }
2911 if !enforce_window_pair {
2912 return true;
2913 }
2914 matches!(
2915 (&anchor_chunk.view, &positive_chunk.view),
2916 (ChunkView::Window { .. }, ChunkView::Window { .. })
2917 )
2918}
2919
2920impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> TripletSampler<S> {
2921 pub fn new(config: SamplerConfig, split_store: Arc<S>) -> Self {
2923 let inner = TripletSamplerInner::new(config, split_store);
2924 Self {
2925 inner: Mutex::new(inner),
2926 }
2927 }
2928
2929 pub fn new_with_chunker(
2931 config: SamplerConfig,
2932 split_store: Arc<S>,
2933 chunker: Arc<dyn ChunkingAlgorithm>,
2934 ) -> Self {
2935 let inner = TripletSamplerInner::new_with_chunker(config, split_store, chunker);
2936 Self {
2937 inner: Mutex::new(inner),
2938 }
2939 }
2940
2941 pub fn next_pair_batch_for_split(
2943 &self,
2944 split: SplitLabel,
2945 ) -> Result<SampleBatch, SamplerError> {
2946 self.next_pair_batch_with_weights_for_split(split, &HashMap::new())
2947 }
2948
2949 pub fn next_text_batch_for_split(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
2951 self.next_text_batch_with_weights_for_split(split, &HashMap::new())
2952 }
2953
2954 pub fn next_triplet_batch_for_split(
2956 &self,
2957 split: SplitLabel,
2958 ) -> Result<TripletBatch, SamplerError> {
2959 self.next_triplet_batch_with_weights_for_split(split, &HashMap::new())
2960 }
2961
2962 fn next_batch_with_retry<B>(
2966 &self,
2967 split: SplitLabel,
2968 weights: &HashMap<SourceId, f32>,
2969 inner_fn: impl Fn(
2970 &mut TripletSamplerInner<S>,
2971 SplitLabel,
2972 Option<&HashMap<SourceId, f32>>,
2973 ) -> Result<B, SamplerError>,
2974 ) -> Result<B, SamplerError> {
2975 let mut inner = self.inner.lock().unwrap();
2976 inner.ensure_split_allowed(split)?;
2977 inner.ensure_ingestion_cursors_loaded()?;
2980 inner.ingestion.increment_epoch_step();
2982 for attempt in 0..=EXHAUSTION_RETRY_LIMIT {
2983 match inner_fn(&mut inner, split, Some(weights)) {
2984 Ok(batch) => return Ok(batch),
2985 Err(SamplerError::Exhausted(_)) if attempt < EXHAUSTION_RETRY_LIMIT => {
2986 inner.force_ingest_refresh_with_weights_for_split(split, weights)?;
2987 }
2988 Err(err) => return Err(err),
2989 }
2990 }
2991 unreachable!()
2993 }
2994
2995 pub fn next_pair_batch_with_weights_for_split(
2997 &self,
2998 split: SplitLabel,
2999 weights: &HashMap<SourceId, f32>,
3000 ) -> Result<SampleBatch, SamplerError> {
3001 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3002 inner.next_pair_batch_inner_with_weights(split, weights)
3003 })
3004 }
3005
3006 pub fn next_text_batch_with_weights_for_split(
3008 &self,
3009 split: SplitLabel,
3010 weights: &HashMap<SourceId, f32>,
3011 ) -> Result<TextBatch, SamplerError> {
3012 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3013 inner.next_text_batch_inner_with_weights(split, weights)
3014 })
3015 }
3016
3017 pub fn next_triplet_batch_with_weights_for_split(
3019 &self,
3020 split: SplitLabel,
3021 weights: &HashMap<SourceId, f32>,
3022 ) -> Result<TripletBatch, SamplerError> {
3023 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3024 inner.next_triplet_batch_inner_with_weights(split, weights)
3025 })
3026 }
3027
3028 pub fn prefetch_triplet_batches(
3030 self: Arc<Self>,
3031 split: SplitLabel,
3032 capacity: usize,
3033 ) -> BatchPrefetcher<TripletBatch> {
3034 BatchPrefetcher::new(capacity, move || self.next_triplet_batch_for_split(split))
3035 }
3036
3037 pub fn prefetch_triplet_batches_with_weights(
3039 self: Arc<Self>,
3040 split: SplitLabel,
3041 capacity: usize,
3042 weights: HashMap<SourceId, f32>,
3043 ) -> BatchPrefetcher<TripletBatch> {
3044 BatchPrefetcher::new(capacity, move || {
3045 self.next_triplet_batch_with_weights_for_split(split, &weights)
3046 })
3047 }
3048
3049 pub fn prefetch_pair_batches(
3051 self: Arc<Self>,
3052 split: SplitLabel,
3053 capacity: usize,
3054 ) -> BatchPrefetcher<SampleBatch> {
3055 BatchPrefetcher::new(capacity, move || self.next_pair_batch_for_split(split))
3056 }
3057
3058 pub fn prefetch_pair_batches_with_weights(
3060 self: Arc<Self>,
3061 split: SplitLabel,
3062 capacity: usize,
3063 weights: HashMap<SourceId, f32>,
3064 ) -> BatchPrefetcher<SampleBatch> {
3065 BatchPrefetcher::new(capacity, move || {
3066 self.next_pair_batch_with_weights_for_split(split, &weights)
3067 })
3068 }
3069
3070 pub fn prefetch_text_batches(
3072 self: Arc<Self>,
3073 split: SplitLabel,
3074 capacity: usize,
3075 ) -> BatchPrefetcher<TextBatch> {
3076 BatchPrefetcher::new(capacity, move || self.next_text_batch_for_split(split))
3077 }
3078
3079 pub fn prefetch_text_batches_with_weights(
3081 self: Arc<Self>,
3082 split: SplitLabel,
3083 capacity: usize,
3084 weights: HashMap<SourceId, f32>,
3085 ) -> BatchPrefetcher<TextBatch> {
3086 BatchPrefetcher::new(capacity, move || {
3087 self.next_text_batch_with_weights_for_split(split, &weights)
3088 })
3089 }
3090
3091 pub fn text_recipes(&self) -> Vec<TextRecipe> {
3093 let inner = self.inner.lock().unwrap();
3094 inner.text_recipes().to_vec()
3095 }
3096
3097 pub fn register_source(
3102 &self,
3103 source: Box<dyn DataSource + 'static>,
3104 ) -> Result<(), SamplerError> {
3105 let mut inner = self.inner.lock().unwrap();
3106 inner.register_source(source)
3107 }
3108
3109 pub fn set_epoch(&self, epoch: u64) -> Result<(), SamplerError> {
3111 let mut inner = self.inner.lock().unwrap();
3112 inner.set_epoch(epoch)
3113 }
3114
3115 pub fn save_sampler_state(&self, save_to: Option<&Path>) -> Result<(), SamplerError> {
3120 let mut inner = self.inner.lock().unwrap();
3121 inner.save_sampler_state(save_to)
3122 }
3123
3124 #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
3132 pub fn bm25_fallback_stats(&self) -> (u64, u64) {
3133 let inner = self.inner.lock().unwrap();
3134 inner.bm25_fallback_stats()
3135 }
3136}
3137
3138impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> Sampler for TripletSampler<S> {
3139 fn next_pair_batch(&self, split: SplitLabel) -> Result<SampleBatch, SamplerError> {
3140 self.next_pair_batch_for_split(split)
3141 }
3142
3143 fn next_pair_batch_with_weights(
3144 &self,
3145 split: SplitLabel,
3146 weights: &HashMap<SourceId, f32>,
3147 ) -> Result<SampleBatch, SamplerError> {
3148 self.next_pair_batch_with_weights_for_split(split, weights)
3149 }
3150
3151 fn next_text_batch(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
3152 self.next_text_batch_for_split(split)
3153 }
3154
3155 fn next_text_batch_with_weights(
3156 &self,
3157 split: SplitLabel,
3158 weights: &HashMap<SourceId, f32>,
3159 ) -> Result<TextBatch, SamplerError> {
3160 self.next_text_batch_with_weights_for_split(split, weights)
3161 }
3162
3163 fn next_triplet_batch(&self, split: SplitLabel) -> Result<TripletBatch, SamplerError> {
3164 self.next_triplet_batch_for_split(split)
3165 }
3166
3167 fn next_triplet_batch_with_weights(
3168 &self,
3169 split: SplitLabel,
3170 weights: &HashMap<SourceId, f32>,
3171 ) -> Result<TripletBatch, SamplerError> {
3172 self.next_triplet_batch_with_weights_for_split(split, weights)
3173 }
3174}
3175
3176fn roles_match(target: &SectionRole, candidate: &SectionRole) -> bool {
3177 target == candidate
3178}
3179
3180fn role_cursor_key(record_id: &RecordId, role: &SectionRole) -> (RecordId, String) {
3181 (record_id.clone(), role_label(role))
3182}
3183
3184fn role_label(role: &SectionRole) -> String {
3185 match role {
3186 SectionRole::Anchor => ROLE_LABEL_ANCHOR.into(),
3187 SectionRole::Context => ROLE_LABEL_CONTEXT.into(),
3188 }
3189}
3190
3191fn taxonomy_value(record: &DataRecord, field: MetadataKey) -> Option<&str> {
3192 record.taxonomy.iter().find_map(|entry| field.strip(entry))
3193}
3194
3195fn strategy_reason(strategy: &NegativeStrategy) -> &'static str {
3196 match strategy {
3197 NegativeStrategy::WrongPublicationDate => NEG_REASON_WRONG_DATE,
3198 NegativeStrategy::WrongArticle => NEG_REASON_WRONG_ARTICLE,
3199 NegativeStrategy::QuestionAnswerMismatch => NEG_REASON_WRONG_QA,
3200 NegativeStrategy::SameRecord => NEG_REASON_SAME_RECORD,
3201 }
3202}
3203
3204#[cfg(test)]
3212fn text_dedup_key(chunk: &RecordChunk) -> (String, String) {
3213 (chunk.record_id.clone(), chunk.text.clone())
3214}
3215
3216fn triplet_chunk_key(chunk: &RecordChunk) -> String {
3217 match &chunk.view {
3218 ChunkView::Window { index, .. } => {
3219 format!("{}|{}|w|{}", chunk.record_id, chunk.section_idx, index)
3220 }
3221 ChunkView::SummaryFallback { strategy, .. } => {
3222 format!("{}|{}|s|{}", chunk.record_id, chunk.section_idx, strategy)
3223 }
3224 }
3225}
3226
3227fn pad_with_reuse<T: Clone>(items: &mut Vec<T>, target: usize) {
3228 if items.is_empty() || items.len() >= target {
3229 return;
3230 }
3231 let seed = items.clone();
3232 let base_len = seed.len();
3233 for idx in 0..(target - items.len()) {
3234 items.push(seed[idx % base_len].clone());
3235 }
3236}
3237
3238#[cfg(test)]
3239mod tests;