Skip to main content

qae_kernel/
certifier.rs

1// SPDX-License-Identifier: BUSL-1.1
2//! SafetyCertifier — orchestrates the certification pipeline.
3//!
4//! Takes a DomainAdapter, evaluates all constraint channels against a
5//! proposed action, applies decision logic, and builds a SafetyCertificate.
6
7use crate::KernelResult;
8use chrono::Utc;
9use std::collections::BTreeMap;
10
11use super::action::ProposedAction;
12use super::certificate::{
13    CertificationDecision, SafetyCertificate, SafetyCertificateBuilder, SafetyZone,
14};
15use super::domain::DomainAdapter;
16
17/// Configuration for the safety certifier.
18#[derive(Debug, Clone)]
19pub struct CertifierConfig {
20    /// Margin threshold above which the action is in the Safe zone.
21    pub safe_threshold: f64,
22    /// Margin threshold above which the action is in the Caution zone.
23    pub caution_threshold: f64,
24    /// Margin threshold at or below which the action is Blocked.
25    pub block_threshold: f64,
26    /// Margin threshold below which warnings are generated for individual channels.
27    pub warning_margin_threshold: f64,
28}
29
30impl Default for CertifierConfig {
31    fn default() -> Self {
32        Self {
33            safe_threshold: 0.6,
34            caution_threshold: 0.3,
35            block_threshold: 0.1,
36            warning_margin_threshold: 0.5,
37        }
38    }
39}
40
41/// Certify an action against a borrowed domain adapter.
42///
43/// This is the core certification pipeline as a free function, allowing callers
44/// to certify without transferring adapter ownership. Used by `SafetyCertifier`
45/// and directly by the gateway.
46///
47/// Flow:
48/// 1. Map action to state vector via domain adapter
49/// 2. Evaluate all constraint channels against the state
50/// 3. Find the bottleneck (minimum margin)
51/// 4. Apply decision logic (zone + decision)
52/// 5. Build and return the safety certificate
53pub fn certify_action(
54    adapter: &dyn DomainAdapter,
55    config: &CertifierConfig,
56    action: &dyn ProposedAction,
57) -> KernelResult<SafetyCertificate> {
58    let decided_at = Utc::now();
59
60    // Step 1: Map action to state vector
61    let state = adapter.map_action_to_state(action)?;
62
63    // Step 2: Evaluate all constraint channels
64    let channels = adapter.constraint_channels();
65    let mut margins = BTreeMap::new();
66
67    for channel in &channels {
68        let margin = channel.evaluate(&state)?.clamp(0.0, 1.0);
69        margins.insert(channel.name().to_string(), margin);
70    }
71
72    // Step 3: Find bottleneck
73    let (binding_channel, min_margin) = margins
74        .iter()
75        .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
76        .map(|(k, v)| (k.clone(), *v))
77        .unwrap_or_else(|| ("none".to_string(), 1.0));
78
79    // Step 4: Detect regime change using adapter's current state
80    let current_state = adapter.current_state().unwrap_or_else(|_| vec![0.0; state.len()]);
81    let regime_change = adapter.detect_regime_change(&current_state, &state);
82
83    // Step 5: Apply decision logic
84    let (decision, zone) = if regime_change {
85        (
86            CertificationDecision::EscalateToHuman {
87                reason: format!(
88                    "Regime change detected (binding constraint: {}, margin: {:.4})",
89                    binding_channel, min_margin
90                ),
91            },
92            // Regime change forces at least Caution zone — emitting Safe while
93            // escalating to human review would be a contradictory certificate.
94            if min_margin > config.caution_threshold {
95                SafetyZone::Caution
96            } else {
97                SafetyZone::Danger
98            },
99        )
100    } else if min_margin <= config.block_threshold {
101        (
102            CertificationDecision::Blocked {
103                reason: format!(
104                    "Margin {:.4} on '{}' is at or below block threshold {:.2}",
105                    min_margin, binding_channel, config.block_threshold
106                ),
107            },
108            SafetyZone::Danger,
109        )
110    } else if min_margin <= config.caution_threshold {
111        (
112            CertificationDecision::EscalateToHuman {
113                reason: format!(
114                    "Margin {:.4} on '{}' requires human review (threshold: {:.2})",
115                    min_margin, binding_channel, config.caution_threshold
116                ),
117            },
118            SafetyZone::Danger,
119        )
120    } else if min_margin <= config.safe_threshold {
121        let mut warnings = Vec::new();
122        for (name, margin) in &margins {
123            if *margin <= config.warning_margin_threshold {
124                warnings.push(format!(
125                    "Channel '{}' margin {:.4} below warning threshold",
126                    name, margin
127                ));
128            }
129        }
130        if warnings.is_empty() {
131            warnings.push(format!(
132                "Minimum margin {:.4} in caution zone",
133                min_margin
134            ));
135        }
136        (
137            CertificationDecision::CertifiedWithWarning { warnings },
138            SafetyZone::Caution,
139        )
140    } else {
141        (CertificationDecision::Certified, SafetyZone::Safe)
142    };
143
144    // Compute drift budget (distance to next zone boundary)
145    let drift_budget = if min_margin > config.safe_threshold {
146        min_margin - config.safe_threshold
147    } else if min_margin > config.caution_threshold {
148        min_margin - config.caution_threshold
149    } else if min_margin > config.block_threshold {
150        min_margin - config.block_threshold
151    } else {
152        0.0 // already blocked — no remaining budget
153    };
154
155    // Get domain payload
156    let domain_payload = adapter.format_domain_payload(&margins);
157
158    // Step 6: Build certificate
159    let mut builder = SafetyCertificateBuilder::new(
160        action.action_id().to_string(),
161        action.agent_id().to_string(),
162        decided_at,
163    )
164    .decision(decision)
165    .zone(zone)
166    .margins(margins)
167    .binding_constraint(binding_channel)
168    .drift_budget(drift_budget);
169
170    if let Some(payload) = domain_payload {
171        builder = builder.domain_payload(payload);
172    }
173
174    builder.build()
175}
176
177/// The safety certifier engine.
178pub struct SafetyCertifier {
179    adapter: Box<dyn DomainAdapter>,
180    config: CertifierConfig,
181}
182
183impl SafetyCertifier {
184    /// Create a new certifier with the given domain adapter and configuration.
185    pub fn new(adapter: Box<dyn DomainAdapter>, config: CertifierConfig) -> Self {
186        Self { adapter, config }
187    }
188
189    /// Certify a proposed action.
190    ///
191    /// Delegates to [`certify_action`] using the owned adapter.
192    pub fn certify(&self, action: &dyn ProposedAction) -> KernelResult<SafetyCertificate> {
193        certify_action(self.adapter.as_ref(), &self.config, action)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::action::{ActionPriority, SimpleAction, StateDelta};
201    use crate::constraint::ConstraintChannel;
202
203    struct FixedChannel {
204        name: String,
205        margin: f64,
206    }
207
208    impl ConstraintChannel for FixedChannel {
209        fn name(&self) -> &str {
210            &self.name
211        }
212        fn evaluate(&self, _state: &[f64]) -> KernelResult<f64> {
213            Ok(self.margin)
214        }
215        fn dimension_names(&self) -> Vec<String> {
216            vec!["x".into()]
217        }
218    }
219
220    struct TestAdapter {
221        channels: Vec<(String, f64)>,
222        regime_change: bool,
223    }
224
225    impl DomainAdapter for TestAdapter {
226        fn domain_name(&self) -> &str {
227            "test"
228        }
229        fn constraint_channels(&self) -> Vec<Box<dyn ConstraintChannel>> {
230            self.channels
231                .iter()
232                .map(|(name, margin)| -> Box<dyn ConstraintChannel> {
233                    Box::new(FixedChannel {
234                        name: name.clone(),
235                        margin: *margin,
236                    })
237                })
238                .collect()
239        }
240        fn map_action_to_state(&self, _action: &dyn ProposedAction) -> KernelResult<Vec<f64>> {
241            Ok(vec![1.0])
242        }
243        fn detect_regime_change(&self, _current: &[f64], _proposed: &[f64]) -> bool {
244            self.regime_change
245        }
246        fn format_domain_payload(
247            &self,
248            margins: &BTreeMap<String, f64>,
249        ) -> Option<serde_json::Value> {
250            Some(serde_json::to_value(margins).unwrap())
251        }
252    }
253
254    fn make_action() -> SimpleAction {
255        SimpleAction {
256            action_id: "act-1".into(),
257            agent_id: "agent-1".into(),
258            proposed_at: Utc::now(),
259            state_deltas: vec![StateDelta {
260                dimension: "x".into(),
261                from_value: 0.0,
262                to_value: 1.0,
263            }],
264            priority: ActionPriority::Standard,
265        }
266    }
267
268    #[test]
269    fn certifies_safe_action() {
270        let adapter = TestAdapter {
271            channels: vec![("ch1".into(), 0.8), ("ch2".into(), 0.9)],
272            regime_change: false,
273        };
274        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
275        let cert = certifier.certify(&make_action()).unwrap();
276
277        assert_eq!(cert.decision, CertificationDecision::Certified);
278        assert_eq!(cert.zone, SafetyZone::Safe);
279    }
280
281    #[test]
282    fn blocks_dangerous_action() {
283        let adapter = TestAdapter {
284            channels: vec![("ch1".into(), 0.05), ("ch2".into(), 0.9)],
285            regime_change: false,
286        };
287        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
288        let cert = certifier.certify(&make_action()).unwrap();
289
290        assert!(matches!(cert.decision, CertificationDecision::Blocked { .. }));
291        assert_eq!(cert.zone, SafetyZone::Danger);
292    }
293
294    #[test]
295    fn warns_on_moderate_action() {
296        let adapter = TestAdapter {
297            channels: vec![("ch1".into(), 0.45), ("ch2".into(), 0.9)],
298            regime_change: false,
299        };
300        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
301        let cert = certifier.certify(&make_action()).unwrap();
302
303        assert!(matches!(
304            cert.decision,
305            CertificationDecision::CertifiedWithWarning { .. }
306        ));
307        assert_eq!(cert.zone, SafetyZone::Caution);
308    }
309
310    #[test]
311    fn escalates_on_low_margin() {
312        let adapter = TestAdapter {
313            channels: vec![("ch1".into(), 0.2), ("ch2".into(), 0.9)],
314            regime_change: false,
315        };
316        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
317        let cert = certifier.certify(&make_action()).unwrap();
318
319        assert!(matches!(
320            cert.decision,
321            CertificationDecision::EscalateToHuman { .. }
322        ));
323        assert_eq!(cert.zone, SafetyZone::Danger);
324    }
325
326    #[test]
327    fn escalates_on_regime_change() {
328        let adapter = TestAdapter {
329            channels: vec![("ch1".into(), 0.8), ("ch2".into(), 0.9)],
330            regime_change: true,
331        };
332        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
333        let cert = certifier.certify(&make_action()).unwrap();
334
335        assert!(matches!(
336            cert.decision,
337            CertificationDecision::EscalateToHuman { .. }
338        ));
339    }
340
341    #[test]
342    fn drift_budget_correct() {
343        let adapter = TestAdapter {
344            channels: vec![("ch1".into(), 0.45)],
345            regime_change: false,
346        };
347        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
348        let cert = certifier.certify(&make_action()).unwrap();
349
350        let budget = cert.drift_budget.unwrap();
351        assert!((budget - 0.15).abs() < 1e-10);
352    }
353
354    #[test]
355    fn binding_constraint_identified() {
356        let adapter = TestAdapter {
357            channels: vec![
358                ("high".into(), 0.9),
359                ("low".into(), 0.4),
360                ("mid".into(), 0.7),
361            ],
362            regime_change: false,
363        };
364        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
365        let cert = certifier.certify(&make_action()).unwrap();
366
367        assert_eq!(cert.binding_constraint.as_deref(), Some("low"));
368    }
369
370    #[test]
371    fn domain_payload_included() {
372        let adapter = TestAdapter {
373            channels: vec![("ch1".into(), 0.8)],
374            regime_change: false,
375        };
376        let certifier = SafetyCertifier::new(Box::new(adapter), CertifierConfig::default());
377        let cert = certifier.certify(&make_action()).unwrap();
378
379        assert!(cert.domain_payload.is_some());
380        let payload = cert.domain_payload.unwrap();
381        assert!(payload.get("ch1").is_some());
382    }
383}