Skip to main content

lean_ctx_protocol/
rollout.rs

1//! Public rollout cohort contracts and deterministic assignment reference.
2//!
3//! Assignment only maps a task identifier to configured cohort metadata. It
4//! does not check eligibility, bypass a policy, or select a model/provider.
5//! Production rollout intelligence remains in `lean-ctx-enterprise` (Class D).
6
7use crate::common::ValidationError;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11/// Versioned rollout configuration made of ordered percentage cohorts.
12#[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    /// Validate names, percentages, and ordered-cohort bounds.
21    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    /// Deterministically assign a task to the ordered cohort ranges.
56    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/// One ordered cohort in a rollout configuration.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct RolloutCohort {
82    pub name: String,
83    pub percentage: u8,
84    /// Metadata for the external eligibility policy; never applied here.
85    pub eligibility_override: bool,
86    /// Metadata indicating that the external rollout owner has disabled work.
87    pub kill_switch: bool,
88}
89
90/// Stable assignment result derived from a task identifier and cohort config.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct RolloutAssignment {
94    pub task_id: String,
95    /// SHA-256-derived bucket in the inclusive range 0..=99.
96    pub bucket: u8,
97    pub cohort: Option<String>,
98    pub eligibility_override: bool,
99    pub kill_switch: bool,
100}
101
102impl RolloutAssignment {
103    /// Return whether the task fell outside all configured cohort ranges.
104    pub fn is_unassigned(&self) -> bool {
105        self.cohort.is_none()
106    }
107}
108
109/// Standalone form for callers that do not need the method syntax.
110pub 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}