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 SuccessRateByPrompt,
23 CostByPrompt,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub enum Binding {
28 GitCommit {
29 control_commit: String,
30 treatment_commit: String,
31 },
32 Branch {
33 control_branch: String,
34 treatment_branch: String,
35 },
36 ManualTag {
37 variant_field: String,
38 },
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Direction {
43 Decrease,
44 Increase,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub enum Criterion {
49 Delta {
50 direction: Direction,
51 target_pct: f64,
52 },
53 Absolute {
54 metric_value: f64,
55 },
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum State {
61 Draft,
62 Running,
63 Concluded,
64 Archived,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct Experiment {
69 pub id: String,
70 pub name: String,
71 pub hypothesis: String,
72 pub change_description: String,
73 pub metric: Metric,
74 pub binding: Binding,
75 pub duration_days: u32,
76 pub success_criterion: Criterion,
77 pub state: State,
78 pub created_at_ms: u64,
79 pub concluded_at_ms: Option<u64>,
80}
81
82impl Metric {
83 pub fn as_str(&self) -> &'static str {
84 match self {
85 Metric::TokensPerSession => "tokens_per_session",
86 Metric::CostPerSession => "cost_per_session",
87 Metric::SuccessRate => "success_rate",
88 Metric::ToolLoops => "tool_loops",
89 Metric::DurationMinutes => "duration_minutes",
90 Metric::FilesPerSession => "files_per_session",
91 Metric::SuccessRateByPrompt => "success_rate_by_prompt",
92 Metric::CostByPrompt => "cost_by_prompt",
93 }
94 }
95
96 pub fn parse(s: &str) -> Option<Metric> {
97 Some(match s {
98 "tokens_per_session" => Metric::TokensPerSession,
99 "cost_per_session" => Metric::CostPerSession,
100 "success_rate" => Metric::SuccessRate,
101 "tool_loops" => Metric::ToolLoops,
102 "duration_minutes" => Metric::DurationMinutes,
103 "files_per_session" => Metric::FilesPerSession,
104 "success_rate_by_prompt" => Metric::SuccessRateByPrompt,
105 "cost_by_prompt" => Metric::CostByPrompt,
106 _ => return None,
107 })
108 }
109}
110
111pub fn transition(state: State, action: &str) -> Option<State> {
113 Some(match (state, action) {
114 (State::Draft, "start") => State::Running,
115 (State::Running, "conclude") => State::Concluded,
116 (State::Concluded, "archive") => State::Archived,
117 _ => return None,
118 })
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn transitions_follow_spec_order() {
127 assert_eq!(transition(State::Draft, "start"), Some(State::Running));
128 assert_eq!(
129 transition(State::Running, "conclude"),
130 Some(State::Concluded)
131 );
132 assert_eq!(
133 transition(State::Concluded, "archive"),
134 Some(State::Archived)
135 );
136 }
137
138 #[test]
139 fn archived_is_terminal() {
140 assert_eq!(transition(State::Archived, "start"), None);
141 assert_eq!(transition(State::Archived, "conclude"), None);
142 assert_eq!(transition(State::Archived, "archive"), None);
143 }
144
145 #[test]
146 fn no_backward_transitions() {
147 assert_eq!(transition(State::Concluded, "start"), None);
148 assert_eq!(transition(State::Running, "archive"), None);
149 }
150
151 #[test]
152 fn metric_round_trip() {
153 for m in [
154 Metric::TokensPerSession,
155 Metric::CostPerSession,
156 Metric::SuccessRate,
157 Metric::ToolLoops,
158 Metric::DurationMinutes,
159 Metric::FilesPerSession,
160 ] {
161 assert_eq!(Metric::parse(m.as_str()), Some(m));
162 }
163 }
164}