Skip to main content

scientific_workflow/
study.rs

1//! Study, phase, and task orchestration.
2//!
3//! A [`Study`] is the largest scope. It owns ordered [`Phase`] declarations,
4//! scheduling, cancellation, recording, and display. Each phase owns its
5//! [`Task`] declarations and scheduling policy. Each task owns one application
6//! workload and communicates through [`TaskContext`].
7//!
8//! # Boundary
9//!
10//! Study owns only orchestration concerns: task ordering, concurrency caps,
11//! cancellation, failure policy, progress reporting, and task lifecycle. It does
12//! not define scientific state schema, persistence formats, artifact identity, or
13//! RNG strategy. Applications feed workloads and state objects into the study.
14//!
15//! Configuration remains independent: applications iterate
16//! [`ResolvedConfiguration`](crate::configuration::ResolvedConfiguration)
17//! values, capture each value in a workload, and construct tasks explicitly.
18//!
19//! ```no_run
20//! use scientific_workflow::prelude::study::*;
21//!
22//! # fn main() -> Result<(), StudyError> {
23//! let task = Task::progress("simulation-0", "simulation 0", |context| {
24//!     context.set_target_iteration(100)?;
25//!     for iteration in 0..=100 {
26//!         context.set_iteration(iteration)?;
27//!         if context.is_cancelled() {
28//!             break;
29//!         }
30//!     }
31//!     Ok(())
32//! });
33//! let phase = Phase::builder(1, "simulation").task(task).build()?;
34//! let summary = Study::builder("study-record.json")
35//!     .phase(phase)
36//!     .hidden()
37//!     .build()?
38//!     .run()?;
39//! assert!(summary.is_success());
40//! # Ok(())
41//! # }
42//! ```
43
44use std::collections::{HashMap, HashSet, VecDeque};
45use std::fmt;
46use std::sync::Arc;
47use std::sync::atomic::{AtomicBool, Ordering};
48
49#[path = "study/command.rs"]
50mod command;
51#[path = "study/display.rs"]
52mod display;
53#[path = "study/error.rs"]
54mod error;
55#[path = "study/phase.rs"]
56mod phase;
57#[path = "study/plan.rs"]
58mod plan;
59#[path = "study/record.rs"]
60mod record;
61#[path = "study/renderer.rs"]
62mod renderer;
63#[path = "study/scheduler.rs"]
64mod scheduler;
65#[path = "study/task.rs"]
66mod task;
67#[path = "study/timing.rs"]
68mod timing;
69#[path = "study/tui.rs"]
70mod tui;
71
72pub use error::StudyError;
73pub use phase::{
74    Phase, PhaseBuilder, PhaseFailurePolicy, PhaseId, Task, TaskId, TaskKey, TaskMode, TaskSelector,
75};
76pub use plan::StudyPlan;
77pub use record::{PhaseRecord, StudyRecord, TaskRecord};
78pub use renderer::{CancellationToken, ProgressSummary, TaskIdentity, TaskStatus};
79pub use task::{TaskContext, TaskResult};
80
81use display::DisplayMode;
82use renderer::StudyRenderer;
83
84static STUDY_OWNED: AtomicBool = AtomicBool::new(false);
85
86type SatisfiedPhaseVerifier = Arc<dyn Fn(PhaseId) -> bool + Send + Sync + 'static>;
87
88/// Builder for one immutable study plan.
89pub struct StudyBuilder {
90    phases: Vec<Phase>,
91    output: DisplayMode,
92    satisfied_phase: Option<SatisfiedPhaseVerifier>,
93    record_path: std::path::PathBuf,
94}
95
96impl StudyBuilder {
97    /// Adds one already validated nonempty phase.
98    pub fn phase(mut self, phase: Phase) -> Self {
99        self.phases.push(phase);
100        self
101    }
102
103    /// Adds phases in deterministic declaration order.
104    pub fn phases<I>(mut self, phases: I) -> Self
105    where
106        I: IntoIterator<Item = Phase>,
107    {
108        self.phases.extend(phases);
109        self
110    }
111
112    /// Supplies application verification for an omitted completed dependency.
113    pub fn satisfied_phase_verifier<F>(mut self, verifier: F) -> Self
114    where
115        F: Fn(PhaseId) -> bool + Send + Sync + 'static,
116    {
117        self.satisfied_phase = Some(Arc::new(verifier));
118        self
119    }
120
121    /// Selects automatic terminal/plain output detection.
122    pub fn automatic(mut self) -> Self {
123        self.output = DisplayMode::Auto;
124        self
125    }
126
127    /// Forces cursor-controlled interactive display.
128    pub fn terminal(mut self) -> Self {
129        self.output = DisplayMode::Terminal;
130        self
131    }
132
133    /// Forces append-only uncolored line output.
134    pub fn plain(mut self) -> Self {
135        self.output = DisplayMode::Plain;
136        self
137    }
138
139    /// Suppresses display while preserving scheduling and lifecycle checks.
140    pub fn hidden(mut self) -> Self {
141        self.output = DisplayMode::Hidden;
142        self
143    }
144
145    /// Validates the complete phase/task/dependency plan.
146    pub fn build(self) -> Result<Study, StudyError> {
147        validate_plan(&self.phases)?;
148        Ok(Study {
149            phases: self.phases,
150            output: self.output,
151            satisfied_phase: self.satisfied_phase,
152            cancellation: CancellationToken::new(),
153            record_path: self.record_path,
154        })
155    }
156}
157
158impl fmt::Debug for StudyBuilder {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter
161            .debug_struct("StudyBuilder")
162            .field("phases", &self.phases.len())
163            .field("output", &self.output)
164            .field("record_path", &self.record_path)
165            .field(
166                "has_satisfied_phase_verifier",
167                &self.satisfied_phase.is_some(),
168            )
169            .finish_non_exhaustive()
170    }
171}
172
173/// Non-clone scheduler and display owner for one declared study plan.
174pub struct Study {
175    phases: Vec<Phase>,
176    output: DisplayMode,
177    satisfied_phase: Option<SatisfiedPhaseVerifier>,
178    cancellation: CancellationToken,
179    record_path: std::path::PathBuf,
180}
181
182impl Study {
183    /// Starts an empty builder with the mandatory study-record destination.
184    pub fn builder(record_path: impl Into<std::path::PathBuf>) -> StudyBuilder {
185        StudyBuilder {
186            phases: Vec::new(),
187            output: DisplayMode::Auto,
188            satisfied_phase: None,
189            record_path: record_path.into(),
190        }
191    }
192
193    /// Borrows all registered phases in deterministic declaration order.
194    pub fn phases(&self) -> &[Phase] {
195        &self.phases
196    }
197
198    /// Materializes a deterministic, side-effect-free description of every
199    /// registered phase and task.
200    pub fn plan(&self) -> StudyPlan {
201        StudyPlan::from_phases(&self.phases)
202    }
203
204    /// Writes the complete registered plan as pretty JSON without running it.
205    /// An existing byte-identical file is accepted. Different existing
206    /// content is rejected and never overwritten.
207    pub fn write_plan_json(&self, path: impl AsRef<std::path::Path>) -> Result<(), StudyError> {
208        self.plan().write_json(path)
209    }
210
211    /// Borrows one registered phase by stable ID.
212    pub fn phase(&self, id: impl Into<PhaseId>) -> Option<&Phase> {
213        let id = id.into();
214        self.phases.iter().find(|phase| phase.id() == id)
215    }
216
217    /// Returns a cheap token that can request or observe study cancellation.
218    pub fn cancellation_token(&self) -> CancellationToken {
219        self.cancellation.clone()
220    }
221
222    /// Returns the unique task matching an exact partial selector.
223    pub fn unique_task_matching(&self, selector: &TaskSelector) -> Result<&Task, StudyError> {
224        let mut matches = self
225            .phases
226            .iter()
227            .flat_map(|phase| phase.tasks())
228            .filter(|task| selector.matches(task));
229        let first = matches.next().ok_or_else(|| StudyError::TaskNotFound {
230            selector: selector.to_string(),
231        })?;
232        if let Some(second) = matches.next() {
233            return Err(StudyError::TaskSelectorAmbiguous {
234                selector: selector.to_string(),
235                first: first.key().to_string(),
236                second: second.key().to_string(),
237            });
238        }
239        Ok(first)
240    }
241
242    /// Runs every registered phase in dependency order.
243    pub fn run(self) -> Result<StudySummary, StudyError> {
244        let selected = topological_positions(&self.phases)?;
245        self.execute(&selected)
246    }
247
248    /// Runs exactly the selected phases; omitted unsatisfied dependencies fail.
249    pub fn run_phases<I, P>(self, phases: I) -> Result<StudySummary, StudyError>
250    where
251        I: IntoIterator<Item = P>,
252        P: Into<PhaseId>,
253    {
254        let selected = self.select_phases(phases, false)?;
255        self.execute(&selected)
256    }
257
258    /// Adds unsatisfied dependencies and runs the deterministic closure.
259    pub fn run_phases_with_dependencies<I, P>(self, phases: I) -> Result<StudySummary, StudyError>
260    where
261        I: IntoIterator<Item = P>,
262        P: Into<PhaseId>,
263    {
264        let selected = self.select_phases(phases, true)?;
265        self.execute(&selected)
266    }
267
268    fn select_phases<I, P>(
269        &self,
270        phases: I,
271        include_dependencies: bool,
272    ) -> Result<Vec<usize>, StudyError>
273    where
274        I: IntoIterator<Item = P>,
275        P: Into<PhaseId>,
276    {
277        let mut selected = selected_ids(&self.phases, phases)?;
278        if include_dependencies {
279            let positions: HashMap<_, _> = self
280                .phases
281                .iter()
282                .enumerate()
283                .map(|(position, phase)| (phase.id(), position))
284                .collect();
285            let mut pending: Vec<_> = selected.iter().copied().collect();
286            while let Some(id) = pending.pop() {
287                let phase = &self.phases[positions[&id]];
288                for dependency in phase.dependencies() {
289                    if !self.is_satisfied(*dependency) && selected.insert(*dependency) {
290                        pending.push(*dependency);
291                    }
292                }
293            }
294        } else {
295            for phase in self
296                .phases
297                .iter()
298                .filter(|phase| selected.contains(&phase.id()))
299            {
300                for dependency in phase.dependencies() {
301                    if !selected.contains(dependency) && !self.is_satisfied(*dependency) {
302                        return Err(StudyError::UnsatisfiedPhaseDependency {
303                            phase: phase.id().get(),
304                            dependency: dependency.get(),
305                        });
306                    }
307                }
308            }
309        }
310        Ok(topological_positions(&self.phases)?
311            .into_iter()
312            .filter(|position| selected.contains(&self.phases[*position].id()))
313            .collect())
314    }
315
316    fn is_satisfied(&self, phase: PhaseId) -> bool {
317        self.satisfied_phase
318            .as_ref()
319            .is_some_and(|verify| verify(phase))
320    }
321
322    fn execute(self, selected: &[usize]) -> Result<StudySummary, StudyError> {
323        let _lease = StudyLease::acquire()?;
324        let total_phases = selected.len();
325        let total_tasks = selected
326            .iter()
327            .map(|position| self.phases[*position].tasks().len())
328            .sum();
329        let execution = {
330            let selected_phases = selected
331                .iter()
332                .map(|position| &self.phases[*position])
333                .collect::<Vec<_>>();
334            record::StudyRecorder::start(self.record_path.clone(), &selected_phases)?
335        };
336        let mut summaries = Vec::with_capacity(total_phases);
337        let mut phases = self.phases.into_iter().map(Some).collect::<Vec<_>>();
338
339        for (selection_position, phase_position) in selected.iter().copied().enumerate() {
340            let phase = phases[phase_position]
341                .take()
342                .expect("selected phase positions are unique");
343            execution.phase_started(phase.id())?;
344            display::phase_start(self.output, &phase, selection_position + 1, total_phases);
345            let heading = display::phase_heading(&phase, selection_position + 1, total_phases);
346            let builder = StudyRenderer::for_phase(&phase, &heading)?
347                .cancellation_token(self.cancellation.clone());
348            let renderer = match self.output {
349                DisplayMode::Auto => builder,
350                DisplayMode::Terminal => builder.terminal(),
351                DisplayMode::Plain => builder.plain(),
352                DisplayMode::Hidden => builder.hidden(),
353            }
354            .start()?;
355            let phase_id = phase.id();
356            let phase_label: Arc<str> = phase.label().into();
357            let require_confirm = phase.requires_confirmation();
358            let result = scheduler::execute_phase(phase, &renderer, &execution);
359            let task_execution = renderer.task_execution_snapshots();
360            let progress = if result.is_ok() {
361                renderer.complete(format!("phase {phase_id} completed"))?
362            } else {
363                renderer.fail(format!("phase {phase_id} failed"))?
364            };
365            let success = result.is_ok() && progress.is_success();
366            execution.phase_finished(phase_id, success, &progress, task_execution)?;
367            display::phase_complete(self.output, phase_id, &phase_label, success);
368            summaries.push(PhaseSummary {
369                id: phase_id,
370                label: phase_label,
371                progress,
372            });
373            if let Err(error) = result {
374                return Err(Self::fail_phase_with_summary(
375                    self.output,
376                    &execution,
377                    summaries,
378                    total_tasks,
379                    error,
380                )?);
381            }
382            if require_confirm && selection_position + 1 < total_phases {
383                let next = phases[selected[selection_position + 1]]
384                    .as_ref()
385                    .expect("the next selected phase has not executed");
386                let confirmed = match display::confirm_transition(phase_id, next) {
387                    Ok(confirmed) => confirmed,
388                    Err(source) => {
389                        return Err(Self::fail_phase_with_summary(
390                            self.output,
391                            &execution,
392                            summaries,
393                            total_tasks,
394                            StudyError::PhaseConfirmationInput {
395                                phase: phase_id.get(),
396                                source,
397                            },
398                        )?);
399                    }
400                };
401                if !confirmed {
402                    return Err(Self::fail_phase_with_summary(
403                        self.output,
404                        &execution,
405                        summaries,
406                        total_tasks,
407                        StudyError::PhaseConfirmationEof {
408                            phase: phase_id.get(),
409                        },
410                    )?);
411                }
412            }
413        }
414
415        display::study_complete(self.output, summaries.len(), total_tasks, true);
416        let record = execution.finish(true)?;
417        Ok(StudySummary {
418            phases: summaries.into(),
419            record: Box::new(record),
420        })
421    }
422
423    fn fail_phase_with_summary(
424        output: DisplayMode,
425        execution: &record::StudyRecorder,
426        summaries: Vec<PhaseSummary>,
427        total_tasks: usize,
428        source: StudyError,
429    ) -> Result<StudyError, StudyError> {
430        display::study_complete(output, summaries.len(), total_tasks, false);
431        let record = execution.finish(false)?;
432        Ok(StudyError::PhaseExecutionFailed {
433            summary: StudySummary {
434                phases: summaries.into(),
435                record: Box::new(record),
436            },
437            source: Box::new(source),
438        })
439    }
440}
441
442impl fmt::Debug for Study {
443    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
444        formatter
445            .debug_struct("Study")
446            .field("phases", &self.phases.len())
447            .field(
448                "tasks",
449                &self.phases.iter().map(|p| p.tasks().len()).sum::<usize>(),
450            )
451            .field("output", &self.output)
452            .finish_non_exhaustive()
453    }
454}
455
456/// Terminal summary for one selected phase.
457#[derive(Clone, Debug, Eq, PartialEq)]
458pub struct PhaseSummary {
459    id: PhaseId,
460    label: Arc<str>,
461    progress: ProgressSummary,
462}
463
464impl PhaseSummary {
465    /// Returns the completed phase's stable identity.
466    pub const fn id(&self) -> PhaseId {
467        self.id
468    }
469
470    /// Borrows the completed phase's display label.
471    pub fn label(&self) -> &str {
472        &self.label
473    }
474
475    /// Borrows the aggregate terminal task progress for this phase.
476    pub fn progress(&self) -> &ProgressSummary {
477        &self.progress
478    }
479
480    /// Reports whether every task in the phase completed successfully.
481    pub fn is_success(&self) -> bool {
482        self.progress.is_success()
483    }
484}
485
486/// Immutable aggregate for a completed study execution.
487#[derive(Clone, Debug, Eq, PartialEq)]
488pub struct StudySummary {
489    phases: Arc<[PhaseSummary]>,
490    record: Box<StudyRecord>,
491}
492
493impl StudySummary {
494    /// Borrows completed phase summaries in execution order.
495    pub fn phases(&self) -> &[PhaseSummary] {
496        &self.phases
497    }
498
499    /// Borrows the always-on durable record for this study execution.
500    pub fn record(&self) -> &StudyRecord {
501        self.record.as_ref()
502    }
503
504    /// Returns the total number of tasks across the completed phase summaries.
505    pub fn total_tasks(&self) -> u64 {
506        self.phases.iter().map(|phase| phase.progress.total()).sum()
507    }
508
509    /// Reports whether at least one phase ran and every phase succeeded.
510    pub fn is_success(&self) -> bool {
511        !self.phases.is_empty() && self.phases.iter().all(PhaseSummary::is_success)
512    }
513}
514
515struct StudyLease;
516
517impl StudyLease {
518    fn acquire() -> Result<Self, StudyError> {
519        STUDY_OWNED
520            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
521            .map(|_| Self)
522            .map_err(|_| StudyError::TerminalAlreadyOwned)
523    }
524}
525
526impl Drop for StudyLease {
527    fn drop(&mut self) {
528        STUDY_OWNED.store(false, Ordering::Release);
529    }
530}
531
532fn selected_ids<I, P>(phases: &[Phase], requested: I) -> Result<HashSet<PhaseId>, StudyError>
533where
534    I: IntoIterator<Item = P>,
535    P: Into<PhaseId>,
536{
537    let known: HashSet<_> = phases.iter().map(Phase::id).collect();
538    let selected: HashSet<_> = requested.into_iter().map(Into::into).collect();
539    if selected.is_empty() {
540        return Err(StudyError::EmptyPhaseSet);
541    }
542    if let Some(unknown) = selected.iter().find(|id| !known.contains(id)) {
543        return Err(StudyError::UnknownSelectedPhase {
544            phase: unknown.get(),
545        });
546    }
547    Ok(selected)
548}
549
550fn validate_plan(phases: &[Phase]) -> Result<(), StudyError> {
551    if phases.is_empty() {
552        return Err(StudyError::EmptyPhaseSet);
553    }
554    let mut ids = HashSet::with_capacity(phases.len());
555    for phase in phases {
556        if !ids.insert(phase.id()) {
557            return Err(StudyError::DuplicatePhaseId {
558                phase: phase.id().get(),
559            });
560        }
561    }
562    for phase in phases {
563        for dependency in phase.dependencies() {
564            if !ids.contains(dependency) {
565                return Err(StudyError::UnknownPhaseDependency {
566                    phase: phase.id().get(),
567                    dependency: dependency.get(),
568                });
569            }
570        }
571        for task in phase.tasks() {
572            if !task.has_workload() && !task.is_completed() {
573                return Err(StudyError::MissingTaskWorkload {
574                    task: task.key().to_string(),
575                });
576            }
577        }
578    }
579    topological_positions(phases).map(|_| ())
580}
581
582fn topological_positions(phases: &[Phase]) -> Result<Vec<usize>, StudyError> {
583    let positions: HashMap<_, _> = phases
584        .iter()
585        .enumerate()
586        .map(|(position, phase)| (phase.id(), position))
587        .collect();
588    let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); phases.len()];
589    let mut indegree = vec![0_usize; phases.len()];
590
591    for (position, phase) in phases.iter().enumerate() {
592        for dependency in phase.dependencies() {
593            let dependency_position = positions[dependency];
594            dependents[dependency_position].push(position);
595            indegree[position] += 1;
596        }
597    }
598
599    let mut queue = VecDeque::new();
600    for (position, degree) in indegree.iter().enumerate() {
601        if *degree == 0 {
602            queue.push_back(position);
603        }
604    }
605
606    let mut ordered = Vec::with_capacity(phases.len());
607    while let Some(position) = queue.pop_front() {
608        ordered.push(position);
609        for dependent in dependents[position].drain(..) {
610            indegree[dependent] -= 1;
611            if indegree[dependent] == 0 {
612                queue.push_back(dependent);
613            }
614        }
615    }
616
617    if ordered.len() != phases.len() {
618        let phase_position = indegree
619            .iter()
620            .position(|degree| *degree > 0)
621            .expect("cyclic dependency must leave at least one phase with indegree");
622        return Err(StudyError::PhaseDependencyCycle {
623            phase: phases[phase_position].id().get(),
624        });
625    }
626
627    Ok(ordered)
628}