Skip to main content

scientific_workflow/
project.rs

1//! Conventional immutable definition of one scientific project.
2//!
3//! [`ScientificProject`] combines the standard three-file task configuration
4//! with one state schema. The schema may belong to the project:
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. Fixed-model crates instead call
19//! [`ScientificProject::load_with_state_schema`] and supply their canonical
20//! schema, so each individual project needs only the other three files.
21
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25use serde::Serialize;
26use thiserror::Error;
27
28use crate::configuration::{
29    ConfigurationError, MatchingTaskConfigIter, ParameterSpace, ProjectConfig, ProjectPaths,
30    TaskConfig, TaskConfigIter,
31};
32use crate::system_state::{StateError, SystemStateSchema};
33
34/// Standard filename of the system-state schema beneath `config/`.
35const STATE_SCHEMA_FILE: &str = "state.json";
36
37/// Failure while loading a complete conventional scientific project.
38#[derive(Debug, Error)]
39#[non_exhaustive]
40pub enum ScientificProjectError {
41    /// Fixed, sweep, or path configuration was invalid.
42    #[error(transparent)]
43    Configuration(#[from] ConfigurationError),
44    /// A project-owned state schema could not be loaded or validated.
45    #[error(transparent)]
46    State(#[from] StateError),
47}
48
49/// Immutable configuration and state schema for one scientific project.
50///
51/// Cloning shares the parsed parameter, path, and schema allocations; it does
52/// not clone JSON values or scientific payloads.
53#[derive(Clone)]
54pub struct ScientificProject {
55    configuration: ProjectConfig,
56    state_schema: SystemStateSchema,
57}
58
59impl ScientificProject {
60    /// Loads all four conventional JSON documents from `project_root/config`.
61    pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ScientificProjectError> {
62        let project_root = project_root.into();
63        let configuration = ProjectConfig::load(&project_root)?;
64        let state_schema = SystemStateSchema::load_json_template(
65            configuration
66                .configuration_directory()
67                .join(STATE_SCHEMA_FILE),
68        )?;
69        Ok(Self {
70            configuration,
71            state_schema,
72        })
73    }
74
75    /// Loads task and path configuration with a model-owned state schema.
76    ///
77    /// This form reads only `config/fixed.json`, `config/sweep.json`, and
78    /// `config/paths.json`. It is intended for scientific crates whose public
79    /// model fixes one canonical state contract and therefore rejects
80    /// project-specific schema changes. The supplied schema is already
81    /// validated by its type and is retained without reparsing or cloning its
82    /// field allocation.
83    pub fn load_with_state_schema(
84        project_root: impl Into<PathBuf>,
85        state_schema: SystemStateSchema,
86    ) -> Result<Self, ScientificProjectError> {
87        let configuration = ProjectConfig::load(project_root.into())?;
88        Ok(Self {
89            configuration,
90            state_schema,
91        })
92    }
93
94    /// Returns the project root exactly as supplied during loading.
95    pub fn project_root(&self) -> &Path {
96        self.configuration.project_root()
97    }
98
99    /// Returns the conventional `config/` directory.
100    pub fn configuration_directory(&self) -> &Path {
101        self.configuration.configuration_directory()
102    }
103
104    /// Borrows the fixed-and-swept task parameter space.
105    pub fn parameters(&self) -> &ParameterSpace {
106        self.configuration.parameters()
107    }
108
109    /// Borrows the named project path dictionary.
110    pub fn paths(&self) -> &ProjectPaths {
111        self.configuration.paths()
112    }
113
114    /// Resolves one named project path against the project root.
115    pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
116        self.configuration.paths().resolve_path(key)
117    }
118
119    /// Returns the checked number of complete task configurations.
120    pub fn task_count(&self) -> u64 {
121        self.configuration.task_count()
122    }
123
124    /// Resolves one complete task configuration by deterministic ordinal.
125    pub fn task_config(&self, ordinal: u64) -> Result<TaskConfig, ConfigurationError> {
126        self.configuration.task_config(ordinal)
127    }
128
129    /// Lazily iterates every complete fixed/sweep/path task configuration.
130    pub fn task_configs(&self) -> TaskConfigIter {
131        self.configuration.task_configs()
132    }
133
134    /// Lazily iterates every task matching one exact sweep key/value pair.
135    pub fn task_configs_matching<V>(
136        &self,
137        key: impl Into<String>,
138        value: V,
139    ) -> Result<MatchingTaskConfigIter, ConfigurationError>
140    where
141        V: Serialize,
142    {
143        self.configuration.task_configs_matching(key, value)
144    }
145
146    /// Returns the unique task matching one exact sweep key/value pair.
147    pub fn unique_task_config_matching<V>(
148        &self,
149        key: impl Into<String>,
150        value: V,
151    ) -> Result<TaskConfig, ConfigurationError>
152    where
153        V: Serialize,
154    {
155        self.configuration.unique_task_config_matching(key, value)
156    }
157
158    /// Borrows the shared project-owned or model-owned system-state schema.
159    pub fn state_schema(&self) -> &SystemStateSchema {
160        &self.state_schema
161    }
162
163    /// Borrows the lower-level three-file configuration facade.
164    pub fn configuration(&self) -> &ProjectConfig {
165        &self.configuration
166    }
167
168    /// Consumes the project and returns its configuration and state schema.
169    pub fn into_parts(self) -> (ProjectConfig, SystemStateSchema) {
170        (self.configuration, self.state_schema)
171    }
172}
173
174impl fmt::Debug for ScientificProject {
175    /// Formats bounded project facts without source JSON or payload data.
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter
178            .debug_struct("ScientificProject")
179            .field("project_root", &self.project_root())
180            .field("parameters", &self.parameters().parameter_count())
181            .field("tasks", &self.task_count())
182            .field("paths", &self.paths().len())
183            .field("state_fields", &self.state_schema().len())
184            .finish()
185    }
186}