1use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20pub const JOB_CATALOG_DIR: &str = "configs/rl/jobs";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum FlowType {
27 Train,
28 Tune,
29 TuneThenTrain,
30 TrainFromTune,
31 ResumeRun,
32}
33
34impl FlowType {
35 pub fn run_mode(self) -> &'static str {
38 match self {
39 FlowType::Tune | FlowType::TuneThenTrain => "tune",
40 FlowType::Train | FlowType::TrainFromTune | FlowType::ResumeRun => "train",
41 }
42 }
43}
44
45impl std::fmt::Display for FlowType {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 let s = match self {
48 FlowType::Train => "train",
49 FlowType::Tune => "tune",
50 FlowType::TuneThenTrain => "tune-then-train",
51 FlowType::TrainFromTune => "train-from-tune",
52 FlowType::ResumeRun => "resume-run",
53 };
54 write!(f, "{s}")
55 }
56}
57
58#[derive(Debug, Clone, Deserialize)]
60pub struct JobDefinition {
61 pub name: String,
62 #[serde(default)]
63 pub description: Option<String>,
64 pub flow_type: FlowType,
65 pub config: String,
67 #[serde(default)]
69 pub env_name: Option<String>,
70 #[serde(default)]
74 pub algorithm: Option<String>,
75 #[serde(default)]
76 pub timesteps: Option<i64>,
77 #[serde(default)]
78 pub n_steps: Option<i64>,
79 #[serde(default)]
80 pub epochs: Option<i64>,
81 #[serde(default)]
82 pub lr: Option<f64>,
83 #[serde(default)]
84 pub patience: Option<i64>,
85 #[serde(default)]
86 pub min_delta: Option<f64>,
87 #[serde(default)]
90 pub resume_run_id: Option<String>,
91 #[serde(default)]
94 pub tune_run_id: Option<String>,
95 #[serde(default)]
96 pub n_trials: Option<i64>,
97 #[serde(default)]
98 pub tune_timesteps: Option<i64>,
99 #[serde(default)]
100 pub eval_eps: Option<i64>,
101
102 #[serde(skip)]
104 pub source_path: PathBuf,
105}
106
107#[derive(Debug, Clone, Serialize)]
113pub struct CreateJobRequest {
114 pub name: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub description: Option<String>,
117 pub algorithm: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub project_id: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub config: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub env_name: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub timesteps: Option<i64>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub n_steps: Option<i64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub epochs: Option<i64>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub lr: Option<f64>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub patience: Option<i64>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub min_delta: Option<f64>,
136}
137
138fn non_sentinel_i64(value: Option<i64>) -> Option<i64> {
139 value.filter(|v| *v != -1)
140}
141
142fn non_sentinel_f64(value: Option<f64>) -> Option<f64> {
143 value.filter(|v| *v != -1.0)
144}
145
146impl JobDefinition {
147 pub fn local_env_vars(
155 &self,
156 control_plane_url: &str,
157 relay_api_key: &str,
158 log_level: &str,
159 ) -> Vec<(String, String)> {
160 vec![
161 ("RL_CONFIG_DIR".to_string(), "configs/rl".to_string()),
162 ("CONFIG".to_string(), self.config.clone()),
163 ("ENV_NAME".to_string(), self.env_name.clone().unwrap_or_default()),
164 ("ALGORITHM_NAME".to_string(), self.algorithm.clone().unwrap_or_default()),
165 ("TOTAL_TIMESTEPS".to_string(), self.timesteps.unwrap_or(-1).to_string()),
166 ("N_STEPS".to_string(), self.n_steps.unwrap_or(-1).to_string()),
167 ("N_EPOCHS".to_string(), self.epochs.unwrap_or(-1).to_string()),
168 ("LEARNING_RATE".to_string(), self.lr.unwrap_or(-1.0).to_string()),
169 ("PATIENCE".to_string(), self.patience.unwrap_or(-1).to_string()),
170 ("MIN_DELTA".to_string(), self.min_delta.unwrap_or(-1.0).to_string()),
171 (
172 "RESUME_RUN_ID".to_string(),
173 self.resume_run_id.clone().unwrap_or_default(),
174 ),
175 ("TUNE_RUN_ID".to_string(), self.tune_run_id.clone().unwrap_or_default()),
176 ("N_TRIALS".to_string(), self.n_trials.unwrap_or(-1).to_string()),
177 (
178 "TUNE_TIMESTEPS".to_string(),
179 self.tune_timesteps.unwrap_or(-1).to_string(),
180 ),
181 ("EVAL_EPS".to_string(), self.eval_eps.unwrap_or(-1).to_string()),
182 ("MLFLOW_ENABLED".to_string(), "true".to_string()),
183 ("TRACKER_BACKEND".to_string(), "relay".to_string()),
184 ("MLFLOW_TRACKING_URI".to_string(), control_plane_url.to_string()),
185 ("RELAY_API_KEY".to_string(), relay_api_key.to_string()),
186 ("LOG_LEVEL".to_string(), log_level.to_string()),
187 ]
188 }
189
190 pub fn run_mode(&self) -> &'static str {
192 self.flow_type.run_mode()
193 }
194
195 pub fn supports_remote(&self) -> bool {
201 matches!(self.flow_type, FlowType::Train)
202 }
203
204 pub fn to_create_job_request(&self, project_id: &str) -> CreateJobRequest {
209 CreateJobRequest {
210 name: self.name.clone(),
211 description: self.description.clone(),
212 algorithm: self.algorithm.clone().unwrap_or_default(),
213 project_id: Some(project_id.to_string()),
214 config: Some(self.config.clone()),
215 env_name: self.env_name.clone(),
216 timesteps: non_sentinel_i64(self.timesteps),
217 n_steps: non_sentinel_i64(self.n_steps),
218 epochs: non_sentinel_i64(self.epochs),
219 lr: non_sentinel_f64(self.lr),
220 patience: non_sentinel_i64(self.patience),
221 min_delta: non_sentinel_f64(self.min_delta),
222 }
223 }
224}
225
226pub fn load_job_catalog(project_root: &Path) -> Result<Vec<JobDefinition>> {
231 let jobs_dir = project_root.join(JOB_CATALOG_DIR);
232 if !jobs_dir.exists() {
233 return Ok(Vec::new());
234 }
235
236 let mut paths: Vec<PathBuf> = std::fs::read_dir(&jobs_dir)
237 .with_context(|| format!("failed to read {}", jobs_dir.display()))?
238 .filter_map(|entry| entry.ok())
239 .map(|entry| entry.path())
240 .filter(|path| {
241 path.extension()
242 .and_then(|ext| ext.to_str())
243 .is_some_and(|ext| ext == "yml" || ext == "yaml")
244 })
245 .collect();
246 paths.sort();
247
248 let mut jobs = Vec::with_capacity(paths.len());
249 for path in paths {
250 let content = std::fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
251 let mut job: JobDefinition = serde_yaml::from_str(&content)
252 .with_context(|| format!("failed to parse job definition {}", path.display()))?;
253 job.source_path = path;
254 jobs.push(job);
255 }
256
257 Ok(jobs)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use tempfile::TempDir;
264
265 fn write_job(dir: &Path, file_name: &str, contents: &str) {
266 std::fs::write(dir.join(file_name), contents).unwrap();
267 }
268
269 #[test]
270 fn load_job_catalog_returns_empty_when_directory_missing() {
271 let temp = TempDir::new().unwrap();
272 let jobs = load_job_catalog(temp.path()).unwrap();
273 assert!(jobs.is_empty());
274 }
275
276 #[test]
277 fn load_job_catalog_parses_yaml_files_sorted_by_name() {
278 let temp = TempDir::new().unwrap();
279 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
280 std::fs::create_dir_all(&jobs_dir).unwrap();
281
282 write_job(
283 &jobs_dir,
284 "train.yml",
285 "name: train\nflow_type: train\nconfig: local\nalgorithm: ppo\n",
286 );
287 write_job(
288 &jobs_dir,
289 "hypertune.yml",
290 "name: hypertune\nflow_type: tune\nconfig: local\nn_trials: 10\n",
291 );
292
293 let jobs = load_job_catalog(temp.path()).unwrap();
294
295 assert_eq!(jobs.len(), 2);
296 assert_eq!(jobs[0].name, "hypertune");
298 assert_eq!(jobs[0].flow_type, FlowType::Tune);
299 assert_eq!(jobs[0].run_mode(), "tune");
300 assert_eq!(jobs[1].name, "train");
301 assert_eq!(jobs[1].flow_type, FlowType::Train);
302 assert_eq!(jobs[1].run_mode(), "train");
303 }
304
305 #[test]
306 fn load_job_catalog_ignores_non_yaml_files() {
307 let temp = TempDir::new().unwrap();
308 let jobs_dir = temp.path().join(JOB_CATALOG_DIR);
309 std::fs::create_dir_all(&jobs_dir).unwrap();
310
311 write_job(&jobs_dir, "README.md", "not a job");
312 write_job(&jobs_dir, "train.yml", "name: train\nflow_type: train\nconfig: local\n");
313
314 let jobs = load_job_catalog(temp.path()).unwrap();
315 assert_eq!(jobs.len(), 1);
316 }
317
318 #[test]
319 fn flow_type_run_mode_dispatches_tune_family_to_tune_mode() {
320 assert_eq!(FlowType::Train.run_mode(), "train");
321 assert_eq!(FlowType::TrainFromTune.run_mode(), "train");
322 assert_eq!(FlowType::ResumeRun.run_mode(), "train");
323 assert_eq!(FlowType::Tune.run_mode(), "tune");
324 assert_eq!(FlowType::TuneThenTrain.run_mode(), "tune");
325 }
326
327 #[test]
328 fn supports_remote_only_for_plain_train() {
329 let base = JobDefinition {
330 name: "job".to_string(),
331 description: None,
332 flow_type: FlowType::Train,
333 config: "local".to_string(),
334 env_name: None,
335 algorithm: Some("ppo".to_string()),
336 timesteps: None,
337 n_steps: None,
338 epochs: None,
339 lr: None,
340 patience: None,
341 min_delta: None,
342 resume_run_id: None,
343 tune_run_id: None,
344 n_trials: None,
345 tune_timesteps: None,
346 eval_eps: None,
347 source_path: PathBuf::new(),
348 };
349 assert!(base.supports_remote());
350
351 let resume = JobDefinition {
352 flow_type: FlowType::ResumeRun,
353 ..base.clone()
354 };
355 assert!(!resume.supports_remote());
356
357 let tune = JobDefinition {
358 flow_type: FlowType::Tune,
359 ..base
360 };
361 assert!(!tune.supports_remote());
362 }
363
364 #[test]
365 fn to_create_job_request_drops_sentinel_values() {
366 let job = JobDefinition {
367 name: "train".to_string(),
368 description: Some("desc".to_string()),
369 flow_type: FlowType::Train,
370 config: "local".to_string(),
371 env_name: Some("rover".to_string()),
372 algorithm: Some("ppo".to_string()),
373 timesteps: Some(-1),
374 n_steps: Some(2048),
375 epochs: Some(-1),
376 lr: Some(-1.0),
377 patience: Some(50),
378 min_delta: Some(-1.0),
379 resume_run_id: None,
380 tune_run_id: None,
381 n_trials: None,
382 tune_timesteps: None,
383 eval_eps: None,
384 source_path: PathBuf::new(),
385 };
386
387 let req = job.to_create_job_request("my-project");
388
389 assert_eq!(req.name, "train");
390 assert_eq!(req.algorithm, "ppo");
391 assert_eq!(req.project_id.as_deref(), Some("my-project"));
392 assert_eq!(req.timesteps, None);
393 assert_eq!(req.n_steps, Some(2048));
394 assert_eq!(req.epochs, None);
395 assert_eq!(req.lr, None);
396 assert_eq!(req.patience, Some(50));
397 assert_eq!(req.min_delta, None);
398 }
399
400 #[test]
401 fn local_env_vars_applies_docker_compose_renaming_and_relay_settings() {
402 let job = JobDefinition {
403 name: "train".to_string(),
404 description: None,
405 flow_type: FlowType::Train,
406 config: "local".to_string(),
407 env_name: Some("rover".to_string()),
408 algorithm: Some("ppo".to_string()),
409 timesteps: Some(5000),
410 n_steps: None,
411 epochs: None,
412 lr: None,
413 patience: None,
414 min_delta: None,
415 resume_run_id: Some("run-123".to_string()),
416 tune_run_id: None,
417 n_trials: None,
418 tune_timesteps: None,
419 eval_eps: None,
420 source_path: PathBuf::new(),
421 };
422
423 let env = job.local_env_vars("https://control-plane.example.com", "mecha_test_key", "debug");
424 let as_map: std::collections::HashMap<_, _> = env.into_iter().collect();
425
426 assert_eq!(as_map.get("TOTAL_TIMESTEPS").map(String::as_str), Some("5000"));
427 assert_eq!(as_map.get("RESUME_RUN_ID").map(String::as_str), Some("run-123"));
428 assert_eq!(as_map.get("TRACKER_BACKEND").map(String::as_str), Some("relay"));
429 assert_eq!(as_map.get("MLFLOW_ENABLED").map(String::as_str), Some("true"));
430 assert_eq!(
431 as_map.get("MLFLOW_TRACKING_URI").map(String::as_str),
432 Some("https://control-plane.example.com")
433 );
434 assert_eq!(as_map.get("RELAY_API_KEY").map(String::as_str), Some("mecha_test_key"));
435 assert_eq!(as_map.get("LOG_LEVEL").map(String::as_str), Some("debug"));
436 }
437}