1use std::path::Path;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
37#[serde(rename_all = "kebab-case")]
38pub enum FlowType {
39 Train,
40 Tune,
41 TuneThenTrain,
42 TrainFromTune,
43 ResumeRun,
44}
45
46impl std::fmt::Display for FlowType {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 let s = match self {
49 FlowType::Train => "train",
50 FlowType::Tune => "tune",
51 FlowType::TuneThenTrain => "tune-then-train",
52 FlowType::TrainFromTune => "train-from-tune",
53 FlowType::ResumeRun => "resume-run",
54 };
55 write!(f, "{s}")
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FieldRequirement {
64 Required,
65 Optional,
66 NotApplicable,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum JobField {
76 TaskName,
77 Algorithm,
78 ResumeRunId,
79 TuneRunId,
80 NTrials,
81 TuneTimesteps,
82 EvalEps,
83}
84
85impl JobField {
86 pub fn wire_name(self) -> &'static str {
93 match self {
94 JobField::TaskName => "task_name",
95 JobField::Algorithm => "algorithm",
96 JobField::ResumeRunId => "resume_run_id",
97 JobField::TuneRunId => "tune_run_id",
98 JobField::NTrials => "n_trials",
99 JobField::TuneTimesteps => "tune_timesteps",
100 JobField::EvalEps => "eval_eps",
101 }
102 }
103
104 pub fn requirement(self, flow_type: FlowType) -> FieldRequirement {
108 use FieldRequirement::*;
109 use FlowType::*;
110 use JobField::*;
111
112 match (self, flow_type) {
113 (TaskName, _) => Required,
114 (Algorithm, _) => Required,
115
116 (ResumeRunId, ResumeRun) => Required,
117 (ResumeRunId, _) => NotApplicable,
118
119 (TuneRunId, TrainFromTune) => Required,
120 (TuneRunId, _) => NotApplicable,
121
122 (NTrials | TuneTimesteps | EvalEps, Tune | TuneThenTrain) => Required,
123 (NTrials | TuneTimesteps | EvalEps, _) => NotApplicable,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default)]
134pub struct JobFields {
135 pub task_name: Option<String>,
136 pub algorithm: Option<String>,
137 pub resume_run_id: Option<String>,
138 pub tune_run_id: Option<String>,
139 pub n_trials: Option<i64>,
140 pub tune_timesteps: Option<i64>,
141 pub eval_eps: Option<i64>,
142}
143
144impl JobFields {
145 fn get(&self, field: JobField) -> FieldPresence<'_> {
146 match field {
147 JobField::TaskName => FieldPresence::Str(self.task_name.as_deref()),
148 JobField::Algorithm => FieldPresence::Str(self.algorithm.as_deref()),
149 JobField::ResumeRunId => FieldPresence::Str(self.resume_run_id.as_deref()),
150 JobField::TuneRunId => FieldPresence::Str(self.tune_run_id.as_deref()),
151 JobField::NTrials => FieldPresence::Int(self.n_trials),
152 JobField::TuneTimesteps => FieldPresence::Int(self.tune_timesteps),
153 JobField::EvalEps => FieldPresence::Int(self.eval_eps),
154 }
155 }
156}
157
158enum FieldPresence<'a> {
159 Str(Option<&'a str>),
160 Int(Option<i64>),
161 Float(Option<f64>),
162}
163
164impl FieldPresence<'_> {
165 fn is_present(&self) -> bool {
173 match self {
174 FieldPresence::Str(s) => s.is_some_and(|s| !s.trim().is_empty()),
175 FieldPresence::Int(i) => i.is_some(),
176 FieldPresence::Float(f) => f.is_some(),
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct MissingField {
184 pub field: JobField,
185 pub flow_type: FlowType,
186}
187
188impl std::fmt::Display for MissingField {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 write!(
191 f,
192 "'{:?}' is required for flow_type '{}' but was not set",
193 self.field, self.flow_type
194 )
195 }
196}
197
198const ALL_FIELDS: &[JobField] = &[
199 JobField::TaskName,
200 JobField::Algorithm,
201 JobField::ResumeRunId,
202 JobField::TuneRunId,
203 JobField::NTrials,
204 JobField::TuneTimesteps,
205 JobField::EvalEps,
206];
207
208pub fn all_fields() -> &'static [JobField] {
212 ALL_FIELDS
213}
214
215pub fn validate_job(flow_type: FlowType, fields: &JobFields) -> Vec<MissingField> {
218 ALL_FIELDS
219 .iter()
220 .filter(|&&field| field.requirement(flow_type) == FieldRequirement::Required)
221 .filter(|&&field| !fields.get(field).is_present())
222 .map(|&field| MissingField { field, flow_type })
223 .collect()
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum AlgorithmField {
247 TotalTimesteps,
248 LearningRate,
249 NSteps,
250 NEpochs,
251 Patience,
252 MinDelta,
253}
254
255impl AlgorithmField {
256 pub fn wire_name(self) -> &'static str {
259 match self {
260 AlgorithmField::TotalTimesteps => "total_timesteps",
261 AlgorithmField::LearningRate => "learning_rate",
262 AlgorithmField::NSteps => "n_steps",
263 AlgorithmField::NEpochs => "n_epochs",
264 AlgorithmField::Patience => "patience",
265 AlgorithmField::MinDelta => "min_delta",
266 }
267 }
268
269 pub fn requirement(self, algorithm: &str, flow_type: FlowType) -> FieldRequirement {
296 use AlgorithmField::*;
297 use FieldRequirement::*;
298
299 if self == NSteps && algorithm == "ppo" && flow_type == FlowType::ResumeRun {
303 return Optional;
304 }
305
306 match algorithm {
307 "ppo" => Required,
308 "sac" | "td3" | "a2c" => match self {
309 TotalTimesteps | LearningRate | Patience | MinDelta => Required,
310 NSteps | NEpochs => NotApplicable,
311 },
312 _ => NotApplicable,
313 }
314 }
315}
316
317#[derive(Debug, Clone, Default)]
321pub struct AlgorithmFields {
322 pub total_timesteps: Option<i64>,
323 pub learning_rate: Option<f64>,
324 pub n_steps: Option<i64>,
325 pub n_epochs: Option<i64>,
326 pub patience: Option<i64>,
327 pub min_delta: Option<f64>,
328}
329
330impl AlgorithmFields {
331 fn get(&self, field: AlgorithmField) -> FieldPresence<'_> {
332 match field {
333 AlgorithmField::TotalTimesteps => FieldPresence::Int(self.total_timesteps),
334 AlgorithmField::LearningRate => FieldPresence::Float(self.learning_rate),
335 AlgorithmField::NSteps => FieldPresence::Int(self.n_steps),
336 AlgorithmField::NEpochs => FieldPresence::Int(self.n_epochs),
337 AlgorithmField::Patience => FieldPresence::Int(self.patience),
338 AlgorithmField::MinDelta => FieldPresence::Float(self.min_delta),
339 }
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct MissingAlgorithmField {
347 pub field: AlgorithmField,
348 pub algorithm: String,
349}
350
351impl std::fmt::Display for MissingAlgorithmField {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 write!(
354 f,
355 "'{}' is required for algorithm '{}' but was not set",
356 self.field.wire_name(),
357 self.algorithm
358 )
359 }
360}
361
362const ALL_ALGORITHM_FIELDS: &[AlgorithmField] = &[
363 AlgorithmField::TotalTimesteps,
364 AlgorithmField::LearningRate,
365 AlgorithmField::NSteps,
366 AlgorithmField::NEpochs,
367 AlgorithmField::Patience,
368 AlgorithmField::MinDelta,
369];
370
371pub fn all_algorithm_fields() -> &'static [AlgorithmField] {
374 ALL_ALGORITHM_FIELDS
375}
376
377pub fn validate_algorithm_fields(
384 algorithm: &str,
385 flow_type: FlowType,
386 fields: &AlgorithmFields,
387) -> Vec<MissingAlgorithmField> {
388 ALL_ALGORITHM_FIELDS
389 .iter()
390 .filter(|&&field| field.requirement(algorithm, flow_type) == FieldRequirement::Required)
391 .filter(|&&field| !fields.get(field).is_present())
392 .map(|&field| MissingAlgorithmField {
393 field,
394 algorithm: algorithm.to_string(),
395 })
396 .collect()
397}
398
399#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum TaskLookupError {
403 NotFound { task_name: String },
404 InvalidJson { task_name: String, message: String },
405}
406
407impl std::fmt::Display for TaskLookupError {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 match self {
410 TaskLookupError::NotFound { task_name } => write!(
411 f,
412 "task '{task_name}' has no configs/rl/tasks/{task_name}/task.json - check the \
413 task_name against the project's task catalog"
414 ),
415 TaskLookupError::InvalidJson { task_name, message } => {
416 write!(f, "configs/rl/tasks/{task_name}/task.json is not valid JSON: {message}")
417 }
418 }
419 }
420}
421
422impl std::error::Error for TaskLookupError {}
423
424pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
429
430pub fn task_exists(project_root: &Path, task_name: &str) -> Result<(), TaskLookupError> {
436 let path = project_root.join(RL_TASKS_DIR).join(task_name).join("task.json");
437 let content = std::fs::read_to_string(&path).map_err(|_| TaskLookupError::NotFound {
438 task_name: task_name.to_string(),
439 })?;
440 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| TaskLookupError::InvalidJson {
441 task_name: task_name.to_string(),
442 message: e.to_string(),
443 })?;
444 Ok(())
445}
446
447pub fn list_tasks(project_root: &Path) -> std::io::Result<Vec<String>> {
453 let dir = project_root.join(RL_TASKS_DIR);
454 if !dir.exists() {
455 return Ok(Vec::new());
456 }
457
458 let mut names: Vec<String> = std::fs::read_dir(&dir)?
459 .filter_map(|entry| entry.ok())
460 .filter(|entry| entry.path().join("task.json").is_file())
461 .filter_map(|entry| entry.file_name().into_string().ok())
462 .collect();
463 names.sort();
464 Ok(names)
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn every_job_field_has_a_unique_wire_name() {
473 let names: Vec<&str> = ALL_FIELDS.iter().map(|f| f.wire_name()).collect();
474 let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
475 assert_eq!(names.len(), unique.len());
476 }
477
478 fn fields_with_task_and_algo() -> JobFields {
479 JobFields {
480 task_name: Some("find_target".to_string()),
481 algorithm: Some("ppo".to_string()),
482 ..Default::default()
483 }
484 }
485
486 #[test]
487 fn train_requires_only_task_name_and_algorithm() {
488 let fields = fields_with_task_and_algo();
492 assert!(validate_job(FlowType::Train, &fields).is_empty());
493 }
494
495 #[test]
496 fn tune_requires_tune_specific_fields() {
497 let fields = fields_with_task_and_algo();
498 let missing = validate_job(FlowType::Tune, &fields);
499 assert_eq!(missing.len(), 3);
500 assert!(missing.iter().all(|m| m.flow_type == FlowType::Tune));
501 }
502
503 #[test]
504 fn tune_passes_with_tune_specific_fields_set() {
505 let mut fields = fields_with_task_and_algo();
506 fields.n_trials = Some(10);
507 fields.tune_timesteps = Some(1000);
508 fields.eval_eps = Some(2);
509 assert!(validate_job(FlowType::Tune, &fields).is_empty());
510 }
511
512 #[test]
513 fn resume_run_requires_resume_run_id() {
514 let fields = fields_with_task_and_algo();
515 let missing = validate_job(FlowType::ResumeRun, &fields);
516 assert_eq!(
517 missing,
518 vec![MissingField {
519 field: JobField::ResumeRunId,
520 flow_type: FlowType::ResumeRun
521 }]
522 );
523 }
524
525 #[test]
526 fn train_from_tune_requires_tune_run_id() {
527 let fields = fields_with_task_and_algo();
528 let missing = validate_job(FlowType::TrainFromTune, &fields);
529 assert_eq!(
530 missing,
531 vec![MissingField {
532 field: JobField::TuneRunId,
533 flow_type: FlowType::TrainFromTune
534 }]
535 );
536 }
537
538 #[test]
539 fn missing_task_name_and_algorithm_reported_for_every_flow_type() {
540 let missing = validate_job(FlowType::Train, &JobFields::default());
541 assert!(missing.iter().any(|m| m.field == JobField::TaskName));
542 assert!(missing.iter().any(|m| m.field == JobField::Algorithm));
543 }
544
545 #[test]
546 fn blank_string_fields_count_as_absent() {
547 let mut fields = fields_with_task_and_algo();
548 fields.resume_run_id = Some(" ".to_string());
549 let missing = validate_job(FlowType::ResumeRun, &fields);
550 assert!(missing.iter().any(|m| m.field == JobField::ResumeRunId));
551 }
552
553 #[test]
554 fn task_exists_finds_bundled_find_target_task() {
555 let dir = tempfile::tempdir().unwrap();
556 let task_dir = dir.path().join("configs/rl/tasks/find_target");
557 std::fs::create_dir_all(&task_dir).unwrap();
558 std::fs::write(task_dir.join("task.json"), r#"{"env_name": "basic_arena"}"#).unwrap();
559 assert!(task_exists(dir.path(), "find_target").is_ok());
560 }
561
562 #[test]
563 fn task_exists_reports_missing_task() {
564 let dir = tempfile::tempdir().unwrap();
565 std::fs::create_dir_all(dir.path().join("configs/rl/tasks")).unwrap();
566 let err = task_exists(dir.path(), "nonexistent").unwrap_err();
567 assert_eq!(
568 err,
569 TaskLookupError::NotFound {
570 task_name: "nonexistent".to_string()
571 }
572 );
573 }
574
575 #[test]
576 fn list_tasks_returns_empty_when_directory_missing() {
577 let dir = tempfile::tempdir().unwrap();
578 assert_eq!(list_tasks(dir.path()).unwrap(), Vec::<String>::new());
579 }
580
581 #[test]
582 fn list_tasks_lists_directories_with_a_task_json_sorted() {
583 let dir = tempfile::tempdir().unwrap();
584 for name in ["task2", "find_target", "task1"] {
585 let task_dir = dir.path().join("configs/rl/tasks").join(name);
586 std::fs::create_dir_all(&task_dir).unwrap();
587 std::fs::write(task_dir.join("task.json"), "{}").unwrap();
588 }
589 std::fs::create_dir_all(dir.path().join("configs/rl/tasks/not-a-task")).unwrap();
590
591 assert_eq!(
592 list_tasks(dir.path()).unwrap(),
593 vec!["find_target".to_string(), "task1".to_string(), "task2".to_string()]
594 );
595 }
596
597 #[test]
598 fn task_exists_reports_invalid_json() {
599 let dir = tempfile::tempdir().unwrap();
600 let task_dir = dir.path().join("configs/rl/tasks/broken");
601 std::fs::create_dir_all(&task_dir).unwrap();
602 std::fs::write(task_dir.join("task.json"), "not json").unwrap();
603 let err = task_exists(dir.path(), "broken").unwrap_err();
604 assert!(matches!(err, TaskLookupError::InvalidJson { .. }));
605 }
606
607 mod algorithm_field_tests {
608 use super::*;
609
610 #[test]
611 fn every_algorithm_field_has_a_unique_wire_name() {
612 let names: Vec<&str> = ALL_ALGORITHM_FIELDS.iter().map(|f| f.wire_name()).collect();
613 let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
614 assert_eq!(names.len(), unique.len());
615 }
616
617 fn all_ppo_fields_set() -> AlgorithmFields {
618 AlgorithmFields {
619 total_timesteps: Some(1_000_000),
620 learning_rate: Some(0.0003),
621 n_steps: Some(2048),
622 n_epochs: Some(10),
623 patience: Some(10),
624 min_delta: Some(0.01),
625 }
626 }
627
628 #[test]
629 fn ppo_passes_with_all_six_fields_set() {
630 assert!(validate_algorithm_fields("ppo", FlowType::Train, &all_ppo_fields_set()).is_empty());
631 }
632
633 #[test]
634 fn ppo_requires_each_field_individually() {
635 for field in ALL_ALGORITHM_FIELDS {
636 let mut fields = all_ppo_fields_set();
637 match field {
638 AlgorithmField::TotalTimesteps => fields.total_timesteps = None,
639 AlgorithmField::LearningRate => fields.learning_rate = None,
640 AlgorithmField::NSteps => fields.n_steps = None,
641 AlgorithmField::NEpochs => fields.n_epochs = None,
642 AlgorithmField::Patience => fields.patience = None,
643 AlgorithmField::MinDelta => fields.min_delta = None,
644 }
645 let missing = validate_algorithm_fields("ppo", FlowType::Train, &fields);
646 assert_eq!(
647 missing,
648 vec![MissingAlgorithmField {
649 field: *field,
650 algorithm: "ppo".to_string()
651 }],
652 "expected only {field:?} to be reported missing"
653 );
654 }
655 }
656
657 #[test]
658 fn ppo_reports_every_missing_field_when_all_are_absent() {
659 let missing = validate_algorithm_fields("ppo", FlowType::Train, &AlgorithmFields::default());
660 assert_eq!(missing.len(), ALL_ALGORITHM_FIELDS.len());
661 }
662
663 #[test]
664 fn ppo_resume_run_does_not_require_n_steps_but_still_requires_everything_else() {
665 let mut fields = all_ppo_fields_set();
669 fields.n_steps = None;
670 assert!(
671 validate_algorithm_fields("ppo", FlowType::ResumeRun, &fields).is_empty(),
672 "a ppo resume-run without n_steps should pass"
673 );
674
675 fields.total_timesteps = None;
678 let missing = validate_algorithm_fields("ppo", FlowType::ResumeRun, &fields);
679 assert_eq!(
680 missing,
681 vec![MissingAlgorithmField {
682 field: AlgorithmField::TotalTimesteps,
683 algorithm: "ppo".to_string()
684 }],
685 "a ppo resume-run without total_timesteps should still fail (and n_steps stays exempt)"
686 );
687 }
688
689 #[test]
690 fn ppo_n_steps_carve_out_is_resume_run_only() {
691 for flow_type in [
694 FlowType::Train,
695 FlowType::Tune,
696 FlowType::TuneThenTrain,
697 FlowType::TrainFromTune,
698 ] {
699 let mut fields = all_ppo_fields_set();
700 fields.n_steps = None;
701 let missing = validate_algorithm_fields("ppo", flow_type, &fields);
702 assert_eq!(
703 missing,
704 vec![MissingAlgorithmField {
705 field: AlgorithmField::NSteps,
706 algorithm: "ppo".to_string()
707 }],
708 "expected n_steps to be required for ppo on {flow_type}"
709 );
710 }
711 }
712
713 fn off_policy_fields_set() -> AlgorithmFields {
714 AlgorithmFields {
715 total_timesteps: Some(1_000_000),
716 learning_rate: Some(0.0003),
717 n_steps: None,
718 n_epochs: None,
719 patience: Some(10),
720 min_delta: Some(0.01),
721 }
722 }
723
724 #[test]
725 fn sac_td3_a2c_pass_with_their_four_fields_set_and_no_n_steps_or_n_epochs() {
726 for algorithm in ["sac", "td3", "a2c"] {
727 assert!(
728 validate_algorithm_fields(algorithm, FlowType::Train, &off_policy_fields_set()).is_empty(),
729 "expected {algorithm} to pass"
730 );
731 }
732 }
733
734 #[test]
735 fn sac_td3_a2c_require_their_four_fields() {
736 for algorithm in ["sac", "td3", "a2c"] {
737 let missing = validate_algorithm_fields(algorithm, FlowType::Train, &AlgorithmFields::default());
738 assert_eq!(missing.len(), 4, "expected 4 missing fields for {algorithm}");
739 assert!(missing.iter().all(|m| m.algorithm == algorithm));
740 for field in [
741 AlgorithmField::TotalTimesteps,
742 AlgorithmField::LearningRate,
743 AlgorithmField::Patience,
744 AlgorithmField::MinDelta,
745 ] {
746 assert!(
747 missing.iter().any(|m| m.field == field),
748 "expected {field:?} to be reported missing for {algorithm}"
749 );
750 }
751 }
752 }
753
754 #[test]
755 fn sac_td3_a2c_do_not_require_n_steps_or_n_epochs() {
756 for algorithm in ["sac", "td3", "a2c"] {
757 let mut fields = off_policy_fields_set();
758 fields.n_steps = None;
759 fields.n_epochs = None;
760 let missing = validate_algorithm_fields(algorithm, FlowType::Train, &fields);
761 assert!(!missing.iter().any(|m| m.field == AlgorithmField::NSteps));
762 assert!(!missing.iter().any(|m| m.field == AlgorithmField::NEpochs));
763 }
764 }
765
766 #[test]
767 fn act_passes_with_none_of_these_fields_set() {
768 assert!(validate_algorithm_fields("act", FlowType::Train, &AlgorithmFields::default()).is_empty());
769 }
770
771 #[test]
772 fn unknown_or_unset_algorithm_passes_with_none_of_these_fields_set() {
773 for algorithm in ["", "some-future-algorithm"] {
774 assert!(
775 validate_algorithm_fields(algorithm, FlowType::Train, &AlgorithmFields::default()).is_empty(),
776 "expected {algorithm:?} to pass with no fields set"
777 );
778 }
779 }
780 }
781}