Skip to main content

kaizen/shell/
exp.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen exp` — experiment CRUD + report rendering.
3
4use 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::{maybe_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(
244    workspace: Option<&Path>,
245    id: &str,
246    json_out: bool,
247    refresh: bool,
248) -> Result<String> {
249    let ws = workspace_path(workspace)?;
250    let cfg = config::load(&ws)?;
251    let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
252    let ws_str = ws.to_string_lossy().to_string();
253    maybe_scan_all_agents(&ws, &cfg, &ws_str, &store, refresh)?;
254    let exp_rec = exp_store::load_experiment(&store, id)?
255        .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
256    let (start_ms, end_ms) = window_for(&exp_rec);
257    let sessions = sessions_with_events_in(&store, &ws_str, start_ms, end_ms)?;
258    let manual = exp_store::manual_tags(&store, id)?;
259    let report = exp::run(&exp_rec, &sessions, &manual, &ws, false);
260    if json_out {
261        Ok(serde_json::to_string_pretty(&report)?)
262    } else {
263        Ok(exp::to_markdown(&report))
264    }
265}
266
267pub fn cmd_report(workspace: Option<&Path>, id: &str, json_out: bool, refresh: bool) -> Result<()> {
268    print!("{}", exp_report_text(workspace, id, json_out, refresh)?);
269    Ok(())
270}
271
272pub fn exp_conclude_text(workspace: Option<&Path>, id: &str) -> Result<String> {
273    let ws = workspace_path(workspace)?;
274    let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
275    let exp_rec = exp_store::load_experiment(&store, id)?
276        .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
277    let next = transition(exp_rec.state, "conclude")
278        .ok_or_else(|| anyhow!("cannot conclude from {:?}", exp_rec.state))?;
279    exp_store::set_state(&store, id, next, now_ms())?;
280    Ok(format!("concluded {id}\n"))
281}
282
283pub fn cmd_conclude(workspace: Option<&Path>, id: &str) -> Result<()> {
284    print!("{}", exp_conclude_text(workspace, id)?);
285    Ok(())
286}
287
288pub fn exp_start_text(workspace: Option<&Path>, id: &str) -> Result<String> {
289    let ws = workspace_path(workspace)?;
290    let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
291    let exp_rec = exp_store::load_experiment(&store, id)?
292        .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
293    let next = transition(exp_rec.state, "start")
294        .ok_or_else(|| anyhow!("cannot start from {:?}", exp_rec.state))?;
295    exp_store::set_state(&store, id, next, now_ms())?;
296    Ok(format!("started {id}\n"))
297}
298
299pub fn cmd_start(workspace: Option<&Path>, id: &str) -> Result<()> {
300    print!("{}", exp_start_text(workspace, id)?);
301    Ok(())
302}
303
304pub fn exp_archive_text(workspace: Option<&Path>, id: &str) -> Result<String> {
305    let ws = workspace_path(workspace)?;
306    let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
307    let exp_rec = exp_store::load_experiment(&store, id)?
308        .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
309    let next = transition(exp_rec.state, "archive")
310        .ok_or_else(|| anyhow!("cannot archive from {:?}", exp_rec.state))?;
311    exp_store::set_state(&store, id, next, now_ms())?;
312    Ok(format!("archived {id}\n"))
313}
314
315pub fn cmd_archive(workspace: Option<&Path>, id: &str) -> Result<()> {
316    print!("{}", exp_archive_text(workspace, id)?);
317    Ok(())
318}
319
320pub fn exp_power_text(
321    workspace: Option<&Path>,
322    metric: &str,
323    baseline_n: usize,
324    refresh: bool,
325) -> Result<String> {
326    use crate::experiment::stats::power;
327    use std::fmt::Write;
328
329    let ws = workspace_path(workspace)?;
330    let cfg = config::load(&ws)?;
331    let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
332    let ws_str = ws.to_string_lossy().to_string();
333    maybe_scan_all_agents(&ws, &cfg, &ws_str, &store, refresh)?;
334
335    let metric_val = Metric::parse(metric).ok_or_else(|| anyhow!("unknown metric: {metric}"))?;
336    let now = now_ms();
337    let lookback_ms = 90 * 86_400_000_u64;
338    let sessions = sessions_with_events_in(&store, &ws_str, now.saturating_sub(lookback_ms), now)?;
339    let session_records: Vec<crate::core::event::SessionRecord> =
340        sessions.iter().map(|(s, _)| s.clone()).collect();
341    let _ = session_records; // kept for future cluster key use
342    let values: Vec<f64> = sessions
343        .iter()
344        .filter_map(|(s, evs)| crate::experiment::metric::value_for(metric_val, s, evs))
345        .collect();
346
347    let mut out = String::new();
348    match power::mde(&values, baseline_n) {
349        None => writeln!(&mut out, "no data for metric {metric} in the last 90 days").unwrap(),
350        Some(r) => {
351            writeln!(&mut out, "metric:      {metric}").unwrap();
352            writeln!(&mut out, "baseline n:  {}", r.n_per_arm).unwrap();
353            writeln!(&mut out, "observed σ:  {:.3}", r.sigma).unwrap();
354            writeln!(&mut out, "MDE:         {:.3}", r.mde_absolute).unwrap();
355            if let Some(pct) = r.mde_pct {
356                writeln!(&mut out, "MDE %:       {:.1}%", pct).unwrap();
357            }
358            writeln!(
359                &mut out,
360                "\n(80% power · 95% CI · {n} sessions in baseline)",
361                n = values.len()
362            )
363            .unwrap();
364        }
365    }
366    Ok(out)
367}
368
369pub fn cmd_power(
370    workspace: Option<&Path>,
371    metric: &str,
372    baseline_n: usize,
373    refresh: bool,
374) -> Result<()> {
375    print!(
376        "{}",
377        exp_power_text(workspace, metric, baseline_n, refresh)?
378    );
379    Ok(())
380}
381
382fn window_for(e: &Experiment) -> (u64, u64) {
383    let end = e
384        .concluded_at_ms
385        .unwrap_or_else(|| e.created_at_ms + (e.duration_days as u64) * 86_400_000);
386    (e.created_at_ms, end.max(e.created_at_ms))
387}
388
389fn sessions_with_events_in(
390    store: &Store,
391    ws: &str,
392    start_ms: u64,
393    end_ms: u64,
394) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
395    let rows = store.retro_events_in_window(ws, start_ms, end_ms)?;
396    let mut by_id: std::collections::BTreeMap<String, (SessionRecord, Vec<Event>)> =
397        std::collections::BTreeMap::new();
398    for (s, e) in rows {
399        by_id
400            .entry(s.id.clone())
401            .or_insert_with(|| (s.clone(), Vec::new()))
402            .1
403            .push(e);
404    }
405    Ok(by_id.into_values().collect())
406}
407
408fn now_ms() -> u64 {
409    SystemTime::now()
410        .duration_since(UNIX_EPOCH)
411        .unwrap_or_default()
412        .as_millis() as u64
413}
414
415/// Deterministic UUIDv5 from experiment name + creation timestamp.
416/// Stable across devices so concurrent creation of same experiment yields same ID.
417fn deterministic_exp_id(name: &str, created_at_ms: u64) -> String {
418    // Application-level namespace: "kaizen:experiments" hashed via UUIDv5 with DNS ns.
419    const NS: uuid::Uuid = uuid::Uuid::from_bytes([
420        0x6b, 0x61, 0x69, 0x7a, 0x65, 0x6e, 0x3a, 0x65, 0x78, 0x70, 0x73, 0x00, 0x00, 0x00, 0x00,
421        0x01,
422    ]);
423    let key = format!("{name}:{created_at_ms}");
424    uuid::Uuid::new_v5(&NS, key.as_bytes()).to_string()
425}
426
427fn truncate(s: &str, max: usize) -> String {
428    if s.len() <= max {
429        return s.to_string();
430    }
431    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
432    out.push('…');
433    out
434}