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}