Skip to main content

scientific_workflow/system_state/
schema.rs

1//! Immutable field specifications loaded from a JSON state template.
2//!
3//! A scientific program establishes its SystemState layout before constructing
4//! states. This module loads that layout, validates field declarations, assigns
5//! compact deterministic field indices, and shares the resulting metadata
6//! among all derived states.
7//!
8//! # Template format
9//!
10//! The accepted JSON document has one ordered `fields` array:
11//!
12//! ```json
13//! {
14//!   "fields": [
15//!     {
16//!       "name": "population",
17//!       "description": "Population count for each simulated region"
18//!     },
19//!     {"name": "space"}
20//!   ]
21//! }
22//! ```
23//!
24//! Array order is significant. It assigns each field a zero-based index used
25//! by the compact payload-slot vector in `SystemState`. Names and present
26//! descriptions are trimmed. Missing, null, empty, and whitespace-only
27//! descriptions all normalize to no description. Unknown JSON properties are
28//! rejected so misspelled template configuration cannot be silently ignored.
29//! Payload types and storage encodings deliberately do not belong here.
30//!
31//! # Sharing and performance
32//!
33//! `SystemStateSchema` is a small cloneable handle around an immutable, reference-
34//! counted layout. Cloning it never duplicates field names or lookup tables.
35//! Field lookup uses a hash map, while iteration preserves JSON declaration
36//! order through the field slice.
37//!
38//! # Construction boundary
39//!
40//! Public callers load the first specification from a JSON template path using
41//! [`SystemStateSchema::load_json_template`]. A crate-private byte parser applies the identical
42//! validation path for persistence readers that recover an embedded template
43//! from the sole dataset metadata file. Keeping that parser crate-private
44//! preserves the public file-template initialization contract.
45
46use std::collections::HashMap;
47use std::fs;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50
51use serde::{Deserialize, Serialize};
52
53use super::error::StateError;
54use super::state::{SimulationTime, SystemState};
55
56/// One validated field in a state template.
57///
58/// A field specification is immutable after template loading. Its index is the
59/// position of the corresponding payload slot in every `SystemState` created
60/// from the same [`SystemStateSchema`].
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct StateFieldSchema {
63    #[serde(skip)]
64    index: usize,
65    name: Box<str>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    description: Option<Box<str>>,
68}
69
70impl StateFieldSchema {
71    /// Constructs one normalized field definition.
72    fn new(index: usize, name: &str, description: Option<&str>) -> Self {
73        Self {
74            index,
75            name: name.trim().into(),
76            description: description
77                .map(str::trim)
78                .filter(|description| !description.is_empty())
79                .map(Into::into),
80        }
81    }
82
83    /// Returns the zero-based payload-slot index assigned by template order.
84    pub fn position(&self) -> usize {
85        self.index
86    }
87
88    /// Returns the field name used by typed SystemState accessors.
89    pub fn name(&self) -> &str {
90        &self.name
91    }
92
93    /// Returns the optional natural-language description of the payload.
94    ///
95    /// Descriptions are documentation only. They do not identify a Rust type,
96    /// select a decoder, or affect typed access through `SystemState`.
97    pub fn description(&self) -> Option<&str> {
98        self.description.as_deref()
99    }
100}
101
102/// A validated, shareable SystemState layout.
103///
104/// `SystemStateSchema` owns an [`Arc`] to immutable metadata, making `Clone` a cheap
105/// reference-count increment. Every state derived from a specification shares
106/// the exact field order and name lookup table.
107#[derive(Clone, Debug)]
108pub struct SystemStateSchema {
109    inner: Arc<StateLayout>,
110}
111
112impl SystemStateSchema {
113    /// Loads and validates a state specification from a JSON template.
114    ///
115    /// The file is read as bytes and parsed directly, avoiding an intermediate
116    /// UTF-8 `String` allocation. The returned specification retains the input
117    /// path for diagnostics and provenance but does not canonicalize it or keep
118    /// the file open.
119    ///
120    /// # Errors
121    ///
122    /// Returns:
123    ///
124    /// - [`StateError::TemplateRead`] when the file cannot be read;
125    /// - [`StateError::TemplateParse`] when JSON syntax or structure is invalid;
126    /// - [`StateError::EmptyFieldName`] for an empty normalized field name;
127    /// - [`StateError::DuplicateField`] for repeated normalized names.
128    pub fn load_json_template(path: impl AsRef<Path>) -> Result<Self, StateError> {
129        let source = path.as_ref().to_path_buf();
130        let bytes = fs::read(&source).map_err(|error| StateError::TemplateRead {
131            path: source.clone(),
132            source: error,
133        })?;
134
135        Self::parse(source, &bytes)
136    }
137
138    /// Parses and validates a specification from an in-memory JSON document.
139    ///
140    /// This is the internal reconstruction boundary for a future persistence
141    /// reader. `source` identifies the containing metadata file for provenance
142    /// and errors; it need not be a standalone state-template path. Parsing
143    /// uses the same strict Serde representation and semantic validation as
144    /// [`SystemStateSchema::load_json_template`].
145    ///
146    /// The method is crate-private so application code cannot bypass the
147    /// required public initialization from a JSON template file.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`StateError::TemplateParse`] for invalid JSON structure or
152    /// syntax and the same semantic template variants documented by
153    /// [`SystemStateSchema::load_json_template`]. The input bytes are borrowed only for this call.
154    pub(crate) fn parse(source: PathBuf, bytes: &[u8]) -> Result<Self, StateError> {
155        let template: StateTemplate =
156            serde_json::from_slice(bytes).map_err(|error| StateError::TemplateParse {
157                path: source.clone(),
158                source: error,
159            })?;
160
161        Self::from_template(source, template)
162    }
163
164    /// Creates an empty SystemState that shares this specification.
165    ///
166    /// Every declared field exists in the returned state's layout, while every
167    /// payload slot starts empty. Cloning the specification is constant-time
168    /// and does not duplicate layout data.
169    pub fn create_empty_state(&self, time: SimulationTime) -> SystemState {
170        SystemState::new(self.clone(), time)
171    }
172
173    /// Converts this specification into a pretty-printed JSON template.
174    ///
175    /// The generated document has the same strict `fields` structure accepted
176    /// by [`SystemStateSchema::load_json_template`]. Runtime-only field indices and the source path
177    /// are omitted: field indices are reconstructed from array order, and the
178    /// destination path becomes the source when the JSON is loaded again.
179    ///
180    /// Serialization borrows the immutable field slice and does not clone
181    /// field names or descriptions. Absent descriptions are omitted.
182    ///
183    /// # Errors
184    ///
185    /// Returns the underlying [`serde_json::Error`] if JSON serialization
186    /// fails.
187    pub fn to_json_template(&self) -> Result<String, serde_json::Error> {
188        serde_json::to_string_pretty(&StateTemplateRef {
189            fields: self.field_schemas(),
190        })
191    }
192
193    /// Returns the path from which this specification was loaded.
194    ///
195    /// The path is retained exactly as supplied to [`SystemStateSchema::load_json_template`]. It may
196    /// be relative and is not guaranteed to remain accessible after loading.
197    pub fn template_path(&self) -> &Path {
198        &self.inner.source
199    }
200
201    /// Returns field definitions in deterministic template order.
202    pub fn field_schemas(&self) -> &[StateFieldSchema] {
203        &self.inner.fields
204    }
205
206    /// Returns the number of declared fields.
207    pub fn len(&self) -> usize {
208        self.inner.fields.len()
209    }
210
211    /// Reports whether the template declares no fields.
212    ///
213    /// Empty templates are structurally valid and can represent time-bearing
214    /// event records without scientific payloads.
215    pub fn is_empty(&self) -> bool {
216        self.inner.fields.is_empty()
217    }
218
219    /// Looks up a field definition by its normalized name.
220    pub fn field_schema(&self, name: &str) -> Option<&StateFieldSchema> {
221        let index = self.inner.by_name.get(name)?;
222        self.inner.fields.get(*index)
223    }
224
225    /// Reports whether the template declares `name`.
226    pub fn contains_field(&self, name: &str) -> bool {
227        self.inner.by_name.contains_key(name)
228    }
229
230    /// Reports whether two specification handles share one immutable layout.
231    ///
232    /// This is an identity comparison, not structural equality. Two templates
233    /// loaded independently may declare identical fields but still return
234    /// `false`; states derived by cloning one `SystemStateSchema` return `true` without
235    /// comparing field names, descriptions, source paths, or lookup maps.
236    ///
237    /// Identity is useful when building a homogeneous collection of states.
238    /// Once a collection accepts only states sharing its canonical layout,
239    /// later indexing and serialization can rely on one field order without
240    /// repeating structural comparisons.
241    pub(crate) fn shares_schema_instance(&self, other: &Self) -> bool {
242        Arc::ptr_eq(&self.inner, &other.inner)
243    }
244
245    /// Resolves a declared field name to its payload-slot index.
246    ///
247    /// This is crate-private because compact indices are an implementation
248    /// detail. Public callers address fields by name or inspect [`StateFieldSchema`].
249    pub(crate) fn index_of(&self, name: &str) -> Result<usize, StateError> {
250        self.inner
251            .by_name
252            .get(name)
253            .copied()
254            .ok_or_else(|| StateError::UnknownField {
255                field: name.to_owned(),
256            })
257    }
258
259    /// Validates a parsed template and constructs its shared lookup layout.
260    fn from_template(source: PathBuf, template: StateTemplate) -> Result<Self, StateError> {
261        let mut fields = Vec::with_capacity(template.fields.len());
262        let mut by_name = HashMap::with_capacity(template.fields.len());
263
264        for (index, declaration) in template.fields.into_iter().enumerate() {
265            let name = declaration.name.trim();
266            if name.is_empty() {
267                return Err(StateError::EmptyFieldName { index });
268            }
269
270            if by_name.contains_key(name) {
271                return Err(StateError::DuplicateField {
272                    field: name.to_owned(),
273                });
274            }
275
276            let field = StateFieldSchema::new(index, name, declaration.description.as_deref());
277            by_name.insert(field.name.clone(), index);
278            fields.push(field);
279        }
280
281        Ok(Self {
282            inner: Arc::new(StateLayout {
283                source,
284                fields,
285                by_name,
286            }),
287        })
288    }
289}
290
291/// Immutable metadata shared by every clone of a [`SystemStateSchema`].
292#[derive(Debug)]
293struct StateLayout {
294    /// Original template path retained for provenance and diagnostics.
295    source: PathBuf,
296    /// Validated fields in deterministic template order.
297    fields: Vec<StateFieldSchema>,
298    /// Normalized field name to compact payload-slot index.
299    by_name: HashMap<Box<str>, usize>,
300}
301
302/// Serde-only representation of the top-level JSON template.
303#[derive(Debug, Deserialize)]
304#[serde(deny_unknown_fields)]
305struct StateTemplate {
306    /// Ordered field declarations.
307    fields: Vec<FieldDeclaration>,
308}
309
310/// Serde-only representation of one JSON field declaration.
311#[derive(Debug, Deserialize)]
312#[serde(deny_unknown_fields)]
313struct FieldDeclaration {
314    /// Human-facing dictionary key.
315    name: String,
316    /// Optional human-facing payload documentation.
317    #[serde(default)]
318    description: Option<String>,
319}
320
321/// Borrowed serialization view of a validated state specification.
322///
323/// Keeping this separate from [`StateTemplate`] prevents deserialization-only
324/// owned strings from being allocated when converting an existing
325/// specification back to JSON.
326#[derive(Serialize)]
327struct StateTemplateRef<'a> {
328    /// Fields borrowed in deterministic template order.
329    fields: &'a [StateFieldSchema],
330}