use serde::{Deserialize, Serialize};
use crate::error::{BodhError, Result, validate_finite};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Affect {
pub valence: f64,
pub arousal: f64,
}
impl Affect {
pub fn new(valence: f64, arousal: f64) -> Result<Self> {
validate_finite(valence, "valence")?;
validate_finite(arousal, "arousal")?;
Ok(Self {
valence: valence.clamp(-1.0, 1.0),
arousal: arousal.clamp(-1.0, 1.0),
})
}
#[inline]
#[must_use]
pub fn distance(self, other: Self) -> f64 {
let dv = self.valence - other.valence;
let da = self.arousal - other.arousal;
(dv * dv + da * da).sqrt()
}
#[inline]
#[must_use]
pub fn intensity(self) -> f64 {
(self.valence * self.valence + self.arousal * self.arousal).sqrt()
}
#[inline]
#[must_use]
pub fn angle(self) -> f64 {
self.arousal.atan2(self.valence)
}
#[must_use = "returns the blended affect without side effects"]
pub fn blend(self, other: Self, t: f64) -> Result<Self> {
validate_finite(t, "t")?;
let t = t.clamp(0.0, 1.0);
Ok(Self {
valence: self.valence + t * (other.valence - self.valence),
arousal: self.arousal + t * (other.arousal - self.arousal),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BasicEmotion {
Happiness,
Sadness,
Anger,
Fear,
Disgust,
Surprise,
}
impl BasicEmotion {
#[must_use]
pub fn canonical_affect(self) -> Affect {
match self {
Self::Happiness => Affect {
valence: 0.8,
arousal: 0.3,
},
Self::Sadness => Affect {
valence: -0.7,
arousal: -0.5,
},
Self::Anger => Affect {
valence: -0.6,
arousal: 0.7,
},
Self::Fear => Affect {
valence: -0.7,
arousal: 0.8,
},
Self::Disgust => Affect {
valence: -0.8,
arousal: 0.2,
},
Self::Surprise => Affect {
valence: 0.1,
arousal: 0.9,
},
}
}
}
#[must_use = "returns the classified emotion without side effects"]
pub fn classify_emotion(affect: Affect) -> BasicEmotion {
use BasicEmotion::*;
let emotions = [Happiness, Sadness, Anger, Fear, Disgust, Surprise];
let mut best = Happiness;
let mut best_dist = f64::MAX;
for e in emotions {
let d = affect.distance(e.canonical_affect());
if d < best_dist {
best_dist = d;
best = e;
}
}
best
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AppraisalDimensions {
pub novelty: f64,
pub pleasantness: f64,
pub goal_conduciveness: f64,
pub coping_potential: f64,
pub norm_compatibility: f64,
}
#[must_use = "returns the appraised affect without side effects"]
pub fn appraise(dims: &AppraisalDimensions) -> Result<Affect> {
validate_finite(dims.novelty, "novelty")?;
validate_finite(dims.pleasantness, "pleasantness")?;
validate_finite(dims.goal_conduciveness, "goal_conduciveness")?;
validate_finite(dims.coping_potential, "coping_potential")?;
validate_finite(dims.norm_compatibility, "norm_compatibility")?;
let valence = (0.5 * dims.pleasantness + 0.5 * dims.goal_conduciveness).clamp(-1.0, 1.0);
let arousal = (0.6 * dims.novelty + 0.4 * (1.0 - dims.coping_potential)).clamp(-1.0, 1.0);
Ok(Affect { valence, arousal })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RegulationStrategy {
SituationSelection,
SituationModification,
AttentionalDeployment,
CognitiveChange,
ResponseModulation,
}
impl RegulationStrategy {
#[inline]
#[must_use]
pub fn effectiveness(self) -> f64 {
match self {
Self::SituationSelection => 0.6,
Self::SituationModification => 0.5,
Self::AttentionalDeployment => 0.45,
Self::CognitiveChange => 0.85,
Self::ResponseModulation => 0.3,
}
}
}
#[must_use = "returns the regulated affect without side effects"]
pub fn regulate(affect: Affect, strategy: RegulationStrategy, effort: f64) -> Result<Affect> {
validate_finite(effort, "effort")?;
let effort = effort.clamp(0.0, 1.0);
let reduction = strategy.effectiveness() * effort;
Ok(Affect {
valence: affect.valence * (1.0 - reduction),
arousal: affect.arousal * (1.0 - reduction),
})
}
#[inline]
#[must_use = "returns the performance level without side effects"]
pub fn yerkes_dodson(arousal: f64, optimal: f64, spread: f64) -> Result<f64> {
validate_finite(arousal, "arousal")?;
validate_finite(optimal, "optimal")?;
crate::error::validate_positive(spread, "spread")?;
let deviation = arousal - optimal;
let perf = 1.0 - (deviation * deviation) / (spread * spread);
Ok(perf.max(0.0))
}
#[must_use = "returns the biased retrieval probability without side effects"]
pub fn mood_congruent_bias(
base_probability: f64,
current_affect: Affect,
memory_affect: Affect,
congruence_weight: f64,
) -> Result<f64> {
validate_finite(base_probability, "base_probability")?;
validate_finite(congruence_weight, "congruence_weight")?;
if congruence_weight < 0.0 {
return Err(BodhError::InvalidParameter(
"congruence_weight must be non-negative".into(),
));
}
let max_dist = 2.0_f64.sqrt() * 2.0;
let dist = current_affect.distance(memory_affect);
let similarity = 1.0 - (dist / max_dist).min(1.0);
let biased = base_probability * (1.0 + congruence_weight * similarity);
Ok(biased.clamp(0.0, 1.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_affect_new_clamps() {
let a = Affect::new(2.0, -3.0).unwrap();
assert!((a.valence - 1.0).abs() < 1e-10);
assert!((a.arousal - (-1.0)).abs() < 1e-10);
}
#[test]
fn test_affect_invalid() {
assert!(Affect::new(f64::NAN, 0.0).is_err());
}
#[test]
fn test_affect_distance_same() {
let a = Affect::new(0.5, 0.5).unwrap();
assert!(a.distance(a) < 1e-10);
}
#[test]
fn test_affect_distance_opposites() {
let a = Affect::new(1.0, 1.0).unwrap();
let b = Affect::new(-1.0, -1.0).unwrap();
let d = a.distance(b);
assert!((d - 2.0 * 2.0_f64.sqrt()).abs() < 1e-10);
}
#[test]
fn test_affect_intensity() {
let a = Affect::new(0.0, 0.0).unwrap();
assert!(a.intensity() < 1e-10);
let b = Affect::new(1.0, 0.0).unwrap();
assert!((b.intensity() - 1.0).abs() < 1e-10);
}
#[test]
fn test_affect_blend() {
let a = Affect::new(0.0, 0.0).unwrap();
let b = Affect::new(1.0, 1.0).unwrap();
let mid = a.blend(b, 0.5).unwrap();
assert!((mid.valence - 0.5).abs() < 1e-10);
assert!((mid.arousal - 0.5).abs() < 1e-10);
}
#[test]
fn test_affect_blend_endpoints() {
let a = Affect::new(-0.5, 0.3).unwrap();
let b = Affect::new(0.8, -0.2).unwrap();
let at0 = a.blend(b, 0.0).unwrap();
let at1 = a.blend(b, 1.0).unwrap();
assert!((at0.valence - a.valence).abs() < 1e-10);
assert!((at1.valence - b.valence).abs() < 1e-10);
}
#[test]
fn test_affect_serde_roundtrip() {
let a = Affect::new(0.5, -0.3).unwrap();
let json = serde_json::to_string(&a).unwrap();
let back: Affect = serde_json::from_str(&json).unwrap();
assert!((a.valence - back.valence).abs() < 1e-10);
assert!((a.arousal - back.arousal).abs() < 1e-10);
}
#[test]
fn test_classify_happiness() {
let affect = Affect {
valence: 0.9,
arousal: 0.2,
};
assert_eq!(classify_emotion(affect), BasicEmotion::Happiness);
}
#[test]
fn test_classify_sadness() {
let affect = Affect {
valence: -0.6,
arousal: -0.6,
};
assert_eq!(classify_emotion(affect), BasicEmotion::Sadness);
}
#[test]
fn test_classify_anger() {
let affect = Affect {
valence: -0.5,
arousal: 0.6,
};
assert_eq!(classify_emotion(affect), BasicEmotion::Anger);
}
#[test]
fn test_basic_emotion_serde_roundtrip() {
let e = BasicEmotion::Fear;
let json = serde_json::to_string(&e).unwrap();
let back: BasicEmotion = serde_json::from_str(&json).unwrap();
assert_eq!(e, back);
}
#[test]
fn test_appraise_pleasant_novel() {
let dims = AppraisalDimensions {
novelty: 0.8,
pleasantness: 0.9,
goal_conduciveness: 0.7,
coping_potential: 0.3,
norm_compatibility: 0.5,
};
let a = appraise(&dims).unwrap();
assert!(a.valence > 0.5); assert!(a.arousal > 0.3); }
#[test]
fn test_appraise_neutral() {
let dims = AppraisalDimensions {
novelty: 0.0,
pleasantness: 0.0,
goal_conduciveness: 0.0,
coping_potential: 1.0,
norm_compatibility: 0.0,
};
let a = appraise(&dims).unwrap();
assert!(a.valence.abs() < 1e-10);
assert!(a.arousal.abs() < 1e-10);
}
#[test]
fn test_appraisal_serde_roundtrip() {
let dims = AppraisalDimensions {
novelty: 0.5,
pleasantness: -0.3,
goal_conduciveness: 0.2,
coping_potential: 0.8,
norm_compatibility: 0.1,
};
let json = serde_json::to_string(&dims).unwrap();
let back: AppraisalDimensions = serde_json::from_str(&json).unwrap();
assert!((dims.novelty - back.novelty).abs() < 1e-10);
}
#[test]
fn test_regulate_reduces_intensity() {
let affect = Affect::new(-0.8, 0.7).unwrap();
let regulated = regulate(affect, RegulationStrategy::CognitiveChange, 1.0).unwrap();
assert!(regulated.intensity() < affect.intensity());
}
#[test]
fn test_regulate_zero_effort() {
let affect = Affect::new(0.5, 0.5).unwrap();
let regulated = regulate(affect, RegulationStrategy::CognitiveChange, 0.0).unwrap();
assert!((regulated.valence - affect.valence).abs() < 1e-10);
}
#[test]
fn test_regulation_strategy_ordering() {
let reappraisal = RegulationStrategy::CognitiveChange.effectiveness();
let suppression = RegulationStrategy::ResponseModulation.effectiveness();
assert!(reappraisal > suppression);
}
#[test]
fn test_regulation_strategy_serde_roundtrip() {
let s = RegulationStrategy::AttentionalDeployment;
let json = serde_json::to_string(&s).unwrap();
let back: RegulationStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn test_yerkes_dodson_optimal() {
let p = yerkes_dodson(0.5, 0.5, 1.0).unwrap();
assert!((p - 1.0).abs() < 1e-10);
}
#[test]
fn test_yerkes_dodson_inverted_u() {
let at_opt = yerkes_dodson(0.5, 0.5, 0.5).unwrap();
let below = yerkes_dodson(0.0, 0.5, 0.5).unwrap();
let above = yerkes_dodson(1.0, 0.5, 0.5).unwrap();
assert!(at_opt > below);
assert!(at_opt > above);
}
#[test]
fn test_yerkes_dodson_floors_at_zero() {
let p = yerkes_dodson(10.0, 0.5, 0.5).unwrap();
assert!((p - 0.0).abs() < 1e-10);
}
#[test]
fn test_mood_congruent_bias_same_affect() {
let affect = Affect::new(0.5, 0.5).unwrap();
let biased = mood_congruent_bias(0.5, affect, affect, 0.5).unwrap();
assert!(biased > 0.5); }
#[test]
fn test_mood_congruent_bias_opposite_affect() {
let current = Affect::new(0.8, 0.8).unwrap();
let memory = Affect::new(-0.8, -0.8).unwrap();
let same = mood_congruent_bias(0.5, current, current, 0.5).unwrap();
let opposite = mood_congruent_bias(0.5, current, memory, 0.5).unwrap();
assert!(same > opposite);
}
#[test]
fn test_mood_congruent_bias_zero_weight() {
let a = Affect::new(0.5, 0.5).unwrap();
let b = Affect::new(-0.5, -0.5).unwrap();
let biased = mood_congruent_bias(0.3, a, b, 0.0).unwrap();
assert!((biased - 0.3).abs() < 1e-10);
}
}