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