Skip to main content

rill_ml/
persistence.rs

1//! Model state persistence via a versioned [`Snapshot`] envelope.
2//!
3//! Only available when the `serde` feature is enabled.
4
5use crate::error::RillError;
6
7/// The current snapshot format version.
8pub const SNAPSHOT_FORMAT_VERSION: u32 = 1;
9
10/// Maximum byte length accepted by the validated restore entry points
11/// (`from_json` in Python/WASM bindings and
12/// [`Snapshot::from_json_validated`]).
13///
14/// The limit is intentionally generous (64 MiB) so that legitimately large
15/// model state (e.g. high-dimensional FTRL or LinUCB) is not rejected, while
16/// still bounding memory growth from untrusted JSON input. The limit is
17/// enforced on the raw JSON byte length *before* deserialization so a
18/// malicious payload cannot allocate a large intermediate `serde_json::Value`
19/// tree.
20pub const MAX_SNAPSHOT_JSON_BYTES: usize = 64 * 1024 * 1024;
21
22/// Unified state-validation interface for restorable model types.
23///
24/// Implementations enforce type-specific invariants that cannot be violated
25/// by a semantically valid `serde` deserialization. This trait is the single
26/// hook used by [`Snapshot::into_validated_model`] and by every Python/WASM
27/// `from_json` entry point, so adding a new restorable type only requires
28/// implementing this trait once.
29///
30/// # When to implement
31///
32/// Implement `ValidateState` for every public type that can appear inside a
33/// [`Snapshot`] and be restored from untrusted JSON. At minimum this covers
34/// all types exposed via Python and WASM `from_json`.
35///
36/// # What to check
37///
38/// - Dimensions / vector lengths match the type's own recorded feature count.
39/// - All stored floating-point values are finite.
40/// - Counts and statistics are non-negative.
41/// - Optimizer parameter counts match the model's feature count.
42/// - Encoder mapping consistency (no dangling indices).
43/// - Pipeline transformer/model dimensions agree.
44/// - Bandit arm state length matches the configured arm count.
45/// - Drift detector buffer length respects the configured capacity.
46///
47/// # Errors
48///
49/// Return [`RillError::InvalidState`] with a descriptive message so callers
50/// can distinguish validation failures from version mismatches.
51pub trait ValidateState {
52    /// Validate the in-memory state of this type.
53    ///
54    /// This method must be idempotent and must not mutate `self`.
55    fn validate_state(&self) -> Result<(), RillError>;
56}
57
58/// A versioned envelope around a serializable model state.
59///
60/// Versioning is centralized here so individual models do not need to
61/// duplicate format-version fields.
62///
63/// # Examples
64///
65/// ```
66/// # #[cfg(feature = "serde")] {
67/// use rill_ml::persistence::Snapshot;
68/// use rill_ml::stats::Mean;
69/// use rill_ml::OnlineStatistic;
70///
71/// let mut mean = Mean::new();
72/// mean.update(1.0).unwrap();
73/// mean.update(2.0).unwrap();
74///
75/// let snap = Snapshot::new(mean);
76/// let json = serde_json::to_string(&snap).unwrap();
77/// let restored: Snapshot<Mean> = serde_json::from_str(&json).unwrap();
78/// let m = restored.into_model().unwrap();
79/// assert!((m.value() - 1.5).abs() < 1e-12);
80/// # }
81/// ```
82#[derive(Debug, Clone)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84pub struct Snapshot<T> {
85    /// The format version of this snapshot.
86    pub format_version: u32,
87    /// The model state.
88    pub model: T,
89}
90
91impl<T> Snapshot<T> {
92    /// Wrap a model in a new snapshot with the current format version.
93    pub fn new(model: T) -> Self {
94        Self {
95            format_version: SNAPSHOT_FORMAT_VERSION,
96            model,
97        }
98    }
99
100    /// Consume the snapshot and return the model, verifying the format version.
101    ///
102    /// Returns [`RillError::IncompatibleStateVersion`] if the version does not
103    /// match [`SNAPSHOT_FORMAT_VERSION`].
104    pub fn into_model(self) -> Result<T, RillError> {
105        if self.format_version != SNAPSHOT_FORMAT_VERSION {
106            return Err(RillError::IncompatibleStateVersion {
107                expected: SNAPSHOT_FORMAT_VERSION,
108                actual: self.format_version,
109            });
110        }
111        Ok(self.model)
112    }
113
114    /// Consume the snapshot, verify its format version, and run an
115    /// application-provided model-state validator before returning the model.
116    ///
117    /// The snapshot envelope can only validate its own version field because
118    /// `T` may be an application type. Use this method at trust boundaries to
119    /// enforce model-specific invariants before activating restored state.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`RillError::IncompatibleStateVersion`] for a version mismatch,
124    /// or propagates the validator's error.
125    pub fn into_model_with_validation<F>(self, validate: F) -> Result<T, RillError>
126    where
127        F: FnOnce(&T) -> Result<(), RillError>,
128    {
129        let model = self.into_model()?;
130        validate(&model)?;
131        Ok(model)
132    }
133}
134
135impl<T: ValidateState> Snapshot<T> {
136    /// Consume the snapshot, verify its format version, and run the
137    /// type-specific [`ValidateState`] validator before returning the model.
138    ///
139    /// This is the required restore path at trust boundaries (Python/WASM
140    /// `from_json`, IPC state restore, etc.). It is atomic: on error, no
141    /// model is returned and no half-validated state is activated.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`RillError::IncompatibleStateVersion`] for a version mismatch,
146    /// or propagates the [`ValidateState`] error.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// # #[cfg(feature = "serde")] {
152    /// use rill_ml::persistence::Snapshot;
153    /// use rill_ml::stats::Mean;
154    /// use rill_ml::OnlineStatistic;
155    ///
156    /// let mut mean = Mean::new();
157    /// mean.update(1.0).unwrap();
158    /// let snap = Snapshot::new(mean);
159    /// let json = serde_json::to_string(&snap).unwrap();
160    /// let restored: Snapshot<Mean> = serde_json::from_str(&json).unwrap();
161    /// let m = restored.into_validated_model().unwrap();
162    /// assert_eq!(m.count(), 1);
163    /// # }
164    /// ```
165    pub fn into_validated_model(self) -> Result<T, RillError> {
166        let model = self.into_model()?;
167        model.validate_state()?;
168        Ok(model)
169    }
170
171    /// Deserialize a snapshot from JSON, enforce the byte-size limit, verify
172    /// the format version, and run the type-specific [`ValidateState`]
173    /// validator before returning the model.
174    ///
175    /// This is the single entry point that Python/WASM `from_json` and any
176    /// other untrusted-state restore path must call. It enforces
177    /// [`MAX_SNAPSHOT_JSON_BYTES`] on the raw input *before* deserialization.
178    ///
179    /// # Errors
180    ///
181    /// - [`RillError::InvalidState`] if the input exceeds
182    ///   [`MAX_SNAPSHOT_JSON_BYTES`].
183    /// - [`RillError::IncompatibleStateVersion`] for a version mismatch.
184    /// - Propagates the serde error if the JSON is malformed.
185    /// - Propagates the [`ValidateState`] error if the model state is invalid.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// # #[cfg(feature = "serde")] {
191    /// use rill_ml::persistence::Snapshot;
192    /// use rill_ml::stats::Mean;
193    /// use rill_ml::OnlineStatistic;
194    ///
195    /// let mut mean = Mean::new();
196    /// mean.update(1.0).unwrap();
197    /// let json = serde_json::to_string(&Snapshot::new(mean)).unwrap();
198    /// let m: Mean = Snapshot::from_json_validated(&json).unwrap();
199    /// assert_eq!(m.count(), 1);
200    /// # }
201    /// ```
202    #[cfg(feature = "serde")]
203    pub fn from_json_validated(json: &str) -> Result<T, RillError>
204    where
205        T: serde::de::DeserializeOwned,
206    {
207        if json.len() > MAX_SNAPSHOT_JSON_BYTES {
208            return Err(RillError::InvalidState(format!(
209                "snapshot JSON exceeds the maximum byte limit ({} > {})",
210                json.len(),
211                MAX_SNAPSHOT_JSON_BYTES
212            )));
213        }
214        let snap: Snapshot<T> =
215            serde_json::from_str(json).map_err(|e| RillError::InvalidState(e.to_string()))?;
216        snap.into_validated_model()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::stats::Mean;
224    #[cfg(feature = "serde")]
225    use crate::traits::OnlineStatistic;
226
227    #[cfg(feature = "serde")]
228    #[test]
229    fn snapshot_roundtrip() {
230        let mut mean = Mean::new();
231        mean.update(1.0).unwrap();
232        mean.update(2.0).unwrap();
233        let snap = Snapshot::new(mean);
234        let json = serde_json::to_string(&snap).unwrap();
235        let restored: Snapshot<Mean> = serde_json::from_str(&json).unwrap();
236        let m = restored.into_model().unwrap();
237        assert!((m.value() - 1.5).abs() < 1e-12);
238        assert_eq!(m.count(), 2);
239    }
240
241    #[test]
242    fn incompatible_version_rejected() {
243        let snap = Snapshot {
244            format_version: 999,
245            model: Mean::new(),
246        };
247        assert!(snap.into_model().is_err());
248    }
249
250    #[test]
251    fn application_validation_runs_before_activation() {
252        let snap = Snapshot::new(Mean::new());
253        let result = snap.into_model_with_validation(|_| {
254            Err(RillError::InvalidState(
255                "application check failed".to_owned(),
256            ))
257        });
258        assert!(matches!(result, Err(RillError::InvalidState(_))));
259    }
260}