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::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    /// Reports whether this resolved dictionary contains an exact or nested key.
286    pub fn contains(&self, key: &str) -> bool {
287        self.inner.fixed_by_name.contains_key(key)
288            || self.inner.sweep_by_name.contains_key(key)
289            || split_nested_key(key).is_some_and(|(root, path)| {
290                self.inner.fixed_by_name.get(root).is_some_and(|&position| {
291                    lookup_json_path(&self.inner.fixed[position].value, path).is_some()
292                }) || self.inner.sweep_by_name.get(root).is_some_and(|&position| {
293                    lookup_json_path(self.inner.sweep.value(self.ordinal, position), path).is_some()
294                })
295            })
296    }
297
298    /// Returns the fixed-plus-swept entry count.
299    pub fn len(&self) -> usize {
300        self.inner.fixed.len() + self.inner.sweep.key_count()
301    }
302
303    /// Reports whether the resolved task contains no parameter entries.
304    pub fn is_empty(&self) -> bool {
305        self.len() == 0
306    }
307
308    /// Iterates exact keys with fixed declarations first and swept declarations
309    /// second, preserving source declaration order within each group.
310    pub fn keys(&self) -> impl Iterator<Item = &str> {
311        self.inner
312            .fixed
313            .iter()
314            .map(|entry| entry.name.as_ref())
315            .chain(self.inner.sweep.keys())
316    }
317
318    /// Iterates resolved key/value references in the same order as
319    /// [`TaskParameters::keys`].
320    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
321        self.keys().map(|key| {
322            (
323                key,
324                self.value(key)
325                    .expect("a key yielded by a validated parameter space must resolve"),
326            )
327        })
328    }
329
330    /// Serializes the resolved fixed-plus-sweep dictionary as compact JSON.
331    ///
332    /// This is derived task data, not either original source document. Keys are
333    /// emitted in [`TaskParameters::keys`] order and values are serialized by
334    /// reference without constructing a merged `serde_json::Map`.
335    pub fn to_json(&self) -> Result<String, ConfigurationError> {
336        serde_json::to_string(&ResolvedTaskRef { task: self }).map_err(|source| {
337            ConfigurationError::SerializeTaskParameters {
338                task_ordinal: self.ordinal,
339                source,
340            }
341        })
342    }
343}
344
345/// Splits `a.b.c` into (`a`, `b.c`) for nested parameter lookup.
346fn split_nested_key(key: &str) -> Option<(&str, &str)> {
347    let (root, path) = key.split_once('.')?;
348    if root.is_empty() || path.is_empty() {
349        return None;
350    }
351    Some((root, path))
352}
353
354/// Looks up one nested JSON path inside an object value.
355fn lookup_json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
356    let mut current = value;
357    for segment in path.split('.') {
358        match current {
359            Value::Object(values) => current = values.get(segment)?,
360            _ => return None,
361        }
362    }
363    Some(current)
364}
365
366impl fmt::Debug for TaskParameters {
367    /// Formats identity and key counts without exposing parameter values.
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter
370            .debug_struct("TaskParameters")
371            .field("task_ordinal", &self.ordinal)
372            .field("parameters", &self.len())
373            .finish_non_exhaustive()
374    }
375}
376
377/// Owning iterator over cheap resolved task dictionaries.
378///
379/// The iterator retains the shared parameter space independently of the
380/// `ParameterSpace` handle that created it.
381#[derive(Clone)]
382pub struct TaskParametersIter {
383    inner: Arc<ParameterSpaceInner>,
384    next: u64,
385    end: u64,
386}
387
388impl Iterator for TaskParametersIter {
389    type Item = TaskParameters;
390
391    /// Produces the next increasing task ordinal without parameter-value
392    /// allocation.
393    fn next(&mut self) -> Option<Self::Item> {
394        if self.next == self.end {
395            return None;
396        }
397        let ordinal = self.next;
398        self.next += 1;
399        Some(TaskParameters {
400            inner: Arc::clone(&self.inner),
401            ordinal,
402        })
403    }
404
405    /// Reports an exact upper bound whenever the remaining `u64` count fits in
406    /// the platform's `usize`.
407    fn size_hint(&self) -> (usize, Option<usize>) {
408        let remaining = self.end - self.next;
409        match usize::try_from(remaining) {
410            Ok(remaining) => (remaining, Some(remaining)),
411            Err(_) => (usize::MAX, None),
412        }
413    }
414}
415
416impl FusedIterator for TaskParametersIter {}
417
418impl fmt::Debug for TaskParametersIter {
419    /// Formats only the remaining ordinal range.
420    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
421        formatter
422            .debug_struct("TaskParametersIter")
423            .field("next", &self.next)
424            .field("end", &self.end)
425            .finish_non_exhaustive()
426    }
427}
428
429/// Shared immutable allocation behind spaces, task views, and task iterators.
430struct ParameterSpaceInner {
431    configuration_directory: PathBuf,
432    fixed_source: Box<[u8]>,
433    sweep_source: Box<[u8]>,
434    fixed: Vec<NamedValue>,
435    fixed_by_name: HashMap<Box<str>, usize>,
436    sweep: SweepPlan,
437    sweep_by_name: HashMap<Box<str>, usize>,
438    task_count: u64,
439}
440
441/// One fixed parameter retained in source declaration order.
442struct NamedValue {
443    name: Box<str>,
444    value: Value,
445}
446
447/// Validated storage for either supported sweep expansion mode.
448enum SweepPlan {
449    Cartesian {
450        axes: Vec<SweepAxis>,
451        task_count: u64,
452    },
453    Cases {
454        keys: Vec<Box<str>>,
455        cases: Vec<Vec<Value>>,
456    },
457}
458
459impl SweepPlan {
460    /// Iterates resolved sweep keys in their authoritative declaration order.
461    fn keys(&self) -> impl ExactSizeIterator<Item = &str> {
462        let keys: &[Box<str>] = match self {
463            Self::Cartesian { axes, .. } => {
464                return SweepKeys::Axes(axes.iter()).map(SweepKey::into_str);
465            }
466            Self::Cases { keys, .. } => keys,
467        };
468        SweepKeys::Cases(keys.iter()).map(SweepKey::into_str)
469    }
470
471    /// Returns the number of sweep entries in each resolved task.
472    fn key_count(&self) -> usize {
473        match self {
474            Self::Cartesian { axes, .. } => axes.len(),
475            Self::Cases { keys, .. } => keys.len(),
476        }
477    }
478
479    /// Returns the validated total number of generated tasks.
480    fn task_count(&self) -> u64 {
481        match self {
482            Self::Cartesian { task_count, .. } => *task_count,
483            Self::Cases { cases, .. } => {
484                u64::try_from(cases.len()).expect("validated explicit case count must fit in u64")
485            }
486        }
487    }
488
489    /// Borrows one selected value for an already validated task and key
490    /// position.
491    fn value(&self, task_ordinal: u64, key_position: usize) -> &Value {
492        match self {
493            Self::Cartesian { axes, .. } => {
494                let axis = &axes[key_position];
495                let axis_length = u64::try_from(axis.values.len())
496                    .expect("validated Cartesian axis length must fit in u64");
497                let selected = (task_ordinal / axis.stride) % axis_length;
498                &axis.values[usize::try_from(selected)
499                    .expect("selected Cartesian index originated from a usize length")]
500            }
501            Self::Cases { cases, .. } => {
502                &cases[usize::try_from(task_ordinal)
503                    .expect("validated explicit task ordinal originated from a usize count")]
504                    [key_position]
505            }
506        }
507    }
508}
509
510/// One Cartesian axis with a precomputed mixed-radix stride.
511struct SweepAxis {
512    name: Box<str>,
513    values: Vec<Value>,
514    stride: u64,
515}
516
517/// Common key reference used to return one concrete iterator from both sweep
518/// plan variants.
519enum SweepKey<'a> {
520    Axis(&'a SweepAxis),
521    Case(&'a str),
522}
523
524impl<'a> SweepKey<'a> {
525    fn into_str(self) -> &'a str {
526        match self {
527            Self::Axis(axis) => &axis.name,
528            Self::Case(key) => key,
529        }
530    }
531}
532
533/// Variant-erased exact-size iterator over sweep keys.
534enum SweepKeys<'a> {
535    Axes(std::slice::Iter<'a, SweepAxis>),
536    Cases(std::slice::Iter<'a, Box<str>>),
537}
538
539impl<'a> Iterator for SweepKeys<'a> {
540    type Item = SweepKey<'a>;
541
542    fn next(&mut self) -> Option<Self::Item> {
543        match self {
544            Self::Axes(iter) => iter.next().map(SweepKey::Axis),
545            Self::Cases(iter) => iter.next().map(|key| SweepKey::Case(key)),
546        }
547    }
548
549    fn size_hint(&self) -> (usize, Option<usize>) {
550        match self {
551            Self::Axes(iter) => iter.size_hint(),
552            Self::Cases(iter) => iter.size_hint(),
553        }
554    }
555}
556
557impl ExactSizeIterator for SweepKeys<'_> {}
558impl FusedIterator for SweepKeys<'_> {}
559
560/// Borrowed serializer that emits a resolved task without cloning its values.
561struct ResolvedTaskRef<'a> {
562    task: &'a TaskParameters,
563}
564
565impl Serialize for ResolvedTaskRef<'_> {
566    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
567    where
568        S: Serializer,
569    {
570        let mut map = serializer.serialize_map(Some(self.task.len()))?;
571        for (key, value) in self.task.iter() {
572            map.serialize_entry(key, value)?;
573        }
574        map.end()
575    }
576}
577
578/// Duplicate-preserving JSON syntax tree used only during strict validation.
579///
580/// `serde_json::Value` stores objects as maps and would discard repeated keys.
581/// Retaining ordered pairs until validation allows the loader to reject every
582/// duplicate before converting accepted payloads to the ordinary public JSON
583/// value type.
584pub(super) enum StrictValue {
585    Null,
586    Bool(bool),
587    Number(Number),
588    String(String),
589    Array(Vec<Self>),
590    Object(Vec<(String, Self)>),
591}
592
593impl StrictValue {
594    /// Converts a duplicate-validated syntax tree to `serde_json::Value`.
595    fn into_json(self) -> Value {
596        match self {
597            Self::Null => Value::Null,
598            Self::Bool(value) => Value::Bool(value),
599            Self::Number(value) => Value::Number(value),
600            Self::String(value) => Value::String(value),
601            Self::Array(values) => {
602                Value::Array(values.into_iter().map(StrictValue::into_json).collect())
603            }
604            Self::Object(entries) => Value::Object(
605                entries
606                    .into_iter()
607                    .map(|(key, value)| (key, value.into_json()))
608                    .collect(),
609            ),
610        }
611    }
612
613    /// Finds the first repeated exact key at any object depth.
614    fn duplicate_key(&self) -> Option<&str> {
615        match self {
616            Self::Array(values) => values.iter().find_map(StrictValue::duplicate_key),
617            Self::Object(entries) => {
618                let mut seen = HashSet::with_capacity(entries.len());
619                for (key, value) in entries {
620                    if !seen.insert(key.as_str()) {
621                        return Some(key);
622                    }
623                    if let Some(duplicate) = value.duplicate_key() {
624                        return Some(duplicate);
625                    }
626                }
627                None
628            }
629            Self::Null | Self::Bool(_) | Self::Number(_) | Self::String(_) => None,
630        }
631    }
632}
633
634impl<'de> Deserialize<'de> for StrictValue {
635    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
636    where
637        D: Deserializer<'de>,
638    {
639        deserializer.deserialize_any(StrictValueVisitor)
640    }
641}
642
643/// Serde visitor that preserves object entries instead of collecting a map.
644struct StrictValueVisitor;
645
646impl<'de> Visitor<'de> for StrictValueVisitor {
647    type Value = StrictValue;
648
649    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
650        formatter.write_str("any valid JSON value")
651    }
652
653    fn visit_unit<E>(self) -> Result<Self::Value, E> {
654        Ok(StrictValue::Null)
655    }
656
657    fn visit_none<E>(self) -> Result<Self::Value, E> {
658        Ok(StrictValue::Null)
659    }
660
661    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
662    where
663        D: Deserializer<'de>,
664    {
665        StrictValue::deserialize(deserializer)
666    }
667
668    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
669        Ok(StrictValue::Bool(value))
670    }
671
672    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
673        Ok(StrictValue::Number(Number::from(value)))
674    }
675
676    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
677        Ok(StrictValue::Number(Number::from(value)))
678    }
679
680    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
681    where
682        E: serde::de::Error,
683    {
684        Number::from_f64(value)
685            .map(StrictValue::Number)
686            .ok_or_else(|| E::custom("JSON numbers must be finite"))
687    }
688
689    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
690    where
691        E: serde::de::Error,
692    {
693        Ok(StrictValue::String(value.to_owned()))
694    }
695
696    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
697        Ok(StrictValue::String(value))
698    }
699
700    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
701    where
702        A: SeqAccess<'de>,
703    {
704        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0));
705        while let Some(value) = sequence.next_element()? {
706            values.push(value);
707        }
708        Ok(StrictValue::Array(values))
709    }
710
711    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
712    where
713        A: MapAccess<'de>,
714    {
715        let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0));
716        while let Some((key, value)) = map.next_entry()? {
717            entries.push((key, value));
718        }
719        Ok(StrictValue::Object(entries))
720    }
721}
722
723/// Reads one source without normalizing bytes needed for exact re-export.
724pub(super) fn read_source(path: &Path) -> Result<Vec<u8>, ConfigurationError> {
725    fs::read(path).map_err(|source| ConfigurationError::ReadConfigurationFile {
726        path: path.to_path_buf(),
727        source,
728    })
729}
730
731/// Parses JSON while preserving duplicates for a distinct semantic error.
732pub(super) fn parse_strict_json(
733    path: &Path,
734    source: &[u8],
735) -> Result<StrictValue, ConfigurationError> {
736    let value: StrictValue = serde_json::from_slice(source).map_err(|source| {
737        ConfigurationError::ParseConfigurationFile {
738            path: path.to_path_buf(),
739            source,
740        }
741    })?;
742    if let Some(key) = value.duplicate_key() {
743        return Err(ConfigurationError::DuplicateConfigurationKey {
744            path: path.to_path_buf(),
745            key: key.to_owned(),
746        });
747    }
748    Ok(value)
749}
750
751/// Validates and converts the fixed root object in declaration order.
752fn parse_fixed(path: &Path, document: StrictValue) -> Result<Vec<NamedValue>, ConfigurationError> {
753    let entries = require_object(path, document, "fixed.json root must be an object")?;
754    entries
755        .into_iter()
756        .map(|(name, value)| {
757            validate_name(path, &name, "fixed parameter")?;
758            Ok(NamedValue {
759                name: name.into_boxed_str(),
760                value: value.into_json(),
761            })
762        })
763        .collect()
764}
765
766/// Dispatches the tagged sweep document to its mode-specific validator.
767fn parse_sweep(path: &Path, document: StrictValue) -> Result<SweepPlan, ConfigurationError> {
768    let mut root = require_object(path, document, "sweep.json root must be an object")?;
769    let mode = take_required(path, &mut root, "mode")?;
770    let StrictValue::String(mode) = mode else {
771        return invalid(path, "sweep field `mode` must be a string");
772    };
773    match mode.as_str() {
774        "cartesian" => {
775            let axes = take_required(path, &mut root, "axes")?;
776            reject_remaining(path, &root, "cartesian sweep")?;
777            parse_cartesian(path, axes)
778        }
779        "cases" => {
780            let cases = take_required(path, &mut root, "cases")?;
781            reject_remaining(path, &root, "explicit-case sweep")?;
782            parse_cases(path, cases)
783        }
784        _ => invalid(
785            path,
786            format!("unsupported sweep mode `{mode}`; expected `cartesian` or `cases`"),
787        ),
788    }
789}
790
791/// Validates ordered axes and precomputes mixed-radix strides.
792fn parse_cartesian(path: &Path, axes: StrictValue) -> Result<SweepPlan, ConfigurationError> {
793    let StrictValue::Array(axis_documents) = axes else {
794        return invalid(path, "cartesian sweep field `axes` must be an array");
795    };
796    let mut parsed = Vec::with_capacity(axis_documents.len());
797    let mut names = HashSet::with_capacity(axis_documents.len());
798    for (position, document) in axis_documents.into_iter().enumerate() {
799        let mut axis = require_object(
800            path,
801            document,
802            format!("cartesian axis at position {position} must be an object"),
803        )?;
804        let name = take_required(path, &mut axis, "name")?;
805        let StrictValue::String(name) = name else {
806            return invalid(
807                path,
808                format!("cartesian axis at position {position} has a non-string `name`"),
809            );
810        };
811        validate_name(path, &name, "sweep axis")?;
812        if !names.insert(name.clone()) {
813            return invalid(
814                path,
815                format!("sweep axis name `{name}` is declared more than once"),
816            );
817        }
818        let values = take_required(path, &mut axis, "values")?;
819        let StrictValue::Array(values) = values else {
820            return invalid(
821                path,
822                format!("cartesian axis `{name}` field `values` must be an array"),
823            );
824        };
825        if values.is_empty() {
826            return invalid(path, format!("cartesian axis `{name}` has no candidates"));
827        }
828        reject_remaining(path, &axis, &format!("cartesian axis `{name}`"))?;
829        parsed.push(SweepAxis {
830            name: name.into_boxed_str(),
831            values: values.into_iter().map(StrictValue::into_json).collect(),
832            stride: 0,
833        });
834    }
835
836    let mut task_count = 1_u64;
837    for axis in parsed.iter_mut().rev() {
838        axis.stride = task_count;
839        let length = u64::try_from(axis.values.len()).map_err(|_| {
840            ConfigurationError::TaskCountOverflow {
841                axis: axis.name.to_string(),
842            }
843        })?;
844        task_count = task_count.checked_mul(length).ok_or_else(|| {
845            ConfigurationError::TaskCountOverflow {
846                axis: axis.name.to_string(),
847            }
848        })?;
849    }
850    Ok(SweepPlan::Cartesian {
851        axes: parsed,
852        task_count,
853    })
854}
855
856/// Validates correlated cases and normalizes their values to first-case order.
857fn parse_cases(path: &Path, cases: StrictValue) -> Result<SweepPlan, ConfigurationError> {
858    let StrictValue::Array(case_documents) = cases else {
859        return invalid(path, "explicit sweep field `cases` must be an array");
860    };
861    if case_documents.is_empty() {
862        return invalid(path, "explicit sweep must contain at least one case");
863    }
864
865    let mut case_iter = case_documents.into_iter().enumerate();
866    let (_, first) = case_iter
867        .next()
868        .expect("an explicitly non-empty case array has a first item");
869    let first = require_object(path, first, "explicit case at position 0 must be an object")?;
870    if first.is_empty() {
871        return invalid(
872            path,
873            "explicit sweep cases must contain at least one parameter",
874        );
875    }
876    let mut keys = Vec::with_capacity(first.len());
877    let mut first_values = Vec::with_capacity(first.len());
878    for (name, value) in first {
879        validate_name(path, &name, "explicit-case parameter")?;
880        keys.push(name.into_boxed_str());
881        first_values.push(value.into_json());
882    }
883
884    let expected = keys.iter().map(AsRef::as_ref).collect::<HashSet<&str>>();
885    let mut parsed_cases = Vec::with_capacity(case_iter.size_hint().0 + 1);
886    parsed_cases.push(first_values);
887    for (position, document) in case_iter {
888        let entries = require_object(
889            path,
890            document,
891            format!("explicit case at position {position} must be an object"),
892        )?;
893        let actual = entries
894            .iter()
895            .map(|(name, _)| name.as_str())
896            .collect::<HashSet<_>>();
897        if actual != expected {
898            return invalid(
899                path,
900                format!(
901                    "explicit case at position {position} does not contain the same key set as case 0"
902                ),
903            );
904        }
905        let mut by_name = entries.into_iter().collect::<HashMap<_, _>>();
906        parsed_cases.push(
907            keys.iter()
908                .map(|key| {
909                    by_name
910                        .remove(key.as_ref())
911                        .expect("validated explicit case contains every first-case key")
912                        .into_json()
913                })
914                .collect(),
915        );
916    }
917    let _ =
918        u64::try_from(parsed_cases.len()).map_err(|_| ConfigurationError::TaskCountOverflow {
919            axis: "explicit cases".to_owned(),
920        })?;
921    Ok(SweepPlan::Cases {
922        keys,
923        cases: parsed_cases,
924    })
925}
926
927/// Rejects every fixed key that is also selected by the sweep.
928fn validate_disjoint(
929    fixed_path: &Path,
930    sweep_path: &Path,
931    fixed: &[NamedValue],
932    sweep: &SweepPlan,
933) -> Result<(), ConfigurationError> {
934    let fixed_names = fixed
935        .iter()
936        .map(|entry| entry.name.as_ref())
937        .collect::<HashSet<&str>>();
938    if let Some(key) = sweep.keys().find(|key| fixed_names.contains(key)) {
939        return Err(ConfigurationError::FixedSweepKeyConflict {
940            key: key.to_owned(),
941            fixed_path: fixed_path.to_path_buf(),
942            sweep_path: sweep_path.to_path_buf(),
943        });
944    }
945    Ok(())
946}
947
948/// Extracts an object or constructs one contextual semantic error.
949pub(super) fn require_object(
950    path: &Path,
951    value: StrictValue,
952    reason: impl Into<String>,
953) -> Result<Vec<(String, StrictValue)>, ConfigurationError> {
954    match value {
955        StrictValue::Object(entries) => Ok(entries),
956        _ => invalid(path, reason),
957    }
958}
959
960/// Removes one required exact field from an already duplicate-checked object.
961fn take_required(
962    path: &Path,
963    entries: &mut Vec<(String, StrictValue)>,
964    name: &str,
965) -> Result<StrictValue, ConfigurationError> {
966    let Some(position) = entries.iter().position(|(key, _)| key == name) else {
967        return invalid(path, format!("required field `{name}` is missing"));
968    };
969    Ok(entries.remove(position).1)
970}
971
972/// Rejects unsupported fields after all required fields have been consumed.
973fn reject_remaining(
974    path: &Path,
975    entries: &[(String, StrictValue)],
976    context: &str,
977) -> Result<(), ConfigurationError> {
978    if let Some((name, _)) = entries.first() {
979        return invalid(path, format!("{context} contains unknown field `{name}`"));
980    }
981    Ok(())
982}
983
984/// Enforces non-empty human-visible exact lookup names without normalization.
985pub(super) fn validate_name(path: &Path, name: &str, kind: &str) -> Result<(), ConfigurationError> {
986    if name.trim().is_empty() {
987        return invalid(
988            path,
989            format!("{kind} name must not be empty or whitespace-only"),
990        );
991    }
992    Ok(())
993}
994
995/// Constructs a semantic document error while preserving its source path.
996pub(super) fn invalid<T>(path: &Path, reason: impl Into<String>) -> Result<T, ConfigurationError> {
997    Err(ConfigurationError::InvalidConfigurationDocument {
998        path: path.to_path_buf(),
999        reason: reason.into(),
1000    })
1001}