use crate::builder::ConfigValue;
use crate::core::ArrowSpace;
use crate::reduction::ImplicitProjection;
use crate::{
builder::ArrowSpaceBuilder,
graph::GraphLaplacian,
sampling::SamplerType,
search::taumode::TauMode,
tests::test_data::{make_gaussian_blob, make_gaussian_hd, make_moons_hd},
};
use log::debug;
use std::collections::HashMap;
fn laplacian_eq(a: &GraphLaplacian, b: &GraphLaplacian, eps: f64) -> bool {
if a.matrix.shape() != b.matrix.shape() {
return false;
}
let (r, c) = a.matrix.shape();
for i in 0..r {
for j in 0..c {
let ai = *a.matrix.get(i, j).unwrap_or(&0.0);
let bj = *b.matrix.get(i, j).unwrap_or(&0.0);
if (ai - bj).abs() > eps {
return false;
}
}
}
true
}
fn diag_vec(gl: &GraphLaplacian) -> Vec<f64> {
let (n, _) = gl.matrix.shape();
(0..n).map(|i| *gl.matrix.get(i, i).unwrap()).collect()
}
#[allow(dead_code)]
fn l2_norm(x: &[f64]) -> f64 {
x.iter().map(|&v| v * v).sum::<f64>().sqrt()
}
#[test]
fn test_builder_direction_vs_magnitude_sensitivity() {
let items = make_gaussian_blob(99, 0.5);
let (aspace_norm, gl_norm) = ArrowSpaceBuilder::default()
.with_lambda_graph(1.0, 3, 2, 2.0, Some(0.25))
.with_normalisation(true)
.with_spectral(true)
.build(items.clone());
let (aspace_tau, gl_tau) = ArrowSpaceBuilder::default()
.with_lambda_graph(1.0, 3, 2, 2.0, Some(0.25))
.with_normalisation(false)
.with_spectral(true)
.build(items.clone());
let matrices_equal = laplacian_eq(&gl_norm, &gl_tau, 1e-12);
assert!(
!matrices_equal,
"Ï„-mode should differ from normalised graph due to magnitude sensitivity"
);
let lambdas_norm = aspace_norm.lambdas();
let lambdas_tau = aspace_tau.lambdas();
debug!(
"Normalized lambdas (first 3): {:?}",
&lambdas_norm[..3.min(lambdas_norm.len())]
);
debug!(
"Tau lambdas (first 3): {:?}",
&lambdas_tau[..3.min(lambdas_tau.len())]
);
}
#[test]
fn test_builder_normalisation_flag_is_preserved() {
let items = make_moons_hd(99, 0.1, 0.5, 3, 123);
let (_aspace, gl) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.25, 2, 1, 2.0, None)
.with_normalisation(false)
.build(items);
assert_eq!(
gl.graph_params.normalise, false,
"normalise flag must be preserved"
);
}
#[test]
fn test_builder_clustering_produces_valid_assignments() {
let items = make_moons_hd(99, 0.05, 1.5, 3, 456);
let (aspace, _gl) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 3, 2, 2.0, None)
.with_normalisation(true)
.build(items.clone());
debug!("Assignments: {:?}", aspace.cluster_assignments);
let assigned_count = aspace
.cluster_assignments
.iter()
.filter(|x| x.is_some())
.count();
assert!(
assigned_count > 0,
"At least some items should be assigned to clusters"
);
}
#[test]
fn test_builder_spectral_laplacian_computation() {
let items = make_moons_hd(4, 0.12, 0.4, 5, 789);
let (aspace_no_spectral, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_spectral(false)
.with_inline_sampling(None)
.build(items.clone());
let (aspace_spectral, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_spectral(true)
.with_inline_sampling(None)
.build(items.clone());
debug!(
"No spectral - signals shape: {:?}",
aspace_no_spectral.signals.shape()
);
debug!(
"With spectral - signals shape: {:?}",
aspace_spectral.signals.shape()
);
assert_eq!(
aspace_no_spectral.signals.shape(),
(0, 0),
"Signals should be empty when spectral computation is disabled"
);
assert_ne!(
aspace_spectral.signals.shape(),
(0, 0),
"Signals should be populated when spectral computation is enabled"
);
}
#[test]
fn test_builder_lambda_computation_with_different_tau_modes() {
let items = make_moons_hd(3, 0.15, 0.35, 4, 321);
let (aspace_median, _) = ArrowSpaceBuilder::default()
.with_synthesis(TauMode::Median)
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_inline_sampling(None)
.build(items.clone());
let (aspace_fixed, _) = ArrowSpaceBuilder::default()
.with_synthesis(TauMode::Fixed(0.5))
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_inline_sampling(None)
.build(items.clone());
let lambdas_median = aspace_median.lambdas();
let lambdas_fixed = aspace_fixed.lambdas();
debug!("Median tau lambdas: {:?}", lambdas_median);
debug!("Max tau lambdas: {:?}", lambdas_fixed);
let mut differences = 0;
for (m, mx) in lambdas_median.iter().zip(lambdas_fixed.iter()) {
if (m - mx).abs() > 1e-10 {
differences += 1;
}
}
assert!(
differences > 0,
"Different tau modes should produce different lambda values"
);
}
#[test]
fn test_builder_with_normalized_vs_unnormalized_items() {
let items = make_moons_hd(4, 0.18, 0.4, 6, 654);
let scales = vec![1.0, 3.0, 0.5, 2.5];
let items_unnormalized: Vec<Vec<f64>> = items
.iter()
.zip(scales.iter())
.map(|(item, &scale)| item.iter().map(|x| x * scale).collect())
.collect();
let (aspace_norm, gl_norm) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_normalisation(true)
.with_spectral(true)
.with_inline_sampling(None)
.build(items.clone());
let (aspace_unnorm, gl_unnorm) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.2, 2, 1, 2.0, None)
.with_normalisation(false)
.with_spectral(true)
.with_inline_sampling(None)
.build(items_unnormalized);
debug!("=== SPECTRAL ANALYSIS ===");
let lambdas_norm = aspace_norm.lambdas();
let lambdas_unnorm = aspace_unnorm.lambdas();
debug!(
"Normalized lambdas: {:?}",
&lambdas_norm[..3.min(lambdas_norm.len())]
);
debug!(
"Unnormalized lambdas: {:?}",
&lambdas_unnorm[..3.min(lambdas_unnorm.len())]
);
let d_norm = diag_vec(&gl_norm);
let d_unnorm = diag_vec(&gl_unnorm);
debug!("Normalized diagonals: {:?}", &d_norm[..3.min(d_norm.len())]);
debug!(
"Unnormalized diagonals: {:?}",
&d_unnorm[..3.min(d_unnorm.len())]
);
assert!(
!laplacian_eq(&gl_norm, &gl_unnorm, 1e-10),
"Normalized and unnormalized builds should produce different graphs"
);
}
#[test]
fn test_builder_with_inline_sampling() {
let items = make_gaussian_blob(100, 0.5);
let (_aspace_sampling, _gl_sampling) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 4, 2, 2.0, None)
.with_inline_sampling(Some(SamplerType::DensityAdaptive(0.5)))
.build(items.clone());
let (_aspace_no_sampling, _gl_no_sampl) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 4, 2, 2.0, None)
.with_inline_sampling(Some(SamplerType::DensityAdaptive(0.5)))
.build(items);
}
#[test]
fn test_builder_dimensionality_reduction() {
let items = make_moons_hd(50, 0.15, 0.35, 128, 111);
let (aspace_reduced, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 4, 2, 2.0, None)
.with_dims_reduction(true, Some(0.3))
.with_sparsity_check(false)
.build(items.clone());
let (aspace_full, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 4, 2, 2.0, None)
.with_dims_reduction(false, None)
.with_sparsity_check(false)
.build(items);
debug!("Original dimension: {}", aspace_full.nfeatures);
debug!("Reduced dimension: {:?}", aspace_reduced.reduced_dim);
if let Some(reduced_dim) = aspace_reduced.reduced_dim {
assert!(
reduced_dim < aspace_full.nfeatures,
"Reduced dimension should be less than original"
);
assert!(
aspace_reduced.projection_matrix.is_some(),
"Projection matrix should be present when reduction is enabled"
);
}
}
#[test]
fn test_builder_lambda_statistics() {
let items: Vec<Vec<f64>> = make_moons_hd(
200, 0.3, 0.5, 40, 768, );
debug!("=== LAMBDA STATISTICS TEST ===");
debug!(
"Generated {} items with {} features",
items.len(),
items[0].len()
);
let (aspace, gl) = ArrowSpaceBuilder::default()
.with_lambda_graph(
0.5, 6, 3, 2.0, None, )
.with_sparsity_check(false)
.build(items);
debug!("Graph has {} nodes", gl.nnodes);
let lambdas = aspace.lambdas();
let min = lambdas.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max = lambdas.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let mean = lambdas.iter().sum::<f64>() / lambdas.len() as f64;
let variance = lambdas.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / lambdas.len() as f64;
let std_dev = variance.sqrt();
debug!("=== LAMBDA DISTRIBUTION ===");
debug!("Min: {:.6}", min);
debug!("Max: {:.6}", max);
debug!("Mean: {:.6}", mean);
debug!("Std Dev: {:.6}", std_dev);
debug!("Range: {:.6}", max - min);
debug!("First 5 lambdas: {:?}", &lambdas[..5.min(lambdas.len())]);
assert!(
min >= 0.0,
"All lambdas should be non-negative, got min={}",
min
);
assert!(
max > min,
"Lambdas should have some variance: max={:.6}, min={:.6}",
max,
min
);
let relative_range = (max - min) / mean.max(1e-10);
assert!(
relative_range > 0.1,
"Lambda range should be at least 10% of mean for high-variance data: \
range={:.6}, mean={:.6}, relative={:.6}",
max - min,
mean,
relative_range
);
assert!(
std_dev > 1e-6,
"Lambda standard deviation should indicate spread: std_dev={:.6}",
std_dev
);
debug!("✓ Lambda statistics show expected variance from noisy moon dataset");
}
#[test]
fn test_builder_cluster_radius_impact() {
let items = make_gaussian_blob(99, 0.5);
let (aspace, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 3, 2, 2.0, None)
.with_seed(42)
.build(items);
assert!(
aspace.cluster_radius > 0.0,
"Cluster radius should be positive"
);
}
#[test]
fn test_empty_with_projection_path() {
crate::tests::init();
let mut proj_data = HashMap::new();
proj_data.insert(
"pj_mtx_original_dim".to_string(),
ConfigValue::OptionUsize(Some(384)),
);
proj_data.insert(
"pj_mtx_reduced_dim".to_string(),
ConfigValue::OptionUsize(Some(91)),
);
proj_data.insert(
"pj_mtx_seed".to_string(),
ConfigValue::OptionU64(Some(123456789)),
);
proj_data.insert("extra_reduced_dim".to_string(), ConfigValue::Bool(false));
let nrows = 10_000;
let ncols = 384;
let aspace = ArrowSpace::empty_with_projection(proj_data, nrows, ncols);
assert_eq!(aspace.nitems, nrows);
assert_eq!(aspace.nfeatures, ncols);
let proj = aspace
.projection_matrix
.as_ref()
.expect("projection_matrix must be Some");
assert_eq!(
*proj,
ImplicitProjection {
original_dim: 384,
reduced_dim: 91,
seed: 123456789,
}
);
assert_eq!(aspace.reduced_dim, Some(91));
assert_eq!(aspace.extra_reduced_dim, false);
}
#[test]
#[should_panic(expected = "Reconstructing with extra dim reduction is not implemented yet")]
fn test_empty_with_projection_panics_on_extra_reduced_dim_true() {
let mut proj_data = HashMap::new();
proj_data.insert("pj_mtx_original_dim".to_string(), ConfigValue::Usize(384));
proj_data.insert("pj_mtx_reduced_dim".to_string(), ConfigValue::Usize(91));
proj_data.insert("pj_mtx_seed".to_string(), ConfigValue::U64(123456789));
proj_data.insert("extra_reduced_dim".to_string(), ConfigValue::Bool(true));
let nrows = 10_000;
let ncols = 384;
let _ = ArrowSpace::empty_with_projection(proj_data, nrows, ncols);
}
#[test]
fn test_empty_with_projection_none_path() {
let mut proj_data = HashMap::new();
proj_data.insert(
"pj_mtx_original_dim".to_string(),
ConfigValue::OptionUsize(None),
);
proj_data.insert(
"pj_mtx_reduced_dim".to_string(),
ConfigValue::OptionUsize(None),
);
proj_data.insert("pj_mtx_seed".to_string(), ConfigValue::OptionUsize(None));
proj_data.insert("extra_reduced_dim".to_string(), ConfigValue::Bool(false));
let nrows = 10_000;
let ncols = 384;
let aspace = ArrowSpace::empty_with_projection(proj_data, nrows, ncols);
assert_eq!(aspace.projection_matrix, None);
assert_eq!(aspace.reduced_dim, None);
assert_eq!(aspace.extra_reduced_dim, false);
}
#[test]
fn test_arrowspace_config_typed_without_projection() {
let items = make_gaussian_blob(99, 0.5);
let (aspace, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 3, 2, 2.0, None)
.with_seed(42)
.build(items);
let config = aspace.arrowspace_config_typed();
assert_eq!(config.get("nitems").unwrap().as_usize().unwrap(), 99);
assert_eq!(config.get("nfeatures").unwrap().as_usize().unwrap(), 10);
assert_eq!(config.get("pj_mtx_original_dim").unwrap().as_usize(), None);
assert_eq!(config.get("pj_mtx_reduced_dim").unwrap().as_usize(), None);
assert_eq!(config.get("pj_mtx_seed").unwrap().as_u64(), None);
assert_eq!(
config.get("extra_reduced_dim").unwrap().as_bool().unwrap(),
false
);
assert!(config.contains_key("taumode"));
assert!(config.contains_key("n_clusters"));
assert!(config.contains_key("cluster_radius"));
}
#[test]
fn test_arrowspace_config_typed_with_projection() {
let items = make_gaussian_hd(99, 0.5);
let (aspace, _) = ArrowSpaceBuilder::default()
.with_lambda_graph(0.3, 3, 2, 2.0, None)
.with_seed(42)
.with_dims_reduction(true, Some(0.25))
.build(items);
let config = aspace.arrowspace_config_typed();
assert_eq!(config.get("nitems").unwrap().as_usize().unwrap(), 99);
assert_eq!(config.get("nfeatures").unwrap().as_usize().unwrap(), 100);
assert_eq!(
config
.get("pj_mtx_original_dim")
.unwrap()
.as_usize()
.unwrap(),
100
);
assert_eq!(
config
.get("pj_mtx_reduced_dim")
.unwrap()
.as_usize()
.unwrap(),
50
);
assert_eq!(config.get("pj_mtx_seed").unwrap().as_u64().unwrap(), 42);
assert_eq!(
config.get("extra_reduced_dim").unwrap().as_bool().unwrap(),
false
);
let taumode = config.get("taumode").unwrap();
match taumode {
ConfigValue::TauMode(_) => {} _ => panic!("Expected TauMode variant"),
}
assert!(config.get("n_clusters").is_some());
assert!(config.get("cluster_radius").is_some());
}