diff --git a/src/reputation/historical_window.rs b/src/reputation/historical_window.rs new file mode 100644 index 0000000..6076078 --- /dev/null +++ b/src/reputation/historical_window.rs @@ -0,0 +1,122 @@ + +pub const WINDOW_SIZE: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CircularWindow { + buffer: Vec, + write_ptr: usize, + entries_written: u64, +} + +impl CircularWindow { + pub fn new() -> Self { + CircularWindow { + buffer: vec![0; WINDOW_SIZE], + write_ptr: 0, + entries_written: 0, + } + } + + pub fn push_score(&mut self, score: u64) { + self.buffer[self.write_ptr] = score; + self.write_ptr = (self.write_ptr + 1) % WINDOW_SIZE; + self.entries_written += 1; + } + + pub fn effective_entries(&self) -> Vec { + let effective_len = std::cmp::min(self.entries_written, WINDOW_SIZE as u64) as usize; + if effective_len == 0 { + return vec![]; + } + + if self.entries_written <= WINDOW_SIZE as u64 { + self.buffer[0..effective_len].to_vec() + } else { + let mut entries = Vec::with_capacity(WINDOW_SIZE); + entries.extend_from_slice(&self.buffer[self.write_ptr..]); + entries.extend_from_slice(&self.buffer[..self.write_ptr]); + entries + } + } + + pub fn entry_count(&self) -> usize { + std::cmp::min(self.entries_written, WINDOW_SIZE as u64) as usize + } +} + +impl Default for CircularWindow { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reputation::compute_weighted_average; + + #[test] + fn test_new_window_is_empty() { + let window = CircularWindow::new(); + assert_eq!(window.entry_count(), 0); + assert!(window.effective_entries().is_empty()); + } + + #[test] + fn test_push_few_entries() { + let mut window = CircularWindow::new(); + for i in 1..=100 { + window.push_score(i); + } + assert_eq!(window.entry_count(), 100); + let entries = window.effective_entries(); + assert_eq!(entries.len(), 100); + for (idx, &entry) in entries.iter().enumerate() { + assert_eq!(entry, (idx + 1) as u64); + } + } + + #[test] + fn test_push_full_window() { + let mut window = CircularWindow::new(); + for i in 1..=WINDOW_SIZE { + window.push_score(i as u64); + } + assert_eq!(window.entry_count(), WINDOW_SIZE); + let entries = window.effective_entries(); + assert_eq!(entries.len(), WINDOW_SIZE); + for (idx, &entry) in entries.iter().enumerate() { + assert_eq!(entry, (idx + 1) as u64); + } + } + + #[test] + fn test_push_over_window() { + let mut window = CircularWindow::new(); + for i in 1..=WINDOW_SIZE + 100 { + window.push_score(i as u64); + } + assert_eq!(window.entry_count(), WINDOW_SIZE); + let entries = window.effective_entries(); + assert_eq!(entries.len(), WINDOW_SIZE); + for (idx, &entry) in entries.iter().enumerate() { + assert_eq!(entry, (idx + 101) as u64); + } + } + + #[test] + fn test_compute_weighted_average_empty() { + let window = CircularWindow::new(); + assert_eq!(compute_weighted_average(&window), 0); + } + + #[test] + fn test_compute_weighted_average_few_entries() { + let mut window = CircularWindow::new(); + for i in 1..=100 { + window.push_score(i); + } + // Sum 1..100 is 5050, average is 5050 / 100 = 50 + assert_eq!(compute_weighted_average(&window), 50); + } +} diff --git a/src/reputation/mod.rs b/src/reputation/mod.rs index fc98c04..6a8b686 100644 --- a/src/reputation/mod.rs +++ b/src/reputation/mod.rs @@ -1,11 +1,13 @@ pub mod fixed_point; +pub mod historical_window; pub mod score; pub mod score_engine; pub mod types; +pub use historical_window::{CircularWindow, WINDOW_SIZE}; pub use score_engine::{ - apply_decay, decay_for_epochs, ema_update, reputation_weight, update_reputation, - MAX_DECAY_READOUT_ERROR, + apply_decay, compute_weighted_average, decay_for_epochs, ema_update, reputation_weight, + update_reputation, MAX_DECAY_READOUT_ERROR, }; pub use types::{ DecayFactor, EmaWeights, ReputationScore, TimeSinceLastUpdate, WindowSize, DEFAULT_DECAY_Q16, diff --git a/src/reputation/score_engine.rs b/src/reputation/score_engine.rs index 1ad03ca..159e38a 100644 --- a/src/reputation/score_engine.rs +++ b/src/reputation/score_engine.rs @@ -1,5 +1,18 @@ +use super::historical_window::CircularWindow; use super::types::{DecayFactor, EmaWeights, ReputationScore, TimeSinceLastUpdate, MAX_REPUTATION}; +/// Computes the weighted average of scores in a circular window, using only +/// the actual number of valid entries written (not the full buffer size). +pub fn compute_weighted_average(window: &CircularWindow) -> u64 { + let entries = window.effective_entries(); + let count = entries.len(); + if count == 0 { + return 0; + } + let sum: u64 = entries.iter().sum(); + sum / count as u64 +} + /// Updates a reputation score using a bounded exponential moving average. /// /// For gaps greater than or equal to the configured window, the historical