use crate::models::AttributeValue;
use crate::state::{get_or_init_state, StoredObject};
use serde_json::json;
use std::collections::HashSet;
use wasm_bindgen::prelude::*;
pub const DEFAULT_DRIFT_THRESHOLD: f64 = 0.3;
pub const TREND_STABILITY_FRACTION: f64 = 0.05;
pub fn jaccard_distance(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
let union = a.union(b).count();
if union == 0 {
return 0.0;
}
let inter = a.intersection(b).count();
1.0 - (inter as f64 / union as f64)
}
pub fn ewma_series(values: &[f64], alpha: f64) -> Vec<f64> {
if values.is_empty() {
return Vec::new();
}
let alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
let mut out = Vec::with_capacity(values.len());
out.push(values[0]);
for i in 1..values.len() {
let prev = out[i - 1];
out.push(alpha * values[i] + (1.0 - alpha) * prev);
}
out
}
pub fn classify_trend(smoothed: &[f64]) -> &'static str {
if smoothed.len() < 2 {
return "stable";
}
let first = smoothed[0];
let last = *smoothed.last().expect("len >= 2 checked above");
let range = (last - first).abs();
let scale = first.abs().max(last.abs()).max(1e-9);
if range / scale < TREND_STABILITY_FRACTION {
"stable"
} else if last > first {
"rising"
} else {
"falling"
}
}
#[wasm_bindgen]
pub fn detect_drift(
log_handle: &str,
activity_key: &str,
window_size: usize,
) -> Result<JsValue, JsValue> {
let window_size = window_size.max(1);
get_or_init_state().with_event_log(log_handle, |log| {
let mut drifts: Vec<serde_json::Value> = Vec::new();
let mut previous_activities: Option<HashSet<String>> = None;
for (idx, window) in log.traces.windows(window_size).enumerate() {
let mut current_activities: HashSet<String> = HashSet::new();
for trace in window {
for event in &trace.events {
if let Some(AttributeValue::String(activity)) =
event.attributes.get(activity_key)
{
current_activities.insert(activity.clone());
}
}
}
if let Some(prev) = &previous_activities {
let distance = jaccard_distance(¤t_activities, prev);
if distance > DEFAULT_DRIFT_THRESHOLD {
let appeared: std::collections::BTreeSet<&str> = current_activities
.difference(prev)
.map(String::as_str)
.collect();
let disappeared: std::collections::BTreeSet<&str> = prev
.difference(¤t_activities)
.map(String::as_str)
.collect();
let suggestion = if let Some(first) = disappeared.iter().next() {
format!(
"Activity '{}' disappeared — re-run discovery or check for process change",
first
)
} else if let Some(first) = appeared.iter().next() {
format!(
"Activity '{}' appeared — new path detected, consider model update",
first
)
} else {
"Frequency shift detected — inspect directly-follows graph".to_string()
};
drifts.push(json!({
"position": idx * window_size,
"distance": distance,
"type": "concept_drift",
"appeared": appeared,
"disappeared": disappeared,
"suggestion": suggestion,
}));
}
}
previous_activities = Some(current_activities);
}
let result = json!({
"drifts_detected": drifts.len(),
"drifts": drifts,
"window_size": window_size,
"method": "jaccard_window",
"threshold": DEFAULT_DRIFT_THRESHOLD,
});
serde_json::to_string(&result)
.map(|s| crate::error::js_val(&s))
.map_err(|e| crate::error::js_val(&e.to_string()))
})
}
#[wasm_bindgen]
pub fn compute_ewma(values_json: &str, alpha: f64) -> Result<JsValue, JsValue> {
let values: Vec<f64> = serde_json::from_str(values_json)
.map_err(|e| crate::error::js_val(&format!("Invalid values JSON: {}", e)))?;
let smoothed = ewma_series(&values, alpha);
let (trend, last_value): (&'static str, serde_json::Value) = if smoothed.is_empty() {
("stable", serde_json::Value::Null)
} else {
let last = *smoothed.last().expect("non-empty checked");
(classify_trend(&smoothed), json!(last))
};
let result = json!({
"smoothed": smoothed,
"trend": trend,
"last_value": last_value,
"alpha": alpha.clamp(f64::MIN_POSITIVE, 1.0),
});
serde_json::to_string(&result)
.map(|s| crate::error::js_val(&s))
.map_err(|e| crate::error::js_val(&e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn set(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn jaccard_identical_is_zero() {
let a = set(&["A", "B", "C"]);
assert_eq!(jaccard_distance(&a, &a), 0.0);
}
#[test]
fn jaccard_disjoint_is_one() {
let a = set(&["A", "B"]);
let b = set(&["X", "Y"]);
assert_eq!(jaccard_distance(&a, &b), 1.0);
}
#[test]
fn jaccard_both_empty_is_zero_by_convention() {
let a: HashSet<String> = HashSet::new();
assert_eq!(jaccard_distance(&a, &a), 0.0);
}
#[test]
fn jaccard_partial_overlap() {
let a = set(&["A", "B"]);
let b = set(&["B", "C"]);
let d = jaccard_distance(&a, &b);
assert!((d - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn jaccard_symmetry() {
let a = set(&["A", "B", "C", "D"]);
let b = set(&["C", "D", "E"]);
assert!((jaccard_distance(&a, &b) - jaccard_distance(&b, &a)).abs() < 1e-12);
}
#[test]
fn ewma_empty_input() {
assert!(ewma_series(&[], 0.5).is_empty());
}
#[test]
fn ewma_single_value_is_identity() {
assert_eq!(ewma_series(&[42.0], 0.5), vec![42.0]);
}
#[test]
fn ewma_constant_series_is_constant() {
let v = vec![5.0; 10];
let s = ewma_series(&v, 0.3);
for x in s {
assert!((x - 5.0).abs() < 1e-12);
}
}
#[test]
fn ewma_alpha_one_tracks_input_with_one_step_lag() {
let v = vec![1.0, 2.0, 3.0, 4.0];
let s = ewma_series(&v, 1.0);
assert_eq!(s, v);
}
#[test]
fn ewma_alpha_clamped_below() {
let v = vec![1.0, 2.0, 3.0];
let s = ewma_series(&v, 0.0);
assert_eq!(s.len(), 3);
assert!((s[1] - 1.0).abs() < 1e-9);
assert!((s[2] - 1.0).abs() < 1e-9);
}
#[test]
fn ewma_alpha_clamped_above() {
let v = vec![1.0, 2.0, 3.0];
let s = ewma_series(&v, 5.0);
assert_eq!(s, v);
}
#[test]
fn ewma_recurrence_holds() {
let v = vec![1.0, 4.0, 9.0, 16.0, 25.0];
let alpha = 0.4;
let s = ewma_series(&v, alpha);
for i in 1..v.len() {
let expected = alpha * v[i] + (1.0 - alpha) * s[i - 1];
assert!((s[i] - expected).abs() < 1e-12);
}
}
#[test]
fn ewma_convergence_to_constant() {
let mut v = vec![0.0];
v.extend(std::iter::repeat(10.0).take(200));
let s = ewma_series(&v, 0.3);
let last = *s.last().unwrap();
assert!((last - 10.0).abs() < 1e-6, "last = {}", last);
}
#[test]
fn classify_trend_short_series() {
assert_eq!(classify_trend(&[]), "stable");
assert_eq!(classify_trend(&[1.0]), "stable");
}
#[test]
fn classify_trend_rising_falling_stable() {
assert_eq!(classify_trend(&[1.0, 2.0, 3.0, 4.0]), "rising");
assert_eq!(classify_trend(&[10.0, 8.0, 6.0]), "falling");
assert_eq!(classify_trend(&[5.0, 5.001, 5.0, 4.999]), "stable");
}
}