Skip to main content

kaizen/experiment/
types.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Pure data for experiments. See `docs/experiments.md`.
3
4use serde::{Deserialize, Serialize};
5
6/// Variant a session falls into under a binding.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum Classification {
9    Control,
10    Treatment,
11    Excluded,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum Metric {
16    TokensPerSession,
17    CostPerSession,
18    SuccessRate,
19    ToolLoops,
20    DurationMinutes,
21    FilesPerSession,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum Binding {
26    GitCommit {
27        control_commit: String,
28        treatment_commit: String,
29    },
30    Branch {
31        control_branch: String,
32        treatment_branch: String,
33    },
34    ManualTag {
35        variant_field: String,
36    },
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum Direction {
41    Decrease,
42    Increase,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub enum Criterion {
47    Delta {
48        direction: Direction,
49        target_pct: f64,
50    },
51    Absolute {
52        metric_value: f64,
53    },
54}
55
56/// Lifecycle state. `Archived` is terminal.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub enum State {
59    Draft,
60    Running,
61    Concluded,
62    Archived,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct Experiment {
67    pub id: String,
68    pub name: String,
69    pub hypothesis: String,
70    pub change_description: String,
71    pub metric: Metric,
72    pub binding: Binding,
73    pub duration_days: u32,
74    pub success_criterion: Criterion,
75    pub state: State,
76    pub created_at_ms: u64,
77    pub concluded_at_ms: Option<u64>,
78}
79
80impl Metric {
81    pub fn as_str(&self) -> &'static str {
82        match self {
83            Metric::TokensPerSession => "tokens_per_session",
84            Metric::CostPerSession => "cost_per_session",
85            Metric::SuccessRate => "success_rate",
86            Metric::ToolLoops => "tool_loops",
87            Metric::DurationMinutes => "duration_minutes",
88            Metric::FilesPerSession => "files_per_session",
89        }
90    }
91
92    pub fn parse(s: &str) -> Option<Metric> {
93        Some(match s {
94            "tokens_per_session" => Metric::TokensPerSession,
95            "cost_per_session" => Metric::CostPerSession,
96            "success_rate" => Metric::SuccessRate,
97            "tool_loops" => Metric::ToolLoops,
98            "duration_minutes" => Metric::DurationMinutes,
99            "files_per_session" => Metric::FilesPerSession,
100            _ => return None,
101        })
102    }
103}
104
105/// Pure state-machine transition. Returns `Some(next)` when `action` is enabled.
106pub fn transition(state: State, action: &str) -> Option<State> {
107    Some(match (state, action) {
108        (State::Draft, "start") => State::Running,
109        (State::Running, "conclude") => State::Concluded,
110        (State::Concluded, "archive") => State::Archived,
111        _ => return None,
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn transitions_follow_spec_order() {
121        assert_eq!(transition(State::Draft, "start"), Some(State::Running));
122        assert_eq!(
123            transition(State::Running, "conclude"),
124            Some(State::Concluded)
125        );
126        assert_eq!(
127            transition(State::Concluded, "archive"),
128            Some(State::Archived)
129        );
130    }
131
132    #[test]
133    fn archived_is_terminal() {
134        assert_eq!(transition(State::Archived, "start"), None);
135        assert_eq!(transition(State::Archived, "conclude"), None);
136        assert_eq!(transition(State::Archived, "archive"), None);
137    }
138
139    #[test]
140    fn no_backward_transitions() {
141        assert_eq!(transition(State::Concluded, "start"), None);
142        assert_eq!(transition(State::Running, "archive"), None);
143    }
144
145    #[test]
146    fn metric_round_trip() {
147        for m in [
148            Metric::TokensPerSession,
149            Metric::CostPerSession,
150            Metric::SuccessRate,
151            Metric::ToolLoops,
152            Metric::DurationMinutes,
153            Metric::FilesPerSession,
154        ] {
155            assert_eq!(Metric::parse(m.as_str()), Some(m));
156        }
157    }
158}