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}