lean_ctx_protocol/
rollout.rs1use crate::common::ValidationError;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct RolloutConfig {
15 #[serde(default)]
16 pub cohorts: Vec<RolloutCohort>,
17}
18
19impl RolloutConfig {
20 pub fn validate(&self) -> Result<(), ValidationError> {
22 let mut total = 0_u16;
23 for (index, cohort) in self.cohorts.iter().enumerate() {
24 if cohort.name.trim().is_empty() {
25 return Err(ValidationError::new(format!(
26 "cohorts[{index}].name must not be empty"
27 )));
28 }
29 if cohort.percentage == 0 {
30 return Err(ValidationError::new(format!(
31 "cohorts[{index}].percentage must be greater than zero"
32 )));
33 }
34 if self
35 .cohorts
36 .iter()
37 .take(index)
38 .any(|previous| previous.name == cohort.name)
39 {
40 return Err(ValidationError::new(format!(
41 "duplicate rollout cohort name: {}",
42 cohort.name
43 )));
44 }
45 total = total.saturating_add(u16::from(cohort.percentage));
46 if total > 100 {
47 return Err(ValidationError::new(
48 "rollout cohort percentages must total at most 100",
49 ));
50 }
51 }
52 Ok(())
53 }
54
55 pub fn assign(&self, task_id: &str) -> RolloutAssignment {
57 let bucket = bucket_for(task_id);
58 let mut upper_bound = 0_u8;
59 let mut selected = None;
60 for cohort in &self.cohorts {
61 upper_bound = upper_bound.saturating_add(cohort.percentage);
62 if bucket < upper_bound {
63 selected = Some(cohort);
64 break;
65 }
66 }
67
68 RolloutAssignment {
69 task_id: task_id.to_owned(),
70 bucket,
71 cohort: selected.map(|cohort| cohort.name.clone()),
72 eligibility_override: selected.is_some_and(|cohort| cohort.eligibility_override),
73 kill_switch: selected.is_some_and(|cohort| cohort.kill_switch),
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct RolloutCohort {
82 pub name: String,
83 pub percentage: u8,
84 pub eligibility_override: bool,
86 pub kill_switch: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct RolloutAssignment {
94 pub task_id: String,
95 pub bucket: u8,
97 pub cohort: Option<String>,
98 pub eligibility_override: bool,
99 pub kill_switch: bool,
100}
101
102impl RolloutAssignment {
103 pub fn is_unassigned(&self) -> bool {
105 self.cohort.is_none()
106 }
107}
108
109pub fn assign_rollout(task_id: &str, config: &RolloutConfig) -> RolloutAssignment {
111 config.assign(task_id)
112}
113
114fn bucket_for(task_id: &str) -> u8 {
115 let digest = Sha256::digest(task_id.as_bytes());
116 let value = u64::from_be_bytes(digest[..8].try_into().expect("SHA-256 has eight bytes"));
117 (value % 100) as u8
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn config() -> RolloutConfig {
125 RolloutConfig {
126 cohorts: vec![
127 RolloutCohort {
128 name: "internal-10".to_owned(),
129 percentage: 10,
130 eligibility_override: false,
131 kill_switch: false,
132 },
133 RolloutCohort {
134 name: "internal-50".to_owned(),
135 percentage: 50,
136 eligibility_override: true,
137 kill_switch: true,
138 },
139 ],
140 }
141 }
142
143 #[test]
144 fn rollout_types_round_trip_and_validate() {
145 let config = config();
146 let assignment = config.assign("task-1");
147 let config_json = serde_json::to_string(&config).expect("config serializes");
148 let assignment_json = serde_json::to_string(&assignment).expect("assignment serializes");
149 assert_eq!(
150 serde_json::from_str::<RolloutConfig>(&config_json).unwrap(),
151 config
152 );
153 assert_eq!(
154 serde_json::from_str::<RolloutAssignment>(&assignment_json).unwrap(),
155 assignment
156 );
157 config.validate().expect("valid rollout");
158 }
159
160 #[test]
161 fn assignment_is_sha_based_deterministic_and_metadata_only() {
162 let config = config();
163 let first = config.assign("task-1");
164 let second = config.assign("task-1");
165 assert_eq!(first, second);
166 assert!(first.bucket < 100);
167 assert_eq!(assign_rollout("task-1", &config), first);
168 assert_eq!(first.task_id, "task-1");
169 }
170
171 #[test]
172 fn invalid_rollout_percentages_are_rejected() {
173 let invalid = RolloutConfig {
174 cohorts: vec![
175 RolloutCohort {
176 name: "a".to_owned(),
177 percentage: 60,
178 eligibility_override: false,
179 kill_switch: false,
180 },
181 RolloutCohort {
182 name: "b".to_owned(),
183 percentage: 60,
184 eligibility_override: false,
185 kill_switch: false,
186 },
187 ],
188 };
189 assert!(invalid.validate().is_err());
190 }
191}