Skip to main content

perspt_sdk/
conformal.rs

1//! Calibrated risk budgets via conformal risk control (PSP-8 System 7).
2//!
3//! When autonomous commitment without per-effect approval is enabled, a
4//! validator's acceptance threshold is not an asserted constant: it is
5//! calibrated from the deployment's own ledger so the marginal accepted-unsafe
6//! rate is bounded by the declared budget `rho`. For a validator that accepts
7//! when `s(p) > theta`, the conformal threshold is
8//!
9//! ```text
10//! theta_hat = inf { theta in [0,1] : (n R_n(theta) + 1) / (n + 1) <= rho }
11//! ```
12//!
13//! where `R_n(theta)` is the empirical accepted-unsafe rate on the calibration
14//! set. A drift monitor compares the live score distribution to the calibration
15//! distribution; on divergence the calibration is flagged stale. A stale flag
16//! does **not** assert the conformal bound during the stale window, because
17//! exchangeability is broken. Scores below the conservative back-off reject;
18//! scores above it route mutations to approval. Read-only or independently
19//! deterministic-safe effects are handled before this risk-calibration gate.
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{Result, SdkError};
24
25/// One calibration sample: a validator score and whether the accepted state was
26/// later found unsafe (from undo/redo boundary or regression residuals).
27#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub struct CalibrationSample {
29    pub score: f64,
30    pub is_unsafe: bool,
31}
32
33impl CalibrationSample {
34    pub fn new(score: f64, is_unsafe: bool) -> Self {
35        Self { score, is_unsafe }
36    }
37}
38
39/// Empirical accepted-unsafe rate `R_n(theta)`: the fraction of calibration
40/// samples that would be accepted (`score > theta`) and are unsafe.
41pub fn accepted_unsafe_rate(samples: &[CalibrationSample], theta: f64) -> f64 {
42    if samples.is_empty() {
43        return 0.0;
44    }
45    let unsafe_accepted = samples
46        .iter()
47        .filter(|s| s.score > theta && s.is_unsafe)
48        .count();
49    unsafe_accepted as f64 / samples.len() as f64
50}
51
52/// Compute the conformal threshold `theta_hat` for a target budget `rho`.
53///
54/// Returns the infimum threshold whose conformal-adjusted accepted-unsafe rate
55/// is at most `rho`. If even rejecting everything cannot meet `rho` (when
56/// `1/(n+1) > rho`), returns `1.0` (accept nothing in `[0,1]`).
57///
58/// Crate-private on purpose: this raw computation carries no feasibility-floor
59/// signal, so a caller cannot distinguish "reject everything" from "not enough
60/// calibration". External callers go through [`conformal_threshold_checked`].
61pub(crate) fn conformal_threshold(samples: &[CalibrationSample], rho: f64) -> Result<f64> {
62    if !(0.0..=1.0).contains(&rho) {
63        return Err(SdkError::InvalidGate(format!(
64            "rho must be in [0,1]: {rho}"
65        )));
66    }
67    if samples.is_empty() {
68        return Err(SdkError::Domain(
69            "conformal calibration needs samples".into(),
70        ));
71    }
72    let n = samples.len() as f64;
73
74    // Candidate thresholds: 0.0 then each unique score, ascending. R_n is a step
75    // function that only changes at sample scores.
76    let mut candidates: Vec<f64> = vec![0.0];
77    let mut scores: Vec<f64> = samples.iter().map(|s| s.score).collect();
78    scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
79    candidates.extend(scores);
80
81    for theta in candidates {
82        let r = accepted_unsafe_rate(samples, theta);
83        let adjusted = (n * r + 1.0) / (n + 1.0);
84        if adjusted <= rho {
85            return Ok(theta);
86        }
87    }
88    Ok(1.0)
89}
90
91/// Two-sample Kolmogorov–Smirnov statistic `D = max |F_live(x) - F_calib(x)|`.
92pub fn ks_statistic(live: &[f64], calib: &[f64]) -> f64 {
93    if live.is_empty() || calib.is_empty() {
94        return 0.0;
95    }
96    let mut all: Vec<f64> = live.iter().chain(calib.iter()).copied().collect();
97    all.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
98    all.dedup();
99    let cdf =
100        |data: &[f64], x: f64| data.iter().filter(|&&v| v <= x).count() as f64 / data.len() as f64;
101    all.iter()
102        .map(|&x| (cdf(live, x) - cdf(calib, x)).abs())
103        .fold(0.0, f64::max)
104}
105
106/// Whether the live distribution has drifted from the calibration distribution.
107/// Uses the KS statistic against the asymptotic critical value at level `alpha`
108/// (`c(alpha) * sqrt((n+m)/(n*m))`, with `c(0.05) ≈ 1.36`).
109pub fn is_drifted(live: &[f64], calib: &[f64], alpha_c: f64) -> bool {
110    if live.is_empty() || calib.is_empty() {
111        return false;
112    }
113    let n = live.len() as f64;
114    let m = calib.len() as f64;
115    let critical = alpha_c * ((n + m) / (n * m)).sqrt();
116    ks_statistic(live, calib) > critical
117}
118
119/// Calibration state of the conformal threshold.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub enum CalibrationState {
122    /// Calibrated and exchangeable: the conformal bound may be asserted.
123    Calibrated { theta_hat: f64, rho: f64 },
124    /// Stale: exchangeability broken. The bound is NOT asserted; a back-off
125    /// threshold applies and recalibration runs in the background.
126    Stale { backoff_theta: f64, last_theta: f64 },
127}
128
129impl CalibrationState {
130    /// Build a calibrated state from samples.
131    pub fn calibrate(samples: &[CalibrationSample], rho: f64) -> Result<Self> {
132        match conformal_threshold_checked(samples, rho)? {
133            ThresholdOutcome::Feasible { theta_hat } => {
134                Ok(CalibrationState::Calibrated { theta_hat, rho })
135            }
136            ThresholdOutcome::InsufficientCalibration { have, need } => Err(SdkError::Domain(
137                format!("insufficient calibration: have {have} labeled samples, need {need}"),
138            )),
139        }
140    }
141
142    /// Transition to a stale state with a conservative back-off (the threshold
143    /// is inflated toward 1.0 to accept fewer candidates).
144    pub fn mark_stale(&self) -> Self {
145        let last = match self {
146            CalibrationState::Calibrated { theta_hat, .. } => *theta_hat,
147            CalibrationState::Stale { last_theta, .. } => *last_theta,
148        };
149        // Inflate halfway toward 1.0.
150        let backoff = (last + 1.0) / 2.0;
151        CalibrationState::Stale {
152            backoff_theta: backoff,
153            last_theta: last,
154        }
155    }
156
157    /// Whether the conformal accepted-unsafe bound may currently be asserted.
158    /// Only true while calibrated and exchangeable (PSP-8 System 7).
159    pub fn bound_is_asserted(&self) -> bool {
160        matches!(self, CalibrationState::Calibrated { .. })
161    }
162
163    fn active_threshold(&self) -> f64 {
164        match self {
165            CalibrationState::Calibrated { theta_hat, .. } => *theta_hat,
166            CalibrationState::Stale { backoff_theta, .. } => *backoff_theta,
167        }
168    }
169}
170
171/// The outcome of an autonomous-commit acceptance decision.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum AcceptOutcome {
175    /// Accepted under the asserted conformal bound (certified).
176    CertifiedAccept,
177    /// Routed to approval because the calibration bound is unavailable.
178    RouteToApproval,
179    /// Rejected: score below the active threshold.
180    Reject,
181}
182
183/// Decide autonomous acceptance for a validator score under the current
184/// calibration state.
185///
186/// During a stale window the backed-off threshold remains useful for routing,
187/// but no score alone authorizes a mutation: passing proposals route to
188/// approval and failing proposals reject. The conformal bound is never asserted
189/// in that window (PSP-9 Gate Q). The effect's risk class is deliberately not
190/// an input: risk gating happens in the admissibility kernel, and Theorem 5's
191/// bound does not sharpen with risk.
192pub fn decide(state: &CalibrationState, score: f64) -> AcceptOutcome {
193    let threshold = state.active_threshold();
194    match state {
195        CalibrationState::Calibrated { .. } => {
196            if score > threshold {
197                AcceptOutcome::CertifiedAccept
198            } else {
199                AcceptOutcome::Reject
200            }
201        }
202        CalibrationState::Stale { .. } => {
203            if score > threshold {
204                // Exchangeability is broken, so no mutation may commit from
205                // this score alone. Read-only and separately certified
206                // deterministic effects are admitted before this risk gate.
207                AcceptOutcome::RouteToApproval
208            } else {
209                AcceptOutcome::Reject
210            }
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// PSP-9 system 16: feasibility floor, strata, and readiness epochs
217// ---------------------------------------------------------------------------
218
219/// The sample floor a declared budget implies: the feasible set of Theorem 5
220/// is empty exactly when `rho < 1/(n+1)`, so `n >= 1/rho - 1` labeled
221/// samples are required before any proposal can be autonomously accepted at
222/// all. For the common `rho = 0.05` this is 19.
223pub fn sample_floor(rho: f64) -> Result<usize> {
224    if !(0.0..=1.0).contains(&rho) || rho == 0.0 {
225        return Err(SdkError::InvalidGate(format!(
226            "rho must be in (0,1]: {rho}"
227        )));
228    }
229    Ok((1.0 / rho - 1.0).ceil().max(0.0) as usize)
230}
231
232/// A threshold computation that names infeasibility instead of hiding it.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case", tag = "status")]
235pub enum ThresholdOutcome {
236    /// The finite-sample certificate is active at this threshold; acceptance
237    /// is strict (`score > theta`), ties rejected.
238    Feasible { theta_hat: f64 },
239    /// `rho < 1/(n+1)`: the feasible set is empty and everything would be
240    /// rejected. Reporting this as a satisfied budget is prohibited — it is
241    /// technically true and operationally a lie (Gate Q).
242    InsufficientCalibration { have: usize, need: usize },
243}
244
245/// Compute the threshold, reporting the feasibility floor explicitly.
246pub fn conformal_threshold_checked(
247    samples: &[CalibrationSample],
248    rho: f64,
249) -> Result<ThresholdOutcome> {
250    let need = sample_floor(rho)?;
251    if samples.len() < need {
252        return Ok(ThresholdOutcome::InsufficientCalibration {
253            have: samples.len(),
254            need,
255        });
256    }
257    let theta_hat = conformal_threshold(samples, rho)?;
258    Ok(ThresholdOutcome::Feasible { theta_hat })
259}
260
261/// The exchangeability stratum a calibration set is keyed by (PSP-9
262/// system 16). `(capability, model)` alone is not enough: different models
263/// fail differently, and so do different verifier suites and score
264/// definitions. Changing **any** field marks the calibration stale.
265#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
266pub struct CalibrationStratum {
267    pub domain_package: String,
268    pub domain_version: String,
269    pub effect_kind: String,
270    pub risk_class: String,
271    pub model_route: String,
272    pub verifier_suite_fingerprint: String,
273    pub tool_catalog_fingerprint: String,
274    pub policy_version: String,
275    pub score_definition: String,
276}
277
278/// Cold-start readiness (PSP-9 system 16): before `Active`, read-only work
279/// and deterministically contracted effects continue; other mutations route
280/// to approval while shadow decisions build the local set.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283pub enum CalibrationReadiness {
284    InsufficientSamples,
285    /// Decisions are recorded counterfactually; nothing is certified.
286    Shadow,
287    Active,
288    Stale,
289}
290
291/// Derive readiness from the sample count and stratum staleness.
292pub fn readiness(samples: usize, rho: f64, stratum_changed: bool) -> Result<CalibrationReadiness> {
293    if stratum_changed {
294        return Ok(CalibrationReadiness::Stale);
295    }
296    let need = sample_floor(rho)?;
297    if samples < need {
298        return Ok(if samples == 0 {
299            CalibrationReadiness::InsufficientSamples
300        } else {
301            CalibrationReadiness::Shadow
302        });
303    }
304    Ok(CalibrationReadiness::Active)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn samples() -> Vec<CalibrationSample> {
312        // Unsafe states tend to have low scores; safe states high scores.
313        vec![
314            CalibrationSample::new(0.1, true),
315            CalibrationSample::new(0.2, true),
316            CalibrationSample::new(0.3, true),
317            CalibrationSample::new(0.6, false),
318            CalibrationSample::new(0.7, false),
319            CalibrationSample::new(0.8, false),
320            CalibrationSample::new(0.9, false),
321            CalibrationSample::new(0.95, false),
322        ]
323    }
324
325    #[test]
326    fn threshold_excludes_unsafe_low_scores() {
327        // With a tight budget, theta_hat must rise above the unsafe scores.
328        let theta = conformal_threshold(&samples(), 0.2).unwrap();
329        assert!(
330            theta >= 0.3,
331            "theta={theta} should exclude unsafe scores <= 0.3"
332        );
333        // No unsafe sample is accepted above the threshold.
334        assert_eq!(accepted_unsafe_rate(&samples(), theta), 0.0);
335    }
336
337    #[test]
338    fn looser_budget_allows_lower_threshold() {
339        let tight = conformal_threshold(&samples(), 0.15).unwrap();
340        let loose = conformal_threshold(&samples(), 0.5).unwrap();
341        assert!(loose <= tight);
342    }
343
344    #[test]
345    fn impossible_budget_rejects_everything() {
346        // rho smaller than 1/(n+1) cannot be met even by rejecting all.
347        let theta = conformal_threshold(&samples(), 0.0).unwrap();
348        assert_eq!(theta, 1.0);
349    }
350
351    #[test]
352    fn rho_out_of_range_is_error() {
353        assert!(conformal_threshold(&samples(), 1.5).is_err());
354    }
355
356    #[test]
357    fn ks_detects_distribution_shift() {
358        let calib: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
359        let same: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
360        let shifted: Vec<f64> = (0..100).map(|i| 0.5 + i as f64 / 200.0).collect();
361        assert!(!is_drifted(&same, &calib, 1.36));
362        assert!(is_drifted(&shifted, &calib, 1.36));
363    }
364
365    #[test]
366    fn calibrated_state_asserts_bound_stale_does_not() {
367        let state = CalibrationState::calibrate(&samples(), 0.2).unwrap();
368        assert!(state.bound_is_asserted());
369        let stale = state.mark_stale();
370        assert!(!stale.bound_is_asserted());
371    }
372
373    #[test]
374    fn stale_window_backs_off_to_approval_instead_of_claiming_a_bound() {
375        let state = CalibrationState::calibrate(&samples(), 0.3)
376            .unwrap()
377            .mark_stale();
378        // A stale score can still route a proposal, but cannot commit it.
379        assert_eq!(decide(&state, 0.99), AcceptOutcome::RouteToApproval);
380    }
381
382    #[test]
383    fn calibrated_state_cannot_bypass_the_finite_sample_floor() {
384        let too_small = &samples()[..2];
385        assert!(CalibrationState::calibrate(too_small, 0.2).is_err());
386    }
387
388    #[test]
389    fn calibrated_window_certifies_accepts() {
390        let state = CalibrationState::calibrate(&samples(), 0.3).unwrap();
391        let threshold = match state {
392            CalibrationState::Calibrated { theta_hat, .. } => theta_hat,
393            _ => unreachable!(),
394        };
395        assert_eq!(
396            decide(&state, threshold + 0.05),
397            AcceptOutcome::CertifiedAccept
398        );
399        assert_eq!(decide(&state, 0.0), AcceptOutcome::Reject);
400    }
401
402    #[test]
403    fn mc_q_sub_feasible_target_reports_insufficient_calibration() {
404        // rho = 0.05 needs n >= 19; with 5 samples the outcome names the
405        // floor rather than presenting reject-all as a satisfied budget.
406        let samples: Vec<CalibrationSample> = (0..5)
407            .map(|i| CalibrationSample {
408                score: 0.5 + (i as f64) * 0.05,
409                is_unsafe: false,
410            })
411            .collect();
412        match conformal_threshold_checked(&samples, 0.05).unwrap() {
413            ThresholdOutcome::InsufficientCalibration { have, need } => {
414                assert_eq!(have, 5);
415                assert_eq!(need, 19);
416            }
417            other => panic!("expected insufficiency, got {other:?}"),
418        }
419    }
420
421    #[test]
422    fn mc_q_the_sample_floor_matches_the_theorem() {
423        assert_eq!(sample_floor(0.05).unwrap(), 19);
424        assert_eq!(sample_floor(0.5).unwrap(), 1);
425        assert!(sample_floor(0.0).is_err());
426    }
427
428    #[test]
429    fn mc_q_a_score_exactly_at_the_threshold_is_rejected() {
430        // Acceptance is strict: right-continuity of the strict loss is what
431        // makes the infimum attained, so ties reject.
432        let state = CalibrationState::Calibrated {
433            theta_hat: 0.7,
434            rho: 0.05,
435        };
436        assert_eq!(
437            decide(&state, 0.7),
438            AcceptOutcome::Reject,
439            "tie at the threshold must reject"
440        );
441        assert_eq!(decide(&state, 0.7 + 1e-9), AcceptOutcome::CertifiedAccept);
442    }
443
444    #[test]
445    fn mc_q_any_stratum_field_change_marks_stale() {
446        let stratum = CalibrationStratum {
447            domain_package: "coding".into(),
448            domain_version: "0.6.6".into(),
449            effect_kind: "apply_patch".into(),
450            risk_class: "medium".into(),
451            model_route: "anthropic::claude-opus-5".into(),
452            verifier_suite_fingerprint: "vs1".into(),
453            tool_catalog_fingerprint: "tc1".into(),
454            policy_version: "p1".into(),
455            score_definition: "s1".into(),
456        };
457        let mut failover = stratum.clone();
458        failover.model_route = "openai::gpt-5.5".into();
459        assert_ne!(stratum, failover, "automatic failover changes the stratum");
460        assert_eq!(
461            readiness(100, 0.05, stratum != failover).unwrap(),
462            CalibrationReadiness::Stale
463        );
464    }
465
466    #[test]
467    fn readiness_progresses_from_insufficient_through_shadow_to_active() {
468        assert_eq!(
469            readiness(0, 0.05, false).unwrap(),
470            CalibrationReadiness::InsufficientSamples
471        );
472        assert_eq!(
473            readiness(5, 0.05, false).unwrap(),
474            CalibrationReadiness::Shadow
475        );
476        assert_eq!(
477            readiness(19, 0.05, false).unwrap(),
478            CalibrationReadiness::Active
479        );
480    }
481}