use converge_analytics::packs::anomaly_detection::{AnomalyDetectionInput, ZScoreSolver};
use converge_analytics::packs::classification::{ClassificationInput, LogisticClassifier};
use converge_analytics::packs::descriptive_stats::{DescriptiveStatsInput, DescriptiveStatsSolver};
use converge_analytics::packs::forecasting::{ExponentialSmoothingSolver, ForecastingInput};
use converge_analytics::packs::ranking::{RankItem, RankingInput, WeightedScoringSolver};
use converge_analytics::packs::regression::{LinearRegressionSolver, RegressionInput};
use converge_analytics::packs::segmentation::{KMeansSolver, SegmentationInput};
use converge_analytics::packs::similarity::{
DistanceMetric, PairwiseSimilaritySolver, SimilarityInput, SimilarityItem,
};
use converge_pack::gate::{ObjectiveSpec, ProblemSpec};
fn spec() -> ProblemSpec {
ProblemSpec::builder("ref-test", "test")
.objective(ObjectiveSpec::maximize("accuracy"))
.build()
.unwrap()
}
#[test]
fn zscore_hand_computed() {
let input = AnomalyDetectionInput {
values: vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 100.0],
threshold: 2.0,
labels: None,
};
let (output, _) = ZScoreSolver.solve(&input, &spec()).unwrap();
assert!(
(output.mean - 19.0).abs() < 1e-9,
"mean should be 19.0, got {}",
output.mean
);
assert!(
(output.std_dev - 27.0).abs() < 1e-9,
"stddev should be 27.0, got {}",
output.std_dev
);
assert_eq!(output.anomaly_count, 1, "only 100.0 is an anomaly");
assert_eq!(output.anomalies[0].index, 9);
assert!((output.anomalies[0].z_score - 3.0).abs() < 1e-9);
}
#[test]
fn descriptive_stats_hand_computed() {
let input = DescriptiveStatsInput {
values: vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0],
percentiles: vec![25.0, 50.0, 75.0],
};
let (output, _) = DescriptiveStatsSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.count, 8);
assert!(
(output.mean - 5.0).abs() < 1e-9,
"mean = 5.0, got {}",
output.mean
);
assert!(
(output.median - 4.5).abs() < 1e-9,
"median = 4.5, got {}",
output.median
);
assert!(
(output.variance - 4.0).abs() < 1e-9,
"variance = 4.0, got {}",
output.variance
);
assert!(
(output.std_dev - 2.0).abs() < 1e-9,
"stddev = 2.0, got {}",
output.std_dev
);
assert!((output.min - 2.0).abs() < 1e-9);
assert!((output.max - 9.0).abs() < 1e-9);
assert!((output.range - 7.0).abs() < 1e-9);
}
#[test]
fn linear_regression_exact() {
let input = RegressionInput {
records: vec![vec![1.0, 1.0], vec![2.0, 0.0], vec![0.0, 3.0]],
weights: vec![2.0, 3.0],
bias: 1.0,
};
let (output, _) = LinearRegressionSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.total, 3);
assert!((output.predictions[0].value - 6.0).abs() < 1e-9);
assert!((output.predictions[1].value - 5.0).abs() < 1e-9);
assert!((output.predictions[2].value - 10.0).abs() < 1e-9);
assert!(
(output.mean_prediction - 7.0).abs() < 1e-9,
"mean = (6+5+10)/3 = 7.0"
);
}
#[test]
fn logistic_classification_sigmoid() {
let input = ClassificationInput {
records: vec![vec![1.0, 0.0], vec![0.0, 0.0]],
weights: vec![3.0, 0.0],
bias: -1.5,
threshold: 0.5,
labels: None,
};
let (output, _) = LogisticClassifier.solve(&input, &spec()).unwrap();
let expected_p1 = 1.0 / (1.0 + (-1.5_f64).exp());
let expected_p0 = 1.0 / (1.0 + 1.5_f64.exp());
assert_eq!(output.positive_count, 1);
assert_eq!(output.negative_count, 1);
assert!(
(output.predictions[0].probability - expected_p1).abs() < 1e-6,
"sigmoid(1.5) ≈ {expected_p1}, got {}",
output.predictions[0].probability
);
assert!(
(output.predictions[1].probability - expected_p0).abs() < 1e-6,
"sigmoid(-1.5) ≈ {expected_p0}, got {}",
output.predictions[1].probability
);
}
#[test]
fn cosine_similarity_hand_computed() {
let input = SimilarityInput {
items: vec![
SimilarityItem {
id: "A".to_string(),
features: vec![1.0, 0.0, 0.0],
},
SimilarityItem {
id: "B".to_string(),
features: vec![1.0, 0.0, 0.0],
},
SimilarityItem {
id: "C".to_string(),
features: vec![0.0, 1.0, 0.0],
},
],
metric: DistanceMetric::Cosine,
top_k: None,
};
let (output, _) = PairwiseSimilaritySolver.solve(&input, &spec()).unwrap();
assert_eq!(output.total_pairs, 3);
let ab = output
.pairs
.iter()
.find(|p| (p.id_a == "A" && p.id_b == "B") || (p.id_a == "B" && p.id_b == "A"))
.expect("A-B pair must exist");
assert!(
(ab.score - 1.0).abs() < 1e-9,
"identical vectors → cos = 1.0, got {}",
ab.score
);
let ac = output
.pairs
.iter()
.find(|p| (p.id_a == "A" && p.id_b == "C") || (p.id_a == "C" && p.id_b == "A"))
.expect("A-C pair must exist");
assert!(
ac.score.abs() < 1e-9,
"orthogonal vectors → cos = 0.0, got {}",
ac.score
);
}
#[test]
fn cosine_similarity_45_degrees() {
let input = SimilarityInput {
items: vec![
SimilarityItem {
id: "A".to_string(),
features: vec![1.0, 1.0],
},
SimilarityItem {
id: "B".to_string(),
features: vec![1.0, 0.0],
},
],
metric: DistanceMetric::Cosine,
top_k: None,
};
let (output, _) = PairwiseSimilaritySolver.solve(&input, &spec()).unwrap();
let expected = 1.0 / 2.0_f64.sqrt();
assert!(
(output.pairs[0].score - expected).abs() < 1e-6,
"cos(45°) = 1/√2 ≈ {expected}, got {}",
output.pairs[0].score
);
}
#[test]
fn exponential_smoothing_hand_traced() {
let input = ForecastingInput {
values: vec![100.0, 110.0, 120.0],
horizon: 1,
alpha: 0.5,
};
let (output, _) = ExponentialSmoothingSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.predictions.len(), 1);
assert!(
(output.predictions[0].value - 112.5).abs() < 1e-6,
"SES forecast = 112.5, got {}",
output.predictions[0].value
);
}
#[test]
fn kmeans_two_separated_clusters() {
let input = SegmentationInput {
records: vec![
vec![0.0, 0.0],
vec![1.0, 0.0],
vec![0.0, 1.0],
vec![10.0, 10.0],
vec![11.0, 10.0],
vec![10.0, 11.0],
],
k: 2,
max_iterations: 100,
seed: Some(42),
};
let (output, _) = KMeansSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.assignments[0], output.assignments[1]);
assert_eq!(output.assignments[1], output.assignments[2]);
assert_eq!(output.assignments[3], output.assignments[4]);
assert_eq!(output.assignments[4], output.assignments[5]);
assert_ne!(
output.assignments[0], output.assignments[3],
"the two clusters must be distinct"
);
for centroid in &output.centroids {
let near_origin = centroid[0] < 2.0 && centroid[1] < 2.0;
let near_ten = centroid[0] > 8.0 && centroid[1] > 8.0;
assert!(
near_origin || near_ten,
"centroid {:?} should be near (0.33, 0.33) or (10.33, 10.33)",
centroid
);
}
}
#[test]
fn ranking_hand_computed() {
let input = RankingInput {
items: vec![
RankItem {
id: "A".to_string(),
scores: vec![100.0, 10.0],
},
RankItem {
id: "B".to_string(),
scores: vec![50.0, 90.0],
},
RankItem {
id: "C".to_string(),
scores: vec![75.0, 50.0],
},
],
weights: vec![0.7, 0.3],
higher_is_better: vec![true, true],
top_k: None,
};
let (output, _) = WeightedScoringSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.ranked[0].id, "A");
assert_eq!(output.ranked[1].id, "C");
assert_eq!(output.ranked[2].id, "B");
assert!((output.ranked[0].composite_score - 0.70).abs() < 1e-6);
assert!((output.ranked[1].composite_score - 0.50).abs() < 1e-6);
assert!((output.ranked[2].composite_score - 0.30).abs() < 1e-6);
}
#[test]
fn zscore_20_values_multiple_anomalies() {
let mut values = vec![50.0; 18];
values.push(150.0);
values.push(-50.0);
let input = AnomalyDetectionInput {
values,
threshold: 3.0,
labels: None,
};
let (output, _) = ZScoreSolver.solve(&input, &spec()).unwrap();
assert!((output.mean - 50.0).abs() < 1e-9, "mean = 50.0");
assert!(
(output.std_dev - 1000.0_f64.sqrt()).abs() < 1e-6,
"stddev = √1000 ≈ 31.623"
);
assert_eq!(
output.anomaly_count, 2,
"both 150 and -50 are anomalies at threshold 3.0"
);
}
#[test]
fn descriptive_stats_15_values() {
let input = DescriptiveStatsInput {
values: (1..=15).map(|x| x as f64).collect(),
percentiles: vec![25.0, 50.0, 75.0],
};
let (output, _) = DescriptiveStatsSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.count, 15);
assert!((output.mean - 8.0).abs() < 1e-9);
assert!(
(output.median - 8.0).abs() < 1e-9,
"odd count → middle = 8.0"
);
assert!(
(output.variance - 280.0 / 15.0).abs() < 1e-9,
"variance = 280/15"
);
assert!((output.min - 1.0).abs() < 1e-9);
assert!((output.max - 15.0).abs() < 1e-9);
assert!((output.range - 14.0).abs() < 1e-9);
}
#[test]
fn linear_regression_5d() {
let input = RegressionInput {
records: vec![
vec![1.0, 1.0, 1.0, 1.0, 1.0],
vec![2.0, 0.0, 1.0, 0.0, 1.0],
vec![0.0, 3.0, 0.0, 2.0, 0.0],
vec![1.0, 2.0, 3.0, 0.0, 0.0],
],
weights: vec![1.0, 2.0, 3.0, 4.0, 5.0],
bias: 10.0,
};
let (output, _) = LinearRegressionSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.total, 4);
assert!((output.predictions[0].value - 25.0).abs() < 1e-9);
assert!((output.predictions[1].value - 20.0).abs() < 1e-9);
assert!((output.predictions[2].value - 24.0).abs() < 1e-9);
assert!((output.predictions[3].value - 24.0).abs() < 1e-9);
assert!((output.mean_prediction - 23.25).abs() < 1e-9);
}
#[test]
fn logistic_classification_boundary() {
let input = ClassificationInput {
records: vec![vec![0.5], vec![1.0], vec![0.0], vec![0.3], vec![0.7]],
weights: vec![10.0],
bias: -5.0,
threshold: 0.5,
labels: None,
};
let (output, _) = LogisticClassifier.solve(&input, &spec()).unwrap();
assert_eq!(output.positive_count, 3, "x=0.5, 0.7, 1.0 are positive");
assert_eq!(output.negative_count, 2, "x=0.0, 0.3 are negative");
let p_07 = output.predictions[4].probability;
let p_03 = output.predictions[3].probability;
assert!(
(p_07 + p_03 - 1.0).abs() < 1e-9,
"sigmoid symmetry: p(0.7) + p(0.3) = 1.0"
);
assert!(
(output.predictions[0].probability - 0.5).abs() < 1e-9,
"sigmoid(0) = 0.5 exactly"
);
}
#[test]
fn cosine_similarity_5d() {
let input = SimilarityInput {
items: vec![
SimilarityItem {
id: "A".into(),
features: vec![1.0, 0.0, 0.0, 0.0, 0.0],
},
SimilarityItem {
id: "B".into(),
features: vec![0.0, 1.0, 0.0, 0.0, 0.0],
},
SimilarityItem {
id: "C".into(),
features: vec![1.0, 1.0, 0.0, 0.0, 0.0],
},
SimilarityItem {
id: "D".into(),
features: vec![1.0, 1.0, 1.0, 1.0, 1.0],
},
],
metric: DistanceMetric::Cosine,
top_k: None,
};
let (output, _) = PairwiseSimilaritySolver.solve(&input, &spec()).unwrap();
assert_eq!(output.total_pairs, 6);
let find = |a: &str, b: &str| -> f64 {
output
.pairs
.iter()
.find(|p| (p.id_a == a && p.id_b == b) || (p.id_a == b && p.id_b == a))
.unwrap_or_else(|| panic!("pair {a}-{b} not found"))
.score
};
assert!(find("A", "B").abs() < 1e-9, "orthogonal → 0");
assert!(
(find("A", "C") - 1.0 / 2.0_f64.sqrt()).abs() < 1e-6,
"45° → 1/√2"
);
assert!(
(find("A", "D") - 1.0 / 5.0_f64.sqrt()).abs() < 1e-6,
"A·D/norms → 1/√5"
);
assert!((find("B", "C") - 1.0 / 2.0_f64.sqrt()).abs() < 1e-6);
assert!((find("B", "D") - 1.0 / 5.0_f64.sqrt()).abs() < 1e-6);
assert!(
(find("C", "D") - 2.0 / 10.0_f64.sqrt()).abs() < 1e-6,
"C·D = 2/(√2·√5)"
);
}
#[test]
fn exponential_smoothing_8_values() {
let input = ForecastingInput {
values: vec![100.0, 120.0, 90.0, 130.0, 110.0, 95.0, 105.0, 115.0],
horizon: 1,
alpha: 0.3,
};
let (output, _) = ExponentialSmoothingSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.predictions.len(), 1);
assert!(
(output.predictions[0].value - 108.206584).abs() < 1e-4,
"8-step SES forecast ≈ 108.2066, got {}",
output.predictions[0].value
);
}
#[test]
fn kmeans_4_clusters_3d() {
let input = SegmentationInput {
records: vec![
vec![0.0, 0.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![100.0, 0.0, 0.0],
vec![101.0, 0.0, 0.0],
vec![100.0, 1.0, 0.0],
vec![0.0, 100.0, 0.0],
vec![1.0, 100.0, 0.0],
vec![0.0, 101.0, 0.0],
vec![0.0, 0.0, 100.0],
vec![1.0, 0.0, 100.0],
vec![0.0, 1.0, 100.0],
],
k: 4,
max_iterations: 100,
seed: None,
};
let mut found = false;
for seed in 0..20 {
let mut attempt = input.clone();
attempt.seed = Some(seed);
let (output, _) = KMeansSolver.solve(&attempt, &spec()).unwrap();
let groups_ok = [0, 3, 6, 9].iter().all(|&start| {
output.assignments[start] == output.assignments[start + 1]
&& output.assignments[start + 1] == output.assignments[start + 2]
});
let labels: std::collections::HashSet<_> = [
output.assignments[0],
output.assignments[3],
output.assignments[6],
output.assignments[9],
]
.into_iter()
.collect();
if groups_ok && labels.len() == 4 {
found = true;
break;
}
}
assert!(
found,
"k-means must find 4 clusters in at least one of 20 seed attempts"
);
}
#[test]
fn ranking_5_items_mixed_directions() {
let input = RankingInput {
items: vec![
RankItem {
id: "A".into(),
scores: vec![90.0, 20.0, 80.0],
},
RankItem {
id: "B".into(),
scores: vec![70.0, 10.0, 60.0],
},
RankItem {
id: "C".into(),
scores: vec![80.0, 40.0, 100.0],
},
RankItem {
id: "D".into(),
scores: vec![60.0, 30.0, 70.0],
},
RankItem {
id: "E".into(),
scores: vec![100.0, 50.0, 90.0],
},
],
weights: vec![0.5, 0.3, 0.2],
higher_is_better: vec![true, false, true],
top_k: None,
};
let (output, _) = WeightedScoringSolver.solve(&input, &spec()).unwrap();
assert_eq!(output.ranked[0].id, "A");
assert_eq!(output.ranked[1].id, "E");
assert_eq!(output.ranked[2].id, "C");
assert_eq!(output.ranked[3].id, "B");
assert_eq!(output.ranked[4].id, "D");
assert!((output.ranked[0].composite_score - 0.700).abs() < 1e-6);
assert!((output.ranked[1].composite_score - 0.650).abs() < 1e-6);
assert!((output.ranked[2].composite_score - 0.525).abs() < 1e-6);
assert!((output.ranked[3].composite_score - 0.425).abs() < 1e-6);
assert!((output.ranked[4].composite_score - 0.200).abs() < 1e-6);
}