use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use midstreamer_attractor::{AttractorAnalyzer, AttractorError, AttractorType, PhasePoint};
pub const DEFAULT_TRAJECTORY_LEN: usize = 128;
pub const DEFAULT_EMBEDDING_DIM: usize = 1;
pub const DEFAULT_TOP_K: usize = 5;
pub const DEFAULT_ANALYZE_EVERY_N_FRAMES: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Signature {
pub id: String,
pub label: String,
pub vectors: Vec<Vec<f64>>,
pub dtw: SignatureDtw,
pub promotion_threshold: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SignatureDtw {
pub window: usize,
#[serde(default = "default_step_pattern")]
pub step_pattern: String,
}
fn default_step_pattern() -> String {
"symmetric2".to_string()
}
#[derive(Debug, Default, Clone)]
pub struct SignatureLibrary {
signatures: Vec<Signature>,
}
impl SignatureLibrary {
pub fn new() -> Self {
Self {
signatures: Vec::new(),
}
}
pub fn from_signatures(signatures: Vec<Signature>) -> Self {
Self { signatures }
}
pub fn len(&self) -> usize {
self.signatures.len()
}
pub fn is_empty(&self) -> bool {
self.signatures.is_empty()
}
pub fn signatures(&self) -> &[Signature] {
&self.signatures
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SimilarityMatch {
pub signature_id: String,
pub score: f32,
pub above_threshold: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IntrospectionSnapshot {
pub timestamp_ns: u64,
pub frame_count: u64,
pub regime: Regime,
pub lyapunov_exponent: Option<f64>,
pub attractor_dim: usize,
pub attractor_confidence: f64,
pub regime_changed: bool,
pub top_k_similarity: Vec<SimilarityMatch>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Regime {
Idle,
Periodic,
Transient,
Chaotic,
Unknown,
}
impl Regime {
fn from_attractor(t: AttractorType) -> Self {
match t {
AttractorType::PointAttractor => Regime::Idle,
AttractorType::LimitCycle => Regime::Periodic,
AttractorType::StrangeAttractor => Regime::Chaotic,
AttractorType::Unknown => Regime::Unknown,
}
}
}
pub struct IntrospectionState {
analyzer: AttractorAnalyzer,
library: SignatureLibrary,
recent_amplitudes: VecDeque<f64>,
trajectory_capacity: usize,
frames_since_analyze: u32,
analyze_every_n: u32,
frame_count: u64,
last_snapshot: IntrospectionSnapshot,
}
impl IntrospectionState {
pub fn new() -> Self {
Self::with_config(IntrospectionConfig::default())
}
pub fn with_config(cfg: IntrospectionConfig) -> Self {
let analyzer = AttractorAnalyzer::new(cfg.embedding_dim, cfg.trajectory_len);
Self {
analyzer,
library: cfg.library,
recent_amplitudes: VecDeque::with_capacity(cfg.trajectory_len),
trajectory_capacity: cfg.trajectory_len,
frames_since_analyze: 0,
analyze_every_n: cfg.analyze_every_n.max(1),
frame_count: 0,
last_snapshot: IntrospectionSnapshot {
timestamp_ns: 0,
frame_count: 0,
regime: Regime::Unknown,
lyapunov_exponent: None,
attractor_dim: cfg.embedding_dim,
attractor_confidence: 0.0,
regime_changed: false,
top_k_similarity: Vec::new(),
},
}
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
pub fn snapshot(&self) -> &IntrospectionSnapshot {
&self.last_snapshot
}
pub fn update(
&mut self,
timestamp_ns: u64,
derived_feature: f64,
) -> Result<(), AttractorError> {
self.frame_count = self.frame_count.saturating_add(1);
if self.recent_amplitudes.len() == self.trajectory_capacity {
self.recent_amplitudes.pop_front();
}
self.recent_amplitudes.push_back(derived_feature);
let phase_point = PhasePoint::new(vec![derived_feature], timestamp_ns);
self.analyzer.add_point(phase_point)?;
let prev_regime = self.last_snapshot.regime;
self.frames_since_analyze = self.frames_since_analyze.saturating_add(1);
if self.frames_since_analyze >= self.analyze_every_n {
self.frames_since_analyze = 0;
match self.analyzer.analyze() {
Ok(info) => {
self.last_snapshot.regime = Regime::from_attractor(info.attractor_type);
self.last_snapshot.lyapunov_exponent = info.max_lyapunov_exponent();
self.last_snapshot.attractor_confidence = info.confidence;
}
Err(AttractorError::InsufficientData(_)) => {
}
Err(other) => return Err(other),
}
}
self.last_snapshot.regime_changed = prev_regime != self.last_snapshot.regime;
self.last_snapshot.top_k_similarity =
score_signatures(&self.library, &self.recent_amplitudes, DEFAULT_TOP_K);
self.last_snapshot.timestamp_ns = timestamp_ns;
self.last_snapshot.frame_count = self.frame_count;
Ok(())
}
}
impl Default for IntrospectionState {
fn default() -> Self {
Self::new()
}
}
pub struct IntrospectionConfig {
pub trajectory_len: usize,
pub embedding_dim: usize,
pub analyze_every_n: u32,
pub library: SignatureLibrary,
}
impl Default for IntrospectionConfig {
fn default() -> Self {
IntrospectionConfig {
trajectory_len: DEFAULT_TRAJECTORY_LEN,
embedding_dim: DEFAULT_EMBEDDING_DIM,
analyze_every_n: DEFAULT_ANALYZE_EVERY_N_FRAMES,
library: SignatureLibrary::new(),
}
}
}
fn score_signatures(
library: &SignatureLibrary,
recent: &VecDeque<f64>,
top_k: usize,
) -> Vec<SimilarityMatch> {
if library.is_empty() || recent.is_empty() {
return Vec::new();
}
let mut scored: Vec<SimilarityMatch> = library
.signatures()
.iter()
.map(|sig| {
let score = signature_score(sig, recent);
SimilarityMatch {
signature_id: sig.id.clone(),
score,
above_threshold: score >= sig.promotion_threshold,
}
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);
scored
}
fn signature_score(sig: &Signature, recent: &VecDeque<f64>) -> f32 {
if sig.vectors.is_empty() {
return 0.0;
}
let window = sig.vectors.len().min(recent.len());
if window == 0 {
return 0.0;
}
let start = recent.len() - window;
let mut sum: f64 = 0.0;
for (i, sig_vec) in sig.vectors.iter().rev().take(window).enumerate() {
let s = sig_vec.first().copied().unwrap_or(0.0);
let r = recent.get(recent.len() - 1 - i).copied().unwrap_or(0.0);
sum += (s - r).abs();
}
let mean_abs = sum / window as f64;
let score = 1.0 / (1.0 + mean_abs);
let _ = start; score as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(id: &str, vectors: Vec<f64>, threshold: f32) -> Signature {
Signature {
id: id.to_string(),
label: id.to_string(),
vectors: vectors.into_iter().map(|v| vec![v]).collect(),
dtw: SignatureDtw {
window: 8,
step_pattern: "symmetric2".to_string(),
},
promotion_threshold: threshold,
}
}
#[test]
fn snapshot_is_unknown_before_first_frame() {
let st = IntrospectionState::new();
let s = st.snapshot();
assert_eq!(s.frame_count, 0);
assert_eq!(s.regime, Regime::Unknown);
assert!(s.lyapunov_exponent.is_none());
assert_eq!(s.attractor_confidence, 0.0);
assert!(s.top_k_similarity.is_empty());
}
#[test]
fn update_advances_frame_count_and_timestamp() {
let mut st = IntrospectionState::new();
st.update(1_000, 0.5).unwrap();
st.update(2_000, 0.7).unwrap();
let s = st.snapshot();
assert_eq!(s.frame_count, 2);
assert_eq!(s.timestamp_ns, 2_000);
}
#[test]
fn empty_library_yields_empty_similarity() {
let mut st = IntrospectionState::new();
for k in 0..40 {
st.update(k * 33_000_000, (k as f64).sin()).unwrap();
}
assert!(st.snapshot().top_k_similarity.is_empty());
}
#[test]
fn single_signature_scores_higher_when_recent_matches() {
let lib = SignatureLibrary::from_signatures(vec![sig(
"walking_slow",
vec![1.0, 2.0, 3.0, 4.0, 5.0],
0.5,
)]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
for (i, v) in [1.0f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let s = st.snapshot();
assert_eq!(s.top_k_similarity.len(), 1);
let m = &s.top_k_similarity[0];
assert_eq!(m.signature_id, "walking_slow");
assert!(m.score > 0.95, "score = {}", m.score);
assert!(m.above_threshold);
}
#[test]
fn divergent_signature_scores_low_and_below_threshold() {
let lib = SignatureLibrary::from_signatures(vec![sig(
"walking_slow",
vec![1.0, 2.0, 3.0, 4.0, 5.0],
0.5,
)]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
for (i, v) in [100.0f64, 200.0, 300.0, 400.0, 500.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let m = &st.snapshot().top_k_similarity[0];
assert!(m.score < 0.05, "score = {}", m.score);
assert!(!m.above_threshold);
}
#[test]
fn top_k_truncates_and_orders_descending() {
let lib = SignatureLibrary::from_signatures(vec![
sig("a", vec![1.0, 2.0, 3.0], 0.3),
sig("b", vec![10.0, 20.0, 30.0], 0.3),
sig("c", vec![100.0, 200.0, 300.0], 0.3),
sig("d", vec![1.5, 2.5, 3.5], 0.3),
]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
for (i, v) in [1.0f64, 2.0, 3.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let top = &st.snapshot().top_k_similarity;
assert_eq!(top.len(), 4);
for w in top.windows(2) {
assert!(w[0].score >= w[1].score, "not descending: {:?}", top);
}
assert_eq!(top[0].signature_id, "a");
assert!(top[0].score > 0.95);
}
#[test]
fn signature_with_empty_vectors_does_not_panic() {
let lib = SignatureLibrary::from_signatures(vec![sig("empty", vec![], 0.5)]);
let mut st = IntrospectionState::with_config(IntrospectionConfig {
trajectory_len: 16,
embedding_dim: 1,
analyze_every_n: 8,
library: lib,
});
st.update(1_000, 1.0).unwrap();
let s = st.snapshot();
assert_eq!(s.top_k_similarity.len(), 1);
assert_eq!(s.top_k_similarity[0].score, 0.0);
assert!(!s.top_k_similarity[0].above_threshold);
}
#[test]
fn regime_classification_eventually_runs() {
let mut st = IntrospectionState::with_config(IntrospectionConfig {
trajectory_len: 256,
embedding_dim: 1,
analyze_every_n: 8,
library: SignatureLibrary::new(),
});
for k in 0..200u64 {
let v = (k as f64 * 0.1).sin();
st.update(k * 33_000_000, v).unwrap();
}
let s = st.snapshot();
assert!(
s.regime != Regime::Unknown || s.lyapunov_exponent.is_some(),
"expected regime classified or Lyapunov set after 200 frames; got {:?}",
s
);
}
}