use rill_ml::RillError;
use rill_ml::feature_hasher::FeatureHasher;
use rill_ml::sparse::SparseFeatures;
#[test]
fn sparse_features_roundtrip() {
let sf = SparseFeatures::from_sorted(vec![(0, 1.5), (3, -2.0), (7, 0.25)]).unwrap();
assert_eq!(sf.len(), 3);
assert!(!sf.is_empty());
let values = sf.values();
assert_eq!(values.len(), 3);
assert_eq!(values[0], (0, 1.5));
assert_eq!(values[1], (3, -2.0));
assert_eq!(values[2], (7, 0.25));
assert_eq!(sf.get(0), Some(1.5));
assert_eq!(sf.get(3), Some(-2.0));
assert_eq!(sf.get(7), Some(0.25));
assert_eq!(sf.get(1), None);
assert_eq!(sf.get(100), None);
}
#[test]
fn sparse_features_from_unsorted_merges() {
let sf =
SparseFeatures::from_unsorted(vec![(5, 1.0), (1, 2.0), (5, 0.5), (1, -1.0), (3, 10.0)])
.unwrap();
assert_eq!(sf.len(), 3);
assert_eq!(sf.get(1), Some(1.0));
assert_eq!(sf.get(3), Some(10.0));
assert_eq!(sf.get(5), Some(1.5));
let ids: Vec<_> = sf.values().iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![1, 3, 5]);
}
#[test]
fn feature_hasher_reproducible() {
let hasher = FeatureHasher::new(32, 42).unwrap();
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0), (2, -3.0)]).unwrap();
let out1 = hasher.transform(&sf).unwrap();
let out2 = hasher.transform(&sf).unwrap();
assert_eq!(out1.len(), 32);
assert_eq!(out1, out2, "same hasher + same input must be reproducible");
let hasher2 = FeatureHasher::new(32, 42).unwrap();
let out3 = hasher2.transform(&sf).unwrap();
assert_eq!(out1, out3);
}
#[test]
fn feature_hasher_different_seeds() {
let h1 = FeatureHasher::new(64, 1).unwrap();
let h2 = FeatureHasher::new(64, 2).unwrap();
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0), (2, 3.0), (3, 4.0), (4, 5.0)])
.unwrap();
let out1 = h1.transform(&sf).unwrap();
let out2 = h2.transform(&sf).unwrap();
assert_eq!(out1.len(), out2.len());
assert_ne!(
out1, out2,
"different seeds should produce different outputs"
);
}
#[test]
fn feature_hasher_string_hashing() {
let hasher = FeatureHasher::new(16, 42).unwrap();
let pairs: &[(&str, f64)] = &[("user_id", 1.0), ("device_type", 2.0), ("country", 3.0)];
let sf = hasher.hash_strings(pairs).unwrap();
assert!(sf.validate().is_ok());
assert_eq!(sf.len(), pairs.len());
let id_again = hasher.hash_string("user_id");
assert_eq!(sf.get(id_again), Some(1.0));
let id_user = hasher.hash_string("user_id");
let id_device = hasher.hash_string("device_type");
let id_country = hasher.hash_string("country");
let mut ids = vec![id_user, id_device, id_country];
ids.sort_unstable();
ids.dedup();
assert_eq!(
ids.len(),
3,
"three distinct strings should hash to three distinct ids"
);
}
#[test]
fn feature_hasher_collision_handling() {
let hasher = FeatureHasher::new(1, 42).unwrap();
let sf = SparseFeatures::from_sorted(vec![(0, 2.0), (1, 3.0), (2, -1.0)]).unwrap();
let out = hasher.transform(&sf).unwrap();
assert_eq!(out.len(), 1);
assert!(
out[0] != 0.0,
"collision bucket should accumulate non-zero values, got {}",
out[0]
);
assert!(
out[0].abs() <= 6.0 + 1e-9,
"accumulated value should not exceed sum of absolute inputs"
);
let out_again = hasher.transform(&sf).unwrap();
assert_eq!(out, out_again);
}
#[test]
fn feature_hasher_with_linear_regression() {
use rill_ml::OnlineRegressor;
use rill_ml::loss::RegressionLoss;
use rill_ml::models::{LinearRegression, LinearRegressionConfig};
use rill_ml::optim::{Optimizer, SgdConfig};
let dim = 16;
let hasher = FeatureHasher::new(dim, 42).unwrap();
let d = dim;
let mut model = LinearRegression::new(
d,
LinearRegressionConfig {
optimizer: Optimizer::sgd(
d,
SgdConfig {
learning_rate: 0.05,
l2: 0.0,
},
)
.unwrap(),
loss: RegressionLoss::default(),
},
)
.unwrap();
let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (5, 2.0), (10, -1.0)]).unwrap();
let dense = hasher.transform(&sf).unwrap();
let target: f64 = dense.iter().sum();
for _ in 0..500 {
model.learn(&dense, target).unwrap();
}
let pred = model.predict(&dense).unwrap();
assert!(
(pred - target).abs() < 0.5,
"linear regression should learn the hashed representation: pred={pred}, target={target}"
);
}
#[test]
fn sparse_features_empty_rejected() {
use rill_ml::SparseRegressor;
use rill_ml::models::{FtrlConfig, FtrlRegressor};
let model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
let empty = SparseFeatures::new();
let result = model.predict(&empty);
assert!(
matches!(result, Err(RillError::EmptyFeatures)),
"predict on empty SparseFeatures should return EmptyFeatures, got {:?}",
result
);
}