#![allow(deprecated)]
fn base_config() -> super::SamplerConfig {
super::SamplerConfig::default()
}
#[cfg(feature = "bm25-mining")]
use super::backends::bm25_backend::record_bm25_text;
use super::*;
use crate::chunking::ChunkingAlgorithm;
use crate::config::{ChunkingStrategy, NegativeStrategy, Selector, TextRecipe, TripletRecipe};
use crate::metrics::chunk_proximity_score;
use crate::types::RecordId;
pub const PRIMARY_SOURCE_ID: &str = "source_a";
pub const SECONDARY_SOURCE_ID: &str = "source_b";
pub const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
pub const FNV1A64_PRIME: u64 = 0x100000001b3;
pub const FULL_SEQUENCE_LEN: usize = 45;
pub const TEXT_SEQUENCE_LEN: usize = 22;
pub const TEXT_BATCH_SEQUENCE_HASH: u64 = 16610876526529257994;
#[cfg(not(feature = "bm25-mining"))]
pub const TRIPLET_BATCH_SEQUENCE_HASH: u64 = 13667098550054758072;
#[cfg(feature = "bm25-mining")]
pub const TRIPLET_BATCH_SEQUENCE_HASH: u64 = 3183170921249349780;
#[cfg(not(feature = "bm25-mining"))]
pub const PAIR_BATCH_SEQUENCE_HASH: u64 = 6874876021731074880;
#[cfg(feature = "bm25-mining")]
pub const PAIR_BATCH_SEQUENCE_HASH: u64 = 9916688473817591159;
pub const PREFETCH_TEXT_BATCH_SEQUENCE_HASH: u64 = 16610876526529257994;
#[cfg(not(feature = "bm25-mining"))]
pub const PREFETCH_TRIPLET_BATCH_SEQUENCE_HASH: u64 = 4251833242746332028;
#[cfg(feature = "bm25-mining")]
pub const PREFETCH_TRIPLET_BATCH_SEQUENCE_HASH: u64 = 15105652253366404300;
#[cfg(not(feature = "bm25-mining"))]
pub const PREFETCH_PAIR_BATCH_SEQUENCE_HASH: u64 = 8960363625745535152;
#[cfg(feature = "bm25-mining")]
pub const PREFETCH_PAIR_BATCH_SEQUENCE_HASH: u64 = 16725467462822671025;
pub const READABLE_NON_BM25_TITLES: [&str; 8] = [
"Carbon market and emissions policy",
"Energy transition memo",
"Archaeology field note",
"Carbon market and emissions policy",
"Regulatory market digest",
"Energy transition memo",
"Regulatory market digest",
"Marine geology report",
];
#[cfg(feature = "bm25-mining")]
pub const READABLE_BM25_TITLES: [&str; 8] = [
"Carbon market and emissions policy",
"Carbon policy update",
"Regulatory market digest",
"Carbon market and emissions policy",
"Carbon policy update",
"Regulatory market digest",
"Carbon market and emissions policy",
"Carbon policy update",
];
use crate::data::{ChunkView, QualityScore, RecordChunk, RecordSection};
use crate::kvp::{KvpField, KvpPrefixSampler};
use crate::metadata::META_FIELD_DATE;
use crate::source::{DataSource, InMemorySource, SourceCursor, SourceSnapshot};
use crate::splits::{DeterministicSplitStore, FileSplitStore, SplitLabel, SplitRatios, SplitStore};
use chrono::Utc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration as StdDuration;
use tempfile::tempdir;
struct RecipeSource {
inner: InMemorySource,
triplet_recipes: Vec<TripletRecipe>,
}
#[test]
fn role_helpers_and_taxonomy_value_cover_branches() {
assert!(roles_match(&SectionRole::Anchor, &SectionRole::Anchor));
assert!(!roles_match(&SectionRole::Anchor, &SectionRole::Context));
let key = role_cursor_key(&RecordKey::new("unit", "rec-1"), &SectionRole::Anchor);
assert_eq!(key.0, RecordKey::new("unit", "rec-1"));
assert_eq!(key.1, role_label(&SectionRole::Anchor));
assert_ne!(
role_label(&SectionRole::Anchor),
role_label(&SectionRole::Context)
);
let mut record = sample_record();
record.taxonomy = vec!["source_a".into(), META_FIELD_DATE.encode("2026-02-23")];
assert_eq!(taxonomy_value(&record, META_FIELD_DATE), Some("2026-02-23"));
record.taxonomy = vec!["source_a".into(), "other=value".into()];
assert_eq!(taxonomy_value(&record, META_FIELD_DATE), None);
}
#[test]
fn strategy_reason_and_chunk_key_cover_all_variants() {
let reason_a = strategy_reason(&NegativeStrategy::WrongPublicationDate);
let reason_b = strategy_reason(&NegativeStrategy::WrongArticle);
let reason_c = strategy_reason(&NegativeStrategy::QuestionAnswerMismatch);
let reason_d = strategy_reason(&NegativeStrategy::SameRecord);
assert!(!reason_a.is_empty());
assert!(!reason_b.is_empty());
assert!(!reason_c.is_empty());
assert!(!reason_d.is_empty());
assert_ne!(reason_a, reason_b);
assert_ne!(reason_b, reason_c);
assert_ne!(reason_c, reason_d);
let base = RecordChunk {
record_id: "r1".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 2,
overlap: 0,
span: 8,
},
text: "window".into(),
tokens_estimate: 8,
quality: QualityScore { trust: 1.0 },
kvp_meta: Default::default(),
embedding: None,
};
let key_window = triplet_chunk_key(&base);
assert!(key_window.contains("|w|2"));
let summary = RecordChunk {
view: ChunkView::SummaryFallback {
strategy: "summary".into(),
weight: 0.8,
},
..base
};
let key_summary = triplet_chunk_key(&summary);
assert!(key_summary.contains("|s|summary"));
}
#[test]
fn deterministic_rng_state_roundtrip_and_fill_bytes_are_stable() {
let mut rng_a = DeterministicRng::new(123);
let first = rng_a.next_u64();
let saved = rng_a.state();
let mut rng_b = DeterministicRng::from_state(saved);
assert_eq!(rng_a.next_u64(), rng_b.next_u64());
assert_ne!(first, 0);
let mut bytes_a = [0u8; 13];
let mut bytes_b = [0u8; 13];
let mut rng_c = DeterministicRng::new(999);
let mut rng_d = DeterministicRng::new(999);
rng_c.fill_bytes(&mut bytes_a);
rng_d.fill_bytes(&mut bytes_b);
assert_eq!(bytes_a, bytes_b);
assert!(bytes_a.iter().any(|b| *b != 0));
let mut rng_e = DeterministicRng::new(999);
let mut rng_f = DeterministicRng::new(999);
assert_eq!(rng_e.next_u32() as u64, (rng_f.next_u64() as u32) as u64);
}
#[test]
fn next_chunk_from_pool_returns_none_for_empty_pool() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 17).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
assert!(
inner
.next_chunk_from_pool(&RecordKey::new("", "rec"), 0, Vec::new())
.is_none()
);
}
#[test]
fn recipe_order_cycled_and_text_recipe_order_cycled_return_empty_for_zero_count() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 18).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
assert!(inner.recipe_order_weighted_cycled_seeded(&[], 3).is_empty());
assert!(
inner
.text_recipe_order_weighted_cycled_seeded(&[], 5)
.is_empty()
);
}
#[test]
fn select_chunk_random_handles_empty_and_non_empty_sections() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 4;
config.chunking.overlap_tokens = vec![0];
let store = Arc::new(DeterministicSplitStore::new(split, 19).unwrap());
let mut inner = TripletSamplerInner::new(config, store);
let empty_record = DataRecord {
id: "empty_random".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![],
meta_prefix: None,
label: None,
};
assert!(
inner
.select_chunk(&empty_record, &Selector::Random)
.is_none()
);
let non_empty_record = DataRecord {
id: "non_empty_random".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five six".into(),
sentences: vec!["one two three four five six".into()],
token_count: WhitespaceTokenizer.token_count("one two three four five six"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let sampled = inner
.select_chunk(&non_empty_record, &Selector::Random)
.expect("random selector should sample from non-empty sections");
assert_eq!(sampled.record_id, "non_empty_random");
}
#[test]
fn record_has_long_section_returns_false_when_window_tokens_are_disabled() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 0;
let store = Arc::new(DeterministicSplitStore::new(split, 20).unwrap());
let inner = TripletSamplerInner::new(config, store);
let long_record = DataRecord {
id: "long_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five six seven eight".into(),
sentences: vec!["one two three four five six seven eight".into()],
token_count: WhitespaceTokenizer.token_count("one two three four five six seven eight"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
assert!(!inner.record_has_long_anchor_or_context_section(&long_record));
}
#[test]
fn overlapping_ids_across_sources_coexist_in_shared_pool() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 109).unwrap());
let mut config = base_config();
config.batch_size = 4;
config.ingestion_max_records = 64;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "overlap_probe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
for source in ["collide_a", "collide_b"] {
let records: Vec<DataRecord> = (0..4)
.map(|i| {
let mut r = trader_record(
&format!("{i}"),
"2025-01-01",
&format!("{source} title {i}"),
&format!("{source} body text number {i}"),
);
r.source = source.to_string();
r.taxonomy = vec![source.to_string()];
r
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records(source, records)))
.unwrap();
}
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
{
let inner = sampler.inner.lock().unwrap();
assert_eq!(inner.records.len(), 8, "both sources' records coexist");
assert!(
inner
.records
.contains_key(&RecordKey::new("collide_a", "2"))
);
assert!(
inner
.records
.contains_key(&RecordKey::new("collide_b", "2"))
);
}
let mut seen_a = false;
let mut seen_b = false;
for _ in 0..4 {
let batch = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &HashMap::new())
.unwrap();
for p in &batch.pairs {
if p.anchor.text.starts_with("collide_a ") {
seen_a = true;
}
if p.anchor.text.starts_with("collide_b ") {
seen_b = true;
}
}
}
assert!(
seen_a && seen_b,
"both overlapping-id sources must be sampled"
);
}
#[test]
fn select_weighted_source_apportions_exactly() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 110).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner.source_order = vec!["w_a".into(), "w_b".into()];
inner
.source_record_indices
.insert("w_a".into(), vec![RecordKey::new("w_a", "0")]);
inner
.source_record_indices
.insert("w_b".into(), vec![RecordKey::new("w_b", "0")]);
let weights: HashMap<SourceId, f32> = [("w_a".to_string(), 0.75), ("w_b".to_string(), 0.25)]
.into_iter()
.collect();
let order = inner.source_order.clone();
let seq: Vec<String> = (0..8)
.map(|_| {
inner
.select_weighted_source(&order, &weights)
.expect("eligible sources exist")
})
.collect();
assert_eq!(
seq,
vec!["w_a", "w_a", "w_b", "w_a", "w_a", "w_a", "w_b", "w_a"],
"DRR must apportion exactly 3:1"
);
}
#[test]
fn late_registered_source_is_cycled_without_rebuild() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let mut config = base_config();
config.batch_size = 4;
config.ingestion_max_records = 64;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "late_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let make_records = |source: &str, tag: &str| -> Vec<DataRecord> {
(0..4)
.map(|i| {
let mut r = trader_record(
&format!("{tag}_{i}"),
"2025-01-01",
&format!("{tag} title {i}"),
&format!("{tag} body text number {i}"),
);
r.source = source.to_string();
r.taxonomy = vec![source.to_string()];
r
})
.collect()
};
sampler
.register_source(Box::new(InMemorySource::from_records(
"late_a",
make_records("late_a", "alpha"),
)))
.unwrap();
let first = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &HashMap::new())
.unwrap();
assert!(!first.pairs.is_empty());
assert!(
first
.pairs
.iter()
.all(|p| p.anchor.text.starts_with("alpha ")),
"pre-registration batches come only from the first source"
);
sampler
.register_source(Box::new(InMemorySource::from_records(
"late_b",
make_records("late_b", "beta"),
)))
.unwrap();
let mut saw_beta = false;
for _ in 0..8 {
let batch = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &HashMap::new())
.unwrap();
if batch
.pairs
.iter()
.any(|p| p.anchor.text.starts_with("beta "))
{
saw_beta = true;
break;
}
}
assert!(
saw_beta,
"late-registered source must be reached by round-robin without rebuild"
);
}
#[test]
fn remove_id_from_source_index_removes_correct_entry() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 100).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let source: SourceId = "src_rm".into();
let key_a = RecordKey::new(source.clone(), "rm_a");
let key_b = RecordKey::new(source.clone(), "rm_b");
inner
.source_record_indices
.insert(source.clone(), vec![key_a.clone(), key_b.clone()]);
inner.remove_id_from_source_index(&source, &key_a);
let ids = inner.source_record_indices.get(&source).unwrap();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0], key_b);
inner.remove_id_from_source_index(&source, &key_b);
let ids = inner.source_record_indices.get(&source).unwrap();
assert!(ids.is_empty());
}
#[test]
fn remove_id_from_source_index_noop_for_unknown_source() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 101).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner.remove_id_from_source_index(&"nonexistent".into(), &RecordKey::new("nonexistent", "id"));
}
#[test]
fn insert_id_into_source_index_maintains_sorted_order() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 102).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
for id in ["rec_c", "rec_a", "rec_b"] {
let record = DataRecord {
id: id.into(),
source: "src_ins".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: id.to_string(),
sentences: vec![],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
};
inner
.records
.insert(RecordKey::from(&record), Arc::new(record));
}
let source: SourceId = "src_ins".into();
let allowed: HashSet<SplitLabel> =
[SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test]
.into_iter()
.collect();
let label = SplitLabel::Train;
inner.insert_id_into_source_index(
source.clone(),
RecordKey::new(source.clone(), "rec_b"),
label,
&allowed,
);
inner.insert_id_into_source_index(
source.clone(),
RecordKey::new(source.clone(), "rec_a"),
label,
&allowed,
);
inner.insert_id_into_source_index(
source.clone(),
RecordKey::new(source.clone(), "rec_c"),
label,
&allowed,
);
let ids = inner.source_record_indices.get(&source).unwrap();
assert_eq!(ids.len(), 3);
let id_set: HashSet<&str> = ids.iter().map(|key| key.id.as_str()).collect();
assert!(id_set.contains("rec_a"));
assert!(id_set.contains("rec_b"));
assert!(id_set.contains("rec_c"));
}
#[test]
fn insert_id_into_source_index_skips_disallowed_split() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 103).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let record = DataRecord {
id: "disallowed".into(),
source: "src_dis".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "disallowed".to_string(),
sentences: vec![],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
};
inner
.records
.insert(RecordKey::from(&record), Arc::new(record));
let allowed: HashSet<SplitLabel> = [SplitLabel::Train].into_iter().collect();
inner.insert_id_into_source_index(
"src_dis".into(),
RecordKey::new("src_dis", "disallowed"),
SplitLabel::Test,
&allowed,
);
assert!(
!inner.source_record_indices.contains_key("src_dis")
|| inner
.source_record_indices
.get("src_dis")
.unwrap()
.is_empty()
);
}
#[test]
fn increment_and_decrement_long_section_count() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 104).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let source: SourceId = "src_long".into();
assert_eq!(inner.long_section_counts.get(&source), None);
inner.increment_long_section_count(&source);
assert_eq!(inner.long_section_counts.get(&source), Some(&1));
inner.increment_long_section_count(&source);
assert_eq!(inner.long_section_counts.get(&source), Some(&2));
inner.decrement_long_section_count(&source);
assert_eq!(inner.long_section_counts.get(&source), Some(&1));
inner.decrement_long_section_count(&source);
assert!(!inner.long_section_counts.contains_key(&source));
}
#[test]
fn decrement_long_section_count_noop_when_absent() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 105).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner.decrement_long_section_count(&"absent_src".into());
}
#[test]
fn unknown_weight_source_ids_fail_loudly() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 106).unwrap());
let mut config = base_config();
config.batch_size = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "weight_probe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<DataRecord> = (0..4)
.map(|i| {
let mut r = trader_record(
&format!("w_{i}"),
"2025-01-01",
&format!("Weight title {i}"),
&format!("Weight body text number {i}"),
);
r.source = "weight_src".into();
r.taxonomy = vec!["weight_src".into()];
r
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records(
"weight_src",
records,
)))
.unwrap();
let uniform_unknown: HashMap<SourceId, f32> = [("nope".to_string(), 1.0)].into_iter().collect();
assert!(
matches!(
sampler.next_pair_batch_with_weights_for_split(SplitLabel::Train, &uniform_unknown),
Err(SamplerError::InvalidWeight { .. })
),
"uniform map with unknown source id must fail loudly"
);
let mixed_unknown: HashMap<SourceId, f32> =
[("weight_src".to_string(), 1.0), ("nope".to_string(), 0.5)]
.into_iter()
.collect();
assert!(
matches!(
sampler.next_pair_batch_with_weights_for_split(SplitLabel::Train, &mixed_unknown),
Err(SamplerError::InvalidWeight { .. })
),
"non-uniform map with unknown source id must fail loudly"
);
let valid: HashMap<SourceId, f32> = [("weight_src".to_string(), 1.0)].into_iter().collect();
let batch = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &valid)
.unwrap();
assert_eq!(batch.pairs.len(), 4);
}
#[test]
fn weighted_sampling_honors_frequency_and_splits_jointly() {
let split = SplitRatios {
train: 0.6,
validation: 0.2,
test: 0.2,
};
let store = Arc::new(DeterministicSplitStore::new(split, 108).unwrap());
let mut config = base_config();
config.batch_size = 8;
config.ingestion_max_records = 128;
config.allowed_splits = vec![SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "joint_probe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let mut split_of_key = std::collections::HashMap::new();
for source in ["freq_a", "freq_b"] {
let records: Vec<DataRecord> = (0..12)
.map(|i| {
let mut r = trader_record(
&format!("{i}"),
"2025-01-01",
&format!("{source} title {i}"),
&format!("{source} body text number {i}"),
);
r.source = source.to_string();
r.taxonomy = vec![source.to_string()];
r
})
.collect();
for r in &records {
let label = match r.id.parse::<usize>().unwrap() % 3 {
0 => SplitLabel::Train,
1 => SplitLabel::Validation,
_ => SplitLabel::Test,
};
store.upsert(RecordKey::new(source, &r.id), label).unwrap();
split_of_key.insert(RecordKey::new(source, &r.id), label);
}
sampler
.register_source(Box::new(InMemorySource::from_records(source, records)))
.unwrap();
}
let weights: HashMap<SourceId, f32> =
[("freq_a".to_string(), 0.75), ("freq_b".to_string(), 0.25)]
.into_iter()
.collect();
let mut a_rows = 0usize;
let mut total = 0usize;
for _ in 0..10 {
let batch = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &weights)
.unwrap();
assert_eq!(batch.pairs.len(), 8);
for p in &batch.pairs {
for chunk in [&p.anchor, &p.positive] {
total += 1;
let key = RecordKey::of_chunk(chunk);
assert_eq!(
split_of_key.get(&key),
Some(&SplitLabel::Train),
"split boundary violated: row {:?} is not Train",
(key.source.clone(), key.id.clone())
);
if key.source == "freq_a" {
a_rows += 1;
}
}
}
}
assert_eq!((a_rows, total), (120, 160), "DRR apportions exactly 3:1");
for split_label in [SplitLabel::Validation, SplitLabel::Test] {
let batch = sampler
.next_pair_batch_with_weights_for_split(split_label, &weights)
.unwrap();
assert_eq!(batch.pairs.len(), 8);
for p in &batch.pairs {
for chunk in [&p.anchor, &p.positive] {
let key = RecordKey::of_chunk(chunk);
assert_eq!(
split_of_key.get(&key),
Some(&split_label),
"split boundary violated: row {:?} is not {:?}",
(key.source.clone(), key.id.clone()),
split_label
);
}
}
}
}
#[test]
fn get_or_insert_split_label_caches_result() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 106).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let key = RecordKey::new("lbl_src", "lbl_rec");
let label1 = inner.get_or_insert_split_label(&key).unwrap();
let label2 = inner.get_or_insert_split_label(&key).unwrap();
assert_eq!(label1, label2);
assert!(inner.split_labels.contains_key(&key));
}
#[test]
fn sync_records_from_cache_delta_path_with_additions_and_evictions() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 107).unwrap());
let mut config = base_config();
config.batch_size = 4;
let mut inner = TripletSamplerInner::new(config, store);
let now = Utc::now();
let records = vec![
DataRecord {
id: "d1".into(),
source: "delta_src".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "delta text one".into(),
sentences: vec![],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
},
DataRecord {
id: "d2".into(),
source: "delta_src".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "delta text two".into(),
sentences: vec![],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
},
];
let source = InMemorySource::from_records("delta_src", records);
inner.ingestion.register_source(Box::new(source)).unwrap();
inner.ingestion.refresh_all();
inner.sync_records_from_cache().unwrap();
assert_eq!(inner.records.len(), 2);
let version_after_bootstrap = inner.ingestion.global_version();
inner.last_force_refresh_generation = 0;
inner.ingestion.force_refresh_all();
inner.sync_records_from_cache().unwrap();
assert_eq!(inner.records.len(), 2);
assert_eq!(
inner.ingestion.force_refresh_generation(),
inner.last_force_refresh_generation
);
assert!(inner.last_sync_version >= version_after_bootstrap);
}
#[test]
fn record_has_long_section_increments_count_on_add() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 108).unwrap());
let mut config = base_config();
config.chunking.max_window_tokens = 3;
let mut inner = TripletSamplerInner::new(config, store);
let long_record = DataRecord {
id: "long_r".into(),
source: "long_src".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five".into(),
sentences: vec![],
token_count: 5,
embedding: None,
}],
meta_prefix: None,
label: None,
};
let source = InMemorySource::from_records("long_src", vec![long_record]);
inner.ingestion.register_source(Box::new(source)).unwrap();
inner.ingestion.refresh_all();
inner.sync_records_from_cache().unwrap();
assert_eq!(inner.long_section_counts.get("long_src"), Some(&1));
}
#[test]
fn prefetcher_tracks_errors() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_ref = Arc::clone(&calls);
let prefetcher = BatchPrefetcher::new(2, move || {
let attempt = calls_ref.fetch_add(1, Ordering::Relaxed);
if attempt == 0 {
Err(SamplerError::SourceUnavailable {
source_id: PREFETCHER_SOURCE_ID.into(),
reason: "forced error".into(),
})
} else {
Ok(TripletBatch {
triplets: Vec::new(),
})
}
});
let start = std::time::Instant::now();
while prefetcher.produced_count() < 2 && start.elapsed() < StdDuration::from_millis(200) {
std::thread::sleep(StdDuration::from_millis(5));
}
let _ = prefetcher.next();
let _ = prefetcher.next();
assert!(prefetcher.error_count() >= 1);
assert!(prefetcher.produced_count() >= 2);
}
impl RecipeSource {
fn new(records: Vec<DataRecord>, recipes: Vec<TripletRecipe>) -> Self {
Self {
inner: InMemorySource::from_records("recipe_source", records),
triplet_recipes: recipes,
}
}
}
impl DataSource for RecipeSource {
fn id(&self) -> &str {
<InMemorySource as DataSource>::id(&self.inner)
}
fn refresh(
&self,
config: &SamplerConfig,
cursor: Option<&SourceCursor>,
limit: Option<usize>,
) -> Result<SourceSnapshot, SamplerError> {
<InMemorySource as DataSource>::refresh(&self.inner, config, cursor, limit)
}
fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
<InMemorySource as DataSource>::reported_record_count(&self.inner, config)
}
fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
self.triplet_recipes.clone()
}
}
#[derive(Clone)]
struct CountingSource {
id: SourceId,
records: Vec<DataRecord>,
refresh_calls: Arc<AtomicUsize>,
}
impl CountingSource {
fn new(id: &str, records: Vec<DataRecord>, refresh_calls: Arc<AtomicUsize>) -> Self {
Self {
id: id.to_string(),
records,
refresh_calls,
}
}
}
impl DataSource for CountingSource {
fn id(&self) -> &str {
&self.id
}
fn refresh(
&self,
_config: &SamplerConfig,
_cursor: Option<&SourceCursor>,
limit: Option<usize>,
) -> Result<SourceSnapshot, SamplerError> {
self.refresh_calls.fetch_add(1, Ordering::Relaxed);
let mut records = self.records.clone();
if let Some(max) = limit {
records.truncate(max);
}
Ok(SourceSnapshot {
records,
cursor: SourceCursor {
last_seen: Utc::now(),
revision: 0,
},
})
}
fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
Ok(self.records.len() as u128)
}
}
struct FailingSource {
id: SourceId,
}
impl FailingSource {
fn new(id: &str) -> Self {
Self { id: id.to_string() }
}
}
#[derive(Clone)]
struct FlakySource {
id: SourceId,
records: Vec<DataRecord>,
refresh_calls: Arc<AtomicUsize>,
}
impl FlakySource {
fn new(id: &str, records: Vec<DataRecord>, refresh_calls: Arc<AtomicUsize>) -> Self {
Self {
id: id.to_string(),
records,
refresh_calls,
}
}
}
impl DataSource for FlakySource {
fn id(&self) -> &str {
&self.id
}
fn refresh(
&self,
_config: &SamplerConfig,
_cursor: Option<&SourceCursor>,
limit: Option<usize>,
) -> Result<SourceSnapshot, SamplerError> {
let call = self.refresh_calls.fetch_add(1, Ordering::Relaxed);
if call == 0 {
return Err(SamplerError::SourceUnavailable {
source_id: self.id.clone(),
reason: "first refresh intentionally fails".into(),
});
}
let mut records = self.records.clone();
if let Some(max) = limit {
records.truncate(max);
}
Ok(SourceSnapshot {
records,
cursor: SourceCursor {
last_seen: Utc::now(),
revision: call as u64,
},
})
}
fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
Ok(self.records.len() as u128)
}
}
impl DataSource for FailingSource {
fn id(&self) -> &str {
&self.id
}
fn refresh(
&self,
_config: &SamplerConfig,
_cursor: Option<&SourceCursor>,
_limit: Option<usize>,
) -> Result<SourceSnapshot, SamplerError> {
Err(SamplerError::SourceUnavailable {
source_id: self.id.clone(),
reason: "forced failure".into(),
})
}
fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
Err(SamplerError::SourceUnavailable {
source_id: self.id.clone(),
reason: "forced failure".into(),
})
}
}
fn sample_record() -> DataRecord {
let now = Utc::now();
DataRecord {
id: "record_1".into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec!["SampleCorp".into()],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: "Sample title".into(),
sentences: vec!["Sample title".into()],
token_count: WhitespaceTokenizer.token_count("Sample title"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Intro".into()),
text: "This is the introduction paragraph with enough words for sampling.".into(),
sentences: vec![
"This is the introduction paragraph with enough words for sampling.".into(),
],
token_count: WhitespaceTokenizer.token_count(
"This is the introduction paragraph with enough words for sampling.",
),
embedding: None,
},
],
meta_prefix: None,
label: None,
}
}
fn record_with_offset(id: &str, base: chrono::DateTime<Utc>, offset_seconds: i64) -> DataRecord {
let timestamp = base + Duration::seconds(offset_seconds);
let mut record = sample_record();
record.id = id.into();
record.created_at = timestamp;
record.updated_at = timestamp;
record
}
fn trader_record(id: &str, date: &str, title: &str, body: &str) -> DataRecord {
let now = Utc::now();
DataRecord {
id: id.into(),
source: PRIMARY_SOURCE_ID.into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec![PRIMARY_SOURCE_ID.into(), META_FIELD_DATE.encode(date)],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: title.into(),
sentences: vec![title.into()],
token_count: WhitespaceTokenizer.token_count(title),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Summary".into()),
text: body.into(),
sentences: vec![body.into()],
token_count: WhitespaceTokenizer.token_count(body),
embedding: None,
},
],
meta_prefix: None,
label: None,
}
}
fn text_columns_record(id: &str, common_text: &str) -> DataRecord {
let now = Utc::now();
DataRecord {
id: id.into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec!["SampleCorp".into()],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Text".into()),
text: common_text.into(),
sentences: vec![common_text.into()],
token_count: WhitespaceTokenizer.token_count(common_text),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Text".into()),
text: common_text.into(),
sentences: vec![common_text.into()],
token_count: WhitespaceTokenizer.token_count(common_text),
embedding: None,
},
],
meta_prefix: None,
label: None,
}
}
fn extract_date_prefix(chunk_text: &str) -> Option<String> {
let first_line = chunk_text.lines().next()?;
let prefix = first_line.strip_prefix("meta: ")?;
for part in prefix.split(" | ") {
if let Some(date) = part.strip_prefix("date=") {
return Some(date.to_string());
}
}
None
}
fn extract_meta_prefix(chunk_text: &str) -> Option<String> {
let first_line = chunk_text.lines().next()?;
if first_line.starts_with("meta: ") {
Some(first_line.to_string())
} else {
None
}
}
fn split_meta_parts(meta_prefix: &str) -> Vec<String> {
let body = meta_prefix.strip_prefix("meta: ").unwrap_or(meta_prefix);
body.split(" | ").map(|part| part.to_string()).collect()
}
#[test]
fn decorate_chunk_truncates_and_updates_tokens_estimate() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let mut cfg = base_config();
cfg.chunking.max_window_tokens = 5;
let sampler = TripletSampler::new(cfg, store);
let mut record = sample_record();
record.sections[0].text = "one two three".to_string();
let mut kvp = KvpPrefixSampler::new(1.0);
kvp.add_variant([("long", "a b c d e f g h i")] as [(&str, &str); 1]);
record.meta_prefix = Some(kvp);
let mut inner = sampler.inner.lock().unwrap();
let mut chunks = inner.materialize_chunks(&record, 0, &record.sections[0]);
assert!(!chunks.is_empty());
let mut chunk = chunks.remove(0);
assert_eq!(chunk.tokens_estimate, 3);
inner.decorate_chunk_seeded(&record, &mut chunk);
let tokens_after = chunk.text.split_whitespace().count();
assert_eq!(tokens_after, 5);
assert_eq!(chunk.tokens_estimate, 5);
}
#[test]
fn decorate_chunk_preserves_newline_after_meta_when_truncated() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 24).unwrap());
let mut cfg = base_config();
cfg.chunking.max_window_tokens = 4;
let sampler = TripletSampler::new(cfg, store);
let mut record = sample_record();
record.sections[0].text = "one two three".to_string();
let mut kvp = KvpPrefixSampler::new(1.0);
kvp.add_variant([("source", "unit")]);
record.meta_prefix = Some(kvp);
let mut inner = sampler.inner.lock().unwrap();
let mut chunks = inner.materialize_chunks(&record, 0, &record.sections[0]);
assert!(!chunks.is_empty());
let mut chunk = chunks.remove(0);
inner.decorate_chunk_seeded(&record, &mut chunk);
let expected_prefix = format!("meta: source=unit{}", platform_newline());
assert!(
chunk.text.starts_with(&expected_prefix),
"meta prefix should remain on its own line after truncation"
);
assert_eq!(chunk.tokens_estimate, 4);
}
#[test]
fn kvp_meta_populated_unconditionally_even_when_dropout_suppresses_prefix() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(base_config(), store);
let mut record = sample_record();
let mut kvp = KvpPrefixSampler::new(0.0); kvp.add_variant_fields([
KvpField::many("date", ["2025-01-01", "Jan 1, 2025"]),
KvpField::one("source", "daily-report"),
]);
record.meta_prefix = Some(kvp);
let mut inner = sampler.inner.lock().unwrap();
let mut chunks = inner.materialize_chunks(&record, 0, &record.sections[0]);
assert!(!chunks.is_empty());
let mut chunk = chunks.remove(0);
inner.decorate_chunk_seeded(&record, &mut chunk);
assert!(
!chunk.text.starts_with("meta:"),
"dropout=0.0 should suppress prefix text"
);
assert_eq!(chunk.kvp_meta.len(), 2, "expected two keys in kvp_meta");
let mut dates = chunk.kvp_meta["date"].clone();
dates.sort();
assert_eq!(dates, vec!["2025-01-01", "Jan 1, 2025"]);
assert_eq!(chunk.kvp_meta["source"], vec!["daily-report"]);
}
#[test]
fn kvp_meta_empty_when_record_has_no_meta_prefix() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(base_config(), store);
let record = sample_record();
let mut inner = sampler.inner.lock().unwrap();
let mut chunks = inner.materialize_chunks(&record, 0, &record.sections[0]);
assert!(!chunks.is_empty());
let mut chunk = chunks.remove(0);
inner.decorate_chunk_seeded(&record, &mut chunk);
assert!(
chunk.kvp_meta.is_empty(),
"kvp_meta should be empty when the record has no meta_prefix"
);
}
#[test]
fn kvp_prefix_is_applied_to_non_initial_windows_from_long_sections() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 420).unwrap());
let kvp_long_id = (0u32..)
.find_map(|i| {
let id = format!("kvp_long_{i}");
(store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap();
let mut config = base_config();
config.seed = 7777;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
config.recipes = Vec::new();
config.text_recipes = vec![TextRecipe {
name: "kvp_long_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let mut record = trader_record(
&kvp_long_id,
"2025-01-01",
"T",
"t01 t02 t03 t04 t05 t06 t07 t08 t09 t10 t11 t12",
);
let mut prefix = KvpPrefixSampler::new(1.0);
prefix.add_variant([("date", "2025-01-01")]);
record.meta_prefix = Some(prefix);
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"kvp_source",
vec![record],
)))
.unwrap();
let mut saw_non_initial_window = false;
while let Ok(batch) = sampler.next_text_batch(SplitLabel::Train) {
let sample = &batch.samples[0];
assert!(
sample.chunk.text.starts_with("meta: "),
"expected KVP prefix to be present on every sampled chunk, got '{}'",
sample.chunk.text
);
if let ChunkView::Window { index, .. } = sample.chunk.view
&& index > 0
{
saw_non_initial_window = true;
let expected_start = format!("t{:02}", index * 4 + 1);
assert!(
sample.chunk.text.contains(&expected_start),
"expected chunk window {index} to still carry later-window content token {expected_start}, got '{}'",
sample.chunk.text
);
}
}
assert!(
saw_non_initial_window,
"expected at least one non-initial window sample from long section"
);
}
#[test]
fn exhaustion_retry_limit_returns_exhausted() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 101).unwrap());
let exhaust_id = (0u32..)
.find_map(|i| {
let id = format!("exhaust_{i}");
(store.label_for(&RecordKey::new("unit", &id)) == Some(SplitLabel::Train)).then_some(id)
})
.unwrap();
let mut config = base_config();
config.seed = 202;
config.batch_size = 1;
config.ingestion_max_records = 2;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "exhaust_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let mut record = sample_record();
record.id = exhaust_id;
let records = vec![record];
let refresh_calls = Arc::new(AtomicUsize::new(0));
let source = CountingSource::new("unit", records, Arc::clone(&refresh_calls));
let sampler = TripletSampler::new(config, store);
sampler.register_source(Box::new(source)).unwrap();
let result = sampler.next_triplet_batch(SplitLabel::Train);
assert!(matches!(result, Err(SamplerError::Exhausted(_))));
assert_eq!(
refresh_calls.load(Ordering::Relaxed),
EXHAUSTION_RETRY_LIMIT * 2 + 1
);
}
#[test]
fn single_source_failure_does_not_fail_batch_when_other_source_has_data() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 909).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("healthy_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let healthy_a = find_train_id("healthy_a");
let healthy_b = find_train_id("healthy_b");
let healthy_c = find_train_id("healthy_c");
let mut config = base_config();
config.seed = 1337;
config.batch_size = 1;
config.ingestion_max_records = 8;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "resilient_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let healthy_records = vec![
trader_record(&healthy_a, "2025-01-01", "A", "Body A"),
trader_record(&healthy_b, "2025-01-02", "B", "Body B"),
trader_record(&healthy_c, "2025-01-03", "C", "Body C"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(FailingSource::new("failing_source")))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"healthy_source",
healthy_records,
)))
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 1);
assert!(batch.triplets[0].anchor.record_id.starts_with("healthy_"));
assert!(batch.triplets[0].positive.record_id.starts_with("healthy_"));
assert!(batch.triplets[0].negative.record_id.starts_with("healthy_"));
}
#[test]
fn triplet_batch_is_padded_to_batch_size_when_unique_pool_is_small() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 9001).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("pad_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let pad_a = find_train_id("pad_a");
let pad_b = find_train_id("pad_b");
let mut config = base_config();
config.seed = 101;
config.batch_size = 8;
config.ingestion_max_records = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "fixed_size_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let records = vec![
trader_record(&pad_a, "2025-01-01", "A", "Body A"),
trader_record(&pad_b, "2025-01-02", "B", "Body B"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"pad_source",
records,
)))
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 8);
}
#[test]
fn pair_batch_is_padded_to_batch_size_when_unique_pool_is_small() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 9002).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("pair_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let pair_a = find_train_id("pair_a");
let pair_b = find_train_id("pair_b");
let mut config = base_config();
config.seed = 202;
config.batch_size = 9;
config.ingestion_max_records = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "fixed_size_pairs".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let records = vec![
trader_record(&pair_a, "2025-02-01", "A", "Body A"),
trader_record(&pair_b, "2025-02-02", "B", "Body B"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"pair_source",
records,
)))
.unwrap();
let batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.pairs.len(), 9);
}
#[test]
fn text_batch_is_padded_to_batch_size_when_unique_pool_is_small() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 9003).unwrap());
let text_a = (0u32..)
.find_map(|i| {
let id = format!("text_a_{i}");
(store.label_for(&RecordKey::new("text_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap();
let mut config = base_config();
config.seed = 303;
config.batch_size = 7;
config.ingestion_max_records = 2;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.text_recipes = vec![TextRecipe {
name: "fixed_size_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let records = vec![trader_record(&text_a, "2025-03-01", "A", "Body A")];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"text_source",
records,
)))
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 7);
}
#[test]
fn failed_source_is_retried_on_next_batch_call() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 404).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("steady_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let flaky_a = find_train_id("flaky_a");
let flaky_b = find_train_id("flaky_b");
let steady_a = find_train_id("steady_a");
let steady_b = find_train_id("steady_b");
let steady_c = find_train_id("steady_c");
let mut config = base_config();
config.seed = 505;
config.batch_size = 1;
config.ingestion_max_records = 8;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "retry_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let flaky_calls = Arc::new(AtomicUsize::new(0));
let flaky_records = vec![
trader_record(&flaky_a, "2025-02-01", "Flaky A", "Flaky body A"),
trader_record(&flaky_b, "2025-02-02", "Flaky B", "Flaky body B"),
];
let healthy_records = vec![
trader_record(&steady_a, "2025-03-01", "Steady A", "Steady body A"),
trader_record(&steady_b, "2025-03-02", "Steady B", "Steady body B"),
trader_record(&steady_c, "2025-03-03", "Steady C", "Steady body C"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(FlakySource::new(
"flaky_source",
flaky_records,
Arc::clone(&flaky_calls),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"steady_source",
healthy_records,
)))
.unwrap();
sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(flaky_calls.load(Ordering::Relaxed), 1);
sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert!(flaky_calls.load(Ordering::Relaxed) >= 2);
}
fn qa_pair_record(id: &str, question: &str, answer: &str) -> DataRecord {
let now = Utc::now();
DataRecord {
id: id.into(),
source: SECONDARY_SOURCE_ID.into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec![SECONDARY_SOURCE_ID.into(), "factual".into()],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Question".into()),
text: question.into(),
sentences: vec![question.into()],
token_count: WhitespaceTokenizer.token_count(question),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Answer".into()),
text: answer.into(),
sentences: vec![answer.into()],
token_count: WhitespaceTokenizer.token_count(answer),
embedding: None,
},
],
meta_prefix: None,
label: None,
}
}
struct RecipeDecoratedSource {
records: Vec<DataRecord>,
recipes: Vec<TripletRecipe>,
}
impl RecipeDecoratedSource {
fn new(records: Vec<DataRecord>, recipes: Vec<TripletRecipe>) -> Self {
Self { records, recipes }
}
}
impl DataSource for RecipeDecoratedSource {
fn id(&self) -> &str {
"recipe_decorated_source"
}
fn refresh(
&self,
_config: &SamplerConfig,
cursor: Option<&SourceCursor>,
limit: Option<usize>,
) -> Result<SourceSnapshot, crate::errors::SamplerError> {
let mut records = self.records.clone();
if let Some(cap) = limit {
records.truncate(cap);
}
Ok(SourceSnapshot {
records,
cursor: SourceCursor {
last_seen: Utc::now(),
revision: cursor.map(|c| c.revision + 1).unwrap_or_default(),
},
})
}
fn reported_record_count(
&self,
_config: &SamplerConfig,
) -> Result<u128, crate::errors::SamplerError> {
Ok(self.records.len() as u128)
}
fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
self.recipes.clone()
}
}
#[test]
fn chunk_view_carries_window_index() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 2,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 3).unwrap());
let sampler = TripletSampler::new(config, store);
let section_text = "one two three four five six seven eight nine ten";
let record = DataRecord {
id: "ratio_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: section_text.into(),
sentences: vec![section_text.into()],
token_count: WhitespaceTokenizer.token_count(section_text),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let section = &record.sections[0];
let chunks = sampler
.inner
.lock()
.unwrap()
.materialize_chunks(&record, 0, section);
let indices: Vec<usize> = chunks
.iter()
.filter_map(|chunk| match chunk.view {
ChunkView::Window { index, .. } => Some(index),
_ => None,
})
.collect();
assert!(indices.len() >= 3);
assert_eq!(indices[0], 0);
assert_eq!(indices[1], 1);
assert_eq!(indices[2], 2);
}
#[test]
fn chunk_windows_follow_stride_for_large_sections() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 5,
overlap_tokens: vec![1],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 11).unwrap());
let sampler = TripletSampler::new(config, store);
let block = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu";
let record = DataRecord {
id: "stride_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: block.into(),
sentences: vec![
"alpha beta gamma delta.".into(),
"epsilon zeta eta theta.".into(),
"iota kappa lambda mu.".into(),
],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
};
let section = &record.sections[0];
let chunks = sampler
.inner
.lock()
.unwrap()
.materialize_chunks(&record, 0, section);
let texts: Vec<String> = chunks
.iter()
.filter_map(|chunk| match chunk.view {
ChunkView::Window { .. } => Some(chunk.text.clone()),
_ => None,
})
.collect();
assert_eq!(
texts,
vec![
"alpha beta gamma delta epsilon".to_string(),
"epsilon zeta eta theta iota".to_string(),
"iota kappa lambda mu".to_string(),
]
);
let estimates: Vec<usize> = chunks
.iter()
.filter_map(|chunk| match chunk.view {
ChunkView::Window { .. } => Some(chunk.tokens_estimate),
_ => None,
})
.collect();
assert_eq!(estimates, vec![5, 5, 4]);
}
#[test]
fn chunk_windows_materialize_all_configured_overlaps() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![1, 2],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 23).unwrap());
let sampler = TripletSampler::new(config, store);
let section_text = "one two three four five six seven";
let record = DataRecord {
id: "overlap_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: section_text.into(),
sentences: vec![section_text.into()],
token_count: WhitespaceTokenizer.token_count(section_text),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let section = &record.sections[0];
let chunks = sampler
.inner
.lock()
.unwrap()
.materialize_chunks(&record, 0, section);
let overlaps: Vec<usize> = chunks
.iter()
.filter_map(|chunk| match chunk.view {
ChunkView::Window { overlap, .. } => Some(overlap),
_ => None,
})
.collect();
let overlap_1_count = overlaps.iter().filter(|&&value| value == 1).count();
let overlap_2_count = overlaps.iter().filter(|&&value| value == 2).count();
assert!(overlap_1_count > 0);
assert!(overlap_2_count > 0);
assert_eq!(overlap_1_count, 2);
assert_eq!(overlap_2_count, 3);
}
struct FixedChunker;
impl ChunkingAlgorithm for FixedChunker {
fn materialize(
&self,
_strategy: &ChunkingStrategy,
record: &DataRecord,
section_idx: usize,
_section: &RecordSection,
) -> Vec<RecordChunk> {
vec![RecordChunk {
record_id: record.id.clone(),
section_idx,
source: String::new(),
view: ChunkView::SummaryFallback {
strategy: "fixed".into(),
weight: 0.7,
},
text: "fixed-chunk".into(),
tokens_estimate: 1,
quality: record.quality,
kvp_meta: Default::default(),
embedding: None,
}]
}
}
struct MarkerChunker;
impl ChunkingAlgorithm for MarkerChunker {
fn materialize(
&self,
_strategy: &ChunkingStrategy,
record: &DataRecord,
section_idx: usize,
_section: &RecordSection,
) -> Vec<RecordChunk> {
vec![
RecordChunk {
record_id: record.id.clone(),
section_idx,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 2,
},
text: format!("custom::{}::{}::w0", record.id, section_idx),
tokens_estimate: 2,
quality: record.quality,
kvp_meta: Default::default(),
embedding: None,
},
RecordChunk {
record_id: record.id.clone(),
section_idx,
source: String::new(),
view: ChunkView::Window {
index: 1,
overlap: 0,
span: 2,
},
text: format!("custom::{}::{}::w1", record.id, section_idx),
tokens_estimate: 2,
quality: record.quality,
kvp_meta: Default::default(),
embedding: None,
},
]
}
}
#[test]
fn sampler_uses_custom_chunking_algorithm_when_provided() {
let split = SplitRatios::default();
let config = base_config();
let store = Arc::new(DeterministicSplitStore::new(split, 17).unwrap());
let sampler = TripletSampler::new_with_chunker(config, store, Arc::new(FixedChunker));
let section_text = "one two three four five";
let record = DataRecord {
id: "custom_chunk_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: section_text.into(),
sentences: vec![section_text.into()],
token_count: WhitespaceTokenizer.token_count(section_text),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let chunks = sampler
.inner
.lock()
.unwrap()
.materialize_chunks(&record, 0, &record.sections[0]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].text, "fixed-chunk");
assert!(matches!(chunks[0].view, ChunkView::SummaryFallback { .. }));
}
#[test]
fn runtime_batches_do_not_bypass_custom_chunker() {
let mut config = base_config();
config.batch_size = 2;
config.split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "custom_chunker_runtime_triplet".into(),
anchor: Selector::Role(SectionRole::Context),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "custom_chunker_runtime_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(config.split, 33).unwrap());
let sampler = TripletSampler::new_with_chunker(config, store, Arc::new(MarkerChunker));
let mk = |id: &str| DataRecord {
id: id.into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta".into(),
sentences: vec!["alpha beta gamma delta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta gamma delta"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
sampler
.register_source(Box::new(InMemorySource::from_records(
"unit",
vec![mk("c1"), mk("c2"), mk("c3")],
)))
.unwrap();
let text_batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert!(!text_batch.samples.is_empty());
for sample in text_batch.samples {
assert!(
sample.chunk.text.starts_with("custom::"),
"text sample bypassed custom chunker: {}",
sample.chunk.text
);
}
let pair_batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert!(!pair_batch.pairs.is_empty());
for pair in pair_batch.pairs {
assert!(
pair.anchor.text.starts_with("custom::"),
"pair anchor bypassed custom chunker: {}",
pair.anchor.text
);
assert!(
pair.positive.text.starts_with("custom::"),
"pair positive bypassed custom chunker: {}",
pair.positive.text
);
}
let triplet_batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert!(!triplet_batch.triplets.is_empty());
for triplet in triplet_batch.triplets {
assert!(
triplet.anchor.text.starts_with("custom::"),
"triplet anchor bypassed custom chunker: {}",
triplet.anchor.text
);
assert!(
triplet.positive.text.starts_with("custom::"),
"triplet positive bypassed custom chunker: {}",
triplet.positive.text
);
assert!(
triplet.negative.text.starts_with("custom::"),
"triplet negative bypassed custom chunker: {}",
triplet.negative.text
);
}
}
#[test]
fn chunk_weight_windows_use_trust_and_floor() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.25;
let store = Arc::new(DeterministicSplitStore::new(split, 5).unwrap());
let sampler = TripletSampler::new(config, store);
let base_chunk = RecordChunk {
record_id: "unit".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 3,
overlap: 0,
span: 10,
},
text: "dummy".into(),
tokens_estimate: 10,
quality: QualityScore { trust: 1.0 },
kvp_meta: Default::default(),
embedding: None,
};
assert_eq!(
sampler.inner.lock().unwrap().chunk_weight(&base_chunk),
0.25
);
let mut early_chunk = base_chunk.clone();
early_chunk.view = ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
};
assert_eq!(
sampler.inner.lock().unwrap().chunk_weight(&early_chunk),
1.0
);
}
#[test]
fn summary_fallback_weight_is_clamped() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.5;
let store = Arc::new(DeterministicSplitStore::new(split, 6).unwrap());
let sampler = TripletSampler::new(config, store);
let summary_chunk = RecordChunk {
record_id: "unit".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::SummaryFallback {
strategy: "head".into(),
weight: 0.4,
},
text: "summary".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
assert_eq!(
sampler.inner.lock().unwrap().chunk_weight(&summary_chunk),
0.5
);
}
#[test]
fn chunk_weight_applies_trust_scaling() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 10).unwrap());
let sampler = TripletSampler::new(config, store);
let trusted_chunk = RecordChunk {
record_id: "unit".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "dummy".into(),
tokens_estimate: 10,
quality: QualityScore { trust: 0.5 },
kvp_meta: Default::default(),
embedding: None,
};
let weight = sampler.inner.lock().unwrap().chunk_weight(&trusted_chunk);
assert!((weight - 0.5).abs() < f32::EPSILON);
}
#[test]
fn triplet_weight_averages_chunk_weights() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 7).unwrap());
let sampler = TripletSampler::new(config, store);
let recipe = TripletRecipe {
name: "regular_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let anchor = RecordChunk {
record_id: "a".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "a".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let positive = RecordChunk {
record_id: "b".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "b".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let negative = RecordChunk {
record_id: "c".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "c".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let avg = sampler
.inner
.lock()
.unwrap()
.triplet_chunk_weight(&recipe, &anchor, &positive, &negative);
let trust = QualityScore::default().trust;
let expected = trust;
assert!((avg - expected).abs() < f32::EPSILON);
}
#[test]
fn non_auto_triplet_negative_weight_uses_trust_only() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 71).unwrap());
let sampler = TripletSampler::new(config, store);
let recipe = TripletRecipe {
name: "regular_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let anchor = RecordChunk {
record_id: "a".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "a".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let positive = RecordChunk {
record_id: "b".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "b".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let negative = RecordChunk {
record_id: "c".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 9,
overlap: 0,
span: 10,
},
text: "c".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let avg = sampler
.inner
.lock()
.unwrap()
.triplet_chunk_weight(&recipe, &anchor, &positive, &negative);
let trust = QualityScore::default().trust;
let expected = trust;
assert!((avg - expected).abs() < f32::EPSILON, "avg={avg}");
}
#[test]
fn non_auto_triplet_weight_applies_anchor_positive_proximity() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 72).unwrap());
let sampler = TripletSampler::new(config, store);
let recipe = TripletRecipe {
name: "regular_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let anchor = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "a".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let positive = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 3,
overlap: 0,
span: 10,
},
text: "b".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let negative = RecordChunk {
record_id: "n".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 9,
overlap: 0,
span: 10,
},
text: "c".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let avg = sampler
.inner
.lock()
.unwrap()
.triplet_chunk_weight(&recipe, &anchor, &positive, &negative);
let trust = QualityScore::default().trust;
let expected = ((trust * 0.25) + (trust * 0.25 * 0.25) + trust) / 3.0;
assert!(
(avg - expected).abs() < 1e-6,
"avg={avg} expected={expected}"
);
}
#[test]
fn non_auto_triplet_weight_tracks_positive_window_index() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 73).unwrap());
let sampler = TripletSampler::new(config, store);
let recipe = TripletRecipe {
name: "regular_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let anchor = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "a".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let negative = RecordChunk {
record_id: "n".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 9,
overlap: 0,
span: 10,
},
text: "c".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let cases: &[(usize, f32, f32)] = &[
(0, 1.0, 0.5),
(1, 0.5, 0.29166666),
(2, 1.0 / 3.0, 0.24074075),
(3, 0.25, 0.21875),
];
let mut previous_weight: Option<f32> = None;
for (positive_index, expected_proximity, expected_weight) in cases {
let positive = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: *positive_index,
overlap: 0,
span: 10,
},
text: "b".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let proximity = chunk_proximity_score(&anchor, &positive);
assert!(
(proximity - *expected_proximity).abs() < 1e-6,
"index={positive_index} proximity={proximity} expected={expected_proximity}"
);
let weight = sampler
.inner
.lock()
.unwrap()
.triplet_chunk_weight(&recipe, &anchor, &positive, &negative);
assert!(
(weight - *expected_weight).abs() < 1e-6,
"index={positive_index} weight={weight} expected={expected_weight}"
);
if let Some(prev) = previous_weight {
assert!(
weight <= prev,
"expected weight to be non-increasing with index: prev={prev} current={weight} index={positive_index}"
);
}
previous_weight = Some(weight);
}
}
#[test]
fn auto_chunk_pair_triplet_weight_uses_proximity_inside_chunk_weight() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.chunk_weight_floor = 0.0;
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let sampler = TripletSampler::new(config, store);
let auto_recipe = TripletRecipe {
name: AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME.into(),
anchor: Selector::Role(SectionRole::Context),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let anchor = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 10,
},
text: "a".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let positive = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 1,
overlap: 0,
span: 10,
},
text: "b".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let negative = RecordChunk {
record_id: "r".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 2,
overlap: 0,
span: 10,
},
text: "c".into(),
tokens_estimate: 10,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let weight = sampler.inner.lock().unwrap().triplet_chunk_weight(
&auto_recipe,
&anchor,
&positive,
&negative,
);
let expected = (0.25 + 0.25 + 0.5) / 3.0;
assert!((weight - expected).abs() < 1e-6, "weight={weight}");
}
#[test]
fn non_adjacent_auto_window_pair_proximity_is_not_half() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 19).unwrap());
let sampler = TripletSampler::new(config, store);
let record = DataRecord {
id: "non_adjacent_proximity".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five six seven eight nine ten".into(),
sentences: vec!["one two three four five six seven eight nine ten".into()],
token_count: WhitespaceTokenizer
.token_count("one two three four five six seven eight nine ten"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut inner = sampler.inner.lock().unwrap();
let selector = Selector::Role(SectionRole::Context);
let windows = inner.materialize_chunks(&record, 0, &record.sections[0]);
assert_eq!(
windows
.iter()
.filter(|chunk| matches!(chunk.view, ChunkView::Window { .. }))
.count(),
3
);
let mut non_adjacent_pair = None;
for _ in 0..6 {
let (anchor, positive) = inner
.select_anchor_positive_pair(&record, &selector, &selector, true)
.expect("window pair");
let delta = match (&anchor.view, &positive.view) {
(ChunkView::Window { index: left, .. }, ChunkView::Window { index: right, .. }) => {
left.abs_diff(*right)
}
_ => panic!("expected window chunks"),
};
if delta > 1 {
non_adjacent_pair = Some((anchor, positive));
break;
}
}
let (anchor, positive) = non_adjacent_pair.expect("expected at least one non-adjacent pair");
let proximity = chunk_proximity_score(&anchor, &positive);
assert!(
(proximity - 0.5).abs() > 1e-6,
"non-adjacent proximity should not be 0.5; got {proximity}"
);
}
#[test]
fn text_pair_and_triplet_chunks_all_come_from_materialize_pool() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.seed = 42;
config.batch_size = 2;
config.allowed_splits = vec![SplitLabel::Train];
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![1],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let context_text = "alpha beta gamma delta epsilon zeta eta theta";
config.recipes = vec![TripletRecipe {
name: "parity_triplet".into(),
anchor: Selector::Role(SectionRole::Context),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "parity_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let pool_store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(pool_store.label_for(&RecordKey::new("unit", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let parity_a = find_train_id("parity_a");
let parity_b = find_train_id("parity_b");
let make_record = |id: &str| DataRecord {
id: id.into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: context_text.into(),
sentences: vec![context_text.into()],
token_count: WhitespaceTokenizer.token_count(context_text),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let records = vec![make_record(&parity_a), make_record(&parity_b)];
let pool_sampler = TripletSampler::new(config.clone(), Arc::clone(&pool_store));
let expected_pool: HashSet<String> = pool_sampler
.inner
.lock()
.unwrap()
.materialize_chunks(&records[0], 0, &records[0].sections[0])
.into_iter()
.map(|c| c.text)
.collect();
assert!(!expected_pool.is_empty(), "pool must not be empty");
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
while let Ok(batch) = sampler.next_text_batch(SplitLabel::Train) {
for sample in &batch.samples {
assert!(
expected_pool.contains(sample.chunk.text.as_str()),
"text sample chunk {:?} not in materialize_chunks pool {:?}",
sample.chunk.text,
expected_pool,
);
}
}
for _ in 0..5 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
for triplet in &batch.triplets {
assert!(
expected_pool.contains(triplet.anchor.text.as_str()),
"triplet anchor {:?} not in pool",
triplet.anchor.text,
);
assert!(
expected_pool.contains(triplet.positive.text.as_str()),
"triplet positive {:?} not in pool",
triplet.positive.text,
);
assert!(
expected_pool.contains(triplet.negative.text.as_str()),
"triplet negative {:?} not in pool",
triplet.negative.text,
);
}
}
for _ in 0..5 {
let batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
for pair in &batch.pairs {
assert!(
expected_pool.contains(pair.anchor.text.as_str()),
"pair anchor {:?} not in pool",
pair.anchor.text,
);
assert!(
expected_pool.contains(pair.positive.text.as_str()),
"pair positive {:?} not in pool",
pair.positive.text,
);
}
}
}
#[test]
fn end_to_end_text_weighting_uses_chunk_offsets() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.seed = 9;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.text_recipes = vec![TextRecipe {
name: "weighted".into(),
selector: Selector::Role(SectionRole::Context),
weight: 2.0,
instruction: None,
}];
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 9).unwrap());
let weighted_id = (0u32..)
.find_map(|i| {
let id = format!("weighted_record_{i}");
(store.label_for(&RecordKey::new("unit", &id)) == Some(SplitLabel::Train)).then_some(id)
})
.unwrap();
let sampler = TripletSampler::new(config, store);
let record = DataRecord {
id: weighted_id,
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
sampler
.register_source(Box::new(InMemorySource::from_records("unit", vec![record])))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let first = sampler.next_text_batch(SplitLabel::Train).unwrap();
let second = sampler.next_text_batch(SplitLabel::Train).unwrap();
let trust = QualityScore::default().trust;
assert!((first.samples[0].weight - (2.0 * trust)).abs() < f32::EPSILON);
assert!((second.samples[0].weight - (1.0 * trust)).abs() < f32::EPSILON);
}
#[test]
fn end_to_end_text_weighting_respects_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 21).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..2000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("split_weighted", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let train_id = find_id(SplitLabel::Train, "train_weighted");
let val_id = find_id(SplitLabel::Validation, "val_weighted");
let test_id = find_id(SplitLabel::Test, "test_weighted");
let mut config = base_config();
config.seed = 21;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.text_recipes = vec![TextRecipe {
name: "weighted".into(),
selector: Selector::Role(SectionRole::Context),
weight: 3.0,
instruction: None,
}];
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let chunking = config.chunking.clone();
let sampler = TripletSampler::new(config, store);
let mut train_record =
trader_record(&train_id, "2025-01-01", "Train Title", "one two three four");
let mut val_record =
trader_record(&val_id, "2025-01-02", "Val Title", "alpha beta gamma delta");
let mut test_record = trader_record(&test_id, "2025-01-03", "Test Title", "foo bar baz qux");
train_record.source = "split_weighted".into();
val_record.source = "split_weighted".into();
test_record.source = "split_weighted".into();
sampler
.register_source(Box::new(InMemorySource::from_records(
"split_weighted",
vec![train_record, val_record, test_record],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut labels = std::collections::HashSet::new();
let mut checked = 0;
for _ in 0..20 {
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
let sample = &batch.samples[0];
let label = sampler
.inner
.lock()
.unwrap()
.split_store
.label_for(&RecordKey::of_chunk(&sample.chunk))
.unwrap();
labels.insert(label);
assert_eq!(label, SplitLabel::Train, "text sample leaked across splits");
let expected = 3.0 * chunk_weight(&chunking, &sample.chunk);
assert!((sample.weight - expected).abs() < f32::EPSILON);
checked += 1;
if labels.len() == 1 {
break;
}
}
assert_eq!(labels.len(), 1, "all samples must stay in target split");
assert!(checked > 0);
}
struct SplitOrderFixture {
sampler: Arc<TripletSampler<DeterministicSplitStore>>,
}
fn build_split_order_sampler(seed: u64, batch_size: usize) -> SplitOrderFixture {
build_split_order_sampler_with_window(seed, batch_size, 16)
}
fn build_split_order_sampler_with_window(
seed: u64,
batch_size: usize,
ingestion_max_records: usize,
) -> SplitOrderFixture {
let split = SplitRatios {
train: 0.5,
validation: 0.25,
test: 0.25,
};
let store = Arc::new(DeterministicSplitStore::new(split, seed).unwrap());
let mut config = base_config();
config.seed = seed;
config.batch_size = batch_size;
config.ingestion_max_records = ingestion_max_records;
config.allowed_splits = vec![SplitLabel::Train];
config.text_recipes = vec![TextRecipe {
name: "split_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
config.recipes = vec![TripletRecipe {
name: "split_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let sampler = Arc::new(TripletSampler::new(config, Arc::clone(&store)));
let make_records = |source: &str| {
let mut records = Vec::new();
for idx in 0..15 {
let record_id = format!("{source}::record_{idx:02}");
let title = format!("{source} title {idx}");
let body = format!("{source} body {idx}");
records.push(trader_record(&record_id, "2025-01-01", &title, &body));
}
records
};
sampler
.register_source(Box::new(InMemorySource::from_records(
"source_a",
make_records("source_a"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"source_b",
make_records("source_b"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"source_c",
make_records("source_c"),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
SplitOrderFixture { sampler }
}
fn fnv1a_64(input: &str) -> u64 {
let mut hash = FNV1A64_OFFSET;
for byte in input.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(FNV1A64_PRIME);
}
hash
}
fn fmt_weight(weight: f32) -> String {
format!("{:.6}", weight)
}
fn text_snapshot_hash(batches: &[TextBatch]) -> u64 {
let parts: Vec<crate::types::HashPart> = batches
.iter()
.map(|batch| {
let sample = &batch.samples[0];
format!(
"text|{}|{}|{}|{}",
sample.recipe,
sample.chunk.record_id,
triplet_chunk_key(&sample.chunk),
fmt_weight(sample.weight)
)
})
.collect();
fnv1a_64(&parts.join(";"))
}
fn triplet_snapshot_hash(batches: &[TripletBatch]) -> u64 {
let parts: Vec<crate::types::HashPart> = batches
.iter()
.map(|batch| {
let triplet = &batch.triplets[0];
format!(
"triplet|{}|{}|{}|{}|{}|{}|{}|{}",
triplet.recipe,
triplet.anchor.record_id,
triplet_chunk_key(&triplet.anchor),
triplet.positive.record_id,
triplet_chunk_key(&triplet.positive),
triplet.negative.record_id,
triplet_chunk_key(&triplet.negative),
fmt_weight(triplet.weight)
)
})
.collect();
fnv1a_64(&parts.join(";"))
}
fn label_str(label: &PairLabel) -> &'static str {
match label {
PairLabel::Positive => "positive",
PairLabel::Negative => "negative",
}
}
fn pair_snapshot_hash(batches: &[SampleBatch]) -> u64 {
let mut parts = Vec::new();
for batch in batches {
for pair in &batch.pairs {
let reason = pair.reason.as_deref().unwrap_or("");
parts.push(format!(
"pair|{}|{}|{}|{}|{}|{}|{}|{}",
pair.recipe,
label_str(&pair.label),
pair.anchor.record_id,
triplet_chunk_key(&pair.anchor),
pair.positive.record_id,
triplet_chunk_key(&pair.positive),
fmt_weight(pair.weight),
reason
));
}
}
fnv1a_64(&parts.join(";"))
}
#[test]
fn split_order_is_train_val_test_for_text_batches() {
let fixture = build_split_order_sampler_with_window(31, 1, 100);
let mut record_ids: Vec<String> = Vec::new();
for _ in 0..9 {
let batch = fixture.sampler.next_text_batch(SplitLabel::Train).unwrap();
record_ids.push(batch.samples[0].chunk.record_id.clone());
}
assert_eq!(
record_ids,
vec![
"source_a::record_10".to_string(),
"source_b::record_02".to_string(),
"source_c::record_13".to_string(),
"source_a::record_08".to_string(),
"source_b::record_12".to_string(),
"source_c::record_14".to_string(),
"source_a::record_04".to_string(),
"source_c::record_06".to_string(),
"source_b::record_01".to_string(),
]
);
let inner = fixture.sampler.inner.lock().unwrap();
for id in &record_ids {
let key = inner
.records
.keys()
.find(|k| &k.id == id)
.cloned()
.expect("sampled record pooled");
assert_eq!(
inner.split_store.label_for(&key),
Some(SplitLabel::Train),
"record {id} is not Train-split"
);
}
}
#[test]
fn split_order_is_train_val_test_for_triplet_batches() {
let fixture = build_split_order_sampler(32, 1);
let mut record_ids = Vec::new();
for _ in 0..9 {
let batch = fixture
.sampler
.next_triplet_batch(SplitLabel::Train)
.unwrap();
record_ids.push(batch.triplets[0].anchor.record_id.clone());
}
assert_eq!(
record_ids,
vec![
"source_b::record_01".to_string(),
"source_a::record_01".to_string(),
"source_c::record_00".to_string(),
"source_c::record_00".to_string(),
"source_b::record_03".to_string(),
"source_c::record_06".to_string(),
"source_c::record_05".to_string(),
"source_b::record_07".to_string(),
"source_a::record_01".to_string()
]
);
}
#[test]
fn split_order_is_train_val_test_for_pair_batches() {
let fixture = build_split_order_sampler(33, 2);
let mut record_ids = Vec::new();
for _ in 0..9 {
let batch = fixture.sampler.next_pair_batch(SplitLabel::Train).unwrap();
record_ids.push(batch.pairs[0].anchor.record_id.clone());
}
assert_eq!(
record_ids,
vec![
"source_c::record_04".to_string(),
"source_a::record_04".to_string(),
"source_b::record_01".to_string(),
"source_b::record_05".to_string(),
"source_c::record_01".to_string(),
"source_a::record_00".to_string(),
"source_c::record_07".to_string(),
"source_a::record_09".to_string(),
"source_b::record_04".to_string()
]
);
}
#[test]
fn prefetch_text_batches_preserve_split_order() {
let fixture = build_split_order_sampler(41, 1);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_text_batches(SplitLabel::Train, 1);
let mut record_ids = Vec::new();
for _ in 0..9 {
let batch = prefetcher.next().unwrap();
record_ids.push(batch.samples[0].chunk.record_id.clone());
}
drop(prefetcher);
assert_eq!(
record_ids,
vec![
"source_a::record_05".to_string(),
"source_c::record_02".to_string(),
"source_b::record_03".to_string(),
"source_c::record_03".to_string(),
"source_b::record_01".to_string(),
"source_b::record_02".to_string(),
"source_a::record_03".to_string(),
"source_a::record_00".to_string(),
"source_c::record_05".to_string()
]
);
}
#[test]
fn prefetch_triplet_batches_preserve_split_order() {
let fixture = build_split_order_sampler(42, 1);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_triplet_batches(SplitLabel::Train, 1);
let mut record_ids = Vec::new();
for _ in 0..9 {
let batch = prefetcher.next().unwrap();
record_ids.push(batch.triplets[0].anchor.record_id.clone());
}
drop(prefetcher);
assert_eq!(
record_ids,
vec![
"source_a::record_00".to_string(),
"source_a::record_05".to_string(),
"source_b::record_01".to_string(),
"source_c::record_05".to_string(),
"source_b::record_06".to_string(),
"source_a::record_01".to_string(),
"source_c::record_05".to_string(),
"source_a::record_05".to_string(),
"source_c::record_01".to_string()
]
);
}
#[test]
fn prefetch_pair_batches_preserve_split_order() {
let fixture = build_split_order_sampler(43, 2);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_pair_batches(SplitLabel::Train, 1);
let mut record_ids = Vec::new();
for _ in 0..9 {
let batch = prefetcher.next().unwrap();
record_ids.push(batch.pairs[0].anchor.record_id.clone());
}
drop(prefetcher);
assert_eq!(
record_ids,
vec![
"source_b::record_02".to_string(),
"source_a::record_02".to_string(),
"source_c::record_06".to_string(),
"source_a::record_01".to_string(),
"source_c::record_06".to_string(),
"source_b::record_00".to_string(),
"source_a::record_09".to_string(),
"source_c::record_10".to_string(),
"source_b::record_06".to_string()
]
);
}
#[test]
fn prefetch_triplet_batches_with_weights_match_direct() {
let fixture_prefetch = build_split_order_sampler(101, 1);
let fixture_direct = build_split_order_sampler(101, 1);
let mut weights = HashMap::new();
weights.insert("source_a".to_string(), 1.0);
weights.insert("source_b".to_string(), 2.0);
weights.insert("source_c".to_string(), 0.5);
let prefetcher = Arc::clone(&fixture_prefetch.sampler).prefetch_triplet_batches_with_weights(
SplitLabel::Train,
1,
weights.clone(),
);
let mut prefetch_ids = Vec::new();
for _ in 0..5 {
let batch = prefetcher.next().unwrap();
prefetch_ids.push(batch.triplets[0].anchor.record_id.clone());
}
drop(prefetcher);
let mut direct_ids = Vec::new();
for _ in 0..5 {
let batch = fixture_direct
.sampler
.next_triplet_batch_with_weights(SplitLabel::Train, &weights)
.unwrap();
direct_ids.push(batch.triplets[0].anchor.record_id.clone());
}
assert_eq!(prefetch_ids, direct_ids);
}
#[test]
fn prefetch_pair_batches_with_weights_match_direct() {
let fixture_prefetch = build_split_order_sampler(102, 2);
let fixture_direct = build_split_order_sampler(102, 2);
let mut weights = HashMap::new();
weights.insert("source_a".to_string(), 1.0);
weights.insert("source_b".to_string(), 2.0);
weights.insert("source_c".to_string(), 0.5);
let prefetcher = Arc::clone(&fixture_prefetch.sampler).prefetch_pair_batches_with_weights(
SplitLabel::Train,
1,
weights.clone(),
);
let mut prefetch_ids = Vec::new();
for _ in 0..5 {
let batch = prefetcher.next().unwrap();
prefetch_ids.push(batch.pairs[0].anchor.record_id.clone());
}
drop(prefetcher);
let mut direct_ids = Vec::new();
for _ in 0..5 {
let batch = fixture_direct
.sampler
.next_pair_batch_with_weights(SplitLabel::Train, &weights)
.unwrap();
direct_ids.push(batch.pairs[0].anchor.record_id.clone());
}
assert_eq!(prefetch_ids, direct_ids);
}
#[test]
fn prefetch_text_batches_with_weights_match_direct() {
let fixture_prefetch = build_split_order_sampler(103, 1);
let fixture_direct = build_split_order_sampler(103, 1);
let mut weights = HashMap::new();
weights.insert("source_a".to_string(), 1.0);
weights.insert("source_b".to_string(), 2.0);
weights.insert("source_c".to_string(), 0.5);
let prefetcher = Arc::clone(&fixture_prefetch.sampler).prefetch_text_batches_with_weights(
SplitLabel::Train,
1,
weights.clone(),
);
let mut prefetch_ids = Vec::new();
for _ in 0..5 {
let batch = prefetcher.next().unwrap();
prefetch_ids.push(batch.samples[0].chunk.record_id.clone());
}
drop(prefetcher);
let mut direct_ids = Vec::new();
for _ in 0..5 {
let batch = fixture_direct
.sampler
.next_text_batch_with_weights(SplitLabel::Train, &weights)
.unwrap();
direct_ids.push(batch.samples[0].chunk.record_id.clone());
}
assert_eq!(prefetch_ids, direct_ids);
}
#[test]
fn split_order_differs_with_seed() {
let a = build_split_order_sampler(71, 1);
let b = build_split_order_sampler(72, 1);
let mut a_batches = Vec::new();
let mut b_batches = Vec::new();
for _ in 0..3 {
a_batches.push(a.sampler.next_text_batch(SplitLabel::Train).unwrap());
b_batches.push(b.sampler.next_text_batch(SplitLabel::Train).unwrap());
}
let a_hash = text_snapshot_hash(&a_batches);
let b_hash = text_snapshot_hash(&b_batches);
assert_ne!(a_hash, b_hash);
}
#[test]
fn full_sequence_hashes_match_for_text_batches() {
let fixture = build_split_order_sampler(81, 1);
let mut record_ids = Vec::new();
let mut batches = Vec::new();
for _ in 0..TEXT_SEQUENCE_LEN {
batches.push(fixture.sampler.next_text_batch(SplitLabel::Train).unwrap());
let sample = &batches.last().unwrap().samples[0];
record_ids.push(sample.chunk.record_id.clone());
}
assert_eq!(text_snapshot_hash(&batches), TEXT_BATCH_SEQUENCE_HASH);
}
#[test]
fn full_sequence_hashes_match_for_triplet_batches() {
let fixture = build_split_order_sampler(82, 1);
let mut batches = Vec::new();
for _ in 0..FULL_SEQUENCE_LEN {
batches.push(
fixture
.sampler
.next_triplet_batch(SplitLabel::Train)
.unwrap(),
);
}
assert_eq!(triplet_snapshot_hash(&batches), TRIPLET_BATCH_SEQUENCE_HASH);
}
#[test]
fn full_sequence_hashes_match_for_pair_batches() {
let fixture = build_split_order_sampler(83, 2);
let mut batches = Vec::new();
for _ in 0..FULL_SEQUENCE_LEN {
batches.push(fixture.sampler.next_pair_batch(SplitLabel::Train).unwrap());
}
assert_eq!(pair_snapshot_hash(&batches), PAIR_BATCH_SEQUENCE_HASH);
}
#[test]
fn readable_triplet_examples_by_mode() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let recipe = TripletRecipe {
name: "readable_triplet_demo".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let config = SamplerConfig {
seed: 991,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![recipe.clone()],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let anchor = trader_record(
"readable_anchor",
"2025-01-01",
"Climate policy briefing",
"carbon pricing policy emissions reduction roadmap market design",
);
let candidates = vec![
trader_record(
"readable_topical_1",
"2025-01-01",
"Carbon market and emissions policy",
"carbon pricing policy emissions reduction roadmap market design",
),
trader_record(
"readable_topical_2",
"2025-01-01",
"Carbon policy update",
"carbon pricing policy emissions reduction roadmap",
),
trader_record(
"readable_mid_1",
"2025-01-01",
"Energy transition memo",
"emissions reduction roadmap clean energy transition planning",
),
trader_record(
"readable_mid_2",
"2025-01-01",
"Regulatory market digest",
"policy market design regulatory framework and compliance",
),
trader_record(
"readable_weak_1",
"2025-01-01",
"Archaeology field note",
"bronze age pottery fragments excavation trench mapping",
),
trader_record(
"readable_weak_2",
"2025-01-01",
"Marine geology report",
"subduction zones oceanic crust tectonic shear",
),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
let mut all_records = vec![anchor.clone()];
all_records.extend(candidates);
sampler
.register_source(Box::new(InMemorySource::from_records(
"readable_source",
all_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new("readable_source", "readable_anchor"))
.cloned()
.expect("anchor should be present after ingest");
let display = |id: &str| -> &'static str {
match id {
"readable_topical_1" => "Carbon market and emissions policy",
"readable_topical_2" => "Carbon policy update",
"readable_mid_2" => "Regulatory market digest",
"readable_mid_1" => "Energy transition memo",
"readable_weak_2" => "Marine geology report",
"readable_weak_1" => "Archaeology field note",
_ => "unknown",
}
};
let expected_non_bm25_titles: Vec<String> = READABLE_NON_BM25_TITLES
.iter()
.map(|title| (*title).to_string())
.collect();
#[cfg(not(feature = "bm25-mining"))]
{
let mut negatives = Vec::new();
for _ in 0..8 {
let (negative, _fallback_used) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("expected readable negative sample");
negatives.push(negative.id.clone());
}
let actual_titles: Vec<String> =
negatives.iter().map(|id| display(id).to_string()).collect();
assert_eq!(
actual_titles, expected_non_bm25_titles,
"non-BM25 readable sequence changed unexpectedly"
);
}
#[cfg(feature = "bm25-mining")]
{
let mut negatives = Vec::new();
for _ in 0..8 {
let (negative, _fallback_used) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("expected BM25 negative selection");
negatives.push(negative.id.clone());
}
let negative_titles: Vec<String> =
negatives.iter().map(|id| display(id).to_string()).collect();
let expected_bm25_titles: Vec<String> = READABLE_BM25_TITLES
.iter()
.map(|title| (*title).to_string())
.collect();
assert_eq!(
negative_titles, expected_bm25_titles,
"BM25 readable sequence changed unexpectedly"
);
assert_ne!(
negative_titles, expected_non_bm25_titles,
"BM25 sequence should differ from non-BM25 sequence on the exact same fixture"
);
}
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_not_rng_only_when_only_anchor_text_changes() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let run = |anchor_body: &str| -> Vec<String> {
let config = SamplerConfig {
seed: 991,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "bm25_rng_proof".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let sampler = TripletSampler::new(config, Arc::clone(&store));
let anchor = trader_record("readable_anchor", "2025-01-01", "Anchor", anchor_body);
let candidates = vec![
trader_record(
"readable_topical_1",
"2025-01-01",
"Carbon market and emissions policy",
"carbon pricing policy emissions reduction roadmap market design",
),
trader_record(
"readable_topical_2",
"2025-01-01",
"Carbon policy update",
"carbon pricing policy emissions reduction roadmap",
),
trader_record(
"readable_mid_1",
"2025-01-01",
"Energy transition memo",
"emissions reduction roadmap clean energy transition planning",
),
trader_record(
"readable_mid_2",
"2025-01-01",
"Regulatory market digest",
"policy market design regulatory framework and compliance",
),
trader_record(
"readable_weak_1",
"2025-01-01",
"Archaeology field note",
"bronze age pottery fragments excavation trench mapping",
),
trader_record(
"readable_weak_2",
"2025-01-01",
"Marine geology report",
"subduction zones oceanic crust tectonic shear",
),
];
let mut all_records = vec![anchor];
all_records.extend(candidates);
sampler
.register_source(Box::new(InMemorySource::from_records(
"readable_source",
all_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new("readable_source", "readable_anchor"))
.cloned()
.expect("anchor should exist");
let mut negatives = Vec::new();
for _ in 0..8 {
let (negative, _fallback_used) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("expected BM25 negative selection");
negatives.push(negative.id.clone());
}
negatives
};
let policy = run("carbon pricing policy emissions reduction roadmap market design");
let geology = run("subduction zones oceanic crust tectonic shear");
assert_ne!(
policy, geology,
"same seed/id/pool but different anchor text must change BM25 negatives; RNG-only selection would not"
);
}
#[test]
fn full_sequence_hashes_match_for_prefetch_text_batches() {
let fixture = build_split_order_sampler(81, 1);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_text_batches(SplitLabel::Train, 1);
let mut batches = Vec::new();
for _ in 0..TEXT_SEQUENCE_LEN {
batches.push(prefetcher.next().unwrap());
}
drop(prefetcher);
assert_eq!(
text_snapshot_hash(&batches),
PREFETCH_TEXT_BATCH_SEQUENCE_HASH
);
}
#[test]
fn full_sequence_hashes_match_for_prefetch_triplet_batches() {
let fixture = build_split_order_sampler(92, 1);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_triplet_batches(SplitLabel::Train, 1);
let mut batches = Vec::new();
for _ in 0..FULL_SEQUENCE_LEN {
batches.push(prefetcher.next().unwrap());
}
drop(prefetcher);
assert_eq!(
triplet_snapshot_hash(&batches),
PREFETCH_TRIPLET_BATCH_SEQUENCE_HASH
);
}
#[test]
fn full_sequence_hashes_match_for_prefetch_pair_batches() {
let fixture = build_split_order_sampler(93, 2);
let prefetcher = Arc::clone(&fixture.sampler).prefetch_pair_batches(SplitLabel::Train, 1);
let mut batches = Vec::new();
for _ in 0..FULL_SEQUENCE_LEN {
batches.push(prefetcher.next().unwrap());
}
drop(prefetcher);
assert_eq!(
pair_snapshot_hash(&batches),
PREFETCH_PAIR_BATCH_SEQUENCE_HASH
);
}
#[test]
fn cross_batch_text_dedup_survives_window_advance() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 95).unwrap());
let mut record_ids = Vec::new();
for i in 0..300 {
let id = format!("shared_text_record_{i}");
if record_ids.len() >= 12 {
break;
}
if store.label_for(&RecordKey::new("shared_source", &id)) == Some(SplitLabel::Train) {
record_ids.push(id);
}
}
assert_eq!(record_ids.len(), 12, "need 12 train-split records");
let shared_body = "This is the shared body text that every record uses.";
let records: Vec<DataRecord> = record_ids
.iter()
.map(|id| DataRecord {
id: id.clone(),
source: "shared_source".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore { trust: 1.0 },
taxonomy: vec!["shared_source".into()],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: Some("Body".into()),
text: shared_body.into(),
sentences: vec![shared_body.into()],
token_count: WhitespaceTokenizer.token_count(shared_body),
embedding: None,
}],
meta_prefix: None,
label: None,
})
.collect();
let mut config = base_config();
config.seed = 808;
config.batch_size = 4;
config.ingestion_max_records = 8;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.text_recipes = vec![TextRecipe {
name: "dedup_test".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
config.chunking = ChunkingStrategy {
max_window_tokens: 1024,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
preprocessors: Vec::new(),
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"shared_source",
records,
)))
.unwrap();
let batch1 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch1.samples.len(), 4);
let hashes_after_batch1: HashSet<u64> = sampler
.inner
.lock()
.unwrap()
.emitted_text_hashes
.values()
.flat_map(|s| s.iter().copied())
.collect();
assert_eq!(
hashes_after_batch1.len(),
1,
"one unique text hash should be tracked after batch 1"
);
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let hashes_after_advance: HashSet<u64> = sampler
.inner
.lock()
.unwrap()
.emitted_text_hashes
.values()
.flat_map(|s| s.iter().copied())
.collect();
assert_eq!(
hashes_after_advance.len(),
0,
"emitted_text_hashes must survive advance — overlapping records are still in pool"
);
}
#[test]
fn generates_pairs_from_single_source() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 1,
batch_size: 4,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "title_context_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: vec![TextRecipe {
name: "teacher_chunk".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}],
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 6).unwrap());
let records = vec![
trader_record(
"source_a::2025/01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha",
),
trader_record(
"source_a::2025/01-02/article_b.txt",
"2025-01-02",
"Beta",
"Body beta",
),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert!(!batch.is_empty());
assert_eq!(batch.pairs.len(), 4);
}
#[test]
fn produces_text_samples() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 2,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![],
text_recipes: vec![TextRecipe {
name: "teacher_chunk".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}],
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 11).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"unit",
vec![sample_record()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert!(!batch.is_empty());
assert_eq!(batch.samples.len(), 1);
}
#[test]
fn cycles_through_section_windows_before_repeating() {
let split = SplitRatios::default();
let mut config = base_config();
config.seed = 5;
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
config.text_recipes = vec![TextRecipe {
name: "evidence_chunks".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let record = DataRecord {
id: "window_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", vec![record])))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut outputs = Vec::new();
while let Ok(batch) = sampler.next_text_batch(SplitLabel::Train) {
outputs.push(batch.samples[0].chunk.text.clone());
}
assert!(outputs.contains(&"one two".to_string()));
assert!(outputs.contains(&"three four".to_string()));
assert_eq!(outputs.len(), 2, "expected exactly 2 unique windows");
}
#[test]
fn first_chunk_offset_is_deterministic_and_nonzero_when_hash_demands_it() {
let split = SplitRatios::default();
let key = "window_record::0";
let pool_len = 3usize;
let epoch_seed_mask = 1u64;
let mut seed = 1u64;
while (stable_hash_str(seed ^ epoch_seed_mask, key) as usize).is_multiple_of(pool_len) {
seed = seed.saturating_add(1);
}
let build_sampler = || {
let mut config = base_config();
config.seed = seed;
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
config.text_recipes = vec![TextRecipe {
name: "context_chunks".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let record = DataRecord {
id: "window_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five six".into(),
sentences: vec!["one two three four five six".into()],
token_count: WhitespaceTokenizer.token_count("one two three four five six"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", vec![record])))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
sampler
};
let expected_start = (stable_hash_str(seed ^ epoch_seed_mask, key) as usize) % pool_len;
assert_ne!(expected_start, 0);
let expected = ["one two", "three four", "five six"][expected_start];
let sampler_a = build_sampler();
let first_a = sampler_a
.next_text_batch(SplitLabel::Train)
.unwrap()
.samples[0]
.chunk
.text
.clone();
assert_eq!(first_a, expected);
let sampler_b = build_sampler();
let first_b = sampler_b
.next_text_batch(SplitLabel::Train)
.unwrap()
.samples[0]
.chunk
.text
.clone();
assert_eq!(first_b, expected);
assert_eq!(first_a, first_b);
}
#[test]
fn first_role_section_offset_is_deterministic_and_nonzero_when_hash_demands_it() {
let split = SplitRatios::default();
let key = "role_offset_record::context";
let section_count = 3usize;
let epoch_seed_mask = 1u64;
let mut seed = 1u64;
while (stable_hash_str(seed ^ epoch_seed_mask, key) as usize).is_multiple_of(section_count) {
seed = seed.saturating_add(1);
}
let build_sampler = || {
let mut config = base_config();
config.seed = seed;
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 8,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
config.text_recipes = vec![TextRecipe {
name: "context_role".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 19).unwrap());
let record = DataRecord {
id: "role_offset_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Context,
heading: Some("A".into()),
text: "alpha".into(),
sentences: vec!["alpha".into()],
token_count: WhitespaceTokenizer.token_count("alpha"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("B".into()),
text: "beta".into(),
sentences: vec!["beta".into()],
token_count: WhitespaceTokenizer.token_count("beta"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("C".into()),
text: "gamma".into(),
sentences: vec!["gamma".into()],
token_count: WhitespaceTokenizer.token_count("gamma"),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", vec![record])))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
sampler
};
let expected_start = (stable_hash_str(seed ^ epoch_seed_mask, key) as usize) % section_count;
assert_ne!(expected_start, 0);
let expected = ["alpha", "beta", "gamma"][expected_start];
let sampler_a = build_sampler();
let first_a = sampler_a
.next_text_batch(SplitLabel::Train)
.unwrap()
.samples[0]
.chunk
.text
.clone();
assert_eq!(first_a, expected);
let sampler_b = build_sampler();
let first_b = sampler_b
.next_text_batch(SplitLabel::Train)
.unwrap()
.samples[0]
.chunk
.text
.clone();
assert_eq!(first_b, expected);
assert_eq!(first_a, first_b);
}
#[test]
fn reentry_same_epoch_restarts_from_same_chunk_offset() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 23).unwrap());
let mut config = base_config();
config.seed = 101;
let mut inner = TripletSamplerInner::new(config, store);
let mk_chunk = |index: usize, text: &str| RecordChunk {
record_id: "reentry_record".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index,
overlap: 0,
span: 2,
},
text: text.to_string(),
tokens_estimate: 2,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let pool = vec![mk_chunk(0, "zero"), mk_chunk(1, "one"), mk_chunk(2, "two")];
let first = inner
.next_chunk_from_pool(&RecordKey::new("", "reentry_record"), 0, pool.clone())
.unwrap();
inner
.chunk_cursors
.remove(&(RecordKey::new("", "reentry_record"), 0));
let restarted = inner
.next_chunk_from_pool(&RecordKey::new("", "reentry_record"), 0, pool)
.unwrap();
assert_eq!(restarted.text, first.text);
}
#[test]
fn reentry_after_epoch_change_can_restart_from_different_chunk_offset() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 29).unwrap());
let key = "reentry_record::0";
let pool_len = 3usize;
let mut seed = 1u64;
while (stable_hash_str(seed, key) as usize) % pool_len
== (stable_hash_str(seed ^ 1, key) as usize) % pool_len
{
seed = seed.saturating_add(1);
}
let mut config = base_config();
config.seed = seed;
let mut inner = TripletSamplerInner::new(config, store);
let mk_chunk = |index: usize, text: &str| RecordChunk {
record_id: "reentry_record".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index,
overlap: 0,
span: 2,
},
text: text.to_string(),
tokens_estimate: 2,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
let pool = vec![mk_chunk(0, "zero"), mk_chunk(1, "one"), mk_chunk(2, "two")];
let first_epoch0 = inner
.next_chunk_from_pool(&RecordKey::new("", "reentry_record"), 0, pool.clone())
.unwrap();
inner
.chunk_cursors
.remove(&(RecordKey::new("", "reentry_record"), 0));
inner.epoch = inner.epoch.saturating_add(1);
let first_epoch1 = inner
.next_chunk_from_pool(&RecordKey::new("", "reentry_record"), 0, pool)
.unwrap();
assert_ne!(first_epoch1.text, first_epoch0.text);
}
#[test]
fn kvp_date_formats_can_differ_within_same_triplet_across_all_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let mut config = base_config();
config.seed = 777;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "kvp_date_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let store = Arc::new(DeterministicSplitStore::new(split, 73).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let ids = vec![
find_id(SplitLabel::Train, "kvp_date_train_a"),
find_id(SplitLabel::Train, "kvp_date_train_b"),
find_id(SplitLabel::Validation, "kvp_date_val_a"),
find_id(SplitLabel::Validation, "kvp_date_val_b"),
find_id(SplitLabel::Test, "kvp_date_test_a"),
find_id(SplitLabel::Test, "kvp_date_test_b"),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<DataRecord> = ids
.into_iter()
.enumerate()
.map(|(idx, id)| {
let mut record = trader_record(&id, "2025-05-01", &format!("T{idx}"), "Body");
let mut prefix = KvpPrefixSampler::new(1.0);
if idx % 2 == 0 {
prefix.add_variant([("date", "2025-05-01")]);
prefix.add_variant([("date", "May 1, 2025")]);
} else {
prefix.add_variant([("date", "05/01/2025")]);
prefix.add_variant([("date", "2025-05-01")]);
}
record.meta_prefix = Some(prefix);
record
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen_splits = std::collections::HashSet::new();
let mut saw_mixed_date_formats = false;
for _ in 0..180 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap(),
);
let dates = [
extract_date_prefix(&triplet.anchor.text),
extract_date_prefix(&triplet.positive.text),
extract_date_prefix(&triplet.negative.text),
];
if dates.iter().all(Option::is_some) {
let mut uniq = std::collections::HashSet::new();
for date in dates.into_iter().flatten() {
uniq.insert(date);
}
if uniq.len() >= 2 {
saw_mixed_date_formats = true;
}
}
if saw_mixed_date_formats && seen_splits.len() == 1 {
break;
}
}
assert_eq!(
seen_splits.len(),
1,
"expected sampling to stay in the target split"
);
assert!(
saw_mixed_date_formats,
"expected at least one triplet with multiple date formats across anchor/positive/negative"
);
}
#[test]
fn kvp_date_formats_can_differ_between_anchor_and_positive_across_all_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 83).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let ids = vec![
find_id(SplitLabel::Train, "kvp_anchor_pos_train_a"),
find_id(SplitLabel::Train, "kvp_anchor_pos_train_b"),
find_id(SplitLabel::Validation, "kvp_anchor_pos_val_a"),
find_id(SplitLabel::Validation, "kvp_anchor_pos_val_b"),
find_id(SplitLabel::Test, "kvp_anchor_pos_test_a"),
find_id(SplitLabel::Test, "kvp_anchor_pos_test_b"),
];
let mut config = base_config();
config.seed = 919;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "kvp_date_anchor_positive_all_splits".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<DataRecord> = ids
.into_iter()
.map(|id| {
let mut record = trader_record(&id, "2025-01-31", "T", "B");
let mut prefix = KvpPrefixSampler::new(1.0);
prefix.add_variant([("date", "2025-01-31")]);
prefix.add_variant([("date", "Jan 31, 2025")]);
prefix.add_variant([("date", "01/31/2025")]);
record.meta_prefix = Some(prefix);
record
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen_splits = std::collections::HashSet::new();
let mut saw_anchor_positive_diff = false;
for _ in 0..180 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap(),
);
let anchor_date = extract_date_prefix(&triplet.anchor.text);
let positive_date = extract_date_prefix(&triplet.positive.text);
if let (Some(a), Some(p)) = (anchor_date, positive_date)
&& a != p
{
saw_anchor_positive_diff = true;
}
if saw_anchor_positive_diff && seen_splits.len() == 1 {
break;
}
}
assert_eq!(
seen_splits.len(),
1,
"expected sampling to stay in the target split"
);
assert!(
saw_anchor_positive_diff,
"expected at least one anchor/positive pair with different date formats"
);
}
#[test]
fn kvp_prefix_signatures_are_not_constant_across_triplets_with_all_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let mut config = base_config();
config.seed = 12345;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "kvp_prefix_diversity_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let store = Arc::new(DeterministicSplitStore::new(split, 97).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let ids = vec![
find_id(SplitLabel::Train, "kvp_sign_train_a"),
find_id(SplitLabel::Train, "kvp_sign_train_b"),
find_id(SplitLabel::Validation, "kvp_sign_val_a"),
find_id(SplitLabel::Validation, "kvp_sign_val_b"),
find_id(SplitLabel::Test, "kvp_sign_test_a"),
find_id(SplitLabel::Test, "kvp_sign_test_b"),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<DataRecord> = ids
.into_iter()
.enumerate()
.map(|(idx, id)| {
let mut record = trader_record(&id, "2025-06-01", &format!("R{idx}"), "Body");
let mut prefix = KvpPrefixSampler::new(1.0);
if idx % 2 == 0 {
prefix.add_variant([("date", "2025-06-01"), ("source", "tt")]);
prefix.add_variant([("date", "Jun 1, 2025"), ("source", "trader")]);
prefix.add_variant([("date", "06/01/2025"), ("source", "times")]);
} else {
prefix.add_variant([("date", "2025-06-01"), ("source", "tt")]);
prefix.add_variant([("date", "June 1 2025"), ("source", "trader")]);
prefix.add_variant([("date", "01-06-2025"), ("source", "times")]);
}
record.meta_prefix = Some(prefix);
record
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen_splits = std::collections::HashSet::new();
let mut signatures = std::collections::HashSet::new();
for _ in 0..180 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap(),
);
seen_splits.insert(
store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap(),
);
let anchor = extract_meta_prefix(&triplet.anchor.text);
let positive = extract_meta_prefix(&triplet.positive.text);
let negative = extract_meta_prefix(&triplet.negative.text);
if let (Some(a), Some(p), Some(n)) = (anchor, positive, negative) {
signatures.insert(format!("{a} || {p} || {n}"));
}
if seen_splits.len() == 1 && signatures.len() >= 2 {
break;
}
}
assert_eq!(
seen_splits.len(),
1,
"expected sampling to stay in the target split"
);
assert!(
signatures.len() >= 2,
"expected at least two distinct triplet KVP signatures across samples"
);
}
#[test]
fn triplets_cover_kvp_behaviors_across_all_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 211).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let ids = vec![
find_id(SplitLabel::Train, "kvp_split_train_a"),
find_id(SplitLabel::Train, "kvp_split_train_b"),
find_id(SplitLabel::Validation, "kvp_split_val_a"),
find_id(SplitLabel::Validation, "kvp_split_val_b"),
find_id(SplitLabel::Test, "kvp_split_test_a"),
find_id(SplitLabel::Test, "kvp_split_test_b"),
];
let mut config = base_config();
config.seed = 515151;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "kvp_behavior_triplet_all_splits".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let build_prefix = || {
let mut prefix = KvpPrefixSampler::new(1.0);
prefix.add_variant_fields([
KvpField::many("date", ["2025-08-01", "Aug 1, 2025", "08/01/2025"]),
KvpField::many("source", ["source_a", "source_primary"]),
KvpField::one("ticker", "TT").with_presence(0.5),
KvpField::one("quarter", "Q3").with_presence(0.5),
]);
prefix
};
let records: Vec<DataRecord> = ids
.into_iter()
.map(|id| {
let mut record = trader_record(&id, "2025-08-01", "Split Title", "Split Body");
record.source = "source_a".into();
record.meta_prefix = Some(build_prefix());
record
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen_splits = std::collections::HashSet::new();
let mut saw_triplet_component_divergence = false;
let mut saw_ticker_present = false;
let mut saw_ticker_absent = false;
let mut orderings_by_signature: std::collections::HashMap<
String,
std::collections::HashSet<String>,
> = std::collections::HashMap::new();
for _ in 0..180 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
let anchor_split = store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap();
let positive_split = store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap();
let negative_split = store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap();
seen_splits.insert(anchor_split);
seen_splits.insert(positive_split);
seen_splits.insert(negative_split);
let prefixes = [
extract_meta_prefix(&triplet.anchor.text),
extract_meta_prefix(&triplet.positive.text),
extract_meta_prefix(&triplet.negative.text),
];
if let (Some(a_pref), Some(p_pref), Some(n_pref)) = (
prefixes[0].as_ref(),
prefixes[1].as_ref(),
prefixes[2].as_ref(),
) && (a_pref != p_pref || p_pref != n_pref)
{
saw_triplet_component_divergence = true;
}
for pref in prefixes.into_iter().flatten() {
let parts = split_meta_parts(&pref);
let has_ticker = parts.iter().any(|part| part.starts_with("ticker="));
if has_ticker {
saw_ticker_present = true;
} else {
saw_ticker_absent = true;
}
let ordered = parts.join(" | ");
let mut normalized = parts;
normalized.sort();
let signature = normalized.join(" | ");
orderings_by_signature
.entry(signature)
.or_default()
.insert(ordered);
}
if seen_splits.len() == 1
&& saw_triplet_component_divergence
&& saw_ticker_present
&& saw_ticker_absent
&& orderings_by_signature
.values()
.any(|ordered_forms| ordered_forms.len() >= 2)
{
break;
}
}
let saw_order_permutation = orderings_by_signature
.values()
.any(|ordered_forms| ordered_forms.len() >= 2);
assert_eq!(
seen_splits.len(),
1,
"expected sampling to stay in the target split"
);
assert!(
saw_triplet_component_divergence,
"expected anchor/positive/negative KVP prefixes to diverge in at least one triplet"
);
assert!(
saw_ticker_present && saw_ticker_absent,
"expected optional field to be present on some samples and absent on others"
);
assert!(
saw_order_permutation,
"expected at least one identical KVP field-set signature to appear in multiple key orders"
);
}
#[test]
fn role_reentry_same_epoch_restarts_from_same_section_offset() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 31).unwrap());
let mut config = base_config();
config.seed = 131;
config.chunking = ChunkingStrategy {
max_window_tokens: 64,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let mut inner = TripletSamplerInner::new(config, store);
let record = DataRecord {
id: "role_reentry_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Context,
heading: Some("A".into()),
text: "alpha".into(),
sentences: vec!["alpha".into()],
token_count: WhitespaceTokenizer.token_count("alpha"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("B".into()),
text: "beta".into(),
sentences: vec!["beta".into()],
token_count: WhitespaceTokenizer.token_count("beta"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("C".into()),
text: "gamma".into(),
sentences: vec!["gamma".into()],
token_count: WhitespaceTokenizer.token_count("gamma"),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let first = inner
.select_by_role(&record, &SectionRole::Context)
.expect("first role chunk");
inner
.role_cursors
.remove(&(RecordKey::from(&record), role_label(&SectionRole::Context)));
inner
.chunk_cursors
.retain(|(record_key, _), _| record_key != &RecordKey::from(&record));
let restarted = inner
.select_by_role(&record, &SectionRole::Context)
.expect("restarted role chunk");
assert_eq!(restarted.text, first.text);
}
#[test]
fn role_reentry_after_epoch_change_can_restart_from_different_section_offset() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 37).unwrap());
let role_key = "role_reentry_record::context";
let section_count = 3usize;
let mut seed = 1u64;
while (stable_hash_str(seed, role_key) as usize) % section_count
== (stable_hash_str(seed ^ 1, role_key) as usize) % section_count
{
seed = seed.saturating_add(1);
}
let mut config = base_config();
config.seed = seed;
config.chunking = ChunkingStrategy {
max_window_tokens: 64,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let mut inner = TripletSamplerInner::new(config, store);
let record = DataRecord {
id: "role_reentry_record".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Context,
heading: Some("A".into()),
text: "alpha".into(),
sentences: vec!["alpha".into()],
token_count: WhitespaceTokenizer.token_count("alpha"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("B".into()),
text: "beta".into(),
sentences: vec!["beta".into()],
token_count: WhitespaceTokenizer.token_count("beta"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("C".into()),
text: "gamma".into(),
sentences: vec!["gamma".into()],
token_count: WhitespaceTokenizer.token_count("gamma"),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let first_epoch0 = inner
.select_by_role(&record, &SectionRole::Context)
.expect("first role chunk epoch0");
inner
.role_cursors
.remove(&(RecordKey::from(&record), role_label(&SectionRole::Context)));
inner
.chunk_cursors
.retain(|(record_key, _), _| record_key != &RecordKey::from(&record));
inner.epoch = inner.epoch.saturating_add(1);
let first_epoch1 = inner
.select_by_role(&record, &SectionRole::Context)
.expect("first role chunk epoch1");
assert_ne!(first_epoch1.text, first_epoch0.text);
}
#[test]
fn derives_text_recipes_from_triplets() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 3,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "title_to_intro".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongPublicationDate,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 17).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"unit",
vec![sample_record()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert!(!batch.is_empty());
assert_eq!(batch.samples.len(), 1);
assert!(batch.samples[0].recipe.starts_with("title_to_intro_"));
}
#[test]
fn source_triplets_drive_text_sampling() {
let split = SplitRatios::default();
let mut config = base_config();
config.seed = 7;
config.batch_size = 1;
config.recipes.clear();
config.text_recipes.clear();
let store = Arc::new(DeterministicSplitStore::new(split, 41).unwrap());
let records = vec![
trader_record(
"source_a::2025/01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha",
),
trader_record(
"source_a::2025/01-02/article_b.txt",
"2025-01-02",
"Beta",
"Body beta",
),
];
let recipes = vec![TripletRecipe {
name: Cow::Borrowed("source_auto"),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let decorated = RecipeDecoratedSource::new(records, recipes);
let sampler = TripletSampler::new(config, store);
sampler.register_source(Box::new(decorated)).unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert!(batch.samples[0].recipe.starts_with("source_auto_"));
assert_eq!(batch.samples.len(), 1);
}
#[test]
fn source_defined_recipes_fill_config_gap() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 41,
batch_size: 2,
chunking: ChunkingStrategy::default(),
recipes: vec![],
text_recipes: vec![],
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 19).unwrap());
let recipes = vec![TripletRecipe {
name: "inline_title_summary".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let records = vec![
trader_record(
"source_a::2025/01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha",
),
trader_record(
"source_a::2025/01-02/article_b.txt",
"2025-01-02",
"Beta",
"Body beta",
),
];
for record in &records {
store
.upsert(
RecordKey::new("recipe_source", record.id.clone()),
SplitLabel::Train,
)
.unwrap();
}
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, recipes.clone())))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets[0].recipe, recipes[0].name.as_ref());
assert!(!batch.triplets.is_empty());
}
#[test]
fn source_recipes_drive_text_sampling() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 43,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![],
text_recipes: vec![],
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 29).unwrap());
let recipes = vec![TripletRecipe {
name: "inline_title_context".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let records = vec![
trader_record(
"source_a::2025/01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha",
),
trader_record(
"source_a::2025/01-02/article_b.txt",
"2025-01-02",
"Beta",
"Body beta",
),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, recipes)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 1);
assert!(batch.samples[0].recipe.starts_with("inline_title_context_"));
}
#[test]
fn role_mode_text_batches_cover_all_three_selector_slots() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 77,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![],
text_recipes: vec![],
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let recipes = vec![TripletRecipe {
name: "role_mode_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let records: Vec<DataRecord> = (0..6)
.map(|i| {
trader_record(
&format!("src::article_{i}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body text {i}"),
)
})
.collect();
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, recipes)))
.unwrap();
let mut seen_anchor = false;
let mut seen_positive = false;
let mut seen_negative = false;
for _ in 0..64 {
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
for sample in &batch.samples {
match sample.recipe.as_str() {
"role_mode_recipe_anchor" => seen_anchor = true,
"role_mode_recipe_positive" => seen_positive = true,
"role_mode_recipe_negative" => seen_negative = true,
_ => {}
}
}
if seen_anchor && seen_positive && seen_negative {
break;
}
}
assert!(
seen_anchor,
"text batch never sampled the _anchor derived recipe"
);
assert!(
seen_positive,
"text batch never sampled the _positive derived recipe"
);
assert!(
seen_negative,
"text batch never sampled the _negative derived recipe"
);
}
#[test]
fn source_a_negative_pairs_follow_strategy() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 4,
batch_size: 2,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "tt_wrong_article".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 6).unwrap());
let records = vec![
trader_record(
"source_a::2025/01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha",
),
trader_record(
"source_a::2025/01-01/article_b.txt",
"2025-01-01",
"Beta",
"Body beta",
),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert!(!batch.pairs.is_empty());
let negative = batch
.pairs
.iter()
.find(|pair| pair.label == PairLabel::Negative)
.expect("expected a negative pair");
assert_eq!(negative.reason.as_deref(), Some("wrong_article"));
assert_ne!(negative.anchor.record_id, negative.positive.record_id);
}
#[test]
fn qa_negative_pairs_mismatch() {
let split = SplitRatios::default();
let config = SamplerConfig {
seed: 5,
batch_size: 2,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "qa_wrong_match".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::QuestionAnswerMismatch,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 31).unwrap());
let records = vec![
qa_pair_record(
"source_b::factual/alpha.txt",
"What is alpha?",
"Alpha is excess return.",
),
qa_pair_record(
"source_b::factual/beta.txt",
"What is beta?",
"Beta tracks market sensitivity.",
),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("qa", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert!(!batch.pairs.is_empty());
let negative = batch
.pairs
.iter()
.find(|pair| pair.label == PairLabel::Negative)
.expect("expected a negative pair");
assert_eq!(negative.reason.as_deref(), Some("wrong_qa_pairing"));
assert_ne!(negative.anchor.record_id, negative.positive.record_id);
}
#[test]
fn recipe_order_helpers_cover_empty_and_rotated_cases() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 59).unwrap());
let mut shuffled_inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
assert!(
shuffled_inner
.recipe_order_weighted_shuffled_seeded(&[])
.is_empty()
);
assert!(
shuffled_inner
.text_recipe_order_weighted_shuffled_seeded(&[])
.is_empty()
);
let mut base_inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
let base_triplet = base_inner.recipe_order_weighted_shuffled_seeded(&[1.0, 1.0, 1.0, 1.0]);
assert_eq!(base_triplet.len(), 4);
let mut sorted_triplet = base_triplet.clone();
sorted_triplet.sort_unstable();
assert_eq!(sorted_triplet, vec![0, 1, 2, 3]);
let mut cycled_inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
let cycled_triplet = cycled_inner.recipe_order_weighted_cycled_seeded(&[1.0, 1.0, 1.0, 1.0], 5);
assert_eq!(
cycled_triplet,
vec![
base_triplet[1],
base_triplet[2],
base_triplet[3],
base_triplet[0]
]
);
let mut base_text_inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
let base_text =
base_text_inner.text_recipe_order_weighted_shuffled_seeded(&[1.0, 1.0, 1.0, 1.0]);
assert_eq!(base_text.len(), 4);
let mut sorted_text = base_text.clone();
sorted_text.sort_unstable();
assert_eq!(sorted_text, vec![0, 1, 2, 3]);
let mut cycled_text_inner = TripletSamplerInner::new(base_config(), store);
let cycled_text =
cycled_text_inner.text_recipe_order_weighted_cycled_seeded(&[1.0, 1.0, 1.0, 1.0], 6);
assert_eq!(
cycled_text,
vec![base_text[2], base_text[3], base_text[0], base_text[1]]
);
}
#[test]
fn weighted_recipe_order_zero_weight_recipes_are_excluded() {
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 200).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let weights = [1.0_f32, 0.0, 1.0];
for _ in 0..20 {
let order = inner.recipe_order_weighted_shuffled_seeded(&weights);
assert!(
!order.contains(&1),
"zero-weight recipe 1 appeared in order: {order:?}"
);
assert_eq!(order.len(), 2); }
assert!(
inner
.recipe_order_weighted_shuffled_seeded(&[0.0, 0.0])
.is_empty()
);
}
#[test]
fn weighted_recipe_order_proportional_slot_count() {
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 201).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let order = inner.recipe_order_weighted_shuffled_seeded(&[3.0_f32, 1.0]);
assert_eq!(order.len(), 4);
assert_eq!(order.iter().filter(|&&i| i == 0).count(), 3);
assert_eq!(order.iter().filter(|&&i| i == 1).count(), 1);
let order2 = inner.recipe_order_weighted_shuffled_seeded(&[2.0_f32, 1.0]);
assert_eq!(order2.len(), 3);
assert_eq!(order2.iter().filter(|&&i| i == 0).count(), 2);
assert_eq!(order2.iter().filter(|&&i| i == 1).count(), 1);
let order3 = inner.recipe_order_weighted_shuffled_seeded(&[1.0_f32, 1.0, 1.0, 1.0]);
assert_eq!(order3.len(), 4);
let mut sorted = order3.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 1, 2, 3]);
}
#[test]
fn weighted_recipe_order_cycled_preserves_multiset_across_rotations() {
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 202).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let weights = [3.0_f32, 1.0];
for rr_idx in [0, 1, 2, 3, 7, 11] {
let order = inner.recipe_order_weighted_cycled_seeded(&weights, rr_idx);
assert_eq!(order.len(), 4, "rr_idx={rr_idx}");
assert_eq!(
order.iter().filter(|&&i| i == 0).count(),
3,
"rr_idx={rr_idx}"
);
assert_eq!(
order.iter().filter(|&&i| i == 1).count(),
1,
"rr_idx={rr_idx}"
);
}
}
#[test]
fn weighted_recipe_order_same_for_text_and_triplet_variants() {
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 203).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let weights = [4.0_f32, 2.0, 1.0];
let triplet = inner.recipe_order_weighted_shuffled_seeded(&weights);
let text = inner.text_recipe_order_weighted_shuffled_seeded(&weights);
let expected_len = 7;
assert_eq!(triplet.len(), expected_len);
assert_eq!(text.len(), expected_len);
assert_eq!(triplet.iter().filter(|&&i| i == 0).count(), 4);
assert_eq!(triplet.iter().filter(|&&i| i == 1).count(), 2);
assert_eq!(triplet.iter().filter(|&&i| i == 2).count(), 1);
assert_eq!(text.iter().filter(|&&i| i == 0).count(), 4);
assert_eq!(text.iter().filter(|&&i| i == 1).count(), 2);
assert_eq!(text.iter().filter(|&&i| i == 2).count(), 1);
}
#[test]
fn weighted_recipe_selection_zero_weight_recipe_never_appears_in_batch() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 204).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id))
== Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let id_a = find_train_id("w0a");
let id_b = find_train_id("w0b");
let id_c = find_train_id("w0c");
let mut config = base_config();
config.seed = 1001;
config.batch_size = 4;
config.ingestion_max_records = 8;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![
TripletRecipe {
name: "active".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
TripletRecipe {
name: "excluded".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 0.0, instruction: None,
allow_same_anchor_positive: false,
},
];
let records = vec![
trader_record(&id_a, "2025-01-01", "A", "Body A long enough to chunk"),
trader_record(&id_b, "2025-01-02", "B", "Body B long enough to chunk"),
trader_record(&id_c, "2025-01-03", "C", "Body C long enough to chunk"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records,
)))
.unwrap();
for _ in 0..20 {
if let Ok(batch) = sampler.next_triplet_batch(SplitLabel::Train) {
for triplet in &batch.triplets {
assert_ne!(
triplet.recipe.as_str(),
"excluded",
"zero-weight recipe appeared in batch: {}",
triplet.recipe
);
assert!(
triplet.recipe.starts_with("active"),
"unexpected recipe name: {}",
triplet.recipe
);
}
}
}
}
#[test]
fn weighted_recipe_selection_frequency_matches_weight_ratio() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 205).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id))
== Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let id_a = find_train_id("wfa");
let id_b = find_train_id("wfb");
let id_c = find_train_id("wfc");
let id_d = find_train_id("wfd");
let mut config = base_config();
config.seed = 2002;
config.batch_size = 4;
config.ingestion_max_records = 16;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![
TripletRecipe {
name: "heavy".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 3.0,
instruction: None,
allow_same_anchor_positive: false,
},
TripletRecipe {
name: "light".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
];
let records = vec![
trader_record(&id_a, "2025-01-01", "A", "Body A long enough to chunk"),
trader_record(&id_b, "2025-01-02", "B", "Body B long enough to chunk"),
trader_record(&id_c, "2025-01-03", "C", "Body C long enough to chunk"),
trader_record(&id_d, "2025-01-04", "D", "Body D long enough to chunk"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records,
)))
.unwrap();
let mut heavy_count = 0usize;
let mut light_count = 0usize;
for _ in 0..50 {
if let Ok(batch) = sampler.next_triplet_batch(SplitLabel::Train) {
for triplet in &batch.triplets {
if triplet.recipe.starts_with("heavy") {
heavy_count += 1;
} else if triplet.recipe.starts_with("light") {
light_count += 1;
}
}
}
}
let total = heavy_count + light_count;
assert!(total > 0, "no samples produced");
let heavy_fraction = heavy_count as f64 / total as f64;
assert!(
(0.55..=0.95).contains(&heavy_fraction),
"heavy recipe fraction {heavy_fraction:.2} outside expected range [0.55, 0.95] \
(heavy={heavy_count}, light={light_count})"
);
assert!(
heavy_count > light_count,
"heavy recipe ({heavy_count}) should appear more often than light ({light_count})"
);
}
#[test]
fn disallowed_split_returns_configuration_error() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 61).unwrap());
let mut config = base_config();
config.allowed_splits = vec![SplitLabel::Train];
let sampler = TripletSampler::new(config, store);
let err = sampler
.next_text_batch_for_split(SplitLabel::Validation)
.expect_err("validation split should be rejected");
match err {
SamplerError::Configuration(message) => {
assert!(message.contains("requested split Validation"));
assert!(message.contains("allowed_splits [Train]"));
}
other => panic!("expected configuration error, got {other:?}"),
}
}
#[test]
fn selector_edge_cases_cover_internal_branches() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 67).unwrap());
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 3,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let now = Utc::now();
let record = DataRecord {
id: "selector_record".into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec!["unit".into()],
sections: vec![
RecordSection {
role: SectionRole::Context,
heading: Some("Body".into()),
text: "one two three four five six".into(),
sentences: vec!["one two three four five six".into()],
token_count: WhitespaceTokenizer.token_count("one two three four five six"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Empty".into()),
text: String::new(),
sentences: Vec::new(),
token_count: 0,
embedding: None,
},
],
meta_prefix: None,
label: None,
};
assert!(
inner.record_has_at_least_two_window_chunks_for_selector(&record, &Selector::Paragraph(0),)
);
assert!(inner.record_has_at_least_two_window_chunks_for_selector(&record, &Selector::Random,));
assert!(
!inner
.record_has_at_least_two_window_chunks_for_selector(&record, &Selector::Paragraph(9),)
);
assert!(
!inner.record_has_at_least_two_window_chunks_for_selector(
&record,
&Selector::TemporalOffset(1),
)
);
let paragraph_chunk = inner
.select_chunk(&record, &Selector::Paragraph(0))
.expect("paragraph selector should yield a chunk");
assert_eq!(paragraph_chunk.text, "four five six");
assert!(
inner
.select_chunk(&record, &Selector::Paragraph(9))
.is_none()
);
let empty_record = DataRecord {
sections: Vec::new(),
..record.clone()
};
assert!(
inner
.select_chunk(&empty_record, &Selector::Random)
.is_none()
);
let no_anchor_record = DataRecord {
sections: vec![RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: "headline only".into(),
sentences: vec!["headline only".into()],
token_count: WhitespaceTokenizer.token_count("headline only"),
embedding: None,
}],
..record.clone()
};
assert!(
inner
.select_by_role(&no_anchor_record, &SectionRole::Context)
.is_none()
);
assert!(
inner
.materialize_chunks(&record, 1, &record.sections[1])
.is_empty()
);
let mut neighbor = record.clone();
neighbor.id = "selector_neighbor".into();
neighbor.created_at = now + Duration::days(1);
neighbor.updated_at = neighbor.created_at;
neighbor.sections = vec![RecordSection {
role: SectionRole::Context,
heading: Some("Neighbor".into()),
text: "neighbor chunk text".into(),
sentences: vec!["neighbor chunk text".into()],
token_count: WhitespaceTokenizer.token_count("neighbor chunk text"),
embedding: None,
}];
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
store
.upsert(RecordKey::from(&neighbor), SplitLabel::Train)
.unwrap();
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
inner
.records
.insert(RecordKey::from(&neighbor), Arc::new(neighbor.clone()));
let temporal_chunk = inner
.select_chunk(&record, &Selector::TemporalOffset(1))
.expect("temporal selector should find neighbor chunk");
assert_eq!(temporal_chunk.record_id, neighbor.id);
assert_eq!(temporal_chunk.text, "neighbor chunk text");
}
#[test]
fn empty_recipe_configs_error_when_sampling_without_sources() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 71).unwrap());
let mut config = base_config();
config.batch_size = 1;
config.recipes.clear();
config.text_recipes.clear();
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let record = sample_record();
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
let pair_err = inner
.next_pair_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("pair sampling should fail without triplet recipes");
match pair_err {
SamplerError::Configuration(message) => {
assert_eq!(message, "no triplet recipes available");
}
other => panic!("expected configuration error, got {other:?}"),
}
let text_err = inner
.next_text_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("text sampling should fail without text recipes");
match text_err {
SamplerError::Configuration(message) => {
assert_eq!(message, "no text recipes configured");
}
other => panic!("expected configuration error, got {other:?}"),
}
let triplet_err = inner
.next_triplet_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("triplet sampling should fail without triplet recipes");
match triplet_err {
SamplerError::Configuration(message) => {
assert_eq!(message, "no triplet recipes configured");
}
other => panic!("expected configuration error, got {other:?}"),
}
}
#[test]
fn source_less_batch_builders_sample_from_primed_epoch_tracker() {
fn primed_inner(batch_size: usize) -> TripletSamplerInner<DeterministicSplitStore> {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 79).unwrap());
let mut config = base_config();
config.batch_size = batch_size;
config.recipes = vec![TripletRecipe {
name: "manual_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "manual_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let records = vec![
trader_record(
"manual::2025-01-01/article_a.txt",
"2025-01-01",
"Alpha",
"Body alpha with enough words",
),
trader_record(
"manual::2025-01-01/article_b.txt",
"2025-01-01",
"Beta",
"Body beta with enough words",
),
];
for record in records {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
inner
}
let mut pair_inner = primed_inner(2);
let pair_batch = pair_inner
.next_pair_batch_inner_with_weights(SplitLabel::Train, None)
.expect("pair batch should sample from primed epoch tracker");
assert_eq!(pair_batch.pairs.len(), 2);
assert!(
pair_batch
.pairs
.iter()
.any(|pair| pair.label == PairLabel::Positive)
);
assert!(
pair_batch
.pairs
.iter()
.any(|pair| pair.label == PairLabel::Negative)
);
let mut text_inner = primed_inner(2);
let text_batch = text_inner
.next_text_batch_inner_with_weights(SplitLabel::Train, None)
.expect("text batch should sample from primed epoch tracker");
assert_eq!(text_batch.samples.len(), 2);
assert!(
text_batch
.samples
.iter()
.all(|sample| sample.recipe == "manual_text")
);
let mut triplet_inner = primed_inner(2);
let triplet_batch = triplet_inner
.next_triplet_batch_inner_with_weights(SplitLabel::Train, None)
.expect("triplet batch should sample from primed epoch tracker");
assert_eq!(triplet_batch.triplets.len(), 2);
assert!(
triplet_batch
.triplets
.iter()
.all(|triplet| triplet.recipe == "manual_triplet")
);
}
#[test]
fn source_less_batch_builders_report_last_recipe_when_sampling_exhausts() {
fn primed_failing_inner(
triplet_name: &str,
text_name: &str,
) -> TripletSamplerInner<DeterministicSplitStore> {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 83).unwrap());
let mut config = base_config();
config.batch_size = 1;
config.recipes = vec![TripletRecipe {
name: triplet_name.to_string().into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: text_name.to_string().into(),
selector: Selector::Paragraph(99),
weight: 1.0,
instruction: None,
}];
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let record = trader_record(
"manual::2025-01-01/article_only.txt",
"2025-01-01",
"Solo",
"Only record body",
);
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record),
);
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
inner
}
let mut pair_inner = primed_failing_inner("manual_pair_exhausted", "manual_text_exhausted");
let pair_err = pair_inner
.next_pair_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("pair batch should exhaust with one record and wrong-article negatives");
match pair_err {
SamplerError::Exhausted(message) => assert_eq!(message, "manual_pair_exhausted"),
other => panic!("expected exhausted error, got {other:?}"),
}
let mut text_inner = primed_failing_inner("manual_triplet_exhausted", "manual_text_exhausted");
let text_err = text_inner
.next_text_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("text batch should exhaust when selector never resolves a chunk");
match text_err {
SamplerError::Exhausted(message) => assert_eq!(message, "manual_text_exhausted"),
other => panic!("expected exhausted error, got {other:?}"),
}
let mut triplet_inner =
primed_failing_inner("manual_triplet_exhausted", "manual_text_exhausted");
let triplet_err = triplet_inner
.next_triplet_batch_inner_with_weights(SplitLabel::Train, None)
.expect_err("triplet batch should exhaust with one record and no negative candidate");
match triplet_err {
SamplerError::Exhausted(message) => assert_eq!(message, "manual_triplet_exhausted"),
other => panic!("expected exhausted error, got {other:?}"),
}
}
#[test]
fn source_state_and_recipe_helpers_cover_remaining_branches() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 89).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
let recipe = |name: &str| TripletRecipe {
name: name.to_string().into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
inner.persist_source_state(None).unwrap();
assert!(store.load_sampler_state().unwrap().is_none());
inner.source_state_loaded = true;
inner.source_cycle_idx = 3;
inner.source_record_cursors.insert("source_a".into(), 4);
inner.epoch = 5;
inner.triplet_recipe_rr_idx = 6;
inner.text_recipe_rr_idx = 7;
inner.persist_source_state(None).unwrap();
let persisted = store.load_sampler_state().unwrap().unwrap();
assert_eq!(persisted.source_cycle_idx, 3);
assert_eq!(
persisted.source_record_cursors,
vec![("source_a".into(), 4)]
);
assert_eq!(persisted.epoch, 5);
assert_eq!(persisted.triplet_recipe_rr_idx, 6);
assert_eq!(persisted.text_recipe_rr_idx, 7);
assert!(!inner.source_state_dirty);
inner.using_config_text_recipes = true;
inner.text_recipes = vec![TextRecipe {
name: "keep_me".into(),
selector: Selector::Random,
weight: 1.0,
instruction: None,
}];
inner.triplet_recipes.clear();
inner.rebuild_derived_text_recipes();
assert_eq!(inner.text_recipes.len(), 1);
inner.using_config_text_recipes = false;
inner.rebuild_derived_text_recipes();
assert!(inner.text_recipes.is_empty());
inner.triplet_recipes = vec![recipe("derived_recipe")];
inner.rebuild_derived_text_recipes();
assert!(!inner.text_recipes.is_empty());
let duplicate_name = inner.text_recipes[0].name.clone();
inner.extend_text_recipes_unique(&[
TextRecipe {
name: duplicate_name,
selector: Selector::Random,
weight: 1.0,
instruction: None,
},
TextRecipe {
name: "new_text_recipe".into(),
selector: Selector::Paragraph(0),
weight: 1.0,
instruction: None,
},
]);
assert!(
inner
.text_recipes
.iter()
.any(|recipe| recipe.name == "new_text_recipe")
);
inner.using_config_triplet_recipes = true;
inner.triplet_recipes = vec![recipe("configured_global")];
assert_eq!(
inner.configured_triplet_recipes_for_source("unused")[0].name,
"configured_global"
);
inner.using_config_triplet_recipes = false;
inner
.source_triplet_recipes
.insert("source_a".into(), vec![recipe("source_specific")]);
assert_eq!(
inner.configured_triplet_recipes_for_source("source_a")[0].name,
"source_specific"
);
assert!(!TripletSamplerInner::<DeterministicSplitStore>::contains_auto_chunk_pair_recipe(&[]));
assert!(
TripletSamplerInner::<DeterministicSplitStore>::contains_auto_chunk_pair_recipe(&[
TripletSamplerInner::<DeterministicSplitStore>::source_chunk_pair_recipe(),
])
);
inner.config.chunking.max_window_tokens = 0;
inner.long_section_counts.insert("source_a".into(), 1);
assert!(!inner.source_supports_chunk_pair_recipe("source_a"));
}
#[test]
fn records_by_split_and_anchor_selection_cover_edge_cases() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 2).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), Arc::clone(&store));
let record = trader_record("source_a::record_a", "2025-01-01", "Alpha", "Body alpha");
let record_key = RecordKey::from(&record);
inner
.records
.insert(record_key.clone(), Arc::new(record.clone()));
inner
.chunk_index
.insert(record_key.clone(), record_key.clone());
inner.chunk_index.insert(
RecordKey::new("source_a", "dangling_chunk"),
RecordKey::new("source_a", "missing_record"),
);
let by_split = inner.records_by_split().unwrap();
assert_eq!(by_split.get(&SplitLabel::Train).map(Vec::len), Some(1));
assert_eq!(
store.label_for(&RecordKey::from(&record)),
Some(SplitLabel::Train)
);
assert!(
inner
.choose_anchor_record(Some("missing_source"), SplitLabel::Train)
.is_none()
);
inner
.source_record_indices
.insert("empty_source".into(), Vec::new());
assert!(
inner
.choose_anchor_record(Some("empty_source"), SplitLabel::Train)
.is_none()
);
let validation_record = trader_record("source_b::record_b", "2025-01-02", "Beta", "Body beta");
store
.upsert(RecordKey::from(&validation_record), SplitLabel::Validation)
.unwrap();
inner.records.insert(
RecordKey::from(&validation_record),
Arc::new(validation_record),
);
inner.source_record_indices.insert(
"source_b".into(),
vec![RecordKey::new("source_a", "source_b::record_b")],
);
inner.source_order = vec!["source_b".into()];
inner.source_wrapped.insert("source_b".into(), false);
assert!(
inner
.choose_anchor_record(Some("source_b"), SplitLabel::Train)
.is_none()
);
assert_eq!(inner.epoch, 1);
assert_eq!(inner.source_cycle_idx, 0);
let mut no_source_inner = TripletSamplerInner::new(base_config(), store);
no_source_inner.epoch_tracker.ensure_loaded().unwrap();
assert!(
no_source_inner
.choose_anchor_record(None, SplitLabel::Train)
.is_none()
);
}
#[test]
fn temporal_neighbor_auto_pair_and_weighted_retry_paths_are_covered() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 101).unwrap());
let mut config = base_config();
config.batch_size = 1;
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let mut anchor = record_with_offset("anchor_record", Utc::now(), 0);
anchor.source = "source_a".into();
anchor.taxonomy = vec!["shared_taxonomy".into()];
let mut neighbor = record_with_offset("neighbor_record", anchor.created_at, 86_400);
neighbor.source = "source_b".into();
neighbor.taxonomy = vec!["shared_taxonomy".into()];
store
.upsert(RecordKey::from(&anchor), SplitLabel::Train)
.unwrap();
store
.upsert(RecordKey::from(&neighbor), SplitLabel::Train)
.unwrap();
inner
.records
.insert(RecordKey::from(&anchor), Arc::new(anchor.clone()));
inner
.records
.insert(RecordKey::from(&neighbor), Arc::new(neighbor.clone()));
let selected = inner
.select_temporal_neighbor(&anchor, 1)
.expect("taxonomy match should allow cross-source temporal neighbor");
assert_eq!(selected.id, neighbor.id);
let mismatched_recipe = TripletRecipe {
name: "mismatched_auto".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
assert!(
inner
.select_distinct_window_pair_for_auto_recipe(&mismatched_recipe, &anchor)
.is_none()
);
let failing_config = SamplerConfig {
batch_size: 1,
recipes: vec![TripletRecipe {
name: "retry_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: vec![TextRecipe {
name: "retry_text".into(),
selector: Selector::Paragraph(99),
weight: 1.0,
instruction: None,
}],
..base_config()
};
let retry_store = Arc::new(DeterministicSplitStore::new(split, 103).unwrap());
let sampler = TripletSampler::new(failing_config, retry_store);
let weights = HashMap::new();
let pair_err = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("pair retry path should exhaust without records");
assert!(matches!(pair_err, SamplerError::Exhausted(_)));
let text_err = sampler
.next_text_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("text retry path should exhaust without records");
assert!(matches!(text_err, SamplerError::Exhausted(_)));
let triplet_err = sampler
.next_triplet_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("triplet retry path should exhaust without records");
assert!(matches!(triplet_err, SamplerError::Exhausted(_)));
let unknown = HashMap::from([("missing_source".to_string(), 1.0f32)]);
assert!(
matches!(
sampler.next_pair_batch_with_weights_for_split(SplitLabel::Train, &unknown),
Err(SamplerError::InvalidWeight { .. })
),
"unknown weight source must fail loudly (pairs)"
);
assert!(
matches!(
sampler.next_text_batch_with_weights_for_split(SplitLabel::Train, &unknown),
Err(SamplerError::InvalidWeight { .. })
),
"unknown weight source must fail loudly (text)"
);
assert!(
matches!(
sampler.next_triplet_batch_with_weights_for_split(SplitLabel::Train, &unknown),
Err(SamplerError::InvalidWeight { .. })
),
"unknown weight source must fail loudly (triplets)"
);
}
#[test]
fn wrong_article_falls_back_within_same_split() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let config = SamplerConfig {
seed: 9,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 47).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let anchor_ids = vec![
find_id(SplitLabel::Train, "wa_anchor_train"),
find_id(SplitLabel::Validation, "wa_anchor_val"),
find_id(SplitLabel::Test, "wa_anchor_test"),
];
let other_ids = [
find_id(SplitLabel::Train, "wa_other_train"),
find_id(SplitLabel::Validation, "wa_other_val"),
find_id(SplitLabel::Test, "wa_other_test"),
];
let anchor_records: Vec<DataRecord> = anchor_ids
.iter()
.enumerate()
.map(|(i, id)| trader_record(id, "2025-01-01", &format!("Anchor {i}"), "Body alpha"))
.collect();
let other_records: Vec<DataRecord> = other_ids
.iter()
.enumerate()
.map(|(i, id)| trader_record(id, "2025-01-02", &format!("Other {i}"), "Body beta"))
.collect();
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", anchor_records)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"other",
other_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let mut seen_splits = std::collections::HashSet::new();
for anchor_id in anchor_ids {
let anchor = inner
.records
.get(&RecordKey::new("tt", anchor_id))
.cloned()
.expect("anchor");
let (negative, _fallback) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("negative");
assert_ne!(negative.id, anchor.id);
let anchor_label = inner
.split_store
.label_for(&RecordKey::from(anchor))
.unwrap();
let negative_label = inner
.split_store
.label_for(&RecordKey::from(negative))
.unwrap();
seen_splits.insert(anchor_label);
assert_eq!(negative_label, anchor_label);
}
assert_eq!(seen_splits.len(), 3);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_hard_negative_respects_same_source_split_pool() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 13,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let anchor_id = find_train_id("bm25_anchor");
let similar_id = find_train_id("bm25_similar");
let distant_id = find_train_id("bm25_distant");
let anchor = trader_record(
&anchor_id,
"2025-01-01",
"Apple banana quarterly report",
"Apple banana revenue growth guidance",
);
let similar = trader_record(
&similar_id,
"2025-01-01",
"Banana apple market update",
"Revenue guidance for apple banana market",
);
let distant = trader_record(
&distant_id,
"2025-01-03",
"Quantum field dynamics",
"Black holes and gravitational lensing",
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
vec![anchor.clone(), similar.clone(), distant],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let (negative, fallback_used) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("expected bm25-ranked negative via WrongArticle strategy");
let _ = fallback_used;
assert_ne!(negative.id, anchor.id);
assert_eq!(
store.label_for(&RecordKey::new("tt", anchor.id.clone())),
Some(SplitLabel::Train)
);
assert_eq!(
store.label_for(&RecordKey::new("tt", negative.id.clone())),
Some(SplitLabel::Train)
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_negative_is_lexically_closer_than_uniform_pool_baseline() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 314,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 314).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let anchor_id = find_train_id("bm25_lex_anchor");
let similar_id = find_train_id("bm25_lex_similar");
let distant_id = find_train_id("bm25_lex_distant");
let anchor = trader_record(
&anchor_id,
"2025-01-01",
"Apple banana quarterly report",
"apple banana revenue growth guidance demand outlook",
);
let similar = trader_record(
&similar_id,
"2025-01-01",
"Banana apple market update",
"apple banana revenue guidance and market demand outlook",
);
let distant = trader_record(
&distant_id,
"2025-01-01",
"Deep ocean geology",
"tectonic plates abyssal sediment marine trench volcanism",
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
vec![anchor.clone(), similar, distant],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let ingested_anchor = inner
.records
.get(&RecordKey::new("tt", &anchor.id))
.cloned()
.expect("anchor must be present in ingested records");
let (_selected_negative, _fallback) = inner
.select_negative_record_seeded(&ingested_anchor, &NegativeStrategy::WrongArticle, None)
.expect("expected bm25-ranked negative");
let anchor_text = record_bm25_text(&ingested_anchor, inner.config.chunking.max_window_tokens);
let pool: Vec<Arc<DataRecord>> = inner
.records
.values()
.filter(|candidate| {
candidate.source == ingested_anchor.source
&& candidate.id != ingested_anchor.id
&& inner
.split_store
.label_for(&RecordKey::from(*candidate))
.map(|label| label == SplitLabel::Train)
.unwrap_or(false)
})
.cloned()
.collect();
assert!(!pool.is_empty(), "control pool must not be empty");
let (mean_pool_jaccard, mean_pool_cosine) = {
let mut j_total = 0.0_f32;
let mut c_total = 0.0_f32;
for candidate in &pool {
let candidate_text =
record_bm25_text(candidate, inner.config.chunking.max_window_tokens);
let (j_score, c_score) =
crate::metrics::lexical_similarity_scores(&anchor_text, &candidate_text);
j_total += j_score;
c_total += c_score;
}
let denom = pool.len() as f32;
(j_total / denom, c_total / denom)
};
let ranked = inner.bm25_ranked_candidates(&ingested_anchor);
assert!(
!ranked.is_empty(),
"BM25 must produce at least one ranked candidate"
);
let top_candidate = inner
.records
.get(ranked.first().unwrap())
.cloned()
.expect("top BM25 candidate must be in records");
let top_text = record_bm25_text(&top_candidate, inner.config.chunking.max_window_tokens);
let (j_top, c_top) = crate::metrics::lexical_similarity_scores(&anchor_text, &top_text);
assert!(
j_top > mean_pool_jaccard,
"BM25 top-ranked negative should beat non-bm25 uniform-pool Jaccard baseline (top={j_top:.4}, baseline={mean_pool_jaccard:.4})"
);
assert!(
c_top > mean_pool_cosine,
"BM25 top-ranked negative should beat non-bm25 uniform-pool cosine baseline (top={c_top:.4}, baseline={mean_pool_cosine:.4})"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn custom_recipe_still_respects_strategy_pool_with_bm25() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let recipe = TripletRecipe {
name: "custom_wrong_publication_date".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongPublicationDate,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let config = SamplerConfig {
seed: 23,
batch_size: 8,
chunking: ChunkingStrategy::default(),
recipes: vec![recipe],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 23).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let ca_id = find_train_id("custom_anchor_a");
let cb_id = find_train_id("custom_anchor_b");
let cc_id = find_train_id("custom_anchor_c");
let records = vec![
trader_record(
&ca_id,
"2025-01-01",
"Apple banana quarterly report",
"Apple banana revenue growth guidance",
),
trader_record(
&cb_id,
"2025-01-01",
"Apple banana management update",
"Apple banana demand outlook",
),
trader_record(
&cc_id,
"2025-01-02",
"Energy market briefing",
"Oil and gas supply outlook",
),
];
let date_by_id: HashMap<String, String> = records
.iter()
.filter_map(|record| {
taxonomy_value(record, META_FIELD_DATE)
.map(|date| (record.id.clone(), date.to_string()))
})
.collect();
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
let batch = sampler
.next_triplet_batch(SplitLabel::Train)
.expect("expected custom recipe triplet batch");
assert!(!batch.triplets.is_empty());
for triplet in &batch.triplets {
let anchor_date = date_by_id
.get(&triplet.anchor.record_id)
.expect("anchor date must exist");
let negative_date = date_by_id
.get(&triplet.negative.record_id)
.expect("negative date must exist");
assert_ne!(
anchor_date, negative_date,
"custom recipe negative must respect WrongPublicationDate pool under bm25"
);
}
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_ranked_candidates_never_cross_split_boundaries() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let config = SamplerConfig {
seed: 31,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 71).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..8000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let anchors = vec![
find_id(SplitLabel::Train, "bm25_split_anchor_train"),
find_id(SplitLabel::Validation, "bm25_split_anchor_val"),
find_id(SplitLabel::Test, "bm25_split_anchor_test"),
];
let peers = [
find_id(SplitLabel::Train, "bm25_split_peer_train"),
find_id(SplitLabel::Validation, "bm25_split_peer_val"),
find_id(SplitLabel::Test, "bm25_split_peer_test"),
];
let mut records: Vec<DataRecord> = Vec::new();
for (i, anchor_id) in anchors.iter().enumerate() {
records.push(trader_record(
anchor_id,
"2025-01-01",
&format!("Split anchor {i}"),
&format!("bm25 split scoped text {i}"),
));
}
for (i, peer_id) in peers.iter().enumerate() {
records.push(trader_record(
peer_id,
"2025-01-01",
&format!("Split peer {i}"),
&format!("bm25 split scoped peer text {i}"),
));
}
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
for anchor_id in anchors {
let anchor = inner
.records
.get(&RecordKey::new("tt", anchor_id))
.cloned()
.expect("anchor");
let (negative, _fallback) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None)
.expect("negative should exist");
let anchor_label = inner
.split_store
.label_for(&RecordKey::from(&anchor))
.expect("anchor split label");
let negative_label = inner
.split_store
.label_for(&RecordKey::from(&negative))
.expect("negative split label");
assert_eq!(negative_label, anchor_label);
let ranked: Vec<RecordKey> = inner
.bm25_backend_mut()
.hard_negatives_get(&anchor)
.expect("bm25 cache entry for anchor");
assert!(!ranked.is_empty());
for candidate_id in &ranked {
let candidate_label = inner
.split_store
.label_for(candidate_id)
.expect("candidate split label");
assert_eq!(
candidate_label, anchor_label,
"bm25 candidate leaked across split boundary"
);
}
}
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_ranked_candidates_match_raw_bm25_engine() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 991,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let records = vec![
trader_record(
"readable_anchor",
"2025-01-01",
"Climate policy briefing",
"carbon pricing policy emissions reduction roadmap market design",
),
trader_record(
"readable_topical_1",
"2025-01-01",
"Carbon market and emissions policy",
"carbon pricing policy emissions reduction roadmap market design",
),
trader_record(
"readable_topical_2",
"2025-01-01",
"Carbon policy update",
"carbon pricing policy emissions reduction roadmap",
),
trader_record(
"readable_mid_1",
"2025-01-01",
"Energy transition memo",
"emissions reduction roadmap clean energy transition planning",
),
trader_record(
"readable_mid_2",
"2025-01-01",
"Regulatory market digest",
"policy market design regulatory framework and compliance",
),
trader_record(
"readable_weak_1",
"2025-01-01",
"Archaeology field note",
"bronze age pottery fragments excavation trench mapping",
),
trader_record(
"readable_weak_2",
"2025-01-01",
"Marine geology report",
"subduction zones oceanic crust tectonic shear",
),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"readable_source",
records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new("readable_source", "readable_anchor"))
.cloned()
.expect("anchor should be present");
let sampler_ranked = inner.bm25_ranked_candidates(&anchor);
let meta_record_ids: Vec<RecordKey> = inner
.bm25_backend_mut()
.index_meta_record_ids()
.expect("bm25 global index should be built");
let docs: Vec<::bm25::Document<usize>> = meta_record_ids
.iter()
.enumerate()
.map(|(idx, record_id)| {
let record = inner
.records
.get(record_id)
.expect("record must be in self.records");
::bm25::Document {
id: idx,
contents: record_bm25_text(record, inner.config.chunking.max_window_tokens),
}
})
.collect();
let engine =
::bm25::SearchEngineBuilder::<usize>::with_documents(::bm25::Language::English, docs)
.build();
let query = record_bm25_text(&anchor, inner.config.chunking.max_window_tokens);
let max_results = meta_record_ids.len();
let mut raw = engine.search(&query, max_results);
raw.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.document.id.cmp(&b.document.id))
});
let mut expected: Vec<RecordKey> = Vec::new();
for result in raw {
let Some(record_id) = meta_record_ids.get(result.document.id) else {
continue;
};
if record_id.id != anchor.id {
expected.push(record_id.clone());
}
}
assert_eq!(
sampler_ranked, expected,
"sampler BM25 rank must match direct BM25 crate rank for the same corpus/query"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_ranking_ignores_kvp_meta_prefix_tags() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 888,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 888).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("kvp_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let anchor_id = find_train_id("kvp_anchor");
let bait_id = find_train_id("kvp_bait");
let plain_id = find_train_id("plain_text_best");
let anchor = trader_record(
&anchor_id,
"2025-01-01",
"Anchor",
"carbon pricing policy emissions roadmap",
);
let mut kvp_bait = trader_record(
&bait_id,
"2025-01-01",
"KVP bait",
"ancient pottery shards trench notes",
);
let mut kvp = KvpPrefixSampler::new(1.0);
kvp.add_variant([(
"meta",
"carbon pricing policy emissions roadmap carbon pricing policy emissions roadmap",
)]);
kvp_bait.meta_prefix = Some(kvp);
let plain_text_best = trader_record(
&plain_id,
"2025-01-01",
"Plain text best",
"carbon pricing policy emissions roadmap carbon market",
);
let bait_text = record_bm25_text(&kvp_bait, config.chunking.max_window_tokens);
assert!(
!bait_text.contains("carbon pricing policy emissions roadmap carbon pricing"),
"BM25 corpus text must not include KVP meta-prefix tags"
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"kvp_source",
vec![anchor, kvp_bait, plain_text_best],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new("kvp_source", &anchor_id))
.cloned()
.expect("anchor should exist");
let ranked = inner.bm25_ranked_candidates(&anchor);
assert!(
!ranked.is_empty(),
"expected BM25 to return ranked candidates"
);
assert_eq!(
ranked[0].id.clone(),
plain_id,
"BM25 top candidate should be driven by plain section text, not KVP meta-prefix tags"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_triplets_never_reuse_text_across_slots() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let recipe = TripletRecipe {
name: "bm25_text_distinct_slots".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let config = SamplerConfig {
seed: 91,
batch_size: 6,
chunking: ChunkingStrategy::default(),
recipes: vec![recipe],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 91).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let slot_anchor = find_train_id("bm25_slot_anchor");
let slot_same = find_train_id("bm25_slot_same_context");
let slot_unique = find_train_id("bm25_slot_unique_context");
let records = vec![
trader_record(
&slot_anchor,
"2025-01-01",
"Anchor title unique",
"Shared duplicate context",
),
trader_record(
&slot_same,
"2025-01-01",
"Other title one",
"Shared duplicate context",
),
trader_record(
&slot_unique,
"2025-01-01",
"Other title two",
"A fully distinct context body",
),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
let batch = sampler
.next_triplet_batch(SplitLabel::Train)
.expect("expected bm25 triplet batch");
assert!(!batch.triplets.is_empty());
for triplet in &batch.triplets {
assert_ne!(triplet.anchor.text, triplet.positive.text);
assert_ne!(triplet.anchor.text, triplet.negative.text);
assert_ne!(triplet.positive.text, triplet.negative.text);
}
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_cursor_pruning_runs_even_when_other_cursors_are_empty() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 1234).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
assert!(inner.chunk_cursors.is_empty());
assert!(inner.role_cursors.is_empty());
inner.bm25_backend_mut().negative_cursors_insert(
(RecordKey::new("stale", "stale_anchor"), SplitLabel::Train),
7,
);
assert_eq!(inner.bm25_backend_mut().negative_cursors_len(), 1);
inner.prune_cursor_state();
assert!(
inner.bm25_backend_mut().negative_cursors_is_empty(),
"bm25_negative_cursors should be pruned even when chunk/role cursor maps are empty"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_cursor_state_is_cleared_on_each_record_snapshot_sync() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 2024).unwrap());
let sync_id = (0u32..)
.find_map(|i| {
let id = format!("strict_sync_anchor_{i}");
(store.label_for(&RecordKey::new("strict_sync_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap();
let sampler = TripletSampler::new(base_config(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"strict_sync_source",
vec![trader_record(&sync_id, "2025-01-01", "Anchor", "body")],
)))
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
inner.bm25_backend_mut().negative_cursors_insert(
(
RecordKey::new("strict_sync_source", sync_id.clone()),
SplitLabel::Train,
),
42,
);
assert_eq!(inner.bm25_backend_mut().negative_cursors_len(), 1);
inner.sync_records_from_cache().unwrap();
assert!(
inner.bm25_backend_mut().negative_cursors_is_empty(),
"bm25 cursor state must reset at every record snapshot boundary"
);
}
#[test]
fn chunk_and_role_cursors_are_pruned_when_records_are_removed() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
assert!(inner.records.is_empty());
inner
.chunk_cursors
.insert((RecordKey::new("stale", "stale_record"), 0_usize), 3_usize);
inner.role_cursors.insert(
(
RecordKey::new("stale", "stale_record"),
"Anchor".to_string(),
),
5_usize,
);
assert_eq!(inner.chunk_cursors.len(), 1);
assert_eq!(inner.role_cursors.len(), 1);
inner.prune_cursor_state();
assert!(
inner.chunk_cursors.is_empty(),
"chunk_cursors should be pruned when their record is gone"
);
assert!(
inner.role_cursors.is_empty(),
"role_cursors should be pruned when their record is gone"
);
let mut record = sample_record();
record.id = "valid_record".to_string();
inner
.records
.insert(RecordKey::new("unit", "valid_record"), Arc::new(record));
inner
.chunk_cursors
.insert((RecordKey::new("unit", "valid_record"), 0_usize), 3_usize);
inner
.chunk_cursors
.insert((RecordKey::new("stale", "another_stale"), 0_usize), 7_usize);
inner.role_cursors.insert(
(RecordKey::new("unit", "valid_record"), "Anchor".to_string()),
5_usize,
);
inner.role_cursors.insert(
(
RecordKey::new("stale", "another_stale"),
"Anchor".to_string(),
),
9_usize,
);
assert_eq!(inner.chunk_cursors.len(), 2);
assert_eq!(inner.role_cursors.len(), 2);
inner.prune_cursor_state();
assert_eq!(
inner.chunk_cursors.len(),
1,
"chunk_cursors should keep valid entries and drop stale ones"
);
assert_eq!(
inner.role_cursors.len(),
1,
"role_cursors should keep valid entries and drop stale ones"
);
assert!(
inner
.chunk_cursors
.contains_key(&(RecordKey::new("unit", "valid_record"), 0_usize))
);
assert!(
inner
.role_cursors
.contains_key(&(RecordKey::new("unit", "valid_record"), "Anchor".to_string()))
);
}
#[test]
fn chunk_and_role_cursors_are_pruned_across_cache_sync() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 2024).unwrap());
let sync_id = (0u32..)
.find_map(|i| {
let id = format!("strict_sync_anchor_{i}");
(store.label_for(&RecordKey::new("strict_sync_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap();
let sampler = TripletSampler::new(base_config(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"strict_sync_source",
vec![trader_record(&sync_id, "2025-01-01", "Anchor", "body")],
)))
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
assert!(
inner
.records
.contains_key(&RecordKey::new("strict_sync_source", &sync_id))
);
inner.chunk_cursors.insert(
(
RecordKey::new("strict_sync_source", sync_id.clone()),
0_usize,
),
3_usize,
);
inner
.chunk_cursors
.insert((RecordKey::new("stale", "stale_record"), 0_usize), 7_usize);
inner.role_cursors.insert(
(
RecordKey::new("strict_sync_source", sync_id.clone()),
"Anchor".to_string(),
),
5_usize,
);
inner.role_cursors.insert(
(
RecordKey::new("stale", "stale_record"),
"Anchor".to_string(),
),
9_usize,
);
assert_eq!(inner.chunk_cursors.len(), 2);
assert_eq!(inner.role_cursors.len(), 2);
inner.sync_records_from_cache().unwrap();
assert_eq!(
inner.chunk_cursors.len(),
1,
"chunk_cursors should keep valid entry and drop stale one"
);
assert_eq!(
inner.role_cursors.len(),
1,
"role_cursors should keep valid entry and drop stale one"
);
assert!(inner.chunk_cursors.contains_key(&(
RecordKey::new("strict_sync_source", sync_id.clone()),
0_usize
)));
assert!(inner.role_cursors.contains_key(&(
RecordKey::new("strict_sync_source", sync_id.clone()),
"Anchor".to_string()
)));
assert!(
!inner
.chunk_cursors
.contains_key(&(RecordKey::new("stale", "stale_record"), 0_usize))
);
assert!(!inner.role_cursors.contains_key(&(
RecordKey::new("stale", "stale_record"),
"Anchor".to_string()
)));
}
#[test]
fn wrong_publication_date_falls_back_within_same_split() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let config = SamplerConfig {
seed: 7,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 37).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let anchor_ids = vec![
find_id(SplitLabel::Train, "wpd_anchor_train"),
find_id(SplitLabel::Validation, "wpd_anchor_val"),
find_id(SplitLabel::Test, "wpd_anchor_test"),
];
let other_ids = [
find_id(SplitLabel::Train, "wpd_other_train"),
find_id(SplitLabel::Validation, "wpd_other_val"),
find_id(SplitLabel::Test, "wpd_other_test"),
];
let anchor_records: Vec<DataRecord> = anchor_ids
.iter()
.enumerate()
.map(|(i, id)| trader_record(id, "2025-01-01", &format!("Anchor {i}"), "Body"))
.collect();
let other_records: Vec<DataRecord> = other_ids
.iter()
.enumerate()
.map(|(i, id)| trader_record(id, "2025-01-01", &format!("Other {i}"), "Body"))
.collect();
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", anchor_records)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"other",
other_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let mut seen_splits = std::collections::HashSet::new();
for anchor_id in anchor_ids {
let anchor = inner
.records
.get(&RecordKey::new("tt", anchor_id))
.cloned()
.expect("anchor");
let (negative, _fallback) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongPublicationDate, None)
.expect("negative");
assert_ne!(negative.id, anchor.id);
let anchor_label = inner
.split_store
.label_for(&RecordKey::from(anchor))
.unwrap();
let negative_label = inner
.split_store
.label_for(&RecordKey::from(negative))
.unwrap();
seen_splits.insert(anchor_label);
assert_eq!(negative_label, anchor_label);
}
assert_eq!(seen_splits.len(), 3);
}
#[test]
fn qa_mismatch_falls_back_within_same_split() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let config = SamplerConfig {
seed: 11,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 53).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("qa", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let anchor_ids = vec![
find_id(SplitLabel::Train, "qam_anchor_train"),
find_id(SplitLabel::Validation, "qam_anchor_val"),
find_id(SplitLabel::Test, "qam_anchor_test"),
];
let other_ids = [
find_id(SplitLabel::Train, "qam_other_train"),
find_id(SplitLabel::Validation, "qam_other_val"),
find_id(SplitLabel::Test, "qam_other_test"),
];
let qa_records: Vec<DataRecord> = anchor_ids
.iter()
.enumerate()
.map(|(i, id)| {
qa_pair_record(
id,
&format!("What is item {i}?"),
&format!("Item {i} answer."),
)
})
.collect();
let other_records: Vec<DataRecord> = other_ids
.iter()
.enumerate()
.map(|(i, id)| trader_record(id, "2025-01-02", &format!("Beta {i}"), "Body beta"))
.collect();
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("qa", qa_records)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"other",
other_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let mut seen_splits = std::collections::HashSet::new();
for anchor_id in anchor_ids {
let anchor = inner
.records
.get(&RecordKey::new("qa", anchor_id))
.cloned()
.expect("anchor");
let (negative, _fallback) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::QuestionAnswerMismatch, None)
.expect("negative");
assert_ne!(negative.id, anchor.id);
let anchor_label = inner
.split_store
.label_for(&RecordKey::from(anchor))
.unwrap();
let negative_label = inner
.split_store
.label_for(&RecordKey::from(negative))
.unwrap();
seen_splits.insert(anchor_label);
assert_eq!(negative_label, anchor_label);
}
assert_eq!(seen_splits.len(), 3);
}
#[test]
fn negative_selection_never_falls_back_across_splits() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 17).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..2000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let train_id = find_id(SplitLabel::Train, "neg_train");
let val_id = find_id(SplitLabel::Validation, "neg_val");
let test_id = find_id(SplitLabel::Test, "neg_test");
let config = SamplerConfig {
seed: 21,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let anchor = trader_record(&train_id, "2025-01-01", "Anchor", "Body A");
let other_val = trader_record(&val_id, "2025-01-02", "Other Val", "Body B");
let other_test = trader_record(&test_id, "2025-01-03", "Other Test", "Body C");
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"a",
vec![anchor.clone()],
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"b",
vec![other_val, other_test],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let selected =
inner.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, None);
assert!(
selected.is_none(),
"cross-split fallback must be disallowed when same-split candidates are unavailable"
);
}
#[test]
fn fallback_triplet_negative_never_matches_anchor() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 59).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let records = vec![
trader_record(
&find_id(SplitLabel::Train, "fallback_train_a"),
"2025-01-01",
"Train A",
"Body train a",
),
trader_record(
&find_id(SplitLabel::Train, "fallback_train_b"),
"2025-01-01",
"Train B",
"Body train b",
),
trader_record(
&find_id(SplitLabel::Validation, "fallback_val_a"),
"2025-01-01",
"Val A",
"Body val a",
),
trader_record(
&find_id(SplitLabel::Validation, "fallback_val_b"),
"2025-01-01",
"Val B",
"Body val b",
),
trader_record(
&find_id(SplitLabel::Test, "fallback_test_a"),
"2025-01-01",
"Test A",
"Body test a",
),
trader_record(
&find_id(SplitLabel::Test, "fallback_test_b"),
"2025-01-01",
"Test B",
"Body test b",
),
];
let mut config = SamplerConfig {
seed: 13,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "wrong_date".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongPublicationDate,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
config.allowed_splits = vec![SplitLabel::Train];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen_splits = std::collections::HashSet::new();
let mut saw_fallback = false;
for _ in 0..120 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
let anchor_label = sampler
.inner
.lock()
.unwrap()
.split_store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap();
let negative_label = sampler
.inner
.lock()
.unwrap()
.split_store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap();
seen_splits.insert(anchor_label);
assert_eq!(anchor_label, negative_label);
assert_ne!(triplet.anchor.record_id, triplet.negative.record_id);
assert_ne!(triplet.positive.record_id, triplet.negative.record_id);
if triplet.recipe.ends_with("_fallback_same_split") {
saw_fallback = true;
}
if seen_splits.len() == 3 && saw_fallback {
break;
}
}
assert_eq!(seen_splits.len(), 1);
assert!(saw_fallback, "expected fallback_same_split to occur");
}
#[test]
fn triplets_never_cross_split_boundaries() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 123).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..5000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let records = vec![
trader_record(
&find_id(SplitLabel::Train, "triplet_split_train_a"),
"2025-01-01",
"Train A",
"Body train a",
),
trader_record(
&find_id(SplitLabel::Train, "triplet_split_train_b"),
"2025-01-02",
"Train B",
"Body train b",
),
trader_record(
&find_id(SplitLabel::Validation, "triplet_split_val_a"),
"2025-01-03",
"Val A",
"Body val a",
),
trader_record(
&find_id(SplitLabel::Validation, "triplet_split_val_b"),
"2025-01-04",
"Val B",
"Body val b",
),
trader_record(
&find_id(SplitLabel::Test, "triplet_split_test_a"),
"2025-01-05",
"Test A",
"Body test a",
),
trader_record(
&find_id(SplitLabel::Test, "triplet_split_test_b"),
"2025-01-06",
"Test B",
"Body test b",
),
];
let mut config = base_config();
config.seed = 777;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "split_isolation_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("split_iso", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
for _ in 0..40 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
for triplet in batch.triplets {
let anchor = store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap();
let positive = store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap();
let negative = store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap();
assert_eq!(anchor, positive, "anchor and positive must share split");
assert_eq!(anchor, negative, "negative must stay in anchor split");
}
}
}
#[test]
fn split_specific_batch_apis_return_exact_size_and_requested_split_only() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 333).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..10000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let mut records = Vec::new();
for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
for idx in 0..6 {
let id = find_id(split_label, &format!("split_api_{split_label:?}_{idx}"));
records.push(trader_record(
&id,
"2025-01-01",
&format!("{split_label:?} {idx}"),
&format!("{split_label:?} body {idx}"),
));
}
}
let mut config = base_config();
config.seed = 444;
config.batch_size = 2;
config.allowed_splits = vec![SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test];
config.recipes = vec![TripletRecipe {
name: "split_api_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "split_api_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("split_api", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
for requested_split in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
let pair_batch = sampler.next_pair_batch_for_split(requested_split).unwrap();
assert_eq!(pair_batch.pairs.len(), 2);
for pair in &pair_batch.pairs {
assert_eq!(
store.label_for(&RecordKey::of_chunk(&pair.anchor)).unwrap(),
requested_split
);
assert_eq!(
store
.label_for(&RecordKey::of_chunk(&pair.positive))
.unwrap(),
requested_split
);
}
let text_batch = sampler.next_text_batch_for_split(requested_split).unwrap();
assert_eq!(text_batch.samples.len(), 2);
for sample in &text_batch.samples {
assert_eq!(
store
.label_for(&RecordKey::of_chunk(&sample.chunk))
.unwrap(),
requested_split
);
}
let triplet_batch = sampler
.next_triplet_batch_for_split(requested_split)
.unwrap();
assert_eq!(triplet_batch.triplets.len(), 2);
for triplet in &triplet_batch.triplets {
assert_eq!(
store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap(),
requested_split
);
assert_eq!(
store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap(),
requested_split
);
assert_eq!(
store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap(),
requested_split
);
}
}
}
#[test]
fn split_specific_triplet_api_keeps_anchor_positive_negative_in_same_split() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let store = Arc::new(DeterministicSplitStore::new(split, 445).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..10000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let mut records = Vec::new();
for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
for idx in 0..8 {
let id = find_id(
split_label,
&format!("split_triplet_iso_{split_label:?}_{idx}"),
);
records.push(trader_record(
&id,
"2025-01-01",
&format!("{split_label:?} {idx}"),
&format!("{split_label:?} body {idx}"),
));
}
}
let mut config = base_config();
config.seed = 446;
config.batch_size = 3;
config.allowed_splits = vec![SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test];
config.recipes = vec![TripletRecipe {
name: "split_triplet_only".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"split_triplet_iso",
records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
for requested_split in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
let batch = sampler
.next_triplet_batch_for_split(requested_split)
.unwrap();
assert_eq!(batch.triplets.len(), 3);
for triplet in &batch.triplets {
let anchor = store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap();
let positive = store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap();
let negative = store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap();
assert_eq!(anchor, requested_split);
assert_eq!(positive, requested_split);
assert_eq!(negative, requested_split);
assert_eq!(anchor, positive);
assert_eq!(anchor, negative);
}
}
}
#[test]
fn split_specific_batch_apis_reject_disallowed_splits() {
let mut config = base_config();
config.allowed_splits = vec![SplitLabel::Train];
let split = config.split;
let store = Arc::new(DeterministicSplitStore::new(split, 999).unwrap());
let sampler = TripletSampler::new(config, store);
let pair_err = sampler
.next_pair_batch_for_split(SplitLabel::Validation)
.unwrap_err();
assert!(matches!(
pair_err,
SamplerError::Configuration(ref msg) if msg.contains("not in allowed_splits")
));
let text_err = sampler
.next_text_batch_for_split(SplitLabel::Validation)
.unwrap_err();
assert!(matches!(
text_err,
SamplerError::Configuration(ref msg) if msg.contains("not in allowed_splits")
));
let triplet_err = sampler
.next_triplet_batch_for_split(SplitLabel::Validation)
.unwrap_err();
assert!(matches!(
triplet_err,
SamplerError::Configuration(ref msg) if msg.contains("not in allowed_splits")
));
}
#[test]
fn triplet_sampling_produces_anchor_positive_and_negative() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 6,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "tt_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 43).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let article_a = find_train_id("article_a");
let article_b = find_train_id("article_b");
let records = vec![
trader_record(&article_a, "2025-01-01", "Alpha", "Body alpha"),
trader_record(&article_b, "2025-01-02", "Beta", "Body beta"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 1);
let triplet = &batch.triplets[0];
assert_ne!(triplet.anchor.record_id, triplet.negative.record_id);
assert_eq!(triplet.anchor.record_id, triplet.positive.record_id);
assert!(triplet.instruction.is_none());
}
#[test]
fn refresh_limit_caps_records_per_source() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.split = split;
config.batch_size = 3;
config.ingestion_max_records = 3;
let store = Arc::new(DeterministicSplitStore::new(split, 37).unwrap());
let base = Utc::now() - Duration::seconds(60);
let ids: Vec<String> = (0u32..)
.filter_map(|i| {
let id = format!("record_{i}");
(store.label_for(&RecordKey::new("unit", &id)) == Some(SplitLabel::Train)).then_some(id)
})
.take(10)
.collect();
let records: Vec<DataRecord> = ids
.iter()
.enumerate()
.map(|(idx, id)| record_with_offset(id, base, idx as i64))
.collect();
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
assert_eq!(sampler.inner.lock().unwrap().records.len(), 3);
}
#[test]
fn triplet_sampling_cycles_recipes_over_time() {
let split = SplitRatios::default();
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![
TripletRecipe {
name: "recipe_a".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
TripletRecipe {
name: "recipe_b".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
];
config.text_recipes = Vec::new();
let store = Arc::new(DeterministicSplitStore::new(split, 11).unwrap());
let sampler = TripletSampler::new(config, store);
let records = vec![
trader_record("src::cycle_a", "2025-01-01", "Cycle A", "Body cycle a"),
trader_record("src::cycle_b", "2025-01-02", "Cycle B", "Body cycle b"),
trader_record("src::cycle_c", "2025-01-03", "Cycle C", "Body cycle c"),
];
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
let mut seen = std::collections::HashSet::new();
for _ in 0..10 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
seen.insert(batch.triplets[0].recipe.clone());
if seen.len() == 2 {
break;
}
}
assert!(seen.contains("recipe_a"));
assert!(seen.contains("recipe_b"));
}
#[test]
fn triplet_batch_dedupes_identical_triplets() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "dedupe_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let dedupe_a = find_train_id("dedupe_a");
let dedupe_b = find_train_id("dedupe_b");
let sampler = TripletSampler::new(config, store);
let records = vec![
trader_record(&dedupe_a, "2025-01-01", "Dedupe A", "Body A"),
trader_record(&dedupe_b, "2025-01-02", "Dedupe B", "Body B"),
];
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let mut seen = std::collections::HashSet::new();
for triplet in &batch.triplets {
let key = (
triplet.anchor.record_id.clone(),
triplet.positive.record_id.clone(),
triplet.negative.record_id.clone(),
);
assert!(seen.insert(key), "triplet should be unique within batch");
}
}
#[test]
fn text_batch_dedupes_identical_chunks() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.text_recipes = vec![TextRecipe {
name: "context_only".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 91).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let dedupe_a = find_train_id("text_dedupe_a");
let dedupe_b = find_train_id("text_dedupe_b");
let sampler = TripletSampler::new(config, store);
let records = vec![
trader_record(&dedupe_a, "2025-01-01", "Dedupe A", "Body A"),
trader_record(&dedupe_b, "2025-01-02", "Dedupe B", "Body B"),
];
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
let mut seen = std::collections::HashSet::new();
for sample in &batch.samples {
let key = triplet_chunk_key(&sample.chunk);
assert!(
seen.insert(key),
"text sample should be unique within batch"
);
}
}
#[test]
fn text_batch_prevents_duplicate_text_per_record_from_text_columns() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: true,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 91).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let r1 = find_train_id("tc_rec");
let r2 = find_train_id("tc_other_a");
let r3 = find_train_id("tc_other_b");
let r4 = find_train_id("tc_other_c");
let sampler = TripletSampler::new(config, store);
let records = vec![
text_columns_record(&r1, "Content A identical in both sections."),
text_columns_record(&r2, "Content B identical in both sections."),
text_columns_record(&r3, "Content C identical in both sections."),
text_columns_record(&r4, "Content D identical in both sections."),
];
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(
batch.samples.len(),
4,
"batch should be full with 4 records"
);
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
for sample in &batch.samples {
let dedup_key = text_dedup_key(&sample.chunk);
assert!(
seen.insert(dedup_key),
"Anchor and Context sections of the same record must not produce duplicate text content in batch",
);
}
}
#[test]
fn text_batch_prevents_repeat_across_batches() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 1;
config.ingestion_max_records = 10;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = Vec::new();
config.text_recipes = vec![TextRecipe {
name: "anchor_text".into(),
selector: Selector::Role(SectionRole::Anchor),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = (0..10)
.map(|i| {
let mut r = sample_record();
r.id = format!("consume_rec_{}", i);
r.sections[0].text = format!("Content {}", i);
r.sections[0].sentences = vec![format!("Content {}", i)];
r
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for i in 0..10 {
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 1, "batch {} should have 1 sample", i);
let rid = batch.samples[0].chunk.record_id.clone();
assert!(
seen.insert(rid.clone()),
"record {} was sampled again on batch {} — consumed_text should have blocked it",
rid,
i,
);
}
}
#[test]
fn emitted_text_hashes_allows_resample_after_cache_refresh() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 1;
config.ingestion_max_records = 2;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = Vec::new();
config.text_recipes = vec![TextRecipe {
name: "anchor_text".into(),
selector: Selector::Role(SectionRole::Anchor),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = vec![("rec_a", "hello"), ("rec_b", "world"), ("rec_c", "hello")]
.into_iter()
.map(|(id, text)| {
let mut r = sample_record();
r.id = id.to_string();
r.sections[0].text = text.to_string();
r.sections[0].sentences = vec![text.to_string()];
r
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
let batch1 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch1.samples.len(), 1);
let id1 = batch1.samples[0].chunk.record_id.clone();
let batch2 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch2.samples.len(), 1);
let id2 = batch2.samples[0].chunk.record_id.clone();
assert_ne!(
id1, id2,
"batch 2 should be a different record from batch 1"
);
let batch3 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch3.samples.len(), 1);
let id3 = batch3.samples[0].chunk.record_id.clone();
assert!(
id3 == "rec_a" || id3 == "rec_c",
"batch 3 should sample a record that is still in the pool (rec_a or rec_c), got {id3}"
);
assert_eq!(
batch3.samples[0].chunk.text, "hello",
"the text 'hello' should be re-eligible after its original emitter (rec_a) was evicted"
);
{
let guard = sampler.inner.lock().unwrap();
assert!(
guard.records.len() <= 2,
"records contains {} entries; should be at most 2 after cache slide",
guard.records.len()
);
}
}
#[test]
fn emitted_text_hashes_blocks_repeats_across_batches_with_same_pool() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 1;
config.ingestion_max_records = 10;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = Vec::new();
config.text_recipes = vec![TextRecipe {
name: "anchor_text".into(),
selector: Selector::Role(SectionRole::Anchor),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = vec![("dup_rec_0", "Content A"), ("dup_rec_1", "Content B")]
.into_iter()
.map(|(id, text)| {
let mut r = sample_record();
r.id = id.to_string();
r.sections[0].text = text.to_string();
r.sections[0].sentences = vec![text.to_string()];
r
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
let batch1 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch1.samples.len(), 1);
let id1 = batch1.samples[0].chunk.record_id.clone();
let batch2 = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch2.samples.len(), 1);
let id2 = batch2.samples[0].chunk.record_id.clone();
assert_ne!(id1, id2, "batch 1 and 2 should be different records");
let err = sampler.next_text_batch(SplitLabel::Train).expect_err(
"batch 3 should be Exhausted — only 2 unique texts in pool, both already emitted",
);
assert!(
matches!(err, SamplerError::Exhausted(_)),
"expected Exhausted, got: {err:?}"
);
}
#[test]
fn text_dedup_isolation_does_not_affect_triplet_or_pair_batches() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 2;
config.ingestion_max_records = 10;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "iso_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "iso_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = vec![
trader_record("iso_rec_a", "2025-06-01", "Title A", "Body A content here."),
trader_record("iso_rec_b", "2025-06-01", "Title B", "Body B content here."),
trader_record("iso_rec_c", "2025-06-01", "Title C", "Body C content here."),
];
sampler
.register_source(Box::new(InMemorySource::from_records("iso", records)))
.unwrap();
let text_batch1 = sampler
.next_text_batch(SplitLabel::Train)
.expect("first text batch should succeed");
assert_eq!(text_batch1.samples.len(), 2);
let text_batch2 = sampler
.next_text_batch(SplitLabel::Train)
.expect("second text batch should succeed");
assert_eq!(text_batch2.samples.len(), 2);
let text_err = sampler
.next_text_batch(SplitLabel::Train)
.expect_err("third text batch should be Exhausted");
assert!(
matches!(text_err, SamplerError::Exhausted(_)),
"expected text Exhausted, got: {text_err:?}"
);
let triplet_batch = sampler
.next_triplet_batch(SplitLabel::Train)
.expect("triplet batch must still succeed after text exhaustion");
assert_eq!(
triplet_batch.triplets.len(),
2,
"triplet batch should return full batch_size"
);
let mut triplet_seen: std::collections::HashSet<(String, String, String)> =
std::collections::HashSet::new();
for t in &triplet_batch.triplets {
let key = (
t.anchor.record_id.clone(),
t.positive.record_id.clone(),
t.negative.record_id.clone(),
);
assert!(
triplet_seen.insert(key),
"triplet batch should not contain duplicate triplets"
);
}
let pair_batch = sampler
.next_pair_batch(SplitLabel::Train)
.expect("pair batch must still succeed after text exhaustion");
assert_eq!(
pair_batch.pairs.len(),
2,
"pair batch should return full batch_size"
);
}
#[test]
fn text_sampling_cycles_recipes_over_time() {
let split = SplitRatios::default();
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = Vec::new();
config.text_recipes = vec![
TextRecipe {
name: "text_a".into(),
selector: Selector::Role(SectionRole::Anchor),
weight: 1.0,
instruction: None,
},
TextRecipe {
name: "text_b".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
},
];
let store = Arc::new(DeterministicSplitStore::new(split, 11).unwrap());
let sampler = TripletSampler::new(config, store);
let mut rec_a = sample_record();
rec_a.id = "record_a".into();
let mut rec_b = sample_record();
rec_b.id = "record_b".into();
sampler
.register_source(Box::new(InMemorySource::from_records(
"unit",
vec![rec_a, rec_b],
)))
.unwrap();
let mut seen = std::collections::HashSet::new();
for _ in 0..10 {
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
seen.insert(batch.samples[0].recipe.clone());
if seen.len() == 2 {
break;
}
}
assert!(seen.contains("text_a"));
assert!(seen.contains("text_b"));
}
#[test]
fn epoch_sampling_visits_each_record_before_repeat() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = SamplerConfig {
seed: 101,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "epoch_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
config.allowed_splits = vec![SplitLabel::Train];
let store = Arc::new(DeterministicSplitStore::new(split, 59).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let epoch_a = find_train_id("epoch_a");
let epoch_b = find_train_id("epoch_b");
let epoch_c = find_train_id("epoch_c");
let records = vec![
trader_record(&epoch_a, "2025-01-01", "Epoch Alpha", "Body alpha"),
trader_record(&epoch_b, "2025-01-02", "Epoch Beta", "Body beta"),
trader_record(&epoch_c, "2025-01-03", "Epoch Gamma", "Body gamma"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut anchors = Vec::new();
for _ in 0..10 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
anchors.extend(batch.triplets.iter().map(|t| t.anchor.record_id.clone()));
}
let mut dedup = anchors.clone();
dedup.sort();
dedup.dedup();
assert_eq!(dedup.len(), 3, "all records should appear over time");
}
#[test]
fn epoch_sampling_persists_between_runs() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("epoch_store");
let build_config = || {
let mut cfg = SamplerConfig {
seed: 202,
batch_size: 3,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "persist_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
cfg.allowed_splits = vec![SplitLabel::Train];
cfg
};
let probe_store = DeterministicSplitStore::new(split, 73).unwrap();
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(probe_store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let persist_a = find_train_id("persist_a");
let persist_b = find_train_id("persist_b");
let persist_c = find_train_id("persist_c");
let dataset = vec![
trader_record(&persist_a, "2025-02-01", "Persist A", "Body a"),
trader_record(&persist_b, "2025-02-02", "Persist B", "Body b"),
trader_record(&persist_c, "2025-02-03", "Persist C", "Body c"),
];
let first_anchor = {
let store = Arc::new(FileSplitStore::open(&store_path, split, 73).unwrap());
let sampler = TripletSampler::new(build_config(), store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
dataset.clone(),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let anchor = sampler
.next_triplet_batch(SplitLabel::Train)
.unwrap()
.triplets[0]
.anchor
.record_id
.clone();
sampler.save_sampler_state(None).unwrap();
anchor
};
let store = Arc::new(FileSplitStore::open(&store_path, split, 73).unwrap());
let sampler = TripletSampler::new(build_config(), store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
dataset.clone(),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut anchors = Vec::new();
for _ in 0..5 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
anchors.extend(batch.triplets.iter().map(|t| t.anchor.record_id.clone()));
}
sampler.save_sampler_state(None).unwrap();
assert!(
anchors.contains(&first_anchor),
"previously consumed records may reappear with streaming paging"
);
}
#[test]
fn epoch_sampling_handles_new_records_after_restart() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("epoch_store_new_records");
let mut base_config = SamplerConfig {
seed: 404,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "persist_triplet_new".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
base_config.allowed_splits = vec![SplitLabel::Train];
let probe_store = DeterministicSplitStore::new(split, 111).unwrap();
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(probe_store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let restart_a = find_train_id("restart_a");
let restart_b = find_train_id("restart_b");
let restart_c = find_train_id("restart_c");
let initial_records = vec![
trader_record(&restart_a, "2025-03-01", "Restart Alpha", "Body alpha"),
trader_record(&restart_b, "2025-03-02", "Restart Beta", "Body beta"),
];
let _first_anchor = {
let store = Arc::new(FileSplitStore::open(&store_path, split, 111).unwrap());
let sampler = TripletSampler::new(base_config.clone(), store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
initial_records.clone(),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let anchor = batch.triplets[0].anchor.record_id.clone();
sampler.save_sampler_state(None).unwrap();
anchor
};
let mut expanded_records = initial_records.clone();
expanded_records.push(trader_record(
&restart_c,
"2025-03-03",
"Restart Gamma",
"Body gamma",
));
let store = Arc::new(FileSplitStore::open(&store_path, split, 111).unwrap());
let sampler = TripletSampler::new(base_config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"tt",
expanded_records.clone(),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut seen = std::collections::HashSet::new();
let max_draws = expanded_records.len() * 3;
for _ in 0..max_draws {
if let Ok(batch) = sampler.next_triplet_batch(SplitLabel::Train) {
for triplet in batch.triplets {
seen.insert(triplet.anchor.record_id);
}
}
}
assert!(seen.contains(&restart_c));
}
#[test]
fn epoch_is_propagated_to_ingestion_on_resume() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("epoch_propagation_store");
let build_config = || SamplerConfig {
seed: 77,
batch_size: 2,
chunking: ChunkingStrategy::default(),
recipes: vec![TripletRecipe {
name: "ep_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
split,
allowed_splits: vec![SplitLabel::Train],
..SamplerConfig::default()
};
let records: Vec<DataRecord> = (0..4)
.map(|i| {
trader_record(
&format!("src::ep_record_{i:02}"),
"2025-06-01",
&format!("Title {i}"),
&format!("Body {i}"),
)
})
.collect();
let persisted_epoch = {
let store = Arc::new(FileSplitStore::open(&store_path, split, 11).unwrap());
let sampler = TripletSampler::new(build_config(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src",
records.clone(),
)))
.unwrap();
for _ in 0..8 {
let _ = sampler.next_triplet_batch(SplitLabel::Train);
}
sampler.save_sampler_state(None).unwrap();
let state = store.load_sampler_state().unwrap().unwrap();
state.epoch
};
assert!(
persisted_epoch > 0,
"epoch should have advanced; got {persisted_epoch}"
);
let store = Arc::new(FileSplitStore::open(&store_path, split, 11).unwrap());
let sampler = TripletSampler::new(build_config(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src",
records.clone(),
)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
assert_eq!(
inner.ingestion.epoch(),
persisted_epoch,
"ingestion epoch must match persisted epoch after resume"
);
assert_eq!(
inner.epoch, persisted_epoch,
"sampler epoch must match persisted epoch after resume"
);
}
}
#[test]
fn resume_restores_epoch_and_epoch_step_together() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("epoch_step_resume_store");
let config = SamplerConfig {
seed: 42,
split,
allowed_splits: vec![SplitLabel::Train],
recipes: vec![TripletRecipe {
name: "ep_step".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
..SamplerConfig::default()
};
let records: Vec<DataRecord> = (0..6)
.map(|i| {
trader_record(
&format!("ep_step::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body {i} with enough context for sampling"),
)
})
.collect();
let (saved_epoch, saved_step) = {
let store = Arc::new(FileSplitStore::open(&store_path, split, 42).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src",
records.clone(),
)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal_for_split(SplitLabel::Train).unwrap();
}
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.set_epoch(1).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
let (pre_save_step, pre_save_epoch) = {
let inner = sampler.inner.lock().unwrap();
(inner.ingestion.epoch_step(), inner.epoch)
};
sampler.save_sampler_state(None).unwrap();
let persisted = store.load_sampler_state().unwrap().unwrap();
let persisted_step = persisted.epoch_step;
assert_eq!(
persisted.epoch, pre_save_epoch,
"persisted epoch must match in-memory value"
);
assert_eq!(
persisted_step, pre_save_step,
"persisted step must match in-memory epoch_step"
);
assert!(persisted.epoch > 0, "epoch must have advanced past 0");
assert!(persisted_step > 0, "epoch_step must have advanced past 0");
(persisted.epoch, persisted_step)
};
{
let store = Arc::new(FileSplitStore::open(&store_path, split, 42).unwrap());
let persisted = store.load_sampler_state().unwrap().unwrap();
assert_eq!(
persisted.epoch, saved_epoch,
"persisted epoch must match saved value"
);
let persisted_step = persisted.epoch_step;
assert_eq!(
persisted_step, saved_step,
"persisted step must match saved value"
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
assert_eq!(
inner.epoch, saved_epoch,
"sampler epoch must match saved value after resume"
);
assert_eq!(
inner.ingestion.epoch(),
saved_epoch,
"ingestion epoch must match saved value after resume"
);
}
let _ = sampler.next_pair_batch(SplitLabel::Train);
let post_ingest_step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(
post_ingest_step,
saved_step + 1,
"first post-resume batch call must advance epoch_step from
{saved_step} to {saved_step}+1, indicating it was restored
rather than reset to 0"
);
}
}
#[test]
fn oversampling_advances_cursors_on_large_records() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.seed = 1;
config.batch_size = 3;
config.text_recipes = vec![TextRecipe {
name: "context".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
config.chunking = ChunkingStrategy {
max_window_tokens: 1,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 1).unwrap());
let sampler = TripletSampler::new(config, store);
let multi_chunk_record = DataRecord {
id: "long_record".into(),
source: "small".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "One Two Three".into(),
sentences: vec!["One Two Three".into()],
token_count: WhitespaceTokenizer.token_count("One Two Three"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut large_source_records = Vec::new();
for char in ['A', 'B', 'C'] {
large_source_records.push(DataRecord {
id: format!("short_{}", char),
source: "large".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: char.to_string(),
sentences: vec![char.to_string()],
token_count: 0,
embedding: None,
}],
meta_prefix: None,
label: None,
});
}
sampler
.register_source(Box::new(InMemorySource::from_records(
"small",
vec![multi_chunk_record],
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"large",
large_source_records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut small_samples = Vec::new();
let mut large_samples = Vec::new();
while let Ok(batch) = sampler.next_text_batch(SplitLabel::Train) {
for sample in batch.samples {
let text = sample.chunk.text;
if sample.chunk.record_id == "long_record" {
small_samples.push(text);
} else {
large_samples.push(text);
}
}
}
assert!(
small_samples.len() >= 3,
"Should sample small source multiple times"
);
assert!(
large_samples.len() >= 3,
"Should sample large source multiple times"
);
small_samples.sort();
assert!(small_samples.contains(&"One".to_string()));
assert!(small_samples.contains(&"Two".to_string()));
assert!(small_samples.contains(&"Three".to_string()));
large_samples.sort();
large_samples.dedup();
assert!(
large_samples.len() >= 2,
"large source should contribute multiple distinct samples"
);
}
#[test]
fn text_sampling_balances_sources_without_epoch_tracker() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 2;
config.split = split;
config.allowed_splits = vec![SplitLabel::Train];
config.text_recipes = vec![TextRecipe {
name: "anchors".into(),
selector: Selector::Role(SectionRole::Anchor),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 73).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("qa_factual_source", &id))
== Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let factual_id = find_train_id("factual_record");
let opinion_id = find_train_id("opinionated_record");
let sampler = TripletSampler::new(config, store);
let mut factual = sample_record();
factual.id = factual_id.clone();
factual.source = "qa_factual".into();
let mut opinion = sample_record();
opinion.id = opinion_id.clone();
opinion.source = "qa_opinionated".into();
opinion.sections[0].text = "Opinion title".into();
opinion.sections[0].sentences = vec!["Opinion title".into()];
sampler
.register_source(Box::new(InMemorySource::from_records(
"qa_factual_source",
vec![factual.clone()],
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"qa_opinion_source",
vec![opinion.clone()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 2);
let mut ids: Vec<_> = batch
.samples
.iter()
.map(|sample| sample.chunk.record_id.clone())
.collect();
ids.sort();
let mut expected = vec![factual_id.clone(), opinion_id.clone()];
expected.sort();
assert_eq!(ids, expected);
}
#[test]
fn chunk_sampling_respects_split_boundaries() {
let split = SplitRatios {
train: 0.5,
validation: 0.5,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 88).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..2000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("tt", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let train_id = find_id(SplitLabel::Train, "train_candidate");
let val_id = find_id(SplitLabel::Validation, "val_candidate");
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.text_recipes = vec![TextRecipe {
name: "context".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
config.chunking = ChunkingStrategy {
max_window_tokens: 1,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let sampler = TripletSampler::new(config, store);
let mut train_record = trader_record(&train_id, "2025-01-01", "Train Title", "One Two");
let mut val_record = trader_record(&val_id, "2025-01-02", "Val Title", "Alpha Beta");
train_record.source = "split_test".into();
val_record.source = "split_test".into();
sampler
.register_source(Box::new(InMemorySource::from_records(
"split_test",
vec![train_record, val_record],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
while let Ok(batch) = sampler.next_text_batch(SplitLabel::Train) {
let sample = &batch.samples[0];
let label = sampler
.inner
.lock()
.unwrap()
.split_store
.label_for(&RecordKey::of_chunk(&sample.chunk))
.unwrap();
assert_eq!(label, SplitLabel::Train);
}
}
#[test]
fn adds_dynamic_chunk_pair_recipe_for_long_section_sources() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let recipes = vec![TripletRecipe {
name: "base_title_context".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let now = Utc::now();
let records = vec![
DataRecord {
id: "r1".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline one".into(),
sentences: vec!["Headline one".into()],
token_count: WhitespaceTokenizer.token_count("Headline one"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
DataRecord {
id: "r2".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline two".into(),
sentences: vec!["Headline two".into()],
token_count: WhitespaceTokenizer.token_count("Headline two"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta".into(),
sentences: vec!["alpha beta gamma delta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta gamma delta"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
];
let store = Arc::new(DeterministicSplitStore::new(split, 117).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, recipes)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let effective = sampler
.inner
.lock()
.unwrap()
.triplet_recipes_for_source("recipe_source");
assert!(effective.iter().any(|recipe| {
recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
}));
}
#[test]
fn does_not_add_dynamic_chunk_pair_recipe_when_all_sections_fit_window() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 8,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let recipes = vec![TripletRecipe {
name: "base_title_context".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let now = Utc::now();
let store = Arc::new(DeterministicSplitStore::new(split, 118).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("recipe_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let short1_id = find_train_id("short1");
let short2_id = find_train_id("short2");
let records = vec![
DataRecord {
id: short1_id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline one".into(),
sentences: vec!["Headline one".into()],
token_count: WhitespaceTokenizer.token_count("Headline one"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two".into(),
sentences: vec!["one two".into()],
token_count: WhitespaceTokenizer.token_count("one two"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
DataRecord {
id: short2_id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline two".into(),
sentences: vec!["Headline two".into()],
token_count: WhitespaceTokenizer.token_count("Headline two"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta".into(),
sentences: vec!["alpha beta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, recipes)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let effective = sampler
.inner
.lock()
.unwrap()
.triplet_recipes_for_source("recipe_source");
assert!(effective.iter().all(|recipe| {
recipe.name.as_ref() != AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
}));
}
#[test]
fn adds_dynamic_chunk_pair_recipe_even_with_global_config_recipes() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
config.recipes = vec![TripletRecipe {
name: "global_anchor_context".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let now = Utc::now();
let records = vec![
DataRecord {
id: "cfg1".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline one".into(),
sentences: vec!["Headline one".into()],
token_count: WhitespaceTokenizer.token_count("Headline one"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
DataRecord {
id: "cfg2".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "Headline two".into(),
sentences: vec!["Headline two".into()],
token_count: WhitespaceTokenizer.token_count("Headline two"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta".into(),
sentences: vec!["alpha beta gamma delta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta gamma delta"),
embedding: None,
},
],
meta_prefix: None,
label: None,
},
];
let store = Arc::new(DeterministicSplitStore::new(split, 121).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, Vec::new())))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let effective = sampler
.inner
.lock()
.unwrap()
.triplet_recipes_for_source("recipe_source");
assert!(
effective
.iter()
.any(|recipe| recipe.name.as_ref() == "global_anchor_context")
);
assert!(effective.iter().any(|recipe| {
recipe.name.as_ref() == AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
}));
}
#[test]
fn auto_injected_recipe_uses_distinct_context_chunks_for_anchor_and_positive() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let now = Utc::now();
let store = Arc::new(DeterministicSplitStore::new(split, 119).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("recipe_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let long_anchor_id = find_train_id("long_anchor");
let other_neg_id = find_train_id("other_for_negative");
let records = vec![
DataRecord {
id: long_anchor_id.clone(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
DataRecord {
id: other_neg_id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "other".into(),
sentences: vec!["other".into()],
token_count: WhitespaceTokenizer.token_count("other"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, Vec::new())))
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 1);
let triplet = &batch.triplets[0];
assert_eq!(
triplet.recipe,
AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
);
assert_eq!(triplet.anchor.record_id, long_anchor_id);
assert_eq!(triplet.anchor.record_id, triplet.positive.record_id);
assert_ne!(
triplet_chunk_key(&triplet.anchor),
triplet_chunk_key(&triplet.positive)
);
let expected_a = "one two";
let expected_b = "three four";
let observed = [triplet.anchor.text.as_str(), triplet.positive.text.as_str()];
assert!(
observed.contains(&expected_a),
"expected one window '{expected_a}', got anchor='{}', positive='{}'",
triplet.anchor.text,
triplet.positive.text
);
assert!(
observed.contains(&expected_b),
"expected one window '{expected_b}', got anchor='{}', positive='{}'",
triplet.anchor.text,
triplet.positive.text
);
assert_ne!(
triplet.anchor.text, triplet.positive.text,
"anchor and positive should not use the same chunk text"
);
}
#[test]
fn auto_injected_recipe_never_uses_identical_anchor_and_positive_chunks() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let now = Utc::now();
let store = Arc::new(DeterministicSplitStore::new(split, 120).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("recipe_source", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let long1_id = find_train_id("long1");
let long2_id = find_train_id("long2");
let records = vec![
DataRecord {
id: long1_id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four".into(),
sentences: vec!["one two three four".into()],
token_count: WhitespaceTokenizer.token_count("one two three four"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
DataRecord {
id: long2_id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta".into(),
sentences: vec!["alpha beta gamma delta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta gamma delta"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
];
let store = Arc::new(DeterministicSplitStore::new(split, 120).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, Vec::new())))
.unwrap();
for _ in 0..32 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 1);
let triplet = &batch.triplets[0];
assert_eq!(
triplet.recipe,
AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
);
assert_eq!(triplet.anchor.record_id, triplet.positive.record_id);
assert_ne!(
triplet_chunk_key(&triplet.anchor),
triplet_chunk_key(&triplet.positive),
"anchor and positive chunk keys must differ; anchor='{}' positive='{}'",
triplet.anchor.text,
triplet.positive.text
);
assert_ne!(
triplet.anchor.text, triplet.positive.text,
"anchor and positive chunk text must differ"
);
}
}
#[test]
fn auto_injected_recipe_uses_window_chunks_for_anchor_and_positive() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.chunking = ChunkingStrategy {
max_window_tokens: 4,
overlap_tokens: vec![1],
summary_fallback_weight: 0.5,
summary_fallback_tokens: 2,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let now = Utc::now();
let records = vec![
DataRecord {
id: "long_w1".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five six seven eight nine ten".into(),
sentences: vec!["one two three four five six seven eight nine ten".into()],
token_count: WhitespaceTokenizer
.token_count("one two three four five six seven eight nine ten"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
DataRecord {
id: "long_w2".into(),
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta epsilon zeta eta theta iota kappa".into(),
sentences: vec!["alpha beta gamma delta epsilon zeta eta theta iota kappa".into()],
token_count: WhitespaceTokenizer
.token_count("alpha beta gamma delta epsilon zeta eta theta iota kappa"),
embedding: None,
}],
meta_prefix: None,
label: None,
},
];
let store = Arc::new(DeterministicSplitStore::new(split, 123).unwrap());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(RecipeSource::new(records, Vec::new())))
.unwrap();
for _ in 0..16 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
let triplet = &batch.triplets[0];
assert_eq!(
triplet.recipe,
AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
);
assert!(matches!(triplet.anchor.view, ChunkView::Window { .. }));
assert!(matches!(triplet.positive.view, ChunkView::Window { .. }));
assert_ne!(
triplet_chunk_key(&triplet.anchor),
triplet_chunk_key(&triplet.positive)
);
}
}
#[test]
fn auto_injected_recipe_keeps_all_components_in_requested_split() {
let split = SplitRatios {
train: 0.34,
validation: 0.33,
test: 0.33,
};
let mut config = base_config();
config.seed = 812;
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test];
config.recipes = Vec::new();
config.text_recipes = Vec::new();
config.chunking = ChunkingStrategy {
max_window_tokens: 2,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 1441).unwrap());
let find_id = |label: SplitLabel, prefix: &str| -> String {
for i in 0..20000 {
let id = format!("{prefix}_{i}");
if store.ensure(RecordKey::new("recipe_source", &id)).unwrap() == label {
return id;
}
}
panic!("unable to find id for {:?}", label);
};
let now = Utc::now();
let mut records = Vec::new();
for split_label in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
for idx in 0..2 {
let id = find_id(split_label, &format!("auto_split_{split_label:?}_{idx}"));
assert_eq!(
store
.label_for(&RecordKey::new("recipe_source", &id))
.unwrap(),
split_label
);
let ctx_text = format!("ctx {split_label:?} {idx} one two three four");
let token_count = WhitespaceTokenizer.token_count(&ctx_text);
records.push(DataRecord {
id,
source: "ignored_by_ingestion".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: ctx_text.clone(),
sentences: vec![ctx_text],
token_count,
embedding: None,
}],
meta_prefix: None,
label: None,
});
}
}
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(RecipeSource::new(records, Vec::new())))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
for requested_split in [SplitLabel::Train, SplitLabel::Validation, SplitLabel::Test] {
for _ in 0..8 {
let batch = sampler
.next_triplet_batch_for_split(requested_split)
.unwrap();
assert_eq!(batch.triplets.len(), 1);
let triplet = &batch.triplets[0];
assert_eq!(
triplet.recipe,
AUTO_INJECTED_LONG_SECTION_CHUNK_PAIR_RECIPE_NAME
);
let anchor_split = store
.label_for(&RecordKey::of_chunk(&triplet.anchor))
.unwrap();
let positive_split = store
.label_for(&RecordKey::of_chunk(&triplet.positive))
.unwrap();
let negative_split = store
.label_for(&RecordKey::of_chunk(&triplet.negative))
.unwrap();
assert_eq!(anchor_split, requested_split);
assert_eq!(positive_split, requested_split);
assert_eq!(negative_split, requested_split);
assert_eq!(triplet.anchor.record_id, triplet.positive.record_id);
assert_ne!(triplet.anchor.record_id, triplet.negative.record_id);
assert_ne!(
triplet_chunk_key(&triplet.anchor),
triplet_chunk_key(&triplet.positive)
);
}
}
}
#[test]
fn same_selector_triplet_returns_none_when_only_one_chunk_exists() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.chunking = ChunkingStrategy {
max_window_tokens: 8,
overlap_tokens: vec![0],
summary_fallback_weight: 0.0,
summary_fallback_tokens: 0,
chunk_weight_floor: 0.0,
..ChunkingStrategy::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 122).unwrap());
let mut inner = TripletSamplerInner::new(config, store);
let now = Utc::now();
let anchor = DataRecord {
id: "single_chunk_anchor".into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two".into(),
sentences: vec!["one two".into()],
token_count: WhitespaceTokenizer.token_count("one two"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let negative = DataRecord {
id: "single_chunk_negative".into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta".into(),
sentences: vec!["alpha beta".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
inner
.records
.insert(RecordKey::from(&anchor), Arc::new(anchor.clone()));
inner
.records
.insert(RecordKey::from(&negative), Arc::new(negative.clone()));
inner.rebuild_chunk_index();
let recipe = TripletRecipe {
name: "same_selector_context".into(),
anchor: Selector::Role(SectionRole::Context),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let triplet = inner.make_triplet_with_anchor_seeded(&recipe, &anchor);
assert!(triplet.is_none());
}
#[test]
fn sampler_allows_concurrent_batch_requests() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 9).unwrap());
let mut config = base_config();
config.seed = 7;
config.batch_size = 1;
config.ingestion_max_records = 8;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.text_recipes = vec![TextRecipe {
name: "concurrent_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let bodies = [
"concurrent_body_0",
"concurrent_body_1",
"concurrent_body_2",
"concurrent_body_3",
];
let mut body_idx = 0usize;
let records: Vec<DataRecord> = (0u32..)
.filter_map(|i| {
let id = format!("concurrent_{i}");
(store.label_for(&RecordKey::new("unit", &id)) == Some(SplitLabel::Train)).then(|| {
let mut r = sample_record();
r.id = id;
let text = bodies[body_idx];
body_idx += 1;
r.sections[1].text = text.into();
r.sections[1].sentences = vec![text.into()];
r
})
})
.take(4)
.collect();
let sampler = Arc::new(TripletSampler::new(config, store));
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
let handles: Vec<_> = (0..4)
.map(|_| {
let sampler = Arc::clone(&sampler);
thread::spawn(move || sampler.next_text_batch(SplitLabel::Train))
})
.collect();
for handle in handles {
let batch = handle.join().unwrap().unwrap();
assert_eq!(batch.samples.len(), 1);
}
}
struct DelegatingSampler;
impl Sampler for DelegatingSampler {
fn next_pair_batch_with_weights(
&self,
_split: SplitLabel,
weights: &HashMap<SourceId, f32>,
) -> Result<SampleBatch, SamplerError> {
assert!(weights.is_empty());
Ok(SampleBatch { pairs: Vec::new() })
}
fn next_text_batch_with_weights(
&self,
_split: SplitLabel,
weights: &HashMap<SourceId, f32>,
) -> Result<TextBatch, SamplerError> {
assert!(weights.is_empty());
Ok(TextBatch {
samples: Vec::new(),
})
}
fn next_triplet_batch_with_weights(
&self,
_split: SplitLabel,
weights: &HashMap<SourceId, f32>,
) -> Result<TripletBatch, SamplerError> {
assert!(weights.is_empty());
Ok(TripletBatch {
triplets: Vec::new(),
})
}
}
#[test]
fn sampler_trait_default_methods_delegate_to_weighted_variants() {
let sampler = DelegatingSampler;
assert!(
sampler
.next_pair_batch(SplitLabel::Train)
.unwrap()
.is_empty()
);
assert!(
sampler
.next_text_batch(SplitLabel::Train)
.unwrap()
.is_empty()
);
assert!(
sampler
.next_triplet_batch(SplitLabel::Train)
.unwrap()
.is_empty()
);
}
#[test]
fn batch_prefetcher_reports_stopped_when_receiver_missing() {
let prefetcher = BatchPrefetcher::<TripletBatch> {
receiver: None,
handle: None,
stats: Arc::new(PrefetcherStats::default()),
};
let err = prefetcher.next().unwrap_err();
assert!(matches!(
err,
SamplerError::SourceUnavailable { ref reason, .. } if reason == PREFETCHER_STOPPED_REASON
));
}
#[test]
fn batch_prefetcher_reports_stopped_when_worker_panics() {
let prefetcher = BatchPrefetcher::<TripletBatch>::new(1, || panic!("prefetcher panic path"));
let err = prefetcher.next().unwrap_err();
assert!(matches!(
err,
SamplerError::SourceUnavailable { ref source_id, .. } if source_id == PREFETCHER_SOURCE_ID
));
}
fn sampler_for_prefetch_tests() -> Arc<TripletSampler<DeterministicSplitStore>> {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 501).unwrap());
let mut config = base_config();
config.split = split;
config.allowed_splits = vec![SplitLabel::Train];
config.batch_size = 1;
config.ingestion_max_records = 16;
config.recipes = vec![TripletRecipe {
name: "prefetch_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "prefetch_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let sampler = Arc::new(TripletSampler::new(config, store.clone()));
let records: Vec<DataRecord> = (0u32..)
.filter_map(|i| {
let id = format!("prefetch_{i}");
(store.label_for(&RecordKey::new("prefetch_source", &id)) == Some(SplitLabel::Train))
.then(|| {
let mut record = trader_record(
&id,
"2025-01-01",
&format!("Prefetch title {i}"),
&format!("Prefetch body {i}"),
);
record.source = "prefetch_source".to_string();
record
})
})
.take(4)
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records(
"prefetch_source",
records,
)))
.unwrap();
sampler
}
#[test]
fn prefetch_public_apis_produce_batches_and_stats() {
fn wait_for_count<T: Send + 'static>(prefetcher: &BatchPrefetcher<T>, minimum: usize) {
let start = std::time::Instant::now();
while prefetcher.produced_count() < minimum
&& start.elapsed() < StdDuration::from_millis(250)
{
std::thread::sleep(StdDuration::from_millis(5));
}
}
let sampler = sampler_for_prefetch_tests();
let triplet = Arc::clone(&sampler).prefetch_triplet_batches(SplitLabel::Train, 1);
let pair = Arc::clone(&sampler).prefetch_pair_batches(SplitLabel::Train, 1);
let text = Arc::clone(&sampler).prefetch_text_batches(SplitLabel::Train, 1);
let triplet_batch = triplet.next().unwrap();
assert_eq!(triplet_batch.triplets.len(), 1);
wait_for_count(&triplet, 1);
assert!(triplet.produced_count() >= 1);
let pair_batch = pair.next().unwrap();
assert_eq!(pair_batch.pairs.len(), 1);
wait_for_count(&pair, 1);
assert!(pair.produced_count() >= 1);
let text_batch = text.next().unwrap();
assert_eq!(text_batch.samples.len(), 1);
wait_for_count(&text, 1);
assert!(text.produced_count() >= 1);
}
#[test]
fn prefetch_weighted_public_apis_produce_batches() {
let sampler = sampler_for_prefetch_tests();
let mut weights = HashMap::new();
weights.insert("prefetch_source".to_string(), 1.0);
let triplet = Arc::clone(&sampler).prefetch_triplet_batches_with_weights(
SplitLabel::Train,
1,
weights.clone(),
);
let pair = Arc::clone(&sampler).prefetch_pair_batches_with_weights(
SplitLabel::Train,
1,
weights.clone(),
);
let text =
Arc::clone(&sampler).prefetch_text_batches_with_weights(SplitLabel::Train, 1, weights);
assert_eq!(triplet.next().unwrap().triplets.len(), 1);
assert_eq!(pair.next().unwrap().pairs.len(), 1);
assert_eq!(text.next().unwrap().samples.len(), 1);
assert_eq!(triplet.error_count(), 0);
}
#[test]
fn different_epochs_produce_different_record_orderings() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 55).unwrap());
let n_records = 6;
let records: Vec<DataRecord> = (0..n_records)
.map(|i| {
trader_record(
&format!("epoch_order::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body {i} with enough context for sampling"),
)
})
.filter(|r| store.label_for(&RecordKey::from(r)) == Some(SplitLabel::Train))
.collect();
let n_train = records.len();
let config = SamplerConfig {
seed: 55,
batch_size: 1,
recipes: vec![TripletRecipe {
name: "epoch_order".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
allowed_splits: vec![SplitLabel::Train],
..SamplerConfig::default()
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"epoch_order",
records,
)))
.unwrap();
let epoch0: Vec<String> = (0..n_train)
.map(|_| {
sampler
.next_triplet_batch(SplitLabel::Train)
.expect("epoch-0 batch must succeed")
.triplets[0]
.anchor
.record_id
.clone()
})
.collect();
sampler.set_epoch(1).unwrap();
let epoch1: Vec<String> = (0..n_train)
.map(|_| {
sampler
.next_triplet_batch(SplitLabel::Train)
.expect("epoch-1 batch must succeed")
.triplets[0]
.anchor
.record_id
.clone()
})
.collect();
assert_ne!(
epoch0, epoch1,
"epoch 0 and epoch 1 produced identical anchor orderings — \
epoch seed has no effect on record selection"
);
}
#[test]
fn resumed_sampler_uses_persisted_epoch_seed() {
let base_seed = 0xC0FFEE_u64;
let n_records = 20_usize;
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("epoch_seed_resume_store");
let probe_store = DeterministicSplitStore::new(split, base_seed).unwrap();
let records: Vec<DataRecord> = (0..n_records)
.map(|i| {
trader_record(
&format!("ep_resume::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body {i} with enough context for sampling"),
)
})
.filter(|r| probe_store.label_for(&RecordKey::from(r)) == Some(SplitLabel::Train))
.collect();
let n_train = records.len();
let n_draws = n_train / 2;
let make_config = || SamplerConfig {
seed: base_seed,
batch_size: 1,
recipes: vec![TripletRecipe {
name: "ep".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
allowed_splits: vec![SplitLabel::Train],
..SamplerConfig::default()
};
let draw_n = |sampler: &dyn Sampler, n: usize| -> Vec<String> {
(0..n)
.map(|_| {
sampler
.next_triplet_batch(SplitLabel::Train)
.expect("batch must succeed")
.triplets[0]
.anchor
.record_id
.clone()
})
.collect()
};
let epoch0_sequence: Vec<String> = {
let store = Arc::new(DeterministicSplitStore::new(split, base_seed).unwrap());
let sampler = TripletSampler::new(make_config(), store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"ep_resume",
records.clone(),
)))
.unwrap();
draw_n(&sampler, n_draws)
};
{
let store = Arc::new(FileSplitStore::open(&store_path, split, base_seed).unwrap());
let sampler = TripletSampler::new(make_config(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"ep_resume",
records.clone(),
)))
.unwrap();
sampler
.next_triplet_batch(SplitLabel::Train)
.expect("priming batch must succeed");
sampler.set_epoch(1).unwrap();
sampler.save_sampler_state(None).unwrap();
}
let epoch1_sequence: Vec<String> = {
let store = Arc::new(FileSplitStore::open(&store_path, split, base_seed).unwrap());
let sampler = TripletSampler::new(make_config(), store);
sampler
.register_source(Box::new(InMemorySource::from_records(
"ep_resume",
records.clone(),
)))
.unwrap();
draw_n(&sampler, n_draws)
};
assert_ne!(
epoch0_sequence, epoch1_sequence,
"epoch 0 and epoch 1 (resumed from store) produced identical anchor orderings — \
epoch advancement has no effect on record selection order"
);
let half = n_draws / 2;
let e0_first: std::collections::HashSet<&str> =
epoch0_sequence[..half].iter().map(String::as_str).collect();
let e1_first: std::collections::HashSet<&str> =
epoch1_sequence[..half].iter().map(String::as_str).collect();
assert_ne!(
e0_first, e1_first,
"the first {half} records drawn in epoch 0 and epoch 1 (resumed) are the same set — \
records are not actually changing position across epochs"
);
}
#[test]
fn triplet_rejects_negative_with_duplicate_text_content() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "content_dedup".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 55).unwrap());
let unique_id = (0u32..)
.find_map(|i| {
let id = format!("content_unique_{i}");
(store.label_for(&RecordKey::new("tt", &id)) == Some(SplitLabel::Train)).then_some(id)
})
.unwrap();
let sampler = TripletSampler::new(config, store);
let records = vec![
trader_record(
"src::content_dup_a",
"2025-01-01",
"Title A",
"Shared body text",
),
trader_record(
"src::content_dup_b",
"2025-01-02",
"Title B",
"Shared body text",
),
trader_record(
&unique_id,
"2025-01-03",
"Title C",
"Completely different body",
),
];
sampler
.register_source(Box::new(InMemorySource::from_records("tt", records)))
.unwrap();
for _ in 0..32 {
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
for triplet in &batch.triplets {
assert_ne!(
triplet.anchor.text, triplet.positive.text,
"anchor and positive must not share text"
);
assert_ne!(
triplet.negative.text, triplet.positive.text,
"negative must not share text with positive"
);
assert_ne!(
triplet.negative.text, triplet.anchor.text,
"negative must not share text with anchor"
);
}
}
}
#[test]
fn wrong_publication_date_covers_some_none_branch_with_undated_candidates() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let find_train_id = |prefix: &str| -> String {
for i in 0..10_000_u32 {
let id = format!("{prefix}_{i}");
if store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(SplitLabel::Train) {
return id;
}
}
panic!("no Train id found for prefix {prefix}");
};
let anchor_id = find_train_id("wpd_sn_anchor");
let no_date_id = find_train_id("wpd_sn_nodate");
let same_date_id = find_train_id("wpd_sn_same");
let config = SamplerConfig {
seed: 42,
batch_size: 1,
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let anchor_dated = trader_record(&anchor_id, "2025-01-01", "Dated anchor", "Body A");
let mut cand_no_date = trader_record(&no_date_id, "2025-01-02", "No date cand", "Body B");
cand_no_date
.taxonomy
.retain(|t| META_FIELD_DATE.strip(t).is_none());
let cand_same = trader_record(&same_date_id, "2025-01-01", "Same date cand", "Body C");
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![anchor_dated, cand_no_date, cand_same],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, anchor_id))
.cloned()
.expect("anchor");
let (neg, _) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongPublicationDate, None)
.expect("should find undated candidate as negative");
assert_eq!(neg.id, no_date_id);
}
#[test]
fn wrong_publication_date_covers_none_some_and_none_none_branches() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 55).unwrap());
let find_train_id = |prefix: &str| -> String {
for i in 0..10_000_u32 {
let id = format!("{prefix}_{i}");
if store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(SplitLabel::Train) {
return id;
}
}
panic!("no Train id found for prefix {prefix}");
};
let anchor_id = find_train_id("wpd_nn_anchor");
let dated_id = find_train_id("wpd_nn_dated");
let undated_id = find_train_id("wpd_nn_undated");
let config = SamplerConfig {
seed: 55,
batch_size: 1,
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let mut anchor_no_date = trader_record(&anchor_id, "2025-01-01", "No date anchor", "Body A");
anchor_no_date
.taxonomy
.retain(|t| META_FIELD_DATE.strip(t).is_none());
let cand_dated = trader_record(&dated_id, "2025-01-02", "Dated cand", "Body B");
let mut cand_no_date = trader_record(&undated_id, "2025-01-01", "No date cand", "Body C");
cand_no_date
.taxonomy
.retain(|t| META_FIELD_DATE.strip(t).is_none());
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![anchor_no_date, cand_dated, cand_no_date],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, anchor_id))
.cloned()
.expect("anchor");
let (neg, _) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongPublicationDate, None)
.expect("undated anchor should match dated candidate");
assert_eq!(neg.id, dated_id);
}
#[test]
fn temporal_offset_selector_finds_nearest_chronological_neighbor() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let find_train_id = |prefix: &str| -> String {
for i in 0..10_000_u32 {
let id = format!("{prefix}_{i}");
if store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(SplitLabel::Train) {
return id;
}
}
panic!("no Train id found for prefix {prefix}");
};
let base_id = find_train_id("toff_base");
let id_7d = find_train_id("toff_7d");
let id_30d = find_train_id("toff_30d");
let config = SamplerConfig {
seed: 77,
batch_size: 1,
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let base = Utc::now();
let r0 = record_with_offset(&base_id, base, 0);
let r7d = record_with_offset(&id_7d, base, 7 * 86400);
let r30d = record_with_offset(&id_30d, base, 30 * 86400);
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![r0, r7d, r30d],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, &base_id))
.cloned()
.expect("anchor");
let neighbor = inner.select_temporal_neighbor(&anchor, 7);
assert!(neighbor.is_some(), "should find a temporal neighbor");
assert_eq!(neighbor.unwrap().id, id_7d);
let neighbor_near = inner.select_temporal_neighbor(&anchor, 1);
assert!(neighbor_near.is_some());
assert_eq!(neighbor_near.unwrap().id, id_7d);
}
#[test]
fn temporal_offset_selector_never_crosses_split_boundaries() {
let split = SplitRatios {
train: 0.6,
validation: 0.3,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 13).unwrap());
let find_id_for_split = |label: SplitLabel, prefix: &str| -> String {
for i in 0..10_000_u32 {
let id = format!("{prefix}_{i}");
if store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(label) {
return id;
}
}
panic!("could not find an id hashing to {label:?}");
};
let anchor_id = find_id_for_split(SplitLabel::Train, "temporal_anchor");
let train_id = find_id_for_split(SplitLabel::Train, "temporal_train");
assert_ne!(anchor_id, train_id);
let val_id = find_id_for_split(SplitLabel::Validation, "temporal_val");
let base = Utc::now();
let anchor_rec = record_with_offset(&anchor_id, base, 0);
let val_rec = record_with_offset(&val_id, base, 86_400);
let train_rec = record_with_offset(&train_id, base, 3 * 86_400);
let config = SamplerConfig {
seed: 13,
batch_size: 1,
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![anchor_rec, val_rec, train_rec],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, anchor_id))
.cloned()
.expect("anchor");
let neighbor = inner.select_temporal_neighbor(&anchor, 1);
assert!(
neighbor.is_some(),
"should find a same-split temporal neighbor"
);
assert_eq!(
neighbor.unwrap().id,
train_id,
"temporal neighbor must not cross split boundaries"
);
}
#[test]
fn instruction_propagates_from_recipe_to_sample_triplet() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 55).unwrap());
let mut config = base_config();
config.batch_size = 1;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "instr_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: Some("Retrieve a relevant document.".into()),
allow_same_anchor_positive: false,
}];
let now = Utc::now();
let make_record = |id: &str, anchor: &str, context: &str| DataRecord {
id: id.into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: anchor.into(),
sentences: vec![anchor.into()],
token_count: WhitespaceTokenizer.token_count(anchor),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: context.into(),
sentences: vec![context.into()],
token_count: WhitespaceTokenizer.token_count(context),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let records = vec![
make_record("r1", "anchor one unique text", "context one unique text"),
make_record("r2", "anchor two unique text", "context two unique text"),
make_record(
"r3",
"anchor three unique text",
"context three unique text",
),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.triplets.len(), 1);
assert_eq!(
batch.triplets[0].instruction.as_deref(),
Some("Retrieve a relevant document."),
"instruction must propagate from TripletRecipe to SampleTriplet"
);
}
#[test]
fn allow_same_anchor_positive_permits_identical_text_triplet() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let mut config = base_config();
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
let now = Utc::now();
let make_record = |id: &str, text: &str| DataRecord {
id: id.into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: None,
text: text.into(),
sentences: vec![text.into()],
token_count: WhitespaceTokenizer.token_count(text),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: text.into(), sentences: vec![text.into()],
token_count: 0,
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let records = vec![
make_record("t1", "the fox jumped over the lazy dog"),
make_record("t2", "a quick brown cat sat on the mat"),
make_record("t3", "stars shine brightly in the night sky"),
];
let simcse_recipe_blocked = TripletRecipe {
name: "blocked_simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let mut config_blocked = config.clone();
config_blocked.batch_size = 1;
config_blocked.recipes = vec![simcse_recipe_blocked];
let sampler_blocked = TripletSampler::new(config_blocked, Arc::clone(&store));
sampler_blocked
.register_source(Box::new(InMemorySource::from_records(
"unit",
records.clone(),
)))
.unwrap();
assert!(
sampler_blocked
.next_triplet_batch(SplitLabel::Train)
.is_err(),
"same-text anchor/positive must be rejected when allow_same_anchor_positive=false"
);
let simcse_recipe_allowed = TripletRecipe {
name: "allowed_simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: true,
};
let mut config_allowed = config.clone();
config_allowed.batch_size = 1;
config_allowed.recipes = vec![simcse_recipe_allowed];
let sampler_allowed = TripletSampler::new(config_allowed, Arc::clone(&store));
sampler_allowed
.register_source(Box::new(InMemorySource::from_records("unit", records)))
.unwrap();
let batch = sampler_allowed
.next_triplet_batch(SplitLabel::Train)
.expect("triplet must be produced when allow_same_anchor_positive=true");
assert_eq!(batch.triplets.len(), 1);
let triplet = &batch.triplets[0];
assert_eq!(
triplet.anchor.text, triplet.positive.text,
"anchor and positive must share identical text in SimCSE mode"
);
assert_ne!(
triplet.negative.text, triplet.anchor.text,
"negative must differ from anchor even in SimCSE mode"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_ranked_candidates_are_scoped_to_anchor_source() {
let split = SplitRatios {
train: 0.8,
validation: 0.1,
test: 0.1,
};
let config = SamplerConfig {
seed: 77,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new("source_alpha", &id)) == Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let anchor_id = find_train_id("scope_anchor");
let same_source_id = find_train_id("scope_same");
let other_source_id = find_train_id("scope_other");
let anchor = DataRecord {
id: anchor_id.clone(),
source: "source_alpha".into(),
..trader_record(
&anchor_id,
"2025-03-01",
"quantum computing error correction",
"surface codes and topological qubits for fault tolerance",
)
};
let same_source = DataRecord {
id: same_source_id.clone(),
source: "source_alpha".into(),
..trader_record(
&same_source_id,
"2025-03-02",
"quantum error correction surface codes",
"topological qubits fault tolerance thresholds",
)
};
let other_source = DataRecord {
id: other_source_id.clone(),
source: "source_beta".into(),
..trader_record(
&other_source_id,
"2025-03-02",
"quantum error correction surface codes",
"topological qubits fault tolerance thresholds",
)
};
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"source_alpha",
vec![anchor.clone(), same_source],
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"source_beta",
vec![other_source.clone()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let ranked = inner.bm25_ranked_candidates(&anchor);
assert!(
!ranked.is_empty(),
"ranked candidates must not be empty — same-source record should be found"
);
for id in &ranked {
let record = inner
.records
.get(id)
.expect("ranked key must be in records");
assert_eq!(
record.source, "source_alpha",
"BM25 ranked candidate '{id:?}' came from source '{}' but anchor is in \
'source_alpha' — cross-source leak detected (regression)",
record.source,
);
}
assert!(
!ranked.contains(&RecordKey::new("source_beta", &other_source_id)),
"other-source record must not appear in BM25 ranked candidates for anchor \
in a different source (global search regression)"
);
}
#[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
#[test]
fn bm25_fallback_counter_increments_when_no_bm25_candidates_match() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let config = SamplerConfig {
seed: 42,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 77).unwrap());
let anchor_id = "bm25_fallback_anchor";
let peer_id = "bm25_fallback_peer";
let records = vec![
trader_record(
anchor_id,
"2025-06-01",
"Quarterly results",
"revenue profit margin guidance outlook fiscal year",
),
trader_record(
peer_id,
"2025-06-02",
"Annual report",
"revenue profit margin guidance outlook fiscal year",
),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, anchor_id))
.cloned()
.expect("anchor");
let novel_query = "xyzzy_zyxqnv_mnbvcx_qwfpgjluy_no_such_term";
let result = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, Some(novel_query))
.expect("fallback must still return a negative");
assert_eq!(
result.0.id, peer_id,
"fallback must select the only available peer"
);
let (fallback, total) = inner.bm25_fallback_stats();
assert_eq!(total, 1, "selection count must be 1 after first call");
assert_eq!(
fallback, 1,
"fallback count must be 1 — novel query triggered BM25 fallback"
);
let shared_query = "revenue profit margin guidance outlook fiscal year";
let result2 = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::WrongArticle, Some(shared_query))
.expect("bm25 ranked path must return a negative");
assert_eq!(result2.0.id, peer_id);
let (fallback2, total2) = inner.bm25_fallback_stats();
assert_eq!(total2, 2, "selection count must be 2 after second call");
assert_eq!(
fallback2, 1,
"fallback count must remain 1 — shared-term query took the BM25 ranked path"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_query_uses_raw_chunk_text_not_decorated_text() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let config = SamplerConfig {
seed: 99,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 7).unwrap());
let raw_token = "quux_unique_content_token";
let prefix_token = "zork_unique_prefix_token";
let mut anchor = trader_record("bm25_raw_query_anchor", "2025-01-01", "Anchor", raw_token);
let mut kvp = KvpPrefixSampler::new(1.0);
kvp.add_variant([("decoration", prefix_token)]);
anchor.meta_prefix = Some(kvp);
let content_peer = trader_record(
"bm25_raw_query_content_peer",
"2025-01-02",
"Content peer",
raw_token,
);
let prefix_peer = trader_record(
"bm25_raw_query_prefix_peer",
"2025-01-03",
"Prefix peer",
prefix_token,
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![anchor.clone(), content_peer.clone(), prefix_peer.clone()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let anchor_rec = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, "bm25_raw_query_anchor"))
.cloned()
.unwrap();
let (selected_raw, _) = inner
.select_negative_record_seeded(
&anchor_rec,
&NegativeStrategy::WrongArticle,
Some(raw_token),
)
.expect("should select a negative with raw query");
assert_eq!(
selected_raw.id, content_peer.id,
"BM25 should rank content_peer first when queried with raw anchor text; \
got '{}' instead — possible regression: decorated text used as query",
selected_raw.id,
);
let (selected_prefix, _) = inner
.select_negative_record_seeded(
&anchor_rec,
&NegativeStrategy::WrongArticle,
Some(prefix_token),
)
.expect("should select a negative with prefix query");
assert_eq!(
selected_prefix.id, prefix_peer.id,
"BM25 should rank prefix_peer first when queried with prefix token; \
got '{}' — the two queries must be distinguishable for this test to be valid",
selected_prefix.id,
);
}
#[test]
fn select_chunk_parallel_paragraph_selector_returns_chunk_or_none() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 8;
config.chunking.overlap_tokens = vec![0];
let store = Arc::new(DeterministicSplitStore::new(split, 601).unwrap());
let sampler = TripletSampler::new(config, store);
let record = DataRecord {
id: "para_test".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "one two three four five".into(),
sentences: vec!["one two three four five".into()],
token_count: WhitespaceTokenizer.token_count("one two three four five"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let inner = sampler.inner.lock().unwrap();
let mut rng = DeterministicRng::new(7777);
let chunk = inner.select_chunk_parallel(&record, &Selector::Paragraph(0), &mut rng);
assert!(
chunk.is_some(),
"Paragraph(0) should return Some when section exists"
);
assert_eq!(chunk.unwrap().record_id, "para_test");
let none = inner.select_chunk_parallel(&record, &Selector::Paragraph(99), &mut rng);
assert!(
none.is_none(),
"Paragraph(99) should return None when no such section"
);
}
#[test]
fn select_chunk_parallel_random_selector_handles_empty_and_non_empty() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 8;
config.chunking.overlap_tokens = vec![0];
let store = Arc::new(DeterministicSplitStore::new(split, 602).unwrap());
let sampler = TripletSampler::new(config, store);
let empty_record = DataRecord {
id: "rand_empty".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![],
meta_prefix: None,
label: None,
};
let record_with_sections = DataRecord {
id: "rand_nonempty".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "alpha beta gamma delta epsilon".into(),
sentences: vec!["alpha beta gamma delta epsilon".into()],
token_count: WhitespaceTokenizer.token_count("alpha beta gamma delta epsilon"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let inner = sampler.inner.lock().unwrap();
let mut rng = DeterministicRng::new(8888);
let none = inner.select_chunk_parallel(&empty_record, &Selector::Random, &mut rng);
assert!(none.is_none(), "Random on empty record must return None");
let some = inner.select_chunk_parallel(&record_with_sections, &Selector::Random, &mut rng);
assert!(
some.is_some(),
"Random on non-empty record must return Some"
);
assert_eq!(some.unwrap().record_id, "rand_nonempty");
}
#[test]
fn select_chunk_parallel_temporal_offset_returns_chunk_from_neighbor() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(split, 603).unwrap());
let find_train = |prefix: &str| -> String {
for i in 0..10_000u32 {
let id = format!("{prefix}_{i}");
if store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id)) == Some(SplitLabel::Train) {
return id;
}
}
panic!("no Train id for prefix {prefix}");
};
let anchor_id = find_train("toff_par_anchor");
let neighbor_id = find_train("toff_par_neighbor");
let mut config = base_config();
config.seed = 603;
config.batch_size = 1;
config.recipes = Vec::new();
config.text_recipes = Vec::new();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let base = Utc::now();
let anchor_rec = record_with_offset(&anchor_id, base, 0);
let neighbor_rec = {
let mut r = record_with_offset(&neighbor_id, base, 86400); r.sections = vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "neighbor context text here".into(),
sentences: vec!["neighbor context text here".into()],
token_count: WhitespaceTokenizer.token_count("neighbor context text here"),
embedding: None,
}];
r
};
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
vec![anchor_rec.clone(), neighbor_rec],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, &anchor_id))
.cloned()
.expect("anchor record");
let mut rng = DeterministicRng::new(9999);
let chunk = inner.select_chunk_parallel(&anchor, &Selector::TemporalOffset(1), &mut rng);
assert!(
chunk.is_some(),
"TemporalOffset(1) should yield a context chunk from the temporal neighbor"
);
}
#[test]
fn select_role_parallel_returns_none_when_no_matching_role() {
let split = SplitRatios::default();
let config = base_config();
let store = Arc::new(DeterministicSplitStore::new(split, 604).unwrap());
let sampler = TripletSampler::new(config, store);
let anchor_only_record = DataRecord {
id: "anchor_only".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "anchor text here".into(),
sentences: vec!["anchor text here".into()],
token_count: WhitespaceTokenizer.token_count("anchor text here"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let inner = sampler.inner.lock().unwrap();
let mut rng = DeterministicRng::new(1234);
let result = inner.select_chunk_parallel(
&anchor_only_record,
&Selector::Role(SectionRole::Context),
&mut rng,
);
assert!(
result.is_none(),
"Context selector on Anchor-only record must return None"
);
let recipe = TripletRecipe {
name: "no_anchor_test".into(),
anchor: Selector::Role(SectionRole::Context), positive_selector: Selector::Role(SectionRole::Anchor),
negative_selector: Selector::Role(SectionRole::Anchor),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let ap = inner.select_anchor_positive_for_recipe(&recipe, &anchor_only_record, &mut rng);
assert!(
ap.is_none(),
"non-auto recipe with no matching anchor sections must return None"
);
}
#[test]
fn select_role_parallel_returns_none_when_all_pools_are_empty() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 8;
config.chunking.overlap_tokens = vec![0];
let store = Arc::new(DeterministicSplitStore::new(split, 605).unwrap());
let sampler = TripletSampler::new(config, store);
let empty_section_record = DataRecord {
id: "empty_pool_rec".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "".into(), sentences: vec![],
token_count: WhitespaceTokenizer.token_count(""),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let inner = sampler.inner.lock().unwrap();
let mut rng = DeterministicRng::new(5678);
let result = inner.select_chunk_parallel(
&empty_section_record,
&Selector::Role(SectionRole::Context),
&mut rng,
);
assert!(
result.is_none(),
"Context selector with empty-text section must return None (pool always empty)"
);
}
#[test]
fn decorate_chunk_parallel_truncation_paths() {
let split = SplitRatios::default();
let mut config_a = base_config();
config_a.chunking.max_window_tokens = 3; config_a.chunking.overlap_tokens = vec![0];
let store_a = Arc::new(DeterministicSplitStore::new(split, 606).unwrap());
let sampler_a = TripletSampler::new(config_a, store_a);
let mut record_a = DataRecord {
id: "pfx_fill".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "word1 word2".into(),
sentences: vec!["word1 word2".into()],
token_count: WhitespaceTokenizer.token_count("word1 word2"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut kvp_a = KvpPrefixSampler::new(1.0);
kvp_a.add_variant([("k", "a b c d")] as [(&str, &str); 1]); record_a.meta_prefix = Some(kvp_a);
let inner_a = sampler_a.inner.lock().unwrap();
let mut rng_a = DeterministicRng::new(11111);
let mut chunk_a = inner_a
.materialize_chunks(&record_a, 0, &record_a.sections[0])
.into_iter()
.next()
.expect("non-empty section must produce at least one chunk");
inner_a.decorate_chunk_parallel(&record_a, &mut chunk_a, &mut rng_a);
let tokens: Vec<&str> = chunk_a.text.split_whitespace().collect();
assert_eq!(
tokens.len(),
3,
"prefix-fills-window path must truncate to max_window tokens"
);
assert_eq!(chunk_a.tokens_estimate, 3);
drop(inner_a);
let mut config_b = base_config();
config_b.chunking.max_window_tokens = 5;
config_b.chunking.overlap_tokens = vec![0];
let store_b = Arc::new(DeterministicSplitStore::new(split, 607).unwrap());
let sampler_b = TripletSampler::new(config_b, store_b);
let mut record_b = DataRecord {
id: "pfx_body_trunc".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "word1 word2 word3 word4 word5 word6 word7 word8".into(),
sentences: vec!["word1 word2 word3 word4 word5 word6 word7 word8".into()],
token_count: WhitespaceTokenizer
.token_count("word1 word2 word3 word4 word5 word6 word7 word8"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut kvp_b = KvpPrefixSampler::new(1.0);
kvp_b.add_variant([("p", "pa pb")] as [(&str, &str); 1]); record_b.meta_prefix = Some(kvp_b);
let inner_b = sampler_b.inner.lock().unwrap();
let mut rng_b = DeterministicRng::new(22222);
let mut chunk_b = inner_b
.materialize_chunks(&record_b, 0, &record_b.sections[0])
.into_iter()
.next()
.expect("non-empty section must produce at least one chunk");
inner_b.decorate_chunk_parallel(&record_b, &mut chunk_b, &mut rng_b);
assert_eq!(
chunk_b.tokens_estimate, 5,
"truncated chunk must have max_window tokens"
);
let decorated_tokens: Vec<&str> = chunk_b.text.split_whitespace().collect();
assert_eq!(
decorated_tokens.len(),
5,
"decorated text must have exactly 5 tokens"
);
}
#[test]
fn decorate_chunk_no_truncation_when_window_is_zero() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 0; let store = Arc::new(DeterministicSplitStore::new(split, 608).unwrap());
let sampler = TripletSampler::new(config, store);
let mut record = DataRecord {
id: "no_trunc_rec".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "body token one two three".into(),
sentences: vec!["body token one two three".into()],
token_count: WhitespaceTokenizer.token_count("body token one two three"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut kvp = KvpPrefixSampler::new(1.0);
kvp.add_variant([("x", "pre fix")] as [(&str, &str); 1]); record.meta_prefix = Some(kvp);
let mut inner = sampler.inner.lock().unwrap();
let mut chunk = RecordChunk {
record_id: "no_trunc_rec".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::Window {
index: 0,
overlap: 0,
span: 5,
},
text: "body token one two three".to_string(),
tokens_estimate: 5,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
};
inner.decorate_chunk_seeded(&record, &mut chunk);
assert!(
chunk.text.contains("pre"),
"decorated text should contain part of the prefix key/value"
);
assert!(
chunk.text.contains("body token one two three"),
"full body should be present when no truncation occurs"
);
assert!(
chunk.tokens_estimate > 5,
"tokens_estimate should include prefix tokens"
);
}
#[test]
fn select_anchor_positive_parallel_returns_none_when_retries_exhausted() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 1024;
config.chunking.overlap_tokens = vec![0];
let store = Arc::new(DeterministicSplitStore::new(split, 609).unwrap());
let sampler = TripletSampler::new(config, store);
let single_chunk_record = DataRecord {
id: "single_chunk".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "unique text word".into(), sentences: vec!["unique text word".into()],
token_count: WhitespaceTokenizer.token_count("unique text word"),
embedding: None,
}],
meta_prefix: None,
label: None,
};
let inner = sampler.inner.lock().unwrap();
let mut rng = DeterministicRng::new(33333);
let selector = Selector::Role(SectionRole::Anchor);
let result = inner.select_anchor_positive_parallel(
&single_chunk_record,
&selector,
&selector,
false, &mut rng,
);
assert!(
result.is_none(),
"retry exhaustion with single-chunk record must return None"
);
}
#[test]
fn for_split_weight_apis_succeed_with_registered_source() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 610).unwrap());
let mut config = base_config();
config.seed = 610;
config.batch_size = 1;
config.recipes = vec![TripletRecipe {
name: "for_split_weight_test".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "for_split_text_test".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<_> = (0..8)
.map(|i| {
trader_record(
&format!("fsw_{i}"),
"2025-01-01",
&format!("title {i}"),
&format!("body text words extra {i}"),
)
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let weights = std::collections::HashMap::new();
let pair_batch = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect("pair batch must succeed with registered source");
assert_eq!(pair_batch.pairs.len(), 1);
let text_batch = sampler
.next_text_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect("text batch must succeed with registered source");
assert_eq!(text_batch.samples.len(), 1);
let triplet_batch = sampler
.next_triplet_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect("triplet batch must succeed with registered source");
assert_eq!(triplet_batch.triplets.len(), 1);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_query_text_over_token_limit_is_truncated_before_search() {
const TOKEN_LIMIT: usize = 64;
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 611).unwrap());
let config = base_config();
let sampler = TripletSampler::new(config, Arc::clone(&store));
let records: Vec<_> = (0..6)
.map(|i| {
trader_record(
&format!("bm25_trunc_{i}"),
"2025-01-01",
&format!("title {i}"),
&format!("body text content {i}"),
)
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records.clone(),
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let long_query: String = (0..(TOKEN_LIMIT + 10))
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
assert!(
long_query.split_whitespace().count() > TOKEN_LIMIT,
"query must exceed the token limit to exercise the truncation path"
);
let mut inner = sampler.inner.lock().unwrap();
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, "bm25_trunc_0"))
.cloned()
.expect("anchor");
let _result = inner.select_negative_record_seeded(
&anchor,
&NegativeStrategy::WrongArticle,
Some(&long_query),
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn record_bm25_text_with_zero_max_tokens_returns_full_text() {
let record = DataRecord {
id: "bm25_text_zero".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Heading".into()),
text: "anchor body text with many tokens here and there and everywhere".into(),
sentences: vec![
"anchor body text with many tokens here and there and everywhere".into(),
],
token_count: WhitespaceTokenizer
.token_count("anchor body text with many tokens here and there and everywhere"),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: None,
text: "context section body words more and more content".into(),
sentences: vec!["context section body words more and more content".into()],
token_count: WhitespaceTokenizer
.token_count("context section body words more and more content"),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let full = record_bm25_text(&record, 0);
let capped = record_bm25_text(&record, 5);
assert!(
full.split_whitespace().count() > 5,
"full text should have more tokens than the capped version"
);
assert_eq!(
capped.split_whitespace().count(),
5,
"capped version should contain exactly 5 tokens"
);
assert!(
full.contains("Heading"),
"full text (max_tokens=0) must include the heading"
);
}
#[cfg(feature = "bm25-mining")]
#[test]
fn bm25_index_meta_record_ids_returns_none_when_no_indexes_built() {
let split = SplitRatios::default();
let config = base_config();
let store = Arc::new(DeterministicSplitStore::new(split, 612).unwrap());
let sampler = TripletSampler::new(config, store);
let mut inner_mut = sampler.inner.lock().unwrap();
let ids = inner_mut.bm25_backend_mut().index_meta_record_ids();
assert!(
ids.is_none(),
"index_meta_record_ids must return None when source_indexes is empty"
);
}
#[test]
fn for_split_non_exhausted_error_propagates_immediately() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 613).unwrap());
let mut config = base_config();
config.seed = 613;
config.batch_size = 1;
config.recipes = vec![];
config.text_recipes = vec![];
config.allowed_splits = vec![SplitLabel::Train];
let sampler = TripletSampler::new(config, Arc::clone(&store));
let record_id: RecordId = "no_recipe_rec".to_string();
let section = RecordSection {
role: SectionRole::Context,
heading: None,
text: "some context text".into(),
sentences: vec!["some context text".into()],
token_count: WhitespaceTokenizer.token_count("some context text"),
embedding: None,
};
let record = DataRecord {
id: record_id.clone(),
source: "unit".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![section],
meta_prefix: None,
label: None,
};
{
let mut inner = sampler.inner.lock().unwrap();
inner.records.insert(
RecordKey::new("unit", record_id.clone()),
std::sync::Arc::new(record),
);
inner.chunk_index.insert(
RecordKey::new("unit", record_id.clone()),
RecordKey::new("unit", record_id.clone()),
);
}
let weights: std::collections::HashMap<_, f32> = std::collections::HashMap::new();
let pair_err = sampler
.next_pair_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("must fail with no recipes configured");
assert!(
matches!(pair_err, SamplerError::Configuration(_)),
"expected Configuration error from pair batch, got: {pair_err:?}"
);
let text_err = sampler
.next_text_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("must fail with no text recipes configured");
assert!(
matches!(text_err, SamplerError::Configuration(_)),
"expected Configuration error from text batch, got: {text_err:?}"
);
let triplet_err = sampler
.next_triplet_batch_with_weights_for_split(SplitLabel::Train, &weights)
.expect_err("must fail with no triplet recipes configured");
assert!(
matches!(triplet_err, SamplerError::Configuration(_)),
"expected Configuration error from triplet batch, got: {triplet_err:?}"
);
}
#[test]
fn batch_size_guard_prevents_oversampling_from_large_pool() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let store = Arc::new(DeterministicSplitStore::new(split, 614).unwrap());
const BATCH: usize = 4;
const POOL: usize = 20;
let mut config = base_config();
config.seed = 614;
config.batch_size = BATCH;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "oversample_guard".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "oversample_guard_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let records: Vec<_> = (0..POOL)
.map(|i| {
trader_record(
&format!("osg_{i}"),
"2025-06-01",
&format!("title oversample {i}"),
&format!("body context words filling slot {i} for oversample guard test"),
)
})
.collect();
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"osg_source",
records,
)))
.unwrap();
let pair_batch = sampler
.next_pair_batch(SplitLabel::Train)
.expect("pair batch must succeed with large pool");
assert_eq!(
pair_batch.pairs.len(),
BATCH,
"pair batch length must equal batch_size; got {}",
pair_batch.pairs.len()
);
let text_batch = sampler
.next_text_batch(SplitLabel::Train)
.expect("text batch must succeed with large pool");
assert_eq!(
text_batch.samples.len(),
BATCH,
"text batch length must equal batch_size; got {}",
text_batch.samples.len()
);
let triplet_batch = sampler
.next_triplet_batch(SplitLabel::Train)
.expect("triplet batch must succeed with large pool");
assert_eq!(
triplet_batch.triplets.len(),
BATCH,
"triplet batch length must equal batch_size; got {}",
triplet_batch.triplets.len()
);
}
#[test]
fn sampler_register_source_rejects_reserved_id_pattern() {
let ratios = SplitRatios {
train: 0.8,
validation: 0.1,
test: 0.1,
};
let store = Arc::new(DeterministicSplitStore::new(ratios, 42).unwrap());
let sampler = TripletSampler::new(SamplerConfig::default(), store);
let result = sampler.register_source(Box::new(InMemorySource::new("__reserved__")));
assert!(
result.is_err(),
"TripletSampler::register_source should reject reserved source id"
);
let err = result.unwrap_err();
assert!(
matches!(err, SamplerError::ReservedSourceId(ref id) if id == "__reserved__"),
"expected ReservedSourceId error, got: {err}"
);
sampler
.register_source(Box::new(InMemorySource::new("valid_id")))
.unwrap();
}
#[test]
fn text_batch_cursor_wrap_during_batch_does_not_duplicate_text() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 4;
config.ingestion_max_records = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: true,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let records = vec![
text_columns_record("rec_0", "text_A"),
text_columns_record("rec_1", "text_B"),
text_columns_record("rec_2", "text_C"),
text_columns_record("rec_3", "text_D"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tf", records)))
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 4, "batch should be full");
let mut texts_seen = std::collections::HashSet::new();
for sample in &batch.samples {
let text = &sample.chunk.text;
assert!(
texts_seen.insert(text.clone()),
"duplicate text within single batch: '{text}'. Samples so far: {texts_seen:?}"
);
}
}
#[test]
fn text_batch_cursor_wrap_mid_batch_with_diff_size() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 3;
config.ingestion_max_records = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: true,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let records = vec![
text_columns_record("rec_0", "text_A"),
text_columns_record("rec_1", "text_B"),
text_columns_record("rec_2", "text_C"),
text_columns_record("rec_3", "text_D"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tf", records)))
.unwrap();
for batch_num in 1..=5 {
match sampler.next_text_batch(SplitLabel::Train) {
Ok(batch) => {
let mut texts_seen = std::collections::HashSet::new();
for sample in &batch.samples {
let text = &sample.chunk.text;
if batch.samples.len() == 3 {
texts_seen.insert(text.clone());
} else {
assert!(
texts_seen.insert(text.clone()),
"batch {batch_num}: duplicate text '{text}' within a full-3 batch"
);
}
}
}
Err(SamplerError::Exhausted(_)) => {
break;
}
Err(e) => panic!("unexpected error on batch {batch_num}: {e:?}"),
}
}
}
#[test]
fn text_batch_duplicate_text_different_records() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let mut config = base_config();
config.batch_size = 4;
config.ingestion_max_records = 4;
config.allowed_splits = vec![SplitLabel::Train];
config.split = split;
config.recipes = vec![TripletRecipe {
name: "simcse".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: true,
}];
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let records = vec![
text_columns_record("rec_0", "shared_text_alpha"),
text_columns_record("rec_1", "unique_text_beta"),
text_columns_record("rec_2", "shared_text_alpha"), text_columns_record("rec_3", "unique_text_gamma"),
];
let sampler = TripletSampler::new(config, store);
sampler
.register_source(Box::new(InMemorySource::from_records("tf", records)))
.unwrap();
let batch = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(batch.samples.len(), 4, "batch should be full");
let shared_count = batch
.samples
.iter()
.filter(|s| s.chunk.text == "shared_text_alpha")
.count();
assert!(
shared_count <= 2,
"shared_text_alpha should appear at most twice (dedup + pad_with_reuse), got {shared_count}"
);
}
#[test]
fn epoch_step_increments_through_public_batch_apis() {
let mut config = base_config();
config.batch_size = 20; config.recipes = vec![TripletRecipe {
name: "step_test".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "context".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = (0..20)
.map(|i| {
trader_record(
&format!("step_pub::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Unique body {i} for public batch step test"),
)
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(step, 0, "step starts at 0");
for expected in 1u64..=3 {
let _ = sampler.next_pair_batch(SplitLabel::Train);
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(
step, expected,
"step must be {expected} after {expected} next_pair_batch calls (got {step})"
);
}
for expected in 4u64..=6 {
let _ = sampler.next_triplet_batch(SplitLabel::Train);
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(
step, expected,
"step must be {expected} after next_triplet_batch (got {step})"
);
}
{
let before = sampler.inner.lock().unwrap().ingestion.epoch_step();
let _ = sampler.next_text_batch(SplitLabel::Train);
let after = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert!(
after > before,
"step must increase after next_text_batch call (was {before}, now {after})"
);
}
}
#[test]
fn explicit_set_epoch_resets_epoch_step_then_increments_per_batch() {
let mut config = base_config();
config.batch_size = 20;
config.recipes = vec![TripletRecipe {
name: "epoch_step".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 42).unwrap());
let sampler = TripletSampler::new(config, store);
let records: Vec<DataRecord> = (0..20)
.map(|i| {
trader_record(
&format!("epoch_step::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body {i} for epoch step test"),
)
})
.collect();
sampler
.register_source(Box::new(InMemorySource::from_records("src", records)))
.unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
3,
"epoch 0 step should be 3 after 3 batches"
);
sampler.set_epoch(1).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
0,
"step must reset to 0 after explicit set_epoch"
);
sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
1,
"epoch 1 first batch → step 1"
);
sampler.next_pair_batch(SplitLabel::Train).unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
3,
"epoch 1 step should be 3 after 3 batches"
);
}
#[test]
fn epoch_step_resume_preserves_epoch_and_continues_from_saved_value() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let temp = tempdir().unwrap();
let store_path = temp.path().join("step_resume_test");
let config = SamplerConfig {
seed: 42,
split,
batch_size: 6,
allowed_splits: vec![SplitLabel::Train],
recipes: vec![TripletRecipe {
name: "resume_step".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}],
text_recipes: Vec::new(),
..SamplerConfig::default()
};
let records: Vec<DataRecord> = (0..6)
.map(|i| {
trader_record(
&format!("res_step::rec_{i:02}"),
"2025-01-01",
&format!("Title {i}"),
&format!("Body {i} for resume step test"),
)
})
.collect();
let saved_step = {
let store = Arc::new(FileSplitStore::open(&store_path, split, 42).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src",
records.clone(),
)))
.unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap(); sampler.next_pair_batch(SplitLabel::Train).unwrap(); sampler.next_pair_batch(SplitLabel::Train).unwrap();
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
sampler.save_sampler_state(None).unwrap();
step
};
assert_eq!(saved_step, 3, "must have run 3 batches before save");
{
let store = Arc::new(FileSplitStore::open(&store_path, split, 42).unwrap());
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src",
records.clone(),
)))
.unwrap();
sampler.next_pair_batch(SplitLabel::Train).unwrap();
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(
step,
saved_step + 1,
"first resumed batch must produce step {saved_step}+1, got {step}"
);
sampler.next_pair_batch(SplitLabel::Train).unwrap();
let step = sampler.inner.lock().unwrap().ingestion.epoch_step();
assert_eq!(
step,
saved_step + 2,
"second resumed batch must produce step {saved_step}+2, got {step}"
);
}
}
#[test]
fn resume_continues_epoch_step_from_saved_value() {
let split = SplitRatios::default();
let make_records = |source: &str| -> Vec<DataRecord> {
(0..10)
.map(|i| {
trader_record(
&format!("{source}::rec_{i:02}"),
"2025-01-01",
&format!("{source} title {i}"),
&format!("{source} body {i} with enough content"),
)
})
.collect()
};
let mut config = base_config();
config.seed = 42;
config.batch_size = 4;
config.split = split;
config.allowed_splits = vec![SplitLabel::Train];
config.recipes = vec![TripletRecipe {
name: "resume_content".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
{
let temp = tempdir().unwrap();
let store_path = temp.path().join("resume_pair");
let (step_at_save, expected_hash) = {
let store =
Arc::new(FileSplitStore::open(&store_path, config.split, config.seed).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("pair"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("pair"),
)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
}
for _ in 0..3 {
sampler.next_pair_batch(SplitLabel::Train).unwrap();
}
let step_at_save = sampler.inner.lock().unwrap().ingestion.epoch_step();
sampler.save_sampler_state(None).unwrap();
let batch4 = sampler.next_pair_batch(SplitLabel::Train).unwrap();
(step_at_save, pair_snapshot_hash(&[batch4]))
};
assert_eq!(step_at_save, 3, "pair: 3 batches before save");
let store = Arc::new(FileSplitStore::open(&store_path, config.split, config.seed).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("pair"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("pair"),
)))
.unwrap();
let resumed = sampler.next_pair_batch(SplitLabel::Train).unwrap();
assert_eq!(
pair_snapshot_hash(&[resumed]),
expected_hash,
"pair batch content must be bit-identical after resume"
);
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
step_at_save + 1,
"pair step must continue after resume"
);
}
{
let mut text_config = config.clone();
text_config.text_recipes = vec![TextRecipe {
name: "resume_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let temp = tempdir().unwrap();
let store_path = temp.path().join("resume_step_text");
let store = Arc::new(FileSplitStore::open(&store_path, split, config.seed).unwrap());
let sampler = TripletSampler::new(text_config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("step_text"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("step_text"),
)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
}
for _ in 0..2 {
sampler.next_text_batch(SplitLabel::Train).unwrap();
}
let step_at_save = sampler.inner.lock().unwrap().ingestion.epoch_step();
sampler.save_sampler_state(None).unwrap();
assert_eq!(step_at_save, 2, "text: 2 batches before save");
let store = Arc::new(FileSplitStore::open(&store_path, split, config.seed).unwrap());
let sampler = TripletSampler::new(text_config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("step_text"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("step_text"),
)))
.unwrap();
let _ = sampler.next_text_batch(SplitLabel::Train).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
step_at_save + 1,
"text step must continue after resume"
);
}
{
let temp = tempdir().unwrap();
let store_path = temp.path().join("resume_step_triplet");
let store = Arc::new(FileSplitStore::open(&store_path, split, config.seed).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("step_tri"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("step_tri"),
)))
.unwrap();
{
let mut inner = sampler.inner.lock().unwrap();
inner.ingest_internal(SplitLabel::Train).unwrap();
}
for _ in 0..3 {
sampler.next_triplet_batch(SplitLabel::Train).unwrap();
}
let step_at_save = sampler.inner.lock().unwrap().ingestion.epoch_step();
sampler.save_sampler_state(None).unwrap();
assert_eq!(step_at_save, 3, "triplet: 3 batches before save");
let store = Arc::new(FileSplitStore::open(&store_path, split, config.seed).unwrap());
let sampler = TripletSampler::new(config.clone(), Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_a",
make_records("step_tri"),
)))
.unwrap();
sampler
.register_source(Box::new(InMemorySource::from_records(
"src_b",
make_records("step_tri"),
)))
.unwrap();
let _ = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert_eq!(
sampler.inner.lock().unwrap().ingestion.epoch_step(),
step_at_save + 1,
"triplet step must continue after resume"
);
}
}
#[test]
fn same_record_strategy_returns_anchor_as_negative() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 42,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let anchor = trader_record(
"same_rec_anchor",
"2025-01-01",
"Anchor title",
"Anchor body text with enough words for sampling purposes here",
);
let other = trader_record(
"same_rec_other",
"2025-01-02",
"Other title",
"Other body text with enough words for sampling purposes here",
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"test_src",
vec![anchor.clone(), other],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let (negative, fallback_used) = inner
.select_negative_record_seeded(&anchor, &NegativeStrategy::SameRecord, None)
.expect("SameRecord must return a negative");
assert_eq!(
negative.id, anchor.id,
"SameRecord must return the anchor record itself as the negative"
);
assert!(!fallback_used, "SameRecord never uses fallback");
}
#[cfg(all(feature = "bm25-mining", feature = "extended-metrics"))]
#[test]
fn same_record_strategy_bypasses_negative_backend() {
let split = SplitRatios {
train: 1.0,
validation: 0.0,
test: 0.0,
};
let config = SamplerConfig {
seed: 99,
batch_size: 1,
chunking: ChunkingStrategy::default(),
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 99).unwrap());
let anchor = trader_record(
"bm25_bypass_anchor",
"2025-03-15",
"Quarterly revenue report",
"revenue profit margin guidance outlook fiscal year analysis",
);
let peer = trader_record(
"bm25_bypass_peer",
"2025-03-16",
"Annual financial summary",
"revenue profit margin guidance outlook fiscal year analysis",
);
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"test_src",
vec![anchor.clone(), peer],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let mut inner = sampler.inner.lock().unwrap();
let (fb0, sel0) = inner.bm25_fallback_stats();
assert_eq!(sel0, 0, "selection count must be 0 before any call");
assert_eq!(fb0, 0, "fallback count must be 0 before any call");
let result = inner.select_negative_record_seeded(
&anchor,
&NegativeStrategy::SameRecord,
Some("revenue profit margin guidance"),
);
assert!(result.is_some(), "SameRecord must return a negative");
let (fb1, sel1) = inner.bm25_fallback_stats();
assert_eq!(
sel1, 0,
"selection count must still be 0 — SameRecord bypasses the backend"
);
assert_eq!(
fb1, 0,
"fallback count must still be 0 — SameRecord bypasses the backend"
);
}
#[test]
fn same_record_negative_selector_picks_from_context() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 7,
batch_size: 1,
chunking: ChunkingStrategy {
max_window_tokens: 8,
overlap_tokens: vec![2],
..ChunkingStrategy::default()
},
recipes: Vec::new(),
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 7).unwrap());
let now = Utc::now();
let anchor = DataRecord {
id: "ctx_anchor".into(),
source: "unit".into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec!["unit".into()],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: "The quick brown fox jumps over the lazy dog. \
Pack my box with five dozen liquor jugs. \
How vexingly quick daft zebras jump. \
The five boxing wizards jump quickly. \
Sphinx of black quartz judge my vow. \
Two driven jocks help fax my big quiz."
.into(),
sentences: vec![
"The quick brown fox jumps over the lazy dog.".into(),
"Pack my box with five dozen liquor jugs.".into(),
"How vexingly quick daft zebras jump.".into(),
"The five boxing wizards jump quickly.".into(),
"Sphinx of black quartz judge my vow.".into(),
"Two driven jocks help fax my big quiz.".into(),
],
token_count: 0,
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Body".into()),
text: "Context section text for negative selection".into(),
sentences: vec!["Context section text for negative selection".into()],
token_count: WhitespaceTokenizer
.token_count("Context section text for negative selection"),
embedding: None,
},
],
meta_prefix: None,
label: None,
};
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
"test_src",
vec![anchor.clone()],
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let recipe = TripletRecipe {
name: "same_record_ctx".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Anchor),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::SameRecord,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
};
let mut inner = sampler.inner.lock().unwrap();
let triplet = inner.make_triplet_with_anchor_seeded(&recipe, &anchor);
let triplet = triplet.expect("SameRecord + Context selector must produce a triplet");
assert_eq!(
triplet.negative.record_id, anchor.id,
"negative must come from the same record as the anchor"
);
assert_eq!(
triplet.negative.text, "Context section text for negative selection",
"negative chunk must be the Context section text"
);
assert_ne!(
triplet.negative.text, triplet.anchor.text,
"negative must differ from anchor"
);
}
#[test]
fn same_record_and_wrong_article_recipes_coexist() {
let split = SplitRatios {
train: 0.7,
validation: 0.2,
test: 0.1,
};
let config = SamplerConfig {
seed: 55,
batch_size: 4,
chunking: ChunkingStrategy::default(),
recipes: vec![
TripletRecipe {
name: "wrong_article_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Paragraph(1),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
TripletRecipe {
name: "same_record_recipe".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Paragraph(1),
negative_selector: Selector::Paragraph(2),
negative_strategy: NegativeStrategy::SameRecord,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
},
],
text_recipes: Vec::new(),
split,
..SamplerConfig::default()
};
let store = Arc::new(DeterministicSplitStore::new(split, 55).unwrap());
let find_train_id = |prefix: &str| -> String {
(0u32..)
.find_map(|i| {
let id = format!("{prefix}_{i}");
(store.label_for(&RecordKey::new(PRIMARY_SOURCE_ID, &id))
== Some(SplitLabel::Train))
.then_some(id)
})
.unwrap()
};
let id_a = find_train_id("mix_a");
let id_b = find_train_id("mix_b");
let id_c = find_train_id("mix_c");
let id_d = find_train_id("mix_d");
let make_record = |id: &str| -> DataRecord {
let now = Utc::now();
DataRecord {
id: id.into(),
source: PRIMARY_SOURCE_ID.into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec![PRIMARY_SOURCE_ID.into()],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: format!("Anchor text for {id}"),
sentences: vec![format!("Anchor text for {id}")],
token_count: 0,
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Positive".into()),
text: format!("positive_{id}"),
sentences: vec![format!("positive_{id}")],
token_count: 0,
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Negative".into()),
text: format!("negative_{id}"),
sentences: vec![format!("negative_{id}")],
token_count: 0,
embedding: None,
},
],
meta_prefix: None,
label: None,
}
};
let records = vec![
make_record(&id_a),
make_record(&id_b),
make_record(&id_c),
make_record(&id_d),
];
let sampler = TripletSampler::new(config, Arc::clone(&store));
sampler
.register_source(Box::new(InMemorySource::from_records(
PRIMARY_SOURCE_ID,
records,
)))
.unwrap();
sampler
.inner
.lock()
.unwrap()
.ingest_internal(SplitLabel::Train)
.unwrap();
let batch = sampler.next_triplet_batch(SplitLabel::Train).unwrap();
assert!(
!batch.triplets.is_empty(),
"must produce at least one triplet"
);
let mut saw_same_record = false;
let mut saw_wrong_article = false;
for triplet in &batch.triplets {
if triplet.recipe.starts_with("same_record_recipe") {
saw_same_record = true;
assert_eq!(
triplet.negative.record_id, triplet.anchor.record_id,
"SameRecord: negative record must match anchor record"
);
assert_ne!(
triplet.negative.text, triplet.positive.text,
"SameRecord: negative text must differ from positive text"
);
} else if triplet.recipe.starts_with("wrong_article_recipe") {
saw_wrong_article = true;
assert_ne!(
triplet.negative.record_id, triplet.anchor.record_id,
"WrongArticle: negative record must differ from anchor record"
);
}
}
assert!(
saw_same_record,
"batch must contain at least one SameRecord triplet"
);
assert!(
saw_wrong_article,
"batch must contain at least one WrongArticle triplet"
);
}
#[test]
fn text_batch_samples_from_positive_and_negative_pair_label_records() {
fn labeled_record(
id: &str,
date: &str,
title: &str,
body: &str,
label: PairLabel,
) -> DataRecord {
let now = Utc::now();
DataRecord {
id: id.into(),
source: PRIMARY_SOURCE_ID.into(),
created_at: now,
updated_at: now,
quality: QualityScore { trust: 0.9 },
taxonomy: vec![PRIMARY_SOURCE_ID.into(), META_FIELD_DATE.encode(date)],
sections: vec![
RecordSection {
role: SectionRole::Anchor,
heading: Some("Title".into()),
text: title.into(),
sentences: vec![title.into()],
token_count: WhitespaceTokenizer.token_count(title),
embedding: None,
},
RecordSection {
role: SectionRole::Context,
heading: Some("Summary".into()),
text: body.into(),
sentences: vec![body.into()],
token_count: WhitespaceTokenizer.token_count(body),
embedding: None,
},
],
meta_prefix: None,
label: Some(label),
}
}
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 42).unwrap());
let mut config = base_config();
config.batch_size = 4;
config.text_recipes = vec![TextRecipe {
name: "pair_labeled_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let pos_record = labeled_record(
"pair_label::2025-01-01/positive.txt",
"2025-01-01",
"Positive Title",
"Positive body text for pair label testing",
PairLabel::Positive,
);
let neg_record = labeled_record(
"pair_label::2025-01-01/negative.txt",
"2025-01-01",
"Negative Title",
"Negative body text for pair label testing",
PairLabel::Negative,
);
for record in [pos_record.clone(), neg_record.clone()] {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
let text_batch = inner
.next_text_batch_inner_with_weights(SplitLabel::Train, None)
.expect("text batch should sample from records with PairLabel");
assert_eq!(text_batch.samples.len(), 4);
assert!(
text_batch
.samples
.iter()
.all(|sample| sample.recipe == "pair_labeled_text"),
"all samples should use the pair_labeled_text recipe"
);
let mut saw_positive_record = false;
let mut saw_negative_record = false;
for sample in &text_batch.samples {
if let Some(record) = inner.records.get(&RecordKey::of_chunk(&sample.chunk)) {
match record.label {
Some(PairLabel::Positive) => saw_positive_record = true,
Some(PairLabel::Negative) => saw_negative_record = true,
None => {}
}
}
}
assert!(
saw_positive_record,
"text batch should contain at least one sample from a PairLabel::Positive record"
);
assert!(
saw_negative_record,
"text batch should contain at least one sample from a PairLabel::Negative record"
);
}
#[test]
fn build_derived_text_recipes_produces_anchor_positive_negative_variants() {
let recipes = vec![
TripletRecipe {
name: "qa".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.5,
instruction: None,
allow_same_anchor_positive: false,
},
TripletRecipe {
name: "retrieval".into(),
anchor: Selector::Random,
positive_selector: Selector::Random,
negative_selector: Selector::Random,
negative_strategy: NegativeStrategy::WrongArticle,
weight: 0.8,
instruction: None,
allow_same_anchor_positive: false,
},
];
let derived =
TripletSamplerInner::<DeterministicSplitStore>::build_derived_text_recipes(&recipes);
assert_eq!(derived.len(), 6);
assert_eq!(derived[0].name.as_ref(), "qa_anchor");
assert_eq!(derived[1].name.as_ref(), "qa_positive");
assert_eq!(derived[2].name.as_ref(), "qa_negative");
assert_eq!(derived[3].name.as_ref(), "retrieval_anchor");
assert_eq!(derived[4].name.as_ref(), "retrieval_positive");
assert_eq!(derived[5].name.as_ref(), "retrieval_negative");
assert!((derived[0].weight - 1.5).abs() < f32::EPSILON);
assert!((derived[3].weight - 0.8).abs() < f32::EPSILON);
assert!(derived.iter().all(|r| r.instruction.is_none()));
}
#[test]
fn select_chunk_returns_none_for_empty_sections() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 200).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let empty_record = DataRecord {
id: "empty_sel".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![],
meta_prefix: None,
label: None,
};
assert!(
inner
.select_chunk(&empty_record, &Selector::Role(SectionRole::Anchor))
.is_none()
);
assert!(
inner
.select_chunk(&empty_record, &Selector::Paragraph(0))
.is_none()
);
assert!(
inner
.select_chunk(&empty_record, &Selector::Random)
.is_none()
);
}
#[test]
fn select_chunk_returns_none_when_pool_is_empty_for_paragraph() {
let split = SplitRatios::default();
let mut config = base_config();
config.chunking.max_window_tokens = 1;
let store = Arc::new(DeterministicSplitStore::new(split, 201).unwrap());
let mut inner = TripletSamplerInner::new(config, store);
let record = DataRecord {
id: "para_empty".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "tiny".into(),
sentences: vec![],
token_count: 1,
embedding: None,
}],
meta_prefix: None,
label: None,
};
assert!(
inner
.select_chunk(&record, &Selector::Paragraph(1))
.is_none()
);
}
#[test]
fn mark_source_wrapped_advances_epoch_when_all_wrapped() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 202).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner.source_order = vec!["src_a".into(), "src_b".into()];
let epoch_before = inner.epoch;
inner.mark_source_wrapped("src_a");
assert_eq!(inner.epoch, epoch_before);
assert_eq!(inner.source_wrapped.get("src_a"), Some(&true));
inner.mark_source_wrapped("src_b");
assert!(inner.epoch > epoch_before);
}
#[test]
fn mark_source_wrapped_noop_when_source_order_empty() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 203).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner.source_order = vec![];
inner.mark_source_wrapped("src_x");
assert_eq!(inner.epoch, 0);
}
#[test]
fn next_chunk_from_pool_resets_cursor_when_stale() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 204).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
inner
.chunk_cursors
.insert((RecordKey::new("", "rec"), 0), 999);
let pool = vec![RecordChunk {
record_id: "rec".into(),
section_idx: 0,
source: String::new(),
view: ChunkView::SummaryFallback {
strategy: "test".into(),
weight: 1.0,
},
text: "chunk".into(),
tokens_estimate: 4,
quality: QualityScore::default(),
kvp_meta: Default::default(),
embedding: None,
}];
let chunk = inner.next_chunk_from_pool(&RecordKey::new("", "rec"), 0, pool);
assert!(chunk.is_some());
assert_eq!(
*inner
.chunk_cursors
.get(&(RecordKey::new("", "rec"), 0))
.unwrap(),
0
);
}
#[test]
fn negative_strategy_wrong_publication_date_uses_date_taxonomy() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 205).unwrap());
let mut config = base_config();
config.batch_size = 4;
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let records = vec![
trader_record(
"anchor",
"2025-01-15",
"Anchor Title",
"Anchor body text for date test",
),
trader_record(
"neg_candidate",
"2025-06-20",
"Different Date Title",
"Different date body text",
),
trader_record(
"same_date",
"2025-01-15",
"Same Date Title",
"Same date body text",
),
];
for record in &records {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, "anchor"))
.unwrap();
let mut rng = DeterministicRng::new(42);
let anchor_date = taxonomy_value(anchor, META_FIELD_DATE).map(|d| d.to_string());
let neg = inner.select_negative_record(
anchor,
&NegativeStrategy::WrongPublicationDate,
None,
&mut rng,
);
assert!(neg.is_some());
let (neg_record, _swapped) = neg.unwrap();
let neg_date = taxonomy_value(&neg_record, META_FIELD_DATE);
if let Some(ref anchor_d) = anchor_date {
assert_ne!(neg_date, Some(anchor_d.as_str()));
}
}
#[test]
fn negative_strategy_question_answer_mismatch_same_source() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 206).unwrap());
let mut config = base_config();
config.batch_size = 4;
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let records = vec![
trader_record(
"qa_anchor",
"2025-01-01",
"QA Anchor",
"What is the question?",
),
trader_record("qa_neg", "2025-01-01", "QA Negative", "A different answer."),
];
for record in &records {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, "qa_anchor"))
.unwrap();
let mut rng = DeterministicRng::new(42);
let neg = inner.select_negative_record(
anchor,
&NegativeStrategy::QuestionAnswerMismatch,
None,
&mut rng,
);
assert!(neg.is_some());
let (neg_record, _swapped) = neg.unwrap();
assert_eq!(neg_record.source, anchor.source);
assert_ne!(neg_record.id, anchor.id);
}
#[test]
fn negative_strategy_wrong_publication_date_falls_back_when_no_different_date() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 207).unwrap());
let mut config = base_config();
config.batch_size = 4;
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let records = vec![
trader_record("wpd_a", "2025-01-01", "Title A", "Body A"),
trader_record("wpd_b", "2025-01-01", "Title B", "Body B"),
];
for record in &records {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
let anchor = inner
.records
.get(&RecordKey::new(PRIMARY_SOURCE_ID, "wpd_a"))
.unwrap();
let mut rng = DeterministicRng::new(42);
let neg = inner.select_negative_record(
anchor,
&NegativeStrategy::WrongPublicationDate,
None,
&mut rng,
);
assert!(neg.is_some());
let (neg_record, _swapped) = neg.unwrap();
assert_ne!(neg_record.id, anchor.id);
}
#[test]
fn sampler_trait_default_save_sampler_state_is_noop() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 300).unwrap());
let sampler = TripletSampler::new(base_config(), store);
sampler.save_sampler_state(None).unwrap();
}
#[test]
fn batch_builders_handle_empty_source_pool() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 301).unwrap());
let mut config = base_config();
config.batch_size = 2;
config.recipes = vec![TripletRecipe {
name: "empty_pool_triplet".into(),
anchor: Selector::Role(SectionRole::Anchor),
positive_selector: Selector::Role(SectionRole::Context),
negative_selector: Selector::Role(SectionRole::Context),
negative_strategy: NegativeStrategy::WrongArticle,
weight: 1.0,
instruction: None,
allow_same_anchor_positive: false,
}];
config.text_recipes = vec![TextRecipe {
name: "empty_pool_text".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let sampler = TripletSampler::new(config, store);
let result = sampler.next_text_batch(SplitLabel::Train);
assert!(result.is_err());
let result = sampler.next_text_batch_with_weights(SplitLabel::Train, &HashMap::new());
assert!(result.is_err());
}
#[test]
fn text_batch_inner_with_weights_early_return_when_nothing_new() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 302).unwrap());
let mut config = base_config();
config.batch_size = 2;
config.text_recipes = vec![TextRecipe {
name: "t_with_w".into(),
selector: Selector::Role(SectionRole::Context),
weight: 1.0,
instruction: None,
}];
let mut inner = TripletSamplerInner::new(config, Arc::clone(&store));
let records = vec![
trader_record("twr_1", "2025-01-01", "Title 1", "Body text one"),
trader_record("twr_2", "2025-01-02", "Title 2", "Body text two"),
];
for record in &records {
store
.upsert(
RecordKey::new(record.source.clone(), record.id.clone()),
SplitLabel::Train,
)
.unwrap();
inner.chunk_index.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
RecordKey::new(record.source.clone(), record.id.clone()),
);
inner.records.insert(
RecordKey::new(record.source.clone(), record.id.clone()),
Arc::new(record.clone()),
);
}
inner.epoch_tracker.ensure_loaded().unwrap();
let records_by_split = inner.records_by_split().unwrap();
inner
.epoch_tracker
.reconcile(SplitLabel::Train, &records_by_split);
let weights = HashMap::new();
let result = inner.next_text_batch_inner_with_weights(SplitLabel::Train, Some(&weights));
assert!(result.is_ok());
let batch1 = result.unwrap();
assert_eq!(batch1.samples.len(), 2);
let result2 = inner.next_text_batch_inner_with_weights(SplitLabel::Train, Some(&weights));
assert!(result2.is_ok() || matches!(result2, Err(SamplerError::Exhausted(_))));
}
#[test]
fn ingest_internal_for_split_no_sources_returns_ok() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 303).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let result = inner.ingest_internal(SplitLabel::Train);
assert!(
result.is_ok(),
"no-sources ingest should return Ok: {result:?}"
);
}
#[test]
fn ingest_internal_with_weights_no_sources_returns_ok() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 304).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let weights = HashMap::new();
let result = inner.ingest_internal_with_weights_for_split(SplitLabel::Train, &weights);
assert!(
result.is_ok(),
"no-sources weighted ingest should return Ok: {result:?}"
);
}
#[test]
fn force_ingest_refresh_with_weights_for_split_no_sources_returns_ok() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 305).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let weights = HashMap::new();
let result = inner.force_ingest_refresh_with_weights_for_split(SplitLabel::Train, &weights);
assert!(
result.is_ok(),
"no-sources force-refresh should return Ok: {result:?}"
);
}
#[test]
fn select_role_parallel_returns_none_when_no_matching_sections() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 306).unwrap());
let inner = TripletSamplerInner::new(base_config(), store);
let record = DataRecord {
id: "role_none".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "only anchor".into(),
sentences: vec![],
token_count: 2,
embedding: None,
}],
meta_prefix: None,
label: None,
};
let mut rng = DeterministicRng::new(42);
assert!(
inner
.select_role_parallel(&record, &SectionRole::Context, &mut rng)
.is_none()
);
}
#[test]
fn select_chunk_paragraph_returns_none_for_out_of_bounds_index() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 307).unwrap());
let mut inner = TripletSamplerInner::new(base_config(), store);
let record = DataRecord {
id: "para_oob".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "content".into(),
sentences: vec![],
token_count: 1,
embedding: None,
}],
meta_prefix: None,
label: None,
};
assert!(
inner
.select_chunk(&record, &Selector::Paragraph(5))
.is_none()
);
}
#[test]
fn record_has_at_least_two_window_chunks_returns_false_for_small_sections() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 308).unwrap());
let mut config = base_config();
config.chunking.max_window_tokens = 100;
let inner = TripletSamplerInner::new(config, store);
let record = DataRecord {
id: "small_sec".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Context,
heading: None,
text: "tiny".into(),
sentences: vec![],
token_count: 1,
embedding: None,
}],
meta_prefix: None,
label: None,
};
assert!(!inner.record_has_at_least_two_window_chunks_for_selector(
&record,
&Selector::Role(SectionRole::Context)
));
}
#[test]
fn record_has_at_least_two_window_chunks_returns_false_for_no_section_match() {
let split = SplitRatios::default();
let store = Arc::new(DeterministicSplitStore::new(split, 309).unwrap());
let mut config = base_config();
config.chunking.max_window_tokens = 100;
let inner = TripletSamplerInner::new(config, store);
let record = DataRecord {
id: "no_match".into(),
source: "unit".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
quality: QualityScore::default(),
taxonomy: vec![],
sections: vec![RecordSection {
role: SectionRole::Anchor,
heading: None,
text: "short".into(),
sentences: vec![],
token_count: 1,
embedding: None,
}],
meta_prefix: None,
label: None,
};
assert!(!inner.record_has_at_least_two_window_chunks_for_selector(
&record,
&Selector::Role(SectionRole::Context)
));
assert!(
!inner.record_has_at_least_two_window_chunks_for_selector(
&record,
&Selector::TemporalOffset(1)
)
);
}