basin 1.1.0

Numerical optimization in pure Rust, with pluggable linear-algebra backends and WASM support.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! TRSTEP: LINCOA's linearly-constrained trust-region subproblem (Powell 2015,
//! §3–§5; PRIMA `trustregion.f90::trstep`).
//!
//! Approximately solves
//!
//! ```text
//! minimize  Q(x_opt + d)   subject to   ‖d‖ ≤ Δ  and  A (x_opt + d) ≤ b,
//! ```
//!
//! by a **projected truncated conjugate gradient**: [`getact`](super::getact)
//! selects the nearly-active constraints, maintains their QR factorization, and
//! returns the projected steepest-descent direction `psd`; the CG search
//! directions are projected onto the null space of the active normals and
//! truncated at the trust boundary or when a new constraint becomes active. It is
//! the linearly-constrained analogue of the shared
//! [`TrustRegionSubproblem`](crate::solver::powell::TrustRegionSubproblem)
//! strategies — TRSAPP (NEWUOA) and TRSBOX (BOBYQA) are the no-constraint and
//! box-only special cases.
//!
//! Unlike NEWUOA/BOBYQA, LINCOA does **not** implement the `TrustRegionSubproblem`
//! seam: its active-set QR ([`ActiveSetQr`]) is a *warm start* that must persist
//! across driver iterations, so the driver calls the free [`trstep`] directly
//! with its own `&mut ActiveSetQr` rather than through the seam's stateless
//! `&self` `solve`. The seam stays the right shape for the stateless members.
//!
//! D = 0 is assumed feasible (`b ≥ 0` up to rounding), which LINCOA's init
//! guarantees by keeping `x_opt` feasible.

use crate::core::math::Scalar;
use crate::solver::powell::{QuadraticModel, TrustRegionStep};

use super::getact::{ActiveSetQr, getact};

/// Plain dot product.
fn dot<F: Scalar>(a: &[F], b: &[F]) -> F {
    a.iter().zip(b).fold(F::zero(), |acc, (x, y)| acc + *x * *y)
}

/// `vᵀ a_j` where `a_j` is column `j` of the `n × m` column-major `amat`.
fn amat_col_dot<F: Scalar>(v: &[F], amat: &[F], j: usize, n: usize) -> F {
    (0..n).fold(F::zero(), |acc, r| acc + v[r] * amat[r + j * n])
}

/// Solve the lower-triangular system `Rᵀ z = b` (forward substitution), where `R`
/// is the `n × n` column-major upper-triangular factor and only its leading
/// `k × k` block is used. PRIMA `solve(transpose(rfac), ·)`.
fn solve_rt<F: Scalar>(rfac: &[F], n: usize, k: usize, b: &[F]) -> Vec<F> {
    let mut z = vec![F::zero(); k];
    for i in 0..k {
        let mut s = b[i];
        for j in 0..i {
            s = s - rfac[j + i * n] * z[j]; // Rᵀ(i,j) = R(j,i) = rfac[j + i*n]
        }
        z[i] = s / rfac[i + i * n];
    }
    z
}

/// Largest of three values (PRIMA's `maxval([...])` guard against `sqrt`
/// underflow in a discriminant).
fn max3<F: Scalar>(a: F, b: F, c: F) -> F {
    a.max(b).max(c)
}

