Skip to main content

triplets_core/
splits.rs

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/// Logical dataset partitions used during sampling.
24#[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    /// Training split.
38    Train,
39    /// Validation split.
40    Validation,
41    /// Test split.
42    Test,
43}
44
45/// Ratio configuration for train/validation/test assignment.
46#[derive(Clone, Copy, Debug, Serialize, Deserialize, bitcode::Encode, bitcode::Decode)]
47pub struct SplitRatios {
48    /// Fraction assigned to train.
49    pub train: f32,
50    /// Fraction assigned to validation.
51    pub validation: f32,
52    /// Fraction assigned to test.
53    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    /// Validate that ratios sum to `1.0` (within epsilon).
68    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/// Persisted epoch cursor metadata for one split.
82#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
83pub struct PersistedSplitMeta {
84    /// Current epoch for this split.
85    pub epoch: u64,
86    /// Cursor offset within the epoch hash list.
87    pub offset: u64,
88    /// Checksum of the persisted hash list.
89    pub hashes_checksum: u64,
90}
91
92/// Persisted deterministic epoch hash ordering for one split.
93#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
94pub struct PersistedSplitHashes {
95    /// Checksum of `hashes`.
96    pub checksum: u64,
97    /// Deterministic per-epoch hash ordering.
98    pub hashes: Vec<u64>,
99}
100
101/// Persisted sampler runtime state (cursors, recipe indices, RNG).
102///
103/// Every field here is restored on resume.  Notable things NOT persisted:
104///
105/// - **`emitted_text_hashes`** — cross-batch text dedup restarts fresh after
106///   a save/load boundary.  The same text may appear on both sides.
107/// - **`source_wrapped`** — reconstructed from the restored cursors.
108/// - **`chunk_index` / role cursors** — rebuilt from the ingested pool.
109#[derive(Clone, Debug, bitcode::Encode, bitcode::Decode)]
110pub struct PersistedSamplerState {
111    /// Source-cycle round-robin index.
112    pub source_cycle_idx: u64,
113    /// Per-source record cursors.
114    pub source_record_cursors: Vec<(SourceId, u64)>,
115    /// Current epoch used for deterministic reshuffle.
116    pub epoch: u64,
117    /// Epoch step counter — incremented once per `next_*_batch` call.
118    pub epoch_step: u64,
119    /// Deterministic RNG internal state.
120    pub rng_state: u64,
121    /// Round-robin index for triplet recipes.
122    pub triplet_recipe_rr_idx: u64,
123    /// Round-robin index for text recipes.
124    pub text_recipe_rr_idx: u64,
125    /// Per-source I/O stream cursors for restart-resume support.
126    pub source_stream_cursors: Vec<(SourceId, u64)>,
127}
128
129/// Split assignment backend.
130///
131/// Implementations map composite [`RecordKey`] values to split labels
132/// deterministically. Keys (not bare ids) so same-id records from different
133/// sources are assigned independently.
134pub trait SplitStore: Send + Sync {
135    /// Return split label for `key` if known/derivable.
136    fn label_for(&self, key: &RecordKey) -> Option<SplitLabel>;
137    /// Persist an explicit split assignment for `key`.
138    fn upsert(&self, key: RecordKey, label: SplitLabel) -> Result<(), SamplerError>;
139    /// Return configured split ratios.
140    fn ratios(&self) -> SplitRatios;
141    /// Return the split label for `key`, creating/deriving one when needed.
142    fn ensure(&self, key: RecordKey) -> Result<SplitLabel, SamplerError>;
143}
144
145/// Persistence backend for epoch metadata and epoch hash orderings.
146pub trait EpochStateStore: Send + Sync {
147    /// Load split→epoch metadata map.
148    fn load_epoch_meta(&self) -> Result<HashMap<SplitLabel, PersistedSplitMeta>, SamplerError>;
149    /// Load persisted epoch hashes for one split, if available.
150    fn load_epoch_hashes(
151        &self,
152        label: SplitLabel,
153    ) -> Result<Option<PersistedSplitHashes>, SamplerError>;
154    /// Persist split→epoch metadata map.
155    fn save_epoch_meta(
156        &self,
157        meta: &HashMap<SplitLabel, PersistedSplitMeta>,
158    ) -> Result<(), SamplerError>;
159    /// Persist epoch hash list for one split.
160    fn save_epoch_hashes(
161        &self,
162        label: SplitLabel,
163        hashes: &PersistedSplitHashes,
164    ) -> Result<(), SamplerError>;
165}
166
167/// Persistence backend for sampler runtime state.
168pub trait SamplerStateStore: Send + Sync {
169    /// Load persisted sampler runtime state, if present.
170    fn load_sampler_state(&self) -> Result<Option<PersistedSamplerState>, SamplerError>;
171    /// Save sampler runtime state, optionally mirroring to `save_path`.
172    fn save_sampler_state(
173        &self,
174        state: &PersistedSamplerState,
175        save_path: Option<&Path>,
176    ) -> Result<(), SamplerError>;
177}
178
179/// In-memory split store with deterministic assignment derivation.
180pub 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    /// Create an in-memory split store configured with `ratios` and `seed`.
191    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)]
302/// Versioned metadata header stored in file-backed split stores.
303struct 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
320/// File-backed split store for persistent runs.
321///
322/// Persists assignment metadata, epoch state, and sampler runtime state.
323///
324/// The store **always** works against a private temporary copy of the source
325/// snapshot.  All reads and mutations accumulate in the temp file.
326/// State is published to permanent storage only when
327/// [`SamplerStateStore::save_sampler_state`] is called:
328///
329/// * `save_to == None`  → publish temp to `save_path` (may overwrite).
330/// * `save_to == Some(p)` → publish temp to `p` only; `save_path` is left
331///   untouched.
332///
333/// This guarantees that the original source file is never modified and that
334/// no partial state leaks to the target before an explicit save.
335pub struct FileSplitStore {
336    store: DataStore,
337    /// Working path: always a private temp file; all reads and writes go here.
338    path: PathBuf,
339    /// Declared save destination; published to on `save_sampler_state(None)`.
340    save_path: PathBuf,
341    ratios: SplitRatios,
342    seed: u64,
343    /// Keeps the temporary directory alive for the lifetime of this store.
344    _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    /// Open (or create) a file-backed split store at `path`.
359    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    /// Open (or create) a file-backed split store at `save_path`, optionally
368    /// bootstrapping initial state from `load_path`.
369    ///
370    /// Always stages through a **private temporary file**.  The source used to
371    /// seed the temp is chosen as follows:
372    ///
373    /// 1. `load_path` if supplied and it exists.
374    /// 2. `save_path` if it already exists.
375    /// 3. Nothing — a fresh empty store is created in the temp.
376    ///
377    /// All mutations accumulate in the temp.  State is published to permanent
378    /// storage only when [`SamplerStateStore::save_sampler_state`] is called.
379    /// The original source file is never modified.
380    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        // Determine source to seed the working temp from.
391        // Priority: explicit load_path > existing save_path > fresh (nothing).
392        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    /// Persist `state` to the working temp store and publish to the destination.
584    ///
585    /// * `save_to == None`  → publish temp to `save_path` (may overwrite).
586    /// * `save_to == Some(p)` → publish temp to `p` only; `save_path` is left
587    ///   untouched.  `p` must not already exist.
588    fn save_sampler_state(
589        &self,
590        state: &PersistedSamplerState,
591        save_to: Option<&Path>,
592    ) -> Result<(), SamplerError> {
593        // Determine publish destination before writing anything.
594        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        // Refuse to overwrite an explicitly-named destination that already exists.
601        // Saving back to the canonical save_path (None) is always allowed.
602        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        // Write state into the working temp store.
610        let payload = encode_sampler_state(state);
611        write_bytes(&self.store, SAMPLER_STATE_KEY, &payload)?;
612
613        // Publish: copy the temp store to the destination.
614        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    // Id-only derivation, deliberately: identical to the historical
637    // `derive_label_for_id`, so split assignments are unchanged for existing
638    // corpora. Same-id records across sources share an assignment, which is
639    // harmless (pools, cursors, and explicit upserts stay per-key).
640    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    /// `save(None)` must publish the temp to `save_path` on disk.  A fresh
875    /// `open` of that path must see everything that was written before the save.
876    #[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        // Fresh open must read everything back from disk.
913        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        // Publish to disk so the seed is committed before the next open.
930        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        // Verify the same data survives a drop + fresh open from disk.
1036        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        // Destination gets the existing store data AND the new sampler state.
1348        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        // save_to=Some(path_b) must not publish to path_a (the canonical save_path).
1356        assert!(
1357            !path_a.exists(),
1358            "save_to=Some(...) must not publish to the canonical save_path"
1359        );
1360    }
1361
1362    /// Saving to a custom path on a plain `open` store must not mutate the
1363    /// working store file -- i.e. a previously-saved state is preserved.
1364    #[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        // Establish a baseline state in the working store.
1374        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        // Snapshot a newer state to a separate checkpoint path.
1387        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        // The on-disk save_path (path_a) must not have been overwritten by save_to=Some(...).
1402        // Re-open from disk to verify; path_a was last published by save_to=None above.
1403        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        // The checkpoint must hold the new state.
1412        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        // Publish the corrupted temp to disk so the next open reads the bad version.
1438        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        // Publish so ratio_path exists on disk before reopening with different ratios.
1461        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        // Stores populated before keys were namespaced persist assignments
1545        // under `split:<id>`. Those keys are dead: label_for reads only the
1546        // canonical namespaced layout, and the STORE_VERSION bump rejects
1547        // such stores loudly at open. This pins the no-fallback behavior —
1548        // a legacy key must never resolve.
1549        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    // -----------------------------------------------------------------------
1573    // Temp-dir bootstrap contract
1574    // -----------------------------------------------------------------------
1575
1576    /// `open_with_load_path` must NOT modify the source file while the store is open.
1577    #[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        // Seed the source with known state.
1585        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        // Open bootstrapped store and write mutations to it.
1602        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        // Source must be byte-identical to before bootstrap.
1618        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        // Verify source still holds the original state.
1625        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    /// `save_sampler_state(None)` on a bootstrapped store must publish to the
1632    /// declared `save_path` only, not to the source and not to a temp path.
1633    #[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    /// `save_sampler_state(Some(other))` on a bootstrapped store must publish to
1672    /// `other` only — the declared `save_path` must remain absent.
1673    #[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"); // canonical — should stay empty
1678        let other = dir.path().join("other.bin"); // explicit target
1679        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    /// Repeated `save(None)` calls on a bootstrapped store are idempotent: the
1717    /// canonical save_path is overwritten cleanly each time.
1718    #[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}