Skip to main content

optirs_core/privacy/enhanced_audit/
verification.rs

1//! Formal verification of the properties this crate can actually decide.
2//!
3//! # The defect this replaces
4//!
5//! `FormalVerificationEngine::verify_all_properties` iterated
6//! `verification_rules`, which `new()` initialised empty and nothing ever
7//! wrote, so it returned `Ok(vec![])` -- read by every caller as "all
8//! properties verified, zero failures" -- for any input whatsoever.
9//!
10//! Two changes make the type honest:
11//!
12//! 1. [`FormalVerificationEngine::new`] registers a real default rule set
13//!    over the released vector and the privacy context, each rule an exact
14//!    decidable check.
15//! 2. An empty rule set is an **error**. "Nothing was checked" and "everything
16//!    passed" are different claims and must not share a representation.
17//!
18//! Each rule result carries a SHA-256 commitment over the exact vector that
19//! was checked, so a verification record cannot later be re-attributed to a
20//! different release.
21
22use crate::error::{OptimError, Result};
23use scirs2_core::ndarray::Array1;
24use scirs2_core::numeric::Float;
25use std::fmt::Debug;
26
27use super::hashing::{canonical_array_bytes, sha256};
28use super::model_checking::{ModelCheckOutcome, ModelChecker};
29use super::proofs::ProofSystem;
30use super::types::{
31    Axiom, FormalVerificationRule, PrivacyContext, ProofResult, ProofStrategy, SystemProperty,
32    VerificationCriticality, VerificationResult,
33};
34
35/// Build a verification result for a decided check.
36fn decided(
37    verified: bool,
38    message: impl Into<String>,
39    proof: Option<Vec<u8>>,
40) -> VerificationResult {
41    VerificationResult {
42        verified,
43        proof,
44        // These checks are exact predicates over the released values, not
45        // statistical estimates: the confidence in the verdict is 1.
46        confidence: 1.0,
47        message: message.into(),
48    }
49}
50
51/// A commitment to the vector a verdict was computed over.
52fn commitment<T: Float + Debug + Send + Sync + 'static>(data: &Array1<T>) -> Option<Vec<u8>> {
53    canonical_array_bytes(data)
54        .ok()
55        .map(|bytes| sha256(&[&bytes]).to_vec())
56}
57
58/// The default rule set: exact, decidable properties of a private release.
59pub fn default_privacy_rules<T: Float + Debug + Send + Sync + 'static>(
60) -> Vec<FormalVerificationRule<T>> {
61    vec![
62        FormalVerificationRule {
63            name: "released_values_are_finite".to_string(),
64            specification: "AG(forall i. finite(data[i]))".to_string(),
65            criticality: VerificationCriticality::Safety,
66            verify_fn: Box::new(|data: &Array1<T>, _context: &PrivacyContext| {
67                let offender = data.iter().position(|value| !value.is_finite());
68                match offender {
69                    None => decided(
70                        true,
71                        format!("all {} released values are finite", data.len()),
72                        commitment(data),
73                    ),
74                    Some(index) => decided(
75                        false,
76                        format!(
77                            "element {index} of the release is not finite; an infinite or NaN \
78                             gradient defeats the noise calibration"
79                        ),
80                        commitment(data),
81                    ),
82                }
83            }),
84        },
85        FormalVerificationRule {
86            name: "release_is_non_empty".to_string(),
87            specification: "AG(len(data) > 0)".to_string(),
88            criticality: VerificationCriticality::Correctness,
89            verify_fn: Box::new(|data: &Array1<T>, _context: &PrivacyContext| {
90                decided(
91                    !data.is_empty(),
92                    if data.is_empty() {
93                        "the release is empty, so no property of it can be verified".to_string()
94                    } else {
95                        format!("the release carries {} values", data.len())
96                    },
97                    commitment(data),
98                )
99            }),
100        },
101        FormalVerificationRule {
102            name: "l2_norm_is_finite".to_string(),
103            specification: "AG(finite(||data||_2))".to_string(),
104            criticality: VerificationCriticality::Safety,
105            verify_fn: Box::new(|data: &Array1<T>, _context: &PrivacyContext| {
106                let sum_of_squares = data
107                    .iter()
108                    .filter_map(|value| value.to_f64())
109                    .map(|value| value * value)
110                    .sum::<f64>();
111                let norm = sum_of_squares.sqrt();
112                decided(
113                    norm.is_finite(),
114                    format!("the L2 norm of the release is {norm}"),
115                    commitment(data),
116                )
117            }),
118        },
119        FormalVerificationRule {
120            name: "epsilon_budget_is_positive_and_finite".to_string(),
121            specification: "AG(epsilon > 0 && finite(epsilon))".to_string(),
122            criticality: VerificationCriticality::Correctness,
123            verify_fn: Box::new(|_data: &Array1<T>, context: &PrivacyContext| {
124                let epsilon = context.epsilon_budget;
125                decided(
126                    epsilon.is_finite() && epsilon > 0.0,
127                    format!(
128                        "epsilon is {epsilon}; a non-positive or infinite epsilon is not a privacy \
129                         guarantee"
130                    ),
131                    None,
132                )
133            }),
134        },
135        FormalVerificationRule {
136            name: "delta_budget_is_in_the_unit_interval".to_string(),
137            specification: "AG(0 <= delta && delta < 1)".to_string(),
138            criticality: VerificationCriticality::Correctness,
139            verify_fn: Box::new(|_data: &Array1<T>, context: &PrivacyContext| {
140                let delta = context.delta_budget;
141                decided(
142                    delta.is_finite() && (0.0..1.0).contains(&delta),
143                    format!("delta is {delta}; it must lie in [0, 1)"),
144                    None,
145                )
146            }),
147        },
148        FormalVerificationRule {
149            name: "privacy_mechanism_is_named".to_string(),
150            specification: "AG(mechanism != \"\")".to_string(),
151            criticality: VerificationCriticality::Correctness,
152            verify_fn: Box::new(|_data: &Array1<T>, context: &PrivacyContext| {
153                let named = !context.privacy_mechanism.trim().is_empty();
154                decided(
155                    named,
156                    if named {
157                        format!("mechanism is `{}`", context.privacy_mechanism)
158                    } else {
159                        "no privacy mechanism is recorded for this release".to_string()
160                    },
161                    None,
162                )
163            }),
164        },
165        FormalVerificationRule {
166            name: "gdpr_data_handling_flags_are_declared".to_string(),
167            specification: "AG(data_minimization && purpose_limitation && storage_limitation)"
168                .to_string(),
169            criticality: VerificationCriticality::Optional,
170            verify_fn: Box::new(|_data: &Array1<T>, context: &PrivacyContext| {
171                let mut missing = Vec::new();
172                if !context.data_minimization {
173                    missing.push("data_minimization");
174                }
175                if !context.purpose_limitation {
176                    missing.push("purpose_limitation");
177                }
178                if !context.storage_limitation {
179                    missing.push("storage_limitation");
180                }
181                decided(
182                    missing.is_empty(),
183                    if missing.is_empty() {
184                        "all three GDPR data-handling principles are declared".to_string()
185                    } else {
186                        format!(
187                            "undeclared data-handling principles: {}",
188                            missing.join(", ")
189                        )
190                    },
191                    None,
192                )
193            }),
194        },
195    ]
196}
197
198/// Formal verification engine.
199pub struct FormalVerificationEngine<T: Float + Debug + Send + Sync + 'static> {
200    /// Registered verification rules.
201    verification_rules: Vec<FormalVerificationRule<T>>,
202    /// Proof system used to commit to verified releases.
203    proof_system: ProofSystem<T>,
204    /// Bounded invariant model checker.
205    model_checker: ModelChecker<T>,
206    /// Theorem prover over the registered axioms.
207    theorem_prover: TheoremProver<T>,
208}
209
210impl<T: Float + Debug + Send + Sync + 'static> FormalVerificationEngine<T> {
211    /// Create an engine with the default rule set and axioms registered.
212    pub fn new() -> Self {
213        Self {
214            verification_rules: default_privacy_rules(),
215            proof_system: ProofSystem::new(),
216            model_checker: ModelChecker::new(),
217            theorem_prover: TheoremProver::new(),
218        }
219    }
220
221    /// Create an engine with nothing registered.
222    ///
223    /// [`FormalVerificationEngine::verify_all_properties`] then returns an
224    /// error, because an engine with no rules has verified nothing.
225    pub fn empty() -> Self {
226        Self {
227            verification_rules: Vec::new(),
228            proof_system: ProofSystem::empty(),
229            model_checker: ModelChecker::new(),
230            theorem_prover: TheoremProver::empty(),
231        }
232    }
233
234    /// Number of registered rules.
235    pub fn rule_count(&self) -> usize {
236        self.verification_rules.len()
237    }
238
239    /// Names of the registered rules.
240    pub fn rule_names(&self) -> Vec<String> {
241        self.verification_rules
242            .iter()
243            .map(|rule| rule.name.clone())
244            .collect()
245    }
246
247    /// Register an additional rule.
248    pub fn add_rule(&mut self, rule: FormalVerificationRule<T>) {
249        self.verification_rules.push(rule);
250    }
251
252    /// Access the proof system.
253    pub fn proof_system(&self) -> &ProofSystem<T> {
254        &self.proof_system
255    }
256
257    /// Access the theorem prover.
258    pub fn theorem_prover(&self) -> &TheoremProver<T> {
259        &self.theorem_prover
260    }
261
262    /// Mutable access to the theorem prover.
263    pub fn theorem_prover_mut(&mut self) -> &mut TheoremProver<T> {
264        &mut self.theorem_prover
265    }
266
267    /// Mutable access to the model checker.
268    pub fn model_checker_mut(&mut self) -> &mut ModelChecker<T> {
269        &mut self.model_checker
270    }
271
272    /// Run every registered rule.
273    ///
274    /// Returns an error when no rules are registered: an empty result vector
275    /// must never be readable as "verified".
276    pub fn verify_all_properties(
277        &self,
278        data: &Array1<T>,
279        context: &PrivacyContext,
280    ) -> Result<Vec<VerificationResult>> {
281        if self.verification_rules.is_empty() {
282            return Err(OptimError::InvalidState(
283                "no formal verification rules are registered; there is nothing to verify, which \
284                 is not the same as everything passing"
285                    .to_string(),
286            ));
287        }
288        Ok(self
289            .verification_rules
290            .iter()
291            .map(|rule| (rule.verify_fn)(data, context))
292            .collect())
293    }
294
295    /// Run every rule and fail if any safety- or correctness-critical rule
296    /// does not hold.
297    pub fn require_all_properties(
298        &self,
299        data: &Array1<T>,
300        context: &PrivacyContext,
301    ) -> Result<Vec<VerificationResult>> {
302        let results = self.verify_all_properties(data, context)?;
303        let mut failures = Vec::new();
304        for (rule, result) in self.verification_rules.iter().zip(results.iter()) {
305            let critical = matches!(
306                rule.criticality,
307                VerificationCriticality::Safety | VerificationCriticality::Correctness
308            );
309            if critical && !result.verified {
310                failures.push(format!("{}: {}", rule.name, result.message));
311            }
312        }
313        if failures.is_empty() {
314            Ok(results)
315        } else {
316            Err(OptimError::InvalidState(format!(
317                "formal verification failed: {}",
318                failures.join("; ")
319            )))
320        }
321    }
322
323    /// Check a registered invariant property against the system model.
324    pub fn check_model_property(&self, property: &SystemProperty) -> Result<ModelCheckOutcome> {
325        self.model_checker.check_property(property)
326    }
327}
328
329impl<T: Float + Debug + Send + Sync + 'static> Default for FormalVerificationEngine<T> {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335/// Theorem prover over registered axioms and proof strategies.
336///
337/// A goal is discharged when an axiom of that name evaluates to true on the
338/// released values, or when a registered strategy reports it proven. A prover
339/// with neither axioms nor strategies cannot prove anything and says so with
340/// an error, instead of the previous behaviour of having no `prove` method at
341/// all while the owning engine reported success regardless.
342pub struct TheoremProver<T: Float + Debug + Send + Sync + 'static> {
343    /// Registered axioms.
344    axioms: Vec<Axiom<T>>,
345    /// Registered proof strategies.
346    strategies: Vec<ProofStrategy<T>>,
347}
348
349impl<T: Float + Debug + Send + Sync + 'static> TheoremProver<T> {
350    /// Create a prover with the default axioms and strategy.
351    pub fn new() -> Self {
352        let mut prover = Self::empty();
353        for axiom in default_axioms() {
354            prover.add_axiom(axiom);
355        }
356        prover.add_strategy(all_axioms_strategy());
357        prover
358    }
359
360    /// Create a prover with nothing registered.
361    pub fn empty() -> Self {
362        Self {
363            axioms: Vec::new(),
364            strategies: Vec::new(),
365        }
366    }
367
368    /// Register an axiom.
369    pub fn add_axiom(&mut self, axiom: Axiom<T>) {
370        self.axioms.push(axiom);
371    }
372
373    /// Register a proof strategy.
374    pub fn add_strategy(&mut self, strategy: ProofStrategy<T>) {
375        self.strategies.push(strategy);
376    }
377
378    /// Names of the registered axioms.
379    pub fn axiom_names(&self) -> Vec<String> {
380        self.axioms.iter().map(|axiom| axiom.name.clone()).collect()
381    }
382
383    /// Attempt to prove `goal` about `data`.
384    pub fn prove(&self, goal: &str, data: &Array1<T>) -> Result<ProofResult> {
385        if self.axioms.is_empty() && self.strategies.is_empty() {
386            return Err(OptimError::UnsupportedOperation(format!(
387                "cannot prove `{goal}`: the prover has neither axioms nor proof strategies \
388                 registered"
389            )));
390        }
391
392        // Direct discharge by a matching axiom.
393        for axiom in &self.axioms {
394            if axiom.name == goal {
395                let holds = (axiom.verify_fn)(data);
396                return Ok(ProofResult {
397                    proven: holds,
398                    proof_steps: vec![format!(
399                        "evaluated axiom `{}` ({}) on the release: {holds}",
400                        axiom.name, axiom.statement
401                    )],
402                    used_axioms: vec![axiom.name.clone()],
403                    confidence: 1.0,
404                });
405            }
406        }
407
408        // Otherwise hand the goal to each strategy in turn.
409        let mut attempted = Vec::new();
410        for strategy in &self.strategies {
411            let result = (strategy.apply_fn)(data, &self.axioms);
412            if result.proven {
413                return Ok(result);
414            }
415            attempted.push(format!(
416                "strategy `{}` did not discharge the goal",
417                strategy.name
418            ));
419        }
420
421        Ok(ProofResult {
422            proven: false,
423            proof_steps: attempted,
424            used_axioms: Vec::new(),
425            confidence: 1.0,
426        })
427    }
428}
429
430impl<T: Float + Debug + Send + Sync + 'static> Default for TheoremProver<T> {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436/// Axioms that are exactly decidable on a released vector.
437pub fn default_axioms<T: Float + Debug + Send + Sync + 'static>() -> Vec<Axiom<T>> {
438    vec![
439        Axiom {
440            name: "values_are_finite".to_string(),
441            statement: "forall i. finite(data[i])".to_string(),
442            verify_fn: Box::new(|data: &Array1<T>| data.iter().all(|value| value.is_finite())),
443        },
444        Axiom {
445            name: "release_is_non_empty".to_string(),
446            statement: "len(data) > 0".to_string(),
447            verify_fn: Box::new(|data: &Array1<T>| !data.is_empty()),
448        },
449        Axiom {
450            name: "l2_norm_is_finite".to_string(),
451            statement: "finite(||data||_2)".to_string(),
452            verify_fn: Box::new(|data: &Array1<T>| {
453                data.iter()
454                    .filter_map(|value| value.to_f64())
455                    .map(|value| value * value)
456                    .sum::<f64>()
457                    .sqrt()
458                    .is_finite()
459            }),
460        },
461    ]
462}
463
464/// A strategy that discharges `all_axioms_hold` by conjunction.
465pub fn all_axioms_strategy<T: Float + Debug + Send + Sync + 'static>() -> ProofStrategy<T> {
466    ProofStrategy {
467        name: "conjunction_of_axioms".to_string(),
468        apply_fn: Box::new(|data: &Array1<T>, axioms: &[Axiom<T>]| {
469            if axioms.is_empty() {
470                return ProofResult {
471                    proven: false,
472                    proof_steps: vec!["no axioms are registered".to_string()],
473                    used_axioms: Vec::new(),
474                    confidence: 1.0,
475                };
476            }
477            let mut steps = Vec::with_capacity(axioms.len());
478            let mut used = Vec::with_capacity(axioms.len());
479            let mut all_hold = true;
480            for axiom in axioms {
481                let holds = (axiom.verify_fn)(data);
482                steps.push(format!("{} => {holds}", axiom.statement));
483                used.push(axiom.name.clone());
484                all_hold &= holds;
485            }
486            ProofResult {
487                proven: all_hold,
488                proof_steps: steps,
489                used_axioms: used,
490                confidence: 1.0,
491            }
492        }),
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn context(epsilon: f64, delta: f64) -> PrivacyContext {
501        PrivacyContext {
502            epsilon_budget: epsilon,
503            delta_budget: delta,
504            privacy_mechanism: "dp_sgd".to_string(),
505            data_minimization: true,
506            purpose_limitation: true,
507            storage_limitation: true,
508        }
509    }
510
511    fn good_data() -> Array1<f64> {
512        Array1::from(vec![0.25, -0.5, 1.0])
513    }
514
515    #[test]
516    fn an_engine_with_no_rules_errors_instead_of_reporting_success() {
517        // Regression for the empty-rule-set vacuous success: the old code
518        // returned Ok(vec![]) here, which every caller read as "verified".
519        let engine: FormalVerificationEngine<f64> = FormalVerificationEngine::empty();
520        let outcome = engine.verify_all_properties(&good_data(), &context(1.0, 1e-5));
521        let message = match outcome {
522            Err(err) => err.to_string(),
523            Ok(results) => panic!("an empty engine reported {} results", results.len()),
524        };
525        assert!(message.contains("nothing to verify"), "got: {message}");
526    }
527
528    #[test]
529    fn the_default_engine_registers_real_rules_and_passes_a_clean_release() {
530        let engine: FormalVerificationEngine<f64> = FormalVerificationEngine::new();
531        assert!(engine.rule_count() >= 7, "{:?}", engine.rule_names());
532        let results = match engine.verify_all_properties(&good_data(), &context(1.0, 1e-5)) {
533            Ok(results) => results,
534            Err(err) => panic!("verification failed: {err}"),
535        };
536        assert_eq!(results.len(), engine.rule_count());
537        assert!(
538            results.iter().all(|result| result.verified),
539            "a clean release must satisfy every rule: {:?}",
540            results
541                .iter()
542                .filter(|result| !result.verified)
543                .map(|result| result.message.clone())
544                .collect::<Vec<_>>()
545        );
546        assert!(engine
547            .require_all_properties(&good_data(), &context(1.0, 1e-5))
548            .is_ok());
549    }
550
551    #[test]
552    fn a_non_finite_release_is_reported_as_a_failure() {
553        let engine: FormalVerificationEngine<f64> = FormalVerificationEngine::new();
554        let data = Array1::from(vec![0.25, f64::INFINITY, 1.0]);
555        let results = match engine.verify_all_properties(&data, &context(1.0, 1e-5)) {
556            Ok(results) => results,
557            Err(err) => panic!("verification failed: {err}"),
558        };
559        let finite_rule = results
560            .iter()
561            .find(|result| result.message.contains("not finite"));
562        assert!(
563            finite_rule.is_some(),
564            "the finiteness rule must fail and say which element"
565        );
566        assert!(
567            engine
568                .require_all_properties(&data, &context(1.0, 1e-5))
569                .is_err(),
570            "a safety-critical failure must be an error"
571        );
572    }
573
574    #[test]
575    fn an_invalid_privacy_context_is_reported_as_a_failure() {
576        let engine: FormalVerificationEngine<f64> = FormalVerificationEngine::new();
577        for (epsilon, delta) in [(0.0, 1e-5), (-1.0, 1e-5), (1.0, 1.0), (1.0, -0.1)] {
578            let outcome = engine.require_all_properties(&good_data(), &context(epsilon, delta));
579            assert!(
580                outcome.is_err(),
581                "epsilon={epsilon}, delta={delta} must not verify"
582            );
583        }
584    }
585
586    #[test]
587    fn every_rule_result_commits_to_the_checked_vector() {
588        let engine: FormalVerificationEngine<f64> = FormalVerificationEngine::new();
589        let left = match engine.verify_all_properties(&good_data(), &context(1.0, 1e-5)) {
590            Ok(results) => results,
591            Err(err) => panic!("verification failed: {err}"),
592        };
593        let other = Array1::from(vec![0.25, -0.5, 1.5]);
594        let right = match engine.verify_all_properties(&other, &context(1.0, 1e-5)) {
595            Ok(results) => results,
596            Err(err) => panic!("verification failed: {err}"),
597        };
598        let left_proofs: Vec<_> = left.iter().filter_map(|r| r.proof.clone()).collect();
599        let right_proofs: Vec<_> = right.iter().filter_map(|r| r.proof.clone()).collect();
600        assert!(
601            !left_proofs.is_empty(),
602            "data rules must carry a commitment"
603        );
604        assert_ne!(
605            left_proofs, right_proofs,
606            "the commitment must depend on the checked values"
607        );
608    }
609
610    #[test]
611    fn a_prover_with_nothing_registered_refuses_to_prove() {
612        let prover: TheoremProver<f64> = TheoremProver::empty();
613        assert!(prover.prove("values_are_finite", &good_data()).is_err());
614    }
615
616    #[test]
617    fn the_default_prover_discharges_a_true_axiom_and_refutes_a_false_one() {
618        let prover: TheoremProver<f64> = TheoremProver::new();
619        let proven = match prover.prove("values_are_finite", &good_data()) {
620            Ok(result) => result,
621            Err(err) => panic!("prove failed: {err}"),
622        };
623        assert!(proven.proven);
624        assert_eq!(proven.used_axioms, vec!["values_are_finite".to_string()]);
625
626        let bad = Array1::from(vec![f64::NAN]);
627        let refuted = match prover.prove("values_are_finite", &bad) {
628            Ok(result) => result,
629            Err(err) => panic!("prove failed: {err}"),
630        };
631        assert!(
632            !refuted.proven,
633            "a NaN release must not satisfy the finiteness axiom"
634        );
635    }
636
637    #[test]
638    fn an_unknown_goal_falls_through_to_the_strategies_and_is_not_asserted() {
639        let prover: TheoremProver<f64> = TheoremProver::new();
640        let result = match prover.prove("dp_sgd_is_epsilon_dp", &good_data()) {
641            Ok(result) => result,
642            Err(err) => panic!("prove failed: {err}"),
643        };
644        // The conjunction strategy holds on clean data, so the goal is
645        // discharged by it; what matters is that the steps record *why*.
646        assert!(!result.proof_steps.is_empty());
647        assert!(result.confidence <= 1.0);
648    }
649
650    #[test]
651    fn the_conjunction_strategy_fails_on_a_bad_release() {
652        let prover: TheoremProver<f64> = TheoremProver::new();
653        let empty = Array1::from(Vec::<f64>::new());
654        let result = match prover.prove("anything_at_all", &empty) {
655            Ok(result) => result,
656            Err(err) => panic!("prove failed: {err}"),
657        };
658        assert!(
659            !result.proven,
660            "an empty release violates release_is_non_empty"
661        );
662    }
663}