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        // None of the base job fields have a `patience`/`min_delta`-style meaningful-sentinel
147        // convention today - a `-1` on `n_trials`/`tune_timesteps`/`eval_eps` is always just a
148        // "not set" placeholder, never a deliberate value - so every int field here uses
149        // `NotSetPlaceholder`.
150        match field {
151            JobField::TaskName => FieldPresence::Str(self.task_name.as_deref()),
152            JobField::Algorithm => FieldPresence::Str(self.algorithm.as_deref()),
153            JobField::ResumeRunId => FieldPresence::Str(self.resume_run_id.as_deref()),
154            JobField::TuneRunId => FieldPresence::Str(self.tune_run_id.as_deref()),
155            JobField::NTrials => FieldPresence::Int(self.n_trials, SentinelConvention::NotSetPlaceholder),
156            JobField::TuneTimesteps => FieldPresence::Int(self.tune_timesteps, SentinelConvention::NotSetPlaceholder),
157            JobField::EvalEps => FieldPresence::Int(self.eval_eps, SentinelConvention::NotSetPlaceholder),
158        }
159    }
160}
161
162/// Whether a numeric field's `-1`/`-1.0` sentinel is a genuine, intended value (e.g. ppo's
163/// `patience`/`min_delta`, where `-1` means "no early-stopping limit" - a real hyperparameter
164/// choice the training run honors) or just a placeholder some job-authoring path uses to mean
165/// "this was never actually set" (every other numeric field this crate validates today, e.g.
166/// `total_timesteps`/`n_steps` - a job left at `-1` there is missing a real value, not expressing
167/// a deliberate choice).
168///
169/// This is what used to be answered outside this crate, ad hoc, by having every *caller* run
170/// every numeric field through the same blanket "drop `-1`/`-1.0`" helper
171/// (`non_sentinel_i64`/`non_sentinel_f64` in `packages/cli/src/services/train_jobs.rs`) before
172/// validation ever saw the value - which is exactly how a real `patience: -1`/`min_delta: -1.0`
173/// (the scaffolded `train.yml` template's own values, see `mecha10-templates`'
174/// `rl_bundle.rs::FIXTURE_TRAIN`) got reported as "missing" instead of "set to a value that means
175/// disabled": the blanket helper can't tell `patience`'s `-1` (meaningful) apart from
176/// `total_timesteps`'s `-1` (placeholder) because it never knew which field it was stripping.
177/// Making that distinction part of each field's own schema entry here - not a strip applied
178/// before validation runs - is what lets validation tell them apart. See PR #1639 (attempted this
179/// as a pre-validation strip, special-cased to just `patience`/`min_delta`) and its revert PR
180/// #1641 (determined this needed real schema design, not a quick patch).
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum SentinelConvention {
183    /// `-1`/`-1.0` is a legitimate, meaningful value for this field - presence is simply whether
184    /// the field was set at all (`Option::is_some()`); the crate does not second-guess the value
185    /// itself.
186    MeaningfulValue,
187    /// `-1`/`-1.0` has no meaning for this field; it's only ever a "not set" placeholder, so a
188    /// field left at that value counts as absent for requiredness purposes, same as `None`.
189    NotSetPlaceholder,
190}
191
192/// The `-1` placeholder [`SentinelConvention::NotSetPlaceholder`] int fields use for "not set".
193const SENTINEL_I64: i64 = -1;
194/// The `-1.0` placeholder [`SentinelConvention::NotSetPlaceholder`] float fields use for "not
195/// set".
196const SENTINEL_F64: f64 = -1.0;
197
198enum FieldPresence<'a> {
199    Str(Option<&'a str>),
200    Int(Option<i64>, SentinelConvention),
201    Float(Option<f64>, SentinelConvention),
202}
203
204impl FieldPresence<'_> {
205    /// A string field counts as absent when blank/whitespace-only (matches the CLI's existing
206    /// `.trim().is_empty()` checks for `algorithm`/`resume_run_id`). An int/float field counts as
207    /// absent when unset, or - for a [`SentinelConvention::NotSetPlaceholder`] field only - when
208    /// set to that field's `-1`/`-1.0` placeholder; a [`SentinelConvention::MeaningfulValue`]
209    /// field (e.g. ppo's `patience`/`min_delta`) counts as present at `-1`/`-1.0` too, since that
210    /// value is a deliberate choice, not an unset marker.
211    fn is_present(&self) -> bool {
212        match self {
213            FieldPresence::Str(s) => s.is_some_and(|s| !s.trim().is_empty()),
214            FieldPresence::Int(i, convention) => match convention {
215                SentinelConvention::MeaningfulValue => i.is_some(),
216                SentinelConvention::NotSetPlaceholder => i.is_some_and(|v| v != SENTINEL_I64),
217            },
218            FieldPresence::Float(f, convention) => match convention {
219                SentinelConvention::MeaningfulValue => f.is_some(),
220                SentinelConvention::NotSetPlaceholder => f.is_some_and(|v| v != SENTINEL_F64),
221            },
222        }
223    }
224}
225
226/// One validation failure: a field that's `Required` for this job's `flow_type` but absent.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct MissingField {
229    pub field: JobField,
230    pub flow_type: FlowType,
231}
232
233impl std::fmt::Display for MissingField {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        write!(
236            f,
237            "'{:?}' is required for flow_type '{}' but was not set",
238            self.field, self.flow_type
239        )
240    }
241}
242
243const ALL_FIELDS: &[JobField] = &[
244    JobField::TaskName,
245    JobField::Algorithm,
246    JobField::ResumeRunId,
247    JobField::TuneRunId,
248    JobField::NTrials,
249    JobField::TuneTimesteps,
250    JobField::EvalEps,
251];
252
253/// Every [`JobField`] this crate has an opinion about - the list a consumer's own contract
254/// test (asserting its hand-typed `CreateJobRequest` carries a matching field for each one
255/// that's applicable to a flow type it actually dispatches) iterates over.
256pub fn all_fields() -> &'static [JobField] {
257    ALL_FIELDS
258}
259
260/// Validate `fields` against `flow_type`'s requirement table. Returns every missing required
261/// field (not just the first), so a caller can report the whole list at once.
262pub fn validate_job(flow_type: FlowType, fields: &JobFields) -> Vec<MissingField> {
263    ALL_FIELDS
264        .iter()
265        .filter(|&&field| field.requirement(flow_type) == FieldRequirement::Required)
266        .filter(|&&field| !fields.get(field).is_present())
267        .map(|&field| MissingField { field, flow_type })
268        .collect()
269}
270
271// --- By-algorithm schema ---------------------------------------------------------------------
272//
273// [`JobField`]/[`validate_job`] above answer "what's required for this job's `flow_type`" - the
274// algorithm-agnostic, flow-shape questions. They deliberately don't answer "what does this
275// *algorithm* need" - `total_timesteps`/`learning_rate`/`n_steps`/`n_epochs`/`patience`/
276// `min_delta` aren't flow_type concepts, they're per-algorithm concepts: each SB3 algorithm's
277// own Python config dataclass in the separate `mecha10-rl` repo
278// (`rl-task-runner/rl_task_runner/algorithms/<name>.py`) declares its own subset of these with
279// no default, so a job that omits one for the algorithm it names crashes training at runtime
280// with a plain `TypeError` instead of failing validation up front. This table is this crate's
281// "custom schema" layer - the same idea as `NodeConfigBase`/`compose_node_schema`'s base/custom
282// split for node configs (`docs/adr/0001-node-config-base-schema.md`), except keyed by
283// algorithm name instead of node type, and composed generically by the caller (see
284// `JobDefinition::validate` in `packages/cli/src/services/train_jobs.rs`) instead of via a
285// one-off `if algorithm == "ppo"` check that risks silently missing the next new algorithm the
286// way `act` did.
287
288/// The per-algorithm hyperparameter fields this crate has an opinion about. See the module-level
289/// comment above for why these (and not every `PPOConfig` field) are the ones in dispute.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum AlgorithmField {
292    TotalTimesteps,
293    LearningRate,
294    NSteps,
295    NEpochs,
296    Patience,
297    MinDelta,
298}
299
300impl AlgorithmField {
301    /// The wire/YAML field name this field is expected to carry - mirrors
302    /// [`JobField::wire_name`].
303    pub fn wire_name(self) -> &'static str {
304        match self {
305            AlgorithmField::TotalTimesteps => "total_timesteps",
306            AlgorithmField::LearningRate => "learning_rate",
307            AlgorithmField::NSteps => "n_steps",
308            AlgorithmField::NEpochs => "n_epochs",
309            AlgorithmField::Patience => "patience",
310            AlgorithmField::MinDelta => "min_delta",
311        }
312    }
313
314    /// Requiredness of this field for `algorithm` on `flow_type` - the single table a caller
315    /// reads instead of re-deriving it with its own `if algorithm == "..."` checks.
316    ///
317    /// Requiredness is keyed primarily by algorithm (`total_timesteps`/`learning_rate`/
318    /// `n_steps`/`n_epochs`/`patience`/`min_delta` are per-algorithm hyperparameter concepts,
319    /// not flow-shape ones), with one flow_type-specific carve-out: `n_steps` is `Optional`, not
320    /// `Required`, for a `ppo` `resume-run` job. A resume-run restores the rollout length from
321    /// the checkpoint it resumes, so that one field need not be re-specified - this preserves the
322    /// pre-existing hand-rolled behavior (`flow_type != ResumeRun && algorithm == "ppo" &&
323    /// n_steps missing`) that a one-off `if` used to encode in the CLI. Every *other* `ppo`
324    /// hyperparameter (`total_timesteps`/`learning_rate`/`n_epochs`/`patience`/`min_delta`)
325    /// stays `Required` even for a `ppo` resume-run - only `n_steps` had this carve-out.
326    ///
327    /// `ppo` is the only algorithm with an on-policy rollout (`n_steps`) and a separate PPO
328    /// epoch count (`n_epochs`), so those two are `NotApplicable` for every other algorithm.
329    /// `sac`/`td3`/`a2c` still require the remaining four (`a2c.py`'s `n_steps` has a default of
330    /// `5`, so it's not in dispute there either - only the fields with *no* Python default
331    /// belong in this table). `act` (imitation learning, no SB3 on-policy/off-policy config at
332    /// all) and any unrecognized/future algorithm name (including an unset/`None` algorithm,
333    /// which callers pass as `""`) resolve every field to `NotApplicable`, so an algorithm this
334    /// table doesn't know about yet fails open - no spurious "missing field" errors - rather
335    /// than silently inheriting `ppo`'s requirements.
336    ///
337    /// If `act`'s own no-default field (`dataset_path`) ever becomes a typed, validated
338    /// `JobDefinition` field, it gets its own `AlgorithmField` variant here, not another
339    /// one-off `if`.
340    pub fn requirement(self, algorithm: &str, flow_type: FlowType) -> FieldRequirement {
341        use AlgorithmField::*;
342        use FieldRequirement::*;
343
344        // A `ppo` resume-run reuses the rollout length (`n_steps`) baked into the checkpoint it
345        // resumes, so `n_steps` specifically is not required there - but every other `ppo`
346        // hyperparameter still is (handled by the `match` below).
347        if self == NSteps && algorithm == "ppo" && flow_type == FlowType::ResumeRun {
348            return Optional;
349        }
350
351        match algorithm {
352            "ppo" => Required,
353            "sac" | "td3" | "a2c" => match self {
354                TotalTimesteps | LearningRate | Patience | MinDelta => Required,
355                NSteps | NEpochs => NotApplicable,
356            },
357            _ => NotApplicable,
358        }
359    }
360
361    /// This field's [`SentinelConvention`] - whether `-1`/`-1.0` is a real, intended value for
362    /// this field or just a "not set" placeholder. See [`SentinelConvention`]'s doc comment for
363    /// the bug this distinction exists to fix.
364    ///
365    /// Only `patience`/`min_delta` carry a deliberate "disabled" meaning at `-1`/`-1.0` - that's
366    /// an early-stopping concept every algorithm that has these fields (`ppo`/`sac`/`td3`/`a2c`,
367    /// per [`Self::requirement`]) shares, so this is a per-field distinction rather than a
368    /// per-(field, algorithm) one today. If a future algorithm needs a different convention for
369    /// one of these fields, this is where that split would go - taking an `algorithm: &str`
370    /// parameter the same way [`Self::requirement`] does.
371    pub fn sentinel_convention(self) -> SentinelConvention {
372        match self {
373            AlgorithmField::Patience | AlgorithmField::MinDelta => SentinelConvention::MeaningfulValue,
374            AlgorithmField::TotalTimesteps
375            | AlgorithmField::LearningRate
376            | AlgorithmField::NSteps
377            | AlgorithmField::NEpochs => SentinelConvention::NotSetPlaceholder,
378        }
379    }
380}
381
382/// The subset of a job definition's per-algorithm hyperparameter fields this crate validates -
383/// the by-algorithm counterpart to [`JobFields`]. See [`AlgorithmField`]'s doc comment for why
384/// these particular fields.
385#[derive(Debug, Clone, Default)]
386pub struct AlgorithmFields {
387    pub total_timesteps: Option<i64>,
388    pub learning_rate: Option<f64>,
389    pub n_steps: Option<i64>,
390    pub n_epochs: Option<i64>,
391    pub patience: Option<i64>,
392    pub min_delta: Option<f64>,
393}
394
395impl AlgorithmFields {
396    /// Builds this field's [`FieldPresence`] directly from the raw value this struct was given -
397    /// callers (the CLI's `JobDefinition::validation_fields_for_algorithm`) pass the job's value
398    /// through untouched, no pre-stripping. [`AlgorithmField::sentinel_convention`] is what tells
399    /// [`FieldPresence::is_present`] whether a `-1`/`-1.0` on this particular field counts as set.
400    fn get(&self, field: AlgorithmField) -> FieldPresence<'_> {
401        let convention = field.sentinel_convention();
402        match field {
403            AlgorithmField::TotalTimesteps => FieldPresence::Int(self.total_timesteps, convention),
404            AlgorithmField::LearningRate => FieldPresence::Float(self.learning_rate, convention),
405            AlgorithmField::NSteps => FieldPresence::Int(self.n_steps, convention),
406            AlgorithmField::NEpochs => FieldPresence::Int(self.n_epochs, convention),
407            AlgorithmField::Patience => FieldPresence::Int(self.patience, convention),
408            AlgorithmField::MinDelta => FieldPresence::Float(self.min_delta, convention),
409        }
410    }
411}
412
413/// One by-algorithm validation failure: a field that's `Required` for `algorithm` but absent -
414/// the by-algorithm counterpart to [`MissingField`].
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct MissingAlgorithmField {
417    pub field: AlgorithmField,
418    pub algorithm: String,
419}
420
421impl std::fmt::Display for MissingAlgorithmField {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        write!(
424            f,
425            "'{}' is required for algorithm '{}' but was not set",
426            self.field.wire_name(),
427            self.algorithm
428        )
429    }
430}
431
432const ALL_ALGORITHM_FIELDS: &[AlgorithmField] = &[
433    AlgorithmField::TotalTimesteps,
434    AlgorithmField::LearningRate,
435    AlgorithmField::NSteps,
436    AlgorithmField::NEpochs,
437    AlgorithmField::Patience,
438    AlgorithmField::MinDelta,
439];
440
441/// Every [`AlgorithmField`] this crate has an opinion about - the by-algorithm counterpart to
442/// [`all_fields`].
443pub fn all_algorithm_fields() -> &'static [AlgorithmField] {
444    ALL_ALGORITHM_FIELDS
445}
446
447/// Validate `fields` against `algorithm`'s requirement table for `flow_type`. Returns every
448/// missing required field (not just the first), so a caller can report the whole list at once -
449/// the by-algorithm counterpart to [`validate_job`]. `flow_type` is needed because a few
450/// per-algorithm requirements are relaxed for specific flows (e.g. `ppo`'s `n_steps` is not
451/// required for a `resume-run`, which restores it from the checkpoint) - see
452/// [`AlgorithmField::requirement`].
453pub fn validate_algorithm_fields(
454    algorithm: &str,
455    flow_type: FlowType,
456    fields: &AlgorithmFields,
457) -> Vec<MissingAlgorithmField> {
458    ALL_ALGORITHM_FIELDS
459        .iter()
460        .filter(|&&field| field.requirement(algorithm, flow_type) == FieldRequirement::Required)
461        .filter(|&&field| !fields.get(field).is_present())
462        .map(|&field| MissingAlgorithmField {
463            field,
464            algorithm: algorithm.to_string(),
465        })
466        .collect()
467}
468
469/// Error from [`task_exists`]: `task_name` has no corresponding `task.json` under the task
470/// catalog, or that file isn't valid JSON.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub enum TaskLookupError {
473    NotFound { task_name: String },
474    InvalidJson { task_name: String, message: String },
475}
476
477impl std::fmt::Display for TaskLookupError {
478    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479        match self {
480            TaskLookupError::NotFound { task_name } => write!(
481                f,
482                "task '{task_name}' has no configs/rl/tasks/{task_name}/task.json - check the \
483                 task_name against the project's task catalog"
484            ),
485            TaskLookupError::InvalidJson { task_name, message } => {
486                write!(f, "configs/rl/tasks/{task_name}/task.json is not valid JSON: {message}")
487            }
488        }
489    }
490}
491
492impl std::error::Error for TaskLookupError {}
493
494/// `configs/rl/tasks` relative to a project root - kept as a single literal here (rather than a
495/// dependency on `mecha10-cli-core`, to keep this crate's dependency footprint minimal for
496/// mecha10-api's sake) and covered by `RL_TASKS_DIR`'s own equality test in
497/// `mecha10-cli-core::paths::project`, so the two can't silently drift.
498pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
499
500/// Confirm `task_name` has a real `task.json` under `{project_root}/configs/rl/tasks/` and that
501/// it parses as JSON. This is the check that was previously only ever done, best-effort, deep in
502/// `rl_task_runner.run` at actual training time (and even there, on failure it only logged a
503/// warning and continued rather than failing) - moving it here means a bad `task_name` is caught
504/// at CLI/API accept-time instead of surfacing as unexplained training behavior much later.
505pub fn task_exists(project_root: &Path, task_name: &str) -> Result<(), TaskLookupError> {
506    let path = project_root.join(RL_TASKS_DIR).join(task_name).join("task.json");
507    let content = std::fs::read_to_string(&path).map_err(|_| TaskLookupError::NotFound {
508        task_name: task_name.to_string(),
509    })?;
510    serde_json::from_str::<serde_json::Value>(&content).map_err(|e| TaskLookupError::InvalidJson {
511        task_name: task_name.to_string(),
512        message: e.to_string(),
513    })?;
514    Ok(())
515}
516
517/// List every task name under `{project_root}/configs/rl/tasks/` that has a `task.json` - the
518/// catalog `mecha10 train`'s task picker prompts from after a job is selected. A job's own
519/// `configs/rl/jobs/*.yml` no longer pins `task_name` itself (that field is filled in at
520/// dispatch time from this picker instead); this is the list it prompts against. Sorted for a
521/// stable menu order; empty (not an error) when the directory doesn't exist.
522pub fn list_tasks(project_root: &Path) -> std::io::Result<Vec<String>> {
523    let dir = project_root.join(RL_TASKS_DIR);
524    if !dir.exists() {
525        return Ok(Vec::new());
526    }
527
528    let mut names: Vec<String> = std::fs::read_dir(&dir)?
529        .filter_map(|entry| entry.ok())
530        .filter(|entry| entry.path().join("task.json").is_file())
531        .filter_map(|entry| entry.file_name().into_string().ok())
532        .collect();
533    names.sort();
534    Ok(names)
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn every_job_field_has_a_unique_wire_name() {
543        let names: Vec<&str> = ALL_FIELDS.iter().map(|f| f.wire_name()).collect();
544        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
545        assert_eq!(names.len(), unique.len());
546    }
547
548    fn fields_with_task_and_algo() -> JobFields {
549        JobFields {
550            task_name: Some("find_target".to_string()),
551            algorithm: Some("ppo".to_string()),
552            ..Default::default()
553        }
554    }
555
556    #[test]
557    fn train_requires_only_task_name_and_algorithm() {
558        // `total_timesteps` used to be required here too, but it's a per-algorithm concept
559        // (not every flow_type), so it's validated by `validate_algorithm_fields` now instead -
560        // see the `algorithm_field_tests` module below.
561        let fields = fields_with_task_and_algo();
562        assert!(validate_job(FlowType::Train, &fields).is_empty());
563    }
564
565    #[test]
566    fn tune_requires_tune_specific_fields() {
567        let fields = fields_with_task_and_algo();
568        let missing = validate_job(FlowType::Tune, &fields);
569        assert_eq!(missing.len(), 3);
570        assert!(missing.iter().all(|m| m.flow_type == FlowType::Tune));
571    }
572
573    #[test]
574    fn tune_passes_with_tune_specific_fields_set() {
575        let mut fields = fields_with_task_and_algo();
576        fields.n_trials = Some(10);
577        fields.tune_timesteps = Some(1000);
578        fields.eval_eps = Some(2);
579        assert!(validate_job(FlowType::Tune, &fields).is_empty());
580    }
581
582    #[test]
583    fn resume_run_requires_resume_run_id() {
584        let fields = fields_with_task_and_algo();
585        let missing = validate_job(FlowType::ResumeRun, &fields);
586        assert_eq!(
587            missing,
588            vec![MissingField {
589                field: JobField::ResumeRunId,
590                flow_type: FlowType::ResumeRun
591            }]
592        );
593    }
594
595    #[test]
596    fn train_from_tune_requires_tune_run_id() {
597        let fields = fields_with_task_and_algo();
598        let missing = validate_job(FlowType::TrainFromTune, &fields);
599        assert_eq!(
600            missing,
601            vec![MissingField {
602                field: JobField::TuneRunId,
603                flow_type: FlowType::TrainFromTune
604            }]
605        );
606    }
607
608    #[test]
609    fn missing_task_name_and_algorithm_reported_for_every_flow_type() {
610        let missing = validate_job(FlowType::Train, &JobFields::default());
611        assert!(missing.iter().any(|m| m.field == JobField::TaskName));
612        assert!(missing.iter().any(|m| m.field == JobField::Algorithm));
613    }
614
615    #[test]
616    fn blank_string_fields_count_as_absent() {
617        let mut fields = fields_with_task_and_algo();
618        fields.resume_run_id = Some("   ".to_string());
619        let missing = validate_job(FlowType::ResumeRun, &fields);
620        assert!(missing.iter().any(|m| m.field == JobField::ResumeRunId));
621    }
622
623    #[test]
624    fn task_exists_finds_bundled_find_target_task() {
625        let dir = tempfile::tempdir().unwrap();
626        let task_dir = dir.path().join("configs/rl/tasks/find_target");
627        std::fs::create_dir_all(&task_dir).unwrap();
628        std::fs::write(task_dir.join("task.json"), r#"{"env_name": "basic_arena"}"#).unwrap();
629        assert!(task_exists(dir.path(), "find_target").is_ok());
630    }
631
632    #[test]
633    fn task_exists_reports_missing_task() {
634        let dir = tempfile::tempdir().unwrap();
635        std::fs::create_dir_all(dir.path().join("configs/rl/tasks")).unwrap();
636        let err = task_exists(dir.path(), "nonexistent").unwrap_err();
637        assert_eq!(
638            err,
639            TaskLookupError::NotFound {
640                task_name: "nonexistent".to_string()
641            }
642        );
643    }
644
645    #[test]
646    fn list_tasks_returns_empty_when_directory_missing() {
647        let dir = tempfile::tempdir().unwrap();
648        assert_eq!(list_tasks(dir.path()).unwrap(), Vec::<String>::new());
649    }
650
651    #[test]
652    fn list_tasks_lists_directories_with_a_task_json_sorted() {
653        let dir = tempfile::tempdir().unwrap();
654        for name in ["task2", "find_target", "task1"] {
655            let task_dir = dir.path().join("configs/rl/tasks").join(name);
656            std::fs::create_dir_all(&task_dir).unwrap();
657            std::fs::write(task_dir.join("task.json"), "{}").unwrap();
658        }
659        std::fs::create_dir_all(dir.path().join("configs/rl/tasks/not-a-task")).unwrap();
660
661        assert_eq!(
662            list_tasks(dir.path()).unwrap(),
663            vec!["find_target".to_string(), "task1".to_string(), "task2".to_string()]
664        );
665    }
666
667    #[test]
668    fn task_exists_reports_invalid_json() {
669        let dir = tempfile::tempdir().unwrap();
670        let task_dir = dir.path().join("configs/rl/tasks/broken");
671        std::fs::create_dir_all(&task_dir).unwrap();
672        std::fs::write(task_dir.join("task.json"), "not json").unwrap();
673        let err = task_exists(dir.path(), "broken").unwrap_err();
674        assert!(matches!(err, TaskLookupError::InvalidJson { .. }));
675    }
676
677    mod algorithm_field_tests {
678        use super::*;
679
680        #[test]
681        fn every_algorithm_field_has_a_unique_wire_name() {
682            let names: Vec<&str> = ALL_ALGORITHM_FIELDS.iter().map(|f| f.wire_name()).collect();
683            let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
684            assert_eq!(names.len(), unique.len());
685        }
686
687        fn all_ppo_fields_set() -> AlgorithmFields {
688            AlgorithmFields {
689                total_timesteps: Some(1_000_000),
690                learning_rate: Some(0.0003),
691                n_steps: Some(2048),
692                n_epochs: Some(10),
693                patience: Some(10),
694                min_delta: Some(0.01),
695            }
696        }
697
698        #[test]
699        fn ppo_passes_with_all_six_fields_set() {
700            assert!(validate_algorithm_fields("ppo", FlowType::Train, &all_ppo_fields_set()).is_empty());
701        }
702
703        /// Regression test for the bug this `SentinelConvention` split exists to fix: a `ppo`
704        /// job with `patience: -1` / `min_delta: -1.0` - exactly what the scaffolded `train.yml`
705        /// template sets (see `mecha10-templates`' `rl_bundle.rs::FIXTURE_TRAIN`) to mean "no
706        /// early-stopping limit" - must validate as *set*, not get reported as missing. Before
707        /// this fix, every caller stripped `-1`/`-1.0` off every numeric field (including these
708        /// two) before this crate ever saw the value, so a correctly-configured ppo job failed
709        /// validation. See PR #1639/#1641 for the reverted ad hoc attempt at this fix.
710        #[test]
711        fn ppo_passes_with_patience_and_min_delta_at_their_disabled_sentinel_value() {
712            let fields = AlgorithmFields {
713                patience: Some(-1),
714                min_delta: Some(-1.0),
715                ..all_ppo_fields_set()
716            };
717            assert!(
718                validate_algorithm_fields("ppo", FlowType::Train, &fields).is_empty(),
719                "patience: -1 / min_delta: -1.0 are meaningful 'disabled' values for ppo, not absence"
720            );
721        }
722
723        /// The flip side of the regression above: fields with no sentinel convention
724        /// (`total_timesteps` here) must still treat `-1` as absent, the same as actually unset -
725        /// this guards against over-generalizing "sentinels are fine everywhere" when fixing the
726        /// `patience`/`min_delta` bug.
727        #[test]
728        fn ppo_still_rejects_total_timesteps_set_to_the_placeholder_sentinel_value() {
729            let fields = AlgorithmFields {
730                total_timesteps: Some(-1),
731                ..all_ppo_fields_set()
732            };
733            let missing = validate_algorithm_fields("ppo", FlowType::Train, &fields);
734            assert_eq!(
735                missing,
736                vec![MissingAlgorithmField {
737                    field: AlgorithmField::TotalTimesteps,
738                    algorithm: "ppo".to_string()
739                }],
740                "total_timesteps: -1 has no meaningful-sentinel convention, so it must still count as absent"
741            );
742        }
743
744        #[test]
745        fn ppo_requires_each_field_individually() {
746            for field in ALL_ALGORITHM_FIELDS {
747                let mut fields = all_ppo_fields_set();
748                match field {
749                    AlgorithmField::TotalTimesteps => fields.total_timesteps = None,
750                    AlgorithmField::LearningRate => fields.learning_rate = None,
751                    AlgorithmField::NSteps => fields.n_steps = None,
752                    AlgorithmField::NEpochs => fields.n_epochs = None,
753                    AlgorithmField::Patience => fields.patience = None,
754                    AlgorithmField::MinDelta => fields.min_delta = None,
755                }
756                let missing = validate_algorithm_fields("ppo", FlowType::Train, &fields);
757                assert_eq!(
758                    missing,
759                    vec![MissingAlgorithmField {
760                        field: *field,
761                        algorithm: "ppo".to_string()
762                    }],
763                    "expected only {field:?} to be reported missing"
764                );
765            }
766        }
767
768        #[test]
769        fn ppo_reports_every_missing_field_when_all_are_absent() {
770            let missing = validate_algorithm_fields("ppo", FlowType::Train, &AlgorithmFields::default());
771            assert_eq!(missing.len(), ALL_ALGORITHM_FIELDS.len());
772        }
773
774        #[test]
775        fn ppo_resume_run_does_not_require_n_steps_but_still_requires_everything_else() {
776            // A `ppo` resume-run restores the rollout length from the checkpoint it resumes, so
777            // `n_steps` specifically is the one field it may omit. Only `n_steps` is carved out -
778            // every other `ppo` hyperparameter is still required for a resume-run.
779            let mut fields = all_ppo_fields_set();
780            fields.n_steps = None;
781            assert!(
782                validate_algorithm_fields("ppo", FlowType::ResumeRun, &fields).is_empty(),
783                "a ppo resume-run without n_steps should pass"
784            );
785
786            // ...but dropping total_timesteps (not carved out) must still fail, even for a
787            // resume-run.
788            fields.total_timesteps = None;
789            let missing = validate_algorithm_fields("ppo", FlowType::ResumeRun, &fields);
790            assert_eq!(
791                missing,
792                vec![MissingAlgorithmField {
793                    field: AlgorithmField::TotalTimesteps,
794                    algorithm: "ppo".to_string()
795                }],
796                "a ppo resume-run without total_timesteps should still fail (and n_steps stays exempt)"
797            );
798        }
799
800        #[test]
801        fn ppo_n_steps_carve_out_is_resume_run_only() {
802            // The carve-out is specific to resume-run: on every other flow_type a ppo job still
803            // requires n_steps.
804            for flow_type in [
805                FlowType::Train,
806                FlowType::Tune,
807                FlowType::TuneThenTrain,
808                FlowType::TrainFromTune,
809            ] {
810                let mut fields = all_ppo_fields_set();
811                fields.n_steps = None;
812                let missing = validate_algorithm_fields("ppo", flow_type, &fields);
813                assert_eq!(
814                    missing,
815                    vec![MissingAlgorithmField {
816                        field: AlgorithmField::NSteps,
817                        algorithm: "ppo".to_string()
818                    }],
819                    "expected n_steps to be required for ppo on {flow_type}"
820                );
821            }
822        }
823
824        fn off_policy_fields_set() -> AlgorithmFields {
825            AlgorithmFields {
826                total_timesteps: Some(1_000_000),
827                learning_rate: Some(0.0003),
828                n_steps: None,
829                n_epochs: None,
830                patience: Some(10),
831                min_delta: Some(0.01),
832            }
833        }
834
835        #[test]
836        fn sac_td3_a2c_pass_with_their_four_fields_set_and_no_n_steps_or_n_epochs() {
837            for algorithm in ["sac", "td3", "a2c"] {
838                assert!(
839                    validate_algorithm_fields(algorithm, FlowType::Train, &off_policy_fields_set()).is_empty(),
840                    "expected {algorithm} to pass"
841                );
842            }
843        }
844
845        #[test]
846        fn sac_td3_a2c_require_their_four_fields() {
847            for algorithm in ["sac", "td3", "a2c"] {
848                let missing = validate_algorithm_fields(algorithm, FlowType::Train, &AlgorithmFields::default());
849                assert_eq!(missing.len(), 4, "expected 4 missing fields for {algorithm}");
850                assert!(missing.iter().all(|m| m.algorithm == algorithm));
851                for field in [
852                    AlgorithmField::TotalTimesteps,
853                    AlgorithmField::LearningRate,
854                    AlgorithmField::Patience,
855                    AlgorithmField::MinDelta,
856                ] {
857                    assert!(
858                        missing.iter().any(|m| m.field == field),
859                        "expected {field:?} to be reported missing for {algorithm}"
860                    );
861                }
862            }
863        }
864
865        #[test]
866        fn sac_td3_a2c_do_not_require_n_steps_or_n_epochs() {
867            for algorithm in ["sac", "td3", "a2c"] {
868                let mut fields = off_policy_fields_set();
869                fields.n_steps = None;
870                fields.n_epochs = None;
871                let missing = validate_algorithm_fields(algorithm, FlowType::Train, &fields);
872                assert!(!missing.iter().any(|m| m.field == AlgorithmField::NSteps));
873                assert!(!missing.iter().any(|m| m.field == AlgorithmField::NEpochs));
874            }
875        }
876
877        #[test]
878        fn act_passes_with_none_of_these_fields_set() {
879            assert!(validate_algorithm_fields("act", FlowType::Train, &AlgorithmFields::default()).is_empty());
880        }
881
882        #[test]
883        fn unknown_or_unset_algorithm_passes_with_none_of_these_fields_set() {
884            for algorithm in ["", "some-future-algorithm"] {
885                assert!(
886                    validate_algorithm_fields(algorithm, FlowType::Train, &AlgorithmFields::default()).is_empty(),
887                    "expected {algorithm:?} to pass with no fields set"
888                );
889            }
890        }
891    }
892}