1use 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 {
13 #[arg(short, long, default_value = "20")]
15 limit: usize,
16 },
17 Compare {
19 a: String,
21 b: String,
23 },
24 Show {
26 id: String,
28 },
29 Delete {
31 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}