Skip to main content

mecha10_job_validation/
lib.rs

1//! Shared validation for `mecha10 train`'s job catalog (`configs/rl/jobs/*.yml`) and its
2//! `task_name` references into the task catalog (`configs/rl/tasks/*/task.json`).
3//!
4//! This crate is the single source of truth for two separate questions, deliberately kept in
5//! two separate tables rather than one, the same way node configs separate "fields every node
6//! has" ([`crate::...`] - see `packages/core/src/node_config_base.rs` in the main workspace,
7//! `docs/adr/0001-node-config-base-schema.md`) from "fields specific to one node type":
8//!
9//! - "what's required/optional/not-applicable per `flow_type`" ([`JobField`]/[`validate_job`]) -
10//!   algorithm-agnostic, flow-shape concepts like `task_name`/`resume_run_id`/`tune_run_id`.
11//! - "what's required/optional/not-applicable per `algorithm`"
12//!   ([`AlgorithmField`]/[`validate_algorithm_fields`]) - per-algorithm hyperparameter concepts
13//!   like `total_timesteps`/`n_steps`, which vary mostly by which SB3 algorithm a job names
14//!   rather than which flow it runs (with one carve-out: `ppo`'s `n_steps` is relaxed for a
15//!   `resume-run`, which restores it from the checkpoint). See that section's own doc comment
16//!   for the full rationale.
17//!
18//! Before this crate existed, both questions were answered ad hoc, in multiple places (the
19//! CLI's job struct defaults, the CLI's dispatch-time `if` checks, and mecha10-api's
20//! `create_job` handler), which could and did drift out of sync with each other - and even
21//! after this crate existed, the per-algorithm question kept being answered with one-off
22//! `if algorithm == "ppo"` checks in the CLI rather than a table here, which is exactly the
23//! shape that silently broke when `act` was added (it has no `total_timesteps` field at all).
24//! The CLI (`packages/cli`) depends on this crate and calls
25//! [`validate_job`]/[`validate_algorithm_fields`]/[`task_exists`] instead of re-deriving these
26//! rules locally; `mecha10-api`'s `create_job` handler (`services/api`) is intended to do the
27//! same, but as of now does not depend on this crate and does not call any of them.
28//!
29//! Deliberately Python-free: `rl_task_runner.run` (the Python runtime) does not depend on this
30//! crate and does not re-validate - it trusts that a job it's asked to run already passed
31//! validation upstream (CLI local dispatch, or mecha10-api's `create_job` for remote dispatch).
32
33use std::path::Path;
34
35/// Which training pipeline flow a job runs as.
36#[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/// Whether a field is required, optional (sentinel/default-backed), or not applicable for a
60/// given `flow_type` - e.g. `resume_run_id` is `Required` for `ResumeRun`, `NotApplicable` for
61/// `Train`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FieldRequirement {
64    Required,
65    Optional,
66    NotApplicable,
67}
68
69/// The fields this crate has an opinion about. Deliberately not every `PPOConfig` hyperparameter
70/// (`learning_rate`, `gamma`, `clip_range`, ...) - those are legitimately optional/sentinel-
71/// defaulted for every `flow_type` today and aren't in dispute; this covers job identity
72/// (`task_name`/`algorithm`) and the fields that control how long a flow runs or which prior run
73/// it targets, which is where "required vs. optional vs. n/a" actually varies by `flow_type`.
74#[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    /// The wire/YAML field name a `CreateJobRequest`/job-definition struct is expected to carry
87    /// this field under - the single list a contract test in each consumer (the CLI's own
88    /// `CreateJobRequest`, `mecha10-api`'s `CreateJobRequest`) checks its own hand-typed struct
89    /// against, so a new variant here shows up as a failing test in both instead of silently
90    /// missing a field the way `Tune`'s `n_trials`/`tune_timesteps`/`eval_eps` did before either
91    /// request struct picked them up.
92    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    /// Requiredness of this field for `flow_type`, the single table the CLI reads instead of
105    /// re-deriving it; `mecha10-api` is intended to read it too, once it's wired up to depend on
106    /// this crate.
107    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/// The subset of a job definition's fields this crate validates. The CLI's `JobDefinition`
129/// (`packages/cli/src/services/train_jobs.rs`) and mecha10-api's `CreateJobRequest`
130/// (`services/api/src/jobs/types.rs`) each convert their own richer type into this one (via a
131/// small `From`/helper on their side) rather than this crate depending on either of them -
132/// this stays the shared validation vocabulary, not the wire/storage shape.
133#[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    /// A string field counts as absent when blank/whitespace-only (matches the CLI's existing
166    /// `.trim().is_empty()` checks for `algorithm`/`resume_run_id`); an int/float field counts
167    /// as absent when unset - `-1`/`-1.0` sentinels are a *value*, not absence, and are out of
168    /// scope for this presence check (a duration-controlling field left at its sentinel is a
169    /// job-authoring smell, not a validation error). Callers are expected to have already
170    /// dropped sentinel values (e.g. the CLI's `non_sentinel_i64`/`non_sentinel_f64`) before
171    /// handing a value to this type.
172    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/// One validation failure: a field that's `Required` for this job's `flow_type` but absent.
182#[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
208/// Every [`JobField`] this crate has an opinion about - the list a consumer's own contract
209/// test (asserting its hand-typed `CreateJobRequest` carries a matching field for each one
210/// that's applicable to a flow type it actually dispatches) iterates over.
211pub fn all_fields() -> &'static [JobField] {
212    ALL_FIELDS
213}
214
215/// Validate `fields` against `flow_type`'s requirement table. Returns every missing required
216/// field (not just the first), so a caller can report the whole list at once.
217pub 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// --- By-algorithm schema ---------------------------------------------------------------------
227//
228// [`JobField`]/[`validate_job`] above answer "what's required for this job's `flow_type`" - the
229// algorithm-agnostic, flow-shape questions. They deliberately don't answer "what does this
230// *algorithm* need" - `total_timesteps`/`learning_rate`/`n_steps`/`n_epochs`/`patience`/
231// `min_delta` aren't flow_type concepts, they're per-algorithm concepts: each SB3 algorithm's
232// own Python config dataclass in the separate `mecha10-rl` repo
233// (`rl-task-runner/rl_task_runner/algorithms/<name>.py`) declares its own subset of these with
234// no default, so a job that omits one for the algorithm it names crashes training at runtime
235// with a plain `TypeError` instead of failing validation up front. This table is this crate's
236// "custom schema" layer - the same idea as `NodeConfigBase`/`compose_node_schema`'s base/custom
237// split for node configs (`docs/adr/0001-node-config-base-schema.md`), except keyed by
238// algorithm name instead of node type, and composed generically by the caller (see
239// `JobDefinition::validate` in `packages/cli/src/services/train_jobs.rs`) instead of via a
240// one-off `if algorithm == "ppo"` check that risks silently missing the next new algorithm the
241// way `act` did.
242
243/// The per-algorithm hyperparameter fields this crate has an opinion about. See the module-level
244/// comment above for why these (and not every `PPOConfig` field) are the ones in dispute.
245#[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    /// The wire/YAML field name this field is expected to carry - mirrors
257    /// [`JobField::wire_name`].
258    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    /// Requiredness of this field for `algorithm` on `flow_type` - the single table a caller
270    /// reads instead of re-deriving it with its own `if algorithm == "..."` checks.
271    ///
272    /// Requiredness is keyed primarily by algorithm (`total_timesteps`/`learning_rate`/
273    /// `n_steps`/`n_epochs`/`patience`/`min_delta` are per-algorithm hyperparameter concepts,
274    /// not flow-shape ones), with one flow_type-specific carve-out: `n_steps` is `Optional`, not
275    /// `Required`, for a `ppo` `resume-run` job. A resume-run restores the rollout length from
276    /// the checkpoint it resumes, so that one field need not be re-specified - this preserves the
277    /// pre-existing hand-rolled behavior (`flow_type != ResumeRun && algorithm == "ppo" &&
278    /// n_steps missing`) that a one-off `if` used to encode in the CLI. Every *other* `ppo`
279    /// hyperparameter (`total_timesteps`/`learning_rate`/`n_epochs`/`patience`/`min_delta`)
280    /// stays `Required` even for a `ppo` resume-run - only `n_steps` had this carve-out.
281    ///
282    /// `ppo` is the only algorithm with an on-policy rollout (`n_steps`) and a separate PPO
283    /// epoch count (`n_epochs`), so those two are `NotApplicable` for every other algorithm.
284    /// `sac`/`td3`/`a2c` still require the remaining four (`a2c.py`'s `n_steps` has a default of
285    /// `5`, so it's not in dispute there either - only the fields with *no* Python default
286    /// belong in this table). `act` (imitation learning, no SB3 on-policy/off-policy config at
287    /// all) and any unrecognized/future algorithm name (including an unset/`None` algorithm,
288    /// which callers pass as `""`) resolve every field to `NotApplicable`, so an algorithm this
289    /// table doesn't know about yet fails open - no spurious "missing field" errors - rather
290    /// than silently inheriting `ppo`'s requirements.
291    ///
292    /// If `act`'s own no-default field (`dataset_path`) ever becomes a typed, validated
293    /// `JobDefinition` field, it gets its own `AlgorithmField` variant here, not another
294    /// one-off `if`.
295    pub fn requirement(self, algorithm: &str, flow_type: FlowType) -> FieldRequirement {
296        use AlgorithmField::*;
297        use FieldRequirement::*;
298
299        // A `ppo` resume-run reuses the rollout length (`n_steps`) baked into the checkpoint it
300        // resumes, so `n_steps` specifically is not required there - but every other `ppo`
301        // hyperparameter still is (handled by the `match` below).
302        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/// The subset of a job definition's per-algorithm hyperparameter fields this crate validates -
318/// the by-algorithm counterpart to [`JobFields`]. See [`AlgorithmField`]'s doc comment for why
319/// these particular fields.
320#[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/// One by-algorithm validation failure: a field that's `Required` for `algorithm` but absent -
344/// the by-algorithm counterpart to [`MissingField`].
345#[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
371/// Every [`AlgorithmField`] this crate has an opinion about - the by-algorithm counterpart to
372/// [`all_fields`].
373pub fn all_algorithm_fields() -> &'static [AlgorithmField] {
374    ALL_ALGORITHM_FIELDS
375}
376
377/// Validate `fields` against `algorithm`'s requirement table for `flow_type`. Returns every
378/// missing required field (not just the first), so a caller can report the whole list at once -
379/// the by-algorithm counterpart to [`validate_job`]. `flow_type` is needed because a few
380/// per-algorithm requirements are relaxed for specific flows (e.g. `ppo`'s `n_steps` is not
381/// required for a `resume-run`, which restores it from the checkpoint) - see
382/// [`AlgorithmField::requirement`].
383pub 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/// Error from [`task_exists`]: `task_name` has no corresponding `task.json` under the task
400/// catalog, or that file isn't valid JSON.
401#[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
424/// `configs/rl/tasks` relative to a project root - kept as a single literal here (rather than a
425/// dependency on `mecha10-cli-core`, to keep this crate's dependency footprint minimal for
426/// mecha10-api's sake) and covered by `RL_TASKS_DIR`'s own equality test in
427/// `mecha10-cli-core::paths::project`, so the two can't silently drift.
428pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
429
430/// Confirm `task_name` has a real `task.json` under `{project_root}/configs/rl/tasks/` and that
431/// it parses as JSON. This is the check that was previously only ever done, best-effort, deep in
432/// `rl_task_runner.run` at actual training time (and even there, on failure it only logged a
433/// warning and continued rather than failing) - moving it here means a bad `task_name` is caught
434/// at CLI/API accept-time instead of surfacing as unexplained training behavior much later.
435pub 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
447/// List every task name under `{project_root}/configs/rl/tasks/` that has a `task.json` - the
448/// catalog `mecha10 train`'s task picker prompts from after a job is selected. A job's own
449/// `configs/rl/jobs/*.yml` no longer pins `task_name` itself (that field is filled in at
450/// dispatch time from this picker instead); this is the list it prompts against. Sorted for a
451/// stable menu order; empty (not an error) when the directory doesn't exist.
452pub 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        // `total_timesteps` used to be required here too, but it's a per-algorithm concept
489        // (not every flow_type), so it's validated by `validate_algorithm_fields` now instead -
490        // see the `algorithm_field_tests` module below.
491        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            // A `ppo` resume-run restores the rollout length from the checkpoint it resumes, so
666            // `n_steps` specifically is the one field it may omit. Only `n_steps` is carved out -
667            // every other `ppo` hyperparameter is still required for a resume-run.
668            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            // ...but dropping total_timesteps (not carved out) must still fail, even for a
676            // resume-run.
677            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            // The carve-out is specific to resume-run: on every other flow_type a ppo job still
692            // requires n_steps.
693            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}