solvax 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solvax/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """solvax: differentiable structured linear solvers and matrix-free methods in JAX.
2
+
3
+ Generic solver infrastructure for kinetic and PDE codes: batched structured
4
+ direct solves, preconditioned/recycled Krylov methods, and implicit
5
+ differentiation — everything jit/vmap/grad-transparent unless explicitly
6
+ marked as a host-side native bridge.
7
+ """
8
+
9
+ from solvax.banded import (
10
+ BandedLUFactors,
11
+ PeriodicBandedLUFactors,
12
+ banded_matvec,
13
+ lu_factor_banded,
14
+ lu_factor_banded_periodic,
15
+ lu_solve_banded,
16
+ lu_solve_banded_periodic,
17
+ )
18
+ from solvax.direct import (
19
+ BlockTridiagFactors,
20
+ block_thomas,
21
+ block_thomas_factor,
22
+ block_thomas_solve,
23
+ block_thomas_truncated,
24
+ block_thomas_truncated_fn,
25
+ )
26
+ from solvax.implicit import linear_solve, root_solve
27
+ from solvax.krylov import KrylovSolution, gcrot, gmres
28
+ from solvax.native import SpluFactorization, splu_solve
29
+ from solvax.operators import (
30
+ BlockTridiagonalOperator,
31
+ BorderedOperator,
32
+ KroneckerOperator,
33
+ MatrixFreeOperator,
34
+ SumOperator,
35
+ schur_projected_precond,
36
+ )
37
+ from solvax.precond import (
38
+ block_jacobi,
39
+ coarse_operator,
40
+ jacobi,
41
+ kronecker_nkp,
42
+ line_smoother,
43
+ mixed_precision,
44
+ nearest_kronecker,
45
+ p_multigrid,
46
+ )
47
+ from solvax.refine import as_low_precision, iterative_refinement
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [
52
+ "BandedLUFactors",
53
+ "PeriodicBandedLUFactors",
54
+ "banded_matvec",
55
+ "lu_factor_banded",
56
+ "lu_factor_banded_periodic",
57
+ "lu_solve_banded",
58
+ "lu_solve_banded_periodic",
59
+ "BlockTridiagFactors",
60
+ "block_thomas",
61
+ "block_thomas_factor",
62
+ "block_thomas_solve",
63
+ "block_thomas_truncated",
64
+ "block_thomas_truncated_fn",
65
+ "KrylovSolution",
66
+ "gmres",
67
+ "gcrot",
68
+ "linear_solve",
69
+ "root_solve",
70
+ "MatrixFreeOperator",
71
+ "SumOperator",
72
+ "KroneckerOperator",
73
+ "BlockTridiagonalOperator",
74
+ "BorderedOperator",
75
+ "schur_projected_precond",
76
+ "jacobi",
77
+ "block_jacobi",
78
+ "coarse_operator",
79
+ "line_smoother",
80
+ "p_multigrid",
81
+ "mixed_precision",
82
+ "kronecker_nkp",
83
+ "nearest_kronecker",
84
+ "iterative_refinement",
85
+ "as_low_precision",
86
+ "SpluFactorization",
87
+ "splu_solve",
88
+ "__version__",
89
+ ]
solvax/banded.py ADDED
@@ -0,0 +1,385 @@
1
+ """Banded LU solvers: non-pivoted factorization and periodic (circulant-banded) systems.
2
+
3
+ Storage convention (identical to :func:`scipy.linalg.solve_banded`): a matrix
4
+ ``A`` with ``lower_bw`` sub-diagonals and ``upper_bw`` super-diagonals is held
5
+ in a ``bands`` array of shape ``(n_diags, n)`` with
6
+ ``n_diags = lower_bw + upper_bw + 1``, where row ``r`` holds the diagonal with
7
+ offset ``upper_bw - r``:
8
+
9
+ bands[upper_bw + i - j, j] = A[i, j] for max(0, j - upper_bw) <= i <= min(n - 1, j + lower_bw)
10
+
11
+ Entries of ``bands`` outside that range are ignored. The LU factorization is
12
+ Doolittle elimination *without pivoting*, carried out column by column in
13
+ banded storage with a ``jax.lax.scan`` (static shapes, so everything is
14
+ jit/vmap/grad-transparent — XLA handles row pivoting poorly, and avoiding it
15
+ is the point of this module). Two safeguards substitute for pivoting:
16
+
17
+ - *row equilibration*: each row is pre-scaled by ``1 / max|row|``;
18
+ - *static pivoting*: any pivot with ``|pivot| < floor`` is clamped to
19
+ ``sign(pivot) * floor``, and the number of clamps is recorded in the factors
20
+ so callers can detect near-singularity and fall back to iterative
21
+ refinement (see ``solvax.refine``).
22
+
23
+ LU without pivoting is guaranteed backward-stable only for diagonally
24
+ dominant (or block-dominant) systems (Demmel, Higham & Schreiber, Numer.
25
+ Linear Algebra Appl. 2, 173 (1995)); with equilibration and static pivoting it
26
+ is a robust practical choice for the advection-dominated periodic 1-D
27
+ operators these routines target, but callers should monitor the clamp counter
28
+ in weakly-dominant regimes.
29
+
30
+ Periodic (circulant-banded) systems ``A = B + U V^T`` — a banded core ``B``
31
+ plus wrap-around corner blocks expressed as a low-rank update — are solved
32
+ with the Sherman-Morrison-Woodbury capacitance-matrix identity
33
+
34
+ (B + U V^T)^{-1} = B^{-1} - B^{-1} U (I + V^T B^{-1} U)^{-1} V^T B^{-1},
35
+
36
+ where the small dense capacitance matrix ``I + V^T B^{-1} U`` is LU-factored
37
+ once (with partial pivoting — it is tiny) and ``B^{-1} U`` is precomputed with
38
+ the banded factorization, so each periodic solve costs one banded solve plus
39
+ O(bw) dense work.
40
+
41
+ References
42
+ ----------
43
+ - G. H. Golub & C. F. Van Loan, *Matrix Computations*, 4th ed., section 4.3
44
+ (banded Gaussian elimination) and section 2.1.4 (Sherman-Morrison-Woodbury).
45
+ - J. W. Demmel, N. J. Higham & R. S. Schreiber, "Stability of block LU
46
+ factorization", Numer. Linear Algebra Appl. 2, 173 (1995) — stability
47
+ caveats for elimination without pivoting.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from typing import NamedTuple
53
+
54
+ import jax
55
+ import jax.numpy as jnp
56
+ from jax.scipy.linalg import lu_factor, lu_solve
57
+
58
+
59
+ def _band_indices(n_diags: int, n: int, upper_bw: int):
60
+ """Row-index grid and validity mask for the banded layout.
61
+
62
+ Entry ``bands[r, j]`` represents ``A[i, j]`` with ``i = j + r - upper_bw``;
63
+ it is valid iff ``0 <= i < n``.
64
+ """
65
+ r = jnp.arange(n_diags)[:, None]
66
+ j = jnp.arange(n)[None, :]
67
+ i = j + r - upper_bw
68
+ return i, (i >= 0) & (i < n)
69
+
70
+
71
+ def _shift_rows(v: jax.Array, d: int) -> jax.Array:
72
+ """Zero-padded shift along axis 0: ``out[i] = v[i + d]`` where defined."""
73
+ if d == 0:
74
+ return v
75
+ if d > 0:
76
+ return jnp.concatenate([v[d:], jnp.zeros_like(v[:d])], axis=0)
77
+ return jnp.concatenate([jnp.zeros_like(v[d:]), v[:d]], axis=0)
78
+
79
+
80
+ def banded_matvec(
81
+ bands: jax.Array, lower_bw: int, upper_bw: int, x: jax.Array
82
+ ) -> jax.Array:
83
+ """Matrix-vector product ``A @ x`` with ``A`` in banded storage.
84
+
85
+ Vectorized over the matrix dimension (the only Python loop is over the
86
+ ``n_diags`` diagonals, which is static).
87
+
88
+ Args:
89
+ bands: banded storage of ``A``, shape ``(lower_bw + upper_bw + 1, n)``
90
+ (see module docstring); out-of-range entries are ignored.
91
+ lower_bw: number of sub-diagonals.
92
+ upper_bw: number of super-diagonals.
93
+ x: vector ``(n,)`` or block of vectors ``(n, k)``.
94
+
95
+ Returns:
96
+ ``A @ x`` with the same shape as ``x``.
97
+ """
98
+ bands = jnp.asarray(bands)
99
+ x = jnp.asarray(x)
100
+ n_diags, n = bands.shape
101
+ if n_diags != lower_bw + upper_bw + 1:
102
+ raise ValueError("bands must have lower_bw + upper_bw + 1 rows")
103
+ _, mask = _band_indices(n_diags, n, upper_bw)
104
+ ab = jnp.where(mask, bands, 0.0)
105
+
106
+ y = jnp.zeros(x.shape, dtype=jnp.result_type(ab.dtype, x.dtype))
107
+ for r in range(n_diags):
108
+ w = ab[r]
109
+ c = w[:, None] * x if x.ndim > 1 else w * x
110
+ # bands[r, j] multiplies x[j] and contributes to y[i], i = j - (upper_bw - r).
111
+ y = y + _shift_rows(c, upper_bw - r)
112
+ return y
113
+
114
+
115
+ class BandedLUFactors(NamedTuple):
116
+ """Packed non-pivoted banded LU from :func:`lu_factor_banded`.
117
+
118
+ The bandwidths are recovered from the array shapes, so the factors stay
119
+ jit/vmap-transparent (no static metadata crosses transform boundaries).
120
+
121
+ Attributes:
122
+ l_bands: unit-lower-triangular multipliers, shape ``(lower_bw, n)``;
123
+ ``l_bands[t - 1, k]`` holds ``L[k + t, k]``.
124
+ u_bands: upper factor in banded storage, shape ``(upper_bw + 1, n)``;
125
+ ``u_bands[upper_bw + i - j, j]`` holds ``U[i, j]``.
126
+ row_scale: equilibration scales applied to the rows of ``A`` (all ones
127
+ when ``equilibrate=False``), shape ``(n,)``.
128
+ n_clamped: int32 count of pivots clamped by static pivoting; a
129
+ nonzero value signals near-singularity of the (equilibrated) core.
130
+ """
131
+
132
+ l_bands: jax.Array
133
+ u_bands: jax.Array
134
+ row_scale: jax.Array
135
+ n_clamped: jax.Array
136
+
137
+
138
+ def lu_factor_banded(
139
+ bands: jax.Array,
140
+ lower_bw: int,
141
+ upper_bw: int,
142
+ *,
143
+ equilibrate: bool = True,
144
+ static_pivot_floor: float | None = None,
145
+ ) -> BandedLUFactors:
146
+ """Non-pivoted (Doolittle) LU of a banded matrix, in banded storage.
147
+
148
+ Columns are eliminated left to right with a ``jax.lax.scan``; the carry
149
+ holds the multipliers of the previous ``upper_bw`` steps, so shapes are
150
+ static and the factorization is jit/vmap/grad-transparent. Row
151
+ equilibration and static pivoting (see module docstring) substitute for
152
+ the row pivoting that XLA handles poorly.
153
+
154
+ Args:
155
+ bands: banded storage of ``A``, shape ``(lower_bw + upper_bw + 1, n)``;
156
+ out-of-range entries are ignored.
157
+ lower_bw: number of sub-diagonals.
158
+ upper_bw: number of super-diagonals.
159
+ equilibrate: scale each row by ``1 / max|row|`` before factoring
160
+ (the scales are stored and applied to the right-hand side by
161
+ :func:`lu_solve_banded`).
162
+ static_pivot_floor: clamp threshold for pivots; ``None`` (default)
163
+ uses ``sqrt(machine eps) * max|bands|`` of the (equilibrated)
164
+ matrix.
165
+
166
+ Returns:
167
+ Factors for :func:`lu_solve_banded`, including the clamp counter.
168
+ """
169
+ bands = jnp.asarray(bands)
170
+ kl, ku = int(lower_bw), int(upper_bw)
171
+ n_diags, n = bands.shape
172
+ if n_diags != kl + ku + 1:
173
+ raise ValueError("bands must have lower_bw + upper_bw + 1 rows")
174
+ i_idx, mask = _band_indices(n_diags, n, ku)
175
+ ab = jnp.where(mask, bands, 0.0)
176
+ dtype = ab.dtype
177
+
178
+ if equilibrate:
179
+ # Row i of A is scattered across the diagonals: |A[i, j]| = |ab[r, i + ku - r]|.
180
+ rows = jnp.stack([_shift_rows(jnp.abs(ab[r]), ku - r) for r in range(n_diags)])
181
+ row_max = rows.max(axis=0)
182
+ safe = jnp.where(row_max > 0, row_max, 1.0)
183
+ row_scale = jnp.where(row_max > 0, 1.0 / safe, 1.0)
184
+ ab = jnp.where(mask, ab * row_scale[jnp.clip(i_idx, 0, n - 1)], 0.0)
185
+ else:
186
+ row_scale = jnp.ones(n, dtype)
187
+
188
+ if static_pivot_floor is None:
189
+ floor = jnp.sqrt(jnp.finfo(dtype).eps) * jnp.max(jnp.abs(ab))
190
+ else:
191
+ floor = jnp.asarray(static_pivot_floor, dtype)
192
+
193
+ def step(carry, c):
194
+ # c = ab[:, j]: c[r] holds A[j + r - ku, j]. m_hist[:, ku - t] holds the
195
+ # multipliers of elimination step k = j - t (zeros for k < 0).
196
+ m_hist, n_clamped = carry
197
+ for t in range(ku, 0, -1): # oldest step first
198
+ u_kj = c[ku - t] # U[j - t, j], final after older updates
199
+ c = c.at[ku - t + 1 : ku - t + 1 + kl].add(-m_hist[:, ku - t] * u_kj)
200
+ p = c[ku]
201
+ small = jnp.abs(p) < floor
202
+ p = jnp.where(small, jnp.where(p >= 0, floor, -floor), p)
203
+ mults = c[ku + 1 :] / p
204
+ c = c.at[ku].set(p).at[ku + 1 :].set(mults)
205
+ if ku > 0:
206
+ m_hist = jnp.concatenate([m_hist[:, 1:], mults[:, None]], axis=1)
207
+ return (m_hist, n_clamped + small.astype(jnp.int32)), c
208
+
209
+ carry0 = (jnp.zeros((kl, ku), dtype), jnp.int32(0))
210
+ (_, n_clamped), cols = jax.lax.scan(step, carry0, ab.T)
211
+ packed = cols.T # LAPACK-style packed LU in the input layout
212
+ return BandedLUFactors(packed[ku + 1 :], packed[: ku + 1], row_scale, n_clamped)
213
+
214
+
215
+ def lu_solve_banded(factors: BandedLUFactors, b: jax.Array) -> jax.Array:
216
+ """Solve ``A x = b`` using precomputed banded LU factors.
217
+
218
+ Forward substitution with the unit-lower multipliers, then backward
219
+ substitution with the banded upper factor, each as a ``jax.lax.scan``
220
+ carrying a bandwidth-sized window of the solution.
221
+
222
+ Args:
223
+ factors: output of :func:`lu_factor_banded`.
224
+ b: right-hand side, shape ``(n,)`` or ``(n, k)``.
225
+
226
+ Returns:
227
+ Solution with the same shape as ``b``.
228
+ """
229
+ l_bands, u_bands, row_scale, _ = factors
230
+ kl = l_bands.shape[0]
231
+ ku = u_bands.shape[0] - 1
232
+ b = jnp.asarray(b)
233
+ vector = b.ndim == 1
234
+ bb = b[:, None] if vector else b
235
+ bb = bb * row_scale[:, None]
236
+
237
+ # Forward: y[i] = b[i] - sum_t L[i, i - t] y[i - t], L[i, i - t] = l_bands[t - 1, i - t].
238
+ if kl > 0:
239
+ lcoef = jnp.stack(
240
+ [_shift_rows(l_bands[t - 1], -t) for t in range(1, kl + 1)], axis=1
241
+ )
242
+
243
+ def fwd_step(y_win, inputs): # y_win[s] = y[i - kl + s]
244
+ coeffs, b_i = inputs
245
+ y_i = b_i - coeffs[::-1] @ y_win
246
+ return jnp.concatenate([y_win[1:], y_i[None]], axis=0), y_i
247
+
248
+ y0 = jnp.zeros((kl, bb.shape[1]), bb.dtype)
249
+ _, y = jax.lax.scan(fwd_step, y0, (lcoef, bb))
250
+ else:
251
+ y = bb
252
+
253
+ # Backward: x[i] = (y[i] - sum_t U[i, i + t] x[i + t]) / U[i, i].
254
+ diag = u_bands[ku]
255
+ if ku > 0:
256
+ ucoef = jnp.stack(
257
+ [_shift_rows(u_bands[ku - t], t) for t in range(1, ku + 1)], axis=1
258
+ )
259
+
260
+ def bwd_step(x_win, inputs): # x_win[s] = x[i + 1 + s]
261
+ coeffs, y_i, d_i = inputs
262
+ x_i = (y_i - coeffs @ x_win) / d_i
263
+ return jnp.concatenate([x_i[None], x_win[:-1]], axis=0), x_i
264
+
265
+ x0 = jnp.zeros((ku, y.shape[1]), y.dtype)
266
+ _, x = jax.lax.scan(bwd_step, x0, (ucoef, y, diag), reverse=True)
267
+ else:
268
+ x = y / diag[:, None]
269
+
270
+ return x[:, 0] if vector else x
271
+
272
+
273
+ class PeriodicBandedLUFactors(NamedTuple):
274
+ """Woodbury factors for a periodic banded system, from
275
+ :func:`lu_factor_banded_periodic`.
276
+
277
+ Attributes:
278
+ core: banded LU of the (non-periodic) banded core ``B``.
279
+ z_ul: ``B^{-1} U`` columns generated by the top-right corner block,
280
+ shape ``(n, bw_ul)``.
281
+ z_lr: ``B^{-1} U`` columns generated by the bottom-left corner block,
282
+ shape ``(n, bw_lr)``.
283
+ cap_lu: dense LU of the capacitance matrix ``I + V^T B^{-1} U``,
284
+ shape ``(bw_ul + bw_lr, bw_ul + bw_lr)``.
285
+ cap_piv: matching pivot indices.
286
+ """
287
+
288
+ core: BandedLUFactors
289
+ z_ul: jax.Array
290
+ z_lr: jax.Array
291
+ cap_lu: jax.Array
292
+ cap_piv: jax.Array
293
+
294
+
295
+ def lu_factor_banded_periodic(
296
+ bands: jax.Array,
297
+ lower_bw: int,
298
+ upper_bw: int,
299
+ corner_ul: jax.Array,
300
+ corner_lr: jax.Array,
301
+ *,
302
+ equilibrate: bool = True,
303
+ static_pivot_floor: float | None = None,
304
+ ) -> PeriodicBandedLUFactors:
305
+ """Factor a periodic (circulant-banded) matrix via the capacitance method.
306
+
307
+ The matrix is ``A = B + U V^T``: a banded core ``B`` (given in ``bands``)
308
+ plus the periodic wrap-around corners as a rank-``(bw_ul + bw_lr)``
309
+ update, where ``U`` carries the corner blocks and ``V`` selects the
310
+ coupled columns. ``B`` is factored with the non-pivoted banded LU,
311
+ ``Z = B^{-1} U`` is precomputed, and the small dense capacitance matrix
312
+ ``I + V^T Z`` is LU-factored so :func:`lu_solve_banded_periodic` can apply
313
+ the Woodbury identity (module docstring) at the cost of one banded solve.
314
+
315
+ Args:
316
+ bands: banded storage of the core ``B``, shape
317
+ ``(lower_bw + upper_bw + 1, n)``.
318
+ lower_bw: number of sub-diagonals of ``B``.
319
+ upper_bw: number of super-diagonals of ``B``.
320
+ corner_ul: top-right corner block ``A[:bw, n - bw:]`` coupling the
321
+ first rows to the last columns, shape ``(bw, bw)``.
322
+ corner_lr: bottom-left corner block ``A[n - bw:, :bw]`` coupling the
323
+ last rows to the first columns, shape ``(bw, bw)``.
324
+ equilibrate: passed to :func:`lu_factor_banded` for the core.
325
+ static_pivot_floor: passed to :func:`lu_factor_banded` for the core.
326
+
327
+ Returns:
328
+ Factors for :func:`lu_solve_banded_periodic`.
329
+ """
330
+ bands = jnp.asarray(bands)
331
+ corner_ul = jnp.asarray(corner_ul)
332
+ corner_lr = jnp.asarray(corner_lr)
333
+ if corner_ul.ndim != 2 or corner_ul.shape[0] != corner_ul.shape[1]:
334
+ raise ValueError("corner_ul must be a square matrix")
335
+ if corner_lr.ndim != 2 or corner_lr.shape[0] != corner_lr.shape[1]:
336
+ raise ValueError("corner_lr must be a square matrix")
337
+
338
+ core = lu_factor_banded(
339
+ bands,
340
+ lower_bw,
341
+ upper_bw,
342
+ equilibrate=equilibrate,
343
+ static_pivot_floor=static_pivot_floor,
344
+ )
345
+ n = bands.shape[1]
346
+ r_ul, r_lr = corner_ul.shape[0], corner_lr.shape[0]
347
+ dtype = jnp.result_type(bands.dtype, corner_ul.dtype, corner_lr.dtype)
348
+
349
+ # U stacks the corner blocks; V^T picks the last r_ul then first r_lr rows.
350
+ u_cols = jnp.zeros((n, r_ul + r_lr), dtype)
351
+ u_cols = u_cols.at[:r_ul, :r_ul].set(corner_ul)
352
+ u_cols = u_cols.at[n - r_lr :, r_ul:].set(corner_lr)
353
+ z = lu_solve_banded(core, u_cols)
354
+ vt_z = jnp.concatenate([z[n - r_ul :], z[:r_lr]], axis=0)
355
+ cap_lu, cap_piv = lu_factor(jnp.eye(r_ul + r_lr, dtype=dtype) + vt_z)
356
+ return PeriodicBandedLUFactors(core, z[:, :r_ul], z[:, r_ul:], cap_lu, cap_piv)
357
+
358
+
359
+ def lu_solve_banded_periodic(
360
+ factors: PeriodicBandedLUFactors, b: jax.Array
361
+ ) -> jax.Array:
362
+ """Solve a periodic banded system using precomputed Woodbury factors.
363
+
364
+ Applies ``x = B^{-1} b - Z (I + V^T Z)^{-1} V^T B^{-1} b`` with
365
+ ``Z = B^{-1} U`` from :func:`lu_factor_banded_periodic`.
366
+
367
+ Args:
368
+ factors: output of :func:`lu_factor_banded_periodic`.
369
+ b: right-hand side, shape ``(n,)`` or ``(n, k)``.
370
+
371
+ Returns:
372
+ Solution with the same shape as ``b``.
373
+ """
374
+ core, z_ul, z_lr, cap_lu, cap_piv = factors
375
+ r_ul = z_ul.shape[1]
376
+ n = z_ul.shape[0]
377
+ b = jnp.asarray(b)
378
+ vector = b.ndim == 1
379
+ bb = b[:, None] if vector else b
380
+
381
+ y = lu_solve_banded(core, bb)
382
+ vt_y = jnp.concatenate([y[n - r_ul :], y[: z_lr.shape[1]]], axis=0)
383
+ t = lu_solve((cap_lu, cap_piv), vt_y)
384
+ x = y - z_ul @ t[:r_ul] - z_lr @ t[r_ul:]
385
+ return x[:, 0] if vector else x