use crate::error::{AnomalyError, AnomalyResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LscpTarget {
Maximum,
Average,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LscpStrategy {
SelectBest,
AverageCompetent,
}
#[derive(Debug, Clone)]
pub struct LscpConfig {
pub local_region_size: usize,
pub target: LscpTarget,
pub strategy: LscpStrategy,
pub competent_margin: f32,
}
impl Default for LscpConfig {
fn default() -> Self {
Self {
local_region_size: 10,
target: LscpTarget::Maximum,
strategy: LscpStrategy::SelectBest,
competent_margin: 0.1,
}
}
}
fn pearson(a: &[f32], b: &[f32]) -> f32 {
let n = a.len() as f32;
if n < 1.0 {
return f32::NAN;
}
let mean_a = a.iter().sum::<f32>() / n;
let mean_b = b.iter().sum::<f32>() / n;
let mut cov = 0.0_f32;
let mut var_a = 0.0_f32;
let mut var_b = 0.0_f32;
for (x, y) in a.iter().zip(b.iter()) {
let dx = x - mean_a;
let dy = y - mean_b;
cov += dx * dy;
var_a += dx * dx;
var_b += dy * dy;
}
let denom = (var_a * var_b).sqrt();
if denom < 1e-12 { f32::NAN } else { cov / denom }
}
fn argmax_finite(values: &[f32]) -> usize {
let mut best = 0_usize;
let mut best_v = f32::NEG_INFINITY;
let mut found = false;
for (i, &v) in values.iter().enumerate() {
if v.is_finite() && v > best_v {
best_v = v;
best = i;
found = true;
}
}
if found { best } else { 0 }
}
#[derive(Debug, Clone)]
pub struct LscpEnsemble {
config: LscpConfig,
train_scores: Vec<f32>,
n_train: usize,
n_detectors: usize,
fitted: bool,
}
impl LscpEnsemble {
#[must_use]
pub fn new(config: LscpConfig) -> Self {
Self {
config,
train_scores: Vec::new(),
n_train: 0,
n_detectors: 0,
fitted: false,
}
}
pub fn fit(
&mut self,
train_scores: &[f32],
n_train: usize,
n_detectors: usize,
) -> AnomalyResult<()> {
if n_train == 0 {
return Err(AnomalyError::EmptyInput);
}
if n_detectors == 0 {
return Err(AnomalyError::InvalidFeatureCount { n: 0 });
}
if self.config.local_region_size == 0 {
return Err(AnomalyError::InvalidK { k: 0 });
}
if train_scores.len() != n_train * n_detectors {
return Err(AnomalyError::DimensionMismatch {
expected: n_train * n_detectors,
got: train_scores.len(),
});
}
self.train_scores = train_scores.to_vec();
self.n_train = n_train;
self.n_detectors = n_detectors;
self.fitted = true;
Ok(())
}
fn local_region(&self, s: &[f32]) -> Vec<usize> {
let k = self.config.local_region_size.min(self.n_train);
let b = self.n_detectors;
let mut dists: Vec<(usize, f32)> = (0..self.n_train)
.map(|i| {
let row = &self.train_scores[i * b..(i + 1) * b];
let d: f32 = s
.iter()
.zip(row.iter())
.map(|(a, c)| {
let e = a - c;
e * e
})
.sum();
(i, d)
})
.collect();
dists.sort_unstable_by(|x, y| x.1.partial_cmp(&y.1).unwrap_or(std::cmp::Ordering::Equal));
dists[..k].iter().map(|&(i, _)| i).collect()
}
fn pseudo_target(&self, region: &[usize]) -> Vec<f32> {
let b = self.n_detectors;
region
.iter()
.map(|&i| {
let row = &self.train_scores[i * b..(i + 1) * b];
match self.config.target {
LscpTarget::Average => row.iter().sum::<f32>() / b as f32,
LscpTarget::Maximum => row.iter().copied().fold(f32::NEG_INFINITY, f32::max),
}
})
.collect()
}
fn detector_correlations(&self, region: &[usize]) -> Vec<f32> {
let b = self.n_detectors;
let target = self.pseudo_target(region);
(0..b)
.map(|d| {
let column: Vec<f32> = region
.iter()
.map(|&i| self.train_scores[i * b + d])
.collect();
pearson(&column, &target)
})
.collect()
}
pub fn select_local_detector(&self, s: &[f32]) -> AnomalyResult<usize> {
self.check_query(s)?;
let region = self.local_region(s);
let corrs = self.detector_correlations(®ion);
Ok(argmax_finite(&corrs))
}
pub fn score(&self, s: &[f32]) -> AnomalyResult<f32> {
self.check_query(s)?;
let region = self.local_region(s);
let corrs = self.detector_correlations(®ion);
let mean_all = || s.iter().sum::<f32>() / self.n_detectors as f32;
match self.config.strategy {
LscpStrategy::SelectBest => {
if corrs.iter().all(|c| !c.is_finite()) {
Ok(mean_all())
} else {
Ok(s[argmax_finite(&corrs)])
}
}
LscpStrategy::AverageCompetent => {
let max_corr = corrs
.iter()
.copied()
.filter(|c| c.is_finite())
.fold(f32::NEG_INFINITY, f32::max);
if !max_corr.is_finite() {
return Ok(mean_all());
}
let threshold = max_corr - self.config.competent_margin;
let mut sum = 0.0_f32;
let mut count = 0_usize;
for (d, &c) in corrs.iter().enumerate() {
if c.is_finite() && c >= threshold {
sum += s[d];
count += 1;
}
}
if count == 0 {
Ok(mean_all())
} else {
Ok(sum / count as f32)
}
}
}
}
pub fn score_batch(&self, scores: &[f32], n: usize) -> AnomalyResult<Vec<f32>> {
if !self.fitted {
return Err(AnomalyError::NotFitted);
}
if scores.len() != n * self.n_detectors {
return Err(AnomalyError::DimensionMismatch {
expected: n * self.n_detectors,
got: scores.len(),
});
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let row = &scores[i * self.n_detectors..(i + 1) * self.n_detectors];
out.push(self.score(row)?);
}
Ok(out)
}
#[inline]
#[must_use]
pub fn n_detectors(&self) -> usize {
self.n_detectors
}
fn check_query(&self, s: &[f32]) -> AnomalyResult<()> {
if !self.fitted {
return Err(AnomalyError::NotFitted);
}
if s.len() != self.n_detectors {
return Err(AnomalyError::DimensionMismatch {
expected: self.n_detectors,
got: s.len(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pearson_pub(a: &[f32], b: &[f32]) -> f32 {
pearson(a, b)
}
#[test]
fn output_correlates_with_best_detector() {
let n = 40_usize;
let b = 3_usize;
let mut train = Vec::with_capacity(n * b);
let mut det0_train = Vec::with_capacity(n);
for i in 0..n {
let u = i as f32 * 0.25; let noise1 = 5.0 + 0.01 * (((i * 7) % 5) as f32 - 2.0);
let noise2 = 5.0 + 0.01 * (((i * 3) % 5) as f32 - 2.0);
train.push(u);
train.push(noise1);
train.push(noise2);
det0_train.push(u);
}
let cfg = LscpConfig {
local_region_size: 7,
target: LscpTarget::Average,
strategy: LscpStrategy::SelectBest,
competent_margin: 0.1,
};
let mut lscp = LscpEnsemble::new(cfg);
lscp.fit(&train, n, b).expect("fit");
let mut outputs = Vec::new();
let mut det0_test = Vec::new();
let mut det1_test = Vec::new();
for j in 0..15_usize {
let u = j as f32 * 0.6;
let s = [u, 5.0, 5.0];
outputs.push(lscp.score(&s).expect("score"));
det0_test.push(u);
det1_test.push(5.0_f32);
}
let corr0 = pearson_pub(&outputs, &det0_test);
assert!(
corr0 > 0.95,
"output should track detector 0 (corr={corr0})"
);
let corr1 = pearson_pub(&outputs, &det1_test);
assert!(
corr1.partial_cmp(&corr0) != Some(std::cmp::Ordering::Greater),
"output should align with detector 0, not 1 (corr0={corr0}, corr1={corr1})"
);
}
#[test]
fn combined_score_finite_and_bounded() {
let n = 30_usize;
let b = 4_usize;
let mut train = Vec::with_capacity(n * b);
for i in 0..n * b {
let v = ((i as u32).wrapping_mul(2_654_435_761) >> 8) as f32 / 16_777_216.0;
train.push(v.fract());
}
let mut lscp = LscpEnsemble::new(LscpConfig::default());
lscp.fit(&train, n, b).expect("fit");
let queries = [
[0.1_f32, 0.9, 0.3, 0.7],
[0.5, 0.5, 0.5, 0.5],
[0.0, 1.0, 0.0, 1.0],
[0.25, 0.75, 0.6, 0.4],
];
for q in &queries {
let s = lscp.score(q).expect("score");
assert!(s.is_finite(), "score must be finite, got {s}");
assert!((0.0..=1.0).contains(&s), "score {s} must be in [0, 1]");
}
}
#[test]
fn locally_best_detector_is_selected() {
let b = 3_usize;
let mut train: Vec<f32> = Vec::new();
for i in 0..6 {
train.extend_from_slice(&[i as f32, 0.5, 0.5]);
}
for i in 0..6 {
train.extend_from_slice(&[50.0, i as f32, 50.0]);
}
let n = 12_usize;
let cfg = LscpConfig {
local_region_size: 6,
target: LscpTarget::Average,
strategy: LscpStrategy::SelectBest,
competent_margin: 0.1,
};
let mut lscp = LscpEnsemble::new(cfg);
lscp.fit(&train, n, b).expect("fit");
let in_region1 = [2.5_f32, 0.5, 0.5];
assert_eq!(
lscp.select_local_detector(&in_region1).expect("select"),
0,
"detector 0 should win in region 1"
);
let in_region2 = [50.0_f32, 2.5, 50.0];
assert_eq!(
lscp.select_local_detector(&in_region2).expect("select"),
1,
"detector 1 should win in region 2"
);
}
#[test]
fn single_detector_reduces_to_itself() {
let n = 12_usize;
let b = 1_usize;
let train: Vec<f32> = (0..n).map(|i| i as f32 * 0.3).collect();
let mut lscp = LscpEnsemble::new(LscpConfig::default());
lscp.fit(&train, n, b).expect("fit");
for &v in &[0.0_f32, 1.1, 2.7, 9.9] {
let s = lscp.score(&[v]).expect("score");
assert!(
(s - v).abs() < 1e-6,
"single-detector output {s} should equal {v}"
);
}
}
#[test]
fn shape_mismatch_errors() {
let n = 10_usize;
let b = 3_usize;
let train = vec![0.5_f32; n * b];
let mut lscp = LscpEnsemble::new(LscpConfig::default());
assert!(matches!(
lscp.fit(&train[..n * b - 1], n, b),
Err(AnomalyError::DimensionMismatch { .. })
));
lscp.fit(&train, n, b).expect("fit");
assert!(matches!(
lscp.score(&[0.1_f32, 0.2]),
Err(AnomalyError::DimensionMismatch {
expected: 3,
got: 2
})
));
let mut lscp2 = LscpEnsemble::new(LscpConfig::default());
assert!(matches!(
lscp2.fit(&[], 0, b),
Err(AnomalyError::EmptyInput)
));
let lscp3 = LscpEnsemble::new(LscpConfig::default());
assert!(matches!(
lscp3.score(&[0.1_f32]),
Err(AnomalyError::NotFitted)
));
}
#[test]
fn average_competent_strategy_runs() {
let n = 20_usize;
let b = 3_usize;
let mut train = Vec::with_capacity(n * b);
for i in 0..n {
let u = i as f32 * 0.1;
train.extend_from_slice(&[u, u + 0.01, 0.5]);
}
let cfg = LscpConfig {
local_region_size: 5,
target: LscpTarget::Average,
strategy: LscpStrategy::AverageCompetent,
competent_margin: 0.2,
};
let mut lscp = LscpEnsemble::new(cfg);
lscp.fit(&train, n, b).expect("fit");
let out = lscp.score_batch(&train, n).expect("batch");
assert_eq!(out.len(), n);
assert!(out.iter().all(|s| s.is_finite()));
}
}