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