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 #[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 #[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 #[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 fn next_pair_batch_with_weights(
159 &self,
160 split: SplitLabel,
161 weights: &HashMap<SourceId, f32>,
162 ) -> Result<SampleBatch, SamplerError>;
163 fn next_text_batch_with_weights(
165 &self,
166 split: SplitLabel,
167 weights: &HashMap<SourceId, f32>,
168 ) -> Result<TextBatch, SamplerError>;
169 fn next_triplet_batch_with_weights(
171 &self,
172 split: SplitLabel,
173 weights: &HashMap<SourceId, f32>,
174 ) -> Result<TripletBatch, SamplerError>;
175 fn save_sampler_state(&self, _save_to: Option<&std::path::Path>) -> Result<(), SamplerError> {
179 Ok(())
180 }
181}
182
183pub 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)]
191struct 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 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 pub fn queue_len(&self) -> usize {
252 self.stats.queued.load(Ordering::Relaxed)
253 }
254
255 pub fn produced_count(&self) -> usize {
257 self.stats.produced.load(Ordering::Relaxed)
258 }
259
260 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
275pub struct TripletSampler<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
278 inner: Mutex<TripletSamplerInner<S>>,
279}
280
281struct TripletSamplerInner<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> {
283 config: SamplerConfig,
285 chunker: Arc<dyn ChunkingAlgorithm>,
287 split_store: Arc<S>,
289 ingestion: IngestionManager,
291 records: IndexMap<RecordId, Arc<DataRecord>>,
293 rng: DeterministicRng,
295 triplet_recipes: Vec<TripletRecipe>,
297 text_recipes: Vec<TextRecipe>,
299 source_triplet_recipes: HashMap<SourceId, Vec<TripletRecipe>>,
301 long_section_counts: HashMap<SourceId, usize>,
307 source_text_recipes: HashMap<SourceId, Vec<TextRecipe>>,
309 using_config_triplet_recipes: bool,
311 using_config_text_recipes: bool,
313 last_observed_ingest: u64,
315 epoch_tracker: EpochTracker,
317 chunk_cursors: HashMap<(RecordId, usize), usize>,
319 role_cursors: HashMap<(RecordId, String), usize>,
321 negative_backend: Box<dyn backends::NegativeBackend>,
323 chunk_index: HashMap<RecordId, RecordId>,
325 source_order: Vec<SourceId>,
327 source_cycle_idx: usize,
329 source_state_loaded: bool,
331 ingestion_cursors_loaded: bool,
333 source_state_dirty: bool,
335 source_record_indices: HashMap<SourceId, Vec<RecordId>>,
337 source_record_cursors: HashMap<SourceId, usize>,
339 triplet_recipe_rr_idx: usize,
341 emitted_text_hashes: HashMap<RecordId, HashSet<u64>>,
350 text_recipe_rr_idx: usize,
352 epoch: u64,
354
355 source_wrapped: HashMap<SourceId, bool>,
357
358 last_sync_version: u64,
360
361 pool_generation: u64,
366
367 cached_records_by_split: Option<HashMap<SplitLabel, Vec<(RecordId, SourceId)>>>,
371 cached_records_by_split_gen: u64,
373
374 last_force_refresh_generation: u64,
380
381 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 fn epoch_seed(&self) -> u64 {
479 derive_epoch_seed(self.config.seed, self.epoch)
480 }
481
482 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 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 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 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 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 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 fn increment_long_section_count(&mut self, source: &SourceId) {
631 *self.long_section_counts.entry(source.clone()).or_default() += 1;
632 }
633
634 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 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 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 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 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 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 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 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 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 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 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 #[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 self.epoch = self.epoch.saturating_add(1);
1123 self.ingestion.set_epoch(self.epoch);
1124 self.source_record_cursors.clear();
1131 self.source_cycle_idx = 0;
1132 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 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 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 Some((Arc::new(anchor_record.clone()), false))
1349 }
1350 }
1351 }
1352
1353 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 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 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 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, §ion.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 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 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 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 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 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 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 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 return (pair_weight + pair_weight + negative_weight) / 3.0;
1660 }
1661 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 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 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 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 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 Some((anchor_chunk, positive_chunk))
1846 }
1847
1848 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, §ion.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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
3176fn 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 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 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 #[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 #[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 #[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 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 inner.ensure_ingestion_cursors_loaded()?;
3309 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 unreachable!()
3322 }
3323
3324 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 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 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 #[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 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 #[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 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 #[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 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 pub fn text_recipes(&self) -> Vec<TextRecipe> {
3449 let inner = self.inner.lock().unwrap();
3450 inner.text_recipes().to_vec()
3451 }
3452
3453 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 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 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 #[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#[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;