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