Skip to main content

torsh_cli/commands/
train.rs

1//! Training operation commands.
2//!
3//! `torsh train start` runs a **real** optimisation loop: it builds a real
4//! neural network with [`torsh_nn`], computes a real loss, back-propagates
5//! through [`torsh_autograd`], and updates parameters with a real
6//! [`torsh_optim`] optimiser (see [`crate::commands::real_training`]).
7//!
8//! The current trainer targets a multi-layer perceptron on an
9//! **explicitly-synthetic** regression task. It never fabricates losses or
10//! gradients: every reported number is measured from the running model. Loading
11//! arbitrary real datasets / architectures is not yet wired, so those requests
12//! return an honest error rather than a fabricated loss curve.
13
14use 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 model training
27    Start(StartArgs),
28
29    /// Resume training from checkpoint
30    Resume(ResumeArgs),
31
32    /// Monitor training progress
33    Monitor(MonitorArgs),
34
35    /// Stop running training
36    Stop(StopArgs),
37}
38
39#[derive(Args)]
40pub struct StartArgs {
41    /// Training configuration file (JSON)
42    #[arg(short, long)]
43    pub config: PathBuf,
44
45    /// Dataset path
46    #[arg(short, long)]
47    pub data: PathBuf,
48
49    /// Number of epochs (full-batch optimisation steps)
50    #[arg(short, long, default_value = "10")]
51    pub epochs: usize,
52
53    /// Batch size (retained for CLI compatibility; the synthetic trainer uses full-batch steps)
54    #[arg(short, long, default_value = "32")]
55    pub batch_size: usize,
56
57    /// Learning rate
58    #[arg(short, long, default_value = "0.01")]
59    pub learning_rate: f64,
60
61    /// Enable distributed training
62    #[arg(long)]
63    pub distributed: bool,
64
65    /// Device to use for training (cpu, cuda, metal)
66    #[arg(long, default_value = "cpu")]
67    pub device: String,
68
69    /// Optimizer to use (currently: sgd)
70    #[arg(long, default_value = "sgd")]
71    pub optimizer: String,
72
73    /// Learning rate scheduler (constant, step, cosine)
74    #[arg(long, default_value = "constant")]
75    pub scheduler: String,
76
77    /// Enable mixed precision training
78    #[arg(long)]
79    pub mixed_precision: bool,
80
81    /// Gradient clipping threshold
82    #[arg(long)]
83    pub grad_clip: Option<f64>,
84
85    /// Save checkpoint every N epochs
86    #[arg(long, default_value = "5")]
87    pub save_every: usize,
88
89    /// Output directory for checkpoints and logs
90    #[arg(short, long, default_value = "./runs")]
91    pub output_dir: PathBuf,
92
93    /// Allow the explicitly-synthetic fallback dataset when the requested data
94    /// path cannot be loaded by the real data pipeline.
95    #[arg(long)]
96    pub allow_synthetic: bool,
97}
98
99#[derive(Args)]
100pub struct ResumeArgs {
101    /// Checkpoint file to resume from
102    #[arg(short = 'k', long)]
103    pub checkpoint: PathBuf,
104
105    /// Override epochs
106    #[arg(long)]
107    pub epochs: Option<usize>,
108}
109
110#[derive(Args)]
111pub struct MonitorArgs {
112    /// Training run ID or log directory
113    #[arg(short, long)]
114    pub run: PathBuf,
115
116    /// Follow logs in real-time
117    #[arg(short, long)]
118    pub follow: bool,
119}
120
121#[derive(Args)]
122pub struct StopArgs {
123    /// Training run ID
124    #[arg(short, long)]
125    pub run: String,
126
127    /// Force stop without graceful shutdown
128    #[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/// Model / training hyper-parameters recovered from the JSON config file.
142#[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/// Training metrics for monitoring, persisted as JSON.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153struct TrainingMetrics {
154    run_id: String,
155    /// Per-epoch training loss (real, measured MSE).
156    train_losses: Vec<f64>,
157    /// Per-epoch learning rate.
158    learning_rates: Vec<f64>,
159    /// Per-epoch wall-clock time in seconds.
160    epoch_times: Vec<f64>,
161    /// True when the trainer used the explicitly-synthetic fallback dataset.
162    synthetic_data: bool,
163}
164
165/// Final results reported to the user.
166#[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
222/// Run the real training loop and persist real metrics/checkpoints.
223async 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    // Currently only the explicitly-synthetic regression pipeline is wired end
231    // to end. Refuse to fabricate results for an unsupported real-data request.
232    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        // One real full-batch optimisation step per epoch.
282        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    // Save a final checkpoint with real parameters.
318    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    // Real resume requires reconstructing a live autograd model + optimiser state
341    // from disk, which is not yet wired. Return an honest error instead of
342    // fabricating a resumed run.
343    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    // The CLI does not manage a background training daemon, so there is no live
385    // process to signal. Be honest rather than pretending a stop succeeded.
386    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
395/// Load model / dataset hyper-parameters from a JSON config file.
396async 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
415/// Generate a unique run ID.
416fn 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/// A checkpoint containing real serialized model parameters.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426struct ModelCheckpoint {
427    run_id: String,
428    epoch: usize,
429    train_loss: f64,
430    /// Each entry is one parameter tensor: (name, shape, flattened f32 values).
431    parameters: Vec<(String, Vec<usize>, Vec<f32>)>,
432    timestamp: String,
433}
434
435/// Save a checkpoint with the model's real parameter values.
436async 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
470/// Save training metrics.
471async 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
477/// Load training metrics from file.
478async 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
484/// Display training metrics.
485fn 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
499/// Display recent log entries.
500async 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}