zernel/commands/
exp.rs

1// Copyright (C) 2026 Dyber, Inc. — Proprietary
2
3use crate::experiments::compare;
4use crate::experiments::store::ExperimentStore;
5use crate::experiments::tracker;
6use anyhow::Result;
7use clap::Subcommand;
8
9#[derive(Subcommand)]
10pub enum ExpCommands {
11    /// List all experiments
12    List {
13        /// Maximum number of experiments to show
14        #[arg(short, long, default_value = "20")]
15        limit: usize,
16    },
17    /// Compare two experiments
18    Compare {
19        /// First experiment ID
20        a: String,
21        /// Second experiment ID
22        b: String,
23    },
24    /// Show details of an experiment
25    Show {
26        /// Experiment ID
27        id: String,
28    },
29    /// Delete an experiment
30    Delete {
31        /// Experiment ID
32        id: String,
33    },
34}
35
36pub async fn run(cmd: ExpCommands) -> Result<()> {
37    let db_path = tracker::experiments_db_path();
38    let store = ExperimentStore::open(&db_path)?;
39
40    match cmd {
41        ExpCommands::List { limit } => {
42            let experiments = store.list(limit)?;
43            if experiments.is_empty() {
44                println!("No experiments yet. Run `zernel run <script>` to create one.");
45                return Ok(());
46            }
47
48            let header = format!(
49                "{:<28} {:<24} {:<10} {:>10} {:>10} {:>10}",
50                "ID", "Name", "Status", "Loss", "Acc", "Duration"
51            );
52            println!("{header}");
53            println!("{}", "-".repeat(95));
54
55            for exp in &experiments {
56                let loss = exp
57                    .metrics
58                    .get("loss")
59                    .map(|v| format!("{v:.4}"))
60                    .unwrap_or_else(|| "-".into());
61                let acc = exp
62                    .metrics
63                    .get("accuracy")
64                    .map(|v| format!("{v:.4}"))
65                    .unwrap_or_else(|| "-".into());
66                let duration = exp
67                    .duration_secs
68                    .map(format_duration)
69                    .unwrap_or_else(|| "-".into());
70
71                println!(
72                    "{:<28} {:<24} {:<10} {:>10} {:>10} {}",
73                    exp.id, exp.name, exp.status, loss, acc, duration
74                );
75            }
76        }
77        ExpCommands::Compare { a, b } => {
78            let exp_a = store
79                .get(&a)?
80                .ok_or_else(|| anyhow::anyhow!("experiment not found: {a}"))?;
81            let exp_b = store
82                .get(&b)?
83                .ok_or_else(|| anyhow::anyhow!("experiment not found: {b}"))?;
84            println!("{}", compare::compare(&exp_a, &exp_b));
85        }
86        ExpCommands::Show { id } => {
87            let exp = store
88                .get(&id)?
89                .ok_or_else(|| anyhow::anyhow!("experiment not found: {id}"))?;
90
91            println!("Experiment: {}", exp.id);
92            println!("  Name:       {}", exp.name);
93            println!("  Status:     {}", exp.status);
94            println!("  Script:     {}", exp.script.as_deref().unwrap_or("-"));
95            println!("  Git commit: {}", exp.git_commit.as_deref().unwrap_or("-"));
96            println!(
97                "  Created:    {}",
98                exp.created_at.format("%Y-%m-%d %H:%M:%S UTC")
99            );
100            if let Some(f) = exp.finished_at {
101                println!("  Finished:   {}", f.format("%Y-%m-%d %H:%M:%S UTC"));
102            }
103            if let Some(d) = exp.duration_secs {
104                println!("  Duration:   {}", format_duration(d));
105            }
106
107            if !exp.hyperparams.is_empty() {
108                println!("\n  Hyperparameters:");
109                let mut sorted: Vec<_> = exp.hyperparams.iter().collect();
110                sorted.sort_by_key(|(k, _)| (*k).clone());
111                for (k, v) in sorted {
112                    println!("    {k}: {v}");
113                }
114            }
115
116            if !exp.metrics.is_empty() {
117                println!("\n  Metrics:");
118                let mut sorted: Vec<_> = exp.metrics.iter().collect();
119                sorted.sort_by_key(|(k, _)| (*k).clone());
120                for (k, v) in sorted {
121                    println!("    {k}: {v:.6}");
122                }
123            }
124        }
125        ExpCommands::Delete { id } => {
126            if store.delete(&id)? {
127                println!("Deleted experiment: {id}");
128            } else {
129                println!("Experiment not found: {id}");
130            }
131        }
132    }
133    Ok(())
134}
135
136fn format_duration(secs: f64) -> String {
137    if secs < 60.0 {
138        format!("{secs:.1}s")
139    } else if secs < 3600.0 {
140        format!("{:.0}m {:.0}s", secs / 60.0, secs % 60.0)
141    } else {
142        format!("{:.0}h {:.0}m", secs / 3600.0, (secs % 3600.0) / 60.0)
143    }
144}