use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FlowType {
Train,
Tune,
TuneThenTrain,
TrainFromTune,
ResumeRun,
}
impl std::fmt::Display for FlowType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
FlowType::Train => "train",
FlowType::Tune => "tune",
FlowType::TuneThenTrain => "tune-then-train",
FlowType::TrainFromTune => "train-from-tune",
FlowType::ResumeRun => "resume-run",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldRequirement {
Required,
Optional,
NotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobField {
TaskName,
Algorithm,
TotalTimesteps,
ResumeRunId,
TuneRunId,
NTrials,
TuneTimesteps,
EvalEps,
}
impl JobField {
pub fn wire_name(self) -> &'static str {
match self {
JobField::TaskName => "task_name",
JobField::Algorithm => "algorithm",
JobField::TotalTimesteps => "total_timesteps",
JobField::ResumeRunId => "resume_run_id",
JobField::TuneRunId => "tune_run_id",
JobField::NTrials => "n_trials",
JobField::TuneTimesteps => "tune_timesteps",
JobField::EvalEps => "eval_eps",
}
}
pub fn requirement(self, flow_type: FlowType) -> FieldRequirement {
use FieldRequirement::*;
use FlowType::*;
use JobField::*;
match (self, flow_type) {
(TaskName, _) => Required,
(Algorithm, _) => Required,
(TotalTimesteps, Train | TuneThenTrain | TrainFromTune | ResumeRun) => Required,
(TotalTimesteps, Tune) => NotApplicable,
(ResumeRunId, ResumeRun) => Required,
(ResumeRunId, _) => NotApplicable,
(TuneRunId, TrainFromTune) => Required,
(TuneRunId, _) => NotApplicable,
(NTrials | TuneTimesteps | EvalEps, Tune | TuneThenTrain) => Required,
(NTrials | TuneTimesteps | EvalEps, _) => NotApplicable,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct JobFields {
pub task_name: Option<String>,
pub algorithm: Option<String>,
pub total_timesteps: Option<i64>,
pub resume_run_id: Option<String>,
pub tune_run_id: Option<String>,
pub n_trials: Option<i64>,
pub tune_timesteps: Option<i64>,
pub eval_eps: Option<i64>,
}
impl JobFields {
fn get(&self, field: JobField) -> FieldPresence<'_> {
match field {
JobField::TaskName => FieldPresence::Str(self.task_name.as_deref()),
JobField::Algorithm => FieldPresence::Str(self.algorithm.as_deref()),
JobField::TotalTimesteps => FieldPresence::Int(self.total_timesteps),
JobField::ResumeRunId => FieldPresence::Str(self.resume_run_id.as_deref()),
JobField::TuneRunId => FieldPresence::Str(self.tune_run_id.as_deref()),
JobField::NTrials => FieldPresence::Int(self.n_trials),
JobField::TuneTimesteps => FieldPresence::Int(self.tune_timesteps),
JobField::EvalEps => FieldPresence::Int(self.eval_eps),
}
}
}
enum FieldPresence<'a> {
Str(Option<&'a str>),
Int(Option<i64>),
}
impl FieldPresence<'_> {
fn is_present(&self) -> bool {
match self {
FieldPresence::Str(s) => s.is_some_and(|s| !s.trim().is_empty()),
FieldPresence::Int(i) => i.is_some(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingField {
pub field: JobField,
pub flow_type: FlowType,
}
impl std::fmt::Display for MissingField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"'{:?}' is required for flow_type '{}' but was not set",
self.field, self.flow_type
)
}
}
const ALL_FIELDS: &[JobField] = &[
JobField::TaskName,
JobField::Algorithm,
JobField::TotalTimesteps,
JobField::ResumeRunId,
JobField::TuneRunId,
JobField::NTrials,
JobField::TuneTimesteps,
JobField::EvalEps,
];
pub fn all_fields() -> &'static [JobField] {
ALL_FIELDS
}
pub fn validate_job(flow_type: FlowType, fields: &JobFields) -> Vec<MissingField> {
ALL_FIELDS
.iter()
.filter(|&&field| field.requirement(flow_type) == FieldRequirement::Required)
.filter(|&&field| !fields.get(field).is_present())
.map(|&field| MissingField { field, flow_type })
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskLookupError {
NotFound { task_name: String },
InvalidJson { task_name: String, message: String },
}
impl std::fmt::Display for TaskLookupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskLookupError::NotFound { task_name } => write!(
f,
"task '{task_name}' has no configs/rl/tasks/{task_name}/task.json - check the \
task_name against the project's task catalog"
),
TaskLookupError::InvalidJson { task_name, message } => {
write!(f, "configs/rl/tasks/{task_name}/task.json is not valid JSON: {message}")
}
}
}
}
impl std::error::Error for TaskLookupError {}
pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
pub fn task_exists(project_root: &Path, task_name: &str) -> Result<(), TaskLookupError> {
let path = project_root.join(RL_TASKS_DIR).join(task_name).join("task.json");
let content = std::fs::read_to_string(&path).map_err(|_| TaskLookupError::NotFound {
task_name: task_name.to_string(),
})?;
serde_json::from_str::<serde_json::Value>(&content).map_err(|e| TaskLookupError::InvalidJson {
task_name: task_name.to_string(),
message: e.to_string(),
})?;
Ok(())
}
pub fn list_tasks(project_root: &Path) -> std::io::Result<Vec<String>> {
let dir = project_root.join(RL_TASKS_DIR);
if !dir.exists() {
return Ok(Vec::new());
}
let mut names: Vec<String> = std::fs::read_dir(&dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().join("task.json").is_file())
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
names.sort();
Ok(names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_job_field_has_a_unique_wire_name() {
let names: Vec<&str> = ALL_FIELDS.iter().map(|f| f.wire_name()).collect();
let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
assert_eq!(names.len(), unique.len());
}
fn fields_with_task_and_algo() -> JobFields {
JobFields {
task_name: Some("find_target".to_string()),
algorithm: Some("ppo".to_string()),
..Default::default()
}
}
#[test]
fn train_requires_total_timesteps() {
let fields = fields_with_task_and_algo();
let missing = validate_job(FlowType::Train, &fields);
assert_eq!(
missing,
vec![MissingField {
field: JobField::TotalTimesteps,
flow_type: FlowType::Train
}]
);
}
#[test]
fn train_passes_with_total_timesteps_set() {
let mut fields = fields_with_task_and_algo();
fields.total_timesteps = Some(1000);
assert!(validate_job(FlowType::Train, &fields).is_empty());
}
#[test]
fn tune_does_not_require_total_timesteps() {
let mut fields = fields_with_task_and_algo();
fields.n_trials = Some(10);
fields.tune_timesteps = Some(1000);
fields.eval_eps = Some(2);
assert!(validate_job(FlowType::Tune, &fields).is_empty());
}
#[test]
fn tune_requires_tune_specific_fields() {
let fields = fields_with_task_and_algo();
let missing = validate_job(FlowType::Tune, &fields);
assert_eq!(missing.len(), 3);
assert!(missing.iter().all(|m| m.flow_type == FlowType::Tune));
}
#[test]
fn resume_run_requires_resume_run_id_and_total_timesteps() {
let fields = fields_with_task_and_algo();
let missing = validate_job(FlowType::ResumeRun, &fields);
assert_eq!(missing.len(), 2);
assert!(missing.iter().any(|m| m.field == JobField::ResumeRunId));
assert!(missing.iter().any(|m| m.field == JobField::TotalTimesteps));
}
#[test]
fn train_from_tune_requires_tune_run_id() {
let mut fields = fields_with_task_and_algo();
fields.total_timesteps = Some(1000);
let missing = validate_job(FlowType::TrainFromTune, &fields);
assert_eq!(
missing,
vec![MissingField {
field: JobField::TuneRunId,
flow_type: FlowType::TrainFromTune
}]
);
}
#[test]
fn missing_task_name_and_algorithm_reported_for_every_flow_type() {
let missing = validate_job(FlowType::Train, &JobFields::default());
assert!(missing.iter().any(|m| m.field == JobField::TaskName));
assert!(missing.iter().any(|m| m.field == JobField::Algorithm));
}
#[test]
fn blank_string_fields_count_as_absent() {
let mut fields = fields_with_task_and_algo();
fields.resume_run_id = Some(" ".to_string());
let missing = validate_job(FlowType::ResumeRun, &fields);
assert!(missing.iter().any(|m| m.field == JobField::ResumeRunId));
}
#[test]
fn task_exists_finds_bundled_find_target_task() {
let dir = tempfile::tempdir().unwrap();
let task_dir = dir.path().join("configs/rl/tasks/find_target");
std::fs::create_dir_all(&task_dir).unwrap();
std::fs::write(task_dir.join("task.json"), r#"{"env_name": "basic_arena"}"#).unwrap();
assert!(task_exists(dir.path(), "find_target").is_ok());
}
#[test]
fn task_exists_reports_missing_task() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("configs/rl/tasks")).unwrap();
let err = task_exists(dir.path(), "nonexistent").unwrap_err();
assert_eq!(
err,
TaskLookupError::NotFound {
task_name: "nonexistent".to_string()
}
);
}
#[test]
fn list_tasks_returns_empty_when_directory_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(list_tasks(dir.path()).unwrap(), Vec::<String>::new());
}
#[test]
fn list_tasks_lists_directories_with_a_task_json_sorted() {
let dir = tempfile::tempdir().unwrap();
for name in ["task2", "find_target", "task1"] {
let task_dir = dir.path().join("configs/rl/tasks").join(name);
std::fs::create_dir_all(&task_dir).unwrap();
std::fs::write(task_dir.join("task.json"), "{}").unwrap();
}
std::fs::create_dir_all(dir.path().join("configs/rl/tasks/not-a-task")).unwrap();
assert_eq!(
list_tasks(dir.path()).unwrap(),
vec!["find_target".to_string(), "task1".to_string(), "task2".to_string()]
);
}
#[test]
fn task_exists_reports_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let task_dir = dir.path().join("configs/rl/tasks/broken");
std::fs::create_dir_all(&task_dir).unwrap();
std::fs::write(task_dir.join("task.json"), "not json").unwrap();
let err = task_exists(dir.path(), "broken").unwrap_err();
assert!(matches!(err, TaskLookupError::InvalidJson { .. }));
}
}