1use std::{error::Error, fmt};
21
22use rand::{rngs::StdRng, Rng, SeedableRng};
23use rand_distr::StandardNormal;
24
25use crate::EventStream;
26
27#[derive(Clone, Copy, Debug)]
31pub struct FeastConfig {
32 pub n_features: usize,
34 pub patch: usize,
36 pub tau_ms: f64,
38 pub eta: f32,
40 pub delta_i: f32,
42 pub delta_e: f32,
44 pub per_polarity: bool,
48 pub seed: u64,
50}
51
52impl Default for FeastConfig {
53 fn default() -> Self {
54 Self {
55 n_features: 100,
56 patch: 11,
57 tau_ms: 30.0,
58 eta: 0.001,
59 delta_i: 0.001,
60 delta_e: 0.003,
61 per_polarity: true,
62 seed: 0,
63 }
64 }
65}
66
67#[derive(Clone, Debug)]
70pub struct Feast {
71 config: FeastConfig,
72 banks: usize,
74 dim: usize,
76 weights: Vec<f32>,
78 thresholds: Vec<f32>,
80 last_missed_rate: f64,
82}
83
84impl Feast {
85 pub fn new(config: FeastConfig) -> Result<Self, FeastError> {
88 validate(&config)?;
89 let banks = if config.per_polarity { 2 } else { 1 };
90 let dim = config.patch * config.patch;
91 let total = banks * config.n_features;
92
93 let mut rng = StdRng::seed_from_u64(config.seed);
94 let mut weights = vec![0.0_f32; total * dim];
95 for feature in weights.chunks_mut(dim) {
96 let mut norm = 0.0_f32;
97 for value in feature.iter_mut() {
98 let sample: f32 = rng.sample(StandardNormal);
99 *value = sample;
100 norm += sample * sample;
101 }
102 let inv = 1.0 / norm.sqrt();
103 feature.iter_mut().for_each(|value| *value *= inv);
104 }
105 let thresholds = (0..total).map(|_| rng.gen::<f32>()).collect();
106
107 Ok(Self {
108 config,
109 banks,
110 dim,
111 weights,
112 thresholds,
113 last_missed_rate: 0.0,
114 })
115 }
116
117 pub fn from_state(
121 config: FeastConfig,
122 weights: Vec<f32>,
123 thresholds: Vec<f32>,
124 ) -> Result<Self, FeastError> {
125 validate(&config)?;
126 let banks = if config.per_polarity { 2 } else { 1 };
127 let dim = config.patch * config.patch;
128 let total = banks * config.n_features;
129 if weights.len() != total * dim || thresholds.len() != total {
130 return Err(FeastError::StateShapeMismatch);
131 }
132 Ok(Self {
133 config,
134 banks,
135 dim,
136 weights,
137 thresholds,
138 last_missed_rate: 0.0,
139 })
140 }
141
142 pub fn fit(&mut self, stream: &EventStream, epochs: usize) -> f64 {
147 let (width, height) = stream.sensor_size();
148 let scale = stream.timestamp_scale_ms();
149 let plane = width * height;
150 let n = self.config.n_features;
151 let (eta, delta_i, delta_e) = (self.config.eta, self.config.delta_i, self.config.delta_e);
152
153 let mut descriptor = vec![0.0_f32; self.dim];
154 for _ in 0..epochs.max(1) {
155 let mut sae = vec![i64::MIN; self.banks * plane];
156 let (mut seen, mut missed) = (0_u64, 0_u64);
157
158 for event in stream.iter() {
159 let bank = self.bank_of(event.polarity);
160 let surface = &mut sae[bank * plane..(bank + 1) * plane];
161 surface[event.y * width + event.x] = event.timestamp as i64;
162
163 if !self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
164 continue;
165 }
166 seen += 1;
167
168 match self.best_within_threshold(bank, &descriptor) {
169 Some(feature) => {
170 let base = (bank * n + feature) * self.dim;
171 let weights = &mut self.weights[base..base + self.dim];
172 let mut norm = 0.0_f32;
173 for (weight, &input) in weights.iter_mut().zip(&descriptor) {
174 *weight = (1.0 - eta) * *weight + eta * input;
175 norm += *weight * *weight;
176 }
177 let inv = 1.0 / norm.sqrt();
178 weights.iter_mut().for_each(|weight| *weight *= inv);
179
180 let threshold = &mut self.thresholds[bank * n + feature];
181 *threshold = (*threshold - delta_i).max(0.0);
182 }
183 None => {
184 missed += 1;
185 for threshold in &mut self.thresholds[bank * n..(bank + 1) * n] {
186 *threshold += delta_e;
187 }
188 }
189 }
190 }
191 self.last_missed_rate = if seen > 0 {
192 missed as f64 / seen as f64
193 } else {
194 0.0
195 };
196 }
197 self.last_missed_rate
198 }
199
200 pub fn transform(&self, stream: &EventStream) -> Vec<i32> {
206 let (width, height) = stream.sensor_size();
207 let scale = stream.timestamp_scale_ms();
208 let plane = width * height;
209
210 let mut ids = vec![-1_i32; stream.len()];
211 let mut descriptor = vec![0.0_f32; self.dim];
212 let mut sae = vec![i64::MIN; self.banks * plane];
213
214 for (index, event) in stream.iter().enumerate() {
215 let bank = self.bank_of(event.polarity);
216 let surface = &mut sae[bank * plane..(bank + 1) * plane];
217 surface[event.y * width + event.x] = event.timestamp as i64;
218
219 if self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
220 let feature = self.nearest(bank, &descriptor);
221 ids[index] = (bank * self.config.n_features + feature) as i32;
222 }
223 }
224 ids
225 }
226
227 pub fn histogram(&self, stream: &EventStream) -> Vec<u32> {
231 let mut counts = vec![0_u32; self.n_features_total()];
232 for id in self.transform(stream) {
233 if id >= 0 {
234 counts[id as usize] += 1;
235 }
236 }
237 counts
238 }
239
240 pub fn weights(&self) -> &[f32] {
243 &self.weights
244 }
245
246 pub fn thresholds(&self) -> &[f32] {
248 &self.thresholds
249 }
250
251 pub fn n_features_total(&self) -> usize {
253 self.banks * self.config.n_features
254 }
255
256 pub fn config(&self) -> &FeastConfig {
257 &self.config
258 }
259
260 pub fn missed_rate(&self) -> f64 {
262 self.last_missed_rate
263 }
264
265 fn bank_of(&self, polarity: bool) -> usize {
268 if self.config.per_polarity && !polarity {
269 1
270 } else {
271 0
272 }
273 }
274
275 fn fill_descriptor(
280 &self,
281 surface: &[i64],
282 event: &crate::Event,
283 width: usize,
284 height: usize,
285 scale: f64,
286 descriptor: &mut [f32],
287 ) -> bool {
288 let radius = self.config.patch / 2;
289 if event.x < radius
290 || event.y < radius
291 || event.x + radius >= width
292 || event.y + radius >= height
293 {
294 return false;
295 }
296 let now = event.timestamp as i64;
297 let mut norm = 0.0_f32;
298 let mut slot = 0;
299 for dy in -(radius as i32)..=radius as i32 {
300 for dx in -(radius as i32)..=radius as i32 {
301 let x = (event.x as i32 + dx) as usize;
302 let y = (event.y as i32 + dy) as usize;
303 let last = surface[y * width + x];
304 let value = if last == i64::MIN {
305 0.0
306 } else {
307 let age_ms = (now - last).max(0) as f64 * scale;
308 (-age_ms / self.config.tau_ms).exp() as f32
309 };
310 descriptor[slot] = value;
311 norm += value * value;
312 slot += 1;
313 }
314 }
315 if norm <= 0.0 {
316 return false;
317 }
318 let inv = 1.0 / norm.sqrt();
319 descriptor.iter_mut().for_each(|value| *value *= inv);
320 true
321 }
322
323 fn best_within_threshold(&self, bank: usize, descriptor: &[f32]) -> Option<usize> {
326 let n = self.config.n_features;
327 let mut best = None;
328 let mut best_distance = f32::INFINITY;
329 for feature in 0..n {
330 let distance = self.cosine_distance(bank * n + feature, descriptor);
331 if distance <= self.thresholds[bank * n + feature] && distance < best_distance {
332 best_distance = distance;
333 best = Some(feature);
334 }
335 }
336 best
337 }
338
339 fn nearest(&self, bank: usize, descriptor: &[f32]) -> usize {
341 let n = self.config.n_features;
342 (0..n)
343 .min_by(|&a, &b| {
344 let da = self.cosine_distance(bank * n + a, descriptor);
345 let db = self.cosine_distance(bank * n + b, descriptor);
346 da.total_cmp(&db)
347 })
348 .expect("n_features is at least 1")
349 }
350
351 fn cosine_distance(&self, index: usize, descriptor: &[f32]) -> f32 {
353 let base = index * self.dim;
354 let dot: f32 = self.weights[base..base + self.dim]
355 .iter()
356 .zip(descriptor)
357 .map(|(weight, input)| weight * input)
358 .sum();
359 1.0 - dot
360 }
361}
362
363fn validate(config: &FeastConfig) -> Result<(), FeastError> {
364 if config.n_features == 0 {
365 return Err(FeastError::InvalidParameter("n_features must be at least 1"));
366 }
367 if config.patch == 0 || config.patch.is_multiple_of(2) {
368 return Err(FeastError::InvalidParameter("patch must be odd and at least 1"));
369 }
370 if !config.tau_ms.is_finite() || config.tau_ms <= 0.0 {
371 return Err(FeastError::InvalidParameter("tau_ms must be finite and positive"));
372 }
373 if !config.eta.is_finite() || config.eta <= 0.0 || config.eta > 1.0 {
374 return Err(FeastError::InvalidParameter("eta must be in (0, 1]"));
375 }
376 if !config.delta_i.is_finite() || config.delta_i < 0.0 {
377 return Err(FeastError::InvalidParameter("delta_i must be finite and non-negative"));
378 }
379 if !config.delta_e.is_finite() || config.delta_e < 0.0 {
380 return Err(FeastError::InvalidParameter("delta_e must be finite and non-negative"));
381 }
382 Ok(())
383}
384
385#[derive(Debug, PartialEq, Eq)]
386pub enum FeastError {
387 InvalidParameter(&'static str),
388 StateShapeMismatch,
389}
390
391impl fmt::Display for FeastError {
392 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
393 match self {
394 Self::InvalidParameter(message) => formatter.write_str(message),
395 Self::StateShapeMismatch => {
396 formatter.write_str("feature/threshold arrays do not match the configuration")
397 }
398 }
399 }
400}
401
402impl Error for FeastError {}
403
404#[cfg(test)]
405mod tests {
406 use super::{Feast, FeastConfig, FeastError};
407 use crate::EventStream;
408
409 fn sweeping_bar(size: usize, sweeps: usize) -> EventStream {
413 let mut rows = Vec::new();
414 let mut t = 0_u64;
415 for _ in 0..sweeps {
416 for x in 0..size as u64 {
417 for y in 0..size as u64 {
418 rows.push([x, y, t, 1]);
419 }
420 t += 1_000; }
422 }
423 let flat: Vec<u64> = rows.concat();
424 let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
425 EventStream::from_array2(events, size, size, 0.001)
426 }
427
428 fn config(n_features: usize, patch: usize, per_polarity: bool) -> FeastConfig {
429 FeastConfig {
430 n_features,
431 patch,
432 per_polarity,
433 ..FeastConfig::default()
434 }
435 }
436
437 #[test]
438 fn new_rejects_invalid_parameters() {
439 assert_eq!(
440 Feast::new(config(0, 5, false)).unwrap_err(),
441 FeastError::InvalidParameter("n_features must be at least 1")
442 );
443 assert_eq!(
444 Feast::new(config(4, 4, false)).unwrap_err(),
445 FeastError::InvalidParameter("patch must be odd and at least 1")
446 );
447 assert!(matches!(
448 Feast::new(FeastConfig {
449 tau_ms: 0.0,
450 ..config(4, 5, false)
451 }),
452 Err(FeastError::InvalidParameter(_))
453 ));
454 }
455
456 #[test]
457 fn init_is_deterministic_and_unit_norm() {
458 let a = Feast::new(config(8, 5, false)).unwrap();
459 let b = Feast::new(config(8, 5, false)).unwrap();
460 assert_eq!(a.weights(), b.weights());
461 assert_eq!(a.thresholds(), b.thresholds());
462 for feature in a.weights().chunks(5 * 5) {
464 let norm: f32 = feature.iter().map(|value| value * value).sum();
465 assert!((norm - 1.0).abs() < 1e-5, "‖w‖² = {norm}");
466 }
467 }
468
469 #[test]
470 fn fit_converges_to_a_low_miss_rate() {
471 let stream = sweeping_bar(16, 4);
474 let mut feast = Feast::new(config(8, 5, false)).unwrap();
475 let first = feast.fit(&stream, 1);
476 let mut last = first;
477 for _ in 0..14 {
478 last = feast.fit(&stream, 1);
479 }
480 assert!(first > 0.1, "cold network should miss heavily, got {first}");
484 assert!(last < first, "miss rate should fall with training: {first} → {last}");
485 assert!(last < 0.05, "converged miss rate too high: {last}");
486 }
487
488 #[test]
489 fn transform_labels_are_aligned_and_in_range() {
490 let stream = sweeping_bar(16, 20);
491 let mut feast = Feast::new(config(6, 5, false)).unwrap();
492 feast.fit(&stream, 4);
493
494 let ids = feast.transform(&stream);
495 assert_eq!(ids.len(), stream.len());
496 assert!(ids.iter().all(|&id| (-1..feast.n_features_total() as i32).contains(&id)));
497 let labelled = ids.iter().filter(|&&id| id >= 0).count();
499 assert!(labelled > stream.len() / 2);
500
501 let histogram = feast.histogram(&stream);
503 assert_eq!(histogram.len(), feast.n_features_total());
504 assert_eq!(histogram.iter().sum::<u32>() as usize, labelled);
505 }
506
507 #[test]
508 fn per_polarity_splits_populations() {
509 let mut rows = Vec::new();
510 let mut t = 0_u64;
511 for x in 0..16_u64 {
512 for y in 0..16_u64 {
513 rows.push([x, y, t, (x % 2)]); }
515 t += 1_000;
516 }
517 let flat: Vec<u64> = rows.concat();
518 let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
519 let stream = EventStream::from_array2(events, 16, 16, 0.001);
520
521 let mut feast = Feast::new(config(5, 5, true)).unwrap();
522 feast.fit(&stream, 4);
523 assert_eq!(feast.n_features_total(), 10);
524 assert!(feast.transform(&stream).iter().any(|&id| id >= 5));
526 }
527
528 #[test]
529 fn from_state_round_trips_the_model() {
530 let stream = sweeping_bar(16, 10);
531 let mut feast = Feast::new(config(6, 5, false)).unwrap();
532 feast.fit(&stream, 3);
533
534 let rebuilt = Feast::from_state(
535 *feast.config(),
536 feast.weights().to_vec(),
537 feast.thresholds().to_vec(),
538 )
539 .unwrap();
540 assert_eq!(feast.transform(&stream), rebuilt.transform(&stream));
541
542 assert_eq!(
543 Feast::from_state(*feast.config(), vec![0.0; 3], vec![0.0; 2]).unwrap_err(),
544 FeastError::StateShapeMismatch
545 );
546 }
547}