use crate::resilience::arch::{PntArchitecture, RdrrFunction, TechniqueCategory, YangCriterion};
use crate::verification::{OracleKind, VerificationStatus};
use serde::Serialize;
use std::collections::BTreeMap;
pub const HOLDOVER_REF_S: f64 = 3600.0;
pub const DIVERSITY_REF_GROUPS: f64 = 4.0;
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct SimSummary {
pub holdover_s: f64,
pub availability: f64,
pub detect_auc: f64,
pub integrity: f64,
pub security: f64,
pub bounded: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DimensionScore {
pub value: f64,
pub status: VerificationStatus,
pub oracle_kind: OracleKind,
pub basis: String,
}
impl DimensionScore {
fn modelled(value: f64, basis: impl Into<String>) -> Self {
DimensionScore {
value: value.clamp(0.0, 1.0),
status: VerificationStatus::Modelled,
oracle_kind: OracleKind::InternalConsistency,
basis: basis.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ResilienceProfile {
pub rpcf: BTreeMap<TechniqueCategory, DimensionScore>,
pub rdrr: BTreeMap<RdrrFunction, DimensionScore>,
pub yang: BTreeMap<YangCriterion, DimensionScore>,
pub level: u8,
pub level_basis: String,
}
fn mean_quality(arch: &PntArchitecture) -> f64 {
if arch.sources.is_empty() {
return 0.0;
}
let s: f64 = arch.sources.iter().map(|x| x.quality.clamp(0.0, 1.0)).sum();
s / arch.sources.len() as f64
}
fn norm_auc(auc: f64) -> f64 {
((auc - 0.5) * 2.0).clamp(0.0, 1.0)
}
fn norm_holdover(s: f64) -> f64 {
(s / HOLDOVER_REF_S).clamp(0.0, 1.0)
}
fn norm_groups(groups: usize) -> f64 {
((groups as f64 - 1.0) / (DIVERSITY_REF_GROUPS - 1.0)).clamp(0.0, 1.0)
}
pub fn score(arch: &PntArchitecture, sim: &SimSummary) -> ResilienceProfile {
let mq = mean_quality(arch);
let recover_v = norm_holdover(sim.holdover_s) * if sim.bounded { 1.0 } else { 0.5 };
let verify_v = norm_auc(sim.detect_auc);
let diversify_v = norm_groups(arch.independent_group_count());
let mitigate_v = sim.availability.clamp(0.0, 1.0);
let proc_score = |t: TechniqueCategory| -> DimensionScore {
let v = if arch.has(t) { mq } else { 0.0 };
DimensionScore::modelled(
v,
format!("{t:?} = declared({}) x mean source quality", arch.has(t)),
)
};
let mut rpcf = BTreeMap::new();
rpcf.insert(
TechniqueCategory::Obfuscate,
proc_score(TechniqueCategory::Obfuscate),
);
rpcf.insert(
TechniqueCategory::Limit,
proc_score(TechniqueCategory::Limit),
);
rpcf.insert(
TechniqueCategory::Verify,
DimensionScore::modelled(verify_v, "Verify = normalized impairment-detector AUC"),
);
rpcf.insert(
TechniqueCategory::Isolate,
proc_score(TechniqueCategory::Isolate),
);
rpcf.insert(
TechniqueCategory::Diversify,
DimensionScore::modelled(
diversify_v,
"Diversify = normalized independent-group count",
),
);
rpcf.insert(
TechniqueCategory::Mitigate,
DimensionScore::modelled(mitigate_v, "Mitigate = availability under denial"),
);
rpcf.insert(
TechniqueCategory::Recover,
DimensionScore::modelled(
recover_v,
"Recover = normalized holdover x bounded-degradation gate",
),
);
let resist_v = (rpcf[&TechniqueCategory::Obfuscate].value
+ rpcf[&TechniqueCategory::Limit].value
+ rpcf[&TechniqueCategory::Isolate].value
+ diversify_v)
/ 4.0;
let mut rdrr = BTreeMap::new();
rdrr.insert(
RdrrFunction::Resist,
DimensionScore::modelled(resist_v, "Resist = mean(Obfuscate,Limit,Isolate,Diversify)"),
);
rdrr.insert(
RdrrFunction::Detect,
DimensionScore::modelled(verify_v, "Detect = Verify sub-score"),
);
rdrr.insert(
RdrrFunction::Respond,
DimensionScore::modelled(mitigate_v, "Respond = Mitigate sub-score"),
);
rdrr.insert(
RdrrFunction::Recover,
DimensionScore::modelled(recover_v, "Recover = Recover sub-score"),
);
let mut yang = BTreeMap::new();
yang.insert(
YangCriterion::Availability,
DimensionScore::modelled(mitigate_v, "Availability = availability under denial"),
);
yang.insert(
YangCriterion::Reliability,
DimensionScore::modelled(
sim.integrity.clamp(0.0, 1.0),
"Reliability = filter integrity fraction",
),
);
yang.insert(
YangCriterion::Continuity,
DimensionScore::modelled(recover_v, "Continuity = holdover x bounded gate"),
);
yang.insert(
YangCriterion::Accuracy,
DimensionScore::modelled(
mq,
"Accuracy = timing/quality proxy; position-domain accuracy NOT modelled (see fom.rs)",
),
);
let (level, level_basis) = assign_level_from_rpcf(&rpcf, sim.bounded);
ResilienceProfile {
rpcf,
rdrr,
yang,
level,
level_basis,
}
}
pub fn composite(profile: &ResilienceProfile, dim_weights: &[f64]) -> f64 {
let cats = TechniqueCategory::all();
assert_eq!(dim_weights.len(), cats.len(), "composite: need 7 weights");
let wsum: f64 = dim_weights.iter().sum();
if wsum <= 0.0 {
return 0.0;
}
cats.iter()
.zip(dim_weights)
.map(|(c, w)| profile.rpcf[c].value * w)
.sum::<f64>()
/ wsum
}
fn assign_level_from_rpcf(
rpcf: &BTreeMap<TechniqueCategory, DimensionScore>,
bounded: bool,
) -> (u8, String) {
let min_sub = rpcf.values().map(|d| d.value).fold(f64::INFINITY, f64::min);
let raw = if min_sub < 0.2 {
0
} else if min_sub < 0.4 {
1
} else if min_sub < 0.6 {
2
} else if min_sub < 0.8 {
3
} else {
4
};
let level = if bounded { raw } else { raw.min(2) };
let basis = format!(
"weakest RPCF sub-score = {min_sub:.3}; bounded-degradation = {bounded} (caps at Level 2 if unbounded)"
);
(level, basis)
}
pub fn assign_level(profile: &ResilienceProfile, bounded: bool) -> (u8, String) {
assign_level_from_rpcf(&profile.rpcf, bounded)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resilience::arch::{PntSource, SourceKind};
fn strong_arch() -> PntArchitecture {
PntArchitecture::new(
"strong",
vec![
PntSource::new(SourceKind::GnssMultiBand, 1, 1.0),
PntSource::new(SourceKind::Inertial, 2, 1.0),
PntSource::new(SourceKind::Clock, 3, 1.0),
PntSource::new(SourceKind::Eloran, 4, 1.0),
],
TechniqueCategory::all(),
)
}
fn full_sim() -> SimSummary {
SimSummary {
holdover_s: HOLDOVER_REF_S,
availability: 1.0,
detect_auc: 1.0,
integrity: 1.0,
security: 1.0,
bounded: true,
}
}
fn zero_arch() -> PntArchitecture {
PntArchitecture::new("bare", vec![PntSource::new(SourceKind::GnssL1, 1, 0.0)], [])
}
fn zero_sim() -> SimSummary {
SimSummary {
holdover_s: 0.0,
availability: 0.0,
detect_auc: 0.5,
integrity: 0.0,
security: 0.0,
bounded: true,
}
}
#[test]
fn verify_subscore_is_monotone_in_detect_auc_others_fixed() {
let a = strong_arch();
let mut lo = full_sim();
lo.detect_auc = 0.7;
let mut hi = full_sim();
hi.detect_auc = 0.8;
let plo = score(&a, &lo);
let phi = score(&a, &hi);
let v_lo = plo.rpcf[&TechniqueCategory::Verify].value;
let v_hi = phi.rpcf[&TechniqueCategory::Verify].value;
assert!(v_hi > v_lo, "verify not monotone: {v_lo} !< {v_hi}");
for c in TechniqueCategory::all() {
if c != TechniqueCategory::Verify {
assert_eq!(plo.rpcf[&c].value, phi.rpcf[&c].value, "{c:?} moved");
}
}
}
#[test]
fn composite_bounds_and_hand_weighted_value() {
let full = score(&strong_arch(), &full_sim());
let w = [1.0; 7];
assert!((composite(&full, &w) - 1.0).abs() < 1e-12);
let zero = score(&zero_arch(), &zero_sim());
assert!((composite(&zero, &w) - 0.0).abs() < 1e-12);
let mut wv = [0.0; 7];
wv[2] = 1.0;
let mut s = full_sim();
s.detect_auc = 0.75; let p = score(&strong_arch(), &s);
assert!((composite(&p, &wv) - 0.5).abs() < 1e-12);
}
#[test]
fn unbounded_degradation_caps_level_at_two() {
let a = strong_arch();
let mut bounded = full_sim();
bounded.bounded = true;
let pb = score(&a, &bounded);
assert_eq!(pb.level, 4, "strong + bounded should reach Level 4");
let mut unbounded = full_sim();
unbounded.bounded = false;
let pu = score(&a, &unbounded);
assert!(
pu.level <= 2,
"unbounded must cap at Level <= 2, got {}",
pu.level
);
}
#[test]
fn every_subscore_is_modelled_with_a_real_oracle() {
let p = score(&strong_arch(), &full_sim());
let all = p
.rpcf
.values()
.chain(p.rdrr.values())
.chain(p.yang.values());
for d in all {
assert_eq!(d.status, VerificationStatus::Modelled);
assert_ne!(d.oracle_kind, OracleKind::NoneKind);
assert!(!d.basis.is_empty());
}
}
}