1use std::collections::BinaryHeap;
14
15use ogeom_core::{OgeomResult, ogeom_bail};
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct Minimum {
20 pub point: Vec<f64>,
22 pub value: f64,
24 pub certified: bool,
28 pub evaluations: usize,
30}
31
32fn check_box(lower: &[f64], upper: &[f64]) -> OgeomResult<()> {
33 if lower.is_empty() || lower.len() != upper.len() {
34 ogeom_bail!(
35 Construction,
36 "a box needs matching, non-empty bounds; got {} and {}",
37 lower.len(),
38 upper.len()
39 );
40 }
41 if lower
42 .iter()
43 .zip(upper)
44 .any(|(a, b)| !a.is_finite() || !b.is_finite() || b <= a)
45 {
46 ogeom_bail!(Construction, "a box's bounds must be finite and increasing");
47 }
48 Ok(())
49}
50
51fn call<F: FnMut(&[f64]) -> f64>(f: &mut F, x: &[f64], evaluations: &mut usize) -> f64 {
53 *evaluations += 1;
54 let v = f(x);
55 if v.is_nan() { f64::INFINITY } else { v }
56}
57
58fn clamp_into(x: &mut [f64], lower: &[f64], upper: &[f64]) {
59 for ((v, a), b) in x.iter_mut().zip(lower).zip(upper) {
60 *v = v.clamp(*a, *b);
61 }
62}
63
64pub fn minimize_local<F: FnMut(&[f64]) -> f64>(
73 mut f: F,
74 start: &[f64],
75 lower: &[f64],
76 upper: &[f64],
77 step: f64,
78 tolerance: f64,
79 max_evaluations: usize,
80) -> OgeomResult<Minimum> {
81 check_box(lower, upper)?;
82 if start.len() != lower.len() {
83 ogeom_bail!(
84 Construction,
85 "the start has {} coordinates, the box {}",
86 start.len(),
87 lower.len()
88 );
89 }
90 let n = start.len();
91 let mut evaluations = 0usize;
92 let mut eval = |x: &mut Vec<f64>, evaluations: &mut usize| -> f64 {
93 clamp_into(x, lower, upper);
94 call(&mut f, x, evaluations)
95 };
96 let mut simplex: Vec<(Vec<f64>, f64)> = Vec::with_capacity(n + 1);
97 let mut first = start.to_vec();
98 let v = eval(&mut first, &mut evaluations);
99 simplex.push((first, v));
100 for i in 0..n {
101 let mut x = start.to_vec();
102 x[i] += if x[i] + step <= upper[i] { step } else { -step };
103 let v = eval(&mut x, &mut evaluations);
104 simplex.push((x, v));
105 }
106 while evaluations < max_evaluations {
107 simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
108 let (best, worst) = (simplex[0].1, simplex[n].1);
109 let size = simplex[1..]
110 .iter()
111 .map(|(x, _)| {
112 x.iter()
113 .zip(&simplex[0].0)
114 .map(|(a, b)| (a - b).abs())
115 .fold(0.0_f64, f64::max)
116 })
117 .fold(0.0_f64, f64::max);
118 if (worst - best).abs() <= tolerance && size <= tolerance.sqrt() * 1e-3 + 1e-14 {
119 break;
120 }
121 if size <= 1e-15 {
122 break;
123 }
124 #[allow(clippy::cast_precision_loss)]
125 let centroid: Vec<f64> = (0..n)
126 .map(|k| simplex[..n].iter().map(|(x, _)| x[k]).sum::<f64>() / n as f64)
127 .collect();
128 let toward = |t: f64| -> Vec<f64> {
129 centroid
130 .iter()
131 .zip(&simplex[n].0)
132 .map(|(c, w)| c + t * (c - w))
133 .collect()
134 };
135 let mut reflected = toward(1.0);
136 let fr = eval(&mut reflected, &mut evaluations);
137 if fr < simplex[0].1 {
138 let mut expanded = toward(2.0);
139 let fe = eval(&mut expanded, &mut evaluations);
140 simplex[n] = if fe < fr {
141 (expanded, fe)
142 } else {
143 (reflected, fr)
144 };
145 } else if fr < simplex[n - 1].1 {
146 simplex[n] = (reflected, fr);
147 } else {
148 let mut contracted = if fr < simplex[n].1 {
149 toward(0.5)
150 } else {
151 toward(-0.5)
152 };
153 let fc = eval(&mut contracted, &mut evaluations);
154 if fc < simplex[n].1.min(fr) {
155 simplex[n] = (contracted, fc);
156 } else {
157 let anchor = simplex[0].0.clone();
159 for entry in simplex.iter_mut().skip(1) {
160 let mut x: Vec<f64> = entry
161 .0
162 .iter()
163 .zip(&anchor)
164 .map(|(p, a)| a + 0.5 * (p - a))
165 .collect();
166 let v = eval(&mut x, &mut evaluations);
167 *entry = (x, v);
168 }
169 }
170 }
171 }
172 simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
173 let (point, value) = simplex.swap_remove(0);
174 Ok(Minimum {
175 point,
176 value,
177 certified: false,
178 evaluations,
179 })
180}
181
182struct Cell {
184 floor: f64,
185 lower: Vec<f64>,
186 upper: Vec<f64>,
187}
188
189impl PartialEq for Cell {
190 fn eq(&self, other: &Self) -> bool {
191 self.floor.total_cmp(&other.floor).is_eq()
192 }
193}
194impl Eq for Cell {}
195impl PartialOrd for Cell {
196 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
197 Some(self.cmp(other))
198 }
199}
200impl Ord for Cell {
201 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
202 other.floor.total_cmp(&self.floor)
203 }
204}
205
206struct Bounds {
208 lipschitz: f64,
209 curvature: f64,
210}
211
212impl Bounds {
213 fn probe<F: FnMut(&[f64]) -> f64>(
217 &self,
218 f: &mut F,
219 c: &[f64],
220 r: f64,
221 evaluations: &mut usize,
222 ) -> (f64, f64) {
223 let fc = call(f, c, evaluations);
224 let mut gradient = 0.0_f64;
225 let step = (r * 1e-3).max(1e-9);
226 for k in 0..c.len() {
227 let mut a = c.to_vec();
228 let mut b = c.to_vec();
229 a[k] += step;
230 b[k] -= step;
231 let d = (call(f, &a, evaluations) - call(f, &b, evaluations)) / (2.0 * step);
232 gradient += d * d;
233 }
234 let first = fc - self.lipschitz * r;
235 let second = fc - gradient.sqrt() * r - 0.5 * self.curvature * r * r;
236 (fc, first.max(second))
237 }
238}
239
240pub fn global_minimum<F: FnMut(&[f64]) -> f64>(
257 mut f: F,
258 lower: &[f64],
259 upper: &[f64],
260 tolerance: f64,
261 max_evaluations: usize,
262) -> OgeomResult<Minimum> {
263 check_box(lower, upper)?;
264 if !(tolerance > 0.0 && tolerance.is_finite()) {
265 ogeom_bail!(Construction, "a tolerance of {tolerance} is not positive");
266 }
267 let n = lower.len();
268 let mut evaluations = 0usize;
269
270 let widths: Vec<f64> = lower.iter().zip(upper).map(|(a, b)| b - a).collect();
273 let mut best_point: Vec<f64> = lower
274 .iter()
275 .zip(upper)
276 .map(|(a, b)| 0.5 * (a + b))
277 .collect();
278 let mut best = call(&mut f, &best_point, &mut evaluations);
279 let (mut slope, mut bend) = (0.0_f64, 0.0_f64);
280 let directions: Vec<Vec<f64>> = (0..n)
281 .map(|k| (0..n).map(|j| if j == k { 1.0 } else { 0.0 }).collect())
282 .chain((0..n).map(|k| {
283 #[allow(clippy::cast_precision_loss)]
284 let norm = (n as f64).sqrt();
285 (0..n)
286 .map(|j| if j < k { -1.0 / norm } else { 1.0 / norm })
287 .collect()
288 }))
289 .collect();
290 let h = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-3;
291 for i in 1..=32 * n {
292 let x: Vec<f64> = (0..n)
293 .map(|k| {
294 let t = halton(i, PRIMES[k % PRIMES.len()]);
295 lower[k] + h + (widths[k] - 2.0 * h) * t
296 })
297 .collect();
298 let fx = call(&mut f, &x, &mut evaluations);
299 if fx < best {
300 (best, best_point) = (fx, x.clone());
301 }
302 for d in &directions {
303 let ahead: Vec<f64> = x.iter().zip(d).map(|(v, e)| v + h * e).collect();
304 let behind: Vec<f64> = x.iter().zip(d).map(|(v, e)| v - h * e).collect();
305 let (fa, fb) = (
306 call(&mut f, &ahead, &mut evaluations),
307 call(&mut f, &behind, &mut evaluations),
308 );
309 if fx.is_finite() && fa.is_finite() && fb.is_finite() {
310 slope = slope.max((fa - fb).abs() / (2.0 * h));
311 bend = bend.max((fa - 2.0 * fx + fb).abs() / (h * h));
312 }
313 }
314 }
315 #[allow(clippy::cast_precision_loss)]
318 let lipschitz = (2.0 * slope * (n as f64).sqrt()).max(tolerance);
319 #[allow(clippy::cast_precision_loss)]
320 let curvature = (2.0 * bend * n as f64).max(tolerance);
321
322 let half_diagonal = |lo: &[f64], hi: &[f64]| -> f64 {
323 lo.iter()
324 .zip(hi)
325 .map(|(a, b)| (0.5 * (b - a)).powi(2))
326 .sum::<f64>()
327 .sqrt()
328 };
329 let bounds = Bounds {
330 lipschitz,
331 curvature,
332 };
333 let mut heap = BinaryHeap::new();
334 let centre: Vec<f64> = lower
335 .iter()
336 .zip(upper)
337 .map(|(a, b)| 0.5 * (a + b))
338 .collect();
339 let (_, floor) = bounds.probe(
340 &mut f,
341 ¢re,
342 half_diagonal(lower, upper),
343 &mut evaluations,
344 );
345 heap.push(Cell {
346 floor,
347 lower: lower.to_vec(),
348 upper: upper.to_vec(),
349 });
350 let mut certified = false;
351 let step = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-2;
352 while let Some(cell) = heap.pop() {
353 if cell.floor >= best - tolerance {
354 certified = true;
355 break;
356 }
357 if evaluations >= max_evaluations {
358 heap.push(cell);
359 break;
360 }
361 let k = (0..n)
363 .max_by(|a, b| {
364 (cell.upper[*a] - cell.lower[*a]).total_cmp(&(cell.upper[*b] - cell.lower[*b]))
365 })
366 .unwrap_or(0);
367 let mid = 0.5 * (cell.lower[k] + cell.upper[k]);
368 for half in 0..2 {
369 let (mut lo, mut hi) = (cell.lower.clone(), cell.upper.clone());
370 if half == 0 {
371 hi[k] = mid;
372 } else {
373 lo[k] = mid;
374 }
375 let c: Vec<f64> = lo.iter().zip(&hi).map(|(a, b)| 0.5 * (a + b)).collect();
376 let (fc, floor) = bounds.probe(&mut f, &c, half_diagonal(&lo, &hi), &mut evaluations);
377 if fc < best - tolerance {
378 let polished =
380 minimize_local(&mut f, &c, lower, upper, step, tolerance * 1e-3, 400)?;
381 evaluations += polished.evaluations;
382 if polished.value < fc {
383 (best, best_point) = (polished.value, polished.point);
384 } else {
385 (best, best_point) = (fc, c.clone());
386 }
387 } else if fc < best {
388 (best, best_point) = (fc, c.clone());
389 }
390 heap.push(Cell {
391 floor,
392 lower: lo,
393 upper: hi,
394 });
395 }
396 }
397 if heap.is_empty() {
398 certified = true;
399 }
400 Ok(Minimum {
401 point: best_point,
402 value: best,
403 certified,
404 evaluations,
405 })
406}
407
408pub fn swarm_minimum<F: FnMut(&[f64]) -> f64>(
418 mut f: F,
419 lower: &[f64],
420 upper: &[f64],
421 particles: usize,
422 iterations: usize,
423) -> OgeomResult<Minimum> {
424 check_box(lower, upper)?;
425 if particles == 0 {
426 ogeom_bail!(Construction, "a swarm needs particles");
427 }
428 let n = lower.len();
429 let mut evaluations = 0usize;
430 let widths: Vec<f64> = lower.iter().zip(upper).map(|(a, b)| b - a).collect();
431 let mut state = 0x9E37_79B9_7F4A_7C15_u64;
432 let mut random = move || -> f64 {
433 state ^= state >> 12;
435 state ^= state << 25;
436 state ^= state >> 27;
437 #[allow(clippy::cast_precision_loss)]
438 let r = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64;
439 r
440 };
441 let mut position: Vec<Vec<f64>> = (1..=particles)
442 .map(|i| {
443 (0..n)
444 .map(|k| lower[k] + widths[k] * halton(i, PRIMES[k % PRIMES.len()]))
445 .collect()
446 })
447 .collect();
448 let mut velocity: Vec<Vec<f64>> = vec![vec![0.0; n]; particles];
449 let mut own_best: Vec<(Vec<f64>, f64)> = position
450 .iter()
451 .map(|x| (x.clone(), call(&mut f, x, &mut evaluations)))
452 .collect();
453 let mut swarm_best = own_best
454 .iter()
455 .min_by(|a, b| a.1.total_cmp(&b.1))
456 .cloned()
457 .unwrap_or_else(|| (position[0].clone(), f64::INFINITY));
458 let (inertia, own, social) = (0.72, 1.49, 1.49);
459 for _ in 0..iterations {
460 for p in 0..particles {
461 for k in 0..n {
462 let (r1, r2) = (random(), random());
463 velocity[p][k] = inertia * velocity[p][k]
464 + own * r1 * (own_best[p].0[k] - position[p][k])
465 + social * r2 * (swarm_best.0[k] - position[p][k]);
466 velocity[p][k] = velocity[p][k].clamp(-widths[k], widths[k]);
467 position[p][k] = (position[p][k] + velocity[p][k]).clamp(lower[k], upper[k]);
468 }
469 let v = call(&mut f, &position[p], &mut evaluations);
470 if v < own_best[p].1 {
471 own_best[p] = (position[p].clone(), v);
472 if v < swarm_best.1 {
473 swarm_best = (position[p].clone(), v);
474 }
475 }
476 }
477 }
478 let step = widths.iter().copied().fold(f64::INFINITY, f64::min) * 1e-3;
479 let polished = minimize_local(&mut f, &swarm_best.0, lower, upper, step, 1e-14, 400)?;
480 let (point, best) = if polished.value < swarm_best.1 {
481 (polished.point, polished.value)
482 } else {
483 swarm_best
484 };
485 Ok(Minimum {
486 point,
487 value: best,
488 certified: false,
489 evaluations: evaluations + polished.evaluations,
490 })
491}
492
493const PRIMES: [usize; 8] = [2, 3, 5, 7, 11, 13, 17, 19];
494
495fn halton(mut i: usize, base: usize) -> f64 {
497 let mut result = 0.0;
498 #[allow(clippy::cast_precision_loss)]
499 let mut fraction = 1.0 / base as f64;
500 while i > 0 {
501 #[allow(clippy::cast_precision_loss)]
502 {
503 result += fraction * (i % base) as f64;
504 fraction /= base as f64;
505 }
506 i /= base;
507 }
508 result
509}