geop_core_math/solvers/least_squares.rs
1//! Levenberg–Marquardt, constrained: minimizing a sum of squared residuals
2//! `Σ r(x)²` among the points where other residuals `c(x)` vanish, from
3//! their values and Jacobians.
4//!
5//! Each step solves the problem linearized at the current point — minimize
6//! `|r + J_r δ|² + δᵀ(B + λD)δ` subject to `c + J_c δ = 0`, or as near to it
7//! as first order gets — by the null-space method. `D` is
8//! Marquardt's scaling, the diagonal of the Jacobians' normal matrix, and
9//! `λ` shrinks after every step taken and grows after every one turned down:
10//! Gauss–Newton near a solution, gradient descent far from one. `B` is what
11//! Gauss–Newton leaves out — the curvature of the constraints, weighted by
12//! how hard the sum pulls against them — estimated from the steps taken (a
13//! symmetric rank-one update, along the constraints only): without it,
14//! wherever the sum cannot reach zero against curved constraints, steps
15//! along them overshoot and the minimizer zig-zags. That curvature is as
16//! often negative as positive — a mate pulled outwards curves the sum down
17//! along it — so `B` is not kept positive, as a BFGS estimate would be:
18//! one that cannot learn a negative curvature left an arm's drags creeping
19//! towards the preferences' minimum until they ran out of steps. Only
20//! `J_rᵀJ_r + B + λD`, over the directions the constraints leave free, must
21//! be positive, and `λ` grows until it is. Where a step it proposed is
22//! turned down, the step is tried again without it, and it is learned
23//! afresh if that does better — a configuration passing through a singular
24//! one changes it abruptly.
25//!
26//! Keeping `c` exact, rather than adding it to the sum with a large weight,
27//! is what makes the sum a pure preference: it can never buy itself a
28//! little lower with a little violation of `c` — which, wherever a long
29//! lever multiplies that violation, is a lot of motion.
30//!
31//! Nor are the two weighed against each other to judge a step. A step is
32//! taken where it definitely lowers either the sum or the constraints'
33//! violation `|c|` — against the point it starts from and against every
34//! point taken before (a filter), so the two cannot trade back and forth
35//! forever. A merit `Σ r² + ρ |c|` would need `ρ` above the multipliers the
36//! sum pulls against the constraints with, and those grow without bound at
37//! a singular configuration — two links in line, say — where the merit's
38//! own width then hides every step that does reduce the violation.
39//!
40//! A variable no residual depends on is never moved, and a step never has a
41//! component along a direction no residual depends on — unlike a
42//! quasi-Newton method on the sum alone, whose curvature estimate grows
43//! without bound along such a direction until rounding noise in the
44//! gradient throws it arbitrarily far.
45//!
46//! Every value is an honest enclosure. The steps themselves are free
47//! choices — any step is a valid seed for the next — so they are sharpened;
48//! whether a step is taken is not, and it is taken only where it
49//! *definitely* improves. So the minimizer needs no tolerance: it stops where
50//! no step can definitely improve on where it is, which is as near the
51//! minimum as the residuals' own uncertainty can tell.
52
53use crate::{
54 geop_error::GeopResult,
55 scalars::Scalar,
56 solvers::linear::{cholesky, solve_cholesky},
57};
58
59/// How long [`minimize`] may try, and how far a step may go.
60#[derive(Clone, Copy, Debug)]
61pub struct Options {
62 /// Steps taken at most: how hard it tries, never what an answer means.
63 pub max_iterations: usize,
64 /// The longest step, but where the steps before landed as their
65 /// linearization said: where the variables have a natural size, no
66 /// step longer than it is sensible until the problem shows it is.
67 pub max_step: Scalar,
68}
69
70/// Where [`minimize`] ended, and how it got there.
71#[derive(Clone, Debug)]
72pub struct Outcome<P> {
73 pub x: P,
74 pub iterations: usize,
75 pub stop: Stop,
76 /// Steps proposed and turned down — or proposed again without the
77 /// curvature learned — on the way: what the iterations do not count.
78 pub turned_down: usize,
79}
80
81/// Why [`minimize`] stopped where it did.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub enum Stop {
84 /// The residuals cannot be evaluated at the start.
85 Infeasible,
86 /// The next step changes no variable at all — after the steps before it
87 /// were turned down, the last one for `rejected`.
88 NoChange { rejected: Option<Rejection> },
89 /// Neither the sum's change nor the violation's can be told from their
90 /// width, and the step no longer shrinks: as near the minimum as can be
91 /// told.
92 Undecided,
93 /// The linearized model expects the next step to lower neither the sum
94 /// nor the violation definitely — with `independent` of the
95 /// `constraints` rows telling it something new.
96 Flat {
97 independent: usize,
98 constraints: usize,
99 },
100 /// No step, however damped, is taken; the last one tried turned down
101 /// for `rejected`.
102 Damped { rejected: Option<Rejection> },
103 /// [`Options::max_iterations`] steps were taken.
104 Budget,
105}
106
107/// Why a step was turned down.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub enum Rejection {
110 /// The damped model's curvature is not positive.
111 NotPositive,
112 /// The residuals cannot be evaluated where it leads.
113 Infeasible,
114 /// Cut so short that the linearized model cannot tell it from no step.
115 Unresolved,
116 /// Where it leads, neither the sum nor the violation is definitely
117 /// lower than at every point taken before.
118 NotLower,
119}
120
121/// Where [`minimize`]'s points are, and how a step moves one: the variables
122/// themselves, a step added to them ([`Euclidean`]) — or a manifold, where
123/// a step is taken from a point along its tangent space (a pose by the
124/// exponential map of a twist, say), and the Jacobians evaluated at a
125/// point are by steps from that point.
126pub trait Space {
127 type Point: Clone;
128
129 /// How many numbers a step from `at` takes.
130 fn dimension(&self, at: &Self::Point) -> usize;
131
132 /// Where `step` takes `at`; `None` where it cannot. The point a step
133 /// leads to is a free choice: sharp.
134 fn step(&self, at: &Self::Point, step: &[Scalar]) -> Option<Self::Point>;
135
136 /// Whether `b`, where a step from `a` led, is no different from `a`:
137 /// the step changed nothing.
138 fn same(&self, a: &Self::Point, b: &Self::Point) -> bool;
139}
140
141/// The variables themselves, as points: a step is added to them.
142pub struct Euclidean;
143
144impl Space for Euclidean {
145 type Point = Vec<Scalar>;
146
147 fn dimension(&self, at: &Vec<Scalar>) -> usize {
148 at.len()
149 }
150
151 fn step(&self, at: &Vec<Scalar>, step: &[Scalar]) -> Option<Vec<Scalar>> {
152 Some(
153 at.iter()
154 .zip(step)
155 .map(|(a, d)| a.add(*d).sharpen())
156 .collect(),
157 )
158 }
159
160 fn same(&self, a: &Vec<Scalar>, b: &Vec<Scalar>) -> bool {
161 a.iter().zip(b).all(|(a, b)| a.could_be_equal(*b))
162 }
163}
164
165/// Residuals, and their Jacobian row by row.
166#[derive(Clone, Debug, Default)]
167pub struct Rows {
168 pub values: Vec<Scalar>,
169 pub jacobian: Vec<Vec<Scalar>>,
170}
171
172impl Rows {
173 pub fn push(&mut self, value: Scalar, row: Vec<Scalar>) {
174 self.values.push(value);
175 self.jacobian.push(row);
176 }
177
178 /// `other`'s rows, after its own.
179 pub fn append(&mut self, other: Rows) {
180 self.values.extend(other.values);
181 self.jacobian.extend(other.jacobian);
182 }
183
184 /// `values + jacobian δ`: the residuals a step `δ` leads to, to first
185 /// order.
186 fn linearized(&self, delta: &[Scalar]) -> Vec<Scalar> {
187 (self.values.iter().zip(&self.jacobian))
188 .map(|(&v, row)| v.add(dot(row, delta)))
189 .collect()
190 }
191
192 /// `|values + jacobian δ|²`: at `δ = 0`, the sum of squares.
193 fn squared(&self, delta: &[Scalar]) -> Scalar {
194 let r = self.linearized(delta);
195 dot(&r, &r)
196 }
197
198 /// `jacobianᵀ w`.
199 fn transposed(&self, w: &[Scalar], n: usize) -> Vec<Scalar> {
200 let mut g = vec![Scalar::ZERO; n];
201 for (&wi, row) in w.iter().zip(&self.jacobian) {
202 for (gi, &ri) in g.iter_mut().zip(row) {
203 *gi = gi.add(ri.mul(wi));
204 }
205 }
206 g
207 }
208}
209
210/// The residuals at a point: those that must vanish, and those whose
211/// squares are minimized.
212#[derive(Clone, Debug, Default)]
213pub struct Evaluation {
214 pub constraints: Rows,
215 pub sum: Rows,
216}
217
218/// By how much `λ` shrinks after a step taken, and grows after one not.
219const SHRINK: i64 = 3;
220const GROW: i64 = 4;
221
222fn dot(a: &[Scalar], b: &[Scalar]) -> Scalar {
223 a.iter()
224 .zip(b)
225 .fold(Scalar::ZERO, |sum, (&x, &y)| sum.add(x.mul(y)))
226}
227
228/// `|v|`, scaled by the largest size among `v` — a free choice, sharp —
229/// before it is squared: the square of a number well below one keeps half
230/// its digits, and in fixed point none at all. Squared as they were, the
231/// residuals of a mate off by `1e-6` had a norm of `[0, 4.6e-5]` in fixed
232/// point, which no step could definitely lower: a body pinned a half turn
233/// away stopped `1e-6` off, ten times the tolerance.
234fn norm(v: &[Scalar]) -> GeopResult<Scalar> {
235 let largest = v.iter().fold(Scalar::ZERO, |m, x| m.max(x.abs().upper()));
236 if !largest.definitely_greater(Scalar::ZERO) {
237 return dot(v, v).sqrt();
238 }
239 let scaled = (v.iter().map(|x| x.div(largest))).collect::<GeopResult<Vec<Scalar>>>()?;
240 Ok(largest.mul(dot(&scaled, &scaled).sqrt()?))
241}
242
243/// A value of the model a step is computed from — a slope, a residual, a
244/// curvature, the step itself — chosen from its enclosure: any value in it
245/// serves (see the module docs), so zero where zero is among them, else
246/// the middle. A slope that could be zero is rounding around an exact
247/// zero, and turned into a small number it would couple what does not
248/// depend on each other — a coupling a weakly held variable then follows
249/// arbitrarily far.
250fn choose(v: Scalar) -> Scalar {
251 if v.could_be_equal(Scalar::ZERO) {
252 Scalar::ZERO
253 } else {
254 v.sharpen()
255 }
256}
257
258fn sharp(v: &[Scalar]) -> Vec<Scalar> {
259 v.iter().map(|&x| choose(x)).collect()
260}
261
262/// A step of the linearized problem (see [`step`]).
263struct Step {
264 delta: Vec<Scalar>,
265 /// The constraints' multipliers.
266 lambda: Vec<Scalar>,
267 /// How many of the constraints' rows say something the others do not.
268 independent: usize,
269}
270
271/// `v - a w`, chosen (see [`choose`]).
272fn minus_scaled(v: &[Scalar], a: Scalar, w: &[Scalar]) -> Vec<Scalar> {
273 v.iter()
274 .zip(w)
275 .map(|(&x, &y)| choose(x.sub(a.mul(y))))
276 .collect()
277}
278
279/// The step of the problem linearized at `e`, with curvature `b` and
280/// damping `mu`, sharpened — and the constraints' multipliers. `None` if
281/// the damped curvature is not positive along the directions the
282/// constraints leave free, among the variables `moving`.
283///
284/// By the null-space method: an orthonormal basis `Q` of the constraints'
285/// rows, and one `Z` of the directions they leave free. The step is the
286/// shortest one meeting the linearized constraints, `δ_c = Q y` with
287/// `R y = -c` for the rows' coefficients `R` in `Q` — no curvature in it at
288/// all — plus the one along `Z` that minimizes the model there, from the
289/// curvature reduced to those directions, `Zᵀ H Z`. Unlike a Schur
290/// complement `J_c H⁻¹ J_cᵀ`, nothing inverts `H` along a direction it
291/// barely curves in — a variable only the damping holds, say, which the
292/// constraints pin — where the inverse is huge and the complement loses as
293/// many digits as it is large.
294///
295/// A row says nothing the rows before it do not where what is left of it,
296/// after taking out its component along their basis, could be zero — asked
297/// of the row's honest enclosure: a question about the constraints, not a
298/// choice. Everything else is the model's own choice, so sharp.
299fn step(
300 e: &Evaluation,
301 b: &[Vec<Scalar>],
302 moving: &[bool],
303 mu: Scalar,
304) -> GeopResult<Option<Step>> {
305 let n = moving.len();
306 let keep: Vec<usize> = (0..n).filter(|&i| moving[i]).collect();
307 let k = keep.len();
308 let squeeze = |v: &[Scalar]| -> Vec<Scalar> { keep.iter().map(|&i| v[i]).collect() };
309 let jr: Vec<Vec<Scalar>> = e.sum.jacobian.iter().map(|r| sharp(&squeeze(r))).collect();
310 let jc_wide: Vec<Vec<Scalar>> = e.constraints.jacobian.iter().map(|r| squeeze(r)).collect();
311 let jc: Vec<Vec<Scalar>> = jc_wide.iter().map(|r| sharp(r)).collect();
312 let c = sharp(&e.constraints.values);
313 let m = jc.len();
314 super::factorised(jr.len() + m, k);
315
316 // H = J_rᵀJ_r + B + λ D, over the variables that move.
317 let mut h: Vec<Vec<Scalar>> = keep.iter().map(|&i| squeeze(&b[i])).collect();
318 for row in &jr {
319 for i in 0..k {
320 for j in 0..k {
321 h[i][j] = h[i][j].add(row[i].mul(row[j]));
322 }
323 }
324 }
325 for (i, row) in h.iter_mut().enumerate() {
326 let d = jr
327 .iter()
328 .chain(&jc)
329 .fold(Scalar::ZERO, |d, r| d.add(r[i].mul(r[i])));
330 row[i] = row[i].add(mu.mul(d));
331 row.iter_mut().for_each(|v| *v = choose(*v));
332 }
333 // Damped so hard that the curvature is no finite number: no step.
334 if !h.iter().flatten().all(|v| v.is_finite()) {
335 return Ok(None);
336 }
337
338 // Q, and each independent row's coefficients in it (modified
339 // Gram–Schmidt): row `i` is `Σ_j R[i][j] q_j`, `R[i]` ending in its own
340 // `q`'s.
341 // Whether a row is independent is asked honestly: what is left of its
342 // enclosure outside the span of the rows before — their sharp basis, not
343 // exactly orthonormal, so projected onto through its Gram matrix `QᵀQ`
344 // (enclosed, and close to the identity, so the projection stays tight).
345 // What a sharp basis leaves of a row that depends on the others is
346 // rounding, not zero; what this leaves of it could be zero.
347 let mut q: Vec<Vec<Scalar>> = Vec::new();
348 // The Cholesky factor of the Gram matrix, row by row.
349 let mut gram: Vec<Vec<Scalar>> = Vec::new();
350 let mut rows: Vec<(usize, Vec<Scalar>)> = Vec::new();
351 let mut dependent: Vec<(usize, Vec<Scalar>)> = Vec::new();
352 for i in 0..m {
353 let r = q.len();
354 let b: Vec<Scalar> = q.iter().map(|qj| dot(&jc_wide[i], qj)).collect();
355 let a = solve_cholesky(&gram, &vec![false; r], &b)?;
356 let outside: Vec<Scalar> = (0..k)
357 .map(|p| (0..r).fold(jc_wide[i][p], |sum, j| sum.sub(a[j].mul(q[j][p]))))
358 .collect();
359 if !norm(&outside)?.definitely_greater(Scalar::ZERO) {
360 // Its coefficients in the basis, for the least-squares
361 // correction below.
362 dependent.push((i, sharp(&a)));
363 continue;
364 }
365 let mut v = jc[i].clone();
366 let mut coefficients = Vec::new();
367 for qj in &q {
368 let a = choose(dot(&v, qj));
369 v = minus_scaled(&v, a, qj);
370 coefficients.push(a);
371 }
372 let length = norm(&v)?.sharpen();
373 coefficients.push(length);
374 let q_new: Vec<Scalar> = v
375 .iter()
376 .map(|&x| choose(x.div(length).unwrap_or(Scalar::ZERO)))
377 .collect();
378 let mut row = vec![Scalar::ZERO; r + 1];
379 for j in 0..r {
380 row[j] = (0..j)
381 .fold(dot(&q_new, &q[j]), |sum, k| sum.sub(row[k].mul(gram[j][k])))
382 .div(gram[j][j])?;
383 }
384 row[r] = (0..r)
385 .fold(dot(&q_new, &q_new), |sum, k| sum.sub(row[k].mul(row[k])))
386 .sqrt()?;
387 gram.push(row);
388 q.push(q_new);
389 rows.push((i, coefficients));
390 }
391 let r = q.len();
392
393 // δ_c = Q y, the least-squares correction of every row — `|c + J_c Q
394 // y|` least — by Householder reflections of the rows' coefficients in
395 // Q, not their normal equations, which square how ill-conditioned they
396 // are: exactly `R y = -c` where the dependent rows agree with the
397 // others, and as near as first order gets where they do not. Squared, a
398 // notched outline's trimmed offset — two arcs held to one crossing by a
399 // tangency each — gave a step that met none of the rows it was solved
400 // for, and the solve stopped flat at its first.
401 let coefficients_of = |coefficients: &[Scalar]| -> Vec<Scalar> {
402 (0..r)
403 .map(|j| coefficients.get(j).copied().unwrap_or(Scalar::ZERO))
404 .collect()
405 };
406 let all: Vec<(usize, Vec<Scalar>)> = rows
407 .iter()
408 .chain(&dependent)
409 .map(|(i, coefficients)| (*i, coefficients_of(coefficients)))
410 .collect();
411 let rhs: Vec<Scalar> = all.iter().map(|(i, _)| c[*i].neg()).collect();
412 let y = sharp(&least_squares(
413 all.into_iter().map(|(_, row)| row).collect(),
414 rhs,
415 )?);
416 let mut delta_c = vec![Scalar::ZERO; k];
417 for (t, qt) in q.iter().enumerate() {
418 delta_c = minus_scaled(&delta_c, y[t].neg(), qt);
419 }
420
421 // Z: the unit directions with what is left of them after Q, taken one
422 // at a time by the largest remainder — orthonormalized against Q and
423 // each other.
424 let mut left: Vec<Vec<Scalar>> = (0..k)
425 .map(|p| {
426 let mut v = vec![Scalar::ZERO; k];
427 v[p] = Scalar::ONE;
428 for qt in &q {
429 v = minus_scaled(&v, choose(dot(&v, qt)), qt);
430 }
431 v
432 })
433 .collect();
434 let mut z: Vec<Vec<Scalar>> = Vec::new();
435 while z.len() < k - r {
436 let lengths: Vec<Scalar> = left.iter().map(|v| dot(v, v)).collect();
437 let Some(best) = (0..k).reduce(|a, b| {
438 if lengths[b].definitely_greater(lengths[a]) {
439 b
440 } else {
441 a
442 }
443 }) else {
444 break;
445 };
446 let length = lengths[best].sqrt()?.sharpen();
447 if !length.definitely_greater(Scalar::ZERO) {
448 break;
449 }
450 let zt: Vec<Scalar> = left[best]
451 .iter()
452 .map(|&x| choose(x.div(length).unwrap_or(Scalar::ZERO)))
453 .collect();
454 for v in &mut left {
455 *v = minus_scaled(v, choose(dot(v, &zt)), &zt);
456 }
457 z.push(zt);
458 }
459
460 // Along Z: (Zᵀ H Z) w = -Zᵀ (g + H δ_c).
461 let g = sharp(&squeeze(&e.sum.transposed(&sharp(&e.sum.values), n)));
462 let h_times =
463 |v: &[Scalar]| -> Vec<Scalar> { h.iter().map(|row| choose(dot(row, v))).collect() };
464 let force: Vec<Scalar> = g
465 .iter()
466 .zip(h_times(&delta_c))
467 .map(|(&a, b)| choose(a.add(b)))
468 .collect();
469 let hz: Vec<Vec<Scalar>> = z.iter().map(|zt| h_times(zt)).collect();
470 let reduced: Vec<Vec<Scalar>> = z
471 .iter()
472 .map(|zi| hz.iter().map(|hzj| choose(dot(zi, hzj))).collect())
473 .collect();
474 let (l, not_positive) = cholesky(&reduced)?;
475 if not_positive.contains(&true) {
476 return Ok(None);
477 }
478 let rhs: Vec<Scalar> = z.iter().map(|zt| choose(dot(zt, &force).neg())).collect();
479 let w = sharp(&solve_cholesky(&l, ¬_positive, &rhs)?);
480 let mut kept = delta_c;
481 for (t, zt) in z.iter().enumerate() {
482 kept = minus_scaled(&kept, w[t].neg(), zt);
483 }
484
485 // The multipliers: J_cᵀ λ = -(g + H δ), R ᵀ λ = Qᵀ (-(g + H δ)).
486 let pull: Vec<Scalar> = g
487 .iter()
488 .zip(h_times(&kept))
489 .map(|(&a, b)| choose(a.add(b).neg()))
490 .collect();
491 // A curvature positive by so little that the step it solves for, or
492 // the force along it, is no finite number is as good as not positive.
493 if !kept.iter().chain(&pull).all(|v| v.is_finite()) {
494 return Ok(None);
495 }
496 let mut lambda_rows = vec![Scalar::ZERO; r];
497 for t in (0..r).rev() {
498 let known = (t + 1..r).fold(dot(&q[t], &pull), |sum, s| {
499 sum.sub(rows[s].1[t].mul(lambda_rows[s]))
500 });
501 lambda_rows[t] = choose(known.div(rows[t].1[t])?);
502 }
503 let mut lambda = vec![Scalar::ZERO; m];
504 for (t, (i, _)) in rows.iter().enumerate() {
505 lambda[*i] = lambda_rows[t];
506 }
507 let mut delta = vec![Scalar::ZERO; n];
508 for (p, &i) in keep.iter().enumerate() {
509 delta[i] = kept[p];
510 }
511 Ok(Some(Step {
512 delta,
513 lambda,
514 independent: r,
515 }))
516}
517
518/// Minimizes `Σ r(x)²` from `x0` among the points where `c(x) = 0`, with
519/// `evaluate` giving both and their Jacobians — by steps from the point,
520/// in `space` — and `None` where they cannot be evaluated, which makes the
521/// point infeasible. Where the constraints cannot all hold, it ends where
522/// they come closest. Always returns the best point found; whether it is
523/// good enough is the caller's question.
524///
525/// `evaluate` is awaited: a caller running many minimizations side by side
526/// answers all their evaluations at once (see
527/// [`crate::solvers::system`]'s groups).
528pub async fn minimize<P: Space>(
529 space: &P,
530 evaluate: impl AsyncFn(&P::Point) -> Option<Evaluation>,
531 x0: P::Point,
532 options: Options,
533) -> GeopResult<Outcome<P::Point>> {
534 let n = space.dimension(&x0);
535 let mut x = x0;
536 let Some(mut e) = evaluate(&x).await else {
537 return Ok(Outcome {
538 x,
539 iterations: 0,
540 stop: Stop::Infeasible,
541 turned_down: 0,
542 });
543 };
544 // A variable moves only if some residual definitely depends on it: a
545 // slope that could be zero — rounding around an exact zero — says
546 // nothing about which way to move it, only how far a step along it
547 // could go unchecked.
548 let moving: Vec<bool> = (0..n)
549 .map(|i| {
550 e.sum
551 .jacobian
552 .iter()
553 .chain(&e.constraints.jacobian)
554 .any(|row| row[i].definitely_not_equal(Scalar::ZERO))
555 })
556 .collect();
557 // The sum and the violation at every point taken: a step must
558 // definitely improve on each in one of the two.
559 let mut filter: Vec<(Scalar, Scalar)> = Vec::new();
560 let mut mu = Scalar::from_ratio(1, 1000)?;
561 let mut turned_down = 0;
562 let mut b = vec![vec![Scalar::ZERO; n]; n];
563 // The length of the last step taken.
564 let mut last: Option<Scalar> = None;
565 // How far a step may go: what bounds a step along the constraints, which
566 // `λ` does not damp — their linearization holds only so far.
567 let mut radius = options.max_step;
568 for iteration in 0..options.max_iterations {
569 let (f, theta) = (e.sum.squared(&[]), norm(&e.constraints.values)?);
570 let mut first = true;
571 let mut rejected = None;
572 // Whether this iteration's steps leave out the curvature learned
573 // (see below).
574 let mut plain = false;
575 let no_curvature = vec![vec![Scalar::ZERO; n]; n];
576 let accepted = loop {
577 let curvature = if plain { &no_curvature } else { &b };
578 if let Some(Step {
579 mut delta,
580 lambda,
581 independent,
582 }) = step(&e, curvature, &moving, mu)?
583 {
584 // What the linearized model expects of a step: where its own
585 // step expects no definite drop in either, no smaller step
586 // makes one — as near the minimum as can be told, or, damped
587 // already, no step helps — and no point is evaluated to
588 // learn it. The model of the sum is the one the step was
589 // computed from: its rows' linearization, and the curvature
590 // `B` it was computed with — what the constraints' curvature
591 // costs or gains a step along them. Without `B`, a step that
592 // `B` lengthened where the constraints curve the sum down
593 // looked to the rows alone like no drop at all: a gear pair
594 // dragged a quarter turn stopped, flat, at 36°.
595 let expects = |delta: &[Scalar]| -> GeopResult<bool> {
596 let bent = (0..n).fold(Scalar::ZERO, |sum, i| {
597 sum.add(delta[i].mul(dot(&curvature[i], delta)))
598 });
599 Ok(f.sub(e.sum.squared(delta))
600 .sub(bent)
601 .definitely_greater(Scalar::ZERO)
602 || theta
603 .sub(norm(&e.constraints.linearized(delta))?)
604 .definitely_greater(Scalar::ZERO))
605 };
606 if !expects(&delta)? {
607 return Ok(Outcome {
608 x,
609 iterations: iteration,
610 stop: if first {
611 Stop::Flat {
612 independent,
613 constraints: e.constraints.values.len(),
614 }
615 } else {
616 Stop::Damped { rejected }
617 },
618 turned_down,
619 });
620 }
621 let length = norm(&delta)?;
622 if length.definitely_greater(radius) {
623 let shrink = radius.div(length)?;
624 delta.iter_mut().for_each(|d| *d = choose(d.mul(shrink)));
625 }
626 // Cut so short that not even the model can tell it from no
627 // step at all, the cut is below what the arithmetic resolves:
628 // it is lifted again.
629 if !expects(&delta)? {
630 radius = options.max_step;
631 rejected = Some(Rejection::Unresolved);
632 } else {
633 let delta = sharp(&delta);
634 // What the linearization expects of it: the violation
635 // where it leads.
636 let predicted = norm(&e.constraints.linearized(&delta))?;
637 let next = space.step(&x, &delta);
638 // Where not even this step changes anything, no smaller one
639 // will: nothing improves on here.
640 if next.as_ref().is_some_and(|next| space.same(next, &x)) {
641 return Ok(Outcome {
642 x,
643 iterations: iteration,
644 stop: Stop::NoChange { rejected },
645 turned_down,
646 });
647 }
648 // How far the step went, before any correction: what
649 // the radius shrinks from where it is turned down.
650 let tried = norm(&delta)?;
651 let landed = match next {
652 Some(next) => match evaluate(&next).await {
653 Some(e_next) => Some(
654 corrected(space, &evaluate, &moving, theta, next, e_next, delta)
655 .await?,
656 ),
657 None => None,
658 },
659 None => None,
660 };
661 if let Some(landed) = landed {
662 let judged = judge(&e, &landed.1, theta, &filter, last, landed.2)?;
663 if first && judged.undecided && last.is_some() && !judged.contracting {
664 return Ok(Outcome {
665 x,
666 iterations: iteration,
667 stop: Stop::Undecided,
668 turned_down,
669 });
670 }
671 let taken = |j: &Judged| j.lower || (first && j.undecided && j.contracting);
672 if taken(&judged) {
673 let (next, e_next, _) = landed;
674 // Whether the step brought the constraints as near
675 // as their linearization said it would — three
676 // quarters of the drop it predicted, at least:
677 // the linearization then
678 // holds that far, and `max_step` does not bound
679 // the next. A part mated from far away is
680 // brought there in a few steps, not one of its
681 // sizes at a time.
682 let held = |old: Scalar, predicted: Scalar, new: Scalar| {
683 let gained = old.sub(new).mul(Scalar::from_i64(4));
684 gained
685 .definitely_greater(old.sub(predicted).mul(Scalar::from_i64(3)))
686 };
687 let linear = theta.definitely_greater(Scalar::ZERO)
688 && held(theta, predicted, norm(&e_next.constraints.values)?);
689 let length = judged.length;
690 // Never to zero, which the scalar's resolution
691 // rounds a small one to in fixed point: no
692 // growth brings it back, and a step turned down
693 // is then tried again as it was, forever.
694 let shrunk = mu.div(Scalar::from_i64(SHRINK))?.sharpen();
695 if shrunk.definitely_greater(Scalar::ZERO) {
696 mu = shrunk;
697 }
698 let reach = Scalar::TWO.mul(length);
699 if reach.definitely_greater(radius) {
700 radius = if linear || reach.definitely_less(options.max_step) {
701 reach.upper()
702 } else {
703 options.max_step
704 };
705 }
706 // Without the curvature learned, where that did better:
707 // it is learned afresh from here.
708 if plain {
709 b.iter_mut().flatten().for_each(|v| *v = Scalar::ZERO);
710 }
711 update_curvature(&mut b, &judged.s, &e, &e_next, &lambda)?;
712 filter.push((f, theta));
713 last = Some(length);
714 break Some((next, e_next));
715 }
716 rejected = Some(Rejection::NotLower);
717 // The curvature learned from earlier steps proposed this
718 // one. Where it fails, that curvature may no longer
719 // hold — a configuration passing through a singular
720 // one changes it abruptly — so the step is first tried
721 // again without it, as it stands, before damping it.
722 if !plain && learned(&b) {
723 plain = true;
724 turned_down += 1;
725 continue;
726 }
727 } else {
728 rejected = Some(Rejection::Infeasible);
729 }
730 radius = tried.div(Scalar::from_i64(GROW))?.sharpen();
731 }
732 } else {
733 rejected = Some(Rejection::NotPositive);
734 }
735 first = false;
736 turned_down += 1;
737 mu = mu.mul(Scalar::from_i64(GROW)).sharpen();
738 if !mu.is_finite() {
739 break None;
740 }
741 };
742 let Some((next, e_next)) = accepted else {
743 return Ok(Outcome {
744 x,
745 iterations: iteration,
746 stop: Stop::Damped { rejected },
747 turned_down,
748 });
749 };
750 (x, e) = (next, e_next);
751 }
752 Ok(Outcome {
753 x,
754 iterations: options.max_iterations,
755 stop: Stop::Budget,
756 turned_down,
757 })
758}
759
760/// How the step `s` from a point (evaluated as `e`) to the next (as
761/// `e_next`) fares — see [`minimize`], where it is taken or turned down on
762/// this.
763struct Judged {
764 /// The step, and its length.
765 s: Vec<Scalar>,
766 length: Scalar,
767 /// It definitely improves on `x`, and is not worse in both than any
768 /// point taken before.
769 lower: bool,
770 /// Neither the sum's change nor the violation's can be told from their
771 /// width.
772 undecided: bool,
773 /// It is at most half the last step taken.
774 contracting: bool,
775}
776
777fn judge(
778 e: &Evaluation,
779 e_next: &Evaluation,
780 theta: Scalar,
781 filter: &[(Scalar, Scalar)],
782 last: Option<Scalar>,
783 s: Vec<Scalar>,
784) -> GeopResult<Judged> {
785 let length = norm(&s)?;
786 // `Σ r² - Σ r'²` summed as `Σ (r - r')(r + r')`: near the minimum of a
787 // sum that stays large, the difference of the two sums is lost to their
788 // width.
789 let lower_sum = e
790 .sum
791 .values
792 .iter()
793 .zip(&e_next.sum.values)
794 .fold(Scalar::ZERO, |sum, (&r, &r_next)| {
795 sum.add(r.sub(r_next).mul(r.add(r_next)))
796 });
797 let theta_next = norm(&e_next.constraints.values)?;
798 let lower_violation = theta.sub(theta_next);
799 let f_next = e_next.sum.squared(&[]);
800 // Far from the minimum, a step is taken where it definitely improves on
801 // here, and is not worse in both than any point taken before. Near it,
802 // both changes drown in their own width, and what still tells progress
803 // is Newton's: a step at most half the last one taken contracts towards
804 // a solution — until rounding stops it shrinking, which is where it
805 // ends. Shrinking any less is no contraction: steps that only barely
806 // shrink add up to any distance at all.
807 let improves = lower_sum.definitely_greater(Scalar::ZERO)
808 || lower_violation.definitely_greater(Scalar::ZERO);
809 let lower = improves
810 && filter.iter().all(|&(f_taken, theta_taken)| {
811 f_next.definitely_less(f_taken) || theta_next.definitely_less(theta_taken)
812 });
813 let undecided = [lower_sum, lower_violation].iter().all(|change| {
814 !change.definitely_greater(Scalar::ZERO) && !change.definitely_less(Scalar::ZERO)
815 });
816 let contracting = last.is_some_and(|last| Scalar::TWO.mul(length).definitely_less(last));
817 Ok(Judged {
818 s,
819 length,
820 lower,
821 undecided,
822 contracting,
823 })
824}
825
826/// Corrections of a step back onto the constraints, at most: each a Newton
827/// step on them alone, which squares how far off they are — how hard a
828/// step is corrected, never whether it is taken.
829const CORRECTIONS: usize = 4;
830
831/// The point `x` (with its evaluation `e`), where the step `taken` from a
832/// point off the constraints by `before` landed, moved back onto them if
833/// the step left them further off — the shortest moves among the variables
834/// `moving` that meet their linearization there, as long as each definitely
835/// lowers their violation (a second-order correction) — and the step, with
836/// the corrections, all told.
837///
838/// A step meets the constraints' linearization where it starts, so it
839/// leaves them by as much as they curve over its length: a body turned by a
840/// large step along the directions they leave free is off them by that
841/// much. Judged there, the filter took steps that lowered the sum while the
842/// violation grew, and the minimizer zig-zagged along the constraints — a
843/// mated arm wandered off its mates by whole lengths and ran out of steps.
844/// Corrected, such a step is judged where it actually leads along them.
845/// A step that brought the constraints nearer is left as it is: the
846/// shortest moves are not the ones the sum prefers, and correcting every
847/// step drifted a sketch's line off where it was.
848async fn corrected<P: Space>(
849 space: &P,
850 evaluate: &impl AsyncFn(&P::Point) -> Option<Evaluation>,
851 moving: &[bool],
852 before: Scalar,
853 mut x: P::Point,
854 mut e: Evaluation,
855 mut taken: Vec<Scalar>,
856) -> GeopResult<(P::Point, Evaluation, Vec<Scalar>)> {
857 if !norm(&e.constraints.values)?.definitely_greater(before) {
858 return Ok((x, e, taken));
859 }
860 for _ in 0..CORRECTIONS {
861 let theta = norm(&e.constraints.values)?;
862 // δ = -J_cᵀ k with J_c J_cᵀ k = c: the shortest step meeting the
863 // linearized constraints, rows that say nothing new left out.
864 let jc: Vec<Vec<Scalar>> = e
865 .constraints
866 .jacobian
867 .iter()
868 .map(|row| {
869 row.iter()
870 .zip(moving)
871 .map(|(&v, &m)| if m { choose(v) } else { Scalar::ZERO })
872 .collect()
873 })
874 .collect();
875 let zero = vec![Scalar::ZERO; taken.len()];
876 let d: Vec<Scalar> = (minus_solved(&jc, &sharp(&e.constraints.values), &zero)?.into_iter())
877 .map(Scalar::sharpen)
878 .collect();
879 if !d.iter().all(|v| v.is_finite()) {
880 break;
881 }
882 let Some(next) = space.step(&x, &d) else {
883 break;
884 };
885 if space.same(&next, &x) {
886 break;
887 }
888 let Some(e_next) = evaluate(&next).await else {
889 break;
890 };
891 if !norm(&e_next.constraints.values)?.definitely_less(theta) {
892 break;
893 }
894 // The steps add up as they would among the variables themselves:
895 // exactly so there, and to first order on a manifold.
896 taken = taken
897 .iter()
898 .zip(&d)
899 .map(|(a, b)| a.add(*b).sharpen())
900 .collect();
901 (x, e) = (next, e_next);
902 }
903 Ok((x, e, taken))
904}
905
906/// Whether any curvature has been learned into `b`.
907fn learned(b: &[Vec<Scalar>]) -> bool {
908 b.iter()
909 .flatten()
910 .any(|v| !v.is_sharp() || v.definitely_not_equal(Scalar::ZERO))
911}
912
913/// The part of `v` along the constraints — orthogonal to the rows of their
914/// Jacobian, `v - J_cᵀ (J_c J_cᵀ)⁻¹ J_c v` — chosen (see [`choose`]); rows
915/// that say nothing the others do not left out.
916fn along(constraints: &Rows, v: &[Scalar]) -> GeopResult<Vec<Scalar>> {
917 let jc: Vec<Vec<Scalar>> = constraints.jacobian.iter().map(|r| sharp(r)).collect();
918 let rhs: Vec<Scalar> = jc.iter().map(|row| dot(row, v)).collect();
919 minus_solved(&jc, &rhs, v)
920}
921
922/// `from - J_cᵀ k` with `J_c J_cᵀ k = rhs`, chosen (see [`choose`]); rows
923/// of `jc` that say nothing the others do not left out.
924fn minus_solved(jc: &[Vec<Scalar>], rhs: &[Scalar], from: &[Scalar]) -> GeopResult<Vec<Scalar>> {
925 let gram: Vec<Vec<Scalar>> = jc
926 .iter()
927 .map(|a| jc.iter().map(|b| dot(a, b)).collect())
928 .collect();
929 let (l, dependent) = cholesky(&gram)?;
930 let k = solve_cholesky(&l, &dependent, rhs)?;
931 Ok((0..from.len())
932 .map(|i| choose((0..jc.len()).fold(from[i], |sum, r| sum.sub(jc[r][i].mul(k[r])))))
933 .collect())
934}
935
936/// Updates `b`, the curvature Gauss–Newton leaves out, from the step `s`
937/// from `e` to `e_next`: by how much the gradient of the Lagrangian
938/// `½ Σ r² + λᵀc` turned along it, beyond what `J_rᵀJ_r` explains (a
939/// structured symmetric rank-one update): `b` then turns the gradient along
940/// `s` exactly as it did, curving up or down. Skipped where the correction
941/// could be square to `s`, which leaves its size undetermined.
942fn update_curvature(
943 b: &mut [Vec<Scalar>],
944 s: &[Scalar],
945 e: &Evaluation,
946 e_next: &Evaluation,
947 lambda: &[Scalar],
948) -> GeopResult<()> {
949 let n = s.len();
950 let r_next = sharp(&e_next.sum.values);
951 let constraints_next = e_next.constraints.transposed(lambda, n);
952 let constraints_before = e.constraints.transposed(lambda, n);
953 let sum_next = e_next.sum.transposed(&r_next, n);
954 let sum_before = e.sum.transposed(&r_next, n);
955 let y: Vec<Scalar> = (0..n)
956 .map(|i| {
957 constraints_next[i]
958 .sub(constraints_before[i])
959 .add(sum_next[i])
960 .sub(sum_before[i])
961 })
962 .collect();
963 // Only along the constraints: off them, the constraints decide where a
964 // step goes, so a curvature there means nothing — and a large one, along
965 // a variable they hold outright, would only skew their solve.
966 let s = along(&e.constraints, s)?;
967 let bs: Vec<Scalar> = (0..n).map(|i| dot(&b[i], &s)).collect();
968 // `(y - B s)·s`, asked of `y`'s honest enclosure — the same along the
969 // constraints as off them, `s` being along them. Where it could be
970 // zero, the correction is square to the step as far as the gradients
971 // tell, and dividing by it would make the curvature whatever their
972 // rounding says.
973 if !dot(&y, &s)
974 .sub(dot(&bs, &s))
975 .definitely_not_equal(Scalar::ZERO)
976 {
977 return Ok(());
978 }
979 let y = along(&e.constraints, &y)?;
980 let v: Vec<Scalar> = (0..n).map(|i| choose(y[i].sub(bs[i]))).collect();
981 let vs = choose(dot(&v, &s));
982 if !vs.definitely_not_equal(Scalar::ZERO) {
983 return Ok(());
984 }
985 for i in 0..n {
986 for j in 0..n {
987 b[i][j] = choose(b[i][j].add(v[i].mul(v[j]).div(vs)?));
988 }
989 }
990 Ok(())
991}
992
993/// The `y` with `|rows y - rhs|` least, by Householder reflections, each
994/// value chosen (see [`choose`]): the model's own numbers. A column with
995/// nothing left of it, after the reflections before, is left out — zero in
996/// `y` — as a pivot that could be zero is.
997fn least_squares(rows: Vec<Vec<Scalar>>, rhs: Vec<Scalar>) -> GeopResult<Vec<Scalar>> {
998 let m = rows.len();
999 let r = rows.first().map_or(0, Vec::len);
1000 // By columns, the right-hand side the last: each reflection is applied
1001 // to every column from the diagonal's on.
1002 let mut columns: Vec<Vec<Scalar>> = (0..r)
1003 .map(|j| rows.iter().map(|row| row[j]).collect())
1004 .collect();
1005 columns.push(rhs);
1006 let mut left_out = vec![false; r];
1007 for j in 0..r.min(m) {
1008 let length = norm(&columns[j][j..])?;
1009 if !length.definitely_greater(Scalar::ZERO) {
1010 left_out[j] = true;
1011 continue;
1012 }
1013 // The reflection taking the column below the diagonal onto it,
1014 // `v = x + sign(x₀) |x| e₀`, away from cancelling.
1015 let alpha = if columns[j][j].definitely_less(Scalar::ZERO) {
1016 length
1017 } else {
1018 length.neg()
1019 };
1020 let mut v = columns[j][j..].to_vec();
1021 v[0] = choose(v[0].sub(alpha));
1022 let vv = choose(dot(&v, &v));
1023 if !vv.definitely_greater(Scalar::ZERO) {
1024 continue;
1025 }
1026 for column in &mut columns[j..] {
1027 let f = dot(&v, &column[j..]).mul(Scalar::TWO).div(vv)?;
1028 for (x, &vt) in column[j..].iter_mut().zip(&v) {
1029 *x = choose(x.sub(f.mul(vt)));
1030 }
1031 }
1032 }
1033 let rhs = &columns[r];
1034 let mut y = vec![Scalar::ZERO; r];
1035 for j in (0..r.min(m)).rev() {
1036 if left_out[j] || !columns[j][j].definitely_not_equal(Scalar::ZERO) {
1037 continue;
1038 }
1039 let known = (j + 1..r).fold(rhs[j], |sum, t| sum.sub(columns[t][j].mul(y[t])));
1040 y[j] = choose(known.div(columns[j][j])?);
1041 }
1042 Ok(y)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 /// [`super::minimize`], every evaluation answered as it is asked.
1050 fn minimize<P: Space>(
1051 space: &P,
1052 evaluate: impl Fn(&P::Point) -> Option<Evaluation>,
1053 x0: P::Point,
1054 options: Options,
1055 ) -> GeopResult<Outcome<P::Point>> {
1056 let work = std::pin::pin!(super::minimize(
1057 space,
1058 async |x: &P::Point| evaluate(x),
1059 x0,
1060 options
1061 ));
1062 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
1063 match std::future::Future::poll(work, &mut context) {
1064 std::task::Poll::Ready(outcome) => outcome,
1065 std::task::Poll::Pending => panic!("nothing awaited is pending"),
1066 }
1067 }
1068
1069 fn s(v: f64) -> Scalar {
1070 Scalar::from_f64(v)
1071 }
1072
1073 fn options() -> Options {
1074 Options {
1075 max_iterations: 500,
1076 max_step: s(10.0),
1077 }
1078 }
1079
1080 fn rows(values: Vec<Scalar>, jacobian: Vec<Vec<Scalar>>) -> Rows {
1081 Rows { values, jacobian }
1082 }
1083
1084 fn at(outcome: &Outcome<Vec<Scalar>>) -> Vec<f64> {
1085 outcome.x.iter().map(|v| v.to_f64()).collect()
1086 }
1087
1088 /// Rosenbrock, as residuals `(1 - a, 10 (b - a²))`.
1089 #[test]
1090 fn minimizes_rosenbrock() {
1091 let outcome = minimize(
1092 &Euclidean,
1093 |x: &Vec<Scalar>| {
1094 let (a, b) = (x[0], x[1]);
1095 Some(Evaluation {
1096 constraints: Rows::default(),
1097 sum: rows(
1098 vec![Scalar::ONE.sub(a), s(10.0).mul(b.sub(a.mul(a)))],
1099 vec![vec![s(-1.0), Scalar::ZERO], vec![s(-20.0).mul(a), s(10.0)]],
1100 ),
1101 })
1102 },
1103 vec![s(-1.2), s(1.0)],
1104 options(),
1105 )
1106 .unwrap();
1107 let x = at(&outcome);
1108 assert!(
1109 (x[0] - 1.0).abs() < 1e-12 && (x[1] - 1.0).abs() < 1e-12,
1110 "{outcome:?}"
1111 );
1112 }
1113
1114 /// A variable no residual depends on is not moved at all.
1115 #[test]
1116 fn free_variables_stay_put() {
1117 let outcome = minimize(
1118 &Euclidean,
1119 |x: &Vec<Scalar>| {
1120 Some(Evaluation {
1121 constraints: rows(
1122 vec![x[0].sub(s(2.0))],
1123 vec![vec![Scalar::ONE, Scalar::ZERO]],
1124 ),
1125 sum: Rows::default(),
1126 })
1127 },
1128 vec![s(0.0), s(0.7)],
1129 options(),
1130 )
1131 .unwrap();
1132 let x = at(&outcome);
1133 assert!((x[0] - 2.0).abs() < 1e-15, "{outcome:?}");
1134 assert_eq!(x[1], 0.7);
1135 }
1136
1137 /// The point of the unit circle nearest `(3, 4)` — the sum pulling, the
1138 /// circle holding exactly, however hard the sum pulls — found quickly:
1139 /// the constraint's curvature is learned, not stumbled over. Found as
1140 /// near as the sum tells: `ε` along the circle from the nearest point
1141 /// lowers the sum, about 16, by `5 ε²`, lost in its width below
1142 /// `ε` of a few `1e-8` — where the minimizer stops is then a matter of
1143 /// where its last step happened to land.
1144 #[test]
1145 fn minimizes_on_a_circle() {
1146 let outcome = minimize(
1147 &Euclidean,
1148 |x: &Vec<Scalar>| {
1149 let (a, b) = (x[0], x[1]);
1150 Some(Evaluation {
1151 constraints: rows(
1152 vec![a.mul(a).add(b.mul(b)).sub(Scalar::ONE)],
1153 vec![vec![Scalar::TWO.mul(a), Scalar::TWO.mul(b)]],
1154 ),
1155 sum: rows(
1156 vec![a.sub(s(3.0)), b.sub(s(4.0))],
1157 vec![
1158 vec![Scalar::ONE, Scalar::ZERO],
1159 vec![Scalar::ZERO, Scalar::ONE],
1160 ],
1161 ),
1162 })
1163 },
1164 vec![s(1.0), s(0.0)],
1165 options(),
1166 )
1167 .unwrap();
1168 let x = at(&outcome);
1169 assert!(
1170 (x[0] - 0.6).abs() < 1e-9 && (x[1] - 0.8).abs() < 1e-9,
1171 "{outcome:?}"
1172 );
1173 assert!(outcome.iterations < 30, "{outcome:?}");
1174 }
1175
1176 /// Two constraints that say the same are met as one.
1177 #[test]
1178 fn repeated_constraints_are_met() {
1179 let outcome = minimize(
1180 &Euclidean,
1181 |x: &Vec<Scalar>| {
1182 let c = x[0].add(x[1]).sub(Scalar::ONE);
1183 Some(Evaluation {
1184 constraints: rows(vec![c, c], vec![vec![Scalar::ONE, Scalar::ONE]; 2]),
1185 sum: rows(vec![x[0]], vec![vec![Scalar::ONE, Scalar::ZERO]]),
1186 })
1187 },
1188 vec![s(0.3), s(0.3)],
1189 options(),
1190 )
1191 .unwrap();
1192 let x = at(&outcome);
1193 assert!(
1194 x[0].abs() < 1e-15 && (x[1] - 1.0).abs() < 1e-15,
1195 "{outcome:?}"
1196 );
1197 }
1198
1199 /// A variable no residual depends on, between two that are minimized,
1200 /// stays put while they go where the sum wants them.
1201 #[test]
1202 fn a_free_variable_between_others_stays_put() {
1203 let outcome = minimize(
1204 &Euclidean,
1205 |x: &Vec<Scalar>| {
1206 Some(Evaluation {
1207 constraints: Rows::default(),
1208 sum: rows(
1209 vec![x[0].sub(Scalar::ONE), x[2].sub(Scalar::TWO)],
1210 vec![
1211 vec![Scalar::ONE, Scalar::ZERO, Scalar::ZERO],
1212 vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE],
1213 ],
1214 ),
1215 })
1216 },
1217 vec![s(0.0), s(0.5), s(0.0)],
1218 options(),
1219 )
1220 .unwrap();
1221 assert_eq!(at(&outcome), [1.0, 0.5, 2.0], "{outcome:?}");
1222 }
1223
1224 /// A point held a unit from a fixed one, pulled towards `(0, 3)` from
1225 /// `(1, 0)`: it turns about the fixed point until it is straight below
1226 /// the pull — a pull it can never meet, against a constraint that curves.
1227 /// The residuals of [`a_held_point_follows_a_pull_round`] at `x`.
1228 fn held_point(x: &[Scalar]) -> Option<Evaluation> {
1229 {
1230 {
1231 let (ax, ay, bx, by) = (x[0], x[1], x[2], x[3]);
1232 let (dx, dy) = (bx.sub(ax), by.sub(ay));
1233 let l = dx.mul(dx).add(dy.mul(dy)).sqrt().ok()?;
1234 let (ux, uy) = (dx.div(l).ok()?, dy.div(l).ok()?);
1235 let z = Scalar::ZERO;
1236 let w = s(1e-4);
1237 Some(Evaluation {
1238 constraints: rows(
1239 vec![ax, ay, l.sub(Scalar::ONE)],
1240 vec![
1241 vec![Scalar::ONE, z, z, z],
1242 vec![z, Scalar::ONE, z, z],
1243 vec![ux.neg(), uy.neg(), ux, uy],
1244 ],
1245 ),
1246 sum: rows(
1247 vec![bx, by.sub(s(3.0)), w.mul(ax), w.mul(ay)],
1248 vec![
1249 vec![z, z, Scalar::ONE, z],
1250 vec![z, z, z, Scalar::ONE],
1251 vec![w, z, z, z],
1252 vec![z, w, z, z],
1253 ],
1254 ),
1255 })
1256 }
1257 }
1258 }
1259
1260 #[test]
1261 fn a_held_point_follows_a_pull_round() {
1262 let outcome = minimize(
1263 &Euclidean,
1264 |x: &Vec<Scalar>| held_point(x),
1265 vec![s(0.0), s(0.0), s(1.0), s(0.0)],
1266 Options {
1267 max_step: Scalar::ONE,
1268 ..options()
1269 },
1270 )
1271 .unwrap();
1272 let x = at(&outcome);
1273 assert!(
1274 x[2].abs() < 1e-9 && (x[3] - 1.0).abs() < 1e-9,
1275 "{outcome:?}"
1276 );
1277 }
1278
1279 /// Off a constraint, a step — however damped — corrects it: what holds
1280 /// the constraints is their linearization, which the damping does not
1281 /// weaken. The point held a unit from a fixed one (see
1282 /// [`a_held_point_follows_a_pull_round`]), where it once stalled.
1283 #[test]
1284 fn a_step_corrects_a_violated_constraint() {
1285 let (bx, by) = (s(0.0004572033706418954), s(1.0003621608700888));
1286 let l = bx.mul(bx).add(by.mul(by)).sqrt().unwrap();
1287 let (ux, uy) = (bx.div(l).unwrap(), by.div(l).unwrap());
1288 let (z, w) = (Scalar::ZERO, s(1e-4));
1289 let e = Evaluation {
1290 constraints: rows(
1291 vec![z, z, l.sub(Scalar::ONE)],
1292 vec![
1293 vec![Scalar::ONE, z, z, z],
1294 vec![z, Scalar::ONE, z, z],
1295 vec![ux.neg(), uy.neg(), ux, uy],
1296 ],
1297 ),
1298 sum: rows(
1299 vec![bx, by.sub(s(3.0)), z, z],
1300 vec![
1301 vec![z, z, Scalar::ONE, z],
1302 vec![z, z, z, Scalar::ONE],
1303 vec![w, z, z, z],
1304 vec![z, w, z, z],
1305 ],
1306 ),
1307 };
1308 let b = vec![vec![Scalar::ZERO; 4]; 4];
1309 for mu in [
1310 1e-30,
1311 1e-12,
1312 4.115226337448558e-6,
1313 1e-3,
1314 1.0,
1315 1e6,
1316 1e12,
1317 1e30,
1318 ] {
1319 let Step { delta, lambda, .. } = step(&e, &b, &[true; 4], s(mu)).unwrap().unwrap();
1320 let after = e.constraints.squared(&delta).sqrt().unwrap();
1321 assert!(
1322 after.definitely_less(l.sub(Scalar::ONE)),
1323 "damped {mu}: {delta:?}, {lambda:?}, violation after {after:?}"
1324 );
1325 }
1326 }
1327
1328 /// A step meets the linearized constraints, at every damping — here at
1329 /// the held point (see [`a_held_point_follows_a_pull_round`]) nearly on
1330 /// its circle, where one did not.
1331 #[test]
1332 fn a_step_meets_the_linearized_constraints() {
1333 let x = [
1334 s(0.0),
1335 s(0.0),
1336 s(-4.223620663585878e-8),
1337 s(1.0000000001445193),
1338 ];
1339 let e = held_point(&x).unwrap();
1340 let b = vec![vec![Scalar::ZERO; 4]; 4];
1341 for mu in [4.572473708276175e-7, 1e-3, 1.0] {
1342 let Step { delta, lambda, .. } = step(&e, &b, &[true; 4], s(mu)).unwrap().unwrap();
1343 let after: Vec<Scalar> = e
1344 .constraints
1345 .values
1346 .iter()
1347 .zip(&e.constraints.jacobian)
1348 .map(|(c, row)| c.add(dot(row, &delta)))
1349 .collect();
1350 assert!(
1351 after.iter().all(|a| a.abs().to_f64() < 1e-14),
1352 "damped {mu}: {delta:?}, {lambda:?}, linearized after {after:?}"
1353 );
1354 }
1355 }
1356
1357 /// Two constraints that say the same: the step meets both, from the one
1358 /// that counts.
1359 #[test]
1360 fn a_step_meets_repeated_constraints() {
1361 let c = s(0.3).add(s(0.3)).sub(Scalar::ONE);
1362 let e = Evaluation {
1363 constraints: rows(vec![c, c], vec![vec![Scalar::ONE, Scalar::ONE]; 2]),
1364 sum: rows(vec![s(0.3)], vec![vec![Scalar::ONE, Scalar::ZERO]]),
1365 };
1366 let Step { delta, lambda, .. } =
1367 step(&e, &vec![vec![Scalar::ZERO; 2]; 2], &[true; 2], s(1e-3))
1368 .unwrap()
1369 .unwrap();
1370 let after: Vec<f64> = e
1371 .constraints
1372 .values
1373 .iter()
1374 .zip(&e.constraints.jacobian)
1375 .map(|(c, row)| c.add(dot(row, &delta)).to_f64())
1376 .collect();
1377 assert!(
1378 after.iter().all(|a| a.abs() < 1e-14),
1379 "{delta:?} {lambda:?} {after:?}"
1380 );
1381 }
1382}