1use serde::{Deserialize, Serialize};
11
12use crate::canon::CanonicalEncoder;
13use crate::error::{Result, SdkError};
14
15use super::section::{PromptSectionId, PromptSectionVersion, PROMPT_DIGEST_TAG};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ActivationState {
21 Experimental,
23 Active,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ManifestEntry {
30 pub id: PromptSectionId,
31 pub version: PromptSectionVersion,
32 pub stage: String,
33 pub role: String,
34 pub required: bool,
35 pub priority: u16,
36 pub max_bytes: usize,
37 pub owner: String,
38 pub content_hash: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub activation: Option<ActivationState>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub change_record: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
49pub struct PromptManifest {
50 pub entries: Vec<ManifestEntry>,
51}
52
53impl PromptManifest {
54 pub fn digest(&self) -> String {
56 let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
57 encoder.text("manifest");
58 for entry in &self.entries {
59 encoder
60 .text(&entry.id.0)
61 .u64(u64::from(entry.version.0))
62 .text(&entry.stage)
63 .text(&entry.role)
64 .bool(entry.required)
65 .u64(u64::from(entry.priority))
66 .u64(entry.max_bytes as u64)
67 .text(&entry.owner)
68 .text(&entry.content_hash)
69 .text(match &entry.activation {
70 None => "base",
71 Some(ActivationState::Experimental) => "experimental",
72 Some(ActivationState::Active) => "active",
73 })
74 .text(entry.change_record.as_deref().unwrap_or(""));
75 }
76 encoder.digest()
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83pub struct ActivationBounds {
84 pub min_tasks: u32,
86 pub noninferiority_margin: f64,
88}
89
90impl Default for ActivationBounds {
91 fn default() -> Self {
92 Self {
93 min_tasks: 30,
94 noninferiority_margin: 0.05,
95 }
96 }
97}
98
99impl ActivationBounds {
100 pub fn validate(&self) -> Result<()> {
102 if self.min_tasks < 30 {
103 return Err(SdkError::Domain(format!(
104 "activation_min_tasks {} is below the 30-task floor",
105 self.min_tasks
106 )));
107 }
108 if !(0.0..=0.05).contains(&self.noninferiority_margin) {
109 return Err(SdkError::Domain(format!(
110 "noninferiority_margin {} outside [0, 0.05]",
111 self.noninferiority_margin
112 )));
113 }
114 Ok(())
115 }
116}
117
118pub const ACTIVATION_BOOTSTRAP_RESAMPLES: u64 = 10_000;
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct PromptChangeRecord {
124 pub base_id: PromptSectionId,
125 pub base_version: PromptSectionVersion,
126 pub base_hash: String,
127 pub override_id: PromptSectionId,
128 pub override_version: PromptSectionVersion,
129 pub override_hash: String,
130 pub baseline_manifest_digest: String,
131 pub candidate_manifest_digest: String,
132 pub route: String,
133 pub stage: String,
134 pub benchmark_digest: String,
135 pub task_order_seed: u64,
136 pub resampling_seed: u64,
137 pub resamples: u64,
138 pub model_revision: String,
139 pub catalog_digest: String,
140 pub budgets: String,
141 pub paired_tasks: u32,
142 pub hard_pass_ci: (f64, f64),
144 pub cost_diff_upper: f64,
146 pub escaped_regressions: u32,
148 pub reviewer: String,
149 pub decision: String,
150}
151
152impl PromptChangeRecord {
153 pub fn digest(&self) -> String {
155 let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
156 encoder
157 .text("change-record")
158 .text(&self.base_id.0)
159 .u64(u64::from(self.base_version.0))
160 .text(&self.base_hash)
161 .text(&self.override_id.0)
162 .u64(u64::from(self.override_version.0))
163 .text(&self.override_hash)
164 .text(&self.baseline_manifest_digest)
165 .text(&self.candidate_manifest_digest)
166 .text(&self.route)
167 .text(&self.stage)
168 .text(&self.benchmark_digest)
169 .u64(self.task_order_seed)
170 .u64(self.resampling_seed)
171 .u64(self.resamples)
172 .text(&self.model_revision)
173 .text(&self.catalog_digest)
174 .text(&self.budgets)
175 .u64(u64::from(self.paired_tasks))
176 .text(&format!("{:?}", self.hard_pass_ci))
177 .text(&format!("{:?}", self.cost_diff_upper))
178 .u64(u64::from(self.escaped_regressions))
179 .text(&self.reviewer)
180 .text(&self.decision);
181 encoder.digest()
182 }
183
184 pub fn permits_activation(
188 &self,
189 live_override_hash: &str,
190 bounds: &ActivationBounds,
191 ) -> Result<()> {
192 bounds.validate()?;
193 let refuse = |reason: &str| Err(SdkError::Domain(format!("activation refused: {reason}")));
194 if self.override_hash != live_override_hash {
195 return refuse("record is not bound to this override's content hash");
196 }
197 if self.paired_tasks < bounds.min_tasks {
198 return refuse("paired sample below the task floor");
199 }
200 if self.resamples != ACTIVATION_BOOTSTRAP_RESAMPLES {
201 return refuse("resampling procedure differs from the declared 10,000");
202 }
203 if self.escaped_regressions > 0 {
204 return refuse("candidate escaped a hard-gate regression");
205 }
206 let (lower, _upper) = self.hard_pass_ci;
207 if lower < -bounds.noninferiority_margin {
208 return refuse("hard-pass difference fails noninferiority");
209 }
210 if lower <= 0.0 && self.cost_diff_upper >= 0.0 {
211 return refuse("neither a hard-pass benefit nor a cost benefit is demonstrated");
212 }
213 Ok(())
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn record() -> PromptChangeRecord {
222 PromptChangeRecord {
223 base_id: PromptSectionId("graph_plan/update_protocol".into()),
224 base_version: PromptSectionVersion(1),
225 base_hash: "sha256:base".into(),
226 override_id: PromptSectionId("graph_plan/update_protocol".into()),
227 override_version: PromptSectionVersion(1),
228 override_hash: "sha256:override".into(),
229 baseline_manifest_digest: "sha256:m0".into(),
230 candidate_manifest_digest: "sha256:m1".into(),
231 route: "genai/Qwen".into(),
232 stage: "graph_plan".into(),
233 benchmark_digest: "sha256:bench".into(),
234 task_order_seed: 7,
235 resampling_seed: 11,
236 resamples: ACTIVATION_BOOTSTRAP_RESAMPLES,
237 model_revision: "qwen-3.8".into(),
238 catalog_digest: "sha256:catalog".into(),
239 budgets: "default".into(),
240 paired_tasks: 30,
241 hard_pass_ci: (0.01, 0.09),
242 cost_diff_upper: 0.2,
243 escaped_regressions: 0,
244 reviewer: "vikrant".into(),
245 decision: "activate".into(),
246 }
247 }
248
249 #[test]
250 fn a_passing_record_activates_and_is_content_addressed() {
251 let record = record();
252 let bounds = ActivationBounds::default();
253 assert!(record
254 .permits_activation("sha256:override", &bounds)
255 .is_ok());
256 assert_eq!(record.digest(), record.digest());
257 }
258
259 #[test]
260 fn each_activation_condition_is_independently_falsifiable() {
261 let bounds = ActivationBounds::default();
262 assert!(record()
264 .permits_activation("sha256:other", &bounds)
265 .is_err());
266 let mut r = record();
268 r.paired_tasks = 29;
269 assert!(r.permits_activation("sha256:override", &bounds).is_err());
270 let mut r = record();
272 r.resamples = 9_999;
273 assert!(r.permits_activation("sha256:override", &bounds).is_err());
274 let mut r = record();
276 r.escaped_regressions = 1;
277 assert!(r.permits_activation("sha256:override", &bounds).is_err());
278 let mut r = record();
280 r.hard_pass_ci = (-0.06, 0.0);
281 assert!(r.permits_activation("sha256:override", &bounds).is_err());
282 let mut r = record();
284 r.hard_pass_ci = (-0.01, 0.05);
285 r.cost_diff_upper = 0.1;
286 assert!(r.permits_activation("sha256:override", &bounds).is_err());
287 let mut r = record();
289 r.hard_pass_ci = (-0.01, 0.05);
290 r.cost_diff_upper = -0.02;
291 assert!(r.permits_activation("sha256:override", &bounds).is_ok());
292 }
293
294 #[test]
295 fn configured_bounds_are_floor_and_range_checked() {
296 assert!(ActivationBounds {
297 min_tasks: 29,
298 noninferiority_margin: 0.05
299 }
300 .validate()
301 .is_err());
302 assert!(ActivationBounds {
303 min_tasks: 30,
304 noninferiority_margin: 0.06
305 }
306 .validate()
307 .is_err());
308 assert!(ActivationBounds {
309 min_tasks: 50,
310 noninferiority_margin: 0.0
311 }
312 .validate()
313 .is_ok());
314 }
315}