Skip to main content

scientific_workflow/configuration/parameters/
space.rs

1//! Study-wide loading and workload-scoped configuration spaces.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::iter::FusedIterator;
6use std::path::{Path, PathBuf};
7use std::slice;
8use std::sync::Arc;
9
10use super::super::error::ConfigurationError;
11use super::super::parameter_path::ParameterPath;
12use super::super::parameter_tree::{ParameterLeaf, paths_conflict};
13use super::super::source::{
14    StrictValue, parse_strict_json, read_source, require_object, validate_name,
15};
16use super::super::sweep::{ParsedScope, SweepPlan, parse_scope};
17use super::resolved_configuration::{ConfigurationIter, ResolvedConfiguration};
18
19const PARAMETERS_FILE: &str = "parameters.json";
20const CONFIGURATION_DIRECTORY: &str = "config";
21
22/// One validated study-wide parameter registry.
23///
24/// Call [`StudyConfiguration::workload`] to obtain the only iterable space. A
25/// workload automatically includes the global and containing component scopes.
26#[derive(Clone)]
27pub struct StudyConfiguration {
28    inner: Arc<StudyConfigurationInner>,
29}
30
31/// The lazily expanded parameter space for one component-qualified workload.
32#[derive(Clone)]
33pub struct WorkloadConfiguration {
34    pub(super) inner: Arc<WorkloadConfigurationInner>,
35}
36
37impl StudyConfiguration {
38    /// Loads `config/parameters.json` beneath `study_root`.
39    pub fn load(study_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
40        let study_root = study_root.into();
41        let configuration_directory = Arc::new(study_root.join(CONFIGURATION_DIRECTORY));
42        let source_path = configuration_directory.join(PARAMETERS_FILE);
43        let source = read_source(&source_path)?;
44        let document = parse_strict_json(&source_path, &source)?;
45        let mut root = require_object(
46            &source_path,
47            document,
48            "parameters.json root must be an object",
49        )?;
50        let global = take_required(&source_path, &mut root, "global")?;
51        let components = take_required(&source_path, &mut root, "components")?;
52        reject_remaining(&source_path, &root, "parameters.json")?;
53
54        let global = Arc::new(ScopeConfiguration::new(parse_scope(
55            &source_path,
56            require_object(&source_path, global, "`global` must be an object")?,
57            "global scope",
58        )?)?);
59        let components =
60            require_object(&source_path, components, "`components` must be an object")?;
61        if components.is_empty() {
62            return super::super::source::invalid(
63                &source_path,
64                "`components` must contain at least one component",
65            );
66        }
67
68        let mut workloads = HashMap::with_capacity(components.len());
69        for (component_key, component) in components {
70            validate_name(&source_path, &component_key, "component key")?;
71            let mut component = require_object(
72                &source_path,
73                component,
74                format!("component `{component_key}` must be an object"),
75            )?;
76            let shared = take_required(&source_path, &mut component, "shared")?;
77            let workload = take_required(&source_path, &mut component, "workloads")?;
78            reject_remaining(
79                &source_path,
80                &component,
81                &format!("component `{component_key}`"),
82            )?;
83            let shared = Arc::new(ScopeConfiguration::new(parse_scope(
84                &source_path,
85                require_object(
86                    &source_path,
87                    shared,
88                    format!("component `{component_key}` field `shared` must be an object"),
89                )?,
90                &format!("component `{component_key}` shared scope"),
91            )?)?);
92            let workload = require_object(
93                &source_path,
94                workload,
95                format!("component `{component_key}` field `workloads` must be an object"),
96            )?;
97            if workload.is_empty() {
98                return super::super::source::invalid(
99                    &source_path,
100                    format!("component `{component_key}` must contain at least one workload"),
101                );
102            }
103            let mut component_workloads = HashMap::with_capacity(workload.len());
104            for (workload_key, values) in workload {
105                validate_name(&source_path, &workload_key, "workload key")?;
106                let local = Arc::new(ScopeConfiguration::new(parse_scope(
107                    &source_path,
108                    require_object(
109                        &source_path,
110                        values,
111                        format!("workload `{component_key}/{workload_key}` must be an object"),
112                    )?,
113                    &format!("workload `{component_key}/{workload_key}`"),
114                )?)?);
115                let space = compose_space(
116                    Arc::clone(&configuration_directory),
117                    &source_path,
118                    &component_key,
119                    &workload_key,
120                    [Arc::clone(&global), Arc::clone(&shared), local],
121                )?;
122                component_workloads.insert(workload_key.into_boxed_str(), Arc::new(space));
123            }
124            workloads.insert(component_key.into_boxed_str(), component_workloads);
125        }
126        Ok(Self {
127            inner: Arc::new(StudyConfigurationInner {
128                study_root,
129                configuration_directory,
130                source_path,
131                source: source.into_boxed_slice(),
132                workloads,
133            }),
134        })
135    }
136
137    /// Returns the study root supplied to [`Self::load`].
138    pub fn study_root(&self) -> &Path {
139        &self.inner.study_root
140    }
141
142    /// Returns the conventional `config` directory beneath the study root.
143    pub fn configuration_directory(&self) -> &Path {
144        self.inner.configuration_directory.as_path()
145    }
146
147    /// Returns the exact `parameters.json` source path.
148    pub fn source_path(&self) -> &Path {
149        &self.inner.source_path
150    }
151
152    /// Borrows the original validated source bytes without reserialization.
153    pub fn source_json(&self) -> &[u8] {
154        &self.inner.source
155    }
156
157    /// Returns one exact component-qualified workload configuration.
158    pub fn workload(
159        &self,
160        component: &str,
161        workload: &str,
162    ) -> Result<WorkloadConfiguration, ConfigurationError> {
163        let inner = self
164            .inner
165            .workloads
166            .get(component)
167            .and_then(|workloads| workloads.get(workload))
168            .map(Arc::clone)
169            .ok_or_else(|| ConfigurationError::UnknownWorkloadConfiguration {
170                component: component.to_owned(),
171                workload: workload.to_owned(),
172            })?;
173        Ok(WorkloadConfiguration { inner })
174    }
175}
176
177impl WorkloadConfiguration {
178    /// Returns the directory containing the study-wide parameter source.
179    pub fn configuration_directory(&self) -> &Path {
180        self.inner.configuration_directory.as_path()
181    }
182
183    /// Returns this workload's stable containing component key.
184    pub fn component(&self) -> &str {
185        &self.inner.component
186    }
187
188    /// Returns this workload's stable string key.
189    pub fn workload(&self) -> &str {
190        &self.inner.workload
191    }
192
193    /// Returns the complete `global × shared × workload` combination count.
194    pub fn combination_count(&self) -> u64 {
195        self.inner.combination_count
196    }
197
198    /// Returns the number of ordinary terminal leaves in the merged view.
199    pub fn fixed_value_count(&self) -> usize {
200        self.inner.fixed_leaves().count()
201    }
202
203    /// Returns the number of terminal paths selected by sweep dimensions.
204    pub fn swept_value_count(&self) -> usize {
205        self.inner
206            .scopes
207            .iter()
208            .map(|scope| scope.sweep.selectable_paths().len())
209            .sum()
210    }
211
212    /// Reports whether a fixed or selectable value exists at or below `key`.
213    pub fn contains(&self, key: &str) -> bool {
214        let Some(path) = ParameterPath::parse(key) else {
215            return false;
216        };
217        self.inner
218            .fixed_leaves()
219            .any(|leaf| leaf.path == path || path.is_ancestor_of(&leaf.path))
220            || self
221                .inner
222                .selectable_paths()
223                .any(|candidate| candidate == &path || path.is_ancestor_of(candidate))
224    }
225
226    /// Iterates ordinary terminal JSON Pointer keys in declaration order.
227    pub fn fixed_keys(&self) -> impl ExactSizeIterator<Item = &str> {
228        self.inner.fixed_leaves().map(|leaf| leaf.path.identifier())
229    }
230
231    /// Iterates selectable terminal JSON Pointer keys in declaration order.
232    pub fn sweep_keys(&self) -> impl ExactSizeIterator<Item = &str> {
233        self.inner.selectable_paths().map(ParameterPath::identifier)
234    }
235
236    /// Resolves one flattened workload ordinal with bounds checking.
237    pub fn combination(&self, ordinal: u64) -> Result<ResolvedConfiguration, ConfigurationError> {
238        if ordinal >= self.combination_count() {
239            return Err(ConfigurationError::CombinationOrdinalOutOfBounds {
240                ordinal,
241                combination_count: self.combination_count(),
242            });
243        }
244        Ok(ResolvedConfiguration::new(Arc::clone(&self.inner), ordinal))
245    }
246
247    /// Lazily iterates every resolved combination in deterministic order.
248    pub fn combinations(&self) -> ConfigurationIter {
249        ConfigurationIter::new(Arc::clone(&self.inner), self.combination_count())
250    }
251}
252
253impl fmt::Debug for StudyConfiguration {
254    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255        formatter
256            .debug_struct("StudyConfiguration")
257            .field("study_root", &self.study_root())
258            .field(
259                "workloads",
260                &self
261                    .inner
262                    .workloads
263                    .values()
264                    .map(HashMap::len)
265                    .sum::<usize>(),
266            )
267            .finish_non_exhaustive()
268    }
269}
270
271impl fmt::Debug for WorkloadConfiguration {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        formatter
274            .debug_struct("WorkloadConfiguration")
275            .field("component", &self.component())
276            .field("workload", &self.workload())
277            .field("combinations", &self.combination_count())
278            .finish_non_exhaustive()
279    }
280}
281
282struct StudyConfigurationInner {
283    study_root: PathBuf,
284    configuration_directory: Arc<PathBuf>,
285    source_path: PathBuf,
286    source: Box<[u8]>,
287    workloads: HashMap<Box<str>, HashMap<Box<str>, Arc<WorkloadConfigurationInner>>>,
288}
289
290pub(crate) struct WorkloadConfigurationInner {
291    pub(super) configuration_directory: Arc<PathBuf>,
292    pub(super) component: Box<str>,
293    pub(super) workload: Box<str>,
294    scopes: [Arc<ScopeConfiguration>; 3],
295    pub(super) combination_count: u64,
296}
297
298fn compose_space(
299    configuration_directory: Arc<PathBuf>,
300    source_path: &Path,
301    component: &str,
302    workload: &str,
303    scopes: [Arc<ScopeConfiguration>; 3],
304) -> Result<WorkloadConfigurationInner, ConfigurationError> {
305    validate_scopes_disjoint(source_path, &scopes)?;
306    let combination_count = scopes.iter().try_fold(1_u64, |count, scope| {
307        count.checked_mul(scope.combination_count()).ok_or_else(|| {
308            ConfigurationError::CombinationCountOverflow {
309                axis: format!("workload `{component}/{workload}`"),
310            }
311        })
312    })?;
313    Ok(WorkloadConfigurationInner {
314        configuration_directory,
315        component: component.to_owned().into_boxed_str(),
316        workload: workload.to_owned().into_boxed_str(),
317        scopes,
318        combination_count,
319    })
320}
321
322struct ScopeConfiguration {
323    fixed: Vec<ParameterLeaf>,
324    fixed_by_path: HashMap<ParameterPath, usize>,
325    sweep: SweepPlan,
326}
327
328impl ScopeConfiguration {
329    fn new(scope: ParsedScope) -> Result<Self, ConfigurationError> {
330        let fixed_by_path = scope
331            .fixed
332            .iter()
333            .enumerate()
334            .map(|(index, leaf)| (leaf.path.clone(), index))
335            .collect();
336        Ok(Self {
337            fixed: scope.fixed,
338            fixed_by_path,
339            sweep: SweepPlan::new(scope.dimensions)?,
340        })
341    }
342
343    const fn combination_count(&self) -> u64 {
344        self.sweep.combination_count()
345    }
346}
347
348impl WorkloadConfigurationInner {
349    pub(super) fn fixed_leaves(&self) -> ScopeSliceIter<'_, ParameterLeaf> {
350        ScopeSliceIter::new([
351            &self.scopes[0].fixed,
352            &self.scopes[1].fixed,
353            &self.scopes[2].fixed,
354        ])
355    }
356
357    pub(super) fn selected_leaves(&self, ordinal: u64) -> impl Iterator<Item = &ParameterLeaf> {
358        self.scopes[0]
359            .sweep
360            .selected_leaves(self.scope_ordinal(ordinal, 0))
361            .chain(
362                self.scopes[1]
363                    .sweep
364                    .selected_leaves(self.scope_ordinal(ordinal, 1)),
365            )
366            .chain(
367                self.scopes[2]
368                    .sweep
369                    .selected_leaves(self.scope_ordinal(ordinal, 2)),
370            )
371    }
372
373    pub(super) fn selectable_paths(&self) -> ScopeSliceIter<'_, ParameterPath> {
374        ScopeSliceIter::new([
375            self.scopes[0].sweep.selectable_paths(),
376            self.scopes[1].sweep.selectable_paths(),
377            self.scopes[2].sweep.selectable_paths(),
378        ])
379    }
380
381    pub(super) fn fixed_leaf(&self, path: &ParameterPath) -> Option<&ParameterLeaf> {
382        self.scopes.iter().find_map(|scope| {
383            scope
384                .fixed_by_path
385                .get(path)
386                .map(|&position| &scope.fixed[position])
387        })
388    }
389
390    pub(super) fn scope_ordinal(&self, ordinal: u64, scope: usize) -> u64 {
391        let following = self.scopes[(scope + 1)..]
392            .iter()
393            .map(|scope| scope.combination_count())
394            .product::<u64>();
395        (ordinal / following) % self.scopes[scope].combination_count()
396    }
397}
398
399fn validate_scopes_disjoint(
400    source_path: &Path,
401    scopes: &[Arc<ScopeConfiguration>; 3],
402) -> Result<(), ConfigurationError> {
403    let mut paths: Vec<&ParameterPath> = Vec::new();
404    for path in scopes.iter().flat_map(|scope| {
405        scope
406            .fixed
407            .iter()
408            .map(|leaf| &leaf.path)
409            .chain(scope.sweep.selectable_paths())
410    }) {
411        if let Some(previous) = paths.iter().find(|previous| paths_conflict(previous, path)) {
412            return super::super::source::invalid(
413                source_path,
414                format!("parameter paths `{previous}` and `{path}` overlap"),
415            );
416        }
417        paths.push(path);
418    }
419    Ok(())
420}
421
422pub(super) struct ScopeSliceIter<'a, T> {
423    scopes: [slice::Iter<'a, T>; 3],
424    current: usize,
425    remaining: usize,
426}
427
428impl<'a, T> ScopeSliceIter<'a, T> {
429    fn new(scopes: [&'a [T]; 3]) -> Self {
430        Self {
431            remaining: scopes.iter().map(|scope| scope.len()).sum(),
432            scopes: scopes.map(<[T]>::iter),
433            current: 0,
434        }
435    }
436}
437
438impl<'a, T> Iterator for ScopeSliceIter<'a, T> {
439    type Item = &'a T;
440
441    fn next(&mut self) -> Option<Self::Item> {
442        while self.current < self.scopes.len() {
443            if let Some(value) = self.scopes[self.current].next() {
444                self.remaining -= 1;
445                return Some(value);
446            }
447            self.current += 1;
448        }
449        None
450    }
451
452    fn size_hint(&self) -> (usize, Option<usize>) {
453        (self.remaining, Some(self.remaining))
454    }
455}
456
457impl<T> ExactSizeIterator for ScopeSliceIter<'_, T> {}
458impl<T> FusedIterator for ScopeSliceIter<'_, T> {}
459
460fn take_required(
461    path: &Path,
462    entries: &mut Vec<(String, StrictValue)>,
463    name: &str,
464) -> Result<StrictValue, ConfigurationError> {
465    let Some(position) = entries.iter().position(|(key, _)| key == name) else {
466        return super::super::source::invalid(path, format!("required field `{name}` is missing"));
467    };
468    Ok(entries.remove(position).1)
469}
470
471fn reject_remaining(
472    path: &Path,
473    entries: &[(String, StrictValue)],
474    context: &str,
475) -> Result<(), ConfigurationError> {
476    if let Some((name, _)) = entries.first() {
477        return super::super::source::invalid(
478            path,
479            format!("{context} contains unknown field `{name}`"),
480        );
481    }
482    Ok(())
483}