basin 2.0.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
use crate::core::inner::{InitialState, WarmStart};
use crate::core::math::{
    DenseBackend, Dot, GeneralRankOneUpdate, MatVec, MatrixIdentity,
    MatrixIndex, NegInPlace, NormSquared, Scalar, ScaleInPlace, ScaledAdd,
    VectorLen,
};
use crate::core::problem::{CostFunction, Gradient, Problem};
use crate::core::solver::Solver;
use crate::core::state::{FirstOrderState, State};
use crate::core::termination::Termination;
use crate::line_search::{LineSearch, LineSearchOutcome, Wolfe};

mod initial_inverse_hessian;
pub use initial_inverse_hessian::{
    FixedInverseHessian, IdentityInverseHessian, InitialInverseHessian,
};

/// BFGS quasi-Newton solver.
///
/// Maintains a dense inverse-Hessian approximation `H` updated by the
/// rank-2 BFGS formula. The search direction is `d = −H·∇f(x)`; the step
/// length is set by a configurable line search (default: strong Wolfe,
/// which is what guarantees `yᵀs > 0` so each update preserves positive
/// definiteness).
///
/// By default, each fresh solve starts with `H = I`. Immediately before the
/// first accepted curvature update, we rescale `H ← (sᵀy / yᵀy)·I`
/// (Nocedal & Wright (6.20)), improving convergence on poorly scaled problems.
/// [`with_initial_inverse_hessian`](Self::with_initial_inverse_hessian) sets
/// a fixed seed, and [`with_initial_inverse_hessian_initializer`](Self::with_initial_inverse_hessian_initializer)
/// can construct one from the initial point and gradient. Custom seeds skip
/// this rescaling by default so that their scaling survives the first update.
/// Later curvature failures skip updates without replacing the model.
///
/// **Curvature failure (`yᵀs ≤ ε · |y| · |s|`):** the H update is skipped
/// for that iteration. Strong Wolfe with `c2 < 1` guarantees `yᵀs > 0` in
/// exact arithmetic, so this branch is a numerical safeguard, not the
/// primary path. (Damped BFGS and Powell's modification are overkill when
/// strong Wolfe is in place; see plan.)
///
/// # Backends
///
/// Runs on `Vec<F>` (via the hand-rolled
/// [`DenseMatrix`](crate::core::math::DenseMatrix)), nalgebra
/// (`DVector<F>`/`DMatrix<F>`), ndarray (`Array1<F>`/`Array2<F>`), and
/// faer (`Col<F>`/`Mat<F>`), for both `f32` and `f64`. The dense
/// inverse-Hessian needs only matvec, an identity constructor, scaling, and
/// the rank-one update `GeneralRankOneUpdate` (no factorization), so it
/// stays backend-generic. Fixed seeds additionally require `M: Clone +
/// MatrixIndex<F>`; closure initializers require `M: MatrixIndex<F>`, but
/// not `Clone`. These optional requirements do not affect the default seed.
///
/// # Examples
///
/// BFGS on the 2-D Rosenbrock function over the dependency-free
/// `Vec<f64>` backend. Progress uses [`FirstOrderState`]; the solver owns
/// its inverse Hessian and infers the matrix through [`DenseBackend`].
/// Fresh solves reset both the model and line-search history. Exact
/// solver-and-state checkpoints retain them.
///
/// ```
/// use basin::{
///     Bfgs, CostFunction, FirstOrderState, Executor, Gradient};
///
/// struct Rosenbrock;
/// impl basin::ProblemConstraints for Rosenbrock  {
///     type Constraints = basin::NoConstraints;
///     fn constraint_set(&self) -> basin::NoConstraints { basin::NoConstraints::EMPTY }
/// }
/// impl CostFunction for Rosenbrock {
///     type Param = Vec<f64>;
///     type Output = f64;
///     type Error = std::convert::Infallible;
///     fn cost(&self, x: &Vec<f64>) -> Result<f64, Self::Error> {
///         Ok((1.0 - x[0]).powi(2) + 100.0 * (x[1] - x[0].powi(2)).powi(2))
///     }
/// }
/// impl Gradient for Rosenbrock {
///     type Gradient = Vec<f64>;
///     fn gradient(&self, x: &Vec<f64>) -> Result<Vec<f64>, Self::Error> {
///         Ok(vec![
///             -2.0 * (1.0 - x[0]) - 400.0 * x[0] * (x[1] - x[0].powi(2)),
///             200.0 * (x[1] - x[0].powi(2)),
///         ])
///     }
/// }
///
/// let result = Executor::new(
///     Rosenbrock,
///     Bfgs::new(),
///     FirstOrderState::new(vec![-1.2, 1.0]),
/// )
/// .max_iter(100)
/// .run()
/// .unwrap();
/// assert!(result.cost() < 1e-8);
/// ```
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Bfgs<
    V: DenseBackend<F>,
    F: Scalar = f64,
    M = <V as DenseBackend<F>>::Matrix,
    S = Wolfe<F>,
    I = IdentityInverseHessian,
