scientific-workflow 0.8.0

Configuration-driven scientific tasks, typed state, and durable recordings
Documentation
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};

use scientific_workflow::configuration::{
    ConfigurationError, ProjectPaths, ReplicateExecutionMode, ReplicateFailurePolicy,
    ResolvedConfiguration, StudyConfiguration, StudySettings,
};

static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);

fn study_directory(name: &str, parameters: &str) -> PathBuf {
    let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
    let root = std::env::temp_dir().join(format!(
        "scientific-workflow-{name}-{}-{sequence}",
        std::process::id()
    ));
    fs::create_dir_all(root.join("config")).unwrap();
    fs::write(root.join("config/parameters.json"), parameters).unwrap();
    root
}

#[test]
fn phase_expansion_combines_global_group_and_local_scopes() {
    let root = study_directory(
        "scoped-cartesian",
        r#"{
          "global":{
            "temperature":{"$sweep":[280.0,300.0]},
            "solver":{"step":0.01}
          },
          "phase_group":{"models":{
            "shared":{
              "seed":{"$sweep":[7,11]},
              "solver":{"tolerance":0.000001}
            },
            "phase":{
              "glv":{"solver":{"method":"adaptive"}},
              "lattice":{
                "size":{"$sweep":[4,8]},
                "solver":{"method":"rk4"}
              }
            }
          }}
        }"#,
    );
    let study = StudyConfiguration::load(&root).unwrap();
    let glv = study.phase("models", "glv").unwrap();
    let lattice = study.phase("models", "lattice").unwrap();

    assert_eq!(glv.combination_count(), 4);
    assert_eq!(lattice.combination_count(), 8);
    assert_eq!(
        lattice.fixed_keys().collect::<Vec<_>>(),
        ["/solver/step", "/solver/tolerance", "/solver/method"]
    );
    assert_eq!(lattice.sweep_keys().len(), 3);
    assert_eq!(
        lattice.sweep_keys().collect::<Vec<_>>(),
        ["/temperature", "/seed", "/size"]
    );
    let resolved = lattice
        .combinations()
        .map(|configuration| {
            let values: (f64, u64, usize) = configuration
                .decode_values(("/temperature", "/seed", "/size"))
                .unwrap();
            (
                configuration.global_ordinal(),
                configuration.group_ordinal(),
                configuration.phase_ordinal(),
                values,
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(resolved[0], (0, 0, 0, (280.0, 7, 4)));
    assert_eq!(resolved[1], (0, 0, 1, (280.0, 7, 8)));
    assert_eq!(resolved[7], (1, 1, 1, (300.0, 11, 8)));

    let first = lattice.combination(0).unwrap();
    assert_eq!(
        first.require_value("/solver").unwrap(),
        &serde_json::json!({
            "step": 0.01,
            "tolerance": 0.000001,
            "method": "rk4"
        })
    );
    assert_eq!(
        serde_json::from_str::<serde_json::Value>(&first.to_json()).unwrap()["solver"]["method"],
        "rk4"
    );

    fs::remove_dir_all(root).unwrap();
}

#[test]
fn ordinary_arrays_are_literal_and_cases_remain_correlated() {
    let root = study_directory(
        "literal-and-cases",
        r#"{
          "global":{"shape":[64],"fields":["abundance","space"]},
          "phase_group":{"analysis":{
            "shared":{},
            "phase":{"convert":{
              "$cases":[
                {"temperature":280.0,"seed":1},
                {"temperature":300.0,"seed":9}
              ]
            }}
          }}
        }"#,
    );
    let combinations = StudyConfiguration::load(&root)
        .unwrap()
        .phase("analysis", "convert")
        .unwrap()
        .combinations()
        .collect::<Vec<ResolvedConfiguration>>();

    assert_eq!(combinations.len(), 2);
    assert_eq!(
        combinations[0]
            .decode_value::<Vec<usize>>("/shape")
            .unwrap(),
        [64]
    );
    assert_eq!(combinations[0].decode_value::<u64>("/seed").unwrap(), 1);
    assert_eq!(combinations[1].decode_value::<u64>("/seed").unwrap(), 9);

    fs::remove_dir_all(root).unwrap();
}

#[test]
fn rejects_scope_overlap_and_mixed_case_grammars() {
    let overlap = study_directory(
        "overlap",
        r#"{
          "global":{"seed":1},
          "phase_group":{"g":{"shared":{"seed":2},"phase":{"p":{}}}}
        }"#,
    );
    assert!(matches!(
        StudyConfiguration::load(&overlap),
        Err(ConfigurationError::InvalidConfigurationDocument { .. })
    ));
    fs::remove_dir_all(overlap).unwrap();

    let mixed = study_directory(
        "mixed",
        r#"{
          "global":{},
          "phase_group":{"g":{"shared":{},"phase":{"p":{
            "seed":{"$sweep":[1,2]},"$cases":[{"size":4},{"size":8}]
          }}}}
        }"#,
    );
    assert!(matches!(
        StudyConfiguration::load(&mixed),
        Err(ConfigurationError::InvalidConfigurationDocument { .. })
    ));
    fs::remove_dir_all(mixed).unwrap();
}

