pub fn recency(hours_since: f32) -> f32 {
0.995_f32.powf(hours_since.max(0.0))
}
pub fn familiarity(peak: f32, elapsed_days: f32, tau_days: f32, decay_exp: f32) -> f32 {
peak * (1.0 + elapsed_days.max(0.0) / tau_days).powf(-decay_exp)
}
pub fn exposed_peak(familiarity_now: f32, rate_per_day: f32, dt_days: f32) -> f32 {
(familiarity_now + rate_per_day * dt_days * (1.0 - familiarity_now)).clamp(0.0, 1.0)
}
pub fn grudge(memories: impl IntoIterator<Item = (f32, f32)>) -> f32 {
memories
.into_iter()
.map(|(valence, r)| (-valence).max(0.0) * r)
.fold(0.0_f32, f32::max)
.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recency_halves_in_about_138_hours() {
assert!((recency(0.0) - 1.0).abs() < 1e-6);
assert!((recency(138.0) - 0.5).abs() < 0.01);
assert_eq!(recency(-5.0), 1.0);
}
#[test]
fn familiarity_follows_the_power_law() {
assert!((familiarity(0.8, 3.0, 1.0, 0.5) - 0.4).abs() < 1e-6);
assert_eq!(familiarity(0.8, 0.0, 1.0, 0.5), 0.8);
}
#[test]
fn exposure_approaches_one_and_never_exceeds_it() {
let f = exposed_peak(0.0, 2.0, 1.0);
assert!((f - 1.0).abs() < 1e-6);
assert!(exposed_peak(0.9, 2.0, 1.0) <= 1.0);
}
#[test]
fn grudge_counts_only_negative_memories_and_clamps() {
assert_eq!(grudge([(0.5, 1.0), (0.3, 1.0)]), 0.0);
assert!((grudge([(-0.3, 1.0), (-0.3, 1.0)]) - 0.3).abs() < 1e-6);
assert!((grudge([(-0.5, 1.0), (-0.2, 1.0)]) - 0.5).abs() < 1e-6);
assert!((grudge([(-0.3, 1.0)]) - 0.3).abs() < 1e-6);
assert_eq!(grudge([(-0.9, 1.0), (-0.9, 1.0)]), 0.9);
assert_eq!(grudge([(-1.4, 1.0)]), 1.0);
}
}