1use anyhow::{bail, Context, Result};
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30
31pub const JOB_CATALOG_DIR: &str = "configs/rl/jobs";
32
33pub use mecha10_job_validation::FlowType;
34
35#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
37pub struct SearchSpaceEntry {
38 pub name: String,
39 #[serde(rename = "type")]
40 pub type_: SearchSpaceType,
41 #[serde(default)]
42 pub low: Option<f64>,
43 #[serde(default)]
44 pub high: Option<f64>,
45 #[serde(default)]
46 pub log: Option<bool>,
47 #[serde(default)]
48 pub choices: Option<Vec<serde_json::Value>>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
54#[serde(rename_all = "lowercase")]
55pub enum SearchSpaceType {
56 Float,
57 Int,
58 Categorical,
59}
60
61#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
63#[allow(dead_code)]
64pub struct JobDefinition {
65 pub name: String,
66 #[serde(default)]
67 pub description: Option<String>,
68 pub flow_type: FlowType,
69 #[serde(default)]
72 #[schemars(example = Some("warehouse_nav".to_string()))]
73 pub env_name: Option<String>,
74 #[serde(default)]
76 #[schemars(example = Some("patrol_curriculum".to_string()))]
77 pub task_name: Option<String>,
78 #[serde(default)]
80 #[schemars(example = Some("ppo".to_string()))]
81 pub algorithm: Option<String>,
82
83 #[serde(default)]
86 #[schemars(example = Some("MlpPolicy".to_string()))]
87 #[allow(dead_code, reason = "schema/deserialize-only, see doc comment above")]
88 pub policy: Option<String>,
89 #[serde(default)]
90 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
91 pub seed: Option<i64>,
92 #[serde(default)]
93 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
94 pub environment_map: Option<String>,
95 #[serde(default)]
96 pub total_timesteps: Option<i64>,
97 #[serde(default)]
98 pub learning_rate: Option<f64>,
99 #[serde(default)]
100 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
101 pub linear_lr_schedule: Option<bool>,
102 #[serde(default)]
103 pub n_steps: Option<i64>,
104 #[serde(default)]
105 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
106 pub batch_size: Option<i64>,
107 #[serde(default)]
108 pub n_epochs: Option<i64>,
109 #[serde(default)]
110 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
111 pub gamma: Option<f64>,
112 #[serde(default)]
113 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
114 pub gae_lambda: Option<f64>,
115 #[serde(default)]
116 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
117 pub clip_range: Option<f64>,
118 #[serde(default)]
119 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
120 pub ent_coef: Option<f64>,
121 #[serde(default)]
122 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
123 pub vf_coef: Option<f64>,
124 #[serde(default)]
125 #[allow(dead_code, reason = "schema/deserialize-only, see `policy`'s doc comment above")]
126 pub max_grad_norm: Option<f64>,
127 #[serde(default)]
129 #[schemars(example = Some(10))]
130 pub patience: Option<i64>,
131 #[serde(default)]
132 pub min_delta: Option<f64>,
133 #[serde(default)]
135 #[schemars(example = Some(8))]
136 pub n_envs: Option<i64>,
137
138 #[serde(default)]
139 pub n_trials: Option<i64>,
140 #[serde(default)]
141 pub tune_timesteps: Option<i64>,
142 #[serde(default)]
143 pub eval_eps: Option<i64>,
144 #[serde(default)]
147 pub search_space: Option<Vec<SearchSpaceEntry>>,
148
149 #[serde(skip)]
150 #[schemars(skip)]
151 pub source_path: PathBuf,
152}
153
154#[derive(Debug, Clone, Serialize)]
155pub struct CreateJobRequest {
156 pub name: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub description: Option<String>,
159 pub algorithm: String,
160 pub robot_id: String,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub project_id: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub project_name: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub env_name: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub task_name: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub total_timesteps: Option<i64>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub n_steps: Option<i64>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub n_epochs: Option<i64>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub learning_rate: Option<f64>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub patience: Option<i64>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub min_delta: Option<f64>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub n_envs: Option<i64>,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub config_yaml: Option<String>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub resume_run_id: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub n_trials: Option<i64>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub tune_timesteps: Option<i64>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub eval_eps: Option<i64>,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub search_space: Option<Vec<SearchSpaceEntry>>,
195}
196
197fn env_name_from_task_json(project_root: &Path, task_name: &str) -> Option<String> {
202 task_json_field(project_root, task_name, "env_name")
203}
204
205fn task_json_field(project_root: &Path, task_name: &str, field: &str) -> Option<String> {
206 let path = project_root
207 .join(mecha10_cli_core::paths::project::RL_TASKS_DIR)
208 .join(task_name)
209 .join("task.json");
210 let content = std::fs::read_to_string(path).ok()?;
211 let value: serde_json::Value = serde_json::from_str(&content).ok()?;
212 value.get(field)?.as_str().map(str::to_string)
213}
214
215fn non_sentinel<T: PartialEq>(value: Option<T>, sentinel: T) -> Option<T> {
218 value.filter(|v| *v != sentinel)
219}
220
221fn non_sentinel_i64(value: Option<i64>) -> Option<i64> {
222 non_sentinel(value, -1)
223}
224
225fn non_sentinel_f64(value: Option<f64>) -> Option<f64> {
226 non_sentinel(value, -1.0)
227}
228
229impl JobDefinition {
230 fn validation_fields(&self, resume_run_id: Option<&str>) -> mecha10_job_validation::JobFields {
234 mecha10_job_validation::JobFields {
235 task_name: self.task_name.clone(),
236 algorithm: self.algorithm.clone(),
237 total_timesteps: non_sentinel_i64(self.total_timesteps),
238 resume_run_id: resume_run_id.map(str::to_string),
239 tune_run_id: None,
240 n_trials: non_sentinel_i64(self.n_trials),
241 tune_timesteps: non_sentinel_i64(self.tune_timesteps),
242 eval_eps: non_sentinel_i64(self.eval_eps),
243 }
244 }
245
246 pub fn validate(&self, project_root: &Path, resume_run_id: Option<&str>) -> Result<()> {
253 let missing = mecha10_job_validation::validate_job(self.flow_type, &self.validation_fields(resume_run_id));
254 if !missing.is_empty() {
255 let details = missing.iter().map(|m| m.to_string()).collect::<Vec<_>>().join("; ");
256 bail!("job '{}' is missing required fields: {details}", self.name);
257 }
258
259 let mut missing_hyperparams = Vec::new();
260 if non_sentinel_i64(self.total_timesteps).is_none() {
261 missing_hyperparams.push("total_timesteps");
262 }
263 if self.flow_type != FlowType::ResumeRun && non_sentinel_i64(self.n_steps).is_none() {
264 missing_hyperparams.push("n_steps");
265 }
266 if !missing_hyperparams.is_empty() {
267 bail!(
268 "job '{}' is missing required field(s): {}",
269 self.name,
270 missing_hyperparams.join(", ")
271 );
272 }
273
274 if let Some(task_name) = self.task_name.as_deref() {
275 mecha10_job_validation::task_exists(project_root, task_name)
276 .with_context(|| format!("job '{}' has an invalid task_name", self.name))?;
277 }
278
279 Ok(())
280 }
281
282 pub fn resolve_env_name(&self, project_root: &Path) -> Option<String> {
290 self.env_name.clone().or_else(|| {
291 self.task_name
292 .as_deref()
293 .and_then(|task_name| env_name_from_task_json(project_root, task_name))
294 })
295 }
296
297 pub fn supports_remote(&self) -> bool {
303 matches!(self.flow_type, FlowType::Train | FlowType::ResumeRun | FlowType::Tune)
304 }
305
306 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 #[test]
489 fn load_job_catalog_returns_empty_when_directory_missing() {
490 let temp = TempDir::new().unwrap();
491 let jobs = load_job_catalog(temp.path()).unwrap();
492 assert!(jobs.is_empty());
493 }
494
495 #[test]
496 fn load_job_catalog_parses_yaml_files_sorted_by_name() {
497 let temp = TempDir::new().unwrap();
498 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
499 std::fs::create_dir_all(&jobs_dir).unwrap();
500
501 write_job(
502 &jobs_dir,
503 "train.yml",
504 "name: train\nflow_type: train\nalgorithm: ppo\ntotal_timesteps: 1000000\n",
505 );
506 write_job(
507 &jobs_dir,
508 "hypertune.yml",
509 "name: hypertune\nflow_type: tune\nalgorithm: ppo\nn_trials: 10\n",
510 );
511
512 let jobs = load_job_catalog(temp.path()).unwrap();
513
514 assert_eq!(jobs.len(), 2);
515 assert_eq!(jobs[0].name, "hypertune");
516 assert_eq!(jobs[0].flow_type, FlowType::Tune);
517 assert_eq!(jobs[1].name, "train");
518 assert_eq!(jobs[1].flow_type, FlowType::Train);
519 assert_eq!(jobs[1].total_timesteps, Some(1000000));
520 }
521
522 #[test]
523 fn load_job_catalog_ignores_non_yaml_files() {
524 let temp = TempDir::new().unwrap();
525 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
526 std::fs::create_dir_all(&jobs_dir).unwrap();
527
528 write_job(&jobs_dir, "README.md", "not a job");
529 write_job(
530 &jobs_dir,
531 "train.yml",
532 "name: train\nflow_type: train\nalgorithm: ppo\n",
533 );
534
535 let jobs = load_job_catalog(temp.path()).unwrap();
536 assert_eq!(jobs.len(), 1);
537 }
538
539 #[test]
540 fn supports_remote_for_train_resume_run_and_tune() {
541 let base = base_job();
542 assert!(base.supports_remote());
543
544 let resume = JobDefinition {
545 flow_type: FlowType::ResumeRun,
546 ..base.clone()
547 };
548 assert!(resume.supports_remote());
549
550 let tune = JobDefinition {
551 flow_type: FlowType::Tune,
552 ..base.clone()
553 };
554 assert!(tune.supports_remote());
555
556 let tune_then_train = JobDefinition {
557 flow_type: FlowType::TuneThenTrain,
558 ..base.clone()
559 };
560 assert!(!tune_then_train.supports_remote());
561
562 let train_from_tune = JobDefinition {
563 flow_type: FlowType::TrainFromTune,
564 ..base
565 };
566 assert!(!train_from_tune.supports_remote());
567 }
568
569 #[test]
570 fn to_create_job_request_forwards_resume_run_id() {
571 let temp = TempDir::new().unwrap();
572 let jobs_dir = temp.path().join("configs/rl/jobs");
573 std::fs::create_dir_all(&jobs_dir).unwrap();
574 let source_path = jobs_dir.join("train-from-checkpoint.yml");
575 std::fs::write(&source_path, "name: train-from-checkpoint\nflow_type: resume-run\n").unwrap();
576
577 let job = JobDefinition {
578 name: "train-from-checkpoint".to_string(),
579 flow_type: FlowType::ResumeRun,
580 source_path,
581 ..base_job()
582 };
583
584 let req = job
585 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" run-abc123 "))
586 .unwrap();
587
588 assert_eq!(req.resume_run_id.as_deref(), Some("run-abc123"));
589 }
590
591 #[test]
592 fn to_create_job_request_omits_empty_resume_run_id() {
593 let temp = TempDir::new().unwrap();
594 let jobs_dir = temp.path().join("configs/rl/jobs");
595 std::fs::create_dir_all(&jobs_dir).unwrap();
596 let source_path = jobs_dir.join("train-from-checkpoint.yml");
597 std::fs::write(&source_path, "name: train-from-checkpoint\nflow_type: resume-run\n").unwrap();
598
599 let job = JobDefinition {
600 name: "train-from-checkpoint".to_string(),
601 flow_type: FlowType::ResumeRun,
602 source_path,
603 ..base_job()
604 };
605
606 let req = job
607 .to_create_job_request(temp.path(), "my-project", "my-robot", Some(" "))
608 .unwrap();
609
610 assert_eq!(req.resume_run_id, None);
611 }
612
613 #[test]
624 fn create_job_request_has_a_field_for_every_dispatchable_job_field() {
625 let dispatchable = [FlowType::Train, FlowType::ResumeRun, FlowType::Tune];
626
627 let req = CreateJobRequest {
628 name: "job".to_string(),
629 description: None,
630 algorithm: "ppo".to_string(),
631 robot_id: "robot-1".to_string(),
632 project_id: None,
633 project_name: None,
634 env_name: None,
635 task_name: Some("find_target".to_string()),
636 total_timesteps: Some(1_000_000),
637 n_steps: None,
638 n_epochs: None,
639 learning_rate: None,
640 patience: None,
641 min_delta: None,
642 n_envs: None,
643 config_yaml: None,
644 resume_run_id: Some("run-123".to_string()),
645 n_trials: Some(10),
646 tune_timesteps: Some(500_000),
647 eval_eps: Some(5),
648 search_space: None,
649 };
650 let json = serde_json::to_value(&req).expect("CreateJobRequest serializes");
651
652 for field in mecha10_job_validation::all_fields() {
653 let applicable = dispatchable
654 .iter()
655 .any(|&ft| field.requirement(ft) != mecha10_job_validation::FieldRequirement::NotApplicable);
656 if applicable {
657 assert!(
658 json.get(field.wire_name()).is_some(),
659 "CreateJobRequest has no '{}' field for {field:?}, which is applicable to a \
660 dispatchable flow type",
661 field.wire_name()
662 );
663 }
664 }
665 }
666
667 #[test]
668 fn to_create_job_request_forwards_tune_fields() {
669 let temp = TempDir::new().unwrap();
670 let jobs_dir = temp.path().join("configs/rl/jobs");
671 std::fs::create_dir_all(&jobs_dir).unwrap();
672 let source_path = jobs_dir.join("hypertune.yml");
673 std::fs::write(&source_path, "name: hypertune\nflow_type: tune\n").unwrap();
674
675 let job = JobDefinition {
676 name: "hypertune".to_string(),
677 flow_type: FlowType::Tune,
678 n_trials: Some(20),
679 tune_timesteps: Some(250_000),
680 eval_eps: Some(3),
681 source_path,
682 ..base_job()
683 };
684
685 let req = job
686 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
687 .unwrap();
688
689 assert_eq!(req.n_trials, Some(20));
690 assert_eq!(req.tune_timesteps, Some(250_000));
691 assert_eq!(req.eval_eps, Some(3));
692 }
693
694 #[test]
695 fn to_create_job_request_forwards_n_envs() {
696 let temp = TempDir::new().unwrap();
697 let jobs_dir = temp.path().join("configs/rl/jobs");
698 std::fs::create_dir_all(&jobs_dir).unwrap();
699 let source_path = jobs_dir.join("train.yml");
700 std::fs::write(&source_path, "name: train\nflow_type: train\nn_envs: 8\n").unwrap();
701
702 let job = JobDefinition {
703 name: "train".to_string(),
704 n_envs: Some(8),
705 source_path,
706 ..base_job()
707 };
708
709 let req = job
710 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
711 .unwrap();
712
713 assert_eq!(req.n_envs, Some(8));
714 }
715
716 #[test]
717 fn to_create_job_request_forwards_search_space() {
718 let temp = TempDir::new().unwrap();
719 let jobs_dir = temp.path().join("configs/rl/jobs");
720 std::fs::create_dir_all(&jobs_dir).unwrap();
721 let source_path = jobs_dir.join("hypertune.yml");
722 let yaml = "name: hypertune\nflow_type: tune\nsearch_space:\n - name: learning_rate\n \
723 type: float\n low: 1e-5\n high: 1e-3\n log: true\n - name: n_steps\n \
724 type: categorical\n choices: [1024, 2048, 4096]\n - name: n_epochs\n \
725 type: int\n low: 5\n high: 20\n";
726 std::fs::write(&source_path, yaml).unwrap();
727 let job: JobDefinition = serde_yaml::from_str(yaml).unwrap();
728 let job = JobDefinition { source_path, ..job };
729
730 let req = job
731 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
732 .unwrap();
733
734 let search_space = req.search_space.expect("search_space forwarded");
735 assert_eq!(search_space.len(), 3);
736 assert_eq!(search_space[0].name, "learning_rate");
737 assert_eq!(search_space[0].type_, SearchSpaceType::Float);
738 assert_eq!(search_space[0].low, Some(1e-5));
739 assert_eq!(search_space[0].high, Some(1e-3));
740 assert_eq!(search_space[0].log, Some(true));
741 assert_eq!(search_space[1].name, "n_steps");
742 assert_eq!(search_space[1].type_, SearchSpaceType::Categorical);
743 assert_eq!(
744 search_space[1].choices,
745 Some(vec![
746 serde_json::json!(1024),
747 serde_json::json!(2048),
748 serde_json::json!(4096)
749 ])
750 );
751 assert_eq!(search_space[2].name, "n_epochs");
752 assert_eq!(search_space[2].type_, SearchSpaceType::Int);
753 assert_eq!(search_space[2].low, Some(5.0));
754 assert_eq!(search_space[2].high, Some(20.0));
755 }
756
757 #[test]
758 fn to_create_job_request_omits_search_space_when_unset() {
759 let temp = TempDir::new().unwrap();
760 let jobs_dir = temp.path().join("configs/rl/jobs");
761 std::fs::create_dir_all(&jobs_dir).unwrap();
762 let source_path = jobs_dir.join("train.yml");
763 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
764
765 let job = JobDefinition {
766 name: "train".to_string(),
767 source_path,
768 ..base_job()
769 };
770
771 let req = job
772 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
773 .unwrap();
774
775 assert_eq!(req.search_space, None);
776 }
777
778 #[test]
779 fn to_create_job_request_drops_sentinel_values() {
780 let temp = TempDir::new().unwrap();
781 let jobs_dir = temp.path().join("configs/rl/jobs");
782 std::fs::create_dir_all(&jobs_dir).unwrap();
783 let source_path = jobs_dir.join("train.yml");
784 std::fs::write(&source_path, "name: train\nflow_type: train\nenv_name: rover\n").unwrap();
785
786 let job = JobDefinition {
787 name: "train".to_string(),
788 description: Some("desc".to_string()),
789 env_name: Some("rover".to_string()),
790 total_timesteps: Some(-1),
791 n_steps: Some(2048),
792 n_epochs: Some(-1),
793 learning_rate: Some(-1.0),
794 patience: Some(50),
795 min_delta: Some(-1.0),
796 n_envs: Some(-1),
797 source_path,
798 ..base_job()
799 };
800
801 let req = job
802 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
803 .unwrap();
804
805 assert_eq!(req.name, "train");
806 assert_eq!(req.algorithm, "ppo");
807 assert_eq!(req.robot_id, "my-project");
808 assert_eq!(req.project_id.as_deref(), Some("my-project"));
809 assert_eq!(req.project_name.as_deref(), Some("my-robot"));
810 assert_eq!(req.total_timesteps, None);
811 assert_eq!(req.n_steps, Some(2048));
812 assert_eq!(req.n_epochs, None);
813 assert_eq!(req.learning_rate, None);
814 assert_eq!(req.patience, Some(50));
815 assert_eq!(req.min_delta, None);
816 assert_eq!(req.n_envs, None);
817 assert_eq!(
818 req.config_yaml.as_deref(),
819 Some("name: train\nflow_type: train\nenv_name: rover\n")
820 );
821 assert_eq!(req.task_name, None);
822 }
823
824 #[test]
825 fn to_create_job_request_forwards_task_name() {
826 let temp = TempDir::new().unwrap();
827 let jobs_dir = temp.path().join("configs/rl/jobs");
828 std::fs::create_dir_all(&jobs_dir).unwrap();
829 let source_path = jobs_dir.join("train.yml");
830 std::fs::write(&source_path, "name: train\nflow_type: train\n").unwrap();
831
832 let job = JobDefinition {
833 name: "train".to_string(),
834 task_name: Some("navigate".to_string()),
835 source_path,
836 ..base_job()
837 };
838
839 let req = job
840 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
841 .unwrap();
842
843 assert_eq!(req.task_name.as_deref(), Some("navigate"));
844 }
845
846 #[test]
847 fn to_create_job_request_errors_when_the_jobs_own_file_is_missing() {
848 let temp = TempDir::new().unwrap();
849 let job = JobDefinition {
850 name: "train".to_string(),
851 source_path: temp.path().join("configs/rl/jobs/train.yml"),
852 ..base_job()
853 };
854
855 let err = job
856 .to_create_job_request(temp.path(), "my-project", "my-robot", None)
857 .unwrap_err();
858 assert!(err.to_string().contains("configs/rl/jobs/train.yml"));
859 }
860}