scientific_workflow/time_series/state_series.rs
1//! Owned and borrowed in-memory collections of scientific system states.
2//!
3//! [`StateSeries`] is the analysis-facing growable array for complete
4//! [`SystemState`] snapshots. It owns states, preserves one canonical
5//! [`SystemStateSchema`], and maintains strictly increasing simulation indices.
6//! [`StateSeriesView`] provides a lightweight read-only view without cloning states
7//! or payloads.
8//!
9//! # Scope
10//!
11//! This module performs no sampling, serialization, decoding, filesystem IO,
12//! chunking, or queue management. A simulation sends borrowed partial states
13//! to the future storage layer; a `StateSeries` is instead built when an
14//! application or reader wants an owned collection for analysis.
15//!
16//! # Ownership and cloning
17//!
18//! Successful [`StateSeries::push_state`] calls move a complete state into the
19//! backing `Vec`. [`StateSeries::pop_state`], [`StateSeries::into_states`], and owned
20//! iteration move those owners back out. None of these paths clones a payload.
21//! A rejected append returns [`StateSeriesPushError`], which retains the unchanged state.
22//!
23//! Explicitly cloning a `StateSeries` is intentionally expensive: every
24//! populated payload is deep-cloned through [`SystemState::clone`]. Prefer
25//! [`StateSeries::as_view`] for scoped immutable access or an application-owned
26//! `Arc<StateSeries>` when shared ownership is required.
27//!
28//! # Invariants
29//!
30//! Every accepted state must:
31//!
32//! - share the exact immutable layout allocation held by the series; and
33//! - have an iteration greater than the current final iteration.
34//!
35//! Iteration gaps are valid. Optional physical time does not determine
36//! ordering. The module never exposes `&mut SystemState`, because callers could
37//! otherwise change time or replace structural state behind the collection's
38//! validation boundary. [`StateSeries::payload_mut_at`] permits mutation of one
39//! payload while leaving those invariants inaccessible.
40
41use std::any::Any;
42use std::error::Error;
43use std::fmt;
44
45use crate::system_state::{SystemState, SystemStateSchema};
46
47use super::error::StateSeriesError;
48
49/// A growable homogeneous array of owned, time-ordered system states.
50///
51/// `spec` remains present even when the collection is empty, so later appends
52/// can be checked with constant-time layout identity. Each stored state carries
53/// its own cheap handle to the same immutable layout allocation and therefore
54/// remains independently valid after removal from the series.
55pub struct StateSeries {
56 spec: SystemStateSchema,
57 states: Vec<SystemState>,
58}
59
60impl StateSeries {
61 /// Creates an empty series with no state capacity reserved.
62 ///
63 /// This stores the supplied specification handle but allocates no state or
64 /// payload storage. Use [`StateSeries::with_capacity`] when an analysis or
65 /// reader already knows an approximate state count.
66 pub fn new(spec: SystemStateSchema) -> Self {
67 Self {
68 spec,
69 states: Vec::new(),
70 }
71 }
72
73 /// Creates an empty series with capacity for at least `capacity` states.
74 ///
75 /// The reservation covers only `SystemState` owners in the backing vector.
76 /// It does not create states, duplicate the shared layout, or allocate any
77 /// scientific payload.
78 pub fn with_capacity(spec: SystemStateSchema, capacity: usize) -> Self {
79 Self {
80 spec,
81 states: Vec::with_capacity(capacity),
82 }
83 }
84
85 /// Returns the canonical immutable specification for this collection.
86 pub fn schema(&self) -> &SystemStateSchema {
87 &self.spec
88 }
89
90 /// Creates a copyable read-only view over the complete collection.
91 ///
92 /// The view contains only borrowed references to the canonical
93 /// specification and state slice. Constructing, copying, or cloning it
94 /// never clones a state, layout, payload, or vector allocation.
95 pub fn as_view(&self) -> StateSeriesView<'_> {
96 StateSeriesView::new(&self.spec, &self.states)
97 }
98
99 /// Returns the number of states currently owned by the collection.
100 pub fn len(&self) -> usize {
101 self.states.len()
102 }
103
104 /// Reports whether the collection currently owns no states.
105 pub fn is_empty(&self) -> bool {
106 self.states.is_empty()
107 }
108
109 /// Returns the backing vector's current state-owner capacity.
110 pub fn capacity(&self) -> usize {
111 self.states.capacity()
112 }
113
114 /// Reserves capacity for at least `additional` more state owners.
115 ///
116 /// Existing states and their payload allocations remain logically
117 /// unchanged. As with [`Vec::reserve`], the allocator may reserve more than
118 /// the exact requested amount.
119 pub fn reserve(&mut self, additional: usize) {
120 self.states.reserve(additional);
121 }
122
123 /// Returns one immutable state by zero-based collection position.
124 ///
125 /// This follows slice conventions and returns `None` when `position` is
126 /// outside the collection. The position is distinct from the state's
127 /// iteration because sampled iterations may contain gaps.
128 pub fn state_at(&self, position: usize) -> Option<&SystemState> {
129 self.states.get(position)
130 }
131
132 /// Mutably borrows one typed payload in one stored state.
133 ///
134 /// This is the collection's only mutable analysis boundary. It delegates
135 /// concrete type validation to [`SystemState::payload_mut`] but does not expose
136 /// the containing `SystemState`; callers therefore cannot change its time,
137 /// clear unrelated fields, or replace it with a foreign layout.
138 ///
139 /// Only one payload can be borrowed mutably at a time under ordinary Rust
140 /// borrowing rules. Applications requiring coupled mutation should group
141 /// the coupled values into one payload type.
142 ///
143 /// # Errors
144 ///
145 /// Returns [`StateSeriesError::PositionOutOfBounds`] when no state exists at
146 /// `position`. An unknown key, empty field, or concrete type mismatch is
147 /// returned as [`StateSeriesError::PayloadAccess`] with the original
148 /// [`crate::system_state::StateError`] preserved as its source.
149 pub fn payload_mut_at<T>(
150 &mut self,
151 position: usize,
152 key: &str,
153 ) -> Result<&mut T, StateSeriesError>
154 where
155 T: Any,
156 {
157 let len = self.states.len();
158 let state = self
159 .states
160 .get_mut(position)
161 .ok_or(StateSeriesError::PositionOutOfBounds { position, len })?;
162
163 state
164 .payload_mut::<T>(key)
165 .map_err(|source| StateSeriesError::PayloadAccess { position, source })
166 }
167
168 /// Returns the earliest stored state, or `None` when the series is empty.
169 pub fn first_state(&self) -> Option<&SystemState> {
170 self.states.first()
171 }
172
173 /// Returns the latest stored state, or `None` when the series is empty.
174 pub fn last_state(&self) -> Option<&SystemState> {
175 self.states.last()
176 }
177
178 /// Returns every state as one immutable contiguous slice.
179 ///
180 /// No mutable slice is exposed because element replacement could bypass
181 /// both shared-layout validation and increasing-iteration validation.
182 pub fn as_state_slice(&self) -> &[SystemState] {
183 &self.states
184 }
185
186 /// Returns an iterator over immutable states in increasing iteration order.
187 pub fn iter(&self) -> std::slice::Iter<'_, SystemState> {
188 self.states.iter()
189 }
190
191 /// Appends one owned state after validating collection invariants.
192 ///
193 /// Success moves `state` directly into the backing vector without cloning
194 /// it or any payload. Failure returns [`StateSeriesPushError`] containing the complete
195 /// unchanged state, allowing the caller to recover expensive data without
196 /// cloning before the operation.
197 ///
198 /// # Errors
199 ///
200 /// - [`StateSeriesError::SchemaMismatch`] if `state` does not share the exact
201 /// canonical layout allocation;
202 /// - [`StateSeriesError::NonIncreasingIteration`] if its iteration is not
203 /// greater than the current final iteration.
204 pub fn push_state(&mut self, state: SystemState) -> Result<(), StateSeriesPushError> {
205 if !self.spec.shares_schema_instance(state.schema()) {
206 return Err(StateSeriesPushError::new(
207 StateSeriesError::SchemaMismatch {
208 iteration: state.simulation_time().iteration(),
209 },
210 state,
211 ));
212 }
213
214 if let Some(previous) = self
215 .last_state()
216 .map(|state| state.simulation_time().iteration())
217 {
218 let next = state.simulation_time().iteration();
219 if next <= previous {
220 return Err(StateSeriesPushError::new(
221 StateSeriesError::NonIncreasingIteration { previous, next },
222 state,
223 ));
224 }
225 }
226
227 self.states.push(state);
228 Ok(())
229 }
230
231 /// Removes and returns the latest state without cloning its payloads.
232 ///
233 /// A later append is compared with the new final state. Once empty, the
234 /// series accepts any iteration from a state sharing its layout.
235 pub fn pop_state(&mut self) -> Option<SystemState> {
236 self.states.pop()
237 }
238
239 /// Drops every state while retaining specification and vector capacity.
240 ///
241 /// Stored payloads are dropped with their owning states. This method is an
242 /// explicit analysis working-set operation and has no relationship to
243 /// writer rollover or persistent chunks.
244 pub fn clear_states(&mut self) {
245 self.states.clear();
246 }
247
248 /// Consumes the series and returns its complete state vector.
249 ///
250 /// The vector allocation, states, and payload allocations move unchanged.
251 /// Dropping the separate canonical specification handle is safe because
252 /// every returned state retains its own shared handle.
253 pub fn into_states(self) -> Vec<SystemState> {
254 self.states
255 }
256}
257
258impl Clone for StateSeries {
259 /// Creates a fully independent deep copy of all states and payloads.
260 ///
261 /// # Performance warning
262 ///
263 /// Cost scales with the complete populated payload volume and may involve
264 /// gigabytes of allocation and copying. This method is appropriate only
265 /// when analysis requires independent mutable payload ownership. Use
266 /// [`StateSeries::as_view`] or an `Arc<StateSeries>` for lightweight sharing.
267 /// The immutable specification allocation remains shared.
268 fn clone(&self) -> Self {
269 Self {
270 spec: self.spec.clone(),
271 states: self.states.clone(),
272 }
273 }
274}
275
276impl fmt::Debug for StateSeries {
277 /// Formats bounded structural context without traversing payload values.
278 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279 formatter
280 .debug_struct("StateSeries")
281 .field("source", &self.spec.template_path())
282 .field("states", &self.len())
283 .field(
284 "first_iteration",
285 &self
286 .first_state()
287 .map(|state| state.simulation_time().iteration()),
288 )
289 .field(
290 "last_iteration",
291 &self
292 .last_state()
293 .map(|state| state.simulation_time().iteration()),
294 )
295 .finish_non_exhaustive()
296 }
297}
298
299impl<'a> IntoIterator for &'a StateSeries {
300 type Item = &'a SystemState;
301 type IntoIter = std::slice::Iter<'a, SystemState>;
302
303 /// Iterates over borrowed states without exposing mutable replacement.
304 fn into_iter(self) -> Self::IntoIter {
305 self.iter()
306 }
307}
308
309impl IntoIterator for StateSeries {
310 type Item = SystemState;
311 type IntoIter = std::vec::IntoIter<SystemState>;
312
313 /// Consumes the series and moves each owned state out in iteration order.
314 fn into_iter(self) -> Self::IntoIter {
315 self.states.into_iter()
316 }
317}
318
319/// A lightweight immutable view over a canonical specification and state slice.
320///
321/// Both `Copy` and `Clone` copy only two references. Neither operation clones a
322/// specification, state, payload, or vector allocation. The private
323/// constructor ensures every public view originates from a validated
324/// [`StateSeries`].
325#[must_use = "a series view has no effect unless it is inspected"]
326#[derive(Clone, Copy)]
327pub struct StateSeriesView<'a> {
328 spec: &'a SystemStateSchema,
329 states: &'a [SystemState],
330}
331
332impl<'a> StateSeriesView<'a> {
333 /// Creates an invariant-preserving view over one complete state series.
334 fn new(spec: &'a SystemStateSchema, states: &'a [SystemState]) -> Self {
335 Self { spec, states }
336 }
337
338 /// Returns the canonical specification shared by the borrowed states.
339 pub fn schema(self) -> &'a SystemStateSchema {
340 self.spec
341 }
342
343 /// Returns the number of borrowed states.
344 pub fn len(self) -> usize {
345 self.states.len()
346 }
347
348 /// Reports whether the view contains no states.
349 pub fn is_empty(self) -> bool {
350 self.states.is_empty()
351 }
352
353 /// Returns a state by zero-based view position.
354 pub fn state_at(self, position: usize) -> Option<&'a SystemState> {
355 self.states.get(position)
356 }
357
358 /// Returns the earliest borrowed state, or `None` for an empty view.
359 pub fn first_state(self) -> Option<&'a SystemState> {
360 self.states.first()
361 }
362
363 /// Returns the latest borrowed state, or `None` for an empty view.
364 pub fn last_state(self) -> Option<&'a SystemState> {
365 self.states.last()
366 }
367
368 /// Returns the complete immutable state slice.
369 pub fn as_state_slice(self) -> &'a [SystemState] {
370 self.states
371 }
372
373 /// Returns an iterator over borrowed states in increasing iteration order.
374 pub fn iter(self) -> std::slice::Iter<'a, SystemState> {
375 self.states.iter()
376 }
377}
378
379impl fmt::Debug for StateSeriesView<'_> {
380 /// Formats bounded structural context without traversing payload values.
381 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
382 formatter
383 .debug_struct("StateSeriesView")
384 .field("source", &self.spec.template_path())
385 .field("states", &self.len())
386 .field(
387 "first_iteration",
388 &self
389 .first_state()
390 .map(|state| state.simulation_time().iteration()),
391 )
392 .field(
393 "last_iteration",
394 &self
395 .last_state()
396 .map(|state| state.simulation_time().iteration()),
397 )
398 .finish_non_exhaustive()
399 }
400}
401
402impl<'a> IntoIterator for StateSeriesView<'a> {
403 type Item = &'a SystemState;
404 type IntoIter = std::slice::Iter<'a, SystemState>;
405
406 /// Iterates over the borrowed states without cloning payloads.
407 fn into_iter(self) -> Self::IntoIter {
408 self.iter()
409 }
410}
411
412/// An append failure that preserves ownership of the rejected state.
413///
414/// This follows the ownership behavior of standard-library channel send
415/// errors: callers never need to clone expensive scientific data before an
416/// operation that may reject it. The state is boxed internally so
417/// `Result<(), StateSeriesPushError>` remains small on the successful hot path. The box is
418/// allocated only after validation fails.
419#[must_use = "the rejected SystemState remains owned by this error until recovered or dropped"]
420pub struct StateSeriesPushError {
421 error: StateSeriesError,
422 state: Box<SystemState>,
423}
424
425impl StateSeriesPushError {
426 /// Creates a failure-path owner for one unchanged rejected state.
427 fn new(error: StateSeriesError, state: SystemState) -> Self {
428 Self {
429 error,
430 state: Box::new(state),
431 }
432 }
433
434 /// Returns the collection invariant that rejected the state.
435 pub fn error(&self) -> &StateSeriesError {
436 &self.error
437 }
438
439 /// Returns the unchanged rejected state by shared reference.
440 pub fn state(&self) -> &SystemState {
441 &self.state
442 }
443
444 /// Consumes the error and returns its reason and original state.
445 ///
446 /// Moving the state out of its failure-only outer box does not clone the
447 /// state or any scientific payload allocation. The tuple follows borrowed
448 /// inspection order: error first, then state.
449 pub fn into_parts(self) -> (StateSeriesError, SystemState) {
450 (self.error, *self.state)
451 }
452}
453
454impl fmt::Debug for StateSeriesPushError {
455 /// Formats the reason and structural state context without payload values.
456 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
457 formatter
458 .debug_struct("StateSeriesPushError")
459 .field("error", &self.error)
460 .field("state", &self.state)
461 .finish_non_exhaustive()
462 }
463}
464
465impl fmt::Display for StateSeriesPushError {
466 /// Delegates user-facing formatting to the collection invariant failure.
467 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468 fmt::Display::fmt(&self.error, formatter)
469 }
470}
471
472impl Error for StateSeriesPushError {
473 /// Exposes the underlying collection error for standard source traversal.
474 fn source(&self) -> Option<&(dyn Error + 'static)> {
475 Some(&self.error)
476 }
477}