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}