#[derive(Debug, Clone)]
pub struct BackwardOutcome {
pub intercept: f64,
pub coefficients: Vec<f64>,
pub objective_value: f64,
}
#[derive(Debug, Clone)]
pub enum RiskMeasure {
Expectation,
CVaR {
alpha: f64,
lambda: f64,
},
}
impl From<cobre_core::StageRiskConfig> for RiskMeasure {
fn from(config: cobre_core::StageRiskConfig) -> Self {
match config {
cobre_core::StageRiskConfig::Expectation => Self::Expectation,
cobre_core::StageRiskConfig::CVaR { alpha, lambda } => Self::CVaR { alpha, lambda },
}
}
}
impl RiskMeasure {
#[must_use]
pub fn aggregate_cut(
&self,
outcomes: &[BackwardOutcome],
probabilities: &[f64],
) -> (f64, Vec<f64>) {
debug_assert_eq!(
outcomes.len(),
probabilities.len(),
"aggregate_cut: outcomes and probabilities must have the same length"
);
debug_assert!(
!outcomes.is_empty(),
"aggregate_cut: at least one outcome required"
);
match self {
RiskMeasure::Expectation => aggregate_weighted(outcomes, probabilities),
RiskMeasure::CVaR { alpha, lambda } => {
let mu = compute_cvar_weights(outcomes, probabilities, *alpha, *lambda);
aggregate_weighted(outcomes, &mu)
}
}
}
#[must_use]
pub fn evaluate_risk(&self, costs: &[f64], probabilities: &[f64]) -> f64 {
debug_assert_eq!(
costs.len(),
probabilities.len(),
"evaluate_risk: costs and probabilities must have the same length"
);
debug_assert!(
!costs.is_empty(),
"evaluate_risk: at least one cost required"
);
match self {
RiskMeasure::Expectation => {
costs.iter().zip(probabilities).map(|(c, p)| c * p).sum()
}
RiskMeasure::CVaR { alpha, lambda } => {
let mu = compute_cvar_weights_from_costs(costs, probabilities, *alpha, *lambda);
costs.iter().zip(mu.iter()).map(|(c, w)| c * w).sum()
}
}
}
}
fn compute_cvar_weights(
outcomes: &[BackwardOutcome],
probabilities: &[f64],
alpha: f64,
lambda: f64,
) -> Vec<f64> {
let n = outcomes.len();
let upper_bounds: Vec<f64> = probabilities
.iter()
.map(|&p| (1.0 - lambda) * p + lambda * p / alpha)
.collect();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&i, &j| {
outcomes[j]
.objective_value
.partial_cmp(&outcomes[i].objective_value)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut mu = vec![0.0_f64; n];
let mut remaining = 1.0_f64;
for &idx in &order {
if remaining <= 0.0 {
break;
}
let alloc = upper_bounds[idx].min(remaining);
mu[idx] = alloc;
remaining -= alloc;
}
mu
}
fn compute_cvar_weights_from_costs(
costs: &[f64],
probabilities: &[f64],
alpha: f64,
lambda: f64,
) -> Vec<f64> {
let n = costs.len();
let upper_bounds: Vec<f64> = probabilities
.iter()
.map(|&p| (1.0 - lambda) * p + lambda * p / alpha)
.collect();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&i, &j| {
costs[j]
.partial_cmp(&costs[i])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut mu = vec![0.0_f64; n];
let mut remaining = 1.0_f64;
for &idx in &order {
if remaining <= 0.0 {
break;
}
let alloc = upper_bounds[idx].min(remaining);
mu[idx] = alloc;
remaining -= alloc;
}
mu
}
fn aggregate_weighted(outcomes: &[BackwardOutcome], weights: &[f64]) -> (f64, Vec<f64>) {
let state_dim = outcomes.first().map_or(0, |o| o.coefficients.len());
let mut agg_intercept = 0.0_f64;
let mut agg_coefficients = vec![0.0_f64; state_dim];
for (outcome, &w) in outcomes.iter().zip(weights) {
agg_intercept += w * outcome.intercept;
for (agg, &coeff) in agg_coefficients.iter_mut().zip(&outcome.coefficients) {
*agg += w * coeff;
}
}
(agg_intercept, agg_coefficients)
}
#[cfg(test)]
#[allow(clippy::cast_precision_loss)] mod tests {
use super::{BackwardOutcome, RiskMeasure};
fn outcome(intercept: f64, obj: f64) -> BackwardOutcome {
BackwardOutcome {
intercept,
coefficients: vec![],
objective_value: obj,
}
}
fn outcome_with_coeffs(intercept: f64, obj: f64, coeffs: Vec<f64>) -> BackwardOutcome {
BackwardOutcome {
intercept,
coefficients: coeffs,
objective_value: obj,
}
}
fn uniform(n: usize) -> Vec<f64> {
let p = 1.0_f64 / (n as f64);
vec![p; n]
}
#[test]
fn expectation_aggregate_cut_equal_probs_mean_intercept() {
let outcomes = vec![
outcome(10.0, 10.0),
outcome(20.0, 20.0),
outcome(30.0, 30.0),
];
let probs = uniform(3);
let (intercept, _) = RiskMeasure::Expectation.aggregate_cut(&outcomes, &probs);
assert!(
(intercept - 20.0).abs() < 1e-10,
"expected 20.0, got {intercept}"
);
}
#[test]
fn expectation_aggregate_cut_nonuniform_probs() {
let outcomes = vec![
outcome(10.0, 10.0),
outcome(20.0, 20.0),
outcome(30.0, 30.0),
];
let probs = vec![0.5, 0.3, 0.2];
let (intercept, _) = RiskMeasure::Expectation.aggregate_cut(&outcomes, &probs);
let expected = 0.5 * 10.0 + 0.3 * 20.0 + 0.2 * 30.0; assert!(
(intercept - expected).abs() < 1e-10,
"expected {expected}, got {intercept}"
);
}
#[test]
fn expectation_aggregate_cut_coefficients_weighted() {
let outcomes = vec![
outcome_with_coeffs(0.0, 0.0, vec![1.0, 2.0]),
outcome_with_coeffs(0.0, 0.0, vec![3.0, 4.0]),
];
let probs = vec![0.5, 0.5];
let (_, coeffs) = RiskMeasure::Expectation.aggregate_cut(&outcomes, &probs);
assert_eq!(coeffs.len(), 2);
assert!((coeffs[0] - 2.0).abs() < 1e-10); assert!((coeffs[1] - 3.0).abs() < 1e-10); }
#[test]
fn expectation_evaluate_risk_equal_probs() {
let costs = vec![10.0, 20.0, 30.0];
let probs = uniform(3);
let result = RiskMeasure::Expectation.evaluate_risk(&costs, &probs);
assert!((result - 20.0).abs() < 1e-10, "expected 20.0, got {result}");
}
#[test]
fn expectation_evaluate_risk_nonuniform_probs() {
let costs = vec![100.0, 200.0];
let probs = vec![0.7, 0.3];
let result = RiskMeasure::Expectation.evaluate_risk(&costs, &probs);
let expected = 0.7 * 100.0 + 0.3 * 200.0; assert!(
(result - expected).abs() < 1e-10,
"expected {expected}, got {result}"
);
}
#[test]
fn cvar_evaluate_risk_pure_cvar_alpha_half() {
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 1.0,
};
let costs = vec![10.0, 20.0, 30.0, 40.0];
let probs = vec![0.25; 4];
let result = rm.evaluate_risk(&costs, &probs);
assert!((result - 35.0).abs() < 1e-10, "expected 35.0, got {result}");
}
#[test]
fn cvar_evaluate_risk_alpha_one_equals_expectation() {
let rm_cvar = RiskMeasure::CVaR {
alpha: 1.0,
lambda: 1.0,
};
let costs = vec![10.0, 20.0, 30.0, 40.0];
let probs = vec![0.25; 4];
let result_cvar = rm_cvar.evaluate_risk(&costs, &probs);
let result_exp = RiskMeasure::Expectation.evaluate_risk(&costs, &probs);
assert!(
(result_cvar - result_exp).abs() < 1e-10,
"CVaR with alpha=1 should equal Expectation: {result_cvar} vs {result_exp}"
);
}
#[test]
fn cvar_evaluate_risk_lambda_zero_equals_expectation() {
let rm_cvar = RiskMeasure::CVaR {
alpha: 0.2,
lambda: 0.0,
};
let costs = vec![5.0, 15.0, 25.0, 35.0];
let probs = vec![0.25; 4];
let result_cvar = rm_cvar.evaluate_risk(&costs, &probs);
let result_exp = RiskMeasure::Expectation.evaluate_risk(&costs, &probs);
assert!(
(result_cvar - result_exp).abs() < 1e-10,
"CVaR with lambda=0 should equal Expectation: {result_cvar} vs {result_exp}"
);
}
#[test]
fn cvar_evaluate_risk_convex_combination() {
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 0.5,
};
let costs = vec![0.0, 100.0];
let probs = vec![0.5, 0.5];
let result = rm.evaluate_risk(&costs, &probs);
assert!((result - 75.0).abs() < 1e-10);
}
#[test]
fn cvar_aggregate_cut_pure_cvar_selects_worst() {
let outcomes = vec![
outcome(10.0, 10.0), outcome(20.0, 20.0),
outcome(30.0, 30.0),
outcome(40.0, 40.0), ];
let probs = vec![0.25; 4];
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 1.0,
};
let (intercept, _) = rm.aggregate_cut(&outcomes, &probs);
assert!((intercept - 35.0).abs() < 1e-10);
}
#[test]
fn cvar_aggregate_cut_with_coefficients() {
let outcomes = vec![
outcome_with_coeffs(10.0, 10.0, vec![1.0, 0.0]), outcome_with_coeffs(20.0, 20.0, vec![0.0, 1.0]), ];
let probs = vec![0.5, 0.5];
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 1.0,
};
let (intercept, coeffs) = rm.aggregate_cut(&outcomes, &probs);
assert!((intercept - 20.0).abs() < 1e-10);
assert_eq!(coeffs.len(), 2);
assert!((coeffs[0] - 0.0).abs() < 1e-10);
assert!((coeffs[1] - 1.0).abs() < 1e-10);
}
#[test]
fn cvar_aggregate_cut_alpha_one_equals_expectation() {
let outcomes = vec![
outcome(10.0, 10.0),
outcome(20.0, 20.0),
outcome(30.0, 30.0),
];
let probs = uniform(3);
let rm_exp = RiskMeasure::Expectation;
let rm_cvar = RiskMeasure::CVaR {
alpha: 1.0,
lambda: 1.0,
};
let (int_exp, _) = rm_exp.aggregate_cut(&outcomes, &probs);
let (int_cvar, _) = rm_cvar.aggregate_cut(&outcomes, &probs);
assert!(
(int_exp - int_cvar).abs() < 1e-10,
"alpha=1 CVaR should equal Expectation: {int_exp} vs {int_cvar}"
);
}
#[test]
fn cvar_aggregate_cut_lambda_zero_equals_expectation() {
let outcomes = vec![
outcome(10.0, 10.0),
outcome(20.0, 20.0),
outcome(30.0, 30.0),
];
let probs = uniform(3);
let rm_exp = RiskMeasure::Expectation;
let rm_cvar = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 0.0,
};
let (int_exp, _) = rm_exp.aggregate_cut(&outcomes, &probs);
let (int_cvar, _) = rm_cvar.aggregate_cut(&outcomes, &probs);
assert!(
(int_exp - int_cvar).abs() < 1e-10,
"lambda=0 CVaR should equal Expectation: {int_exp} vs {int_cvar}"
);
}
#[test]
fn cvar_aggregate_cut_weights_sum_to_one() {
let outcomes = [
outcome(10.0, 15.0),
outcome(20.0, 5.0),
outcome(30.0, 25.0),
outcome(40.0, 35.0),
];
let probs = vec![0.3, 0.2, 0.3, 0.2];
let rm = RiskMeasure::CVaR {
alpha: 0.3,
lambda: 0.8,
};
let unit_outcomes: Vec<_> = (0..4)
.map(|i| super::BackwardOutcome {
intercept: 1.0,
coefficients: vec![1.0],
objective_value: outcomes[i].objective_value,
})
.collect();
let (intercept, coeffs) = rm.aggregate_cut(&unit_outcomes, &probs);
assert!(
(intercept - 1.0).abs() < 1e-10,
"weight sum must be 1.0, got intercept={intercept}"
);
assert!(
(coeffs[0] - 1.0).abs() < 1e-10,
"weight sum must be 1.0 (coeff check), got {}",
coeffs[0]
);
}
#[test]
fn risk_measure_debug_and_clone() {
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 0.8,
};
let cloned = rm.clone();
let debug_str = format!("{rm:?}");
assert!(debug_str.contains("CVaR"));
let _ = cloned;
}
#[test]
fn backward_outcome_debug_and_clone() {
let o = BackwardOutcome {
intercept: 1.0,
coefficients: vec![2.0, 3.0],
objective_value: 5.0,
};
let cloned = o.clone();
let debug_str = format!("{o:?}");
assert!(debug_str.contains("BackwardOutcome"));
assert!((cloned.intercept - o.intercept).abs() < f64::EPSILON);
}
#[test]
fn test_from_stage_risk_config_expectation() {
let config = cobre_core::StageRiskConfig::Expectation;
let rm = RiskMeasure::from(config);
assert!(matches!(rm, RiskMeasure::Expectation));
}
#[test]
fn test_from_stage_risk_config_cvar() {
let config = cobre_core::StageRiskConfig::CVaR {
alpha: 0.95,
lambda: 0.5,
};
let rm = RiskMeasure::from(config);
assert!(matches!(
rm,
RiskMeasure::CVaR {
alpha: 0.95,
lambda: 0.5
}
));
}
}