Skip to main content

perspt_sdk/prompt/
manifest.rs

1//! Section manifests and measured prompt activation (PSP-10 system 25,
2//! Gate AE).
3//!
4//! The base sections are the active baseline. An override whose rendered
5//! text differs from its base starts `Experimental` and activates only
6//! through a content-addressed [`PromptChangeRecord`] that passes the
7//! sample, safety, noninferiority, and benefit rules. Route preference and
8//! online statistics can never activate a section.
9
10use serde::{Deserialize, Serialize};
11
12use crate::canon::CanonicalEncoder;
13use crate::error::{Result, SdkError};
14
15use super::section::{PromptSectionId, PromptSectionVersion, PROMPT_DIGEST_TAG};
16
17/// Gate AE lifecycle state of an override.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ActivationState {
21    /// Runs only under `--allow-experimental-prompts`, which is ledgered.
22    Experimental,
23    /// Activated by a passing, digest-bound change record.
24    Active,
25}
26
27/// One manifest entry: a section or override's committed identity.
28#[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    /// Present on overrides only.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub activation: Option<ActivationState>,
42    /// The bound change-record digest, for `Active` overrides.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub change_record: Option<String>,
45}
46
47/// The committed, CLI-generated manifest of one section library.
48#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
49pub struct PromptManifest {
50    pub entries: Vec<ManifestEntry>,
51}
52
53impl PromptManifest {
54    /// The manifest digest that enters session provenance.
55    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/// Configuration bounds for activation. The floor may only be raised and
81/// the margin narrowed; invalid values fail at startup.
82#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83pub struct ActivationBounds {
84    /// Minimum paired tasks; never below 30.
85    pub min_tasks: u32,
86    /// Noninferiority margin ε ∈ [0, 0.05].
87    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    /// Startup validation of configured bounds.
101    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
118/// The number of paired percentile-bootstrap resamples Gate AE requires.
119pub const ACTIVATION_BOOTSTRAP_RESAMPLES: u64 = 10_000;
120
121/// The content-addressed evidence that activates one section override.
122#[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    /// Paired 95% bootstrap interval for the hidden hard-pass difference.
143    pub hard_pass_ci: (f64, f64),
144    /// Upper endpoint of the predeclared normalized cost difference CI.
145    pub cost_diff_upper: f64,
146    /// New escaped hard-gate regressions introduced by the candidate.
147    pub escaped_regressions: u32,
148    pub reviewer: String,
149    pub decision: String,
150}
151
152impl PromptChangeRecord {
153    /// The record's content address.
154    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    /// Gate AE: every activation condition, each independently falsifiable.
185    /// `override_hash` is the live override's hash — a record not bound to
186    /// it cannot activate it.
187    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        // Digest binding.
263        assert!(record()
264            .permits_activation("sha256:other", &bounds)
265            .is_err());
266        // Sample floor.
267        let mut r = record();
268        r.paired_tasks = 29;
269        assert!(r.permits_activation("sha256:override", &bounds).is_err());
270        // Resampling procedure.
271        let mut r = record();
272        r.resamples = 9_999;
273        assert!(r.permits_activation("sha256:override", &bounds).is_err());
274        // Escaped regression.
275        let mut r = record();
276        r.escaped_regressions = 1;
277        assert!(r.permits_activation("sha256:override", &bounds).is_err());
278        // Noninferiority.
279        let mut r = record();
280        r.hard_pass_ci = (-0.06, 0.0);
281        assert!(r.permits_activation("sha256:override", &bounds).is_err());
282        // Benefit: neither hard-pass lower bound > 0 nor cost upper < 0.
283        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        // Cost benefit alone suffices under noninferiority.
288        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}