> {
    line_search: S,
    epsilon: F,
    inverse_hessian: Option<M>,
    initial_scaling_done: bool,
    initial_inverse_hessian: I,
    param: std::marker::PhantomData<V>,
}

impl<V: DenseBackend<F>, F: Scalar> Default for Bfgs<V, F> {
    fn default() -> Self {
        Self::new()
    }
}

impl<V: DenseBackend<F>, F: Scalar> Bfgs<V, F> {
    /// BFGS with a strong-Wolfe line search and the backend's default matrix.
    /// The model is initialized from the seed's dimension during each fresh run.
    pub fn new() -> Self {
        Self::with_line_search(Wolfe::new())
    }
}

impl<V: DenseBackend<F>, F: Scalar, S>
    Bfgs<V, F, <V as DenseBackend<F>>::Matrix, S>
{
    /// BFGS with a configured line search and the backend's default matrix.
    pub fn with_line_search(line_search: S) -> Self {
        Self::with_matrix_and_line_search(line_search)
    }
}

impl<V: DenseBackend<F>, F: Scalar, M, S> Bfgs<V, F, M, S> {
    /// Construct with an explicit matrix type and a configured line search.
    /// The line search resets its evolving history on each fresh initialization.
    pub fn with_matrix_and_line_search(line_search: S) -> Self {
        Self {
            line_search,
            epsilon: F::from_f64(1e-10).unwrap(),
            inverse_hessian: None,
            initial_scaling_done: false,
            initial_inverse_hessian: IdentityInverseHessian,
            param: std::marker::PhantomData,
        }
    }
}

impl<V: DenseBackend<F>, F: Scalar, M, S, I> Bfgs<V, F, M, S, I> {
    /// Current inverse-Hessian model, available after initialization.
    /// Use [`crate::Executor::run_with_solver`] to retain it after a solve.
    pub fn inverse_hessian(&self) -> Option<&M> {
        self.inverse_hessian.as_ref()
    }

    /// Use a fixed initial inverse Hessian for each fresh solve.
    ///
    /// Initialization checks that the seed matches the starting dimension and
    /// is finite, exactly symmetric, and positive definite, then clones it
    /// into the working model. Invalid seeds panic at initialization. The
    /// check uses `O(n³)` pure-Rust Cholesky work and `O(n²)` scratch storage.
    /// The stored seed is never updated; reuse with a different dimension
    /// requires a new seed or a dimension-aware initializer.
    ///
    /// This disables first-update curvature rescaling. Existing callers using
    /// the default identity seed retain that rescaling. Changing the seed on
    /// an initialized solver affects its next fresh solve; exact continuation
    /// retains the current model and rescaling flag.
    pub fn with_initial_inverse_hessian(
        self,
        matrix: M,
    ) -> Bfgs<V, F, M, S, FixedInverseHessian<M>>
    where
        V: VectorLen,
        M: Clone + MatrixIndex<F>,
    {
        self.with_initial_inverse_hessian_initializer(FixedInverseHessian(
            matrix,
        ))
    }

