use crate::ml::classification::{extract_features, knn_sweep_cv};
use crate::ml::forecasting::get_windows;
use crate::state::{get_or_init_state, StoredObject};
use serde_json::json;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn discover_automl_forecast(
eventlog_handle: &str,
_activity_key: &str,
) -> Result<JsValue, JsValue> {
let (windows, count) = get_windows(eventlog_handle)?;
if count < 10 {
return to_js_val(&json!({
"algorithm": "automl_forecast",
"error": "Insufficient data for 5-fold CV"
}));
}
let result = discover_automl_forecast_internal(&windows);
to_js_val(&json!({
"algorithm": "automl_forecast",
"best_alpha": result.best_alpha,
"avg_rmse": result.min_avg_rmse,
"cv_rmse": result.min_avg_rmse,
"cv_mae": result.min_avg_mae,
"cv_folds": result.folds,
"cv_method": "kfold_train_complement_test_holdout",
"status": "OPTIMIZED",
"scope": "exhaustive_sweep_0.05_0.95"
}))
}
pub struct AutomlForecastResult {
pub best_alpha: f64,
pub min_avg_rmse: f64,
pub min_avg_mae: f64,
pub folds: usize,
}
fn eval_fold(windows: &[f64], alpha: f64, test_start: usize, test_end: usize) -> (f64, f64, usize) {
let mut s_opt: Option<f64> = None;
let prefix = &windows[..test_start];
let suffix = &windows[test_end..];
for &val in prefix.iter().chain(suffix.iter()) {
s_opt = Some(match s_opt {
None => val,
Some(prev_s) => alpha * val + (1.0 - alpha) * prev_s,
});
}
let mut s = match s_opt {
Some(v) => v,
None => return (0.0, 0.0, 0),
};
let mut sum_sq = 0.0;
let mut sum_abs = 0.0;
let mut n_err = 0usize;
for &val in windows[test_start..test_end].iter() {
let pred = s;
let err = val - pred;
sum_sq += err * err;
sum_abs += err.abs();
n_err += 1;
s = alpha * val + (1.0 - alpha) * pred;
}
(sum_sq, sum_abs, n_err)
}
pub fn discover_automl_forecast_internal(windows: &[f64]) -> AutomlForecastResult {
const FOLDS: usize = 5;
let n = windows.len();
if n < FOLDS + 1 {
return AutomlForecastResult {
best_alpha: 0.3,
min_avg_rmse: f64::INFINITY,
min_avg_mae: f64::INFINITY,
folds: FOLDS,
};
}
let fold_size = n / FOLDS;
let mut best_alpha = 0.3;
let mut min_avg_rmse = f64::MAX;
let mut min_avg_mae = f64::MAX;
for i in 1..20 {
let alpha = i as f64 * 0.05;
let mut total_sq = 0.0;
let mut total_abs = 0.0;
let mut total_n = 0usize;
for fold in 0..FOLDS {
let test_start = fold * fold_size;
let test_end = if fold == FOLDS - 1 {
n
} else {
(fold + 1) * fold_size
};
let (sq, ab, nn) = eval_fold(windows, alpha, test_start, test_end);
total_sq += sq;
total_abs += ab;
total_n += nn;
}
if total_n == 0 {
continue;
}
let cv_rmse = (total_sq / total_n as f64).sqrt();
let cv_mae = total_abs / total_n as f64;
if cv_rmse < min_avg_rmse {
min_avg_rmse = cv_rmse;
min_avg_mae = cv_mae;
best_alpha = alpha;
}
}
AutomlForecastResult {
best_alpha,
min_avg_rmse,
min_avg_mae,
folds: FOLDS,
}
}
#[wasm_bindgen]
pub fn discover_automl_classify(
eventlog_handle: &str,
activity_key: &str,
) -> Result<JsValue, JsValue> {
let state = get_or_init_state();
let (features, labels) = state.with_object(eventlog_handle, |obj| match obj {
Some(StoredObject::EventLog(log)) => Ok(extract_features(log, activity_key)),
_ => Err(crate::error::js_val("not_found")),
})?;
let n = features.len();
if n < 10 {
return to_js_val(&json!({
"algorithm": "automl_classify",
"error": "Insufficient data for 5-fold CV"
}));
}
let result = discover_automl_classify_internal(&features, &labels);
to_js_val(&json!({
"algorithm": "automl_classify",
"best_k": result.best_k,
"max_accuracy": result.max_avg_accuracy,
"status": "OPTIMIZED",
"folds": 5
}))
}
pub struct AutomlClassifyResult {
pub best_k: usize,
pub max_avg_accuracy: f64,
}
pub fn discover_automl_classify_internal(
features: &[[f64; 2]],
labels: &[u8],
) -> AutomlClassifyResult {
const FOLDS: usize = 5;
const MAX_K: usize = 15;
let accuracies = knn_sweep_cv(features, labels, FOLDS, MAX_K);
let mut best_k = 1;
let mut max_avg_accuracy = -1.0;
for (k, &acc) in accuracies[1..=MAX_K].iter().enumerate() {
let k = k + 1; if acc > max_avg_accuracy {
max_avg_accuracy = acc;
best_k = k;
}
}
AutomlClassifyResult {
best_k,
max_avg_accuracy,
}
}
fn to_js_val(value: &serde_json::Value) -> Result<JsValue, JsValue> {
serde_json::to_string(value)
.map(|s| crate::error::js_val(&s))
.map_err(|e| crate::error::wasm_err(crate::error::codes::INTERNAL_ERROR, e.to_string()))
}
#[cfg(test)]
mod cv_semantics_tests {
use super::*;
#[test]
fn fold_indices_train_and_test_are_disjoint() {
let n = 25usize;
const FOLDS: usize = 5;
let fold_size = n / FOLDS;
for fold in 0..FOLDS {
let test_start = fold * fold_size;
let test_end = if fold == FOLDS - 1 {
n
} else {
(fold + 1) * fold_size
};
let test: std::collections::HashSet<usize> = (test_start..test_end).collect();
let train: std::collections::HashSet<usize> =
(0..test_start).chain(test_end..n).collect();
assert!(
train.is_disjoint(&test),
"fold {}: train ∩ test = {:?}",
fold,
train.intersection(&test).collect::<Vec<_>>()
);
assert_eq!(
train.len() + test.len(),
n,
"fold {}: train+test must partition [0,n)",
fold
);
}
}
#[test]
fn test_fold_does_not_contribute_to_training() {
let mut windows = vec![1.0; 16];
windows.extend(vec![9.0; 4]);
let alpha = 0.5;
let (sum_sq, sum_abs, n_err) = eval_fold(&windows, alpha, 16, 20);
assert_eq!(
n_err, 4,
"every test-fold element must contribute one residual"
);
assert!(
sum_sq >= 64.0,
"first test residual must reflect train-derived level (~1.0) vs test[0]=9.0; \
sum_sq={} (broken chunked-eval would give sum_sq < 64.0 because it would \
initialize EWMA from test[0] itself)",
sum_sq
);
assert!(
sum_abs >= 8.0,
"first |residual| must be ≥ |9 - 1| = 8; sum_abs={}",
sum_abs
);
}
#[test]
fn proper_cv_differs_from_chunked_evaluation() {
let windows: Vec<f64> = (0..20).map(|i| (i as f64).sin() * 3.0 + 5.0).collect();
let alpha = 0.3;
let (proper_sq, _, _) = eval_fold(&windows, alpha, 8, 12);
let chunked_rmse = {
let slice = &windows[8..12];
let n = slice.len();
if n == 0 {
0.0
} else {
let mut s = slice[0];
let mut sq = 0.0;
for &v in slice.iter().skip(1) {
let err = v - s;
sq += err * err;
s = alpha * v + (1.0 - alpha) * s;
}
sq
}
};
let diff = (proper_sq - chunked_rmse).abs();
assert!(
diff > 1e-6,
"proper-CV sum_sq ({}) must differ from chunked-eval sum_sq ({}); diff={}",
proper_sq,
chunked_rmse,
diff
);
}
#[test]
fn aggregated_cv_mae_and_rmse_are_reported_and_consistent() {
let windows: Vec<f64> = (0..30).map(|i| 1.0 + (i as f64) * 0.5).collect();
let result = discover_automl_forecast_internal(&windows);
assert!(result.min_avg_mae.is_finite(), "cv_mae must be finite");
assert!(result.min_avg_rmse.is_finite(), "cv_rmse must be finite");
assert!(result.min_avg_mae >= 0.0, "cv_mae must be non-negative");
assert!(result.min_avg_rmse >= 0.0, "cv_rmse must be non-negative");
assert!(
result.min_avg_mae <= result.min_avg_rmse + 1e-9,
"MAE ({}) must be <= RMSE ({})",
result.min_avg_mae,
result.min_avg_rmse
);
assert_eq!(result.folds, 5, "folds metadata must be reported");
}
#[test]
fn flat_series_yields_near_zero_cv_rmse() {
let windows = vec![7.0_f64; 25];
let result = discover_automl_forecast_internal(&windows);
assert!(
result.min_avg_rmse < 1e-9,
"flat series cv_rmse must be ~0, got {}",
result.min_avg_rmse
);
assert!(
result.min_avg_mae < 1e-9,
"flat series cv_mae must be ~0, got {}",
result.min_avg_mae
);
}
#[test]
fn insufficient_data_returns_infinity_sentinel() {
let windows = vec![1.0, 2.0, 3.0];
let result = discover_automl_forecast_internal(&windows);
assert!(
result.min_avg_rmse.is_infinite(),
"n < folds+1 must yield infinity sentinel, got {}",
result.min_avg_rmse
);
}
}