Skip to main content

scientific_workflow/
project.rs

1//! Conventional immutable definition of one scientific project.
2//!
3//! [`ScientificProject`] is the normal entry point for the complete standard
4//! project layout:
5//!
6//! ```text
7//! project-root/
8//! └── config/
9//!     ├── fixed.json
10//!     ├── sweep.json
11//!     ├── paths.json
12//!     └── state.json
13//! ```
14//!
15//! It combines immutable task/path configuration with the shared state schema
16//! and delegates complete lazy task generation through [`ScientificProject::task_configs`].
17//! It does not create execution directories, construct model payloads, run
18//! tasks, or configure output streams.
19
20use std::fmt;
21use std::path::{Path, PathBuf};
22
23use serde::Serialize;
24use thiserror::Error;
25
26use crate::configuration::{
27    ConfigurationError, MatchingTaskConfigIter, ParameterSpace, ProjectConfig, ProjectPaths,
28    TaskConfig, TaskConfigIter,
29};
30use crate::system_state::{StateError, SystemStateSchema};
31
32/// Standard filename of the system-state schema beneath `config/`.
33const STATE_SCHEMA_FILE: &str = "state.json";
34
35/// Failure while loading a complete conventional scientific project.
36#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum ScientificProjectError {
39    /// Fixed, sweep, or path configuration was invalid.
40    #[error(transparent)]
41    Configuration(#[from] ConfigurationError),
42    /// The mandatory state schema could not be loaded or validated.
43    #[error(transparent)]
44    State(#[from] StateError),
45}
46
47/// Immutable configuration and state schema for one scientific project.
48///
49/// Cloning shares the parsed parameter, path, and schema allocations; it does
50/// not clone JSON values or scientific payloads.
51#[derive(Clone)]
52pub struct ScientificProject {
53    configuration: ProjectConfig,
54    state_schema: SystemStateSchema,
55}
56
57impl ScientificProject {
58    /// Loads all four conventional JSON documents from `project_root/config`.
59    pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ScientificProjectError> {
60        let project_root = project_root.into();
61        let configuration = ProjectConfig::load(&project_root)?;
62        let state_schema = SystemStateSchema::load_json_template(
63            configuration
64                .configuration_directory()
65                .join(STATE_SCHEMA_FILE),
66        )?;
67        Ok(Self {
68            configuration,
69            state_schema,
70        })
71    }
72
73    /// Returns the project root exactly as supplied during loading.
74    pub fn project_root(&self) -> &Path {
75        self.configuration.project_root()
76    }
77
78    /// Returns the conventional `config/` directory.
79    pub fn configuration_directory(&self) -> &Path {
80        self.configuration.configuration_directory()
81    }
82
83    /// Borrows the fixed-and-swept task parameter space.
84    pub fn parameters(&self) -> &ParameterSpace {
85        self.configuration.parameters()
86    }
87
88    /// Borrows the named project path dictionary.
89    pub fn paths(&self) -> &ProjectPaths {
90        self.configuration.paths()
91    }
92
93    /// Resolves one named project path against the project root.
94    pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
95        self.configuration.paths().resolve_path(key)
96    }
97
98    /// Returns the checked number of complete task configurations.
99    pub fn task_count(&self) -> u64 {
100        self.configuration.task_count()
101    }
102
103    /// Resolves one complete task configuration by deterministic ordinal.
104    pub fn task_config(&self, ordinal: u64) -> Result<TaskConfig, ConfigurationError> {
105        self.configuration.task_config(ordinal)
106    }
107
108    /// Lazily iterates every complete fixed/sweep/path task configuration.
109    pub fn task_configs(&self) -> TaskConfigIter {
110        self.configuration.task_configs()
111    }
112
113    /// Lazily iterates every task matching one exact sweep key/value pair.
114    pub fn task_configs_matching<V>(
115        &self,
116        key: impl Into<String>,
117        value: V,
118    ) -> Result<MatchingTaskConfigIter, ConfigurationError>
119    where
120        V: Serialize,
121    {
122        self.configuration.task_configs_matching(key, value)
123    }
124
125    /// Returns the unique task matching one exact sweep key/value pair.
126    pub fn unique_task_config_matching<V>(
127        &self,
128        key: impl Into<String>,
129        value: V,
130    ) -> Result<TaskConfig, ConfigurationError>
131    where
132        V: Serialize,
133    {
134        self.configuration.unique_task_config_matching(key, value)
135    }
136
137    /// Borrows the shared system-state schema loaded from `config/state.json`.
138    pub fn state_schema(&self) -> &SystemStateSchema {
139        &self.state_schema
140    }
141
142    /// Borrows the lower-level three-file configuration facade.
143    pub fn configuration(&self) -> &ProjectConfig {
144        &self.configuration
145    }
146
147    /// Consumes the project and returns its configuration and state schema.
148    pub fn into_parts(self) -> (ProjectConfig, SystemStateSchema) {
149        (self.configuration, self.state_schema)
150    }
151}
152
153impl fmt::Debug for ScientificProject {
154    /// Formats bounded project facts without source JSON or payload data.
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        formatter
157            .debug_struct("ScientificProject")
158            .field("project_root", &self.project_root())
159            .field("parameters", &self.parameters().parameter_count())
160            .field("tasks", &self.task_count())
161            .field("paths", &self.paths().len())
162            .field("state_fields", &self.state_schema().len())
163            .finish()
164    }
165}