use serde::{Deserialize, Serialize};
use crate::error::{
Result, SanghaError, validate_finite, validate_non_negative, validate_positive,
};
#[derive(Debug, Clone, Copy, Serialize)]
#[non_exhaustive]
pub struct EmotionalState {
pub valence: f64,
pub susceptibility: f64,
}
impl<'de> Deserialize<'de> for EmotionalState {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> core::result::Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
valence: f64,
susceptibility: f64,
}
let raw = Raw::deserialize(deserializer)?;
EmotionalState::new(raw.valence, raw.susceptibility).map_err(serde::de::Error::custom)
}
}
impl EmotionalState {
pub fn new(valence: f64, susceptibility: f64) -> Result<Self> {
validate_finite(valence, "valence")?;
validate_finite(susceptibility, "susceptibility")?;
if !(0.0..=1.0).contains(&valence) {
return Err(SanghaError::ComputationError(format!(
"valence must be in [0, 1], got {valence}"
)));
}
if !(0.0..=1.0).contains(&susceptibility) {
return Err(SanghaError::ComputationError(format!(
"susceptibility must be in [0, 1], got {susceptibility}"
)));
}
Ok(Self {
valence,
susceptibility,
})
}
pub fn validate(&self) -> Result<()> {
validate_finite(self.valence, "valence")?;
validate_finite(self.susceptibility, "susceptibility")?;
if !(0.0..=1.0).contains(&self.valence) {
return Err(SanghaError::ComputationError(format!(
"valence must be in [0, 1], got {}",
self.valence
)));
}
if !(0.0..=1.0).contains(&self.susceptibility) {
return Err(SanghaError::ComputationError(format!(
"susceptibility must be in [0, 1], got {}",
self.susceptibility
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize)]
#[non_exhaustive]
pub struct SisState {
pub s: f64,
pub i: f64,
}
impl<'de> Deserialize<'de> for SisState {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> core::result::Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
s: f64,
i: f64,
}
let raw = Raw::deserialize(deserializer)?;
SisState::new(raw.s, raw.i).map_err(serde::de::Error::custom)
}
}
impl SisState {
pub fn new(s: f64, i: f64) -> Result<Self> {
validate_non_negative(s, "s")?;
validate_non_negative(i, "i")?;
let total = s + i;
if (total - 1.0).abs() > 1e-6 {
return Err(SanghaError::ComputationError(format!(
"s + i must equal 1.0, got {total}"
)));
}
Ok(Self { s, i })
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct HatfieldConfig {
pub mimicry_rate: f64,
pub feedback_strength: f64,
}
impl<'de> Deserialize<'de> for HatfieldConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> core::result::Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
mimicry_rate: f64,
feedback_strength: f64,
}
let raw = Raw::deserialize(deserializer)?;
HatfieldConfig::new(raw.mimicry_rate, raw.feedback_strength)
.map_err(serde::de::Error::custom)
}
}
impl HatfieldConfig {
pub fn new(mimicry_rate: f64, feedback_strength: f64) -> Result<Self> {
validate_non_negative(mimicry_rate, "mimicry_rate")?;
validate_non_negative(feedback_strength, "feedback_strength")?;
Ok(Self {
mimicry_rate,
feedback_strength,
})
}
pub fn validate(&self) -> Result<()> {
validate_non_negative(self.mimicry_rate, "mimicry_rate")?;
validate_non_negative(self.feedback_strength, "feedback_strength")
}
}
#[must_use = "returns the updated emotional states without side effects"]
pub fn hatfield_contagion_step(
states: &[EmotionalState],
adjacency: &[Vec<(usize, f64)>],
config: &HatfieldConfig,
dt: f64,
) -> Result<Vec<EmotionalState>> {
validate_positive(dt, "dt")?;
if states.len() != adjacency.len() {
return Err(SanghaError::ComputationError(format!(
"states length {} != adjacency length {}",
states.len(),
adjacency.len()
)));
}
let n = states.len();
let mut new_states = Vec::with_capacity(n);
for (i, state) in states.iter().enumerate() {
let mut influence_sum = 0.0;
for &(j, w) in &adjacency[i] {
if j >= n {
return Err(SanghaError::InvalidNetwork(format!(
"neighbor index {j} out of bounds for {n} agents"
)));
}
influence_sum += w * (states[j].valence - state.valence);
}
let mimicry = state.susceptibility * config.mimicry_rate * influence_sum;
let expressed = if adjacency[i].is_empty() {
state.valence
} else {
let total_w: f64 = adjacency[i].iter().map(|&(_, w)| w).sum();
if total_w > 0.0 {
adjacency[i]
.iter()
.map(|&(j, w)| w * states[j].valence)
.sum::<f64>()
/ total_w
} else {
state.valence
}
};
let feedback = config.feedback_strength * (expressed - state.valence);
let dv = mimicry + feedback;
let new_valence = (state.valence + dt * dv).clamp(0.0, 1.0);
new_states.push(EmotionalState {
valence: new_valence,
susceptibility: state.susceptibility,
});
}
Ok(new_states)
}
#[inline]
#[must_use = "returns the new SIS state without side effects"]
pub fn sis_step(s: f64, i: f64, beta: f64, gamma: f64, dt: f64) -> Result<SisState> {
validate_non_negative(s, "s")?;
validate_non_negative(i, "i")?;
validate_positive(beta, "beta")?;
validate_positive(gamma, "gamma")?;
validate_positive(dt, "dt")?;
let ds = -beta * s * i + gamma * i;
let di = beta * s * i - gamma * i;
let new_s = (s + ds * dt).max(0.0);
let new_i = (i + di * dt).max(0.0);
let total = new_s + new_i;
if total > 0.0 {
Ok(SisState {
s: new_s / total,
i: new_i / total,
})
} else {
Ok(SisState { s: 1.0, i: 0.0 })
}
}
#[inline]
#[must_use = "returns the endemic equilibrium without side effects"]
pub fn sis_endemic_equilibrium(beta: f64, gamma: f64) -> Result<f64> {
validate_positive(beta, "beta")?;
validate_positive(gamma, "gamma")?;
if beta > gamma {
Ok(1.0 - gamma / beta)
} else {
Ok(0.0)
}
}
#[must_use = "returns the updated moods without side effects"]
pub fn mood_propagation(
moods: &[f64],
adjacency: &[Vec<(usize, f64)>],
decay: f64,
dt: f64,
) -> Result<Vec<f64>> {
validate_non_negative(decay, "decay")?;
validate_positive(dt, "dt")?;
if moods.len() != adjacency.len() {
return Err(SanghaError::ComputationError(format!(
"moods length {} != adjacency length {}",
moods.len(),
adjacency.len()
)));
}
let n = moods.len();
let mut new_moods = Vec::with_capacity(n);
for (i, &mood) in moods.iter().enumerate() {
validate_finite(mood, &format!("moods[{i}]"))?;
let mut diffusion = 0.0;
for &(j, w) in &adjacency[i] {
if j >= n {
return Err(SanghaError::InvalidNetwork(format!(
"neighbor index {j} out of bounds for {n} agents"
)));
}
diffusion += w * (moods[j] - mood);
}
let decay_term = decay * (mood - 0.5);
let new_mood = mood + dt * diffusion - dt * decay_term;
new_moods.push(new_mood.clamp(0.0, 1.0));
}
Ok(new_moods)
}
#[must_use = "returns the epidemic threshold without side effects"]
pub fn contagion_threshold(adjacency: &[Vec<(usize, f64)>]) -> Result<f64> {
let n = adjacency.len();
if n == 0 {
return Err(SanghaError::ComputationError("empty network".into()));
}
let mut v = vec![1.0 / (n as f64).sqrt(); n];
let mut lambda = 0.0;
let max_iter = 1000;
let tol = 1e-10;
for _ in 0..max_iter {
let mut w = vec![0.0; n];
for (i, neighbors) in adjacency.iter().enumerate() {
for &(j, weight) in neighbors {
if j >= n {
return Err(SanghaError::InvalidNetwork(format!(
"neighbor index {j} out of bounds for {n} agents"
)));
}
w[i] += weight * v[j];
}
}
let norm: f64 = w.iter().map(|&x| x * x).sum::<f64>().sqrt();
if norm < f64::EPSILON {
return Err(SanghaError::SimulationFailed(
"adjacency matrix has zero spectral radius".into(),
));
}
let new_lambda = norm;
for x in &mut w {
*x /= norm;
}
if (new_lambda - lambda).abs() < tol {
return Ok(1.0 / new_lambda);
}
lambda = new_lambda;
v = w;
}
Err(SanghaError::SimulationFailed(
"power iteration did not converge after 1000 iterations".into(),
))
}
#[inline]
#[must_use = "returns convergence status without side effects"]
pub fn emotional_convergence(states: &[EmotionalState], epsilon: f64) -> Result<bool> {
validate_positive(epsilon, "epsilon")?;
if states.len() <= 1 {
return Ok(true);
}
let mean: f64 = states.iter().map(|s| s.valence).sum::<f64>() / states.len() as f64;
Ok(states.iter().all(|s| (s.valence - mean).abs() < epsilon))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emotional_state_valid() {
let s = EmotionalState::new(0.5, 0.8).unwrap();
assert!((s.valence - 0.5).abs() < 1e-10);
assert!((s.susceptibility - 0.8).abs() < 1e-10);
}
#[test]
fn test_emotional_state_out_of_range() {
assert!(EmotionalState::new(1.5, 0.5).is_err());
assert!(EmotionalState::new(0.5, -0.1).is_err());
}
#[test]
fn test_emotional_state_nan() {
assert!(EmotionalState::new(f64::NAN, 0.5).is_err());
}
#[test]
fn test_hatfield_convergence() {
let states = vec![
EmotionalState::new(0.8, 1.0).unwrap(),
EmotionalState::new(0.2, 1.0).unwrap(),
];
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)]];
let config = HatfieldConfig::new(0.5, 0.0).unwrap();
let new = hatfield_contagion_step(&states, &adj, &config, 0.1).unwrap();
assert!(new[0].valence < 0.8);
assert!(new[1].valence > 0.2);
}
#[test]
fn test_hatfield_isolated_no_change() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
let adj: Vec<Vec<(usize, f64)>> = vec![vec![]];
let config = HatfieldConfig::new(0.5, 0.0).unwrap();
let new = hatfield_contagion_step(&states, &adj, &config, 0.1).unwrap();
assert!((new[0].valence - 0.5).abs() < 1e-10);
}
#[test]
fn test_hatfield_zero_susceptibility() {
let states = vec![
EmotionalState::new(0.8, 0.0).unwrap(), EmotionalState::new(0.2, 1.0).unwrap(),
];
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)]];
let config = HatfieldConfig::new(1.0, 0.0).unwrap();
let new = hatfield_contagion_step(&states, &adj, &config, 0.1).unwrap();
assert!((new[0].valence - 0.8).abs() < 1e-10);
assert!(new[1].valence > 0.2);
}
#[test]
fn test_hatfield_uniform_no_change() {
let states = vec![
EmotionalState::new(0.5, 1.0).unwrap(),
EmotionalState::new(0.5, 1.0).unwrap(),
EmotionalState::new(0.5, 1.0).unwrap(),
];
let adj = vec![vec![(1, 1.0), (2, 1.0)], vec![(0, 1.0)], vec![(0, 1.0)]];
let config = HatfieldConfig::new(0.5, 0.0).unwrap();
let new = hatfield_contagion_step(&states, &adj, &config, 0.1).unwrap();
for s in &new {
assert!((s.valence - 0.5).abs() < 1e-10);
}
}
#[test]
fn test_hatfield_length_mismatch() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
let adj: Vec<Vec<(usize, f64)>> = vec![vec![], vec![]];
let config = HatfieldConfig::new(0.5, 0.0).unwrap();
assert!(hatfield_contagion_step(&states, &adj, &config, 0.1).is_err());
}
#[test]
fn test_hatfield_out_of_bounds_neighbor() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
let adj = vec![vec![(5, 1.0)]]; let config = HatfieldConfig::new(0.5, 0.0).unwrap();
assert!(hatfield_contagion_step(&states, &adj, &config, 0.1).is_err());
}
#[test]
fn test_sis_conservation() {
let state = sis_step(0.9, 0.1, 0.5, 0.2, 0.01).unwrap();
let total = state.s + state.i;
assert!((total - 1.0).abs() < 0.01);
}
#[test]
fn test_sis_declining_epidemic() {
let state = sis_step(0.9, 0.1, 0.1, 0.5, 0.1).unwrap();
assert!(state.i < 0.1);
}
#[test]
fn test_sis_endemic_above_threshold() {
let eq = sis_endemic_equilibrium(0.5, 0.2).unwrap();
assert!((eq - 0.6).abs() < 1e-10); }
#[test]
fn test_sis_endemic_below_threshold() {
let eq = sis_endemic_equilibrium(0.2, 0.5).unwrap();
assert!((eq - 0.0).abs() < 1e-10);
}
#[test]
fn test_mood_propagation_converges() {
let moods = vec![0.8, 0.2];
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)]];
let new = mood_propagation(&moods, &adj, 0.0, 0.1).unwrap();
assert!(new[0] < 0.8);
assert!(new[1] > 0.2);
}
#[test]
fn test_mood_propagation_decay_to_neutral() {
let moods = vec![0.9];
let adj: Vec<Vec<(usize, f64)>> = vec![vec![]];
let new = mood_propagation(&moods, &adj, 1.0, 0.1).unwrap();
assert!(new[0] < 0.9); }
#[test]
fn test_mood_propagation_uniform() {
let moods = vec![0.5, 0.5, 0.5];
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0), (2, 1.0)], vec![(1, 1.0)]];
let new = mood_propagation(&moods, &adj, 0.0, 0.1).unwrap();
for &m in &new {
assert!((m - 0.5).abs() < 1e-10);
}
}
#[test]
fn test_contagion_threshold_complete_graph() {
let adj = vec![
vec![(1, 1.0), (2, 1.0), (3, 1.0)],
vec![(0, 1.0), (2, 1.0), (3, 1.0)],
vec![(0, 1.0), (1, 1.0), (3, 1.0)],
vec![(0, 1.0), (1, 1.0), (2, 1.0)],
];
let threshold = contagion_threshold(&adj).unwrap();
assert!((threshold - 1.0 / 3.0).abs() < 1e-6);
}
#[test]
fn test_contagion_threshold_empty_error() {
let adj: Vec<Vec<(usize, f64)>> = vec![];
assert!(contagion_threshold(&adj).is_err());
}
#[test]
fn test_emotional_convergence_true() {
let states = vec![
EmotionalState::new(0.5, 1.0).unwrap(),
EmotionalState::new(0.5, 0.5).unwrap(),
];
assert!(emotional_convergence(&states, 0.01).unwrap());
}
#[test]
fn test_emotional_convergence_false() {
let states = vec![
EmotionalState::new(0.1, 1.0).unwrap(),
EmotionalState::new(0.9, 1.0).unwrap(),
];
assert!(!emotional_convergence(&states, 0.01).unwrap());
}
#[test]
fn test_emotional_convergence_single() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
assert!(emotional_convergence(&states, 0.01).unwrap());
}
#[test]
fn test_emotional_convergence_invalid_epsilon() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
assert!(emotional_convergence(&states, 0.0).is_err());
}
#[test]
fn test_emotional_state_serde_roundtrip() {
let s = EmotionalState::new(0.7, 0.3).unwrap();
let json = serde_json::to_string(&s).unwrap();
let back: EmotionalState = serde_json::from_str(&json).unwrap();
assert!((s.valence - back.valence).abs() < 1e-10);
}
#[test]
fn test_sis_state_serde_roundtrip() {
let s = SisState::new(0.9, 0.1).unwrap();
let json = serde_json::to_string(&s).unwrap();
let back: SisState = serde_json::from_str(&json).unwrap();
assert!((s.s - back.s).abs() < 1e-10);
}
#[test]
fn test_hatfield_config_serde_roundtrip() {
let c = HatfieldConfig::new(0.5, 0.3).unwrap();
let json = serde_json::to_string(&c).unwrap();
let back: HatfieldConfig = serde_json::from_str(&json).unwrap();
assert!((c.mimicry_rate - back.mimicry_rate).abs() < 1e-10);
}
#[test]
fn test_sis_step_clamp_negative() {
let state = sis_step(0.01, 0.99, 0.5, 10.0, 1.0).unwrap();
assert!(state.s >= 0.0);
assert!(state.i >= 0.0);
assert!((state.s + state.i - 1.0).abs() < 1e-10);
}
#[test]
fn test_sis_state_invalid() {
assert!(SisState::new(0.5, 0.6).is_err()); assert!(SisState::new(-0.1, 1.1).is_err()); assert!(SisState::new(f64::NAN, 0.5).is_err());
}
#[test]
fn test_contagion_threshold_disconnected() {
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)], vec![]];
let threshold = contagion_threshold(&adj).unwrap();
assert!(threshold > 0.0);
}
#[test]
fn test_mood_propagation_oob_error() {
let moods = vec![0.5];
let adj = vec![vec![(5, 1.0)]]; assert!(mood_propagation(&moods, &adj, 0.0, 0.1).is_err());
}
#[test]
fn test_hatfield_dt_zero_error() {
let states = vec![EmotionalState::new(0.5, 1.0).unwrap()];
let adj: Vec<Vec<(usize, f64)>> = vec![vec![]];
let config = HatfieldConfig::new(0.5, 0.0).unwrap();
assert!(hatfield_contagion_step(&states, &adj, &config, 0.0).is_err());
}
#[test]
fn test_mood_propagation_single_no_decay() {
let moods = vec![0.7];
let adj: Vec<Vec<(usize, f64)>> = vec![vec![]];
let new = mood_propagation(&moods, &adj, 0.0, 0.1).unwrap();
assert!((new[0] - 0.7).abs() < 1e-10);
}
#[test]
fn test_contagion_threshold_oob_error() {
let adj = vec![vec![(5, 1.0)]]; assert!(contagion_threshold(&adj).is_err());
}
#[test]
fn test_hatfield_feedback_strength() {
let states = vec![
EmotionalState::new(0.2, 1.0).unwrap(),
EmotionalState::new(0.8, 1.0).unwrap(),
];
let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)]];
let no_feedback = HatfieldConfig::new(0.5, 0.0).unwrap();
let with_feedback = HatfieldConfig::new(0.5, 0.5).unwrap();
let new_no = hatfield_contagion_step(&states, &adj, &no_feedback, 0.1).unwrap();
let new_yes = hatfield_contagion_step(&states, &adj, &with_feedback, 0.1).unwrap();
assert!(new_yes[0].valence > new_no[0].valence);
}
#[test]
fn test_emotional_state_deserialize_rejects_invalid() {
let json = r#"{"valence":1.5,"susceptibility":0.5}"#;
let result: core::result::Result<EmotionalState, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_sis_state_deserialize_rejects_invalid() {
let json = r#"{"s":0.5,"i":0.8}"#;
let result: core::result::Result<SisState, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_hatfield_config_deserialize_rejects_invalid() {
let json = r#"{"mimicry_rate":-0.5,"feedback_strength":0.1}"#;
let result: core::result::Result<HatfieldConfig, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}