use super::wsm::Direction;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RiskAttitude {
Averse,
Neutral,
Seeking,
}
impl RiskAttitude {
pub fn rho(self) -> f64 {
match self {
RiskAttitude::Averse => 2.0,
RiskAttitude::Neutral => 0.0,
RiskAttitude::Seeking => -2.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Attribute {
pub min: f64,
pub max: f64,
pub direction: Direction,
pub rho: f64,
}
impl Attribute {
pub fn benefit(min: f64, max: f64, attitude: RiskAttitude) -> Self {
Self {
min,
max,
direction: Direction::Benefit,
rho: attitude.rho(),
}
}
pub fn cost(min: f64, max: f64, attitude: RiskAttitude) -> Self {
Self {
min,
max,
direction: Direction::Cost,
rho: attitude.rho(),
}
}
pub fn utility(&self, x: f64) -> f64 {
let range = self.max - self.min;
let t = if range == 0.0 {
1.0 } else {
let raw = (x - self.min) / range;
let oriented = match self.direction {
Direction::Benefit => raw,
Direction::Cost => 1.0 - raw,
};
oriented.clamp(0.0, 1.0)
};
exp_utility(t, self.rho)
}
}
pub fn exp_utility(t: f64, rho: f64) -> f64 {
if rho.abs() < 1e-9 {
return t.clamp(0.0, 1.0);
}
let t = t.clamp(0.0, 1.0);
(1.0 - (-rho * t).exp()) / (1.0 - (-rho).exp())
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AdditiveUtility {
pub attributes: Vec<Attribute>,
pub weights: Vec<f64>,
}
impl AdditiveUtility {
pub fn new(attributes: Vec<Attribute>, weights: Vec<f64>) -> Result<Self, String> {
if attributes.is_empty() {
return Err("additive utility has no attributes".into());
}
if attributes.len() != weights.len() {
return Err(format!(
"{} attributes but {} weights",
attributes.len(),
weights.len()
));
}
let mut sum = 0.0;
for &w in &weights {
if !w.is_finite() || w < 0.0 {
return Err(format!("invalid scaling constant {w}"));
}
sum += w;
}
if sum <= 0.0 {
return Err("scaling constants sum to zero".into());
}
Ok(Self {
attributes,
weights,
})
}
pub fn evaluate(&self, x: &[f64]) -> Result<f64, String> {
if x.len() != self.attributes.len() {
return Err(format!(
"{} attribute values expected, got {}",
self.attributes.len(),
x.len()
));
}
let wsum: f64 = self.weights.iter().sum();
let u = self
.attributes
.iter()
.zip(self.weights.iter())
.zip(x.iter())
.map(|((attr, &w), &xi)| (w / wsum) * attr.utility(xi))
.sum();
Ok(u)
}
pub fn rank(&self, alternatives: &[Vec<f64>]) -> Result<Vec<usize>, String> {
let mut scored: Vec<(usize, f64)> = Vec::with_capacity(alternatives.len());
for (i, row) in alternatives.iter().enumerate() {
scored.push((i, self.evaluate(row)?));
}
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
Ok(scored.into_iter().map(|(i, _)| i).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn exp_utility_endpoints_and_known_midpoint() {
for rho in [-3.0, -1.0, 0.0, 1.0, 2.0, 5.0] {
assert!(approx(exp_utility(0.0, rho), 0.0, 1e-12));
assert!(approx(exp_utility(1.0, rho), 1.0, 1e-12));
}
assert!(approx(exp_utility(0.5, 2.0), 0.731_058_578_630_005, 1e-12));
assert!(approx(exp_utility(0.37, 0.0), 0.37, 1e-15));
}
#[test]
fn risk_attitude_curvature_signs() {
let mid = 0.5;
assert!(exp_utility(mid, RiskAttitude::Averse.rho()) > mid);
assert!(exp_utility(mid, RiskAttitude::Seeking.rho()) < mid);
assert!(approx(
exp_utility(mid, RiskAttitude::Neutral.rho()),
mid,
1e-15
));
}
#[test]
fn utility_is_monotone_increasing_in_preference() {
let a = Attribute::benefit(0.0, 100.0, RiskAttitude::Averse);
let mut last = -1.0;
for i in 0..=100 {
let u = a.utility(i as f64);
assert!(u >= last - 1e-15, "non-monotone at {i}: {u} < {last}");
last = u;
}
let c = Attribute::cost(0.0, 100.0, RiskAttitude::Neutral);
assert!(approx(c.utility(0.0), 1.0, 1e-12));
assert!(approx(c.utility(100.0), 0.0, 1e-12));
assert!(approx(c.utility(25.0), 0.75, 1e-12)); }
#[test]
fn additive_aggregation_weights_and_ranks() {
let au = AdditiveUtility::new(
vec![
Attribute::benefit(0.0, 1.0, RiskAttitude::Neutral),
Attribute::benefit(0.0, 1.0, RiskAttitude::Neutral),
],
vec![0.75, 0.25],
)
.unwrap();
assert!(approx(au.evaluate(&[1.0, 0.0]).unwrap(), 0.75, 1e-12));
assert!(approx(au.evaluate(&[0.0, 1.0]).unwrap(), 0.25, 1e-12));
assert!(approx(au.evaluate(&[0.5, 0.5]).unwrap(), 0.5, 1e-12));
let r = au
.rank(&[vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]])
.unwrap();
assert_eq!(r, vec![0, 2, 1]);
}
#[test]
fn risk_aversion_changes_the_winner() {
let alts = [vec![0.5, 0.5], vec![1.0, 0.0]];
let weights = vec![0.5, 0.5];
let averse = AdditiveUtility::new(
vec![
Attribute::benefit(0.0, 1.0, RiskAttitude::Averse),
Attribute::benefit(0.0, 1.0, RiskAttitude::Averse),
],
weights.clone(),
)
.unwrap();
assert_eq!(
averse.rank(&alts).unwrap()[0],
0,
"averse prefers the safe option"
);
let seeking = AdditiveUtility::new(
vec![
Attribute::benefit(0.0, 1.0, RiskAttitude::Seeking),
Attribute::benefit(0.0, 1.0, RiskAttitude::Seeking),
],
weights,
)
.unwrap();
assert_eq!(
seeking.rank(&alts).unwrap()[0],
1,
"seeking prefers the gamble"
);
}
#[test]
fn construction_validates() {
assert!(AdditiveUtility::new(vec![], vec![]).is_err());
assert!(AdditiveUtility::new(
vec![Attribute::benefit(0.0, 1.0, RiskAttitude::Neutral)],
vec![1.0, 2.0]
)
.is_err());
}
}