/// Solve LINCOA's linearly-constrained trust-region subproblem, warm-starting
/// (and updating) the active-set QR in `warm` (Powell 2015, §3–§5; PRIMA
/// `trstep`).
///
/// `amat` is the `n × m` column-major matrix of unit constraint normals and
/// `rescon` the length-`m` residuals (`b − A x_opt`). Returns the step `d = s`
/// (relative to `x_opt`), `crvmin = 0` (LINCOA tracks no interior curvature —
/// the driver's accurate-model test does not use it), and the predicted
/// reduction `Q(x_opt) − Q(x_opt + s) ≥ 0`, paired with `ngetact`, the number of
/// [`getact`] invocations (PRIMA's `ngetact`; the driver's short-step test uses
/// it — `ngetact ≥ 2` means the active set changed during the CG).
pub(crate) fn trstep<F: Scalar>(
    model: &QuadraticModel<F>,
    delta: F,
    amat: &[F],
    rescon: &[F],
    warm: &mut ActiveSetQr<F>,
) -> (TrustRegionStep<F>, usize) {
    let n = model.n();
    let m = rescon.len();
    let (zero, one) = (F::zero(), F::one());
    let two = F::from_f64(2.0).expect("2.0 representable");
    let half = F::from_f64(0.5).expect("0.5 representable");
    let eps = F::epsilon();
    let realmin = F::min_positive_value();
    let tinycv = two.powi(-200);
    let ctest = F::from_f64(0.01).expect("0.01 representable");
    let pt2 = F::from_f64(0.2).expect("0.2 representable");
    let c1em4 = F::from_f64(1e-4).expect("1e-4 representable");

    let make_step = |s: Vec<F>| -> TrustRegionStep<F> {
        let hs = model.hessian_matvec(&s);
        let gopt = model.gradient_at_opt();
        let pred = -(dot(&gopt, &s) + half * dot(&s, &hs));
        TrustRegionStep {
            d: s,
            crvmin: zero,
            predicted_reduction: pred,
        }
    };

    // Scale the gradient/Hessian if GOPT is huge, to avoid FP exceptions. The
    // step is scale-invariant; the predicted reduction is recomputed unscaled.
    let gopt0 = model.gradient_at_opt();
    let gmax = gopt0.iter().fold(zero, |acc, v| acc.max(v.abs()));
    let modscal = if gmax > F::from_f64(1e12).expect("1e12 representable") {
        (two * realmin).max(one / gmax)
    } else {
        one
    };
    let mut g: Vec<F> = gopt0.iter().map(|&v| v * modscal).collect();
    if !g.iter().fold(zero, |a, v| a + v.abs()).is_finite() {
        return (make_step(vec![zero; n]), 0);
    }

    // Initialize RESNEW / RESACT (PRIMA trstep init).
    let mut resnew = vec![zero; m];
    for j in 0..m {
        let mut r = rescon[j];
        if rescon[j] >= zero {
            r = tinycv.max(rescon[j]);
        }
        if rescon[j] >= delta {
            r = -one;
        }
        resnew[j] = r;
    }
    for ic in 0..warm.nact {
        resnew[warm.iact[ic]] = zero;
    }
    let mut resact = vec![zero; m];
    for ic in 0..warm.nact {
        resact[ic] = rescon[warm.iact[ic]];
    }

    let delsq = delta * delta;
    let mut s = vec![zero; n];
    let mut ss = zero;
    let mut reduct = zero;
    let mut newact = true;
    let mut ngetact: usize = 0;
    let mut itercg: isize = -1;
    let mut d = vec![zero; n];
    let mut gamma = zero;
    // Assigned (and read) within each iteration before any use.
    let mut hd: Vec<F>;

    let maxiter = (10 * (m + n)).min(10_000);
    for _iter in 0..maxiter {
        if newact {
            let mut psd = vec![zero; n];
            getact(
                amat,
                n,
                m,
                delta,
                &g,
                warm,
                &mut resact,
                &mut resnew,
                &mut psd,
            );
            ngetact += 1;
            let dd = dot(&psd, &psd);
            if dd <= eps * delsq || dd.is_nan() {
                break;
            }
            let scale_psd = (pt2 * delta) / dd.sqrt();
            for p in psd.iter_mut() {
                *p = *p * scale_psd;
            }

            // Optionally modify PSD by a projection step toward the boundaries of
            // the active constraints when their residuals are substantial.
            gamma = zero;
            let mut dproj = vec![zero; n];
            if (0..warm.nact).any(|ic| resact[ic] > c1em4 * delta) {
                let z = solve_rt(&warm.rfac, n, warm.nact, &resact[..warm.nact]);
                for k in 0..warm.nact {
                    for r in 0..n {
                        dproj[r] = dproj[r] + warm.qfac[r + k * n] * z[k];
                    }
                }
                let spsd: Vec<F> = (0..n).map(|i| s[i] + psd[i]).collect();
                let ds = dot(&dproj, &spsd);
                let dd2 = dot(&dproj, &dproj);
                let resid = delsq - dot(&spsd, &spsd);
                if resid > zero && dd2 > eps * delsq && !ds.is_nan() {
                    let sqrtd = max3(
                        (ds * ds + dd2 * resid).sqrt(),
                        ds.abs(),
                        (dd2 * resid).sqrt(),
                    );
                    gamma = if ds <= zero {
                        (sqrtd - ds) / dd2
                    } else {
                        resid / (sqrtd + ds)
                    };
                    if gamma < zero || !gamma.is_finite() {
                        gamma = zero;
                    }
                    // Reduce GAMMA so the move also keeps the inactive constraints feasible.
                    let mut gmin = gamma.min(one);
                    for j in 0..m {
                        if resnew[j] > zero {
                            let adj = amat_col_dot(&dproj, amat, j, n);
                            if adj > zero {
                                let psd_aj = amat_col_dot(&psd, amat, j, n);
                                let fr = (resnew[j] - psd_aj) / adj;
                                if fr < gmin {
                                    gmin = fr;
                                }
                            }
                        }
                    }
                    gamma = gmin;
                }
            }

            if gamma > zero {
                for i in 0..n {
                    d[i] = psd[i] + gamma * dproj[i];
                }
                itercg = -1;
            } else {
                d.copy_from_slice(&psd);
                itercg = 0;
            }
        }
        itercg += 1;

        // ALPHA: steplength along D to the trust boundary.
        let resid = delsq - ss;
        let dg = dot(&d, &g);
        let ds = dot(&d, &s);
        let dd = dot(&d, &d);
        if resid <= zero || dd <= eps * delsq || ds.is_nan() {
            break;
        }
        let sqrtd = max3((ds * ds + dd * resid).sqrt(), ds.abs(), (dd * resid).sqrt());
        let mut alpha = if ds <= zero {
            (sqrtd - ds) / dd
        } else {
            resid / (sqrtd + ds)
        };
        if alpha <= zero || !alpha.is_finite() {
            break;
        }
        if -alpha * dg <= ctest * reduct || (alpha * dg).is_nan() {
            break;
        }

        // Curvature along D; reduce ALPHA to the model minimizer if needed.
        hd = model.hessian_matvec(&d);
        for h in hd.iter_mut() {
            *h = *h * modscal;
        }
        let dhd = dot(&d, &hd);
        let alpht = alpha;
        if dg + alpha * dhd > zero {
            alpha = -dg / dhd;
        }

        // Reduce ALPHA further to preserve feasibility; record the binding
        // constraint JSAV (the first global argmin of FRAC, if it is < ALPHA).
        let alphm = alpha;
        let mut ad = vec![-one; m];
        for j in 0..m {
            if resnew[j] > zero {
                ad[j] = amat_col_dot(&d, amat, j, n);
            }
        }
        let mut jsav: Option<usize> = None;
        for j in 0..m {
            if ad[j] > zero {
                let mut fr = resnew[j] / ad[j];
                if fr.is_nan() {
                    fr = alpha;
                }
                if fr < alpha {
                    alpha = fr;
                    jsav = Some(j);
                }
            }
        }

        // Post-process ALPHA per ITERCG.
        if itercg == 0 {
            alpha = one;
        } else if itercg == 1 && gamma <= zero {
            alpha = alpha.max(one);
        } else {
            alpha = alpha.max(zero);
        }
        alpha = alpha.min(alphm);

        // Update S, G.
        let sold = s.clone();
        for i in 0..n {
            s[i] = s[i] + alpha * d[i];
        }
        ss = dot(&s, &s);
        if !ss.is_finite() {
            s = sold;
            break;
        }
        for i in 0..n {
            g[i] = g[i] + alpha * hd[i];
        }
        if !g.iter().fold(zero, |a, v| a + v.abs()).is_finite() {
            break;
        }

        // Update RESNEW for the inactive constraints.
        for j in 0..m {
            if resnew[j] > zero {
                resnew[j] = tinycv.max(resnew[j] - alpha * ad[j]);
            }
        }
        // Update RESACT iff this was the modified post-GETACT step.
        if itercg == 0 {
            let factor = one - alpha * gamma;
            for ic in 0..warm.nact {
                resact[ic] = factor * resact[ic];
            }
        }

        // Update REDUCT.
        reduct = reduct - alpha * (dg + half * alpha * dhd);
        if reduct <= zero || reduct.is_nan() {
            s = sold;
            break;
        }

        // Termination tests.
        if alpha >= alpht || -alphm * (dg + half * alphm * dhd) <= ctest * reduct {
            break;
        }

        // New active constraint → recompute the active set next iteration.
        newact = jsav.is_some();
        if newact {
            continue;
        }
        // A stationary point of the current null space is reached after N-NACT
        // CG iterations.
        if itercg as usize >= n - warm.nact {
            break;
        }

        // Next (conjugate) search direction, projected onto the inactive subspace.
        let pg: Vec<F> = if warm.nact == 0 {
            g.clone()
        } else {
            let mut p = vec![zero; n];
            for col in warm.nact..n {
                let coeff = (0..n).fold(zero, |a, r| a + g[r] * warm.qfac[r + col * n]);
                for r in 0..n {
                    p[r] = p[r] + coeff * warm.qfac[r + col * n];
                }
            }
            p
        };
        let beta = if itercg == 0 {
            zero
        } else {
            dot(&pg, &hd) / dhd
        };
        for i in 0..n {
            d[i] = -pg[i] + beta * d[i];
        }
    }

    (make_step(s), ngetact)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A diagonal convex quadratic `0.5 Σ hᵢ (xᵢ − cᵢ)²`. With `npt = 2n+1` the
    /// least-Frobenius model interpolates it exactly (no cross terms).
    fn diag_quadratic(h: &'static [f64], c: &'static [f64]) -> impl Fn(&[f64]) -> f64 {
        move |x: &[f64]| {
            0.5 * (0..x.len())
                .map(|i| h[i] * (x[i] - c[i]).powi(2))
                .sum::<f64>()
        }
    }

    /// Unconstrained (m = 0): TRSTEP reaches the interior minimizer, so the
    /// gradient at `x_opt + s` is ≈ 0 and the predicted reduction is positive.
    #[test]
    fn unconstrained_reaches_interior_min() {
        let h = &[2.0, 4.0];
        let c = &[0.4, -0.3];
        let model = QuadraticModel::initialize(vec![0.0, 0.0], 0.3, 5, &diag_quadratic(h, c));
        let n = 2;
        let mut warm = ActiveSetQr::<f64>::new(n, 0);
        let (step, _) = trstep(&model, 5.0, &[], &[], &mut warm);
        // Gradient at x_opt + s ≈ 0 (interior stationary point).
        let gopt = model.gradient_at_opt();
        let hs = model.hessian_matvec(&step.d);
        for i in 0..n {
            assert!(
                (gopt[i] + hs[i]).abs() < 1e-7,
                "g_final[{i}] = {}",
                gopt[i] + hs[i]
            );
        }
        assert!(step.predicted_reduction > 0.0);
    }

    /// A single linear constraint that cuts off the unconstrained minimizer:
    /// TRSTEP returns a feasible step that activates the constraint, with the
    /// projected gradient (KKT residual) ≈ 0 on the constraint's null space.
    #[test]
    fn constrained_step_is_feasible_and_kkt() {
        let h = &[2.0, 2.0];
        let c = &[1.0, 0.0]; // unconstrained min at (1, 0)
        let model = QuadraticModel::initialize(vec![0.0, 0.0], 0.3, 5, &diag_quadratic(h, c));
        let n = 2;
        let m = 1;
        // Constraint x0 ≤ 0.5 (normal (1,0)). x_opt is near 0, so rescon = 0.5 - x_opt0.
        let amat = vec![1.0, 0.0];
        let xopt: Vec<f64> = (0..n)
            .map(|i| model.x0()[i] + model.xpt_row(model.kopt())[i])
            .collect();
        let rescon = vec![0.5 - xopt[0]];
        let mut warm = ActiveSetQr::<f64>::new(n, m);
        // Δ ≈ ρ (realistic): a huge Δ would make the constraint "nearly active"
        // immediately, so GETACT's projected gradient would be exactly 0 (the
        // correct LCPP solution) and the step would be empty.
        let (step, _) = trstep(&model, 0.3, &amat, &rescon, &mut warm);

        // Feasibility: a·(x_opt + s) ≤ b  ⟺  a·s ≤ rescon.
        let a_dot_s = amat_col_dot(&step.d, &amat, 0, n);
        assert!(
            a_dot_s <= rescon[0] + 1e-9,
            "a·s = {a_dot_s}, rescon = {}",
            rescon[0]
        );
        assert!(step.predicted_reduction > 0.0);
        // The minimizer (1,0) is infeasible (x0=1 > 0.5), so the constraint binds.
        assert_eq!(warm.nact, 1);
        // KKT: gradient at x_opt+s is parallel to the active normal (its
        // projection onto the null space of (1,0), i.e. the y-component, ≈ 0).
        let gopt = model.gradient_at_opt();
        let hs = model.hessian_matvec(&step.d);
        assert!(
            (gopt[1] + hs[1]).abs() < 1e-7,
            "null-space grad = {}",
            gopt[1] + hs[1]
        );
        // The active constraint is essentially tight at the solution.
        assert!(
            (a_dot_s - rescon[0]).abs() < 1e-6,
            "constraint not tight: {a_dot_s}"
        );
    }

    /// The step stays within (a small multiple of) the trust radius.
    #[test]
    fn step_respects_trust_radius() {
        let h = &[1.0, 1.0];
        let c = &[10.0, 10.0]; // far min → boundary solution
        let model = QuadraticModel::initialize(vec![0.0, 0.0], 0.3, 5, &diag_quadratic(h, c));
        let n = 2;
        let delta = 1.0;
        let mut warm = ActiveSetQr::<f64>::new(n, 0);
        let (step, _) = trstep(&model, delta, &[], &[], &mut warm);
        let snorm = dot(&step.d, &step.d).sqrt();
        assert!(snorm <= 2.0 * delta, "‖s‖ = {snorm}");
        assert!(
            snorm > 0.5 * delta,
            "‖s‖ = {snorm} (expected near boundary)"
        );
        assert!(step.predicted_reduction > 0.0);
    }
}