    /// Construct the seed from the freshly evaluated point and gradient.
    ///
    /// Accepts an [`InitialInverseHessian`] implementation, including a
    /// closure `Fn(&V, &V) -> M`. Called once per fresh solve, even with a
    /// zero iteration budget, without extra objective or gradient requests.
    /// Point warm starts reevaluate the point and reconstruct the seed; exact
    /// continuation retains the model and does not call the initializer.
    /// First-update rescaling is disabled unless a named implementation
    /// explicitly enables it. Changing the initializer affects the next fresh
    /// solve, leaving an initialized model and its rescaling flag intact.
    ///
    /// Closure results are checked like fixed seeds, requiring only
    /// `M: MatrixIndex<F>` beyond the usual BFGS operations. They need not be
    /// `Clone`. Named implementations enforce their own matrix contract and
    /// may support custom storage or serialization. Closures support owned,
    /// nonserialized checkpoints.
    ///
    /// For example, a diagonal seed based on the initial gradient bounds the
    /// first search direction in each coordinate without changing variables:
    ///
    /// ```
    /// use basin::{Bfgs, DenseMatrix};
    /// let solver = Bfgs::<Vec<f64>>::new()
    ///     .with_initial_inverse_hessian_initializer(
    ///         |x: &Vec<f64>, gradient: &Vec<f64>| {
    ///             DenseMatrix::from_fn(x.len(), x.len(), |i, j| {
    ///                 if i == j { 0.1 / gradient[i].abs().max(1e-3) }
    ///                 else { 0.0 }
    ///             })
    ///         },
    ///     );
    /// assert!(solver.inverse_hessian().is_none());
    /// ```
    pub fn with_initial_inverse_hessian_initializer<Init>(
        self,
        initializer: Init,
    ) -> Bfgs<V, F, M, S, Init>
    where
        Init: InitialInverseHessian<V, M, F>,
    {
        Bfgs {
            line_search: self.line_search,
            epsilon: self.epsilon,
            inverse_hessian: self.inverse_hessian,
            initial_scaling_done: self.initial_scaling_done,
            initial_inverse_hessian: initializer,
            param: self.param,
        }
    }

    /// Set the relative threshold for accepting a curvature update.
    /// This is an algorithm safeguard, not an optimization stopping test.
    pub fn with_relative_curvature_tolerance(mut self, epsilon: F) -> Self {
        assert!(epsilon >= F::zero(), "epsilon must be ≥ 0");
        self.epsilon = epsilon;
        self
    }
}

