1use crate::error::{OptimError, Result};
29use scirs2_core::numeric::Float;
30use std::collections::{HashMap, HashSet, VecDeque};
31use std::fmt::Debug;
32
33use super::types::{PropertyType, SystemProperty, SystemState, TransitionFunction};
34
35pub const DEFAULT_STATE_BOUND: usize = 100_000;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Term {
41 Epsilon,
43 Delta,
45 Variable(String),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CompareOp {
52 LessOrEqual,
54 Less,
56 GreaterOrEqual,
58 Greater,
60 Equal,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ContextFlag {
67 DataMinimization,
69 PurposeLimitation,
71 StorageLimitation,
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub enum StatePredicate {
78 Compare {
80 term: Term,
82 op: CompareOp,
84 value: f64,
86 },
87 Finite(Term),
89 Flag(ContextFlag),
91 Not(Box<StatePredicate>),
93}
94
95impl StatePredicate {
96 pub fn parse(specification: &str) -> Result<Self> {
103 let trimmed = specification.trim();
104 let inner = if let Some(rest) = strip_wrapper(trimmed, "AG") {
105 rest
106 } else if let Some(rest) = strip_wrapper(trimmed, "INV") {
107 rest
108 } else {
109 for unsupported in ["AF", "AX", "AU", "EG", "EF", "EX", "EU", "G", "F", "X", "U"] {
110 if strip_wrapper(trimmed, unsupported).is_some() {
111 return Err(OptimError::UnsupportedOperation(format!(
112 "the temporal operator `{unsupported}` in specification `{specification}` \
113 is not decided by this checker; only invariants (`AG(...)` / `INV(...)`) \
114 are supported"
115 )));
116 }
117 }
118 trimmed
119 };
120 Self::parse_atom(inner.trim(), specification)
121 }
122
123 fn parse_atom(text: &str, full: &str) -> Result<Self> {
125 if let Some(rest) = text.strip_prefix('!') {
126 return Ok(Self::Not(Box::new(Self::parse_atom(rest.trim(), full)?)));
127 }
128 if let Some(rest) = strip_wrapper(text, "finite") {
129 return Ok(Self::Finite(parse_term(rest.trim(), full)?));
130 }
131 match text {
132 "data_minimization" => return Ok(Self::Flag(ContextFlag::DataMinimization)),
133 "purpose_limitation" => return Ok(Self::Flag(ContextFlag::PurposeLimitation)),
134 "storage_limitation" => return Ok(Self::Flag(ContextFlag::StorageLimitation)),
135 _ => {}
136 }
137
138 for (token, op) in [
140 ("<=", CompareOp::LessOrEqual),
141 (">=", CompareOp::GreaterOrEqual),
142 ("==", CompareOp::Equal),
143 ("<", CompareOp::Less),
144 (">", CompareOp::Greater),
145 ] {
146 if let Some(position) = text.find(token) {
147 let left = text[..position].trim();
148 let right = text[position + token.len()..].trim();
149 let value: f64 = right.parse().map_err(|_| {
150 OptimError::InvalidParameter(format!(
151 "the right-hand side `{right}` of specification `{full}` is not a number"
152 ))
153 })?;
154 return Ok(Self::Compare {
155 term: parse_term(left, full)?,
156 op,
157 value,
158 });
159 }
160 }
161
162 Err(OptimError::UnsupportedOperation(format!(
163 "specification `{full}` is not an atomic predicate this checker understands; see the \
164 grammar in `privacy::enhanced_audit::model_checking`"
165 )))
166 }
167
168 pub fn evaluate<T: Float + Debug + Send + Sync + 'static>(
170 &self,
171 state: &SystemState<T>,
172 ) -> Result<bool> {
173 match self {
174 Self::Compare { term, op, value } => {
175 let left = read_term(term, state)?;
176 Ok(match op {
177 CompareOp::LessOrEqual => left <= *value,
178 CompareOp::Less => left < *value,
179 CompareOp::GreaterOrEqual => left >= *value,
180 CompareOp::Greater => left > *value,
181 CompareOp::Equal => left == *value,
182 })
183 }
184 Self::Finite(term) => Ok(read_term(term, state)?.is_finite()),
185 Self::Flag(flag) => Ok(match flag {
186 ContextFlag::DataMinimization => state.privacy_params.data_minimization,
187 ContextFlag::PurposeLimitation => state.privacy_params.purpose_limitation,
188 ContextFlag::StorageLimitation => state.privacy_params.storage_limitation,
189 }),
190 Self::Not(inner) => Ok(!inner.evaluate(state)?),
191 }
192 }
193}
194
195fn strip_wrapper<'a>(text: &'a str, name: &str) -> Option<&'a str> {
197 let rest = text.strip_prefix(name)?;
198 let rest = rest.trim_start();
199 let rest = rest.strip_prefix('(')?;
200 rest.strip_suffix(')')
201}
202
203fn parse_term(text: &str, full: &str) -> Result<Term> {
205 match text {
206 "epsilon" => Ok(Term::Epsilon),
207 "delta" => Ok(Term::Delta),
208 _ => match text.strip_prefix("var:") {
209 Some(name) if !name.is_empty() => Ok(Term::Variable(name.to_string())),
210 _ => Err(OptimError::InvalidParameter(format!(
211 "`{text}` in specification `{full}` is not a term (expected `epsilon`, `delta` or \
212 `var:<name>`)"
213 ))),
214 },
215 }
216}
217
218fn read_term<T: Float + Debug + Send + Sync + 'static>(
220 term: &Term,
221 state: &SystemState<T>,
222) -> Result<f64> {
223 match term {
224 Term::Epsilon => Ok(state.privacy_params.epsilon_budget),
225 Term::Delta => Ok(state.privacy_params.delta_budget),
226 Term::Variable(name) => {
227 let value = state.variables.get(name).ok_or_else(|| {
228 OptimError::InvalidState(format!(
229 "state `{}` has no variable named `{name}`, so the property cannot be decided",
230 state.id
231 ))
232 })?;
233 value.to_f64().ok_or_else(|| {
234 OptimError::InvalidState(format!(
235 "variable `{name}` of state `{}` cannot be represented as f64",
236 state.id
237 ))
238 })
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
245pub struct ModelCheckOutcome {
246 pub property: String,
248 pub holds: bool,
250 pub states_explored: usize,
252 pub counterexample: Option<String>,
254}
255
256pub struct SystemModel<T: Float + Debug + Send + Sync + 'static> {
258 states: Vec<SystemState<T>>,
260 transitions: HashMap<String, TransitionFunction<T>>,
262}
263
264impl<T: Float + Debug + Send + Sync + 'static> SystemModel<T> {
265 pub fn new() -> Self {
267 Self {
268 states: Vec::new(),
269 transitions: HashMap::new(),
270 }
271 }
272
273 pub fn add_initial_state(&mut self, state: SystemState<T>) {
275 self.states.push(state);
276 }
277
278 pub fn add_transition(&mut self, transition: TransitionFunction<T>) {
280 self.transitions.insert(transition.name.clone(), transition);
281 }
282
283 pub fn initial_state_count(&self) -> usize {
285 self.states.len()
286 }
287
288 pub fn transition_count(&self) -> usize {
290 self.transitions.len()
291 }
292
293 pub fn check_invariant(
299 &self,
300 predicate: &StatePredicate,
301 state_bound: usize,
302 ) -> Result<(bool, usize, Option<String>)> {
303 if self.states.is_empty() {
304 return Err(OptimError::InvalidState(
305 "the system model declares no initial state, so no property can be decided"
306 .to_string(),
307 ));
308 }
309
310 let mut seen: HashSet<String> = HashSet::new();
311 let mut queue: VecDeque<SystemState<T>> = VecDeque::new();
312 for state in &self.states {
313 if seen.insert(state.id.clone()) {
314 queue.push_back(state.clone());
315 }
316 }
317
318 let mut explored = 0usize;
319 while let Some(state) = queue.pop_front() {
320 explored += 1;
321 if explored > state_bound {
322 return Err(OptimError::ResourceError(format!(
323 "the reachable state space exceeded the exploration bound of {state_bound} \
324 states; the property is undecided (not verified)"
325 )));
326 }
327 if !predicate.evaluate(&state)? {
328 return Ok((false, explored, Some(state.id.clone())));
329 }
330 for transition in self.transitions.values() {
331 for successor in (transition.logic)(&state) {
332 if seen.insert(successor.id.clone()) {
333 queue.push_back(successor);
334 }
335 }
336 }
337 }
338
339 Ok((true, explored, None))
340 }
341}
342
343impl<T: Float + Debug + Send + Sync + 'static> Default for SystemModel<T> {
344 fn default() -> Self {
345 Self::new()
346 }
347}
348
349pub struct ModelChecker<T: Float + Debug + Send + Sync + 'static> {
351 model: SystemModel<T>,
353 properties: Vec<SystemProperty>,
355 state_bound: usize,
357}
358
359impl<T: Float + Debug + Send + Sync + 'static> ModelChecker<T> {
360 pub fn new() -> Self {
362 Self {
363 model: SystemModel::new(),
364 properties: Vec::new(),
365 state_bound: DEFAULT_STATE_BOUND,
366 }
367 }
368
369 pub fn set_state_bound(&mut self, state_bound: usize) -> Result<()> {
371 if state_bound == 0 {
372 return Err(OptimError::InvalidParameter(
373 "the state exploration bound must be positive".to_string(),
374 ));
375 }
376 self.state_bound = state_bound;
377 Ok(())
378 }
379
380 pub fn model_mut(&mut self) -> &mut SystemModel<T> {
382 &mut self.model
383 }
384
385 pub fn add_property(&mut self, property: SystemProperty) -> Result<()> {
390 match property.property_type {
391 PropertyType::Invariant | PropertyType::Safety => {
392 let _ = StatePredicate::parse(&property.specification)?;
393 self.properties.push(property);
394 Ok(())
395 }
396 PropertyType::Liveness | PropertyType::Temporal => {
397 Err(OptimError::UnsupportedOperation(format!(
398 "property `{}` is a {:?} property; this checker decides invariants only, and \
399 will not report an undecided property as verified",
400 property.name, property.property_type
401 )))
402 }
403 }
404 }
405
406 pub fn property_count(&self) -> usize {
408 self.properties.len()
409 }
410
411 pub fn check_property(&self, property: &SystemProperty) -> Result<ModelCheckOutcome> {
413 match property.property_type {
414 PropertyType::Invariant | PropertyType::Safety => {}
415 PropertyType::Liveness | PropertyType::Temporal => {
416 return Err(OptimError::UnsupportedOperation(format!(
417 "property `{}` is a {:?} property; deciding it needs a full temporal-logic \
418 model checker, which is not implemented here",
419 property.name, property.property_type
420 )))
421 }
422 }
423 let predicate = StatePredicate::parse(&property.specification)?;
424 let (holds, explored, counterexample) =
425 self.model.check_invariant(&predicate, self.state_bound)?;
426 Ok(ModelCheckOutcome {
427 property: property.name.clone(),
428 holds,
429 states_explored: explored,
430 counterexample,
431 })
432 }
433
434 pub fn check_all(&self) -> Result<Vec<ModelCheckOutcome>> {
439 if self.properties.is_empty() {
440 return Err(OptimError::InvalidState(
441 "no properties are registered with the model checker; there is nothing to verify"
442 .to_string(),
443 ));
444 }
445 self.properties
446 .iter()
447 .map(|property| self.check_property(property))
448 .collect()
449 }
450}
451
452impl<T: Float + Debug + Send + Sync + 'static> Default for ModelChecker<T> {
453 fn default() -> Self {
454 Self::new()
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use crate::privacy::enhanced_audit::types::PrivacyContext;
462
463 fn context(epsilon: f64) -> PrivacyContext {
464 PrivacyContext {
465 epsilon_budget: epsilon,
466 delta_budget: 1e-6,
467 privacy_mechanism: "dp_sgd".to_string(),
468 data_minimization: true,
469 purpose_limitation: true,
470 storage_limitation: false,
471 }
472 }
473
474 fn state(id: &str, spent: f64) -> SystemState<f64> {
475 let mut variables = HashMap::new();
476 variables.insert("spent".to_string(), spent);
477 SystemState {
478 id: id.to_string(),
479 variables,
480 privacy_params: context(spent),
481 }
482 }
483
484 fn spending_model(steps: usize) -> SystemModel<f64> {
486 let mut model = SystemModel::new();
487 model.add_initial_state(state("s0", 0.0));
488 let limit = steps;
489 model.add_transition(TransitionFunction {
490 name: "spend".to_string(),
491 logic: Box::new(move |current: &SystemState<f64>| {
492 let step = current
493 .id
494 .strip_prefix('s')
495 .and_then(|rest| rest.parse::<usize>().ok())
496 .unwrap_or(0);
497 if step >= limit {
498 Vec::new()
499 } else {
500 vec![state(&format!("s{}", step + 1), (step + 1) as f64 * 0.5)]
501 }
502 }),
503 });
504 model
505 }
506
507 #[test]
508 fn an_invariant_that_holds_is_reported_as_holding() {
509 let model = spending_model(4);
510 let predicate = match StatePredicate::parse("AG(var:spent <= 2.0)") {
511 Ok(predicate) => predicate,
512 Err(err) => panic!("parse failed: {err}"),
513 };
514 let (holds, explored, counterexample) =
515 match model.check_invariant(&predicate, DEFAULT_STATE_BOUND) {
516 Ok(outcome) => outcome,
517 Err(err) => panic!("check failed: {err}"),
518 };
519 assert!(holds);
520 assert_eq!(explored, 5, "s0..s4 inclusive");
521 assert!(counterexample.is_none());
522 }
523
524 #[test]
525 fn an_invariant_that_is_violated_yields_a_counterexample() {
526 let model = spending_model(4);
527 let predicate = match StatePredicate::parse("AG(var:spent <= 1.0)") {
528 Ok(predicate) => predicate,
529 Err(err) => panic!("parse failed: {err}"),
530 };
531 let (holds, _explored, counterexample) =
532 match model.check_invariant(&predicate, DEFAULT_STATE_BOUND) {
533 Ok(outcome) => outcome,
534 Err(err) => panic!("check failed: {err}"),
535 };
536 assert!(!holds, "spending reaches 2.0, which violates <= 1.0");
537 assert_eq!(counterexample.as_deref(), Some("s3"));
538 }
539
540 #[test]
541 fn exceeding_the_state_bound_is_an_error_not_a_pass() {
542 let model = spending_model(1000);
543 let predicate = match StatePredicate::parse("AG(var:spent >= 0.0)") {
544 Ok(predicate) => predicate,
545 Err(err) => panic!("parse failed: {err}"),
546 };
547 let outcome = model.check_invariant(&predicate, 10);
548 assert!(
549 outcome.is_err(),
550 "an unfinished exploration must not report success"
551 );
552 }
553
554 #[test]
555 fn a_model_with_no_initial_state_cannot_decide_anything() {
556 let model: SystemModel<f64> = SystemModel::new();
557 let predicate = match StatePredicate::parse("data_minimization") {
558 Ok(predicate) => predicate,
559 Err(err) => panic!("parse failed: {err}"),
560 };
561 assert!(model.check_invariant(&predicate, 10).is_err());
562 }
563
564 #[test]
565 fn liveness_and_temporal_properties_are_refused_explicitly() {
566 let mut checker: ModelChecker<f64> = ModelChecker::new();
567 let outcome = checker.add_property(SystemProperty {
568 name: "eventually_terminates".to_string(),
569 specification: "AF(var:spent >= 2.0)".to_string(),
570 property_type: PropertyType::Liveness,
571 });
572 let message = match outcome {
573 Err(err) => err.to_string(),
574 Ok(()) => panic!("a liveness property must not be accepted"),
575 };
576 assert!(message.contains("invariants only"), "got: {message}");
577 }
578
579 #[test]
580 fn unsupported_temporal_operators_are_named_in_the_error() {
581 let outcome = StatePredicate::parse("EF(var:spent >= 1.0)");
582 let message = match outcome {
583 Err(err) => err.to_string(),
584 Ok(_) => panic!("EF must not parse"),
585 };
586 assert!(message.contains("EF"), "got: {message}");
587 }
588
589 #[test]
590 fn checking_with_no_registered_properties_is_an_error() {
591 let checker: ModelChecker<f64> = ModelChecker::new();
592 assert!(
593 checker.check_all().is_err(),
594 "zero properties checked must not read as verified"
595 );
596 }
597
598 #[test]
599 fn the_checker_runs_registered_invariants_end_to_end() {
600 let mut checker: ModelChecker<f64> = ModelChecker::new();
601 {
602 let model = checker.model_mut();
603 model.add_initial_state(state("s0", 0.0));
604 model.add_transition(TransitionFunction {
605 name: "spend".to_string(),
606 logic: Box::new(|current: &SystemState<f64>| {
607 if current.id == "s0" {
608 vec![state("s1", 3.0)]
609 } else {
610 Vec::new()
611 }
612 }),
613 });
614 }
615 let ok = checker.add_property(SystemProperty {
616 name: "budget_bounded".to_string(),
617 specification: "AG(epsilon <= 1.0)".to_string(),
618 property_type: PropertyType::Safety,
619 });
620 assert!(ok.is_ok());
621
622 let outcomes = match checker.check_all() {
623 Ok(outcomes) => outcomes,
624 Err(err) => panic!("check_all failed: {err}"),
625 };
626 assert_eq!(outcomes.len(), 1);
627 assert!(!outcomes[0].holds, "s1 spends 3.0 > 1.0");
628 assert_eq!(outcomes[0].counterexample.as_deref(), Some("s1"));
629 }
630
631 #[test]
632 fn every_atom_of_the_grammar_evaluates() {
633 let good = state("ok", 0.25);
634 let cases: [(&str, bool); 9] = [
635 ("epsilon <= 1.0", true),
636 ("epsilon > 1.0", false),
637 ("delta < 0.001", true),
638 ("var:spent == 0.25", true),
639 ("finite(var:spent)", true),
640 ("data_minimization", true),
641 ("purpose_limitation", true),
642 ("storage_limitation", false),
643 ("!storage_limitation", true),
644 ];
645 for (specification, expected) in cases {
646 let predicate = match StatePredicate::parse(specification) {
647 Ok(predicate) => predicate,
648 Err(err) => panic!("`{specification}` failed to parse: {err}"),
649 };
650 let value = match predicate.evaluate(&good) {
651 Ok(value) => value,
652 Err(err) => panic!("`{specification}` failed to evaluate: {err}"),
653 };
654 assert_eq!(value, expected, "specification `{specification}`");
655 }
656 }
657
658 #[test]
659 fn a_missing_state_variable_is_an_error_not_false() {
660 let predicate = match StatePredicate::parse("var:absent <= 1.0") {
661 Ok(predicate) => predicate,
662 Err(err) => panic!("parse failed: {err}"),
663 };
664 assert!(predicate.evaluate(&state("s", 0.0)).is_err());
665 }
666
667 #[test]
668 fn nonsense_specifications_are_rejected() {
669 assert!(StatePredicate::parse("this is not a predicate").is_err());
670 assert!(StatePredicate::parse("epsilon <= not_a_number").is_err());
671 assert!(StatePredicate::parse("unknown_term <= 1.0").is_err());
672 }
673}