#[test]
fn indexed_access_and_errors_retain_the_combination_ordinal() {
    let root = study_directory(
        "indexed",
        r#"{"global":{"seed":7},"phase_group":{"g":{"shared":{},"phase":{"p":{}}}}}"#,
    );
    let space = StudyConfiguration::load(&root)
        .unwrap()
        .phase("g", "p")
        .unwrap();
    let configuration = space.combination(0).unwrap();

    assert!(matches!(
        configuration.decode_value::<String>("/seed"),
        Err(ConfigurationError::DecodeConfigurationValue { ordinal: 0, .. })
    ));
    assert!(matches!(
        space.combination(1),
        Err(ConfigurationError::CombinationOrdinalOutOfBounds {
            ordinal: 1,
            combination_count: 1
        })
    ));
    assert!(matches!(
        StudyConfiguration::load(&root)
            .unwrap()
            .phase("g", "missing"),
        Err(ConfigurationError::UnknownPhaseConfiguration { .. })
    ));
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn project_paths_are_strict_ordered_and_resolve_lexically() {
    let root = study_directory(
        "paths",
        r#"{"global":{},"phase_group":{"g":{"shared":{},"phase":{"p":{}}}}}"#,
    );
    let configuration = root.join("config");
    fs::write(
        configuration.join("paths.json"),
        r#"{"recordings":"results","input":"data/input.json"}"#,
    )
    .unwrap();

    let paths = ProjectPaths::load(&root).unwrap();
    assert_eq!(paths.keys().collect::<Vec<_>>(), ["recordings", "input"]);
    assert_eq!(
        paths.resolve_path("recordings").unwrap(),
        root.join("results")
    );
    assert_eq!(paths.to_json_value()["input"], "data/input.json");

    fs::write(
        configuration.join("paths.json"),
        r#"{"recordings":"first","recordings":"second"}"#,
    )
    .unwrap();
    assert!(matches!(
        ProjectPaths::load(&root),
        Err(ConfigurationError::DuplicateConfigurationKey { key, .. }) if key == "recordings"
    ));

    fs::remove_dir_all(root).unwrap();
}

#[test]
fn study_settings_strictly_define_replicate_execution() {
    let root = study_directory(
        "replicate-settings",
        r#"{"global":{},"phase_group":{"g":{"shared":{},"phase":{"p":{}}}}}"#,
    );
    let source = br#"{
      "replicate_settings": {
        "replicates": 4,
        "execution": "parallel",
        "failure_policy": "finish_all",
        "seed": 1101
      }
    }"#;
    fs::write(root.join("study.json"), source).unwrap();

    let study = StudySettings::load(&root).unwrap();
    let settings = study.replicate_settings();
    assert_eq!(study.study_root(), root);
    assert_eq!(study.source_json(), source);
    assert_eq!(settings.replicates(), 4);
    assert_eq!(settings.execution(), ReplicateExecutionMode::Parallel);
    assert_eq!(settings.failure_policy(), ReplicateFailurePolicy::FinishAll);
    assert_eq!(settings.seed(), 1101);

    fs::write(
        root.join("study.json"),
        r#"{"replicate_settings":{"replicates":0,"execution":"sequential","failure_policy":"fail_fast","seed":1}}"#,
    )
    .unwrap();
    assert!(matches!(
        StudySettings::load(&root),
        Err(ConfigurationError::InvalidConfigurationDocument { .. })
    ));

    fs::write(
        root.join("study.json"),
        r#"{"replicate_settings":{"replicates":1,"execution":"sequential","failure_policy":"fail_fast","seed":1,"unknown":true}}"#,
    )
    .unwrap();
    assert!(matches!(
        StudySettings::load(&root),
        Err(ConfigurationError::InvalidConfigurationDocument { .. })
    ));

    fs::remove_dir_all(root).unwrap();
}