use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ParetoPoint {
pub epsilon: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<f64>,
pub utility_score: f64,
pub benford_mad: f64,
pub correlation_score: f64,
}
pub struct ParetoFrontier;
impl ParetoFrontier {
pub fn explore(epsilons: &[f64], utility_fn: impl Fn(f64) -> ParetoPoint) -> Vec<ParetoPoint> {
if epsilons.is_empty() {
return Vec::new();
}
let mut points: Vec<ParetoPoint> = epsilons.iter().map(|&eps| utility_fn(eps)).collect();
points.sort_by(|a, b| a.epsilon.total_cmp(&b.epsilon));
Self::filter_dominated(&points)
}
pub fn recommend(points: &[ParetoPoint], target_utility: f64) -> Option<f64> {
let mut candidates: Vec<&ParetoPoint> = points
.iter()
.filter(|p| p.utility_score >= target_utility)
.collect();
candidates.sort_by(|a, b| a.epsilon.total_cmp(&b.epsilon));
candidates.first().map(|p| p.epsilon)
}
pub fn is_dominated(a: &ParetoPoint, b: &ParetoPoint) -> bool {
let b_leq_eps = b.epsilon <= a.epsilon;
let b_geq_util = b.utility_score >= a.utility_score;
let strictly_better = b.epsilon < a.epsilon || b.utility_score > a.utility_score;
b_leq_eps && b_geq_util && strictly_better
}
fn filter_dominated(points: &[ParetoPoint]) -> Vec<ParetoPoint> {
let mut result = Vec::new();
for (i, point) in points.iter().enumerate() {
let mut dominated = false;
for (j, other) in points.iter().enumerate() {
if i != j && Self::is_dominated(point, other) {
dominated = true;
break;
}
}
if !dominated {
result.push(point.clone());
}
}
result.sort_by(|a, b| a.epsilon.total_cmp(&b.epsilon));
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_monotonic_utility_all_pareto_optimal() {
let points = ParetoFrontier::explore(&[0.1, 0.5, 1.0, 2.0, 5.0], |eps| ParetoPoint {
epsilon: eps,
delta: None,
utility_score: 1.0 - (-eps).exp(),
benford_mad: 0.05 / eps,
correlation_score: (1.0 - (-0.5 * eps).exp()).min(1.0),
});
assert_eq!(points.len(), 5);
for i in 1..points.len() {
assert!(
points[i].epsilon > points[i - 1].epsilon,
"Points should be sorted by epsilon"
);
assert!(
points[i].utility_score > points[i - 1].utility_score,
"Utility should increase with epsilon for this function"
);
}
}
#[test]
fn test_recommend_returns_correct_epsilon() {
let points = ParetoFrontier::explore(&[0.1, 0.5, 1.0, 2.0, 5.0], |eps| ParetoPoint {
epsilon: eps,
delta: None,
utility_score: 1.0 - (-eps).exp(),
benford_mad: 0.05 / eps,
correlation_score: 0.9,
});
let rec = ParetoFrontier::recommend(&points, 0.8);
assert!(rec.is_some());
let eps = rec.expect("recommendation exists");
assert!(
(eps - 2.0).abs() < 1e-10,
"Should recommend eps=2.0 for utility target 0.8, got {}",
eps
);
let rec_impossible = ParetoFrontier::recommend(&points, 0.9999);
assert!(
rec_impossible.is_none(),
"Should return None for unachievable target"
);
}
#[test]
fn test_dominated_points_filtered() {
let points = ParetoFrontier::explore(&[0.5, 1.0, 1.5, 2.0], |eps| {
let utility = if (eps - 1.5).abs() < 1e-10 {
0.3 } else {
1.0 - (-eps).exp()
};
ParetoPoint {
epsilon: eps,
delta: None,
utility_score: utility,
benford_mad: 0.05,
correlation_score: 0.9,
}
});
let epsilons: Vec<f64> = points.iter().map(|p| p.epsilon).collect();
assert!(
!epsilons.contains(&1.5),
"eps=1.5 should be filtered as dominated, got frontier: {:?}",
epsilons
);
assert!(epsilons.contains(&0.5));
assert!(epsilons.contains(&1.0));
assert!(epsilons.contains(&2.0));
}
#[test]
fn test_is_dominated() {
let a = ParetoPoint {
epsilon: 2.0,
delta: None,
utility_score: 0.5,
benford_mad: 0.02,
correlation_score: 0.9,
};
let b = ParetoPoint {
epsilon: 1.0,
delta: None,
utility_score: 0.6,
benford_mad: 0.03,
correlation_score: 0.8,
};
assert!(ParetoFrontier::is_dominated(&a, &b));
assert!(!ParetoFrontier::is_dominated(&b, &a));
}
#[test]
fn test_same_point_not_dominated() {
let a = ParetoPoint {
epsilon: 1.0,
delta: None,
utility_score: 0.5,
benford_mad: 0.02,
correlation_score: 0.9,
};
assert!(!ParetoFrontier::is_dominated(&a, &a));
}
#[test]
fn test_empty_input_handled() {
let points = ParetoFrontier::explore(&[], |eps| ParetoPoint {
epsilon: eps,
delta: None,
utility_score: 0.0,
benford_mad: 0.0,
correlation_score: 0.0,
});
assert!(points.is_empty());
let rec = ParetoFrontier::recommend(&[], 0.5);
assert!(rec.is_none());
}
#[test]
fn test_single_point_frontier() {
let points = ParetoFrontier::explore(&[1.0], |eps| ParetoPoint {
epsilon: eps,
delta: Some(1e-5),
utility_score: 0.7,
benford_mad: 0.02,
correlation_score: 0.85,
});
assert_eq!(points.len(), 1);
assert!((points[0].epsilon - 1.0).abs() < 1e-10);
assert!((points[0].utility_score - 0.7).abs() < 1e-10);
}
#[test]
fn test_recommend_with_exact_match() {
let points = vec![
ParetoPoint {
epsilon: 0.5,
delta: None,
utility_score: 0.3,
benford_mad: 0.05,
correlation_score: 0.5,
},
ParetoPoint {
epsilon: 1.0,
delta: None,
utility_score: 0.6,
benford_mad: 0.03,
correlation_score: 0.7,
},
ParetoPoint {
epsilon: 2.0,
delta: None,
utility_score: 0.9,
benford_mad: 0.01,
correlation_score: 0.95,
},
];
let rec = ParetoFrontier::recommend(&points, 0.6);
assert_eq!(rec, Some(1.0));
}
}