use crate::paths;
use anyhow::{Context, Result};
use std::path::Path;
use tokio::io::AsyncWriteExt;
const RL_JOBS_DIR: &str = "configs/rl/jobs";
const RL_JOBS: [(&str, &str); 3] = [
("train.yml", "train.yml.template"),
("train-from-checkpoint.yml", "train-from-checkpoint.yml.template"),
("hypertune.yml", "hypertune.yml.template"),
];
pub struct RlBundleTemplates;
impl RlBundleTemplates {
pub fn new() -> Self {
Self
}
pub async fn create_rl_jobs(&self, path: &Path, assets_root: &Path) -> Result<()> {
let jobs_dir = path.join(RL_JOBS_DIR);
tokio::fs::create_dir_all(&jobs_dir)
.await
.with_context(|| format!("Failed to create {}", RL_JOBS_DIR))?;
for (file_name, template_name) in RL_JOBS {
let template_path = assets_root.join("rl/jobs").join(template_name);
let content = tokio::fs::read_to_string(&template_path)
.await
.with_context(|| format!("Failed to read {:?} from downloaded mecha10-assets", template_path))?;
tokio::fs::write(jobs_dir.join(file_name), content)
.await
.with_context(|| format!("Failed to write {}/{}", RL_JOBS_DIR, file_name))?;
}
Ok(())
}
pub async fn append_rl_requirements(&self, path: &Path, assets_root: &Path) -> Result<()> {
let requirements_path = path.join(paths::meta::REQUIREMENTS_TXT);
let template_path = assets_root.join("rl/requirements-rl.txt.template");
let rl_requirements = tokio::fs::read_to_string(&template_path)
.await
.with_context(|| format!("Failed to read {:?} from downloaded mecha10-assets", template_path))?;
let mut file = tokio::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&requirements_path)
.await
.context("Failed to open requirements.txt for appending RL bundle dependencies")?;
file.write_all(rl_requirements.as_bytes())
.await
.context("Failed to append RL bundle dependencies to requirements.txt")?;
Ok(())
}
}
impl Default for RlBundleTemplates {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const FIXTURE_TRAIN: &str = r#"name: train
description: Train PPO from scratch
flow_type: train
algorithm: ppo
seed: 0
environment_map: ""
policy: ""
total_timesteps: 1000
learning_rate: 0.0003
linear_lr_schedule: true
n_steps: 256
batch_size: 64
n_epochs: 10
gamma: 0.99
gae_lambda: 0.95
clip_range: 0.2
ent_coef: 0.01
vf_coef: 0.5
max_grad_norm: 0.5
n_envs: 1
patience: -1
min_delta: -1.0
"#;
const FIXTURE_TRAIN_FROM_CHECKPOINT: &str = r#"name: train-from-checkpoint
description: Resume training from a prior run's checkpoint
flow_type: resume-run
algorithm: ppo
total_timesteps: 1000
patience: -1
min_delta: -1.0
n_envs: 1
"#;
const FIXTURE_HYPERTUNE: &str = r#"name: hypertune
description: Optuna hyperparameter search over learning rate / rollout steps / epochs
flow_type: tune
algorithm: ppo
n_trials: 2
tune_timesteps: 1000
eval_eps: 1
total_timesteps: 10000
n_steps: 2048
n_envs: 1
search_space:
- name: learning_rate
type: float
low: 0.00001
high: 0.001
log: true
"#;
const FIXTURE_RL_REQUIREMENTS: &str = "\nmecha10-rl-task-runner @ git+ssh://git@github.com/mecha-industries/mecha10-rl.git@main#subdirectory=rl-task-runner\nmecha10-rl @ git+ssh://git@github.com/mecha-industries/mecha10-rl.git@main#subdirectory=rl-trainer\n";
fn stage_assets_root() -> TempDir {
let assets_root = TempDir::new().unwrap();
let jobs_dir = assets_root.path().join("rl/jobs");
std::fs::create_dir_all(&jobs_dir).unwrap();
std::fs::write(jobs_dir.join("train.yml.template"), FIXTURE_TRAIN).unwrap();
std::fs::write(
jobs_dir.join("train-from-checkpoint.yml.template"),
FIXTURE_TRAIN_FROM_CHECKPOINT,
)
.unwrap();
std::fs::write(jobs_dir.join("hypertune.yml.template"), FIXTURE_HYPERTUNE).unwrap();
std::fs::write(
assets_root.path().join("rl/requirements-rl.txt.template"),
FIXTURE_RL_REQUIREMENTS,
)
.unwrap();
assets_root
}
#[tokio::test]
async fn test_create_rl_jobs_train_carries_the_full_ppo_hyperparameter_schema() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/train.yml"))
.await
.unwrap();
assert!(content.contains("policy: \"\""));
assert!(content.contains("total_timesteps:"));
assert!(content.contains("learning_rate:"));
assert!(content.contains("algorithm: ppo"));
}
#[tokio::test]
async fn test_create_rl_jobs_default_n_envs_to_one() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
for file_name in ["train.yml", "train-from-checkpoint.yml", "hypertune.yml"] {
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs").join(file_name))
.await
.unwrap();
assert!(content.contains("n_envs: 1"), "{file_name} missing n_envs: 1");
}
}
#[tokio::test]
async fn test_create_rl_jobs_writes_standard_catalog() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
for file_name in ["train.yml", "train-from-checkpoint.yml", "hypertune.yml"] {
let job_path = temp.path().join("configs/rl/jobs").join(file_name);
let content = tokio::fs::read_to_string(&job_path)
.await
.unwrap_or_else(|e| panic!("failed to read {}: {}", job_path.display(), e));
assert!(content.contains("name:"), "{file_name} missing name:");
assert!(content.contains("flow_type:"), "{file_name} missing flow_type:");
assert!(content.contains("algorithm: ppo"), "{file_name} missing algorithm: ppo");
}
}
#[tokio::test]
async fn test_create_rl_jobs_train_maps_to_the_train_flow() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/train.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: train"));
assert!(content.contains("algorithm: ppo"));
}
#[tokio::test]
async fn test_create_rl_jobs_train_from_checkpoint_maps_to_resume_run_flow() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/train-from-checkpoint.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: resume-run"));
assert!(!content.contains("resume_run_id"));
}
#[tokio::test]
async fn test_create_rl_jobs_hypertune_maps_to_tune_flow() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/hypertune.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: tune"));
assert!(content.contains("n_trials"));
}
#[tokio::test]
async fn test_create_rl_jobs_do_not_pin_a_task_name() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path(), assets_root.path()).await.unwrap();
for file in ["train.yml", "train-from-checkpoint.yml", "hypertune.yml"] {
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs").join(file))
.await
.unwrap();
assert!(!content.contains("task_name"), "{file} should not pin a task_name");
}
}
#[tokio::test]
async fn test_append_rl_requirements_preserves_existing_content() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
tokio::fs::write(temp.path().join("requirements.txt"), "onnx>=1.15.0\n")
.await
.unwrap();
bundle
.append_rl_requirements(temp.path(), assets_root.path())
.await
.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("requirements.txt"))
.await
.unwrap();
assert!(content.contains("onnx>=1.15.0"));
assert!(content.contains("mecha10-rl-task-runner"));
assert!(content.contains("mecha10-rl "));
}
#[tokio::test]
async fn test_append_rl_requirements_creates_file_if_missing() {
let temp = TempDir::new().unwrap();
let assets_root = stage_assets_root();
let bundle = RlBundleTemplates::new();
bundle
.append_rl_requirements(temp.path(), assets_root.path())
.await
.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("requirements.txt"))
.await
.unwrap();
assert!(content.contains("mecha10-rl-task-runner"));
}
}