1use serde::{Deserialize, Serialize};
5
6#[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#[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
105pub 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}