use crate::detection::{normal_cdf, normal_inv_cdf};
use crate::eval_stats::bootstrap_auc_ci;
use crate::qtrade::{TradeEvidence, TradeFom, TradeFrame};
use crate::representativeness::{Gap, Representativeness};
use crate::verification::VerificationStatus;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
pub enum FaultKind {
ClockFrequencyJump,
ClockDrift,
ClockLockLoss,
SensorBiasStep,
SensorDropout,
}
impl FaultKind {
pub fn catalog() -> [FaultKind; 5] {
[
FaultKind::ClockFrequencyJump,
FaultKind::ClockDrift,
FaultKind::ClockLockLoss,
FaultKind::SensorBiasStep,
FaultKind::SensorDropout,
]
}
pub fn label(self) -> &'static str {
match self {
FaultKind::ClockFrequencyJump => "clock-frequency-jump",
FaultKind::ClockDrift => "clock-drift",
FaultKind::ClockLockLoss => "clock-lock-loss",
FaultKind::SensorBiasStep => "sensor-bias-step",
FaultKind::SensorDropout => "sensor-dropout",
}
}
}
pub fn analytic_auc(mu: f64, sigma: f64) -> f64 {
if sigma <= 0.0 {
return if mu > 0.0 { 1.0 } else { 0.5 };
}
normal_cdf(mu / (sigma * std::f64::consts::SQRT_2))
}
pub fn min_detectable_fault(sigma: f64, pfa: f64, pd: f64) -> f64 {
sigma * (normal_inv_cdf(1.0 - pfa) + normal_inv_cdf(pd))
}
fn d_fault_mu() -> f64 {
1.0
}
fn d_quantum_sigma() -> f64 {
0.3
}
fn d_classical_sigma() -> f64 {
1.0
}
fn d_pfa() -> f64 {
1.0e-3
}
fn d_pd() -> f64 {
0.9
}
fn d_samples() -> usize {
2000
}
fn d_seed() -> u64 {
42
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
pub struct QuantumAnomalyScenario {
#[serde(default = "d_fault_mu")]
pub fault_mu: f64,
#[serde(default = "d_quantum_sigma")]
pub quantum_sigma: f64,
#[serde(default = "d_classical_sigma")]
pub classical_sigma: f64,
#[serde(default = "d_pfa")]
pub pfa: f64,
#[serde(default = "d_pd")]
pub pd: f64,
#[serde(default = "d_samples")]
pub samples: usize,
#[serde(default = "d_seed")]
pub seed: u64,
}
impl Default for QuantumAnomalyScenario {
fn default() -> Self {
QuantumAnomalyScenario {
fault_mu: d_fault_mu(),
quantum_sigma: d_quantum_sigma(),
classical_sigma: d_classical_sigma(),
pfa: d_pfa(),
pd: d_pd(),
samples: d_samples(),
seed: d_seed(),
}
}
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct QuantumAnomalyReport {
pub fault_catalog: Vec<String>,
pub quantum_auc: f64,
pub classical_auc: f64,
pub quantum_auc_ci: (f64, f64),
pub quantum_min_detectable: f64,
pub classical_min_detectable: f64,
pub trade: TradeEvidence,
}
impl QuantumAnomalyScenario {
pub fn run(&self) -> QuantumAnomalyReport {
let quantum_auc = analytic_auc(self.fault_mu, self.quantum_sigma);
let classical_auc = analytic_auc(self.fault_mu, self.classical_sigma);
let mut rng = ChaCha8Rng::seed_from_u64(self.seed);
let q_sigma = {
let s = self.quantum_sigma.max(1e-12);
if s.is_finite() {
s
} else {
1e-12
}
};
let n0 = Normal::new(0.0, q_sigma)
.expect("q_sigma is finite and strictly positive, which Normal::new always accepts");
let n1 = Normal::new(self.fault_mu, q_sigma)
.expect("q_sigma is finite and strictly positive, which Normal::new always accepts");
let neg: Vec<f64> = (0..self.samples).map(|_| n0.sample(&mut rng)).collect();
let pos: Vec<f64> = (0..self.samples).map(|_| n1.sample(&mut rng)).collect();
let quantum_auc_ci = bootstrap_auc_ci(&pos, &neg, 200, self.seed, 0.05);
let quantum_min_detectable = min_detectable_fault(self.quantum_sigma, self.pfa, self.pd);
let classical_min_detectable =
min_detectable_fault(self.classical_sigma, self.pfa, self.pd);
let rep =
Representativeness::modelled("quantum vs classical PNT anomaly detection", (3, 4))
.with_assumption(
"Gaussian detection statistic; quantum-clock-aided monitor has lower noise",
)
.with_assumption(
"labelled synthetic fault catalog; illustrative public-source parameters",
)
.with_gap(Gap::new(
"real telemetry from quantum-PNT hardware with ground-truth fault labels",
"Phase B2 hardware-in-the-loop + flight telemetry",
));
let trade = TradeEvidence::new(TradeFrame::new("quantum-anomaly-detect", self.seed), rep)
.with_fom(TradeFom {
name: "detection AUC".into(),
unit: "AUC".into(),
quantum: quantum_auc,
classical: classical_auc,
higher_is_better: true,
ci95: Some(quantum_auc_ci),
status: VerificationStatus::Modelled,
})
.with_fom(TradeFom {
name: "minimum detectable fault".into(),
unit: "stat".into(),
quantum: quantum_min_detectable,
classical: classical_min_detectable,
higher_is_better: false,
ci95: None,
status: VerificationStatus::Modelled,
});
QuantumAnomalyReport {
fault_catalog: FaultKind::catalog()
.iter()
.map(|f| f.label().to_string())
.collect(),
quantum_auc,
classical_auc,
quantum_auc_ci,
quantum_min_detectable,
classical_min_detectable,
trade,
}
}
}
pub fn to_svg(r: &QuantumAnomalyReport) -> String {
let qh = (r.quantum_auc * 180.0).min(180.0);
let ch = (r.classical_auc * 180.0).min(180.0);
format!(
"<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220'>\
<rect width='320' height='220' fill='white'/>\
<text x='10' y='20' font-size='12'>quantum-anomaly-detect AUC (MODELLED)</text>\
<rect x='60' y='{:.1}' width='60' height='{:.1}' fill='#3a6'/>\
<text x='62' y='210' font-size='10'>quantum</text>\
<rect x='180' y='{:.1}' width='60' height='{:.1}' fill='#c44'/>\
<text x='182' y='210' font-size='10'>classical</text></svg>",
200.0 - qh,
qh,
200.0 - ch,
ch
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analytic_auc_matches_known_values() {
assert!((analytic_auc(0.0, 1.0) - 0.5).abs() < 1e-6);
assert!(analytic_auc(10.0, 1.0) > 0.999);
assert!(analytic_auc(1.0, 0.3) > analytic_auc(1.0, 1.0));
}
#[test]
fn empirical_auc_brackets_the_analytic() {
let r = QuantumAnomalyScenario::default().run();
let (lo, hi) = r.quantum_auc_ci;
assert!(
lo <= r.quantum_auc + 1e-3 && r.quantum_auc <= hi + 1e-3,
"analytic {} not in CI ({lo},{hi})",
r.quantum_auc
);
}
#[test]
fn quantum_monitor_detects_smaller_faults_and_has_higher_auc() {
let r = QuantumAnomalyScenario::default().run();
assert!(r.quantum_auc > r.classical_auc);
assert!(r.quantum_min_detectable < r.classical_min_detectable);
}
#[test]
fn advantage_vanishes_for_huge_faults() {
let small = QuantumAnomalyScenario {
fault_mu: 0.5,
..Default::default()
}
.run();
let huge = QuantumAnomalyScenario {
fault_mu: 20.0,
..Default::default()
}
.run();
let small_gap = small.quantum_auc - small.classical_auc;
let huge_gap = huge.quantum_auc - huge.classical_auc;
assert!(huge_gap < small_gap);
}
#[test]
fn catalog_has_five_labelled_faults() {
let r = QuantumAnomalyScenario::default().run();
assert_eq!(r.fault_catalog.len(), 5);
assert!(r.fault_catalog.iter().any(|f| f == "clock-frequency-jump"));
}
#[test]
fn trade_is_honest() {
let r = QuantumAnomalyScenario::default().run();
assert!(
r.trade.is_honest(),
"violations: {:?}",
r.trade.honesty_violations()
);
assert_eq!(r.trade.quantum_wins(), 2);
}
}