1use fugue::{addr, factor, Beta, Model, ModelExt, Trace};
38
39use super::likelihood::GenomeLikelihood;
40use crate::fitness::multi_objective::MultiObjectiveFitness;
41
42#[derive(Clone)]
45pub struct ParetoScalarization<M> {
46 pub objectives: M,
48 pub sharpness: f64,
52}
53
54impl<M> ParetoScalarization<M> {
55 pub fn new(objectives: M, sharpness: f64) -> Self {
57 Self {
58 objectives,
59 sharpness,
60 }
61 }
62}
63
64fn weight_model(k: usize) -> Model<Vec<f64>> {
69 fn stick(i: usize, k: usize, acc: Vec<f64>, remaining: f64) -> Model<Vec<f64>> {
70 if i == k - 1 {
71 let mut acc = acc;
72 acc.push(remaining);
73 return fugue::pure(acc);
74 }
75 let b = (k - 1 - i) as f64;
76 fugue::sample(
77 addr!("pareto", format!("v{i}")),
78 Beta::new(1.0, b).expect("valid stick-breaking Beta"),
79 )
80 .bind(move |v| {
81 let mut acc = acc;
82 let w = v * remaining;
83 acc.push(w);
84 stick(i + 1, k, acc, remaining - w)
85 })
86 }
87 stick(0, k, Vec::with_capacity(k), 1.0)
88}
89
90impl<G, M> GenomeLikelihood<G> for ParetoScalarization<M>
91where
92 G: 'static,
93 M: MultiObjectiveFitness<G> + Clone + Send + Sync + 'static,
94{
95 fn model(&self, genome: &G, beta: f64) -> Model<()> {
96 let objs = self.objectives.evaluate(genome);
97 let k = objs.len();
98 let sharpness = self.sharpness;
99 if k == 0 {
100 return fugue::pure(());
101 }
102 weight_model(k).bind(move |w| {
103 let scalarized: f64 = w.iter().zip(&objs).map(|(wi, fi)| wi * fi).sum();
104 if scalarized.is_finite() {
105 factor(-beta * sharpness * scalarized)
106 } else {
107 factor(f64::NEG_INFINITY)
108 }
109 })
110 }
111}
112
113#[derive(Clone)]
131pub struct ChebyshevScalarization<M> {
132 pub objectives: M,
134 pub sharpness: f64,
136 pub ideal: Vec<f64>,
138 pub weight: Option<Vec<f64>>,
143}
144
145impl<M> ChebyshevScalarization<M> {
146 pub fn new(objectives: M, sharpness: f64, ideal: Vec<f64>) -> Self {
148 Self {
149 objectives,
150 sharpness,
151 ideal,
152 weight: None,
153 }
154 }
155
156 pub fn with_weight(mut self, weight: Vec<f64>) -> Self {
160 self.weight = Some(weight);
161 self
162 }
163}
164
165impl<G, M> GenomeLikelihood<G> for ChebyshevScalarization<M>
166where
167 G: 'static,
168 M: MultiObjectiveFitness<G> + Clone + Send + Sync + 'static,
169{
170 fn model(&self, genome: &G, beta: f64) -> Model<()> {
171 let objs = self.objectives.evaluate(genome);
172 let k = objs.len();
173 let sharpness = self.sharpness;
174 let ideal = self.ideal.clone();
175 if k == 0 {
176 return fugue::pure(());
177 }
178 debug_assert_eq!(ideal.len(), k, "ideal point must match objective count");
179 let cheby_factor = move |w: &[f64], objs: &[f64], ideal: &[f64]| -> Model<()> {
180 let cheby = w
181 .iter()
182 .zip(objs.iter().zip(ideal))
183 .map(|(wi, (fi, zi))| wi * (fi - zi))
184 .fold(f64::NEG_INFINITY, f64::max);
185 if cheby.is_finite() {
186 factor(-beta * sharpness * cheby)
187 } else {
188 factor(f64::NEG_INFINITY)
189 }
190 };
191 match self.weight.clone() {
192 Some(w) => cheby_factor(&w, &objs, &ideal),
193 None => weight_model(k).bind(move |w| cheby_factor(&w, &objs, &ideal)),
194 }
195 }
196}
197
198pub fn particle_weights(trace: &Trace, num_objectives: usize) -> Option<Vec<f64>> {
202 let k = num_objectives;
203 let mut w = Vec::with_capacity(k);
204 let mut remaining = 1.0;
205 for i in 0..k - 1 {
206 let v = trace.get_f64(&addr!("pareto", format!("v{i}")))?;
207 let wi = v * remaining;
208 w.push(wi);
209 remaining -= wi;
210 }
211 w.push(remaining);
212 Some(w)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::genome::bounds::{Bounds, MultiBounds};
219 use crate::genome::real_vector::RealVector;
220 use crate::genome::traits::RealValuedGenome;
221 use crate::inference::model::EvolutionModel;
222 use crate::inference::prior::UniformBoxPrior;
223 use crate::inference::smc::{CrossoverConfig, EvoSmcConfig, EvolutionSMC};
224 use fugue::ResamplingMethod;
225 use rand::rngs::StdRng;
226 use rand::SeedableRng;
227
228 #[test]
235 fn test_pareto_posterior_traces_the_front() {
236 #[derive(Clone)]
237 struct BiObjective;
238 impl MultiObjectiveFitness<RealVector> for BiObjective {
239 fn num_objectives(&self) -> usize {
240 2
241 }
242 fn evaluate(&self, g: &RealVector) -> Vec<f64> {
243 let x = g.genes()[0];
244 vec![x * x, (x - 2.0) * (x - 2.0)]
245 }
246 }
247 let objectives = BiObjective;
248 let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-1.0, 3.0)]));
249 let model =
250 EvolutionModel::from_likelihood(prior, ParetoScalarization::new(objectives, 8.0));
251
252 let mut rng = StdRng::seed_from_u64(1618);
253 let result = EvolutionSMC::anneal(
255 &mut rng,
256 &model,
257 EvoSmcConfig {
258 num_particles: 500,
259 ess_threshold: 0.5,
260 resampling: ResamplingMethod::Systematic,
261 rejuvenation_steps: 5,
262 crossover: Some(CrossoverConfig::default()),
263 },
264 8.0,
265 8,
266 );
267
268 let model_fn = model.smc_model();
269 let decoded = fugue::decode_particles(&result.particles, &model_fn);
270
271 let mut on_set_mass = 0.0;
272 let mut low_end = 0.0;
273 let mut high_end = 0.0;
274 let mut w_err_sum = 0.0;
275 let mut w_err_n = 0.0;
276 for (p, (g, w)) in result.particles.iter().zip(&decoded) {
277 let x = g.genes()[0];
278 if (-0.25..=2.25).contains(&x) {
279 on_set_mass += w;
280 }
281 if x < 0.5 {
282 low_end += w;
283 }
284 if x > 1.5 {
285 high_end += w;
286 }
287 if let Some(wv) = particle_weights(&p.trace, 2) {
288 let x_star = 2.0 * (1.0 - wv[0]);
289 w_err_sum += w * (x - x_star).abs();
290 w_err_n += w;
291 }
292 }
293 assert!(
294 on_set_mass > 0.9,
295 "only {on_set_mass:.2} of posterior mass on the Pareto set [0,2]"
296 );
297 assert!(
298 low_end > 0.08 && high_end > 0.08,
299 "front ends not covered: low {low_end:.2}, high {high_end:.2}"
300 );
301 let mean_w_err = w_err_sum / w_err_n.max(1e-12);
302 assert!(
303 mean_w_err < 0.45,
304 "particles should sit near their weight's scalarized optimum; mean |x − 2(1−w)| = {mean_w_err:.3}"
305 );
306 }
307
308 #[test]
318 fn test_chebyshev_reaches_nonconvex_front_where_weighted_sum_cannot() {
319 #[derive(Clone)]
320 struct ConcaveFront;
321 impl MultiObjectiveFitness<RealVector> for ConcaveFront {
322 fn num_objectives(&self) -> usize {
323 2
324 }
325 fn evaluate(&self, g: &RealVector) -> Vec<f64> {
326 let x = g.genes()[0];
327 vec![x, 1.0 - x * x]
328 }
329 }
330
331 #[derive(Clone)]
334 struct FixedWeightSum {
335 w: f64,
336 sharpness: f64,
337 }
338 impl GenomeLikelihood<RealVector> for FixedWeightSum {
339 fn model(&self, g: &RealVector, beta: f64) -> Model<()> {
340 let objs = ConcaveFront.evaluate(g);
341 let s = self.w * objs[0] + (1.0 - self.w) * objs[1];
342 factor(-beta * self.sharpness * s)
343 }
344 }
345
346 let prior = || UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(0.0, 1.0)]));
347 let cfg = || EvoSmcConfig {
348 num_particles: 500,
349 ess_threshold: 0.5,
350 resampling: ResamplingMethod::Systematic,
351 rejuvenation_steps: 5,
352 crossover: Some(CrossoverConfig::default()),
353 };
354
355 let mut rng = StdRng::seed_from_u64(271828);
356
357 let ws_model = EvolutionModel::from_likelihood(
360 prior(),
361 FixedWeightSum {
362 w: 0.5,
363 sharpness: 25.0,
364 },
365 );
366 let ws = EvolutionSMC::anneal(&mut rng, &ws_model, cfg(), 8.0, 8);
367 let ws_fn = ws_model.smc_model();
368 let ws_interior: f64 = fugue::decode_particles(&ws.particles, &ws_fn)
369 .iter()
370 .filter(|(g, _)| (0.25..0.75).contains(&g.genes()[0]))
371 .map(|(_, w)| w)
372 .sum();
373 assert!(
374 ws_interior < 0.1,
375 "fixed-w weighted sum put {ws_interior:.3} mass in the interior — impossible for a concave front"
376 );
377
378 let x_star = (5.0f64.sqrt() - 1.0) / 2.0;
381 let ch_model = EvolutionModel::from_likelihood(
382 prior(),
383 ChebyshevScalarization::new(ConcaveFront, 25.0, vec![0.0, 0.0])
384 .with_weight(vec![0.5, 0.5]),
385 );
386 let ch = EvolutionSMC::anneal(&mut rng, &ch_model, cfg(), 8.0, 8);
387 let mean = ch.weighted_mean(0);
388 assert!(
389 (mean - x_star).abs() < 0.08,
390 "fixed-w Chebyshev posterior mean {mean:.3} should sit at the interior front point {x_star:.3}"
391 );
392 let ch_fn = ch_model.smc_model();
393 let ch_interior: f64 = fugue::decode_particles(&ch.particles, &ch_fn)
394 .iter()
395 .filter(|(g, _)| (0.25..0.75).contains(&g.genes()[0]))
396 .map(|(_, w)| w)
397 .sum();
398 assert!(
399 ch_interior > 0.8,
400 "fixed-w Chebyshev interior mass {ch_interior:.3} — must reach the non-convex front interior"
401 );
402
403 for (w, lo, hi) in [(0.15, 0.75, 1.0), (0.5, 0.5, 0.75), (0.85, 0.1, 0.45)] {
405 let m = EvolutionModel::from_likelihood(
406 prior(),
407 ChebyshevScalarization::new(ConcaveFront, 25.0, vec![0.0, 0.0])
408 .with_weight(vec![w, 1.0 - w]),
409 );
410 let r = EvolutionSMC::anneal(&mut rng, &m, cfg(), 8.0, 8);
411 let mean = r.weighted_mean(0);
412 assert!(
413 (lo..=hi).contains(&mean),
414 "weight {w}: front point {mean:.3} outside expected band [{lo}, {hi}]"
415 );
416 }
417 }
418
419 #[test]
425 fn test_chebyshev_latent_weight_conditional_tracks_front() {
426 #[derive(Clone)]
427 struct ConcaveFront;
428 impl MultiObjectiveFitness<RealVector> for ConcaveFront {
429 fn num_objectives(&self) -> usize {
430 2
431 }
432 fn evaluate(&self, g: &RealVector) -> Vec<f64> {
433 let x = g.genes()[0];
434 vec![x, 1.0 - x * x]
435 }
436 }
437
438 let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(0.0, 1.0)]));
439 let model = EvolutionModel::from_likelihood(
440 prior,
441 ChebyshevScalarization::new(ConcaveFront, 8.0, vec![0.0, 0.0]),
442 );
443 let mut rng = StdRng::seed_from_u64(314159);
444 let result = EvolutionSMC::run(
447 &mut rng,
448 &model,
449 EvoSmcConfig {
450 num_particles: 800,
451 ess_threshold: 0.5,
452 resampling: ResamplingMethod::Systematic,
453 rejuvenation_steps: 6,
454 crossover: Some(CrossoverConfig::default()),
455 },
456 );
457 let model_fn = model.smc_model();
458 let decoded = fugue::decode_particles(&result.particles, &model_fn);
459
460 let mut stratum_mass = 0.0;
461 let mut stratum_interior = 0.0;
462 for (p, (g, w)) in result.particles.iter().zip(&decoded) {
463 if let Some(wv) = particle_weights(&p.trace, 2) {
464 if (0.35..=0.65).contains(&wv[0]) {
465 stratum_mass += w;
466 let x = g.genes()[0];
467 if (0.25..0.75).contains(&x) {
468 stratum_interior += w;
469 }
470 }
471 }
472 }
473 assert!(
474 stratum_mass > 0.02,
475 "interior-weight stratum carries only {stratum_mass:.4} mass — too depleted to test"
476 );
477 let frac = stratum_interior / stratum_mass;
478 assert!(
479 frac > 0.5,
480 "interior-weight particles put only {frac:.2} of their mass on the front interior"
481 );
482 }
483
484 #[test]
487 fn test_stick_breaking_weights_uniform_simplex() {
488 use fugue::runtime::handler::run;
489 use fugue::runtime::interpreters::PriorHandler;
490 let mut rng = StdRng::seed_from_u64(9);
491 let mut sums = [0.0f64; 3];
492 let n = 4000;
493 for _ in 0..n {
494 let (w, _) = run(
495 PriorHandler {
496 rng: &mut rng,
497 trace: Trace::default(),
498 },
499 weight_model(3),
500 );
501 assert!((w.iter().sum::<f64>() - 1.0).abs() < 1e-12);
502 assert!(w.iter().all(|&x| (0.0..=1.0).contains(&x)));
503 for (s, wi) in sums.iter_mut().zip(&w) {
504 *s += wi;
505 }
506 }
507 for s in sums {
508 let mean = s / n as f64;
509 assert!(
510 (mean - 1.0 / 3.0).abs() < 0.02,
511 "uniform-simplex component mean {mean} should be 1/3"
512 );
513 }
514 }
515}