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 if pairs.len() < self.config.batch_size {
2196 pairs.push(SamplePair {
2197 recipe: triplet_recipe_name.clone(),
2198 anchor: anchor.clone(),
2199 positive: positive.clone(),
2200 weight,
2201 instruction: instruction.clone(),
2202 label: PairLabel::Positive,
2203 reason: None,
2204 });
2205 }
2206 if pairs.len() < self.config.batch_size {
2207 pairs.push(SamplePair {
2208 recipe: triplet_recipe_name,
2209 anchor,
2210 positive: negative,
2211 weight,
2212 instruction,
2213 label: PairLabel::Negative,
2214 reason: Some(
2215 strategy_reason(&recipe.negative_strategy).to_string(),
2216 ),
2217 });
2218 }
2219 }
2220 }
2221 }
2222 if recipe_steps > 0 {
2223 self.triplet_recipe_rr_idx =
2224 self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2225 }
2226 self.rng = rng;
2227 pad_with_reuse(&mut pairs, self.config.batch_size);
2228 if pairs.len() == self.config.batch_size {
2229 return Ok(SampleBatch { pairs });
2230 }
2231 return Err(SamplerError::Exhausted(
2232 last_recipe_name
2233 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2234 .to_string(),
2235 ));
2236 }
2237
2238 let mut pairs = Vec::new();
2239 let mut seen = HashSet::new();
2240 let mut source_steps = 0usize;
2241 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2242 let mut source_idx = self.source_cycle_idx % sources.len();
2243 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2244 let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2245 let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2246 let mut recipe_steps = 0usize;
2247 let max_recipe_len = sources
2248 .iter()
2249 .map(|source| self.triplet_recipe_count_for_source(source))
2250 .max()
2251 .unwrap_or(1)
2252 .max(1);
2253 let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2254 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2255 for _ in 0..attempts {
2256 if pairs.len() >= self.config.batch_size {
2257 break;
2258 }
2259 let source = cycle_sources[source_idx].as_str();
2260 let (triplet, attempts_used) = self.sample_source_triplet_candidate(
2261 source,
2262 target_split,
2263 &mut recipe_orders,
2264 &mut recipe_positions,
2265 &mut rng,
2266 );
2267 recipe_steps = recipe_steps.saturating_add(attempts_used);
2268 if let Some((recipe, triplet)) = triplet {
2269 let key = (
2270 triplet.anchor.record_id.clone(),
2271 triplet.positive.record_id.clone(),
2272 triplet.negative.record_id.clone(),
2273 );
2274 if seen.insert(key) {
2275 let SampleTriplet {
2276 recipe: triplet_recipe_name,
2277 anchor,
2278 positive,
2279 negative,
2280 weight,
2281 instruction,
2282 } = triplet;
2283 if pairs.len() < self.config.batch_size {
2284 pairs.push(SamplePair {
2285 recipe: triplet_recipe_name.clone(),
2286 anchor: anchor.clone(),
2287 positive: positive.clone(),
2288 weight,
2289 instruction: instruction.clone(),
2290 label: PairLabel::Positive,
2291 reason: None,
2292 });
2293 }
2294 if pairs.len() < self.config.batch_size {
2295 pairs.push(SamplePair {
2296 recipe: triplet_recipe_name,
2297 anchor,
2298 positive: negative,
2299 weight,
2300 instruction,
2301 label: PairLabel::Negative,
2302 reason: Some(strategy_reason(&recipe.negative_strategy).to_string()),
2303 });
2304 }
2305 }
2306 }
2307 source_idx += 1;
2308 source_steps += 1;
2309 if source_idx >= cycle_sources.len() {
2310 source_idx = 0;
2311 cycle = cycle.saturating_add(1);
2312 cycle_sources = self.shuffled_source_cycle(cycle);
2313 }
2314 }
2315 if recipe_steps > 0 {
2316 self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2317 }
2318 self.rng = rng;
2319 pad_with_reuse(&mut pairs, self.config.batch_size);
2320 if pairs.len() == self.config.batch_size {
2321 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2322 self.source_state_dirty = sources.len() > 1;
2323 return Ok(SampleBatch { pairs });
2324 }
2325 Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2326 }
2327
2328 fn try_emit_text_sample(
2333 &mut self,
2334 sample: &TextSample,
2335 batch_texts: &mut HashSet<String>,
2336 ) -> bool {
2337 batch_texts.insert(sample.chunk.text.clone())
2338 && self
2339 .emitted_text_hashes
2340 .entry(sample.chunk.record_id.clone())
2341 .or_default()
2342 .insert(stable_hash_str(0, &sample.chunk.text))
2343 }
2344
2345 fn next_text_batch_inner_with_weights(
2346 &mut self,
2347 target_split: SplitLabel,
2348 weights: Option<&HashMap<SourceId, f32>>,
2349 ) -> Result<TextBatch, SamplerError> {
2350 self.ingest_with_weights_fallback(target_split, weights)?;
2351 self.ensure_split_has_records(target_split)?;
2352 let sources = self.source_order.clone();
2353 if sources.is_empty() {
2354 if self.text_recipes.is_empty() {
2355 return Err(SamplerError::Configuration(
2356 "no text recipes configured".into(),
2357 ));
2358 }
2359 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2360 let recipe_weights: Vec<f32> = self.text_recipes.iter().map(|r| r.weight).collect();
2361 let recipe_order = self.text_recipe_order_weighted_cycled(
2362 &recipe_weights,
2363 self.text_recipe_rr_idx,
2364 &mut rng,
2365 );
2366 let mut samples = Vec::new();
2367 let mut batch_texts = HashSet::new();
2368 let mut last_recipe_name = None;
2369 let mut recipe_pos = 0usize;
2370 let mut recipe_steps = 0usize;
2371 let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2372 for _ in 0..attempts {
2373 if samples.len() >= self.config.batch_size {
2374 break;
2375 }
2376 let recipe_idx = recipe_order[recipe_pos];
2377 recipe_pos = (recipe_pos + 1) % recipe_order.len();
2378 recipe_steps = recipe_steps.saturating_add(1);
2379 let recipe = self.text_recipes[recipe_idx].clone();
2380 last_recipe_name = Some(recipe.name.clone());
2381 if let Some(sample) =
2382 self.make_text_sample_for_split(&recipe, None, target_split, &mut rng)
2383 && self.try_emit_text_sample(&sample, &mut batch_texts)
2384 {
2385 samples.push(sample);
2386 }
2387 }
2388 if recipe_steps > 0 {
2389 self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2390 }
2391 self.rng = rng;
2392 pad_with_reuse(&mut samples, self.config.batch_size);
2393 if samples.len() == self.config.batch_size {
2394 return Ok(TextBatch { samples });
2395 }
2396 return Err(SamplerError::Exhausted(
2397 last_recipe_name
2398 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TEXT))
2399 .to_string(),
2400 ));
2401 }
2402
2403 let mut samples = Vec::new();
2404 let mut batch_texts = HashSet::new();
2405 let mut source_steps = 0usize;
2406 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2407 let mut idx = self.source_cycle_idx % sources.len();
2408 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2409 let mut recipe_orders: HashMap<RecipeKey, Vec<usize>> = HashMap::new();
2410 let mut recipe_positions: HashMap<RecipeKey, usize> = HashMap::new();
2411 let mut recipe_steps = 0usize;
2412 let max_recipe_len = sources
2413 .iter()
2414 .map(|source| self.text_recipes_for_source(source).len())
2415 .max()
2416 .unwrap_or(1)
2417 .max(1);
2418 let attempts = self.config.batch_size * 4 * sources.len() * max_recipe_len;
2419 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2420 for _ in 0..attempts {
2421 if samples.len() >= self.config.batch_size {
2422 break;
2423 }
2424 let source = cycle_sources[idx].as_str();
2425 let recipes = self.text_recipes_for_source(source).to_vec();
2426 if recipes.is_empty() {
2427 idx += 1;
2428 source_steps += 1;
2429 if idx >= cycle_sources.len() {
2430 idx = 0;
2431 cycle = cycle.saturating_add(1);
2432 cycle_sources = self.shuffled_source_cycle(cycle);
2433 }
2434 continue;
2435 }
2436 if !recipe_orders.contains_key(source) {
2437 let recipe_weights: Vec<f32> = recipes.iter().map(|r| r.weight).collect();
2438 let order = self.text_recipe_order_weighted_cycled(
2439 &recipe_weights,
2440 self.text_recipe_rr_idx,
2441 &mut rng,
2442 );
2443 recipe_orders.insert(source.to_string(), order);
2444 }
2445 let order = recipe_orders
2446 .get(source)
2447 .expect("recipe order missing for source");
2448 let pos = recipe_positions.entry(source.to_string()).or_insert(0);
2449 let mut sample: Option<(TextRecipe, TextSample)> = None;
2450 for offset in 0..order.len() {
2451 let recipe_idx = order[(*pos + offset) % order.len()];
2452 let recipe = recipes[recipe_idx].clone();
2453 if let Some(item) =
2454 self.make_text_sample_for_split(&recipe, Some(source), target_split, &mut rng)
2455 && self.try_emit_text_sample(&item, &mut batch_texts)
2456 {
2457 recipe_steps = recipe_steps.saturating_add(offset + 1);
2458 *pos = (*pos + offset + 1) % order.len();
2459 sample = Some((recipe, item));
2460 break;
2461 }
2462 }
2463 if sample.is_none() {
2464 recipe_steps = recipe_steps.saturating_add(order.len());
2465 }
2466 if let Some((_recipe, sample)) = sample {
2467 samples.push(sample);
2468 }
2469 idx += 1;
2470 source_steps += 1;
2471 if idx >= cycle_sources.len() {
2472 idx = 0;
2473 cycle = cycle.saturating_add(1);
2474 cycle_sources = self.shuffled_source_cycle(cycle);
2475 }
2476 }
2477 if samples.len() != self.config.batch_size {
2478 pad_with_reuse(&mut samples, self.config.batch_size);
2479 }
2480 if samples.len() != self.config.batch_size {
2481 self.rng = rng;
2482 return Err(SamplerError::Exhausted(RECIPE_LABEL_TEXT.into()));
2483 }
2484 self.rng = rng;
2485 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2486 self.source_state_dirty = sources.len() > 1;
2487 if recipe_steps > 0 {
2488 self.text_recipe_rr_idx = self.text_recipe_rr_idx.saturating_add(recipe_steps);
2489 }
2490 Ok(TextBatch { samples })
2491 }
2492
2493 fn next_triplet_batch_inner_with_weights(
2494 &mut self,
2495 target_split: SplitLabel,
2496 weights: Option<&HashMap<SourceId, f32>>,
2497 ) -> Result<TripletBatch, SamplerError> {
2498 self.ingest_with_weights_fallback(target_split, weights)?;
2499 self.ensure_split_has_records(target_split)?;
2500 let sources = self.source_order.clone();
2501 if sources.is_empty() {
2502 if self.triplet_recipes.is_empty() {
2503 return Err(SamplerError::Configuration(
2504 "no triplet recipes configured".into(),
2505 ));
2506 }
2507 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2508 let recipe_weights: Vec<f32> = self.triplet_recipes.iter().map(|r| r.weight).collect();
2509 let recipe_order = self.recipe_order_weighted_cycled(
2510 &recipe_weights,
2511 self.triplet_recipe_rr_idx,
2512 &mut rng,
2513 );
2514 let mut triplets = Vec::new();
2515 let mut seen = HashSet::new();
2516 let mut last_recipe_name = None;
2517 let mut recipe_pos = 0usize;
2518 let mut recipe_steps = 0usize;
2519 let attempts = self.config.batch_size * 4 * recipe_order.len().max(1);
2520 for _ in 0..attempts {
2521 if triplets.len() >= self.config.batch_size {
2522 break;
2523 }
2524 let Some(anchor) = self.choose_anchor_record(None, target_split) else {
2525 break;
2526 };
2527 let mut triplet = None;
2528 for offset in 0..recipe_order.len() {
2529 let idx = recipe_order[(recipe_pos + offset) % recipe_order.len()];
2530 recipe_steps = recipe_steps.saturating_add(1);
2531 let recipe = self.triplet_recipes[idx].clone();
2532 last_recipe_name = Some(recipe.name.clone());
2533 if let Some(sample) = self.make_triplet_with_anchor(&recipe, &anchor, &mut rng)
2534 {
2535 triplet = Some(sample);
2536 recipe_pos = (recipe_pos + offset + 1) % recipe_order.len();
2537 break;
2538 }
2539 }
2540 if let Some(triplet) = triplet {
2541 let key = (
2542 triplet.anchor.record_id.clone(),
2543 triplet.positive.record_id.clone(),
2544 triplet.negative.record_id.clone(),
2545 );
2546 if seen.insert(key) {
2547 triplets.push(triplet);
2548 }
2549 }
2550 }
2551 if recipe_steps > 0 {
2552 self.triplet_recipe_rr_idx =
2553 self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2554 }
2555 self.rng = rng;
2556 pad_with_reuse(&mut triplets, self.config.batch_size);
2557 if triplets.len() == self.config.batch_size {
2558 return Ok(TripletBatch { triplets });
2559 }
2560 return Err(SamplerError::Exhausted(
2561 last_recipe_name
2562 .unwrap_or(Cow::Borrowed(RECIPE_LABEL_TRIPLETS))
2563 .to_string(),
2564 ));
2565 }
2566
2567 let mut triplets: Vec<SampleTriplet> = Vec::new();
2569 let mut seen: HashSet<(RecordId, RecordId, RecordId)> = HashSet::new();
2570 let mut source_steps = 0usize;
2571 let mut cycle = (self.source_cycle_idx / sources.len()) as u64;
2572 let mut source_idx = self.source_cycle_idx % sources.len();
2573 let mut cycle_sources = self.shuffled_source_cycle(cycle);
2574 let mut recipe_steps = 0usize;
2575 let max_recipe_len = sources
2576 .iter()
2577 .map(|source| self.triplet_recipe_count_for_source(source.as_str()))
2578 .max()
2579 .unwrap_or(1)
2580 .max(1);
2581 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2582
2583 struct SlotPlan {
2588 anchor: Arc<DataRecord>,
2589 recipes: Vec<TripletRecipe>,
2590 fork_seed: u64,
2591 }
2592 let target_slots = self.config.batch_size * 4 * max_recipe_len;
2593 let mut slot_plans: Vec<SlotPlan> = Vec::with_capacity(target_slots);
2594
2595 for _ in 0..target_slots {
2596 if slot_plans.len() >= target_slots {
2597 break;
2598 }
2599 let source = cycle_sources[source_idx].as_str();
2600 let (recipes, _) = self.resolve_source_triplet_plan(source);
2601 if !recipes.is_empty() {
2602 let fork_seed = rng.next_u64();
2603 if let Some(anchor) = self.choose_anchor_record(Some(source), target_split) {
2604 slot_plans.push(SlotPlan {
2605 anchor,
2606 recipes,
2607 fork_seed,
2608 });
2609 recipe_steps = recipe_steps.saturating_add(1);
2610 }
2611 }
2612 source_idx += 1;
2613 source_steps += 1;
2614 if source_idx >= cycle_sources.len() {
2615 source_idx = 0;
2616 cycle = cycle.saturating_add(1);
2617 cycle_sources = self.shuffled_source_cycle(cycle);
2618 }
2619 }
2620 self.rng = rng;
2621
2622 struct SlotCandidate {
2627 recipe: TripletRecipe,
2628 anchor: Arc<DataRecord>,
2629 anchor_chunk: RecordChunk,
2630 positive_chunk: RecordChunk,
2631 anchor_raw_text: String,
2632 }
2633 let mut raw_candidates: Vec<(usize, Option<SlotCandidate>)> = slot_plans
2634 .par_iter()
2635 .enumerate()
2636 .map(|(slot_idx, plan)| {
2637 let mut fork_rng = DeterministicRng::new(plan.fork_seed);
2638 let weights: Vec<f32> = plan.recipes.iter().map(|r| r.weight).collect();
2641 let order = weighted_recipe_order(&weights, &mut fork_rng);
2642 let mut candidate = None;
2643 for &idx in &order {
2644 let recipe = &plan.recipes[idx];
2645 if let Some((ac, pc, raw)) =
2646 self.select_anchor_positive_for_recipe(recipe, &plan.anchor, &mut fork_rng)
2647 {
2648 candidate = Some(SlotCandidate {
2649 recipe: recipe.clone(),
2650 anchor: Arc::clone(&plan.anchor),
2651 anchor_chunk: ac,
2652 positive_chunk: pc,
2653 anchor_raw_text: raw,
2654 });
2655 break;
2656 }
2657 }
2658 (slot_idx, candidate)
2659 })
2660 .collect();
2661
2662 raw_candidates.sort_unstable_by_key(|(i, _)| *i);
2667 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2668 for (_, candidate) in raw_candidates {
2669 if triplets.len() >= self.config.batch_size {
2670 break;
2671 }
2672 let Some(sc) = candidate else { continue };
2673 let (negative_record, fallback_used) = match self.select_negative_record(
2674 &sc.anchor,
2675 &sc.recipe.negative_strategy,
2676 Some(&sc.anchor_raw_text),
2677 &mut rng,
2678 ) {
2679 Some(pair) => pair,
2680 None => continue,
2681 };
2682 let mut negative_chunk = match self.select_chunk_parallel(
2683 &negative_record,
2684 &sc.recipe.negative_selector,
2685 &mut rng,
2686 ) {
2687 Some(c) => c,
2688 None => continue,
2689 };
2690 self.decorate_chunk_parallel(&negative_record, &mut negative_chunk, &mut rng);
2691
2692 let (anchor_chunk, positive_chunk) = if rng.next_u64() & ANCHOR_POSITIVE_SWAP_MASK == 0
2694 {
2695 (sc.positive_chunk, sc.anchor_chunk)
2696 } else {
2697 (sc.anchor_chunk, sc.positive_chunk)
2698 };
2699
2700 if (!sc.recipe.allow_same_anchor_positive && anchor_chunk.text == positive_chunk.text)
2701 || negative_chunk.text == positive_chunk.text
2702 || negative_chunk.text == anchor_chunk.text
2703 {
2704 continue;
2705 }
2706
2707 let chunk_weight = self.triplet_chunk_weight(
2708 &sc.recipe,
2709 &anchor_chunk,
2710 &positive_chunk,
2711 &negative_chunk,
2712 );
2713 let weight = sc.recipe.weight * chunk_weight;
2714 let recipe_name = if fallback_used {
2715 format!("{}_fallback_same_split", sc.recipe.name)
2716 } else {
2717 sc.recipe.name.to_string()
2718 };
2719 let triplet = SampleTriplet {
2720 recipe: recipe_name,
2721 anchor: anchor_chunk,
2722 positive: positive_chunk,
2723 negative: negative_chunk,
2724 weight,
2725 instruction: sc.recipe.instruction.as_ref().map(|s| s.to_string()),
2726 };
2727 let key = (
2728 triplet.anchor.record_id.clone(),
2729 triplet.positive.record_id.clone(),
2730 triplet.negative.record_id.clone(),
2731 );
2732 if seen.insert(key) && triplets.len() < self.config.batch_size {
2733 triplets.push(triplet);
2734 }
2735 }
2736 self.rng = rng;
2737
2738 if recipe_steps > 0 {
2739 self.triplet_recipe_rr_idx = self.triplet_recipe_rr_idx.saturating_add(recipe_steps);
2740 }
2741 pad_with_reuse(&mut triplets, self.config.batch_size);
2742 if triplets.len() == self.config.batch_size {
2743 self.source_cycle_idx = self.source_cycle_idx.saturating_add(source_steps);
2744 self.source_state_dirty = sources.len() > 1;
2745 let batch = TripletBatch { triplets };
2746 return Ok(batch);
2747 }
2748 Err(SamplerError::Exhausted(RECIPE_LABEL_TRIPLETS.into()))
2749 }
2750
2751 #[cfg(test)]
2757 #[cfg(feature = "bm25-mining")]
2758 fn bm25_backend_mut(&mut self) -> &mut backends::Bm25Backend {
2759 self.negative_backend
2760 .as_any_mut()
2761 .downcast_mut::<backends::Bm25Backend>()
2762 .expect("bm25_backend_mut: negative_backend is Bm25Backend when bm25-mining feature is active")
2763 }
2764
2765 #[cfg(test)]
2767 fn recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2768 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2769 let result = self.recipe_order_weighted_shuffled(weights, &mut rng);
2770 self.rng = rng;
2771 result
2772 }
2773
2774 #[cfg(test)]
2776 fn recipe_order_weighted_cycled_seeded(
2777 &mut self,
2778 weights: &[f32],
2779 rr_idx: usize,
2780 ) -> Vec<usize> {
2781 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2782 let result = self.recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2783 self.rng = rng;
2784 result
2785 }
2786
2787 #[cfg(test)]
2789 fn text_recipe_order_weighted_shuffled_seeded(&mut self, weights: &[f32]) -> Vec<usize> {
2790 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2791 let result = self.text_recipe_order_weighted_shuffled(weights, &mut rng);
2792 self.rng = rng;
2793 result
2794 }
2795
2796 #[cfg(test)]
2798 fn text_recipe_order_weighted_cycled_seeded(
2799 &mut self,
2800 weights: &[f32],
2801 rr_idx: usize,
2802 ) -> Vec<usize> {
2803 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2804 let result = self.text_recipe_order_weighted_cycled(weights, rr_idx, &mut rng);
2805 self.rng = rng;
2806 result
2807 }
2808
2809 #[cfg(test)]
2811 fn select_negative_record_seeded(
2812 &mut self,
2813 anchor_record: &DataRecord,
2814 strategy: &NegativeStrategy,
2815 anchor_query_text: Option<&str>,
2816 ) -> Option<(Arc<DataRecord>, bool)> {
2817 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2818 let result =
2819 self.select_negative_record(anchor_record, strategy, anchor_query_text, &mut rng);
2820 self.rng = rng;
2821 result
2822 }
2823
2824 #[cfg(test)]
2826 fn make_triplet_with_anchor_seeded(
2827 &mut self,
2828 recipe: &TripletRecipe,
2829 anchor: &DataRecord,
2830 ) -> Option<SampleTriplet> {
2831 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2832 let result = self.make_triplet_with_anchor(recipe, anchor, &mut rng);
2833 self.rng = rng;
2834 result
2835 }
2836
2837 #[cfg(test)]
2839 fn decorate_chunk_seeded(&mut self, record: &DataRecord, chunk: &mut RecordChunk) {
2840 let mut rng = std::mem::replace(&mut self.rng, DeterministicRng::new(0));
2841 self.decorate_chunk(record, chunk, &mut rng);
2842 self.rng = rng;
2843 }
2844
2845 #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
2851 fn bm25_fallback_stats(&self) -> (u64, u64) {
2852 self.negative_backend.bm25_fallback_stats()
2853 }
2854
2855 #[cfg(test)]
2860 #[cfg(feature = "bm25-mining")]
2861 fn bm25_ranked_candidates(&mut self, anchor: &crate::data::DataRecord) -> Vec<RecordId> {
2862 let split = self
2863 .split_store
2864 .label_for(&anchor.id)
2865 .unwrap_or(SplitLabel::Train);
2866 self.negative_backend
2867 .as_any_mut()
2868 .downcast_mut::<backends::Bm25Backend>()
2869 .expect("bm25_ranked_candidates: Bm25Backend")
2870 .ranked_candidates_pub(anchor, split)
2871 }
2872}
2873
2874fn weighted_recipe_order(weights: &[f32], rng: &mut DeterministicRng) -> Vec<usize> {
2885 let nonzero: Vec<(usize, f32)> = weights
2886 .iter()
2887 .enumerate()
2888 .filter(|(_, w)| **w > 0.0)
2889 .map(|(i, &w)| (i, w))
2890 .collect();
2891 if nonzero.is_empty() {
2892 return Vec::new();
2893 }
2894 let w_min = nonzero
2895 .iter()
2896 .map(|(_, w)| *w)
2897 .fold(f32::INFINITY, f32::min);
2898 let mut order: Vec<usize> = Vec::new();
2899 for (recipe_idx, w) in &nonzero {
2900 let tickets = ((w / w_min).round() as usize).clamp(1, RECIPE_ORDER_MAX_WEIGHT_MULTIPLIER);
2901 for _ in 0..tickets {
2902 order.push(*recipe_idx);
2903 }
2904 }
2905 order.shuffle(rng);
2906 order
2907}
2908
2909fn same_selector_pair_is_valid(
2910 anchor_chunk: &RecordChunk,
2911 positive_chunk: &RecordChunk,
2912 enforce_window_pair: bool,
2913) -> bool {
2914 if triplet_chunk_key(anchor_chunk) == triplet_chunk_key(positive_chunk) {
2915 return false;
2916 }
2917 if !enforce_window_pair {
2918 return true;
2919 }
2920 matches!(
2921 (&anchor_chunk.view, &positive_chunk.view),
2922 (ChunkView::Window { .. }, ChunkView::Window { .. })
2923 )
2924}
2925
2926impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> TripletSampler<S> {
2927 pub fn new(config: SamplerConfig, split_store: Arc<S>) -> Self {
2929 let inner = TripletSamplerInner::new(config, split_store);
2930 Self {
2931 inner: Mutex::new(inner),
2932 }
2933 }
2934
2935 pub fn new_with_chunker(
2937 config: SamplerConfig,
2938 split_store: Arc<S>,
2939 chunker: Arc<dyn ChunkingAlgorithm>,
2940 ) -> Self {
2941 let inner = TripletSamplerInner::new_with_chunker(config, split_store, chunker);
2942 Self {
2943 inner: Mutex::new(inner),
2944 }
2945 }
2946
2947 pub fn next_pair_batch_for_split(
2949 &self,
2950 split: SplitLabel,
2951 ) -> Result<SampleBatch, SamplerError> {
2952 self.next_pair_batch_with_weights_for_split(split, &HashMap::new())
2953 }
2954
2955 pub fn next_text_batch_for_split(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
2957 self.next_text_batch_with_weights_for_split(split, &HashMap::new())
2958 }
2959
2960 pub fn next_triplet_batch_for_split(
2962 &self,
2963 split: SplitLabel,
2964 ) -> Result<TripletBatch, SamplerError> {
2965 self.next_triplet_batch_with_weights_for_split(split, &HashMap::new())
2966 }
2967
2968 fn next_batch_with_retry<B>(
2972 &self,
2973 split: SplitLabel,
2974 weights: &HashMap<SourceId, f32>,
2975 inner_fn: impl Fn(
2976 &mut TripletSamplerInner<S>,
2977 SplitLabel,
2978 Option<&HashMap<SourceId, f32>>,
2979 ) -> Result<B, SamplerError>,
2980 ) -> Result<B, SamplerError> {
2981 let mut inner = self.inner.lock().unwrap();
2982 inner.ensure_split_allowed(split)?;
2983 inner.ensure_ingestion_cursors_loaded()?;
2986 inner.ingestion.increment_epoch_step();
2988 for attempt in 0..=EXHAUSTION_RETRY_LIMIT {
2989 match inner_fn(&mut inner, split, Some(weights)) {
2990 Ok(batch) => return Ok(batch),
2991 Err(SamplerError::Exhausted(_)) if attempt < EXHAUSTION_RETRY_LIMIT => {
2992 inner.force_ingest_refresh_with_weights_for_split(split, weights)?;
2993 }
2994 Err(err) => return Err(err),
2995 }
2996 }
2997 unreachable!()
2999 }
3000
3001 pub fn next_pair_batch_with_weights_for_split(
3003 &self,
3004 split: SplitLabel,
3005 weights: &HashMap<SourceId, f32>,
3006 ) -> Result<SampleBatch, SamplerError> {
3007 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3008 inner.next_pair_batch_inner_with_weights(split, weights)
3009 })
3010 }
3011
3012 pub fn next_text_batch_with_weights_for_split(
3014 &self,
3015 split: SplitLabel,
3016 weights: &HashMap<SourceId, f32>,
3017 ) -> Result<TextBatch, SamplerError> {
3018 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3019 inner.next_text_batch_inner_with_weights(split, weights)
3020 })
3021 }
3022
3023 pub fn next_triplet_batch_with_weights_for_split(
3025 &self,
3026 split: SplitLabel,
3027 weights: &HashMap<SourceId, f32>,
3028 ) -> Result<TripletBatch, SamplerError> {
3029 self.next_batch_with_retry(split, weights, |inner, split, weights| {
3030 inner.next_triplet_batch_inner_with_weights(split, weights)
3031 })
3032 }
3033
3034 pub fn prefetch_triplet_batches(
3036 self: Arc<Self>,
3037 split: SplitLabel,
3038 capacity: usize,
3039 ) -> BatchPrefetcher<TripletBatch> {
3040 BatchPrefetcher::new(capacity, move || self.next_triplet_batch_for_split(split))
3041 }
3042
3043 pub fn prefetch_triplet_batches_with_weights(
3045 self: Arc<Self>,
3046 split: SplitLabel,
3047 capacity: usize,
3048 weights: HashMap<SourceId, f32>,
3049 ) -> BatchPrefetcher<TripletBatch> {
3050 BatchPrefetcher::new(capacity, move || {
3051 self.next_triplet_batch_with_weights_for_split(split, &weights)
3052 })
3053 }
3054
3055 pub fn prefetch_pair_batches(
3057 self: Arc<Self>,
3058 split: SplitLabel,
3059 capacity: usize,
3060 ) -> BatchPrefetcher<SampleBatch> {
3061 BatchPrefetcher::new(capacity, move || self.next_pair_batch_for_split(split))
3062 }
3063
3064 pub fn prefetch_pair_batches_with_weights(
3066 self: Arc<Self>,
3067 split: SplitLabel,
3068 capacity: usize,
3069 weights: HashMap<SourceId, f32>,
3070 ) -> BatchPrefetcher<SampleBatch> {
3071 BatchPrefetcher::new(capacity, move || {
3072 self.next_pair_batch_with_weights_for_split(split, &weights)
3073 })
3074 }
3075
3076 pub fn prefetch_text_batches(
3078 self: Arc<Self>,
3079 split: SplitLabel,
3080 capacity: usize,
3081 ) -> BatchPrefetcher<TextBatch> {
3082 BatchPrefetcher::new(capacity, move || self.next_text_batch_for_split(split))
3083 }
3084
3085 pub fn prefetch_text_batches_with_weights(
3087 self: Arc<Self>,
3088 split: SplitLabel,
3089 capacity: usize,
3090 weights: HashMap<SourceId, f32>,
3091 ) -> BatchPrefetcher<TextBatch> {
3092 BatchPrefetcher::new(capacity, move || {
3093 self.next_text_batch_with_weights_for_split(split, &weights)
3094 })
3095 }
3096
3097 pub fn text_recipes(&self) -> Vec<TextRecipe> {
3099 let inner = self.inner.lock().unwrap();
3100 inner.text_recipes().to_vec()
3101 }
3102
3103 pub fn register_source(
3108 &self,
3109 source: Box<dyn DataSource + 'static>,
3110 ) -> Result<(), SamplerError> {
3111 let mut inner = self.inner.lock().unwrap();
3112 inner.register_source(source)
3113 }
3114
3115 pub fn set_epoch(&self, epoch: u64) -> Result<(), SamplerError> {
3117 let mut inner = self.inner.lock().unwrap();
3118 inner.set_epoch(epoch)
3119 }
3120
3121 pub fn save_sampler_state(&self, save_to: Option<&Path>) -> Result<(), SamplerError> {
3126 let mut inner = self.inner.lock().unwrap();
3127 inner.save_sampler_state(save_to)
3128 }
3129
3130 #[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
3138 pub fn bm25_fallback_stats(&self) -> (u64, u64) {
3139 let inner = self.inner.lock().unwrap();
3140 inner.bm25_fallback_stats()
3141 }
3142}
3143
3144impl<S: SplitStore + EpochStateStore + SamplerStateStore + 'static> Sampler for TripletSampler<S> {
3145 fn next_pair_batch(&self, split: SplitLabel) -> Result<SampleBatch, SamplerError> {
3146 self.next_pair_batch_for_split(split)
3147 }
3148
3149 fn next_pair_batch_with_weights(
3150 &self,
3151 split: SplitLabel,
3152 weights: &HashMap<SourceId, f32>,
3153 ) -> Result<SampleBatch, SamplerError> {
3154 self.next_pair_batch_with_weights_for_split(split, weights)
3155 }
3156
3157 fn next_text_batch(&self, split: SplitLabel) -> Result<TextBatch, SamplerError> {
3158 self.next_text_batch_for_split(split)
3159 }
3160
3161 fn next_text_batch_with_weights(
3162 &self,
3163 split: SplitLabel,
3164 weights: &HashMap<SourceId, f32>,
3165 ) -> Result<TextBatch, SamplerError> {
3166 self.next_text_batch_with_weights_for_split(split, weights)
3167 }
3168
3169 fn next_triplet_batch(&self, split: SplitLabel) -> Result<TripletBatch, SamplerError> {
3170 self.next_triplet_batch_for_split(split)
3171 }
3172
3173 fn next_triplet_batch_with_weights(
3174 &self,
3175 split: SplitLabel,
3176 weights: &HashMap<SourceId, f32>,
3177 ) -> Result<TripletBatch, SamplerError> {
3178 self.next_triplet_batch_with_weights_for_split(split, weights)
3179 }
3180
3181 fn save_sampler_state(&self, save_to: Option<&std::path::Path>) -> Result<(), SamplerError> {
3182 TripletSampler::save_sampler_state(self, save_to)
3183 }
3184}
3185
3186fn roles_match(target: &SectionRole, candidate: &SectionRole) -> bool {
3187 target == candidate
3188}
3189
3190fn role_cursor_key(record_id: &RecordId, role: &SectionRole) -> (RecordId, String) {
3191 (record_id.clone(), role_label(role))
3192}
3193
3194fn role_label(role: &SectionRole) -> String {
3195 match role {
3196 SectionRole::Anchor => ROLE_LABEL_ANCHOR.into(),
3197 SectionRole::Context => ROLE_LABEL_CONTEXT.into(),
3198 }
3199}
3200
3201fn taxonomy_value(record: &DataRecord, field: MetadataKey) -> Option<&str> {
3202 record.taxonomy.iter().find_map(|entry| field.strip(entry))
3203}
3204
3205fn strategy_reason(strategy: &NegativeStrategy) -> &'static str {
3206 match strategy {
3207 NegativeStrategy::WrongPublicationDate => NEG_REASON_WRONG_DATE,
3208 NegativeStrategy::WrongArticle => NEG_REASON_WRONG_ARTICLE,
3209 NegativeStrategy::QuestionAnswerMismatch => NEG_REASON_WRONG_QA,
3210 NegativeStrategy::SameRecord => NEG_REASON_SAME_RECORD,
3211 }
3212}
3213
3214#[cfg(test)]
3222fn text_dedup_key(chunk: &RecordChunk) -> (String, String) {
3223 (chunk.record_id.clone(), chunk.text.clone())
3224}
3225
3226fn triplet_chunk_key(chunk: &RecordChunk) -> String {
3227 match &chunk.view {
3228 ChunkView::Window { index, .. } => {
3229 format!("{}|{}|w|{}", chunk.record_id, chunk.section_idx, index)
3230 }
3231 ChunkView::SummaryFallback { strategy, .. } => {
3232 format!("{}|{}|s|{}", chunk.record_id, chunk.section_idx, strategy)
3233 }
3234 }
3235}
3236
3237fn pad_with_reuse<T: Clone>(items: &mut Vec<T>, target: usize) {
3238 if items.is_empty() || items.len() >= target {
3239 return;
3240 }
3241 let seed = items.clone();
3242 let base_len = seed.len();
3243 for idx in 0..(target - items.len()) {
3244 items.push(seed[idx % base_len].clone());
3245 }
3246}
3247
3248#[cfg(test)]
3249mod tests;