#![forbid(unsafe_code)]
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use fsci_cluster::dbscan;
use fsci_conformance::{ArmCounts, CompareLedger};
use serde::Serialize;
const PACKET_ID: &str = "FSCI-P2C-007";
const ARMS: [&str; 15] = [
"two_clusters_count",
"two_clusters_first_group_same_label",
"two_clusters_last_group_same_label",
"two_clusters_distinct_labels",
"two_clusters_no_noise",
"noise_isolated_point_labeled_minus_1",
"noise_cluster_count_one",
"core_sample_indices_all_five",
"core_sample_indices_sorted",
"permutation_invariance_modulo_label_remap",
"empty_data_errors",
"zero_eps_errors",
"nan_eps_errors",
"zero_min_samples_errors",
"non_finite_data_errors",
];
#[derive(Debug, Clone, Serialize)]
struct CaseDiff {
case_id: String,
pass: bool,
note: String,
}
#[derive(Debug, Clone, Serialize)]
struct DiffLog {
test_id: String,
category: String,
case_count: usize,
compared: BTreeMap<String, ArmCounts>,
pass: bool,
timestamp_ms: u128,
duration_ns: u128,
cases: Vec<CaseDiff>,
}
fn output_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("fixtures/artifacts/{PACKET_ID}/diff"))
}
fn ensure_output_dir() {
fs::create_dir_all(output_dir()).expect("create dbscan diff dir");
}
fn timestamp_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
fn emit_log(log: &DiffLog) {
ensure_output_dir();
let path = output_dir().join(format!("{}.json", log.test_id));
let json = serde_json::to_string_pretty(log).expect("serialize log");
fs::write(path, json).expect("write log");
}
fn canonicalize(labels: &[i64]) -> Vec<i64> {
let mut map: HashMap<i64, i64> = HashMap::new();
let mut next_id = 0_i64;
labels
.iter()
.map(|&l| {
if l == -1 {
-1
} else {
*map.entry(l).or_insert_with(|| {
let id = next_id;
next_id += 1;
id
})
}
})
.collect()
}
#[test]
fn diff_cluster_dbscan_properties() {
let start = Instant::now();
let mut diffs: Vec<CaseDiff> = Vec::new();
let mut ledger = CompareLedger::new("diff_cluster_dbscan_properties", &ARMS);
let mut check = |id: &str, ok: bool, note: String| {
ledger.compared(id, id, ok);
diffs.push(CaseDiff {
case_id: id.into(),
pass: ok,
note,
});
};
{
let data: Vec<Vec<f64>> = vec![
vec![0.0, 0.0],
vec![0.1, 0.1],
vec![0.0, 0.2],
vec![0.2, 0.0],
vec![10.0, 10.0],
vec![10.1, 10.0],
vec![10.0, 10.1],
vec![10.05, 10.05],
];
let r = dbscan(&data, 0.5, 2).expect("dbscan");
check(
"two_clusters_count",
r.n_clusters == 2,
format!("n_clusters={} labels={:?}", r.n_clusters, r.labels),
);
let first_labels = &r.labels[..4];
let last_labels = &r.labels[4..];
let all_first_same =
first_labels.iter().all(|&l| l == first_labels[0]) && first_labels[0] >= 0;
let all_last_same = last_labels.iter().all(|&l| l == last_labels[0]) && last_labels[0] >= 0;
check(
"two_clusters_first_group_same_label",
all_first_same,
format!("first={first_labels:?}"),
);
check(
"two_clusters_last_group_same_label",
all_last_same,
format!("last={last_labels:?}"),
);
check(
"two_clusters_distinct_labels",
first_labels[0] != last_labels[0],
format!("first={} last={}", first_labels[0], last_labels[0]),
);
check(
"two_clusters_no_noise",
r.labels.iter().all(|&l| l >= 0),
format!("labels={:?}", r.labels),
);
}
{
let data: Vec<Vec<f64>> = vec![
vec![0.0, 0.0],
vec![0.1, 0.1],
vec![0.0, 0.2],
vec![100.0, 100.0], ];
let r = dbscan(&data, 0.5, 2).expect("dbscan noise");
check(
"noise_isolated_point_labeled_minus_1",
r.labels[3] == -1,
format!("labels={:?}", r.labels),
);
check(
"noise_cluster_count_one",
r.n_clusters == 1,
format!("n_clusters={}", r.n_clusters),
);
}
{
let data: Vec<Vec<f64>> = vec![
vec![0.0, 0.0],
vec![0.1, 0.1],
vec![0.2, 0.0],
vec![0.05, 0.15],
vec![0.1, 0.2],
];
let r = dbscan(&data, 0.5, 2).expect("dbscan core");
check(
"core_sample_indices_all_five",
r.core_sample_indices.len() == 5,
format!("core_idx={:?}", r.core_sample_indices),
);
check(
"core_sample_indices_sorted",
r.core_sample_indices.windows(2).all(|w| w[0] < w[1]),
format!("core_idx={:?}", r.core_sample_indices),
);
}
{
let data: Vec<Vec<f64>> = vec![
vec![0.0, 0.0],
vec![0.1, 0.1],
vec![5.0, 5.0],
vec![5.1, 5.0],
vec![5.0, 5.1],
vec![0.0, 0.2],
];
let original = dbscan(&data, 0.5, 2).expect("original");
let reversed: Vec<Vec<f64>> = data.iter().rev().cloned().collect();
let rev_labels = dbscan(&reversed, 0.5, 2).expect("reversed").labels;
let rev_aligned: Vec<i64> = rev_labels.into_iter().rev().collect();
check(
"permutation_invariance_modulo_label_remap",
canonicalize(&original.labels) == canonicalize(&rev_aligned),
format!(
"orig={:?} rev_aligned={:?}",
canonicalize(&original.labels),
canonicalize(&rev_aligned)
),
);
}
{
let r = dbscan(&[], 0.5, 2);
check("empty_data_errors", r.is_err(), format!("res={r:?}"));
}
{
let data: Vec<Vec<f64>> = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
let r = dbscan(&data, 0.0, 2);
check("zero_eps_errors", r.is_err(), format!("res={r:?}"));
}
{
let data: Vec<Vec<f64>> = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
let r = dbscan(&data, f64::NAN, 2);
check("nan_eps_errors", r.is_err(), format!("res={r:?}"));
}
{
let data: Vec<Vec<f64>> = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
let r = dbscan(&data, 0.5, 0);
check("zero_min_samples_errors", r.is_err(), format!("res={r:?}"));
}
{
let data: Vec<Vec<f64>> = vec![vec![0.0, 0.0], vec![f64::NAN, 1.0]];
let r = dbscan(&data, 0.5, 2);
check("non_finite_data_errors", r.is_err(), format!("res={r:?}"));
}
let all_pass = diffs.iter().all(|d| d.pass);
let log = DiffLog {
test_id: "diff_cluster_dbscan_properties".into(),
category: "fsci_cluster::dbscan property-based coverage".into(),
case_count: diffs.len(),
compared: ledger.counts().clone(),
pass: all_pass,
timestamp_ms: timestamp_ms(),
duration_ns: start.elapsed().as_nanos(),
cases: diffs.clone(),
};
emit_log(&log);
for d in &diffs {
if !d.pass {
eprintln!("dbscan mismatch: {} — {}", d.case_id, d.note);
}
}
assert!(
all_pass,
"dbscan property coverage failed: {} cases",
diffs.len()
);
ledger.finish(1);
}