use std::{error::Error, fmt};
use rand::{rngs::StdRng, Rng, SeedableRng};
use rand_distr::StandardNormal;
use crate::EventStream;
#[derive(Clone, Copy, Debug)]
pub struct FeastConfig {
pub n_features: usize,
pub patch: usize,
pub tau_ms: f64,
pub eta: f32,
pub delta_i: f32,
pub delta_e: f32,
pub per_polarity: bool,
pub seed: u64,
}
impl Default for FeastConfig {
fn default() -> Self {
Self {
n_features: 100,
patch: 11,
tau_ms: 30.0,
eta: 0.001,
delta_i: 0.001,
delta_e: 0.003,
per_polarity: true,
seed: 0,
}
}
}
#[derive(Clone, Debug)]
pub struct Feast {
config: FeastConfig,
banks: usize,
dim: usize,
weights: Vec<f32>,
thresholds: Vec<f32>,
last_missed_rate: f64,
}
impl Feast {
pub fn new(config: FeastConfig) -> Result<Self, FeastError> {
validate(&config)?;
let banks = if config.per_polarity { 2 } else { 1 };
let dim = config.patch * config.patch;
let total = banks * config.n_features;
let mut rng = StdRng::seed_from_u64(config.seed);
let mut weights = vec![0.0_f32; total * dim];
for feature in weights.chunks_mut(dim) {
let mut norm = 0.0_f32;
for value in feature.iter_mut() {
let sample: f32 = rng.sample(StandardNormal);
*value = sample;
norm += sample * sample;
}
let inv = 1.0 / norm.sqrt();
feature.iter_mut().for_each(|value| *value *= inv);
}
let thresholds = (0..total).map(|_| rng.gen::<f32>()).collect();
Ok(Self {
config,
banks,
dim,
weights,
thresholds,
last_missed_rate: 0.0,
})
}
pub fn from_state(
config: FeastConfig,
weights: Vec<f32>,
thresholds: Vec<f32>,
) -> Result<Self, FeastError> {
validate(&config)?;
let banks = if config.per_polarity { 2 } else { 1 };
let dim = config.patch * config.patch;
let total = banks * config.n_features;
if weights.len() != total * dim || thresholds.len() != total {
return Err(FeastError::StateShapeMismatch);
}
Ok(Self {
config,
banks,
dim,
weights,
thresholds,
last_missed_rate: 0.0,
})
}
pub fn fit(&mut self, stream: &EventStream, epochs: usize) -> f64 {
let (width, height) = stream.sensor_size();
let scale = stream.timestamp_scale_ms();
let plane = width * height;
let n = self.config.n_features;
let (eta, delta_i, delta_e) = (self.config.eta, self.config.delta_i, self.config.delta_e);
let mut descriptor = vec![0.0_f32; self.dim];
for _ in 0..epochs.max(1) {
let mut sae = vec![i64::MIN; self.banks * plane];
let (mut seen, mut missed) = (0_u64, 0_u64);
for event in stream.iter() {
let bank = self.bank_of(event.polarity);
let surface = &mut sae[bank * plane..(bank + 1) * plane];
surface[event.y * width + event.x] = event.timestamp as i64;
if !self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
continue;
}
seen += 1;
match self.best_within_threshold(bank, &descriptor) {
Some(feature) => {
let base = (bank * n + feature) * self.dim;
let weights = &mut self.weights[base..base + self.dim];
let mut norm = 0.0_f32;
for (weight, &input) in weights.iter_mut().zip(&descriptor) {
*weight = (1.0 - eta) * *weight + eta * input;
norm += *weight * *weight;
}
let inv = 1.0 / norm.sqrt();
weights.iter_mut().for_each(|weight| *weight *= inv);
let threshold = &mut self.thresholds[bank * n + feature];
*threshold = (*threshold - delta_i).max(0.0);
}
None => {
missed += 1;
for threshold in &mut self.thresholds[bank * n..(bank + 1) * n] {
*threshold += delta_e;
}
}
}
}
self.last_missed_rate = if seen > 0 {
missed as f64 / seen as f64
} else {
0.0
};
}
self.last_missed_rate
}
pub fn transform(&self, stream: &EventStream) -> Vec<i32> {
let (width, height) = stream.sensor_size();
let scale = stream.timestamp_scale_ms();
let plane = width * height;
let mut ids = vec![-1_i32; stream.len()];
let mut descriptor = vec![0.0_f32; self.dim];
let mut sae = vec![i64::MIN; self.banks * plane];
for (index, event) in stream.iter().enumerate() {
let bank = self.bank_of(event.polarity);
let surface = &mut sae[bank * plane..(bank + 1) * plane];
surface[event.y * width + event.x] = event.timestamp as i64;
if self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
let feature = self.nearest(bank, &descriptor);
ids[index] = (bank * self.config.n_features + feature) as i32;
}
}
ids
}
pub fn histogram(&self, stream: &EventStream) -> Vec<u32> {
let mut counts = vec![0_u32; self.n_features_total()];
for id in self.transform(stream) {
if id >= 0 {
counts[id as usize] += 1;
}
}
counts
}
pub fn weights(&self) -> &[f32] {
&self.weights
}
pub fn thresholds(&self) -> &[f32] {
&self.thresholds
}
pub fn n_features_total(&self) -> usize {
self.banks * self.config.n_features
}
pub fn config(&self) -> &FeastConfig {
&self.config
}
pub fn missed_rate(&self) -> f64 {
self.last_missed_rate
}
fn bank_of(&self, polarity: bool) -> usize {
if self.config.per_polarity && !polarity {
1
} else {
0
}
}
fn fill_descriptor(
&self,
surface: &[i64],
event: &crate::Event,
width: usize,
height: usize,
scale: f64,
descriptor: &mut [f32],
) -> bool {
let radius = self.config.patch / 2;
if event.x < radius
|| event.y < radius
|| event.x + radius >= width
|| event.y + radius >= height
{
return false;
}
let now = event.timestamp as i64;
let mut norm = 0.0_f32;
let mut slot = 0;
for dy in -(radius as i32)..=radius as i32 {
for dx in -(radius as i32)..=radius as i32 {
let x = (event.x as i32 + dx) as usize;
let y = (event.y as i32 + dy) as usize;
let last = surface[y * width + x];
let value = if last == i64::MIN {
0.0
} else {
let age_ms = (now - last).max(0) as f64 * scale;
(-age_ms / self.config.tau_ms).exp() as f32
};
descriptor[slot] = value;
norm += value * value;
slot += 1;
}
}
if norm <= 0.0 {
return false;
}
let inv = 1.0 / norm.sqrt();
descriptor.iter_mut().for_each(|value| *value *= inv);
true
}
fn best_within_threshold(&self, bank: usize, descriptor: &[f32]) -> Option<usize> {
let n = self.config.n_features;
let mut best = None;
let mut best_distance = f32::INFINITY;
for feature in 0..n {
let distance = self.cosine_distance(bank * n + feature, descriptor);
if distance <= self.thresholds[bank * n + feature] && distance < best_distance {
best_distance = distance;
best = Some(feature);
}
}
best
}
fn nearest(&self, bank: usize, descriptor: &[f32]) -> usize {
let n = self.config.n_features;
(0..n)
.min_by(|&a, &b| {
let da = self.cosine_distance(bank * n + a, descriptor);
let db = self.cosine_distance(bank * n + b, descriptor);
da.total_cmp(&db)
})
.expect("n_features is at least 1")
}
fn cosine_distance(&self, index: usize, descriptor: &[f32]) -> f32 {
let base = index * self.dim;
let dot: f32 = self.weights[base..base + self.dim]
.iter()
.zip(descriptor)
.map(|(weight, input)| weight * input)
.sum();
1.0 - dot
}
}
fn validate(config: &FeastConfig) -> Result<(), FeastError> {
if config.n_features == 0 {
return Err(FeastError::InvalidParameter("n_features must be at least 1"));
}
if config.patch == 0 || config.patch.is_multiple_of(2) {
return Err(FeastError::InvalidParameter("patch must be odd and at least 1"));
}
if !config.tau_ms.is_finite() || config.tau_ms <= 0.0 {
return Err(FeastError::InvalidParameter("tau_ms must be finite and positive"));
}
if !config.eta.is_finite() || config.eta <= 0.0 || config.eta > 1.0 {
return Err(FeastError::InvalidParameter("eta must be in (0, 1]"));
}
if !config.delta_i.is_finite() || config.delta_i < 0.0 {
return Err(FeastError::InvalidParameter("delta_i must be finite and non-negative"));
}
if !config.delta_e.is_finite() || config.delta_e < 0.0 {
return Err(FeastError::InvalidParameter("delta_e must be finite and non-negative"));
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum FeastError {
InvalidParameter(&'static str),
StateShapeMismatch,
}
impl fmt::Display for FeastError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidParameter(message) => formatter.write_str(message),
Self::StateShapeMismatch => {
formatter.write_str("feature/threshold arrays do not match the configuration")
}
}
}
}
impl Error for FeastError {}
#[cfg(test)]
mod tests {
use super::{Feast, FeastConfig, FeastError};
use crate::EventStream;
fn sweeping_bar(size: usize, sweeps: usize) -> EventStream {
let mut rows = Vec::new();
let mut t = 0_u64;
for _ in 0..sweeps {
for x in 0..size as u64 {
for y in 0..size as u64 {
rows.push([x, y, t, 1]);
}
t += 1_000; }
}
let flat: Vec<u64> = rows.concat();
let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
EventStream::from_array2(events, size, size, 0.001)
}
fn config(n_features: usize, patch: usize, per_polarity: bool) -> FeastConfig {
FeastConfig {
n_features,
patch,
per_polarity,
..FeastConfig::default()
}
}
#[test]
fn new_rejects_invalid_parameters() {
assert_eq!(
Feast::new(config(0, 5, false)).unwrap_err(),
FeastError::InvalidParameter("n_features must be at least 1")
);
assert_eq!(
Feast::new(config(4, 4, false)).unwrap_err(),
FeastError::InvalidParameter("patch must be odd and at least 1")
);
assert!(matches!(
Feast::new(FeastConfig {
tau_ms: 0.0,
..config(4, 5, false)
}),
Err(FeastError::InvalidParameter(_))
));
}
#[test]
fn init_is_deterministic_and_unit_norm() {
let a = Feast::new(config(8, 5, false)).unwrap();
let b = Feast::new(config(8, 5, false)).unwrap();
assert_eq!(a.weights(), b.weights());
assert_eq!(a.thresholds(), b.thresholds());
for feature in a.weights().chunks(5 * 5) {
let norm: f32 = feature.iter().map(|value| value * value).sum();
assert!((norm - 1.0).abs() < 1e-5, "‖w‖² = {norm}");
}
}
#[test]
fn fit_converges_to_a_low_miss_rate() {
let stream = sweeping_bar(16, 4);
let mut feast = Feast::new(config(8, 5, false)).unwrap();
let first = feast.fit(&stream, 1);
let mut last = first;
for _ in 0..14 {
last = feast.fit(&stream, 1);
}
assert!(first > 0.1, "cold network should miss heavily, got {first}");
assert!(last < first, "miss rate should fall with training: {first} → {last}");
assert!(last < 0.05, "converged miss rate too high: {last}");
}
#[test]
fn transform_labels_are_aligned_and_in_range() {
let stream = sweeping_bar(16, 20);
let mut feast = Feast::new(config(6, 5, false)).unwrap();
feast.fit(&stream, 4);
let ids = feast.transform(&stream);
assert_eq!(ids.len(), stream.len());
assert!(ids.iter().all(|&id| (-1..feast.n_features_total() as i32).contains(&id)));
let labelled = ids.iter().filter(|&&id| id >= 0).count();
assert!(labelled > stream.len() / 2);
let histogram = feast.histogram(&stream);
assert_eq!(histogram.len(), feast.n_features_total());
assert_eq!(histogram.iter().sum::<u32>() as usize, labelled);
}
#[test]
fn per_polarity_splits_populations() {
let mut rows = Vec::new();
let mut t = 0_u64;
for x in 0..16_u64 {
for y in 0..16_u64 {
rows.push([x, y, t, (x % 2)]); }
t += 1_000;
}
let flat: Vec<u64> = rows.concat();
let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
let stream = EventStream::from_array2(events, 16, 16, 0.001);
let mut feast = Feast::new(config(5, 5, true)).unwrap();
feast.fit(&stream, 4);
assert_eq!(feast.n_features_total(), 10);
assert!(feast.transform(&stream).iter().any(|&id| id >= 5));
}
#[test]
fn from_state_round_trips_the_model() {
let stream = sweeping_bar(16, 10);
let mut feast = Feast::new(config(6, 5, false)).unwrap();
feast.fit(&stream, 3);
let rebuilt = Feast::from_state(
*feast.config(),
feast.weights().to_vec(),
feast.thresholds().to_vec(),
)
.unwrap();
assert_eq!(feast.transform(&stream), rebuilt.transform(&stream));
assert_eq!(
Feast::from_state(*feast.config(), vec![0.0; 3], vec![0.0; 2]).unwrap_err(),
FeastError::StateShapeMismatch
);
}
}