Skip to main content

scientific_workflow/system_state/
spec.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//!     {"name": "population", "type": "vec.u64"},
16//!     {"name": "space", "type": "example.lattice.v1"}
17//!   ]
18//! }
19//! ```
20//!
21//! Array order is significant. It assigns each field a zero-based index used
22//! by the compact payload-slot vector in `SystemState`. Names and type tags are
23//! trimmed before validation and storage. Unknown JSON properties are rejected
24//! so misspelled template configuration cannot be silently ignored.
25//!
26//! # Type tags
27//!
28//! A field's `type` is a stable serialization tag, not a Rust type name.
29//! Runtime Rust types are still checked by `SystemState` through `TypeId`.
30//! Connecting stable tags to codecs is a later storage-layer responsibility.
31//!
32//! # Sharing and performance
33//!
34//! `StateSpec` is a small cloneable handle around an immutable, reference-
35//! counted layout. Cloning it never duplicates field names or lookup tables.
36//! Field lookup uses a hash map, while iteration preserves JSON declaration
37//! order through the field slice.
38
39use std::collections::HashMap;
40use std::fs;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43
44use serde::{Deserialize, Serialize};
45
46use super::error::StateError;
47use super::state::{SystemState, TimePoint};
48
49/// One validated field in a state template.
50///
51/// A field specification is immutable after template loading. Its index is the
52/// position of the corresponding payload slot in every `SystemState` created
53/// from the same [`StateSpec`].
54#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
55pub struct FieldSpec {
56    #[serde(skip)]
57    index: usize,
58    name: Box<str>,
59    #[serde(rename = "type")]
60    type_tag: Box<str>,
61}
62
63impl FieldSpec {
64    /// Constructs one normalized field definition.
65    fn new(index: usize, name: &str, type_tag: &str) -> Self {
66        Self {
67            index,
68            name: name.trim().into(),
69            type_tag: type_tag.trim().into(),
70        }
71    }
72
73    /// Returns the zero-based payload-slot index assigned by template order.
74    pub fn index(&self) -> usize {
75        self.index
76    }
77
78    /// Returns the field name used by typed SystemState accessors.
79    pub fn name(&self) -> &str {
80        &self.name
81    }
82
83    /// Returns the stable codec type tag declared by the template.
84    ///
85    /// The tag identifies serialized meaning across processes and versions. It
86    /// must not be interpreted as `std::any::type_name::<T>()`.
87    pub fn type_tag(&self) -> &str {
88        &self.type_tag
89    }
90}
91
92/// A validated, shareable SystemState layout.
93///
94/// `StateSpec` owns an [`Arc`] to immutable metadata, making `Clone` a cheap
95/// reference-count increment. Every state derived from a specification shares
96/// the exact field order and name lookup table.
97#[derive(Clone, Debug)]
98pub struct StateSpec {
99    inner: Arc<StateLayout>,
100}
101
102impl StateSpec {
103    /// Loads and validates a state specification from a JSON template.
104    ///
105    /// The file is read as bytes and parsed directly, avoiding an intermediate
106    /// UTF-8 `String` allocation. The returned specification retains the input
107    /// path for diagnostics and provenance but does not canonicalize it or keep
108    /// the file open.
109    ///
110    /// # Errors
111    ///
112    /// Returns:
113    ///
114    /// - [`StateError::TemplateRead`] when the file cannot be read;
115    /// - [`StateError::TemplateParse`] when JSON syntax or structure is invalid;
116    /// - [`StateError::EmptyFieldName`] for an empty normalized field name;
117    /// - [`StateError::DuplicateField`] for repeated normalized names;
118    /// - [`StateError::EmptyTypeTag`] for an empty normalized type tag.
119    pub fn load(path: impl AsRef<Path>) -> Result<Self, StateError> {
120        let path = path.as_ref();
121        let bytes = fs::read(path).map_err(|source| StateError::TemplateRead {
122            path: path.to_path_buf(),
123            source,
124        })?;
125        let template: StateTemplate =
126            serde_json::from_slice(&bytes).map_err(|source| StateError::TemplateParse {
127                path: path.to_path_buf(),
128                source,
129            })?;
130
131        Self::from_template(path.to_path_buf(), template)
132    }
133
134    /// Creates an empty SystemState that shares this specification.
135    ///
136    /// Every declared field exists in the returned state's layout, while every
137    /// payload slot starts empty. Cloning the specification is constant-time
138    /// and does not duplicate layout data.
139    pub fn empty(&self, time: TimePoint) -> SystemState {
140        SystemState::new(self.clone(), time)
141    }
142
143    /// Converts this specification into a pretty-printed JSON template.
144    ///
145    /// The generated document has the same strict `fields` structure accepted
146    /// by [`StateSpec::load`]. Runtime-only field indices and the source path
147    /// are omitted: field indices are reconstructed from array order, and the
148    /// destination path becomes the source when the JSON is loaded again.
149    ///
150    /// Serialization borrows the immutable field slice and does not clone
151    /// field names or type tags.
152    ///
153    /// # Errors
154    ///
155    /// Returns the underlying [`serde_json::Error`] if JSON serialization
156    /// fails.
157    pub fn to_json(&self) -> Result<String, serde_json::Error> {
158        serde_json::to_string_pretty(&StateTemplateRef {
159            fields: self.fields(),
160        })
161    }
162
163    /// Returns the path from which this specification was loaded.
164    ///
165    /// The path is retained exactly as supplied to [`StateSpec::load`]. It may
166    /// be relative and is not guaranteed to remain accessible after loading.
167    pub fn source(&self) -> &Path {
168        &self.inner.source
169    }
170
171    /// Returns field definitions in deterministic template order.
172    pub fn fields(&self) -> &[FieldSpec] {
173        &self.inner.fields
174    }
175
176    /// Returns the number of declared fields.
177    pub fn len(&self) -> usize {
178        self.inner.fields.len()
179    }
180
181    /// Reports whether the template declares no fields.
182    ///
183    /// Empty templates are structurally valid. They can represent a
184    /// time-bearing event stream whose payload schema will be extended in a
185    /// later template revision.
186    pub fn is_empty(&self) -> bool {
187        self.inner.fields.is_empty()
188    }
189
190    /// Looks up a field definition by its normalized name.
191    pub fn get(&self, name: &str) -> Option<&FieldSpec> {
192        let index = self.inner.by_name.get(name)?;
193        self.inner.fields.get(*index)
194    }
195
196    /// Reports whether the template declares `name`.
197    pub fn contains(&self, name: &str) -> bool {
198        self.inner.by_name.contains_key(name)
199    }
200
201    /// Resolves a declared field name to its payload-slot index.
202    ///
203    /// This is crate-private because compact indices are an implementation
204    /// detail. Public callers address fields by name or inspect [`FieldSpec`].
205    pub(crate) fn index_of(&self, name: &str) -> Result<usize, StateError> {
206        self.inner
207            .by_name
208            .get(name)
209            .copied()
210            .ok_or_else(|| StateError::UnknownField {
211                field: name.to_owned(),
212            })
213    }
214
215    /// Validates a parsed template and constructs its shared lookup layout.
216    fn from_template(source: PathBuf, template: StateTemplate) -> Result<Self, StateError> {
217        let mut fields = Vec::with_capacity(template.fields.len());
218        let mut by_name = HashMap::with_capacity(template.fields.len());
219
220        for (index, declaration) in template.fields.into_iter().enumerate() {
221            let name = declaration.name.trim();
222            if name.is_empty() {
223                return Err(StateError::EmptyFieldName { index });
224            }
225
226            let type_tag = declaration.type_tag.trim();
227            if type_tag.is_empty() {
228                return Err(StateError::EmptyTypeTag {
229                    field: name.to_owned(),
230                });
231            }
232
233            if by_name.contains_key(name) {
234                return Err(StateError::DuplicateField {
235                    field: name.to_owned(),
236                });
237            }
238
239            let field = FieldSpec::new(index, name, type_tag);
240            by_name.insert(field.name.clone(), index);
241            fields.push(field);
242        }
243
244        Ok(Self {
245            inner: Arc::new(StateLayout {
246                source,
247                fields,
248                by_name,
249            }),
250        })
251    }
252}
253
254/// Immutable metadata shared by every clone of a [`StateSpec`].
255#[derive(Debug)]
256struct StateLayout {
257    /// Original template path retained for provenance and diagnostics.
258    source: PathBuf,
259    /// Validated fields in deterministic template order.
260    fields: Vec<FieldSpec>,
261    /// Normalized field name to compact payload-slot index.
262    by_name: HashMap<Box<str>, usize>,
263}
264
265/// Serde-only representation of the top-level JSON template.
266#[derive(Debug, Deserialize)]
267#[serde(deny_unknown_fields)]
268struct StateTemplate {
269    /// Ordered field declarations.
270    fields: Vec<FieldDeclaration>,
271}
272
273/// Serde-only representation of one JSON field declaration.
274#[derive(Debug, Deserialize)]
275#[serde(deny_unknown_fields)]
276struct FieldDeclaration {
277    /// Human-facing dictionary key.
278    name: String,
279    /// Stable serialization tag; `type` is renamed because it is a Rust
280    /// keyword.
281    #[serde(rename = "type")]
282    type_tag: String,
283}
284
285/// Borrowed serialization view of a validated state specification.
286///
287/// Keeping this separate from [`StateTemplate`] prevents deserialization-only
288/// owned strings from being allocated when converting an existing
289/// specification back to JSON.
290#[derive(Serialize)]
291struct StateTemplateRef<'a> {
292    /// Fields borrowed in deterministic template order.
293    fields: &'a [FieldSpec],
294}