use ferrolearn_core::error::FerroError;
use ferrolearn_core::traits::{Fit, Transform};
use ferrolearn_preprocess::Normalizer;
use ferrolearn_preprocess::normalizer::{FittedNormalizer, NormType};
use ndarray::{Array2, array};
#[test]
fn guard_l1_matches_oracle() {
const SK: [[f64; 3]; 2] = [[-0.4, 0.2, 0.4], [-0.5, 0.0, 0.5]];
let x = array![[-2.0, 1.0, 2.0], [-1.0, 0.0, 1.0]];
let out = Normalizer::<f64>::l1().transform(&x).unwrap();
assert_eq!(out.dim(), (2, 3));
for i in 0..2 {
for j in 0..3 {
assert!(
(out[[i, j]] - SK[i][j]).abs() < 1e-12,
"l1[{i},{j}]: ferro={} sklearn={}",
out[[i, j]],
SK[i][j]
);
}
}
}
#[test]
fn guard_l2_matches_oracle() {
const SK: [[f64; 3]; 2] = [
[
-0.666_666_666_666_666_6,
0.333_333_333_333_333_3,
0.666_666_666_666_666_6,
],
[-0.707_106_781_186_547_5, 0.0, 0.707_106_781_186_547_5],
];
let x = array![[-2.0, 1.0, 2.0], [-1.0, 0.0, 1.0]];
let out = Normalizer::<f64>::l2().transform(&x).unwrap();
assert_eq!(out.dim(), (2, 3));
for i in 0..2 {
for j in 0..3 {
assert!(
(out[[i, j]] - SK[i][j]).abs() < 1e-12,
"l2[{i},{j}]: ferro={} sklearn={}",
out[[i, j]],
SK[i][j]
);
}
}
}
#[test]
fn guard_max_matches_oracle() {
const SK: [f64; 3] = [-1.0, 0.6, 0.2];
let x = array![[-5.0, 3.0, 1.0]];
let out = Normalizer::<f64>::new(NormType::Max).transform(&x).unwrap();
assert_eq!(out.dim(), (1, 3));
for j in 0..3 {
assert!(
(out[[0, j]] - SK[j]).abs() < 1e-12,
"max[0,{j}]: ferro={} sklearn={}",
out[[0, j]],
SK[j]
);
}
}
#[test]
fn guard_zero_row_unchanged_matches_oracle() {
const SK: [[f64; 3]; 2] = [[0.0, 0.0, 0.0], [0.6, 0.8, 0.0]];
let x = array![[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]];
let out = Normalizer::<f64>::l2().transform(&x).unwrap();
for i in 0..2 {
for j in 0..3 {
assert!(
(out[[i, j]] - SK[i][j]).abs() < 1e-12,
"zero[{i},{j}]: ferro={} sklearn={}",
out[[i, j]],
SK[i][j]
);
}
}
}
#[test]
fn guard_f32_l2_matches_oracle() {
const SK: [f32; 2] = [0.6, 0.8];
let x: Array2<f32> = array![[3.0f32, 4.0]];
let out = Normalizer::<f32>::l2().transform(&x).unwrap();
for j in 0..2 {
assert!(
(out[[0, j]] - SK[j]).abs() < 1e-6,
"f32[0,{j}]: ferro={} sklearn={}",
out[[0, j]],
SK[j]
);
}
}
#[test]
fn divergence_transform_rejects_nan() {
let x = array![[f64::NAN, 1.0]];
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
result.is_err(),
"sklearn raises ValueError on NaN input; ferrolearn returned Ok"
);
}
#[test]
fn divergence_transform_rejects_pos_inf() {
let x = array![[f64::INFINITY, 1.0]];
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
result.is_err(),
"sklearn raises ValueError on +inf input; ferrolearn returned Ok"
);
}
#[test]
fn divergence_transform_rejects_neg_inf() {
let x = array![[f64::NEG_INFINITY, 1.0]];
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
result.is_err(),
"sklearn raises ValueError on -inf input; ferrolearn returned Ok"
);
}
#[test]
fn divergence_transform_rejects_zero_samples() {
let x: Array2<f64> = Array2::zeros((0, 3));
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
result.is_err(),
"sklearn raises ValueError (0 samples); ferrolearn returned Ok"
);
assert!(
matches!(result, Err(FerroError::InsufficientSamples { .. })),
"zero-sample input should map to FerroError::InsufficientSamples"
);
}
#[test]
fn divergence_transform_rejects_zero_features() {
let x: Array2<f64> = Array2::zeros((2, 0));
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
result.is_err(),
"sklearn raises ValueError (0 features); ferrolearn returned Ok"
);
assert!(
matches!(result, Err(FerroError::InvalidParameter { .. })),
"zero-feature input should map to FerroError::InvalidParameter"
);
}
#[test]
fn divergence_transform_empty_reports_samples_first() {
let x: Array2<f64> = Array2::zeros((0, 0));
let result = Normalizer::<f64>::l2().transform(&x);
assert!(
matches!(result, Err(FerroError::InsufficientSamples { .. })),
"(0,0) should report samples error first (sklearn check_array order)"
);
}
#[test]
fn guard_zero_norm_row_not_rejected_matches_oracle() {
const SK: [[f64; 3]; 2] = [[0.0, 0.0, 0.0], [0.6, 0.8, 0.0]];
let x = array![[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]];
let out = Normalizer::<f64>::l2()
.transform(&x)
.expect("zero-NORM row in a (2,3) array must NOT be rejected by guards");
assert_eq!(out.dim(), (2, 3));
for i in 0..2 {
for j in 0..3 {
assert!(
(out[[i, j]] - SK[i][j]).abs() < 1e-12,
"zeronorm[{i},{j}]: ferro={} sklearn={}",
out[[i, j]],
SK[i][j]
);
}
}
}
#[test]
fn guard_large_finite_not_rejected_matches_oracle() {
const SK: [f64; 2] = [1.0, 0.0];
let x = array![[1e308, 0.0]];
let out = Normalizer::<f64>::max()
.transform(&x)
.expect("large-but-finite 1e308 must NOT be rejected by the finite guard");
for j in 0..2 {
assert!(
(out[[0, j]] - SK[j]).abs() < 1e-12,
"largefinite[0,{j}]: ferro={} sklearn={}",
out[[0, j]],
SK[j]
);
}
}
#[test]
fn guard_subnormal_not_rejected_matches_oracle() {
const SK: [f64; 2] = [5e-324, 0.0];
let x = array![[5e-324, 0.0]];
let out = Normalizer::<f64>::l2()
.transform(&x)
.expect("subnormal 5e-324 is finite and must NOT be rejected");
assert!(
(out[[0, 0]] - SK[0]).abs() <= f64::from_bits(1),
"subnormal[0,0]: ferro={} sklearn={}",
out[[0, 0]],
SK[0]
);
assert_eq!(out[[0, 1]], SK[1]);
}
#[test]
fn guard_neg_zero_not_rejected_matches_oracle() {
let x = array![[-0.0_f64, -0.0_f64]];
let out = Normalizer::<f64>::l2()
.transform(&x)
.expect("-0.0 is finite and must NOT be rejected");
assert_eq!(out[[0, 0]], 0.0);
assert_eq!(out[[0, 1]], 0.0);
}
#[test]
fn fit_l1_l2_max_matches_oracle_and_stateless() {
let x = array![[1.0, 2.0, 2.0], [0.0, 3.0, 4.0], [-5.0, 3.0, 1.0]];
let l1: [[f64; 3]; 3] = [
[0.2, 0.4, 0.4],
[0.0, 0.428_571_428_571_428_55, 0.571_428_571_428_571_4],
[
-0.555_555_555_555_555_6,
0.333_333_333_333_333_3,
0.111_111_111_111_111_1,
],
];
let l2: [[f64; 3]; 3] = [
[
0.333_333_333_333_333_3,
0.666_666_666_666_666_6,
0.666_666_666_666_666_6,
],
[0.0, 0.6, 0.8],
[
-0.845_154_254_728_516_6,
0.507_092_552_837_11,
0.169_030_850_945_703_3,
],
];
let max: [[f64; 3]; 3] = [[0.5, 1.0, 1.0], [0.0, 0.75, 1.0], [-1.0, 0.6, 0.2]];
for (norm, sk) in [(NormType::L1, l1), (NormType::L2, l2), (NormType::Max, max)] {
let fitted: FittedNormalizer<f64> = Normalizer::<f64>::new(norm).fit(&x, &()).unwrap();
let fit_out = fitted.transform(&x).unwrap();
let stateless_out = Normalizer::<f64>::new(norm).transform(&x).unwrap();
assert_eq!(fit_out.dim(), (3, 3));
for i in 0..3 {
for j in 0..3 {
assert!(
(fit_out[[i, j]] - sk[i][j]).abs() < 1e-12,
"fit {norm:?}[{i},{j}]: ferro={} sklearn={}",
fit_out[[i, j]],
sk[i][j]
);
assert_eq!(
fit_out[[i, j]].to_bits(),
stateless_out[[i, j]].to_bits(),
"fit-path != stateless-path at {norm:?}[{i},{j}]"
);
}
}
}
}
#[test]
fn fit_n_features_in_matches_ncols() {
let x = array![[1.0, 2.0, 2.0], [0.0, 3.0, 4.0], [-5.0, 3.0, 1.0]];
let fitted = Normalizer::<f64>::l2().fit(&x, &()).unwrap();
assert_eq!(fitted.n_features_in(), 3);
let x2 = array![[1.0, 2.0], [3.0, 4.0]];
assert_eq!(
Normalizer::<f64>::l2()
.fit(&x2, &())
.unwrap()
.n_features_in(),
2
);
}
#[test]
fn fit_rejects_nan() {
let x = array![[f64::NAN, 1.0]];
assert!(
Normalizer::<f64>::l2().fit(&x, &()).is_err(),
"sklearn Normalizer().fit raises ValueError on NaN; ferrolearn fit must Err"
);
}
#[test]
fn fit_rejects_pos_inf() {
let x = array![[f64::INFINITY, 1.0]];
assert!(
Normalizer::<f64>::l2().fit(&x, &()).is_err(),
"sklearn Normalizer().fit raises ValueError on +inf; ferrolearn fit must Err"
);
}
#[test]
fn fit_rejects_neg_inf() {
let x = array![[f64::NEG_INFINITY, 1.0]];
assert!(
Normalizer::<f64>::l2().fit(&x, &()).is_err(),
"sklearn Normalizer().fit raises ValueError on -inf; ferrolearn fit must Err"
);
}
#[test]
fn fit_copy_true_false_identical() {
let x = array![[1.0, 2.0, 2.0], [0.0, 3.0, 4.0], [-5.0, 3.0, 1.0]];
let a = Normalizer::<f64>::l2()
.with_copy(true)
.fit(&x, &())
.unwrap();
let b = Normalizer::<f64>::l2()
.with_copy(false)
.fit(&x, &())
.unwrap();
assert!(a.copy());
assert!(!b.copy());
let out_a = a.transform(&x).unwrap();
let out_b = b.transform(&x).unwrap();
for (va, vb) in out_a.iter().zip(out_b.iter()) {
assert_eq!(va.to_bits(), vb.to_bits(), "copy flag changed the output");
}
}
#[test]
fn fit_zero_row_unchanged() {
const SK: [[f64; 3]; 2] = [[0.0, 0.0, 0.0], [0.6, 0.8, 0.0]];
let z = array![[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]];
let out = Normalizer::<f64>::l2()
.fit(&z, &())
.unwrap()
.transform(&z)
.unwrap();
for i in 0..2 {
for j in 0..3 {
assert!(
(out[[i, j]] - SK[i][j]).abs() < 1e-12,
"fit zero[{i},{j}]: ferro={} sklearn={}",
out[[i, j]],
SK[i][j]
);
}
}
}
#[test]
fn fitted_transform_shape_mismatch() {
let x_fit = array![[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]];
let fitted = Normalizer::<f64>::l2().fit(&x_fit, &()).unwrap();
let x_wrong = array![[1.0, 1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0, 1.0]];
let result = fitted.transform(&x_wrong);
assert!(
matches!(result, Err(FerroError::ShapeMismatch { .. })),
"wrong column count after fit should map to FerroError::ShapeMismatch"
);
}
#[test]
fn fit_path_equals_stateless_path() {
let x = array![[-2.0, 1.0, 2.0], [-1.0, 0.0, 1.0], [10.0, -3.0, 0.5]];
for norm in [NormType::L1, NormType::L2, NormType::Max] {
let fit_out = Normalizer::<f64>::new(norm)
.fit(&x, &())
.unwrap()
.transform(&x)
.unwrap();
let stateless_out = Normalizer::<f64>::new(norm).transform(&x).unwrap();
for (a, b) in fit_out.iter().zip(stateless_out.iter()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"fit-path diverged from stateless-path for {norm:?}"
);
}
}
}
#[test]
fn fit_f32_matches_oracle() {
const SK: [f32; 2] = [0.6, 0.8];
let x: Array2<f32> = array![[3.0f32, 4.0]];
let out = Normalizer::<f32>::l2()
.fit(&x, &())
.unwrap()
.transform(&x)
.unwrap();
for j in 0..2 {
assert!(
(out[[0, j]] - SK[j]).abs() < 1e-6,
"fit f32[0,{j}]: ferro={} sklearn={}",
out[[0, j]],
SK[j]
);
}
}