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