use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StimulusResponse {
pub habituation: f32,
pub sensitization: f32,
pub exposure_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HabituationParams {
pub habituation_rate: f32,
pub habituation_max: f32,
pub habituation_decay: f32,
pub sensitization_rate: f32,
pub sensitization_decay: f32,
}
impl Default for HabituationParams {
fn default() -> Self {
Self {
habituation_rate: 0.15,
habituation_max: 0.9,
habituation_decay: 0.003,
sensitization_rate: 0.3,
sensitization_decay: 0.05,
}
}
}
impl StimulusResponse {
#[must_use]
pub fn new() -> Self {
Self {
habituation: 0.0,
sensitization: 0.0,
exposure_count: 0,
}
}
pub fn expose(&mut self, intensity: f32, params: &HabituationParams) {
let intensity = intensity.clamp(0.0, 1.0);
self.exposure_count = self.exposure_count.saturating_add(1);
let intensity_dampening = 1.0 - intensity * 0.8;
let h_room = params.habituation_max - self.habituation;
self.habituation += params.habituation_rate * h_room * intensity_dampening;
self.habituation = self.habituation.clamp(0.0, params.habituation_max);
self.sensitization += params.sensitization_rate * intensity;
}
pub fn decay(&mut self, dt: f32, params: &HabituationParams) {
self.habituation *= (-params.habituation_decay * dt).exp();
self.sensitization *= (-params.sensitization_decay * dt).exp();
if self.habituation < 1e-6 {
self.habituation = 0.0;
}
if self.sensitization < 1e-6 {
self.sensitization = 0.0;
}
}
#[must_use]
#[inline]
pub fn response_multiplier(&self) -> f32 {
(1.0 - self.habituation) * (1.0 + self.sensitization)
}
#[must_use]
#[inline]
pub fn is_habituated(&self) -> bool {
self.response_multiplier() < 0.2
}
#[must_use]
#[inline]
pub fn is_sensitized(&self) -> bool {
self.response_multiplier() > 1.5
}
}
impl Default for StimulusResponse {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn dishabituation_boost(intensity: f32, params: &HabituationParams) -> f32 {
let intensity = intensity.clamp(0.0, 1.0);
params.sensitization_rate * intensity * 1.5
}
#[must_use]
pub fn generalized_habituation(source_habituation: f32, similarity: f32) -> f32 {
let similarity = similarity.clamp(0.0, 1.0);
source_habituation * similarity * similarity }
#[cfg(test)]
mod tests {
use super::*;
fn default_params() -> HabituationParams {
HabituationParams::default()
}
#[test]
fn fresh_stimulus_baseline_response() {
let sr = StimulusResponse::new();
assert!((sr.response_multiplier() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn repeated_low_intensity_habituates() {
let params = default_params();
let mut sr = StimulusResponse::new();
for _ in 0..20 {
sr.expose(0.1, ¶ms);
}
assert!(
sr.response_multiplier() < 0.5,
"expected habituation, got multiplier {}",
sr.response_multiplier()
);
}
#[test]
fn high_intensity_sensitizes() {
let params = default_params();
let mut sr = StimulusResponse::new();
for _ in 0..5 {
sr.expose(1.0, ¶ms);
}
assert!(
sr.is_sensitized(),
"high intensity should sensitize, got multiplier {}",
sr.response_multiplier()
);
}
#[test]
fn habituation_caps_at_max() {
let params = default_params();
let mut sr = StimulusResponse::new();
for _ in 0..100 {
sr.expose(0.0, ¶ms); }
assert!(sr.habituation <= params.habituation_max + f32::EPSILON);
}
#[test]
fn spontaneous_recovery() {
let params = default_params();
let mut sr = StimulusResponse::new();
for _ in 0..20 {
sr.expose(0.0, ¶ms);
}
let habituated = sr.habituation;
sr.decay(1000.0, ¶ms);
assert!(
sr.habituation < habituated * 0.1,
"habituation should decay during rest"
);
}
#[test]
fn sensitization_decays_faster_than_habituation() {
let params = default_params();
let mut sr = StimulusResponse::new();
sr.expose(0.5, ¶ms);
let h0 = sr.habituation;
let s0 = sr.sensitization;
sr.decay(10.0, ¶ms);
let h_remaining = sr.habituation / h0;
let s_remaining = sr.sensitization / s0;
assert!(
s_remaining < h_remaining,
"sensitization should decay faster: s_rem={s_remaining}, h_rem={h_remaining}"
);
}
#[test]
fn dishabituation_boosts_response() {
let params = default_params();
let mut sr = StimulusResponse::new();
for _ in 0..30 {
sr.expose(0.0, ¶ms);
}
let before = sr.response_multiplier();
let boost = dishabituation_boost(0.9, ¶ms);
sr.sensitization += boost;
let after = sr.response_multiplier();
assert!(
after > before,
"dishabituation should restore response: before={before}, after={after}"
);
}
#[test]
fn generalization_gradient() {
let source_h = 0.8;
let identical = generalized_habituation(source_h, 1.0);
let similar = generalized_habituation(source_h, 0.7);
let different = generalized_habituation(source_h, 0.2);
assert!((identical - source_h).abs() < f32::EPSILON);
assert!(similar < identical);
assert!(different < similar);
assert!(different < 0.1); }
#[test]
fn exposure_count_tracks() {
let params = default_params();
let mut sr = StimulusResponse::new();
sr.expose(0.5, ¶ms);
sr.expose(0.5, ¶ms);
sr.expose(0.5, ¶ms);
assert_eq!(sr.exposure_count, 3);
}
#[test]
fn serde_roundtrip_stimulus_response() {
let mut sr = StimulusResponse::new();
sr.habituation = 0.5;
sr.sensitization = 0.3;
sr.exposure_count = 7;
let json = serde_json::to_string(&sr).unwrap();
let sr2: StimulusResponse = serde_json::from_str(&json).unwrap();
assert!((sr.habituation - sr2.habituation).abs() < f32::EPSILON);
assert!((sr.sensitization - sr2.sensitization).abs() < f32::EPSILON);
assert_eq!(sr.exposure_count, sr2.exposure_count);
}
#[test]
fn serde_roundtrip_habituation_params() {
let p = HabituationParams::default();
let json = serde_json::to_string(&p).unwrap();
let p2: HabituationParams = serde_json::from_str(&json).unwrap();
assert!((p.habituation_rate - p2.habituation_rate).abs() < f32::EPSILON);
assert!((p.habituation_max - p2.habituation_max).abs() < f32::EPSILON);
}
}