1use 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#[derive(Debug, Clone)]
19pub struct CertifierConfig {
20 pub safe_threshold: f64,
22 pub caution_threshold: f64,
24 pub block_threshold: f64,
26 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
41pub 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 let state = adapter.map_action_to_state(action)?;
62
63 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 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 let current_state = adapter.current_state().unwrap_or_else(|_| vec![0.0; state.len()]);
81 let regime_change = adapter.detect_regime_change(¤t_state, &state);
82
83 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 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 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 };
154
155 let domain_payload = adapter.format_domain_payload(&margins);
157
158 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
177pub struct SafetyCertifier {
179 adapter: Box<dyn DomainAdapter>,
180 config: CertifierConfig,
181}
182
183impl SafetyCertifier {
184 pub fn new(adapter: Box<dyn DomainAdapter>, config: CertifierConfig) -> Self {
186 Self { adapter, config }
187 }
188
189 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}