use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PcaObservation {
pub run_index: usize,
pub t2: Option<f64>,
pub q_stat: Option<f64>,
pub n_components: usize,
pub pc1_loading: Option<Vec<f64>>,
pub residual_vector: Option<Vec<f64>>,
pub sensor_labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StructuralVerdict {
Nominal,
ModelledShift { dominant_sensors: Vec<String> },
UnmodelledExcursion,
JointExcursion { dominant_sensors: Vec<String> },
Unavailable,
}
impl StructuralVerdict {
pub fn grammar_state(&self) -> &'static str {
match self {
Self::Nominal => "Admissible",
Self::ModelledShift { .. } => "SustainedDrift",
Self::UnmodelledExcursion => "TransientViolation",
Self::JointExcursion { .. } => "PersistentViolation",
Self::Unavailable => "Unavailable",
}
}
pub fn action(&self) -> &'static str {
match self {
Self::Nominal => "Monitor",
Self::ModelledShift { .. } => "Review",
Self::UnmodelledExcursion => "Review — investigate new failure mode",
Self::JointExcursion { .. } => "Escalate",
Self::Unavailable => "Check FDC telemetry",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralPCA {
pub t2_ucl: f64,
pub q_ucl: f64,
pub top_k_sensors: usize,
}
impl Default for StructuralPCA {
fn default() -> Self {
Self {
t2_ucl: 9.21, q_ucl: 3.0, top_k_sensors: 5,
}
}
}
impl StructuralPCA {
pub fn interpret(&self, obs: &PcaObservation) -> StructuralInterpretation {
let t2_alarm = obs.t2.map(|v| v > self.t2_ucl);
let q_alarm = obs.q_stat.map(|v| v > self.q_ucl);
let verdict = match (t2_alarm, q_alarm) {
(None, _) | (_, None) => StructuralVerdict::Unavailable,
(Some(false), Some(false)) => StructuralVerdict::Nominal,
(Some(true), Some(false)) => {
let dominant = self.dominant_sensors(obs, true);
StructuralVerdict::ModelledShift {
dominant_sensors: dominant,
}
}
(Some(false), Some(true)) => StructuralVerdict::UnmodelledExcursion,
(Some(true), Some(true)) => {
let dominant = self.dominant_sensors(obs, false);
StructuralVerdict::JointExcursion {
dominant_sensors: dominant,
}
}
};
StructuralInterpretation {
run_index: obs.run_index,
t2: obs.t2,
t2_ucl: self.t2_ucl,
q_stat: obs.q_stat,
q_ucl: self.q_ucl,
verdict: verdict.clone(),
grammar_state: verdict.grammar_state().to_string(),
action: verdict.action().to_string(),
integration_mode: "read_only_side_channel".into(),
}
}
fn dominant_sensors(&self, obs: &PcaObservation, use_loadings: bool) -> Vec<String> {
let scores: Option<Vec<f64>> = if use_loadings {
obs.pc1_loading
.as_ref()
.map(|l| l.iter().map(|v| v.abs()).collect())
} else {
obs.residual_vector
.as_ref()
.map(|r| r.iter().map(|v| v.abs()).collect())
};
let Some(mut scored) = scores.map(|scores| {
obs.sensor_labels
.iter()
.zip(scores.iter())
.map(|(label, &score)| (label.clone(), score))
.collect::<Vec<_>>()
}) else {
return Vec::new();
};
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored
.into_iter()
.take(self.top_k_sensors)
.map(|(label, _)| label)
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralInterpretation {
pub run_index: usize,
pub t2: Option<f64>,
pub t2_ucl: f64,
pub q_stat: Option<f64>,
pub q_ucl: f64,
pub verdict: StructuralVerdict,
pub grammar_state: String,
pub action: String,
pub integration_mode: String,
}
#[derive(Debug, Default)]
pub struct MultivariateObserver {
pub structural_pca: StructuralPCA,
history: Vec<StructuralInterpretation>,
}
impl MultivariateObserver {
pub fn with_config(structural_pca: StructuralPCA) -> Self {
Self {
structural_pca,
history: Vec::new(),
}
}
pub fn ingest(&mut self, obs: &PcaObservation) -> &StructuralInterpretation {
let interpretation = self.structural_pca.interpret(obs);
self.history.push(interpretation);
self.history.last().unwrap()
}
pub fn interpretations(&self) -> &[StructuralInterpretation] {
&self.history
}
pub fn count_verdicts(&self, verdict: &StructuralVerdict) -> usize {
self.history
.iter()
.filter(|i| std::mem::discriminant(&i.verdict) == std::mem::discriminant(verdict))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_obs(run: usize, t2: f64, q: f64) -> PcaObservation {
PcaObservation {
run_index: run,
t2: Some(t2),
q_stat: Some(q),
n_components: 3,
pc1_loading: Some(vec![0.8, 0.5, 0.1, 0.05]),
residual_vector: Some(vec![1.2, 0.3, 0.1, 0.05]),
sensor_labels: vec![
"S001".into(),
"S002".into(),
"S003".into(),
"S004".into(),
],
}
}
#[test]
fn nominal_verdict_when_both_within_limits() {
let spca = StructuralPCA::default();
let obs = base_obs(0, 5.0, 1.5);
let interp = spca.interpret(&obs);
assert_eq!(interp.verdict, StructuralVerdict::Nominal);
assert_eq!(interp.grammar_state, "Admissible");
}
#[test]
fn modelled_shift_when_t2_alarm() {
let spca = StructuralPCA::default();
let obs = base_obs(1, 15.0, 1.5);
let interp = spca.interpret(&obs);
assert!(
matches!(interp.verdict, StructuralVerdict::ModelledShift { .. }),
"expected ModelledShift, got {:?}",
interp.verdict
);
assert_eq!(interp.grammar_state, "SustainedDrift");
}
#[test]
fn unmodelled_excursion_when_q_alarm() {
let spca = StructuralPCA::default();
let obs = base_obs(2, 5.0, 8.0);
let interp = spca.interpret(&obs);
assert_eq!(interp.verdict, StructuralVerdict::UnmodelledExcursion);
assert_eq!(interp.grammar_state, "TransientViolation");
}
#[test]
fn joint_excursion_when_both_alarm() {
let spca = StructuralPCA::default();
let obs = base_obs(3, 15.0, 8.0);
let interp = spca.interpret(&obs);
assert!(matches!(
interp.verdict,
StructuralVerdict::JointExcursion { .. }
));
assert_eq!(interp.grammar_state, "PersistentViolation");
}
#[test]
fn unavailable_when_t2_missing() {
let spca = StructuralPCA::default();
let mut obs = base_obs(4, 0.0, 1.5);
obs.t2 = None;
let interp = spca.interpret(&obs);
assert_eq!(interp.verdict, StructuralVerdict::Unavailable);
}
#[test]
fn dominant_sensors_returns_top_k() {
let spca = StructuralPCA { top_k_sensors: 2, ..Default::default() };
let obs = base_obs(5, 15.0, 1.5);
let interp = spca.interpret(&obs);
if let StructuralVerdict::ModelledShift { dominant_sensors } = interp.verdict {
assert_eq!(dominant_sensors.len(), 2);
assert_eq!(dominant_sensors[0], "S001"); } else {
panic!("expected ModelledShift");
}
}
#[test]
fn observer_accumulates_history() {
let mut obs_engine = MultivariateObserver::default();
for i in 0..5 {
obs_engine.ingest(&base_obs(i, 5.0, 1.5));
}
assert_eq!(obs_engine.interpretations().len(), 5);
assert_eq!(
obs_engine.count_verdicts(&StructuralVerdict::Nominal),
5
);
}
#[test]
fn integration_mode_is_read_only() {
let spca = StructuralPCA::default();
let obs = base_obs(0, 5.0, 1.5);
let interp = spca.interpret(&obs);
assert_eq!(interp.integration_mode, "read_only_side_channel");
}
}