use ndarray::{Array2, array, s};
use rustyml::error::Error;
use rustyml::machine_learning::IsolationForest;
#[test]
fn test_new_n_estimators_zero_returns_invalid_parameter() {
let err = IsolationForest::new(0, 256).unwrap_err();
assert!(
matches!(err, Error::InvalidParameter { ref name, .. } if name == "n_estimators"),
"expected InvalidParameter for n_estimators=0, got: {err:?}"
);
}
#[test]
fn test_new_max_samples_zero_returns_invalid_parameter() {
let err = IsolationForest::new(10, 0).unwrap_err();
assert!(
matches!(err, Error::InvalidParameter { ref name, .. } if name == "max_samples"),
"expected InvalidParameter for max_samples=0, got: {err:?}"
);
}
#[test]
fn test_new_max_depth_zero_returns_invalid_parameter() {
let err = IsolationForest::new(10, 256)
.unwrap()
.with_max_depth(0)
.unwrap_err();
assert!(
matches!(err, Error::InvalidParameter { ref name, .. } if name == "max_depth"),
"expected InvalidParameter for max_depth=Some(0), got: {err:?}"
);
}
#[test]
fn test_new_valid_explicit_max_depth_succeeds() {
let model = IsolationForest::new(20, 64)
.unwrap()
.with_max_depth(5)
.unwrap()
.with_random_state(99);
assert_eq!(model.get_n_estimators(), 20);
assert_eq!(model.get_max_samples(), 64);
assert_eq!(model.get_max_depth(), 5);
assert_eq!(model.get_random_state(), Some(99));
assert_eq!(model.get_n_features(), 0);
assert!(model.get_trees().is_none());
}
#[test]
fn test_new_auto_max_depth_ceil_log2_max_samples() {
let model = IsolationForest::new(10, 256).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 8);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_non_power_of_two() {
let model = IsolationForest::new(10, 100).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 7);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_two() {
let model = IsolationForest::new(10, 2).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 1);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_one() {
let model = IsolationForest::new(10, 1).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 0);
}
#[test]
fn test_default_has_expected_parameter_values() {
let model = IsolationForest::default();
assert_eq!(model.get_n_estimators(), 100);
assert_eq!(model.get_max_samples(), 256);
assert_eq!(model.get_max_depth(), 8); assert_eq!(model.get_random_state(), None);
assert_eq!(model.get_n_features(), 0);
assert!(model.get_trees().is_none());
}
#[test]
fn test_predict_before_fit_returns_not_fitted() {
let model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, 2.0], [3.0, 4.0]];
let err = model.predict(&x).unwrap_err();
assert!(
matches!(err, Error::NotFitted("IsolationForest")),
"expected NotFitted, got: {err:?}"
);
}
#[test]
fn test_anomaly_score_before_fit_returns_not_fitted() {
let model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let err = model.anomaly_score(&[1.0, 2.0]).unwrap_err();
assert!(
matches!(err, Error::NotFitted("IsolationForest")),
"expected NotFitted, got: {err:?}"
);
}
#[test]
fn test_fit_empty_data_returns_empty_input() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x: Array2<f64> = Array2::zeros((0, 2));
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::EmptyInput(_)),
"expected EmptyInput, got: {err:?}"
);
}
#[test]
fn test_fit_nan_returns_non_finite() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, f64::NAN], [2.0, 3.0]];
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite for NaN, got: {err:?}"
);
}
#[test]
fn test_fit_inf_returns_non_finite() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, f64::INFINITY], [2.0, 3.0]];
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite for Inf, got: {err:?}"
);
}
#[test]
fn test_fit_neg_inf_returns_non_finite() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, f64::NEG_INFINITY], [2.0, 3.0]];
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite for -Inf, got: {err:?}"
);
}
#[test]
fn test_predict_empty_data_returns_empty_input() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
model.fit(&train).unwrap();
let x_empty: Array2<f64> = Array2::zeros((0, 2));
let err = model.predict(&x_empty).unwrap_err();
assert!(
matches!(err, Error::EmptyInput(_)),
"expected EmptyInput, got: {err:?}"
);
}
#[test]
fn test_predict_wrong_feature_count_returns_dimension_mismatch() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let x_wrong = array![[1.0, 2.0, 3.0]];
let err = model.predict(&x_wrong).unwrap_err();
assert!(
matches!(
err,
Error::DimensionMismatch {
expected: 2,
found: 3
}
),
"expected DimensionMismatch{{expected:2, found:3}}, got: {err:?}"
);
}
#[test]
fn test_predict_nan_returns_non_finite() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let x_nan = array![[f64::NAN, 2.0]];
let err = model.predict(&x_nan).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite, got: {err:?}"
);
}
#[test]
fn test_anomaly_score_wrong_dim_returns_dimension_mismatch() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let err = model.anomaly_score(&[1.0, 2.0, 3.0]).unwrap_err();
assert!(
matches!(
err,
Error::DimensionMismatch {
expected: 2,
found: 3
}
),
"expected DimensionMismatch{{expected:2, found:3}}, got: {err:?}"
);
}
#[test]
fn test_fit_sets_n_features() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(42);
let train = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
model.fit(&train).unwrap();
assert_eq!(model.get_n_features(), 3);
}
#[test]
fn test_fit_stores_exactly_n_estimators_trees() {
let n_estimators = 15_usize;
let mut model = IsolationForest::new(n_estimators, 50)
.unwrap()
.with_random_state(42);
let train = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [2.0, 3.0], [4.0, 5.0]];
model.fit(&train).unwrap();
let trees = model.get_trees().expect("trees should be Some after fit");
assert_eq!(
trees.len(),
n_estimators,
"expected exactly {n_estimators} trees, got {}",
trees.len()
);
}
#[test]
fn test_predict_scores_are_in_unit_interval() {
let mut model = IsolationForest::new(50, 64).unwrap().with_random_state(7);
let train = array![
[0.0, 0.0],
[0.1, 0.0],
[0.0, 0.1],
[0.1, 0.1],
[0.2, 0.2],
[100.0, 100.0]
];
model.fit(&train).unwrap();
let scores = model.predict(&train).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(0.0..=1.0).contains(&s),
"score[{i}] = {s} is outside [0, 1]"
);
}
}
#[test]
fn test_anomaly_score_is_in_unit_interval() {
let mut model = IsolationForest::new(50, 64).unwrap().with_random_state(7);
let train = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [50.0, 50.0]];
model.fit(&train).unwrap();
let s_inlier = model.anomaly_score(&[0.0, 0.0]).unwrap();
let s_outlier = model.anomaly_score(&[50.0, 50.0]).unwrap();
assert!(
(0.0..=1.0).contains(&s_inlier),
"inlier score {s_inlier} not in [0,1]"
);
assert!(
(0.0..=1.0).contains(&s_outlier),
"outlier score {s_outlier} not in [0,1]"
);
}
#[test]
fn test_outlier_score_exceeds_all_inlier_scores() {
let inliers = array![
[0.0, 0.0],
[0.1, 0.0],
[-0.1, 0.0],
[0.0, 0.1],
[0.0, -0.1],
[0.1, 0.1],
[-0.1, 0.1],
[0.1, -0.1],
[-0.1, -0.1],
[0.2, 0.2]
];
let outlier_row = array![[1000.0, 1000.0]];
let mut train_data: Array2<f64> = Array2::zeros((11, 2));
train_data.slice_mut(s![..10, ..]).assign(&inliers);
train_data.slice_mut(s![10..11, ..]).assign(&outlier_row);
let mut model = IsolationForest::new(100, 64).unwrap().with_random_state(42);
model.fit(&train_data).unwrap();
let scores = model.predict(&train_data).unwrap();
let outlier_score = scores[10];
let max_inlier_score = scores
.slice(s![..10])
.fold(f64::NEG_INFINITY, |acc, &v| acc.max(v));
assert!(
outlier_score > max_inlier_score,
"outlier score {outlier_score:.4} should exceed all inlier scores (max inlier: {max_inlier_score:.4})"
);
}
#[test]
fn test_outlier_anomaly_score_exceeds_inlier_via_single_sample_api() {
let mut train_data: Array2<f64> = Array2::zeros((11, 2));
let inlier_coords: &[(f64, f64)] = &[
(0.0, 0.0),
(0.1, 0.0),
(-0.1, 0.0),
(0.0, 0.1),
(0.0, -0.1),
(0.1, 0.1),
(-0.1, 0.1),
(0.1, -0.1),
(-0.1, -0.1),
(0.2, 0.2),
];
for (i, &(x, y)) in inlier_coords.iter().enumerate() {
train_data[[i, 0]] = x;
train_data[[i, 1]] = y;
}
train_data[[10, 0]] = 1000.0;
train_data[[10, 1]] = 1000.0;
let mut model = IsolationForest::new(100, 64).unwrap().with_random_state(42);
model.fit(&train_data).unwrap();
let outlier_score = model.anomaly_score(&[1000.0, 1000.0]).unwrap();
let max_inlier_score = inlier_coords
.iter()
.map(|&(x, y)| model.anomaly_score(&[x, y]).unwrap())
.fold(f64::NEG_INFINITY, f64::max);
assert!(
outlier_score > max_inlier_score,
"outlier score {outlier_score:.4} should exceed max inlier score {max_inlier_score:.4}"
);
}
#[test]
fn test_identical_points_have_equal_scores() {
let data = array![
[1.0, 2.0],
[3.0, 4.0],
[1.0, 2.0], [5.0, 6.0]
];
let mut model = IsolationForest::new(50, 32).unwrap().with_random_state(55);
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
assert_eq!(
scores[0], scores[2],
"identical inputs must produce identical anomaly scores: {} vs {}",
scores[0], scores[2]
);
}
#[test]
fn test_same_seed_produces_identical_scores() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [2.0, 2.0], [50.0, 50.0]];
let mut model_a = IsolationForest::new(30, 20).unwrap().with_random_state(13);
model_a.fit(&data).unwrap();
let scores_a = model_a.predict(&data).unwrap();
let mut model_b = IsolationForest::new(30, 20).unwrap().with_random_state(13);
model_b.fit(&data).unwrap();
let scores_b = model_b.predict(&data).unwrap();
assert_eq!(
scores_a, scores_b,
"two models with the same seed must produce identical scores"
);
}
#[test]
fn test_different_seeds_may_produce_different_scores() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [2.0, 2.0], [50.0, 50.0]];
let mut model_a = IsolationForest::new(50, 32).unwrap().with_random_state(1);
model_a.fit(&data).unwrap();
let scores_a = model_a.predict(&data).unwrap();
let mut model_b = IsolationForest::new(50, 32).unwrap().with_random_state(2);
model_b.fit(&data).unwrap();
let scores_b = model_b.predict(&data).unwrap();
let any_differ = scores_a
.iter()
.zip(scores_b.iter())
.any(|(a, b)| (a - b).abs() > 1e-12);
assert!(
any_differ,
"different seeds should produce different scores (got identical scores for seeds 1 and 2)"
);
}
#[test]
fn test_fit_predict_matches_fit_then_predict() {
let data = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [10.0, 10.0]];
let mut model_a = IsolationForest::new(40, 32).unwrap().with_random_state(77);
model_a.fit(&data).unwrap();
let scores_a = model_a.predict(&data).unwrap();
let mut model_b = IsolationForest::new(40, 32).unwrap().with_random_state(77);
let scores_b = model_b.fit_predict(&data).unwrap();
assert_eq!(
scores_a, scores_b,
"fit_predict must produce the same scores as fit + predict with the same seed"
);
}
#[test]
fn test_fit_and_predict_on_single_sample() {
let mut model = IsolationForest::new(5, 10).unwrap().with_random_state(1);
let data = array![[3.0, 4.0]];
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
assert_eq!(scores.len(), 1);
assert!(
(scores[0] - 1.0).abs() < 1e-12,
"single-sample score should be 1.0, got {}",
scores[0]
);
}
#[test]
fn test_n_features_reflects_training_data_columns() {
let mut model = IsolationForest::new(10, 20).unwrap().with_random_state(1);
assert_eq!(model.get_n_features(), 0);
let data = array![[1.0, 2.0, 3.0, 4.0, 5.0]];
model.fit(&data).unwrap();
assert_eq!(model.get_n_features(), 5);
}
#[test]
fn test_fit_with_fewer_rows_than_max_samples_succeeds() {
let mut model = IsolationForest::new(10, 256).unwrap().with_random_state(1);
let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0], [9.0, 10.0]];
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
assert_eq!(scores.len(), 5);
for &s in scores.iter() {
assert!((0.0..=1.0).contains(&s), "score {s} not in [0,1]");
}
}
#[test]
fn test_fit_with_constant_feature_column_does_not_panic() {
let mut model = IsolationForest::new(10, 32).unwrap().with_random_state(42);
let data = array![[1.0, 5.0], [2.0, 5.0], [3.0, 5.0], [4.0, 5.0], [5.0, 5.0]];
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
assert_eq!(scores.len(), 5);
}
#[test]
fn test_fit_and_predict_with_single_feature() {
let mut model = IsolationForest::new(20, 32).unwrap().with_random_state(42);
let data = array![[0.0], [0.1], [-0.1], [0.2], [999.0]];
model.fit(&data).unwrap();
assert_eq!(model.get_n_features(), 1);
let scores = model.predict(&data).unwrap();
assert_eq!(scores.len(), 5);
let outlier_score = scores[4];
let max_inlier = scores
.slice(s![..4])
.fold(f64::NEG_INFINITY, |a, &v| a.max(v));
assert!(
outlier_score > max_inlier,
"1-D outlier score {outlier_score:.4} should exceed inlier scores (max={max_inlier:.4})"
);
}
#[test]
fn test_fit_and_predict_high_dimensional() {
let mut model = IsolationForest::new(50, 32).unwrap().with_random_state(42);
let mut data: Array2<f64> = Array2::zeros((7, 5));
for i in 0..6 {
for j in 0..5 {
data[[i, j]] = 0.1 * i as f64;
}
}
for j in 0..5 {
data[[6, j]] = 100.0;
}
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
for &s in scores.iter() {
assert!((0.0..=1.0).contains(&s));
}
let outlier_score = scores[6];
let max_inlier = scores
.slice(s![..6])
.fold(f64::NEG_INFINITY, |a, &v| a.max(v));
assert!(
outlier_score > max_inlier,
"high-dim outlier {outlier_score:.4} must exceed inlier max {max_inlier:.4}"
);
}
#[test]
fn test_save_load_roundtrip_yields_identical_predictions() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 0.0], [0.0, 1.0], [50.0, 50.0]];
let mut model = IsolationForest::new(30, 20).unwrap().with_random_state(99);
model.fit(&data).unwrap();
let scores_before = model.predict(&data).unwrap();
let path = "/tmp/rustyml_isolation_forest_test.json";
model.save_to_path(path).unwrap();
let loaded = IsolationForest::load_from_path(path).unwrap();
let scores_after = loaded.predict(&data).unwrap();
assert_eq!(
scores_before, scores_after,
"predictions must be identical before and after save/load"
);
assert_eq!(loaded.get_n_estimators(), model.get_n_estimators());
assert_eq!(loaded.get_max_samples(), model.get_max_samples());
assert_eq!(loaded.get_max_depth(), model.get_max_depth());
assert_eq!(loaded.get_n_features(), model.get_n_features());
let _ = std::fs::remove_file(path);
}
#[test]
fn test_load_from_nonexistent_path_returns_io_error() {
let err =
IsolationForest::load_from_path("/tmp/this_file_does_not_exist_rustyml.json").unwrap_err();
assert!(
matches!(err, Error::Io(_)),
"expected Io error when loading from missing file, got: {err:?}"
);
}
#[test]
fn test_identical_points_score_equals_one_half_when_sample_size_equals_max_samples() {
let data = array![[2.0, 7.0], [2.0, 7.0], [2.0, 7.0], [2.0, 7.0]];
let mut model = IsolationForest::new(20, 4).unwrap().with_random_state(123);
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(s - 0.5).abs() < 1e-12,
"row {i}: expected exactly 0.5, got {s}"
);
}
}
#[test]
fn test_identical_points_score_matches_closed_form_when_sample_size_below_max_samples() {
let data = array![[1.0, -3.0], [1.0, -3.0], [1.0, -3.0], [1.0, -3.0]];
let mut model = IsolationForest::new(25, 8).unwrap().with_random_state(7);
model.fit(&data).unwrap();
let scores = model.predict(&data).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(s - 0.5).abs() < 1e-12,
"row {i}: expected 0.5 (normalization uses c(sample_size), not c(max_samples)), got {s}"
);
}
}
#[test]
fn predict_handles_non_contiguous_input() {
let train = array![[0.0, 0.0], [0.1, 0.1], [0.2, -0.1], [5.0, 5.0], [-4.0, 4.0]];
let mut model = IsolationForest::new(20, 8).unwrap().with_random_state(1);
model.fit(&train).unwrap();
let ft = array![[0.0, 0.1, 5.0], [0.0, 0.1, 5.0]]; let x = ft.t(); assert!(
x.row(0).as_slice().is_none(),
"test setup: transposed rows must be non-contiguous to exercise the bug"
);
let result = model.predict(&x);
assert!(
result.is_ok(),
"predict must handle non-contiguous input without panicking, got {result:?}"
);
assert_eq!(result.unwrap().len(), 3, "one score per input row");
}
#[test]
fn predict_labels_flags_obvious_outlier() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0] ];
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_random_state(42);
model.fit(&x).unwrap();
let labels = model.predict_labels(&x, 0.1).unwrap();
let n_out = labels.iter().filter(|&&l| l == -1).count();
assert_eq!(
n_out, 1,
"contamination 0.1 on 10 samples flags exactly 1 outlier"
);
assert_eq!(labels[9], -1, "the far point must be the flagged outlier");
for i in 0..9 {
assert_eq!(labels[i], 1, "inlier {i} must be labelled +1");
}
}
#[test]
fn predict_labels_count_matches_contamination() {
let mut flat = Vec::new();
for i in 0..20 {
let v = i as f64;
flat.push(v.sin());
flat.push(v.cos() * 3.0);
}
let x = Array2::from_shape_vec((20, 2), flat).unwrap();
let mut model = IsolationForest::new(100, 256).unwrap().with_random_state(7);
model.fit(&x).unwrap();
let labels = model.predict_labels(&x, 0.25).unwrap();
let n_out = labels.iter().filter(|&&l| l == -1).count();
assert_eq!(
n_out, 5,
"ceil(0.25 * 20) = 5 outliers expected, got {n_out}"
);
for &l in labels.iter() {
assert!(l == -1 || l == 1, "label {l} not in {{-1, +1}}");
}
}
#[test]
fn predict_labels_rejects_invalid_contamination() {
let x = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [0.1, 0.1]];
let mut model = IsolationForest::new(10, 8).unwrap().with_random_state(1);
model.fit(&x).unwrap();
for bad in [0.0, -0.1, 0.51, 1.0, f64::NAN, f64::INFINITY] {
assert!(
matches!(
model.predict_labels(&x, bad),
Err(Error::InvalidParameter { .. })
),
"contamination={bad} must be rejected"
);
}
assert!(model.predict_labels(&x, 0.25).is_ok());
}