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> {
195 value.filter(|v| *v != sentinel)
196}
197
198fn non_sentinel_i64(value: Option<i64>) -> Option<i64> {
199 non_sentinel(value, -1)
200}
201
202fn non_sentinel_f64(value: Option<f64>) -> Option<f64> {
203 non_sentinel(value, -1.0)
204}
205
206impl JobDefinition {
207 fn validation_fields(&self, resume_run_id: Option<&str>) -> mecha10_job_validation::JobFields {
210 mecha10_job_validation::JobFields {
211 task_name: self.task_name.clone(),
212 algorithm: self.algorithm.clone(),
213 resume_run_id: resume_run_id.map(str::to_string),
214 tune_run_id: None,
215 n_trials: non_sentinel_i64(self.n_trials),
216 tune_timesteps: non_sentinel_i64(self.tune_timesteps),
217 eval_eps: non_sentinel_i64(self.eval_eps),
218 }
219 }
220
221 fn validation_fields_for_algorithm(&self) -> mecha10_job_validation::AlgorithmFields {
225 mecha10_job_validation::AlgorithmFields {
226 total_timesteps: non_sentinel_i64(self.total_timesteps),
227 learning_rate: non_sentinel_f64(self.learning_rate),
228 n_steps: non_sentinel_i64(self.n_steps),
229 n_epochs: non_sentinel_i64(self.n_epochs),
230 patience: non_sentinel_i64(self.patience),
231 min_delta: non_sentinel_f64(self.min_delta),
232 }
233 }
234
235 pub fn validate(&self, project_root: &Path, resume_run_id: Option<&str>) -> Result<()> {
243 let missing_basic =
244 mecha10_job_validation::validate_job(self.flow_type, &self.validation_fields(resume_run_id));
245 let missing_algorithm = mecha10_job_validation::validate_algorithm_fields(
246 self.algorithm.as_deref().unwrap_or_default(),
247 self.flow_type,
248 &self.validation_fields_for_algorithm(),
249 );
250
251 if !missing_basic.is_empty() || !missing_algorithm.is_empty() {
252 let details = missing_basic
253 .iter()
254 .map(|m| m.to_string())
255 .chain(missing_algorithm.iter().map(|m| m.to_string()))
256 .collect::<Vec<_>>()
257 .join("; ");
258 bail!("job '{}' is missing required fields: {details}", self.name);
259 }
260
261 if let Some(task_name) = self.task_name.as_deref() {
262 mecha10_job_validation::task_exists(project_root, task_name)
263 .with_context(|| format!("job '{}' has an invalid task_name", self.name))?;
264 }
265
266 Ok(())
267 }
268
269 pub fn resolve_env_name(&self, project_root: &Path) -> Option<String> {
272 self.env_name.clone().or_else(|| {
273 self.task_name
274 .as_deref()
275 .and_then(|task_name| env_name_from_task_json(project_root, task_name))
276 })
277 }
278
279 pub fn supports_remote(&self) -> bool {
282 matches!(self.flow_type, FlowType::Train | FlowType::ResumeRun | FlowType::Tune)
283 }
284
285 pub fn to_create_job_request(
288 &self,
289 project_root: &Path,
290 robot_id: &str,
291 project_name: &str,
292 resume_run_id: Option<&str>,
293 ) -> Result<CreateJobRequest> {
294 let config_yaml = std::fs::read_to_string(&self.source_path).with_context(|| {
295 format!(
296 "failed to read {} - this job definition's own file must exist before dispatching remotely",
297 self.source_path.display()
298 )
299 })?;
300
301 Ok(CreateJobRequest {
302 name: self.name.clone(),
303 description: self.description.clone(),
304 algorithm: self.algorithm.clone().unwrap_or_default(),
305 robot_id: robot_id.to_string(),
306 project_id: Some(robot_id.to_string()),
307 project_name: Some(project_name.to_string()),
308 env_name: self.resolve_env_name(project_root),
309 task_name: self.task_name.clone(),
310 total_timesteps: non_sentinel_i64(self.total_timesteps),
311 n_steps: non_sentinel_i64(self.n_steps),
312 n_epochs: non_sentinel_i64(self.n_epochs),
313 learning_rate: non_sentinel_f64(self.learning_rate),
314 patience: non_sentinel_i64(self.patience),
315 min_delta: non_sentinel_f64(self.min_delta),
316 n_envs: non_sentinel_i64(self.n_envs),
317 config_yaml: Some(config_yaml),
318 resume_run_id: resume_run_id
319 .map(str::trim)
320 .filter(|id| !id.is_empty())
321 .map(str::to_string),
322 n_trials: non_sentinel_i64(self.n_trials),
323 tune_timesteps: non_sentinel_i64(self.tune_timesteps),
324 eval_eps: non_sentinel_i64(self.eval_eps),
325 search_space: self.search_space.clone(),
326 })
327 }
328}
329
330pub fn load_job_catalog(project_root: &Path) -> Result<Vec<JobDefinition>> {
335 let jobs_dir = project_root.join(JOB_CATALOG_DIR);
336 if !jobs_dir.exists() {
337 return Ok(Vec::new());
338 }
339
340 let mut paths: Vec<PathBuf> = std::fs::read_dir(&jobs_dir)
341 .with_context(|| format!("failed to read {}", jobs_dir.display()))?
342 .filter_map(|entry| entry.ok())
343 .map(|entry| entry.path())
344 .filter(|path| {
345 path.extension()
346 .and_then(|ext| ext.to_str())
347 .is_some_and(|ext| ext == "yml" || ext == "yaml")
348 })
349 .collect();
350 paths.sort();
351
352 let mut jobs = Vec::with_capacity(paths.len());
353 for path in paths {
354 let content = std::fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
355 let mut job: JobDefinition = serde_yaml::from_str(&content)
356 .with_context(|| format!("failed to parse job definition {}", path.display()))?;
357 job.source_path = path;
358 jobs.push(job);
359 }
360
361 Ok(jobs)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use tempfile::TempDir;
368
369 fn write_job(dir: &Path, file_name: &str, contents: &str) {
370 std::fs::write(dir.join(file_name), contents).unwrap();
371 }
372
373 fn write_task_json(project_root: &Path, task_name: &str, contents: &str) {
374 let task_dir = project_root
375 .join(mecha10_cli_core::paths::project::RL_TASKS_DIR)
376 .join(task_name);
377 std::fs::create_dir_all(&task_dir).unwrap();
378 std::fs::write(task_dir.join("task.json"), contents).unwrap();
379 }
380
381 #[test]
382 fn resolve_env_name_prefers_an_explicit_env_name_over_the_tasks_own() {
383 let dir = TempDir::new().unwrap();
384 write_task_json(dir.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
385
386 let job = JobDefinition {
387 env_name: Some("rover_arena".to_string()),
388 task_name: Some("find_target".to_string()),
389 ..base_job()
390 };
391 assert_eq!(job.resolve_env_name(dir.path()), Some("rover_arena".to_string()));
392 }
393
394 #[test]
395 fn resolve_env_name_falls_back_to_the_tasks_own_task_json() {
396 let dir = TempDir::new().unwrap();
397 write_task_json(
398 dir.path(),
399 "find_target",
400 r#"{"env_name": "basic_arena", "model": "humanoid"}"#,
401 );
402
403 let job = JobDefinition {
404 task_name: Some("find_target".to_string()),
405 ..base_job()
406 };
407 assert_eq!(job.resolve_env_name(dir.path()), Some("basic_arena".to_string()));
408 }
409
410 #[test]
411 fn resolve_env_name_is_none_when_task_has_no_env_name_field() {
412 let dir = TempDir::new().unwrap();
413 write_task_json(dir.path(), "find_target", r#"{"model": "humanoid"}"#);
414
415 let job = JobDefinition {
416 task_name: Some("find_target".to_string()),
417 ..base_job()
418 };
419 assert_eq!(job.resolve_env_name(dir.path()), None);
420 }
421
422 #[test]
423 fn resolve_env_name_is_none_without_an_env_name_or_a_task_name() {
424 let dir = TempDir::new().unwrap();
425 let job = base_job();
426 assert_eq!(job.resolve_env_name(dir.path()), None);
427 }
428
429 fn base_job() -> JobDefinition {
432 JobDefinition {
433 name: "job".to_string(),
434 description: None,
435 flow_type: FlowType::Train,
436 env_name: None,
437 task_name: None,
438 algorithm: Some("ppo".to_string()),
439 policy: None,
440 seed: None,
441 environment_map: None,
442 total_timesteps: None,
443 learning_rate: None,
444 linear_lr_schedule: None,
445 n_steps: None,
446 batch_size: None,
447 n_epochs: None,
448 gamma: None,
449 gae_lambda: None,
450 clip_range: None,
451 ent_coef: None,
452 vf_coef: None,
453 max_grad_norm: None,
454 patience: None,
455 min_delta: None,
456 n_envs: None,
457 n_trials: None,
458 tune_timesteps: None,
459 eval_eps: None,
460 search_space: None,
461 source_path: PathBuf::new(),
462 }
463 }
464
465 fn ppo_job_with_hyperparams_set() -> JobDefinition {
469 JobDefinition {
470 task_name: Some("find_target".to_string()),
471 algorithm: Some("ppo".to_string()),
472 total_timesteps: Some(1000),
473 learning_rate: Some(0.0003),
474 n_steps: Some(2048),
475 n_epochs: Some(10),
476 patience: Some(10),
477 min_delta: Some(0.01),
478 ..base_job()
479 }
480 }
481
482 #[test]
483 fn validate_does_not_require_any_hyperparameter_fields_for_a_non_ppo_algorithm() {
484 let temp = TempDir::new().unwrap();
485 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
486
487 let job = JobDefinition {
488 task_name: Some("find_target".to_string()),
489 algorithm: Some("act".to_string()),
490 total_timesteps: Some(1000),
491 n_steps: None,
492 ..base_job()
493 };
494
495 assert!(job.validate(temp.path(), None).is_ok());
496 }
497
498 #[test]
504 fn validate_does_not_require_total_timesteps_for_an_act_train_job() {
505 let temp = TempDir::new().unwrap();
506 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
507
508 let job = JobDefinition {
509 flow_type: FlowType::Train,
510 task_name: Some("find_target".to_string()),
511 algorithm: Some("act".to_string()),
512 total_timesteps: None,
513 n_steps: None,
514 ..base_job()
515 };
516
517 assert!(job.validate(temp.path(), None).is_ok());
518 }
519
520 #[test]
521 fn validate_still_requires_n_steps_for_ppo() {
522 let temp = TempDir::new().unwrap();
523 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
524
525 let job = JobDefinition {
526 n_steps: None,
527 ..ppo_job_with_hyperparams_set()
528 };
529
530 let err = job.validate(temp.path(), None).unwrap_err();
531 assert!(
532 err.to_string().contains("n_steps"),
533 "expected error to mention n_steps, got: {err}"
534 );
535 }
536
537 #[test]
538 fn validate_passes_for_ppo_with_every_required_hyperparameter_set() {
539 let temp = TempDir::new().unwrap();
540 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
541
542 let job = ppo_job_with_hyperparams_set();
543
544 assert!(job.validate(temp.path(), None).is_ok());
545 }
546
547 #[test]
555 fn validate_does_not_require_n_steps_for_a_ppo_resume_run() {
556 let temp = TempDir::new().unwrap();
557 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
558
559 let without_n_steps = JobDefinition {
560 flow_type: FlowType::ResumeRun,
561 n_steps: None,
562 ..ppo_job_with_hyperparams_set()
563 };
564 assert!(
565 without_n_steps.validate(temp.path(), Some("run-123")).is_ok(),
566 "a ppo resume-run job without n_steps must still validate"
567 );
568 }
569
570 #[test]
573 fn validate_still_requires_total_timesteps_for_a_ppo_resume_run() {
574 let temp = TempDir::new().unwrap();
575 write_task_json(temp.path(), "find_target", r#"{"env_name": "basic_arena"}"#);
576
577 let missing_total_timesteps = JobDefinition {
578 flow_type: FlowType::ResumeRun,
579 n_steps: None,
580 total_timesteps: None,
581 ..ppo_job_with_hyperparams_set()
582 };
583 let err = missing_total_timesteps
584 .validate(temp.path(), Some("run-123"))
585 .unwrap_err();
586 assert!(
587 err.to_string().contains("total_timesteps"),
588 "expected error to mention total_timesteps, got: {err}"
589 );
590 assert!(
591 !err.to_string().contains("n_steps"),
592 "n_steps should remain exempt for a ppo resume-run, got: {err}"
593 );
594 }
595
596 #[test]
597 fn load_job_catalog_returns_empty_when_directory_missing() {
598 let temp = TempDir::new().unwrap();
599 let jobs = load_job_catalog(temp.path()).unwrap();
600 assert!(jobs.is_empty());
601 }
602
603 #[test]
604 fn load_job_catalog_parses_yaml_files_sorted_by_name() {
605 let temp = TempDir::new().unwrap();
606 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
607 std::fs::create_dir_all(&jobs_dir).unwrap();
608
609 write_job(
610 &jobs_dir,
611 "train.yml",
612 "name: train\nflow_type: train\nalgorithm: ppo\ntotal_timesteps: 1000000\n",
613 );
614 write_job(
615 &jobs_dir,
616 "hypertune.yml",
617 "name: hypertune\nflow_type: tune\nalgorithm: ppo\nn_trials: 10\n",
618 );
619
620 let jobs = load_job_catalog(temp.path()).unwrap();
621
622 assert_eq!(jobs.len(), 2);
623 assert_eq!(jobs[0].name, "hypertune");
624 assert_eq!(jobs[0].flow_type, FlowType::Tune);
625 assert_eq!(jobs[1].name, "train");
626 assert_eq!(jobs[1].flow_type, FlowType::Train);
627 assert_eq!(jobs[1].total_timesteps, Some(1000000));
628 }
629
630 #[test]
631 fn load_job_catalog_ignores_non_yaml_files() {
632 let temp = TempDir::new().unwrap();
633 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
634 std::fs::create_dir_all(&jobs_dir).unwrap();
635
636 write_job(&jobs_dir, "README.md", "not a job");
637 write_job(
638 &jobs_dir,
639 "train.yml",
640 "name: train\nflow_type: train\nalgorithm: ppo\n",
641 );
642
643 let jobs = load_job_catalog(temp.path()).unwrap();
644 assert_eq!(jobs.len(), 1);
645 }
646
647 #[test]
648 fn supports_remote_for_train_resume_run_and_tune() {
649 let base = base_job();
650 assert!(base.supports_remote());
651
652 let resume = JobDefinition {
653 flow_type: FlowType::ResumeRun,
654 ..base.clone()
655 };
656 assert!(resume.supports_remote());
657
658 let tune = JobDefinition {
659 flow_type: FlowType::Tune,
660 ..base.clone()
661 };
662 assert!(tune.supports_remote());
663
664 let tune_then_train = JobDefinition {
665 flow_type: FlowType::TuneThenTrain,
666 ..base.clone()
667 };
668 assert!(!tune_then_train.supports_remote());
669
670 let train_from_tune = JobDefinition {
671 flow_type: FlowType::TrainFromTune,
672 ..base
673 };
674 assert!(!train_from_tune.supports_remote());
675 }
676
677 #[test]
678 fn to_create_job_request_forwards_resume_run_id() {
679 let temp = TempDir::new().unwrap();
680 let jobs_dir = temp.path().join("configs/rl/jobs");
681 std::fs::create_dir_all(&jobs_dir).unwrap();
682 let source_path = jobs_dir.join("train-from-checkpoint.yml");
683 std::fs::write(&source_path, "name: train-from-checkpoint\nflow_type: resume-run\n").unwrap();
684
685 let job = JobDefinition {
686 name: "train-from-checkpoint".to_string(),
687 flow_type: FlowType::ResumeRun,
688 source_path,
689 ..base_job()
690 };
691
692 let req = job
693 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" run-abc123 "))
694 .unwrap();
695
696 assert_eq!(req.resume_run_id.as_deref(), Some("run-abc123"));
697 }
698
699 #[test]
700 fn to_create_job_request_omits_empty_resume_run_id() {
701 let temp = TempDir::new().unwrap();
702 let jobs_dir = temp.path().join("configs/rl/jobs");
703 std::fs::create_dir_all(&jobs_dir).unwrap();
704 let source_path = jobs_dir.join("train-from-checkpoint.yml");
705 std::fs::write(&source_path, "name: train-from-checkpoint\nflow_type: resume-run\n").unwrap();
706
707 let job = JobDefinition {
708 name: "train-from-checkpoint".to_string(),
709 flow_type: FlowType::ResumeRun,
710 source_path,
711 ..base_job()
712 };
713
714 let req = job
715 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" "))
716 .unwrap();
717
718 assert_eq!(req.resume_run_id, None);
719 }
720
721 #[test]
724 fn create_job_request_has_a_field_for_every_dispatchable_job_field() {
725 let dispatchable = [FlowType::Train, FlowType::ResumeRun, FlowType::Tune];
726
727 let req = CreateJobRequest {
728 name: "job".to_string(),
729 description: None,
730 algorithm: "ppo".to_string(),
731 robot_id: "robot-1".to_string(),
732 project_id: None,
733 project_name: None,
734 env_name: None,
735 task_name: Some("find_target".to_string()),
736 total_timesteps: Some(1_000_000),
737 n_steps: None,
738 n_epochs: None,
739 learning_rate: None,
740 patience: None,
741 min_delta: None,
742 n_envs: None,
743 config_yaml: None,
744 resume_run_id: Some("run-123".to_string()),
745 n_trials: Some(10),
746 tune_timesteps: Some(500_000),
747 eval_eps: Some(5),
748 search_space: None,
749 };
750 let json = serde_json::to_value(&req).expect("CreateJobRequest serializes");
751
752 for field in mecha10_job_validation::all_fields() {
753 let applicable = dispatchable
754 .iter()
755 .any(|&ft| field.requirement(ft) != mecha10_job_validation::FieldRequirement::NotApplicable);
756 if applicable {
757 assert!(
758 json.get(field.wire_name()).is_some(),
759 "CreateJobRequest has no '{}' field for {field:?}, which is applicable to a \
760 dispatchable flow type",
761 field.wire_name()
762 );
763 }
764 }
765 }
766
767 #[test]
768 fn to_create_job_request_forwards_tune_fields() {
769 let temp = TempDir::new().unwrap();
770 let jobs_dir = temp.path().join("configs/rl/jobs");
771 std::fs::create_dir_all(&jobs_dir).unwrap();
772 let source_path = jobs_dir.join("hypertune.yml");
773 std::fs::write(&source_path, "name: hypertune\nflow_type: tune\n").unwrap();
774
775 let job = JobDefinition {
776 name: "hypertune".to_string(),
777 flow_type: FlowType::Tune,
778 n_trials: Some(20),
779 tune_timesteps: Some(250_000),
780 eval_eps: Some(3),
781 source_path,
782 ..base_job()
783 };
784
785 let req = job
786 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
787 .unwrap();
788
789 assert_eq!(req.n_trials, Some(20));
790 assert_eq!(req.tune_timesteps, Some(250_000));
791 assert_eq!(req.eval_eps, Some(3));
792 }
793
794 #[test]
795 fn to_create_job_request_forwards_n_envs() {
796 let temp = TempDir::new().unwrap();
797 let jobs_dir = temp.path().join("configs/rl/jobs");
798 std::fs::create_dir_all(&jobs_dir).unwrap();
799 let source_path = jobs_dir.join("train.yml");
800 std::fs::write(&source_path, "name: train\nflow_type: train\nn_envs: 8\n").unwrap();
801
802 let job = JobDefinition {
803 name: "train".to_string(),
804 n_envs: Some(8),
805 source_path,
806 ..base_job()
807 };
808
809 let req = job
810 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
811 .unwrap();
812
813 assert_eq!(req.n_envs, Some(8));
814 }
815
816 #[test]
817 fn to_create_job_request_forwards_search_space() {
818 let temp = TempDir::new().unwrap();
819 let jobs_dir = temp.path().join("configs/rl/jobs");
820 std::fs::create_dir_all(&jobs_dir).unwrap();
821 let source_path = jobs_dir.join("hypertune.yml");
822 let yaml = "name: hypertune\nflow_type: tune\nsearch_space:\n - name: learning_rate\n \
823 type: float\n low: 1e-5\n high: 1e-3\n log: true\n - name: n_steps\n \
824 type: categorical\n choices: [1024, 2048, 4096]\n - name: n_epochs\n \
825 type: int\n low: 5\n high: 20\n";
826 std::fs::write(&source_path, yaml).unwrap();
827 let job: JobDefinition = serde_yaml::from_str(yaml).unwrap();
828 let job = JobDefinition { source_path, ..job };
829
830 let req = job
831 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
832 .unwrap();
833
834 let search_space = req.search_space.expect("search_space forwarded");
835 assert_eq!(search_space.len(), 3);
836 assert_eq!(search_space[0].name, "learning_rate");
837 assert_eq!(search_space[0].type_, SearchSpaceType::Float);
838 assert_eq!(search_space[0].low, Some(1e-5));
839 assert_eq!(search_space[0].high, Some(1e-3));
840 assert_eq!(search_space[0].log, Some(true));
841 assert_eq!(search_space[1].name, "n_steps");
842 assert_eq!(search_space[1].type_, SearchSpaceType::Categorical);
843 assert_eq!(
844 search_space[1].choices,
845 Some(vec![
846 serde_json::json!(1024),
847 serde_json::json!(2048),
848 serde_json::json!(4096)
849 ])
850 );
851 assert_eq!(search_space[2].name, "n_epochs");
852 assert_eq!(search_space[2].type_, SearchSpaceType::Int);
853 assert_eq!(search_space[2].low, Some(5.0));
854 assert_eq!(search_space[2].high, Some(20.0));
855 }
856
857 #[test]
858 fn to_create_job_request_omits_search_space_when_unset() {
859 let temp = TempDir::new().unwrap();
860 let jobs_dir = temp.path().join("configs/rl/jobs");
861 std::fs::create_dir_all(&jobs_dir).unwrap();
862 let source_path = jobs_dir.join("train.yml");
863 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
864
865 let job = JobDefinition {
866 name: "train".to_string(),
867 source_path,
868 ..base_job()
869 };
870
871 let req = job
872 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
873 .unwrap();
874
875 assert_eq!(req.search_space, None);
876 }
877
878 #[test]
879 fn to_create_job_request_drops_sentinel_values() {
880 let temp = TempDir::new().unwrap();
881 let jobs_dir = temp.path().join("configs/rl/jobs");
882 std::fs::create_dir_all(&jobs_dir).unwrap();
883 let source_path = jobs_dir.join("train.yml");
884 std::fs::write(&source_path, "name: train\nflow_type: train\nenv_name: rover\n").unwrap();
885
886 let job = JobDefinition {
887 name: "train".to_string(),
888 description: Some("desc".to_string()),
889 env_name: Some("rover".to_string()),
890 total_timesteps: Some(-1),
891 n_steps: Some(2048),
892 n_epochs: Some(-1),
893 learning_rate: Some(-1.0),
894 patience: Some(50),
895 min_delta: Some(-1.0),
896 n_envs: Some(-1),
897 source_path,
898 ..base_job()
899 };
900
901 let req = job
902 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
903 .unwrap();
904
905 assert_eq!(req.name, "train");
906 assert_eq!(req.algorithm, "ppo");
907 assert_eq!(req.robot_id, "my-project");
908 assert_eq!(req.project_id.as_deref(), Some("my-project"));
909 assert_eq!(req.project_name.as_deref(), Some("my-robot"));
910 assert_eq!(req.total_timesteps, None);
911 assert_eq!(req.n_steps, Some(2048));
912 assert_eq!(req.n_epochs, None);
913 assert_eq!(req.learning_rate, None);
914 assert_eq!(req.patience, Some(50));
915 assert_eq!(req.min_delta, None);
916 assert_eq!(req.n_envs, None);
917 assert_eq!(
918 req.config_yaml.as_deref(),
919 Some("name: train\nflow_type: train\nenv_name: rover\n")
920 );
921 assert_eq!(req.task_name, None);
922 }
923
924 #[test]
925 fn to_create_job_request_forwards_task_name() {
926 let temp = TempDir::new().unwrap();
927 let jobs_dir = temp.path().join("configs/rl/jobs");
928 std::fs::create_dir_all(&jobs_dir).unwrap();
929 let source_path = jobs_dir.join("train.yml");
930 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
931
932 let job = JobDefinition {
933 name: "train".to_string(),
934 task_name: Some("navigate".to_string()),
935 source_path,
936 ..base_job()
937 };
938
939 let req = job
940 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
941 .unwrap();
942
943 assert_eq!(req.task_name.as_deref(), Some("navigate"));
944 }
945
946 #[test]
947 fn to_create_job_request_errors_when_the_jobs_own_file_is_missing() {
948 let temp = TempDir::new().unwrap();
949 let job = JobDefinition {
950 name: "train".to_string(),
951 source_path: temp.path().join("configs/rl/jobs/train.yml"),
952 ..base_job()
953 };
954
955 let err = job
956 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
957 .unwrap_err();
958 assert!(err.to_string().contains("configs/rl/jobs/train.yml"));
959 }
960}