impl<P, S, V, M, F, I> Solver<P, FirstOrderState<V, F>> for Bfgs<V, F, M, S, I>
where
    F: Scalar,
    P: CostFunction<Param = V, Output = F> + Gradient<Gradient = V>,
    S: LineSearch<P, V, F, Error = P::Error>,
    I: InitialInverseHessian<V, M, F>,
    V: DenseBackend<F>
        + Clone
        + Dot<F>
        + NormSquared<F>
        + ScaledAdd<F>
        + ScaleInPlace<F>
        + NegInPlace
        + VectorLen,
    M: MatVec<V>
        + MatrixIdentity
        + ScaleInPlace<F>
        + GeneralRankOneUpdate<V, F>,
    P: crate::ProblemConstraints<Constraints = crate::NoConstraints>,
{
    type Error = P::Error;

    fn init(
        &mut self,
        problem: &mut Problem<P>,
        mut state: FirstOrderState<V, F>,
    ) -> Result<FirstOrderState<V, F>, Self::Error> {
        state.reset();
        self.line_search.reset();
        self.inverse_hessian = None;
        self.initial_scaling_done =
            !self.initial_inverse_hessian.rescale_on_first_update();
        let param = state.param().clone();
        let (cost, grad) = problem.cost_and_gradient(&param)?;
        state
            .replace(param, cost, grad)
            .expect("gradient dimension differs from parameter");
        self.inverse_hessian = Some(self.initial_inverse_hessian.initialize(
            state.param(),
            state.current().expect("initialized BFGS progress").2,
        ));
        Ok(state)
    }

    fn next_iter(
        &mut self,
        problem: &mut Problem<P>,
        mut state: FirstOrderState<V, F>,
    ) -> Result<crate::SolverStep<FirstOrderState<V, F>>, Self::Error> {
        let (param, cost_old, g) =
            state.current().expect("BFGS requires initialized progress");
        let mut param = param.clone();
        let h = self
            .inverse_hessian
            .as_mut()
            .expect("BFGS model is not initialized");

        // Quasi-Newton direction: d = −H g. With H positive definite this
        // is automatically a descent direction (gᵀd = −gᵀHg < 0).
        let mut direction = (*h).matvec(g);
        direction.neg_in_place();

        let alpha = match self
            .line_search
            .next_with_outcome(problem, &param, cost_old, g, &direction)?
        {
            LineSearchOutcome::Step(alpha) => alpha,
            LineSearchOutcome::Failed => {
                return Ok(crate::SolverStep::from((
                    state,
                    Some(Termination::numerical_failure(
                        "BFGS line search failed.",
                    )),
                )));
            }
        };

        // A nonpositive step may indicate stagnation even away from a
        // stationary point, so it cannot establish convergence on its own.
        if !(alpha.is_finite() && alpha > F::zero()) {
            return Ok(crate::SolverStep::from((
                state,
                Some(if alpha.is_finite() {
                    Termination::numerical_stall(
                        "The line search returned a nonpositive step without establishing stationarity.",
                    )
                } else {
                    Termination::numerical_failure(
                        "The line search returned a non-finite step.",
                    )
                }),
            )));
        }

        // s = α d, x ← x + s.
        let mut s = direction;
        s.scale_in_place(alpha);
        param.scaled_add(F::one(), &s);

        // Fused cost+grad at the new iterate: one fused call gives both
        // values consumed below (BFGS update reads g_new; state caches
        // cost_new at the bottom of the iter).
        let (cost_new, g_new) = problem.cost_and_gradient(&param)?;

        // y = g_new − g.
        let mut y = g_new.clone();
        y.scaled_add(-F::one(), g);
        let sy = s.dot(&y);
        let s_norm = s.norm_squared().sqrt();
        let y_norm = y.norm_squared().sqrt();

        if sy > self.epsilon * s_norm * y_norm {
            // Initial-Hessian rescaling: align H₀ with the local curvature
            // before applying the first BFGS update. Without this, the
            // identity-initialized H produces a unit step that's far too
            // large or small on poorly scaled problems.
            if !self.initial_scaling_done {
                let yy = y.dot(&y);
                if yy > F::zero() {
                    let scale = sy / yy;
                    let n = param.vec_len();
                    let mut h0 = M::identity(n);
                    h0.scale_in_place(scale);
                    (*h) = h0;
                }
                self.initial_scaling_done = true;
            }

            let rho = F::one() / sy;
            let hy = (*h).matvec(&y);
            let yhy = y.dot(&hy);
            let coef = rho * (F::one() + rho * yhy);

            // H ← H + coef · s sᵀ − ρ · (s (Hy)ᵀ + (Hy) sᵀ).
            // Three rank-1 updates, all in place.
            (*h).general_rank_one_update(coef, &s, &s);
            (*h).general_rank_one_update(-rho, &s, &hy);
            (*h).general_rank_one_update(-rho, &hy, &s);
        }
        // else: curvature failure (very rare with strong Wolfe). Skip the
        // H update; the line search still produced a descent step, so we
        // continue. If this persists, max_iter or GradientTolerance halt.

        state
            .replace(param, cost_new, g_new)
            .expect("gradient dimension differs from parameter");
        Ok(crate::SolverStep::from((state, None)))
    }
}

/// A point warm start reevaluates the point and constructs the configured seed.
impl<V: DenseBackend<F> + Clone, F: Scalar, M, S, I> InitialState<V>
    for Bfgs<V, F, M, S, I>
{
    type State = FirstOrderState<V, F>;
    fn seed(&self, x: &V) -> Self::State {
        FirstOrderState::new(x.clone())
    }
}

impl<V: DenseBackend<F> + Clone, F: Scalar, M, S, I> WarmStart<V>
    for Bfgs<V, F, M, S, I>
{
}