1use crate::core::config;
5use crate::core::event::{Event, SessionRecord};
6use crate::core::repo::repo_head;
7use crate::experiment::store as exp_store;
8use crate::experiment::types::{
9 Binding, Classification, Criterion, Direction, Experiment, Metric, State, transition,
10};
11use crate::experiment::{self as exp};
12use crate::shell::cli::{scan_all_agents, workspace_path};
13use crate::store::Store;
14use anyhow::{Context, Result, anyhow};
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub struct NewArgs {
19 pub name: String,
20 pub hypothesis: String,
21 pub change: String,
22 pub metric: String,
23 pub bind: String,
24 pub duration_days: u32,
25 pub target_pct: f64,
26 pub control_commit: Option<String>,
27 pub treatment_commit: Option<String>,
28 pub control_branch: Option<String>,
29 pub treatment_branch: Option<String>,
30}
31
32pub fn exp_new_text(workspace: Option<&Path>, args: NewArgs) -> Result<String> {
33 let ws = workspace_path(workspace)?;
34 let db_path = ws.join(".kaizen/kaizen.db");
35 let store = Store::open(&db_path)?;
36 let metric =
37 Metric::parse(&args.metric).ok_or_else(|| anyhow!("unknown metric: {}", args.metric))?;
38 let binding = build_binding(&ws, &args)?;
39 let (direction, target_pct) = split_target(args.target_pct);
40 let created_at = now_ms();
41 let exp_rec = Experiment {
42 id: deterministic_exp_id(&args.name, created_at),
43 name: args.name.clone(),
44 hypothesis: args.hypothesis,
45 change_description: args.change,
46 metric,
47 binding,
48 duration_days: args.duration_days,
49 success_criterion: Criterion::Delta {
50 direction,
51 target_pct,
52 },
53 state: State::Draft,
54 created_at_ms: created_at,
55 concluded_at_ms: None,
56 guardrails: Vec::new(),
57 };
58 exp_store::save_experiment(&store, &exp_rec)?;
59 Ok(format!("created {} · {}\n", exp_rec.id, exp_rec.name))
60}
61
62pub fn cmd_new(workspace: Option<&Path>, args: NewArgs) -> Result<()> {
63 print!("{}", exp_new_text(workspace, args)?);
64 Ok(())
65}
66
67fn build_binding(ws: &Path, args: &NewArgs) -> Result<Binding> {
68 match args.bind.as_str() {
69 "git" => {
70 let treatment = match args.treatment_commit.clone() {
71 Some(v) => v,
72 None => repo_head(ws)?
73 .ok_or_else(|| anyhow!("not a git repo; pass --treatment-commit"))?,
74 };
75 let control = match args.control_commit.clone() {
76 Some(v) => v,
77 None => parent_of(ws, &treatment)?,
78 };
79 Ok(Binding::GitCommit {
80 control_commit: control,
81 treatment_commit: treatment,
82 })
83 }
84 "branch" => {
85 let control = args
86 .control_branch
87 .clone()
88 .ok_or_else(|| anyhow!("--control-branch required for --bind branch"))?;
89 let treatment = args
90 .treatment_branch
91 .clone()
92 .ok_or_else(|| anyhow!("--treatment-branch required for --bind branch"))?;
93 Ok(Binding::Branch {
94 control_branch: control,
95 treatment_branch: treatment,
96 })
97 }
98 "manual" => Ok(Binding::ManualTag {
99 variant_field: "variant".into(),
100 }),
101 other => Err(anyhow!("unsupported bind: {other} (use git|branch|manual)")),
102 }
103}
104
105fn split_target(pct: f64) -> (Direction, f64) {
106 if pct < 0.0 {
107 (Direction::Decrease, pct)
108 } else {
109 (Direction::Increase, pct)
110 }
111}
112
113fn parent_of(ws: &Path, commit: &str) -> Result<String> {
114 let out = std::process::Command::new("git")
115 .arg("-C")
116 .arg(ws)
117 .args(["rev-parse", &format!("{commit}^")])
118 .output()
119 .context("git rev-parse parent")?;
120 if !out.status.success() {
121 return Err(anyhow!(
122 "git rev-parse failed: {}",
123 String::from_utf8_lossy(&out.stderr)
124 ));
125 }
126 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
127}
128
129pub fn exp_list_text(workspace: Option<&Path>) -> Result<String> {
130 use std::fmt::Write;
131 let ws = workspace_path(workspace)?;
132 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
133 let all = exp_store::list_experiments(&store)?;
134 let mut out = String::new();
135 if all.is_empty() {
136 writeln!(&mut out, "(no experiments)").unwrap();
137 return Ok(out);
138 }
139 writeln!(
140 &mut out,
141 "{:<38} {:<10} {:<24} METRIC",
142 "ID", "STATE", "NAME"
143 )
144 .unwrap();
145 writeln!(&mut out, "{}", "-".repeat(96)).unwrap();
146 for e in &all {
147 writeln!(
148 &mut out,
149 "{:<38} {:<10?} {:<24} {}",
150 e.id,
151 e.state,
152 truncate(&e.name, 24),
153 e.metric.as_str()
154 )
155 .unwrap();
156 }
157 Ok(out)
158}
159
160pub fn cmd_list(workspace: Option<&Path>) -> Result<()> {
161 print!("{}", exp_list_text(workspace)?);
162 Ok(())
163}
164
165pub fn exp_status_text(workspace: Option<&Path>, id: &str) -> Result<String> {
166 use std::fmt::Write;
167 let ws = workspace_path(workspace)?;
168 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
169 let e = exp_store::load_experiment(&store, id)?
170 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
171 let mut out = String::new();
172 writeln!(&mut out, "id: {}", e.id).unwrap();
173 writeln!(&mut out, "name: {}", e.name).unwrap();
174 writeln!(&mut out, "state: {:?}", e.state).unwrap();
175 writeln!(&mut out, "metric: {}", e.metric.as_str()).unwrap();
176 writeln!(&mut out, "duration: {}d", e.duration_days).unwrap();
177 writeln!(&mut out, "created: {}", e.created_at_ms).unwrap();
178 if let Some(c) = e.concluded_at_ms {
179 writeln!(&mut out, "concluded: {c}").unwrap();
180 }
181 writeln!(&mut out, "hypothesis: {}", e.hypothesis).unwrap();
182 writeln!(&mut out, "change: {}", e.change_description).unwrap();
183 match &e.binding {
184 Binding::GitCommit {
185 control_commit,
186 treatment_commit,
187 } => {
188 writeln!(
189 &mut out,
190 "binding: git control={control_commit} treatment={treatment_commit}"
191 )
192 .unwrap();
193 }
194 Binding::Branch {
195 control_branch,
196 treatment_branch,
197 } => {
198 writeln!(
199 &mut out,
200 "binding: branch control={control_branch} treatment={treatment_branch}"
201 )
202 .unwrap();
203 }
204 Binding::ManualTag { variant_field } => {
205 writeln!(&mut out, "binding: manual({variant_field})").unwrap();
206 }
207 }
208 Ok(out)
209}
210
211pub fn cmd_status(workspace: Option<&Path>, id: &str) -> Result<()> {
212 print!("{}", exp_status_text(workspace, id)?);
213 Ok(())
214}
215
216pub fn exp_tag_text(
217 workspace: Option<&Path>,
218 id: &str,
219 session_id: &str,
220 variant: &str,
221) -> Result<String> {
222 let ws = workspace_path(workspace)?;
223 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
224 let v = match variant {
225 "control" => Classification::Control,
226 "treatment" => Classification::Treatment,
227 "excluded" => Classification::Excluded,
228 other => {
229 return Err(anyhow!(
230 "variant must be control|treatment|excluded, got {other}"
231 ));
232 }
233 };
234 exp_store::tag_session(&store, id, session_id, v)?;
235 Ok(format!("tagged {session_id} -> {variant} for {id}\n"))
236}
237
238pub fn cmd_tag(workspace: Option<&Path>, id: &str, session_id: &str, variant: &str) -> Result<()> {
239 print!("{}", exp_tag_text(workspace, id, session_id, variant)?);
240 Ok(())
241}
242
243pub fn exp_report_text(workspace: Option<&Path>, id: &str, json_out: bool) -> Result<String> {
244 let ws = workspace_path(workspace)?;
245 let cfg = config::load(&ws)?;
246 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
247 let ws_str = ws.to_string_lossy().to_string();
248 scan_all_agents(&ws, &cfg, &ws_str, &store)?;
249 let exp_rec = exp_store::load_experiment(&store, id)?
250 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
251 let (start_ms, end_ms) = window_for(&exp_rec);
252 let sessions = sessions_with_events_in(&store, &ws_str, start_ms, end_ms)?;
253 let manual = exp_store::manual_tags(&store, id)?;
254 let report = exp::run(&exp_rec, &sessions, &manual, &ws, false);
255 if json_out {
256 Ok(serde_json::to_string_pretty(&report)?)
257 } else {
258 Ok(exp::to_markdown(&report))
259 }
260}
261
262pub fn cmd_report(workspace: Option<&Path>, id: &str, json_out: bool) -> Result<()> {
263 print!("{}", exp_report_text(workspace, id, json_out)?);
264 Ok(())
265}
266
267pub fn exp_conclude_text(workspace: Option<&Path>, id: &str) -> Result<String> {
268 let ws = workspace_path(workspace)?;
269 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
270 let exp_rec = exp_store::load_experiment(&store, id)?
271 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
272 let next = transition(exp_rec.state, "conclude")
273 .ok_or_else(|| anyhow!("cannot conclude from {:?}", exp_rec.state))?;
274 exp_store::set_state(&store, id, next, now_ms())?;
275 Ok(format!("concluded {id}\n"))
276}
277
278pub fn cmd_conclude(workspace: Option<&Path>, id: &str) -> Result<()> {
279 print!("{}", exp_conclude_text(workspace, id)?);
280 Ok(())
281}
282
283pub fn exp_start_text(workspace: Option<&Path>, id: &str) -> Result<String> {
284 let ws = workspace_path(workspace)?;
285 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
286 let exp_rec = exp_store::load_experiment(&store, id)?
287 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
288 let next = transition(exp_rec.state, "start")
289 .ok_or_else(|| anyhow!("cannot start from {:?}", exp_rec.state))?;
290 exp_store::set_state(&store, id, next, now_ms())?;
291 Ok(format!("started {id}\n"))
292}
293
294pub fn cmd_start(workspace: Option<&Path>, id: &str) -> Result<()> {
295 print!("{}", exp_start_text(workspace, id)?);
296 Ok(())
297}
298
299pub fn exp_archive_text(workspace: Option<&Path>, id: &str) -> Result<String> {
300 let ws = workspace_path(workspace)?;
301 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
302 let exp_rec = exp_store::load_experiment(&store, id)?
303 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
304 let next = transition(exp_rec.state, "archive")
305 .ok_or_else(|| anyhow!("cannot archive from {:?}", exp_rec.state))?;
306 exp_store::set_state(&store, id, next, now_ms())?;
307 Ok(format!("archived {id}\n"))
308}
309
310pub fn cmd_archive(workspace: Option<&Path>, id: &str) -> Result<()> {
311 print!("{}", exp_archive_text(workspace, id)?);
312 Ok(())
313}
314
315pub fn exp_power_text(workspace: Option<&Path>, metric: &str, baseline_n: usize) -> Result<String> {
316 use crate::experiment::stats::power;
317 use std::fmt::Write;
318
319 let ws = workspace_path(workspace)?;
320 let cfg = config::load(&ws)?;
321 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
322 let ws_str = ws.to_string_lossy().to_string();
323 scan_all_agents(&ws, &cfg, &ws_str, &store)?;
324
325 let metric_val = Metric::parse(metric).ok_or_else(|| anyhow!("unknown metric: {metric}"))?;
326 let now = now_ms();
327 let lookback_ms = 90 * 86_400_000_u64;
328 let sessions = sessions_with_events_in(&store, &ws_str, now.saturating_sub(lookback_ms), now)?;
329 let session_records: Vec<crate::core::event::SessionRecord> =
330 sessions.iter().map(|(s, _)| s.clone()).collect();
331 let _ = session_records; let values: Vec<f64> = sessions
333 .iter()
334 .filter_map(|(s, evs)| crate::experiment::metric::value_for(metric_val, s, evs))
335 .collect();
336
337 let mut out = String::new();
338 match power::mde(&values, baseline_n) {
339 None => writeln!(&mut out, "no data for metric {metric} in the last 90 days").unwrap(),
340 Some(r) => {
341 writeln!(&mut out, "metric: {metric}").unwrap();
342 writeln!(&mut out, "baseline n: {}", r.n_per_arm).unwrap();
343 writeln!(&mut out, "observed σ: {:.3}", r.sigma).unwrap();
344 writeln!(&mut out, "MDE: {:.3}", r.mde_absolute).unwrap();
345 if let Some(pct) = r.mde_pct {
346 writeln!(&mut out, "MDE %: {:.1}%", pct).unwrap();
347 }
348 writeln!(
349 &mut out,
350 "\n(80% power · 95% CI · {n} sessions in baseline)",
351 n = values.len()
352 )
353 .unwrap();
354 }
355 }
356 Ok(out)
357}
358
359pub fn cmd_power(workspace: Option<&Path>, metric: &str, baseline_n: usize) -> Result<()> {
360 print!("{}", exp_power_text(workspace, metric, baseline_n)?);
361 Ok(())
362}
363
364fn window_for(e: &Experiment) -> (u64, u64) {
365 let end = e
366 .concluded_at_ms
367 .unwrap_or_else(|| e.created_at_ms + (e.duration_days as u64) * 86_400_000);
368 (e.created_at_ms, end.max(e.created_at_ms))
369}
370
371fn sessions_with_events_in(
372 store: &Store,
373 ws: &str,
374 start_ms: u64,
375 end_ms: u64,
376) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
377 let rows = store.retro_events_in_window(ws, start_ms, end_ms)?;
378 let mut by_id: std::collections::BTreeMap<String, (SessionRecord, Vec<Event>)> =
379 std::collections::BTreeMap::new();
380 for (s, e) in rows {
381 by_id
382 .entry(s.id.clone())
383 .or_insert_with(|| (s.clone(), Vec::new()))
384 .1
385 .push(e);
386 }
387 Ok(by_id.into_values().collect())
388}
389
390fn now_ms() -> u64 {
391 SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .unwrap_or_default()
394 .as_millis() as u64
395}
396
397fn deterministic_exp_id(name: &str, created_at_ms: u64) -> String {
400 const NS: uuid::Uuid = uuid::Uuid::from_bytes([
402 0x6b, 0x61, 0x69, 0x7a, 0x65, 0x6e, 0x3a, 0x65, 0x78, 0x70, 0x73, 0x00, 0x00, 0x00, 0x00,
403 0x01,
404 ]);
405 let key = format!("{name}:{created_at_ms}");
406 uuid::Uuid::new_v5(&NS, key.as_bytes()).to_string()
407}
408
409fn truncate(s: &str, max: usize) -> String {
410 if s.len() <= max {
411 return s.to_string();
412 }
413 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
414 out.push('…');
415 out
416}