1use anyhow::{bail, Context, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10pub const JOB_CATALOG_DIR: &str = "configs/rl/jobs";
11
12pub use mecha10_job_validation::FlowType;
13
14#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
16pub struct SearchSpaceEntry {
17 pub name: String,
18 #[serde(rename = "type")]
19 pub type_: SearchSpaceType,
20 #[serde(default)]
21 pub low: Option<f64>,
22 #[serde(default)]
23 pub high: Option<f64>,
24 #[serde(default)]
25 pub log: Option<bool>,
26 #[serde(default)]
27 pub choices: Option<Vec<serde_json::Value>>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
33#[serde(rename_all = "lowercase")]
34pub enum SearchSpaceType {
35 Float,
36 Int,
37 Categorical,
38}
39
40#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
42#[allow(dead_code)]
43pub struct JobDefinition {
44 pub name: String,
45 #[serde(default)]
46 pub description: Option<String>,
47 pub flow_type: FlowType,
48 #[serde(default)]
51 #[schemars(example = Some("warehouse_nav".to_string()))]
52 pub env_name: Option<String>,
53 #[serde(default)]
55 #[schemars(example = Some("patrol_curriculum".to_string()))]
56 pub task_name: Option<String>,
57 #[serde(default)]
59 #[schemars(example = Some("ppo".to_string()))]
60 pub algorithm: Option<String>,
61
62 #[serde(default)]
65 #[schemars(example = Some("MlpPolicy".to_string()))]
66 #[allow(dead_code, reason = "schema/deserialize-only, see doc comment above")]
67 pub policy: Option<String>,
68 #[serde(default)]
69 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
70 pub seed: Option<i64>,
71 #[serde(default)]
72 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
73 pub environment_map: Option<String>,
74 #[serde(default)]
75 pub total_timesteps: Option<i64>,
76 #[serde(default)]
77 pub learning_rate: Option<f64>,
78 #[serde(default)]
79 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
80 pub linear_lr_schedule: Option<bool>,
81 #[serde(default)]
82 pub n_steps: Option<i64>,
83 #[serde(default)]
84 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
85 pub batch_size: Option<i64>,
86 #[serde(default)]
87 pub n_epochs: Option<i64>,
88 #[serde(default)]
89 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
90 pub gamma: Option<f64>,
91 #[serde(default)]
92 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
93 pub gae_lambda: Option<f64>,
94 #[serde(default)]
95 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
96 pub clip_range: Option<f64>,
97 #[serde(default)]
98 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
99 pub ent_coef: Option<f64>,
100 #[serde(default)]
101 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
102 pub vf_coef: Option<f64>,
103 #[serde(default)]
104 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
105 pub max_grad_norm: Option<f64>,
106 #[serde(default)]
108 #[schemars(example = Some(10))]
109 pub patience: Option<i64>,
110 #[serde(default)]
111 pub min_delta: Option<f64>,
112 #[serde(default)]
114 #[schemars(example = Some(8))]
115 pub n_envs: Option<i64>,
116
117 #[serde(default)]
118 pub n_trials: Option<i64>,
119 #[serde(default)]
120 pub tune_timesteps: Option<i64>,
121 #[serde(default)]
122 pub eval_eps: Option<i64>,
123 #[serde(default)]
126 pub search_space: Option<Vec<SearchSpaceEntry>>,
127
128 #[serde(skip)]
129 #[schemars(skip)]
130 pub source_path: PathBuf,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct CreateJobRequest {
135 pub name: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub description: Option<String>,
138 pub algorithm: String,
139 pub robot_id: String,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub project_id: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub project_name: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub env_name: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub task_name: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub total_timesteps: Option<i64>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub n_steps: Option<i64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub n_epochs: Option<i64>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub learning_rate: Option<f64>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub patience: Option<i64>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub min_delta: Option<f64>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub n_envs: Option<i64>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub config_yaml: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub resume_run_id: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub n_trials: Option<i64>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub tune_timesteps: Option<i64>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub eval_eps: Option<i64>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub search_space: Option<Vec<SearchSpaceEntry>>,
174}
175
176fn env_name_from_task_json(project_root: &Path, task_name: &str) -> Option<String> {
179 task_json_field(project_root, task_name, "env_name")
180}
181
182fn task_json_field(project_root: &Path, task_name: &str, field: &str) -> Option<String> {
183 let path = project_root
184 .join(mecha10_cli_core::paths::project::RL_TASKS_DIR)
185 .join(task_name)
186 .join("task.json");
187 let content = std::fs::read_to_string(path).ok()?;
188 let value: serde_json::Value = serde_json::from_str(&content).ok()?;
189 value.get(field)?.as_str().map(str::to_string)
190}
191
192fn non_sentinel<T: PartialEq>(value: Option<T>, sentinel: T) -> Option<T> {
210 value.filter(|v| *v != sentinel)
211}
212
213fn non_sentinel_i64(value: Option<i64>) -> Option<i64> {
214 non_sentinel(value, -1)
215}
216
217fn non_sentinel_f64(value: Option<f64>) -> Option<f64> {
218 non_sentinel(value, -1.0)
219}
220
221impl JobDefinition {
222 fn validation_fields(&self, resume_run_id: Option<&str>) -> mecha10_job_validation::JobFields {
229 mecha10_job_validation::JobFields {
230 task_name: self.task_name.clone(),
231 algorithm: self.algorithm.clone(),
232 resume_run_id: resume_run_id.map(str::to_string),
233 tune_run_id: None,
234 n_trials: self.n_trials,
235 tune_timesteps: self.tune_timesteps,
236 eval_eps: self.eval_eps,
237 }
238 }
239
240 fn validation_fields_for_algorithm(&self) -> mecha10_job_validation::AlgorithmFields {
248 mecha10_job_validation::AlgorithmFields {
249 total_timesteps: self.total_timesteps,
250 learning_rate: self.learning_rate,
251 n_steps: self.n_steps,
252 n_epochs: self.n_epochs,
253 patience: self.patience,
254 min_delta: self.min_delta,
255 }
256 }
257
258 pub fn validate(&self, project_root: &Path, resume_run_id: Option<&str>) -> Result<()> {
266 let missing_basic =
267 mecha10_job_validation::validate_job(self.flow_type, &self.validation_fields(resume_run_id));
268 let missing_algorithm = mecha10_job_validation::validate_algorithm_fields(
269 self.algorithm.as_deref().unwrap_or_default(),
270 self.flow_type,
271 &self.validation_fields_for_algorithm(),
272 );
273
274 if !missing_basic.is_empty() || !missing_algorithm.is_empty() {
275 let details = missing_basic
276 .iter()
277 .map(|m| m.to_string())
278 .chain(missing_algorithm.iter().map(|m| m.to_string()))
279 .collect::<Vec<_>>()
280 .join("; ");
281 bail!("job '{}' is missing required fields: {details}", self.name);
282 }
283
284 if let Some(task_name) = self.task_name.as_deref() {
285 mecha10_job_validation::task_exists(project_root, task_name)
286 .with_context(|| format!("job '{}' has an invalid task_name", self.name))?;
287 }
288
289 Ok(())
290 }
291
292 pub fn resolve_env_name(&self, project_root: &Path) -> Option<String> {
295 self.env_name.clone().or_else(|| {
296 self.task_name
297 .as_deref()
298 .and_then(|task_name| env_name_from_task_json(project_root, task_name))
299 })
300 }
301
302 pub fn supports_remote(&self) -> bool {
305 matches!(self.flow_type, FlowType::Train | FlowType::ResumeRun | FlowType::Tune)
306 }
307
308 pub fn to_create_job_request(
311 &self,
312 project_root: &Path,
313 robot_id: &str,
314 project_name: &str,
315 resume_run_id: Option<&str>,
316 ) -> Result<CreateJobRequest> {
317 let config_yaml = std::fs::read_to_string(&self.source_path).with_context(|| {
318 format!(
319 "failed to read {} - this job definition's own file must exist before dispatching remotely",
320 self.source_path.display()
321 )
322 })?;
323
324 Ok(CreateJobRequest {
325 name: self.name.clone(),
326 description: self.description.clone(),
327 algorithm: self.algorithm.clone().unwrap_or_default(),
328 robot_id: robot_id.to_string(),
329 project_id: Some(robot_id.to_string()),
330 project_name: Some(project_name.to_string()),
331 env_name: self.resolve_env_name(project_root),
332 task_name: self.task_name.clone(),
333 total_timesteps: non_sentinel_i64(self.total_timesteps),
334 n_steps: non_sentinel_i64(self.n_steps),
335 n_epochs: non_sentinel_i64(self.n_epochs),
336 learning_rate: non_sentinel_f64(self.learning_rate),
337 patience: non_sentinel_i64(self.patience),
338 min_delta: non_sentinel_f64(self.min_delta),
339 n_envs: non_sentinel_i64(self.n_envs),
340 config_yaml: Some(config_yaml),
341 resume_run_id: resume_run_id
342 .map(str::trim)
343 .filter(|id| !id.is_empty())
344 .map(str::to_string),
345 n_trials: non_sentinel_i64(self.n_trials),
346 tune_timesteps: non_sentinel_i64(self.tune_timesteps),
347 eval_eps: non_sentinel_i64(self.eval_eps),
348 search_space: self.search_space.clone(),
349 })
350 }
351}
352
353pub fn load_job_catalog(project_root: &Path) -> Result<Vec<JobDefinition>> {
358 let jobs_dir = project_root.join(JOB_CATALOG_DIR);
359 if !jobs_dir.exists() {
360 return Ok(Vec::new());
361 }
362
363 let mut paths: Vec<PathBuf> = std::fs::read_dir(&jobs_dir)
364 .with_context(|| format!("failed to read {}", jobs_dir.display()))?
365 .filter_map(|entry| entry.ok())
366 .map(|entry| entry.path())
367 .filter(|path| {
368 path.extension()
369 .and_then(|ext| ext.to_str())
370 .is_some_and(|ext| ext == "yml" || ext == "yaml")
371 })
372 .collect();
373 paths.sort();
374
375 let mut jobs = Vec::with_capacity(paths.len());
376 for path in paths {
377 let content = std::fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
378 let mut job: JobDefinition = serde_yaml::from_str(&content)
379 .with_context(|| format!("failed to parse job definition {}", path.display()))?;
380 job.source_path = path;
381 jobs.push(job);
382 }
383
384 Ok(jobs)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use tempfile::TempDir;
391
392 fn write_job(dir: &Path, file_name: &str, contents: &str) {
393 std::fs::write(dir.join(file_name), contents).unwrap();
394 }
395
396 fn write_task_json(project_root: &Path, task_name: &str, contents: &str) {
397 let task_dir = project_root
398 .join(mecha10_cli_core::paths::project::RL_TASKS_DIR)
399 .join(task_name);
400 std::fs::create_dir_all(&task_dir).unwrap();
401 std::fs::write(task_dir.join("task.json"), contents).unwrap();
402 }
403
404 #[test]
405 fn resolve_env_name_prefers_an_explicit_env_name_over_the_tasks_own() {
406 let dir = TempDir::new().unwrap();
407 write_task_json(dir.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
408
409 let job = JobDefinition {
410 env_name: Some("rover_arena".to_string()),
411 task_name: Some("find_target".to_string()),
412 ..base_job()
413 };
414 assert_eq!(job.resolve_env_name(dir.path()), Some("rover_arena".to_string()));
415 }
416
417 #[test]
418 fn resolve_env_name_falls_back_to_the_tasks_own_task_json() {
419 let dir = TempDir::new().unwrap();
420 write_task_json(
421 dir.path(),
422 "find_target",
423 r#"{"env_name": "basic_arena", "model": "humanoid"}"#,
424 );
425
426 let job = JobDefinition {
427 task_name: Some("find_target".to_string()),
428 ..base_job()
429 };
430 assert_eq!(job.resolve_env_name(dir.path()), Some("basic_arena".to_string()));
431 }
432
433 #[test]
434 fn resolve_env_name_is_none_when_task_has_no_env_name_field() {
435 let dir = TempDir::new().unwrap();
436 write_task_json(dir.path(), "find_target", r#"{"model": "humanoid"}"#);
437
438 let job = JobDefinition {
439 task_name: Some("find_target".to_string()),
440 ..base_job()
441 };
442 assert_eq!(job.resolve_env_name(dir.path()), None);
443 }
444
445 #[test]
446 fn resolve_env_name_is_none_without_an_env_name_or_a_task_name() {
447 let dir = TempDir::new().unwrap();
448 let job = base_job();
449 assert_eq!(job.resolve_env_name(dir.path()), None);
450 }
451
452 fn base_job() -> JobDefinition {
455 JobDefinition {
456 name: "job".to_string(),
457 description: None,
458 flow_type: FlowType::Train,
459 env_name: None,
460 task_name: None,
461 algorithm: Some("ppo".to_string()),
462 policy: None,
463 seed: None,
464 environment_map: None,
465 total_timesteps: None,
466 learning_rate: None,
467 linear_lr_schedule: None,
468 n_steps: None,
469 batch_size: None,
470 n_epochs: None,
471 gamma: None,
472 gae_lambda: None,
473 clip_range: None,
474 ent_coef: None,
475 vf_coef: None,
476 max_grad_norm: None,
477 patience: None,
478 min_delta: None,
479 n_envs: None,
480 n_trials: None,
481 tune_timesteps: None,
482 eval_eps: None,
483 search_space: None,
484 source_path: PathBuf::new(),
485 }
486 }
487
488 fn ppo_job_with_hyperparams_set() -> JobDefinition {
492 JobDefinition {
493 task_name: Some("find_target".to_string()),
494 algorithm: Some("ppo".to_string()),
495 total_timesteps: Some(1000),
496 learning_rate: Some(0.0003),
497 n_steps: Some(2048),
498 n_epochs: Some(10),
499 patience: Some(10),
500 min_delta: Some(0.01),
501 ..base_job()
502 }
503 }
504
505 #[test]
506 fn validate_does_not_require_any_hyperparameter_fields_for_a_non_ppo_algorithm() {
507 let temp = TempDir::new().unwrap();
508 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
509
510 let job = JobDefinition {
511 task_name: Some("find_target".to_string()),
512 algorithm: Some("act".to_string()),
513 total_timesteps: Some(1000),
514 n_steps: None,
515 ..base_job()
516 };
517
518 assert!(job.validate(temp.path(), None).is_ok());
519 }
520
521 #[test]
527 fn validate_does_not_require_total_timesteps_for_an_act_train_job() {
528 let temp = TempDir::new().unwrap();
529 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
530
531 let job = JobDefinition {
532 flow_type: FlowType::Train,
533 task_name: Some("find_target".to_string()),
534 algorithm: Some("act".to_string()),
535 total_timesteps: None,
536 n_steps: None,
537 ..base_job()
538 };
539
540 assert!(job.validate(temp.path(), None).is_ok());
541 }
542
543 #[test]
544 fn validate_still_requires_n_steps_for_ppo() {
545 let temp = TempDir::new().unwrap();
546 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
547
548 let job = JobDefinition {
549 n_steps: None,
550 ..ppo_job_with_hyperparams_set()
551 };
552
553 let err = job.validate(temp.path(), None).unwrap_err();
554 assert!(
555 err.to_string().contains("n_steps"),
556 "expected error to mention n_steps, got: {err}"
557 );
558 }
559
560 #[test]
561 fn validate_passes_for_ppo_with_every_required_hyperparameter_set() {
562 let temp = TempDir::new().unwrap();
563 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
564
565 let job = ppo_job_with_hyperparams_set();
566
567 assert!(job.validate(temp.path(), None).is_ok());
568 }
569
570 #[test]
577 fn validate_passes_for_ppo_with_patience_and_min_delta_at_their_disabled_sentinel_value() {
578 let temp = TempDir::new().unwrap();
579 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
580
581 let job = JobDefinition {
582 patience: Some(-1),
583 min_delta: Some(-1.0),
584 ..ppo_job_with_hyperparams_set()
585 };
586
587 assert!(
588 job.validate(temp.path(), None).is_ok(),
589 "a ppo job with patience: -1 / min_delta: -1.0 (the scaffolded train.yml template's \
590 own values) must validate - those are meaningful 'disabled' values, not absence"
591 );
592 }
593
594 #[test]
599 fn validate_still_rejects_total_timesteps_set_to_the_placeholder_sentinel_value() {
600 let temp = TempDir::new().unwrap();
601 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
602
603 let job = JobDefinition {
604 total_timesteps: Some(-1),
605 ..ppo_job_with_hyperparams_set()
606 };
607
608 let err = job.validate(temp.path(), None).unwrap_err();
609 assert!(
610 err.to_string().contains("total_timesteps"),
611 "expected error to mention total_timesteps, got: {err}"
612 );
613 }
614
615 #[test]
621 fn validate_passes_for_act_with_patience_and_min_delta_set_to_the_sentinel_value() {
622 let temp = TempDir::new().unwrap();
623 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
624
625 let job = JobDefinition {
626 flow_type: FlowType::Train,
627 task_name: Some("find_target".to_string()),
628 algorithm: Some("act".to_string()),
629 total_timesteps: Some(1000),
630 patience: Some(-1),
631 min_delta: Some(-1.0),
632 ..base_job()
633 };
634
635 assert!(job.validate(temp.path(), None).is_ok());
636 }
637
638 #[test]
646 fn validate_does_not_require_n_steps_for_a_ppo_resume_run() {
647 let temp = TempDir::new().unwrap();
648 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
649
650 let without_n_steps = JobDefinition {
651 flow_type: FlowType::ResumeRun,
652 n_steps: None,
653 ..ppo_job_with_hyperparams_set()
654 };
655 assert!(
656 without_n_steps.validate(temp.path(), Some("run-123")).is_ok(),
657 "a ppo resume-run job without n_steps must still validate"
658 );
659 }
660
661 #[test]
664 fn validate_still_requires_total_timesteps_for_a_ppo_resume_run() {
665 let temp = TempDir::new().unwrap();
666 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
667
668 let missing_total_timesteps = JobDefinition {
669 flow_type: FlowType::ResumeRun,
670 n_steps: None,
671 total_timesteps: None,
672 ..ppo_job_with_hyperparams_set()
673 };
674 let err = missing_total_timesteps
675 .validate(temp.path(), Some("run-123"))
676 .unwrap_err();
677 assert!(
678 err.to_string().contains("total_timesteps"),
679 "expected error to mention total_timesteps, got: {err}"
680 );
681 assert!(
682 !err.to_string().contains("n_steps"),
683 "n_steps should remain exempt for a ppo resume-run, got: {err}"
684 );
685 }
686
687 #[test]
688 fn load_job_catalog_returns_empty_when_directory_missing() {
689 let temp = TempDir::new().unwrap();
690 let jobs = load_job_catalog(temp.path()).unwrap();
691 assert!(jobs.is_empty());
692 }
693
694 #[test]
695 fn load_job_catalog_parses_yaml_files_sorted_by_name() {
696 let temp = TempDir::new().unwrap();
697 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
698 std::fs::create_dir_all(&jobs_dir).unwrap();
699
700 write_job(
701 &jobs_dir,
702 "train.yml",
703 "name: train\nflow_type: train\nalgorithm: ppo\ntotal_timesteps: 1000000\n",
704 );
705 write_job(
706 &jobs_dir,
707 "hypertune.yml",
708 "name: hypertune\nflow_type: tune\nalgorithm: ppo\nn_trials: 10\n",
709 );
710
711 let jobs = load_job_catalog(temp.path()).unwrap();
712
713 assert_eq!(jobs.len(), 2);
714 assert_eq!(jobs[0].name, "hypertune");
715 assert_eq!(jobs[0].flow_type, FlowType::Tune);
716 assert_eq!(jobs[1].name, "train");
717 assert_eq!(jobs[1].flow_type, FlowType::Train);
718 assert_eq!(jobs[1].total_timesteps, Some(1000000));
719 }
720
721 #[test]
722 fn load_job_catalog_ignores_non_yaml_files() {
723 let temp = TempDir::new().unwrap();
724 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
725 std::fs::create_dir_all(&jobs_dir).unwrap();
726
727 write_job(&jobs_dir, "README.md", "not a job");
728 write_job(
729 &jobs_dir,
730 "train.yml",
731 "name: train\nflow_type: train\nalgorithm: ppo\n",
732 );
733
734 let jobs = load_job_catalog(temp.path()).unwrap();
735 assert_eq!(jobs.len(), 1);
736 }
737
738 #[test]
739 fn supports_remote_for_train_resume_run_and_tune() {
740 let base = base_job();
741 assert!(base.supports_remote());
742
743 let resume = JobDefinition {
744 flow_type: FlowType::ResumeRun,
745 ..base.clone()
746 };
747 assert!(resume.supports_remote());
748
749 let tune = JobDefinition {
750 flow_type: FlowType::Tune,
751 ..base.clone()
752 };
753 assert!(tune.supports_remote());
754
755 let tune_then_train = JobDefinition {
756 flow_type: FlowType::TuneThenTrain,
757 ..base.clone()
758 };
759 assert!(!tune_then_train.supports_remote());
760
761 let train_from_tune = JobDefinition {
762 flow_type: FlowType::TrainFromTune,
763 ..base
764 };
765 assert!(!train_from_tune.supports_remote());
766 }
767
768 #[test]
769 fn to_create_job_request_forwards_resume_run_id() {
770 let temp = TempDir::new().unwrap();
771 let jobs_dir = temp.path().join("configs/rl/jobs");
772 std::fs::create_dir_all(&jobs_dir).unwrap();
773 let source_path = jobs_dir.join("resume-train.yml");
774 std::fs::write(&source_path, "name: resume-train\nflow_type: resume-run\n").unwrap();
775
776 let job = JobDefinition {
777 name: "resume-train".to_string(),
778 flow_type: FlowType::ResumeRun,
779 source_path,
780 ..base_job()
781 };
782
783 let req = job
784 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" run-abc123 "))
785 .unwrap();
786
787 assert_eq!(req.resume_run_id.as_deref(), Some("run-abc123"));
788 }
789
790 #[test]
791 fn to_create_job_request_omits_empty_resume_run_id() {
792 let temp = TempDir::new().unwrap();
793 let jobs_dir = temp.path().join("configs/rl/jobs");
794 std::fs::create_dir_all(&jobs_dir).unwrap();
795 let source_path = jobs_dir.join("resume-train.yml");
796 std::fs::write(&source_path, "name: resume-train\nflow_type: resume-run\n").unwrap();
797
798 let job = JobDefinition {
799 name: "resume-train".to_string(),
800 flow_type: FlowType::ResumeRun,
801 source_path,
802 ..base_job()
803 };
804
805 let req = job
806 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" "))
807 .unwrap();
808
809 assert_eq!(req.resume_run_id, None);
810 }
811
812 #[test]
815 fn create_job_request_has_a_field_for_every_dispatchable_job_field() {
816 let dispatchable = [FlowType::Train, FlowType::ResumeRun, FlowType::Tune];
817
818 let req = CreateJobRequest {
819 name: "job".to_string(),
820 description: None,
821 algorithm: "ppo".to_string(),
822 robot_id: "robot-1".to_string(),
823 project_id: None,
824 project_name: None,
825 env_name: None,
826 task_name: Some("find_target".to_string()),
827 total_timesteps: Some(1_000_000),
828 n_steps: None,
829 n_epochs: None,
830 learning_rate: None,
831 patience: None,
832 min_delta: None,
833 n_envs: None,
834 config_yaml: None,
835 resume_run_id: Some("run-123".to_string()),
836 n_trials: Some(10),
837 tune_timesteps: Some(500_000),
838 eval_eps: Some(5),
839 search_space: None,
840 };
841 let json = serde_json::to_value(&req).expect("CreateJobRequest serializes");
842
843 for field in mecha10_job_validation::all_fields() {
844 let applicable = dispatchable
845 .iter()
846 .any(|&ft| field.requirement(ft) != mecha10_job_validation::FieldRequirement::NotApplicable);
847 if applicable {
848 assert!(
849 json.get(field.wire_name()).is_some(),
850 "CreateJobRequest has no '{}' field for {field:?}, which is applicable to a \
851 dispatchable flow type",
852 field.wire_name()
853 );
854 }
855 }
856 }
857
858 #[test]
859 fn to_create_job_request_forwards_tune_fields() {
860 let temp = TempDir::new().unwrap();
861 let jobs_dir = temp.path().join("configs/rl/jobs");
862 std::fs::create_dir_all(&jobs_dir).unwrap();
863 let source_path = jobs_dir.join("hypertune.yml");
864 std::fs::write(&source_path, "name: hypertune\nflow_type: tune\n").unwrap();
865
866 let job = JobDefinition {
867 name: "hypertune".to_string(),
868 flow_type: FlowType::Tune,
869 n_trials: Some(20),
870 tune_timesteps: Some(250_000),
871 eval_eps: Some(3),
872 source_path,
873 ..base_job()
874 };
875
876 let req = job
877 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
878 .unwrap();
879
880 assert_eq!(req.n_trials, Some(20));
881 assert_eq!(req.tune_timesteps, Some(250_000));
882 assert_eq!(req.eval_eps, Some(3));
883 }
884
885 #[test]
886 fn to_create_job_request_forwards_n_envs() {
887 let temp = TempDir::new().unwrap();
888 let jobs_dir = temp.path().join("configs/rl/jobs");
889 std::fs::create_dir_all(&jobs_dir).unwrap();
890 let source_path = jobs_dir.join("train.yml");
891 std::fs::write(&source_path, "name: train\nflow_type: train\nn_envs: 8\n").unwrap();
892
893 let job = JobDefinition {
894 name: "train".to_string(),
895 n_envs: Some(8),
896 source_path,
897 ..base_job()
898 };
899
900 let req = job
901 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
902 .unwrap();
903
904 assert_eq!(req.n_envs, Some(8));
905 }
906
907 #[test]
908 fn to_create_job_request_forwards_search_space() {
909 let temp = TempDir::new().unwrap();
910 let jobs_dir = temp.path().join("configs/rl/jobs");
911 std::fs::create_dir_all(&jobs_dir).unwrap();
912 let source_path = jobs_dir.join("hypertune.yml");
913 let yaml = "name: hypertune\nflow_type: tune\nsearch_space:\n - name: learning_rate\n \
914 type: float\n low: 1e-5\n high: 1e-3\n log: true\n - name: n_steps\n \
915 type: categorical\n choices: [1024, 2048, 4096]\n - name: n_epochs\n \
916 type: int\n low: 5\n high: 20\n";
917 std::fs::write(&source_path, yaml).unwrap();
918 let job: JobDefinition = serde_yaml::from_str(yaml).unwrap();
919 let job = JobDefinition { source_path, ..job };
920
921 let req = job
922 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
923 .unwrap();
924
925 let search_space = req.search_space.expect("search_space forwarded");
926 assert_eq!(search_space.len(), 3);
927 assert_eq!(search_space[0].name, "learning_rate");
928 assert_eq!(search_space[0].type_, SearchSpaceType::Float);
929 assert_eq!(search_space[0].low, Some(1e-5));
930 assert_eq!(search_space[0].high, Some(1e-3));
931 assert_eq!(search_space[0].log, Some(true));
932 assert_eq!(search_space[1].name, "n_steps");
933 assert_eq!(search_space[1].type_, SearchSpaceType::Categorical);
934 assert_eq!(
935 search_space[1].choices,
936 Some(vec![
937 serde_json::json!(1024),
938 serde_json::json!(2048),
939 serde_json::json!(4096)
940 ])
941 );
942 assert_eq!(search_space[2].name, "n_epochs");
943 assert_eq!(search_space[2].type_, SearchSpaceType::Int);
944 assert_eq!(search_space[2].low, Some(5.0));
945 assert_eq!(search_space[2].high, Some(20.0));
946 }
947
948 #[test]
949 fn to_create_job_request_omits_search_space_when_unset() {
950 let temp = TempDir::new().unwrap();
951 let jobs_dir = temp.path().join("configs/rl/jobs");
952 std::fs::create_dir_all(&jobs_dir).unwrap();
953 let source_path = jobs_dir.join("train.yml");
954 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
955
956 let job = JobDefinition {
957 name: "train".to_string(),
958 source_path,
959 ..base_job()
960 };
961
962 let req = job
963 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
964 .unwrap();
965
966 assert_eq!(req.search_space, None);
967 }
968
969 #[test]
970 fn to_create_job_request_drops_sentinel_values() {
971 let temp = TempDir::new().unwrap();
972 let jobs_dir = temp.path().join("configs/rl/jobs");
973 std::fs::create_dir_all(&jobs_dir).unwrap();
974 let source_path = jobs_dir.join("train.yml");
975 std::fs::write(&source_path, "name: train\nflow_type: train\nenv_name: rover\n").unwrap();
976
977 let job = JobDefinition {
978 name: "train".to_string(),
979 description: Some("desc".to_string()),
980 env_name: Some("rover".to_string()),
981 total_timesteps: Some(-1),
982 n_steps: Some(2048),
983 n_epochs: Some(-1),
984 learning_rate: Some(-1.0),
985 patience: Some(50),
986 min_delta: Some(-1.0),
987 n_envs: Some(-1),
988 source_path,
989 ..base_job()
990 };
991
992 let req = job
993 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
994 .unwrap();
995
996 assert_eq!(req.name, "train");
997 assert_eq!(req.algorithm, "ppo");
998 assert_eq!(req.robot_id, "my-project");
999 assert_eq!(req.project_id.as_deref(), Some("my-project"));
1000 assert_eq!(req.project_name.as_deref(), Some("my-robot"));
1001 assert_eq!(req.total_timesteps, None);
1002 assert_eq!(req.n_steps, Some(2048));
1003 assert_eq!(req.n_epochs, None);
1004 assert_eq!(req.learning_rate, None);
1005 assert_eq!(req.patience, Some(50));
1006 assert_eq!(req.min_delta, None);
1007 assert_eq!(req.n_envs, None);
1008 assert_eq!(
1009 req.config_yaml.as_deref(),
1010 Some("name: train\nflow_type: train\nenv_name: rover\n")
1011 );
1012 assert_eq!(req.task_name, None);
1013 }
1014
1015 #[test]
1016 fn to_create_job_request_forwards_task_name() {
1017 let temp = TempDir::new().unwrap();
1018 let jobs_dir = temp.path().join("configs/rl/jobs");
1019 std::fs::create_dir_all(&jobs_dir).unwrap();
1020 let source_path = jobs_dir.join("train.yml");
1021 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
1022
1023 let job = JobDefinition {
1024 name: "train".to_string(),
1025 task_name: Some("navigate".to_string()),
1026 source_path,
1027 ..base_job()
1028 };
1029
1030 let req = job
1031 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
1032 .unwrap();
1033
1034 assert_eq!(req.task_name.as_deref(), Some("navigate"));
1035 }
1036
1037 #[test]
1038 fn to_create_job_request_errors_when_the_jobs_own_file_is_missing() {
1039 let temp = TempDir::new().unwrap();
1040 let job = JobDefinition {
1041 name: "train".to_string(),
1042 source_path: temp.path().join("configs/rl/jobs/train.yml"),
1043 ..base_job()
1044 };
1045
1046 let err = job
1047 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
1048 .unwrap_err();
1049 assert!(err.to_string().contains("configs/rl/jobs/train.yml"));
1050 }
1051}