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}