use serde::{Deserialize, Serialize};
use crate::error::{BodhError, Result, validate_finite, validate_positive};
#[must_use = "returns the conformity probability without side effects"]
pub fn asch_conformity(
group_size: usize,
unanimity: f64,
max_conformity: f64,
k: f64,
) -> Result<f64> {
validate_finite(unanimity, "unanimity")?;
validate_finite(max_conformity, "max_conformity")?;
validate_positive(k, "k")?;
if !(0.0..=1.0).contains(&unanimity) {
return Err(BodhError::InvalidParameter(
"unanimity must be in [0, 1]".into(),
));
}
if !(0.0..=1.0).contains(&max_conformity) {
return Err(BodhError::InvalidParameter(
"max_conformity must be in [0, 1]".into(),
));
}
let size_effect = 1.0 - (-k * group_size as f64).exp();
Ok(max_conformity * size_effect * unanimity)
}
#[inline]
#[must_use = "returns the social impact without side effects"]
pub fn social_impact(source_strength: f64, num_sources: usize, exponent: f64) -> Result<f64> {
validate_finite(source_strength, "source_strength")?;
validate_finite(exponent, "exponent")?;
if num_sources == 0 {
return Ok(0.0);
}
Ok(source_strength * (num_sources as f64).powf(exponent))
}
#[inline]
#[must_use = "returns the per-target impact without side effects"]
pub fn social_impact_diffusion(
total_impact: f64,
num_targets: usize,
exponent: f64,
) -> Result<f64> {
validate_finite(total_impact, "total_impact")?;
validate_finite(exponent, "exponent")?;
if num_targets == 0 {
return Err(BodhError::InvalidParameter(
"num_targets must be at least 1".into(),
));
}
Ok(total_impact / (num_targets as f64).powf(exponent))
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CovariationInfo {
pub consensus: f64,
pub distinctiveness: f64,
pub consistency: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AttributionType {
External,
Internal,
Circumstantial,
}
#[must_use = "returns the attribution type without side effects"]
pub fn kelley_attribution(info: &CovariationInfo) -> AttributionType {
if info.consistency < 0.5 {
return AttributionType::Circumstantial;
}
if info.consensus >= 0.5 && info.distinctiveness >= 0.5 {
AttributionType::External
} else {
AttributionType::Internal
}
}
#[must_use = "returns the biased covariation info without side effects"]
pub fn fundamental_attribution_error(info: &CovariationInfo, bias: f64) -> Result<CovariationInfo> {
validate_finite(bias, "bias")?;
if !(0.0..=1.0).contains(&bias) {
return Err(BodhError::InvalidParameter("bias must be in [0, 1]".into()));
}
Ok(CovariationInfo {
consensus: info.consensus * (1.0 - bias),
distinctiveness: info.distinctiveness * (1.0 - bias),
consistency: info.consistency,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ComparisonDirection {
Upward,
Lateral,
Downward,
}
#[inline]
#[must_use = "returns the self-evaluation shift without side effects"]
pub fn social_comparison_shift(
self_ability: f64,
other_ability: f64,
direction: ComparisonDirection,
) -> Result<f64> {
validate_finite(self_ability, "self_ability")?;
validate_finite(other_ability, "other_ability")?;
let raw_diff = other_ability - self_ability;
let weight = match direction {
ComparisonDirection::Upward => -0.7, ComparisonDirection::Lateral => -0.3, ComparisonDirection::Downward => -0.6, };
Ok(weight * raw_diff)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_asch_conformity_increases_with_group() {
let p3 = asch_conformity(3, 1.0, 0.37, 0.3).unwrap();
let p1 = asch_conformity(1, 1.0, 0.37, 0.3).unwrap();
assert!(p3 > p1);
}
#[test]
fn test_asch_conformity_saturates() {
let p5 = asch_conformity(5, 1.0, 0.37, 0.3).unwrap();
let p20 = asch_conformity(20, 1.0, 0.37, 0.3).unwrap();
assert!((p20 - p5).abs() < 0.1);
}
#[test]
fn test_asch_conformity_broken_unanimity() {
let unanimous = asch_conformity(5, 1.0, 0.37, 0.3).unwrap();
let broken = asch_conformity(5, 0.5, 0.37, 0.3).unwrap();
assert!(unanimous > broken);
}
#[test]
fn test_asch_conformity_zero_group() {
let p = asch_conformity(0, 1.0, 0.37, 0.3).unwrap();
assert!(p.abs() < 1e-10);
}
#[test]
fn test_asch_conformity_reference() {
let p = asch_conformity(4, 1.0, 0.37, 0.3).unwrap();
assert!(p > 0.2 && p < 0.37);
}
#[test]
fn test_social_impact_basic() {
let impact = social_impact(10.0, 4, 0.5).unwrap();
assert!((impact - 20.0).abs() < 1e-10);
}
#[test]
fn test_social_impact_zero_sources() {
let impact = social_impact(10.0, 0, 0.5).unwrap();
assert!(impact.abs() < 1e-10);
}
#[test]
fn test_social_impact_diminishing() {
let i1 = social_impact(1.0, 1, 0.5).unwrap();
let i3 = social_impact(1.0, 3, 0.5).unwrap();
let i8 = social_impact(1.0, 8, 0.5).unwrap();
let i10 = social_impact(1.0, 10, 0.5).unwrap();
let gain_1_to_3 = i3 - i1;
let gain_8_to_10 = i10 - i8;
assert!(gain_1_to_3 > gain_8_to_10);
}
#[test]
fn test_social_impact_diffusion() {
let total = social_impact(10.0, 4, 0.5).unwrap();
let per_1 = social_impact_diffusion(total, 1, 0.5).unwrap();
let per_4 = social_impact_diffusion(total, 4, 0.5).unwrap();
assert!(per_1 > per_4); }
#[test]
fn test_kelley_external() {
let info = CovariationInfo {
consensus: 0.9,
distinctiveness: 0.9,
consistency: 0.9,
};
assert_eq!(kelley_attribution(&info), AttributionType::External);
}
#[test]
fn test_kelley_internal() {
let info = CovariationInfo {
consensus: 0.1,
distinctiveness: 0.1,
consistency: 0.9,
};
assert_eq!(kelley_attribution(&info), AttributionType::Internal);
}
#[test]
fn test_kelley_circumstantial() {
let info = CovariationInfo {
consensus: 0.9,
distinctiveness: 0.9,
consistency: 0.1,
};
assert_eq!(kelley_attribution(&info), AttributionType::Circumstantial);
}
#[test]
fn test_fundamental_attribution_error_full_bias() {
let info = CovariationInfo {
consensus: 0.9,
distinctiveness: 0.9,
consistency: 0.9,
};
let biased = fundamental_attribution_error(&info, 1.0).unwrap();
assert_eq!(kelley_attribution(&biased), AttributionType::Internal);
}
#[test]
fn test_fundamental_attribution_error_no_bias() {
let info = CovariationInfo {
consensus: 0.9,
distinctiveness: 0.9,
consistency: 0.9,
};
let unbiased = fundamental_attribution_error(&info, 0.0).unwrap();
assert!((unbiased.consensus - info.consensus).abs() < 1e-10);
}
#[test]
fn test_upward_comparison_negative_shift() {
let shift = social_comparison_shift(5.0, 8.0, ComparisonDirection::Upward).unwrap();
assert!(shift < 0.0);
}
#[test]
fn test_downward_comparison_positive_shift() {
let shift = social_comparison_shift(5.0, 3.0, ComparisonDirection::Downward).unwrap();
assert!(shift > 0.0);
}
#[test]
fn test_comparison_equal_ability() {
let shift = social_comparison_shift(5.0, 5.0, ComparisonDirection::Lateral).unwrap();
assert!(shift.abs() < 1e-10);
}
#[test]
fn test_covariation_info_serde_roundtrip() {
let info = CovariationInfo {
consensus: 0.8,
distinctiveness: 0.3,
consistency: 0.9,
};
let json = serde_json::to_string(&info).unwrap();
let back: CovariationInfo = serde_json::from_str(&json).unwrap();
assert!((info.consensus - back.consensus).abs() < 1e-10);
}
#[test]
fn test_attribution_type_serde_roundtrip() {
let a = AttributionType::External;
let json = serde_json::to_string(&a).unwrap();
let back: AttributionType = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
}
#[test]
fn test_comparison_direction_serde_roundtrip() {
let d = ComparisonDirection::Upward;
let json = serde_json::to_string(&d).unwrap();
let back: ComparisonDirection = serde_json::from_str(&json).unwrap();
assert_eq!(d, back);
}
}