Skip to main content

solver/numeric/
ergodic.rs

1//! Ergonomic stationary eigenproblems solved by Perron iteration.
2//!
3//! Infinite-horizon control problems without discounting have a one-parameter
4//! translation invariance and therefore no unique solution of
5//! `sup_u { f + L^u V } = 0`. Their exact `T -> infinity` limit is instead the
6//! principal (Perron) eigenpair of a linear operator.
7//!
8//! This module provides a symmetric-operator contract and a power-iteration
9//! solver for that eigenpair. It is deliberately separate from
10//! [`crate::numeric::finite_difference::elliptic`], which solves the
11//! discounted elliptic problem `-T V + r V = source` with `r > 0`. Those two
12//! problems coincide only when an explicit normalization is imposed.
13//!
14//! See `docs/src/reference/soc_exact.md` for the derivation and the
15//! sign convention.
16
17use crate::core::grid::Grid;
18use crate::linalg::csr::CsrMatrix;
19
20/// A symmetric linear operator whose principal eigenvector is a stationary
21/// value.
22///
23/// The operator is stored in the sign convention used by the exact reference:
24/// for the Avellaneda-Stoikov GLT matrix this is the negated matrix
25/// `diag(-alpha q^2) + offdiag(+eta)` whose Perron eigenvector is the
26/// long-horizon limit.
27pub trait StationaryEigenProblem<const N: usize> {
28    /// Builds the symmetric operator on `grid`.
29    ///
30    /// The returned matrix must have `grid.total_size()` rows and columns.
31    fn operator(&self, grid: &Grid<N>) -> CsrMatrix;
32}
33
34/// The principal eigenpair returned by [`PerronSolver::solve`].
35#[derive(Clone, Debug)]
36pub struct PerronSolution {
37    /// Largest algebraic eigenvalue of the operator.
38    pub eigenvalue: f64,
39    /// Unit-norm eigenvector associated with `eigenvalue`.
40    pub eigenvector: Vec<f64>,
41}
42
43/// Power-iteration solver for the principal eigenpair of a symmetric operator.
44#[derive(Clone, Copy, Debug)]
45pub struct PerronSolver {
46    /// Convergence tolerance on the L2 distance between consecutive iterates.
47    pub tol: f64,
48    /// Maximum number of power iterations.
49    pub max_iter: usize,
50    /// Extra shift added to the Gershgorin lower bound. A positive margin
51    /// guarantees a strictly positive definite shifted operator.
52    pub shift_margin: f64,
53}
54
55impl Default for PerronSolver {
56    fn default() -> Self {
57        Self {
58            tol: 1e-10,
59            max_iter: 10_000,
60            shift_margin: 1.0,
61        }
62    }
63}
64
65impl PerronSolver {
66    /// Creates a solver with default settings.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use solver::numeric::ergodic::PerronSolver;
72    /// let solver = PerronSolver::new();
73    /// assert_eq!(solver.tol, 1e-10);
74    /// ```
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Selects the power-iteration convergence tolerance.
80    ///
81    /// # Panics
82    ///
83    /// Panics if `tol` is not positive.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use solver::numeric::ergodic::PerronSolver;
89    /// let solver = PerronSolver::new().with_tol(1e-12);
90    /// assert_eq!(solver.tol, 1e-12);
91    /// ```
92    pub fn with_tol(mut self, tol: f64) -> Self {
93        assert!(tol > 0.0, "tolerance must be positive");
94        self.tol = tol;
95        self
96    }
97
98    /// Selects the maximum number of power iterations.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `max_iter` is zero.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use solver::numeric::ergodic::PerronSolver;
108    /// let solver = PerronSolver::new().with_max_iter(500);
109    /// assert_eq!(solver.max_iter, 500);
110    /// ```
111    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
112        assert!(max_iter > 0, "max_iter must be positive");
113        self.max_iter = max_iter;
114        self
115    }
116
117    /// Selects the shift margin added to the Gershgorin lower bound.
118    ///
119    /// # Panics
120    ///
121    /// Panics if `shift_margin` is not positive.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use solver::numeric::ergodic::PerronSolver;
127    /// let solver = PerronSolver::new().with_shift_margin(0.5);
128    /// assert_eq!(solver.shift_margin, 0.5);
129    /// ```
130    pub fn with_shift_margin(mut self, shift_margin: f64) -> Self {
131        assert!(shift_margin > 0.0, "shift margin must be positive");
132        self.shift_margin = shift_margin;
133        self
134    }
135
136    /// Solves for the principal eigenpair of `problem` on `grid`.
137    ///
138    /// Power iteration is applied to `A + shift * I`, where `A` is the
139    /// operator and `shift` is chosen from the Gershgorin lower bound so that
140    /// the shifted operator is positive definite. Shifting preserves
141    /// eigenvectors and moves every eigenvalue by the same constant, so the
142    /// returned eigenvalue subtracts the shift.
143    ///
144    /// # Panics
145    ///
146    /// Panics if `problem.operator(grid)` has a different size from the grid.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use solver::core::grid::Grid;
152    /// use solver::linalg::csr::CsrMatrix;
153    /// use solver::numeric::ergodic::{PerronSolver, StationaryEigenProblem};
154    ///
155    /// struct Single;
156    /// impl StationaryEigenProblem<1> for Single {
157    ///     fn operator(&self, _grid: &Grid<1>) -> CsrMatrix {
158    ///         let mut mat = CsrMatrix::new(1, 1);
159    ///         mat.add_entry(0, 5.0);
160    ///         mat.finish_row();
161    ///         mat
162    ///     }
163    /// }
164    ///
165    /// let grid = Grid::<1>::new([1], [0.0], [0.0]);
166    /// let solution = PerronSolver::new().solve(&grid, &Single);
167    /// assert!((solution.eigenvalue - 5.0).abs() < 1e-12);
168    /// ```
169    pub fn solve<const N: usize, P: StationaryEigenProblem<N>>(
170        &self,
171        grid: &Grid<N>,
172        problem: &P,
173    ) -> PerronSolution {
174        let mat = problem.operator(grid);
175        let n = mat.size;
176        assert_eq!(n, grid.total_size(), "operator size must match the grid");
177
178        let shift = self.shift(&mat);
179        let mut v = vec![(n as f64).sqrt().recip(); n];
180
181        for _ in 0..self.max_iter {
182            let w = shifted_mat_vec(&mat, shift, &v);
183            let norm = l2_norm(&w);
184            if norm == 0.0 {
185                break;
186            }
187
188            let mut next = w;
189            for entry in next.iter_mut() {
190                *entry /= norm;
191            }
192
193            let delta = l2_distance(&v, &next);
194            v = next;
195            if delta < self.tol {
196                break;
197            }
198        }
199
200        let av = mat_vec(&mat, &v);
201        let vv = dot(&v, &v);
202        let eigenvalue = if vv > 0.0 { dot(&v, &av) / vv } else { 0.0 };
203
204        PerronSolution {
205            eigenvalue,
206            eigenvector: v,
207        }
208    }
209
210    /// Computes the shift that makes `A + shift * I` positive definite.
211    ///
212    /// The Gershgorin theorem gives a lower bound on the smallest eigenvalue.
213    /// Subtracting that bound and adding a margin guarantees non-negative
214    /// diagonal entries and a non-negative shifted operator when the
215    /// off-diagonal entries are already non-negative.
216    fn shift(&self, mat: &CsrMatrix) -> f64 {
217        let mut lower = f64::INFINITY;
218        for i in 0..mat.size {
219            let row_start = mat.row_ptr[i];
220            let row_end = mat.row_ptr[i + 1];
221
222            let mut diagonal = 0.0;
223            let mut off_sum = 0.0;
224            for idx in row_start..row_end {
225                let val = mat.values[idx];
226                if mat.col_indices[idx] == i {
227                    diagonal = val;
228                } else {
229                    off_sum += val.abs();
230                }
231            }
232
233            lower = lower.min(diagonal - off_sum);
234        }
235
236        (-lower).max(0.0) + self.shift_margin
237    }
238}
239
240/// Dense matrix-vector product for a CSR matrix.
241fn mat_vec(mat: &CsrMatrix, x: &[f64]) -> Vec<f64> {
242    let mut y = vec![0.0; mat.size];
243    for (i, y_i) in y.iter_mut().enumerate() {
244        let mut acc = 0.0;
245        for idx in mat.row_ptr[i]..mat.row_ptr[i + 1] {
246            acc += mat.values[idx] * x[mat.col_indices[idx]];
247        }
248        *y_i = acc;
249    }
250    y
251}
252
253/// Applies `(A + shift * I) x`.
254fn shifted_mat_vec(mat: &CsrMatrix, shift: f64, x: &[f64]) -> Vec<f64> {
255    let mut y = mat_vec(mat, x);
256    for (entry, &x_i) in y.iter_mut().zip(x) {
257        *entry += shift * x_i;
258    }
259    y
260}
261
262fn dot(a: &[f64], b: &[f64]) -> f64 {
263    a.iter().zip(b).map(|(x, y)| x * y).sum()
264}
265
266fn l2_norm(x: &[f64]) -> f64 {
267    x.iter().map(|v| v * v).sum::<f64>().sqrt()
268}
269
270fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
271    a.iter()
272        .zip(b)
273        .map(|(x, y)| (x - y).powi(2))
274        .sum::<f64>()
275        .sqrt()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    struct KnownTridiagonal;
283
284    impl StationaryEigenProblem<1> for KnownTridiagonal {
285        fn operator(&self, _grid: &Grid<1>) -> CsrMatrix {
286            // Diagonal -2, off-diagonal +1. Eigenvalues are -1 and -3; the
287            // Perron eigenvector for the largest eigenvalue -1 is [1, 1].
288            let mut mat = CsrMatrix::new(2, 6);
289            mat.add_entry(0, -2.0);
290            mat.add_entry(1, 1.0);
291            mat.finish_row();
292            mat.add_entry(0, 1.0);
293            mat.add_entry(1, -2.0);
294            mat.finish_row();
295            mat
296        }
297    }
298
299    struct SingleNode;
300
301    impl StationaryEigenProblem<1> for SingleNode {
302        fn operator(&self, _grid: &Grid<1>) -> CsrMatrix {
303            let mut mat = CsrMatrix::new(1, 1);
304            mat.add_entry(0, 5.0);
305            mat.finish_row();
306            mat
307        }
308    }
309
310    #[test]
311    fn perron_iteration_recovers_largest_eigenpair() {
312        let grid = Grid::<1>::new([2], [0.0], [1.0]);
313        let solution = PerronSolver::new()
314            .with_tol(1e-12)
315            .solve(&grid, &KnownTridiagonal);
316
317        assert!((solution.eigenvalue - (-1.0)).abs() < 1e-8);
318        assert!((solution.eigenvector[0] - solution.eigenvector[1]).abs() < 1e-8);
319        assert!(solution.eigenvector[0] > 0.0);
320    }
321
322    #[test]
323    fn perron_single_node_recovers_its_scalar() {
324        let grid = Grid::<1>::new([1], [0.0], [0.0]);
325        let solution = PerronSolver::new().solve(&grid, &SingleNode);
326
327        assert_eq!(solution.eigenvector.len(), 1);
328        assert!((solution.eigenvalue - 5.0).abs() < 1e-12);
329        assert!(solution.eigenvector[0] > 0.0);
330    }
331}