use ndarray::{Array2, array};
use rustyml::error::Error;
use rustyml::machine_learning::DBSCAN;
use rustyml::machine_learning::DistanceCalculationMetric;
fn two_blobs_noise() -> Array2<f64> {
Array2::from_shape_vec(
(9, 2),
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, 5.0, 5.0,
],
)
.unwrap()
}
#[test]
fn constructor_rejects_eps_zero() {
let result = DBSCAN::new(0.0, 2);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for eps=0, got: {:?}",
result
);
}
#[test]
fn constructor_rejects_eps_negative() {
let result = DBSCAN::new(-1.0, 2);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for eps=-1, got: {:?}",
result
);
}
#[test]
fn constructor_rejects_eps_nan() {
let result = DBSCAN::new(f64::NAN, 2);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for eps=NaN, got: {:?}",
result
);
}
#[test]
fn constructor_rejects_eps_inf() {
let result = DBSCAN::new(f64::INFINITY, 2);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for eps=inf, got: {:?}",
result
);
}
#[test]
fn constructor_rejects_min_samples_zero() {
let result = DBSCAN::new(0.5, 0);
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for min_samples=0, got: {:?}",
result
);
}
#[test]
fn constructor_rejects_minkowski_p_zero() {
let result = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(0.0));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for Minkowski(0), got: {:?}",
result
);
}
#[test]
fn constructor_rejects_minkowski_p_below_one() {
let result = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(0.5));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for Minkowski(0.5), got: {:?}",
result
);
}
#[test]
fn constructor_rejects_minkowski_p_negative() {
let result = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(-1.0));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for Minkowski(-1), got: {:?}",
result
);
}
#[test]
fn constructor_rejects_minkowski_p_nan() {
let result = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(f64::NAN));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for Minkowski(NaN), got: {:?}",
result
);
}
#[test]
fn constructor_rejects_minkowski_p_inf() {
let result = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(f64::INFINITY));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"expected InvalidParameter for Minkowski(inf), got: {:?}",
result
);
}
#[test]
fn constructor_valid_stores_parameters() {
let m = DBSCAN::new(0.75, 3)
.unwrap()
.with_metric(DistanceCalculationMetric::Manhattan)
.unwrap();
approx::assert_abs_diff_eq!(m.get_epsilon(), 0.75, epsilon = 1e-12);
assert_eq!(m.get_min_samples(), 3);
assert_eq!(m.get_metric(), DistanceCalculationMetric::Manhattan);
}
#[test]
fn constructor_default_values() {
let m = DBSCAN::default();
approx::assert_abs_diff_eq!(m.get_epsilon(), 0.5, epsilon = 1e-12);
assert_eq!(m.get_min_samples(), 5);
assert_eq!(m.get_metric(), DistanceCalculationMetric::Euclidean);
assert!(m.get_labels().is_none());
assert!(m.get_core_sample_indices().is_none());
}
#[test]
fn fit_rejects_empty_data() {
let data: Array2<f64> = Array2::zeros((0, 2));
let mut m = DBSCAN::new(0.5, 2).unwrap();
assert!(
matches!(m.fit(&data), Err(Error::EmptyInput(_))),
"expected EmptyInput for 0-row data"
);
}
#[test]
fn fit_rejects_nan_in_data() {
let data = array![[1.0f64, 2.0], [f64::NAN, 3.0]];
let mut m = DBSCAN::new(0.5, 2).unwrap();
assert!(
matches!(m.fit(&data), Err(Error::NonFinite(_))),
"expected NonFinite for NaN in data"
);
}
#[test]
fn fit_rejects_inf_in_data() {
let data = array![[1.0f64, 2.0], [f64::INFINITY, 3.0]];
let mut m = DBSCAN::new(0.5, 2).unwrap();
assert!(
matches!(m.fit(&data), Err(Error::NonFinite(_))),
"expected NonFinite for infinity in data"
);
}
#[test]
fn predict_before_fit_returns_not_fitted() {
let m = DBSCAN::new(0.5, 2).unwrap();
let data = array![[1.0f64, 2.0]];
assert!(
matches!(m.predict(&data), Err(Error::NotFitted(_))),
"expected NotFitted before fit"
);
}
#[test]
fn predict_empty_new_data_returns_empty_array() {
let train = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&train).unwrap();
let empty: Array2<f64> = Array2::zeros((0, 2));
let preds = m.predict(&empty).expect("expected Ok for empty new_data");
assert_eq!(preds.len(), 0);
}
#[test]
fn predict_wrong_feature_count_returns_dimension_mismatch() {
let train = two_blobs_noise(); let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&train).unwrap();
let wrong: Array2<f64> = Array2::zeros((3, 3)); assert!(
matches!(m.predict(&wrong), Err(Error::DimensionMismatch { .. })),
"expected DimensionMismatch for wrong feature count"
);
}
#[test]
fn predict_nan_in_new_data_returns_non_finite() {
let train = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&train).unwrap();
let bad = array![[f64::NAN, 1.0f64]];
assert!(
matches!(m.predict(&bad), Err(Error::NonFinite(_))),
"expected NonFinite for NaN in new_data"
);
}
#[test]
fn predict_inf_in_new_data_returns_non_finite() {
let train = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&train).unwrap();
let bad = array![[f64::INFINITY, 1.0f64]];
assert!(
matches!(m.predict(&bad), Err(Error::NonFinite(_))),
"expected NonFinite for infinity in new_data"
);
}
#[test]
fn fit_euclidean_correct_labels_two_blobs_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
assert_eq!(labels.len(), 9, "label count must equal n_samples");
let label_a = labels[0];
assert!(label_a >= 0, "blob A must not be noise");
assert_eq!(labels[1], label_a);
assert_eq!(labels[2], label_a);
assert_eq!(labels[3], label_a);
let label_b = labels[4];
assert!(label_b >= 0, "blob B must not be noise");
assert_eq!(labels[5], label_b);
assert_eq!(labels[6], label_b);
assert_eq!(labels[7], label_b);
assert_ne!(
label_a, label_b,
"the two blobs must have different cluster labels"
);
assert_eq!(labels[8], -1, "isolated noise point must be labelled -1");
assert_eq!(label_a, 0, "blob A should be cluster 0 (discovered first)");
assert_eq!(label_b, 1, "blob B should be cluster 1 (discovered second)");
}
#[test]
fn fit_high_dimensional_falls_back_to_brute_force() {
let n_features = 18; let mut data = Array2::<f64>::zeros((9, n_features));
for i in 0..4 {
for j in 0..n_features {
data[[i, j]] = 0.1 * i as f64;
}
}
for i in 4..8 {
for j in 0..n_features {
data[[i, j]] = 10.0 + 0.1 * i as f64;
}
}
for j in 0..n_features {
data[[8, j]] = 100.0;
}
let mut m = DBSCAN::new(2.0, 2).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
let label_a = labels[0];
let label_b = labels[4];
assert!(label_a >= 0 && label_b >= 0, "both blobs must be clustered");
assert_ne!(label_a, label_b, "the two blobs must be distinct clusters");
for i in 0..4 {
assert_eq!(labels[i], label_a, "blob A row {i} mislabeled");
}
for i in 4..8 {
assert_eq!(labels[i], label_b, "blob B row {i} mislabeled");
}
assert_eq!(labels[8], -1, "isolated point must be noise");
}
#[test]
fn fit_euclidean_core_indices_sorted_and_non_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let core_indices = m.get_core_sample_indices().unwrap();
let labels = m.get_labels().unwrap();
for window in core_indices.windows(2) {
assert!(
window[0] < window[1],
"core_sample_indices must be sorted: {:?}",
core_indices
);
}
for &idx in core_indices.iter() {
assert!(
labels[idx] >= 0,
"core point at index {} has label {} (noise)",
idx,
labels[idx]
);
}
assert_eq!(
core_indices.len(),
8,
"all 8 blob points should be core points"
);
}
#[test]
fn predict_core_training_points_return_fit_labels() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let fit_labels = m.get_labels().unwrap();
let core_indices = m.get_core_sample_indices().unwrap();
let n_core = core_indices.len();
let mut core_data = Array2::<f64>::zeros((n_core, 2));
for (i, &idx) in core_indices.iter().enumerate() {
core_data.row_mut(i).assign(&data.row(idx));
}
let preds = m.predict(&core_data).unwrap();
for (i, &idx) in core_indices.iter().enumerate() {
assert_eq!(
preds[i], fit_labels[idx],
"predict on core point {} (row {}) returned {} but fit label is {}",
i, idx, preds[i], fit_labels[idx]
);
}
}
#[test]
fn predict_new_point_near_blob_a_returns_label_0() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let new_point = array![[0.05f64, 0.05]];
let preds = m.predict(&new_point).unwrap();
assert_eq!(
preds[0], 0,
"point near blob A should be predicted as cluster 0"
);
}
#[test]
fn predict_new_point_near_blob_b_returns_label_1() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let new_point = array![[10.05f64, 10.05]];
let preds = m.predict(&new_point).unwrap();
assert_eq!(
preds[0], 1,
"point near blob B should be predicted as cluster 1"
);
}
#[test]
fn predict_far_point_returns_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let far_point = array![[5.0f64, 5.0]];
let preds = m.predict(&far_point).unwrap();
assert_eq!(
preds[0], -1,
"far-away point should be predicted as noise (-1)"
);
}
#[test]
fn predict_point_at_eps_boundary_inclusive() {
let train = array![[0.0f64, 0.0]];
let mut m = DBSCAN::new(0.5, 1).unwrap();
m.fit(&train).unwrap();
let boundary_point = array![[0.5f64, 0.0]];
let preds = m.predict(&boundary_point).unwrap();
assert_eq!(
preds[0], 0,
"point at exactly eps from core (0,0) must be assigned to cluster 0"
);
}
#[test]
fn predict_point_just_beyond_eps_is_noise() {
let train = array![[0.0f64, 0.0]];
let mut m = DBSCAN::new(0.5, 1).unwrap();
m.fit(&train).unwrap();
let beyond_point = array![[0.65f64, 0.0]];
let preds = m.predict(&beyond_point).unwrap();
assert_eq!(
preds[0], -1,
"point 0.65 away from core, beyond eps=0.5, must be -1"
);
}
#[test]
fn clustering_euclidean_metric_two_blobs_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
let labels = m.fit_predict(&data).unwrap();
assert_eq!(labels.len(), 9);
assert!(labels.iter().take(4).all(|&l| l == 0));
assert!(labels.iter().skip(4).take(4).all(|&l| l == 1));
assert_eq!(labels[8], -1);
}
#[test]
fn clustering_manhattan_metric_two_blobs_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Manhattan)
.unwrap();
let labels = m.fit_predict(&data).unwrap();
assert_eq!(labels.len(), 9);
let label_a = labels[0];
assert!(label_a >= 0);
assert!(labels.iter().take(4).all(|&l| l == label_a));
let label_b = labels[4];
assert!(label_b >= 0);
assert!(labels.iter().skip(4).take(4).all(|&l| l == label_b));
assert_ne!(label_a, label_b);
assert_eq!(labels[8], -1);
}
#[test]
fn clustering_minkowski_p3_metric_two_blobs_noise() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(3.0))
.unwrap();
let labels = m.fit_predict(&data).unwrap();
assert_eq!(labels.len(), 9);
let label_a = labels[0];
assert!(label_a >= 0);
assert!(labels.iter().take(4).all(|&l| l == label_a));
let label_b = labels[4];
assert!(label_b >= 0);
assert!(labels.iter().skip(4).take(4).all(|&l| l == label_b));
assert_ne!(label_a, label_b);
assert_eq!(labels[8], -1);
}
#[test]
fn minkowski_p2_same_structure_as_euclidean() {
let data = two_blobs_noise();
let mut m_euc = DBSCAN::new(0.5, 2).unwrap();
let labels_euc = m_euc.fit_predict(&data).unwrap();
let mut m_mink2 = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(2.0))
.unwrap();
let labels_mink2 = m_mink2.fit_predict(&data).unwrap();
assert_eq!(
labels_euc, labels_mink2,
"Minkowski(2) must match Euclidean on the same data"
);
}
#[test]
fn minkowski_p1_same_structure_as_manhattan() {
let data = two_blobs_noise();
let mut m_man = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Manhattan)
.unwrap();
let labels_man = m_man.fit_predict(&data).unwrap();
let mut m_mink1 = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(1.0))
.unwrap();
let labels_mink1 = m_mink1.fit_predict(&data).unwrap();
assert_eq!(
labels_man, labels_mink1,
"Minkowski(1) must match Manhattan on the same data"
);
}
#[test]
fn fit_predict_equals_fit_then_get_labels() {
let data = two_blobs_noise();
let mut m_a = DBSCAN::new(0.5, 2).unwrap();
let labels_fp = m_a.fit_predict(&data).unwrap();
let mut m_b = DBSCAN::new(0.5, 2).unwrap();
m_b.fit(&data).unwrap();
let labels_fit = m_b.get_labels().unwrap().clone();
assert_eq!(
labels_fp, labels_fit,
"fit_predict must return the same labels as fit + get_labels"
);
}
#[test]
fn single_point_min_samples_1_is_core_cluster_0() {
let data = array![[1.0f64, 2.0]];
let mut m = DBSCAN::new(0.5, 1).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
let core_indices = m.get_core_sample_indices().unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(
labels[0], 0,
"single point with min_samples=1 should be cluster 0"
);
assert_eq!(core_indices.len(), 1);
assert_eq!(core_indices[0], 0);
}
#[test]
fn all_noise_when_eps_tiny() {
let data = array![[0.0f64, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]];
let mut m = DBSCAN::new(0.01, 3).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
let core_indices = m.get_core_sample_indices().unwrap();
assert!(
labels.iter().all(|&l| l == -1),
"all points should be noise; got: {:?}",
labels
);
assert_eq!(
core_indices.len(),
0,
"core_sample_indices must be empty when all are noise"
);
}
#[test]
fn all_connected_single_cluster() {
let data = array![[0.0f64, 0.0], [0.1, 0.0], [0.2, 0.0], [0.3, 0.0]];
let mut m = DBSCAN::new(5.0, 2).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
assert!(
labels.iter().all(|&l| l == 0),
"all-connected data should produce a single cluster (label 0); got: {:?}",
labels
);
}
#[test]
fn default_constructor_model_is_usable() {
let data = two_blobs_noise();
let mut m = DBSCAN::default();
assert!(
m.fit(&data).is_ok(),
"fit with default parameters should not error"
);
}
#[test]
fn predict_assigns_nearest_core_label_not_arbitrary() {
let train = array![[0.0f64, 0.0], [2.0, 0.0]];
let mut m = DBSCAN::new(1.5, 1).unwrap();
m.fit(&train).unwrap();
let labels = m.get_labels().unwrap();
assert_eq!(labels[0], 0);
assert_eq!(labels[1], 1);
let query = array![[0.6f64, 0.0]];
let preds = m.predict(&query).unwrap();
assert_eq!(
preds[0], 0,
"nearest core (A) is cluster 0; Q should be predicted 0"
);
}
#[test]
fn predict_nearest_core_outside_eps_returns_noise() {
let train = array![[0.0f64, 0.0]];
let mut m = DBSCAN::new(1.5, 1).unwrap();
m.fit(&train).unwrap();
let query = array![[2.0f64, 0.0]];
let preds = m.predict(&query).unwrap();
assert_eq!(
preds[0], -1,
"nearest core is at dist=2.0 > eps=1.5; predict must return -1"
);
}
#[test]
fn save_load_round_trip_preserves_state_and_predictions() {
let data = two_blobs_noise();
let mut original = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Manhattan)
.unwrap();
original.fit(&data).unwrap();
let path = "/tmp/rustyml_dbscan_test_roundtrip.json";
original
.save_to_path(path)
.expect("save_to_path must succeed");
let loaded = DBSCAN::load_from_path(path).expect("load_from_path must succeed");
approx::assert_abs_diff_eq!(
loaded.get_epsilon(),
original.get_epsilon(),
epsilon = 1e-12
);
assert_eq!(loaded.get_min_samples(), original.get_min_samples());
assert_eq!(loaded.get_metric(), original.get_metric());
let orig_labels = original.get_labels().unwrap();
let loaded_labels = loaded.get_labels().unwrap();
assert_eq!(
orig_labels, loaded_labels,
"labels must survive serialization"
);
let orig_indices = original.get_core_sample_indices().unwrap();
let loaded_indices = loaded.get_core_sample_indices().unwrap();
assert_eq!(
orig_indices, loaded_indices,
"core_sample_indices must survive serialization"
);
let new_points = array![[0.05f64, 0.05], [10.05, 10.05], [5.0, 5.0]];
let preds_orig = original.predict(&new_points).unwrap();
let preds_loaded = loaded.predict(&new_points).unwrap();
assert_eq!(
preds_orig, preds_loaded,
"predict output must be identical after round-trip"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn fit_labels_domain_correct() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
for &l in labels.iter() {
assert!(
l >= -1,
"label {} is outside valid domain (must be ≥ -1)",
l
);
}
}
#[test]
fn predict_labels_domain_correct() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let test_points = array![[0.05f64, 0.05], [10.05, 10.05], [5.0, 5.0]];
let preds = m.predict(&test_points).unwrap();
for &l in preds.iter() {
assert!(l >= -1, "predicted label {} is outside valid domain", l);
}
}
#[test]
fn predict_label_values_canonical_three_cases() {
let data = two_blobs_noise();
let mut m = DBSCAN::new(0.5, 2).unwrap();
m.fit(&data).unwrap();
let test_points = array![[0.05f64, 0.05], [10.05, 10.05], [5.0, 5.0]];
let preds = m.predict(&test_points).unwrap();
assert_eq!(preds.len(), 3);
assert_eq!(preds[0], 0, "point near blob A → cluster 0");
assert_eq!(preds[1], 1, "point near blob B → cluster 1");
assert_eq!(preds[2], -1, "isolated point → noise");
}
#[test]
fn all_three_metric_variants_stored_correctly() {
let m_euc = DBSCAN::new(0.5, 2).unwrap();
let m_man = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Manhattan)
.unwrap();
let m_mink = DBSCAN::new(0.5, 2)
.unwrap()
.with_metric(DistanceCalculationMetric::Minkowski(4.0))
.unwrap();
assert_eq!(m_euc.get_metric(), DistanceCalculationMetric::Euclidean);
assert_eq!(m_man.get_metric(), DistanceCalculationMetric::Manhattan);
assert_eq!(
m_mink.get_metric(),
DistanceCalculationMetric::Minkowski(4.0)
);
}
fn three_blobs_1200() -> Array2<f64> {
let centers = [(0.0_f64, 0.0_f64), (50.0, 0.0), (25.0, 50.0)];
let mut v = Vec::with_capacity(1200 * 2);
for (cx, cy) in centers {
for k in 0..400u32 {
v.push(cx + ((k * 7) % 11) as f64 * 0.04 - 0.20);
v.push(cy + ((k * 5) % 13) as f64 * 0.03 - 0.18);
}
}
Array2::from_shape_vec((1200, 2), v).unwrap()
}
#[test]
fn fit_parallel_branch_three_blobs_1200_correct_structure() {
let data = three_blobs_1200();
assert_eq!(data.nrows(), 1200, "dataset must cross the 1000 threshold");
let mut m = DBSCAN::new(1.0, 5).unwrap();
m.fit(&data).unwrap();
let labels = m.get_labels().unwrap();
assert_eq!(labels.len(), 1200, "one label per sample");
assert!(
labels.iter().all(|&l| l >= 0),
"no point should be noise on three dense, separated blobs"
);
let mut distinct: Vec<isize> = labels.iter().copied().collect();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(
distinct,
vec![0, 1, 2],
"expected exactly clusters {{0,1,2}}, got {distinct:?}"
);
for (blob, expected_id) in [(0usize, 0isize), (1, 1), (2, 2)] {
let start = blob * 400;
for i in start..start + 400 {
assert_eq!(
labels[i], expected_id,
"row {i} (blob {blob}) should be cluster {expected_id}, got {}",
labels[i]
);
}
}
let core = m.get_core_sample_indices().unwrap();
assert_eq!(core.len(), 1200, "all 1200 points should be core points");
}
#[test]
fn predict_parallel_branch_large_heldout_matches_blobs() {
let data = three_blobs_1200();
let mut m = DBSCAN::new(1.0, 5).unwrap();
m.fit(&data).unwrap();
let centers = [(0.0_f64, 0.0_f64), (50.0, 0.0), (25.0, 50.0)];
let mut v = Vec::with_capacity(1200 * 2);
for (cx, cy) in centers {
for k in 0..400u32 {
v.push(cx + ((k * 3) % 7) as f64 * 0.05 - 0.15);
v.push(cy + ((k * 4) % 7) as f64 * 0.05 - 0.15);
}
}
let held_out = Array2::from_shape_vec((1200, 2), v).unwrap();
assert_eq!(
held_out.nrows(),
1200,
"held-out set must cross the 1000 threshold"
);
let preds = m.predict(&held_out).unwrap();
assert_eq!(preds.len(), 1200);
for (block, expected_id) in [(0usize, 0isize), (1, 1), (2, 2)] {
let start = block * 400;
for i in start..start + 400 {
assert_eq!(
preds[i], expected_id,
"held-out row {i} (block {block}) should predict cluster {expected_id}, got {}",
preds[i]
);
}
}
let far = array![[100.0f64, 100.0]];
let far_pred = m.predict(&far).unwrap();
assert_eq!(far_pred[0], -1, "point at (100,100) must be noise (-1)");
}