#![allow(clippy::needless_range_loop)]
use crate::ml::classification::extract_features;
use crate::state::{get_or_init_state, StoredObject};
use serde_json::json;
use wasm_bindgen::prelude::*;
const MAX_ITERATIONS: usize = 10;
const K_CLUSTERS: usize = 3;
#[wasm_bindgen]
pub fn discover_ml_cluster(eventlog_handle: &str, activity_key: &str) -> Result<JsValue, JsValue> {
let state = get_or_init_state();
let features = state.with_object(eventlog_handle, |obj| match obj {
Some(StoredObject::EventLog(log)) => {
let (f, _) = extract_features(log, activity_key);
Ok(f)
}
_ => Err(crate::error::js_val("not_found")),
})?;
if features.is_empty() {
return crate::utilities::to_js_str(&json!({
"algorithm": "ml_cluster",
"error": "Insufficient data",
"clusters": [],
"k": 0,
"centroids": [],
"assignments": [],
"inertia": 0.0,
"silhouette": 0.0,
"iterations": 0
}));
}
let result = kmeans_internal(&features, K_CLUSTERS);
crate::utilities::to_js_str(&json!({
"algorithm": "ml_cluster",
"k": result.k,
"centroids": result.centroids,
"assignments": result.assignments,
"inertia": result.inertia,
"silhouette": result.silhouette,
"iterations": result.iterations
}))
}
pub struct KmeansResult {
pub k: usize,
pub centroids: Vec<[f64; 2]>,
pub assignments: Vec<usize>,
pub inertia: f64,
pub silhouette: f64,
pub iterations: usize,
}
pub fn kmeans_internal(features: &[[f64; 2]], k_request: usize) -> KmeansResult {
let n = features.len();
let k = k_request.min(n).max(1);
let mut centroids = vec![[0.0, 0.0]; k];
for i in 0..k {
centroids[i] = features[i * (n / k)];
}
let mut assignments = vec![0usize; n];
let mut iterations = 0usize;
for iter in 0..MAX_ITERATIONS {
iterations = iter + 1;
let mut changed = false;
for i in 0..n {
let f = features[i];
let mut best_dist = f64::MAX;
let mut best_c = 0;
for j in 0..k {
let c = centroids[j];
let dist = (f[0] - c[0]) * (f[0] - c[0]) + (f[1] - c[1]) * (f[1] - c[1]);
let is_better = (dist < best_dist) as usize;
best_c = j * is_better + best_c * (1 - is_better);
best_dist = best_dist.min(dist); }
if assignments[i] != best_c {
assignments[i] = best_c;
changed = true;
}
}
if !changed && iter > 0 {
break;
}
let mut sums = vec![[0.0f64, 0.0f64]; k];
let mut counts = vec![0usize; k];
for i in 0..n {
let c = assignments[i];
sums[c][0] += features[i][0];
sums[c][1] += features[i][1];
counts[c] += 1;
}
for j in 0..k {
if counts[j] > 0 {
let cnt = counts[j] as f64;
centroids[j][0] = sums[j][0] / cnt;
centroids[j][1] = sums[j][1] / cnt;
}
}
}
let mut inertia = 0.0f64;
for i in 0..n {
let c = centroids[assignments[i]];
let dx = features[i][0] - c[0];
let dy = features[i][1] - c[1];
inertia += dx * dx + dy * dy;
}
let silhouette = silhouette_score(features, &assignments, k);
KmeansResult {
k,
centroids,
assignments,
inertia,
silhouette,
iterations,
}
}
fn silhouette_score(features: &[[f64; 2]], assignments: &[usize], k: usize) -> f64 {
let n = features.len();
if k < 2 || n <= k {
return 0.0;
}
let mut counts = vec![0usize; k];
for &c in assignments {
counts[c] += 1;
}
if counts.iter().all(|&c| c <= 1) {
return 0.0;
}
let mut total = 0.0f64;
let mut counted = 0usize;
for i in 0..n {
let own = assignments[i];
if counts[own] < 2 {
counted += 1;
continue;
}
let mut sum_intra = 0.0f64;
let mut inter_sums = vec![0.0f64; k];
let mut inter_counts = vec![0usize; k];
for j in 0..n {
if i == j {
continue;
}
let dx = features[i][0] - features[j][0];
let dy = features[i][1] - features[j][1];
let d = (dx * dx + dy * dy).sqrt();
let c = assignments[j];
if c == own {
sum_intra += d;
} else {
inter_sums[c] += d;
inter_counts[c] += 1;
}
}
let a = sum_intra / (counts[own] - 1) as f64;
let mut best_inter = f64::MAX;
for c in 0..k {
if c == own || inter_counts[c] == 0 {
continue;
}
let mean_b = inter_sums[c] / inter_counts[c] as f64;
if mean_b < best_inter {
best_inter = mean_b;
}
}
if best_inter == f64::MAX {
counted += 1;
continue;
}
let s = (best_inter - a) / a.max(best_inter).max(f64::MIN_POSITIVE);
total += s;
counted += 1;
}
if counted == 0 {
0.0
} else {
total / counted as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kmeans_two_well_separated_clusters_resolved() {
let features = vec![
[0.0, 0.0],
[0.1, 0.0],
[0.0, 0.1],
[0.1, 0.1],
[10.0, 10.0],
[10.1, 10.0],
[10.0, 10.1],
[10.1, 10.1],
];
let result = kmeans_internal(&features, 2);
assert_eq!(result.k, 2);
assert_eq!(result.assignments.len(), 8);
let a = result.assignments[0];
let b = result.assignments[4];
assert_ne!(
a, b,
"two well-separated blobs must get different cluster labels"
);
for i in 0..4 {
assert_eq!(
result.assignments[i], a,
"sample {i} in low blob has wrong label"
);
}
for i in 4..8 {
assert_eq!(
result.assignments[i], b,
"sample {i} in high blob has wrong label"
);
}
let low = result.centroids[a];
let high = result.centroids[b];
assert!(
(low[0] - 0.05).abs() < 0.1 && (low[1] - 0.05).abs() < 0.1,
"low-blob centroid should converge near (0.05, 0.05), got {:?}",
low
);
assert!(
(high[0] - 10.05).abs() < 0.1 && (high[1] - 10.05).abs() < 0.1,
"high-blob centroid should converge near (10.05, 10.05), got {:?}",
high
);
}
#[test]
fn kmeans_inertia_is_non_negative_and_decreases_with_more_clusters() {
let features: Vec<[f64; 2]> = (0..30).map(|i| [(i % 6) as f64, (i / 6) as f64]).collect();
let r1 = kmeans_internal(&features, 1);
let r3 = kmeans_internal(&features, 3);
assert!(r1.inertia >= 0.0, "inertia must be non-negative");
assert!(r3.inertia >= 0.0, "inertia must be non-negative");
assert!(
r3.inertia <= r1.inertia + 1e-9,
"inertia should not increase as k grows: k=1 -> {}, k=3 -> {}",
r1.inertia,
r3.inertia
);
}
#[test]
fn kmeans_silhouette_in_valid_range_for_separated_clusters() {
let features = vec![
[0.0, 0.0],
[0.1, 0.0],
[0.0, 0.1],
[0.1, 0.1],
[10.0, 10.0],
[10.1, 10.0],
[10.0, 10.1],
[10.1, 10.1],
];
let result = kmeans_internal(&features, 2);
assert!(
result.silhouette >= -1.0 && result.silhouette <= 1.0,
"silhouette must lie in [-1, 1], got {}",
result.silhouette
);
assert!(
result.silhouette > 0.9,
"well-separated blobs should give silhouette > 0.9, got {}",
result.silhouette
);
}
#[test]
fn kmeans_single_point_does_not_panic() {
let features = vec![[1.0, 1.0]];
let result = kmeans_internal(&features, 3);
assert_eq!(result.k, 1, "k must be clamped to n");
assert_eq!(result.assignments, vec![0]);
assert_eq!(result.inertia, 0.0);
assert_eq!(result.silhouette, 0.0, "silhouette undefined for k<2");
}
}