Skip to main content

scientific_workflow/configuration/
parameters.rs

1//! Immutable fixed and swept parameter definitions with deterministic task
2//! expansion.
3//!
4//! [`ParameterSpace`] reads `fixed.json` and `sweep.json` from one standard
5//! configuration directory. It validates both documents once and stores every
6//! JSON value behind shared immutable ownership. [`TaskParameters`] identifies
7//! one resolved task by ordinal and performs dictionary lookups directly into
8//! that shared storage; creating or cloning a task does not clone parameter
9//! values or allocate a merged JSON object.
10//!
11//! # Sweep formats
12//!
13//! Cartesian mode preserves declared axis order and changes the final axis
14//! fastest:
15//!
16//! ```json
17//! {
18//!   "mode": "cartesian",
19//!   "axes": [
20//!     {"name": "temperature", "values": [280.0, 300.0]},
21//!     {"name": "seed", "values": [1, 2]}
22//!   ]
23//! }
24//! ```
25//!
26//! Explicit correlated cases use:
27//!
28//! ```json
29//! {
30//!   "mode": "cases",
31//!   "cases": [
32//!     {"temperature": 280.0, "physical_time_increment": 0.1},
33//!     {"temperature": 300.0, "physical_time_increment": 0.05}
34//!   ]
35//! }
36//! ```
37//!
38//! Fixed and swept keys must be disjoint. All exact JSON object keys are
39//! duplicate-checked recursively, including objects nested inside parameter
40//! values. An empty Cartesian axis list produces one fixed-only task; an empty
41//! candidate list or empty explicit-case list is rejected.
42//!
43//! # Lookup and ownership
44//!
45//! Raw lookup returns `&serde_json::Value` without copying. Typed decoding is an
46//! explicit conversion requested by the application and may allocate an owned
47//! `String`, `Vec`, or domain value. Applications should decode their required
48//! constants once before entering a numerical hot loop.
49
50use std::collections::{HashMap, HashSet};
51use std::fmt;
52use std::fs;
53use std::iter::FusedIterator;
54use std::path::{Path, PathBuf};
55use std::sync::Arc;
56
57use serde::de::{DeserializeOwned, MapAccess, SeqAccess, Visitor};
58use serde::ser::SerializeMap;
59use serde::{Deserialize, Deserializer, Serialize, Serializer};
60use serde_json::{Number, Value};
61
62use super::{ParameterKeyTuple, error::ConfigurationError};
63
64const FIXED_FILE: &str = "fixed.json";
65const SWEEP_FILE: &str = "sweep.json";
66
67/// A validated immutable fixed-and-swept parameter definition.
68///
69/// Cloning this type clones only an [`Arc`]. Parsed source values, lookup
70/// indexes, Cartesian strides, explicit cases, and exact source bytes remain in
71/// one shared allocation.
72#[derive(Clone)]
73pub struct ParameterSpace {
74    inner: Arc<ParameterSpaceInner>,
75}
76
77impl ParameterSpace {
78    /// Loads `fixed.json` and `sweep.json` from `configuration_directory`.
79    ///
80    /// The directory is the `config/` directory itself. The later
81    /// `ProjectConfig` facade accepts a project root and applies that standard
82    /// suffix automatically.
83    ///
84    /// # Errors
85    ///
86    /// Returns contextual IO or JSON errors, duplicate-key and document-shape
87    /// errors, fixed/sweep collisions, empty sweep definitions, or checked task
88    /// count overflow. Neither source file is modified.
89    pub fn load(configuration_directory: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
90        let configuration_directory = configuration_directory.into();
91        let fixed_path = configuration_directory.join(FIXED_FILE);
92        let sweep_path = configuration_directory.join(SWEEP_FILE);
93        let fixed_source = read_source(&fixed_path)?;
94        let sweep_source = read_source(&sweep_path)?;
95        let fixed_document = parse_strict_json(&fixed_path, &fixed_source)?;
96        let sweep_document = parse_strict_json(&sweep_path, &sweep_source)?;
97        let fixed = parse_fixed(&fixed_path, fixed_document)?;
98        let sweep = parse_sweep(&sweep_path, sweep_document)?;
99        validate_disjoint(&fixed_path, &sweep_path, &fixed, &sweep)?;
100
101        let fixed_by_name = fixed
102            .iter()
103            .enumerate()
104            .map(|(index, entry)| (entry.name.clone(), index))
105            .collect();
106        let sweep_by_name = sweep
107            .keys()
108            .enumerate()
109            .map(|(index, key)| (Box::<str>::from(key), index))
110            .collect();
111        let task_count = sweep.task_count();
112
113        Ok(Self {
114            inner: Arc::new(ParameterSpaceInner {
115                configuration_directory,
116                fixed_source: fixed_source.into_boxed_slice(),
117                sweep_source: sweep_source.into_boxed_slice(),
118                fixed,
119                fixed_by_name,
120                sweep,
121                sweep_by_name,
122                task_count,
123            }),
124        })
125    }
126
127    /// Returns the configuration directory exactly as supplied at load time.
128    pub fn configuration_directory(&self) -> &Path {
129        &self.inner.configuration_directory
130    }
131
132    /// Borrows the validated original bytes of `fixed.json` unchanged.
133    ///
134    /// These bytes include the source document's original whitespace, key
135    /// presentation order, and number spelling.
136    pub fn fixed_source_json(&self) -> &[u8] {
137        &self.inner.fixed_source
138    }
139
140    /// Borrows the validated original bytes of `sweep.json` unchanged.
141    pub fn sweep_source_json(&self) -> &[u8] {
142        &self.inner.sweep_source
143    }
144
145    /// Returns the number of names supplied by `fixed.json`.
146    pub fn fixed_parameter_count(&self) -> usize {
147        self.inner.fixed.len()
148    }
149
150    /// Returns the number of names supplied by one resolved sweep selection.
151    pub fn sweep_parameter_count(&self) -> usize {
152        self.inner.sweep.key_count()
153    }
154
155    /// Returns the number of entries in every resolved task dictionary.
156    pub fn parameter_count(&self) -> usize {
157        self.fixed_parameter_count() + self.sweep_parameter_count()
158    }
159
160    /// Returns the exact checked number of deterministic task combinations.
161    pub fn task_count(&self) -> u64 {
162        self.inner.task_count
163    }
164
165    /// Reports whether either the fixed table or sweep definition declares an
166    /// exact parameter key.
167    pub fn contains_parameter(&self, key: &str) -> bool {
168        self.inner.fixed_by_name.contains_key(key) || self.inner.sweep_by_name.contains_key(key)
169    }
170
171    /// Iterates fixed parameter names in their JSON declaration order.
172    pub fn fixed_keys(&self) -> impl ExactSizeIterator<Item = &str> {
173        self.inner.fixed.iter().map(|entry| entry.name.as_ref())
174    }
175
176    /// Iterates swept parameter names in declared axis or first-case order.
177    pub fn sweep_keys(&self) -> impl ExactSizeIterator<Item = &str> {
178        self.inner.sweep.keys()
179    }
180
181    /// Resolves one zero-based deterministic task ordinal.
182    ///
183    /// The returned dictionary shares this space's allocation and computes
184    /// selected values lazily during lookup. No parameter value is cloned.
185    pub fn task(&self, ordinal: u64) -> Result<TaskParameters, ConfigurationError> {
186        if ordinal >= self.task_count() {
187            return Err(ConfigurationError::TaskOrdinalOutOfBounds {
188                ordinal,
189                task_count: self.task_count(),
190            });
191        }
192        Ok(TaskParameters {
193            inner: Arc::clone(&self.inner),
194            ordinal,
195        })
196    }
197
198    /// Iterates every resolved task in increasing deterministic ordinal order.
199    ///
200    /// Iterator items cannot fail because the range is constructed from the
201    /// already validated task count.
202    pub fn tasks(&self) -> TaskParametersIter {
203        TaskParametersIter {
204            inner: Arc::clone(&self.inner),
205            next: 0,
206            end: self.task_count(),
207        }
208    }
209}
210
211impl fmt::Debug for ParameterSpace {
212    /// Formats bounded structural facts without traversing parameter values.
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter
215            .debug_struct("ParameterSpace")
216            .field("configuration_directory", &self.configuration_directory())
217            .field("fixed_parameters", &self.fixed_parameter_count())
218            .field("sweep_parameters", &self.sweep_parameter_count())
219            .field("task_count", &self.task_count())
220            .finish_non_exhaustive()
221    }
222}
223
224/// One immutable dict-like fixed-plus-sweep parameter selection.
225///
226/// This type owns no JSON values. Cloning it increments one shared reference
227/// count and copies one `u64` task ordinal.
228#[derive(Clone)]
229pub struct TaskParameters {
230    inner: Arc<ParameterSpaceInner>,
231    ordinal: u64,
232}
233
234impl TaskParameters {
235    /// Returns this task's zero-based deterministic ordinal.
236    pub fn task_ordinal(&self) -> u64 {
237        self.ordinal
238    }
239
240    /// Borrows one fixed or selected sweep value by exact or dotted-nested JSON key.
241    ///
242    /// Missing keys return `None`. No value is cloned, decoded, or allocated.
243    pub fn value(&self, key: &str) -> Option<&Value> {
244        if let Some((root, path)) = split_nested_key(key) {
245            if let Some(&position) = self.inner.fixed_by_name.get(root) {
246                return lookup_json_path(&self.inner.fixed[position].value, path);
247            }
248            if let Some(&position) = self.inner.sweep_by_name.get(root) {
249                return lookup_json_path(self.inner.sweep.value(self.ordinal, position), path);
250            }
251        }
252        if let Some(&position) = self.inner.fixed_by_name.get(key) {
253            return Some(&self.inner.fixed[position].value);
254        }
255        let &position = self.inner.sweep_by_name.get(key)?;
256        Some(self.inner.sweep.value(self.ordinal, position))
257    }
258
259    /// Borrows one required value or reports its task and exact missing key.
260    pub fn require_value(&self, key: &str) -> Result<&Value, ConfigurationError> {
261        self.value(key)
262            .ok_or_else(|| ConfigurationError::UnknownTaskParameter {
263                task_ordinal: self.ordinal,
264                key: key.to_owned(),
265            })
266    }
267
268    /// Decodes one required JSON value into the caller's concrete Rust type.
269    ///
270    /// Deserialization reads directly from the borrowed `serde_json::Value`;
271    /// this method does not first clone the generic JSON tree. The returned
272    /// concrete value is owned and may allocate according to `T`.
273    pub fn decode_value<T>(&self, key: &str) -> Result<T, ConfigurationError>
274    where
275        T: DeserializeOwned,
276    {
277        let value = self.require_value(key)?;
278        T::deserialize(value).map_err(|source| ConfigurationError::DecodeTaskParameter {
279            task_ordinal: self.ordinal,
280            key: key.to_owned(),
281            source,
282        })
283    }
284
285    /// Decodes several required parameters into a heterogeneous tuple.
286    ///
287    /// Supported key tuples have arities two through twelve. Every element
288    /// follows [`Self::decode_value`], so a failure retains the exact task
289    /// ordinal and parameter key without constructing a merged JSON object.
290    pub fn decode_values<Values, Keys>(&self, keys: Keys) -> Result<Values, ConfigurationError>
291    where
292        Keys: ParameterKeyTuple<Values>,
293    {
294        keys.decode(self)
295    }
296
297    /// Reports whether this resolved dictionary contains an exact or nested key.
298    pub fn contains(&self, key: &str) -> bool {
299        self.inner.fixed_by_name.contains_key(key)
300            || self.inner.sweep_by_name.contains_key(key)
301            || split_nested_key(key).is_some_and(|(root, path)| {
302                self.inner.fixed_by_name.get(root).is_some_and(|&position| {
303                    lookup_json_path(&self.inner.fixed[position].value, path).is_some()
304                }) || self.inner.sweep_by_name.get(root).is_some_and(|&position| {
305                    lookup_json_path(self.inner.sweep.value(self.ordinal, position), path).is_some()
306                })
307            })
308    }
309
310    /// Returns the fixed-plus-swept entry count.
311    pub fn len(&self) -> usize {
312        self.inner.fixed.len() + self.inner.sweep.key_count()
313    }
314
315    /// Reports whether the resolved task contains no parameter entries.
316    pub fn is_empty(&self) -> bool {
317        self.len() == 0
318    }
319
320    /// Iterates exact keys with fixed declarations first and swept declarations
321    /// second, preserving source declaration order within each group.
322    pub fn keys(&self) -> impl Iterator<Item = &str> {
323        self.inner
324            .fixed
325            .iter()
326            .map(|entry| entry.name.as_ref())
327            .chain(self.inner.sweep.keys())
328    }
329
330    /// Iterates resolved key/value references in the same order as
331    /// [`TaskParameters::keys`].
332    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
333        self.keys().map(|key| {
334            (
335                key,
336                self.value(key)
337                    .expect("a key yielded by a validated parameter space must resolve"),
338            )
339        })
340    }
341
342    /// Serializes the resolved fixed-plus-sweep dictionary as compact JSON.
343    ///
344    /// This is derived task data, not either original source document. Keys are
345    /// emitted in [`TaskParameters::keys`] order and values are serialized by
346    /// reference without constructing a merged `serde_json::Map`.
347    pub fn to_json(&self) -> Result<String, ConfigurationError> {
348        serde_json::to_string(&ResolvedTaskRef { task: self }).map_err(|source| {
349            ConfigurationError::SerializeTaskParameters {
350                task_ordinal: self.ordinal,
351                source,
352            }
353        })
354    }
355}
356
357/// Splits `a.b.c` into (`a`, `b.c`) for nested parameter lookup.
358fn split_nested_key(key: &str) -> Option<(&str, &str)> {
359    let (root, path) = key.split_once('.')?;
360    if root.is_empty() || path.is_empty() {
361        return None;
362    }
363    Some((root, path))
364}
365
366/// Looks up one nested JSON path inside an object value.
367fn lookup_json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
368    let mut current = value;
369    for segment in path.split('.') {
370        match current {
371            Value::Object(values) => current = values.get(segment)?,
372            _ => return None,
373        }
374    }
375    Some(current)
376}
377
378impl fmt::Debug for TaskParameters {
379    /// Formats identity and key counts without exposing parameter values.
380    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381        formatter
382            .debug_struct("TaskParameters")
383            .field("task_ordinal", &self.ordinal)
384            .field("parameters", &self.len())
385            .finish_non_exhaustive()
386    }
387}
388
389/// Owning iterator over cheap resolved task dictionaries.
390///
391/// The iterator retains the shared parameter space independently of the
392/// `ParameterSpace` handle that created it.
393#[derive(Clone)]
394pub struct TaskParametersIter {
395    inner: Arc<ParameterSpaceInner>,
396    next: u64,
397    end: u64,
398}
399
400impl Iterator for TaskParametersIter {
401    type Item = TaskParameters;
402
403    /// Produces the next increasing task ordinal without parameter-value
404    /// allocation.
405    fn next(&mut self) -> Option<Self::Item> {
406        if self.next == self.end {
407            return None;
408        }
409        let ordinal = self.next;
410        self.next += 1;
411        Some(TaskParameters {
412            inner: Arc::clone(&self.inner),
413            ordinal,
414        })
415    }
416
417    /// Reports an exact upper bound whenever the remaining `u64` count fits in
418    /// the platform's `usize`.
419    fn size_hint(&self) -> (usize, Option<usize>) {
420        let remaining = self.end - self.next;
421        match usize::try_from(remaining) {
422            Ok(remaining) => (remaining, Some(remaining)),
423            Err(_) => (usize::MAX, None),
424        }
425    }
426}
427
428impl FusedIterator for TaskParametersIter {}
429
430impl fmt::Debug for TaskParametersIter {
431    /// Formats only the remaining ordinal range.
432    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
433        formatter
434            .debug_struct("TaskParametersIter")
435            .field("next", &self.next)
436            .field("end", &self.end)
437            .finish_non_exhaustive()
438    }
439}
440
441/// Shared immutable allocation behind spaces, task views, and task iterators.
442struct ParameterSpaceInner {
443    configuration_directory: PathBuf,
444    fixed_source: Box<[u8]>,
445    sweep_source: Box<[u8]>,
446    fixed: Vec<NamedValue>,
447    fixed_by_name: HashMap<Box<str>, usize>,
448    sweep: SweepPlan,
449    sweep_by_name: HashMap<Box<str>, usize>,
450    task_count: u64,
451}
452
453/// One fixed parameter retained in source declaration order.
454struct NamedValue {
455    name: Box<str>,
456    value: Value,
457}
458
459/// Validated storage for either supported sweep expansion mode.
460enum SweepPlan {
461    Cartesian {
462        axes: Vec<SweepAxis>,
463        task_count: u64,
464    },
465    Cases {
466        keys: Vec<Box<str>>,
467        cases: Vec<Vec<Value>>,
468    },
469}
470
471impl SweepPlan {
472    /// Iterates resolved sweep keys in their authoritative declaration order.
473    fn keys(&self) -> impl ExactSizeIterator<Item = &str> {
474        let keys: &[Box<str>] = match self {
475            Self::Cartesian { axes, .. } => {
476                return SweepKeys::Axes(axes.iter()).map(SweepKey::into_str);
477            }
478            Self::Cases { keys, .. } => keys,
479        };
480        SweepKeys::Cases(keys.iter()).map(SweepKey::into_str)
481    }
482
483    /// Returns the number of sweep entries in each resolved task.
484    fn key_count(&self) -> usize {
485        match self {
486            Self::Cartesian { axes, .. } => axes.len(),
487            Self::Cases { keys, .. } => keys.len(),
488        }
489    }
490
491    /// Returns the validated total number of generated tasks.
492    fn task_count(&self) -> u64 {
493        match self {
494            Self::Cartesian { task_count, .. } => *task_count,
495            Self::Cases { cases, .. } => {
496                u64::try_from(cases.len()).expect("validated explicit case count must fit in u64")
497            }
498        }
499    }
500
501    /// Borrows one selected value for an already validated task and key
502    /// position.
503    fn value(&self, task_ordinal: u64, key_position: usize) -> &Value {
504        match self {
505            Self::Cartesian { axes, .. } => {
506                let axis = &axes[key_position];
507                let axis_length = u64::try_from(axis.values.len())
508                    .expect("validated Cartesian axis length must fit in u64");
509                let selected = (task_ordinal / axis.stride) % axis_length;
510                &axis.values[usize::try_from(selected)
511                    .expect("selected Cartesian index originated from a usize length")]
512            }
513            Self::Cases { cases, .. } => {
514                &cases[usize::try_from(task_ordinal)
515                    .expect("validated explicit task ordinal originated from a usize count")]
516                    [key_position]
517            }
518        }
519    }
520}
521
522/// One Cartesian axis with a precomputed mixed-radix stride.
523struct SweepAxis {
524    name: Box<str>,
525    values: Vec<Value>,
526    stride: u64,
527}
528
529/// Common key reference used to return one concrete iterator from both sweep
530/// plan variants.
531enum SweepKey<'a> {
532    Axis(&'a SweepAxis),
533    Case(&'a str),
534}
535
536impl<'a> SweepKey<'a> {
537    fn into_str(self) -> &'a str {
538        match self {
539            Self::Axis(axis) => &axis.name,
540            Self::Case(key) => key,
541        }
542    }
543}
544
545/// Variant-erased exact-size iterator over sweep keys.
546enum SweepKeys<'a> {
547    Axes(std::slice::Iter<'a, SweepAxis>),
548    Cases(std::slice::Iter<'a, Box<str>>),
549}
550
551impl<'a> Iterator for SweepKeys<'a> {
552    type Item = SweepKey<'a>;
553
554    fn next(&mut self) -> Option<Self::Item> {
555        match self {
556            Self::Axes(iter) => iter.next().map(SweepKey::Axis),
557            Self::Cases(iter) => iter.next().map(|key| SweepKey::Case(key)),
558        }
559    }
560
561    fn size_hint(&self) -> (usize, Option<usize>) {
562        match self {
563            Self::Axes(iter) => iter.size_hint(),
564            Self::Cases(iter) => iter.size_hint(),
565        }
566    }
567}
568
569impl ExactSizeIterator for SweepKeys<'_> {}
570impl FusedIterator for SweepKeys<'_> {}
571
572/// Borrowed serializer that emits a resolved task without cloning its values.
573struct ResolvedTaskRef<'a> {
574    task: &'a TaskParameters,
575}
576
577impl Serialize for ResolvedTaskRef<'_> {
578    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
579    where
580        S: Serializer,
581    {
582        let mut map = serializer.serialize_map(Some(self.task.len()))?;
583        for (key, value) in self.task.iter() {
584            map.serialize_entry(key, value)?;
585        }
586        map.end()
587    }
588}
589
590/// Duplicate-preserving JSON syntax tree used only during strict validation.
591///
592/// `serde_json::Value` stores objects as maps and would discard repeated keys.
593/// Retaining ordered pairs until validation allows the loader to reject every
594/// duplicate before converting accepted payloads to the ordinary public JSON
595/// value type.
596pub(super) enum StrictValue {
597    Null,
598    Bool(bool),
599    Number(Number),
600    String(String),
601    Array(Vec<Self>),
602    Object(Vec<(String, Self)>),
603}
604
605impl StrictValue {
606    /// Converts a duplicate-validated syntax tree to `serde_json::Value`.
607    fn into_json(self) -> Value {
608        match self {
609            Self::Null => Value::Null,
610            Self::Bool(value) => Value::Bool(value),
611            Self::Number(value) => Value::Number(value),
612            Self::String(value) => Value::String(value),
613            Self::Array(values) => {
614                Value::Array(values.into_iter().map(StrictValue::into_json).collect())
615            }
616            Self::Object(entries) => Value::Object(
617                entries
618                    .into_iter()
619                    .map(|(key, value)| (key, value.into_json()))
620                    .collect(),
621            ),
622        }
623    }
624
625    /// Finds the first repeated exact key at any object depth.
626    fn duplicate_key(&self) -> Option<&str> {
627        match self {
628            Self::Array(values) => values.iter().find_map(StrictValue::duplicate_key),
629            Self::Object(entries) => {
630                let mut seen = HashSet::with_capacity(entries.len());
631                for (key, value) in entries {
632                    if !seen.insert(key.as_str()) {
633                        return Some(key);
634                    }
635                    if let Some(duplicate) = value.duplicate_key() {
636                        return Some(duplicate);
637                    }
638                }
639                None
640            }
641            Self::Null | Self::Bool(_) | Self::Number(_) | Self::String(_) => None,
642        }
643    }
644}
645
646impl<'de> Deserialize<'de> for StrictValue {
647    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
648    where
649        D: Deserializer<'de>,
650    {
651        deserializer.deserialize_any(StrictValueVisitor)
652    }
653}
654
655/// Serde visitor that preserves object entries instead of collecting a map.
656struct StrictValueVisitor;
657
658impl<'de> Visitor<'de> for StrictValueVisitor {
659    type Value = StrictValue;
660
661    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
662        formatter.write_str("any valid JSON value")
663    }
664
665    fn visit_unit<E>(self) -> Result<Self::Value, E> {
666        Ok(StrictValue::Null)
667    }
668
669    fn visit_none<E>(self) -> Result<Self::Value, E> {
670        Ok(StrictValue::Null)
671    }
672
673    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
674    where
675        D: Deserializer<'de>,
676    {
677        StrictValue::deserialize(deserializer)
678    }
679
680    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
681        Ok(StrictValue::Bool(value))
682    }
683
684    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
685        Ok(StrictValue::Number(Number::from(value)))
686    }
687
688    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
689        Ok(StrictValue::Number(Number::from(value)))
690    }
691
692    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
693    where
694        E: serde::de::Error,
695    {
696        Number::from_f64(value)
697            .map(StrictValue::Number)
698            .ok_or_else(|| E::custom("JSON numbers must be finite"))
699    }
700
701    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
702    where
703        E: serde::de::Error,
704    {
705        Ok(StrictValue::String(value.to_owned()))
706    }
707
708    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
709        Ok(StrictValue::String(value))
710    }
711
712    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
713    where
714        A: SeqAccess<'de>,
715    {
716        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0));
717        while let Some(value) = sequence.next_element()? {
718            values.push(value);
719        }
720        Ok(StrictValue::Array(values))
721    }
722
723    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
724    where
725        A: MapAccess<'de>,
726    {
727        let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0));
728        while let Some((key, value)) = map.next_entry()? {
729            entries.push((key, value));
730        }
731        Ok(StrictValue::Object(entries))
732    }
733}
734
735/// Reads one source without normalizing bytes needed for exact re-export.
736pub(super) fn read_source(path: &Path) -> Result<Vec<u8>, ConfigurationError> {
737    fs::read(path).map_err(|source| ConfigurationError::ReadConfigurationFile {
738        path: path.to_path_buf(),
739        source,
740    })
741}
742
743/// Parses JSON while preserving duplicates for a distinct semantic error.
744pub(super) fn parse_strict_json(
745    path: &Path,
746    source: &[u8],
747) -> Result<StrictValue, ConfigurationError> {
748    let value: StrictValue = serde_json::from_slice(source).map_err(|source| {
749        ConfigurationError::ParseConfigurationFile {
750            path: path.to_path_buf(),
751            source,
752        }
753    })?;
754    if let Some(key) = value.duplicate_key() {
755        return Err(ConfigurationError::DuplicateConfigurationKey {
756            path: path.to_path_buf(),
757            key: key.to_owned(),
758        });
759    }
760    Ok(value)
761}
762
763/// Validates and converts the fixed root object in declaration order.
764fn parse_fixed(path: &Path, document: StrictValue) -> Result<Vec<NamedValue>, ConfigurationError> {
765    let entries = require_object(path, document, "fixed.json root must be an object")?;
766    entries
767        .into_iter()
768        .map(|(name, value)| {
769            validate_name(path, &name, "fixed parameter")?;
770            Ok(NamedValue {
771                name: name.into_boxed_str(),
772                value: value.into_json(),
773            })
774        })
775        .collect()
776}
777
778/// Dispatches the tagged sweep document to its mode-specific validator.
779fn parse_sweep(path: &Path, document: StrictValue) -> Result<SweepPlan, ConfigurationError> {
780    let mut root = require_object(path, document, "sweep.json root must be an object")?;
781    let mode = take_required(path, &mut root, "mode")?;
782    let StrictValue::String(mode) = mode else {
783        return invalid(path, "sweep field `mode` must be a string");
784    };
785    match mode.as_str() {
786        "cartesian" => {
787            let axes = take_required(path, &mut root, "axes")?;
788            reject_remaining(path, &root, "cartesian sweep")?;
789            parse_cartesian(path, axes)
790        }
791        "cases" => {
792            let cases = take_required(path, &mut root, "cases")?;
793            reject_remaining(path, &root, "explicit-case sweep")?;
794            parse_cases(path, cases)
795        }
796        _ => invalid(
797            path,
798            format!("unsupported sweep mode `{mode}`; expected `cartesian` or `cases`"),
799        ),
800    }
801}
802
803/// Validates ordered axes and precomputes mixed-radix strides.
804fn parse_cartesian(path: &Path, axes: StrictValue) -> Result<SweepPlan, ConfigurationError> {
805    let StrictValue::Array(axis_documents) = axes else {
806        return invalid(path, "cartesian sweep field `axes` must be an array");
807    };
808    let mut parsed = Vec::with_capacity(axis_documents.len());
809    let mut names = HashSet::with_capacity(axis_documents.len());
810    for (position, document) in axis_documents.into_iter().enumerate() {
811        let mut axis = require_object(
812            path,
813            document,
814            format!("cartesian axis at position {position} must be an object"),
815        )?;
816        let name = take_required(path, &mut axis, "name")?;
817        let StrictValue::String(name) = name else {
818            return invalid(
819                path,
820                format!("cartesian axis at position {position} has a non-string `name`"),
821            );
822        };
823        validate_name(path, &name, "sweep axis")?;
824        if !names.insert(name.clone()) {
825            return invalid(
826                path,
827                format!("sweep axis name `{name}` is declared more than once"),
828            );
829        }
830        let values = take_required(path, &mut axis, "values")?;
831        let StrictValue::Array(values) = values else {
832            return invalid(
833                path,
834                format!("cartesian axis `{name}` field `values` must be an array"),
835            );
836        };
837        if values.is_empty() {
838            return invalid(path, format!("cartesian axis `{name}` has no candidates"));
839        }
840        reject_remaining(path, &axis, &format!("cartesian axis `{name}`"))?;
841        parsed.push(SweepAxis {
842            name: name.into_boxed_str(),
843            values: values.into_iter().map(StrictValue::into_json).collect(),
844            stride: 0,
845        });
846    }
847
848    let mut task_count = 1_u64;
849    for axis in parsed.iter_mut().rev() {
850        axis.stride = task_count;
851        let length = u64::try_from(axis.values.len()).map_err(|_| {
852            ConfigurationError::TaskCountOverflow {
853                axis: axis.name.to_string(),
854            }
855        })?;
856        task_count = task_count.checked_mul(length).ok_or_else(|| {
857            ConfigurationError::TaskCountOverflow {
858                axis: axis.name.to_string(),
859            }
860        })?;
861    }
862    Ok(SweepPlan::Cartesian {
863        axes: parsed,
864        task_count,
865    })
866}
867
868/// Validates correlated cases and normalizes their values to first-case order.
869fn parse_cases(path: &Path, cases: StrictValue) -> Result<SweepPlan, ConfigurationError> {
870    let StrictValue::Array(case_documents) = cases else {
871        return invalid(path, "explicit sweep field `cases` must be an array");
872    };
873    if case_documents.is_empty() {
874        return invalid(path, "explicit sweep must contain at least one case");
875    }
876
877    let mut case_iter = case_documents.into_iter().enumerate();
878    let (_, first) = case_iter
879        .next()
880        .expect("an explicitly non-empty case array has a first item");
881    let first = require_object(path, first, "explicit case at position 0 must be an object")?;
882    if first.is_empty() {
883        return invalid(
884            path,
885            "explicit sweep cases must contain at least one parameter",
886        );
887    }
888    let mut keys = Vec::with_capacity(first.len());
889    let mut first_values = Vec::with_capacity(first.len());
890    for (name, value) in first {
891        validate_name(path, &name, "explicit-case parameter")?;
892        keys.push(name.into_boxed_str());
893        first_values.push(value.into_json());
894    }
895
896    let expected = keys.iter().map(AsRef::as_ref).collect::<HashSet<&str>>();
897    let mut parsed_cases = Vec::with_capacity(case_iter.size_hint().0 + 1);
898    parsed_cases.push(first_values);
899    for (position, document) in case_iter {
900        let entries = require_object(
901            path,
902            document,
903            format!("explicit case at position {position} must be an object"),
904        )?;
905        let actual = entries
906            .iter()
907            .map(|(name, _)| name.as_str())
908            .collect::<HashSet<_>>();
909        if actual != expected {
910            return invalid(
911                path,
912                format!(
913                    "explicit case at position {position} does not contain the same key set as case 0"
914                ),
915            );
916        }
917        let mut by_name = entries.into_iter().collect::<HashMap<_, _>>();
918        parsed_cases.push(
919            keys.iter()
920                .map(|key| {
921                    by_name
922                        .remove(key.as_ref())
923                        .expect("validated explicit case contains every first-case key")
924                        .into_json()
925                })
926                .collect(),
927        );
928    }
929    let _ =
930        u64::try_from(parsed_cases.len()).map_err(|_| ConfigurationError::TaskCountOverflow {
931            axis: "explicit cases".to_owned(),
932        })?;
933    Ok(SweepPlan::Cases {
934        keys,
935        cases: parsed_cases,
936    })
937}
938
939/// Rejects every fixed key that is also selected by the sweep.
940fn validate_disjoint(
941    fixed_path: &Path,
942    sweep_path: &Path,
943    fixed: &[NamedValue],
944    sweep: &SweepPlan,
945) -> Result<(), ConfigurationError> {
946    let fixed_names = fixed
947        .iter()
948        .map(|entry| entry.name.as_ref())
949        .collect::<HashSet<&str>>();
950    if let Some(key) = sweep.keys().find(|key| fixed_names.contains(key)) {
951        return Err(ConfigurationError::FixedSweepKeyConflict {
952            key: key.to_owned(),
953            fixed_path: fixed_path.to_path_buf(),
954            sweep_path: sweep_path.to_path_buf(),
955        });
956    }
957    Ok(())
958}
959
960/// Extracts an object or constructs one contextual semantic error.
961pub(super) fn require_object(
962    path: &Path,
963    value: StrictValue,
964    reason: impl Into<String>,
965) -> Result<Vec<(String, StrictValue)>, ConfigurationError> {
966    match value {
967        StrictValue::Object(entries) => Ok(entries),
968        _ => invalid(path, reason),
969    }
970}
971
972/// Removes one required exact field from an already duplicate-checked object.
973fn take_required(
974    path: &Path,
975    entries: &mut Vec<(String, StrictValue)>,
976    name: &str,
977) -> Result<StrictValue, ConfigurationError> {
978    let Some(position) = entries.iter().position(|(key, _)| key == name) else {
979        return invalid(path, format!("required field `{name}` is missing"));
980    };
981    Ok(entries.remove(position).1)
982}
983
984/// Rejects unsupported fields after all required fields have been consumed.
985fn reject_remaining(
986    path: &Path,
987    entries: &[(String, StrictValue)],
988    context: &str,
989) -> Result<(), ConfigurationError> {
990    if let Some((name, _)) = entries.first() {
991        return invalid(path, format!("{context} contains unknown field `{name}`"));
992    }
993    Ok(())
994}
995
996/// Enforces non-empty human-visible exact lookup names without normalization.
997pub(super) fn validate_name(path: &Path, name: &str, kind: &str) -> Result<(), ConfigurationError> {
998    if name.trim().is_empty() {
999        return invalid(
1000            path,
1001            format!("{kind} name must not be empty or whitespace-only"),
1002        );
1003    }
1004    Ok(())
1005}
1006
1007/// Constructs a semantic document error while preserving its source path.
1008pub(super) fn invalid<T>(path: &Path, reason: impl Into<String>) -> Result<T, ConfigurationError> {
1009    Err(ConfigurationError::InvalidConfigurationDocument {
1010        path: path.to_path_buf(),
1011        reason: reason.into(),
1012    })
1013}