use crate::error::{RillError, checked_finite_add, ensure_finite};
use crate::sparse::{FeatureId, SparseFeatures};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FeatureHasherConfig {
pub dimension: usize,
pub seed: u64,
pub signed: bool,
}
impl Default for FeatureHasherConfig {
fn default() -> Self {
Self {
dimension: 1024,
seed: 0,
signed: true,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct FeatureHasher {
config: FeatureHasherConfig,
}
impl FeatureHasher {
pub fn new(dimension: usize, seed: u64) -> Result<Self, RillError> {
Self::with_config(FeatureHasherConfig {
dimension,
seed,
signed: true,
})
}
pub fn with_config(config: FeatureHasherConfig) -> Result<Self, RillError> {
if config.dimension == 0 {
return Err(RillError::InvalidHashDimension(config.dimension));
}
Ok(Self { config })
}
pub const fn dimension(&self) -> usize {
self.config.dimension
}
pub const fn seed(&self) -> u64 {
self.config.seed
}
pub const fn signed(&self) -> bool {
self.config.signed
}
fn hash_id(&self, id: FeatureId) -> (usize, f64) {
let bucket = self.hash_bucket(id);
let sign = if self.config.signed {
self.hash_sign(id)
} else {
1.0
};
(bucket, sign)
}
fn hash_bucket(&self, id: FeatureId) -> usize {
let mut hasher = DefaultHasher::new();
self.config.seed.hash(&mut hasher);
id.hash(&mut hasher);
(hasher.finish() as usize) % self.config.dimension
}
fn hash_sign(&self, id: FeatureId) -> f64 {
let mut hasher = DefaultHasher::new();
(self.config.seed.wrapping_mul(0x517cc1b727220a95)).hash(&mut hasher);
id.hash(&mut hasher);
if hasher.finish() & 1 == 1 { -1.0 } else { 1.0 }
}
pub fn hash_string(&self, name: &str) -> FeatureId {
let mut hasher = DefaultHasher::new();
self.config.seed.hash(&mut hasher);
name.hash(&mut hasher);
hasher.finish()
}
pub fn hash_strings(&self, pairs: &[(&str, f64)]) -> Result<SparseFeatures, RillError> {
let mut ids: Vec<(FeatureId, f64)> = Vec::with_capacity(pairs.len());
for (name, value) in pairs {
ensure_finite("hash_value", *value)?;
ids.push((self.hash_string(name), *value));
}
SparseFeatures::from_unsorted(ids)
}
pub fn transform(&self, features: &SparseFeatures) -> Result<Vec<f64>, RillError> {
if self.config.dimension == 0 {
return Err(RillError::InvalidHashDimension(0));
}
features.validate()?;
let mut output = vec![0.0; self.config.dimension];
for &(id, value) in features.values() {
ensure_finite("sparse_value", value)?;
let (bucket, sign) = self.hash_id(id);
output[bucket] = checked_finite_add(output[bucket], sign * value, "hashed feature")?;
}
Ok(output)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for FeatureHasher {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct FeatureHasherState {
config: FeatureHasherConfig,
}
let state = FeatureHasherState::deserialize(deserializer)?;
Self::with_config(state.config).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reproducible_output() {
let h = FeatureHasher::new(16, 42).unwrap();
let sf = SparseFeatures::from_sorted(vec![(1, 3.0), (5, -2.0), (10, 1.0)]).unwrap();
let out1 = h.transform(&sf).unwrap();
let out2 = h.transform(&sf).unwrap();
assert_eq!(out1, out2);
}
#[test]
fn collision_overflow_is_rejected() {
let hasher = FeatureHasher::with_config(FeatureHasherConfig {
dimension: 1,
seed: 42,
signed: false,
})
.unwrap();
let features = SparseFeatures::from_sorted(vec![(1, f64::MAX), (2, f64::MAX)]).unwrap();
assert!(hasher.transform(&features).is_err());
}
#[cfg(feature = "serde")]
#[test]
fn serde_rejects_zero_dimension() {
let malformed = r#"{"config":{"dimension":0,"seed":0,"signed":true}}"#;
assert!(serde_json::from_str::<FeatureHasher>(malformed).is_err());
}
#[test]
fn different_seeds_produce_different_output() {
let h1 = FeatureHasher::new(16, 1).unwrap();
let h2 = FeatureHasher::new(16, 2).unwrap();
let sf = SparseFeatures::from_sorted(vec![(1, 1.0), (2, 2.0)]).unwrap();
let out1 = h1.transform(&sf).unwrap();
let out2 = h2.transform(&sf).unwrap();
assert_ne!(out1, out2);
}
#[test]
fn signed_hashing_produces_negatives() {
let h = FeatureHasher::with_config(FeatureHasherConfig {
dimension: 256,
seed: 42,
signed: true,
})
.unwrap();
let sf =
SparseFeatures::from_sorted((0..100).map(|i| (i, 1.0)).collect::<Vec<_>>()).unwrap();
let out = h.transform(&sf).unwrap();
assert!(out.iter().any(|&v| v < 0.0));
}
#[test]
fn unsigned_hashing_all_positive() {
let h = FeatureHasher::with_config(FeatureHasherConfig {
dimension: 256,
seed: 42,
signed: false,
})
.unwrap();
let sf =
SparseFeatures::from_sorted((0..100).map(|i| (i, 1.0)).collect::<Vec<_>>()).unwrap();
let out = h.transform(&sf).unwrap();
assert!(out.iter().all(|&v| v >= 0.0));
}
#[test]
fn dimension_one_all_same_bucket() {
let h = FeatureHasher::new(1, 42).unwrap();
let sf = SparseFeatures::from_sorted(vec![(1, 3.0), (2, 5.0)]).unwrap();
let out = h.transform(&sf).unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].abs() > 0.0);
}
#[test]
fn empty_features_returns_zeros() {
let h = FeatureHasher::new(8, 42).unwrap();
let sf = SparseFeatures::new();
let out = h.transform(&sf).unwrap();
assert_eq!(out, vec![0.0; 8]);
}
#[test]
fn string_hash_reproducible() {
let h = FeatureHasher::new(8, 42).unwrap();
let id1 = h.hash_string("user_id");
let id2 = h.hash_string("user_id");
assert_eq!(id1, id2);
}
#[test]
fn different_strings_different_ids() {
let h = FeatureHasher::new(8, 42).unwrap();
let id1 = h.hash_string("user_id");
let id2 = h.hash_string("device_id");
assert_ne!(id1, id2);
}
#[test]
fn hash_strings_creates_sorted_features() {
let h = FeatureHasher::new(8, 42).unwrap();
let sf = h
.hash_strings(&[("alpha", 1.0), ("beta", 2.0), ("gamma", 3.0)])
.unwrap();
assert!(sf.validate().is_ok());
assert_eq!(sf.len(), 3);
}
#[test]
fn invalid_dimension_rejected() {
assert!(matches!(
FeatureHasher::new(0, 42),
Err(RillError::InvalidHashDimension(0))
));
}
#[test]
#[cfg(feature = "serde")]
fn serde_roundtrip() {
let h = FeatureHasher::new(16, 42).unwrap();
let json = serde_json::to_string(&h).unwrap();
let restored: FeatureHasher = serde_json::from_str(&json).unwrap();
assert_eq!(restored.dimension(), 16);
assert_eq!(restored.seed(), 42);
assert!(restored.signed());
}
}