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}