Skip to main content

scientific_workflow/configuration/parameters/
space.rs

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