1use anyhow::Result;
15use clap::{Args, Subcommand};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use tracing::{info, warn};
19
20use crate::commands::real_training::{self, MlpConfig};
21use crate::config::Config;
22use crate::utils::{output, progress, time, validation};
23
24#[derive(Subcommand)]
25pub enum TrainCommands {
26 Start(StartArgs),
28
29 Resume(ResumeArgs),
31
32 Monitor(MonitorArgs),
34
35 Stop(StopArgs),
37}
38
39#[derive(Args)]
40pub struct StartArgs {
41 #[arg(short, long)]
43 pub config: PathBuf,
44
45 #[arg(short, long)]
47 pub data: PathBuf,
48
49 #[arg(short, long, default_value = "10")]
51 pub epochs: usize,
52
53 #[arg(short, long, default_value = "32")]
55 pub batch_size: usize,
56
57 #[arg(short, long, default_value = "0.01")]
59 pub learning_rate: f64,
60
61 #[arg(long)]
63 pub distributed: bool,
64
65 #[arg(long, default_value = "cpu")]
67 pub device: String,
68
69 #[arg(long, default_value = "sgd")]
71 pub optimizer: String,
72
73 #[arg(long, default_value = "constant")]
75 pub scheduler: String,
76
77 #[arg(long)]
79 pub mixed_precision: bool,
80
81 #[arg(long)]
83 pub grad_clip: Option<f64>,
84
85 #[arg(long, default_value = "5")]
87 pub save_every: usize,
88
89 #[arg(short, long, default_value = "./runs")]
91 pub output_dir: PathBuf,
92
93 #[arg(long)]
96 pub allow_synthetic: bool,
97}
98
99#[derive(Args)]
100pub struct ResumeArgs {
101 #[arg(short = 'k', long)]
103 pub checkpoint: PathBuf,
104
105 #[arg(long)]
107 pub epochs: Option<usize>,
108}
109
110#[derive(Args)]
111pub struct MonitorArgs {
112 #[arg(short, long)]
114 pub run: PathBuf,
115
116 #[arg(short, long)]
118 pub follow: bool,
119}
120
121#[derive(Args)]
122pub struct StopArgs {
123 #[arg(short, long)]
125 pub run: String,
126
127 #[arg(long)]
129 pub force: bool,
130}
131
132pub async fn execute(command: TrainCommands, _config: &Config, _output_format: &str) -> Result<()> {
133 match command {
134 TrainCommands::Start(args) => start_training(args).await,
135 TrainCommands::Resume(args) => resume_training(args).await,
136 TrainCommands::Monitor(args) => monitor_training(args).await,
137 TrainCommands::Stop(args) => stop_training(args).await,
138 }
139}
140
141#[derive(Debug, Clone)]
143struct TrainingConfig {
144 model_name: String,
145 input_dim: usize,
146 hidden_dim: usize,
147 output_dim: usize,
148 num_samples: usize,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153struct TrainingMetrics {
154 run_id: String,
155 train_losses: Vec<f64>,
157 learning_rates: Vec<f64>,
159 epoch_times: Vec<f64>,
161 synthetic_data: bool,
163}
164
165#[derive(Debug, Clone)]
167struct TrainingResults {
168 run_id: String,
169 epochs_completed: usize,
170 initial_train_loss: f64,
171 final_train_loss: f64,
172 synthetic_data: bool,
173 converged: bool,
174}
175
176async fn start_training(args: StartArgs) -> Result<()> {
177 validation::validate_file_exists(&args.config)?;
178 validation::validate_directory_exists(&args.data)?;
179 validation::validate_device(&args.device)?;
180
181 if args.optimizer.to_lowercase() != "sgd" {
182 warn!(
183 "optimizer '{}' is not yet wired into the real trainer; using SGD",
184 args.optimizer
185 );
186 }
187
188 let (results, total_duration) = time::measure_time(run_real_training(&args)).await;
189 let results = results?;
190
191 output::print_success("Training completed successfully!");
192 output::print_info(&format!(
193 "Total duration: {}",
194 time::format_duration(total_duration)
195 ));
196 if results.synthetic_data {
197 output::print_warning(
198 "NOTE: trained on an EXPLICITLY-SYNTHETIC regression dataset (not the files in --data). \
199 Reported losses are real measurements on that synthetic task.",
200 );
201 }
202 output::print_info(&format!(
203 "Initial training loss (MSE): {:.6}",
204 results.initial_train_loss
205 ));
206 output::print_info(&format!(
207 "Final training loss (MSE): {:.6}",
208 results.final_train_loss
209 ));
210 output::print_info(&format!("Epochs completed: {}", results.epochs_completed));
211 output::print_info(&format!("Run ID: {}", results.run_id));
212
213 if results.converged {
214 output::print_success("Training loss decreased over the run");
215 } else {
216 output::print_warning("Training loss did not decrease over the run");
217 }
218
219 Ok(())
220}
221
222async fn run_real_training(args: &StartArgs) -> Result<TrainingResults> {
224 info!("Starting real ToRSh training loop");
225 info!("Configuration: {}", args.config.display());
226
227 let cfg = load_training_config(&args.config).await?;
228 info!("Loaded training configuration: {}", cfg.model_name);
229
230 if !args.allow_synthetic {
233 return Err(anyhow::anyhow!(
234 "the CLI trainer can currently only train on its explicitly-synthetic regression task; \
235 re-run with --allow-synthetic to train on synthetic data, or use the library API to \
236 train on your real dataset. It will not fabricate a loss curve for '{}'.",
237 args.data.display()
238 ));
239 }
240
241 let model = real_training::build_mlp(&MlpConfig {
242 input_dim: cfg.input_dim,
243 hidden_dim: cfg.hidden_dim,
244 output_dim: cfg.output_dim,
245 })?;
246 info!(
247 "Built real MLP ({}->{}->{})",
248 cfg.input_dim, cfg.hidden_dim, cfg.output_dim
249 );
250
251 let data = real_training::synthetic_regression(
252 cfg.num_samples,
253 cfg.input_dim,
254 cfg.output_dim,
255 0x5eed_1234,
256 )?;
257
258 tokio::fs::create_dir_all(&args.output_dir).await?;
259 let run_id = generate_run_id();
260 let run_dir = args.output_dir.join(&run_id);
261 tokio::fs::create_dir_all(&run_dir).await?;
262 info!("Created training run directory: {}", run_dir.display());
263
264 let epochs = args.epochs.max(1);
265 let lr = args.learning_rate as f32;
266
267 let mut metrics = TrainingMetrics {
268 run_id: run_id.clone(),
269 train_losses: Vec::with_capacity(epochs),
270 learning_rates: Vec::with_capacity(epochs),
271 epoch_times: Vec::with_capacity(epochs),
272 synthetic_data: true,
273 };
274
275 let initial_train_loss = real_training::evaluate_loss(&model, &data)?;
276
277 let pb = progress::create_progress_bar(epochs as u64, "Training");
278 for epoch in 0..epochs {
279 let epoch_start = std::time::Instant::now();
280
281 let step_losses = real_training::train_regression(&model, &data, lr, 1)?;
283 let train_loss = step_losses.last().copied().unwrap_or(initial_train_loss);
284
285 metrics.train_losses.push(train_loss);
286 metrics.learning_rates.push(args.learning_rate);
287 metrics
288 .epoch_times
289 .push(epoch_start.elapsed().as_secs_f64());
290
291 pb.set_position(epoch as u64 + 1);
292
293 if (epoch + 1) % args.save_every == 0 {
294 let checkpoint_path = run_dir.join(format!("checkpoint_epoch_{}.json", epoch + 1));
295 save_checkpoint(&model, epoch, train_loss, &run_id, &checkpoint_path).await?;
296 }
297
298 let metrics_path = run_dir.join("training_metrics.json");
299 save_training_metrics(&metrics, &metrics_path).await?;
300
301 output::print_info(&format!(
302 "Epoch {}/{} - Train Loss (MSE): {:.6}",
303 epoch + 1,
304 epochs,
305 train_loss
306 ));
307 }
308 pb.finish_with_message("Training completed");
309
310 let final_train_loss = metrics
311 .train_losses
312 .last()
313 .copied()
314 .unwrap_or(initial_train_loss);
315 let converged = final_train_loss < initial_train_loss;
316
317 let final_ckpt = run_dir.join("final_model.json");
319 save_checkpoint(
320 &model,
321 epochs.saturating_sub(1),
322 final_train_loss,
323 &run_id,
324 &final_ckpt,
325 )
326 .await?;
327
328 Ok(TrainingResults {
329 run_id,
330 epochs_completed: metrics.train_losses.len(),
331 initial_train_loss,
332 final_train_loss,
333 synthetic_data: true,
334 converged,
335 })
336}
337
338async fn resume_training(args: ResumeArgs) -> Result<()> {
339 validation::validate_file_exists(&args.checkpoint)?;
340 Err(anyhow::anyhow!(
344 "resuming training from a checkpoint is not yet implemented in the CLI; \
345 the checkpoint at {} was validated but cannot be resumed. Use `torsh train start` \
346 or the library API.",
347 args.checkpoint.display()
348 ))
349}
350
351async fn monitor_training(args: MonitorArgs) -> Result<()> {
352 validation::validate_directory_exists(&args.run)?;
353
354 info!(
355 "Monitoring training progress for run: {}",
356 args.run.display()
357 );
358
359 let metrics_file = args.run.join("training_metrics.json");
360 let log_file = args.run.join("training.log");
361
362 if metrics_file.exists() {
363 let metrics = load_training_metrics(&metrics_file).await?;
364 display_training_metrics(&metrics);
365 } else {
366 output::print_warning("No metrics file found in the specified run directory");
367 }
368
369 if args.follow && log_file.exists() {
370 output::print_info("Real-time log following is not implemented; showing recent entries");
371 display_recent_logs(&log_file).await?;
372 } else if log_file.exists() {
373 output::print_info("Recent training log entries:");
374 display_recent_logs(&log_file).await?;
375 } else {
376 output::print_warning("No log file found in the specified run directory");
377 }
378
379 Ok(())
380}
381
382async fn stop_training(args: StopArgs) -> Result<()> {
383 info!("Attempting to stop training run: {}", args.run);
384 let _ = args.force;
387 output::print_warning(&format!(
388 "No background training process tracking is available; nothing to stop for run '{}'. \
389 CLI training runs are synchronous and stop when the command exits.",
390 args.run
391 ));
392 Ok(())
393}
394
395async fn load_training_config(config_path: &PathBuf) -> Result<TrainingConfig> {
397 info!(
398 "Loading training configuration from {}",
399 config_path.display()
400 );
401
402 let config_content = tokio::fs::read_to_string(config_path).await?;
403 let config: serde_json::Value = serde_json::from_str(&config_content)?;
404
405 let model = &config["model"];
406 Ok(TrainingConfig {
407 model_name: model["name"].as_str().unwrap_or("mlp").to_string(),
408 input_dim: model["input_dim"].as_u64().unwrap_or(16) as usize,
409 hidden_dim: model["hidden_dim"].as_u64().unwrap_or(32) as usize,
410 output_dim: model["output_dim"].as_u64().unwrap_or(1) as usize,
411 num_samples: config["data"]["num_samples"].as_u64().unwrap_or(256) as usize,
412 })
413}
414
415fn generate_run_id() -> String {
417 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
418 let suffix: String = (0..6)
419 .map(|_| char::from(b'a' + (fastrand::u8(0..26))))
420 .collect();
421 format!("run_{}_{}", timestamp, suffix)
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426struct ModelCheckpoint {
427 run_id: String,
428 epoch: usize,
429 train_loss: f64,
430 parameters: Vec<(String, Vec<usize>, Vec<f32>)>,
432 timestamp: String,
433}
434
435async fn save_checkpoint(
437 model: &torsh::nn::container::Sequential,
438 epoch: usize,
439 train_loss: f64,
440 run_id: &str,
441 checkpoint_path: &PathBuf,
442) -> Result<()> {
443 use torsh::nn::Module;
444
445 info!("Saving checkpoint to {}", checkpoint_path.display());
446
447 let mut parameters = Vec::new();
448 for (name, param) in model.parameters() {
449 let tensor = param.tensor();
450 let guard = tensor.read();
451 let shape = guard.shape().dims().to_vec();
452 let values = guard.to_vec()?;
453 parameters.push((name, shape, values));
454 }
455
456 let checkpoint = ModelCheckpoint {
457 run_id: run_id.to_string(),
458 epoch,
459 train_loss,
460 parameters,
461 timestamp: chrono::Local::now().to_rfc3339(),
462 };
463
464 let data = serde_json::to_vec_pretty(&checkpoint)?;
465 tokio::fs::write(checkpoint_path, data).await?;
466
467 Ok(())
468}
469
470async fn save_training_metrics(metrics: &TrainingMetrics, metrics_path: &PathBuf) -> Result<()> {
472 let metrics_data = serde_json::to_vec_pretty(metrics)?;
473 tokio::fs::write(metrics_path, metrics_data).await?;
474 Ok(())
475}
476
477async fn load_training_metrics(metrics_path: &PathBuf) -> Result<TrainingMetrics> {
479 let metrics_data = tokio::fs::read(metrics_path).await?;
480 let metrics: TrainingMetrics = serde_json::from_slice(&metrics_data)?;
481 Ok(metrics)
482}
483
484fn display_training_metrics(metrics: &TrainingMetrics) {
486 output::print_info(&format!("Run ID: {}", metrics.run_id));
487 output::print_info(&format!("Epochs completed: {}", metrics.train_losses.len()));
488 if metrics.synthetic_data {
489 output::print_warning("This run used the explicitly-synthetic regression dataset");
490 }
491
492 if let (Some(&first), Some(&last)) = (metrics.train_losses.first(), metrics.train_losses.last())
493 {
494 output::print_info(&format!("Initial training loss (MSE): {:.6}", first));
495 output::print_info(&format!("Final training loss (MSE): {:.6}", last));
496 }
497}
498
499async fn display_recent_logs(log_path: &PathBuf) -> Result<()> {
501 let log_content = tokio::fs::read_to_string(log_path).await?;
502 let lines: Vec<&str> = log_content.lines().collect();
503 let recent_lines = lines.iter().rev().take(20).rev();
504
505 for line in recent_lines {
506 println!("{}", line);
507 }
508
509 Ok(())
510}