1use serde::{Deserialize, Serialize};
2use simd_r_drive::storage_engine::DataStore;
3use simd_r_drive::storage_engine::traits::{DataStoreReader, DataStoreWriter};
4use siphasher::sip::SipHasher;
5use std::collections::HashMap;
6use std::fmt;
7use std::fs;
8use std::hash::{Hash, Hasher};
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12use tempfile::TempDir;
13
14use crate::constants::splits::{
15 ALL_SPLITS, BITCODE_PREFIX, EPOCH_HASH_RECORD_VERSION, EPOCH_HASHES_PREFIX, EPOCH_META_PREFIX,
16 EPOCH_META_RECORD_VERSION, EPOCH_RECORD_TOMBSTONE, META_KEY, SAMPLER_STATE_KEY,
17 SAMPLER_STATE_RECORD_VERSION, SPLIT_PREFIX, STORE_VERSION,
18};
19use crate::data::RecordId;
20use crate::errors::SamplerError;
21use crate::types::{RecordKey, SourceId};
22
23#[derive(
25 Clone,
26 Copy,
27 Debug,
28 PartialEq,
29 Eq,
30 Hash,
31 Serialize,
32 Deserialize,
33 bitcode::Encode,
34 bitcode::Decode,
35)]
36pub enum SplitLabel {
37 Train,
39 Validation,
41 Test,
43}
44
45#[derive(Clone, Copy, Debug, Serialize, Deserialize, bitcode::Encode, bitcode::Decode)]
47pub struct SplitRatios {
48 pub train: f32,
50 pub validation: f32,
52 pub test: f32,
54}
55
56impl Default for SplitRatios {
57 fn default() -> Self {
58 Self {
59 train: 0.8,
60 validation: 0.1,
61 test: 0.1,
62 }
63 }
64}
65
66impl SplitRatios {
67 pub fn normalized(self) -> Result<Self, SamplerError> {
69 let sum = self.train + self.validation + self.test;
70 if (sum - 1.0).abs() > 1e-6 {
71 return Err(SamplerError::Configuration(
72 "split ratios must sum to 1.0".to_string(),
73 ));
74 }
75 Ok(self)
76 }
77}
78
79pub use crate::constants::splits::EPOCH_STATE_VERSION;
80
81#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
83pub struct PersistedSplitMeta {
84 pub epoch: u64,
86 pub offset: u64,
88 pub hashes_checksum: u64,
90}
91
92#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
94pub struct PersistedSplitHashes {
95 pub checksum: u64,
97 pub hashes: Vec<u64>,
99}
100
101#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
110pub struct PersistedSamplerState {
111 pub source_cycle_idx: u64,
113 pub source_record_cursors: Vec<(SourceId, u64)>,
115 pub epoch: u64,
117 pub epoch_step: u64,
119 pub rng_state: u64,
121 pub triplet_recipe_rr_idx: u64,
123 pub text_recipe_rr_idx: u64,
125 pub source_stream_cursors: Vec<(SourceId, u64)>,
127}
128
129pub trait SplitStore: Send + Sync {
135 fn label_for(&self, key: &RecordKey) -> Option<SplitLabel>;
137 fn upsert(&self, key: RecordKey, label: SplitLabel) -> Result<(), SamplerError>;
139 fn ratios(&self) -> SplitRatios;
141 fn ensure(&self, key: RecordKey) -> Result<SplitLabel, SamplerError>;
143}
144
145pub trait EpochStateStore: Send + Sync {
147 fn load_epoch_meta(&self) -> Result<HashMap<SplitLabel, PersistedSplitMeta>, SamplerError>;
149 fn load_epoch_hashes(
151 &self,
152 label: SplitLabel,
153 ) -> Result<Option<PersistedSplitHashes>, SamplerError>;
154 fn save_epoch_meta(
156 &self,
157 meta: &HashMap<SplitLabel, PersistedSplitMeta>,
158 ) -> Result<(), SamplerError>;
159 fn save_epoch_hashes(
161 &self,
162 label: SplitLabel,
163 hashes: &PersistedSplitHashes,
164 ) -> Result<(), SamplerError>;
165}
166
167pub trait SamplerStateStore: Send + Sync {
169 fn load_sampler_state(&self) -> Result<Option<PersistedSamplerState>, SamplerError>;
171 fn save_sampler_state(
173 &self,
174 state: &PersistedSamplerState,
175 save_path: Option<&Path>,
176 ) -> Result<(), SamplerError>;
177}
178
179pub struct DeterministicSplitStore {
181 ratios: SplitRatios,
182 assignments: RwLock<HashMap<RecordKey, SplitLabel>>,
183 seed: u64,
184 epoch_meta: RwLock<HashMap<SplitLabel, PersistedSplitMeta>>,
185 epoch_hashes: RwLock<HashMap<SplitLabel, PersistedSplitHashes>>,
186 sampler_state: RwLock<Option<PersistedSamplerState>>,
187}
188
189impl DeterministicSplitStore {
190 pub fn new(ratios: SplitRatios, seed: u64) -> Result<Self, SamplerError> {
192 ratios.normalized()?;
193 Ok(Self {
194 ratios,
195 assignments: RwLock::new(HashMap::new()),
196 seed,
197 epoch_meta: RwLock::new(HashMap::new()),
198 epoch_hashes: RwLock::new(HashMap::new()),
199 sampler_state: RwLock::new(None),
200 })
201 }
202
203 fn derive_label(&self, key: &RecordKey) -> SplitLabel {
204 derive_label_for_key(key, self.seed, self.ratios)
205 }
206}
207
208impl SplitStore for DeterministicSplitStore {
209 fn label_for(&self, key: &RecordKey) -> Option<SplitLabel> {
210 if let Some(label) = self.assignments.read().ok()?.get(key).copied() {
211 return Some(label);
212 }
213 Some(self.derive_label(key))
214 }
215
216 fn upsert(&self, key: RecordKey, label: SplitLabel) -> Result<(), SamplerError> {
217 let mut guard = self
218 .assignments
219 .write()
220 .map_err(|_| SamplerError::SplitStore("lock poisoned".into()))?;
221 guard.insert(key, label);
222 Ok(())
223 }
224
225 fn ratios(&self) -> SplitRatios {
226 self.ratios
227 }
228
229 fn ensure(&self, key: RecordKey) -> Result<SplitLabel, SamplerError> {
230 Ok(self.derive_label(&key))
231 }
232}
233
234impl EpochStateStore for DeterministicSplitStore {
235 fn load_epoch_meta(&self) -> Result<HashMap<SplitLabel, PersistedSplitMeta>, SamplerError> {
236 self.epoch_meta
237 .read()
238 .map_err(|_| SamplerError::SplitStore("epoch meta lock poisoned".into()))
239 .map(|guard| guard.clone())
240 }
241
242 fn load_epoch_hashes(
243 &self,
244 label: SplitLabel,
245 ) -> Result<Option<PersistedSplitHashes>, SamplerError> {
246 Ok(self
247 .epoch_hashes
248 .read()
249 .map_err(|_| SamplerError::SplitStore("epoch hashes lock poisoned".into()))?
250 .get(&label)
251 .cloned())
252 }
253
254 fn save_epoch_meta(
255 &self,
256 meta: &HashMap<SplitLabel, PersistedSplitMeta>,
257 ) -> Result<(), SamplerError> {
258 *self
259 .epoch_meta
260 .write()
261 .map_err(|_| SamplerError::SplitStore("epoch meta lock poisoned".into()))? =
262 meta.clone();
263 Ok(())
264 }
265
266 fn save_epoch_hashes(
267 &self,
268 label: SplitLabel,
269 hashes: &PersistedSplitHashes,
270 ) -> Result<(), SamplerError> {
271 self.epoch_hashes
272 .write()
273 .map_err(|_| SamplerError::SplitStore("epoch hashes lock poisoned".into()))?
274 .insert(label, hashes.clone());
275 Ok(())
276 }
277}
278
279impl SamplerStateStore for DeterministicSplitStore {
280 fn load_sampler_state(&self) -> Result<Option<PersistedSamplerState>, SamplerError> {
281 self.sampler_state
282 .read()
283 .map_err(|_| SamplerError::SplitStore("sampler state lock poisoned".into()))
284 .map(|guard| guard.clone())
285 }
286
287 fn save_sampler_state(
288 &self,
289 state: &PersistedSamplerState,
290 _save_path: Option<&Path>,
291 ) -> Result<(), SamplerError> {
292 *self
293 .sampler_state
294 .write()
295 .map_err(|_| SamplerError::SplitStore("sampler state lock poisoned".into()))? =
296 Some(state.clone());
297 Ok(())
298 }
299}
300
301#[derive(Clone, Copy, Debug, bitcode::Encode, bitcode::Decode)]
302struct StoreMeta {
304 version: u8,
305 seed: u64,
306 ratios: SplitRatios,
307}
308
309fn encode_store_meta(meta: &StoreMeta) -> Vec<u8> {
310 encode_bitcode_payload(&bitcode::encode(meta))
311}
312
313fn decode_store_meta(bytes: &[u8]) -> Result<StoreMeta, SamplerError> {
314 let raw = decode_bitcode_payload(bytes)?;
315 bitcode::decode(&raw).map_err(|err| {
316 SamplerError::SplitStore(format!("failed to decode split store metadata: {err}"))
317 })
318}
319
320pub struct FileSplitStore {
336 store: DataStore,
337 path: PathBuf,
339 save_path: PathBuf,
341 ratios: SplitRatios,
342 seed: u64,
343 _temp_dir: TempDir,
345}
346
347impl fmt::Debug for FileSplitStore {
348 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349 f.debug_struct("FileSplitStore")
350 .field("path", &self.save_path)
351 .field("ratios", &self.ratios)
352 .field("seed", &self.seed)
353 .finish()
354 }
355}
356
357impl FileSplitStore {
358 pub fn open<P: Into<PathBuf>>(
360 path: P,
361 ratios: SplitRatios,
362 seed: u64,
363 ) -> Result<Self, SamplerError> {
364 Self::open_with_load_path(None::<PathBuf>, path, ratios, seed)
365 }
366
367 pub fn open_with_load_path<LP: Into<PathBuf>, SP: Into<PathBuf>>(
381 load_path: Option<LP>,
382 save_path: SP,
383 ratios: SplitRatios,
384 seed: u64,
385 ) -> Result<Self, SamplerError> {
386 let ratios = ratios.normalized()?;
387 let save_path = coerce_store_path(save_path.into());
388 ensure_parent_dir(&save_path)?;
389
390 let source: Option<PathBuf> = if let Some(lp) = load_path {
393 let lp = coerce_store_path(lp.into());
394 if lp.exists() { Some(lp) } else { None }
395 } else if save_path.exists() {
396 Some(save_path.clone())
397 } else {
398 None
399 };
400
401 let temp_dir = tempfile::tempdir().map_err(|err| {
402 SamplerError::SplitStore(format!("failed to create temp dir for split store: {err}"))
403 })?;
404 let working_path = temp_dir.path().join("working_store.bin");
405 if let Some(src) = &source {
406 fs::copy(src, &working_path).map_err(|err| {
407 SamplerError::SplitStore(format!(
408 "failed to copy split store from '{}' to temp: {err}",
409 src.display()
410 ))
411 })?;
412 }
413
414 let raw_store = DataStore::open(&working_path).map_err(map_store_err)?;
415 let store = Self {
416 store: raw_store,
417 path: working_path,
418 save_path,
419 ratios,
420 seed,
421 _temp_dir: temp_dir,
422 };
423 store.verify_metadata()?;
424 Ok(store)
425 }
426
427 fn verify_metadata(&self) -> Result<(), SamplerError> {
428 match read_bytes(&self.store, META_KEY)? {
429 Some(bytes) => {
430 let meta = decode_store_meta(&bytes)?;
431 if meta.version != STORE_VERSION {
432 return Err(SamplerError::SplitStore(format!(
433 "split store version mismatch (expected {}, found {})",
434 STORE_VERSION, meta.version
435 )));
436 }
437 if meta.seed != self.seed {
438 return Err(SamplerError::SplitStore(format!(
439 "split store seed mismatch (expected {}, found {})",
440 self.seed, meta.seed
441 )));
442 }
443 if !ratios_close(meta.ratios, self.ratios) {
444 return Err(SamplerError::SplitStore(
445 "split store ratios mismatch".into(),
446 ));
447 }
448 }
449 None => {
450 let blob = StoreMeta {
451 version: STORE_VERSION,
452 seed: self.seed,
453 ratios: self.ratios,
454 };
455 let payload = encode_store_meta(&blob);
456 write_bytes(&self.store, META_KEY, &payload)?;
457 }
458 }
459 Ok(())
460 }
461
462 fn read_epoch_meta_entry(
463 &self,
464 label: SplitLabel,
465 ) -> Result<Option<PersistedSplitMeta>, SamplerError> {
466 let key = epoch_meta_key(label);
467 let entry = self.store.read(&key).map_err(map_store_err)?;
468 match entry {
469 None => Ok(None),
470 Some(bytes) => decode_epoch_meta(bytes.as_ref()),
471 }
472 }
473
474 fn write_epoch_meta_entry(
475 &self,
476 label: SplitLabel,
477 meta: Option<&PersistedSplitMeta>,
478 ) -> Result<(), SamplerError> {
479 let key = epoch_meta_key(label);
480 let payload = encode_epoch_meta(meta);
481 self.store
482 .write(&key, payload.as_slice())
483 .map_err(map_store_err)?;
484 Ok(())
485 }
486
487 fn read_epoch_hashes_entry(
488 &self,
489 label: SplitLabel,
490 ) -> Result<Option<PersistedSplitHashes>, SamplerError> {
491 let key = epoch_hashes_key(label);
492 let entry = self.store.read(&key).map_err(map_store_err)?;
493 match entry {
494 None => Ok(None),
495 Some(bytes) => decode_epoch_hashes(bytes.as_ref()),
496 }
497 }
498
499 fn write_epoch_hashes_entry(
500 &self,
501 label: SplitLabel,
502 hashes: &PersistedSplitHashes,
503 ) -> Result<(), SamplerError> {
504 let key = epoch_hashes_key(label);
505 let payload = encode_epoch_hashes(hashes);
506 self.store
507 .write(&key, payload.as_slice())
508 .map_err(map_store_err)?;
509 Ok(())
510 }
511}
512
513impl SplitStore for FileSplitStore {
514 fn label_for(&self, key: &RecordKey) -> Option<SplitLabel> {
515 let store_key = split_key(&key.source, &key.id);
516 if let Ok(Some(value)) = self.store.read(&store_key)
517 && let Ok(label) = decode_label(value.as_ref())
518 {
519 return Some(label);
520 }
521 Some(derive_label_for_key(key, self.seed, self.ratios))
522 }
523
524 fn upsert(&self, key: RecordKey, label: SplitLabel) -> Result<(), SamplerError> {
525 let _ = (key, label);
526 Ok(())
527 }
528
529 fn ratios(&self) -> SplitRatios {
530 self.ratios
531 }
532
533 fn ensure(&self, key: RecordKey) -> Result<SplitLabel, SamplerError> {
534 Ok(derive_label_for_key(&key, self.seed, self.ratios))
535 }
536}
537
538impl EpochStateStore for FileSplitStore {
539 fn load_epoch_meta(&self) -> Result<HashMap<SplitLabel, PersistedSplitMeta>, SamplerError> {
540 let mut meta = HashMap::new();
541 for label in ALL_SPLITS {
542 if let Some(entry) = self.read_epoch_meta_entry(label)? {
543 meta.insert(label, entry);
544 }
545 }
546 Ok(meta)
547 }
548
549 fn load_epoch_hashes(
550 &self,
551 label: SplitLabel,
552 ) -> Result<Option<PersistedSplitHashes>, SamplerError> {
553 self.read_epoch_hashes_entry(label)
554 }
555
556 fn save_epoch_meta(
557 &self,
558 meta: &HashMap<SplitLabel, PersistedSplitMeta>,
559 ) -> Result<(), SamplerError> {
560 for label in ALL_SPLITS {
561 self.write_epoch_meta_entry(label, meta.get(&label))?;
562 }
563 Ok(())
564 }
565
566 fn save_epoch_hashes(
567 &self,
568 label: SplitLabel,
569 hashes: &PersistedSplitHashes,
570 ) -> Result<(), SamplerError> {
571 self.write_epoch_hashes_entry(label, hashes)
572 }
573}
574
575impl SamplerStateStore for FileSplitStore {
576 fn load_sampler_state(&self) -> Result<Option<PersistedSamplerState>, SamplerError> {
577 match read_bytes(&self.store, SAMPLER_STATE_KEY)? {
578 Some(bytes) => decode_sampler_state(bytes.as_ref()),
579 None => Ok(None),
580 }
581 }
582
583 fn save_sampler_state(
589 &self,
590 state: &PersistedSamplerState,
591 save_to: Option<&Path>,
592 ) -> Result<(), SamplerError> {
593 let dest = if let Some(p) = save_to {
595 coerce_store_path(p.to_path_buf())
596 } else {
597 self.save_path.clone()
598 };
599
600 if save_to.is_some() && dest.exists() {
603 return Err(SamplerError::SplitStore(format!(
604 "refusing to overwrite existing split store '{}'; choose a new path",
605 dest.display()
606 )));
607 }
608
609 let payload = encode_sampler_state(state);
611 write_bytes(&self.store, SAMPLER_STATE_KEY, &payload)?;
612
613 ensure_parent_dir(&dest)?;
615 fs::copy(&self.path, &dest).map_err(|err| {
616 SamplerError::SplitStore(format!(
617 "failed to publish split store to '{}': {err}",
618 dest.display()
619 ))
620 })?;
621
622 Ok(())
623 }
624}
625
626fn decode_label(bytes: &[u8]) -> Result<SplitLabel, SamplerError> {
627 match bytes.first() {
628 Some(b'0') => Ok(SplitLabel::Train),
629 Some(b'1') => Ok(SplitLabel::Validation),
630 Some(b'2') => Ok(SplitLabel::Test),
631 _ => Err(SamplerError::SplitStore("invalid split label".into())),
632 }
633}
634
635fn derive_label_for_key(key: &RecordKey, seed: u64, ratios: SplitRatios) -> SplitLabel {
636 derive_label_for_id(&key.id, seed, ratios)
641}
642
643fn derive_label_for_id(id: &RecordId, seed: u64, ratios: SplitRatios) -> SplitLabel {
644 let mut hasher = SipHasher::new();
645 id.hash(&mut hasher);
646 seed.hash(&mut hasher);
647 let value = hasher.finish() as f64 / u64::MAX as f64;
648 let train_cut = ratios.train as f64;
649 let val_cut = train_cut + ratios.validation as f64;
650 if value < train_cut {
651 SplitLabel::Train
652 } else if value < val_cut {
653 SplitLabel::Validation
654 } else {
655 SplitLabel::Test
656 }
657}
658
659fn ratios_close(a: SplitRatios, b: SplitRatios) -> bool {
660 ((a.train - b.train).abs() + (a.validation - b.validation).abs() + (a.test - b.test).abs())
661 < 1e-5
662}
663
664fn split_key(source: &SourceId, id: &RecordId) -> Vec<u8> {
665 let mut key = Vec::with_capacity(SPLIT_PREFIX.len() + source.len() + 1 + id.len());
666 key.extend_from_slice(SPLIT_PREFIX);
667 key.extend_from_slice(source.as_bytes());
668 key.push(0);
669 key.extend_from_slice(id.as_bytes());
670 key
671}
672
673fn read_bytes(store: &DataStore, key: &[u8]) -> Result<Option<Vec<u8>>, SamplerError> {
674 store
675 .read(key)
676 .map_err(map_store_err)?
677 .map(|entry| Ok(entry.as_ref().to_vec()))
678 .transpose()
679}
680
681fn write_bytes(store: &DataStore, key: &[u8], payload: &[u8]) -> Result<(), SamplerError> {
682 store.write(key, payload).map_err(map_store_err)?;
683 Ok(())
684}
685
686fn epoch_meta_key(label: SplitLabel) -> Vec<u8> {
687 let mut key = Vec::with_capacity(EPOCH_META_PREFIX.len() + 12);
688 key.extend_from_slice(EPOCH_META_PREFIX);
689 let suffix = match label {
690 SplitLabel::Train => b"train".as_ref(),
691 SplitLabel::Validation => b"validation".as_ref(),
692 SplitLabel::Test => b"test".as_ref(),
693 };
694 key.extend_from_slice(suffix);
695 key
696}
697
698fn epoch_hashes_key(label: SplitLabel) -> Vec<u8> {
699 let mut key = Vec::with_capacity(EPOCH_HASHES_PREFIX.len() + 12);
700 key.extend_from_slice(EPOCH_HASHES_PREFIX);
701 let suffix = match label {
702 SplitLabel::Train => b"train".as_ref(),
703 SplitLabel::Validation => b"validation".as_ref(),
704 SplitLabel::Test => b"test".as_ref(),
705 };
706 key.extend_from_slice(suffix);
707 key
708}
709
710fn encode_epoch_meta(meta: Option<&PersistedSplitMeta>) -> Vec<u8> {
711 match meta {
712 None => vec![EPOCH_RECORD_TOMBSTONE],
713 Some(meta) => {
714 let payload = encode_bitcode_payload(&bitcode::encode(meta));
715 let mut buf = Vec::with_capacity(1 + payload.len());
716 buf.push(EPOCH_META_RECORD_VERSION);
717 buf.extend_from_slice(&payload);
718 buf
719 }
720 }
721}
722
723fn decode_epoch_meta(bytes: &[u8]) -> Result<Option<PersistedSplitMeta>, SamplerError> {
724 if bytes.is_empty() || bytes[0] == EPOCH_RECORD_TOMBSTONE {
725 return Ok(None);
726 }
727 if bytes[0] != EPOCH_META_RECORD_VERSION {
728 return Err(SamplerError::SplitStore(
729 "epoch meta record version mismatch".into(),
730 ));
731 }
732 let raw = decode_bitcode_payload(&bytes[1..])?;
733 bitcode::decode(&raw)
734 .map(Some)
735 .map_err(|err| SamplerError::SplitStore(format!("corrupt epoch meta record: {err}")))
736}
737
738fn encode_epoch_hashes(hashes: &PersistedSplitHashes) -> Vec<u8> {
739 let payload = encode_bitcode_payload(&bitcode::encode(hashes));
740 let mut buf = Vec::with_capacity(1 + payload.len());
741 buf.push(EPOCH_HASH_RECORD_VERSION);
742 buf.extend_from_slice(&payload);
743 buf
744}
745
746fn decode_epoch_hashes(bytes: &[u8]) -> Result<Option<PersistedSplitHashes>, SamplerError> {
747 if bytes.is_empty() || bytes[0] == EPOCH_RECORD_TOMBSTONE {
748 return Ok(None);
749 }
750 if bytes[0] != EPOCH_HASH_RECORD_VERSION {
751 return Err(SamplerError::SplitStore(
752 "epoch hashes record version mismatch".into(),
753 ));
754 }
755 let raw = decode_bitcode_payload(&bytes[1..])?;
756 bitcode::decode(&raw)
757 .map(Some)
758 .map_err(|err| SamplerError::SplitStore(format!("corrupt epoch hashes record: {err}")))
759}
760
761fn encode_sampler_state(state: &PersistedSamplerState) -> Vec<u8> {
762 let payload = encode_bitcode_payload(&bitcode::encode(state));
763 let mut buf = Vec::with_capacity(1 + payload.len());
764 buf.push(SAMPLER_STATE_RECORD_VERSION);
765 buf.extend_from_slice(&payload);
766 buf
767}
768
769fn decode_sampler_state(bytes: &[u8]) -> Result<Option<PersistedSamplerState>, SamplerError> {
770 if bytes.is_empty() {
771 return Ok(None);
772 }
773 if bytes[0] != SAMPLER_STATE_RECORD_VERSION {
774 return Err(SamplerError::SplitStore(
775 "sampler state record version mismatch".into(),
776 ));
777 }
778 let raw = decode_bitcode_payload(&bytes[1..])?;
779 bitcode::decode(&raw)
780 .map(Some)
781 .map_err(|err| SamplerError::SplitStore(format!("corrupt sampler state record: {err}")))
782}
783
784fn encode_bitcode_payload(bytes: &[u8]) -> Vec<u8> {
785 let mut out = Vec::with_capacity(1 + bytes.len());
786 out.push(BITCODE_PREFIX);
787 out.extend_from_slice(bytes);
788 out
789}
790
791fn decode_bitcode_payload(bytes: &[u8]) -> Result<Vec<u8>, SamplerError> {
792 if bytes.first().copied() != Some(BITCODE_PREFIX) {
793 return Err(SamplerError::SplitStore(
794 "bitcode payload missing expected prefix".into(),
795 ));
796 }
797 Ok(bytes[1..].to_vec())
798}
799
800fn coerce_store_path(path: PathBuf) -> PathBuf {
801 path
802}
803
804fn ensure_parent_dir(path: &Path) -> Result<(), SamplerError> {
805 if let Some(parent) = path.parent()
806 && !parent.as_os_str().is_empty()
807 {
808 fs::create_dir_all(parent)?;
809 }
810 Ok(())
811}
812
813fn map_store_err(err: io::Error) -> SamplerError {
814 SamplerError::SplitStore(err.to_string())
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use std::collections::HashMap;
821 use tempfile::tempdir;
822
823 #[test]
824 fn split_ratios_reject_non_unit_sum() {
825 let invalid = SplitRatios {
826 train: 0.6,
827 validation: 0.3,
828 test: 0.3,
829 };
830
831 let err = DeterministicSplitStore::new(invalid, 1)
832 .err()
833 .expect("expected non-unit split ratios to fail");
834 assert!(matches!(
835 err,
836 SamplerError::Configuration(ref msg) if msg.contains("split ratios must sum to 1.0")
837 ));
838
839 let dir = tempdir().unwrap();
840 let path = dir.path().join("split_store.bin");
841 let err = FileSplitStore::open(&path, invalid, 1).unwrap_err();
842 assert!(matches!(
843 err,
844 SamplerError::Configuration(ref msg) if msg.contains("split ratios must sum to 1.0")
845 ));
846 }
847
848 #[test]
849 fn zero_test_ratio_never_assigns_test_labels() {
850 let ratios = SplitRatios {
851 train: 0.5,
852 validation: 0.5,
853 test: 0.0,
854 };
855 let store = DeterministicSplitStore::new(ratios, 7).unwrap();
856
857 let mut saw_train = false;
858 let mut saw_validation = false;
859 for idx in 0..20_000 {
860 let key = RecordKey::new("src", format!("record_{idx}"));
861 let label = store.ensure(key).unwrap();
862 assert_ne!(label, SplitLabel::Test);
863 saw_train |= label == SplitLabel::Train;
864 saw_validation |= label == SplitLabel::Validation;
865 if saw_train && saw_validation {
866 break;
867 }
868 }
869
870 assert!(saw_train);
871 assert!(saw_validation);
872 }
873
874 #[test]
877 fn save_none_publishes_to_save_path_and_reloads_cleanly() {
878 let dir = tempdir().unwrap();
879 let path = dir.path().join("persist.bin");
880 let ratios = SplitRatios::default();
881
882 {
883 let store = FileSplitStore::open(&path, ratios, 123).unwrap();
884 let mut meta = HashMap::new();
885 meta.insert(
886 SplitLabel::Train,
887 PersistedSplitMeta {
888 epoch: 3,
889 offset: 7,
890 hashes_checksum: 42,
891 },
892 );
893 store.save_epoch_meta(&meta).unwrap();
894 let state = PersistedSamplerState {
895 source_cycle_idx: 11,
896 source_record_cursors: vec![("s".to_string(), 1)],
897 epoch: 5,
898 epoch_step: 0,
899 rng_state: 99,
900 triplet_recipe_rr_idx: 2,
901 text_recipe_rr_idx: 3,
902 source_stream_cursors: vec![],
903 };
904 assert!(!path.exists(), "save_path must not exist before save(None)");
905 store.save_sampler_state(&state, None).unwrap();
906 assert!(
907 path.exists(),
908 "save(None) must publish to save_path on disk"
909 );
910 }
911
912 let reopened = FileSplitStore::open(&path, ratios, 123).unwrap();
914 let loaded_state = reopened.load_sampler_state().unwrap().unwrap();
915 assert_eq!(loaded_state.source_cycle_idx, 11);
916 assert_eq!(loaded_state.epoch, 5);
917 assert_eq!(loaded_state.rng_state, 99);
918 let loaded_meta = reopened.load_epoch_meta().unwrap();
919 assert_eq!(loaded_meta.get(&SplitLabel::Train).unwrap().epoch, 3);
920 }
921
922 #[test]
923 fn file_store_rejects_seed_mismatch() {
924 let dir = tempdir().unwrap();
925 let path = dir.path().join("splits.json");
926 let ratios = SplitRatios::default();
927 let store = FileSplitStore::open(&path, ratios, 123).unwrap();
928 store.ensure(RecordKey::new("src", "abc")).unwrap();
929 store
931 .save_sampler_state(
932 &PersistedSamplerState {
933 source_cycle_idx: 0,
934 source_record_cursors: vec![],
935 epoch: 0,
936 epoch_step: 0,
937 rng_state: 0,
938 triplet_recipe_rr_idx: 0,
939 text_recipe_rr_idx: 0,
940 source_stream_cursors: vec![],
941 },
942 None,
943 )
944 .unwrap();
945 drop(store);
946
947 let err = FileSplitStore::open(&path, ratios, 999).unwrap_err();
948 assert!(matches!(
949 err,
950 SamplerError::SplitStore(msg) if msg.contains("seed")
951 ));
952 }
953
954 #[test]
955 fn file_store_accepts_directory_path() {
956 let dir = tempdir().unwrap();
957 let ratios = SplitRatios::default();
958 let err = FileSplitStore::open(dir.path(), ratios, 777).unwrap_err();
959 assert!(matches!(err, SamplerError::SplitStore(_)));
960 }
961
962 #[test]
963 fn bitcode_payload_requires_prefix() {
964 let err = decode_bitcode_payload(&[0x00, 0x01]).unwrap_err();
965 assert!(
966 matches!(err, SamplerError::SplitStore(msg) if msg.contains("missing expected prefix"))
967 );
968 }
969
970 #[test]
971 fn file_store_round_trips_epoch_and_sampler_state() {
972 let dir = tempdir().unwrap();
973 let path = dir.path().join("epoch_sampler_state.bin");
974 let store = FileSplitStore::open(&path, SplitRatios::default(), 222).unwrap();
975
976 assert!(store.load_epoch_hashes(SplitLabel::Test).unwrap().is_none());
977
978 let mut epoch_meta = HashMap::new();
979 epoch_meta.insert(
980 SplitLabel::Train,
981 PersistedSplitMeta {
982 epoch: 3,
983 offset: 7,
984 hashes_checksum: 42,
985 },
986 );
987 store.save_epoch_meta(&epoch_meta).unwrap();
988
989 let loaded_meta = store.load_epoch_meta().unwrap();
990 let loaded_train = loaded_meta.get(&SplitLabel::Train).unwrap();
991 assert_eq!(loaded_train.epoch, 3);
992 assert_eq!(loaded_train.offset, 7);
993 assert_eq!(loaded_train.hashes_checksum, 42);
994
995 let hashes = PersistedSplitHashes {
996 checksum: 99,
997 hashes: vec![10, 20, 30],
998 };
999 store
1000 .save_epoch_hashes(SplitLabel::Validation, &hashes)
1001 .unwrap();
1002 let loaded_hashes = store
1003 .load_epoch_hashes(SplitLabel::Validation)
1004 .unwrap()
1005 .unwrap();
1006 assert_eq!(loaded_hashes.checksum, 99);
1007 assert_eq!(loaded_hashes.hashes, vec![10, 20, 30]);
1008
1009 let state = PersistedSamplerState {
1010 source_cycle_idx: 11,
1011 source_record_cursors: vec![("source_a".to_string(), 1)],
1012 epoch: 8,
1013 epoch_step: 0,
1014 rng_state: 1234,
1015 triplet_recipe_rr_idx: 2,
1016 text_recipe_rr_idx: 5,
1017 source_stream_cursors: vec![("source_a".to_string(), 9)],
1018 };
1019 store.save_sampler_state(&state, None).unwrap();
1020 let loaded_state = store.load_sampler_state().unwrap().unwrap();
1021 assert_eq!(loaded_state.source_cycle_idx, 11);
1022 assert_eq!(loaded_state.epoch, 8);
1023 assert_eq!(loaded_state.rng_state, 1234);
1024 assert_eq!(loaded_state.triplet_recipe_rr_idx, 2);
1025 assert_eq!(loaded_state.text_recipe_rr_idx, 5);
1026 assert_eq!(
1027 loaded_state.source_record_cursors,
1028 vec![("source_a".to_string(), 1)]
1029 );
1030 assert_eq!(
1031 loaded_state.source_stream_cursors,
1032 vec![("source_a".to_string(), 9)]
1033 );
1034
1035 drop(store);
1037 let reopened = FileSplitStore::open(&path, SplitRatios::default(), 222).unwrap();
1038 let disk_state = reopened.load_sampler_state().unwrap().unwrap();
1039 assert_eq!(disk_state.source_cycle_idx, 11);
1040 assert_eq!(disk_state.epoch, 8);
1041 assert_eq!(disk_state.rng_state, 1234);
1042 let disk_meta = reopened.load_epoch_meta().unwrap();
1043 assert_eq!(disk_meta.get(&SplitLabel::Train).unwrap().epoch, 3);
1044 let disk_hashes = reopened
1045 .load_epoch_hashes(SplitLabel::Validation)
1046 .unwrap()
1047 .unwrap();
1048 assert_eq!(disk_hashes.checksum, 99);
1049 }
1050
1051 #[test]
1052 fn split_keys_and_labels_cover_helper_paths() {
1053 let key = split_key(&"src".to_string(), &"abc".to_string());
1054 assert!(key.starts_with(SPLIT_PREFIX));
1055
1056 assert!(matches!(decode_label(b"0"), Ok(SplitLabel::Train)));
1057 assert!(matches!(decode_label(b"1"), Ok(SplitLabel::Validation)));
1058 assert!(matches!(decode_label(b"2"), Ok(SplitLabel::Test)));
1059 assert!(decode_label(b"x").is_err());
1060
1061 let epoch_meta_train = epoch_meta_key(SplitLabel::Train);
1062 let epoch_hashes_train = epoch_hashes_key(SplitLabel::Train);
1063 let epoch_hashes_test = epoch_hashes_key(SplitLabel::Test);
1064 assert!(epoch_meta_train.starts_with(EPOCH_META_PREFIX));
1065 assert!(epoch_hashes_train.starts_with(EPOCH_HASHES_PREFIX));
1066 assert!(epoch_hashes_test.starts_with(EPOCH_HASHES_PREFIX));
1067 }
1068
1069 #[test]
1070 fn encode_decode_store_meta_roundtrip_and_corrupt_prefix_error() {
1071 let meta = StoreMeta {
1072 version: STORE_VERSION,
1073 seed: 55,
1074 ratios: SplitRatios::default(),
1075 };
1076 let encoded = encode_store_meta(&meta);
1077 let decoded = decode_store_meta(&encoded).unwrap();
1078 assert_eq!(decoded.version, STORE_VERSION);
1079 assert_eq!(decoded.seed, 55);
1080
1081 let err = decode_store_meta(&[0x00, 0x01]).unwrap_err();
1082 assert!(matches!(
1083 err,
1084 SamplerError::SplitStore(msg) if msg.contains("missing expected prefix")
1085 ));
1086 }
1087
1088 #[test]
1089 fn epoch_and_sampler_decoders_cover_tombstone_and_version_mismatch() {
1090 assert!(decode_epoch_meta(&[]).unwrap().is_none());
1091 assert!(
1092 decode_epoch_meta(&[EPOCH_RECORD_TOMBSTONE])
1093 .unwrap()
1094 .is_none()
1095 );
1096 assert!(decode_epoch_hashes(&[]).unwrap().is_none());
1097 assert!(
1098 decode_epoch_hashes(&[EPOCH_RECORD_TOMBSTONE])
1099 .unwrap()
1100 .is_none()
1101 );
1102 assert!(decode_sampler_state(&[]).unwrap().is_none());
1103
1104 let meta_mismatch = decode_epoch_meta(&[EPOCH_META_RECORD_VERSION.wrapping_add(1), 1]);
1105 assert!(matches!(
1106 meta_mismatch,
1107 Err(SamplerError::SplitStore(msg)) if msg.contains("version mismatch")
1108 ));
1109 let hashes_mismatch = decode_epoch_hashes(&[EPOCH_HASH_RECORD_VERSION.wrapping_add(1), 1]);
1110 assert!(matches!(
1111 hashes_mismatch,
1112 Err(SamplerError::SplitStore(msg)) if msg.contains("version mismatch")
1113 ));
1114 let state_mismatch =
1115 decode_sampler_state(&[SAMPLER_STATE_RECORD_VERSION.wrapping_add(1), 1]);
1116 assert!(matches!(
1117 state_mismatch,
1118 Err(SamplerError::SplitStore(msg)) if msg.contains("version mismatch")
1119 ));
1120 }
1121
1122 #[test]
1123 fn split_store_trait_methods_and_path_helpers_are_exercised() {
1124 let dir = tempdir().unwrap();
1125 let file_path = dir.path().join("nested").join("store.bin");
1126 ensure_parent_dir(&file_path).unwrap();
1127 assert!(file_path.parent().unwrap().exists());
1128
1129 let existing_dir_path = coerce_store_path(dir.path().to_path_buf());
1130 assert_eq!(existing_dir_path, dir.path().to_path_buf());
1131
1132 let ratios = SplitRatios::default();
1133 let store = FileSplitStore::open(&file_path, ratios, 444).unwrap();
1134 assert!((store.ratios().train - ratios.train).abs() < 1e-6);
1135 store
1136 .upsert(RecordKey::new("src", "record_1"), SplitLabel::Validation)
1137 .unwrap();
1138 let ensured = store.ensure(RecordKey::new("src", "record_1")).unwrap();
1139 assert!(matches!(
1140 ensured,
1141 SplitLabel::Train | SplitLabel::Validation | SplitLabel::Test
1142 ));
1143
1144 let mapped = map_store_err(io::Error::other("boom"));
1145 assert!(matches!(mapped, SamplerError::SplitStore(msg) if msg.contains("boom")));
1146 }
1147
1148 #[test]
1149 fn epoch_and_sampler_encode_decode_roundtrips() {
1150 let meta = PersistedSplitMeta {
1151 epoch: 4,
1152 offset: 9,
1153 hashes_checksum: 21,
1154 };
1155 let encoded_meta = encode_epoch_meta(Some(&meta));
1156 let decoded_meta = decode_epoch_meta(&encoded_meta).unwrap().unwrap();
1157 assert_eq!(decoded_meta.epoch, 4);
1158 assert_eq!(decoded_meta.offset, 9);
1159
1160 let hashes = PersistedSplitHashes {
1161 checksum: 7,
1162 hashes: vec![1, 2, 3],
1163 };
1164 let encoded_hashes = encode_epoch_hashes(&hashes);
1165 let decoded_hashes = decode_epoch_hashes(&encoded_hashes).unwrap().unwrap();
1166 assert_eq!(decoded_hashes.checksum, 7);
1167 assert_eq!(decoded_hashes.hashes, vec![1, 2, 3]);
1168
1169 let state = PersistedSamplerState {
1170 source_cycle_idx: 1,
1171 source_record_cursors: vec![("s".to_string(), 2)],
1172 epoch: 3,
1173 epoch_step: 0,
1174 rng_state: 4,
1175 triplet_recipe_rr_idx: 5,
1176 text_recipe_rr_idx: 6,
1177 source_stream_cursors: vec![("s".to_string(), 7)],
1178 };
1179 let encoded_state = encode_sampler_state(&state);
1180 let decoded_state = decode_sampler_state(&encoded_state).unwrap().unwrap();
1181 assert_eq!(decoded_state.source_cycle_idx, 1);
1182 assert_eq!(decoded_state.epoch, 3);
1183 assert_eq!(decoded_state.rng_state, 4);
1184 }
1185
1186 #[test]
1187 fn deterministic_store_trait_methods_work() {
1188 let ratios = SplitRatios::default();
1189 let store = DeterministicSplitStore::new(ratios, 999).unwrap();
1190
1191 assert_eq!(store.ratios().train, ratios.train);
1192
1193 let key = RecordKey::new("src", "source::record");
1194 let derived = store.label_for(&key).unwrap();
1195 store.upsert(key.clone(), SplitLabel::Validation).unwrap();
1196 assert_eq!(store.label_for(&key), Some(SplitLabel::Validation));
1197 assert!(matches!(
1198 derived,
1199 SplitLabel::Train | SplitLabel::Validation | SplitLabel::Test
1200 ));
1201
1202 let mut meta = HashMap::new();
1203 meta.insert(
1204 SplitLabel::Test,
1205 PersistedSplitMeta {
1206 epoch: 1,
1207 offset: 2,
1208 hashes_checksum: 3,
1209 },
1210 );
1211 store.save_epoch_meta(&meta).unwrap();
1212 let loaded_meta = store.load_epoch_meta().unwrap();
1213 assert_eq!(loaded_meta.get(&SplitLabel::Test).unwrap().offset, 2);
1214
1215 assert!(
1216 store
1217 .load_epoch_hashes(SplitLabel::Train)
1218 .unwrap()
1219 .is_none()
1220 );
1221 store
1222 .save_epoch_hashes(
1223 SplitLabel::Train,
1224 &PersistedSplitHashes {
1225 checksum: 11,
1226 hashes: vec![4, 5],
1227 },
1228 )
1229 .unwrap();
1230 assert_eq!(
1231 store
1232 .load_epoch_hashes(SplitLabel::Train)
1233 .unwrap()
1234 .unwrap()
1235 .checksum,
1236 11
1237 );
1238
1239 assert!(store.load_sampler_state().unwrap().is_none());
1240 let sampler_state = PersistedSamplerState {
1241 source_cycle_idx: 1,
1242 source_record_cursors: vec![("s1".to_string(), 2)],
1243 epoch: 3,
1244 epoch_step: 0,
1245 rng_state: 4,
1246 triplet_recipe_rr_idx: 5,
1247 text_recipe_rr_idx: 6,
1248 source_stream_cursors: vec![("s1".to_string(), 7)],
1249 };
1250 store.save_sampler_state(&sampler_state, None).unwrap();
1251 assert_eq!(
1252 store.load_sampler_state().unwrap().unwrap().epoch,
1253 sampler_state.epoch
1254 );
1255 }
1256
1257 #[test]
1258 fn open_with_load_path_bootstraps_state_explicitly() {
1259 let dir = tempdir().unwrap();
1260 let path_a = dir.path().join("snapshot_a.bin");
1261 let path_b = dir.path().join("snapshot_b.bin");
1262 let ratios = SplitRatios::default();
1263
1264 let store_a = FileSplitStore::open(&path_a, ratios, 42).unwrap();
1265
1266 let mut meta = HashMap::new();
1267 meta.insert(
1268 SplitLabel::Train,
1269 PersistedSplitMeta {
1270 epoch: 5,
1271 offset: 3,
1272 hashes_checksum: 999,
1273 },
1274 );
1275 store_a.save_epoch_meta(&meta).unwrap();
1276
1277 let sampler_state = PersistedSamplerState {
1278 source_cycle_idx: 1,
1279 source_record_cursors: vec![("s1".to_string(), 2)],
1280 epoch: 7,
1281 epoch_step: 0,
1282 rng_state: 123,
1283 triplet_recipe_rr_idx: 4,
1284 text_recipe_rr_idx: 6,
1285 source_stream_cursors: vec![("s1".to_string(), 8)],
1286 };
1287 store_a.save_sampler_state(&sampler_state, None).unwrap();
1288 drop(store_a);
1289
1290 let store_b =
1291 FileSplitStore::open_with_load_path(Some(path_a.clone()), &path_b, ratios, 42).unwrap();
1292 assert_eq!(
1293 store_b
1294 .load_epoch_meta()
1295 .unwrap()
1296 .get(&SplitLabel::Train)
1297 .unwrap()
1298 .epoch,
1299 5
1300 );
1301 assert_eq!(store_b.load_sampler_state().unwrap().unwrap().epoch, 7);
1302
1303 let store_a_again = FileSplitStore::open(&path_a, ratios, 42).unwrap();
1304 assert_eq!(
1305 store_a_again
1306 .load_epoch_meta()
1307 .unwrap()
1308 .get(&SplitLabel::Train)
1309 .unwrap()
1310 .epoch,
1311 5
1312 );
1313 assert_eq!(
1314 store_a_again.load_sampler_state().unwrap().unwrap().epoch,
1315 7
1316 );
1317 }
1318
1319 #[test]
1320 fn save_sampler_state_to_new_path_copies_existing_store_first() {
1321 let dir = tempdir().unwrap();
1322 let path_a = dir.path().join("source_store.bin");
1323 let path_b = dir.path().join("mirror_store.bin");
1324 let ratios = SplitRatios::default();
1325
1326 let store_a = FileSplitStore::open(&path_a, ratios, 42).unwrap();
1327
1328 let assigned_id = "record_with_assignment".to_string();
1329 let assigned_key = split_key(&"src".to_string(), &assigned_id);
1330 store_a.store.write(&assigned_key, b"1").unwrap();
1331
1332 let sampler_state = PersistedSamplerState {
1333 source_cycle_idx: 1,
1334 source_record_cursors: vec![("s1".to_string(), 2)],
1335 epoch: 9,
1336 epoch_step: 0,
1337 rng_state: 123,
1338 triplet_recipe_rr_idx: 4,
1339 text_recipe_rr_idx: 6,
1340 source_stream_cursors: vec![("s1".to_string(), 8)],
1341 };
1342
1343 store_a
1344 .save_sampler_state(&sampler_state, Some(path_b.as_path()))
1345 .unwrap();
1346
1347 let store_b = FileSplitStore::open(&path_b, ratios, 42).unwrap();
1349 assert_eq!(
1350 store_b.label_for(&RecordKey::new("src", assigned_id)),
1351 Some(SplitLabel::Validation)
1352 );
1353 assert_eq!(store_b.load_sampler_state().unwrap().unwrap().epoch, 9);
1354
1355 assert!(
1357 !path_a.exists(),
1358 "save_to=Some(...) must not publish to the canonical save_path"
1359 );
1360 }
1361
1362 #[test]
1365 fn save_some_on_regular_open_does_not_modify_working_store() {
1366 let dir = tempdir().unwrap();
1367 let path_a = dir.path().join("working_store.bin");
1368 let path_b = dir.path().join("checkpoint_store.bin");
1369 let ratios = SplitRatios::default();
1370
1371 let store_a = FileSplitStore::open(&path_a, ratios, 42).unwrap();
1372
1373 let initial_state = PersistedSamplerState {
1375 source_cycle_idx: 1,
1376 source_record_cursors: vec![],
1377 epoch: 1,
1378 epoch_step: 0,
1379 rng_state: 0,
1380 triplet_recipe_rr_idx: 0,
1381 text_recipe_rr_idx: 0,
1382 source_stream_cursors: vec![],
1383 };
1384 store_a.save_sampler_state(&initial_state, None).unwrap();
1385
1386 let checkpoint_state = PersistedSamplerState {
1388 source_cycle_idx: 99,
1389 source_record_cursors: vec![],
1390 epoch: 99,
1391 epoch_step: 0,
1392 rng_state: 42,
1393 triplet_recipe_rr_idx: 0,
1394 text_recipe_rr_idx: 0,
1395 source_stream_cursors: vec![],
1396 };
1397 store_a
1398 .save_sampler_state(&checkpoint_state, Some(path_b.as_path()))
1399 .unwrap();
1400
1401 drop(store_a);
1404 let store_a_disk = FileSplitStore::open(&path_a, ratios, 42).unwrap();
1405 assert_eq!(
1406 store_a_disk.load_sampler_state().unwrap().unwrap().epoch,
1407 1,
1408 "save_to=Some(...) must not overwrite the on-disk save_path"
1409 );
1410
1411 let store_b = FileSplitStore::open(&path_b, ratios, 42).unwrap();
1413 let state_from_b = store_b.load_sampler_state().unwrap().unwrap();
1414 assert_eq!(
1415 state_from_b.epoch, 99,
1416 "checkpoint store must hold the snapshotted state"
1417 );
1418 }
1419
1420 #[test]
1421 fn file_store_metadata_mismatch_and_debug_paths_are_covered() {
1422 let dir = tempdir().unwrap();
1423 let path = dir.path().join("meta_mismatch.bin");
1424 let ratios = SplitRatios::default();
1425 let store = FileSplitStore::open(&path, ratios, 123).unwrap();
1426
1427 let debug_repr = format!("{store:?}");
1428 assert!(debug_repr.contains("FileSplitStore"));
1429
1430 let wrong_version = StoreMeta {
1431 version: STORE_VERSION.wrapping_add(1),
1432 seed: 123,
1433 ratios,
1434 };
1435 let payload = encode_store_meta(&wrong_version);
1436 store.store.write(META_KEY, &payload).unwrap();
1437 store
1439 .save_sampler_state(
1440 &PersistedSamplerState {
1441 source_cycle_idx: 0,
1442 source_record_cursors: vec![],
1443 epoch: 0,
1444 epoch_step: 0,
1445 rng_state: 0,
1446 triplet_recipe_rr_idx: 0,
1447 text_recipe_rr_idx: 0,
1448 source_stream_cursors: vec![],
1449 },
1450 None,
1451 )
1452 .unwrap();
1453 drop(store);
1454
1455 let err = FileSplitStore::open(&path, ratios, 123).unwrap_err();
1456 assert!(matches!(err, SamplerError::SplitStore(msg) if msg.contains("version mismatch")));
1457
1458 let ratio_path = dir.path().join("ratio_mismatch.bin");
1459 let baseline = FileSplitStore::open(&ratio_path, ratios, 777).unwrap();
1460 baseline
1462 .save_sampler_state(
1463 &PersistedSamplerState {
1464 source_cycle_idx: 0,
1465 source_record_cursors: vec![],
1466 epoch: 0,
1467 epoch_step: 0,
1468 rng_state: 0,
1469 triplet_recipe_rr_idx: 0,
1470 text_recipe_rr_idx: 0,
1471 source_stream_cursors: vec![],
1472 },
1473 None,
1474 )
1475 .unwrap();
1476 drop(baseline);
1477
1478 let different_ratios = SplitRatios {
1479 train: 0.7,
1480 validation: 0.2,
1481 test: 0.1,
1482 };
1483 let err = FileSplitStore::open(&ratio_path, different_ratios, 777).unwrap_err();
1484 assert!(matches!(err, SamplerError::SplitStore(msg) if msg.contains("ratios mismatch")));
1485 }
1486
1487 #[test]
1488 fn split_decode_helpers_reject_corrupt_bitcode_payloads() {
1489 let store_meta_err = decode_store_meta(&[BITCODE_PREFIX, 0xFF, 0xEE]).unwrap_err();
1490 assert!(matches!(
1491 store_meta_err,
1492 SamplerError::SplitStore(msg) if msg.contains("failed to decode split store metadata")
1493 ));
1494
1495 let epoch_meta_err =
1496 decode_epoch_meta(&[EPOCH_META_RECORD_VERSION, BITCODE_PREFIX, 0xFF]).unwrap_err();
1497 assert!(
1498 matches!(epoch_meta_err, SamplerError::SplitStore(msg) if msg.contains("corrupt epoch meta record"))
1499 );
1500
1501 let epoch_hashes_err =
1502 decode_epoch_hashes(&[EPOCH_HASH_RECORD_VERSION, BITCODE_PREFIX, 0xFF]).unwrap_err();
1503 assert!(matches!(
1504 epoch_hashes_err,
1505 SamplerError::SplitStore(msg) if msg.contains("corrupt epoch hashes record")
1506 ));
1507
1508 let sampler_state_err =
1509 decode_sampler_state(&[SAMPLER_STATE_RECORD_VERSION, BITCODE_PREFIX, 0xFF])
1510 .unwrap_err();
1511 assert!(matches!(
1512 sampler_state_err,
1513 SamplerError::SplitStore(msg) if msg.contains("corrupt sampler state record")
1514 ));
1515 }
1516
1517 #[test]
1518 fn file_store_label_fallback_and_validation_keys_are_covered() {
1519 let dir = tempdir().unwrap();
1520 let path = dir.path().join("labels.bin");
1521 let store = FileSplitStore::open(&path, SplitRatios::default(), 42).unwrap();
1522
1523 let id = "bad_label_record".to_string();
1524 let key = RecordKey::new("test-source", id);
1525 let expected = derive_label_for_key(&key, 42, SplitRatios::default());
1526 let store_key = split_key(&key.source, &key.id);
1527
1528 store.store.write(&store_key, b"x").unwrap();
1529 assert_eq!(store.label_for(&key), Some(expected));
1530
1531 store.store.write(&store_key, b"1").unwrap();
1532 assert_eq!(store.label_for(&key), Some(SplitLabel::Validation));
1533
1534 let meta_validation = epoch_meta_key(SplitLabel::Validation);
1535 let hashes_validation = epoch_hashes_key(SplitLabel::Validation);
1536 assert!(meta_validation.starts_with(EPOCH_META_PREFIX));
1537 assert!(hashes_validation.starts_with(EPOCH_HASHES_PREFIX));
1538 assert!(meta_validation.ends_with(b"validation"));
1539 assert!(hashes_validation.ends_with(b"validation"));
1540 }
1541
1542 #[test]
1543 fn file_store_ignores_pre_composite_legacy_label_keys() {
1544 let dir = tempdir().unwrap();
1550 let path = dir.path().join("labels.bin");
1551 let store = FileSplitStore::open(&path, SplitRatios::default(), 42).unwrap();
1552
1553 let key = RecordKey::new("src", "legacy_record");
1554 let mut legacy_key = Vec::new();
1555 legacy_key.extend_from_slice(SPLIT_PREFIX);
1556 legacy_key.extend_from_slice(b"legacy_record");
1557 store.store.write(&legacy_key, b"1").unwrap();
1558 assert_eq!(
1559 store.label_for(&key),
1560 Some(derive_label_for_key(&key, 42, SplitRatios::default())),
1561 "pre-composite legacy keys must not resolve"
1562 );
1563 }
1564
1565 #[test]
1566 fn ensure_parent_dir_allows_plain_file_names() {
1567 ensure_parent_dir(Path::new("split_store_local.bin")).unwrap();
1568 let coerced = coerce_store_path(PathBuf::from("explicit_store.bin"));
1569 assert_eq!(coerced, PathBuf::from("explicit_store.bin"));
1570 }
1571
1572 #[test]
1578 fn load_path_source_is_never_modified_while_open() {
1579 let dir = tempdir().unwrap();
1580 let source = dir.path().join("source.bin");
1581 let dest = dir.path().join("dest.bin");
1582 let ratios = SplitRatios::default();
1583
1584 let seeded = FileSplitStore::open(&source, ratios, 77).unwrap();
1586 let state = PersistedSamplerState {
1587 source_cycle_idx: 5,
1588 source_record_cursors: vec![("s".to_string(), 3)],
1589 epoch: 9,
1590 epoch_step: 0,
1591 rng_state: 42,
1592 triplet_recipe_rr_idx: 1,
1593 text_recipe_rr_idx: 2,
1594 source_stream_cursors: vec![("s".to_string(), 4)],
1595 };
1596 seeded.save_sampler_state(&state, None).unwrap();
1597 drop(seeded);
1598
1599 let source_size_before = std::fs::metadata(&source).unwrap().len();
1600
1601 let bootstrapped =
1603 FileSplitStore::open_with_load_path(Some(&source), &dest, ratios, 77).unwrap();
1604 let new_state = PersistedSamplerState {
1605 source_cycle_idx: 99,
1606 source_record_cursors: vec![("s".to_string(), 77)],
1607 epoch: 100,
1608 epoch_step: 0,
1609 rng_state: 0,
1610 triplet_recipe_rr_idx: 0,
1611 text_recipe_rr_idx: 0,
1612 source_stream_cursors: vec![],
1613 };
1614 bootstrapped.save_sampler_state(&new_state, None).unwrap();
1615 drop(bootstrapped);
1616
1617 let source_size_after = std::fs::metadata(&source).unwrap().len();
1619 assert_eq!(
1620 source_size_before, source_size_after,
1621 "source file was modified during bootstrapped open"
1622 );
1623
1624 let verify_source = FileSplitStore::open(&source, ratios, 77).unwrap();
1626 let loaded = verify_source.load_sampler_state().unwrap().unwrap();
1627 assert_eq!(loaded.source_cycle_idx, 5);
1628 assert_eq!(loaded.epoch, 9);
1629 }
1630
1631 #[test]
1634 fn save_none_on_bootstrapped_store_publishes_to_save_path() {
1635 let dir = tempdir().unwrap();
1636 let source = dir.path().join("load.bin");
1637 let dest = dir.path().join("save.bin");
1638 let ratios = SplitRatios::default();
1639
1640 let _ = FileSplitStore::open(&source, ratios, 11).unwrap();
1641
1642 assert!(!dest.exists(), "dest must not exist before first save");
1643
1644 let store = FileSplitStore::open_with_load_path(Some(&source), &dest, ratios, 11).unwrap();
1645 let state = PersistedSamplerState {
1646 source_cycle_idx: 7,
1647 source_record_cursors: vec![],
1648 epoch: 2,
1649 epoch_step: 0,
1650 rng_state: 1,
1651 triplet_recipe_rr_idx: 0,
1652 text_recipe_rr_idx: 0,
1653 source_stream_cursors: vec![],
1654 };
1655 store.save_sampler_state(&state, None).unwrap();
1656 drop(store);
1657
1658 assert!(dest.exists(), "dest must exist after save(None)");
1659
1660 let loaded_dest = FileSplitStore::open(&dest, ratios, 11).unwrap();
1661 assert_eq!(
1662 loaded_dest
1663 .load_sampler_state()
1664 .unwrap()
1665 .unwrap()
1666 .source_cycle_idx,
1667 7
1668 );
1669 }
1670
1671 #[test]
1674 fn save_some_on_bootstrapped_store_publishes_to_explicit_path_only() {
1675 let dir = tempdir().unwrap();
1676 let source = dir.path().join("load.bin");
1677 let save = dir.path().join("save.bin"); let other = dir.path().join("other.bin"); let ratios = SplitRatios::default();
1680
1681 let _ = FileSplitStore::open(&source, ratios, 22).unwrap();
1682
1683 let store = FileSplitStore::open_with_load_path(Some(&source), &save, ratios, 22).unwrap();
1684 let state = PersistedSamplerState {
1685 source_cycle_idx: 3,
1686 source_record_cursors: vec![],
1687 epoch: 1,
1688 epoch_step: 0,
1689 rng_state: 0,
1690 triplet_recipe_rr_idx: 0,
1691 text_recipe_rr_idx: 0,
1692 source_stream_cursors: vec![],
1693 };
1694 store
1695 .save_sampler_state(&state, Some(other.as_path()))
1696 .unwrap();
1697 drop(store);
1698
1699 assert!(
1700 !save.exists(),
1701 "canonical save_path must not be created when saving to explicit path"
1702 );
1703 assert!(other.exists(), "explicit target must be created");
1704
1705 let loaded_other = FileSplitStore::open(&other, ratios, 22).unwrap();
1706 assert_eq!(
1707 loaded_other
1708 .load_sampler_state()
1709 .unwrap()
1710 .unwrap()
1711 .source_cycle_idx,
1712 3
1713 );
1714 }
1715
1716 #[test]
1719 fn repeated_save_none_on_bootstrapped_store_is_idempotent() {
1720 let dir = tempdir().unwrap();
1721 let source = dir.path().join("load.bin");
1722 let dest = dir.path().join("save.bin");
1723 let ratios = SplitRatios::default();
1724
1725 let _ = FileSplitStore::open(&source, ratios, 33).unwrap();
1726
1727 let store = FileSplitStore::open_with_load_path(Some(&source), &dest, ratios, 33).unwrap();
1728
1729 for cycle_idx in [1_u64, 2, 3] {
1730 let state = PersistedSamplerState {
1731 source_cycle_idx: cycle_idx,
1732 source_record_cursors: vec![],
1733 epoch: 0,
1734 epoch_step: 0,
1735 rng_state: 0,
1736 triplet_recipe_rr_idx: 0,
1737 text_recipe_rr_idx: 0,
1738 source_stream_cursors: vec![],
1739 };
1740 store.save_sampler_state(&state, None).unwrap();
1741 }
1742 drop(store);
1743
1744 let reloaded = FileSplitStore::open(&dest, ratios, 33).unwrap();
1745 assert_eq!(
1746 reloaded
1747 .load_sampler_state()
1748 .unwrap()
1749 .unwrap()
1750 .source_cycle_idx,
1751 3,
1752 "dest should hold the last-saved state"
1753 );
1754 }
1755}