Skip to main content

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}